collet 0.1.0

Relentless agentic coding orchestrator with zero-drop agent loops
Documentation
//! Serialisable web event — a Clone + Serialize projection of `AgentEvent`.
//!
//! `AgentEvent` contains non-Clone types (`ConversationContext`) so it
//! cannot be placed directly on a broadcast channel.  `WebEvent` strips
//! those fields and keeps only what the browser needs.

use serde::Serialize;

use crate::agent::r#loop::AgentEvent;

/// A lightweight, serialisable representation of agent activity.
#[derive(Clone, Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum WebEvent {
    Token {
        text: String,
    },
    Response {
        text: String,
    },
    ToolCall {
        name: String,
        args: String,
    },
    ToolResult {
        name: String,
        result: String,
        success: bool,
    },
    FileModified {
        path: String,
    },
    Error {
        message: String,
    },
    GuardStop {
        message: String,
    },
    StreamRetry {
        attempt: u32,
        max: u32,
    },
    Status {
        iteration: u32,
        elapsed_secs: u64,
        prompt_tokens: u32,
        completion_tokens: u32,
        cached_tokens: u32,
        context_tokens: usize,
    },
    PhaseChange {
        label: String,
    },
    PlanReady {
        plan: String,
    },
    SwarmAgentStarted {
        agent_id: String,
        agent_name: String,
        task_preview: String,
    },
    SwarmAgentProgress {
        agent_id: String,
        agent_name: String,
        iteration: u32,
        status: String,
    },
    SwarmAgentToolCall {
        agent_id: String,
        name: String,
        args: String,
    },
    SwarmAgentToolResult {
        agent_id: String,
        name: String,
        result: String,
        success: bool,
    },
    SwarmAgentToken {
        agent_id: String,
        text: String,
    },
    SwarmAgentResponse {
        agent_id: String,
        text: String,
    },
    SwarmAgentDone {
        agent_id: String,
        agent_name: String,
        success: bool,
        modified_files: Vec<String>,
        tool_calls: u32,
        input_tokens: u64,
        output_tokens: u64,
        response: String,
    },
    SwarmWorkerApproaching {
        agent_id: String,
        task_preview: String,
        remaining: u32,
    },
    SwarmConflict {
        conflicts: Vec<(String, Vec<String>)>,
    },
    SwarmDone {
        merged_response: String,
        agent_count: usize,
        total_tool_calls: u32,
        conflicts_resolved: usize,
    },
    SwarmWorkersDispatched,
    SwarmWorkerPaused {
        agent_id: String,
    },
    SwarmWorkerResumed {
        agent_id: String,
    },
    PerformanceUpdate {
        tool_latency_avg_ms: f64,
        api_latency_avg_ms: f64,
        total_iterations: u32,
        total_tokens_used: u64,
    },
    Done,
}

