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