1use 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
32enum Work {
34 Request(AgentRequest),
36 Line(String),
38 Disconnected,
40}
41
42pub struct AcpAgent {
44 work: Sender<Work>,
45 child: Option<Child>,
46 capabilities: ClientCapabilities,
47}
48
49impl AcpAgent {
50 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 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 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 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 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; }
126 }
127 let _ = reader_work.send(Work::Disconnected);
128 });
129
130 let _ = thread::Builder::new().name("termesh-acp".into()).spawn(move || {
133 let mut translator = Translator::new();
134
135 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 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 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 AgentEvent::Failed { session: SessionId::new(0), message: message.to_string() }
193}
194
195fn 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 let _ = self.work.send(Work::Request(request));
214 }
215
216 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 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 #[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 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 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), }
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 fn say(&self, line: &str) {
326 self.stdout.as_ref().unwrap().send(format!("{line}\n")).unwrap();
327 }
328
329 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 fn wrote(&self, needle: &str) -> bool {
340 self.wait_for(needle, 500)
341 }
342
343 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 #[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 #[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 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}