Skip to main content

magic_coder_types/protocol/
types.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use uuid::Uuid;
4
5#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
6pub enum Role {
7    Assistant,
8    User,
9}
10
11#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
12pub enum RuntimeMode {
13    Agent,
14    Plan,
15}
16
17#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
18pub enum ToolExecutionTarget {
19    Unspecified,
20    /// Tool must be executed by a connected client (TUI/VS Code).
21    ClientLocal,
22    /// Tool is executed server-side (agents/MCP/etc). Clients must not provide outputs.
23    ServerAgents,
24}
25
26#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
27pub enum ToolCallApproval {
28    Unspecified,
29    Pending,
30    Approved,
31    AutoApproved,
32    Rejected,
33}
34
35#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
36pub struct ModelConfig {
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub temperature: Option<f32>,
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub top_p: Option<f32>,
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub presence_penalty: Option<f32>,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub frequency_penalty: Option<f32>,
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub max_tokens: Option<i32>,
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub reasoning_effort: Option<String>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
52pub struct ThreadModelOverride {
53    pub model_id: Uuid,
54    pub model_config: ModelConfig,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct ToolCallOutput {
59    /// Tool call id
60    pub id: String,
61
62    pub is_error: bool,
63    pub output: String,
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub duration_seconds: Option<i32>,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
69pub enum SubagentEscalationResolution {
70    Approved,
71    Rejected {
72        #[serde(default, skip_serializing_if = "Option::is_none")]
73        reason: Option<String>,
74    },
75    ResolvedWithOutput {
76        #[serde(default)]
77        is_error: bool,
78        output: String,
79        #[serde(default, skip_serializing_if = "Option::is_none")]
80        duration_seconds: Option<i32>,
81    },
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
85pub struct LocalSkillMetadata {
86    pub name: String,
87    pub description: String,
88    pub cwd: String,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
92pub struct AgentsMdMetadata {
93    pub cwd: String,
94    pub content: String,
95}
96
97trait BoolExt {
98    fn is_false(&self) -> bool;
99}
100
101impl BoolExt for bool {
102    fn is_false(&self) -> bool {
103        !*self
104    }
105}
106
107/// Normalized operating system family reported by a client during the handshake.
108#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
109pub enum Os {
110    /// The client could not determine a supported OS family.
111    #[default]
112    #[serde(rename = "other")]
113    Other,
114    /// Linux distributions.
115    #[serde(rename = "linux")]
116    Linux,
117    /// macOS / Darwin.
118    #[serde(rename = "macos")]
119    MacOS,
120    /// Microsoft Windows.
121    #[serde(rename = "windows")]
122    Windows,
123}
124
125/// Normalized CPU architecture reported by a client during the handshake.
126#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
127pub enum Arch {
128    /// The client could not determine a supported CPU architecture.
129    #[default]
130    #[serde(rename = "other")]
131    Other,
132    /// 32-bit x86.
133    #[serde(rename = "x86")]
134    X86,
135    /// 64-bit x86.
136    #[serde(rename = "amd64")]
137    Amd64,
138    /// 64-bit ARM.
139    #[serde(rename = "aarch64")]
140    Aarch64,
141}
142
143/// Snapshot of coarse client machine characteristics captured when the socket connects.
144#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
145#[serde(default)]
146pub struct ClientSystemInfo {
147    /// Operating system family.
148    pub os: Os,
149    /// Human-readable OS version string reported by the client.
150    pub os_version: String,
151    /// CPU architecture family.
152    pub arch: Arch,
153    /// Logical CPU core count.
154    pub cpu_cores: u16,
155    /// Total physical memory reported by the client, in whole megabytes.
156    pub ram_mb: u32,
157}
158
159impl ClientSystemInfo {
160    fn is_unknown(&self) -> bool {
161        self == &Self::default()
162    }
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub enum ClientMessage {
167    HelloMath {
168        /// Random per-client-instance id (generated on client startup).
169        ///
170        /// Used for multi-client tool-call claiming/coordination. Not derived from auth token.
171        client_instance_id: String,
172        /// Client version.
173        version: String,
174        /// Minimum supported server version (the client can work with any server >= this).
175        min_supported_version: String,
176        /// Optional thread root message id used by reconnecting clients to restore active thread state.
177        #[serde(default, skip_serializing_if = "Option::is_none")]
178        resume_thread_id: Option<Uuid>,
179        /// Whether the client is currently running in automagic mode.
180        #[serde(default, skip_serializing_if = "BoolExt::is_false")]
181        automagic: bool,
182        /// Normalized snapshot of the client machine.
183        ///
184        /// Older clients may omit this; missing values deserialize as "unknown".
185        #[serde(default, skip_serializing_if = "ClientSystemInfo::is_unknown")]
186        system_info: ClientSystemInfo,
187    },
188    /// Send a user message.
189    ///
190    /// If `thread_id` is `None`, the server creates a new thread and returns it in `SendMessageAck`.
191    /// `request_id` is a client-generated correlation id for 1:1 request↔response mapping.
192    SendMessage {
193        request_id: Uuid,
194        thread_id: Option<Uuid>,
195        text: String,
196        /// Optional runtime mode update to apply to the thread before generation.
197        #[serde(default, skip_serializing_if = "Option::is_none")]
198        runtime_mode: Option<RuntimeMode>,
199        /// Optional model override update to apply to the thread before generation.
200        ///
201        /// - `None`: do not change current override.
202        /// - `Some(value)`: set override to `value`.
203        #[serde(default, skip_serializing_if = "Option::is_none")]
204        model_override: Option<ThreadModelOverride>,
205    },
206    /// Update/refresh auth token without reconnecting the WebSocket.
207    UpdateAuthToken {
208        token: String,
209    },
210    /// Update the client's workspace roots / default root (used for prompt context + validations).
211    ///
212    /// Sent separately from `HelloMath` so roots can be updated without reconnecting.
213    UpdateWorkspaceRoots {
214        default_root: String,
215        workspace_roots: Vec<String>,
216    },
217    /// Replace the client-local skill snapshot exposed to the server for this connection.
218    UpdateLocalSkills {
219        skills: Vec<LocalSkillMetadata>,
220    },
221    /// Replace the client-local AGENTS.md snapshots exposed to the server for this connection.
222    UpdateAgentsMds {
223        agents_mds: Vec<AgentsMdMetadata>,
224    },
225    RejectToolCall {
226        id: String,
227        #[serde(default, skip_serializing_if = "Option::is_none")]
228        reason: Option<String>,
229    },
230    AcceptToolCall {
231        id: String,
232    },
233    ResolveSubagentEscalation {
234        parent_message_id: Uuid,
235        subagent_run_id: Uuid,
236        escalation_id: String,
237        resolution: SubagentEscalationResolution,
238    },
239    ToolCallOutputs {
240        outputs: Vec<ToolCallOutput>,
241    },
242    /// Cancel the current in-progress generation for a specific assistant message.
243    CancelGeneration {
244        message_id: Uuid,
245    },
246}
247
248#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
249pub struct Usage {
250    pub input_tokens: i32,
251    pub output_tokens: i32,
252    #[serde(default)]
253    pub cache_read_input_tokens: i32,
254    #[serde(default)]
255    pub cache_creation_input_tokens: i32,
256    #[serde(default)]
257    pub cache_creation_input_tokens_5m: i32,
258    #[serde(default)]
259    pub cache_creation_input_tokens_1h: i32,
260}
261
262#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
263pub enum MessageStatus {
264    Completed,
265    /// The agent is waiting for the client/user to provide more input (e.g. tool outputs / approvals)
266    /// before it can continue generation.
267    WaitingForUser,
268    Failed,
269    Cancelled,
270}
271
272#[derive(Debug, Clone, Serialize, Deserialize)]
273pub enum ServerMessage {
274    HelloMagic {
275        version: String,
276        min_supported_version: String,
277    },
278    VersionMismatch {
279        server_version: String,
280        server_min_supported_version: String,
281    },
282    Goodbye {
283        reconnect: bool,
284    },
285    SendMessageAck {
286        request_id: Uuid,
287        thread_id: Uuid,
288        user_message_id: Uuid,
289    },
290    AuthUpdated,
291    RuntimeModeUpdated {
292        thread_id: Uuid,
293        mode: RuntimeMode,
294        #[serde(default, skip_serializing_if = "Option::is_none")]
295        changed_by_client_instance_id: Option<String>,
296    },
297    ThreadModelUpdated {
298        thread_id: Uuid,
299        #[serde(default, skip_serializing_if = "Option::is_none")]
300        model_override: Option<ThreadModelOverride>,
301        #[serde(default, skip_serializing_if = "Option::is_none")]
302        changed_by_client_instance_id: Option<String>,
303    },
304    MessageHeader {
305        message_id: Uuid,
306        thread_id: Uuid,
307        role: Role,
308        #[serde(default, skip_serializing_if = "Option::is_none")]
309        request_id: Option<Uuid>,
310    },
311    ReasoningDelta {
312        message_id: Uuid,
313        content: String,
314    },
315    TextDelta {
316        message_id: Uuid,
317        content: String,
318    },
319    ToolCallHeader {
320        message_id: Uuid,
321        tool_call_id: String,
322        name: String,
323        execution_target: ToolExecutionTarget,
324        approval: ToolCallApproval,
325    },
326    ToolCallArgumentsDelta {
327        message_id: Uuid,
328        tool_call_id: String,
329        delta: String,
330    },
331    ToolCall {
332        message_id: Uuid,
333        tool_call_id: String,
334        args: Value,
335    },
336    ToolCallResult {
337        message_id: Uuid,
338        tool_call_id: String,
339        is_error: bool,
340        output: String,
341        #[serde(default, skip_serializing_if = "Option::is_none")]
342        duration_seconds: Option<i32>,
343    },
344    ToolCallClaimed {
345        message_id: Uuid,
346        tool_call_id: String,
347        claimed_by_client_instance_id: String,
348    },
349    ToolCallApprovalUpdated {
350        message_id: Uuid,
351        tool_call_id: String,
352        approval: ToolCallApproval,
353    },
354    MessageDone {
355        message_id: Uuid,
356        #[serde(default, skip_serializing_if = "Option::is_none")]
357        usage: Option<Usage>,
358        status: MessageStatus,
359    },
360    Error {
361        #[serde(default, skip_serializing_if = "Option::is_none")]
362        request_id: Option<Uuid>,
363        #[serde(default, skip_serializing_if = "Option::is_none")]
364        message_id: Option<Uuid>,
365        code: String,
366        message: String,
367    },
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use serde_json::json;
374
375    fn hello_math(resume_thread_id: Option<Uuid>) -> ClientMessage {
376        ClientMessage::HelloMath {
377            client_instance_id: "client-a".to_string(),
378            version: "1.2.3".to_string(),
379            min_supported_version: "1.0.0".to_string(),
380            resume_thread_id,
381            automagic: false,
382            system_info: ClientSystemInfo::default(),
383        }
384    }
385
386    #[test]
387    fn send_message_omits_optional_updates_when_not_set() {
388        let msg = ClientMessage::SendMessage {
389            request_id: Uuid::nil(),
390            thread_id: None,
391            text: "hello".to_string(),
392            runtime_mode: None,
393            model_override: None,
394        };
395
396        let value = serde_json::to_value(msg).expect("serialize");
397        let body = value
398            .get("SendMessage")
399            .and_then(|v| v.as_object())
400            .expect("SendMessage body");
401
402        assert!(body.get("runtime_mode").is_none());
403        assert!(body.get("model_override").is_none());
404    }
405
406    #[test]
407    fn hello_math_omits_resume_thread_id_when_not_set() {
408        let msg = hello_math(None);
409
410        let value = serde_json::to_value(msg).expect("serialize");
411        let body = value
412            .get("HelloMath")
413            .and_then(|v| v.as_object())
414            .expect("HelloMath body");
415
416        assert!(body.get("resume_thread_id").is_none());
417        assert!(body.get("automagic").is_none());
418        assert!(body.get("system_info").is_none());
419    }
420
421    #[test]
422    fn hello_math_round_trip_resume_thread_id() {
423        let thread_id = Uuid::new_v4();
424        let msg = hello_math(Some(thread_id));
425
426        let value = serde_json::to_value(&msg).expect("serialize");
427        let body = value
428            .get("HelloMath")
429            .and_then(|v| v.as_object())
430            .expect("HelloMath body");
431        assert_eq!(
432            body.get("resume_thread_id"),
433            Some(&serde_json::Value::String(thread_id.to_string()))
434        );
435
436        let back: ClientMessage = serde_json::from_value(value).expect("deserialize");
437        match back {
438            ClientMessage::HelloMath {
439                resume_thread_id, ..
440            } => assert_eq!(resume_thread_id, Some(thread_id)),
441            _ => panic!("expected HelloMath"),
442        }
443    }
444
445    #[test]
446    fn hello_math_deserializes_defaults_for_new_fields() {
447        let value = json!({
448            "HelloMath": {
449                "client_instance_id": "client-a",
450                "version": "1.2.3",
451                "min_supported_version": "1.0.0"
452            }
453        });
454
455        let back: ClientMessage = serde_json::from_value(value).expect("deserialize");
456        match back {
457            ClientMessage::HelloMath {
458                automagic,
459                system_info,
460                ..
461            } => {
462                assert!(!automagic);
463                assert_eq!(system_info, ClientSystemInfo::default());
464            }
465            _ => panic!("expected HelloMath"),
466        }
467    }
468
469    #[test]
470    fn hello_math_serializes_non_default_system_info() {
471        let msg = ClientMessage::HelloMath {
472            client_instance_id: "client-a".to_string(),
473            version: "1.2.3".to_string(),
474            min_supported_version: "1.0.0".to_string(),
475            resume_thread_id: None,
476            automagic: true,
477            system_info: ClientSystemInfo {
478                os: Os::MacOS,
479                os_version: "15.5".to_string(),
480                arch: Arch::Amd64,
481                cpu_cores: 10,
482                ram_mb: 32768,
483            },
484        };
485
486        let value = serde_json::to_value(msg).expect("serialize");
487        let body = value
488            .get("HelloMath")
489            .and_then(|v| v.as_object())
490            .expect("HelloMath body");
491
492        assert_eq!(body.get("automagic"), Some(&json!(true)));
493        assert_eq!(
494            body.get("system_info"),
495            Some(&json!({
496                "os": "macos",
497                "os_version": "15.5",
498                "arch": "amd64",
499                "cpu_cores": 10,
500                "ram_mb": 32768
501            }))
502        );
503    }
504
505    #[test]
506    fn hello_math_deserializes_canonical_arch_names() {
507        let value = json!({
508            "HelloMath": {
509                "client_instance_id": "client-a",
510                "version": "1.2.3",
511                "min_supported_version": "1.0.0",
512                "system_info": {
513                    "os": "linux",
514                    "os_version": "6.8",
515                    "arch": "amd64",
516                    "cpu_cores": 8,
517                    "ram_mb": 16384
518                }
519            }
520        });
521
522        let back: ClientMessage = serde_json::from_value(value).expect("deserialize");
523        match back {
524            ClientMessage::HelloMath { system_info, .. } => {
525                assert_eq!(system_info.arch, Arch::Amd64);
526            }
527            _ => panic!("expected HelloMath"),
528        }
529    }
530
531    #[test]
532    fn send_message_serializes_model_override_when_set() {
533        let msg = ClientMessage::SendMessage {
534            request_id: Uuid::nil(),
535            thread_id: Some(Uuid::nil()),
536            text: "hello".to_string(),
537            runtime_mode: Some(RuntimeMode::Plan),
538            model_override: Some(ThreadModelOverride {
539                model_id: Uuid::nil(),
540                model_config: ModelConfig::default(),
541            }),
542        };
543
544        let value = serde_json::to_value(msg).expect("serialize");
545        let body = value
546            .get("SendMessage")
547            .and_then(|v| v.as_object())
548            .expect("SendMessage body");
549
550        assert_eq!(body.get("runtime_mode"), Some(&json!("Plan")));
551        assert!(body.get("model_override").is_some());
552    }
553
554    #[test]
555    fn send_message_deserializes_model_override_states() {
556        let set_json = json!({
557            "SendMessage": {
558                "request_id": Uuid::nil(),
559                "thread_id": Uuid::nil(),
560                "text": "hello",
561                "runtime_mode": "Agent",
562                "model_override": {
563                    "model_id": Uuid::nil(),
564                    "model_config": {}
565                }
566            }
567        });
568        let keep_json = json!({
569            "SendMessage": {
570                "request_id": Uuid::nil(),
571                "thread_id": Uuid::nil(),
572                "text": "hello"
573            }
574        });
575
576        let set_msg: ClientMessage = serde_json::from_value(set_json).expect("deserialize set");
577        let keep_msg: ClientMessage = serde_json::from_value(keep_json).expect("deserialize keep");
578
579        match set_msg {
580            ClientMessage::SendMessage {
581                runtime_mode,
582                model_override,
583                ..
584            } => {
585                assert_eq!(runtime_mode, Some(RuntimeMode::Agent));
586                assert!(model_override.is_some());
587            }
588            _ => panic!("expected SendMessage"),
589        }
590
591        match keep_msg {
592            ClientMessage::SendMessage { model_override, .. } => {
593                assert_eq!(model_override, None);
594            }
595            _ => panic!("expected SendMessage"),
596        }
597    }
598
599    #[test]
600    fn update_local_skills_round_trip_full_and_empty() {
601        let full = ClientMessage::UpdateLocalSkills {
602            skills: vec![LocalSkillMetadata {
603                name: "Build skill".to_string(),
604                description: "Run and fix build failures".to_string(),
605                cwd: "/Users/dev/project".to_string(),
606            }],
607        };
608        let empty = ClientMessage::UpdateLocalSkills { skills: vec![] };
609
610        let full_json = serde_json::to_value(&full).expect("serialize full");
611        let empty_json = serde_json::to_value(&empty).expect("serialize empty");
612
613        let full_back: ClientMessage = serde_json::from_value(full_json).expect("deserialize full");
614        let empty_back: ClientMessage =
615            serde_json::from_value(empty_json).expect("deserialize empty");
616
617        match full_back {
618            ClientMessage::UpdateLocalSkills { skills } => {
619                assert_eq!(skills.len(), 1);
620                assert_eq!(skills[0].name, "Build skill");
621                assert_eq!(skills[0].description, "Run and fix build failures");
622                assert_eq!(skills[0].cwd, "/Users/dev/project");
623            }
624            _ => panic!("expected UpdateLocalSkills"),
625        }
626
627        match empty_back {
628            ClientMessage::UpdateLocalSkills { skills } => {
629                assert!(skills.is_empty());
630            }
631            _ => panic!("expected UpdateLocalSkills"),
632        }
633    }
634
635    #[test]
636    fn update_agents_mds_round_trip_full_and_empty() {
637        let full = ClientMessage::UpdateAgentsMds {
638            agents_mds: vec![AgentsMdMetadata {
639                cwd: "/Users/dev/project".to_string(),
640                content: "Follow the repository rules first.\nThen run tests.".to_string(),
641            }],
642        };
643        let empty = ClientMessage::UpdateAgentsMds { agents_mds: vec![] };
644
645        let full_json = serde_json::to_value(&full).expect("serialize full");
646        let empty_json = serde_json::to_value(&empty).expect("serialize empty");
647
648        let full_back: ClientMessage = serde_json::from_value(full_json).expect("deserialize full");
649        let empty_back: ClientMessage =
650            serde_json::from_value(empty_json).expect("deserialize empty");
651
652        match full_back {
653            ClientMessage::UpdateAgentsMds { agents_mds } => {
654                assert_eq!(agents_mds.len(), 1);
655                assert_eq!(agents_mds[0].cwd, "/Users/dev/project");
656                assert_eq!(
657                    agents_mds[0].content,
658                    "Follow the repository rules first.\nThen run tests."
659                );
660            }
661            _ => panic!("expected UpdateAgentsMds"),
662        }
663
664        match empty_back {
665            ClientMessage::UpdateAgentsMds { agents_mds } => {
666                assert!(agents_mds.is_empty());
667            }
668            _ => panic!("expected UpdateAgentsMds"),
669        }
670    }
671
672    #[test]
673    fn resolve_subagent_escalation_approved_round_trip() {
674        let msg = ClientMessage::ResolveSubagentEscalation {
675            parent_message_id: Uuid::nil(),
676            subagent_run_id: Uuid::nil(),
677            escalation_id: "esc-0".to_string(),
678            resolution: SubagentEscalationResolution::Approved,
679        };
680
681        let value = serde_json::to_value(&msg).expect("serialize");
682        let back: ClientMessage = serde_json::from_value(value).expect("deserialize");
683        match back {
684            ClientMessage::ResolveSubagentEscalation {
685                escalation_id,
686                resolution: SubagentEscalationResolution::Approved,
687                ..
688            } => assert_eq!(escalation_id, "esc-0"),
689            _ => panic!("expected ResolveSubagentEscalation::Approved"),
690        }
691    }
692
693    #[test]
694    fn resolve_subagent_escalation_rejected_round_trip() {
695        let msg = ClientMessage::ResolveSubagentEscalation {
696            parent_message_id: Uuid::nil(),
697            subagent_run_id: Uuid::nil(),
698            escalation_id: "esc-1".to_string(),
699            resolution: SubagentEscalationResolution::Rejected {
700                reason: Some("not now".to_string()),
701            },
702        };
703
704        let value = serde_json::to_value(&msg).expect("serialize");
705        let body = value
706            .get("ResolveSubagentEscalation")
707            .and_then(|v| v.as_object())
708            .expect("ResolveSubagentEscalation body");
709        assert_eq!(body.get("escalation_id"), Some(&json!("esc-1")));
710
711        let back: ClientMessage = serde_json::from_value(value).expect("deserialize");
712        match back {
713            ClientMessage::ResolveSubagentEscalation {
714                resolution:
715                    SubagentEscalationResolution::Rejected {
716                        reason: Some(reason),
717                    },
718                ..
719            } => assert_eq!(reason, "not now"),
720            _ => panic!("expected ResolveSubagentEscalation::Rejected"),
721        }
722    }
723
724    #[test]
725    fn resolve_subagent_escalation_resolved_with_output_round_trip() {
726        let msg = ClientMessage::ResolveSubagentEscalation {
727            parent_message_id: Uuid::nil(),
728            subagent_run_id: Uuid::nil(),
729            escalation_id: "esc-2".to_string(),
730            resolution: SubagentEscalationResolution::ResolvedWithOutput {
731                is_error: false,
732                output: "ok".to_string(),
733                duration_seconds: Some(3),
734            },
735        };
736
737        let value = serde_json::to_value(&msg).expect("serialize");
738        let back: ClientMessage = serde_json::from_value(value).expect("deserialize");
739        match back {
740            ClientMessage::ResolveSubagentEscalation {
741                escalation_id,
742                resolution:
743                    SubagentEscalationResolution::ResolvedWithOutput {
744                        is_error,
745                        output,
746                        duration_seconds,
747                    },
748                ..
749            } => {
750                assert_eq!(escalation_id, "esc-2");
751                assert!(!is_error);
752                assert_eq!(output, "ok");
753                assert_eq!(duration_seconds, Some(3));
754            }
755            _ => panic!("expected ResolveSubagentEscalation::ResolvedWithOutput"),
756        }
757    }
758}