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