synaps_cli/core/rpc_protocol.rs
1//! RPC protocol types for the `synaps-bridge` parent↔child IPC channel.
2//!
3//! # Overview
4//!
5//! The synaps-bridge RPC protocol enables a parent process to spawn a
6//! long-lived "rpc child" process and communicate with it over a pair of
7//! `stdio` pipes (child's `stdin` / `stdout`). This module defines every
8//! message type exchanged over that channel.
9//!
10//! See also: `synaps-bridge.SPEC.md §4` (path:
11//! `/home/jr/Projects/Maha-Media/synaps-bridge.SPEC.md`).
12//!
13//! # Framing
14//!
15//! * **Encoding:** UTF-8, line-delimited JSON (LDJSON / NDJSON).
16//! * **One frame per line:** each JSON object is terminated by a single `\n`
17//! (`0x0A`). No `Content-Length` header or other envelope.
18//! * **Max frame size:** 1 MiB (1 048 576 bytes). Frames that exceed this
19//! limit are considered malformed. The rpc child must emit an
20//! [`RpcEvent::Error`] with `id: None` and remain alive when it encounters
21//! an oversized inbound frame. Enforcement logic lives in Task 2.
22//! * **Direction:** the parent writes [`RpcCommand`] frames to the child's
23//! `stdin`; the child writes [`RpcEvent`] frames to its `stdout`.
24//!
25//! # Version semantics
26//!
27//! The current protocol version is [`RPC_PROTOCOL_VERSION`] = `1`. The child
28//! emits [`RpcEvent::Ready`] immediately after startup, advertising its
29//! `protocol_version`. The parent must refuse to proceed if the version does
30//! not match its own expectation.
31//!
32//! # Correlation
33//!
34//! Every [`RpcCommand`] variant **except** [`RpcCommand::Shutdown`] carries
35//! an `id: String` field. The rpc child echoes the same `id` in the
36//! corresponding [`RpcEvent::Response`] frame, allowing the parent to
37//! correlate requests and responses. The `id` format is opaque to the child
38//! (UUID, monotonic counter, or any other string).
39
40use serde::{Deserialize, Serialize};
41
42// ---------------------------------------------------------------------------
43// Protocol version
44// ---------------------------------------------------------------------------
45
46/// Wire-format protocol version. Both sides must agree on this value;
47/// the child advertises it in its [`RpcEvent::Ready`] frame.
48pub const RPC_PROTOCOL_VERSION: u32 = 1;
49
50// ---------------------------------------------------------------------------
51// Auxiliary types
52// ---------------------------------------------------------------------------
53
54/// A file attachment included with a [`RpcCommand::Prompt`] message.
55///
56/// The rpc child reads the file at `path` from the local filesystem.
57/// `name` and `mime` are optional hints; if absent the child falls back to
58/// the basename of `path` and MIME auto-detection respectively.
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
60pub struct RpcAttachment {
61 /// Local filesystem path the rpc child can read.
62 ///
63 /// Convention (enforced when Task 10 adds binary attachment support):
64 /// MUST be an absolute path; MUST NOT contain `..` segments. Path-traversal
65 /// validation will reject relative or `..`-bearing paths at that point.
66 pub path: String,
67 /// Optional human-meaningful filename (defaults to basename of `path`).
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub name: Option<String>,
70 /// Optional MIME hint; rpc child re-detects if absent.
71 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub mime: Option<String>,
73}
74
75/// Token-usage summary for a completed agent turn.
76///
77/// Mirrors the shape of `runtime::types::SessionEvent::Usage` so that
78/// consumers of the RPC protocol have identical fields without depending on
79/// the internal runtime type.
80#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
81pub struct TurnUsage {
82 /// Prompt tokens sent to the model.
83 pub input_tokens: u64,
84 /// Completion tokens returned by the model.
85 pub output_tokens: u64,
86 /// Tokens served from the prompt cache (not billed at full rate).
87 #[serde(default)]
88 pub cache_read_input_tokens: u64,
89 /// Tokens written into the prompt cache during this turn.
90 #[serde(default)]
91 pub cache_creation_input_tokens: u64,
92 /// The model identifier used for this turn, if known.
93 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub model: Option<String>,
95}
96
97// ---------------------------------------------------------------------------
98// Commands: parent → rpc child
99// ---------------------------------------------------------------------------
100
101/// Commands sent from the **parent** process to the **rpc child** over the
102/// child's `stdin`.
103///
104/// All variants except [`RpcCommand::Shutdown`] carry an `id` field that the
105/// child echoes back in the matching [`RpcEvent::Response`] frame.
106#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
107#[serde(tag = "type")]
108pub enum RpcCommand {
109 /// Submit a new user prompt, optionally with file attachments.
110 #[serde(rename = "prompt")]
111 Prompt {
112 /// Correlation id; echoed in the corresponding `Response` event.
113 id: String,
114 /// The user's message text.
115 message: String,
116 /// Zero or more file attachments. Defaults to an empty list when
117 /// the field is absent from the JSON frame.
118 #[serde(default)]
119 attachments: Vec<RpcAttachment>,
120 },
121
122 /// Send a follow-up message continuing the current conversation turn.
123 #[serde(rename = "follow_up")]
124 FollowUp {
125 /// Correlation id.
126 id: String,
127 /// The follow-up message text.
128 message: String,
129 },
130
131 /// Request in-context compaction of the conversation history.
132 #[serde(rename = "compact")]
133 Compact {
134 /// Correlation id.
135 id: String,
136 },
137
138 /// Start a fresh conversation session, discarding history.
139 #[serde(rename = "new_session")]
140 NewSession {
141 /// Correlation id.
142 id: String,
143 },
144
145 /// Retrieve the current conversation message history.
146 #[serde(rename = "get_messages")]
147 GetMessages {
148 /// Correlation id.
149 id: String,
150 },
151
152 /// Switch the active model for subsequent turns.
153 #[serde(rename = "set_model")]
154 SetModel {
155 /// Correlation id.
156 id: String,
157 /// The model identifier to activate (e.g. `"claude-opus-4-5"`).
158 model: String,
159 },
160
161 /// Enumerate models available to the current auth context.
162 #[serde(rename = "get_available_models")]
163 GetAvailableModels {
164 /// Correlation id.
165 id: String,
166 },
167
168 /// Abort the currently running prompt / agent turn.
169 #[serde(rename = "abort")]
170 Abort {
171 /// Correlation id.
172 id: String,
173 },
174
175 /// Retrieve aggregated token-usage statistics for the session.
176 #[serde(rename = "get_session_stats")]
177 GetSessionStats {
178 /// Correlation id.
179 id: String,
180 },
181
182 /// Retrieve the full runtime state snapshot of the rpc child.
183 #[serde(rename = "get_state")]
184 GetState {
185 /// Correlation id.
186 id: String,
187 },
188
189 /// Enumerate all tools currently registered in this rpc session's tool
190 /// registry (built-ins + any MCP / extension tools loaded at boot).
191 ///
192 /// The `id` field follows the same optional-correlation convention used by
193 /// other commands: when present it is echoed in the `Response` frame so the
194 /// bridge can match the reply to its pending probe. The bridge Phase 8
195 /// router sends `{"type":"tools_list"}` without an `id`; both forms are
196 /// accepted.
197 #[serde(rename = "tools_list")]
198 ToolsList {
199 /// Optional correlation id; echoed in the corresponding `Response`.
200 #[serde(default, skip_serializing_if = "Option::is_none")]
201 id: Option<String>,
202 },
203
204 /// Instruct the rpc child to exit cleanly.
205 ///
206 /// No `id` field — the child does not send a `Response` for shutdown.
207 #[serde(rename = "shutdown")]
208 Shutdown,
209}
210
211// ---------------------------------------------------------------------------
212// Events: rpc child → parent
213// ---------------------------------------------------------------------------
214
215/// Events emitted by the **rpc child** to the **parent** over the child's
216/// `stdout`.
217#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
218#[serde(tag = "type")]
219pub enum RpcEvent {
220 /// A streaming update from the assistant (text delta, thinking, tool
221 /// call lifecycle, …). One or more of these frames precede each
222 /// [`RpcEvent::AgentEnd`].
223 #[serde(rename = "message_update")]
224 MessageUpdate {
225 /// The granular assistant event payload.
226 event: AssistantEvent,
227 },
228
229 /// A subagent has been spawned to handle a delegated task.
230 #[serde(rename = "subagent_start")]
231 SubagentStart {
232 /// Opaque monotonic id for this subagent instance.
233 subagent_id: u64,
234 /// Human-readable agent name.
235 agent_name: String,
236 /// First few words of the task description.
237 task_preview: String,
238 },
239
240 /// A running subagent has produced an intermediate status update.
241 #[serde(rename = "subagent_update")]
242 SubagentUpdate {
243 /// Identifies the subagent (matches a prior [`RpcEvent::SubagentStart`]).
244 subagent_id: u64,
245 /// Human-readable agent name.
246 agent_name: String,
247 /// Free-form status string (e.g. `"running"`, `"tool_call"`).
248 status: String,
249 },
250
251 /// A subagent has finished.
252 #[serde(rename = "subagent_done")]
253 SubagentDone {
254 /// Identifies the subagent.
255 subagent_id: u64,
256 /// Human-readable agent name.
257 agent_name: String,
258 /// First few words of the result.
259 result_preview: String,
260 /// Wall-clock seconds the subagent ran for.
261 duration_secs: f64,
262 },
263
264 /// The agent turn has completed. Carries final token-usage data.
265 #[serde(rename = "agent_end")]
266 AgentEnd {
267 /// Token usage for the completed turn.
268 usage: TurnUsage,
269 },
270
271 /// A response to a specific [`RpcCommand`], correlated by `id`.
272 ///
273 /// The `body` is **flattened** into the enclosing JSON object — its keys
274 /// appear at the top level alongside `"type"`, `"id"`, and `"command"`.
275 #[serde(rename = "response")]
276 Response {
277 /// Echoed from the originating [`RpcCommand`]'s `id` field.
278 id: String,
279 /// The command name this is responding to (e.g. `"get_messages"`).
280 command: String,
281 /// Arbitrary response payload, flattened into the JSON frame.
282 ///
283 /// Type-erased to `serde_json::Value` for forward-compat with new
284 /// `command` strings. Rust consumers wanting strong typing should
285 /// inspect `command` and re-deserialise `body` into a per-command struct.
286 #[serde(flatten)]
287 body: serde_json::Value,
288 },
289
290 /// A protocol-level or runtime error.
291 ///
292 /// `id` is `None` for errors not attributable to a specific command
293 /// (e.g. oversized frame, internal crash).
294 #[serde(rename = "error")]
295 Error {
296 /// Correlation id of the command that caused the error, if any.
297 #[serde(default, skip_serializing_if = "Option::is_none")]
298 id: Option<String>,
299 /// Human-readable error description.
300 message: String,
301 },
302
303 /// Emitted by the rpc child immediately after startup, before any
304 /// commands are accepted.
305 #[serde(rename = "ready")]
306 Ready {
307 /// Unique identifier for this session (UUID or similar).
308 session_id: String,
309 /// The model currently active.
310 model: String,
311 /// The protocol version implemented by this child.
312 /// Must equal [`RPC_PROTOCOL_VERSION`] for the parent to proceed.
313 protocol_version: u32,
314 },
315}
316
317// ---------------------------------------------------------------------------
318// Assistant streaming events
319// ---------------------------------------------------------------------------
320
321/// Granular events emitted by the assistant during a streaming turn.
322///
323/// Carried inside [`RpcEvent::MessageUpdate`].
324#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
325#[serde(tag = "type")]
326pub enum AssistantEvent {
327 /// An incremental text chunk from the assistant's response.
328 #[serde(rename = "text_delta")]
329 TextDelta {
330 /// The text fragment.
331 delta: String,
332 },
333
334 /// An incremental thinking/reasoning chunk (extended thinking mode).
335 #[serde(rename = "thinking_delta")]
336 ThinkingDelta {
337 /// The thinking fragment.
338 delta: String,
339 },
340
341 /// A tool call has started streaming. Subsequent
342 /// [`AssistantEvent::ToolcallInputDelta`] frames carry the JSON input.
343 #[serde(rename = "toolcall_start")]
344 ToolcallStart {
345 /// Model-assigned opaque identifier for this tool call.
346 tool_id: String,
347 /// The tool being invoked.
348 tool_name: String,
349 },
350
351 /// An incremental JSON fragment of a tool call's input.
352 #[serde(rename = "toolcall_input_delta")]
353 ToolcallInputDelta {
354 /// Matches the `tool_id` from [`AssistantEvent::ToolcallStart`].
355 tool_id: String,
356 /// Raw JSON fragment (not yet a complete object).
357 delta: String,
358 },
359
360 /// The complete, finalised input for a tool call.
361 #[serde(rename = "toolcall_input")]
362 ToolcallInput {
363 /// Matches the `tool_id` from [`AssistantEvent::ToolcallStart`].
364 tool_id: String,
365 /// The fully parsed JSON input value.
366 input: serde_json::Value,
367 },
368
369 /// The result returned by tool execution.
370 #[serde(rename = "toolcall_result")]
371 ToolcallResult {
372 /// Matches the `tool_id` from [`AssistantEvent::ToolcallStart`].
373 tool_id: String,
374 /// The serialised tool result string.
375 result: String,
376 },
377}
378
379// ---------------------------------------------------------------------------
380// Tests
381// ---------------------------------------------------------------------------
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386 use serde_json::{from_str, json, to_string};
387
388 // ── RpcCommand::ToolsList round-trip ────────────────────────────────────
389
390 #[test]
391 fn tools_list_no_id_serialises() {
392 let cmd = RpcCommand::ToolsList { id: None };
393 let json = to_string(&cmd).expect("serialise");
394 let val: serde_json::Value = from_str(&json).unwrap();
395 assert_eq!(val["type"], "tools_list");
396 assert!(val.get("id").is_none(), "absent id must be omitted (skip_serializing_if)");
397 }
398
399 #[test]
400 fn tools_list_with_id_serialises() {
401 let cmd = RpcCommand::ToolsList { id: Some("req-42".to_string()) };
402 let json = to_string(&cmd).expect("serialise");
403 let val: serde_json::Value = from_str(&json).unwrap();
404 assert_eq!(val["type"], "tools_list");
405 assert_eq!(val["id"], "req-42");
406 }
407
408 #[test]
409 fn tools_list_no_id_deserialises() {
410 let line = r#"{"type":"tools_list"}"#;
411 let cmd: RpcCommand = from_str(line).expect("deserialise");
412 match cmd {
413 RpcCommand::ToolsList { id } => assert!(id.is_none()),
414 other => panic!("expected ToolsList, got {other:?}"),
415 }
416 }
417
418 #[test]
419 fn tools_list_with_id_deserialises() {
420 let line = r#"{"type":"tools_list","id":"abc-123"}"#;
421 let cmd: RpcCommand = from_str(line).expect("deserialise");
422 match cmd {
423 RpcCommand::ToolsList { id } => assert_eq!(id.as_deref(), Some("abc-123")),
424 other => panic!("expected ToolsList, got {other:?}"),
425 }
426 }
427
428 // ── Response body shape expected by the bridge ──────────────────────────
429
430 /// The bridge validates: `response.ok === true && Array.isArray(response.tools)`.
431 /// Verify the flattened RpcEvent::Response body produces exactly that shape.
432 #[test]
433 fn tools_list_response_body_shape_matches_bridge_expectation() {
434 let tools_json = json!([
435 {
436 "name": "bash",
437 "description": "Run a bash command",
438 "input_schema": {"type": "object", "properties": {"command": {"type": "string"}}}
439 }
440 ]);
441 let ev = RpcEvent::Response {
442 id: "req-1".to_string(),
443 command: "tools_list".to_string(),
444 body: json!({ "ok": true, "tools": tools_json }),
445 };
446 let serialised = to_string(&ev).expect("serialise");
447 let val: serde_json::Value = from_str(&serialised).unwrap();
448
449 assert_eq!(val["type"], "response");
450 assert_eq!(val["id"], "req-1");
451 assert_eq!(val["command"], "tools_list");
452 // Bridge checks these two fields at the top level (flattened):
453 assert_eq!(val["ok"], true, "bridge needs ok=true at top level");
454 assert!(val["tools"].is_array(), "bridge needs tools array at top level");
455 assert_eq!(val["tools"][0]["name"], "bash");
456 }
457}