Skip to main content

agent_client_protocol/
acp_agent.rs

1//! Utilities for connecting to ACP agents and proxies.
2//!
3//! This module provides [`AcpAgent`], a convenient component for launching an
4//! ACP agent subprocess from an [`AcpAgentConfig`], command string, or JSON
5//! configuration.
6
7use std::collections::{BTreeMap, VecDeque};
8use std::path::{Path, PathBuf};
9use std::str::FromStr;
10use std::sync::Arc;
11use std::time::Duration;
12
13use async_process::Child;
14use serde::{Deserialize, Serialize};
15use std::pin::pin;
16
17use crate::{Client, Conductor, Role};
18
19type DebugCallback = Arc<dyn Fn(&str, LineDirection) + Send + Sync + 'static>;
20
21const STDERR_CAPTURE_LIMIT: usize = 64 * 1024;
22const STDERR_READ_BUFFER_SIZE: usize = 8 * 1024;
23const STDERR_LINE_TRUNCATION_MARKER: &str = "… [stderr line truncated]";
24const SHUTDOWN_GRACE_PERIOD: Duration = Duration::from_secs(1);
25
26/// Direction of a line being sent or received.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum LineDirection {
29    /// Line being sent to the agent (stdin)
30    Stdin,
31    /// Line being received from the agent (stdout)
32    Stdout,
33    /// Line being received from the agent (stderr)
34    Stderr,
35}
36
37/// Configuration for launching an ACP agent subprocess.
38///
39/// This is local SDK configuration, not an ACP wire-protocol type. It contains
40/// only the values used to launch the child process.
41///
42/// ```
43/// use agent_client_protocol::{AcpAgent, AcpAgentConfig};
44///
45/// let agent = AcpAgent::new(
46///     AcpAgentConfig::new("python")
47///         .arg("agent.py")
48///         .env("RUST_LOG", "debug"),
49/// );
50/// ```
51#[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    /// Create a configuration for the given executable or command name.
63    #[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    /// Append one command-line argument.
73    #[must_use]
74    pub fn arg(mut self, arg: impl Into<String>) -> Self {
75        self.args.push(arg.into());
76        self
77    }
78
79    /// Append command-line arguments.
80    #[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    /// Set one environment variable for the child process.
91    #[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    /// Set environment variables for the child process.
98    #[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    /// The executable path or command name.
113    #[must_use]
114    pub fn command(&self) -> &Path {
115        &self.command
116    }
117
118    /// Command-line arguments passed to the executable.
119    #[must_use]
120    pub fn arguments(&self) -> &[String] {
121        &self.args
122    }
123
124    /// Environment variables set for the child process.
125    #[must_use]
126    pub fn environment(&self) -> &BTreeMap<String, String> {
127        &self.env
128    }
129}
130
131/// A component representing an external ACP agent running in a separate process.
132///
133/// `AcpAgent` implements the [`ConnectTo`](`crate::ConnectTo`) trait for spawning and communicating with
134/// external agents or proxies via stdio. It handles process spawning, stream setup, and
135/// byte stream serialization automatically. This is the primary way to connect to agents
136/// that run as separate executables.
137///
138/// The launch configuration is independent from ACP wire-schema types and can
139/// be parsed from command-line strings or JSON configurations.
140/// On Unix, dropping an active connection terminates the spawned process group, including agents
141/// started through wrapper commands such as `npx` and `uvx`.
142///
143/// # Use Cases
144///
145/// - **External agents**: Connect to agents written in any language (Python, Node.js, Rust, etc.)
146/// - **Proxy chains**: Spawn intermediate proxies that transform or intercept messages
147/// - **Conductor components**: Use with the conductor to build proxy chains
148/// - **Subprocess isolation**: Run potentially untrusted code in a separate process
149///
150/// # Examples
151///
152/// Parse from a command string:
153/// ```
154/// # use agent_client_protocol::AcpAgent;
155/// # use std::str::FromStr;
156/// let agent = AcpAgent::from_str("python my_agent.py --verbose").unwrap();
157/// ```
158///
159/// Parse from JSON:
160/// ```
161/// # use agent_client_protocol::AcpAgent;
162/// # use std::str::FromStr;
163/// let agent = AcpAgent::from_str(r#"{"command": "python", "args": ["my_agent.py"], "env": {"RUST_LOG": "info"}}"#).unwrap();
164/// ```
165pub struct AcpAgent {
166    config: AcpAgentConfig,
167    debug_callback: Option<DebugCallback>,
168}
169
170impl std::fmt::Debug for AcpAgent {
171    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172        f.debug_struct("AcpAgent")
173            .field("config", &self.config)
174            .field(
175                "debug_callback",
176                &self.debug_callback.as_ref().map(|_| "..."),
177            )
178            .finish()
179    }
180}
181
182impl AcpAgent {
183    /// Create an ACP agent from its process-launch configuration.
184    #[must_use]
185    pub fn new(config: AcpAgentConfig) -> Self {
186        Self {
187            config,
188            debug_callback: None,
189        }
190    }
191
192    /// Create an ACP agent for the Claude Agent adapter.
193    /// Just runs `npx -y @agentclientprotocol/claude-agent-acp@latest`.
194    #[must_use]
195    pub fn claude_agent() -> Self {
196        Self::from_str("npx -y @agentclientprotocol/claude-agent-acp@latest")
197            .expect("valid bash command")
198    }
199
200    /// Create an ACP agent for the Codex adapter.
201    /// Just runs `npx -y @agentclientprotocol/codex-acp@latest`.
202    #[must_use]
203    pub fn codex() -> Self {
204        Self::from_str("npx -y @agentclientprotocol/codex-acp@latest").expect("valid bash command")
205    }
206
207    /// Get the process-launch configuration.
208    #[must_use]
209    pub fn config(&self) -> &AcpAgentConfig {
210        &self.config
211    }
212
213    /// Convert into the process-launch configuration.
214    #[must_use]
215    pub fn into_config(self) -> AcpAgentConfig {
216        self.config
217    }
218
219    /// Add a debug callback that will be invoked for each line sent/received.
220    ///
221    /// The callback receives the line content and the direction (stdin/stdout/stderr).
222    /// This is useful for logging, debugging, or monitoring agent communication.
223    /// Exceptionally long stderr lines are truncated to keep memory usage bounded.
224    ///
225    /// # Example
226    ///
227    /// ```no_run
228    /// # use agent_client_protocol::{AcpAgent, LineDirection};
229    /// # use std::str::FromStr;
230    /// let agent = AcpAgent::from_str("python my_agent.py")
231    ///     .unwrap()
232    ///     .with_debug(|line, direction| {
233    ///         eprintln!("{:?}: {}", direction, line);
234    ///     });
235    /// ```
236    #[must_use]
237    pub fn with_debug<F>(mut self, callback: F) -> Self
238    where
239        F: Fn(&str, LineDirection) + Send + Sync + 'static,
240    {
241        self.debug_callback = Some(Arc::new(callback));
242        self
243    }
244
245    /// Spawn the configured process and return its stdio streams and raw child handle.
246    ///
247    /// This is a low-level escape hatch. The caller owns the returned child process and is
248    /// responsible for terminating it. Connections created through [`crate::ConnectTo`] instead
249    /// install a guard that tears down the spawned process group on Unix.
250    pub fn spawn_process(
251        &self,
252    ) -> Result<
253        (
254            async_process::ChildStdin,
255            async_process::ChildStdout,
256            async_process::ChildStderr,
257            Child,
258        ),
259        crate::Error,
260    > {
261        let mut std_cmd = std::process::Command::new(&self.config.command);
262        std_cmd.args(&self.config.args);
263        std_cmd.envs(&self.config.env);
264        #[cfg(unix)]
265        {
266            use std::os::unix::process::CommandExt as _;
267
268            // Make the child the leader of its own process group so
269            // `ChildGuard` can terminate the entire process tree.
270            // Agents are commonly distributed behind wrapper launchers
271            // (`npx …`, `uvx …`): killing only the immediate child
272            // orphans the real agent, which re-parents to pid 1 and
273            // does not reliably exit on stdin EOF.
274            std_cmd.process_group(0);
275        }
276        let mut cmd = async_process::Command::from(std_cmd);
277        #[cfg(windows)]
278        {
279            use async_process::windows::CommandExt as _;
280
281            cmd.creation_flags(windows_sys::Win32::System::Threading::CREATE_NO_WINDOW);
282        }
283        cmd.stdin(std::process::Stdio::piped())
284            .stdout(std::process::Stdio::piped())
285            .stderr(std::process::Stdio::piped());
286
287        let mut child = cmd.spawn().map_err(crate::Error::into_internal_error)?;
288
289        let child_stdin = child
290            .stdin
291            .take()
292            .ok_or_else(|| crate::util::internal_error("Failed to open stdin"))?;
293        let child_stdout = child
294            .stdout
295            .take()
296            .ok_or_else(|| crate::util::internal_error("Failed to open stdout"))?;
297        let child_stderr = child
298            .stderr
299            .take()
300            .ok_or_else(|| crate::util::internal_error("Failed to open stderr"))?;
301
302        Ok((child_stdin, child_stdout, child_stderr, child))
303    }
304}
305
306/// A wrapper around Child that kills the process — and, on unix, its whole
307/// process group (see `spawn_process`) — when dropped.
308struct ChildGuard(Child);
309
310impl ChildGuard {
311    async fn wait(&mut self) -> std::io::Result<std::process::ExitStatus> {
312        self.0.status().await
313    }
314
315    fn terminate(&mut self) {
316        // SIGKILL the child's process group first: the child was spawned as
317        // its own group leader, so this reaches grandchildren spawned by
318        // wrapper launchers (`npx → node`, `uvx → python`). This also covers
319        // the case where the direct child already exited but its wrapper left
320        // the real agent running. An error (e.g. `ESRCH`) just means the
321        // group is already gone.
322        #[cfg(unix)]
323        if let Some(pid) = rustix::process::Pid::from_raw(self.0.id().cast_signed()) {
324            let _result = rustix::process::kill_process_group(pid, rustix::process::Signal::KILL);
325        }
326        // Fallback for platforms without group semantics (and a no-op double
327        // tap on unix).
328        drop(self.0.kill());
329    }
330}
331
332impl Drop for ChildGuard {
333    fn drop(&mut self) {
334        self.terminate();
335    }
336}
337
338#[derive(Default)]
339struct StderrTail {
340    bytes: VecDeque<u8>,
341    truncated: bool,
342}
343
344impl StderrTail {
345    fn push(&mut self, bytes: &[u8]) {
346        if bytes.len() >= STDERR_CAPTURE_LIMIT {
347            self.truncated |= !self.bytes.is_empty() || bytes.len() > STDERR_CAPTURE_LIMIT;
348            self.bytes.clear();
349            self.bytes
350                .extend(bytes[bytes.len() - STDERR_CAPTURE_LIMIT..].iter().copied());
351            return;
352        }
353
354        let overflow = self
355            .bytes
356            .len()
357            .saturating_add(bytes.len())
358            .saturating_sub(STDERR_CAPTURE_LIMIT);
359        if overflow > 0 {
360            self.truncated = true;
361            drop(self.bytes.drain(..overflow));
362        }
363        self.bytes.extend(bytes.iter().copied());
364    }
365
366    fn into_string(mut self) -> String {
367        let truncated = self.truncated;
368        let stderr = String::from_utf8_lossy(self.bytes.make_contiguous());
369        if truncated {
370            format!("[stderr truncated; showing last {STDERR_CAPTURE_LIMIT} bytes]\n{stderr}")
371        } else {
372            stderr.into_owned()
373        }
374    }
375}
376
377#[derive(Default)]
378struct StderrDebugLines {
379    current: Vec<u8>,
380    truncated: bool,
381    pending_carriage_return: bool,
382}
383
384impl StderrDebugLines {
385    fn push(&mut self, bytes: &[u8], callback: &DebugCallback) {
386        for &byte in bytes {
387            if self.pending_carriage_return {
388                if byte == b'\n' {
389                    self.pending_carriage_return = false;
390                    self.emit(callback);
391                    continue;
392                }
393
394                self.push_byte(b'\r');
395                self.pending_carriage_return = false;
396            }
397
398            match byte {
399                b'\r' => self.pending_carriage_return = true,
400                b'\n' => self.emit(callback),
401                byte => self.push_byte(byte),
402            }
403        }
404    }
405
406    fn finish(&mut self, callback: &DebugCallback) {
407        if self.pending_carriage_return {
408            self.push_byte(b'\r');
409            self.pending_carriage_return = false;
410        }
411        if !self.current.is_empty() || self.truncated {
412            self.emit(callback);
413        }
414    }
415
416    fn push_byte(&mut self, byte: u8) {
417        if self.current.len() < STDERR_CAPTURE_LIMIT {
418            self.current.push(byte);
419        } else {
420            self.truncated = true;
421        }
422    }
423
424    fn emit(&mut self, callback: &DebugCallback) {
425        let line = String::from_utf8_lossy(&self.current);
426
427        if self.truncated {
428            let mut line = line.into_owned();
429            line.push_str(STDERR_LINE_TRUNCATION_MARKER);
430            callback(&line, LineDirection::Stderr);
431        } else {
432            callback(line.as_ref(), LineDirection::Stderr);
433        }
434
435        self.current.clear();
436        self.truncated = false;
437    }
438}
439
440struct StderrDrainResult {
441    captured: String,
442    read_error: Option<std::io::Error>,
443}
444
445async fn drain_stderr(
446    mut stderr: impl futures::AsyncRead + Unpin,
447    debug_callback: Option<DebugCallback>,
448) -> StderrDrainResult {
449    use futures::AsyncReadExt as _;
450
451    let mut tail = StderrTail::default();
452    let mut debug_lines = debug_callback.as_ref().map(|_| StderrDebugLines::default());
453    let mut buffer = [0; STDERR_READ_BUFFER_SIZE];
454
455    let read_error = loop {
456        match stderr.read(&mut buffer).await {
457            Ok(0) => break None,
458            Ok(read) => {
459                let bytes = &buffer[..read];
460                tail.push(bytes);
461                if let (Some(lines), Some(callback)) =
462                    (debug_lines.as_mut(), debug_callback.as_ref())
463                {
464                    lines.push(bytes, callback);
465                }
466            }
467            Err(error) => break Some(error),
468        }
469    };
470
471    if let (Some(lines), Some(callback)) = (debug_lines.as_mut(), debug_callback.as_ref()) {
472        lines.finish(callback);
473    }
474
475    StderrDrainResult {
476        captured: tail.into_string(),
477        read_error,
478    }
479}
480
481struct ExitedChild {
482    guard: ChildGuard,
483    status: std::process::ExitStatus,
484    stderr_rx: futures::channel::oneshot::Receiver<String>,
485}
486
487/// Waits for the direct child process while retaining its process-group guard
488/// and stderr receiver for exit reporting.
489async fn wait_for_child(
490    mut guard: ChildGuard,
491    stderr_rx: futures::channel::oneshot::Receiver<String>,
492) -> Result<ExitedChild, crate::Error> {
493    let status = guard
494        .wait()
495        .await
496        .map_err(|e| crate::util::internal_error(format!("Failed to wait for process: {e}")))?;
497
498    Ok(ExitedChild {
499        guard,
500        status,
501        stderr_rx,
502    })
503}
504
505/// Reports an observed child exit, including a bounded stderr tail for a
506/// nonzero status.
507async fn finish_child_exit(child: ExitedChild) -> Result<(), crate::Error> {
508    let ExitedChild {
509        mut guard,
510        status,
511        stderr_rx,
512    } = child;
513
514    // A launcher may exit while a descendant remains alive holding inherited
515    // stdio. Terminate the rest of the group before waiting for stderr EOF.
516    guard.terminate();
517
518    if status.success() {
519        Ok(())
520    } else {
521        let stderr =
522            match futures::future::select(stderr_rx, async_io::Timer::after(SHUTDOWN_GRACE_PERIOD))
523                .await
524            {
525                futures::future::Either::Left((stderr, _)) => stderr.unwrap_or_default(),
526                futures::future::Either::Right((_, stderr_rx)) => {
527                    tracing::debug!(
528                        grace = ?SHUTDOWN_GRACE_PERIOD,
529                        "Agent stderr remained open after process exit; reporting status without it"
530                    );
531                    drop(stderr_rx);
532                    String::new()
533                }
534            };
535
536        let message = if stderr.is_empty() {
537            format!("Process exited with {status}")
538        } else {
539            format!("Process exited with {status}: {stderr}")
540        };
541
542        Err(crate::util::internal_error(message))
543    }
544}
545
546async fn await_protocol_shutdown_after_successful_child_exit<F>(
547    protocol_future: F,
548    grace: Duration,
549) -> Result<(), crate::Error>
550where
551    F: std::future::Future<Output = Result<(), crate::Error>> + Unpin,
552{
553    match futures::future::select(protocol_future, async_io::Timer::after(grace)).await {
554        futures::future::Either::Left((result, _)) => result,
555        futures::future::Either::Right((_, protocol_future)) => {
556            tracing::debug!(
557                ?grace,
558                "Protocol transport remained open after successful agent process exit; stopping it"
559            );
560            drop(protocol_future);
561            Ok(())
562        }
563    }
564}
565
566async fn write_line_with_shutdown_timeout<W>(
567    writer: &mut W,
568    line: String,
569    stdout_eof_rx: &mut Option<futures::channel::oneshot::Receiver<()>>,
570    stdout_eof_seen: &mut bool,
571    grace: Duration,
572) -> std::io::Result<()>
573where
574    W: futures::AsyncWrite + Unpin + ?Sized,
575{
576    let write = Box::pin(crate::jsonrpc::write_line(writer, line));
577
578    if *stdout_eof_seen {
579        return await_write_during_shutdown(write, grace).await;
580    }
581
582    let Some(stdout_eof) = stdout_eof_rx.as_mut() else {
583        return write.await;
584    };
585
586    match futures::future::select(write, stdout_eof).await {
587        futures::future::Either::Left((result, _)) => result,
588        futures::future::Either::Right((stdout_eof, write)) => {
589            *stdout_eof_rx = None;
590            if stdout_eof.is_err() {
591                // Dropping the incoming stream cancels the signal. Only an
592                // explicit send represents a clean EOF.
593                return write.await;
594            }
595
596            *stdout_eof_seen = true;
597            await_write_during_shutdown(write, grace).await
598        }
599    }
600}
601
602async fn await_write_during_shutdown<F>(write: F, grace: Duration) -> std::io::Result<()>
603where
604    F: std::future::Future<Output = std::io::Result<()>> + Unpin,
605{
606    match futures::future::select(write, async_io::Timer::after(grace)).await {
607        futures::future::Either::Left((result, _)) => result,
608        futures::future::Either::Right((_, write)) => {
609            tracing::debug!(
610                ?grace,
611                "Pending protocol output did not drain after agent stdout closed"
612            );
613            drop(write);
614            Err(std::io::Error::new(
615                std::io::ErrorKind::TimedOut,
616                format!(
617                    "Agent closed its protocol output but pending protocol output did not drain within {grace:?}"
618                ),
619            ))
620        }
621    }
622}
623
624/// Roles that an ACP agent executable can potentially serve.
625pub trait AcpAgentCounterpartRole: Role {}
626
627impl AcpAgentCounterpartRole for Client {}
628
629impl AcpAgentCounterpartRole for Conductor {}
630
631impl<Counterpart: AcpAgentCounterpartRole> crate::ConnectTo<Counterpart> for AcpAgent {
632    async fn connect_to(
633        self,
634        client: impl crate::ConnectTo<Counterpart::Counterpart>,
635    ) -> Result<(), crate::Error> {
636        use futures::io::BufReader;
637        use futures::{AsyncBufReadExt, StreamExt};
638
639        let (child_stdin, child_stdout, child_stderr, child) = self.spawn_process()?;
640
641        // Create a channel to collect stderr for error reporting
642        let (stderr_tx, stderr_rx) = futures::channel::oneshot::channel::<String>();
643
644        // Read stderr concurrently, optionally calling the debug callback.
645        // We use futures::future::select below to race this against the protocol,
646        // so this runs as part of the same task — no tokio::spawn needed.
647        let debug_callback = self.debug_callback.clone();
648        let stderr_future = async move {
649            let StderrDrainResult {
650                captured,
651                read_error,
652            } = drain_stderr(child_stderr, debug_callback).await;
653            drop(stderr_tx.send(captured));
654
655            if let Some(error) = read_error {
656                tracing::warn!(
657                    ?error,
658                    "Failed to read process stderr; stderr will no longer be captured"
659                );
660            }
661        };
662
663        // Create the guard eagerly so cancelling this connection before the
664        // monitor is first polled still terminates the whole process group.
665        let child_wait = wait_for_child(ChildGuard(child), stderr_rx);
666
667        // Convert stdio to line streams with optional debug inspection.
668        let incoming_lines: std::pin::Pin<
669            Box<dyn futures::Stream<Item = std::io::Result<String>> + Send>,
670        > = if let Some(callback) = self.debug_callback.clone() {
671            Box::pin(BufReader::new(child_stdout).lines().inspect(move |result| {
672                if let Ok(line) = result {
673                    callback(line, LineDirection::Stdout);
674                }
675            }))
676        } else {
677            Box::pin(BufReader::new(child_stdout).lines())
678        };
679
680        // The JSON-RPC transport keeps polling stdout while it drains stdin.
681        // Signal physical EOF so a child that half-closes stdout and stops
682        // reading cannot hold a final write open forever. Dropping this stream
683        // merely cancels the signal and is not treated as EOF.
684        let (stdout_eof_tx, stdout_eof_rx) = futures::channel::oneshot::channel();
685        let mut stdout_eof_tx = Some(stdout_eof_tx);
686        let mut incoming_lines = incoming_lines;
687        let incoming_lines = Box::pin(futures::stream::poll_fn(move |cx| {
688            let next = incoming_lines.as_mut().poll_next(cx);
689            if matches!(next, std::task::Poll::Ready(None))
690                && let Some(stdout_eof_tx) = stdout_eof_tx.take()
691            {
692                let _ = stdout_eof_tx.send(());
693            }
694            next
695        }));
696
697        // Create a sink that writes lines (with newlines) to stdin with optional debug logging
698        let outgoing_sink: std::pin::Pin<
699            Box<dyn futures::Sink<String, Error = std::io::Error> + Send>,
700        > = Box::pin(futures::sink::unfold(
701            (
702                child_stdin,
703                self.debug_callback.clone(),
704                Some(stdout_eof_rx),
705                false,
706            ),
707            async move |(mut writer, callback, mut stdout_eof_rx, mut stdout_eof_seen),
708                        line: String| {
709                if let Some(callback) = callback.as_ref() {
710                    callback(&line, LineDirection::Stdin);
711                }
712                write_line_with_shutdown_timeout(
713                    &mut writer,
714                    line,
715                    &mut stdout_eof_rx,
716                    &mut stdout_eof_seen,
717                    SHUTDOWN_GRACE_PERIOD,
718                )
719                .await?;
720                Ok::<_, std::io::Error>((writer, callback, stdout_eof_rx, stdout_eof_seen))
721            },
722        ));
723
724        // Race the protocol against child process exit.
725        // Also run stderr collection concurrently.
726        let protocol_future = crate::ConnectTo::<Counterpart>::connect_to(
727            crate::Lines::new(outgoing_sink, incoming_lines),
728            client,
729        );
730
731        let stderr_future = pin!(stderr_future);
732        let protocol_future = Box::pin(protocol_future);
733        let child_wait = Box::pin(child_wait);
734
735        // Run stderr reader alongside the main race. Errors still stop the
736        // connection immediately. After protocol shutdown succeeds, give the
737        // child a bounded grace period so delayed failures remain observable
738        // without letting a non-exiting launcher hang shutdown forever.
739        let main_race = async {
740            match futures::future::select(protocol_future, child_wait).await {
741                futures::future::Either::Left((result, child_wait)) => {
742                    result?;
743                    match futures::future::select(
744                        child_wait,
745                        async_io::Timer::after(SHUTDOWN_GRACE_PERIOD),
746                    )
747                    .await
748                    {
749                        futures::future::Either::Left((child, _)) => {
750                            finish_child_exit(child?).await
751                        }
752                        futures::future::Either::Right((_, child_wait)) => {
753                            tracing::debug!(
754                                grace = ?SHUTDOWN_GRACE_PERIOD,
755                                "Agent process did not exit after protocol shutdown; terminating it"
756                            );
757                            drop(child_wait);
758                            Ok(())
759                        }
760                    }
761                }
762                futures::future::Either::Right((child, protocol_future)) => {
763                    finish_child_exit(child?).await?;
764                    await_protocol_shutdown_after_successful_child_exit(
765                        protocol_future,
766                        SHUTDOWN_GRACE_PERIOD,
767                    )
768                    .await
769                }
770            }
771        };
772
773        // Run stderr collection concurrently with the main logic.
774        // When main_race completes, we don't need stderr anymore.
775        let main_race = pin!(main_race);
776        match futures::future::select(main_race, stderr_future).await {
777            futures::future::Either::Left((result, _)) => result,
778            futures::future::Either::Right(((), main_race)) => main_race.await,
779        }
780    }
781}
782
783impl AcpAgent {
784    /// Create an `AcpAgent` from an iterator of command-line arguments.
785    ///
786    /// Leading arguments of the form `NAME=value` are parsed as environment variables.
787    /// The first non-env argument is the command, and the rest are arguments.
788    ///
789    /// # Example
790    ///
791    /// ```
792    /// # use agent_client_protocol::AcpAgent;
793    /// let agent = AcpAgent::from_args([
794    ///     "RUST_LOG=debug",
795    ///     "cargo",
796    ///     "run",
797    ///     "-p",
798    ///     "my-crate",
799    /// ]).unwrap();
800    /// ```
801    pub fn from_args<I, T>(args: I) -> Result<Self, crate::Error>
802    where
803        I: IntoIterator<Item = T>,
804        T: ToString,
805    {
806        let args: Vec<String> = args.into_iter().map(|s| s.to_string()).collect();
807
808        if args.is_empty() {
809            return Err(crate::util::internal_error("Arguments cannot be empty"));
810        }
811
812        let mut env = BTreeMap::new();
813        let mut command_idx = 0;
814
815        for (i, arg) in args.iter().enumerate() {
816            if let Some((name, value)) = parse_env_var(arg) {
817                env.insert(name, value);
818                command_idx = i + 1;
819            } else {
820                break;
821            }
822        }
823
824        if command_idx >= args.len() {
825            return Err(crate::util::internal_error(
826                "No command found (only environment variables provided)",
827            ));
828        }
829
830        let command = PathBuf::from(&args[command_idx]);
831        let cmd_args = args[command_idx + 1..].to_vec();
832
833        Ok(Self::new(
834            AcpAgentConfig::new(command).args(cmd_args).envs(env),
835        ))
836    }
837}
838
839/// Parse a string as an environment variable assignment (NAME=value).
840fn parse_env_var(s: &str) -> Option<(String, String)> {
841    let eq_pos = s.find('=')?;
842    if eq_pos == 0 {
843        return None;
844    }
845
846    let name = &s[..eq_pos];
847    let value = &s[eq_pos + 1..];
848
849    let mut chars = name.chars();
850    let first = chars.next()?;
851    if !first.is_ascii_alphabetic() && first != '_' {
852        return None;
853    }
854    if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_') {
855        return None;
856    }
857
858    Some((name.to_string(), value.to_string()))
859}
860
861impl FromStr for AcpAgent {
862    type Err = crate::Error;
863
864    fn from_str(s: &str) -> Result<Self, Self::Err> {
865        let trimmed = s.trim();
866
867        if trimmed.starts_with('{') {
868            let config = serde_json::from_str(trimmed)
869                .map_err(|e| crate::util::internal_error(format!("Failed to parse JSON: {e}")))?;
870            return Ok(Self::new(config));
871        }
872
873        let parts = shell_words::split(trimmed)
874            .map_err(|e| crate::util::internal_error(format!("Failed to parse command: {e}")))?;
875
876        Self::from_args(parts)
877    }
878}
879
880#[cfg(test)]
881mod tests {
882    use super::*;
883    use std::sync::Mutex;
884    use std::sync::atomic::{AtomicUsize, Ordering};
885
886    fn recording_debug_callback() -> (DebugCallback, Arc<Mutex<Vec<String>>>) {
887        let lines = Arc::new(Mutex::new(Vec::new()));
888        let recorded = lines.clone();
889        let callback = Arc::new(move |line: &str, direction| {
890            assert_eq!(direction, LineDirection::Stderr);
891            recorded.lock().unwrap().push(line.to_owned());
892        });
893        (callback, lines)
894    }
895
896    #[test]
897    fn stderr_tail_keeps_last_bytes() {
898        let initial = vec![b'a'; STDERR_CAPTURE_LIMIT];
899
900        let mut exact = StderrTail::default();
901        exact.push(&initial);
902        assert_eq!(exact.into_string(), String::from_utf8(initial).unwrap());
903
904        let mut truncated = StderrTail::default();
905        truncated.push(&vec![b'a'; STDERR_CAPTURE_LIMIT]);
906        truncated.push(b"the end");
907        let captured = truncated.into_string();
908        let (notice, tail) = captured.split_once('\n').unwrap();
909        assert_eq!(
910            notice,
911            format!("[stderr truncated; showing last {STDERR_CAPTURE_LIMIT} bytes]")
912        );
913        assert_eq!(tail.len(), STDERR_CAPTURE_LIMIT);
914        assert!(tail.ends_with("the end"));
915    }
916
917    #[test]
918    fn stderr_debug_callback_preserves_lines() {
919        let (callback, recorded) = recording_debug_callback();
920        let mut lines = StderrDebugLines::default();
921
922        lines.push(b"one\r", &callback);
923        lines.push(b"\n\ntw", &callback);
924        lines.push(b"o\nbad\xff\nlast\r", &callback);
925        lines.finish(&callback);
926
927        assert_eq!(
928            *recorded.lock().unwrap(),
929            ["one", "", "two", "bad\u{fffd}", "last\r"]
930        );
931    }
932
933    #[test]
934    fn stderr_debug_callback_truncates_oversized_lines() {
935        let (callback, recorded) = recording_debug_callback();
936        let mut lines = StderrDebugLines::default();
937        let exact = vec![b'y'; STDERR_CAPTURE_LIMIT];
938        let oversized = vec![b'x'; STDERR_CAPTURE_LIMIT + 1];
939
940        lines.push(&exact, &callback);
941        lines.push(b"\r\n", &callback);
942        lines.push(&oversized, &callback);
943        assert_eq!(lines.current.len(), STDERR_CAPTURE_LIMIT);
944        assert!(lines.truncated);
945        lines.push(b"\nnext\n", &callback);
946
947        let recorded = recorded.lock().unwrap();
948        assert_eq!(recorded.len(), 3);
949        assert_eq!(recorded[0].len(), STDERR_CAPTURE_LIMIT);
950        assert!(!recorded[0].ends_with(STDERR_LINE_TRUNCATION_MARKER));
951        assert_eq!(
952            recorded[1].len(),
953            STDERR_CAPTURE_LIMIT + STDERR_LINE_TRUNCATION_MARKER.len()
954        );
955        assert!(recorded[1].ends_with(STDERR_LINE_TRUNCATION_MARKER));
956        assert_eq!(recorded[2], "next");
957    }
958
959    struct ErrorAfterData {
960        polls: Arc<AtomicUsize>,
961    }
962
963    impl futures::AsyncRead for ErrorAfterData {
964        fn poll_read(
965            self: std::pin::Pin<&mut Self>,
966            _cx: &mut std::task::Context<'_>,
967            buffer: &mut [u8],
968        ) -> std::task::Poll<std::io::Result<usize>> {
969            match self.polls.fetch_add(1, Ordering::SeqCst) {
970                0 => {
971                    buffer[..7].copy_from_slice(b"partial");
972                    std::task::Poll::Ready(Ok(7))
973                }
974                1 => std::task::Poll::Ready(Err(std::io::Error::other("read failed"))),
975                _ => panic!("stderr reader was polled again after an error"),
976            }
977        }
978    }
979
980    #[tokio::test]
981    async fn stderr_drain_stops_after_read_error() {
982        let polls = Arc::new(AtomicUsize::new(0));
983        let (callback, recorded) = recording_debug_callback();
984
985        let result = drain_stderr(
986            ErrorAfterData {
987                polls: polls.clone(),
988            },
989            Some(callback),
990        )
991        .await;
992
993        assert_eq!(result.captured, "partial");
994        assert_eq!(result.read_error.unwrap().to_string(), "read failed");
995        assert_eq!(polls.load(Ordering::SeqCst), 2);
996        assert_eq!(*recorded.lock().unwrap(), ["partial"]);
997    }
998
999    #[tokio::test]
1000    async fn successful_child_exit_bounds_protocol_shutdown_cleanly() {
1001        let grace = std::time::Duration::from_millis(10);
1002        tokio::time::timeout(
1003            std::time::Duration::from_secs(1),
1004            await_protocol_shutdown_after_successful_child_exit(
1005                futures::future::pending::<Result<(), crate::Error>>(),
1006                grace,
1007            ),
1008        )
1009        .await
1010        .expect("protocol shutdown wait should be bounded")
1011        .expect("a successful child exit should stop the pending protocol cleanly");
1012    }
1013
1014    #[tokio::test]
1015    async fn successful_child_exit_preserves_ready_protocol_error() {
1016        let error = await_protocol_shutdown_after_successful_child_exit(
1017            futures::future::ready(Err(crate::util::internal_error(
1018                "protocol failed during shutdown",
1019            ))),
1020            std::time::Duration::from_secs(1),
1021        )
1022        .await
1023        .expect_err("a ready protocol error should remain authoritative");
1024        let detail = error
1025            .data
1026            .as_ref()
1027            .and_then(serde_json::Value::as_str)
1028            .unwrap_or_default();
1029
1030        assert!(
1031            detail.contains("protocol failed during shutdown"),
1032            "unexpected protocol error: {error:?}"
1033        );
1034    }
1035
1036    #[cfg(unix)]
1037    #[tokio::test]
1038    async fn large_unterminated_stderr_is_fully_drained() {
1039        let agent = AcpAgent::from_args([
1040            "/bin/sh",
1041            "-c",
1042            r#"i=0; while [ "$i" -lt 4096 ]; do printf '%01024d' 0; i=$((i + 1)); done >&2; printf ACP_END >&2; exit 17"#,
1043        ])
1044        .unwrap();
1045        let (child_stdin, child_stdout, child_stderr, child) = agent.spawn_process().unwrap();
1046        drop(child_stdin);
1047        drop(child_stdout);
1048        let mut guard = ChildGuard(child);
1049
1050        let (drained, status) = tokio::time::timeout(std::time::Duration::from_secs(10), async {
1051            futures::join!(drain_stderr(child_stderr, None), guard.wait())
1052        })
1053        .await
1054        .expect("stderr drain should not block after its retained tail is full");
1055
1056        assert_eq!(status.unwrap().code(), Some(17));
1057        assert!(drained.read_error.is_none());
1058        let (notice, tail) = drained.captured.split_once('\n').unwrap();
1059        assert_eq!(
1060            notice,
1061            format!("[stderr truncated; showing last {STDERR_CAPTURE_LIMIT} bytes]")
1062        );
1063        assert_eq!(tail.len(), STDERR_CAPTURE_LIMIT);
1064        assert!(tail.ends_with("ACP_END"));
1065    }
1066
1067    #[cfg(unix)]
1068    #[tokio::test]
1069    async fn protocol_eof_still_reports_nonzero_child_exit() {
1070        let agent = AcpAgent::from_args([
1071            "/bin/sh",
1072            "-c",
1073            "exec 1>&-; cat >/dev/null; printf ACP_TEST_FAILURE_AFTER_STDOUT_EOF >&2; exit 17",
1074        ])
1075        .unwrap();
1076
1077        let error = tokio::time::timeout(
1078            std::time::Duration::from_secs(5),
1079            Client.builder().connect_to(agent),
1080        )
1081        .await
1082        .expect("connection should finish after the child exits")
1083        .expect_err("nonzero child exit after protocol EOF should be reported");
1084        let detail = error
1085            .data
1086            .as_ref()
1087            .map(serde_json::Value::to_string)
1088            .unwrap_or_default();
1089
1090        assert!(
1091            detail.contains("exit status: 17"),
1092            "child exit status should be preserved: {error:?}"
1093        );
1094        assert!(
1095            detail.contains("ACP_TEST_FAILURE_AFTER_STDOUT_EOF"),
1096            "child stderr should be preserved: {error:?}"
1097        );
1098    }
1099
1100    #[cfg(unix)]
1101    #[tokio::test]
1102    async fn successful_child_exit_does_not_cancel_active_foreground() {
1103        let agent = AcpAgent::from_args(["/bin/sh", "-c", "exit 0"]).unwrap();
1104        let (started_tx, started_rx) = futures::channel::oneshot::channel();
1105        let (closed_tx, closed_rx) = futures::channel::oneshot::channel();
1106        let (close_release_tx, close_release_rx) = futures::channel::oneshot::channel();
1107        let (release_tx, release_rx) = futures::channel::oneshot::channel();
1108        let connection = tokio::spawn(
1109            Client
1110                .builder()
1111                .on_close(async move |_cx| {
1112                    closed_tx.send(()).map_err(|()| {
1113                        crate::Error::internal_error().data("close observer dropped")
1114                    })?;
1115                    close_release_rx.await.map_err(|_| {
1116                        crate::Error::internal_error().data("close callback release dropped")
1117                    })
1118                })
1119                .connect_with(agent, async move |_cx| {
1120                    started_tx.send(()).map_err(|()| {
1121                        crate::Error::internal_error().data("foreground observer dropped")
1122                    })?;
1123                    release_rx.await.map_err(|_| {
1124                        crate::Error::internal_error().data("foreground release dropped")
1125                    })
1126                }),
1127        );
1128
1129        tokio::time::timeout(std::time::Duration::from_secs(5), started_rx)
1130            .await
1131            .expect("foreground should start")
1132            .expect("foreground should report that it started");
1133
1134        tokio::time::timeout(std::time::Duration::from_secs(5), closed_rx)
1135            .await
1136            .expect("successful child exit should close the protocol transport")
1137            .expect("successful child exit should invoke close callbacks");
1138
1139        tokio::time::sleep(SHUTDOWN_GRACE_PERIOD + std::time::Duration::from_millis(250)).await;
1140        assert!(
1141            !connection.is_finished(),
1142            "successful child exit canceled active cleanup"
1143        );
1144
1145        close_release_tx
1146            .send(())
1147            .expect("clean child exit should preserve close callbacks");
1148        release_tx
1149            .send(())
1150            .expect("clean child exit should preserve the foreground");
1151        tokio::time::timeout(std::time::Duration::from_secs(5), connection)
1152            .await
1153            .expect("released foreground should finish")
1154            .expect("connection task should not panic")
1155            .expect("successful child exit should remain a clean EOF");
1156    }
1157
1158    #[cfg(unix)]
1159    struct KillOnDrop(Option<rustix::process::Pid>);
1160
1161    #[cfg(unix)]
1162    impl KillOnDrop {
1163        fn disarm(&mut self) {
1164            self.0 = None;
1165        }
1166    }
1167
1168    #[cfg(unix)]
1169    impl Drop for KillOnDrop {
1170        fn drop(&mut self) {
1171            if let Some(pid) = self.0 {
1172                let _result = rustix::process::kill_process(pid, rustix::process::Signal::KILL);
1173            }
1174        }
1175    }
1176
1177    #[cfg(unix)]
1178    fn wrapper_agent(script: &str) -> (AcpAgent, tokio::sync::mpsc::UnboundedReceiver<String>) {
1179        let (pid_tx, pid_rx) = tokio::sync::mpsc::unbounded_channel();
1180        let agent = AcpAgent::from_args(["/bin/sh", "-c", script])
1181            .unwrap()
1182            .with_debug(move |line, direction| {
1183                if direction == LineDirection::Stderr {
1184                    drop(pid_tx.send(line.to_owned()));
1185                }
1186            });
1187        (agent, pid_rx)
1188    }
1189
1190    #[cfg(unix)]
1191    fn process_is_running(pid: rustix::process::Pid) -> bool {
1192        if rustix::process::test_kill_process(pid).is_err() {
1193            return false;
1194        }
1195
1196        // A killed orphan can remain as a zombie under a container PID 1 that
1197        // does not reap promptly. Treat zombies as exited for this test.
1198        match std::process::Command::new("ps")
1199            .args(["-o", "stat=", "-p", &pid.to_string()])
1200            .output()
1201        {
1202            Ok(output) if output.status.success() => {
1203                let state = String::from_utf8_lossy(&output.stdout);
1204                !state.trim().is_empty() && !state.trim_start().starts_with('Z')
1205            }
1206            Ok(_) => false,
1207            Err(_) => true,
1208        }
1209    }
1210
1211    #[cfg(unix)]
1212    async fn reported_descendant_pid(
1213        connection: &mut futures::future::BoxFuture<'static, Result<(), crate::Error>>,
1214        pid_rx: &mut tokio::sync::mpsc::UnboundedReceiver<String>,
1215    ) -> rustix::process::Pid {
1216        tokio::time::timeout(std::time::Duration::from_secs(5), async {
1217            loop {
1218                tokio::select! {
1219                    biased;
1220                    line = pid_rx.recv() => {
1221                        let line = line.expect("wrapper stderr should remain open");
1222                        if let Some(pid) = line.strip_prefix("ACP_TEST_CHILD_PID=") {
1223                            let pid = pid.parse::<i32>().expect("valid descendant PID");
1224                            break rustix::process::Pid::from_raw(pid)
1225                                .expect("nonzero descendant PID");
1226                        }
1227                    }
1228                    result = &mut *connection => {
1229                        panic!("agent connection exited before reporting descendant PID: {result:?}");
1230                    }
1231                }
1232            }
1233        })
1234        .await
1235        .expect("wrapper should report descendant PID")
1236    }
1237
1238    #[cfg(unix)]
1239    async fn assert_process_exits(pid: rustix::process::Pid) {
1240        let exited = tokio::time::timeout(std::time::Duration::from_secs(5), async {
1241            while process_is_running(pid) {
1242                tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1243            }
1244        })
1245        .await
1246        .is_ok();
1247        assert!(exited, "descendant process {pid} remained alive");
1248    }
1249
1250    #[cfg(unix)]
1251    #[tokio::test]
1252    async fn protocol_eof_terminates_a_child_that_does_not_exit() {
1253        let (agent, mut pid_rx) =
1254            wrapper_agent("echo ACP_TEST_CHILD_PID=$$ >&2; exec 1>&-; while :; do sleep 30; done");
1255        let mut connection: futures::future::BoxFuture<'static, Result<(), crate::Error>> =
1256            Box::pin(Client.builder().connect_to(agent));
1257        let child_pid = reported_descendant_pid(&mut connection, &mut pid_rx).await;
1258        let mut cleanup = KillOnDrop(Some(child_pid));
1259
1260        assert!(process_is_running(child_pid));
1261        tokio::time::timeout(std::time::Duration::from_secs(5), &mut connection)
1262            .await
1263            .expect("protocol shutdown should bound its child-exit wait")
1264            .expect("clean protocol shutdown should terminate a non-exiting child");
1265        assert_process_exits(child_pid).await;
1266        cleanup.disarm();
1267    }
1268
1269    #[cfg(unix)]
1270    #[tokio::test]
1271    async fn protocol_eof_bounds_a_blocked_outgoing_drain() {
1272        let (agent, mut pid_rx) = wrapper_agent(
1273            "echo ACP_TEST_CHILD_PID=$$ >&2; exec 1>&-; sleep 30 & child=$!; wait \"$child\"",
1274        );
1275        let (channel, mut connection) = crate::ConnectTo::<Client>::into_channel_and_future(agent);
1276        let crate::Channel {
1277            rx: _incoming,
1278            tx: outgoing,
1279        } = channel;
1280
1281        let response = crate::RawJsonRpcMessage::response(
1282            crate::schema::v1::RequestId::Number(1),
1283            Ok(serde_json::json!({ "payload": "x".repeat(4 * 1024 * 1024) })),
1284        );
1285        outgoing
1286            .unbounded_send(crate::TransportFrame::Single(response))
1287            .expect("response should be accepted before the connection starts");
1288        outgoing.close_channel();
1289
1290        let child_pid = reported_descendant_pid(&mut connection, &mut pid_rx).await;
1291        let mut cleanup = KillOnDrop(Some(child_pid));
1292
1293        let error = tokio::time::timeout(std::time::Duration::from_secs(5), &mut connection)
1294            .await
1295            .expect("stdout EOF should bound a blocked outgoing drain")
1296            .expect_err("an undelivered accepted response must not report success");
1297        let detail = error
1298            .data
1299            .as_ref()
1300            .and_then(serde_json::Value::as_str)
1301            .unwrap_or_default();
1302        assert!(
1303            detail.contains("pending protocol output did not drain"),
1304            "the error should identify the blocked outgoing drain: {error:?}"
1305        );
1306
1307        assert_process_exits(child_pid).await;
1308        cleanup.disarm();
1309    }
1310
1311    #[cfg(unix)]
1312    #[tokio::test]
1313    async fn test_connection_drop_kills_wrapper_descendant() {
1314        let (agent, mut pid_rx) = wrapper_agent(
1315            "sleep 30 & child=$!; echo ACP_TEST_CHILD_PID=$child >&2; wait \"$child\"",
1316        );
1317        let (_channel, mut connection) = crate::ConnectTo::<Client>::into_channel_and_future(agent);
1318        let descendant_pid = reported_descendant_pid(&mut connection, &mut pid_rx).await;
1319        let mut cleanup = KillOnDrop(Some(descendant_pid));
1320
1321        assert!(process_is_running(descendant_pid));
1322        drop(connection);
1323        assert_process_exits(descendant_pid).await;
1324        cleanup.disarm();
1325    }
1326
1327    #[cfg(unix)]
1328    #[tokio::test]
1329    async fn test_launcher_exit_kills_descendant_before_stderr_wait() {
1330        let (agent, mut pid_rx) = wrapper_agent(
1331            "sh -c 'trap \"\" HUP; exec sleep 30' >/dev/null & child=$!; echo ACP_TEST_CHILD_PID=$child >&2; exit 17",
1332        );
1333        let (_channel, mut connection) = crate::ConnectTo::<Client>::into_channel_and_future(agent);
1334        let descendant_pid = reported_descendant_pid(&mut connection, &mut pid_rx).await;
1335        let mut cleanup = KillOnDrop(Some(descendant_pid));
1336
1337        let result = tokio::time::timeout(std::time::Duration::from_secs(5), &mut connection)
1338            .await
1339            .expect("connection should observe the launcher exit");
1340        let error = result.expect_err("nonzero launcher exit should be an error");
1341        let detail = error
1342            .data
1343            .as_ref()
1344            .and_then(serde_json::Value::as_str)
1345            .unwrap_or_default();
1346        assert!(
1347            detail.contains("ACP_TEST_CHILD_PID="),
1348            "launcher stderr should be preserved: {error:?}"
1349        );
1350        assert_process_exits(descendant_pid).await;
1351        cleanup.disarm();
1352    }
1353
1354    #[test]
1355    fn test_parse_simple_command() {
1356        let agent = AcpAgent::from_str("python agent.py").unwrap();
1357        let config = agent.config();
1358        assert_eq!(config.command(), Path::new("python"));
1359        assert_eq!(config.arguments(), ["agent.py"]);
1360        assert!(config.environment().is_empty());
1361    }
1362
1363    #[test]
1364    fn test_parse_environment_from_args() {
1365        let agent =
1366            AcpAgent::from_args(["RUST_LOG=debug", "NO_COLOR=1", "python", "agent.py"]).unwrap();
1367        let config = agent.config();
1368
1369        assert_eq!(config.command(), Path::new("python"));
1370        assert_eq!(config.arguments(), ["agent.py"]);
1371        assert_eq!(
1372            config.environment(),
1373            &BTreeMap::from([
1374                ("NO_COLOR".to_owned(), "1".to_owned()),
1375                ("RUST_LOG".to_owned(), "debug".to_owned()),
1376            ])
1377        );
1378    }
1379
1380    #[test]
1381    fn test_new_accepts_agent_configuration() {
1382        let config = AcpAgentConfig::new("/usr/bin/agent")
1383            .arg("--verbose")
1384            .env("RUST_LOG", "debug");
1385        let agent = AcpAgent::new(config.clone());
1386
1387        assert_eq!(agent.into_config(), config);
1388    }
1389
1390    #[test]
1391    fn test_parse_command_with_args() {
1392        let agent = AcpAgent::from_str("node server.js --port 8080 --verbose").unwrap();
1393        let config = agent.config();
1394        assert_eq!(config.command(), Path::new("node"));
1395        assert_eq!(
1396            config.arguments(),
1397            ["server.js", "--port", "8080", "--verbose"]
1398        );
1399        assert!(config.environment().is_empty());
1400    }
1401
1402    #[test]
1403    fn test_parse_command_with_quotes() {
1404        let agent = AcpAgent::from_str(r#"python "my agent.py" --name "Test Agent""#).unwrap();
1405        let config = agent.into_config();
1406        assert_eq!(config.command(), Path::new("python"));
1407        assert_eq!(config.arguments(), ["my agent.py", "--name", "Test Agent"]);
1408        assert!(config.environment().is_empty());
1409    }
1410
1411    #[test]
1412    fn test_parse_json_config() {
1413        let json = r#"{
1414            "command": "/usr/bin/python",
1415            "args": ["agent.py", "--verbose"],
1416            "env": {"RUST_LOG": "debug"}
1417        }"#;
1418        let agent = AcpAgent::from_str(json).unwrap();
1419        let config = agent.config();
1420        assert_eq!(config.command(), Path::new("/usr/bin/python"));
1421        assert_eq!(config.arguments(), ["agent.py", "--verbose"]);
1422        assert_eq!(
1423            config.environment().get("RUST_LOG").map(String::as_str),
1424            Some("debug")
1425        );
1426    }
1427
1428    #[test]
1429    fn test_config_json_round_trip() {
1430        let config = AcpAgentConfig::new("agent")
1431            .args(["--mode", "fast"])
1432            .envs([("RUST_LOG", "debug"), ("NO_COLOR", "1")]);
1433
1434        let json = serde_json::to_value(&config).unwrap();
1435        assert_eq!(
1436            json,
1437            serde_json::json!({
1438                "command": "agent",
1439                "args": ["--mode", "fast"],
1440                "env": {
1441                    "NO_COLOR": "1",
1442                    "RUST_LOG": "debug"
1443                }
1444            })
1445        );
1446        assert_eq!(
1447            serde_json::from_value::<AcpAgentConfig>(json).unwrap(),
1448            config
1449        );
1450    }
1451
1452    #[test]
1453    fn test_config_json_defaults_and_omits_empty_collections() {
1454        let config = serde_json::from_value::<AcpAgentConfig>(serde_json::json!({
1455            "command": "agent"
1456        }))
1457        .unwrap();
1458
1459        assert!(config.arguments().is_empty());
1460        assert!(config.environment().is_empty());
1461        assert_eq!(
1462            serde_json::to_value(config).unwrap(),
1463            serde_json::json!({ "command": "agent" })
1464        );
1465    }
1466
1467    #[test]
1468    fn test_reject_mcp_server_json() {
1469        let json = r#"{
1470            "type": "stdio",
1471            "name": "my-agent",
1472            "command": "/usr/bin/python",
1473            "args": ["agent.py"],
1474            "env": []
1475        }"#;
1476        let error = AcpAgent::from_str(json).unwrap_err();
1477        assert!(
1478            error
1479                .data
1480                .as_ref()
1481                .and_then(serde_json::Value::as_str)
1482                .is_some_and(|message| message.contains("unknown field")),
1483            "unexpected error: {error:?}"
1484        );
1485    }
1486}