1use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use std::collections::BTreeMap;
10
11pub const PROTOCOL_VERSION: ProtocolVersion = ProtocolVersion { major: 0, minor: 3 };
12pub const MAX_FRAME_BYTES: usize = 1024 * 1024;
13
14#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
15#[serde(rename_all = "camelCase")]
16pub struct ProtocolVersion {
17 pub major: u16,
18 pub minor: u16,
19}
20
21impl ProtocolVersion {
22 #[must_use]
23 pub const fn negotiate(self, peer: Self) -> Option<Self> {
24 if self.major != peer.major {
25 return None;
26 }
27 Some(Self {
28 major: self.major,
29 minor: if self.minor < peer.minor {
30 self.minor
31 } else {
32 peer.minor
33 },
34 })
35 }
36}
37
38#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
39#[serde(transparent)]
40pub struct RunId(pub String);
41
42#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
43#[serde(rename_all = "camelCase")]
44pub struct ClientFrame {
45 pub request_id: u64,
46 #[serde(flatten)]
47 pub message: ClientMessage,
48}
49
50#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
51#[serde(tag = "type", rename_all = "snake_case")]
52pub enum ClientMessage {
53 Hello {
54 client_name: String,
55 version: ProtocolVersion,
56 },
57 ListRuns,
58 ProbeProviders,
59 ReadAccountUsage,
60 ShutdownIfIdle,
62 Shutdown {
65 mode: ShutdownMode,
66 },
67 StartRun {
68 run_id: RunId,
69 request: Box<RunRequest>,
70 idempotency_key: String,
71 },
72 AttachRun {
73 run_id: RunId,
74 after_sequence: u64,
75 },
76 DetachRun {
77 run_id: RunId,
78 },
79 InjectMessage {
80 run_id: RunId,
81 body: String,
82 idempotency_key: String,
83 },
84 CancelRun {
85 run_id: RunId,
86 idempotency_key: String,
87 },
88 DecideApproval {
89 run_id: RunId,
90 approval_id: String,
91 decision: ApprovalDecision,
92 idempotency_key: String,
93 },
94 AckEvents {
95 run_id: RunId,
96 through_sequence: u64,
97 },
98}
99
100#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
101#[serde(rename_all = "snake_case")]
102pub enum ShutdownMode {
103 Drain,
105 Terminate,
107}
108
109#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
110#[serde(rename_all = "camelCase")]
111pub struct RunRequest {
112 pub provider: String,
113 pub model: String,
114 pub prompt: String,
115 #[serde(default)]
116 pub is_command: bool,
117 pub system: Option<String>,
118 pub permission: String,
119 pub effort: Option<String>,
120 pub extra_thinking: Option<bool>,
121 pub approvals: bool,
122 pub interactive: bool,
123 pub workspace_roots: Vec<String>,
124 pub resume_session_id: Option<String>,
125 pub binary: Option<String>,
127 #[serde(default)]
128 pub environment: BTreeMap<String, String>,
129 #[serde(default)]
130 pub unchecked_args: Vec<String>,
131 #[serde(default)]
132 pub metadata: BTreeMap<String, Value>,
133}
134
135#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
136#[serde(rename_all = "snake_case")]
137pub enum ApprovalDecision {
138 AllowOnce,
139 AllowSimilar,
140 Deny,
141}
142
143#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
144#[serde(tag = "kind", rename_all = "snake_case")]
145pub enum ServerFrame {
146 Response {
147 request_id: u64,
148 #[serde(flatten)]
149 response: ServerResponse,
150 },
151 Event {
152 run_id: RunId,
153 sequence: u64,
154 event: RunEvent,
155 },
156}
157
158#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
159#[serde(tag = "type", rename_all = "snake_case")]
160pub enum ServerResponse {
161 Hello {
162 server_name: String,
163 version: ProtocolVersion,
164 capabilities: Vec<Capability>,
165 },
166 Runs {
167 runs: Vec<RunSnapshot>,
168 },
169 Providers {
170 providers: Vec<ProviderStatus>,
171 },
172 AccountUsage {
173 providers: Vec<ProviderAccountUsage>,
174 },
175 Run {
176 run: RunSnapshot,
177 },
178 Accepted,
179 Error {
180 code: ErrorCode,
181 message: String,
182 },
183}
184
185#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
186#[serde(rename_all = "snake_case")]
187pub enum Capability {
188 EventReplay,
189 LiveInjection,
190 Approvals,
191 Cancellation,
192 ProviderDetection,
193 LifecycleControl,
194}
195
196#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
197#[serde(rename_all = "camelCase")]
198pub struct ProviderStatus {
199 pub provider: String,
200 pub installed: bool,
201 pub version: Option<String>,
202 pub outdated: bool,
203 pub auth_state: String,
204 pub detail: String,
205 pub auth_method: Option<String>,
206 pub account: Option<String>,
207 pub plan: Option<String>,
208 pub login_hint: String,
209}
210
211#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
212#[serde(rename_all = "camelCase")]
213pub struct ProviderAccountUsage {
214 pub provider: String,
215 pub supported: bool,
216 pub usage: Option<Value>,
217 pub error: Option<String>,
218}
219
220#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
221#[serde(rename_all = "snake_case")]
222pub enum ErrorCode {
223 IncompatibleVersion,
224 ProtocolViolation,
225 NotFound,
226 NotImplemented,
227 Conflict,
228 Internal,
229}
230
231#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
232#[serde(rename_all = "snake_case")]
233pub enum RunState {
234 Starting,
235 Running,
236 WaitingApproval,
237 Finishing,
238 Completed,
239 Failed,
240 Canceled,
241 Interrupted,
242}
243
244#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
245#[serde(rename_all = "camelCase")]
246pub struct RunSnapshot {
247 pub run_id: RunId,
248 pub state: RunState,
249 pub provider: String,
250 pub model: String,
251 pub provider_session_id: Option<String>,
252 pub latest_sequence: u64,
253 pub acknowledged_sequence: u64,
254 pub workspace_roots: Vec<String>,
255 #[serde(default)]
256 pub metadata: BTreeMap<String, Value>,
257}
258
259#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
260#[serde(tag = "type", content = "payload", rename_all = "snake_case")]
261pub enum RunEvent {
262 Finished(Value),
264 Failed(String),
266 StateChanged(RunState),
267 SessionOpened {
268 provider_session_id: String,
269 model: Option<String>,
270 },
271 Reasoning(String),
272 Text(String),
273 MessageBoundary,
274 ToolCall {
275 id: Option<String>,
276 name: String,
277 input: Value,
278 },
279 ToolResult {
280 id: Option<String>,
281 ok: Option<bool>,
282 output: String,
283 },
284 ApprovalRequested {
285 approval_id: String,
286 title: String,
287 detail: Value,
288 },
289 Usage(Value),
290 RateLimit(Value),
291 Compaction(Value),
292 Commands(Value),
293 Error(String),
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299
300 #[test]
301 fn negotiates_only_within_the_same_major_version() {
302 assert_eq!(
303 PROTOCOL_VERSION.negotiate(ProtocolVersion { major: 0, minor: 0 }),
304 Some(ProtocolVersion { major: 0, minor: 0 })
305 );
306 assert_eq!(
307 PROTOCOL_VERSION.negotiate(ProtocolVersion { major: 1, minor: 0 }),
308 None
309 );
310 }
311
312 #[test]
313 fn frames_round_trip_without_transport_specific_state() {
314 let frame = ClientFrame {
315 request_id: 7,
316 message: ClientMessage::AttachRun {
317 run_id: RunId("run-7".into()),
318 after_sequence: 41,
319 },
320 };
321 let encoded = serde_json::to_vec(&frame).expect("frame should encode");
322 let decoded: ClientFrame = serde_json::from_slice(&encoded).expect("frame should decode");
323 assert_eq!(decoded, frame);
324 }
325}