返回 CodeWhale
protocol_parity.rs
根目录 / crates / tui / src / core / protocol_parity.rs
1 //! Compile-enforced parity between the engine's internal `Op` / `Event` and
2 //! the protocol's `Op` / `EventMsg` (core/protocol extraction spec, Phase A1).
3 //!
4 //! Every function here is one exhaustive `match` with **no wildcard arm**.
5 //! Adding an engine `Event` or `Op` variant without a protocol twin fails to
6 //! compile right here — that is the `protocol_covers_engine_events` /
7 //! `protocol_covers_engine_ops` guard from spec §7. The `#[cfg(test)]`
8 //! block below only proves the projections agree with the protocol's own
9 //! wire-tag tables and that this file keeps its no-wildcard discipline.
10 //!
11 //! The reverse direction (protocol `Op` -> engine `Op`) is not total — engine
12 //! ops carry resolved routes, reply channels, and hook executors that the
13 //! host must supply — so it lands with the engine handle in Phase C/D, not
14 //! here.
15 //!
16 //! The foreground pet observer consumes the event projection, retaining only
17 //! lifecycle metadata. Other projections remain compile-time parity guards;
18 //! dead-code is allowed here rather than hiding those guards behind a test cfg
19 //! (which would let `cargo build` pass with an unmapped variant).
20 #![allow(dead_code)]
21
22 use std::collections::BTreeMap;
23
24 use codewhale_protocol::event_msg as wire;
25 use codewhale_protocol::ids::{SessionId, ThreadId};
26 use codewhale_protocol::op as wire_op;
27 use serde::Serialize;
28 use serde_json::Value;
29
30 use crate::agent_roster::{AgentRosterRow, RosterState};
31 use crate::compaction::CompactionConfig;
32 use crate::config::ApiProvider;
33 use crate::core::engine::preview::PreviewUnresolved;
34 use crate::core::events::{
35 Event, RouteBillingEnvelope, ToolGate, ToolGateVerdict, TurnOutcomeStatus, TurnRoute,
36 };
37 use crate::core::ops::Op;
38 use crate::cost_status::RouteBillingMode;
39 use crate::mcp::{McpManagerSnapshot, McpServerCapabilityMetadata};
40 use crate::model_profile::SupportState;
41 use crate::route_billing::RouteProduct;
42 use crate::tools::spec::ToolError;
43 use crate::tools::subagent::AgentWorkerStatus;
44 use crate::tools::user_input::UserInputRequest;
45 use codewhale_config::AppMode;
46 use codewhale_execpolicy::ApprovalMode;
47 use codewhale_models::Usage;
48 use codewhale_protocol::ResponseChannel;
49
50 /// Routing ids the engine does not carry on each event; the emitter supplies
51 /// them once per session.
52 #[derive(Debug, Clone)]
53 pub struct ProtocolIds {
54 pub thread_id: ThreadId,
55 pub session_id: SessionId,
56 }
57
58 fn to_value<T: Serialize>(value: &T) -> Value {
59 serde_json::to_value(value).unwrap_or(Value::Null)
60 }
61
62 fn count(value: usize) -> u64 {
63 u64::try_from(value).unwrap_or(u64::MAX)
64 }
65
66 /// Lossless mode label; round-trips through `AppMode::parse`.
67 #[must_use]
68 pub fn app_mode_str(mode: AppMode) -> &'static str {
69 match mode {
70 AppMode::Agent => "agent",
71 AppMode::Plan => "plan",
72 AppMode::Operate => "operate",
73 }
74 }
75
76 #[must_use]
77 pub fn approval_mode_str(mode: ApprovalMode) -> &'static str {
78 match mode {
79 ApprovalMode::Auto => "auto",
80 ApprovalMode::Bypass => "bypass",
81 ApprovalMode::Suggest => "suggest",
82 ApprovalMode::Never => "never",
83 }
84 }
85
86 #[must_use]
87 pub fn worker_status_str(status: AgentWorkerStatus) -> &'static str {
88 match status {
89 AgentWorkerStatus::Queued => "queued",
90 AgentWorkerStatus::Starting => "starting",
91 AgentWorkerStatus::Running => "running",
92 AgentWorkerStatus::WaitingForUser => "waiting_for_user",
93 AgentWorkerStatus::ModelWait => "model_wait",
94 AgentWorkerStatus::RunningTool => "running_tool",
95 AgentWorkerStatus::Completed => "completed",
96 AgentWorkerStatus::Failed => "failed",
97 AgentWorkerStatus::Cancelled => "cancelled",
98 AgentWorkerStatus::Interrupted => "interrupted",
99 }
100 }
101
102 fn roster_state_str(state: RosterState) -> &'static str {
103 match state {
104 RosterState::Running => "running",
105 RosterState::Waiting => "waiting",
106 RosterState::Parked => "parked",
107 RosterState::Done => "done",
108 RosterState::Failed => "failed",
109 RosterState::Cancelled => "cancelled",
110 }
111 }
112
113 fn billing_mode_str(mode: RouteBillingMode) -> &'static str {
114 match mode {
115 RouteBillingMode::Metered => "metered",
116 RouteBillingMode::Subscription => "subscription",
117 RouteBillingMode::Local => "local",
118 RouteBillingMode::Unknown => "unknown",
119 }
120 }
121
122 fn support_state_str(state: SupportState) -> &'static str {
123 match state {
124 SupportState::Supported => "supported",
125 SupportState::Unsupported => "unsupported",
126 SupportState::Unknown => "unknown",
127 }
128 }
129
130 fn capability_metadata_str(metadata: McpServerCapabilityMetadata) -> &'static str {
131 match metadata {
132 McpServerCapabilityMetadata::Advertised(_) => "advertised",
133 McpServerCapabilityMetadata::LegacyFallback => "legacy_fallback",
134 McpServerCapabilityMetadata::NotObserved => "not_observed",
135 }
136 }
137
138 fn provider_str(provider: ApiProvider) -> String {
139 provider.as_str().to_string()
140 }
141
142 fn usage_to_wire(usage: &Usage) -> wire::TokenUsage {
143 wire::TokenUsage {
144 input_tokens: usage.input_tokens,
145 output_tokens: usage.output_tokens,
146 prompt_cache_hit_tokens: usage.prompt_cache_hit_tokens,
147 prompt_cache_miss_tokens: usage.prompt_cache_miss_tokens,
148 prompt_cache_write_tokens: usage.prompt_cache_write_tokens,
149 reasoning_tokens: usage.reasoning_tokens,
150 reasoning_replay_tokens: usage.reasoning_replay_tokens,
151 code_execution_requests: usage
152 .server_tool_use
153 .as_ref()
154 .and_then(|server| server.code_execution_requests),
155 tool_search_requests: usage
156 .server_tool_use
157 .as_ref()
158 .and_then(|server| server.tool_search_requests),
159 }
160 }
161
162 fn route_product_to_wire(product: RouteProduct) -> wire::RouteProduct {
163 match product {
164 RouteProduct::Unproven => wire::RouteProduct::Unproven,
165 RouteProduct::Subscription(label) => wire::RouteProduct::Subscription {
166 label: label.to_string(),
167 },
168 RouteProduct::Metered => wire::RouteProduct::Metered,
169 }
170 }
171
172 fn billing_to_wire(billing: &RouteBillingEnvelope) -> wire::RouteBillingEnvelope {
173 wire::RouteBillingEnvelope {
174 openrouter_vendor: billing
175 .openrouter_vendor
176 .as_deref()
177 .map(crate::cost_status::sanitize_persisted_route_label),
178 billing_surface: billing.billing_surface.clone(),
179 endpoint_fingerprint: billing.endpoint_fingerprint.clone(),
180 provider_live_pricing: billing
181 .provider_live_pricing
182 .as_ref()
183 .map(to_value)
184 .filter(|v| !v.is_null()),
185 billing_mode: billing_mode_str(billing.billing_mode).to_string(),
186 dispatched_at: billing.dispatched_at,
187 }
188 }
189
190 fn route_to_wire(route: &TurnRoute) -> wire::TurnRoute {
191 wire::TurnRoute {
192 provider: provider_str(route.provider),
193 provider_identity: route.provider_identity.clone(),
194 model: route.model.clone(),
195 auto_model: route.auto_model,
196 receipt: route
197 .receipt
198 .as_ref()
199 .map(|receipt| wire::TurnRouteReceipt {
200 provider: provider_str(receipt.provider()),
201 provider_identity: receipt.provider_identity().to_string(),
202 wire_model: receipt.wire_model().to_string(),
203 endpoint_identity: receipt.endpoint_identity().to_string(),
204 credential_generation_present: !receipt.credential_generation().is_empty(),
205 }),
206 billing: route.billing.as_ref().map(billing_to_wire),
207 base_url: route.base_url.clone(),
208 billing_product: route_product_to_wire(route.billing_product),
209 }
210 }
211
212 fn tool_error_to_wire(error: &ToolError) -> wire::ToolCallError {
213 match error {
214 ToolError::InvalidInput { message } => wire::ToolCallError::InvalidInput {
215 message: message.clone(),
216 },
217 ToolError::MissingField { field } => wire::ToolCallError::MissingField {
218 field: field.clone(),
219 },
220 ToolError::PathEscape { path } => wire::ToolCallError::PathEscape { path: path.clone() },
221 ToolError::ExecutionFailed { message } => wire::ToolCallError::ExecutionFailed {
222 message: message.clone(),
223 },
224 ToolError::Timeout { seconds } => wire::ToolCallError::Timeout { seconds: *seconds },
225 ToolError::Cancelled { message } => wire::ToolCallError::Cancelled {
226 message: message.clone(),
227 },
228 ToolError::NotAvailable { message } => wire::ToolCallError::NotAvailable {
229 message: message.clone(),
230 },
231 ToolError::PermissionDenied { message } => wire::ToolCallError::PermissionDenied {
232 message: message.clone(),
233 },
234 }
235 }
236
237 fn gate_to_wire(gate: ToolGate) -> wire::ToolGate {
238 match gate {
239 ToolGate::AutoReviewDeterministic => wire::ToolGate::AutoReviewDeterministic,
240 ToolGate::AutoReviewGuardian => wire::ToolGate::AutoReviewGuardian,
241 }
242 }
243
244 fn verdict_to_wire(verdict: ToolGateVerdict) -> wire::ToolGateVerdict {
245 match verdict {
246 ToolGateVerdict::Allowed => wire::ToolGateVerdict::Allowed,
247 ToolGateVerdict::Denied => wire::ToolGateVerdict::Denied,
248 ToolGateVerdict::Unavailable => wire::ToolGateVerdict::Unavailable,
249 }
250 }
251
252 fn outcome_status_to_wire(status: TurnOutcomeStatus) -> wire::TurnOutcomeStatus {
253 match status {
254 TurnOutcomeStatus::Completed => wire::TurnOutcomeStatus::Completed,
255 TurnOutcomeStatus::Interrupted => wire::TurnOutcomeStatus::Interrupted,
256 TurnOutcomeStatus::Failed => wire::TurnOutcomeStatus::Failed,
257 }
258 }
259
260 fn mcp_snapshot_to_wire(snapshot: &McpManagerSnapshot) -> wire::McpManagerSnapshot {
261 let item = |item: &crate::mcp::McpDiscoveredItem| wire::McpDiscoveredItem {
262 name: item.name.clone(),
263 model_name: item.model_name.clone(),
264 description: item.description.clone(),
265 };
266 wire::McpManagerSnapshot {
267 config_path: snapshot.config_path.clone(),
268 config_exists: snapshot.config_exists,
269 reload_required: snapshot.reload_required,
270 servers: snapshot
271 .servers
272 .iter()
273 .map(|server| wire::McpServerSnapshot {
274 name: server.name.clone(),
275 enabled: server.enabled,
276 required: server.required,
277 transport: server.transport.clone(),
278 command_or_url: redacted_command_or_url(&server.command_or_url),
279 connect_timeout: server.connect_timeout,
280 execute_timeout: server.execute_timeout,
281 read_timeout: server.read_timeout,
282 connected: server.connected,
283 error: server.error.clone(),
284 capability_metadata: capability_metadata_str(server.capability_metadata)
285 .to_string(),
286 tools: server.tools.iter().map(item).collect(),
287 resources: server.resources.iter().map(item).collect(),
288 prompts: server.prompts.iter().map(item).collect(),
289 })
290 .collect(),
291 }
292 }
293
294 /// Sanitize an MCP server's configured target before it goes on the wire.
295 ///
296 /// `command_or_url` is the raw configuration: either the configured URL, which
297 /// can carry userinfo (`https://user:token@host`) or query credentials, or a
298 /// stdio command line built as `command + " " + args.join(" ")`, whose args can
299 /// carry a token. That is acceptable in the local picker, where the only reader
300 /// is the person who configured it. This projection is not local -- it feeds
301 /// `EventMsg::McpSessionBoot`, which every SSE and stream-JSON consumer
302 /// receives and any frame-retaining log keeps.
303 ///
304 /// URLs reuse the existing masking in `client::redact_url_for_display`. Stdio
305 /// keeps the program name and elides the arguments, because a secret there can
306 /// be positional and is not reliably recognizable by key.
307 fn redacted_command_or_url(raw: &str) -> String {
308 // No `match` here on purpose: `projections_have_no_wildcard_arms` scans
309 // this whole file for wildcard arms, and a `_ =>` would trip it even in a
310 // helper that projects no engine variant.
311 let trimmed = raw.trim();
312 // A URL never contains whitespace; an argv line does. `://` alone is not
313 // enough to route here: `redact_url_for_display` returns its input
314 // verbatim when `Url::parse` fails, so a stdio command that merely
315 // mentions a URL — `docker run -e TOKEN=… img --url https://…` — would
316 // reach the wire with its positional secret intact.
317 if !trimmed.is_empty() && !trimmed.contains(char::is_whitespace) && trimmed.contains("://") {
318 return crate::client::redact_url_for_display(trimmed);
319 }
320 if let Some((program, rest)) = trimmed.split_once(char::is_whitespace)
321 && !rest.trim().is_empty()
322 {
323 return format!("{program} …");
324 }
325 trimmed.to_string()
326 }
327
328 fn roster_row_to_wire(row: &AgentRosterRow) -> wire::AgentRosterRow {
329 wire::AgentRosterRow {
330 worker_id: row.worker_id.clone(),
331 display_name: row.display_name.clone(),
332 model: row.model.clone(),
333 state: roster_state_str(row.state).to_string(),
334 status: worker_status_str(row.status).to_string(),
335 activity: row.activity.clone(),
336 millis: row.millis,
337 input_tokens: row.input_tokens,
338 output_tokens: row.output_tokens,
339 cost_microusd: row.cost_microusd,
340 steps_taken: row.steps_taken,
341 parent_run_id: row.parent_run_id.clone(),
342 run_id: row.run_id.clone(),
343 }
344 }
345
346 fn user_input_to_wire(request: &UserInputRequest) -> wire::UserInputRequest {
347 wire::UserInputRequest {
348 questions: request
349 .questions
350 .iter()
351 .map(|question| codewhale_protocol::UserInputQuestionEvent {
352 header: question.header.clone(),
353 id: question.id.clone(),
354 question: question.question.clone(),
355 options: question
356 .options
357 .iter()
358 .map(|option| codewhale_protocol::UserInputOptionEvent {
359 label: option.label.clone(),
360 description: option.description.clone(),
361 })
362 .collect(),
363 allow_free_text: question.allow_free_text,
364 multi_select: question.multi_select,
365 })
366 .collect(),
367 }
368 }
369
370 fn compaction_to_wire(config: &CompactionConfig) -> wire_op::CompactionPolicy {
371 wire_op::CompactionPolicy {
372 enabled: config.enabled,
373 token_threshold: count(config.token_threshold),
374 model: config.model.clone(),
375 image_input: support_state_str(config.image_input).to_string(),
376 effective_context_window: config.effective_context_window,
377 cache_summary: config.cache_summary,
378 focus: config.focus.clone(),
379 runtime_cost_owner: config.runtime_cost_owner.clone(),
380 workspace: config.workspace.clone(),
381 }
382 }
383
384 /// Project the engine's per-turn authority onto the wire `TurnSpec`. Host-only
385 /// fields (`initial_routed_usage`, `hook_executor`) and resolved routes are
386 /// stripped; only their non-secret receipts cross.
387 fn turn_spec_to_wire(spec: &crate::core::ops::TurnSpec) -> wire_op::TurnSpec {
388 wire_op::TurnSpec {
389 max_output_tokens: spec.max_output_tokens,
390 content: spec.content.clone(),
391 images: spec.images.clone(),
392 mode: app_mode_str(spec.mode).to_string(),
393 model: Some(spec.route.model.clone()),
394 model_provider: Some(spec.route.identity.key.clone()),
395 allowed_tools: spec.allowed_tools.clone(),
396 dynamic_tools: spec.dynamic_tools.clone(),
397 provenance: spec.provenance.as_str().to_string(),
398 compaction: Some(Box::new(compaction_to_wire(&spec.compaction))),
399 goal_objective: spec.goal_objective.clone(),
400 goal_token_budget: spec.goal_token_budget,
401 goal_status: spec.goal_status.as_str().to_string(),
402 reasoning_effort: spec.reasoning_effort.clone(),
403 reasoning_effort_auto: spec.reasoning_effort_auto,
404 auto_model: spec.auto_model,
405 allow_shell: spec.allow_shell,
406 trust_mode: spec.trust_mode,
407 auto_approve: spec.auto_approve,
408 approval_mode: approval_mode_str(spec.approval_mode).to_string(),
409 translation_enabled: spec.translation_enabled,
410 verbosity: spec.verbosity.clone(),
411 }
412 }
413
414 fn preview_unresolved_str(unresolved: &PreviewUnresolved) -> String {
415 match unresolved {
416 PreviewUnresolved::AutoRouteNeedsPrompt => "auto_route_needs_prompt".to_string(),
417 PreviewUnresolved::AutoRouteClassificationNotExecuted => {
418 "auto_route_classification_not_executed".to_string()
419 }
420 PreviewUnresolved::NoPrompt => "no_prompt".to_string(),
421 PreviewUnresolved::PlanFailed(error) => format!("plan_failed: {error}"),
422 PreviewUnresolved::MessageSubmitHooksConfigured => {
423 "message_submit_hooks_configured".to_string()
424 }
425 PreviewUnresolved::PromptResolutionFailed(error) => {
426 format!("prompt_resolution_failed: {error}")
427 }
428 }
429 }
430
431 /// Project one engine event onto the protocol. Exhaustive: a new engine
432 /// variant without a protocol twin does not compile.
433 #[must_use]
434 pub fn event_to_protocol(event: &Event, ids: &ProtocolIds) -> wire::EventMsg {
435 let thread_id = ids.thread_id.clone();
436 let session_id = ids.session_id.clone();
437 match event {
438 Event::ToolProjectionWarning {
439 provider,
440 omitted_tool_names,
441 omitted_tool_count,
442 } => wire::EventMsg::ToolProjectionWarning {
443 thread_id,
444 session_id,
445 provider: provider.clone(),
446 omitted_tool_names: omitted_tool_names.clone(),
447 omitted_tool_count: count(*omitted_tool_count),
448 },
449 Event::SnapshotsDisabled { workspace, reason } => wire::EventMsg::SnapshotsDisabled {
450 thread_id,
451 session_id,
452 workspace: workspace.clone(),
453 reason: reason.clone(),
454 },
455 Event::MessageStarted { index } => wire::EventMsg::MessageStarted {
456 thread_id,
457 session_id,
458 index: count(*index),
459 },
460 Event::MessageDelta { index, content } => wire::EventMsg::ResponseDelta {
461 thread_id,
462 session_id,
463 index: count(*index),
464 delta: content.clone(),
465 channel: ResponseChannel::Text,
466 },
467 Event::MessageComplete { index } => wire::EventMsg::MessageComplete {
468 thread_id,
469 session_id,
470 index: count(*index),
471 },
472 Event::ThinkingStarted { index } => wire::EventMsg::ThinkingStarted {
473 thread_id,
474 session_id,
475 index: count(*index),
476 },
477 Event::ThinkingDelta { index, content } => wire::EventMsg::ResponseDelta {
478 thread_id,
479 session_id,
480 index: count(*index),
481 delta: content.clone(),
482 channel: ResponseChannel::Reasoning,
483 },
484 Event::ThinkingComplete { index } => wire::EventMsg::ThinkingComplete {
485 thread_id,
486 session_id,
487 index: count(*index),
488 },
489 Event::ToolCallStarted { id, name, input } => wire::EventMsg::ToolCallStarted {
490 thread_id,
491 session_id,
492 tool_call_id: id.clone(),
493 tool_name: name.clone(),
494 input: input.clone(),
495 },
496 Event::ToolCallHeartbeat => wire::EventMsg::ToolCallHeartbeat {
497 thread_id,
498 session_id,
499 },
500 Event::ToolCallComplete { id, name, result } => wire::EventMsg::ToolCallComplete {
501 thread_id,
502 session_id,
503 tool_call_id: id.clone(),
504 tool_name: name.clone(),
505 result: match result {
506 Ok(result) => wire::ToolCallOutcome::Ok {
507 content: result.content.clone(),
508 success: result.success,
509 metadata: result.metadata.clone(),
510 },
511 Err(error) => wire::ToolCallOutcome::Err {
512 error: tool_error_to_wire(error),
513 },
514 },
515 },
516 Event::TurnStarted {
517 turn_id,
518 created_at,
519 route,
520 } => wire::EventMsg::TurnStarted {
521 thread_id,
522 session_id,
523 turn_id: turn_id.clone(),
524 created_at: *created_at,
525 route: route.as_ref().map(route_to_wire),
526 },
527 Event::ToolRequestSnapshot { snapshot } => wire::EventMsg::ToolRequestSnapshot {
528 thread_id,
529 session_id,
530 snapshot: to_value(snapshot),
531 },
532 Event::RouteDispatched { turn_id, route } => wire::EventMsg::RouteDispatched {
533 thread_id,
534 session_id,
535 turn_id: turn_id.clone(),
536 route: route_to_wire(route),
537 },
538 Event::TurnComplete {
539 usage,
540 parent_route_usage,
541 routed_usage_dropped_records,
542 status,
543 error,
544 tool_catalog,
545 base_url,
546 } => wire::EventMsg::TurnComplete {
547 thread_id,
548 session_id,
549 turn_id: None,
550 status: outcome_status_to_wire(*status),
551 error: error.clone(),
552 usage: usage_to_wire(usage),
553 parent_route_usage: Some(usage_to_wire(parent_route_usage)),
554 routed_usage_dropped_records: *routed_usage_dropped_records,
555 tool_catalog: tool_catalog
556 .as_ref()
557 .map(|tools| tools.iter().map(to_value).collect()),
558 base_url: base_url.clone(),
559 },
560 Event::TurnUsage {
561 max_output_tokens,
562 usage,
563 duration_ms,
564 first_token_ms,
565 request_ms,
566 } => wire::EventMsg::TurnUsage {
567 max_output_tokens: *max_output_tokens,
568 thread_id,
569 session_id,
570 usage: usage_to_wire(usage),
571 duration_ms: *duration_ms,
572 first_token_ms: *first_token_ms,
573 request_ms: *request_ms,
574 },
575 Event::RoutedTurnUsage {
576 usage,
577 duration_ms,
578 first_token_ms,
579 request_ms,
580 } => wire::EventMsg::RoutedTurnUsage {
581 thread_id,
582 session_id,
583 usage: usage_to_wire(usage),
584 duration_ms: *duration_ms,
585 first_token_ms: *first_token_ms,
586 request_ms: *request_ms,
587 },
588 Event::GoalUpdated { snapshot } => wire::EventMsg::GoalUpdated {
589 thread_id,
590 session_id,
591 snapshot: to_value(snapshot),
592 },
593 Event::GoalContinuationWaiting { delay_seconds } => {
594 wire::EventMsg::GoalContinuationWaiting {
595 thread_id,
596 session_id,
597 delay_seconds: *delay_seconds,
598 }
599 }
600 Event::GoalContinuationWaitEnded { interrupted } => {
601 wire::EventMsg::GoalContinuationWaitEnded {
602 thread_id,
603 session_id,
604 interrupted: *interrupted,
605 }
606 }
607 Event::CompactionStarted { id, auto, message } => wire::EventMsg::CompactionStarted {
608 thread_id,
609 session_id,
610 id: id.clone(),
611 auto: *auto,
612 message: message.clone(),
613 },
614 Event::CompactionCompleted {
615 id,
616 auto,
617 message,
618 messages_before,
619 messages_after,
620 summary_prompt,
621 post_input_tokens,
622 } => wire::EventMsg::CompactionCompleted {
623 thread_id,
624 session_id,
625 id: id.clone(),
626 auto: *auto,
627 message: message.clone(),
628 messages_before: messages_before.map(count),
629 messages_after: messages_after.map(count),
630 summary_prompt: summary_prompt.clone(),
631 post_input_tokens: *post_input_tokens,
632 },
633 Event::CompactionCancelled { id, auto, message } => wire::EventMsg::CompactionCancelled {
634 thread_id,
635 session_id,
636 id: id.clone(),
637 auto: *auto,
638 message: message.clone(),
639 },
640 Event::PurgeStarted { message } => wire::EventMsg::PurgeStarted {
641 thread_id,
642 session_id,
643 message: message.clone(),
644 },
645 Event::PurgeCompleted {
646 messages_before,
647 messages_after,
648 removed_count,
649 replaced_count,
650 message,
651 } => wire::EventMsg::PurgeCompleted {
652 thread_id,
653 session_id,
654 messages_before: count(*messages_before),
655 messages_after: count(*messages_after),
656 removed_count: count(*removed_count),
657 replaced_count: count(*replaced_count),
658 message: message.clone(),
659 },
660 Event::PurgeFailed { message } => wire::EventMsg::PurgeFailed {
661 thread_id,
662 session_id,
663 message: message.clone(),
664 },
665 Event::CompactionFailed { id, auto, message } => wire::EventMsg::CompactionFailed {
666 thread_id,
667 session_id,
668 id: id.clone(),
669 auto: *auto,
670 message: message.clone(),
671 },
672 Event::AgentSpawned {
673 owner_session_id,
674 id,
675 prompt,
676 worker_status,
677 parent_run_id,
678 spawn_depth,
679 model,
680 route_source,
681 } => wire::EventMsg::AgentSpawned {
682 thread_id,
683 session_id,
684 owner_session_id: owner_session_id.clone(),
685 id: id.clone(),
686 prompt: prompt.clone(),
687 worker_status: worker_status.map(|status| worker_status_str(status).to_string()),
688 parent_run_id: parent_run_id.clone(),
689 spawn_depth: *spawn_depth,
690 model: model.clone(),
691 route_source: route_source.clone(),
692 },
693 Event::AgentProgress {
694 owner_session_id,
695 id,
696 status,
697 activity,
698 parent_run_id,
699 spawn_depth,
700 } => wire::EventMsg::AgentProgress {
701 thread_id,
702 session_id,
703 owner_session_id: owner_session_id.clone(),
704 id: id.clone(),
705 status: status.clone(),
706 activity: wire::AgentProgressActivity {
707 worker_status: worker_status_str(activity.worker_status).to_string(),
708 step: activity.step,
709 tool_name: activity.tool_name.clone(),
710 },
711 parent_run_id: parent_run_id.clone(),
712 spawn_depth: *spawn_depth,
713 },
714 Event::AgentComplete {
715 owner_session_id,
716 id,
717 result,
718 outcome,
719 parent_run_id,
720 spawn_depth,
721 continuable,
722 // Child usage stays off the wire: no protocol client consumes
723 // it, and metrics reads the persisted runtime payload (#6315).
724 usage: _,
725 } => wire::EventMsg::AgentComplete {
726 thread_id,
727 session_id,
728 owner_session_id: owner_session_id.clone(),
729 id: id.clone(),
730 result: result.clone(),
731 worker_status: outcome
732 .as_ref()
733 .map(|status| crate::tools::subagent::subagent_status_name(status).to_string()),
734 parent_run_id: parent_run_id.clone(),
735 spawn_depth: *spawn_depth,
736 continuable: *continuable,
737 },
738 Event::SubAgentFollowUp {
739 owner_session_id,
740 agent_id,
741 outcome,
742 } => wire::EventMsg::SubAgentFollowUp {
743 thread_id,
744 session_id,
745 owner_session_id: owner_session_id.clone(),
746 agent_id: agent_id.clone(),
747 outcome: match outcome {
748 Ok(outcome) => wire::SubAgentFollowUpOutcome::Ok {
749 agent_id: outcome.agent_id.clone(),
750 target_agent_id: outcome.target_agent_id.clone(),
751 delivered: outcome.delivered,
752 resumed: outcome.resumed,
753 note: outcome.note.clone(),
754 },
755 Err(reason) => wire::SubAgentFollowUpOutcome::Err {
756 reason: reason.clone(),
757 },
758 },
759 },
760 Event::AgentList {
761 owner_session_id,
762 agents,
763 coordination,
764 queued_follow_ups,
765 roster,
766 } => wire::EventMsg::AgentList {
767 thread_id,
768 session_id,
769 owner_session_id: owner_session_id.clone(),
770 agents: agents.iter().map(to_value).collect(),
771 coordination: to_value(coordination),
772 queued_follow_ups: queued_follow_ups
773 .iter()
774 .map(|(agent_id, queued)| (agent_id.clone(), count(*queued)))
775 .collect::<BTreeMap<_, _>>(),
776 roster: roster.iter().map(roster_row_to_wire).collect(),
777 },
778 Event::SubAgentMailbox {
779 owner_session_id,
780 turn_id,
781 seq,
782 message,
783 } => wire::EventMsg::SubAgentMailbox {
784 thread_id,
785 session_id,
786 owner_session_id: owner_session_id.clone(),
787 turn_id: turn_id.clone(),
788 seq: *seq,
789 message: to_value(message),
790 },
791 Event::WorkflowUi {
792 owner_session_id,
793 run_id,
794 event,
795 } => wire::EventMsg::WorkflowUi {
796 thread_id,
797 session_id,
798 owner_session_id: owner_session_id.clone(),
799 run_id: run_id.clone(),
800 ui_event: event.clone(),
801 },
802 Event::Error {
803 envelope,
804 recoverable,
805 } => wire::EventMsg::Error {
806 thread_id,
807 session_id,
808 category: envelope.category.to_string(),
809 severity: envelope.severity.to_string(),
810 recoverable: *recoverable,
811 code: envelope.code.clone(),
812 message: envelope.message.clone(),
813 },
814 Event::Status { message } => wire::EventMsg::Status {
815 thread_id,
816 session_id,
817 message: message.clone(),
818 },
819 Event::McpSessionBoot {
820 generation,
821 snapshot,
822 connecting,
823 finished,
824 } => wire::EventMsg::McpSessionBoot {
825 thread_id,
826 session_id,
827 generation: *generation,
828 snapshot: mcp_snapshot_to_wire(snapshot),
829 connecting: connecting.clone(),
830 finished: *finished,
831 },
832 Event::RequestManifestReady { rendered } => wire::EventMsg::RequestManifestReady {
833 thread_id,
834 session_id,
835 rendered: rendered.clone(),
836 },
837 // The in-process `ack` notifier is an engine handle, not wire data.
838 Event::PauseEvents { ack: _ } => wire::EventMsg::PauseEvents {
839 thread_id,
840 session_id,
841 },
842 Event::ResumeEvents => wire::EventMsg::ResumeEvents {
843 thread_id,
844 session_id,
845 },
846 Event::ApprovalRequired {
847 id,
848 tool_name,
849 description,
850 input,
851 approval_key,
852 approval_grouping_key,
853 intent_summary,
854 approval_force_prompt,
855 } => wire::EventMsg::ApprovalRequired {
856 thread_id,
857 session_id,
858 id: id.clone(),
859 tool_name: tool_name.clone(),
860 description: description.clone(),
861 input: input.clone(),
862 approval_key: approval_key.clone(),
863 approval_grouping_key: approval_grouping_key.clone(),
864 intent_summary: intent_summary.clone(),
865 approval_force_prompt: *approval_force_prompt,
866 },
867 Event::UserInputRequired { id, request } => wire::EventMsg::UserInputRequired {
868 thread_id,
869 session_id,
870 id: id.clone(),
871 request: user_input_to_wire(request),
872 },
873 Event::SessionUpdated {
874 session_id: engine_session_id,
875 messages,
876 system_prompt,
877 model,
878 workspace,
879 } => wire::EventMsg::SessionUpdated {
880 thread_id,
881 session_id,
882 engine_session_id: engine_session_id.clone(),
883 messages: messages.iter().map(to_value).collect(),
884 system_prompt: system_prompt.as_ref().map(to_value),
885 model: model.clone(),
886 workspace: workspace.clone(),
887 },
888 Event::ElevationRequired {
889 tool_id,
890 tool_name,
891 command,
892 denial_reason,
893 blocked_network,
894 blocked_write,
895 } => wire::EventMsg::ElevationRequired {
896 thread_id,
897 session_id,
898 tool_id: tool_id.clone(),
899 tool_name: tool_name.clone(),
900 command: command.clone(),
901 denial_reason: denial_reason.clone(),
902 blocked_network: *blocked_network,
903 blocked_write: *blocked_write,
904 },
905 Event::LspRepairUpdate {
906 diagnostics_found,
907 files,
908 injected,
909 } => wire::EventMsg::LspRepairUpdate {
910 thread_id,
911 session_id,
912 diagnostics_found: count(*diagnostics_found),
913 files: count(*files),
914 injected: *injected,
915 },
916 Event::ToolGateDecision {
917 agent_id,
918 tool_id,
919 tool_name,
920 gate,
921 decision,
922 risk,
923 reason,
924 } => wire::EventMsg::ToolGateDecision {
925 thread_id,
926 session_id,
927 agent_id: agent_id.clone(),
928 tool_id: tool_id.clone(),
929 tool_name: tool_name.clone(),
930 gate: gate_to_wire(*gate),
931 decision: verdict_to_wire(*decision),
932 risk: risk.clone(),
933 reason: reason.clone(),
934 },
935 Event::AdvisoryNote {
936 turn_id,
937 note,
938 tool_call_count,
939 } => wire::EventMsg::AdvisoryNote {
940 thread_id,
941 session_id,
942 turn_id: turn_id.clone(),
943 note: note.clone(),
944 tool_call_count: *tool_call_count,
945 },
946 Event::PrefixCacheChange {
947 description,
948 system_prompt_changed,
949 tools_changed,
950 stability_pct,
951 changed,
952 pinned_combined_hash,
953 pin_reason,
954 last_miss_reason,
955 context_updates,
956 } => wire::EventMsg::PrefixCacheChange {
957 thread_id,
958 session_id,
959 description: description.clone(),
960 system_prompt_changed: *system_prompt_changed,
961 tools_changed: *tools_changed,
962 stability_pct: *stability_pct,
963 changed: *changed,
964 pinned_combined_hash: pinned_combined_hash.clone(),
965 pin_reason: pin_reason.clone(),
966 last_miss_reason: last_miss_reason.clone(),
967 context_updates: *context_updates,
968 },
969 }
970 }
971
972 /// Project one engine op onto the protocol. Exhaustive: a new engine variant
973 /// without a protocol twin does not compile. Reply channels, hook executors,
974 /// and resolved clients are stripped; only their non-secret receipts cross.
975 #[must_use]
976 pub fn op_to_protocol(op: &Op) -> wire_op::Op {
977 match op {
978 Op::SendMessage(spec) => wire_op::Op::SendMessage(turn_spec_to_wire(spec)),
979 Op::ContinueGoal {
980 dynamic_tools,
981 engine_schedule_id,
982 } => wire_op::Op::ContinueGoal {
983 dynamic_tools: dynamic_tools.clone(),
984 engine_schedule_id: *engine_schedule_id,
985 },
986 Op::RunShellCommand {
987 command,
988 mode,
989 allow_shell,
990 trust_mode,
991 auto_approve,
992 approval_mode,
993 } => wire_op::Op::RunShellCommand {
994 command: command.clone(),
995 mode: app_mode_str(*mode).to_string(),
996 allow_shell: *allow_shell,
997 trust_mode: *trust_mode,
998 auto_approve: *auto_approve,
999 approval_mode: approval_mode_str(*approval_mode).to_string(),
1000 },
1001 Op::SetGoalStatus {
1002 status,
1003 clear,
1004 goal_id,
1005 } => wire_op::Op::SetGoalStatus {
1006 goal_id: goal_id.clone(),
1007 status: status.as_str().to_string(),
1008 clear: *clear,
1009 },
1010 Op::SetGoalObjective {
1011 objective,
1012 token_budget,
1013 goal_id,
1014 } => wire_op::Op::SetGoalObjective {
1015 goal_id: goal_id.clone(),
1016 objective: objective.clone(),
1017 token_budget: *token_budget,
1018 },
1019 Op::PreviewOutboundRequest {
1020 inputs,
1021 json,
1022 base_prompt_only,
1023 } => wire_op::Op::PreviewOutboundRequest {
1024 json: *json,
1025 base_prompt_only: *base_prompt_only,
1026 mode: app_mode_str(inputs.mode).to_string(),
1027 allow_shell: inputs.allow_shell,
1028 trust_mode: inputs.trust_mode,
1029 auto_approve: inputs.auto_approve,
1030 approval_mode: approval_mode_str(inputs.approval_mode).to_string(),
1031 allowed_tools: inputs.allowed_tools.clone(),
1032 dynamic_tools: inputs.dynamic_tools.clone(),
1033 provenance: inputs.provenance.as_str().to_string(),
1034 requested_model: inputs.requested_model.clone(),
1035 requested_reasoning: inputs.requested_reasoning.clone(),
1036 auto_model: inputs.auto_model,
1037 hypothetical_prompt_supplied: inputs.hypothetical_prompt_supplied,
1038 hypothetical_prompt: inputs.next_turn.as_ref().map(|turn| turn.content.clone()),
1039 unresolved: if inputs.next_turn.is_some() {
1040 None
1041 } else {
1042 Some(preview_unresolved_str(&inputs.unresolved))
1043 },
1044 },
1045 Op::ListSubAgents => wire_op::Op::ListSubAgents,
1046 Op::GetSubAgentSettlement { tx: _ } => wire_op::Op::GetSubAgentSettlement,
1047 Op::CancelSubAgent { agent_id } => wire_op::Op::CancelSubAgent {
1048 agent_id: agent_id.clone(),
1049 },
1050 Op::FollowUpSubAgent { agent_id, text } => wire_op::Op::FollowUpSubAgent {
1051 agent_id: agent_id.clone(),
1052 text: text.clone(),
1053 },
1054 Op::ChangeMode {
1055 mode,
1056 allow_shell,
1057 trust_mode,
1058 auto_approve,
1059 approval_mode,
1060 configured_sandbox_mode,
1061 } => wire_op::Op::ChangeMode {
1062 mode: app_mode_str(*mode).to_string(),
1063 allow_shell: *allow_shell,
1064 trust_mode: *trust_mode,
1065 auto_approve: *auto_approve,
1066 approval_mode: approval_mode_str(*approval_mode).to_string(),
1067 configured_sandbox_mode: configured_sandbox_mode.clone(),
1068 },
1069 Op::SetModel {
1070 model,
1071 mode,
1072 route_limits,
1073 } => wire_op::Op::SetModel {
1074 model: model.clone(),
1075 mode: app_mode_str(*mode).to_string(),
1076 route_limits: route_limits.map(|limits| wire_op::RouteLimits {
1077 context_tokens: limits.context_tokens,
1078 input_tokens: limits.input_tokens,
1079 output_tokens: limits.output_tokens,
1080 }),
1081 },
1082 Op::SetCompaction { config } => wire_op::Op::SetCompaction {
1083 config: compaction_to_wire(config),
1084 },
1085 Op::SetStreamChunkTimeout { timeout_secs } => wire_op::Op::SetStreamChunkTimeout {
1086 timeout_secs: *timeout_secs,
1087 },
1088 Op::SetSubagentRuntimeConfig {
1089 enabled,
1090 max_subagents,
1091 launch_concurrency,
1092 max_spawn_depth,
1093 api_timeout_secs,
1094 heartbeat_timeout_secs,
1095 } => wire_op::Op::SetSubagentRuntimeConfig {
1096 enabled: *enabled,
1097 max_subagents: count(*max_subagents),
1098 launch_concurrency: count(*launch_concurrency),
1099 max_spawn_depth: *max_spawn_depth,
1100 api_timeout_secs: *api_timeout_secs,
1101 heartbeat_timeout_secs: *heartbeat_timeout_secs,
1102 },
1103 Op::SetSearchProvider { provider } => wire_op::Op::SetSearchProvider {
1104 provider: provider.as_str().to_string(),
1105 },
1106 Op::SetFleetRoster { roster } => wire_op::Op::SetFleetRoster {
1107 member_ids: roster
1108 .members()
1109 .iter()
1110 .map(|member| member.id.clone())
1111 .collect(),
1112 exact_selection: roster.is_exact_selection(),
1113 load_error: roster.load_error().map(str::to_string),
1114 },
1115 Op::SyncSession {
1116 session_id,
1117 messages,
1118 system_prompt,
1119 system_prompt_override,
1120 model,
1121 workspace,
1122 mode,
1123 } => wire_op::Op::SyncSession {
1124 engine_session_id: session_id.clone(),
1125 messages: messages.iter().map(to_value).collect(),
1126 system_prompt: system_prompt.as_ref().map(to_value),
1127 system_prompt_override: *system_prompt_override,
1128 model: model.clone(),
1129 workspace: workspace.clone(),
1130 mode: app_mode_str(*mode).to_string(),
1131 },
1132 Op::CompactContext {
1133 id,
1134 route,
1135 compaction,
1136 } => wire_op::Op::CompactContext {
1137 id: id.clone(),
1138 model: route.model.clone(),
1139 model_provider: route.identity.key.clone(),
1140 compaction: compaction_to_wire(compaction),
1141 },
1142 Op::CancelCompaction { id } => wire_op::Op::CancelCompaction { id: id.clone() },
1143 // Reply channels never cross the wire: the answer is a frame.
1144 Op::GetSessionSnapshot { tx: _ } => wire_op::Op::GetSessionSnapshot,
1145 Op::GetContextBudget { tx: _ } => wire_op::Op::GetContextBudget,
1146 Op::GetProviderRuntimeStatus { tx: _ } => wire_op::Op::GetProviderRuntimeStatus,
1147 Op::BootstrapMcp { tx: _ } => wire_op::Op::BootstrapMcp,
1148 Op::RetryMcpServer { name, tx: _ } => wire_op::Op::RetryMcpServer { name: name.clone() },
1149 Op::ReloadMcp { config_path, tx: _ } => wire_op::Op::ReloadMcp {
1150 config_path: config_path.clone(),
1151 },
1152 Op::PurgeContext => wire_op::Op::PurgeContext,
1153 Op::EditLastTurn { new_message } => wire_op::Op::EditLastTurn {
1154 new_message: new_message.clone(),
1155 },
1156 Op::SetAdvisorEnabled { enabled } => wire_op::Op::SetAdvisorEnabled { enabled: *enabled },
1157 Op::Shutdown => wire_op::Op::Shutdown,
1158 }
1159 }
1160
1161 impl Event {
1162 /// Protocol projection of this event. See [`event_to_protocol`].
1163 #[must_use]
1164 pub fn to_protocol(&self, ids: &ProtocolIds) -> wire::EventMsg {
1165 event_to_protocol(self, ids)
1166 }
1167 }
1168
1169 impl Op {
1170 /// Protocol projection of this op. See [`op_to_protocol`].
1171 #[must_use]
1172 pub fn to_protocol(&self) -> wire_op::Op {
1173 op_to_protocol(self)
1174 }
1175 }
1176
1177 #[cfg(test)]
1178 mod tests {
1179 #[test]
1180 fn mcp_command_or_url_is_redacted_before_it_reaches_the_wire() {
1181 // Local display may show the configured value; this projection feeds
1182 // EventMsg::McpSessionBoot, which every SSE/stream-JSON consumer sees.
1183 assert_eq!(
1184 redacted_command_or_url("https://user:tok@mcp.example.com/sse"),
1185 "https://***:***@mcp.example.com/sse"
1186 );
1187 let masked = redacted_command_or_url("https://mcp.example.com/sse?api_key=SECRET");
1188 assert!(!masked.contains("SECRET"), "{masked}");
1189 // stdio: keep the program, drop the args -- a token there can be
1190 // positional, so key-based masking is not enough.
1191 assert_eq!(
1192 redacted_command_or_url("npx -y server --token SECRET"),
1193 "npx …"
1194 );
1195 // Nothing to hide, nothing changed.
1196 assert_eq!(redacted_command_or_url("npx"), "npx");
1197 // A stdio command that merely mentions a URL must still collapse to
1198 // the program name. Routing it to the URL redactor returns it verbatim
1199 // (`Url::parse` rejects the spaces), leaking the positional token.
1200 let mentions_url = redacted_command_or_url(
1201 "docker run -e TOKEN=sk-live-abc img --url https://mcp.example.com",
1202 );
1203 assert_eq!(mentions_url, "docker …");
1204 assert!(
1205 !mentions_url.contains("sk-live-abc"),
1206 "argv secret reached the wire: {mentions_url}"
1207 );
1208 }
1209
1210 use super::*;
1211 use crate::error_taxonomy::{ErrorCategory, ErrorEnvelope, ErrorSeverity};
1212 use crate::tools::goal::GoalStatus;
1213 use crate::tools::spec::ToolResult;
1214 use serde_json::json;
1215
1216 const SOURCE: &str = include_str!("protocol_parity.rs");
1217
1218 fn ids() -> ProtocolIds {
1219 ProtocolIds {
1220 thread_id: ThreadId::new(),
1221 session_id: SessionId::new(),
1222 }
1223 }
1224
1225 #[test]
1226 fn worker_lifecycle_wire_preserves_typed_outcomes_without_parsing_result_text() {
1227 use crate::tools::subagent::SubAgentStatus;
1228 let ids = ids();
1229 for (outcome, expected) in [
1230 (Some(SubAgentStatus::Completed), Some("completed")),
1231 (
1232 Some(SubAgentStatus::Failed("private failure".into())),
1233 Some("failed"),
1234 ),
1235 (
1236 Some(SubAgentStatus::Interrupted("private reason".into())),
1237 Some("interrupted"),
1238 ),
1239 (Some(SubAgentStatus::Cancelled), Some("cancelled")),
1240 (
1241 Some(SubAgentStatus::BudgetExhausted),
1242 Some("budget_exhausted"),
1243 ),
1244 (None, None),
1245 ] {
1246 let event = Event::AgentComplete {
1247 owner_session_id: "owner".into(),
1248 id: "worker".into(),
1249 result: "Completed successfully".into(),
1250 outcome,
1251 parent_run_id: Some("parent".into()),
1252 spawn_depth: Some(2),
1253 continuable: Some(false),
1254 usage: None,
1255 };
1256 let wire = serde_json::to_value(event_to_protocol(&event, &ids)).unwrap();
1257 assert_eq!(wire["worker_status"].as_str(), expected);
1258 assert_eq!(wire["parent_run_id"], "parent");
1259 assert_eq!(wire["spawn_depth"], 2);
1260 assert_eq!(wire["continuable"], false);
1261 assert!(!wire.to_string().contains("private"));
1262 }
1263 for status in [
1264 AgentWorkerStatus::Queued,
1265 AgentWorkerStatus::Starting,
1266 AgentWorkerStatus::Running,
1267 AgentWorkerStatus::WaitingForUser,
1268 AgentWorkerStatus::ModelWait,
1269 AgentWorkerStatus::RunningTool,
1270 AgentWorkerStatus::Completed,
1271 AgentWorkerStatus::Failed,
1272 AgentWorkerStatus::Cancelled,
1273 AgentWorkerStatus::Interrupted,
1274 ] {
1275 // Runtime serializes the producer enum; stream-json uses this
1276 // exhaustive adapter. Their discriminants must remain identical.
1277 assert_eq!(
1278 serde_json::to_value(status).unwrap(),
1279 worker_status_str(status)
1280 );
1281 }
1282 }
1283
1284 #[test]
1285 fn wire_accounting_preserves_parent_total_and_distinct_routed_telemetry() {
1286 let ids = ids();
1287 let total = Usage {
1288 input_tokens: 49,
1289 output_tokens: 19,
1290 ..Usage::default()
1291 };
1292 let parent = Usage {
1293 input_tokens: 7,
1294 output_tokens: 5,
1295 ..Usage::default()
1296 };
1297 let complete = event_to_protocol(
1298 &Event::TurnComplete {
1299 usage: total.clone(),
1300 parent_route_usage: parent.clone(),
1301 routed_usage_dropped_records: 3,
1302 status: TurnOutcomeStatus::Completed,
1303 error: None,
1304 tool_catalog: None,
1305 base_url: None,
1306 },
1307 &ids,
1308 );
1309 let json = serde_json::to_value(&complete).unwrap();
1310 assert_eq!(json["usage"]["input_tokens"], 49);
1311 assert_eq!(json["usage"]["output_tokens"], 19);
1312 assert_eq!(json["parent_route_usage"]["input_tokens"], 7);
1313 assert_eq!(json["parent_route_usage"]["output_tokens"], 5);
1314 assert_eq!(json["routed_usage_dropped_records"], 3);
1315 assert_eq!(
1316 serde_json::from_value::<wire::EventMsg>(json.clone()).unwrap(),
1317 complete
1318 );
1319
1320 // A legacy terminal receipt has no parent subset, which differs from
1321 // an explicitly reported zero parent on a compaction-only turn.
1322 let mut legacy = json;
1323 legacy.as_object_mut().unwrap().remove("parent_route_usage");
1324 legacy
1325 .as_object_mut()
1326 .unwrap()
1327 .remove("routed_usage_dropped_records");
1328 assert!(matches!(
1329 serde_json::from_value::<wire::EventMsg>(legacy).unwrap(),
1330 wire::EventMsg::TurnComplete {
1331 parent_route_usage: None,
1332 routed_usage_dropped_records: 0,
1333 ..
1334 }
1335 ));
1336
1337 for (event, tag) in [
1338 (
1339 Event::TurnUsage {
1340 max_output_tokens: None,
1341 usage: parent,
1342 duration_ms: 12,
1343 first_token_ms: Some(2),
1344 request_ms: Some(10),
1345 },
1346 "turn_usage",
1347 ),
1348 (
1349 Event::RoutedTurnUsage {
1350 usage: total,
1351 duration_ms: 27,
1352 first_token_ms: None,
1353 request_ms: None,
1354 },
1355 "routed_turn_usage",
1356 ),
1357 ] {
1358 let projected = event_to_protocol(&event, &ids);
1359 let json = serde_json::to_value(&projected).unwrap();
1360 assert_eq!(json["event"], tag);
1361 assert_eq!(
1362 serde_json::from_value::<wire::EventMsg>(json).unwrap(),
1363 projected
1364 );
1365 }
1366 }
1367
1368 /// The guard is the exhaustive `match` in `event_to_protocol`: this test
1369 /// exists so the guard has a name in the test log and so the projection
1370 /// is proven to agree with the protocol's wire-tag table.
1371 #[test]
1372 fn protocol_covers_engine_events() {
1373 let ids = ids();
1374 let usage = Usage {
1375 input_tokens: 3,
1376 output_tokens: 4,
1377 ..Usage::default()
1378 };
1379 let events = vec![
1380 Event::MessageStarted { index: 0 },
1381 Event::MessageDelta {
1382 index: 0,
1383 content: "hello".into(),
1384 },
1385 Event::ThinkingDelta {
1386 index: 1,
1387 content: "hmm".into(),
1388 },
1389 Event::ToolCallStarted {
1390 id: "c1".into(),
1391 name: "read_file".into(),
1392 input: json!({"path": "x"}),
1393 },
1394 Event::ToolCallHeartbeat,
1395 Event::ToolCallComplete {
1396 id: "c1".into(),
1397 name: "read_file".into(),
1398 result: Ok(ToolResult::success("ok")),
1399 },
1400 Event::ToolCallComplete {
1401 id: "c2".into(),
1402 name: "bash".into(),
1403 result: Err(ToolError::Timeout { seconds: 9 }),
1404 },
1405 Event::TurnStarted {
1406 turn_id: "turn-1".into(),
1407 created_at: chrono::Utc::now(),
1408 route: None,
1409 },
1410 Event::TurnComplete {
1411 usage: usage.clone(),
1412 parent_route_usage: usage.clone(),
1413 routed_usage_dropped_records: 0,
1414 status: TurnOutcomeStatus::Interrupted,
1415 error: Some("stopped".into()),
1416 tool_catalog: None,
1417 base_url: Some("https://example.invalid".into()),
1418 },
1419 Event::RoutedTurnUsage {
1420 usage: usage.clone(),
1421 duration_ms: 12,
1422 first_token_ms: Some(3),
1423 request_ms: None,
1424 },
1425 Event::TurnUsage {
1426 max_output_tokens: None,
1427 usage,
1428 duration_ms: 12,
1429 first_token_ms: Some(3),
1430 request_ms: None,
1431 },
1432 Event::Error {
1433 envelope: ErrorEnvelope {
1434 category: ErrorCategory::RateLimit,
1435 severity: ErrorSeverity::Warning,
1436 recoverable: true,
1437 code: "E429".into(),
1438 message: "slow down".into(),
1439 },
1440 recoverable: true,
1441 },
1442 Event::status("ready"),
1443 Event::PauseEvents { ack: None },
1444 Event::ResumeEvents,
1445 Event::ToolGateDecision {
1446 agent_id: None,
1447 tool_id: "c3".into(),
1448 tool_name: "bash".into(),
1449 gate: ToolGate::AutoReviewGuardian,
1450 decision: ToolGateVerdict::Unavailable,
1451 risk: None,
1452 reason: "timeout".into(),
1453 },
1454 Event::WorkflowUi {
1455 owner_session_id: "owner".into(),
1456 run_id: "run".into(),
1457 event: json!({"type": "task_started"}),
1458 },
1459 ];
1460
1461 for event in &events {
1462 let msg = event.to_protocol(&ids);
1463 assert!(
1464 wire::EVENT_KINDS.contains(&msg.kind_str()),
1465 "{} is not in EVENT_KINDS",
1466 msg.kind_str()
1467 );
1468 assert_eq!(msg.thread_id(), &ids.thread_id);
1469 assert_eq!(msg.session_id(), &ids.session_id);
1470 let value = serde_json::to_value(&msg).unwrap();
1471 assert_eq!(value["event"], msg.kind_str());
1472 let back: wire::EventMsg = serde_json::from_value(value).unwrap();
1473 assert_eq!(back, msg);
1474 }
1475
1476 let delta = events[1].to_protocol(&ids);
1477 let thinking = events[2].to_protocol(&ids);
1478 assert!(matches!(
1479 delta,
1480 wire::EventMsg::ResponseDelta {
1481 channel: ResponseChannel::Text,
1482 ..
1483 }
1484 ));
1485 assert!(matches!(
1486 thinking,
1487 wire::EventMsg::ResponseDelta {
1488 channel: ResponseChannel::Reasoning,
1489 ..
1490 }
1491 ));
1492 assert_eq!(
1493 serde_json::to_value(events[6].to_protocol(&ids)).unwrap()["result"],
1494 json!({"outcome": "err", "error": {"kind": "timeout", "seconds": 9}})
1495 );
1496 assert_eq!(
1497 serde_json::to_value(events[8].to_protocol(&ids)).unwrap()["status"],
1498 "interrupted"
1499 );
1500 let error = events
1501 .iter()
1502 .find(|event| matches!(event, Event::Error { .. }))
1503 .unwrap();
1504 let error = serde_json::to_value(error.to_protocol(&ids)).unwrap();
1505 assert_eq!(error["category"], "rate_limit");
1506 assert_eq!(error["severity"], "warning");
1507 }
1508
1509 #[test]
1510 fn protocol_covers_engine_ops() {
1511 let (tx, _rx) = tokio::sync::oneshot::channel();
1512 let settlement_reply = std::sync::Arc::new(std::sync::Mutex::new(Some(tx)));
1513 let ops = vec![
1514 Op::SetGoalStatus {
1515 goal_id: None,
1516 status: GoalStatus::Paused,
1517 clear: false,
1518 },
1519 Op::SetGoalObjective {
1520 goal_id: None,
1521 objective: "ship".into(),
1522 token_budget: Some(7),
1523 },
1524 Op::ListSubAgents,
1525 Op::CancelSubAgent {
1526 agent_id: "a1".into(),
1527 },
1528 Op::FollowUpSubAgent {
1529 agent_id: "a1".into(),
1530 text: "go".into(),
1531 },
1532 Op::SetStreamChunkTimeout { timeout_secs: 30 },
1533 Op::SetSubagentRuntimeConfig {
1534 enabled: true,
1535 max_subagents: 4,
1536 launch_concurrency: 2,
1537 max_spawn_depth: 1,
1538 api_timeout_secs: 60,
1539 heartbeat_timeout_secs: 10,
1540 },
1541 Op::CancelCompaction { id: "cmp".into() },
1542 Op::GetSessionSnapshot {
1543 tx: std::sync::Arc::new(std::sync::Mutex::new(None)),
1544 },
1545 Op::GetContextBudget {
1546 tx: std::sync::Arc::new(std::sync::Mutex::new(None)),
1547 },
1548 Op::GetProviderRuntimeStatus {
1549 tx: std::sync::Arc::new(std::sync::Mutex::new(None)),
1550 },
1551 Op::BootstrapMcp {
1552 tx: std::sync::Arc::new(std::sync::Mutex::new(None)),
1553 },
1554 Op::RetryMcpServer {
1555 name: "fs".into(),
1556 tx: std::sync::Arc::new(std::sync::Mutex::new(None)),
1557 },
1558 Op::ReloadMcp {
1559 config_path: std::path::PathBuf::from("/tmp/mcp.json"),
1560 tx: std::sync::Arc::new(std::sync::Mutex::new(None)),
1561 },
1562 Op::PurgeContext,
1563 Op::EditLastTurn {
1564 new_message: "again".into(),
1565 },
1566 Op::SetAdvisorEnabled { enabled: true },
1567 Op::GetSubAgentSettlement {
1568 tx: std::sync::Arc::clone(&settlement_reply),
1569 },
1570 Op::Shutdown,
1571 ];
1572
1573 for op in &ops {
1574 let msg = op.to_protocol();
1575 assert!(
1576 wire_op::OP_KINDS.contains(&msg.kind_str()),
1577 "{} is not in OP_KINDS",
1578 msg.kind_str()
1579 );
1580 let value = serde_json::to_value(&msg).unwrap();
1581 assert_eq!(value["kind"], msg.kind_str());
1582 let back: wire_op::Op = serde_json::from_value(value).unwrap();
1583 assert_eq!(back, msg);
1584 }
1585
1586 assert_eq!(
1587 serde_json::to_value(ops[0].to_protocol()).unwrap(),
1588 json!({"kind": "set_goal_status", "status": "paused", "clear": false})
1589 );
1590 assert_eq!(
1591 serde_json::to_value(ops[8].to_protocol()).unwrap(),
1592 json!({"kind": "get_session_snapshot"}),
1593 "reply channels must not leak onto the wire"
1594 );
1595 let settlement = ops
1596 .iter()
1597 .find(|op| matches!(op, Op::GetSubAgentSettlement { .. }))
1598 .unwrap();
1599 assert_eq!(
1600 serde_json::to_value(settlement.to_protocol()).unwrap(),
1601 json!({"kind": "get_sub_agent_settlement"}),
1602 "the settlement operation must retain its own channel-free protocol twin"
1603 );
1604 assert!(
1605 settlement_reply.lock().unwrap().is_some(),
1606 "projection must not consume the host's live response sender"
1607 );
1608 }
1609
1610 #[test]
1611 fn mode_labels_round_trip_through_app_mode_parse() {
1612 for mode in [AppMode::Agent, AppMode::Plan, AppMode::Operate] {
1613 assert_eq!(AppMode::parse(app_mode_str(mode)), Some(mode), "{mode:?}");
1614 }
1615 for mode in [
1616 ApprovalMode::Auto,
1617 ApprovalMode::Bypass,
1618 ApprovalMode::Suggest,
1619 ApprovalMode::Never,
1620 ] {
1621 assert_eq!(
1622 ApprovalMode::from_config_value(approval_mode_str(mode)),
1623 Some(mode)
1624 );
1625 }
1626 }
1627
1628 /// The projections are only a guard while they stay exhaustive. A
1629 /// wildcard arm would let a new engine variant slip through unmapped.
1630 #[test]
1631 fn projections_have_no_wildcard_arms() {
1632 let wildcard_arms: Vec<&str> = SOURCE
1633 .lines()
1634 .filter(|line| {
1635 let trimmed = line.trim_start();
1636 trimmed.starts_with("_ =>")
1637 || trimmed.starts_with("_=>")
1638 || trimmed.starts_with("Event::_")
1639 || trimmed.starts_with("Op::_")
1640 || (trimmed.contains(" => ") && trimmed.starts_with("other =>"))
1641 })
1642 .collect();
1643 assert!(
1644 wildcard_arms.is_empty(),
1645 "protocol_parity.rs must match engine variants exhaustively; found {wildcard_arms:?}"
1646 );
1647 }
1648 }
1649
1649 lines RUST