| 1 | //! Events emitted by the core engine to the UI. |
| 2 | //! |
| 3 | //! These events flow from the engine to the TUI via a channel, |
| 4 | //! enabling non-blocking, real-time updates. |
| 5 | |
| 6 | use std::{path::PathBuf, sync::Arc}; |
| 7 | |
| 8 | use chrono::{DateTime, Utc}; |
| 9 | use serde_json::Value; |
| 10 | |
| 11 | use crate::config::ApiProvider; |
| 12 | use crate::error_taxonomy::ErrorEnvelope; |
| 13 | use crate::models::{Message, SystemPrompt, Tool, Usage}; |
| 14 | use crate::tools::goal::GoalSnapshot; |
| 15 | use crate::tools::spec::{ToolError, ToolResult}; |
| 16 | use crate::tools::subagent::{AgentWorkerStatus, CoordinationDetailProjection, SubAgentResult}; |
| 17 | use crate::tools::user_input::UserInputRequest; |
| 18 | |
| 19 | /// Final status for a turn. |
| 20 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 21 | pub enum TurnOutcomeStatus { |
| 22 | Completed, |
| 23 | Interrupted, |
| 24 | Failed, |
| 25 | } |
| 26 | |
| 27 | /// Provider/model route resolved for a model-backed turn. |
| 28 | /// |
| 29 | /// Emitted at `RouteDispatched` so hosts retain provenance until the matching |
| 30 | /// `TurnComplete` without relying on mutable global selection state. Non-model |
| 31 | /// turns such as composer `!` shell commands use no route. |
| 32 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 33 | pub struct TurnRoute { |
| 34 | pub provider: ApiProvider, |
| 35 | /// Exact non-secret configured route key. Named custom providers all map |
| 36 | /// to [`ApiProvider::Custom`], so the enum alone is not provenance. |
| 37 | pub provider_identity: String, |
| 38 | pub model: String, |
| 39 | pub auto_model: bool, |
| 40 | /// Secret-free proof of the endpoint and credential generation the turn's |
| 41 | /// client was *installed* on, minted from that client rather than re-read |
| 42 | /// from config later. |
| 43 | /// |
| 44 | /// Hosts that dispatch follow-up work derived from this turn's context must |
| 45 | /// authorize it against this receipt: config is mutable and web config |
| 46 | /// events are drained ahead of engine events, so anything a host resolves |
| 47 | /// while handling `TurnStarted` may already describe a different route. |
| 48 | /// `None` when no concrete client was installed (injected-client engines, |
| 49 | /// or a client that failed to construct). |
| 50 | pub receipt: Option<crate::route_receipt::TurnRouteReceipt>, |
| 51 | /// Billing evidence for the request that was actually put on the wire. |
| 52 | /// |
| 53 | /// `None` at `TurnStarted`: a lifecycle start is not a dispatch, and a |
| 54 | /// route that has not been sent has no billing time, no metering surface, |
| 55 | /// and no endpoint to attest. Populated exactly once, at the wire |
| 56 | /// boundary, and delivered on `RouteDispatched`. Consumers that price a |
| 57 | /// turn must treat `None` as *unknown*, never as a zero-cost turn. |
| 58 | pub billing: Option<RouteBillingEnvelope>, |
| 59 | /// Endpoint this turn's client was frozen against, verbatim. |
| 60 | /// |
| 61 | /// [`crate::route_receipt::TurnRouteReceipt`] deliberately keeps only a |
| 62 | /// redacted endpoint identity, which billing cannot classify from, so the |
| 63 | /// non-secret URL travels here. Captured from the resolved route candidate |
| 64 | /// at the client-freeze boundary, before any ambient selection state can |
| 65 | /// move. Empty only when no endpoint was captured, which bills Unknown |
| 66 | /// rather than guessing. |
| 67 | pub base_url: String, |
| 68 | /// Credential/pay-mode product truth captured from the route-scoped config |
| 69 | /// at the same instant. |
| 70 | /// |
| 71 | /// Together with `provider_identity` and `base_url` this is a complete |
| 72 | /// [`crate::route_billing::DispatchedReceipt`]: every fact billing needs, |
| 73 | /// frozen at the client-freeze boundary. Consumers must classify from |
| 74 | /// these fields and must never re-read an ambient `Config` after the turn |
| 75 | /// starts — by `TurnComplete` a provider switch, an auto-router hop, or a |
| 76 | /// `/provider` change can have moved it elsewhere. |
| 77 | pub billing_product: crate::route_billing::RouteProduct, |
| 78 | } |
| 79 | |
| 80 | /// Dispatch-time billing evidence. Separate from [`TurnRoute`] so the type |
| 81 | /// system — not a convention — enforces that no caller can read a billing |
| 82 | /// surface, endpoint fingerprint, or dispatch instant off a route that was |
| 83 | /// only *planned*. |
| 84 | /// |
| 85 | /// This is deliberately *not* the same thing as the classification receipt |
| 86 | /// carried by [`TurnRoute::base_url`] / [`TurnRoute::billing_product`], and |
| 87 | /// the two are not merged. They are captured at different instants and answer |
| 88 | /// different questions: |
| 89 | /// |
| 90 | /// - `base_url` + `billing_product` + `provider_identity` are frozen at the |
| 91 | /// **client-freeze** boundary and answer *which route is this and how does |
| 92 | /// it bill* — a [`crate::route_billing::DispatchedReceipt`]. They must be |
| 93 | /// readable from `TurnStarted` onward so a child turn arriving mid-flight |
| 94 | /// can be billed against the parent's frozen route. |
| 95 | /// - This envelope is stamped at the **wire** boundary and answers *what was |
| 96 | /// actually put on the wire, when*. A planned-but-unsent route has no |
| 97 | /// metering surface and no dispatch instant, so it must be structurally |
| 98 | /// absent rather than defaulted. |
| 99 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 100 | pub struct RouteBillingEnvelope { |
| 101 | pub billing_surface: Option<String>, |
| 102 | pub endpoint_fingerprint: Option<String>, |
| 103 | pub billing_mode: crate::cost_status::RouteBillingMode, |
| 104 | pub dispatched_at: DateTime<Utc>, |
| 105 | } |
| 106 | |
| 107 | impl TurnRoute { |
| 108 | /// Priceable envelope for this route, or `None` when the route was never |
| 109 | /// dispatched. Deliberately not a `Default`-filled envelope: an undispatched |
| 110 | /// route has no cost, and "no cost" is not "zero cost". |
| 111 | #[must_use] |
| 112 | pub fn cost_envelope(&self) -> Option<crate::cost_status::EffectiveRouteEnvelope> { |
| 113 | let billing = self.billing.as_ref()?; |
| 114 | Some(crate::cost_status::EffectiveRouteEnvelope { |
| 115 | provider: self.provider, |
| 116 | provider_identity: self.provider_identity.clone(), |
| 117 | model: self.model.clone(), |
| 118 | billing_surface: billing.billing_surface.clone(), |
| 119 | endpoint_fingerprint: billing.endpoint_fingerprint.clone(), |
| 120 | billing_mode: billing.billing_mode, |
| 121 | dispatched_at: billing.dispatched_at, |
| 122 | }) |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | /// Structured lifecycle metadata paired with a human-readable |
| 127 | /// [`Event::AgentProgress`] message. |
| 128 | /// |
| 129 | /// Producers own this classification. UI consumers may bound the display |
| 130 | /// message, but must never recover lifecycle state by parsing it. |
| 131 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 132 | pub struct AgentProgressEventMeta { |
| 133 | pub worker_status: AgentWorkerStatus, |
| 134 | pub step: Option<u32>, |
| 135 | /// Canonical action/tool name. Presentation aliases are applied by the UI |
| 136 | /// when it creates the bounded current-activity projection. |
| 137 | pub tool_name: Option<String>, |
| 138 | } |
| 139 | |
| 140 | impl AgentProgressEventMeta { |
| 141 | #[must_use] |
| 142 | pub const fn new(worker_status: AgentWorkerStatus) -> Self { |
| 143 | Self { |
| 144 | worker_status, |
| 145 | step: None, |
| 146 | tool_name: None, |
| 147 | } |
| 148 | } |
| 149 | |
| 150 | #[must_use] |
| 151 | pub const fn with_step(mut self, step: u32) -> Self { |
| 152 | self.step = Some(step); |
| 153 | self |
| 154 | } |
| 155 | |
| 156 | #[must_use] |
| 157 | pub fn with_tool(mut self, tool_name: impl Into<String>) -> Self { |
| 158 | self.tool_name = Some(tool_name.into()); |
| 159 | self |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | /// Events emitted by the engine to update the UI. |
| 164 | #[derive(Debug, Clone)] |
| 165 | pub enum Event { |
| 166 | // === Streaming Events === |
| 167 | /// A new message block has started |
| 168 | MessageStarted { |
| 169 | #[allow(dead_code)] |
| 170 | index: usize, |
| 171 | }, |
| 172 | |
| 173 | /// Incremental text content delta |
| 174 | MessageDelta { |
| 175 | #[allow(dead_code)] |
| 176 | index: usize, |
| 177 | content: String, |
| 178 | }, |
| 179 | |
| 180 | /// Message block completed |
| 181 | MessageComplete { |
| 182 | #[allow(dead_code)] |
| 183 | index: usize, |
| 184 | }, |
| 185 | |
| 186 | /// Thinking block started |
| 187 | ThinkingStarted { |
| 188 | #[allow(dead_code)] |
| 189 | index: usize, |
| 190 | }, |
| 191 | |
| 192 | /// Incremental thinking content delta |
| 193 | ThinkingDelta { |
| 194 | #[allow(dead_code)] |
| 195 | index: usize, |
| 196 | content: String, |
| 197 | }, |
| 198 | |
| 199 | /// Thinking block completed |
| 200 | ThinkingComplete { |
| 201 | #[allow(dead_code)] |
| 202 | index: usize, |
| 203 | }, |
| 204 | |
| 205 | // === Tool Events === |
| 206 | /// Tool call initiated |
| 207 | ToolCallStarted { |
| 208 | id: String, |
| 209 | name: String, |
| 210 | input: Value, |
| 211 | }, |
| 212 | |
| 213 | /// Best-effort liveness pulse while a tool future remains pending. |
| 214 | /// |
| 215 | /// This carries no output and must not change user-visible status or the |
| 216 | /// transcript. It only prevents the TUI from declaring a healthy, |
| 217 | /// deliberately long-running tool turn stale. |
| 218 | ToolCallHeartbeat, |
| 219 | |
| 220 | /// Tool call completed |
| 221 | ToolCallComplete { |
| 222 | id: String, |
| 223 | name: String, |
| 224 | result: Result<ToolResult, ToolError>, |
| 225 | }, |
| 226 | |
| 227 | // === Turn Lifecycle === |
| 228 | /// A new turn has started (user sent a message) |
| 229 | TurnStarted { |
| 230 | turn_id: String, |
| 231 | created_at: DateTime<Utc>, |
| 232 | /// Legacy/non-model hosts may still attach a route at start. Model |
| 233 | /// turns emit it separately at the real provider dispatch boundary. |
| 234 | route: Option<TurnRoute>, |
| 235 | }, |
| 236 | |
| 237 | /// Bounded tool-field projection from a prepared model-client request. |
| 238 | /// Delivery remains unknown; this event is emitted before connection setup. |
| 239 | ToolRequestSnapshot { |
| 240 | snapshot: crate::tool_inspection::ToolInspectionSnapshot, |
| 241 | }, |
| 242 | |
| 243 | /// Immutable billing route captured immediately before the first provider |
| 244 | /// request, after snapshots and other potentially slow pre-dispatch work. |
| 245 | RouteDispatched { turn_id: String, route: TurnRoute }, |
| 246 | |
| 247 | /// The turn is complete (no more tool calls) |
| 248 | TurnComplete { |
| 249 | usage: Usage, |
| 250 | status: TurnOutcomeStatus, |
| 251 | error: Option<String>, |
| 252 | /// Tool catalog sent with this turn's model request. |
| 253 | tool_catalog: Option<Vec<Tool>>, |
| 254 | /// API base URL used by this turn's client. |
| 255 | base_url: Option<String>, |
| 256 | }, |
| 257 | |
| 258 | /// A single model call (turn-step) within the turn completed and the |
| 259 | /// provider reported usage for it. Unlike `TurnComplete`, which fires |
| 260 | /// once per turn with the cumulative usage, this fires once per model |
| 261 | /// request so consumers can attribute tokens (including reasoning and |
| 262 | /// cache behavior) to individual steps. It is not emitted when the |
| 263 | /// provider never reported usage for the call — absence is honest, and |
| 264 | /// fields inside `usage` stay `None` when the provider omits them. |
| 265 | TurnUsage { |
| 266 | usage: Usage, |
| 267 | /// Wall-clock duration of this model call's stream. |
| 268 | duration_ms: u64, |
| 269 | }, |
| 270 | |
| 271 | /// Runtime goal state changed inside the engine, usually from model-visible |
| 272 | /// `create_goal` or `update_goal` tool calls. |
| 273 | GoalUpdated { snapshot: GoalSnapshot }, |
| 274 | |
| 275 | /// Context compaction started. |
| 276 | CompactionStarted { |
| 277 | id: String, |
| 278 | auto: bool, |
| 279 | message: String, |
| 280 | }, |
| 281 | |
| 282 | /// Context compaction completed. |
| 283 | CompactionCompleted { |
| 284 | id: String, |
| 285 | auto: bool, |
| 286 | message: String, |
| 287 | /// Number of messages before compaction. |
| 288 | #[allow(dead_code)] |
| 289 | messages_before: Option<usize>, |
| 290 | /// Number of messages after compaction. |
| 291 | #[allow(dead_code)] |
| 292 | messages_after: Option<usize>, |
| 293 | /// Rendered text of the accumulated compaction summary prompt, if any. |
| 294 | /// Host layers (e.g. the /v1 runtime) persist this into the thread |
| 295 | /// record so the summary survives engine reloads — without it the |
| 296 | /// summary lives only in engine memory and is lost on LRU eviction |
| 297 | /// or restart (SyncSession re-extracts it from the record prompt). |
| 298 | summary_prompt: Option<String>, |
| 299 | }, |
| 300 | |
| 301 | /// Context purge started. |
| 302 | PurgeStarted { |
| 303 | /// Status message for display. |
| 304 | message: String, |
| 305 | }, |
| 306 | |
| 307 | /// Context purge completed. |
| 308 | PurgeCompleted { |
| 309 | /// Number of messages before purge. |
| 310 | messages_before: usize, |
| 311 | /// Number of messages after purge. |
| 312 | messages_after: usize, |
| 313 | /// How many messages were removed. |
| 314 | removed_count: usize, |
| 315 | /// How many replace operations were applied. |
| 316 | replaced_count: usize, |
| 317 | /// Summary message for display. |
| 318 | message: String, |
| 319 | }, |
| 320 | |
| 321 | /// Context purge failed. |
| 322 | PurgeFailed { message: String }, |
| 323 | |
| 324 | /// Context compaction failed. |
| 325 | CompactionFailed { |
| 326 | id: String, |
| 327 | auto: bool, |
| 328 | message: String, |
| 329 | }, |
| 330 | |
| 331 | // === Sub-Agent Events === |
| 332 | /// A sub-agent has been spawned |
| 333 | AgentSpawned { |
| 334 | id: String, |
| 335 | prompt: String, |
| 336 | parent_run_id: Option<String>, |
| 337 | spawn_depth: u32, |
| 338 | }, |
| 339 | |
| 340 | /// Sub-agent progress update |
| 341 | AgentProgress { |
| 342 | id: String, |
| 343 | status: String, |
| 344 | activity: AgentProgressEventMeta, |
| 345 | parent_run_id: Option<String>, |
| 346 | spawn_depth: u32, |
| 347 | }, |
| 348 | |
| 349 | /// Sub-agent completed |
| 350 | AgentComplete { id: String, result: String }, |
| 351 | |
| 352 | /// Sub-agent listing plus the same bounded typed coordination projection |
| 353 | /// used by machine-readable `agents/coordinate inspect`. |
| 354 | AgentList { |
| 355 | agents: Vec<SubAgentResult>, |
| 356 | coordination: CoordinationDetailProjection, |
| 357 | }, |
| 358 | |
| 359 | /// Structured sub-agent mailbox envelope (issue #128). Carries the |
| 360 | /// monotonic seq + the typed `MailboxMessage` so the UI can route each |
| 361 | /// envelope to the correct in-transcript card. |
| 362 | SubAgentMailbox { |
| 363 | /// Engine turn identity. Sequence numbers restart for every mailbox, |
| 364 | /// so consumers must deduplicate on `(turn_id, seq)`, never `seq` |
| 365 | /// alone. |
| 366 | turn_id: String, |
| 367 | seq: u64, |
| 368 | message: crate::tools::subagent::MailboxMessage, |
| 369 | }, |
| 370 | |
| 371 | /// Live workflow UI event (#4122). Mirrors a typed `WorkflowUiEvent` JSON |
| 372 | /// object so the TUI can advance the WorkflowPanel and the compact history |
| 373 | /// card while a run is still in flight (not only on tool complete). |
| 374 | WorkflowUi { |
| 375 | run_id: String, |
| 376 | /// Flattened event JSON: `{"type":"task_started", "at_ms":…, …}`. |
| 377 | /// Callers inject `run_id` on the object when available. |
| 378 | event: Value, |
| 379 | }, |
| 380 | |
| 381 | // === System Events === |
| 382 | /// An error occurred |
| 383 | Error { |
| 384 | envelope: ErrorEnvelope, |
| 385 | #[allow(dead_code)] |
| 386 | recoverable: bool, |
| 387 | }, |
| 388 | |
| 389 | /// Status message for UI display |
| 390 | Status { message: String }, |
| 391 | |
| 392 | /// Rendered `/preview-request` manifest (#1004). |
| 393 | /// |
| 394 | /// The engine is the only authority that can rebuild the exact next-turn |
| 395 | /// request, so the manifest is rendered there and delivered as text. The |
| 396 | /// payload is normally a redacted, typed manifest — never a request body. |
| 397 | /// The explicit `base-prompt` mode may instead carry only the exact base |
| 398 | /// prompt; it never carries runtime/system additions. There is no error |
| 399 | /// variant: a manifest that cannot describe something says so in a typed |
| 400 | /// unavailable section instead. |
| 401 | RequestManifestReady { rendered: String }, |
| 402 | |
| 403 | /// Pause terminal input events (for interactive subprocesses). |
| 404 | PauseEvents { |
| 405 | /// Optional one-shot notification fired after the UI has actually |
| 406 | /// released the terminal to the child process. |
| 407 | ack: Option<Arc<tokio::sync::Notify>>, |
| 408 | }, |
| 409 | |
| 410 | /// Resume terminal input events after subprocess completion |
| 411 | ResumeEvents, |
| 412 | |
| 413 | /// Request user approval for a tool call |
| 414 | ApprovalRequired { |
| 415 | id: String, |
| 416 | tool_name: String, |
| 417 | description: String, |
| 418 | /// Tool parameters for approval display. Carried on the event so the |
| 419 | /// TUI does not need to reconstruct them from `pending_tool_uses`. |
| 420 | input: Value, |
| 421 | /// Exact-argument fingerprint, used to scope *denials* (#1617). |
| 422 | approval_key: String, |
| 423 | /// Lossy / arity-aware fingerprint, used to scope *approvals* so an |
| 424 | /// "approve for session" covers later flag variants (v0.8.37). |
| 425 | approval_grouping_key: String, |
| 426 | /// The model's explanation of intent before invoking write tools (#2381). |
| 427 | /// Displayed in the approval view so users understand *why* the change |
| 428 | /// is being made before reviewing *what* will change. |
| 429 | intent_summary: Option<String>, |
| 430 | /// When true, the UI must show the prompt instead of consuming |
| 431 | /// session/auto approval shortcuts. |
| 432 | approval_force_prompt: bool, |
| 433 | }, |
| 434 | |
| 435 | /// Request user input for a tool call |
| 436 | UserInputRequired { |
| 437 | id: String, |
| 438 | request: UserInputRequest, |
| 439 | }, |
| 440 | |
| 441 | /// Authoritative API conversation state from the engine session. |
| 442 | /// |
| 443 | /// The UI receives granular display events, but those are not always a |
| 444 | /// lossless representation of the API transcript. DeepSeek can emit |
| 445 | /// reasoning directly followed by tool calls without a visible assistant |
| 446 | /// text block, and that assistant message still has to be persisted for |
| 447 | /// later `reasoning_content` replay. |
| 448 | SessionUpdated { |
| 449 | session_id: String, |
| 450 | messages: Vec<Message>, |
| 451 | system_prompt: Option<SystemPrompt>, |
| 452 | model: String, |
| 453 | workspace: PathBuf, |
| 454 | }, |
| 455 | |
| 456 | /// Request user decision after sandbox denial |
| 457 | #[allow(dead_code)] |
| 458 | ElevationRequired { |
| 459 | tool_id: String, |
| 460 | tool_name: String, |
| 461 | command: Option<String>, |
| 462 | denial_reason: String, |
| 463 | blocked_network: bool, |
| 464 | blocked_write: bool, |
| 465 | }, |
| 466 | |
| 467 | /// Observable LSP repair-loop update for the Turn Inspector (#4107). |
| 468 | /// Carries only summary counts/state — never raw prompt internals. |
| 469 | LspRepairUpdate { |
| 470 | diagnostics_found: usize, |
| 471 | files: usize, |
| 472 | injected: bool, |
| 473 | }, |
| 474 | |
| 475 | /// Advisory note emitted by the background advisor watcher (#3982). |
| 476 | /// |
| 477 | /// Fired fire-and-forget after `TurnComplete` when the advisor is enabled |
| 478 | /// and the completed turn contained at least one tool call. The note is |
| 479 | /// a concise LLM-generated summary of concerns observed in the bounded |
| 480 | /// tool-call slice; it never blocks or fails the parent turn. |
| 481 | AdvisoryNote { |
| 482 | /// The turn whose tool calls were reviewed. |
| 483 | turn_id: String, |
| 484 | /// Concise advisory text (one to three sentences). May be suppressed |
| 485 | /// by the emission guard's rate-limit or dedup window. |
| 486 | note: String, |
| 487 | /// Number of tool-call pairs that were included in the review slice. |
| 488 | tool_call_count: u32, |
| 489 | }, |
| 490 | |
| 491 | // === Prefix-Cache Stability Events === |
| 492 | /// The prefix (system prompt + tool specs) changed between turns, |
| 493 | /// which invalidates DeepSeek's KV prefix cache. Carries diagnostics |
| 494 | /// for the TUI to surface. |
| 495 | PrefixCacheChange { |
| 496 | /// Human-readable description of what changed. |
| 497 | description: String, |
| 498 | /// Whether the system prompt component changed. |
| 499 | system_prompt_changed: bool, |
| 500 | /// Whether the tool set component changed. |
| 501 | tools_changed: bool, |
| 502 | /// Overall prefix stability percentage (100 = fully stable). |
| 503 | stability_pct: u32, |
| 504 | /// True when the prefix actually changed (cache invalidated). |
| 505 | /// False for routine stable-check heartbeats. |
| 506 | changed: bool, |
| 507 | /// Current pinned prefix combined hash (SHA-256, 64 hex chars). |
| 508 | /// Carried so `/cache stats` can surface it without reaching |
| 509 | /// into the engine's PrefixStabilityManager. |
| 510 | pinned_combined_hash: String, |
| 511 | }, |
| 512 | } |
| 513 | |
| 514 | impl Event { |
| 515 | /// Create an error event from a categorized envelope. The envelope's own |
| 516 | /// `recoverable` flag controls whether the UI flips into offline mode. |
| 517 | pub fn error(envelope: ErrorEnvelope) -> Self { |
| 518 | let recoverable = envelope.recoverable; |
| 519 | Event::Error { |
| 520 | envelope, |
| 521 | recoverable, |
| 522 | } |
| 523 | } |
| 524 | |
| 525 | /// Create a new status event |
| 526 | pub fn status(message: impl Into<String>) -> Self { |
| 527 | Event::Status { |
| 528 | message: message.into(), |
| 529 | } |
| 530 | } |
| 531 | } |
| 532 |