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