Skip to main content

magic_coder_types/protocol/
types.rs

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