use serde::{Deserialize, Serialize};
pub const RPC_PROTOCOL_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RpcAttachment {
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mime: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TurnUsage {
pub input_tokens: u64,
pub output_tokens: u64,
#[serde(default)]
pub cache_read_input_tokens: u64,
#[serde(default)]
pub cache_creation_input_tokens: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type")]
pub enum RpcCommand {
#[serde(rename = "prompt")]
Prompt {
id: String,
message: String,
#[serde(default)]
attachments: Vec<RpcAttachment>,
},
#[serde(rename = "follow_up")]
FollowUp {
id: String,
message: String,
},
#[serde(rename = "compact")]
Compact {
id: String,
},
#[serde(rename = "new_session")]
NewSession {
id: String,
},
#[serde(rename = "get_messages")]
GetMessages {
id: String,
},
#[serde(rename = "set_model")]
SetModel {
id: String,
model: String,
},
#[serde(rename = "get_available_models")]
GetAvailableModels {
id: String,
},
#[serde(rename = "abort")]
Abort {
id: String,
},
#[serde(rename = "get_session_stats")]
GetSessionStats {
id: String,
},
#[serde(rename = "get_state")]
GetState {
id: String,
},
#[serde(rename = "tools_list")]
ToolsList {
#[serde(default, skip_serializing_if = "Option::is_none")]
id: Option<String>,
},
#[serde(rename = "shutdown")]
Shutdown,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type")]
pub enum RpcEvent {
#[serde(rename = "message_update")]
MessageUpdate {
event: AssistantEvent,
},
#[serde(rename = "subagent_start")]
SubagentStart {
subagent_id: u64,
agent_name: String,
task_preview: String,
},
#[serde(rename = "subagent_update")]
SubagentUpdate {
subagent_id: u64,
agent_name: String,
status: String,
},
#[serde(rename = "subagent_done")]
SubagentDone {
subagent_id: u64,
agent_name: String,
result_preview: String,
duration_secs: f64,
},
#[serde(rename = "agent_end")]
AgentEnd {
usage: TurnUsage,
},
#[serde(rename = "response")]
Response {
id: String,
command: String,
#[serde(flatten)]
body: serde_json::Value,
},
#[serde(rename = "error")]
Error {
#[serde(default, skip_serializing_if = "Option::is_none")]
id: Option<String>,
message: String,
},
#[serde(rename = "ready")]
Ready {
session_id: String,
model: String,
protocol_version: u32,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type")]
pub enum AssistantEvent {
#[serde(rename = "text_delta")]
TextDelta {
delta: String,
},
#[serde(rename = "thinking_delta")]
ThinkingDelta {
delta: String,
},
#[serde(rename = "toolcall_start")]
ToolcallStart {
tool_id: String,
tool_name: String,
},
#[serde(rename = "toolcall_input_delta")]
ToolcallInputDelta {
tool_id: String,
delta: String,
},
#[serde(rename = "toolcall_input")]
ToolcallInput {
tool_id: String,
input: serde_json::Value,
},
#[serde(rename = "toolcall_result")]
ToolcallResult {
tool_id: String,
result: String,
},
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::{from_str, json, to_string};
#[test]
fn tools_list_no_id_serialises() {
let cmd = RpcCommand::ToolsList { id: None };
let json = to_string(&cmd).expect("serialise");
let val: serde_json::Value = from_str(&json).unwrap();
assert_eq!(val["type"], "tools_list");
assert!(val.get("id").is_none(), "absent id must be omitted (skip_serializing_if)");
}
#[test]
fn tools_list_with_id_serialises() {
let cmd = RpcCommand::ToolsList { id: Some("req-42".to_string()) };
let json = to_string(&cmd).expect("serialise");
let val: serde_json::Value = from_str(&json).unwrap();
assert_eq!(val["type"], "tools_list");
assert_eq!(val["id"], "req-42");
}
#[test]
fn tools_list_no_id_deserialises() {
let line = r#"{"type":"tools_list"}"#;
let cmd: RpcCommand = from_str(line).expect("deserialise");
match cmd {
RpcCommand::ToolsList { id } => assert!(id.is_none()),
other => panic!("expected ToolsList, got {other:?}"),
}
}
#[test]
fn tools_list_with_id_deserialises() {
let line = r#"{"type":"tools_list","id":"abc-123"}"#;
let cmd: RpcCommand = from_str(line).expect("deserialise");
match cmd {
RpcCommand::ToolsList { id } => assert_eq!(id.as_deref(), Some("abc-123")),
other => panic!("expected ToolsList, got {other:?}"),
}
}
#[test]
fn tools_list_response_body_shape_matches_bridge_expectation() {
let tools_json = json!([
{
"name": "bash",
"description": "Run a bash command",
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}}
}
]);
let ev = RpcEvent::Response {
id: "req-1".to_string(),
command: "tools_list".to_string(),
body: json!({ "ok": true, "tools": tools_json }),
};
let serialised = to_string(&ev).expect("serialise");
let val: serde_json::Value = from_str(&serialised).unwrap();
assert_eq!(val["type"], "response");
assert_eq!(val["id"], "req-1");
assert_eq!(val["command"], "tools_list");
assert_eq!(val["ok"], true, "bridge needs ok=true at top level");
assert!(val["tools"].is_array(), "bridge needs tools array at top level");
assert_eq!(val["tools"][0]["name"], "bash");
}
}