Skip to main content

termesh_agent/
acp.rs

1//! The ACP client transport (ADR-0007 §2).
2//!
3//! A thin shell around [`crate::protocol::Translator`], which holds all the protocol
4//! logic and none of the I/O. This file owns only the parts that cannot be tested without
5//! a process: spawning the agent, three threads, and the channel back into the app.
6//!
7//! Follows ADR-0005's worker template rather than deviating from it — blocking calls on a
8//! dedicated thread, results delivered as `AppMessage`. One subprocess with two pipes is
9//! not a concurrency problem that needs a reactor, which is why `tokio` is still not in
10//! the tree.
11//!
12//! ```text
13//!   stdout reader ──┐
14//!                   ├─► worker (owns Translator + stdin) ──► sink ──► AppMessage::Agent
15//!   AgentRequest ───┘
16//!   stderr drain ─────► log            (drained, never blocked — see `spawn`)
17//! ```
18
19use std::ffi::OsStr;
20use std::io::{BufRead, BufReader, Write};
21use std::path::Path;
22use std::process::{Child, Command, Stdio};
23use std::sync::mpsc::{self, Sender};
24use std::thread;
25
26use termesh_core::{AgentEvent, AgentRequest, SessionId};
27
28use crate::jsonrpc::{DecodeError, Message};
29use crate::protocol::Translator;
30use crate::service::{AgentIntegration, AgentService, ClientCapabilities};
31
32/// Either direction of traffic, funnelled into the one thread that owns the translator.
33enum Work {
34    /// Something the app wants sent.
35    Request(AgentRequest),
36    /// A line the agent wrote.
37    Line(String),
38    /// The agent's stdout closed — it has exited or stopped talking.
39    Disconnected,
40}
41
42/// A running ACP agent subprocess.
43pub struct AcpAgent {
44    work: Sender<Work>,
45    child: Option<Child>,
46    capabilities: ClientCapabilities,
47}
48
49impl AcpAgent {
50    /// Spawn `command` and connect to it.
51    ///
52    /// `command` is an argv array — never a shell string, and never assembled into one
53    /// (ARCHITECTURE.md §11). `sink` receives every event; the app wraps it in
54    /// `AppMessage::Agent` so agent traffic wakes the main loop exactly as filesystem
55    /// traffic does.
56    pub fn spawn<S, F>(
57        command: &[S],
58        cwd: &Path,
59        capabilities: ClientCapabilities,
60        sink: F,
61    ) -> std::io::Result<Self>
62    where
63        S: AsRef<OsStr>,
64        F: Fn(AgentEvent) + Send + 'static,
65    {
66        let Some((program, args)) = command.split_first() else {
67            return Err(std::io::Error::new(
68                std::io::ErrorKind::InvalidInput,
69                "no agent command configured",
70            ));
71        };
72
73        let mut child = Command::new(program)
74            .args(args)
75            .current_dir(cwd)
76            .stdin(Stdio::piped())
77            .stdout(Stdio::piped())
78            .stderr(Stdio::piped())
79            .spawn()?;
80
81        // Piped above, so these are present — but a panic in a library path is worse
82        // than an error the caller can degrade on.
83        let missing = || std::io::Error::other("agent pipes were not created");
84        let stdin = child.stdin.take().ok_or_else(missing)?;
85        let stdout = child.stdout.take().ok_or_else(missing)?;
86        let stderr = child.stderr.take().ok_or_else(missing)?;
87
88        // Agents log to stderr, and a pipe nobody reads fills up and blocks the child
89        // mid-turn. Drain it unconditionally; the content is diagnostics, not protocol.
90        thread::Builder::new().name("termesh-acp-err".into()).spawn(move || {
91            for line in BufReader::new(stderr).lines().map_while(Result::ok) {
92                tracing_line(&line);
93            }
94        })?;
95
96        let mut agent =
97            Self::connect(Box::new(stdin), Box::new(BufReader::new(stdout)), capabilities, sink);
98        agent.child = Some(child);
99        Ok(agent)
100    }
101
102    /// Connect over arbitrary streams.
103    ///
104    /// Exists so the transport can be driven by in-memory pipes in tests: everything
105    /// except `spawn` is then exercised without an agent installed.
106    pub fn connect<F>(
107        mut stdin: Box<dyn Write + Send>,
108        stdout: Box<dyn BufRead + Send>,
109        capabilities: ClientCapabilities,
110        sink: F,
111    ) -> Self
112    where
113        F: Fn(AgentEvent) + Send + 'static,
114    {
115        let (work, inbox) = mpsc::channel::<Work>();
116
117        // Reader: lines in, straight to the worker. Deliberately does no parsing, so a
118        // slow translator can never stall the pipe.
119        let reader_work = work.clone();
120        let _ = thread::Builder::new().name("termesh-acp-in".into()).spawn(move || {
121            for line in stdout.lines() {
122                let Ok(line) = line else { break };
123                if reader_work.send(Work::Line(line)).is_err() {
124                    return; // the app is gone
125                }
126            }
127            let _ = reader_work.send(Work::Disconnected);
128        });
129
130        // Worker: the only thread that touches the translator or writes to stdin, so
131        // neither needs a lock.
132        let _ = thread::Builder::new().name("termesh-acp".into()).spawn(move || {
133            let mut translator = Translator::new();
134
135            // The handshake goes out before anything else; requests that arrive during it
136            // are queued by the translator rather than dropped.
137            let hello = translator.initialize(capabilities);
138            if stdin.write_all(hello.encode().as_bytes()).is_err() {
139                sink(failed("could not reach the agent"));
140                return;
141            }
142            let _ = stdin.flush();
143
144            while let Ok(item) = inbox.recv() {
145                let outgoing = match item {
146                    Work::Request(AgentRequest::Shutdown) => break,
147                    Work::Request(request) => translator.outgoing(request),
148                    Work::Line(line) => match Message::decode(&line) {
149                        Ok(message) => {
150                            let (events, replies) = translator.incoming(message);
151                            for event in events {
152                                sink(event);
153                            }
154                            replies
155                        }
156                        // Chatty agents are common; a stray line is traffic to skip, not
157                        // a reason to end the session.
158                        Err(DecodeError::NotJson(_)) => {
159                            tracing_line(&line);
160                            continue;
161                        }
162                        Err(e) => {
163                            tracing_line(&e.to_string());
164                            continue;
165                        }
166                    },
167                    Work::Disconnected => {
168                        // The one failure a user must never experience as a hang: the
169                        // agent died and the turn will never end on its own.
170                        sink(failed("the agent exited"));
171                        break;
172                    }
173                };
174
175                for message in outgoing {
176                    if stdin.write_all(message.encode().as_bytes()).is_err() {
177                        sink(failed("the agent stopped listening"));
178                        return;
179                    }
180                }
181                let _ = stdin.flush();
182            }
183        });
184
185        Self { work, child: None, capabilities }
186    }
187}
188
189fn failed(message: &str) -> AgentEvent {
190    // Session 0 means "no particular session": the model surfaces it and clears any
191    // in-flight turn rather than waiting forever.
192    AgentEvent::Failed { session: SessionId::new(0), message: message.to_string() }
193}
194
195/// Agent stderr is local diagnostic data. With no application subscriber this compiles
196/// down to an unobserved event; `--trace FILE` is the only route that records it.
197fn tracing_line(line: &str) {
198    tracing::trace!(target: "termesh::agent::acp", line, "agent stderr");
199}
200
201impl AgentService for AcpAgent {
202    fn integration(&self) -> AgentIntegration {
203        AgentIntegration::Acp
204    }
205
206    fn capabilities(&self) -> ClientCapabilities {
207        self.capabilities
208    }
209
210    fn send(&mut self, request: AgentRequest) {
211        // A dead worker means the agent is gone; the Failed event already went out, so
212        // dropping the request here is right rather than panicking on a closed channel.
213        let _ = self.work.send(Work::Request(request));
214    }
215
216    /// Always empty.
217    ///
218    /// Events reach the app through the sink, not by polling: the main loop blocks on its
219    /// message channel, so a client that only answered when asked would never be asked.
220    /// The scripted agent uses `poll`; both end at `Model::on_agent_event`.
221    fn poll(&mut self) -> Vec<AgentEvent> {
222        Vec::new()
223    }
224}
225
226impl Drop for AcpAgent {
227    fn drop(&mut self) {
228        let _ = self.work.send(Work::Request(AgentRequest::Shutdown));
229        // Killing outright rather than waiting politely: the editor is exiting, and an
230        // orphaned model process outliving it is a real cost to the user. Closing stdin
231        // first and giving the agent a grace period is the nicer shutdown, and belongs
232        // here once there is somewhere to report a hung agent to.
233        if let Some(child) = self.child.as_mut() {
234            let _ = child.kill();
235            let _ = child.wait();
236        }
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use std::sync::mpsc::Receiver;
244    use std::time::Duration;
245
246    /// A pipe whose written bytes can be read back, so a test can play the agent.
247    #[derive(Clone)]
248    struct Pipe(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
249
250    impl Pipe {
251        fn new() -> Self {
252            Self(Default::default())
253        }
254        /// Everything written so far, without consuming it — draining would make
255        /// assertions depend on when the worker happened to flush.
256        fn seen(&self) -> String {
257            String::from_utf8(self.0.lock().unwrap().clone()).unwrap()
258        }
259    }
260
261    impl Write for Pipe {
262        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
263            self.0.lock().unwrap().extend_from_slice(buf);
264            Ok(buf.len())
265        }
266        fn flush(&mut self) -> std::io::Result<()> {
267            Ok(())
268        }
269    }
270
271    /// Agent stdout the test drives line by line.
272    ///
273    /// A `Cursor` would hit EOF the instant it was drained, so the worker could see the
274    /// disconnect before the test had sent anything — every test would race the shutdown.
275    /// Here EOF happens only when the test drops the sender.
276    struct ScriptedStdout {
277        lines: Receiver<String>,
278        buf: Vec<u8>,
279        pos: usize,
280    }
281
282    impl std::io::Read for ScriptedStdout {
283        fn read(&mut self, out: &mut [u8]) -> std::io::Result<usize> {
284            if self.pos >= self.buf.len() {
285                match self.lines.recv() {
286                    Ok(line) => {
287                        self.buf = line.into_bytes();
288                        self.pos = 0;
289                    }
290                    Err(_) => return Ok(0), // the test dropped the sender: EOF
291                }
292            }
293            let n = (self.buf.len() - self.pos).min(out.len());
294            out[..n].copy_from_slice(&self.buf[self.pos..self.pos + n]);
295            self.pos += n;
296            Ok(n)
297        }
298    }
299
300    struct Harness {
301        agent: AcpAgent,
302        written: Pipe,
303        events: Receiver<AgentEvent>,
304        stdout: Option<Sender<String>>,
305    }
306
307    impl Harness {
308        fn new() -> Self {
309            let written = Pipe::new();
310            let (tx, events) = mpsc::channel();
311            let (stdout, lines) = mpsc::channel::<String>();
312
313            let agent = AcpAgent::connect(
314                Box::new(written.clone()),
315                Box::new(BufReader::new(ScriptedStdout { lines, buf: Vec::new(), pos: 0 })),
316                ClientCapabilities::default(),
317                move |event| {
318                    let _ = tx.send(event);
319                },
320            );
321            Self { agent, written, events, stdout: Some(stdout) }
322        }
323
324        /// Let the agent say something.
325        fn say(&self, line: &str) {
326            self.stdout.as_ref().unwrap().send(format!("{line}\n")).unwrap();
327        }
328
329        /// Close the agent's stdout, as an exiting process would.
330        fn hang_up(&mut self) {
331            self.stdout = None;
332        }
333
334        fn next_event(&self) -> AgentEvent {
335            self.events.recv_timeout(Duration::from_secs(5)).expect("expected an event")
336        }
337
338        /// Wait until the agent has written something matching `needle`.
339        fn wrote(&self, needle: &str) -> bool {
340            self.wait_for(needle, 500)
341        }
342
343        /// As [`Self::wrote`], but giving up quickly — for asserting something has *not*
344        /// been written yet.
345        fn wrote_quickly(&self, needle: &str) -> bool {
346            self.wait_for(needle, 10)
347        }
348
349        fn wait_for(&self, needle: &str, tries: usize) -> bool {
350            for _ in 0..tries {
351                if self.written.seen().contains(needle) {
352                    return true;
353                }
354                thread::sleep(Duration::from_millis(10));
355            }
356            false
357        }
358
359        fn written(&self) -> String {
360            self.written.seen()
361        }
362    }
363
364    #[test]
365    fn the_handshake_goes_out_before_anything_else() {
366        let h = Harness::new();
367        assert!(h.wrote("\"method\":\"initialize\""));
368        assert!(h.written().contains("readTextFile"));
369    }
370
371    /// A user who starts a session the instant the app opens must not lose it.
372    #[test]
373    fn requests_sent_during_the_handshake_are_flushed_once_it_completes() {
374        let mut h = Harness::new();
375        assert!(h.wrote("initialize"));
376
377        h.agent.send(AgentRequest::NewSession { cwd: "/proj".into() });
378        assert!(!h.wrote_quickly("session/new"), "nothing goes out before the agent replies");
379
380        h.say(r#"{"jsonrpc":"2.0","id":1,"result":{}}"#);
381        assert!(h.wrote("session/new"), "and it is sent once we are ready");
382    }
383
384    #[test]
385    fn a_session_reaches_the_sink_with_our_own_id() {
386        let mut h = Harness::new();
387        assert!(h.wrote("initialize"));
388        h.say(r#"{"jsonrpc":"2.0","id":1,"result":{}}"#);
389        assert!(matches!(h.next_event(), AgentEvent::Ready { .. }));
390
391        h.agent.send(AgentRequest::NewSession { cwd: "/proj".into() });
392        assert!(h.wrote("session/new"));
393        h.say(r#"{"jsonrpc":"2.0","id":2,"result":{"sessionId":"s-1"}}"#);
394
395        assert!(matches!(h.next_event(), AgentEvent::SessionStarted { .. }));
396    }
397
398    #[test]
399    fn streamed_text_reaches_the_sink() {
400        let mut h = Harness::new();
401        assert!(h.wrote("initialize"));
402        h.say(r#"{"jsonrpc":"2.0","id":1,"result":{}}"#);
403        assert!(matches!(h.next_event(), AgentEvent::Ready { .. }));
404        h.agent.send(AgentRequest::NewSession { cwd: "/proj".into() });
405        assert!(h.wrote("session/new"));
406        h.say(r#"{"jsonrpc":"2.0","id":2,"result":{"sessionId":"s-1"}}"#);
407        assert!(matches!(h.next_event(), AgentEvent::SessionStarted { .. }));
408
409        h.say(
410            r#"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"hi"}}}}"#,
411        );
412        match h.next_event() {
413            AgentEvent::MessageChunk { text, .. } => assert_eq!(text, "hi"),
414            other => panic!("expected streamed text, got {other:?}"),
415        }
416    }
417
418    /// The failure a user must never experience as a hang.
419    #[test]
420    fn a_dead_agent_is_reported_rather_than_hanging() {
421        let mut h = Harness::new();
422        h.hang_up();
423        match h.next_event() {
424            AgentEvent::Failed { message, .. } => assert!(message.contains("exited"), "{message}"),
425            other => panic!("expected a failure, got {other:?}"),
426        }
427    }
428
429    #[test]
430    fn noise_on_stdout_does_not_end_the_session() {
431        let mut h = Harness::new();
432        h.say("Listening on stdio...");
433        h.say("not json at all");
434        h.say(r#"{"hello":"world"}"#);
435        h.say(r#"{"jsonrpc":"2.0","id":1,"result":{}}"#);
436
437        // The noise was skipped; the handshake still completed, so a queued request flows.
438        h.agent.send(AgentRequest::NewSession { cwd: "/proj".into() });
439        assert!(h.wrote("session/new"), "a chatty agent is still a working agent");
440    }
441
442    #[test]
443    fn a_response_we_never_asked_for_is_ignored() {
444        let mut h = Harness::new();
445        assert!(h.wrote("initialize"));
446        h.say(r#"{"jsonrpc":"2.0","id":9999,"result":{"sessionId":"ghost"}}"#);
447        h.say(r#"{"jsonrpc":"2.0","id":1,"result":{}}"#);
448
449        h.agent.send(AgentRequest::NewSession { cwd: "/proj".into() });
450        assert!(h.wrote("session/new"), "the stray response did not derail us");
451    }
452
453    #[test]
454    fn spawning_with_no_command_is_an_error_not_a_panic() {
455        let empty: [&str; 0] = [];
456        let result = AcpAgent::spawn(&empty, Path::new("."), ClientCapabilities::default(), |_| {});
457        assert!(result.is_err());
458    }
459
460    #[test]
461    fn spawning_a_missing_binary_reports_the_error() {
462        let result = AcpAgent::spawn(
463            &["definitely-not-a-real-agent-binary"],
464            Path::new("."),
465            ClientCapabilities::default(),
466            |_| {},
467        );
468        assert!(result.is_err(), "a missing agent must not take the editor down");
469    }
470
471    #[test]
472    fn the_transport_reports_itself_as_tier_one() {
473        let mut h = Harness::new();
474        assert_eq!(h.agent.integration(), AgentIntegration::Acp);
475        assert!(h.agent.poll().is_empty(), "events arrive through the sink, not by polling");
476    }
477}