impl WebEvent {
    /// Convert an `AgentEvent` reference into a `WebEvent`.
    ///
    /// Fields that are expensive or non-serialisable (e.g. `ConversationContext`)
    /// are intentionally omitted.
    pub fn from_agent_event(ev: &AgentEvent) -> Self {
        match ev {
            AgentEvent::Token(t) => Self::Token { text: t.clone() },
            AgentEvent::Response(t) => Self::Response { text: t.clone() },
            AgentEvent::ToolCall { name, args, .. } => Self::ToolCall {
                name: name.clone(),
                args: args.clone(),
            },
            AgentEvent::ToolResult {
                name,
                result,
                success,
                ..
            } => Self::ToolResult {
                name: name.clone(),
                result: result.clone(),
                success: *success,
            },
            AgentEvent::FileModified { path } => Self::FileModified { path: path.clone() },
            AgentEvent::Done { .. } => Self::Done,
            AgentEvent::Error(msg) => Self::Error {
                message: msg.clone(),
            },
            AgentEvent::GuardStop(msg) => Self::GuardStop {
                message: msg.clone(),
            },
            AgentEvent::StreamRetry { attempt, max, .. } => Self::StreamRetry {
                attempt: *attempt,
                max: *max,
            },
            AgentEvent::Status {
                iteration,
                elapsed_secs,
                prompt_tokens,
                completion_tokens,
                cached_tokens,
                context_tokens,
            } => Self::Status {
                iteration: *iteration,
                elapsed_secs: *elapsed_secs,
                prompt_tokens: *prompt_tokens,
                completion_tokens: *completion_tokens,
                cached_tokens: *cached_tokens,
                context_tokens: *context_tokens,
            },
            AgentEvent::PhaseChange { label } => Self::PhaseChange {
                label: label.clone(),
            },
            AgentEvent::PlanReady { plan, .. } => Self::PlanReady { plan: plan.clone() },
            AgentEvent::SwarmAgentStarted {
                agent_id,
                agent_name,
                task_preview,
            } => Self::SwarmAgentStarted {
                agent_id: agent_id.clone(),
                agent_name: agent_name.clone(),
                task_preview: task_preview.clone(),
            },
            AgentEvent::SwarmAgentProgress {
                agent_id,
                agent_name,
                iteration,
                status,
            } => Self::SwarmAgentProgress {
                agent_id: agent_id.clone(),
                agent_name: agent_name.clone(),
                iteration: *iteration,
                status: status.clone(),
            },
            AgentEvent::SwarmAgentToolCall {
                agent_id,
                name,
                args,
            } => Self::SwarmAgentToolCall {
                agent_id: agent_id.clone(),
                name: name.clone(),
                args: args.clone(),
            },
            AgentEvent::SwarmAgentToolResult {
                agent_id,
                name,
                result,
                success,
            } => Self::SwarmAgentToolResult {
                agent_id: agent_id.clone(),
                name: name.clone(),
                result: result.clone(),
                success: *success,
            },
            AgentEvent::SwarmAgentToken { agent_id, text } => Self::SwarmAgentToken {
                agent_id: agent_id.clone(),
                text: text.clone(),
            },
            AgentEvent::SwarmAgentResponse { agent_id, text } => Self::SwarmAgentResponse {
                agent_id: agent_id.clone(),
                text: text.clone(),
            },
            AgentEvent::SwarmAgentDone {
                agent_id,
                agent_name,
                success,
                modified_files,
                tool_calls,
                input_tokens,
                output_tokens,
                response,
            } => Self::SwarmAgentDone {
                agent_id: agent_id.clone(),
                agent_name: agent_name.clone(),
                success: *success,
                modified_files: modified_files.clone(),
                tool_calls: *tool_calls,
                input_tokens: *input_tokens,
                output_tokens: *output_tokens,
                response: response.clone(),
            },
            AgentEvent::SwarmModeSwitch { .. } => Self::PhaseChange {
                label: String::new(),
            },
            AgentEvent::SwarmResolvedToSingle { .. } => Self::PhaseChange {
                label: String::new(),
            },
            AgentEvent::SwarmWorkerApproaching {
                agent_id,
                task_preview,
                remaining,
            } => Self::SwarmWorkerApproaching {
                agent_id: agent_id.clone(),
                task_preview: task_preview.clone(),
                remaining: *remaining,
            },
            AgentEvent::SwarmConflict { conflicts } => Self::SwarmConflict {
                conflicts: conflicts.clone(),
            },
            AgentEvent::SwarmDone {
                merged_response,
                agent_count,
                total_tool_calls,
                conflicts_resolved,
                ..
            } => Self::SwarmDone {
                merged_response: merged_response.clone(),
                agent_count: *agent_count,
                total_tool_calls: *total_tool_calls,
                conflicts_resolved: *conflicts_resolved,
            },
            AgentEvent::SwarmWorkersDispatched => Self::SwarmWorkersDispatched,
            AgentEvent::SwarmWorkerPaused { agent_id } => Self::SwarmWorkerPaused {
                agent_id: agent_id.clone(),
            },
            AgentEvent::SwarmWorkerResumed { agent_id } => Self::SwarmWorkerResumed {
                agent_id: agent_id.clone(),
            },
            AgentEvent::PerformanceUpdate {
                tool_latency_avg_ms,
                api_latency_avg_ms,
                total_iterations,
                total_tokens_used,
                ..
            } => Self::PerformanceUpdate {
                tool_latency_avg_ms: *tool_latency_avg_ms,
                api_latency_avg_ms: *api_latency_avg_ms,
                total_iterations: *total_iterations,
                total_tokens_used: *total_tokens_used,
            },
            AgentEvent::LspInstalled { .. }
            | AgentEvent::McpPids { .. }
            | AgentEvent::SoulReflecting { .. }
            | AgentEvent::ImageNotice { .. }
            | AgentEvent::ApprovalRequired { .. }
            | AgentEvent::ApprovalDenied { .. }
            | AgentEvent::Evolution(_)
            | AgentEvent::ShellOutput { .. } => Self::PhaseChange {
                label: String::new(),
            },
            // Observability events — forwarded to web clients as structured phase_change labels.
            AgentEvent::ToolBatchProgress { running, total } => Self::PhaseChange {
                label: format!("tools:{running}/{total}"),
            },
            AgentEvent::StreamWaiting { elapsed_secs } => Self::PhaseChange {
                label: format!("stream_waiting:{elapsed_secs}s"),
            },
            AgentEvent::CompactionStarted { .. } => Self::PhaseChange {
                label: "compaction_started".to_string(),
            },
            AgentEvent::CompactionDone {
                before_tokens,
                after_tokens,
            } => Self::PhaseChange {
                label: format!("compacted:{before_tokens}->{after_tokens}"),
            },
            AgentEvent::ToolResultTruncated {
                tool_name,
                original_bytes,
                truncated_bytes,
            } => Self::PhaseChange {
                label: format!("truncated:{tool_name}:{original_bytes}->{truncated_bytes}"),
            },
        }
    }
}