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
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub enum ClientMessage {
92    HelloMath {
93        /// Random per-client-instance id (generated on client startup).
94        ///
95        /// Used for multi-client tool-call claiming/coordination. Not derived from auth token.
96        client_instance_id: String,
97        /// Client version.
98        version: String,
99        /// Minimum supported server version (the client can work with any server >= this).
100        min_supported_version: String,
101        /// Optional thread root message id used by reconnecting clients to restore active thread state.
102        #[serde(default, skip_serializing_if = "Option::is_none")]
103        resume_thread_id: Option<Uuid>,
104    },
105    /// Send a user message.
106    ///
107    /// If `thread_id` is `None`, the server creates a new thread and returns it in `SendMessageAck`.
108    /// `request_id` is a client-generated correlation id for 1:1 request↔response mapping.
109    SendMessage {
110        request_id: Uuid,
111        thread_id: Option<Uuid>,
112        text: String,
113        /// Optional runtime mode update to apply to the thread before generation.
114        #[serde(default, skip_serializing_if = "Option::is_none")]
115        runtime_mode: Option<RuntimeMode>,
116        /// Optional model override update to apply to the thread before generation.
117        ///
118        /// - `None`: do not change current override.
119        /// - `Some(value)`: set override to `value`.
120        #[serde(default, skip_serializing_if = "Option::is_none")]
121        model_override: Option<ThreadModelOverride>,
122    },
123    /// Update/refresh auth token without reconnecting the WebSocket.
124    UpdateAuthToken {
125        token: String,
126    },
127    /// Update the client's workspace roots / default root (used for prompt context + validations).
128    ///
129    /// Sent separately from `HelloMath` so roots can be updated without reconnecting.
130    UpdateWorkspaceRoots {
131        default_root: String,
132        workspace_roots: Vec<String>,
133    },
134    /// Replace the client-local skill snapshot exposed to the server for this connection.
135    UpdateLocalSkills {
136        skills: Vec<LocalSkillMetadata>,
137    },
138    RejectToolCall {
139        id: String,
140        #[serde(default, skip_serializing_if = "Option::is_none")]
141        reason: Option<String>,
142    },
143    AcceptToolCall {
144        id: String,
145    },
146    ResolveSubagentEscalation {
147        parent_message_id: Uuid,
148        subagent_run_id: Uuid,
149        escalation_id: String,
150        resolution: SubagentEscalationResolution,
151    },
152    ToolCallOutputs {
153        outputs: Vec<ToolCallOutput>,
154    },
155    /// Cancel the current in-progress generation for a specific assistant message.
156    CancelGeneration {
157        message_id: Uuid,
158    },
159}
160
161#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
162pub struct Usage {
163    pub input_tokens: i32,
164    pub output_tokens: i32,
165    #[serde(default)]
166    pub cache_read_input_tokens: i32,
167    #[serde(default)]
168    pub cache_creation_input_tokens: i32,
169    #[serde(default)]
170    pub cache_creation_input_tokens_5m: i32,
171    #[serde(default)]
172    pub cache_creation_input_tokens_1h: i32,
173}
174
175#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
176pub enum MessageStatus {
177    Completed,
178    /// The agent is waiting for the client/user to provide more input (e.g. tool outputs / approvals)
179    /// before it can continue generation.
180    WaitingForUser,
181    Failed,
182    Cancelled,
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
186pub enum ServerMessage {
187    HelloMagic {
188        version: String,
189        min_supported_version: String,
190    },
191    VersionMismatch {
192        server_version: String,
193        server_min_supported_version: String,
194    },
195    Goodbye {
196        reconnect: bool,
197    },
198    SendMessageAck {
199        request_id: Uuid,
200        thread_id: Uuid,
201        user_message_id: Uuid,
202    },
203    AuthUpdated,
204    RuntimeModeUpdated {
205        thread_id: Uuid,
206        mode: RuntimeMode,
207        #[serde(default, skip_serializing_if = "Option::is_none")]
208        changed_by_client_instance_id: Option<String>,
209    },
210    ThreadModelUpdated {
211        thread_id: Uuid,
212        #[serde(default, skip_serializing_if = "Option::is_none")]
213        model_override: Option<ThreadModelOverride>,
214        #[serde(default, skip_serializing_if = "Option::is_none")]
215        changed_by_client_instance_id: Option<String>,
216    },
217    MessageHeader {
218        message_id: Uuid,
219        thread_id: Uuid,
220        role: Role,
221        #[serde(default, skip_serializing_if = "Option::is_none")]
222        request_id: Option<Uuid>,
223    },
224    ReasoningDelta {
225        message_id: Uuid,
226        content: String,
227    },
228    TextDelta {
229        message_id: Uuid,
230        content: String,
231    },
232    ToolCallHeader {
233        message_id: Uuid,
234        tool_call_id: String,
235        name: String,
236        execution_target: ToolExecutionTarget,
237        approval: ToolCallApproval,
238    },
239    ToolCallArgumentsDelta {
240        message_id: Uuid,
241        tool_call_id: String,
242        delta: String,
243    },
244    ToolCall {
245        message_id: Uuid,
246        tool_call_id: String,
247        args: Value,
248    },
249    ToolCallResult {
250        message_id: Uuid,
251        tool_call_id: String,
252        is_error: bool,
253        output: String,
254        #[serde(default, skip_serializing_if = "Option::is_none")]
255        duration_seconds: Option<i32>,
256    },
257    ToolCallClaimed {
258        message_id: Uuid,
259        tool_call_id: String,
260        claimed_by_client_instance_id: String,
261    },
262    ToolCallApprovalUpdated {
263        message_id: Uuid,
264        tool_call_id: String,
265        approval: ToolCallApproval,
266    },
267    MessageDone {
268        message_id: Uuid,
269        #[serde(default, skip_serializing_if = "Option::is_none")]
270        usage: Option<Usage>,
271        status: MessageStatus,
272    },
273    Error {
274        #[serde(default, skip_serializing_if = "Option::is_none")]
275        request_id: Option<Uuid>,
276        #[serde(default, skip_serializing_if = "Option::is_none")]
277        message_id: Option<Uuid>,
278        code: String,
279        message: String,
280    },
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use serde_json::json;
287
288    #[test]
289    fn send_message_omits_optional_updates_when_not_set() {
290        let msg = ClientMessage::SendMessage {
291            request_id: Uuid::nil(),
292            thread_id: None,
293            text: "hello".to_string(),
294            runtime_mode: None,
295            model_override: None,
296        };
297
298        let value = serde_json::to_value(msg).expect("serialize");
299        let body = value
300            .get("SendMessage")
301            .and_then(|v| v.as_object())
302            .expect("SendMessage body");
303
304        assert!(body.get("runtime_mode").is_none());
305        assert!(body.get("model_override").is_none());
306    }
307
308    #[test]
309    fn hello_math_omits_resume_thread_id_when_not_set() {
310        let msg = ClientMessage::HelloMath {
311            client_instance_id: "client-a".to_string(),
312            version: "1.2.3".to_string(),
313            min_supported_version: "1.0.0".to_string(),
314            resume_thread_id: None,
315        };
316
317        let value = serde_json::to_value(msg).expect("serialize");
318        let body = value
319            .get("HelloMath")
320            .and_then(|v| v.as_object())
321            .expect("HelloMath body");
322
323        assert!(body.get("resume_thread_id").is_none());
324    }
325
326    #[test]
327    fn hello_math_round_trip_resume_thread_id() {
328        let thread_id = Uuid::new_v4();
329        let msg = ClientMessage::HelloMath {
330            client_instance_id: "client-a".to_string(),
331            version: "1.2.3".to_string(),
332            min_supported_version: "1.0.0".to_string(),
333            resume_thread_id: Some(thread_id),
334        };
335
336        let value = serde_json::to_value(&msg).expect("serialize");
337        let body = value
338            .get("HelloMath")
339            .and_then(|v| v.as_object())
340            .expect("HelloMath body");
341        assert_eq!(
342            body.get("resume_thread_id"),
343            Some(&serde_json::Value::String(thread_id.to_string()))
344        );
345
346        let back: ClientMessage = serde_json::from_value(value).expect("deserialize");
347        match back {
348            ClientMessage::HelloMath {
349                resume_thread_id, ..
350            } => assert_eq!(resume_thread_id, Some(thread_id)),
351            _ => panic!("expected HelloMath"),
352        }
353    }
354
355    #[test]
356    fn send_message_serializes_model_override_when_set() {
357        let msg = ClientMessage::SendMessage {
358            request_id: Uuid::nil(),
359            thread_id: Some(Uuid::nil()),
360            text: "hello".to_string(),
361            runtime_mode: Some(RuntimeMode::Plan),
362            model_override: Some(ThreadModelOverride {
363                model_id: Uuid::nil(),
364                model_config: ModelConfig::default(),
365            }),
366        };
367
368        let value = serde_json::to_value(msg).expect("serialize");
369        let body = value
370            .get("SendMessage")
371            .and_then(|v| v.as_object())
372            .expect("SendMessage body");
373
374        assert_eq!(body.get("runtime_mode"), Some(&json!("Plan")));
375        assert!(body.get("model_override").is_some());
376    }
377
378    #[test]
379    fn send_message_deserializes_model_override_states() {
380        let set_json = json!({
381            "SendMessage": {
382                "request_id": Uuid::nil(),
383                "thread_id": Uuid::nil(),
384                "text": "hello",
385                "runtime_mode": "Agent",
386                "model_override": {
387                    "model_id": Uuid::nil(),
388                    "model_config": {}
389                }
390            }
391        });
392        let keep_json = json!({
393            "SendMessage": {
394                "request_id": Uuid::nil(),
395                "thread_id": Uuid::nil(),
396                "text": "hello"
397            }
398        });
399
400        let set_msg: ClientMessage = serde_json::from_value(set_json).expect("deserialize set");
401        let keep_msg: ClientMessage = serde_json::from_value(keep_json).expect("deserialize keep");
402
403        match set_msg {
404            ClientMessage::SendMessage {
405                runtime_mode,
406                model_override,
407                ..
408            } => {
409                assert_eq!(runtime_mode, Some(RuntimeMode::Agent));
410                assert!(model_override.is_some());
411            }
412            _ => panic!("expected SendMessage"),
413        }
414
415        match keep_msg {
416            ClientMessage::SendMessage { model_override, .. } => {
417                assert_eq!(model_override, None);
418            }
419            _ => panic!("expected SendMessage"),
420        }
421    }
422
423    #[test]
424    fn update_local_skills_round_trip_full_and_empty() {
425        let full = ClientMessage::UpdateLocalSkills {
426            skills: vec![LocalSkillMetadata {
427                name: "Build skill".to_string(),
428                description: "Run and fix build failures".to_string(),
429                cwd: "/Users/dev/project".to_string(),
430            }],
431        };
432        let empty = ClientMessage::UpdateLocalSkills { skills: vec![] };
433
434        let full_json = serde_json::to_value(&full).expect("serialize full");
435        let empty_json = serde_json::to_value(&empty).expect("serialize empty");
436
437        let full_back: ClientMessage = serde_json::from_value(full_json).expect("deserialize full");
438        let empty_back: ClientMessage =
439            serde_json::from_value(empty_json).expect("deserialize empty");
440
441        match full_back {
442            ClientMessage::UpdateLocalSkills { skills } => {
443                assert_eq!(skills.len(), 1);
444                assert_eq!(skills[0].name, "Build skill");
445                assert_eq!(skills[0].description, "Run and fix build failures");
446                assert_eq!(skills[0].cwd, "/Users/dev/project");
447            }
448            _ => panic!("expected UpdateLocalSkills"),
449        }
450
451        match empty_back {
452            ClientMessage::UpdateLocalSkills { skills } => {
453                assert!(skills.is_empty());
454            }
455            _ => panic!("expected UpdateLocalSkills"),
456        }
457    }
458
459    #[test]
460    fn resolve_subagent_escalation_rejected_round_trip() {
461        let msg = ClientMessage::ResolveSubagentEscalation {
462            parent_message_id: Uuid::nil(),
463            subagent_run_id: Uuid::nil(),
464            escalation_id: "esc-1".to_string(),
465            resolution: SubagentEscalationResolution::Rejected {
466                reason: Some("not now".to_string()),
467            },
468        };
469
470        let value = serde_json::to_value(&msg).expect("serialize");
471        let body = value
472            .get("ResolveSubagentEscalation")
473            .and_then(|v| v.as_object())
474            .expect("ResolveSubagentEscalation body");
475        assert_eq!(body.get("escalation_id"), Some(&json!("esc-1")));
476
477        let back: ClientMessage = serde_json::from_value(value).expect("deserialize");
478        match back {
479            ClientMessage::ResolveSubagentEscalation {
480                resolution:
481                    SubagentEscalationResolution::Rejected {
482                        reason: Some(reason),
483                    },
484                ..
485            } => assert_eq!(reason, "not now"),
486            _ => panic!("expected ResolveSubagentEscalation::Rejected"),
487        }
488    }
489
490    #[test]
491    fn resolve_subagent_escalation_resolved_with_output_round_trip() {
492        let msg = ClientMessage::ResolveSubagentEscalation {
493            parent_message_id: Uuid::nil(),
494            subagent_run_id: Uuid::nil(),
495            escalation_id: "esc-2".to_string(),
496            resolution: SubagentEscalationResolution::ResolvedWithOutput {
497                is_error: false,
498                output: "ok".to_string(),
499                duration_seconds: Some(3),
500            },
501        };
502
503        let value = serde_json::to_value(&msg).expect("serialize");
504        let back: ClientMessage = serde_json::from_value(value).expect("deserialize");
505        match back {
506            ClientMessage::ResolveSubagentEscalation {
507                escalation_id,
508                resolution:
509                    SubagentEscalationResolution::ResolvedWithOutput {
510                        is_error,
511                        output,
512                        duration_seconds,
513                    },
514                ..
515            } => {
516                assert_eq!(escalation_id, "esc-2");
517                assert!(!is_error);
518                assert_eq!(output, "ok");
519                assert_eq!(duration_seconds, Some(3));
520            }
521            _ => panic!("expected ResolveSubagentEscalation::ResolvedWithOutput"),
522        }
523    }
524}