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