1use std::collections::{BTreeMap, VecDeque};
8use std::path::{Path, PathBuf};
9use std::str::FromStr;
10use std::sync::Arc;
11use std::time::Duration;
12
13use async_process::Child;
14use serde::{Deserialize, Serialize};
15use std::pin::pin;
16
17use crate::{Client, Conductor, Role};
18
19type DebugCallback = Arc<dyn Fn(&str, LineDirection) + Send + Sync + 'static>;
20
21const STDERR_CAPTURE_LIMIT: usize = 64 * 1024;
22const STDERR_READ_BUFFER_SIZE: usize = 8 * 1024;
23const STDERR_LINE_TRUNCATION_MARKER: &str = "… [stderr line truncated]";
24const SHUTDOWN_GRACE_PERIOD: Duration = Duration::from_secs(1);
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum LineDirection {
29 Stdin,
31 Stdout,
33 Stderr,
35}
36
37#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
52#[serde(rename_all = "camelCase", deny_unknown_fields)]
53pub struct AcpAgentConfig {
54 command: PathBuf,
55 #[serde(default, skip_serializing_if = "Vec::is_empty")]
56 args: Vec<String>,
57 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
58 env: BTreeMap<String, String>,
59}
60
61impl AcpAgentConfig {
62 #[must_use]
64 pub fn new(command: impl Into<PathBuf>) -> Self {
65 Self {
66 command: command.into(),
67 args: Vec::new(),
68 env: BTreeMap::new(),
69 }
70 }
71
72 #[must_use]
74 pub fn arg(mut self, arg: impl Into<String>) -> Self {
75 self.args.push(arg.into());
76 self
77 }
78
79 #[must_use]
81 pub fn args<I, S>(mut self, args: I) -> Self
82 where
83 I: IntoIterator<Item = S>,
84 S: Into<String>,
85 {
86 self.args.extend(args.into_iter().map(Into::into));
87 self
88 }
89
90 #[must_use]
92 pub fn env(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
93 self.env.insert(name.into(), value.into());
94 self
95 }
96
97 #[must_use]
99 pub fn envs<I, K, V>(mut self, env: I) -> Self
100 where
101 I: IntoIterator<Item = (K, V)>,
102 K: Into<String>,
103 V: Into<String>,
104 {
105 self.env.extend(
106 env.into_iter()
107 .map(|(name, value)| (name.into(), value.into())),
108 );
109 self
110 }
111
112 #[must_use]
114 pub fn command(&self) -> &Path {
115 &self.command
116 }
117
118 #[must_use]
120 pub fn arguments(&self) -> &[String] {
121 &self.args
122 }
123
124 #[must_use]
126 pub fn environment(&self) -> &BTreeMap<String, String> {
127 &self.env
128 }
129}
130
131pub struct AcpAgent {
166 config: AcpAgentConfig,
167 debug_callback: Option<DebugCallback>,
168}
169
170impl std::fmt::Debug for AcpAgent {
171 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172 f.debug_struct("AcpAgent")
173 .field("config", &self.config)
174 .field(
175 "debug_callback",
176 &self.debug_callback.as_ref().map(|_| "..."),
177 )
178 .finish()
179 }
180}
181
182impl AcpAgent {
183 #[must_use]
185 pub fn new(config: AcpAgentConfig) -> Self {
186 Self {
187 config,
188 debug_callback: None,
189 }
190 }
191
192 #[must_use]
195 pub fn claude_agent() -> Self {
196 Self::from_str("npx -y @agentclientprotocol/claude-agent-acp@latest")
197 .expect("valid bash command")
198 }
199
200 #[must_use]
203 pub fn codex() -> Self {
204 Self::from_str("npx -y @agentclientprotocol/codex-acp@latest").expect("valid bash command")
205 }
206
207 #[must_use]
209 pub fn config(&self) -> &AcpAgentConfig {
210 &self.config
211 }
212
213 #[must_use]
215 pub fn into_config(self) -> AcpAgentConfig {
216 self.config
217 }
218
219 #[must_use]
237 pub fn with_debug<F>(mut self, callback: F) -> Self
238 where
239 F: Fn(&str, LineDirection) + Send + Sync + 'static,
240 {
241 self.debug_callback = Some(Arc::new(callback));
242 self
243 }
244
245 pub fn spawn_process(
251 &self,
252 ) -> Result<
253 (
254 async_process::ChildStdin,
255 async_process::ChildStdout,
256 async_process::ChildStderr,
257 Child,
258 ),
259 crate::Error,
260 > {
261 let mut std_cmd = std::process::Command::new(&self.config.command);
262 std_cmd.args(&self.config.args);
263 std_cmd.envs(&self.config.env);
264 #[cfg(unix)]
265 {
266 use std::os::unix::process::CommandExt as _;
267
268 std_cmd.process_group(0);
275 }
276 let mut cmd = async_process::Command::from(std_cmd);
277 #[cfg(windows)]
278 {
279 use async_process::windows::CommandExt as _;
280
281 cmd.creation_flags(windows_sys::Win32::System::Threading::CREATE_NO_WINDOW);
282 }
283 cmd.stdin(std::process::Stdio::piped())
284 .stdout(std::process::Stdio::piped())
285 .stderr(std::process::Stdio::piped());
286
287 let mut child = cmd.spawn().map_err(crate::Error::into_internal_error)?;
288
289 let child_stdin = child
290 .stdin
291 .take()
292 .ok_or_else(|| crate::util::internal_error("Failed to open stdin"))?;
293 let child_stdout = child
294 .stdout
295 .take()
296 .ok_or_else(|| crate::util::internal_error("Failed to open stdout"))?;
297 let child_stderr = child
298 .stderr
299 .take()
300 .ok_or_else(|| crate::util::internal_error("Failed to open stderr"))?;
301
302 Ok((child_stdin, child_stdout, child_stderr, child))
303 }
304}
305
306struct ChildGuard(Child);
309
310impl ChildGuard {
311 async fn wait(&mut self) -> std::io::Result<std::process::ExitStatus> {
312 self.0.status().await
313 }
314
315 fn terminate(&mut self) {
316 #[cfg(unix)]
323 if let Some(pid) = rustix::process::Pid::from_raw(self.0.id().cast_signed()) {
324 let _result = rustix::process::kill_process_group(pid, rustix::process::Signal::KILL);
325 }
326 drop(self.0.kill());
329 }
330}
331
332impl Drop for ChildGuard {
333 fn drop(&mut self) {
334 self.terminate();
335 }
336}
337
338#[derive(Default)]
339struct StderrTail {
340 bytes: VecDeque<u8>,
341 truncated: bool,
342}
343
344impl StderrTail {
345 fn push(&mut self, bytes: &[u8]) {
346 if bytes.len() >= STDERR_CAPTURE_LIMIT {
347 self.truncated |= !self.bytes.is_empty() || bytes.len() > STDERR_CAPTURE_LIMIT;
348 self.bytes.clear();
349 self.bytes
350 .extend(bytes[bytes.len() - STDERR_CAPTURE_LIMIT..].iter().copied());
351 return;
352 }
353
354 let overflow = self
355 .bytes
356 .len()
357 .saturating_add(bytes.len())
358 .saturating_sub(STDERR_CAPTURE_LIMIT);
359 if overflow > 0 {
360 self.truncated = true;
361 drop(self.bytes.drain(..overflow));
362 }
363 self.bytes.extend(bytes.iter().copied());
364 }
365
366 fn into_string(mut self) -> String {
367 let truncated = self.truncated;
368 let stderr = String::from_utf8_lossy(self.bytes.make_contiguous());
369 if truncated {
370 format!("[stderr truncated; showing last {STDERR_CAPTURE_LIMIT} bytes]\n{stderr}")
371 } else {
372 stderr.into_owned()
373 }
374 }
375}
376
377#[derive(Default)]
378struct StderrDebugLines {
379 current: Vec<u8>,
380 truncated: bool,
381 pending_carriage_return: bool,
382}
383
384impl StderrDebugLines {
385 fn push(&mut self, bytes: &[u8], callback: &DebugCallback) {
386 for &byte in bytes {
387 if self.pending_carriage_return {
388 if byte == b'\n' {
389 self.pending_carriage_return = false;
390 self.emit(callback);
391 continue;
392 }
393
394 self.push_byte(b'\r');
395 self.pending_carriage_return = false;
396 }
397
398 match byte {
399 b'\r' => self.pending_carriage_return = true,
400 b'\n' => self.emit(callback),
401 byte => self.push_byte(byte),
402 }
403 }
404 }
405
406 fn finish(&mut self, callback: &DebugCallback) {
407 if self.pending_carriage_return {
408 self.push_byte(b'\r');
409 self.pending_carriage_return = false;
410 }
411 if !self.current.is_empty() || self.truncated {
412 self.emit(callback);
413 }
414 }
415
416 fn push_byte(&mut self, byte: u8) {
417 if self.current.len() < STDERR_CAPTURE_LIMIT {
418 self.current.push(byte);
419 } else {
420 self.truncated = true;
421 }
422 }
423
424 fn emit(&mut self, callback: &DebugCallback) {
425 let line = String::from_utf8_lossy(&self.current);
426
427 if self.truncated {
428 let mut line = line.into_owned();
429 line.push_str(STDERR_LINE_TRUNCATION_MARKER);
430 callback(&line, LineDirection::Stderr);
431 } else {
432 callback(line.as_ref(), LineDirection::Stderr);
433 }
434
435 self.current.clear();
436 self.truncated = false;
437 }
438}
439
440struct StderrDrainResult {
441 captured: String,
442 read_error: Option<std::io::Error>,
443}
444
445async fn drain_stderr(
446 mut stderr: impl futures::AsyncRead + Unpin,
447 debug_callback: Option<DebugCallback>,
448) -> StderrDrainResult {
449 use futures::AsyncReadExt as _;
450
451 let mut tail = StderrTail::default();
452 let mut debug_lines = debug_callback.as_ref().map(|_| StderrDebugLines::default());
453 let mut buffer = [0; STDERR_READ_BUFFER_SIZE];
454
455 let read_error = loop {
456 match stderr.read(&mut buffer).await {
457 Ok(0) => break None,
458 Ok(read) => {
459 let bytes = &buffer[..read];
460 tail.push(bytes);
461 if let (Some(lines), Some(callback)) =
462 (debug_lines.as_mut(), debug_callback.as_ref())
463 {
464 lines.push(bytes, callback);
465 }
466 }
467 Err(error) => break Some(error),
468 }
469 };
470
471 if let (Some(lines), Some(callback)) = (debug_lines.as_mut(), debug_callback.as_ref()) {
472 lines.finish(callback);
473 }
474
475 StderrDrainResult {
476 captured: tail.into_string(),
477 read_error,
478 }
479}
480
481struct ExitedChild {
482 guard: ChildGuard,
483 status: std::process::ExitStatus,
484 stderr_rx: futures::channel::oneshot::Receiver<String>,
485}
486
487async fn wait_for_child(
490 mut guard: ChildGuard,
491 stderr_rx: futures::channel::oneshot::Receiver<String>,
492) -> Result<ExitedChild, crate::Error> {
493 let status = guard
494 .wait()
495 .await
496 .map_err(|e| crate::util::internal_error(format!("Failed to wait for process: {e}")))?;
497
498 Ok(ExitedChild {
499 guard,
500 status,
501 stderr_rx,
502 })
503}
504
505async fn finish_child_exit(child: ExitedChild) -> Result<(), crate::Error> {
508 let ExitedChild {
509 mut guard,
510 status,
511 stderr_rx,
512 } = child;
513
514 guard.terminate();
517
518 if status.success() {
519 Ok(())
520 } else {
521 let stderr =
522 match futures::future::select(stderr_rx, async_io::Timer::after(SHUTDOWN_GRACE_PERIOD))
523 .await
524 {
525 futures::future::Either::Left((stderr, _)) => stderr.unwrap_or_default(),
526 futures::future::Either::Right((_, stderr_rx)) => {
527 tracing::debug!(
528 grace = ?SHUTDOWN_GRACE_PERIOD,
529 "Agent stderr remained open after process exit; reporting status without it"
530 );
531 drop(stderr_rx);
532 String::new()
533 }
534 };
535
536 let message = if stderr.is_empty() {
537 format!("Process exited with {status}")
538 } else {
539 format!("Process exited with {status}: {stderr}")
540 };
541
542 Err(crate::util::internal_error(message))
543 }
544}
545
546async fn await_protocol_shutdown_after_successful_child_exit<F>(
547 protocol_future: F,
548 grace: Duration,
549) -> Result<(), crate::Error>
550where
551 F: std::future::Future<Output = Result<(), crate::Error>> + Unpin,
552{
553 match futures::future::select(protocol_future, async_io::Timer::after(grace)).await {
554 futures::future::Either::Left((result, _)) => result,
555 futures::future::Either::Right((_, protocol_future)) => {
556 tracing::debug!(
557 ?grace,
558 "Protocol transport remained open after successful agent process exit; stopping it"
559 );
560 drop(protocol_future);
561 Ok(())
562 }
563 }
564}
565
566async fn write_line_with_shutdown_timeout<W>(
567 writer: &mut W,
568 line: String,
569 stdout_eof_rx: &mut Option<futures::channel::oneshot::Receiver<()>>,
570 stdout_eof_seen: &mut bool,
571 grace: Duration,
572) -> std::io::Result<()>
573where
574 W: futures::AsyncWrite + Unpin + ?Sized,
575{
576 let write = Box::pin(crate::jsonrpc::write_line(writer, line));
577
578 if *stdout_eof_seen {
579 return await_write_during_shutdown(write, grace).await;
580 }
581
582 let Some(stdout_eof) = stdout_eof_rx.as_mut() else {
583 return write.await;
584 };
585
586 match futures::future::select(write, stdout_eof).await {
587 futures::future::Either::Left((result, _)) => result,
588 futures::future::Either::Right((stdout_eof, write)) => {
589 *stdout_eof_rx = None;
590 if stdout_eof.is_err() {
591 return write.await;
594 }
595
596 *stdout_eof_seen = true;
597 await_write_during_shutdown(write, grace).await
598 }
599 }
600}
601
602async fn await_write_during_shutdown<F>(write: F, grace: Duration) -> std::io::Result<()>
603where
604 F: std::future::Future<Output = std::io::Result<()>> + Unpin,
605{
606 match futures::future::select(write, async_io::Timer::after(grace)).await {
607 futures::future::Either::Left((result, _)) => result,
608 futures::future::Either::Right((_, write)) => {
609 tracing::debug!(
610 ?grace,
611 "Pending protocol output did not drain after agent stdout closed"
612 );
613 drop(write);
614 Err(std::io::Error::new(
615 std::io::ErrorKind::TimedOut,
616 format!(
617 "Agent closed its protocol output but pending protocol output did not drain within {grace:?}"
618 ),
619 ))
620 }
621 }
622}
623
624pub trait AcpAgentCounterpartRole: Role {}
626
627impl AcpAgentCounterpartRole for Client {}
628
629impl AcpAgentCounterpartRole for Conductor {}
630
631impl<Counterpart: AcpAgentCounterpartRole> crate::ConnectTo<Counterpart> for AcpAgent {
632 async fn connect_to(
633 self,
634 client: impl crate::ConnectTo<Counterpart::Counterpart>,
635 ) -> Result<(), crate::Error> {
636 use futures::io::BufReader;
637 use futures::{AsyncBufReadExt, StreamExt};
638
639 let (child_stdin, child_stdout, child_stderr, child) = self.spawn_process()?;
640
641 let (stderr_tx, stderr_rx) = futures::channel::oneshot::channel::<String>();
643
644 let debug_callback = self.debug_callback.clone();
648 let stderr_future = async move {
649 let StderrDrainResult {
650 captured,
651 read_error,
652 } = drain_stderr(child_stderr, debug_callback).await;
653 drop(stderr_tx.send(captured));
654
655 if let Some(error) = read_error {
656 tracing::warn!(
657 ?error,
658 "Failed to read process stderr; stderr will no longer be captured"
659 );
660 }
661 };
662
663 let child_wait = wait_for_child(ChildGuard(child), stderr_rx);
666
667 let incoming_lines: std::pin::Pin<
669 Box<dyn futures::Stream<Item = std::io::Result<String>> + Send>,
670 > = if let Some(callback) = self.debug_callback.clone() {
671 Box::pin(BufReader::new(child_stdout).lines().inspect(move |result| {
672 if let Ok(line) = result {
673 callback(line, LineDirection::Stdout);
674 }
675 }))
676 } else {
677 Box::pin(BufReader::new(child_stdout).lines())
678 };
679
680 let (stdout_eof_tx, stdout_eof_rx) = futures::channel::oneshot::channel();
685 let mut stdout_eof_tx = Some(stdout_eof_tx);
686 let mut incoming_lines = incoming_lines;
687 let incoming_lines = Box::pin(futures::stream::poll_fn(move |cx| {
688 let next = incoming_lines.as_mut().poll_next(cx);
689 if matches!(next, std::task::Poll::Ready(None))
690 && let Some(stdout_eof_tx) = stdout_eof_tx.take()
691 {
692 let _ = stdout_eof_tx.send(());
693 }
694 next
695 }));
696
697 let outgoing_sink: std::pin::Pin<
699 Box<dyn futures::Sink<String, Error = std::io::Error> + Send>,
700 > = Box::pin(futures::sink::unfold(
701 (
702 child_stdin,
703 self.debug_callback.clone(),
704 Some(stdout_eof_rx),
705 false,
706 ),
707 async move |(mut writer, callback, mut stdout_eof_rx, mut stdout_eof_seen),
708 line: String| {
709 if let Some(callback) = callback.as_ref() {
710 callback(&line, LineDirection::Stdin);
711 }
712 write_line_with_shutdown_timeout(
713 &mut writer,
714 line,
715 &mut stdout_eof_rx,
716 &mut stdout_eof_seen,
717 SHUTDOWN_GRACE_PERIOD,
718 )
719 .await?;
720 Ok::<_, std::io::Error>((writer, callback, stdout_eof_rx, stdout_eof_seen))
721 },
722 ));
723
724 let protocol_future = crate::ConnectTo::<Counterpart>::connect_to(
727 crate::Lines::new(outgoing_sink, incoming_lines),
728 client,
729 );
730
731 let stderr_future = pin!(stderr_future);
732 let protocol_future = Box::pin(protocol_future);
733 let child_wait = Box::pin(child_wait);
734
735 let main_race = async {
740 match futures::future::select(protocol_future, child_wait).await {
741 futures::future::Either::Left((result, child_wait)) => {
742 result?;
743 match futures::future::select(
744 child_wait,
745 async_io::Timer::after(SHUTDOWN_GRACE_PERIOD),
746 )
747 .await
748 {
749 futures::future::Either::Left((child, _)) => {
750 finish_child_exit(child?).await
751 }
752 futures::future::Either::Right((_, child_wait)) => {
753 tracing::debug!(
754 grace = ?SHUTDOWN_GRACE_PERIOD,
755 "Agent process did not exit after protocol shutdown; terminating it"
756 );
757 drop(child_wait);
758 Ok(())
759 }
760 }
761 }
762 futures::future::Either::Right((child, protocol_future)) => {
763 finish_child_exit(child?).await?;
764 await_protocol_shutdown_after_successful_child_exit(
765 protocol_future,
766 SHUTDOWN_GRACE_PERIOD,
767 )
768 .await
769 }
770 }
771 };
772
773 let main_race = pin!(main_race);
776 match futures::future::select(main_race, stderr_future).await {
777 futures::future::Either::Left((result, _)) => result,
778 futures::future::Either::Right(((), main_race)) => main_race.await,
779 }
780 }
781}
782
783impl AcpAgent {
784 pub fn from_args<I, T>(args: I) -> Result<Self, crate::Error>
802 where
803 I: IntoIterator<Item = T>,
804 T: ToString,
805 {
806 let args: Vec<String> = args.into_iter().map(|s| s.to_string()).collect();
807
808 if args.is_empty() {
809 return Err(crate::util::internal_error("Arguments cannot be empty"));
810 }
811
812 let mut env = BTreeMap::new();
813 let mut command_idx = 0;
814
815 for (i, arg) in args.iter().enumerate() {
816 if let Some((name, value)) = parse_env_var(arg) {
817 env.insert(name, value);
818 command_idx = i + 1;
819 } else {
820 break;
821 }
822 }
823
824 if command_idx >= args.len() {
825 return Err(crate::util::internal_error(
826 "No command found (only environment variables provided)",
827 ));
828 }
829
830 let command = PathBuf::from(&args[command_idx]);
831 let cmd_args = args[command_idx + 1..].to_vec();
832
833 Ok(Self::new(
834 AcpAgentConfig::new(command).args(cmd_args).envs(env),
835 ))
836 }
837}
838
839fn parse_env_var(s: &str) -> Option<(String, String)> {
841 let eq_pos = s.find('=')?;
842 if eq_pos == 0 {
843 return None;
844 }
845
846 let name = &s[..eq_pos];
847 let value = &s[eq_pos + 1..];
848
849 let mut chars = name.chars();
850 let first = chars.next()?;
851 if !first.is_ascii_alphabetic() && first != '_' {
852 return None;
853 }
854 if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_') {
855 return None;
856 }
857
858 Some((name.to_string(), value.to_string()))
859}
860
861impl FromStr for AcpAgent {
862 type Err = crate::Error;
863
864 fn from_str(s: &str) -> Result<Self, Self::Err> {
865 let trimmed = s.trim();
866
867 if trimmed.starts_with('{') {
868 let config = serde_json::from_str(trimmed)
869 .map_err(|e| crate::util::internal_error(format!("Failed to parse JSON: {e}")))?;
870 return Ok(Self::new(config));
871 }
872
873 let parts = shell_words::split(trimmed)
874 .map_err(|e| crate::util::internal_error(format!("Failed to parse command: {e}")))?;
875
876 Self::from_args(parts)
877 }
878}
879
880#[cfg(test)]
881mod tests {
882 use super::*;
883 use std::sync::Mutex;
884 use std::sync::atomic::{AtomicUsize, Ordering};
885
886 fn recording_debug_callback() -> (DebugCallback, Arc<Mutex<Vec<String>>>) {
887 let lines = Arc::new(Mutex::new(Vec::new()));
888 let recorded = lines.clone();
889 let callback = Arc::new(move |line: &str, direction| {
890 assert_eq!(direction, LineDirection::Stderr);
891 recorded.lock().unwrap().push(line.to_owned());
892 });
893 (callback, lines)
894 }
895
896 #[test]
897 fn stderr_tail_keeps_last_bytes() {
898 let initial = vec![b'a'; STDERR_CAPTURE_LIMIT];
899
900 let mut exact = StderrTail::default();
901 exact.push(&initial);
902 assert_eq!(exact.into_string(), String::from_utf8(initial).unwrap());
903
904 let mut truncated = StderrTail::default();
905 truncated.push(&vec![b'a'; STDERR_CAPTURE_LIMIT]);
906 truncated.push(b"the end");
907 let captured = truncated.into_string();
908 let (notice, tail) = captured.split_once('\n').unwrap();
909 assert_eq!(
910 notice,
911 format!("[stderr truncated; showing last {STDERR_CAPTURE_LIMIT} bytes]")
912 );
913 assert_eq!(tail.len(), STDERR_CAPTURE_LIMIT);
914 assert!(tail.ends_with("the end"));
915 }
916
917 #[test]
918 fn stderr_debug_callback_preserves_lines() {
919 let (callback, recorded) = recording_debug_callback();
920 let mut lines = StderrDebugLines::default();
921
922 lines.push(b"one\r", &callback);
923 lines.push(b"\n\ntw", &callback);
924 lines.push(b"o\nbad\xff\nlast\r", &callback);
925 lines.finish(&callback);
926
927 assert_eq!(
928 *recorded.lock().unwrap(),
929 ["one", "", "two", "bad\u{fffd}", "last\r"]
930 );
931 }
932
933 #[test]
934 fn stderr_debug_callback_truncates_oversized_lines() {
935 let (callback, recorded) = recording_debug_callback();
936 let mut lines = StderrDebugLines::default();
937 let exact = vec![b'y'; STDERR_CAPTURE_LIMIT];
938 let oversized = vec![b'x'; STDERR_CAPTURE_LIMIT + 1];
939
940 lines.push(&exact, &callback);
941 lines.push(b"\r\n", &callback);
942 lines.push(&oversized, &callback);
943 assert_eq!(lines.current.len(), STDERR_CAPTURE_LIMIT);
944 assert!(lines.truncated);
945 lines.push(b"\nnext\n", &callback);
946
947 let recorded = recorded.lock().unwrap();
948 assert_eq!(recorded.len(), 3);
949 assert_eq!(recorded[0].len(), STDERR_CAPTURE_LIMIT);
950 assert!(!recorded[0].ends_with(STDERR_LINE_TRUNCATION_MARKER));
951 assert_eq!(
952 recorded[1].len(),
953 STDERR_CAPTURE_LIMIT + STDERR_LINE_TRUNCATION_MARKER.len()
954 );
955 assert!(recorded[1].ends_with(STDERR_LINE_TRUNCATION_MARKER));
956 assert_eq!(recorded[2], "next");
957 }
958
959 struct ErrorAfterData {
960 polls: Arc<AtomicUsize>,
961 }
962
963 impl futures::AsyncRead for ErrorAfterData {
964 fn poll_read(
965 self: std::pin::Pin<&mut Self>,
966 _cx: &mut std::task::Context<'_>,
967 buffer: &mut [u8],
968 ) -> std::task::Poll<std::io::Result<usize>> {
969 match self.polls.fetch_add(1, Ordering::SeqCst) {
970 0 => {
971 buffer[..7].copy_from_slice(b"partial");
972 std::task::Poll::Ready(Ok(7))
973 }
974 1 => std::task::Poll::Ready(Err(std::io::Error::other("read failed"))),
975 _ => panic!("stderr reader was polled again after an error"),
976 }
977 }
978 }
979
980 #[tokio::test]
981 async fn stderr_drain_stops_after_read_error() {
982 let polls = Arc::new(AtomicUsize::new(0));
983 let (callback, recorded) = recording_debug_callback();
984
985 let result = drain_stderr(
986 ErrorAfterData {
987 polls: polls.clone(),
988 },
989 Some(callback),
990 )
991 .await;
992
993 assert_eq!(result.captured, "partial");
994 assert_eq!(result.read_error.unwrap().to_string(), "read failed");
995 assert_eq!(polls.load(Ordering::SeqCst), 2);
996 assert_eq!(*recorded.lock().unwrap(), ["partial"]);
997 }
998
999 #[tokio::test]
1000 async fn successful_child_exit_bounds_protocol_shutdown_cleanly() {
1001 let grace = std::time::Duration::from_millis(10);
1002 tokio::time::timeout(
1003 std::time::Duration::from_secs(1),
1004 await_protocol_shutdown_after_successful_child_exit(
1005 futures::future::pending::<Result<(), crate::Error>>(),
1006 grace,
1007 ),
1008 )
1009 .await
1010 .expect("protocol shutdown wait should be bounded")
1011 .expect("a successful child exit should stop the pending protocol cleanly");
1012 }
1013
1014 #[tokio::test]
1015 async fn successful_child_exit_preserves_ready_protocol_error() {
1016 let error = await_protocol_shutdown_after_successful_child_exit(
1017 futures::future::ready(Err(crate::util::internal_error(
1018 "protocol failed during shutdown",
1019 ))),
1020 std::time::Duration::from_secs(1),
1021 )
1022 .await
1023 .expect_err("a ready protocol error should remain authoritative");
1024 let detail = error
1025 .data
1026 .as_ref()
1027 .and_then(serde_json::Value::as_str)
1028 .unwrap_or_default();
1029
1030 assert!(
1031 detail.contains("protocol failed during shutdown"),
1032 "unexpected protocol error: {error:?}"
1033 );
1034 }
1035
1036 #[cfg(unix)]
1037 #[tokio::test]
1038 async fn large_unterminated_stderr_is_fully_drained() {
1039 let agent = AcpAgent::from_args([
1040 "/bin/sh",
1041 "-c",
1042 r#"i=0; while [ "$i" -lt 4096 ]; do printf '%01024d' 0; i=$((i + 1)); done >&2; printf ACP_END >&2; exit 17"#,
1043 ])
1044 .unwrap();
1045 let (child_stdin, child_stdout, child_stderr, child) = agent.spawn_process().unwrap();
1046 drop(child_stdin);
1047 drop(child_stdout);
1048 let mut guard = ChildGuard(child);
1049
1050 let (drained, status) = tokio::time::timeout(std::time::Duration::from_secs(10), async {
1051 futures::join!(drain_stderr(child_stderr, None), guard.wait())
1052 })
1053 .await
1054 .expect("stderr drain should not block after its retained tail is full");
1055
1056 assert_eq!(status.unwrap().code(), Some(17));
1057 assert!(drained.read_error.is_none());
1058 let (notice, tail) = drained.captured.split_once('\n').unwrap();
1059 assert_eq!(
1060 notice,
1061 format!("[stderr truncated; showing last {STDERR_CAPTURE_LIMIT} bytes]")
1062 );
1063 assert_eq!(tail.len(), STDERR_CAPTURE_LIMIT);
1064 assert!(tail.ends_with("ACP_END"));
1065 }
1066
1067 #[cfg(unix)]
1068 #[tokio::test]
1069 async fn protocol_eof_still_reports_nonzero_child_exit() {
1070 let agent = AcpAgent::from_args([
1071 "/bin/sh",
1072 "-c",
1073 "exec 1>&-; cat >/dev/null; printf ACP_TEST_FAILURE_AFTER_STDOUT_EOF >&2; exit 17",
1074 ])
1075 .unwrap();
1076
1077 let error = tokio::time::timeout(
1078 std::time::Duration::from_secs(5),
1079 Client.builder().connect_to(agent),
1080 )
1081 .await
1082 .expect("connection should finish after the child exits")
1083 .expect_err("nonzero child exit after protocol EOF should be reported");
1084 let detail = error
1085 .data
1086 .as_ref()
1087 .map(serde_json::Value::to_string)
1088 .unwrap_or_default();
1089
1090 assert!(
1091 detail.contains("exit status: 17"),
1092 "child exit status should be preserved: {error:?}"
1093 );
1094 assert!(
1095 detail.contains("ACP_TEST_FAILURE_AFTER_STDOUT_EOF"),
1096 "child stderr should be preserved: {error:?}"
1097 );
1098 }
1099
1100 #[cfg(unix)]
1101 #[tokio::test]
1102 async fn successful_child_exit_does_not_cancel_active_foreground() {
1103 let agent = AcpAgent::from_args(["/bin/sh", "-c", "exit 0"]).unwrap();
1104 let (started_tx, started_rx) = futures::channel::oneshot::channel();
1105 let (closed_tx, closed_rx) = futures::channel::oneshot::channel();
1106 let (close_release_tx, close_release_rx) = futures::channel::oneshot::channel();
1107 let (release_tx, release_rx) = futures::channel::oneshot::channel();
1108 let connection = tokio::spawn(
1109 Client
1110 .builder()
1111 .on_close(async move |_cx| {
1112 closed_tx.send(()).map_err(|()| {
1113 crate::Error::internal_error().data("close observer dropped")
1114 })?;
1115 close_release_rx.await.map_err(|_| {
1116 crate::Error::internal_error().data("close callback release dropped")
1117 })
1118 })
1119 .connect_with(agent, async move |_cx| {
1120 started_tx.send(()).map_err(|()| {
1121 crate::Error::internal_error().data("foreground observer dropped")
1122 })?;
1123 release_rx.await.map_err(|_| {
1124 crate::Error::internal_error().data("foreground release dropped")
1125 })
1126 }),
1127 );
1128
1129 tokio::time::timeout(std::time::Duration::from_secs(5), started_rx)
1130 .await
1131 .expect("foreground should start")
1132 .expect("foreground should report that it started");
1133
1134 tokio::time::timeout(std::time::Duration::from_secs(5), closed_rx)
1135 .await
1136 .expect("successful child exit should close the protocol transport")
1137 .expect("successful child exit should invoke close callbacks");
1138
1139 tokio::time::sleep(SHUTDOWN_GRACE_PERIOD + std::time::Duration::from_millis(250)).await;
1140 assert!(
1141 !connection.is_finished(),
1142 "successful child exit canceled active cleanup"
1143 );
1144
1145 close_release_tx
1146 .send(())
1147 .expect("clean child exit should preserve close callbacks");
1148 release_tx
1149 .send(())
1150 .expect("clean child exit should preserve the foreground");
1151 tokio::time::timeout(std::time::Duration::from_secs(5), connection)
1152 .await
1153 .expect("released foreground should finish")
1154 .expect("connection task should not panic")
1155 .expect("successful child exit should remain a clean EOF");
1156 }
1157
1158 #[cfg(unix)]
1159 struct KillOnDrop(Option<rustix::process::Pid>);
1160
1161 #[cfg(unix)]
1162 impl KillOnDrop {
1163 fn disarm(&mut self) {
1164 self.0 = None;
1165 }
1166 }
1167
1168 #[cfg(unix)]
1169 impl Drop for KillOnDrop {
1170 fn drop(&mut self) {
1171 if let Some(pid) = self.0 {
1172 let _result = rustix::process::kill_process(pid, rustix::process::Signal::KILL);
1173 }
1174 }
1175 }
1176
1177 #[cfg(unix)]
1178 fn wrapper_agent(script: &str) -> (AcpAgent, tokio::sync::mpsc::UnboundedReceiver<String>) {
1179 let (pid_tx, pid_rx) = tokio::sync::mpsc::unbounded_channel();
1180 let agent = AcpAgent::from_args(["/bin/sh", "-c", script])
1181 .unwrap()
1182 .with_debug(move |line, direction| {
1183 if direction == LineDirection::Stderr {
1184 drop(pid_tx.send(line.to_owned()));
1185 }
1186 });
1187 (agent, pid_rx)
1188 }
1189
1190 #[cfg(unix)]
1191 fn process_is_running(pid: rustix::process::Pid) -> bool {
1192 if rustix::process::test_kill_process(pid).is_err() {
1193 return false;
1194 }
1195
1196 match std::process::Command::new("ps")
1199 .args(["-o", "stat=", "-p", &pid.to_string()])
1200 .output()
1201 {
1202 Ok(output) if output.status.success() => {
1203 let state = String::from_utf8_lossy(&output.stdout);
1204 !state.trim().is_empty() && !state.trim_start().starts_with('Z')
1205 }
1206 Ok(_) => false,
1207 Err(_) => true,
1208 }
1209 }
1210
1211 #[cfg(unix)]
1212 async fn reported_descendant_pid(
1213 connection: &mut futures::future::BoxFuture<'static, Result<(), crate::Error>>,
1214 pid_rx: &mut tokio::sync::mpsc::UnboundedReceiver<String>,
1215 ) -> rustix::process::Pid {
1216 tokio::time::timeout(std::time::Duration::from_secs(5), async {
1217 loop {
1218 tokio::select! {
1219 biased;
1220 line = pid_rx.recv() => {
1221 let line = line.expect("wrapper stderr should remain open");
1222 if let Some(pid) = line.strip_prefix("ACP_TEST_CHILD_PID=") {
1223 let pid = pid.parse::<i32>().expect("valid descendant PID");
1224 break rustix::process::Pid::from_raw(pid)
1225 .expect("nonzero descendant PID");
1226 }
1227 }
1228 result = &mut *connection => {
1229 panic!("agent connection exited before reporting descendant PID: {result:?}");
1230 }
1231 }
1232 }
1233 })
1234 .await
1235 .expect("wrapper should report descendant PID")
1236 }
1237
1238 #[cfg(unix)]
1239 async fn assert_process_exits(pid: rustix::process::Pid) {
1240 let exited = tokio::time::timeout(std::time::Duration::from_secs(5), async {
1241 while process_is_running(pid) {
1242 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1243 }
1244 })
1245 .await
1246 .is_ok();
1247 assert!(exited, "descendant process {pid} remained alive");
1248 }
1249
1250 #[cfg(unix)]
1251 #[tokio::test]
1252 async fn protocol_eof_terminates_a_child_that_does_not_exit() {
1253 let (agent, mut pid_rx) =
1254 wrapper_agent("echo ACP_TEST_CHILD_PID=$$ >&2; exec 1>&-; while :; do sleep 30; done");
1255 let mut connection: futures::future::BoxFuture<'static, Result<(), crate::Error>> =
1256 Box::pin(Client.builder().connect_to(agent));
1257 let child_pid = reported_descendant_pid(&mut connection, &mut pid_rx).await;
1258 let mut cleanup = KillOnDrop(Some(child_pid));
1259
1260 assert!(process_is_running(child_pid));
1261 tokio::time::timeout(std::time::Duration::from_secs(5), &mut connection)
1262 .await
1263 .expect("protocol shutdown should bound its child-exit wait")
1264 .expect("clean protocol shutdown should terminate a non-exiting child");
1265 assert_process_exits(child_pid).await;
1266 cleanup.disarm();
1267 }
1268
1269 #[cfg(unix)]
1270 #[tokio::test]
1271 async fn protocol_eof_bounds_a_blocked_outgoing_drain() {
1272 let (agent, mut pid_rx) = wrapper_agent(
1273 "echo ACP_TEST_CHILD_PID=$$ >&2; exec 1>&-; sleep 30 & child=$!; wait \"$child\"",
1274 );
1275 let (channel, mut connection) = crate::ConnectTo::<Client>::into_channel_and_future(agent);
1276 let crate::Channel {
1277 rx: _incoming,
1278 tx: outgoing,
1279 } = channel;
1280
1281 let response = crate::RawJsonRpcMessage::response(
1282 crate::schema::v1::RequestId::Number(1),
1283 Ok(serde_json::json!({ "payload": "x".repeat(4 * 1024 * 1024) })),
1284 );
1285 outgoing
1286 .unbounded_send(crate::TransportFrame::Single(response))
1287 .expect("response should be accepted before the connection starts");
1288 outgoing.close_channel();
1289
1290 let child_pid = reported_descendant_pid(&mut connection, &mut pid_rx).await;
1291 let mut cleanup = KillOnDrop(Some(child_pid));
1292
1293 let error = tokio::time::timeout(std::time::Duration::from_secs(5), &mut connection)
1294 .await
1295 .expect("stdout EOF should bound a blocked outgoing drain")
1296 .expect_err("an undelivered accepted response must not report success");
1297 let detail = error
1298 .data
1299 .as_ref()
1300 .and_then(serde_json::Value::as_str)
1301 .unwrap_or_default();
1302 assert!(
1303 detail.contains("pending protocol output did not drain"),
1304 "the error should identify the blocked outgoing drain: {error:?}"
1305 );
1306
1307 assert_process_exits(child_pid).await;
1308 cleanup.disarm();
1309 }
1310
1311 #[cfg(unix)]
1312 #[tokio::test]
1313 async fn test_connection_drop_kills_wrapper_descendant() {
1314 let (agent, mut pid_rx) = wrapper_agent(
1315 "sleep 30 & child=$!; echo ACP_TEST_CHILD_PID=$child >&2; wait \"$child\"",
1316 );
1317 let (_channel, mut connection) = crate::ConnectTo::<Client>::into_channel_and_future(agent);
1318 let descendant_pid = reported_descendant_pid(&mut connection, &mut pid_rx).await;
1319 let mut cleanup = KillOnDrop(Some(descendant_pid));
1320
1321 assert!(process_is_running(descendant_pid));
1322 drop(connection);
1323 assert_process_exits(descendant_pid).await;
1324 cleanup.disarm();
1325 }
1326
1327 #[cfg(unix)]
1328 #[tokio::test]
1329 async fn test_launcher_exit_kills_descendant_before_stderr_wait() {
1330 let (agent, mut pid_rx) = wrapper_agent(
1331 "sh -c 'trap \"\" HUP; exec sleep 30' >/dev/null & child=$!; echo ACP_TEST_CHILD_PID=$child >&2; exit 17",
1332 );
1333 let (_channel, mut connection) = crate::ConnectTo::<Client>::into_channel_and_future(agent);
1334 let descendant_pid = reported_descendant_pid(&mut connection, &mut pid_rx).await;
1335 let mut cleanup = KillOnDrop(Some(descendant_pid));
1336
1337 let result = tokio::time::timeout(std::time::Duration::from_secs(5), &mut connection)
1338 .await
1339 .expect("connection should observe the launcher exit");
1340 let error = result.expect_err("nonzero launcher exit should be an error");
1341 let detail = error
1342 .data
1343 .as_ref()
1344 .and_then(serde_json::Value::as_str)
1345 .unwrap_or_default();
1346 assert!(
1347 detail.contains("ACP_TEST_CHILD_PID="),
1348 "launcher stderr should be preserved: {error:?}"
1349 );
1350 assert_process_exits(descendant_pid).await;
1351 cleanup.disarm();
1352 }
1353
1354 #[test]
1355 fn test_parse_simple_command() {
1356 let agent = AcpAgent::from_str("python agent.py").unwrap();
1357 let config = agent.config();
1358 assert_eq!(config.command(), Path::new("python"));
1359 assert_eq!(config.arguments(), ["agent.py"]);
1360 assert!(config.environment().is_empty());
1361 }
1362
1363 #[test]
1364 fn test_parse_environment_from_args() {
1365 let agent =
1366 AcpAgent::from_args(["RUST_LOG=debug", "NO_COLOR=1", "python", "agent.py"]).unwrap();
1367 let config = agent.config();
1368
1369 assert_eq!(config.command(), Path::new("python"));
1370 assert_eq!(config.arguments(), ["agent.py"]);
1371 assert_eq!(
1372 config.environment(),
1373 &BTreeMap::from([
1374 ("NO_COLOR".to_owned(), "1".to_owned()),
1375 ("RUST_LOG".to_owned(), "debug".to_owned()),
1376 ])
1377 );
1378 }
1379
1380 #[test]
1381 fn test_new_accepts_agent_configuration() {
1382 let config = AcpAgentConfig::new("/usr/bin/agent")
1383 .arg("--verbose")
1384 .env("RUST_LOG", "debug");
1385 let agent = AcpAgent::new(config.clone());
1386
1387 assert_eq!(agent.into_config(), config);
1388 }
1389
1390 #[test]
1391 fn test_parse_command_with_args() {
1392 let agent = AcpAgent::from_str("node server.js --port 8080 --verbose").unwrap();
1393 let config = agent.config();
1394 assert_eq!(config.command(), Path::new("node"));
1395 assert_eq!(
1396 config.arguments(),
1397 ["server.js", "--port", "8080", "--verbose"]
1398 );
1399 assert!(config.environment().is_empty());
1400 }
1401
1402 #[test]
1403 fn test_parse_command_with_quotes() {
1404 let agent = AcpAgent::from_str(r#"python "my agent.py" --name "Test Agent""#).unwrap();
1405 let config = agent.into_config();
1406 assert_eq!(config.command(), Path::new("python"));
1407 assert_eq!(config.arguments(), ["my agent.py", "--name", "Test Agent"]);
1408 assert!(config.environment().is_empty());
1409 }
1410
1411 #[test]
1412 fn test_parse_json_config() {
1413 let json = r#"{
1414 "command": "/usr/bin/python",
1415 "args": ["agent.py", "--verbose"],
1416 "env": {"RUST_LOG": "debug"}
1417 }"#;
1418 let agent = AcpAgent::from_str(json).unwrap();
1419 let config = agent.config();
1420 assert_eq!(config.command(), Path::new("/usr/bin/python"));
1421 assert_eq!(config.arguments(), ["agent.py", "--verbose"]);
1422 assert_eq!(
1423 config.environment().get("RUST_LOG").map(String::as_str),
1424 Some("debug")
1425 );
1426 }
1427
1428 #[test]
1429 fn test_config_json_round_trip() {
1430 let config = AcpAgentConfig::new("agent")
1431 .args(["--mode", "fast"])
1432 .envs([("RUST_LOG", "debug"), ("NO_COLOR", "1")]);
1433
1434 let json = serde_json::to_value(&config).unwrap();
1435 assert_eq!(
1436 json,
1437 serde_json::json!({
1438 "command": "agent",
1439 "args": ["--mode", "fast"],
1440 "env": {
1441 "NO_COLOR": "1",
1442 "RUST_LOG": "debug"
1443 }
1444 })
1445 );
1446 assert_eq!(
1447 serde_json::from_value::<AcpAgentConfig>(json).unwrap(),
1448 config
1449 );
1450 }
1451
1452 #[test]
1453 fn test_config_json_defaults_and_omits_empty_collections() {
1454 let config = serde_json::from_value::<AcpAgentConfig>(serde_json::json!({
1455 "command": "agent"
1456 }))
1457 .unwrap();
1458
1459 assert!(config.arguments().is_empty());
1460 assert!(config.environment().is_empty());
1461 assert_eq!(
1462 serde_json::to_value(config).unwrap(),
1463 serde_json::json!({ "command": "agent" })
1464 );
1465 }
1466
1467 #[test]
1468 fn test_reject_mcp_server_json() {
1469 let json = r#"{
1470 "type": "stdio",
1471 "name": "my-agent",
1472 "command": "/usr/bin/python",
1473 "args": ["agent.py"],
1474 "env": []
1475 }"#;
1476 let error = AcpAgent::from_str(json).unwrap_err();
1477 assert!(
1478 error
1479 .data
1480 .as_ref()
1481 .and_then(serde_json::Value::as_str)
1482 .is_some_and(|message| message.contains("unknown field")),
1483 "unexpected error: {error:?}"
1484 );
1485 }
1486}