Skip to main content

scv_protocol/
lib.rs

1//! Dependency-light wire types shared by SCV clients and the server.
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6pub const PROTOCOL_VERSION: u32 = 2;
7
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
9#[serde(rename_all = "snake_case")]
10pub enum ComponentState {
11    Disabled,
12    Starting,
13    Connected,
14    Disconnected,
15    Backoff,
16    Stopping,
17    Stopped,
18    Failed,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
22pub struct ComponentHealth {
23    pub id: String,
24    pub account: String,
25    pub bot_id: Option<String>,
26    pub user_id: Option<String>,
27    pub enabled: bool,
28    pub state: ComponentState,
29    pub last_success_unix_seconds: Option<u64>,
30    pub error: Option<String>,
31    pub restarts: u64,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
35pub struct DaemonStatus {
36    pub version: String,
37    pub pid: u32,
38    pub components: Vec<ComponentHealth>,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
42#[serde(tag = "action", rename_all = "snake_case")]
43pub enum DaemonCommand {
44    Status,
45    Reload,
46    ClawbotSet {
47        account: String,
48        enabled: bool,
49        workspace: Option<String>,
50    },
51    ClawbotLogout {
52        account: String,
53    },
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
57pub struct QueueEntry {
58    pub queue_id: String,
59    pub revision: u64,
60    pub prompt: String,
61    pub submitter: String,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
65pub struct PeerInfo {
66    pub name: String,
67    pub version: String,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
71pub struct Usage {
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub input_tokens: Option<u64>,
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub output_tokens: Option<u64>,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
79#[serde(tag = "type")]
80pub enum ClientMessage {
81    #[serde(rename = "daemon.control")]
82    DaemonControl {
83        request_id: String,
84        command: DaemonCommand,
85    },
86    #[serde(rename = "initialize")]
87    Initialize {
88        request_id: String,
89        protocol_version: u32,
90        client: PeerInfo,
91    },
92    #[serde(rename = "session.start")]
93    SessionStart {
94        request_id: String,
95        cwd: String,
96        #[serde(default, skip_serializing_if = "Option::is_none")]
97        provider: Option<String>,
98        #[serde(default, skip_serializing_if = "Option::is_none")]
99        model: Option<String>,
100        #[serde(default, skip_serializing_if = "Option::is_none")]
101        base_url: Option<String>,
102        #[serde(default, skip_serializing_if = "Option::is_none")]
103        no_tools: Option<bool>,
104    },
105    #[serde(rename = "session.attach")]
106    SessionAttach {
107        request_id: String,
108        session_id: String,
109        cwd: String,
110    },
111    #[serde(rename = "turn.start")]
112    TurnStart {
113        request_id: String,
114        session_id: String,
115        prompt: String,
116    },
117    #[serde(rename = "queue.update")]
118    QueueUpdate {
119        request_id: String,
120        session_id: String,
121        queue_id: String,
122        revision: u64,
123        prompt: String,
124    },
125    #[serde(rename = "queue.move")]
126    QueueMove {
127        request_id: String,
128        session_id: String,
129        queue_id: String,
130        revision: u64,
131        before_queue_id: Option<String>,
132    },
133    #[serde(rename = "queue.remove")]
134    QueueRemove {
135        request_id: String,
136        session_id: String,
137        queue_id: String,
138        revision: u64,
139    },
140    #[serde(rename = "session.pause")]
141    SessionPause {
142        request_id: String,
143        session_id: String,
144        paused: bool,
145    },
146    #[serde(rename = "turn.cancel")]
147    TurnCancel {
148        request_id: String,
149        session_id: String,
150        turn_id: String,
151    },
152    #[serde(rename = "approval.resolve")]
153    ApprovalResolve {
154        request_id: String,
155        session_id: String,
156        approval_id: String,
157        approved: bool,
158    },
159    #[serde(rename = "session.clear")]
160    SessionClear {
161        request_id: String,
162        session_id: String,
163    },
164}
165
166impl ClientMessage {
167    pub fn request_id(&self) -> &str {
168        match self {
169            Self::Initialize { request_id, .. }
170            | Self::DaemonControl { request_id, .. }
171            | Self::SessionStart { request_id, .. }
172            | Self::SessionAttach { request_id, .. }
173            | Self::TurnStart { request_id, .. }
174            | Self::QueueUpdate { request_id, .. }
175            | Self::QueueMove { request_id, .. }
176            | Self::QueueRemove { request_id, .. }
177            | Self::SessionPause { request_id, .. }
178            | Self::TurnCancel { request_id, .. }
179            | Self::ApprovalResolve { request_id, .. }
180            | Self::SessionClear { request_id, .. } => request_id,
181        }
182    }
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
186#[serde(tag = "type")]
187pub enum ServerEvent {
188    #[serde(rename = "daemon.status")]
189    DaemonStatus {
190        request_id: String,
191        status: DaemonStatus,
192    },
193    #[serde(rename = "initialized")]
194    Initialized {
195        request_id: String,
196        protocol_version: u32,
197        server: PeerInfo,
198    },
199    #[serde(rename = "session.started")]
200    SessionStarted {
201        request_id: String,
202        session_id: String,
203        cwd: String,
204        model: String,
205        context_max_tokens: usize,
206        max_server_frame_bytes: usize,
207        max_transcript_bytes: usize,
208        max_transcript_items: usize,
209        max_prompt_history_bytes: usize,
210        max_prompt_history_items: usize,
211    },
212    #[serde(rename = "queue.snapshot")]
213    QueueSnapshot {
214        request_id: Option<String>,
215        session_id: String,
216        seq: u64,
217        entries: Vec<QueueEntry>,
218        paused: bool,
219    },
220    #[serde(rename = "queue.enqueued")]
221    QueueEnqueued {
222        request_id: String,
223        session_id: String,
224        seq: u64,
225        entry: QueueEntry,
226        position: usize,
227    },
228    #[serde(rename = "queue.updated")]
229    QueueUpdated {
230        request_id: String,
231        session_id: String,
232        seq: u64,
233        entry: QueueEntry,
234    },
235    #[serde(rename = "queue.moved")]
236    QueueMoved {
237        request_id: String,
238        session_id: String,
239        seq: u64,
240        queue_id: String,
241        position: usize,
242        revision: u64,
243    },
244    #[serde(rename = "queue.removed")]
245    QueueRemoved {
246        request_id: String,
247        session_id: String,
248        seq: u64,
249        queue_id: String,
250        revision: u64,
251    },
252    #[serde(rename = "queue.dequeued")]
253    QueueDequeued {
254        request_id: String,
255        session_id: String,
256        seq: u64,
257        queue_id: String,
258        turn_id: String,
259    },
260    #[serde(rename = "session.paused")]
261    SessionPaused {
262        request_id: String,
263        session_id: String,
264        seq: u64,
265        paused: bool,
266    },
267    #[serde(rename = "turn.started")]
268    TurnStarted {
269        request_id: String,
270        session_id: String,
271        turn_id: String,
272        seq: u64,
273    },
274    #[serde(rename = "assistant.delta")]
275    AssistantDelta {
276        request_id: String,
277        session_id: String,
278        turn_id: String,
279        seq: u64,
280        content: String,
281    },
282    #[serde(rename = "assistant.completed")]
283    AssistantCompleted {
284        request_id: String,
285        session_id: String,
286        turn_id: String,
287        seq: u64,
288        content: String,
289    },
290    #[serde(rename = "tool.proposed")]
291    ToolProposed {
292        request_id: String,
293        session_id: String,
294        turn_id: String,
295        seq: u64,
296        call_id: String,
297        name: String,
298        arguments: Value,
299    },
300    #[serde(rename = "approval.requested")]
301    ApprovalRequested {
302        request_id: String,
303        session_id: String,
304        turn_id: String,
305        seq: u64,
306        approval_id: String,
307        call_id: String,
308        name: String,
309        risk: String,
310        cwd: String,
311        summary: String,
312    },
313    #[serde(rename = "tool.started")]
314    ToolStarted {
315        request_id: String,
316        session_id: String,
317        turn_id: String,
318        seq: u64,
319        call_id: String,
320        name: String,
321    },
322    #[serde(rename = "tool.completed")]
323    ToolCompleted {
324        request_id: String,
325        session_id: String,
326        turn_id: String,
327        seq: u64,
328        call_id: String,
329        name: String,
330        success: bool,
331        output: String,
332        truncated: bool,
333    },
334    #[serde(rename = "context.compacted")]
335    ContextCompacted {
336        request_id: String,
337        session_id: String,
338        turn_id: String,
339        seq: u64,
340        before_tokens: usize,
341        after_tokens: usize,
342        removed_messages: usize,
343    },
344    #[serde(rename = "session.trimmed")]
345    SessionTrimmed {
346        request_id: String,
347        session_id: String,
348        seq: u64,
349        removed_messages: usize,
350        history_bytes: usize,
351    },
352    #[serde(rename = "session.cleared")]
353    SessionCleared {
354        request_id: String,
355        session_id: String,
356        seq: u64,
357    },
358    #[serde(rename = "turn.completed")]
359    TurnCompleted {
360        request_id: String,
361        session_id: String,
362        turn_id: String,
363        seq: u64,
364        steps: usize,
365        usage: Usage,
366    },
367    #[serde(rename = "turn.cancelled")]
368    TurnCancelled {
369        request_id: String,
370        session_id: String,
371        turn_id: String,
372        seq: u64,
373    },
374    #[serde(rename = "turn.failed")]
375    TurnFailed {
376        request_id: String,
377        session_id: String,
378        turn_id: String,
379        seq: u64,
380        code: String,
381        message: String,
382    },
383    #[serde(rename = "error")]
384    Error {
385        #[serde(skip_serializing_if = "Option::is_none")]
386        request_id: Option<String>,
387        code: String,
388        message: String,
389        fatal: bool,
390    },
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    #[test]
398    fn client_message_round_trip() {
399        let message = ClientMessage::TurnStart {
400            request_id: "3".into(),
401            session_id: "session".into(),
402            prompt: "hello".into(),
403        };
404        let json = serde_json::to_string(&message).unwrap();
405        assert!(json.contains("\"type\":\"turn.start\""));
406        assert_eq!(
407            serde_json::from_str::<ClientMessage>(&json).unwrap(),
408            message
409        );
410    }
411
412    #[test]
413    fn additive_fields_are_ignored() {
414        let json = r#"{"type":"session.clear","request_id":"1","session_id":"s","future":true}"#;
415        assert!(matches!(
416            serde_json::from_str::<ClientMessage>(json).unwrap(),
417            ClientMessage::SessionClear { .. }
418        ));
419    }
420
421    #[test]
422    fn event_round_trip() {
423        let event = ServerEvent::AssistantDelta {
424            request_id: "1".into(),
425            session_id: "s".into(),
426            turn_id: "t".into(),
427            seq: 4,
428            content: "hello".into(),
429        };
430        let encoded = serde_json::to_string(&event).unwrap();
431        assert_eq!(
432            serde_json::from_str::<ServerEvent>(&encoded).unwrap(),
433            event
434        );
435    }
436
437    #[test]
438    fn queue_messages_and_events_round_trip() {
439        let message = ClientMessage::QueueMove {
440            request_id: "q1".into(),
441            session_id: "s".into(),
442            queue_id: "q".into(),
443            revision: 2,
444            before_queue_id: None,
445        };
446        let encoded = serde_json::to_string(&message).unwrap();
447        assert_eq!(
448            serde_json::from_str::<ClientMessage>(&encoded).unwrap(),
449            message
450        );
451        let event = ServerEvent::QueueSnapshot {
452            request_id: None,
453            session_id: "s".into(),
454            seq: 4,
455            entries: vec![QueueEntry {
456                queue_id: "q".into(),
457                revision: 1,
458                prompt: "hello".into(),
459                submitter: "cli".into(),
460            }],
461            paused: false,
462        };
463        let encoded = serde_json::to_string(&event).unwrap();
464        assert_eq!(
465            serde_json::from_str::<ServerEvent>(&encoded).unwrap(),
466            event
467        );
468    }
469}