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