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)]
9pub struct QueueEntry {
10    pub queue_id: String,
11    pub revision: u64,
12    pub prompt: String,
13    pub submitter: String,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17pub struct PeerInfo {
18    pub name: String,
19    pub version: String,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
23pub struct Usage {
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub input_tokens: Option<u64>,
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub output_tokens: Option<u64>,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
31#[serde(tag = "type")]
32pub enum ClientMessage {
33    #[serde(rename = "initialize")]
34    Initialize {
35        request_id: String,
36        protocol_version: u32,
37        client: PeerInfo,
38    },
39    #[serde(rename = "session.start")]
40    SessionStart {
41        request_id: String,
42        cwd: String,
43        #[serde(default, skip_serializing_if = "Option::is_none")]
44        provider: Option<String>,
45        #[serde(default, skip_serializing_if = "Option::is_none")]
46        model: Option<String>,
47        #[serde(default, skip_serializing_if = "Option::is_none")]
48        base_url: Option<String>,
49    },
50    #[serde(rename = "session.attach")]
51    SessionAttach { request_id: String, session_id: String, cwd: String },
52    #[serde(rename = "turn.start")]
53    TurnStart {
54        request_id: String,
55        session_id: String,
56        prompt: String,
57    },
58    #[serde(rename = "queue.update")]
59    QueueUpdate { request_id: String, session_id: String, queue_id: String, revision: u64, prompt: String },
60    #[serde(rename = "queue.move")]
61    QueueMove { request_id: String, session_id: String, queue_id: String, revision: u64, before_queue_id: Option<String> },
62    #[serde(rename = "queue.remove")]
63    QueueRemove { request_id: String, session_id: String, queue_id: String, revision: u64 },
64    #[serde(rename = "session.pause")]
65    SessionPause { request_id: String, session_id: String, paused: bool },
66    #[serde(rename = "turn.cancel")]
67    TurnCancel {
68        request_id: String,
69        session_id: String,
70        turn_id: String,
71    },
72    #[serde(rename = "approval.resolve")]
73    ApprovalResolve {
74        request_id: String,
75        session_id: String,
76        approval_id: String,
77        approved: bool,
78    },
79    #[serde(rename = "session.clear")]
80    SessionClear {
81        request_id: String,
82        session_id: String,
83    },
84}
85
86impl ClientMessage {
87    pub fn request_id(&self) -> &str {
88        match self {
89            Self::Initialize { request_id, .. }
90            | Self::SessionStart { request_id, .. }
91            | Self::SessionAttach { request_id, .. }
92            | Self::TurnStart { request_id, .. }
93            | Self::QueueUpdate { request_id, .. }
94            | Self::QueueMove { request_id, .. }
95            | Self::QueueRemove { request_id, .. }
96            | Self::SessionPause { request_id, .. }
97            | Self::TurnCancel { request_id, .. }
98            | Self::ApprovalResolve { request_id, .. }
99            | Self::SessionClear { request_id, .. } => request_id,
100        }
101    }
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
105#[serde(tag = "type")]
106pub enum ServerEvent {
107    #[serde(rename = "initialized")]
108    Initialized {
109        request_id: String,
110        protocol_version: u32,
111        server: PeerInfo,
112    },
113    #[serde(rename = "session.started")]
114    SessionStarted {
115        request_id: String,
116        session_id: String,
117        cwd: String,
118        model: String,
119        context_max_tokens: usize,
120        max_server_frame_bytes: usize,
121        max_transcript_bytes: usize,
122        max_transcript_items: usize,
123        max_prompt_history_bytes: usize,
124        max_prompt_history_items: usize,
125    },
126    #[serde(rename = "queue.snapshot")]
127    QueueSnapshot { request_id: Option<String>, session_id: String, seq: u64, entries: Vec<QueueEntry>, paused: bool },
128    #[serde(rename = "queue.enqueued")]
129    QueueEnqueued { request_id: String, session_id: String, seq: u64, entry: QueueEntry, position: usize },
130    #[serde(rename = "queue.updated")]
131    QueueUpdated { request_id: String, session_id: String, seq: u64, entry: QueueEntry },
132    #[serde(rename = "queue.moved")]
133    QueueMoved { request_id: String, session_id: String, seq: u64, queue_id: String, position: usize, revision: u64 },
134    #[serde(rename = "queue.removed")]
135    QueueRemoved { request_id: String, session_id: String, seq: u64, queue_id: String, revision: u64 },
136    #[serde(rename = "queue.dequeued")]
137    QueueDequeued { request_id: String, session_id: String, seq: u64, queue_id: String, turn_id: String },
138    #[serde(rename = "session.paused")]
139    SessionPaused { request_id: String, session_id: String, seq: u64, paused: bool },
140    #[serde(rename = "turn.started")]
141    TurnStarted {
142        request_id: String,
143        session_id: String,
144        turn_id: String,
145        seq: u64,
146    },
147    #[serde(rename = "assistant.delta")]
148    AssistantDelta {
149        request_id: String,
150        session_id: String,
151        turn_id: String,
152        seq: u64,
153        content: String,
154    },
155    #[serde(rename = "assistant.completed")]
156    AssistantCompleted {
157        request_id: String,
158        session_id: String,
159        turn_id: String,
160        seq: u64,
161        content: String,
162    },
163    #[serde(rename = "tool.proposed")]
164    ToolProposed {
165        request_id: String,
166        session_id: String,
167        turn_id: String,
168        seq: u64,
169        call_id: String,
170        name: String,
171        arguments: Value,
172    },
173    #[serde(rename = "approval.requested")]
174    ApprovalRequested {
175        request_id: String,
176        session_id: String,
177        turn_id: String,
178        seq: u64,
179        approval_id: String,
180        call_id: String,
181        name: String,
182        risk: String,
183        cwd: String,
184        summary: String,
185    },
186    #[serde(rename = "tool.started")]
187    ToolStarted {
188        request_id: String,
189        session_id: String,
190        turn_id: String,
191        seq: u64,
192        call_id: String,
193        name: String,
194    },
195    #[serde(rename = "tool.completed")]
196    ToolCompleted {
197        request_id: String,
198        session_id: String,
199        turn_id: String,
200        seq: u64,
201        call_id: String,
202        name: String,
203        success: bool,
204        output: String,
205        truncated: bool,
206    },
207    #[serde(rename = "context.compacted")]
208    ContextCompacted {
209        request_id: String,
210        session_id: String,
211        turn_id: String,
212        seq: u64,
213        before_tokens: usize,
214        after_tokens: usize,
215        removed_messages: usize,
216    },
217    #[serde(rename = "session.trimmed")]
218    SessionTrimmed {
219        request_id: String,
220        session_id: String,
221        seq: u64,
222        removed_messages: usize,
223        history_bytes: usize,
224    },
225    #[serde(rename = "session.cleared")]
226    SessionCleared {
227        request_id: String,
228        session_id: String,
229        seq: u64,
230    },
231    #[serde(rename = "turn.completed")]
232    TurnCompleted {
233        request_id: String,
234        session_id: String,
235        turn_id: String,
236        seq: u64,
237        steps: usize,
238        usage: Usage,
239    },
240    #[serde(rename = "turn.cancelled")]
241    TurnCancelled {
242        request_id: String,
243        session_id: String,
244        turn_id: String,
245        seq: u64,
246    },
247    #[serde(rename = "turn.failed")]
248    TurnFailed {
249        request_id: String,
250        session_id: String,
251        turn_id: String,
252        seq: u64,
253        code: String,
254        message: String,
255    },
256    #[serde(rename = "error")]
257    Error {
258        #[serde(skip_serializing_if = "Option::is_none")]
259        request_id: Option<String>,
260        code: String,
261        message: String,
262        fatal: bool,
263    },
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn client_message_round_trip() {
272        let message = ClientMessage::TurnStart {
273            request_id: "3".into(),
274            session_id: "session".into(),
275            prompt: "hello".into(),
276        };
277        let json = serde_json::to_string(&message).unwrap();
278        assert!(json.contains("\"type\":\"turn.start\""));
279        assert_eq!(
280            serde_json::from_str::<ClientMessage>(&json).unwrap(),
281            message
282        );
283    }
284
285    #[test]
286    fn additive_fields_are_ignored() {
287        let json = r#"{"type":"session.clear","request_id":"1","session_id":"s","future":true}"#;
288        assert!(matches!(
289            serde_json::from_str::<ClientMessage>(json).unwrap(),
290            ClientMessage::SessionClear { .. }
291        ));
292    }
293
294    #[test]
295    fn event_round_trip() {
296        let event = ServerEvent::AssistantDelta {
297            request_id: "1".into(),
298            session_id: "s".into(),
299            turn_id: "t".into(),
300            seq: 4,
301            content: "hello".into(),
302        };
303        let encoded = serde_json::to_string(&event).unwrap();
304        assert_eq!(
305            serde_json::from_str::<ServerEvent>(&encoded).unwrap(),
306            event
307        );
308    }
309
310    #[test]
311    fn queue_messages_and_events_round_trip() {
312        let message = ClientMessage::QueueMove {
313            request_id: "q1".into(), session_id: "s".into(), queue_id: "q".into(),
314            revision: 2, before_queue_id: None,
315        };
316        let encoded = serde_json::to_string(&message).unwrap();
317        assert_eq!(serde_json::from_str::<ClientMessage>(&encoded).unwrap(), message);
318        let event = ServerEvent::QueueSnapshot {
319            request_id: None, session_id: "s".into(), seq: 4,
320            entries: vec![QueueEntry { queue_id: "q".into(), revision: 1, prompt: "hello".into(), submitter: "cli".into() }],
321            paused: false,
322        };
323        let encoded = serde_json::to_string(&event).unwrap();
324        assert_eq!(serde_json::from_str::<ServerEvent>(&encoded).unwrap(), event);
325    }
326}