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