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 struct LocalSkillMetadata {
70    pub skill_id: String,
71    pub name: String,
72    pub description: String,
73    pub cwd: String,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub enum ClientMessage {
78    HelloMath {
79        /// Random per-client-instance id (generated on client startup).
80        ///
81        /// Used for multi-client tool-call claiming/coordination. Not derived from auth token.
82        client_instance_id: String,
83        /// Client version.
84        version: String,
85        /// Minimum supported server version (the client can work with any server >= this).
86        min_supported_version: String,
87    },
88    /// Send a user message.
89    ///
90    /// If `thread_id` is `None`, the server creates a new thread and returns it in `SendMessageAck`.
91    /// `request_id` is a client-generated correlation id for 1:1 request↔response mapping.
92    SendMessage {
93        request_id: Uuid,
94        thread_id: Option<Uuid>,
95        text: String,
96        /// Optional runtime mode update to apply to the thread before generation.
97        #[serde(default, skip_serializing_if = "Option::is_none")]
98        runtime_mode: Option<RuntimeMode>,
99        /// Optional model override update to apply to the thread before generation.
100        ///
101        /// - `None`: do not change current override.
102        /// - `Some(value)`: set override to `value`.
103        #[serde(default, skip_serializing_if = "Option::is_none")]
104        model_override: Option<ThreadModelOverride>,
105    },
106    /// Update/refresh auth token without reconnecting the WebSocket.
107    UpdateAuthToken {
108        token: String,
109    },
110    /// Update the client's workspace roots / default root (used for prompt context + validations).
111    ///
112    /// Sent separately from `HelloMath` so roots can be updated without reconnecting.
113    UpdateWorkspaceRoots {
114        default_root: String,
115        workspace_roots: Vec<String>,
116    },
117    /// Replace the client-local skill snapshot exposed to the server for this connection.
118    UpdateLocalSkills {
119        skills: Vec<LocalSkillMetadata>,
120    },
121    RejectToolCall {
122        id: String,
123        #[serde(default, skip_serializing_if = "Option::is_none")]
124        reason: Option<String>,
125    },
126    AcceptToolCall {
127        id: String,
128    },
129    ToolCallOutputs {
130        outputs: Vec<ToolCallOutput>,
131    },
132    /// Cancel the current in-progress generation for a specific assistant message.
133    CancelGeneration {
134        message_id: Uuid,
135    },
136}
137
138#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
139pub struct Usage {
140    pub input_tokens: i32,
141    pub output_tokens: i32,
142    #[serde(default)]
143    pub cache_read_input_tokens: i32,
144    #[serde(default)]
145    pub cache_creation_input_tokens: i32,
146    #[serde(default)]
147    pub cache_creation_input_tokens_5m: i32,
148    #[serde(default)]
149    pub cache_creation_input_tokens_1h: i32,
150}
151
152#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
153pub enum MessageStatus {
154    Completed,
155    /// The agent is waiting for the client/user to provide more input (e.g. tool outputs / approvals)
156    /// before it can continue generation.
157    WaitingForUser,
158    Failed,
159    Cancelled,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub enum ServerMessage {
164    HelloMagic {
165        version: String,
166        min_supported_version: String,
167    },
168    VersionMismatch {
169        server_version: String,
170        server_min_supported_version: String,
171    },
172    Goodbye {
173        reconnect: bool,
174    },
175    SendMessageAck {
176        request_id: Uuid,
177        thread_id: Uuid,
178        user_message_id: Uuid,
179    },
180    AuthUpdated,
181    RuntimeModeUpdated {
182        thread_id: Uuid,
183        mode: RuntimeMode,
184        #[serde(default, skip_serializing_if = "Option::is_none")]
185        changed_by_client_instance_id: Option<String>,
186    },
187    ThreadModelUpdated {
188        thread_id: Uuid,
189        #[serde(default, skip_serializing_if = "Option::is_none")]
190        model_override: Option<ThreadModelOverride>,
191        #[serde(default, skip_serializing_if = "Option::is_none")]
192        changed_by_client_instance_id: Option<String>,
193    },
194    MessageHeader {
195        message_id: Uuid,
196        thread_id: Uuid,
197        role: Role,
198        #[serde(default, skip_serializing_if = "Option::is_none")]
199        request_id: Option<Uuid>,
200    },
201    ReasoningDelta {
202        message_id: Uuid,
203        content: String,
204    },
205    TextDelta {
206        message_id: Uuid,
207        content: String,
208    },
209    ToolCallHeader {
210        message_id: Uuid,
211        tool_call_id: String,
212        name: String,
213        execution_target: ToolExecutionTarget,
214        approval: ToolCallApproval,
215    },
216    ToolCallArgumentsDelta {
217        message_id: Uuid,
218        tool_call_id: String,
219        delta: String,
220    },
221    ToolCall {
222        message_id: Uuid,
223        tool_call_id: String,
224        args: Value,
225    },
226    ToolCallResult {
227        message_id: Uuid,
228        tool_call_id: String,
229        is_error: bool,
230        output: String,
231        #[serde(default, skip_serializing_if = "Option::is_none")]
232        duration_seconds: Option<i32>,
233    },
234    ToolCallClaimed {
235        message_id: Uuid,
236        tool_call_id: String,
237        claimed_by_client_instance_id: String,
238    },
239    ToolCallApprovalUpdated {
240        message_id: Uuid,
241        tool_call_id: String,
242        approval: ToolCallApproval,
243    },
244    MessageDone {
245        message_id: Uuid,
246        #[serde(default, skip_serializing_if = "Option::is_none")]
247        usage: Option<Usage>,
248        status: MessageStatus,
249    },
250    Error {
251        #[serde(default, skip_serializing_if = "Option::is_none")]
252        request_id: Option<Uuid>,
253        #[serde(default, skip_serializing_if = "Option::is_none")]
254        message_id: Option<Uuid>,
255        code: String,
256        message: String,
257    },
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use serde_json::json;
264
265    #[test]
266    fn send_message_omits_optional_updates_when_not_set() {
267        let msg = ClientMessage::SendMessage {
268            request_id: Uuid::nil(),
269            thread_id: None,
270            text: "hello".to_string(),
271            runtime_mode: None,
272            model_override: None,
273        };
274
275        let value = serde_json::to_value(msg).expect("serialize");
276        let body = value
277            .get("SendMessage")
278            .and_then(|v| v.as_object())
279            .expect("SendMessage body");
280
281        assert!(body.get("runtime_mode").is_none());
282        assert!(body.get("model_override").is_none());
283    }
284
285    #[test]
286    fn send_message_serializes_model_override_when_set() {
287        let msg = ClientMessage::SendMessage {
288            request_id: Uuid::nil(),
289            thread_id: Some(Uuid::nil()),
290            text: "hello".to_string(),
291            runtime_mode: Some(RuntimeMode::Plan),
292            model_override: Some(ThreadModelOverride {
293                model_id: Uuid::nil(),
294                model_config: ModelConfig::default(),
295            }),
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_eq!(body.get("runtime_mode"), Some(&json!("Plan")));
305        assert!(body.get("model_override").is_some());
306    }
307
308    #[test]
309    fn send_message_deserializes_model_override_states() {
310        let set_json = json!({
311            "SendMessage": {
312                "request_id": Uuid::nil(),
313                "thread_id": Uuid::nil(),
314                "text": "hello",
315                "runtime_mode": "Agent",
316                "model_override": {
317                    "model_id": Uuid::nil(),
318                    "model_config": {}
319                }
320            }
321        });
322        let keep_json = json!({
323            "SendMessage": {
324                "request_id": Uuid::nil(),
325                "thread_id": Uuid::nil(),
326                "text": "hello"
327            }
328        });
329
330        let set_msg: ClientMessage = serde_json::from_value(set_json).expect("deserialize set");
331        let keep_msg: ClientMessage = serde_json::from_value(keep_json).expect("deserialize keep");
332
333        match set_msg {
334            ClientMessage::SendMessage {
335                runtime_mode,
336                model_override,
337                ..
338            } => {
339                assert_eq!(runtime_mode, Some(RuntimeMode::Agent));
340                assert!(model_override.is_some());
341            }
342            _ => panic!("expected SendMessage"),
343        }
344
345        match keep_msg {
346            ClientMessage::SendMessage { model_override, .. } => {
347                assert_eq!(model_override, None);
348            }
349            _ => panic!("expected SendMessage"),
350        }
351    }
352
353    #[test]
354    fn update_local_skills_round_trip_full_and_empty() {
355        let full = ClientMessage::UpdateLocalSkills {
356            skills: vec![LocalSkillMetadata {
357                skill_id: "ls-abc123".to_string(),
358                name: "Build skill".to_string(),
359                description: "Run and fix build failures".to_string(),
360                cwd: "/Users/dev/project".to_string(),
361            }],
362        };
363        let empty = ClientMessage::UpdateLocalSkills { skills: vec![] };
364
365        let full_json = serde_json::to_value(&full).expect("serialize full");
366        let empty_json = serde_json::to_value(&empty).expect("serialize empty");
367
368        let full_back: ClientMessage = serde_json::from_value(full_json).expect("deserialize full");
369        let empty_back: ClientMessage =
370            serde_json::from_value(empty_json).expect("deserialize empty");
371
372        match full_back {
373            ClientMessage::UpdateLocalSkills { skills } => {
374                assert_eq!(skills.len(), 1);
375                assert_eq!(skills[0].skill_id, "ls-abc123");
376                assert_eq!(skills[0].name, "Build skill");
377                assert_eq!(skills[0].description, "Run and fix build failures");
378                assert_eq!(skills[0].cwd, "/Users/dev/project");
379            }
380            _ => panic!("expected UpdateLocalSkills"),
381        }
382
383        match empty_back {
384            ClientMessage::UpdateLocalSkills { skills } => {
385                assert!(skills.is_empty());
386            }
387            _ => panic!("expected UpdateLocalSkills"),
388        }
389    }
390}