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