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