返回 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::ProviderKind;
12 use crate::error_taxonomy::ErrorEnvelope;
13 use crate::tools::goal::GoalSnapshot;
14 use crate::tools::spec::{ToolError, ToolResult};
15 use crate::tools::subagent::{AgentWorkerStatus, CoordinationDetailProjection, SubAgentResult};
16 use crate::tools::user_input::UserInputRequest;
17 use codewhale_models::{Message, SystemPrompt, Tool, Usage};
18
19 /// Provider correlation retained only for model-history reconstruction.
20 /// Event ids remain host execution ids; this is not approval authority.
21 #[derive(Debug, Clone, PartialEq)]
22 pub struct ModelToolCall {
23 pub provider_id: String,
24 pub caller: Option<codewhale_models::ToolCaller>,
25 pub thought_signature: Option<String>,
26 }
27
28 /// Final status for a turn.
29 #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
30 #[serde(rename_all = "snake_case")]
31 pub enum TurnOutcomeStatus {
32 Completed,
33 Interrupted,
34 Failed,
35 }
36
37 /// Provider/model route resolved for a model-backed turn.
38 ///
39 /// Emitted at `RouteDispatched` so hosts retain provenance until the matching
40 /// `TurnComplete` without relying on mutable global selection state. Non-model
41 /// turns such as composer `!` shell commands use no route.
42 #[derive(Debug, Clone, PartialEq, Eq)]
43 pub struct TurnRoute {
44 pub provider: ProviderKind,
45 /// Exact non-secret configured route key. Named custom providers all map
46 /// to [`ProviderKind::Custom`], so the enum alone is not provenance.
47 pub provider_identity: String,
48 pub model: String,
49 pub auto_model: bool,
50 /// Secret-free proof of the endpoint and credential generation the turn's
51 /// client was *installed* on, minted from that client rather than re-read
52 /// from config later.
53 ///
54 /// Hosts that dispatch follow-up work derived from this turn's context must
55 /// authorize it against this receipt: config is mutable and web config
56 /// events are drained ahead of engine events, so anything a host resolves
57 /// while handling `TurnStarted` may already describe a different route.
58 /// `None` when no concrete client was installed (injected-client engines,
59 /// or a client that failed to construct).
60 pub receipt: Option<crate::route_receipt::TurnRouteReceipt>,
61 /// Billing evidence for a request admitted to application dispatch.
62 ///
63 /// `None` at `TurnStarted`: a lifecycle start is not a dispatch, and a
64 /// route that has not reached admission has no billing time, no metering
65 /// surface, and no endpoint to attest. Populated exactly once at the
66 /// pre-permit application-dispatch boundary and delivered on
67 /// `RouteDispatched`. This does not attest network delivery or a provider
68 /// invoice-time rate. Consumers that price a turn must treat `None` as
69 /// *unknown*, never as a zero-cost turn.
70 pub billing: Option<RouteBillingEnvelope>,
71 /// Endpoint this turn's client was frozen against, verbatim.
72 ///
73 /// [`crate::route_receipt::TurnRouteReceipt`] deliberately keeps only a
74 /// redacted endpoint identity, which billing cannot classify from, so the
75 /// non-secret URL travels here. Captured from the resolved route candidate
76 /// at the client-freeze boundary, before any ambient selection state can
77 /// move. Empty only when no endpoint was captured, which bills Unknown
78 /// rather than guessing.
79 pub base_url: String,
80 /// Credential/pay-mode product truth captured from the route-scoped config
81 /// at the same instant.
82 ///
83 /// Together with `provider_identity` and `base_url` this is a complete
84 /// [`crate::route_billing::DispatchedReceipt`]: every fact billing needs,
85 /// frozen at the client-freeze boundary. Consumers must classify from
86 /// these fields and must never re-read an ambient `Config` after the turn
87 /// starts — by `TurnComplete` a provider switch, an auto-router hop, or a
88 /// `/provider` change can have moved it elsewhere.
89 pub billing_product: crate::route_billing::RouteProduct,
90 }
91
92 /// Dispatch-time billing evidence. Separate from [`TurnRoute`] so the type
93 /// system — not a convention — enforces that no caller can read a billing
94 /// surface, endpoint fingerprint, or dispatch instant off a route that was
95 /// only *planned*.
96 ///
97 /// This is deliberately *not* the same thing as the classification receipt
98 /// carried by [`TurnRoute::base_url`] / [`TurnRoute::billing_product`], and
99 /// the two are not merged. They are captured at different instants and answer
100 /// different questions:
101 ///
102 /// - `base_url` + `billing_product` + `provider_identity` are frozen at the
103 /// **client-freeze** boundary and answer *which route is this and how does
104 /// it bill* — a [`crate::route_billing::DispatchedReceipt`]. They must be
105 /// readable from `TurnStarted` onward so a child turn arriving mid-flight
106 /// can be billed against the parent's frozen route.
107 /// - This envelope is stamped at the **pre-permit application-dispatch**
108 /// boundary and answers *what CodeWhale admitted for provider execution,
109 /// when*. It does not claim network delivery or provider invoice-time
110 /// pricing. A merely planned route has no metering surface or dispatch
111 /// instant, so it must be structurally absent rather than defaulted.
112 #[derive(Debug, Clone, PartialEq, Eq)]
113 pub struct RouteBillingEnvelope {
114 pub openrouter_vendor: Option<String>,
115 pub billing_surface: Option<String>,
116 pub endpoint_fingerprint: Option<String>,
117 pub provider_live_pricing: Option<crate::provider_catalog_live::ProviderLivePricingQuote>,
118 pub billing_mode: crate::cost_status::RouteBillingMode,
119 pub dispatched_at: DateTime<Utc>,
120 }
121
122 impl TurnRoute {
123 /// Priceable envelope for this route, or `None` when the route was never
124 /// dispatched. Deliberately not a `Default`-filled envelope: an undispatched
125 /// route has no cost, and "no cost" is not "zero cost".
126 #[must_use]
127 pub fn cost_envelope(&self) -> Option<crate::cost_status::EffectiveRouteEnvelope> {
128 let billing = self.billing.as_ref()?;
129 Some(crate::cost_status::EffectiveRouteEnvelope {
130 provider: self.provider,
131 provider_identity: self.provider_identity.clone(),
132 model: self.model.clone(),
133 openrouter_vendor: billing.openrouter_vendor.clone(),
134 billing_surface: billing.billing_surface.clone(),
135 endpoint_fingerprint: billing.endpoint_fingerprint.clone(),
136 provider_live_pricing: billing.provider_live_pricing.clone(),
137 billing_mode: billing.billing_mode,
138 dispatched_at: billing.dispatched_at,
139 })
140 }
141 }
142
143 /// Structured lifecycle metadata paired with a human-readable
144 /// [`Event::AgentProgress`] message.
145 ///
146 /// Producers own this classification. UI consumers may bound the display
147 /// message, but must never recover lifecycle state by parsing it.
148 #[derive(Debug, Clone, PartialEq, Eq)]
149 pub struct AgentProgressEventMeta {
150 pub worker_status: AgentWorkerStatus,
151 pub step: Option<u32>,
152 /// Canonical action/tool name. Presentation aliases are applied by the UI
153 /// when it creates the bounded current-activity projection.
154 pub tool_name: Option<String>,
155 /// True when this progress is the routine per-step wait heartbeat
156 /// ("requesting model response"). Retry/timeout waits share the
157 /// `ModelWait` status but carry informative text, so the status alone
158 /// cannot tell them apart — the producer sets this instead, and UI
159 /// consumers rewrite on it rather than sniffing the message (#6290).
160 pub routine_wait: bool,
161 /// The child approval id this progress reports on: set while the agent
162 /// waits on a person and on the first progress after that wait ends, so
163 /// hosts can retire the matching card and pending entry by identity
164 /// (approvals C1) instead of by parsing the message.
165 pub approval_id: Option<String>,
166 }
167
168 impl AgentProgressEventMeta {
169 #[must_use]
170 pub const fn new(worker_status: AgentWorkerStatus) -> Self {
171 Self {
172 worker_status,
173 step: None,
174 tool_name: None,
175 routine_wait: false,
176 approval_id: None,
177 }
178 }
179
180 #[must_use]
181 pub const fn with_step(mut self, step: u32) -> Self {
182 self.step = Some(step);
183 self
184 }
185
186 #[must_use]
187 pub fn with_tool(mut self, tool_name: impl Into<String>) -> Self {
188 self.tool_name = Some(tool_name.into());
189 self
190 }
191
192 #[must_use]
193 pub fn with_approval_id(mut self, approval_id: impl Into<String>) -> Self {
194 self.approval_id = Some(approval_id.into());
195 self
196 }
197 }
198
199 /// Events emitted by the engine to update the UI.
200 #[derive(Debug, Clone)]
201 pub enum Event {
202 /// A route compatibility check omitted tools from the provider request.
203 /// Names are wire-safe and bounded by the request catalog; schemas and
204 /// validator details never cross this user-visible boundary.
205 ToolProjectionWarning {
206 provider: String,
207 omitted_tool_names: Vec<String>,
208 omitted_tool_count: usize,
209 },
210
211 /// Workspace snapshots (undo) could not be enabled for this workspace.
212 /// Emitted once per session/workspace so another session cannot consume
213 /// its notice. The disabled state also remains visible in `/status` (#5930).
214 /// `reason` is the single localized line rendered from the gate, so every
215 /// surface states the workspace, the limit, and the recovery exactly once.
216 SnapshotsDisabled { workspace: String, reason: String },
217 // === Streaming Events ===
218 /// A new message block has started
219 MessageStarted { index: usize },
220
221 /// Incremental text content delta
222 MessageDelta { index: usize, content: String },
223
224 /// Message block completed
225 MessageComplete { index: usize },
226
227 /// Thinking block started
228 ThinkingStarted { index: usize },
229
230 /// Incremental thinking content delta
231 ThinkingDelta { index: usize, content: String },
232
233 /// Thinking block completed
234 ThinkingComplete { index: usize },
235
236 // === Tool Events ===
237 /// Tool call initiated
238 ToolCallStarted {
239 id: String,
240 model_call: Option<ModelToolCall>,
241 name: String,
242 input: Value,
243 },
244
245 /// Best-effort liveness pulse while a tool future remains pending.
246 ///
247 /// This carries no output and must not change user-visible status or the
248 /// transcript. It only prevents the TUI from declaring a healthy,
249 /// deliberately long-running tool turn stale.
250 ToolCallHeartbeat,
251
252 /// Optional actual dispatch observation for a narrowed protocol host.
253 ToolExecutionStarted { id: String },
254 /// Canonical rich result after existing output/media preservation.
255 ToolResultContent {
256 id: String,
257 blocks: Vec<codewhale_tools::ToolResultContentBlock>,
258 },
259
260 /// Tool call completed
261 ToolCallComplete {
262 id: String,
263 model_call: Option<ModelToolCall>,
264 name: String,
265 result: Result<ToolResult, ToolError>,
266 },
267
268 /// Trusted operation activity emitted only after dispatch and authority
269 /// gates resolve the underlying operation. The payload deliberately
270 /// excludes tool names, arguments, commands, and results.
271 OperationActivityStarted {
272 span_id: String,
273 activity_kind: codewhale_protocol::engine_owner::OwnerActivityKind,
274 },
275 OperationActivityCompleted {
276 span_id: String,
277 activity_kind: codewhale_protocol::engine_owner::OwnerActivityKind,
278 outcome: codewhale_protocol::engine_owner::OwnerOperationOutcome,
279 },
280
281 // === Turn Lifecycle ===
282 /// A new turn has started (user sent a message)
283 TurnStarted {
284 turn_id: String,
285 created_at: DateTime<Utc>,
286 /// Legacy/non-model hosts may still attach a route at start. Model
287 /// turns emit it separately at the application dispatch boundary.
288 route: Option<TurnRoute>,
289 /// Correlation with the host submission that produced this turn: the
290 /// verbatim echo of the `submission_id` the host stamped on its
291 /// `SendMessage`/`EditLastTurn` op, `None` for every runtime
292 /// self-started turn (idle sub-agent completion, background shell
293 /// wake, goal continuation) and for the composer shell command turn.
294 /// Hosts use the echo to tell their own pending submission's
295 /// `TurnStarted` apart from an autonomous follow-up whose start event
296 /// overtook it in the stream, so a deferred submit-window action is
297 /// only ever consumed by the turn it targets.
298 submission_id: Option<String>,
299 },
300
301 /// Bounded tool-field projection from a prepared model-client request.
302 /// Delivery remains unknown; this event is emitted before connection setup.
303 ToolRequestSnapshot {
304 snapshot: crate::tool_inspection::ToolInspectionSnapshot,
305 },
306
307 /// The engine took a workspace snapshot for the running turn: before it
308 /// (`pre_turn`), before one file-modifying tool call (`tool`), after that
309 /// call (`post_tool`, recording hosts only), or after the turn
310 /// (`post_turn`). A host that records these on its turn records owns
311 /// exactly those restore points (see `crate::snapshot::WorkspaceSnapshotRef`).
312 /// With `EngineConfig::record_restore_points` every receipt of a turn
313 /// arrives before its `TurnComplete`.
314 WorkspaceSnapshotTaken {
315 snapshot: crate::snapshot::WorkspaceSnapshotRef,
316 },
317
318 /// Immutable billing route captured at CodeWhale's pre-permit application
319 /// dispatch boundary, after request preparation. This is admission-time
320 /// evidence, not proof of network delivery or provider invoice-time rates.
321 RouteDispatched { turn_id: String, route: TurnRoute },
322
323 /// The turn is complete (no more tool calls)
324 TurnComplete {
325 /// Total usage for session/goal/token metrics, including programmatic
326 /// child calls performed inline during this turn.
327 usage: Usage,
328 /// Usage served by the parent turn's frozen route only. Consumers
329 /// price this under the parent quote and price routed children from
330 /// their own receipts, avoiding double billing without subtraction.
331 parent_route_usage: Usage,
332 /// Provider calls whose execution/usage could not be receipted.
333 /// Non-zero makes cost coverage explicitly incomplete.
334 routed_usage_dropped_records: u64,
335 status: TurnOutcomeStatus,
336 error: Option<String>,
337 /// Tool catalog sent with this turn's model request.
338 tool_catalog: Option<Vec<Tool>>,
339 /// API base URL used by this turn's client.
340 base_url: Option<String>,
341 },
342
343 /// A single model call (turn-step) within the turn completed and the
344 /// provider reported usage for it. Unlike `TurnComplete`, which fires
345 /// once per turn with the cumulative usage, this fires once per model
346 /// request so consumers can attribute tokens (including reasoning and
347 /// cache behavior) to individual steps. It is not emitted when the
348 /// provider never reported usage for the call — absence is honest, and
349 /// fields inside `usage` stay `None` when the provider omits them.
350 TurnUsage {
351 /// Primary request allowance; not a claim of provider-reported usage.
352 max_output_tokens: Option<u32>,
353 usage: Usage,
354 /// Wall-clock duration of this model call's stream.
355 duration_ms: u64,
356 /// Wall-clock time from the moment the request was dispatched to the
357 /// provider until the first content-bearing stream event arrived
358 /// (time to first token). `None` when the call produced no content
359 /// or the emitting path does not measure the first content event
360 /// (non-streaming reviewer / REPL consults).
361 first_token_ms: Option<u64>,
362 /// Wall-clock time from request dispatch to the usage receipt for
363 /// this model call — the whole call including connection setup, not
364 /// only the stream. `None` where an individual request is not
365 /// measured (for example an aggregate REPL child receipt). This is
366 /// the denominator for effective session-average throughput.
367 request_ms: Option<u64>,
368 },
369
370 /// Usage telemetry for a programmatic provider call whose cost is carried
371 /// by its own routed receipt rather than the active parent route. TUI
372 /// consumers fold this into model-call metrics only; `TurnComplete.usage`
373 /// remains the authoritative total-token reconciliation.
374 RoutedTurnUsage {
375 usage: Usage,
376 duration_ms: u64,
377 first_token_ms: Option<u64>,
378 request_ms: Option<u64>,
379 },
380
381 /// Runtime goal state changed inside the engine, usually from model-visible
382 /// `create_goal` or `update_goal` tool calls.
383 GoalUpdated { snapshot: GoalSnapshot },
384
385 /// The interactive engine is in the configured quiet period before one
386 /// already-authorized goal continuation. This is lifecycle state, not a
387 /// status string: Esc/Ctrl+C can cancel it without pretending a provider
388 /// turn is still in flight.
389 GoalContinuationWaiting { delay_seconds: u64 },
390
391 /// The between-turn quiet period ended. `interrupted` distinguishes a
392 /// user/external cancel from normal expiry or a goal status control.
393 GoalContinuationWaitEnded { interrupted: bool },
394
395 /// Context compaction started.
396 CompactionStarted {
397 id: String,
398 auto: bool,
399 message: String,
400 },
401
402 /// Context compaction completed.
403 CompactionCompleted {
404 id: String,
405 auto: bool,
406 message: String,
407 /// Number of messages before compaction.
408 messages_before: Option<usize>,
409 /// Number of messages after compaction.
410 messages_after: Option<usize>,
411 /// Rendered text of the accumulated compaction summary prompt, if any.
412 /// Host layers (e.g. the /v1 runtime) persist this into the thread
413 /// record so the summary survives engine reloads — without it the
414 /// summary lives only in engine memory and is lost on LRU eviction
415 /// or restart (SyncSession re-extracts it from the record prompt).
416 summary_prompt: Option<String>,
417 /// Conservative input-token estimate for the complete post-compaction
418 /// request, including its system prompt. Hosts can use this until the
419 /// next provider-reported usage arrives.
420 post_input_tokens: Option<u64>,
421 },
422
423 /// Context compaction was canceled before it could commit a checkpoint.
424 ///
425 /// The stable id makes cancellation idempotent and lets host layers settle
426 /// the exact durable item without inferring lifecycle from status prose.
427 CompactionCancelled {
428 id: String,
429 auto: bool,
430 message: String,
431 },
432
433 /// Context purge started.
434 PurgeStarted {
435 /// Status message for display.
436 message: String,
437 },
438
439 /// Context purge completed.
440 PurgeCompleted {
441 /// Number of messages before purge.
442 messages_before: usize,
443 /// Number of messages after purge.
444 messages_after: usize,
445 /// How many messages were removed.
446 removed_count: usize,
447 /// How many replace operations were applied.
448 replaced_count: usize,
449 /// Summary message for display.
450 message: String,
451 },
452
453 /// Context purge failed.
454 PurgeFailed { message: String },
455
456 /// Context compaction failed.
457 CompactionFailed {
458 id: String,
459 auto: bool,
460 message: String,
461 },
462
463 // === Sub-Agent Events ===
464 /// A sub-agent has been spawned
465 AgentSpawned {
466 owner_session_id: String,
467 id: String,
468 prompt: String,
469 worker_status: Option<AgentWorkerStatus>,
470 parent_run_id: Option<String>,
471 spawn_depth: u32,
472 /// Model the child runtime was actually installed with, after route
473 /// resolution. Structured-output hosts surface this so child billing
474 /// attribution never depends on reading source or an invoice.
475 model: String,
476 /// Why the child got that route (`task.model`, `agent_profile.loadout`,
477 /// `run.model`, …). `None` for spawn paths that bypass route
478 /// resolution (checkpoint resume, engine-internal spawns).
479 route_source: Option<String>,
480 /// The name this agent goes by on every surface: its workflow task
481 /// label, dispatch name, or role, resolved once by the engine
482 /// (`subagent_display_name`). Never the raw id.
483 display_name: Option<String>,
484 },
485
486 /// Sub-agent progress update
487 AgentProgress {
488 owner_session_id: String,
489 id: String,
490 status: String,
491 activity: AgentProgressEventMeta,
492 parent_run_id: Option<String>,
493 spawn_depth: u32,
494 },
495
496 /// Sub-agent completed
497 AgentComplete {
498 owner_session_id: String,
499 id: String,
500 result: String,
501 /// Producer-owned outcome. None is a legacy receipt, never success.
502 outcome: Option<crate::tools::subagent::SubAgentStatus>,
503 parent_run_id: Option<String>,
504 spawn_depth: Option<u32>,
505 continuable: Option<bool>,
506 /// Provider-reported child usage from the durable ledger (#6315).
507 /// None means the worker has no usage receipt, never zero tokens.
508 usage: Option<crate::tools::subagent::AgentRunUsage>,
509 /// Same resolved name as `AgentSpawned::display_name`.
510 display_name: Option<String>,
511 },
512
513 /// Receipt for an operator follow-up sent to a child (`Op::FollowUpSubAgent`).
514 /// `Ok` carries the delivery outcome (the target id may differ from the
515 /// addressed id when a fork was continued from a checkpoint); `Err` is the
516 /// exact reason nothing was delivered.
517 SubAgentFollowUp {
518 owner_session_id: String,
519 agent_id: String,
520 outcome: Result<crate::tools::subagent::UserFollowUpOutcome, String>,
521 },
522
523 /// Sub-agent listing plus the same bounded typed coordination projection
524 /// used by machine-readable `agents/coordinate inspect`.
525 AgentList {
526 owner_session_id: String,
527 agents: Vec<SubAgentResult>,
528 coordination: CoordinationDetailProjection,
529 /// Follow-ups handed to a running child that it has not yet taken at
530 /// its next round boundary (`agent_id` → count). Only non-zero entries.
531 queued_follow_ups: std::collections::HashMap<String, usize>,
532 /// Receipts-only roster of every agent that ran this session (#5479):
533 /// status, current step, elapsed and token usage per row, built from
534 /// the retained worker records rather than from live agent state, so a
535 /// finished agent keeps the numbers it finished with.
536 roster: Vec<crate::agent_roster::AgentRosterRow>,
537 },
538
539 /// Structured sub-agent mailbox envelope (issue #128). Carries the
540 /// monotonic seq + the typed `MailboxMessage` so the UI can route each
541 /// envelope to the correct in-transcript card.
542 SubAgentMailbox {
543 owner_session_id: String,
544 /// Engine turn identity. Sequence numbers restart for every mailbox,
545 /// so consumers must deduplicate on `(turn_id, seq)`, never `seq`
546 /// alone.
547 turn_id: String,
548 seq: u64,
549 message: crate::tools::subagent::MailboxMessage,
550 },
551
552 /// Live workflow UI event (#4122). Mirrors a typed `WorkflowUiEvent` JSON
553 /// object so the TUI can advance the WorkflowPanel and the compact history
554 /// card while a run is still in flight (not only on tool complete).
555 WorkflowUi {
556 /// Immutable conversation owner. Consumers must compare this before
557 /// revealing or applying any workflow state.
558 owner_session_id: String,
559 run_id: String,
560 /// Flattened event JSON: `{"type":"task_started", "at_ms":…, …}`.
561 /// Callers inject `run_id` on the object when available.
562 event: Value,
563 },
564
565 // === System Events ===
566 /// An error occurred
567 Error {
568 envelope: ErrorEnvelope,
569 recoverable: bool,
570 },
571
572 /// Status message for UI display
573 Status { message: String },
574
575 /// Session-owned MCP + plugin boot progress.
576 ///
577 /// Failures stay on this event (and therefore on the session page) until
578 /// retry succeeds. They are not `Status` toasts. `connecting` names the
579 /// enabled servers that have not settled yet; `finished` is the terminal
580 /// receipt for this boot pass.
581 McpSessionBoot {
582 /// Monotonic engine-owned event generation. A direct `/mcp` snapshot
583 /// may supersede one generation without suppressing later passes.
584 generation: u64,
585 snapshot: crate::mcp::McpManagerSnapshot,
586 connecting: Vec<String>,
587 finished: bool,
588 },
589
590 /// Rendered `/preview-request` manifest (#1004).
591 ///
592 /// The engine is the only authority that can rebuild the exact next-turn
593 /// request, so the manifest is rendered there and delivered as text. The
594 /// payload is normally a redacted, typed manifest — never a request body.
595 /// The explicit `base-prompt` mode may instead carry only the exact base
596 /// prompt; it never carries runtime/system additions. There is no error
597 /// variant: a manifest that cannot describe something says so in a typed
598 /// unavailable section instead.
599 RequestManifestReady { rendered: String },
600
601 /// Pause terminal input events (for interactive subprocesses).
602 PauseEvents {
603 /// Optional one-shot notification fired after the UI has actually
604 /// released the terminal to the child process.
605 ack: Option<Arc<tokio::sync::Notify>>,
606 },
607
608 /// Resume terminal input events after subprocess completion
609 ResumeEvents,
610
611 /// Request user approval for a tool call
612 ApprovalRequired {
613 id: String,
614 tool_name: String,
615 description: String,
616 /// Tool parameters for approval display. Carried on the event so the
617 /// TUI does not need to reconstruct them from `pending_tool_uses`.
618 input: Value,
619 /// Exact-argument fingerprint, used to scope *denials* (#1617).
620 approval_key: String,
621 /// Lossy / arity-aware fingerprint, used to scope *approvals* so an
622 /// "approve for session" covers later flag variants (v0.8.37).
623 approval_grouping_key: String,
624 /// The model's explanation of intent before invoking write tools (#2381).
625 /// Displayed in the approval view so users understand *why* the change
626 /// is being made before reviewing *what* will change.
627 intent_summary: Option<String>,
628 /// When true, the UI must show the prompt instead of consuming
629 /// session/auto approval shortcuts.
630 approval_force_prompt: bool,
631 },
632
633 /// The engine no longer waits for this approval. Every decision surface
634 /// must retire the request by identity without sending a user decision.
635 ApprovalWithdrawn { id: String },
636
637 /// Request user input for a tool call
638 UserInputRequired {
639 id: String,
640 request: UserInputRequest,
641 },
642
643 /// Authoritative API conversation state from the engine session.
644 ///
645 /// The UI receives granular display events, but those are not always a
646 /// lossless representation of the API transcript. DeepSeek can emit
647 /// reasoning directly followed by tool calls without a visible assistant
648 /// text block, and that assistant message still has to be persisted for
649 /// later `reasoning_content` replay.
650 SessionUpdated {
651 session_id: String,
652 /// Shared history snapshot (#6214 T2): the engine hands out an `Arc`
653 /// instead of deep-copying the transcript per event.
654 messages: Arc<Vec<Message>>,
655 system_prompt: Option<SystemPrompt>,
656 model: String,
657 workspace: PathBuf,
658 },
659
660 /// Request user decision after sandbox denial
661 // Consumers (TUI, runtime threads, exec agent, protocol parity) handle
662 // this, but the engine never emits it. It stayed "live" only because the
663 // deleted public `rlm::run_rlm_turn` put `Event` in the crate's public
664 // API (#6511). Whether to wire the emitter or drop the elevation flow is
665 // a separate product decision.
666 #[cfg_attr(
667 not(test),
668 expect(dead_code, reason = "no engine emitter yet; tests construct it")
669 )]
670 ElevationRequired {
671 tool_id: String,
672 tool_name: String,
673 command: Option<String>,
674 denial_reason: String,
675 blocked_network: bool,
676 blocked_write: bool,
677 },
678
679 /// Observable LSP repair-loop update for the Turn Inspector (#4107).
680 /// Carries only summary counts/state — never raw prompt internals.
681 LspRepairUpdate {
682 diagnostics_found: usize,
683 files: usize,
684 injected: bool,
685 },
686
687 /// Advisory note emitted by the background advisor watcher (#3982).
688 ///
689 /// A permission decision the runtime made for one proposed tool call
690 /// without a user prompt, so the transcript can carry a visible receipt
691 /// of who decided and why (the audit log keeps the full record).
692 ///
693 /// Only decisions a person would otherwise never see are emitted:
694 /// Auto-Review guardian verdicts, guardian failures (which deny, fail
695 /// closed), and deterministic Auto-Review blocks. Proven-safe
696 /// deterministic allows stay silent, like rule-based auto-approvals in
697 /// other harnesses, so a routine read does not spam the transcript.
698 ToolGateDecision {
699 /// The child (sub-agent / Fleet worker) whose call was gated, or
700 /// `None` for the parent turn. Hosts route a child's receipt into that
701 /// child's transcript.
702 agent_id: Option<String>,
703 /// Tool-call id the decision applies to.
704 tool_id: String,
705 /// Tool name as the model called it.
706 tool_name: String,
707 /// Which gate decided.
708 gate: ToolGate,
709 /// What it decided.
710 decision: ToolGateVerdict,
711 /// Reviewer risk tier when a guardian answered (`low`, `medium`,
712 /// `high`, `critical`); `None` for deterministic gates and failures.
713 risk: Option<String>,
714 /// Bounded, control-stripped rationale safe to render as one line.
715 reason: String,
716 },
717
718 /// Fired fire-and-forget after `TurnComplete` when the advisor is enabled
719 /// and the completed turn contained at least one tool call. The note is
720 /// a concise LLM-generated summary of concerns observed in the bounded
721 /// tool-call slice; it never blocks or fails the parent turn.
722 AdvisoryNote {
723 /// The turn whose tool calls were reviewed.
724 turn_id: String,
725 /// Concise advisory text (one to three sentences). May be suppressed
726 /// by the emission guard's rate-limit or dedup window.
727 note: String,
728 /// Number of tool-call pairs that were included in the review slice.
729 tool_call_count: u32,
730 },
731
732 // === Prefix-Cache Stability Events ===
733 /// The prefix (system prompt + tool specs) changed between turns,
734 /// which invalidates DeepSeek's KV prefix cache. Carries diagnostics
735 /// for the TUI to surface.
736 PrefixCacheChange {
737 /// Human-readable description of what changed.
738 description: String,
739 /// Whether the system prompt component changed.
740 system_prompt_changed: bool,
741 /// Whether the tool set component changed.
742 tools_changed: bool,
743 /// Overall prefix stability percentage (100 = fully stable).
744 stability_pct: u32,
745 /// True when the prefix actually changed (cache invalidated).
746 /// False for routine stable-check heartbeats.
747 changed: bool,
748 /// Current pinned prefix combined hash (SHA-256, 64 hex chars).
749 /// Carried so `/cache stats` can surface it without reaching
750 /// into the engine's PrefixStabilityManager.
751 pinned_combined_hash: String,
752 /// Why the current pin exists: `initial`, `resume`, or
753 /// `change:<what>`. Empty when unknown.
754 pin_reason: String,
755 /// Explanation of the most recent expected miss (declared header
756 /// change, history reset, or undeclared drift). Empty when none.
757 last_miss_reason: String,
758 /// `<context_update>` snapshots appended this session.
759 context_updates: u64,
760 },
761 }
762
763 const TOOL_PROJECTION_WARNING_MAX_NAMES: usize = 8;
764 const TOOL_PROJECTION_WARNING_MAX_NAME_CHARS: usize = 64;
765
766 /// Return a privacy-safe, display-bounded sample of omitted wire tool names.
767 ///
768 /// MCP catalogs may contain thousands of tools and their names are supplied by
769 /// external servers. Keep the exact count separately, but never let one route
770 /// diagnostic flood a transcript, toast, or runtime record.
771 #[must_use]
772 pub fn bounded_tool_projection_warning_names(omitted_tool_names: &[String]) -> Vec<String> {
773 omitted_tool_names
774 .iter()
775 .take(TOOL_PROJECTION_WARNING_MAX_NAMES)
776 .map(|name| {
777 let cleaned: String = name
778 .chars()
779 .filter(|c| !c.is_control() && !is_bidi_format_control(*c))
780 .collect();
781 let collapsed = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
782 let collapsed = if collapsed.is_empty() {
783 "<unnamed>".to_string()
784 } else {
785 collapsed
786 };
787 if collapsed.chars().count() <= TOOL_PROJECTION_WARNING_MAX_NAME_CHARS {
788 return collapsed;
789 }
790 let mut out: String = collapsed
791 .chars()
792 .take(TOOL_PROJECTION_WARNING_MAX_NAME_CHARS - 1)
793 .collect();
794 out.push('…');
795 out
796 })
797 .collect()
798 }
799
800 /// Render the already-bounded name sample, marking an incomplete sample with
801 /// a language-neutral ellipsis. The exact total remains available in typed
802 /// runtime metadata.
803 #[must_use]
804 pub fn tool_projection_warning_tool_list(
805 omitted_tool_names: &[String],
806 omitted_tool_count: usize,
807 ) -> String {
808 let mut rendered = omitted_tool_names.join(", ");
809 if omitted_tool_count > omitted_tool_names.len() {
810 if !rendered.is_empty() {
811 rendered.push_str(", ");
812 }
813 rendered.push('…');
814 }
815 if rendered.is_empty() {
816 rendered.push_str("<unnamed>");
817 }
818 rendered
819 }
820
821 #[must_use]
822 pub fn tool_projection_warning_message(
823 provider: &str,
824 omitted_tool_names: &[String],
825 omitted_tool_count: usize,
826 ) -> String {
827 format!(
828 "Warning: {provider} omitted incompatible tools for this request: {}",
829 tool_projection_warning_tool_list(omitted_tool_names, omitted_tool_count)
830 )
831 }
832
833 impl Event {
834 /// Create an error event from a categorized envelope. The envelope's own
835 /// `recoverable` flag controls whether the UI flips into offline mode.
836 pub fn error(envelope: ErrorEnvelope) -> Self {
837 let recoverable = envelope.recoverable;
838 Event::Error {
839 envelope,
840 recoverable,
841 }
842 }
843
844 /// Create a new status event
845 pub fn status(message: impl Into<String>) -> Self {
846 Event::Status {
847 message: message.into(),
848 }
849 }
850 }
851
852 /// Who a [`Event::Status`] line is for once it leaves the engine.
853 ///
854 /// The TUI shows every status in its transient footer, so it needs no
855 /// classification. Durable clients (the runtime thread store and anything
856 /// that renders its items) do: scheduler, continuation and schema-hydration
857 /// lines are engine plumbing, and rendering them as transcript rows buries
858 /// the user's actual conversation.
859 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
860 pub enum StatusVisibility {
861 /// Worth a transcript row.
862 User,
863 /// Engine plumbing: keep the receipt, but clients collapse it by default.
864 Internal,
865 /// Addressed to the model, which already receives it in a tool result.
866 /// Never persist it as a user-facing item.
867 ModelOnly,
868 }
869
870 impl StatusVisibility {
871 /// Wire value carried in runtime item metadata (`metadata.visibility`).
872 #[must_use]
873 pub fn as_str(self) -> &'static str {
874 match self {
875 Self::User => "user",
876 Self::Internal => "internal",
877 Self::ModelOnly => "model_only",
878 }
879 }
880 }
881
882 /// Engine-owned retry status wording. Retain these observations in the TUI
883 /// transcript without turning unrelated scheduler/footer updates into rows.
884 #[must_use]
885 pub fn is_retry_status_receipt(message: &str) -> bool {
886 [
887 "Retry attempt: ",
888 "Retry recovery: ",
889 "Retry exhaustion: ",
890 "Retry interrupted: ",
891 "Retry stopped: ",
892 ]
893 .iter()
894 .any(|prefix| message.starts_with(prefix))
895 }
896
897 /// Classify an engine status line for durable clients. Unknown lines stay
898 /// user-visible; internal scheduler and model-only notices retain their scope.
899 #[must_use]
900 pub fn status_visibility(message: &str) -> StatusVisibility {
901 let message = message.trim();
902 if message.starts_with("Loaded deferred tool '")
903 && message.contains("Retry the call with its visible schema")
904 {
905 return StatusVisibility::ModelOnly;
906 }
907 let scheduler_row = message.starts_with("Executing tools sequentially")
908 || (message.starts_with("Executing ") && message.ends_with(" parallel chunk(s)"));
909 let continuation_row = message.starts_with("Continuing — ")
910 || message.starts_with("Continuing active goal (pass ");
911 // Successful agent completions already have their own durable receipts.
912 // Keep failure-bearing or unknown resumption notices visible.
913 let agent_resume_row = message
914 .strip_prefix("Resuming turn with ")
915 .and_then(|rest| rest.strip_suffix(" sub-agent completion(s)"))
916 .is_some_and(|count| {
917 let count = [" idle", " queued", " late"]
918 .iter()
919 .find_map(|suffix| count.strip_suffix(suffix))
920 .unwrap_or(count);
921 count.parse::<usize>().is_ok_and(|count| count > 0)
922 });
923 let approval_wait_row = (message.starts_with("Still waiting for tool approval on `")
924 || message.starts_with("Still waiting for user input on `"))
925 && message.ends_with("s — the turn is parked here until it is answered");
926 // #6511: a nested sub-RLM's forwarded rounds are the record of model
927 // calls the parent never saw; keep them, collapsed.
928 let nested_rlm_row = message.starts_with(crate::rlm::bridge::NESTED_RLM_STATUS_PREFIX);
929 if scheduler_row || continuation_row || agent_resume_row || approval_wait_row || nested_rlm_row
930 {
931 StatusVisibility::Internal
932 } else {
933 StatusVisibility::User
934 }
935 }
936
937 /// The tool call named by the engine's tool-approval wait heartbeat
938 /// ("Still waiting for tool approval on `<id>` after Ns — ..."), if `message`
939 /// is one. The runtime uses it to drop a heartbeat for an approval it has
940 /// already settled.
941 #[must_use]
942 pub fn approval_wait_tool_call(message: &str) -> Option<&str> {
943 let message = message.trim();
944 if status_visibility(message) != StatusVisibility::Internal {
945 return None;
946 }
947 message
948 .strip_prefix("Still waiting for tool approval on `")?
949 .rsplit_once("` after ")
950 .map(|(call, _)| call)
951 }
952
953 /// Which permission gate produced a [`Event::ToolGateDecision`].
954 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
955 pub enum ToolGate {
956 /// The deterministic Auto-Review policy engine (configured rules plus
957 /// the built-in safety floor); never model-reviewed.
958 AutoReviewDeterministic,
959 /// The one-shot Auto-Review model guardian consulted for a fallback hold.
960 AutoReviewGuardian,
961 }
962
963 impl ToolGate {
964 #[must_use]
965 pub fn as_str(self) -> &'static str {
966 match self {
967 Self::AutoReviewDeterministic => "auto_review_deterministic",
968 Self::AutoReviewGuardian => "auto_review_guardian",
969 }
970 }
971 }
972
973 /// What a permission gate decided for one proposed tool call.
974 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
975 pub enum ToolGateVerdict {
976 /// The call may run without a user prompt.
977 Allowed,
978 /// The call was refused with a stated rationale.
979 Denied,
980 /// The gate could not produce a verdict (timeout, transport error,
981 /// unparseable answer) and the call was denied, fail closed.
982 Unavailable,
983 }
984
985 impl ToolGateVerdict {
986 #[must_use]
987 pub fn as_str(self) -> &'static str {
988 match self {
989 Self::Allowed => "allowed",
990 Self::Denied => "denied",
991 Self::Unavailable => "unavailable",
992 }
993 }
994 }
995
996 /// Bound a gate rationale to one safe transcript line: control and bidi
997 /// format characters are dropped, whitespace is collapsed, and the text is
998 /// capped so a verbose reviewer cannot flood the transcript.
999 #[must_use]
1000 pub fn bounded_gate_reason(reason: &str) -> String {
1001 const MAX_CHARS: usize = 220;
1002 let cleaned: String = reason
1003 .chars()
1004 .filter(|c| !c.is_control() && !is_bidi_format_control(*c))
1005 .collect();
1006 let collapsed = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
1007 if collapsed.chars().count() <= MAX_CHARS {
1008 return collapsed;
1009 }
1010 let mut out: String = collapsed.chars().take(MAX_CHARS - 1).collect();
1011 out.push('…');
1012 out
1013 }
1014
1015 pub(crate) fn is_bidi_format_control(c: char) -> bool {
1016 matches!(
1017 c,
1018 '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}'
1019 )
1020 }
1021
1022 #[cfg(test)]
1023 mod tool_projection_warning_tests {
1024 use super::*;
1025
1026 #[test]
1027 fn projection_warning_names_are_count_and_length_bounded() {
1028 let names: Vec<String> = (0..12)
1029 .map(|index| format!("tool-{index}-{}\nspoof", "x".repeat(100)))
1030 .collect();
1031
1032 let bounded = bounded_tool_projection_warning_names(&names);
1033
1034 assert_eq!(bounded.len(), TOOL_PROJECTION_WARNING_MAX_NAMES);
1035 assert!(
1036 bounded
1037 .iter()
1038 .all(|name| name.chars().count() <= TOOL_PROJECTION_WARNING_MAX_NAME_CHARS)
1039 );
1040 assert!(bounded.iter().all(|name| !name.contains('\n')));
1041 assert!(tool_projection_warning_tool_list(&bounded, names.len()).ends_with(", …"));
1042 }
1043 }
1044
1045 #[cfg(test)]
1046 mod status_visibility_tests {
1047 use super::{StatusVisibility, approval_wait_tool_call, status_visibility};
1048
1049 #[test]
1050 fn engine_plumbing_statuses_are_not_user_rows() {
1051 for internal in [
1052 "Executing tools sequentially (writes, approvals, or non-parallel tools detected)",
1053 "Executing 3 read-only tools in 2 parallel chunk(s)",
1054 "Continuing — tool results",
1055 "Continuing — queued steer input",
1056 "Continuing active goal (pass 2 this turn, 5 total)",
1057 "Resuming turn with 1 sub-agent completion(s)",
1058 "Resuming turn with 2 idle sub-agent completion(s)",
1059 "Resuming turn with 3 queued sub-agent completion(s)",
1060 "Resuming turn with 4 late sub-agent completion(s)",
1061 "Still waiting for tool approval on `call-1` after 60s — the turn is parked here until it is answered",
1062 "Still waiting for user input on `call-2` after 120s — the turn is parked here until it is answered",
1063 ] {
1064 assert_eq!(
1065 status_visibility(internal),
1066 StatusVisibility::Internal,
1067 "{internal}"
1068 );
1069 }
1070 for model_only in [
1071 "Loaded deferred tool 'load_skill'. Retry the call with its visible schema.",
1072 "Loaded deferred tool 'load_skill' after resolving 'skill'. Retry the call with its visible schema.",
1073 ] {
1074 assert_eq!(
1075 status_visibility(model_only),
1076 StatusVisibility::ModelOnly,
1077 "{model_only}"
1078 );
1079 }
1080 for user in [
1081 "Request cancelled",
1082 "Reconnecting…",
1083 "Goal set; starting goal work.",
1084 "Still waiting for the service to reconnect; retry in a moment.",
1085 "Still waiting for tool approval on `call-1` after an unexpected failure",
1086 "Resuming turn with 1 sub-agent completion(s) (1 failed)",
1087 "Resuming turn with 2 idle sub-agent completion(s) (1 failed)",
1088 "Resuming turn with unexpected sub-agent completion(s)",
1089 "Resuming turn with 1 unknown sub-agent completion(s)",
1090 "Turn ending with 1 detached sub-agent(s) still running in the background; they'll report when done.",
1091 ] {
1092 assert_eq!(status_visibility(user), StatusVisibility::User, "{user}");
1093 }
1094 assert_eq!(StatusVisibility::Internal.as_str(), "internal");
1095 }
1096
1097 #[test]
1098 fn retry_status_receipts_classify_every_closing_kind_without_footer_noise() {
1099 for message in [
1100 "Retry attempt: transport 1/2; upstream 503; waiting 0.00s",
1101 "Retry recovery: stream recovered after 1 retries",
1102 "Retry exhaustion: stream stopped after 2 retries",
1103 "Retry interrupted: request cancelled",
1104 "Retry stopped: transparent stream completion was not observed",
1105 ] {
1106 assert!(super::is_retry_status_receipt(message), "{message}");
1107 assert_eq!(status_visibility(message), StatusVisibility::User);
1108 }
1109 for message in [
1110 "Executing tools sequentially",
1111 "Loaded deferred tool 'load_skill'. Retry the call with its visible schema.",
1112 "Goal set; starting goal work.",
1113 "Retry stopped",
1114 ] {
1115 assert!(!super::is_retry_status_receipt(message), "{message}");
1116 }
1117 }
1118
1119 #[test]
1120 fn approval_wait_tool_call_names_the_raw_call_id() {
1121 assert_eq!(
1122 approval_wait_tool_call(
1123 "Still waiting for tool approval on `call_00_x|99765c30-c427` after 60s — the turn is parked here until it is answered"
1124 ),
1125 Some("call_00_x|99765c30-c427")
1126 );
1127 for not_a_heartbeat in [
1128 "Still waiting for user input on `call-2` after 120s — the turn is parked here until it is answered",
1129 "Still waiting for tool approval on `call-1` after an unexpected failure",
1130 "Continuing — tool results",
1131 ] {
1132 assert_eq!(
1133 approval_wait_tool_call(not_a_heartbeat),
1134 None,
1135 "{not_a_heartbeat}"
1136 );
1137 }
1138 }
1139 }
1140
1140 lines RUST