Skip to main content

capo_agent/
protocol.rs

1#![cfg_attr(test, allow(clippy::expect_used))]
2
3//! v0.4 JSON/RPC wire protocol — line-delimited JSON encoding of the
4//! existing `UiEvent` (out) and `Command` (in) enums. See
5//! `docs/superpowers/specs/2026-05-19-capo-v0.4-design.md` §3.
6
7use serde::ser::Serializer;
8use serde::Serialize;
9
10use crate::events::{Command, UiEvent};
11
12/// Bumped when the wire schema changes incompatibly.
13pub const PROTOCOL_VERSION: u32 = 1;
14
15/// The first line emitted on any JSON/RPC stream.
16#[derive(Debug, Serialize)]
17pub struct SessionHeader {
18    #[serde(rename = "type")]
19    kind: &'static str,
20    pub protocol_version: u32,
21    pub session_id: String,
22    pub cwd: String,
23}
24
25impl SessionHeader {
26    pub fn new(session_id: String, cwd: String) -> Self {
27        Self {
28            kind: "session",
29            protocol_version: PROTOCOL_VERSION,
30            session_id,
31            cwd,
32        }
33    }
34}
35
36/// A failure to decode a client command line.
37#[derive(Debug)]
38pub struct ProtocolError(pub String);
39
40impl std::fmt::Display for ProtocolError {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        write!(f, "{}", self.0)
43    }
44}
45
46impl std::error::Error for ProtocolError {}
47
48/// Decode one JSONL line into a `Command`.
49pub fn decode_command(line: &str) -> Result<Command, ProtocolError> {
50    serde_json::from_str(line).map_err(|e| ProtocolError(e.to_string()))
51}
52
53/// Encode one `UiEvent` as a single JSON object (no trailing newline —
54/// the stream writer adds `\n`). Encoding cannot realistically fail for
55/// our derived `Serialize`; if it ever does, fall back to an `error` line
56/// so the stream stays valid JSONL.
57pub fn encode_event(event: &UiEvent) -> String {
58    serde_json::to_string(event)
59        .unwrap_or_else(|e| format!(r#"{{"type":"error","payload":"event encode failed: {e}"}}"#))
60}
61
62/// `#[serde(serialize_with = "…")]` target for `UiEvent::BranchTree`'s
63/// payload — motosan's `BranchTree`/`BranchNode` are not `Serialize`, so
64/// map them into a local serializable shape.
65pub fn serialize_branch_tree<S>(
66    tree: &motosan_agent_loop::BranchTree,
67    s: S,
68) -> Result<S::Ok, S::Error>
69where
70    S: Serializer,
71{
72    #[derive(Serialize)]
73    struct WireNode {
74        id: String,
75        parent: Option<usize>,
76        children: Vec<usize>,
77        label: String,
78    }
79    #[derive(Serialize)]
80    struct WireTree {
81        nodes: Vec<WireNode>,
82        root: Option<usize>,
83        active_leaf: Option<usize>,
84    }
85    let wire = WireTree {
86        nodes: tree
87            .nodes
88            .iter()
89            .map(|n| WireNode {
90                id: n.id.clone(),
91                parent: n.parent,
92                children: n.children.clone(),
93                label: n.label.clone(),
94            })
95            .collect(),
96        root: tree.root,
97        active_leaf: tree.active_leaf,
98    };
99    wire.serialize(s)
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn session_header_serializes_with_version() {
108        let h = SessionHeader::new("sess-1".into(), "/tmp/x".into());
109        let line = serde_json::to_string(&h).expect("serialize");
110        assert!(line.contains(r#""type":"session""#), "{line}");
111        assert!(line.contains(r#""protocol_version":1"#), "{line}");
112        assert!(line.contains(r#""session_id":"sess-1""#), "{line}");
113    }
114
115    #[test]
116    fn decode_command_parses_a_valid_line() {
117        let cmd = decode_command(r#"{"type":"cancel_agent"}"#).expect("decode");
118        assert_eq!(cmd, crate::events::Command::CancelAgent);
119    }
120
121    #[test]
122    fn decode_command_rejects_garbage() {
123        let err = decode_command("not json").expect_err("should fail");
124        assert!(!err.to_string().is_empty());
125    }
126
127    #[test]
128    fn serialize_branch_tree_emits_nodes() {
129        use motosan_agent_loop::{BranchNode, BranchTree};
130        let tree = BranchTree {
131            nodes: vec![BranchNode {
132                id: "n0".into(),
133                parent: None,
134                children: vec![],
135                label: "root".into(),
136            }],
137            root: Some(0),
138            active_leaf: Some(0),
139        };
140        // Serialize via a tiny wrapper that uses serialize_with.
141        #[derive(serde::Serialize)]
142        struct W<'a>(#[serde(serialize_with = "serialize_branch_tree")] &'a BranchTree);
143        let line = serde_json::to_string(&W(&tree)).expect("serialize");
144        assert!(line.contains(r#""label":"root""#), "{line}");
145        assert!(line.contains(r#""active_leaf":0"#), "{line}");
146    }
147
148    #[test]
149    fn encode_event_produces_one_tagged_line() {
150        use crate::events::UiEvent;
151        assert_eq!(
152            encode_event(&UiEvent::AgentThinking),
153            r#"{"type":"agent_thinking"}"#
154        );
155        let line = encode_event(&UiEvent::AgentTextDelta("hi".into()));
156        assert_eq!(line, r#"{"type":"agent_text_delta","payload":"hi"}"#);
157        assert!(!line.contains('\n'), "encode_event must not add a newline");
158    }
159}