返回 CodeWhale
events.rs
根目录 / crates / tui / src / core / events.rs
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
532 lines RUST