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, 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/// Configuration for launching an ACP agent subprocess.
27///
28/// This is local SDK configuration, not an ACP wire-protocol type. It contains
29/// only the values used to launch the child process.
30///
31/// This non-exhaustive type is constructed with [`Self::new`] and customized
32/// with its fluent methods. A command is required, so there is no `Default`
33/// configuration. Its JSON representation is unchanged by non-exhaustiveness.
34///
35/// ```
36/// use agent_client_protocol::{AcpAgent, AcpAgentConfig};
37///
38/// let agent = AcpAgent::new(
39///     AcpAgentConfig::new("python")
40///         .arg("agent.py")
41///         .env("RUST_LOG", "debug"),
42/// );
43/// ```
44#[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    /// Create a configuration for the given executable or command name.
57    #[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    /// Append one command-line argument.
67    #[must_use]
68    pub fn arg(mut self, arg: impl Into<String>) -> Self {
69        self.args.push(arg.into());
70        self
71    }
72
73    /// Append command-line arguments.
74    #[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    /// Set one environment variable for the child process.
85    #[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    /// Set environment variables for the child process.
92    #[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    /// The executable path or command name.
107    #[must_use]
108    pub fn command(&self) -> &Path {
109        &self.command
110    }
111
112    /// Command-line arguments passed to the executable.
113    #[must_use]
114    pub fn arguments(&self) -> &[String] {
115        &self.args
116    }
117
118    /// Environment variables set for the child process.
119    #[must_use]
120    pub fn environment(&self) -> &BTreeMap<String, String> {
121        &self.env
122    }
123}
124
125/// A component representing an external ACP agent running in a separate process.
126///
127/// `AcpAgent` implements the [`ConnectTo`](`crate::ConnectTo`) trait for spawning and communicating with
128/// external agents or proxies via stdio. It handles process spawning, stream setup, and
129/// byte stream serialization automatically. This is the primary way to connect to agents
130/// that run as separate executables.
131///
132/// The launch configuration is independent from ACP wire-schema types and can
133/// be parsed from command-line strings or JSON configurations.
134/// On Unix, dropping an active connection terminates the spawned process group, including agents
135/// started through wrapper commands such as `npx` and `uvx`.
136///
137/// Nonzero process exits include a bounded stderr tail in the returned error.
138/// Collection waits only for a bounded shutdown period; if stderr EOF does not
139/// arrive, bytes already captured are still reported.
140///
141/// # Use Cases
142///
143/// - **External agents**: Connect to agents written in any language (Python, Node.js, Rust, etc.)
144/// - **Proxy chains**: Spawn intermediate proxies that transform or intercept messages
145/// - **Conductor components**: Use with the conductor to build proxy chains
146/// - **Subprocess isolation**: Run potentially untrusted code in a separate process
147///
148/// # Examples
149///
150/// Parse from a command string:
151/// ```
152/// # use agent_client_protocol::AcpAgent;
153/// # use std::str::FromStr;
154/// let agent = AcpAgent::from_str("python my_agent.py --verbose").unwrap();
155/// ```
156///
157/// Parse from JSON:
158/// ```
159/// # use agent_client_protocol::AcpAgent;
160/// # use std::str::FromStr;
161/// let agent = AcpAgent::from_str(r#"{"command": "python", "args": ["my_agent.py"], "env": {"RUST_LOG": "info"}}"#).unwrap();
162/// ```
163pub 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    /// Create an ACP agent from its process-launch configuration.
182    #[must_use]
183    pub fn new(config: AcpAgentConfig) -> Self {
184        Self {
185            config,
186            debug_callback: None,
187        }
188    }
189
190    /// Create an ACP agent for the Claude Agent adapter.
191    /// Just runs `npx -y @agentclientprotocol/claude-agent-acp@latest`.
192    #[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    /// Create an ACP agent for the Codex adapter.
199    /// Just runs `npx -y @agentclientprotocol/codex-acp@latest`.
200    #[must_use]
201    pub fn codex() -> Self {
202        Self::from_str("npx -y @agentclientprotocol/codex-acp@latest").expect("valid bash command")
203    }
204
205    /// Get the process-launch configuration.
206    #[must_use]
207    pub fn config(&self) -> &AcpAgentConfig {
208        &self.config
209    }
210
211    /// Convert into the process-launch configuration.
212    #[must_use]
213    pub fn into_config(self) -> AcpAgentConfig {
214        self.config
215    }
216
217    /// Add a debug callback that will be invoked for each line sent/received.
218    ///
219    /// The callback receives the line content and the direction (stdin/stdout/stderr).
220    /// This is useful for logging, debugging, or monitoring agent communication.
221    /// Exceptionally long stderr lines are truncated to keep memory usage bounded.
222    ///
223    /// # Example
224    ///
225    /// ```no_run
226    /// # use agent_client_protocol::{AcpAgent, LineDirection};
227    /// # use std::str::FromStr;
228    /// let agent = AcpAgent::from_str("python my_agent.py")
229    ///     .unwrap()
230    ///     .with_debug(|line, direction| {
231    ///         eprintln!("{:?}: {}", direction, line);
232    ///     });
233    /// ```
234    #[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    /// Spawn the configured process and return its stdio streams and raw child handle.
244    ///
245    /// This is a low-level escape hatch. The caller owns the returned child process and is
246    /// responsible for terminating it. Connections created through [`crate::ConnectTo`] instead
247    /// install a guard that tears down the spawned process group on Unix.
248    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            // Make the child the leader of its own process group so
267            // `ChildGuard` can terminate the entire process tree.
268            // Agents are commonly distributed behind wrapper launchers
269            // (`npx …`, `uvx …`): killing only the immediate child
270            // orphans the real agent, which re-parents to pid 1 and
271            // does not reliably exit on stdin EOF.
272            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
304/// A wrapper around Child that kills the process — and, on unix, its whole
305/// process group (see `spawn_process`) — when dropped.
306struct 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        // SIGKILL the child's process group first: the child was spawned as
315        // its own group leader, so this reaches grandchildren spawned by
316        // wrapper launchers (`npx → node`, `uvx → python`). This also covers
317        // the case where the direct child already exited but its wrapper left
318        // the real agent running. An error (e.g. `ESRCH`) just means the
319        // group is already gone.
320        #[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        // Fallback for platforms without group semantics (and a no-op double
325        // tap on unix).
326        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/// Keep diagnostics accessible even when the reader has not reached EOF.
376#[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                // Release the capture lock before invoking user code or awaiting
472                // another read.
473                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
498/// Waits for the direct child process while retaining its process-group guard
499/// and stderr capture and completion receiver for exit reporting.
500async 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
518/// Reports an observed child exit, including a bounded stderr tail for a
519/// nonzero status.
520async 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    // A launcher may exit while a descendant remains alive holding inherited
529    // stdio. Terminate the rest of the group before waiting for stderr EOF.
530    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        // EOF, read error, cancellation, and timeout all retain bytes already
548        // read. The completion signal controls the wait, not ownership of data.
549        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                // Dropping the incoming stream cancels the signal. Only an
607                // explicit send represents a clean EOF.
608                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
639/// Roles that an ACP agent executable can potentially serve.
640pub 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        // Completion and captured data have separate lifetimes: a shutdown
657        // timeout must not discard diagnostics already read from the pipe.
658        let (stderr_tx, stderr_rx) = futures::channel::oneshot::channel();
659        let stderr_capture = StderrCapture::default();
660
661        // Read stderr concurrently, optionally calling the debug callback.
662        // We use futures::future::select below to race this against the protocol,
663        // so this runs as part of the same task — no tokio::spawn needed.
664        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        // Create the guard eagerly so cancelling this connection before the
679        // monitor is first polled still terminates the whole process group.
680        let child_wait = wait_for_child(ChildGuard(child), stderr_rx, stderr_capture);
681
682        // Convert stdio to line streams with optional debug inspection.
683        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        // The JSON-RPC transport keeps polling stdout while it drains stdin.
696        // Signal physical EOF so a child that half-closes stdout and stops
697        // reading cannot hold a final write open forever. Dropping this stream
698        // merely cancels the signal and is not treated as EOF.
699        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        // Create a sink that writes lines (with newlines) to stdin with optional debug logging
713        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        // Race the protocol against child process exit.
740        // Also run stderr collection concurrently.
741        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        // Run stderr reader alongside the main race. Errors still stop the
751        // connection immediately. After protocol shutdown succeeds, give the
752        // child a bounded grace period so delayed failures remain observable
753        // without letting a non-exiting launcher hang shutdown forever.
754        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        // Run stderr collection concurrently with the main logic.
789        // When main_race completes, we don't need stderr anymore.
790        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    /// Create an `AcpAgent` from an iterator of command-line arguments.
800    ///
801    /// Leading arguments of the form `NAME=value` are parsed as environment variables.
802    /// The first non-env argument is the command, and the rest are arguments.
803    ///
804    /// # Example
805    ///
806    /// ```
807    /// # use agent_client_protocol::AcpAgent;
808    /// let agent = AcpAgent::from_args([
809    ///     "RUST_LOG=debug",
810    ///     "cargo",
811    ///     "run",
812    ///     "-p",
813    ///     "my-crate",
814    /// ]).unwrap();
815    /// ```
816    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
854/// Parse a string as an environment variable assignment (NAME=value).
855fn 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                // Simulate an inherited stderr pipe whose writer never closes.
1005                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        // A killed orphan can remain as a zombie under a container PID 1 that
1325        // does not reap promptly. Treat zombies as exited for this test.
1326        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}