返回 CodeWhale
event_msg.rs
根目录 / crates / protocol / src / event_msg.rs
1 //! `EventMsg`-out API in `crates/protocol` (issue #5261, Phase A1 of the
2 //! core/protocol extraction spec).
3 //!
4 //! Mirrors `crates/tui/src/core/events::Event` variant-by-variant as a
5 //! serializable protocol. The TUI's `rx_event` / `Event` channel, the
6 //! app-server's SSE stream, and the CLI's `stream-json` output all speak this
7 //! one type so headless and TUI observe byte-identical event shapes for the
8 //! same `Op`.
9 //!
10 //! Parity is compile-enforced from the engine side:
11 //! `crates/tui/src/core/protocol_parity.rs` matches every engine `Event`
12 //! variant exhaustively into an `EventMsg` (`protocol_covers_engine_events`).
13 //! Adding an engine variant without a twin here fails to compile.
14 //!
15 //! Payload fidelity rules for this phase:
16 //!
17 //! - Scalars, ids, lifecycle enums, route/billing receipts, tool outcomes,
18 //! MCP snapshots, approvals, and gate decisions are typed here.
19 //! - Deep domain payloads whose canonical serde type still lives above this
20 //! crate (goal snapshots, sub-agent results, coordination projections,
21 //! mailbox messages, transcript messages, tool catalogs, tool inspection
22 //! snapshots, workflow UI events) cross as `serde_json::Value` produced by
23 //! that type's own `Serialize`. They are typed in later phases; the variant
24 //! and its field names are already stable.
25 //! - Engine-only handles (`oneshot`/`Notify`, `Arc<HookExecutor>`) never
26 //! cross. A variant that carried one is projected without it.
27
28 use std::collections::BTreeMap;
29 use std::path::PathBuf;
30
31 use chrono::{DateTime, Utc};
32 use serde::{Deserialize, Serialize};
33 use serde_json::Value;
34
35 use crate::ResponseChannel;
36 use crate::UserInputQuestionEvent;
37 use crate::ids::{SessionId, ThreadId};
38
39 /// Final status for a turn (`TurnComplete.status`).
40 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41 #[serde(rename_all = "snake_case")]
42 pub enum TurnOutcomeStatus {
43 Completed,
44 Interrupted,
45 Failed,
46 }
47
48 impl TurnOutcomeStatus {
49 #[must_use]
50 pub fn as_str(self) -> &'static str {
51 match self {
52 Self::Completed => "completed",
53 Self::Interrupted => "interrupted",
54 Self::Failed => "failed",
55 }
56 }
57 }
58
59 /// Token usage reported by a provider for one model call or one whole turn.
60 /// Field-for-field twin of the engine's `Usage`; `None` means the provider
61 /// did not report the fact, never zero.
62 #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
63 pub struct TokenUsage {
64 pub input_tokens: u32,
65 pub output_tokens: u32,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub prompt_cache_hit_tokens: Option<u32>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub prompt_cache_miss_tokens: Option<u32>,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub prompt_cache_write_tokens: Option<u32>,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub reasoning_tokens: Option<u32>,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub reasoning_replay_tokens: Option<u32>,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub code_execution_requests: Option<u32>,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub tool_search_requests: Option<u32>,
80 }
81
82 /// Secret-free proof of the base route a turn's client was installed on.
83 /// The credential generation digest is redacted by design on the engine side
84 /// and never crosses; only its presence does.
85 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86 pub struct TurnRouteReceipt {
87 pub provider: String,
88 pub provider_identity: String,
89 pub wire_model: String,
90 pub endpoint_identity: String,
91 pub credential_generation_present: bool,
92 }
93
94 /// Credential/pay-mode product truth captured at the client-freeze boundary.
95 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96 #[serde(tag = "kind", rename_all = "snake_case")]
97 pub enum RouteProduct {
98 /// No product fact was captured. Not a licence to guess.
99 Unproven,
100 /// Subscription-backed with this user-facing quota label.
101 Subscription { label: String },
102 /// Bills per token.
103 Metered,
104 }
105
106 /// Billing evidence captured at application admission before the provider permit.
107 /// This does not attest network delivery. Absent before admission.
108 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109 pub struct RouteBillingEnvelope {
110 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub openrouter_vendor: Option<String>,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub billing_surface: Option<String>,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub endpoint_fingerprint: Option<String>,
116 /// Validated frozen provider-live quote serialized by the runtime owner.
117 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub provider_live_pricing: Option<Value>,
119 /// `RouteBillingMode` in snake_case.
120 pub billing_mode: String,
121 pub dispatched_at: DateTime<Utc>,
122 }
123
124 /// Provider/model route resolved for a model-backed turn.
125 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126 pub struct TurnRoute {
127 /// `ApiProvider` key (`deepseek`, `openai`, `custom`, ...).
128 pub provider: String,
129 /// Exact non-secret configured route key.
130 pub provider_identity: String,
131 pub model: String,
132 pub auto_model: bool,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub receipt: Option<TurnRouteReceipt>,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub billing: Option<RouteBillingEnvelope>,
137 /// Endpoint the client was frozen against, verbatim. Empty when unknown.
138 pub base_url: String,
139 pub billing_product: RouteProduct,
140 }
141
142 /// Structured error surfaced by a tool execution.
143 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144 #[serde(tag = "kind", rename_all = "snake_case")]
145 pub enum ToolCallError {
146 InvalidInput { message: String },
147 MissingField { field: String },
148 PathEscape { path: PathBuf },
149 ExecutionFailed { message: String },
150 Timeout { seconds: u64 },
151 Cancelled { message: String },
152 NotAvailable { message: String },
153 PermissionDenied { message: String },
154 }
155
156 /// Outcome of a tool call: the engine's `Result<ToolResult, ToolError>`.
157 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
158 #[serde(tag = "outcome", rename_all = "snake_case")]
159 pub enum ToolCallOutcome {
160 Ok {
161 content: String,
162 success: bool,
163 #[serde(default, skip_serializing_if = "Option::is_none")]
164 metadata: Option<Value>,
165 },
166 Err {
167 error: ToolCallError,
168 },
169 }
170
171 /// Lifecycle metadata paired with a human-readable `AgentProgress` message.
172 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
173 pub struct AgentProgressActivity {
174 /// `AgentWorkerStatus` in snake_case.
175 pub worker_status: String,
176 #[serde(default, skip_serializing_if = "Option::is_none")]
177 pub step: Option<u32>,
178 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub tool_name: Option<String>,
180 }
181
182 /// Receipt for an operator follow-up to a child agent.
183 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
184 #[serde(tag = "outcome", rename_all = "snake_case")]
185 pub enum SubAgentFollowUpOutcome {
186 Ok {
187 agent_id: String,
188 target_agent_id: String,
189 delivered: bool,
190 resumed: bool,
191 note: String,
192 },
193 Err {
194 reason: String,
195 },
196 }
197
198 /// One row of the receipts-only agent roster.
199 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
200 pub struct AgentRosterRow {
201 pub worker_id: String,
202 pub display_name: String,
203 pub model: String,
204 /// Coarse rail state:
205 /// `running | waiting | parked | done | failed | cancelled`.
206 pub state: String,
207 /// `AgentWorkerStatus` in snake_case.
208 pub status: String,
209 #[serde(default, skip_serializing_if = "Option::is_none")]
210 pub activity: Option<String>,
211 #[serde(default, skip_serializing_if = "Option::is_none")]
212 pub millis: Option<u64>,
213 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub input_tokens: Option<u64>,
215 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub output_tokens: Option<u64>,
217 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub cost_microusd: Option<u64>,
219 pub steps_taken: u32,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub parent_run_id: Option<String>,
222 pub run_id: String,
223 }
224
225 /// One discovered MCP tool / resource / prompt.
226 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
227 pub struct McpDiscoveredItem {
228 pub name: String,
229 pub model_name: String,
230 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub description: Option<String>,
232 }
233
234 /// One configured MCP server as seen by the engine-owned pool.
235 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
236 pub struct McpServerSnapshot {
237 pub name: String,
238 pub enabled: bool,
239 pub required: bool,
240 pub transport: String,
241 pub command_or_url: String,
242 pub connect_timeout: u64,
243 pub execute_timeout: u64,
244 pub read_timeout: u64,
245 pub connected: bool,
246 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub error: Option<String>,
248 /// `advertised | legacy_fallback | not_observed`.
249 pub capability_metadata: String,
250 pub tools: Vec<McpDiscoveredItem>,
251 pub resources: Vec<McpDiscoveredItem>,
252 pub prompts: Vec<McpDiscoveredItem>,
253 }
254
255 /// Engine-owned MCP pool snapshot.
256 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
257 pub struct McpManagerSnapshot {
258 pub config_path: PathBuf,
259 pub config_exists: bool,
260 pub reload_required: bool,
261 pub servers: Vec<McpServerSnapshot>,
262 }
263
264 /// Structured clarification request (`request_user_input`).
265 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
266 pub struct UserInputRequest {
267 pub questions: Vec<UserInputQuestionEvent>,
268 }
269
270 /// Which permission gate produced a `ToolGateDecision`.
271 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
272 #[serde(rename_all = "snake_case")]
273 pub enum ToolGate {
274 AutoReviewDeterministic,
275 AutoReviewGuardian,
276 }
277
278 /// What a permission gate decided for one proposed tool call.
279 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
280 #[serde(rename_all = "snake_case")]
281 pub enum ToolGateVerdict {
282 Allowed,
283 Denied,
284 Unavailable,
285 }
286
287 /// One event emitted by the core engine to every consumer (TUI, CLI,
288 /// app-server, tests). This is the `EventMsg`-out half of the `Op`-in /
289 /// `EventMsg`-out contract: a projection of every internal engine `Event`
290 /// variant plus the thread/session ids that route it.
291 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
292 #[serde(tag = "event", rename_all = "snake_case")]
293 pub enum EventMsg {
294 /// A route compatibility check omitted tools from the provider request.
295 ToolProjectionWarning {
296 thread_id: ThreadId,
297 session_id: SessionId,
298 provider: String,
299 omitted_tool_names: Vec<String>,
300 omitted_tool_count: u64,
301 },
302
303 /// Workspace snapshots (undo) are off for this workspace. `reason` is one
304 /// rendered, localized line: the consequence, the gate that refused, and
305 /// the recovery that actually lifts *that* gate (the size cap's config key
306 /// appears only for the size gate).
307 SnapshotsDisabled {
308 thread_id: ThreadId,
309 session_id: SessionId,
310 workspace: String,
311 reason: String,
312 },
313
314 // === Streaming ===
315 MessageStarted {
316 thread_id: ThreadId,
317 session_id: SessionId,
318 index: u64,
319 },
320 /// Incremental content delta on the `text` (message) or `reasoning`
321 /// (thinking) channel.
322 ResponseDelta {
323 thread_id: ThreadId,
324 session_id: SessionId,
325 index: u64,
326 delta: String,
327 #[serde(default, skip_serializing_if = "ResponseChannel::is_text")]
328 channel: ResponseChannel,
329 },
330 MessageComplete {
331 thread_id: ThreadId,
332 session_id: SessionId,
333 index: u64,
334 },
335 ThinkingStarted {
336 thread_id: ThreadId,
337 session_id: SessionId,
338 index: u64,
339 },
340 ThinkingComplete {
341 thread_id: ThreadId,
342 session_id: SessionId,
343 index: u64,
344 },
345
346 // === Tools ===
347 ToolCallStarted {
348 thread_id: ThreadId,
349 session_id: SessionId,
350 tool_call_id: String,
351 tool_name: String,
352 input: Value,
353 },
354 /// Liveness pulse while a tool future remains pending. Carries no output.
355 ToolCallHeartbeat {
356 thread_id: ThreadId,
357 session_id: SessionId,
358 },
359 ToolCallComplete {
360 thread_id: ThreadId,
361 session_id: SessionId,
362 tool_call_id: String,
363 tool_name: String,
364 result: ToolCallOutcome,
365 },
366
367 // === Turn lifecycle ===
368 TurnStarted {
369 thread_id: ThreadId,
370 session_id: SessionId,
371 turn_id: String,
372 created_at: DateTime<Utc>,
373 #[serde(default, skip_serializing_if = "Option::is_none")]
374 route: Option<TurnRoute>,
375 },
376 /// Bounded tool-field projection from a prepared model-client request
377 /// (`ToolInspectionSnapshot` serialized).
378 ToolRequestSnapshot {
379 thread_id: ThreadId,
380 session_id: SessionId,
381 snapshot: Value,
382 },
383 /// Immutable billing route captured at application admission.
384 RouteDispatched {
385 thread_id: ThreadId,
386 session_id: SessionId,
387 turn_id: String,
388 route: TurnRoute,
389 },
390 TurnComplete {
391 thread_id: ThreadId,
392 session_id: SessionId,
393 /// The engine's `TurnComplete` carries no turn id; the emitter fills
394 /// it from the envelope when it knows it.
395 #[serde(default, skip_serializing_if = "Option::is_none")]
396 turn_id: Option<String>,
397 status: TurnOutcomeStatus,
398 #[serde(default, skip_serializing_if = "Option::is_none")]
399 error: Option<String>,
400 usage: TokenUsage,
401 /// Parent-route subset; absent in legacy events, whose split is unknown.
402 #[serde(default, skip_serializing_if = "Option::is_none")]
403 parent_route_usage: Option<TokenUsage>,
404 #[serde(default)]
405 routed_usage_dropped_records: u64,
406 /// Tool catalog sent with this turn's model request (`Tool` serialized).
407 #[serde(default, skip_serializing_if = "Option::is_none")]
408 tool_catalog: Option<Vec<Value>>,
409 #[serde(default, skip_serializing_if = "Option::is_none")]
410 base_url: Option<String>,
411 },
412 /// Usage for one model call within the turn.
413 TurnUsage {
414 #[serde(
415 default,
416 rename = "maxOutputTokens",
417 skip_serializing_if = "Option::is_none"
418 )]
419 max_output_tokens: Option<u32>,
420 thread_id: ThreadId,
421 session_id: SessionId,
422 usage: TokenUsage,
423 duration_ms: u64,
424 #[serde(default, skip_serializing_if = "Option::is_none")]
425 first_token_ms: Option<u64>,
426 #[serde(default, skip_serializing_if = "Option::is_none")]
427 request_ms: Option<u64>,
428 },
429
430 /// Child-call telemetry; cost belongs to its own routed receipt.
431 RoutedTurnUsage {
432 thread_id: ThreadId,
433 session_id: SessionId,
434 usage: TokenUsage,
435 duration_ms: u64,
436 #[serde(default, skip_serializing_if = "Option::is_none")]
437 first_token_ms: Option<u64>,
438 #[serde(default, skip_serializing_if = "Option::is_none")]
439 request_ms: Option<u64>,
440 },
441
442 // === Goals ===
443 /// Runtime goal state changed (`GoalSnapshot` serialized).
444 GoalUpdated {
445 thread_id: ThreadId,
446 session_id: SessionId,
447 snapshot: Value,
448 },
449 GoalContinuationWaiting {
450 thread_id: ThreadId,
451 session_id: SessionId,
452 delay_seconds: u64,
453 },
454 GoalContinuationWaitEnded {
455 thread_id: ThreadId,
456 session_id: SessionId,
457 interrupted: bool,
458 },
459
460 // === Compaction / purge ===
461 CompactionStarted {
462 thread_id: ThreadId,
463 session_id: SessionId,
464 id: String,
465 auto: bool,
466 message: String,
467 },
468 CompactionCompleted {
469 thread_id: ThreadId,
470 session_id: SessionId,
471 id: String,
472 auto: bool,
473 message: String,
474 #[serde(default, skip_serializing_if = "Option::is_none")]
475 messages_before: Option<u64>,
476 #[serde(default, skip_serializing_if = "Option::is_none")]
477 messages_after: Option<u64>,
478 #[serde(default, skip_serializing_if = "Option::is_none")]
479 summary_prompt: Option<String>,
480 #[serde(default, skip_serializing_if = "Option::is_none")]
481 post_input_tokens: Option<u64>,
482 },
483 CompactionCancelled {
484 thread_id: ThreadId,
485 session_id: SessionId,
486 id: String,
487 auto: bool,
488 message: String,
489 },
490 CompactionFailed {
491 thread_id: ThreadId,
492 session_id: SessionId,
493 id: String,
494 auto: bool,
495 message: String,
496 },
497 PurgeStarted {
498 thread_id: ThreadId,
499 session_id: SessionId,
500 message: String,
501 },
502 PurgeCompleted {
503 thread_id: ThreadId,
504 session_id: SessionId,
505 messages_before: u64,
506 messages_after: u64,
507 removed_count: u64,
508 replaced_count: u64,
509 message: String,
510 },
511 PurgeFailed {
512 thread_id: ThreadId,
513 session_id: SessionId,
514 message: String,
515 },
516
517 // === Sub-agents ===
518 AgentSpawned {
519 thread_id: ThreadId,
520 session_id: SessionId,
521 owner_session_id: String,
522 id: String,
523 prompt: String,
524 #[serde(default, skip_serializing_if = "Option::is_none")]
525 worker_status: Option<String>,
526 #[serde(default, skip_serializing_if = "Option::is_none")]
527 parent_run_id: Option<String>,
528 spawn_depth: u32,
529 model: String,
530 #[serde(default, skip_serializing_if = "Option::is_none")]
531 route_source: Option<String>,
532 },
533 AgentProgress {
534 thread_id: ThreadId,
535 session_id: SessionId,
536 owner_session_id: String,
537 id: String,
538 status: String,
539 activity: AgentProgressActivity,
540 #[serde(default, skip_serializing_if = "Option::is_none")]
541 parent_run_id: Option<String>,
542 spawn_depth: u32,
543 },
544 AgentComplete {
545 thread_id: ThreadId,
546 session_id: SessionId,
547 owner_session_id: String,
548 id: String,
549 result: String,
550 #[serde(default, skip_serializing_if = "Option::is_none")]
551 worker_status: Option<String>,
552 #[serde(default, skip_serializing_if = "Option::is_none")]
553 parent_run_id: Option<String>,
554 #[serde(default, skip_serializing_if = "Option::is_none")]
555 spawn_depth: Option<u32>,
556 #[serde(default, skip_serializing_if = "Option::is_none")]
557 continuable: Option<bool>,
558 },
559 SubAgentFollowUp {
560 thread_id: ThreadId,
561 session_id: SessionId,
562 owner_session_id: String,
563 agent_id: String,
564 outcome: SubAgentFollowUpOutcome,
565 },
566 /// Sub-agent listing. `agents` are `SubAgentResult`s and `coordination`
567 /// is the `CoordinationDetailProjection`, both serialized.
568 AgentList {
569 thread_id: ThreadId,
570 session_id: SessionId,
571 owner_session_id: String,
572 agents: Vec<Value>,
573 coordination: Value,
574 /// `agent_id` -> queued follow-up count; only non-zero entries.
575 #[serde(default)]
576 queued_follow_ups: BTreeMap<String, u64>,
577 roster: Vec<AgentRosterRow>,
578 },
579 /// Structured sub-agent mailbox envelope (`MailboxMessage` serialized).
580 /// Deduplicate on `(turn_id, seq)`, never `seq` alone.
581 SubAgentMailbox {
582 thread_id: ThreadId,
583 session_id: SessionId,
584 owner_session_id: String,
585 turn_id: String,
586 seq: u64,
587 message: Value,
588 },
589 /// Live workflow UI event. `ui_event` is the flattened
590 /// `{"type": ..., "at_ms": ..., ...}` object (named `event` on the
591 /// engine side; renamed here because `event` is the wire tag).
592 WorkflowUi {
593 thread_id: ThreadId,
594 session_id: SessionId,
595 owner_session_id: String,
596 run_id: String,
597 ui_event: Value,
598 },
599
600 // === System ===
601 Error {
602 thread_id: ThreadId,
603 session_id: SessionId,
604 /// `ErrorCategory` in snake_case.
605 category: String,
606 /// `ErrorSeverity` in snake_case.
607 severity: String,
608 recoverable: bool,
609 code: String,
610 message: String,
611 },
612 Status {
613 thread_id: ThreadId,
614 session_id: SessionId,
615 message: String,
616 },
617 McpSessionBoot {
618 thread_id: ThreadId,
619 session_id: SessionId,
620 generation: u64,
621 snapshot: McpManagerSnapshot,
622 connecting: Vec<String>,
623 finished: bool,
624 },
625 /// Rendered `/preview-request` manifest.
626 RequestManifestReady {
627 thread_id: ThreadId,
628 session_id: SessionId,
629 rendered: String,
630 },
631 /// Pause terminal input events for an interactive subprocess. The engine's
632 /// in-process acknowledgement handle does not cross the wire.
633 PauseEvents {
634 thread_id: ThreadId,
635 session_id: SessionId,
636 },
637 ResumeEvents {
638 thread_id: ThreadId,
639 session_id: SessionId,
640 },
641 ApprovalRequired {
642 thread_id: ThreadId,
643 session_id: SessionId,
644 id: String,
645 tool_name: String,
646 description: String,
647 input: Value,
648 approval_key: String,
649 approval_grouping_key: String,
650 #[serde(default, skip_serializing_if = "Option::is_none")]
651 intent_summary: Option<String>,
652 approval_force_prompt: bool,
653 },
654 UserInputRequired {
655 thread_id: ThreadId,
656 session_id: SessionId,
657 id: String,
658 request: UserInputRequest,
659 },
660 /// Authoritative API conversation state (`Message`s / `SystemPrompt`
661 /// serialized).
662 SessionUpdated {
663 thread_id: ThreadId,
664 session_id: SessionId,
665 engine_session_id: String,
666 messages: Vec<Value>,
667 #[serde(default, skip_serializing_if = "Option::is_none")]
668 system_prompt: Option<Value>,
669 model: String,
670 workspace: PathBuf,
671 },
672 ElevationRequired {
673 thread_id: ThreadId,
674 session_id: SessionId,
675 tool_id: String,
676 tool_name: String,
677 #[serde(default, skip_serializing_if = "Option::is_none")]
678 command: Option<String>,
679 denial_reason: String,
680 blocked_network: bool,
681 blocked_write: bool,
682 },
683 LspRepairUpdate {
684 thread_id: ThreadId,
685 session_id: SessionId,
686 diagnostics_found: u64,
687 files: u64,
688 injected: bool,
689 },
690 ToolGateDecision {
691 thread_id: ThreadId,
692 session_id: SessionId,
693 #[serde(default, skip_serializing_if = "Option::is_none")]
694 agent_id: Option<String>,
695 tool_id: String,
696 tool_name: String,
697 gate: ToolGate,
698 decision: ToolGateVerdict,
699 #[serde(default, skip_serializing_if = "Option::is_none")]
700 risk: Option<String>,
701 reason: String,
702 },
703 AdvisoryNote {
704 thread_id: ThreadId,
705 session_id: SessionId,
706 turn_id: String,
707 note: String,
708 tool_call_count: u32,
709 },
710
711 // === Prefix cache ===
712 PrefixCacheChange {
713 thread_id: ThreadId,
714 session_id: SessionId,
715 description: String,
716 system_prompt_changed: bool,
717 tools_changed: bool,
718 stability_pct: u32,
719 changed: bool,
720 pinned_combined_hash: String,
721 pin_reason: String,
722 last_miss_reason: String,
723 context_updates: u64,
724 },
725 }
726
727 /// Envelope that carries an `EventMsg` over the wire / channel with a
728 /// monotonic seq so consumers can detect drops. Mirrors the existing
729 /// `RuntimeEventEnvelope` but typed to `EventMsg`.
730 #[derive(Debug, Clone, Serialize, Deserialize)]
731 pub struct EventEnvelope {
732 pub seq: u64,
733 pub thread_id: ThreadId,
734 pub session_id: SessionId,
735 pub turn_id: Option<String>,
736 pub event: EventMsg,
737 }
738
739 /// Every wire tag `EventMsg` can carry, in declaration order. Kept next to
740 /// the enum so a new variant is added here in the same edit; the test below
741 /// proves the list and `kind_str` agree.
742 pub const EVENT_KINDS: &[&str] = &[
743 "tool_projection_warning",
744 "message_started",
745 "response_delta",
746 "message_complete",
747 "thinking_started",
748 "thinking_complete",
749 "tool_call_started",
750 "tool_call_heartbeat",
751 "tool_call_complete",
752 "turn_started",
753 "tool_request_snapshot",
754 "route_dispatched",
755 "turn_complete",
756 "turn_usage",
757 "routed_turn_usage",
758 "goal_updated",
759 "goal_continuation_waiting",
760 "goal_continuation_wait_ended",
761 "compaction_started",
762 "compaction_completed",
763 "compaction_cancelled",
764 "compaction_failed",
765 "purge_started",
766 "purge_completed",
767 "purge_failed",
768 "agent_spawned",
769 "agent_progress",
770 "agent_complete",
771 "sub_agent_follow_up",
772 "agent_list",
773 "sub_agent_mailbox",
774 "workflow_ui",
775 "error",
776 "status",
777 "mcp_session_boot",
778 "request_manifest_ready",
779 "pause_events",
780 "resume_events",
781 "approval_required",
782 "user_input_required",
783 "session_updated",
784 "elevation_required",
785 "lsp_repair_update",
786 "tool_gate_decision",
787 "advisory_note",
788 "prefix_cache_change",
789 ];
790
791 impl EventMsg {
792 #[must_use]
793 pub fn kind_str(&self) -> &'static str {
794 match self {
795 Self::ToolProjectionWarning { .. } => "tool_projection_warning",
796 Self::SnapshotsDisabled { .. } => "snapshots_disabled",
797 Self::MessageStarted { .. } => "message_started",
798 Self::ResponseDelta { .. } => "response_delta",
799 Self::MessageComplete { .. } => "message_complete",
800 Self::ThinkingStarted { .. } => "thinking_started",
801 Self::ThinkingComplete { .. } => "thinking_complete",
802 Self::ToolCallStarted { .. } => "tool_call_started",
803 Self::ToolCallHeartbeat { .. } => "tool_call_heartbeat",
804 Self::ToolCallComplete { .. } => "tool_call_complete",
805 Self::TurnStarted { .. } => "turn_started",
806 Self::ToolRequestSnapshot { .. } => "tool_request_snapshot",
807 Self::RouteDispatched { .. } => "route_dispatched",
808 Self::TurnComplete { .. } => "turn_complete",
809 Self::TurnUsage { .. } => "turn_usage",
810 Self::RoutedTurnUsage { .. } => "routed_turn_usage",
811 Self::GoalUpdated { .. } => "goal_updated",
812 Self::GoalContinuationWaiting { .. } => "goal_continuation_waiting",
813 Self::GoalContinuationWaitEnded { .. } => "goal_continuation_wait_ended",
814 Self::CompactionStarted { .. } => "compaction_started",
815 Self::CompactionCompleted { .. } => "compaction_completed",
816 Self::CompactionCancelled { .. } => "compaction_cancelled",
817 Self::CompactionFailed { .. } => "compaction_failed",
818 Self::PurgeStarted { .. } => "purge_started",
819 Self::PurgeCompleted { .. } => "purge_completed",
820 Self::PurgeFailed { .. } => "purge_failed",
821 Self::AgentSpawned { .. } => "agent_spawned",
822 Self::AgentProgress { .. } => "agent_progress",
823 Self::AgentComplete { .. } => "agent_complete",
824 Self::SubAgentFollowUp { .. } => "sub_agent_follow_up",
825 Self::AgentList { .. } => "agent_list",
826 Self::SubAgentMailbox { .. } => "sub_agent_mailbox",
827 Self::WorkflowUi { .. } => "workflow_ui",
828 Self::Error { .. } => "error",
829 Self::Status { .. } => "status",
830 Self::McpSessionBoot { .. } => "mcp_session_boot",
831 Self::RequestManifestReady { .. } => "request_manifest_ready",
832 Self::PauseEvents { .. } => "pause_events",
833 Self::ResumeEvents { .. } => "resume_events",
834 Self::ApprovalRequired { .. } => "approval_required",
835 Self::UserInputRequired { .. } => "user_input_required",
836 Self::SessionUpdated { .. } => "session_updated",
837 Self::ElevationRequired { .. } => "elevation_required",
838 Self::LspRepairUpdate { .. } => "lsp_repair_update",
839 Self::ToolGateDecision { .. } => "tool_gate_decision",
840 Self::AdvisoryNote { .. } => "advisory_note",
841 Self::PrefixCacheChange { .. } => "prefix_cache_change",
842 }
843 }
844
845 #[must_use]
846 pub fn thread_id(&self) -> &ThreadId {
847 match self {
848 Self::ToolProjectionWarning { thread_id, .. }
849 | Self::SnapshotsDisabled { thread_id, .. }
850 | Self::MessageStarted { thread_id, .. }
851 | Self::ResponseDelta { thread_id, .. }
852 | Self::MessageComplete { thread_id, .. }
853 | Self::ThinkingStarted { thread_id, .. }
854 | Self::ThinkingComplete { thread_id, .. }
855 | Self::ToolCallStarted { thread_id, .. }
856 | Self::ToolCallHeartbeat { thread_id, .. }
857 | Self::ToolCallComplete { thread_id, .. }
858 | Self::TurnStarted { thread_id, .. }
859 | Self::ToolRequestSnapshot { thread_id, .. }
860 | Self::RouteDispatched { thread_id, .. }
861 | Self::TurnComplete { thread_id, .. }
862 | Self::TurnUsage { thread_id, .. }
863 | Self::RoutedTurnUsage { thread_id, .. }
864 | Self::GoalUpdated { thread_id, .. }
865 | Self::GoalContinuationWaiting { thread_id, .. }
866 | Self::GoalContinuationWaitEnded { thread_id, .. }
867 | Self::CompactionStarted { thread_id, .. }
868 | Self::CompactionCompleted { thread_id, .. }
869 | Self::CompactionCancelled { thread_id, .. }
870 | Self::CompactionFailed { thread_id, .. }
871 | Self::PurgeStarted { thread_id, .. }
872 | Self::PurgeCompleted { thread_id, .. }
873 | Self::PurgeFailed { thread_id, .. }
874 | Self::AgentSpawned { thread_id, .. }
875 | Self::AgentProgress { thread_id, .. }
876 | Self::AgentComplete { thread_id, .. }
877 | Self::SubAgentFollowUp { thread_id, .. }
878 | Self::AgentList { thread_id, .. }
879 | Self::SubAgentMailbox { thread_id, .. }
880 | Self::WorkflowUi { thread_id, .. }
881 | Self::Error { thread_id, .. }
882 | Self::Status { thread_id, .. }
883 | Self::McpSessionBoot { thread_id, .. }
884 | Self::RequestManifestReady { thread_id, .. }
885 | Self::PauseEvents { thread_id, .. }
886 | Self::ResumeEvents { thread_id, .. }
887 | Self::ApprovalRequired { thread_id, .. }
888 | Self::UserInputRequired { thread_id, .. }
889 | Self::SessionUpdated { thread_id, .. }
890 | Self::ElevationRequired { thread_id, .. }
891 | Self::LspRepairUpdate { thread_id, .. }
892 | Self::ToolGateDecision { thread_id, .. }
893 | Self::AdvisoryNote { thread_id, .. }
894 | Self::PrefixCacheChange { thread_id, .. } => thread_id,
895 }
896 }
897
898 #[must_use]
899 pub fn session_id(&self) -> &SessionId {
900 match self {
901 Self::ToolProjectionWarning { session_id, .. }
902 | Self::SnapshotsDisabled { session_id, .. }
903 | Self::MessageStarted { session_id, .. }
904 | Self::ResponseDelta { session_id, .. }
905 | Self::MessageComplete { session_id, .. }
906 | Self::ThinkingStarted { session_id, .. }
907 | Self::ThinkingComplete { session_id, .. }
908 | Self::ToolCallStarted { session_id, .. }
909 | Self::ToolCallHeartbeat { session_id, .. }
910 | Self::ToolCallComplete { session_id, .. }
911 | Self::TurnStarted { session_id, .. }
912 | Self::ToolRequestSnapshot { session_id, .. }
913 | Self::RouteDispatched { session_id, .. }
914 | Self::TurnComplete { session_id, .. }
915 | Self::TurnUsage { session_id, .. }
916 | Self::RoutedTurnUsage { session_id, .. }
917 | Self::GoalUpdated { session_id, .. }
918 | Self::GoalContinuationWaiting { session_id, .. }
919 | Self::GoalContinuationWaitEnded { session_id, .. }
920 | Self::CompactionStarted { session_id, .. }
921 | Self::CompactionCompleted { session_id, .. }
922 | Self::CompactionCancelled { session_id, .. }
923 | Self::CompactionFailed { session_id, .. }
924 | Self::PurgeStarted { session_id, .. }
925 | Self::PurgeCompleted { session_id, .. }
926 | Self::PurgeFailed { session_id, .. }
927 | Self::AgentSpawned { session_id, .. }
928 | Self::AgentProgress { session_id, .. }
929 | Self::AgentComplete { session_id, .. }
930 | Self::SubAgentFollowUp { session_id, .. }
931 | Self::AgentList { session_id, .. }
932 | Self::SubAgentMailbox { session_id, .. }
933 | Self::WorkflowUi { session_id, .. }
934 | Self::Error { session_id, .. }
935 | Self::Status { session_id, .. }
936 | Self::McpSessionBoot { session_id, .. }
937 | Self::RequestManifestReady { session_id, .. }
938 | Self::PauseEvents { session_id, .. }
939 | Self::ResumeEvents { session_id, .. }
940 | Self::ApprovalRequired { session_id, .. }
941 | Self::UserInputRequired { session_id, .. }
942 | Self::SessionUpdated { session_id, .. }
943 | Self::ElevationRequired { session_id, .. }
944 | Self::LspRepairUpdate { session_id, .. }
945 | Self::ToolGateDecision { session_id, .. }
946 | Self::AdvisoryNote { session_id, .. }
947 | Self::PrefixCacheChange { session_id, .. } => session_id,
948 }
949 }
950 }
951
952 #[cfg(test)]
953 mod tests {
954 use super::*;
955 use serde_json::json;
956
957 fn ids() -> (ThreadId, SessionId) {
958 (ThreadId::new(), SessionId::new())
959 }
960
961 /// One instance of every variant, in declaration order. A new variant
962 /// must be added here too, or `every_variant_is_listed_once` fails.
963 fn every_variant() -> Vec<EventMsg> {
964 let (t, s) = ids();
965 let usage = TokenUsage {
966 input_tokens: 1,
967 output_tokens: 2,
968 ..TokenUsage::default()
969 };
970 let route = TurnRoute {
971 provider: "deepseek".into(),
972 provider_identity: "deepseek".into(),
973 model: "deepseek-chat".into(),
974 auto_model: false,
975 receipt: Some(TurnRouteReceipt {
976 provider: "deepseek".into(),
977 provider_identity: "deepseek".into(),
978 wire_model: "deepseek-chat".into(),
979 endpoint_identity: "api.deepseek.com".into(),
980 credential_generation_present: true,
981 }),
982 billing: Some(RouteBillingEnvelope {
983 openrouter_vendor: None,
984 billing_surface: None,
985 endpoint_fingerprint: Some("fp".into()),
986 provider_live_pricing: None,
987 billing_mode: "metered".into(),
988 dispatched_at: DateTime::<Utc>::from_timestamp(0, 0).unwrap(),
989 }),
990 base_url: "https://api.deepseek.com".into(),
991 billing_product: RouteProduct::Subscription {
992 label: "pro".into(),
993 },
994 };
995 vec![
996 EventMsg::ToolProjectionWarning {
997 thread_id: t.clone(),
998 session_id: s.clone(),
999 provider: "openai".into(),
1000 omitted_tool_names: vec!["a".into()],
1001 omitted_tool_count: 3,
1002 },
1003 EventMsg::MessageStarted {
1004 thread_id: t.clone(),
1005 session_id: s.clone(),
1006 index: 0,
1007 },
1008 EventMsg::ResponseDelta {
1009 thread_id: t.clone(),
1010 session_id: s.clone(),
1011 index: 0,
1012 delta: "hi".into(),
1013 channel: ResponseChannel::Reasoning,
1014 },
1015 EventMsg::MessageComplete {
1016 thread_id: t.clone(),
1017 session_id: s.clone(),
1018 index: 0,
1019 },
1020 EventMsg::ThinkingStarted {
1021 thread_id: t.clone(),
1022 session_id: s.clone(),
1023 index: 1,
1024 },
1025 EventMsg::ThinkingComplete {
1026 thread_id: t.clone(),
1027 session_id: s.clone(),
1028 index: 1,
1029 },
1030 EventMsg::ToolCallStarted {
1031 thread_id: t.clone(),
1032 session_id: s.clone(),
1033 tool_call_id: "c1".into(),
1034 tool_name: "read_file".into(),
1035 input: json!({"path": "x"}),
1036 },
1037 EventMsg::ToolCallHeartbeat {
1038 thread_id: t.clone(),
1039 session_id: s.clone(),
1040 },
1041 EventMsg::ToolCallComplete {
1042 thread_id: t.clone(),
1043 session_id: s.clone(),
1044 tool_call_id: "c1".into(),
1045 tool_name: "read_file".into(),
1046 result: ToolCallOutcome::Err {
1047 error: ToolCallError::Timeout { seconds: 3 },
1048 },
1049 },
1050 EventMsg::TurnStarted {
1051 thread_id: t.clone(),
1052 session_id: s.clone(),
1053 turn_id: "turn-1".into(),
1054 created_at: DateTime::<Utc>::from_timestamp(1, 0).unwrap(),
1055 route: Some(route.clone()),
1056 },
1057 EventMsg::ToolRequestSnapshot {
1058 thread_id: t.clone(),
1059 session_id: s.clone(),
1060 snapshot: json!({"tool_count": 2}),
1061 },
1062 EventMsg::RouteDispatched {
1063 thread_id: t.clone(),
1064 session_id: s.clone(),
1065 turn_id: "turn-1".into(),
1066 route,
1067 },
1068 EventMsg::TurnComplete {
1069 thread_id: t.clone(),
1070 session_id: s.clone(),
1071 turn_id: None,
1072 status: TurnOutcomeStatus::Failed,
1073 error: Some("boom".into()),
1074 usage: usage.clone(),
1075 parent_route_usage: Some(usage.clone()),
1076 routed_usage_dropped_records: 0,
1077 tool_catalog: Some(vec![json!({"name": "read_file"})]),
1078 base_url: None,
1079 },
1080 EventMsg::TurnUsage {
1081 max_output_tokens: None,
1082 thread_id: t.clone(),
1083 session_id: s.clone(),
1084 usage: usage.clone(),
1085 duration_ms: 10,
1086 first_token_ms: Some(2),
1087 request_ms: None,
1088 },
1089 EventMsg::RoutedTurnUsage {
1090 thread_id: t.clone(),
1091 session_id: s.clone(),
1092 usage,
1093 duration_ms: 10,
1094 first_token_ms: Some(2),
1095 request_ms: None,
1096 },
1097 EventMsg::GoalUpdated {
1098 thread_id: t.clone(),
1099 session_id: s.clone(),
1100 snapshot: json!({"status": "active"}),
1101 },
1102 EventMsg::GoalContinuationWaiting {
1103 thread_id: t.clone(),
1104 session_id: s.clone(),
1105 delay_seconds: 5,
1106 },
1107 EventMsg::GoalContinuationWaitEnded {
1108 thread_id: t.clone(),
1109 session_id: s.clone(),
1110 interrupted: true,
1111 },
1112 EventMsg::CompactionStarted {
1113 thread_id: t.clone(),
1114 session_id: s.clone(),
1115 id: "cmp-1".into(),
1116 auto: true,
1117 message: "m".into(),
1118 },
1119 EventMsg::CompactionCompleted {
1120 thread_id: t.clone(),
1121 session_id: s.clone(),
1122 id: "cmp-1".into(),
1123 auto: true,
1124 message: "m".into(),
1125 messages_before: Some(10),
1126 messages_after: Some(2),
1127 summary_prompt: None,
1128 post_input_tokens: Some(100),
1129 },
1130 EventMsg::CompactionCancelled {
1131 thread_id: t.clone(),
1132 session_id: s.clone(),
1133 id: "cmp-1".into(),
1134 auto: false,
1135 message: "m".into(),
1136 },
1137 EventMsg::CompactionFailed {
1138 thread_id: t.clone(),
1139 session_id: s.clone(),
1140 id: "cmp-1".into(),
1141 auto: false,
1142 message: "m".into(),
1143 },
1144 EventMsg::PurgeStarted {
1145 thread_id: t.clone(),
1146 session_id: s.clone(),
1147 message: "m".into(),
1148 },
1149 EventMsg::PurgeCompleted {
1150 thread_id: t.clone(),
1151 session_id: s.clone(),
1152 messages_before: 4,
1153 messages_after: 2,
1154 removed_count: 2,
1155 replaced_count: 0,
1156 message: "m".into(),
1157 },
1158 EventMsg::PurgeFailed {
1159 thread_id: t.clone(),
1160 session_id: s.clone(),
1161 message: "m".into(),
1162 },
1163 EventMsg::AgentSpawned {
1164 thread_id: t.clone(),
1165 session_id: s.clone(),
1166 owner_session_id: "owner".into(),
1167 id: "a1".into(),
1168 prompt: "p".into(),
1169 worker_status: Some("starting".into()),
1170 parent_run_id: None,
1171 spawn_depth: 1,
1172 model: "m".into(),
1173 route_source: Some("task.model".into()),
1174 },
1175 EventMsg::AgentProgress {
1176 thread_id: t.clone(),
1177 session_id: s.clone(),
1178 owner_session_id: "owner".into(),
1179 id: "a1".into(),
1180 status: "running".into(),
1181 activity: AgentProgressActivity {
1182 worker_status: "running_tool".into(),
1183 step: Some(2),
1184 tool_name: Some("bash".into()),
1185 },
1186 parent_run_id: None,
1187 spawn_depth: 1,
1188 },
1189 EventMsg::AgentComplete {
1190 thread_id: t.clone(),
1191 session_id: s.clone(),
1192 owner_session_id: "owner".into(),
1193 id: "a1".into(),
1194 result: "done".into(),
1195 worker_status: Some("completed".into()),
1196 parent_run_id: None,
1197 spawn_depth: Some(1),
1198 continuable: Some(false),
1199 },
1200 EventMsg::SubAgentFollowUp {
1201 thread_id: t.clone(),
1202 session_id: s.clone(),
1203 owner_session_id: "owner".into(),
1204 agent_id: "a1".into(),
1205 outcome: SubAgentFollowUpOutcome::Ok {
1206 agent_id: "a1".into(),
1207 target_agent_id: "a2".into(),
1208 delivered: false,
1209 resumed: true,
1210 note: "resumed".into(),
1211 },
1212 },
1213 EventMsg::AgentList {
1214 thread_id: t.clone(),
1215 session_id: s.clone(),
1216 owner_session_id: "owner".into(),
1217 agents: vec![json!({"id": "a1"})],
1218 coordination: json!({}),
1219 queued_follow_ups: BTreeMap::from([("a1".to_string(), 1)]),
1220 roster: vec![AgentRosterRow {
1221 worker_id: "w1".into(),
1222 display_name: "scout".into(),
1223 model: "m".into(),
1224 state: "running".into(),
1225 status: "running".into(),
1226 activity: None,
1227 millis: Some(5),
1228 input_tokens: None,
1229 output_tokens: None,
1230 cost_microusd: None,
1231 steps_taken: 1,
1232 parent_run_id: None,
1233 run_id: "r1".into(),
1234 }],
1235 },
1236 EventMsg::SubAgentMailbox {
1237 thread_id: t.clone(),
1238 session_id: s.clone(),
1239 owner_session_id: "owner".into(),
1240 turn_id: "turn-1".into(),
1241 seq: 7,
1242 message: json!({"kind": "spawned"}),
1243 },
1244 EventMsg::WorkflowUi {
1245 thread_id: t.clone(),
1246 session_id: s.clone(),
1247 owner_session_id: "owner".into(),
1248 run_id: "run-1".into(),
1249 ui_event: json!({"type": "task_started"}),
1250 },
1251 EventMsg::Error {
1252 thread_id: t.clone(),
1253 session_id: s.clone(),
1254 category: "network".into(),
1255 severity: "error".into(),
1256 recoverable: true,
1257 code: "E1".into(),
1258 message: "m".into(),
1259 },
1260 EventMsg::Status {
1261 thread_id: t.clone(),
1262 session_id: s.clone(),
1263 message: "m".into(),
1264 },
1265 EventMsg::McpSessionBoot {
1266 thread_id: t.clone(),
1267 session_id: s.clone(),
1268 generation: 1,
1269 snapshot: McpManagerSnapshot {
1270 config_path: PathBuf::from("/tmp/mcp.json"),
1271 config_exists: true,
1272 reload_required: false,
1273 servers: vec![McpServerSnapshot {
1274 name: "fs".into(),
1275 enabled: true,
1276 required: false,
1277 transport: "stdio".into(),
1278 command_or_url: "npx".into(),
1279 connect_timeout: 1,
1280 execute_timeout: 2,
1281 read_timeout: 3,
1282 connected: true,
1283 error: None,
1284 capability_metadata: "advertised".into(),
1285 tools: vec![McpDiscoveredItem {
1286 name: "read".into(),
1287 model_name: "fs__read".into(),
1288 description: None,
1289 }],
1290 resources: vec![],
1291 prompts: vec![],
1292 }],
1293 },
1294 connecting: vec!["slow".into()],
1295 finished: false,
1296 },
1297 EventMsg::RequestManifestReady {
1298 thread_id: t.clone(),
1299 session_id: s.clone(),
1300 rendered: "manifest".into(),
1301 },
1302 EventMsg::PauseEvents {
1303 thread_id: t.clone(),
1304 session_id: s.clone(),
1305 },
1306 EventMsg::ResumeEvents {
1307 thread_id: t.clone(),
1308 session_id: s.clone(),
1309 },
1310 EventMsg::ApprovalRequired {
1311 thread_id: t.clone(),
1312 session_id: s.clone(),
1313 id: "c1".into(),
1314 tool_name: "bash".into(),
1315 description: "rm".into(),
1316 input: json!({"command": "rm"}),
1317 approval_key: "k".into(),
1318 approval_grouping_key: "g".into(),
1319 intent_summary: None,
1320 approval_force_prompt: true,
1321 },
1322 EventMsg::UserInputRequired {
1323 thread_id: t.clone(),
1324 session_id: s.clone(),
1325 id: "c2".into(),
1326 request: UserInputRequest {
1327 questions: vec![UserInputQuestionEvent {
1328 header: "h".into(),
1329 id: "q1".into(),
1330 question: "?".into(),
1331 options: vec![],
1332 allow_free_text: true,
1333 multi_select: false,
1334 }],
1335 },
1336 },
1337 EventMsg::SessionUpdated {
1338 thread_id: t.clone(),
1339 session_id: s.clone(),
1340 engine_session_id: "sess".into(),
1341 messages: vec![json!({"role": "user", "content": []})],
1342 system_prompt: Some(json!("sys")),
1343 model: "m".into(),
1344 workspace: PathBuf::from("/ws"),
1345 },
1346 EventMsg::ElevationRequired {
1347 thread_id: t.clone(),
1348 session_id: s.clone(),
1349 tool_id: "c3".into(),
1350 tool_name: "bash".into(),
1351 command: Some("curl".into()),
1352 denial_reason: "net".into(),
1353 blocked_network: true,
1354 blocked_write: false,
1355 },
1356 EventMsg::LspRepairUpdate {
1357 thread_id: t.clone(),
1358 session_id: s.clone(),
1359 diagnostics_found: 1,
1360 files: 1,
1361 injected: true,
1362 },
1363 EventMsg::ToolGateDecision {
1364 thread_id: t.clone(),
1365 session_id: s.clone(),
1366 agent_id: None,
1367 tool_id: "c4".into(),
1368 tool_name: "bash".into(),
1369 gate: ToolGate::AutoReviewGuardian,
1370 decision: ToolGateVerdict::Denied,
1371 risk: Some("high".into()),
1372 reason: "no".into(),
1373 },
1374 EventMsg::AdvisoryNote {
1375 thread_id: t.clone(),
1376 session_id: s.clone(),
1377 turn_id: "turn-1".into(),
1378 note: "n".into(),
1379 tool_call_count: 2,
1380 },
1381 EventMsg::PrefixCacheChange {
1382 thread_id: t,
1383 session_id: s,
1384 description: "d".into(),
1385 system_prompt_changed: false,
1386 tools_changed: true,
1387 stability_pct: 90,
1388 changed: true,
1389 pinned_combined_hash: "h".into(),
1390 pin_reason: "initial".into(),
1391 last_miss_reason: String::new(),
1392 context_updates: 0,
1393 },
1394 ]
1395 }
1396
1397 #[test]
1398 fn every_variant_is_listed_once() {
1399 let kinds: Vec<&str> = every_variant().iter().map(EventMsg::kind_str).collect();
1400 assert_eq!(
1401 kinds, EVENT_KINDS,
1402 "EVENT_KINDS must list every variant in order"
1403 );
1404 }
1405
1406 #[test]
1407 fn every_variant_round_trips_and_tags_by_event() {
1408 for msg in every_variant() {
1409 let value = serde_json::to_value(&msg).unwrap();
1410 assert_eq!(value["event"], msg.kind_str(), "{msg:?}");
1411 assert_eq!(value["thread_id"], msg.thread_id().to_string(), "{msg:?}");
1412 assert_eq!(value["session_id"], msg.session_id().to_string(), "{msg:?}");
1413 let back: EventMsg = serde_json::from_value(value).unwrap();
1414 assert_eq!(back, msg);
1415 }
1416 }
1417
1418 #[test]
1419 fn event_msg_roundtrip() {
1420 let msg = EventMsg::TurnComplete {
1421 thread_id: ThreadId::new(),
1422 session_id: SessionId::new(),
1423 turn_id: Some("turn-1".into()),
1424 status: TurnOutcomeStatus::Completed,
1425 error: None,
1426 usage: TokenUsage::default(),
1427 parent_route_usage: None,
1428 routed_usage_dropped_records: 0,
1429 tool_catalog: None,
1430 base_url: None,
1431 };
1432 let json = serde_json::to_string(&msg).unwrap();
1433 let back: EventMsg = serde_json::from_str(&json).unwrap();
1434 assert_eq!(back.kind_str(), "turn_complete");
1435 assert!(json.contains(r#""status":"completed""#));
1436 }
1437
1438 #[test]
1439 fn text_channel_is_elided_on_the_wire() {
1440 let msg = EventMsg::ResponseDelta {
1441 thread_id: ThreadId::new(),
1442 session_id: SessionId::new(),
1443 index: 0,
1444 delta: "x".into(),
1445 channel: ResponseChannel::Text,
1446 };
1447 let json = serde_json::to_string(&msg).unwrap();
1448 assert!(!json.contains("channel"), "{json}");
1449 let back: EventMsg = serde_json::from_str(&json).unwrap();
1450 assert_eq!(back, msg);
1451 }
1452 }
1453
1453 lines RUST