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