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