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