Skip to main content

termesh_agent/
jsonrpc.rs

1//! JSON-RPC 2.0 framing for the ACP transport (ADR-0007 §1, §2).
2//!
3//! ADR-0007 takes the *wire types* from `agent-client-protocol-schema` — that is where
4//! protocol churn lives — and owns the framing, which has not changed since JSON-RPC 2.0
5//! in 2010. This is that framing: one JSON object per line, in both directions.
6//!
7//! **Verified, not assumed.** The upstream SDK reads with `.lines()`, writes with
8//! `write_line`, and asserts outgoing messages contain no `\r` or `\n`. So a line is a
9//! message, and a message never spans lines.
10//!
11//! Pure: bytes in, typed messages out. No process, no threads, no I/O — which is what
12//! makes the wire behaviour testable without an agent installed.
13
14use serde_json::{json, Value};
15
16/// A message we send to the agent, or one it sends us.
17#[derive(Debug, Clone, PartialEq)]
18pub enum Message {
19    /// A call expecting a response, correlated by `id`.
20    Request { id: u64, method: String, params: Value },
21    /// A successful answer to a request we made.
22    Response { id: u64, result: Value },
23    /// A failed answer to a request we made.
24    Error { id: u64, code: i64, message: String },
25    /// A one-way message. `session/update` — the stream that carries everything
26    /// interesting — is a notification.
27    Notification { method: String, params: Value },
28}
29
30/// Why a line could not be understood.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum DecodeError {
33    /// Not JSON at all. Agents write diagnostics to stdout more often than they should,
34    /// so this is expected traffic rather than a fatal condition — the caller logs it and
35    /// keeps reading.
36    NotJson(String),
37    /// JSON, but not a JSON-RPC message we recognise.
38    Malformed(String),
39}
40
41impl std::fmt::Display for DecodeError {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        match self {
44            DecodeError::NotJson(line) => write!(f, "not JSON: {}", truncate(line)),
45            DecodeError::Malformed(line) => write!(f, "not a JSON-RPC message: {}", truncate(line)),
46        }
47    }
48}
49
50impl std::error::Error for DecodeError {}
51
52fn truncate(s: &str) -> String {
53    const MAX: usize = 120;
54    if s.chars().count() <= MAX {
55        return s.to_string();
56    }
57    let head: String = s.chars().take(MAX).collect();
58    format!("{head}…")
59}
60
61impl Message {
62    /// Serialise to a single line, newline included.
63    pub fn encode(&self) -> String {
64        let value = match self {
65            Message::Request { id, method, params } => {
66                json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })
67            }
68            Message::Response { id, result } => {
69                json!({ "jsonrpc": "2.0", "id": id, "result": result })
70            }
71            Message::Error { id, code, message } => {
72                json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } })
73            }
74            Message::Notification { method, params } => {
75                json!({ "jsonrpc": "2.0", "method": method, "params": params })
76            }
77        };
78        // `to_string` never emits a newline, which is exactly the invariant the framing
79        // depends on — a message must not span lines.
80        format!("{value}\n")
81    }
82
83    /// Parse one line.
84    pub fn decode(line: &str) -> Result<Message, DecodeError> {
85        let line = line.trim();
86        if line.is_empty() {
87            return Err(DecodeError::NotJson(String::new()));
88        }
89        let value: Value =
90            serde_json::from_str(line).map_err(|_| DecodeError::NotJson(line.to_string()))?;
91
92        let id = value.get("id").and_then(Value::as_u64);
93        let method = value.get("method").and_then(Value::as_str).map(str::to_string);
94        let params = value.get("params").cloned().unwrap_or(Value::Null);
95
96        match (id, method) {
97            // A method with an id is a call from the agent — `fs/read_text_file` and
98            // `session/request_permission` both arrive this way, and both need an answer.
99            (Some(id), Some(method)) => Ok(Message::Request { id, method, params }),
100            (None, Some(method)) => Ok(Message::Notification { method, params }),
101            (Some(id), None) => {
102                if let Some(error) = value.get("error") {
103                    return Ok(Message::Error {
104                        id,
105                        code: error.get("code").and_then(Value::as_i64).unwrap_or(0),
106                        message: error
107                            .get("message")
108                            .and_then(Value::as_str)
109                            .unwrap_or("unknown error")
110                            .to_string(),
111                    });
112                }
113                Ok(Message::Response {
114                    id,
115                    result: value.get("result").cloned().unwrap_or(Value::Null),
116                })
117            }
118            (None, None) => Err(DecodeError::Malformed(line.to_string())),
119        }
120    }
121}
122
123/// Hands out request ids.
124///
125/// Ids must be unique for the life of the connection: reusing one would let a late
126/// response be matched to the wrong call.
127#[derive(Debug, Default)]
128pub struct RequestIds(u64);
129
130impl RequestIds {
131    /// Deliberately not named `next`: this is not an iterator, and a `RequestIds` that
132    /// looked like one would invite `.collect()` on an infinite sequence.
133    pub fn allocate(&mut self) -> u64 {
134        self.0 += 1;
135        self.0
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    fn round_trip(message: Message) {
144        let line = message.encode();
145        assert!(line.ends_with('\n'), "every message is one line");
146        assert_eq!(line.matches('\n').count(), 1, "and only one");
147        assert_eq!(Message::decode(&line), Ok(message));
148    }
149
150    #[test]
151    fn every_message_kind_round_trips() {
152        round_trip(Message::Request {
153            id: 1,
154            method: "session/new".into(),
155            params: json!({ "cwd": "/proj" }),
156        });
157        round_trip(Message::Response { id: 1, result: json!({ "sessionId": "s1" }) });
158        round_trip(Message::Error { id: 2, code: -32601, message: "no such method".into() });
159        round_trip(Message::Notification {
160            method: "session/update".into(),
161            params: json!({ "update": { "sessionUpdate": "agent_message_chunk" } }),
162        });
163    }
164
165    #[test]
166    fn a_method_with_an_id_is_a_request_we_must_answer() {
167        // `fs/read_text_file` arrives this way — the agent is asking *us* (ADR-0007 §3).
168        let line =
169            r#"{"jsonrpc":"2.0","id":7,"method":"fs/read_text_file","params":{"path":"/a"}}"#;
170        match Message::decode(line).unwrap() {
171            Message::Request { id, method, .. } => {
172                assert_eq!((id, method.as_str()), (7, "fs/read_text_file"));
173            }
174            other => panic!("expected a request, got {other:?}"),
175        }
176    }
177
178    #[test]
179    fn a_method_without_an_id_is_a_notification() {
180        let line = r#"{"jsonrpc":"2.0","method":"session/update","params":{}}"#;
181        assert!(matches!(Message::decode(line), Ok(Message::Notification { .. })));
182    }
183
184    #[test]
185    fn an_error_response_is_not_mistaken_for_a_result() {
186        let line = r#"{"jsonrpc":"2.0","id":3,"error":{"code":-32000,"message":"boom"}}"#;
187        assert_eq!(
188            Message::decode(line),
189            Ok(Message::Error { id: 3, code: -32000, message: "boom".into() })
190        );
191    }
192
193    #[test]
194    fn a_missing_error_message_still_decodes() {
195        let line = r#"{"jsonrpc":"2.0","id":3,"error":{"code":-32000}}"#;
196        assert!(matches!(Message::decode(line), Ok(Message::Error { .. })));
197    }
198
199    /// Agents log to stdout more often than they should. A stray line is traffic to skip,
200    /// not a reason to tear down the session.
201    #[test]
202    fn noise_on_the_wire_is_reported_rather_than_fatal() {
203        for line in ["Listening on stdio...", "", "   ", "warning: something"] {
204            assert!(matches!(Message::decode(line), Err(DecodeError::NotJson(_))), "{line:?}");
205        }
206    }
207
208    #[test]
209    fn valid_json_that_is_not_jsonrpc_is_distinguished_from_noise() {
210        assert!(matches!(Message::decode("{\"hello\":1}"), Err(DecodeError::Malformed(_))));
211        assert!(matches!(Message::decode("[1,2,3]"), Err(DecodeError::Malformed(_))));
212    }
213
214    #[test]
215    fn a_long_bad_line_is_truncated_in_the_error() {
216        let noise = "x".repeat(5_000);
217        let message = Message::decode(&noise).unwrap_err().to_string();
218        assert!(message.len() < 200, "errors must stay readable, got {} chars", message.len());
219        assert!(message.ends_with('…'));
220    }
221
222    #[test]
223    fn surrounding_whitespace_is_tolerated() {
224        let line = "  {\"jsonrpc\":\"2.0\",\"method\":\"ping\",\"params\":null}  \n";
225        assert!(matches!(Message::decode(line), Ok(Message::Notification { .. })));
226    }
227
228    #[test]
229    fn embedded_newlines_are_escaped_so_a_message_stays_one_line() {
230        // The invariant the whole framing rests on: content with newlines must not split
231        // a message in two.
232        let message = Message::Notification {
233            method: "session/update".into(),
234            params: json!({ "text": "line one\nline two" }),
235        };
236        let encoded = message.encode();
237        assert_eq!(encoded.matches('\n').count(), 1, "only the terminator");
238        assert_eq!(Message::decode(&encoded), Ok(message));
239    }
240
241    #[test]
242    fn request_ids_are_never_reused() {
243        let mut ids = RequestIds::default();
244        let issued: Vec<u64> = (0..100).map(|_| ids.allocate()).collect();
245        let mut sorted = issued.clone();
246        sorted.dedup();
247        assert_eq!(issued.len(), sorted.len(), "a reused id could mismatch a late response");
248        assert_eq!(issued[0], 1, "ids start at 1, so 0 stays available as 'no id'");
249    }
250
251    #[test]
252    fn params_default_to_null_rather_than_failing() {
253        let line = r#"{"jsonrpc":"2.0","method":"session/cancel"}"#;
254        assert_eq!(
255            Message::decode(line),
256            Ok(Message::Notification { method: "session/cancel".into(), params: Value::Null })
257        );
258    }
259}