//! Typed, fire-and-forget event seam for a running agent turn. //! //! Every event a turn produces used to be sent inline as //! `tx.send(ServerEvent::X { .. }).await.ok()`, scattered across `llm_loop`, //! `resume`, `agent_dispatch`, and `approval`. `TurnEmitter` wraps the per-turn //! `mpsc::Sender` (which `ChatHub` bridges onto the global broadcast //! bus) and exposes one semantic method per event, so the loop speaks in domain //! terms (`emitter.tool_done(..)`) instead of constructing wire enums by hand. //! //! It is a zero-cost borrow wrapper: construct one at the top of a function that //! emits and pass `&TurnEmitter` to any helper. This is also the single seam a //! future event-bus / UI-vs-domain split would hook into. use serde_json::Value; use tokio::sync::mpsc; use core_api::message_meta::Attachment; use crate::events::ServerEvent; /// Borrows the per-turn event sender and emits typed [`ServerEvent`]s. pub(super) struct TurnEmitter<'a> { tx: &'a mpsc::Sender, } impl<'a> TurnEmitter<'a> { pub(super) fn new(tx: &'a mpsc::Sender) -> Self { Self { tx } } /// Send an event, dropping it silently if the receiver is gone (the same /// `.await.ok()` semantics every call site used before). async fn emit(&self, event: ServerEvent) { self.tx.send(event).await.ok(); } // ── User / assistant turn events ──────────────────────────────────────── /// A user message row was persisted (telnet-style echo). pub(super) async fn user_message(&self, message_id: i64, content: String, attachments: Vec) { self.emit(ServerEvent::UserMessage { message_id, content, attachments }).await; } /// The assistant produced text alongside tool calls (reasoning before acting). pub(super) async fn thinking(&self, message_id: i64, content: String, input_tokens: Option, output_tokens: Option) { self.emit(ServerEvent::Thinking { message_id, content, input_tokens, output_tokens }).await; } /// The assistant response is complete. pub(super) async fn done(&self, message_id: i64, stack_id: i64, content: String, input_tokens: Option, output_tokens: Option) { self.emit(ServerEvent::Done { message_id, stack_id, content, input_tokens, output_tokens }).await; } /// The LLM was cut off by the token limit. pub(super) async fn truncated(&self, output_tokens: Option) { self.emit(ServerEvent::Truncated { output_tokens }).await; } /// A fatal error occurred processing the request. pub(super) async fn error(&self, message: String) { self.emit(ServerEvent::Error { message }).await; } // ── Tool-call lifecycle ───────────────────────────────────────────────── #[allow(clippy::too_many_arguments)] pub(super) async fn tool_start( &self, tool_call_id: i64, message_id: i64, name: String, arguments: Value, display_name: String, icon: String, label_short: String, label_full: String, path: Option, ) { self.emit(ServerEvent::ToolStart { tool_call_id, message_id, name, arguments, display_name, icon, label_short, label_full, path, }).await; } pub(super) async fn tool_done( &self, tool_call_id: i64, result: String, result_type: String, preview_old: Option, preview_new: Option, ) { self.emit(ServerEvent::ToolDone { tool_call_id, result, result_type, preview_old, preview_new }).await; } pub(super) async fn tool_error(&self, tool_call_id: i64, error: String) { self.emit(ServerEvent::ToolError { tool_call_id, error }).await; } pub(super) async fn tool_cancelled(&self, tool_call_id: i64) { self.emit(ServerEvent::ToolCancelled { tool_call_id }).await; } pub(super) async fn tool_rejected(&self, tool_call_id: i64, reason: String) { self.emit(ServerEvent::ToolRejected { tool_call_id, reason }).await; } /// A file-write tool completed; ask clients holding the file to reload. pub(super) async fn file_changed(&self, path: String) { self.emit(ServerEvent::FileChanged { path }).await; } // ── Approval / clarification prompts ──────────────────────────────────── #[allow(clippy::too_many_arguments)] pub(super) async fn pending_write( &self, request_id: i64, tool_call_id: i64, path: String, old_content: Option, new_content: String, ) { self.emit(ServerEvent::PendingWrite { request_id, tool_call_id, path, old_content, new_content }).await; } pub(super) async fn approval_required(&self, request_id: i64, tool_call_id: i64, tool_name: String, arguments: Value) { self.emit(ServerEvent::ApprovalRequired { request_id, tool_call_id, tool_name, arguments }).await; } // Note: `AgentQuestion` is emitted directly in `dispatch_ask_user_clarification` // because that one site inspects the send Result for diagnostic logging — it is // deliberately not wrapped here. // ── Sub-agent stack frames ────────────────────────────────────────────── #[allow(clippy::too_many_arguments)] pub(super) async fn agent_start( &self, stack_id: i64, parent_tool_call_id: i64, agent_id: String, parent_agent_id: String, depth: i64, prompt_preview: String, ) { self.emit(ServerEvent::AgentStart { stack_id, parent_tool_call_id, agent_id, parent_agent_id, depth, prompt_preview, }).await; } pub(super) async fn agent_done(&self, stack_id: i64, agent_id: String, parent_agent_id: String, result_preview: String) { self.emit(ServerEvent::AgentDone { stack_id, agent_id, parent_agent_id, result_preview }).await; } // ── LLM model fallback ────────────────────────────────────────────────── pub(super) async fn model_fallback(&self, from: String, to: String, reason: String) { self.emit(ServerEvent::ModelFallback { from, to, reason }).await; } pub(super) async fn llm_failed(&self, tried: Vec, last_error: String) { self.emit(ServerEvent::LlmFailed { tried, last_error }).await; } }