Skip to main content

protocol_droid/adapters/secondary/acp/
agent.rs

1//! `AcpAgentPeer` — the agent-side transport for ACP.
2//!
3//! The agent process receives JSON-RPC 2.0
4//! requests from the client on its stdin and
5//! sends replies (and notifications) on its
6//! stdout. The peer is the inverse of
7//! [`AcpStdioPeer`](super::peer::AcpStdioPeer): the
8//! client peer writes to the agent's stdin
9//! and reads from the agent's stdout; the agent
10//! peer reads from its own stdin and writes to
11//! its own stdout.
12//!
13//! # The wire
14//!
15//! Same shape as the client side: newline-
16//! delimited JSON-RPC 2.0. A request is
17//! `{ "jsonrpc": "2.0", "id": N, "method": ...,
18//! "params": ... }`; a reply is `{ "jsonrpc":
19//! "2.0", "id": N, "result": ... }` or
20//! `{ "jsonrpc": "2.0", "id": N, "error": {...} }`.
21//! A notification is a request without an `id`.
22//!
23//! # The peer
24//!
25//! Like the client peer, the agent peer is two
26//! halves:
27//!
28//! - A **reader task**, spawned at construction,
29//!   that owns the agent's stdin and routes
30//!   incoming envelopes to the application
31//!   via an mpsc channel.
32//! - A **writer task**, also spawned at
33//!   construction, that owns the agent's
34//!   stdout. The application hands a
35//!   serialised envelope to the peer; the
36//!   peer hands the bytes to the writer.
37//!
38//! Splitting the two halves means the
39//! application can hold a `&AcpAgentPeer` and
40//! call `send_reply` from any task without
41//! re-entering the same lock that the reader
42//! is holding.
43//!
44//! # Why a generic `from_stdio` and not a
45//! `spawn` builder
46//!
47//! The client peer owns the lifecycle of the
48//! child process — it spawns the agent and
49//! kills it on drop. The agent peer is
50//! the other half: the agent process is
51//! already running (it is the process the
52//! client spawned), and the agent peer's job
53//! is to talk to its own stdin / stdout. A
54//! `spawn` builder would be a synonym for
55//! "wire the peer's halves to my own pipes";
56//! a generic `from_stdio` is the seam a test
57//! (or a server that embeds the peer) uses to
58//! hand in any `AsyncRead + AsyncWrite` pair.
59
60use serde::Serialize;
61use serde_json::Value;
62use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
63use tokio::sync::mpsc;
64
65use super::error::AcpPeerError;
66
67/// A JSON-RPC 2.0 request the agent receives
68/// from the client.
69///
70/// The wire form is `{ jsonrpc, id, method,
71/// params }`. The peer decodes the envelope
72/// and hands the application the typed shape;
73/// the application decodes the `params` into
74/// the concrete request type the method
75/// expects (`InitializeRequest`,
76/// `NewSessionRequest`, etc.).
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct AgentRequest {
79    /// The JSON-RPC 2.0 `id`. The application
80    /// passes it back unchanged in the reply
81    /// envelope so the client can correlate
82    /// the reply with the request. The id is
83    /// a `serde_json::Value` because the wire
84    /// spec allows string, number, or null;
85    /// the agent peer passes it through
86    /// verbatim.
87    pub id: Value,
88    /// The method name. The schema's method
89    /// names are dotted slugs
90    /// (`session/new`, `session/prompt`); the
91    /// application routes by this string.
92    pub method: String,
93    /// The raw params. The application decodes
94    /// into the typed request for the
95    /// method; the peer does not attempt
96    /// any routing.
97    pub params: Value,
98}
99
100/// The agent-side transport for ACP.
101///
102/// Constructed via [`AcpAgentPeer::from_stdio`].
103/// The peer owns two tasks (the reader and
104/// the writer); dropping the peer stops the
105/// tasks. The agent process's own lifecycle
106/// is independent — the agent process is
107/// not owned by this peer.
108pub struct AcpAgentPeer {
109    /// The reader task's output. The
110    /// application awaits the next
111    /// [`AgentRequest`] with
112    /// [`recv_request`][Self::recv_request].
113    request_rx: mpsc::Receiver<AgentRequest>,
114    /// The writer task's input. The
115    /// application hands a fully-serialised
116    /// envelope to the channel; the writer
117    /// writes the bytes.
118    write_tx: mpsc::Sender<Vec<u8>>,
119}
120
121impl std::fmt::Debug for AcpAgentPeer {
122    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123        // The `mpsc::Sender` does not
124        // implement `Debug`; format the
125        // peer by hand, omitting the
126        // channels.
127        formatter
128            .debug_struct("AcpAgentPeer")
129            .finish_non_exhaustive()
130    }
131}
132
133impl AcpAgentPeer {
134    /// Construct an agent peer from a stdin
135    /// reader and a stdout writer. The
136    /// generic parameters let the test suite
137    /// pass `tokio::io::DuplexStream` halves
138    /// instead of a real `ChildStdin` /
139    /// `ChildStdout`.
140    ///
141    /// The peer spawns a reader task and a
142    /// writer task; the application
143    /// communicates with the peer through
144    /// the mpsc channels the reader and
145    /// writer tasks own. The half moves
146    /// happen at construction time; the
147    /// returned peer is `Send + 'static`.
148    pub fn from_stdio<R, W>(stdin: R, stdout: W) -> Self
149    where
150        R: tokio::io::AsyncRead + Unpin + Send + 'static,
151        W: tokio::io::AsyncWrite + Unpin + Send + 'static,
152    {
153        let (request_tx, request_rx) = mpsc::channel::<AgentRequest>(32);
154        let (write_tx, write_rx) = mpsc::channel::<Vec<u8>>(32);
155
156        // The reader task. Owns the
157        // agent's stdin. Reads one line
158        // at a time, parses, and pushes
159        // the request to the application
160        // channel. The line is silently
161        // dropped on parse error (a
162        // misbehaving client cannot
163        // crash the agent's main loop).
164        tokio::spawn(spawn_reader(BufReader::new(stdin), request_tx));
165
166        // The writer task. Owns the
167        // agent's stdout. Receives
168        // pre-serialised bytes and
169        // writes them with a trailing
170        // `\n`.
171        tokio::spawn(spawn_writer(stdout, write_rx));
172
173        Self {
174            request_rx,
175            write_tx,
176        }
177    }
178
179    /// Await the next [`AgentRequest`] the
180    /// client sent.
181    ///
182    /// Returns `None` when the reader task
183    /// is gone (the client closed its end
184    /// of the wire, or the pipe broke).
185    /// The application is expected to
186    /// shut down its handler loop on
187    /// `None`.
188    pub async fn recv_request(&mut self) -> Option<AgentRequest> {
189        self.request_rx.recv().await
190    }
191
192    /// Send a success reply for a previously
193    /// received request.
194    ///
195    /// The wire form is
196    /// `{ "jsonrpc": "2.0", "id": <id>,
197    /// "result": <result> }`. The `id` is
198    /// the value the request carried; the
199    /// peer passes it through unchanged.
200    pub async fn send_reply(&self, id: Value, result: Value) -> Result<(), AcpPeerError> {
201        let envelope = serde_json::json!({
202            "jsonrpc": "2.0",
203            "id": id,
204            "result": result,
205        });
206        let bytes = serde_json::to_vec(&envelope).map_err(AcpPeerError::Decode)?;
207        let mut framed = bytes;
208        framed.push(b'\n');
209        self.write_tx
210            .send(framed)
211            .await
212            .map_err(|_| AcpPeerError::Write(io_other("the writer task is gone")))?;
213        Ok(())
214    }
215
216    /// Send a JSON-RPC 2.0 error reply for a
217    /// previously received request. The
218    /// `code` and `message` are the
219    /// JSON-RPC error code and human-readable
220    /// message; the wire form is
221    /// `{ "jsonrpc": "2.0", "id": <id>,
222    /// "error": { "code": <code>, "message":
223    /// <message> } }`.
224    pub async fn send_error(
225        &self,
226        id: Value,
227        code: i64,
228        message: impl Into<String>,
229    ) -> Result<(), AcpPeerError> {
230        let envelope = serde_json::json!({
231            "jsonrpc": "2.0",
232            "id": id,
233            "error": { "code": code, "message": message.into() },
234        });
235        let bytes = serde_json::to_vec(&envelope).map_err(AcpPeerError::Decode)?;
236        let mut framed = bytes;
237        framed.push(b'\n');
238        self.write_tx
239            .send(framed)
240            .await
241            .map_err(|_| AcpPeerError::Write(io_other("the writer task is gone")))?;
242        Ok(())
243    }
244
245    /// Send a notification to the client.
246    /// The wire form is `{ "jsonrpc": "2.0",
247    /// "method": ..., "params": ... }` with
248    /// no `id` field. The schema's
249    /// `session/update` is the canonical
250    /// example.
251    pub async fn send_notification<Params>(
252        &self,
253        method: &str,
254        params: Params,
255    ) -> Result<(), AcpPeerError>
256    where
257        Params: Serialize,
258    {
259        let params_value: Value = serde_json::to_value(&params).map_err(AcpPeerError::Decode)?;
260        let envelope = serde_json::json!({
261            "jsonrpc": "2.0",
262            "method": method,
263            "params": params_value,
264        });
265        let bytes = serde_json::to_vec(&envelope).map_err(AcpPeerError::Decode)?;
266        let mut framed = bytes;
267        framed.push(b'\n');
268        self.write_tx
269            .send(framed)
270            .await
271            .map_err(|_| AcpPeerError::Write(io_other("the writer task is gone")))?;
272        Ok(())
273    }
274}
275
276async fn spawn_reader<R>(mut reader: BufReader<R>, request_tx: mpsc::Sender<AgentRequest>)
277where
278    R: tokio::io::AsyncRead + Unpin + Send + 'static,
279{
280    let mut line = String::new();
281    loop {
282        line.clear();
283        match reader.read_line(&mut line).await {
284            Ok(0) | Err(_) => return,
285            Ok(_) => {}
286        }
287        let trimmed = line.trim_end_matches('\n');
288        if trimmed.is_empty() {
289            continue;
290        }
291        // A request carries an `id`; a
292        // notification does not. The agent
293        // peer hands the application only
294        // requests (the application is the
295        // one that wants the correlation);
296        // notifications from the client
297        // (e.g. `session/cancel`) are
298        // silently dropped today. A future
299        // increment exposes them through a
300        // second channel.
301        let value: Value = match serde_json::from_str(trimmed) {
302            Ok(value) => value,
303            Err(_) => continue,
304        };
305        if value.get("id").is_none() {
306            // Notification. Not yet
307            // surfaced to the
308            // application.
309            continue;
310        }
311        let id = value.get("id").cloned().unwrap_or(Value::Null);
312        let method = value
313            .get("method")
314            .and_then(Value::as_str)
315            .unwrap_or("")
316            .to_owned();
317        let params = value.get("params").cloned().unwrap_or(Value::Null);
318        let request = AgentRequest { id, method, params };
319        if request_tx.send(request).await.is_err() {
320            // The application dropped
321            // the receiver; the agent
322            // is shutting down.
323            return;
324        }
325    }
326}
327
328async fn spawn_writer<W>(mut stdout: W, mut write_rx: mpsc::Receiver<Vec<u8>>)
329where
330    W: tokio::io::AsyncWrite + Unpin + Send + 'static,
331{
332    while let Some(bytes) = write_rx.recv().await {
333        if stdout.write_all(&bytes).await.is_err() {
334            return;
335        }
336        if stdout.flush().await.is_err() {
337            return;
338        }
339    }
340}
341
342fn io_other(message: &str) -> std::io::Error {
343    std::io::Error::other(message)
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349    use tokio::io::AsyncBufReadExt;
350    use tokio::io::BufReader;
351    use tokio::io::duplex;
352
353    /// An agent peer wired to a duplex
354    /// pair reads a request the test
355    /// wrote to the other end. The test
356    /// asserts the request decodes to
357    /// the expected shape.
358    #[tokio::test]
359    async fn a_request_arrives_at_the_agent_peer() -> Result<(), AcpPeerError> {
360        let (mut client_end, agent_end) = duplex(4096);
361        let (agent_read, agent_write) = tokio::io::split(agent_end);
362        let mut peer = AcpAgentPeer::from_stdio(agent_read, agent_write);
363
364        // The "client" writes a request
365        // to its end of the duplex; the
366        // agent peer should surface it.
367        let request_bytes = b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"foo\":\"bar\"}}\n";
368        client_end
369            .write_all(request_bytes)
370            .await
371            .map_err(AcpPeerError::Write)?;
372
373        let request = peer
374            .recv_request()
375            .await
376            .ok_or_else(|| AcpPeerError::Write(io_other("no request arrived")))?;
377
378        assert_eq!(request.id, serde_json::json!(1));
379        assert_eq!(request.method, "initialize");
380        assert_eq!(request.params, serde_json::json!({"foo": "bar"}));
381
382        drop(peer);
383        drop(client_end);
384        Ok(())
385    }
386
387    /// A reply the agent sends is the
388    /// test reads from the other end of
389    /// the duplex. The test asserts the
390    /// wire form is well-formed and
391    /// carries the request's `id`.
392    #[tokio::test]
393    async fn a_reply_written_by_the_agent_peer_round_trips() -> Result<(), AcpPeerError> {
394        let (client_end, agent_end) = duplex(4096);
395        let (agent_read, agent_write) = tokio::io::split(agent_end);
396        let peer = AcpAgentPeer::from_stdio(agent_read, agent_write);
397
398        peer.send_reply(serde_json::json!(7), serde_json::json!({"answer": 42}))
399            .await?;
400
401        let line = read_line_from(BufReader::new(client_end)).await?;
402        let value: Value = serde_json::from_str(&line).map_err(AcpPeerError::Decode)?;
403        assert_eq!(value.get("jsonrpc"), Some(&Value::String("2.0".to_owned())));
404        assert_eq!(value.get("id"), Some(&Value::from(7u64)));
405        assert_eq!(
406            value.get("result"),
407            Some(&Value::Object(
408                std::iter::once(("answer".to_owned(), Value::from(42u64))).collect()
409            ))
410        );
411
412        drop(peer);
413        Ok(())
414    }
415
416    /// A notification the agent sends
417    /// has no `id` field on the wire.
418    /// The test asserts the wire form
419    /// is `{ jsonrpc, method, params }`
420    /// only.
421    #[tokio::test]
422    async fn a_notification_written_by_the_agent_peer_omits_the_id() -> Result<(), AcpPeerError> {
423        let (client_end, agent_end) = duplex(4096);
424        let (agent_read, agent_write) = tokio::io::split(agent_end);
425        let peer = AcpAgentPeer::from_stdio(agent_read, agent_write);
426
427        peer.send_notification("session/update", serde_json::json!({"foo": "bar"}))
428            .await?;
429
430        let line = read_line_from(BufReader::new(client_end)).await?;
431        let value: Value = serde_json::from_str(&line).map_err(AcpPeerError::Decode)?;
432        assert!(
433            value.get("id").is_none(),
434            "the notification has no `id` field"
435        );
436        assert_eq!(
437            value.get("method"),
438            Some(&Value::String("session/update".to_owned()))
439        );
440
441        drop(peer);
442        Ok(())
443    }
444
445    /// A JSON-RPC error reply the agent
446    /// sends carries the error code and
447    /// message. The test asserts the
448    /// wire form is `{ jsonrpc, id,
449    /// error: { code, message } }`.
450    #[tokio::test]
451    async fn an_error_reply_written_by_the_agent_peer_round_trips() -> Result<(), AcpPeerError> {
452        let (client_end, agent_end) = duplex(4096);
453        let (agent_read, agent_write) = tokio::io::split(agent_end);
454        let peer = AcpAgentPeer::from_stdio(agent_read, agent_write);
455
456        peer.send_error(serde_json::json!(3), -32601, "Method not found")
457            .await?;
458
459        let line = read_line_from(BufReader::new(client_end)).await?;
460        let value: Value = serde_json::from_str(&line).map_err(AcpPeerError::Decode)?;
461        assert_eq!(value.get("id"), Some(&Value::from(3u64)));
462        let error = value
463            .get("error")
464            .ok_or_else(|| AcpPeerError::Write(io_other("missing `error` field")))?;
465        assert_eq!(error.get("code"), Some(&Value::from(-32601)));
466        assert_eq!(
467            error.get("message"),
468            Some(&Value::String("Method not found".to_owned()))
469        );
470
471        drop(peer);
472        Ok(())
473    }
474
475    /// Read one `\n`-terminated line from
476    /// the buffered reader. The helper
477    /// exists so the wire-shape tests can
478    /// consume the agent peer's output
479    /// without depending on
480    /// `tokio::io::AsyncReadExt::read_buf`'s
481    /// "may return short" semantics. The
482    /// trailing newline is stripped so the
483    /// caller parses the JSON object
484    /// directly.
485    async fn read_line_from<R>(mut reader: BufReader<R>) -> Result<String, AcpPeerError>
486    where
487        R: tokio::io::AsyncRead + Unpin + Send,
488    {
489        let mut line = String::new();
490        reader
491            .read_line(&mut line)
492            .await
493            .map_err(AcpPeerError::Write)?;
494        Ok(line.trim_end_matches('\n').to_owned())
495    }
496}