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