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