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]";
24/// How long shutdown waits for the agent to react on its own: to exit after
25/// its stdin has been closed, to drain stderr, or to finish pending writes.
26const SHUTDOWN_GRACE_PERIOD: Duration = Duration::from_secs(1);
27/// How long an agent that ignored stdin EOF gets to exit after `SIGTERM`
28/// before its process group is sent `SIGKILL`.
29///
30/// Two seconds is enough for a well-behaved process (including Node and
31/// Python runtimes behind `npx`/`uvx`) to run its signal handlers, flush logs,
32/// and exit, while keeping the worst-case shutdown of a non-cooperative agent
33/// to about three seconds in total, below what an interactive client would
34/// notice as a hang.
35#[cfg(unix)]
36const TERMINATE_GRACE_PERIOD: Duration = Duration::from_secs(2);
37
38/// Bounds for the graceful shutdown of a spawned agent process.
39///
40/// This is crate-private so tests can shorten the waits without growing the
41/// public API.
42#[derive(Clone, Copy, Debug)]
43struct ShutdownTimeouts {
44    /// How long to wait for the agent to exit after its stdin is closed.
45    exit: Duration,
46    /// How long to wait for the agent to exit after `SIGTERM`.
47    #[cfg(unix)]
48    terminate: Duration,
49}
50
51impl Default for ShutdownTimeouts {
52    fn default() -> Self {
53        Self {
54            exit: SHUTDOWN_GRACE_PERIOD,
55            #[cfg(unix)]
56            terminate: TERMINATE_GRACE_PERIOD,
57        }
58    }
59}
60
61/// Configuration for launching an ACP agent subprocess.
62///
63/// This is local SDK configuration, not an ACP wire-protocol type. It contains
64/// only the values used to launch the child process.
65///
66/// This non-exhaustive type is constructed with [`Self::new`] and customized
67/// with its fluent methods. A command is required, so there is no `Default`
68/// configuration. Its JSON representation is unchanged by non-exhaustiveness.
69///
70/// ```
71/// use agent_client_protocol::{AcpAgent, AcpAgentConfig};
72///
73/// let agent = AcpAgent::new(
74///     AcpAgentConfig::new("python")
75///         .arg("agent.py")
76///         .env("RUST_LOG", "debug"),
77/// );
78/// ```
79#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
80#[serde(rename_all = "camelCase", deny_unknown_fields)]
81#[non_exhaustive]
82pub struct AcpAgentConfig {
83    command: PathBuf,
84    #[serde(default, skip_serializing_if = "Vec::is_empty")]
85    args: Vec<String>,
86    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
87    env: BTreeMap<String, String>,
88}
89
90impl AcpAgentConfig {
91    /// Create a configuration for the given executable or command name.
92    #[must_use]
93    pub fn new(command: impl Into<PathBuf>) -> Self {
94        Self {
95            command: command.into(),
96            args: Vec::new(),
97            env: BTreeMap::new(),
98        }
99    }
100
101    /// Append one command-line argument.
102    #[must_use]
103    pub fn arg(mut self, arg: impl Into<String>) -> Self {
104        self.args.push(arg.into());
105        self
106    }
107
108    /// Append command-line arguments.
109    #[must_use]
110    pub fn args<I, S>(mut self, args: I) -> Self
111    where
112        I: IntoIterator<Item = S>,
113        S: Into<String>,
114    {
115        self.args.extend(args.into_iter().map(Into::into));
116        self
117    }
118
119    /// Set one environment variable for the child process.
120    #[must_use]
121    pub fn env(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
122        self.env.insert(name.into(), value.into());
123        self
124    }
125
126    /// Set environment variables for the child process.
127    #[must_use]
128    pub fn envs<I, K, V>(mut self, env: I) -> Self
129    where
130        I: IntoIterator<Item = (K, V)>,
131        K: Into<String>,
132        V: Into<String>,
133    {
134        self.env.extend(
135            env.into_iter()
136                .map(|(name, value)| (name.into(), value.into())),
137        );
138        self
139    }
140
141    /// The executable path or command name.
142    #[must_use]
143    pub fn command(&self) -> &Path {
144        &self.command
145    }
146
147    /// Command-line arguments passed to the executable.
148    #[must_use]
149    pub fn arguments(&self) -> &[String] {
150        &self.args
151    }
152
153    /// Environment variables set for the child process.
154    #[must_use]
155    pub fn environment(&self) -> &BTreeMap<String, String> {
156        &self.env
157    }
158}
159
160/// A component representing an external ACP agent running in a separate process.
161///
162/// `AcpAgent` implements the [`ConnectTo`](`crate::ConnectTo`) trait for spawning and communicating with
163/// external agents or proxies via stdio. It handles process spawning, stream setup, and
164/// byte stream serialization automatically. This is the primary way to connect to agents
165/// that run as separate executables.
166///
167/// The launch configuration is independent from ACP wire-schema types and can
168/// be parsed from command-line strings or JSON configurations.
169/// # Shutdown
170///
171/// When the protocol connection finishes cleanly (a `connect_with` foreground returns, or the
172/// agent closes its stdout), `AcpAgent` drains pending output, closes the agent's stdin, and waits
173/// briefly (one second) for the agent to exit on its own, as the ACP stdio transport
174/// specifies. A nonzero exit observed during that wait is reported as an error. If the agent
175/// is still running afterwards, it is terminated:
176///
177/// - On Unix, the spawned process group (which includes agents started through wrapper commands
178///   such as `npx` and `uvx`) is sent `SIGTERM`, then `SIGKILL` if it has not exited within two
179///   more seconds. Because the SDK requested this termination, the resulting exit status is not
180///   reported as an error.
181/// - On Windows, the process is killed.
182///
183/// Dropping an active connection (cancellation) cannot wait, so it kills the process
184/// immediately; on Unix it sends `SIGKILL` to the whole process group.
185///
186/// Nonzero process exits include a bounded stderr tail in the returned error.
187/// Collection waits only for a bounded shutdown period; if stderr EOF does not
188/// arrive, bytes already captured are still reported.
189///
190/// # Use Cases
191///
192/// - **External agents**: Connect to agents written in any language (Python, Node.js, Rust, etc.)
193/// - **Proxy chains**: Spawn intermediate proxies that transform or intercept messages
194/// - **Conductor components**: Use with the conductor to build proxy chains
195/// - **Subprocess isolation**: Run potentially untrusted code in a separate process
196///
197/// # Examples
198///
199/// Parse from a command string:
200/// ```
201/// # use agent_client_protocol::AcpAgent;
202/// # use std::str::FromStr;
203/// let agent = AcpAgent::from_str("python my_agent.py --verbose").unwrap();
204/// ```
205///
206/// Parse from JSON:
207/// ```
208/// # use agent_client_protocol::AcpAgent;
209/// # use std::str::FromStr;
210/// let agent = AcpAgent::from_str(r#"{"command": "python", "args": ["my_agent.py"], "env": {"RUST_LOG": "info"}}"#).unwrap();
211/// ```
212pub struct AcpAgent {
213    config: AcpAgentConfig,
214    debug_callback: Option<DebugCallback>,
215    shutdown_timeouts: ShutdownTimeouts,
216}
217
218impl std::fmt::Debug for AcpAgent {
219    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
220        f.debug_struct("AcpAgent")
221            .field("config", &self.config)
222            .field(
223                "debug_callback",
224                &self.debug_callback.as_ref().map(|_| "..."),
225            )
226            .field("shutdown_timeouts", &self.shutdown_timeouts)
227            .finish()
228    }
229}
230
231impl AcpAgent {
232    /// Create an ACP agent from its process-launch configuration.
233    #[must_use]
234    pub fn new(config: AcpAgentConfig) -> Self {
235        Self {
236            config,
237            debug_callback: None,
238            shutdown_timeouts: ShutdownTimeouts::default(),
239        }
240    }
241
242    /// Get the process-launch configuration.
243    #[must_use]
244    pub fn config(&self) -> &AcpAgentConfig {
245        &self.config
246    }
247
248    /// Convert into the process-launch configuration.
249    #[must_use]
250    pub fn into_config(self) -> AcpAgentConfig {
251        self.config
252    }
253
254    /// Add a debug callback that will be invoked for each line sent/received.
255    ///
256    /// The callback receives the line content and the direction (stdin/stdout/stderr).
257    /// This is useful for logging, debugging, or monitoring agent communication.
258    /// Exceptionally long stderr lines are truncated to keep memory usage bounded.
259    ///
260    /// # Example
261    ///
262    /// ```no_run
263    /// # use agent_client_protocol::{AcpAgent, LineDirection};
264    /// # use std::str::FromStr;
265    /// let agent = AcpAgent::from_str("python my_agent.py")
266    ///     .unwrap()
267    ///     .with_debug(|line, direction| {
268    ///         eprintln!("{:?}: {}", direction, line);
269    ///     });
270    /// ```
271    #[must_use]
272    pub fn with_debug<F>(mut self, callback: F) -> Self
273    where
274        F: Fn(&str, LineDirection) + Send + Sync + 'static,
275    {
276        self.debug_callback = Some(Arc::new(callback));
277        self
278    }
279
280    /// Spawn the configured process and return its stdio streams and raw child handle.
281    ///
282    /// The caller owns the returned child process and is responsible for terminating it.
283    /// [`crate::ConnectTo`] wraps it in a guard that tears down the process group on Unix.
284    pub(crate) fn spawn_process(
285        &self,
286    ) -> Result<
287        (
288            async_process::ChildStdin,
289            async_process::ChildStdout,
290            async_process::ChildStderr,
291            Child,
292        ),
293        crate::Error,
294    > {
295        let mut std_cmd = std::process::Command::new(&self.config.command);
296        std_cmd.args(&self.config.args);
297        std_cmd.envs(&self.config.env);
298        #[cfg(unix)]
299        {
300            use std::os::unix::process::CommandExt as _;
301
302            // Make the child the leader of its own process group so
303            // `ChildGuard` can terminate the entire process tree.
304            // Agents are commonly distributed behind wrapper launchers
305            // (`npx …`, `uvx …`): killing only the immediate child
306            // orphans the real agent, which re-parents to pid 1 and
307            // does not reliably exit on stdin EOF.
308            std_cmd.process_group(0);
309        }
310        let mut cmd = async_process::Command::from(std_cmd);
311        #[cfg(windows)]
312        {
313            use async_process::windows::CommandExt as _;
314
315            cmd.creation_flags(windows_sys::Win32::System::Threading::CREATE_NO_WINDOW);
316        }
317        cmd.stdin(std::process::Stdio::piped())
318            .stdout(std::process::Stdio::piped())
319            .stderr(std::process::Stdio::piped());
320
321        let mut child = cmd.spawn().map_err(crate::Error::into_internal_error)?;
322
323        let child_stdin = child
324            .stdin
325            .take()
326            .ok_or_else(|| crate::util::internal_error("Failed to open stdin"))?;
327        let child_stdout = child
328            .stdout
329            .take()
330            .ok_or_else(|| crate::util::internal_error("Failed to open stdout"))?;
331        let child_stderr = child
332            .stderr
333            .take()
334            .ok_or_else(|| crate::util::internal_error("Failed to open stderr"))?;
335
336        Ok((child_stdin, child_stdout, child_stderr, child))
337    }
338}
339
340/// Sends `signal` to the process group led by `pid`.
341///
342/// The child was spawned as its own group leader (see `spawn_process`), so
343/// this reaches grandchildren spawned by wrapper launchers (`npx → node`,
344/// `uvx → python`). An error (e.g. `ESRCH`) just means the group is already
345/// gone.
346#[cfg(unix)]
347fn signal_process_group(pid: u32, signal: rustix::process::Signal) {
348    if let Some(pid) = rustix::process::Pid::from_raw(pid.cast_signed()) {
349        let _result = rustix::process::kill_process_group(pid, signal);
350    }
351}
352
353/// A wrapper around Child that kills the process — and, on unix, its whole
354/// process group (see `spawn_process`) — when dropped.
355///
356/// Dropping the guard is the cancellation path and kills immediately with
357/// `SIGKILL`: `Drop` is synchronous and cannot wait for the agent to react to
358/// a gentler `SIGTERM`. The graceful escalation (stdin EOF, then `SIGTERM`,
359/// then `SIGKILL`) lives on the async shutdown path in
360/// `AgentProcess::supervise`; see `terminate_unresponsive_child`.
361struct ChildGuard(Child);
362
363impl ChildGuard {
364    async fn wait(&mut self) -> std::io::Result<std::process::ExitStatus> {
365        self.0.status().await
366    }
367
368    fn terminate(&mut self) {
369        // SIGKILL the child's process group first. This also covers the case
370        // where the direct child already exited but its wrapper left the real
371        // agent running.
372        #[cfg(unix)]
373        signal_process_group(self.0.id(), rustix::process::Signal::KILL);
374        // Fallback for platforms without group semantics (and a no-op double
375        // tap on unix).
376        drop(self.0.kill());
377    }
378}
379
380impl Drop for ChildGuard {
381    fn drop(&mut self) {
382        self.terminate();
383    }
384}
385
386#[derive(Default)]
387struct StderrTail {
388    bytes: VecDeque<u8>,
389    truncated: bool,
390}
391
392impl StderrTail {
393    fn push(&mut self, bytes: &[u8]) {
394        if bytes.len() >= STDERR_CAPTURE_LIMIT {
395            self.truncated |= !self.bytes.is_empty() || bytes.len() > STDERR_CAPTURE_LIMIT;
396            self.bytes.clear();
397            self.bytes
398                .extend(bytes[bytes.len() - STDERR_CAPTURE_LIMIT..].iter().copied());
399            return;
400        }
401
402        let overflow = self
403            .bytes
404            .len()
405            .saturating_add(bytes.len())
406            .saturating_sub(STDERR_CAPTURE_LIMIT);
407        if overflow > 0 {
408            self.truncated = true;
409            drop(self.bytes.drain(..overflow));
410        }
411        self.bytes.extend(bytes.iter().copied());
412    }
413
414    fn into_string(mut self) -> String {
415        let truncated = self.truncated;
416        let stderr = String::from_utf8_lossy(self.bytes.make_contiguous());
417        if truncated {
418            format!("[stderr truncated; showing last {STDERR_CAPTURE_LIMIT} bytes]\n{stderr}")
419        } else {
420            stderr.into_owned()
421        }
422    }
423}
424
425/// Keep diagnostics accessible even when the reader has not reached EOF.
426#[derive(Clone, Default)]
427struct StderrCapture(Arc<Mutex<StderrTail>>);
428
429impl StderrCapture {
430    fn push(&self, bytes: &[u8]) {
431        self.0
432            .lock()
433            .expect("stderr capture lock poisoned")
434            .push(bytes);
435    }
436
437    fn take(&self) -> String {
438        let tail = std::mem::take(&mut *self.0.lock().expect("stderr capture lock poisoned"));
439        tail.into_string()
440    }
441}
442
443#[derive(Default)]
444struct StderrDebugLines {
445    current: Vec<u8>,
446    truncated: bool,
447    pending_carriage_return: bool,
448}
449
450impl StderrDebugLines {
451    fn push(&mut self, bytes: &[u8], callback: &DebugCallback) {
452        for &byte in bytes {
453            if self.pending_carriage_return {
454                if byte == b'\n' {
455                    self.pending_carriage_return = false;
456                    self.emit(callback);
457                    continue;
458                }
459
460                self.push_byte(b'\r');
461                self.pending_carriage_return = false;
462            }
463
464            match byte {
465                b'\r' => self.pending_carriage_return = true,
466                b'\n' => self.emit(callback),
467                byte => self.push_byte(byte),
468            }
469        }
470    }
471
472    fn finish(&mut self, callback: &DebugCallback) {
473        if self.pending_carriage_return {
474            self.push_byte(b'\r');
475            self.pending_carriage_return = false;
476        }
477        if !self.current.is_empty() || self.truncated {
478            self.emit(callback);
479        }
480    }
481
482    fn push_byte(&mut self, byte: u8) {
483        if self.current.len() < STDERR_CAPTURE_LIMIT {
484            self.current.push(byte);
485        } else {
486            self.truncated = true;
487        }
488    }
489
490    fn emit(&mut self, callback: &DebugCallback) {
491        let line = String::from_utf8_lossy(&self.current);
492
493        if self.truncated {
494            let mut line = line.into_owned();
495            line.push_str(STDERR_LINE_TRUNCATION_MARKER);
496            callback(&line, LineDirection::Stderr);
497        } else {
498            callback(line.as_ref(), LineDirection::Stderr);
499        }
500
501        self.current.clear();
502        self.truncated = false;
503    }
504}
505
506async fn drain_stderr(
507    mut stderr: impl futures::AsyncRead + Unpin,
508    debug_callback: Option<DebugCallback>,
509    capture: StderrCapture,
510) -> Option<std::io::Error> {
511    use futures::AsyncReadExt as _;
512
513    let mut debug_lines = debug_callback.as_ref().map(|_| StderrDebugLines::default());
514    let mut buffer = [0; STDERR_READ_BUFFER_SIZE];
515
516    let read_error = loop {
517        match stderr.read(&mut buffer).await {
518            Ok(0) => break None,
519            Ok(read) => {
520                let bytes = &buffer[..read];
521                // Release the capture lock before invoking user code or awaiting
522                // another read.
523                capture.push(bytes);
524                if let (Some(lines), Some(callback)) =
525                    (debug_lines.as_mut(), debug_callback.as_ref())
526                {
527                    lines.push(bytes, callback);
528                }
529            }
530            Err(error) => break Some(error),
531        }
532    };
533
534    if let (Some(lines), Some(callback)) = (debug_lines.as_mut(), debug_callback.as_ref()) {
535        lines.finish(callback);
536    }
537
538    read_error
539}
540
541struct ExitedChild {
542    guard: ChildGuard,
543    status: std::process::ExitStatus,
544    stderr_rx: futures::channel::oneshot::Receiver<()>,
545    stderr_capture: StderrCapture,
546}
547
548/// Waits for the direct child process while retaining its process-group guard
549/// and stderr capture and completion receiver for exit reporting.
550async fn wait_for_child(
551    mut guard: ChildGuard,
552    stderr_rx: futures::channel::oneshot::Receiver<()>,
553    stderr_capture: StderrCapture,
554) -> Result<ExitedChild, crate::Error> {
555    let status = guard
556        .wait()
557        .await
558        .map_err(|e| crate::util::internal_error(format!("Failed to wait for process: {e}")))?;
559
560    Ok(ExitedChild {
561        guard,
562        status,
563        stderr_rx,
564        stderr_capture,
565    })
566}
567
568/// Reports an observed child exit, including a bounded stderr tail for a
569/// nonzero status.
570async fn finish_child_exit(child: ExitedChild) -> Result<(), crate::Error> {
571    let ExitedChild {
572        mut guard,
573        status,
574        stderr_rx,
575        stderr_capture,
576    } = child;
577
578    // A launcher may exit while a descendant remains alive holding inherited
579    // stdio. Terminate the rest of the group before waiting for stderr EOF.
580    guard.terminate();
581
582    if status.success() {
583        Ok(())
584    } else {
585        match futures::future::select(stderr_rx, async_io::Timer::after(SHUTDOWN_GRACE_PERIOD))
586            .await
587        {
588            futures::future::Either::Left((_, _)) => {}
589            futures::future::Either::Right((_, stderr_rx)) => {
590                tracing::debug!(
591                    grace = ?SHUTDOWN_GRACE_PERIOD,
592                    "Agent stderr remained open after process exit; reporting stderr captured so far"
593                );
594                drop(stderr_rx);
595            }
596        }
597        // EOF, read error, cancellation, and timeout all retain bytes already
598        // read. The completion signal controls the wait, not ownership of data.
599        let stderr = stderr_capture.take();
600
601        let message = if stderr.is_empty() {
602            format!("Process exited with {status}")
603        } else {
604            format!("Process exited with {status}: {stderr}")
605        };
606
607        Err(crate::util::internal_error(message))
608    }
609}
610
611async fn await_protocol_shutdown_after_successful_child_exit<F>(
612    protocol_future: F,
613    grace: Duration,
614) -> Result<(), crate::Error>
615where
616    F: std::future::Future<Output = Result<(), crate::Error>> + Unpin,
617{
618    match futures::future::select(protocol_future, async_io::Timer::after(grace)).await {
619        futures::future::Either::Left((result, _)) => result,
620        futures::future::Either::Right((_, protocol_future)) => {
621            tracing::debug!(
622                ?grace,
623                "Protocol transport remained open after successful agent process exit; stopping it"
624            );
625            drop(protocol_future);
626            Ok(())
627        }
628    }
629}
630
631/// Terminates an agent that is still running after protocol shutdown closed
632/// its stdin and the exit grace period expired.
633///
634/// On Unix the process group is sent `SIGTERM` and given
635/// `timeouts.terminate` to exit; whatever remains is then sent `SIGKILL` by
636/// the guard. Elsewhere the process is killed immediately.
637///
638/// The SDK requested this termination, so the resulting exit status (for
639/// example `signal: 15 (SIGTERM)`) is not reported as an agent failure, just
640/// as an immediate `SIGKILL` is not.
641async fn terminate_unresponsive_child<F>(
642    child_wait: F,
643    #[cfg(unix)] child_pid: u32,
644    timeouts: ShutdownTimeouts,
645) -> Result<(), crate::Error>
646where
647    F: std::future::Future<Output = Result<ExitedChild, crate::Error>> + Unpin,
648{
649    #[cfg(unix)]
650    {
651        tracing::debug!(
652            grace = ?timeouts.exit,
653            "Agent process did not exit after its stdin closed; sending SIGTERM"
654        );
655        // `child_wait` has not observed an exit, so the child has not been
656        // reaped and `child_pid` still identifies its process group.
657        signal_process_group(child_pid, rustix::process::Signal::TERM);
658        match futures::future::select(child_wait, async_io::Timer::after(timeouts.terminate)).await
659        {
660            futures::future::Either::Left((child, _)) => {
661                let ExitedChild {
662                    mut guard, status, ..
663                } = child?;
664                tracing::debug!(%status, "Agent process exited after SIGTERM");
665                // A launcher may exit while a descendant ignores SIGTERM and
666                // holds inherited stdio; do not leave it behind.
667                guard.terminate();
668            }
669            futures::future::Either::Right((_, child_wait)) => {
670                tracing::debug!(
671                    grace = ?timeouts.terminate,
672                    "Agent process did not exit after SIGTERM; sending SIGKILL"
673                );
674                // Dropping the guard SIGKILLs the process group.
675                drop(child_wait);
676            }
677        }
678    }
679    #[cfg(not(unix))]
680    {
681        tracing::debug!(
682            grace = ?timeouts.exit,
683            "Agent process did not exit after its stdin closed; terminating it"
684        );
685        // Dropping the guard kills the process.
686        drop(child_wait);
687    }
688    Ok(())
689}
690
691async fn write_line_with_shutdown_timeout<W>(
692    writer: &mut W,
693    line: String,
694    stdout_eof_rx: &mut Option<futures::channel::oneshot::Receiver<()>>,
695    stdout_eof_seen: &mut bool,
696    grace: Duration,
697) -> std::io::Result<()>
698where
699    W: futures::AsyncWrite + Unpin + ?Sized,
700{
701    let write = Box::pin(crate::jsonrpc::write_line(writer, line));
702
703    if *stdout_eof_seen {
704        return await_write_during_shutdown(write, grace).await;
705    }
706
707    let Some(stdout_eof) = stdout_eof_rx.as_mut() else {
708        return write.await;
709    };
710
711    match futures::future::select(write, stdout_eof).await {
712        futures::future::Either::Left((result, _)) => result,
713        futures::future::Either::Right((stdout_eof, write)) => {
714            *stdout_eof_rx = None;
715            if stdout_eof.is_err() {
716                // Dropping the incoming stream cancels the signal. Only an
717                // explicit send represents a clean EOF.
718                return write.await;
719            }
720
721            *stdout_eof_seen = true;
722            await_write_during_shutdown(write, grace).await
723        }
724    }
725}
726
727async fn await_write_during_shutdown<F>(write: F, grace: Duration) -> std::io::Result<()>
728where
729    F: std::future::Future<Output = std::io::Result<()>> + Unpin,
730{
731    match futures::future::select(write, async_io::Timer::after(grace)).await {
732        futures::future::Either::Left((result, _)) => result,
733        futures::future::Either::Right((_, write)) => {
734            tracing::debug!(
735                ?grace,
736                "Pending protocol output did not drain after agent stdout closed"
737            );
738            drop(write);
739            Err(std::io::Error::new(
740                std::io::ErrorKind::TimedOut,
741                format!(
742                    "Agent closed its protocol output but pending protocol output did not drain within {grace:?}"
743                ),
744            ))
745        }
746    }
747}
748
749/// Roles that an ACP agent executable can potentially serve.
750pub trait AcpAgentCounterpartRole: Role {}
751
752impl AcpAgentCounterpartRole for Client {}
753
754impl AcpAgentCounterpartRole for Conductor {}
755
756impl<Counterpart: AcpAgentCounterpartRole> crate::ConnectTo<Counterpart> for AcpAgent {
757    async fn connect_to(
758        self,
759        client: impl crate::ConnectTo<Counterpart::Counterpart>,
760    ) -> Result<(), crate::Error> {
761        let (lines, process) = self.spawn_connection()?;
762        process
763            .supervise(crate::ConnectTo::<Counterpart>::connect_to(lines, client))
764            .await
765    }
766
767    /// Spawns the agent and returns a driver that supports graceful finish.
768    ///
769    /// A finite foreground (`Builder::connect_with`) requests finish after
770    /// handing off its output. The line transport then drains and closes the
771    /// agent's stdin, and the driver waits for the agent to exit, escalating
772    /// as described in [`AcpAgent`]'s shutdown documentation, instead of being
773    /// dropped (which would kill the agent without closing stdin first).
774    fn into_channel_and_future(self) -> (crate::Channel, Option<crate::ConnectionDriver>) {
775        match self.spawn_connection() {
776            Ok((lines, process)) => {
777                let (channel, driver) =
778                    crate::ConnectTo::<Counterpart>::into_channel_and_future(lines);
779                let driver = driver
780                    .expect("the line transport owns its I/O")
781                    .map_future(|work| process.supervise(work));
782                (channel, Some(driver))
783            }
784            Err(error) => {
785                let (channel, peer) = crate::Channel::duplex();
786                let driver = crate::ConnectionDriver::new(async move {
787                    drop(peer);
788                    Err(error)
789                });
790                (channel, Some(driver))
791            }
792        }
793    }
794}
795
796type AgentOutgoingLines =
797    std::pin::Pin<Box<dyn futures::Sink<String, Error = std::io::Error> + Send>>;
798type AgentIncomingLines =
799    std::pin::Pin<Box<dyn futures::Stream<Item = std::io::Result<String>> + Send>>;
800
801/// The parts of a spawned agent that the protocol connection does not own:
802/// its stderr reader and its process-group guard.
803struct AgentProcess {
804    stderr_future: futures::future::BoxFuture<'static, ()>,
805    child_wait: futures::future::BoxFuture<'static, Result<ExitedChild, crate::Error>>,
806    #[cfg(unix)]
807    child_pid: u32,
808    shutdown_timeouts: ShutdownTimeouts,
809}
810
811impl AcpAgent {
812    /// Spawns the agent process and splits it into the line transport over
813    /// its stdio and the process that `AgentProcess::supervise` monitors.
814    fn spawn_connection(
815        self,
816    ) -> Result<
817        (
818            crate::Lines<AgentOutgoingLines, AgentIncomingLines>,
819            AgentProcess,
820        ),
821        crate::Error,
822    > {
823        use futures::io::BufReader;
824        use futures::{AsyncBufReadExt, StreamExt};
825
826        let (child_stdin, child_stdout, child_stderr, child) = self.spawn_process()?;
827
828        // Completion and captured data have separate lifetimes: a shutdown
829        // timeout must not discard diagnostics already read from the pipe.
830        let (stderr_tx, stderr_rx) = futures::channel::oneshot::channel();
831        let stderr_capture = StderrCapture::default();
832
833        // Read stderr concurrently, optionally calling the debug callback.
834        // We use futures::future::select below to race this against the protocol,
835        // so this runs as part of the same task — no tokio::spawn needed.
836        let debug_callback = self.debug_callback.clone();
837        let capture = stderr_capture.clone();
838        let stderr_future = async move {
839            let read_error = drain_stderr(child_stderr, debug_callback, capture).await;
840            let _ = stderr_tx.send(());
841
842            if let Some(error) = read_error {
843                tracing::warn!(
844                    ?error,
845                    "Failed to read process stderr; stderr will no longer be captured"
846                );
847            }
848        };
849
850        let shutdown_timeouts = self.shutdown_timeouts;
851        #[cfg(unix)]
852        let child_pid = child.id();
853
854        // Create the guard eagerly so cancelling this connection before the
855        // monitor is first polled still terminates the whole process group.
856        let child_wait = wait_for_child(ChildGuard(child), stderr_rx, stderr_capture);
857
858        // Convert stdio to line streams with optional debug inspection.
859        let incoming_lines: std::pin::Pin<
860            Box<dyn futures::Stream<Item = std::io::Result<String>> + Send>,
861        > = if let Some(callback) = self.debug_callback.clone() {
862            Box::pin(BufReader::new(child_stdout).lines().inspect(move |result| {
863                if let Ok(line) = result {
864                    callback(line, LineDirection::Stdout);
865                }
866            }))
867        } else {
868            Box::pin(BufReader::new(child_stdout).lines())
869        };
870
871        // The JSON-RPC transport keeps polling stdout while it drains stdin.
872        // Signal physical EOF so a child that half-closes stdout and stops
873        // reading cannot hold a final write open forever. Dropping this stream
874        // merely cancels the signal and is not treated as EOF.
875        let (stdout_eof_tx, stdout_eof_rx) = futures::channel::oneshot::channel();
876        let mut stdout_eof_tx = Some(stdout_eof_tx);
877        let mut incoming_lines = incoming_lines;
878        let incoming_lines: AgentIncomingLines = Box::pin(futures::stream::poll_fn(move |cx| {
879            let next = incoming_lines.as_mut().poll_next(cx);
880            if matches!(next, std::task::Poll::Ready(None))
881                && let Some(stdout_eof_tx) = stdout_eof_tx.take()
882            {
883                let _ = stdout_eof_tx.send(());
884            }
885            next
886        }));
887
888        // Create a sink that writes lines (with newlines) to stdin with optional debug logging
889        let outgoing_sink: AgentOutgoingLines = Box::pin(futures::sink::unfold(
890            (
891                child_stdin,
892                self.debug_callback.clone(),
893                Some(stdout_eof_rx),
894                false,
895            ),
896            async move |(mut writer, callback, mut stdout_eof_rx, mut stdout_eof_seen),
897                        line: String| {
898                if let Some(callback) = callback.as_ref() {
899                    callback(&line, LineDirection::Stdin);
900                }
901                write_line_with_shutdown_timeout(
902                    &mut writer,
903                    line,
904                    &mut stdout_eof_rx,
905                    &mut stdout_eof_seen,
906                    SHUTDOWN_GRACE_PERIOD,
907                )
908                .await?;
909                Ok::<_, std::io::Error>((writer, callback, stdout_eof_rx, stdout_eof_seen))
910            },
911        ));
912
913        Ok((
914            crate::Lines::new(outgoing_sink, incoming_lines),
915            AgentProcess {
916                stderr_future: Box::pin(stderr_future),
917                child_wait: Box::pin(child_wait),
918                #[cfg(unix)]
919                child_pid,
920                shutdown_timeouts,
921            },
922        ))
923    }
924}
925
926impl AgentProcess {
927    /// Races the protocol connection against the agent process exiting,
928    /// while draining stderr.
929    async fn supervise(
930        self,
931        protocol_future: impl std::future::Future<Output = Result<(), crate::Error>>,
932    ) -> Result<(), crate::Error> {
933        let Self {
934            stderr_future,
935            child_wait,
936            #[cfg(unix)]
937            child_pid,
938            shutdown_timeouts,
939        } = self;
940        // Boxed (not stack-pinned) so `select` drops it on completion.
941        let protocol_future = Box::pin(protocol_future);
942
943        // Run stderr reader alongside the main race. Errors still stop the
944        // connection immediately. After protocol shutdown succeeds, give the
945        // child a bounded grace period so delayed failures remain observable
946        // without letting a non-exiting launcher hang shutdown forever.
947        let main_race = async {
948            match futures::future::select(protocol_future, child_wait).await {
949                futures::future::Either::Left((result, child_wait)) => {
950                    result?;
951                    // `select` has dropped the completed protocol future and,
952                    // with it, the outgoing sink that owns `child_stdin`. The
953                    // agent therefore sees EOF on stdin, which is the ACP
954                    // stdio shutdown signal, before this grace period starts.
955                    match futures::future::select(
956                        child_wait,
957                        async_io::Timer::after(shutdown_timeouts.exit),
958                    )
959                    .await
960                    {
961                        futures::future::Either::Left((child, _)) => {
962                            finish_child_exit(child?).await
963                        }
964                        futures::future::Either::Right((_, child_wait)) => {
965                            terminate_unresponsive_child(
966                                child_wait,
967                                #[cfg(unix)]
968                                child_pid,
969                                shutdown_timeouts,
970                            )
971                            .await
972                        }
973                    }
974                }
975                futures::future::Either::Right((child, protocol_future)) => {
976                    finish_child_exit(child?).await?;
977                    await_protocol_shutdown_after_successful_child_exit(
978                        protocol_future,
979                        SHUTDOWN_GRACE_PERIOD,
980                    )
981                    .await
982                }
983            }
984        };
985
986        // Run stderr collection concurrently with the main logic.
987        // When main_race completes, we don't need stderr anymore.
988        let main_race = pin!(main_race);
989        match futures::future::select(main_race, stderr_future).await {
990            futures::future::Either::Left((result, _)) => result,
991            futures::future::Either::Right(((), main_race)) => main_race.await,
992        }
993    }
994}
995
996impl AcpAgent {
997    /// Create an `AcpAgent` from an iterator of command-line arguments.
998    ///
999    /// Leading arguments of the form `NAME=value` are parsed as environment variables.
1000    /// The first non-env argument is the command, and the rest are arguments.
1001    ///
1002    /// # Example
1003    ///
1004    /// ```
1005    /// # use agent_client_protocol::AcpAgent;
1006    /// let agent = AcpAgent::from_args([
1007    ///     "RUST_LOG=debug",
1008    ///     "cargo",
1009    ///     "run",
1010    ///     "-p",
1011    ///     "my-crate",
1012    /// ]).unwrap();
1013    /// ```
1014    pub fn from_args<I, T>(args: I) -> Result<Self, crate::Error>
1015    where
1016        I: IntoIterator<Item = T>,
1017        T: ToString,
1018    {
1019        let args: Vec<String> = args.into_iter().map(|s| s.to_string()).collect();
1020
1021        if args.is_empty() {
1022            return Err(crate::util::internal_error("Arguments cannot be empty"));
1023        }
1024
1025        let mut env = BTreeMap::new();
1026        let mut command_idx = 0;
1027
1028        for (i, arg) in args.iter().enumerate() {
1029            if let Some((name, value)) = parse_env_var(arg) {
1030                env.insert(name, value);
1031                command_idx = i + 1;
1032            } else {
1033                break;
1034            }
1035        }
1036
1037        if command_idx >= args.len() {
1038            return Err(crate::util::internal_error(
1039                "No command found (only environment variables provided)",
1040            ));
1041        }
1042
1043        let command = PathBuf::from(&args[command_idx]);
1044        let cmd_args = args[command_idx + 1..].to_vec();
1045
1046        Ok(Self::new(
1047            AcpAgentConfig::new(command).args(cmd_args).envs(env),
1048        ))
1049    }
1050}
1051
1052/// Parse a string as an environment variable assignment (NAME=value).
1053fn parse_env_var(s: &str) -> Option<(String, String)> {
1054    let eq_pos = s.find('=')?;
1055    if eq_pos == 0 {
1056        return None;
1057    }
1058
1059    let name = &s[..eq_pos];
1060    let value = &s[eq_pos + 1..];
1061
1062    let mut chars = name.chars();
1063    let first = chars.next()?;
1064    if !first.is_ascii_alphabetic() && first != '_' {
1065        return None;
1066    }
1067    if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_') {
1068        return None;
1069    }
1070
1071    Some((name.to_string(), value.to_string()))
1072}
1073
1074impl FromStr for AcpAgent {
1075    type Err = crate::Error;
1076
1077    fn from_str(s: &str) -> Result<Self, Self::Err> {
1078        let trimmed = s.trim();
1079
1080        if trimmed.starts_with('{') {
1081            let config = serde_json::from_str(trimmed)
1082                .map_err(|e| crate::util::internal_error(format!("Failed to parse JSON: {e}")))?;
1083            return Ok(Self::new(config));
1084        }
1085
1086        let parts = shell_words::split(trimmed)
1087            .map_err(|e| crate::util::internal_error(format!("Failed to parse command: {e}")))?;
1088
1089        Self::from_args(parts)
1090    }
1091}
1092
1093#[cfg(test)]
1094mod tests {
1095    use super::*;
1096    use std::sync::Mutex;
1097    use std::sync::atomic::{AtomicUsize, Ordering};
1098
1099    fn recording_debug_callback() -> (DebugCallback, Arc<Mutex<Vec<String>>>) {
1100        let lines = Arc::new(Mutex::new(Vec::new()));
1101        let recorded = lines.clone();
1102        let callback = Arc::new(move |line: &str, direction| {
1103            assert_eq!(direction, LineDirection::Stderr);
1104            recorded.lock().unwrap().push(line.to_owned());
1105        });
1106        (callback, lines)
1107    }
1108
1109    #[test]
1110    fn stderr_tail_keeps_last_bytes() {
1111        let initial = vec![b'a'; STDERR_CAPTURE_LIMIT];
1112
1113        let mut exact = StderrTail::default();
1114        exact.push(&initial);
1115        assert_eq!(exact.into_string(), String::from_utf8(initial).unwrap());
1116
1117        let mut truncated = StderrTail::default();
1118        truncated.push(&vec![b'a'; STDERR_CAPTURE_LIMIT]);
1119        truncated.push(b"the end");
1120        let captured = truncated.into_string();
1121        let (notice, tail) = captured.split_once('\n').unwrap();
1122        assert_eq!(
1123            notice,
1124            format!("[stderr truncated; showing last {STDERR_CAPTURE_LIMIT} bytes]")
1125        );
1126        assert_eq!(tail.len(), STDERR_CAPTURE_LIMIT);
1127        assert!(tail.ends_with("the end"));
1128    }
1129
1130    #[test]
1131    fn stderr_debug_callback_preserves_lines() {
1132        let (callback, recorded) = recording_debug_callback();
1133        let mut lines = StderrDebugLines::default();
1134
1135        lines.push(b"one\r", &callback);
1136        lines.push(b"\n\ntw", &callback);
1137        lines.push(b"o\nbad\xff\nlast\r", &callback);
1138        lines.finish(&callback);
1139
1140        assert_eq!(
1141            *recorded.lock().unwrap(),
1142            ["one", "", "two", "bad\u{fffd}", "last\r"]
1143        );
1144    }
1145
1146    #[test]
1147    fn stderr_debug_callback_truncates_oversized_lines() {
1148        let (callback, recorded) = recording_debug_callback();
1149        let mut lines = StderrDebugLines::default();
1150        let exact = vec![b'y'; STDERR_CAPTURE_LIMIT];
1151        let oversized = vec![b'x'; STDERR_CAPTURE_LIMIT + 1];
1152
1153        lines.push(&exact, &callback);
1154        lines.push(b"\r\n", &callback);
1155        lines.push(&oversized, &callback);
1156        assert_eq!(lines.current.len(), STDERR_CAPTURE_LIMIT);
1157        assert!(lines.truncated);
1158        lines.push(b"\nnext\n", &callback);
1159
1160        let recorded = recorded.lock().unwrap();
1161        assert_eq!(recorded.len(), 3);
1162        assert_eq!(recorded[0].len(), STDERR_CAPTURE_LIMIT);
1163        assert!(!recorded[0].ends_with(STDERR_LINE_TRUNCATION_MARKER));
1164        assert_eq!(
1165            recorded[1].len(),
1166            STDERR_CAPTURE_LIMIT + STDERR_LINE_TRUNCATION_MARKER.len()
1167        );
1168        assert!(recorded[1].ends_with(STDERR_LINE_TRUNCATION_MARKER));
1169        assert_eq!(recorded[2], "next");
1170    }
1171
1172    struct ErrorAfterData {
1173        polls: Arc<AtomicUsize>,
1174    }
1175
1176    impl futures::AsyncRead for ErrorAfterData {
1177        fn poll_read(
1178            self: std::pin::Pin<&mut Self>,
1179            _cx: &mut std::task::Context<'_>,
1180            buffer: &mut [u8],
1181        ) -> std::task::Poll<std::io::Result<usize>> {
1182            match self.polls.fetch_add(1, Ordering::SeqCst) {
1183                0 => {
1184                    buffer[..7].copy_from_slice(b"partial");
1185                    std::task::Poll::Ready(Ok(7))
1186                }
1187                1 => std::task::Poll::Ready(Err(std::io::Error::other("read failed"))),
1188                _ => panic!("stderr reader was polled again after an error"),
1189            }
1190        }
1191    }
1192
1193    struct HeldOpenStderr(futures::io::Cursor<Vec<u8>>);
1194
1195    impl futures::AsyncRead for HeldOpenStderr {
1196        fn poll_read(
1197            self: std::pin::Pin<&mut Self>,
1198            cx: &mut std::task::Context<'_>,
1199            buffer: &mut [u8],
1200        ) -> std::task::Poll<std::io::Result<usize>> {
1201            match std::pin::Pin::new(&mut self.get_mut().0).poll_read(cx, buffer) {
1202                // Simulate an inherited stderr pipe whose writer never closes.
1203                std::task::Poll::Ready(Ok(0)) => std::task::Poll::Pending,
1204                result => result,
1205            }
1206        }
1207    }
1208
1209    #[test]
1210    fn stderr_capture_is_available_before_eof_and_outside_callback_locks() {
1211        use futures::FutureExt as _;
1212
1213        let capture = StderrCapture::default();
1214        let (record, recorded) = recording_debug_callback();
1215        let callback: DebugCallback = Arc::new({
1216            let capture = capture.clone();
1217            move |line, direction| {
1218                assert!(
1219                    capture.0.try_lock().is_ok(),
1220                    "debug callbacks must not run under the capture lock"
1221                );
1222                record(line, direction);
1223            }
1224        });
1225        let mut drain = pin!(drain_stderr(
1226            HeldOpenStderr(futures::io::Cursor::new(b"diagnostic\npartial".to_vec())),
1227            Some(callback),
1228            capture.clone(),
1229        ));
1230        assert!((&mut drain).now_or_never().is_none());
1231        assert_eq!(capture.take(), "diagnostic\npartial");
1232        assert_eq!(*recorded.lock().unwrap(), ["diagnostic"]);
1233    }
1234
1235    #[cfg(unix)]
1236    #[tokio::test]
1237    async fn nonzero_exit_preserves_captured_stderr_without_eof() {
1238        let agent = AcpAgent::from_args(["/bin/sh", "-c", "exit 17"]).unwrap();
1239        let (stdin, stdout, stderr, child) = agent.spawn_process().unwrap();
1240        drop((stdin, stdout, stderr));
1241
1242        let mut bytes = vec![b'x'; STDERR_CAPTURE_LIMIT + 1024];
1243        bytes.extend_from_slice(b"\nACP_BUFFERED_ERROR\nunterminated");
1244        let (callback, recorded) = recording_debug_callback();
1245        let (stderr_tx, stderr_rx) = futures::channel::oneshot::channel();
1246        let stderr_capture = StderrCapture::default();
1247        let capture = stderr_capture.clone();
1248        let drain = Box::pin(async move {
1249            let _error = drain_stderr(
1250                HeldOpenStderr(futures::io::Cursor::new(bytes)),
1251                Some(callback),
1252                capture,
1253            )
1254            .await;
1255            let _ = stderr_tx.send(());
1256        });
1257        let report = async move {
1258            let child = wait_for_child(ChildGuard(child), stderr_rx, stderr_capture).await?;
1259            finish_child_exit(child).await
1260        };
1261
1262        let error = tokio::time::timeout(std::time::Duration::from_secs(5), async {
1263            match futures::future::select(pin!(report), drain).await {
1264                futures::future::Either::Left((result, _)) => result,
1265                futures::future::Either::Right(_) => panic!("stderr must remain open"),
1266            }
1267        })
1268        .await
1269        .expect("stderr reporting must remain bounded")
1270        .expect_err("nonzero child exit should be reported");
1271
1272        assert!(
1273            recorded
1274                .lock()
1275                .unwrap()
1276                .iter()
1277                .any(|line| line == "ACP_BUFFERED_ERROR"),
1278            "the diagnostic was read before reporting the exit"
1279        );
1280        let detail = error
1281            .data
1282            .as_ref()
1283            .and_then(serde_json::Value::as_str)
1284            .unwrap();
1285        assert!(detail.contains("exit status: 17"), "{error:?}");
1286        assert!(detail.contains("ACP_BUFFERED_ERROR"), "{error:?}");
1287        assert!(
1288            detail.contains("[stderr truncated; showing last"),
1289            "{error:?}"
1290        );
1291        assert!(detail.ends_with("unterminated"), "{error:?}");
1292        assert_eq!(
1293            detail.split_once('\n').unwrap().1.len(),
1294            STDERR_CAPTURE_LIMIT
1295        );
1296    }
1297
1298    #[tokio::test]
1299    async fn stderr_drain_stops_after_read_error() {
1300        let polls = Arc::new(AtomicUsize::new(0));
1301        let (callback, recorded) = recording_debug_callback();
1302        let capture = StderrCapture::default();
1303
1304        let error = drain_stderr(
1305            ErrorAfterData {
1306                polls: polls.clone(),
1307            },
1308            Some(callback),
1309            capture.clone(),
1310        )
1311        .await;
1312
1313        assert_eq!(capture.take(), "partial");
1314        assert_eq!(error.unwrap().to_string(), "read failed");
1315        assert_eq!(polls.load(Ordering::SeqCst), 2);
1316        assert_eq!(*recorded.lock().unwrap(), ["partial"]);
1317    }
1318
1319    #[tokio::test]
1320    async fn successful_child_exit_bounds_protocol_shutdown_cleanly() {
1321        let grace = std::time::Duration::from_millis(10);
1322        tokio::time::timeout(
1323            std::time::Duration::from_secs(1),
1324            await_protocol_shutdown_after_successful_child_exit(
1325                futures::future::pending::<Result<(), crate::Error>>(),
1326                grace,
1327            ),
1328        )
1329        .await
1330        .expect("protocol shutdown wait should be bounded")
1331        .expect("a successful child exit should stop the pending protocol cleanly");
1332    }
1333
1334    #[tokio::test]
1335    async fn successful_child_exit_preserves_ready_protocol_error() {
1336        let error = await_protocol_shutdown_after_successful_child_exit(
1337            futures::future::ready(Err(crate::util::internal_error(
1338                "protocol failed during shutdown",
1339            ))),
1340            std::time::Duration::from_secs(1),
1341        )
1342        .await
1343        .expect_err("a ready protocol error should remain authoritative");
1344        let detail = error
1345            .data
1346            .as_ref()
1347            .and_then(serde_json::Value::as_str)
1348            .unwrap_or_default();
1349
1350        assert!(
1351            detail.contains("protocol failed during shutdown"),
1352            "unexpected protocol error: {error:?}"
1353        );
1354    }
1355
1356    #[cfg(unix)]
1357    #[tokio::test]
1358    async fn large_unterminated_stderr_is_fully_drained() {
1359        let agent = AcpAgent::from_args([
1360            "/bin/sh",
1361            "-c",
1362            r#"i=0; while [ "$i" -lt 4096 ]; do printf '%01024d' 0; i=$((i + 1)); done >&2; printf ACP_END >&2; exit 17"#,
1363        ])
1364        .unwrap();
1365        let (child_stdin, child_stdout, child_stderr, child) = agent.spawn_process().unwrap();
1366        drop(child_stdin);
1367        drop(child_stdout);
1368        let mut guard = ChildGuard(child);
1369        let capture = StderrCapture::default();
1370
1371        let (read_error, status) =
1372            tokio::time::timeout(std::time::Duration::from_secs(10), async {
1373                futures::join!(
1374                    drain_stderr(child_stderr, None, capture.clone()),
1375                    guard.wait()
1376                )
1377            })
1378            .await
1379            .expect("stderr drain should not block after its retained tail is full");
1380
1381        assert_eq!(status.unwrap().code(), Some(17));
1382        assert!(read_error.is_none());
1383        let captured = capture.take();
1384        let (notice, tail) = captured.split_once('\n').unwrap();
1385        assert_eq!(
1386            notice,
1387            format!("[stderr truncated; showing last {STDERR_CAPTURE_LIMIT} bytes]")
1388        );
1389        assert_eq!(tail.len(), STDERR_CAPTURE_LIMIT);
1390        assert!(tail.ends_with("ACP_END"));
1391    }
1392
1393    #[cfg(unix)]
1394    #[tokio::test]
1395    async fn protocol_eof_still_reports_nonzero_child_exit() {
1396        let agent = AcpAgent::from_args([
1397            "/bin/sh",
1398            "-c",
1399            "exec 1>&-; cat >/dev/null; printf ACP_TEST_FAILURE_AFTER_STDOUT_EOF >&2; exit 17",
1400        ])
1401        .unwrap();
1402
1403        let error = tokio::time::timeout(
1404            std::time::Duration::from_secs(5),
1405            Client.v1().connect_to(agent),
1406        )
1407        .await
1408        .expect("connection should finish after the child exits")
1409        .expect_err("nonzero child exit after protocol EOF should be reported");
1410        let detail = error
1411            .data
1412            .as_ref()
1413            .map(serde_json::Value::to_string)
1414            .unwrap_or_default();
1415
1416        assert!(
1417            detail.contains("exit status: 17"),
1418            "child exit status should be preserved: {error:?}"
1419        );
1420        assert!(
1421            detail.contains("ACP_TEST_FAILURE_AFTER_STDOUT_EOF"),
1422            "child stderr should be preserved: {error:?}"
1423        );
1424    }
1425
1426    #[cfg(unix)]
1427    #[tokio::test]
1428    async fn successful_child_exit_does_not_cancel_active_foreground() {
1429        let agent = AcpAgent::from_args(["/bin/sh", "-c", "exit 0"]).unwrap();
1430        let (started_tx, started_rx) = futures::channel::oneshot::channel();
1431        let (closed_tx, closed_rx) = futures::channel::oneshot::channel();
1432        let (close_release_tx, close_release_rx) = futures::channel::oneshot::channel();
1433        let (release_tx, release_rx) = futures::channel::oneshot::channel();
1434        let connection = tokio::spawn(
1435            Client
1436                .v1()
1437                .on_close(async move |_cx| {
1438                    closed_tx.send(()).map_err(|()| {
1439                        crate::Error::internal_error().data("close observer dropped")
1440                    })?;
1441                    close_release_rx.await.map_err(|_| {
1442                        crate::Error::internal_error().data("close callback release dropped")
1443                    })
1444                })
1445                .connect_with(agent, async move |_cx| {
1446                    started_tx.send(()).map_err(|()| {
1447                        crate::Error::internal_error().data("foreground observer dropped")
1448                    })?;
1449                    release_rx.await.map_err(|_| {
1450                        crate::Error::internal_error().data("foreground release dropped")
1451                    })
1452                }),
1453        );
1454
1455        tokio::time::timeout(std::time::Duration::from_secs(5), started_rx)
1456            .await
1457            .expect("foreground should start")
1458            .expect("foreground should report that it started");
1459
1460        tokio::time::timeout(std::time::Duration::from_secs(5), closed_rx)
1461            .await
1462            .expect("successful child exit should close the protocol transport")
1463            .expect("successful child exit should invoke close callbacks");
1464
1465        tokio::time::sleep(SHUTDOWN_GRACE_PERIOD + std::time::Duration::from_millis(250)).await;
1466        assert!(
1467            !connection.is_finished(),
1468            "successful child exit canceled active cleanup"
1469        );
1470
1471        close_release_tx
1472            .send(())
1473            .expect("clean child exit should preserve close callbacks");
1474        release_tx
1475            .send(())
1476            .expect("clean child exit should preserve the foreground");
1477        tokio::time::timeout(std::time::Duration::from_secs(5), connection)
1478            .await
1479            .expect("released foreground should finish")
1480            .expect("connection task should not panic")
1481            .expect("successful child exit should remain a clean EOF");
1482    }
1483
1484    #[cfg(unix)]
1485    struct KillOnDrop(Option<rustix::process::Pid>);
1486
1487    #[cfg(unix)]
1488    impl KillOnDrop {
1489        fn disarm(&mut self) {
1490            self.0 = None;
1491        }
1492    }
1493
1494    #[cfg(unix)]
1495    impl Drop for KillOnDrop {
1496        fn drop(&mut self) {
1497            if let Some(pid) = self.0 {
1498                let _result = rustix::process::kill_process(pid, rustix::process::Signal::KILL);
1499            }
1500        }
1501    }
1502
1503    #[cfg(unix)]
1504    fn wrapper_agent(script: &str) -> (AcpAgent, tokio::sync::mpsc::UnboundedReceiver<String>) {
1505        let (pid_tx, pid_rx) = tokio::sync::mpsc::unbounded_channel();
1506        let agent = AcpAgent::from_args(["/bin/sh", "-c", script])
1507            .unwrap()
1508            .with_debug(move |line, direction| {
1509                if direction == LineDirection::Stderr {
1510                    drop(pid_tx.send(line.to_owned()));
1511                }
1512            });
1513        (agent, pid_rx)
1514    }
1515
1516    #[cfg(unix)]
1517    fn process_is_running(pid: rustix::process::Pid) -> bool {
1518        if rustix::process::test_kill_process(pid).is_err() {
1519            return false;
1520        }
1521
1522        // A killed orphan can remain as a zombie under a container PID 1 that
1523        // does not reap promptly. Treat zombies as exited for this test.
1524        match std::process::Command::new("ps")
1525            .args(["-o", "stat=", "-p", &pid.to_string()])
1526            .output()
1527        {
1528            Ok(output) if output.status.success() => {
1529                let state = String::from_utf8_lossy(&output.stdout);
1530                !state.trim().is_empty() && !state.trim_start().starts_with('Z')
1531            }
1532            Ok(_) => false,
1533            Err(_) => true,
1534        }
1535    }
1536
1537    #[cfg(unix)]
1538    async fn reported_descendant_pid(
1539        connection: &mut (impl Future<Output = Result<(), crate::Error>> + Unpin),
1540        pid_rx: &mut tokio::sync::mpsc::UnboundedReceiver<String>,
1541    ) -> rustix::process::Pid {
1542        tokio::time::timeout(std::time::Duration::from_secs(5), async {
1543            loop {
1544                tokio::select! {
1545                    biased;
1546                    line = pid_rx.recv() => {
1547                        let line = line.expect("wrapper stderr should remain open");
1548                        if let Some(pid) = line.strip_prefix("ACP_TEST_CHILD_PID=") {
1549                            let pid = pid.parse::<i32>().expect("valid descendant PID");
1550                            break rustix::process::Pid::from_raw(pid)
1551                                .expect("nonzero descendant PID");
1552                        }
1553                    }
1554                    result = &mut *connection => {
1555                        panic!("agent connection exited before reporting descendant PID: {result:?}");
1556                    }
1557                }
1558            }
1559        })
1560        .await
1561        .expect("wrapper should report descendant PID")
1562    }
1563
1564    #[cfg(unix)]
1565    async fn assert_process_exits(pid: rustix::process::Pid) {
1566        let exited = tokio::time::timeout(std::time::Duration::from_secs(5), async {
1567            while process_is_running(pid) {
1568                tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1569            }
1570        })
1571        .await
1572        .is_ok();
1573        assert!(exited, "descendant process {pid} remained alive");
1574    }
1575
1576    #[cfg(unix)]
1577    #[tokio::test]
1578    async fn protocol_eof_terminates_a_child_that_does_not_exit() {
1579        let (agent, mut pid_rx) =
1580            wrapper_agent("echo ACP_TEST_CHILD_PID=$$ >&2; exec 1>&-; while :; do sleep 30; done");
1581        let mut connection: futures::future::BoxFuture<'static, Result<(), crate::Error>> =
1582            Box::pin(Client.v1().connect_to(agent));
1583        let child_pid = reported_descendant_pid(&mut connection, &mut pid_rx).await;
1584        let mut cleanup = KillOnDrop(Some(child_pid));
1585
1586        assert!(process_is_running(child_pid));
1587        tokio::time::timeout(std::time::Duration::from_secs(5), &mut connection)
1588            .await
1589            .expect("protocol shutdown should bound its child-exit wait")
1590            .expect("clean protocol shutdown should terminate a non-exiting child");
1591        assert_process_exits(child_pid).await;
1592        cleanup.disarm();
1593    }
1594
1595    #[cfg(unix)]
1596    #[tokio::test]
1597    async fn protocol_eof_bounds_a_blocked_outgoing_drain() {
1598        let (agent, mut pid_rx) = wrapper_agent(
1599            "echo ACP_TEST_CHILD_PID=$$ >&2; exec 1>&-; sleep 30 & child=$!; wait \"$child\"",
1600        );
1601        let (channel, connection) = crate::ConnectTo::<Client>::into_channel_and_future(agent);
1602        let mut connection = connection.expect("AcpAgent owns its process connection");
1603        let crate::Channel {
1604            rx: _incoming,
1605            tx: outgoing,
1606        } = channel;
1607
1608        let response = crate::RawJsonRpcMessage::response(
1609            crate::RawJsonRpcId::Number(1),
1610            Ok(serde_json::json!({ "payload": "x".repeat(4 * 1024 * 1024) })),
1611        );
1612        outgoing
1613            .unbounded_send(crate::TransportFrame::Single(response))
1614            .expect("response should be accepted before the connection starts");
1615        outgoing.close_channel();
1616
1617        let child_pid = reported_descendant_pid(&mut connection, &mut pid_rx).await;
1618        let mut cleanup = KillOnDrop(Some(child_pid));
1619
1620        let error = tokio::time::timeout(std::time::Duration::from_secs(5), &mut connection)
1621            .await
1622            .expect("stdout EOF should bound a blocked outgoing drain")
1623            .expect_err("an undelivered accepted response must not report success");
1624        let detail = error
1625            .data
1626            .as_ref()
1627            .and_then(serde_json::Value::as_str)
1628            .unwrap_or_default();
1629        assert!(
1630            detail.contains("pending protocol output did not drain"),
1631            "the error should identify the blocked outgoing drain: {error:?}"
1632        );
1633
1634        assert_process_exits(child_pid).await;
1635        cleanup.disarm();
1636    }
1637
1638    #[cfg(unix)]
1639    #[tokio::test]
1640    async fn test_connection_drop_kills_wrapper_descendant() {
1641        let (agent, mut pid_rx) = wrapper_agent(
1642            "sleep 30 & child=$!; echo ACP_TEST_CHILD_PID=$child >&2; wait \"$child\"",
1643        );
1644        let (_channel, connection) = crate::ConnectTo::<Client>::into_channel_and_future(agent);
1645        let mut connection = connection.expect("AcpAgent owns its process connection");
1646        let descendant_pid = reported_descendant_pid(&mut connection, &mut pid_rx).await;
1647        let mut cleanup = KillOnDrop(Some(descendant_pid));
1648
1649        assert!(process_is_running(descendant_pid));
1650        drop(connection);
1651        assert_process_exits(descendant_pid).await;
1652        cleanup.disarm();
1653    }
1654
1655    #[cfg(unix)]
1656    #[tokio::test]
1657    async fn test_launcher_exit_kills_descendant_before_stderr_wait() {
1658        let (agent, mut pid_rx) = wrapper_agent(
1659            "sh -c 'trap \"\" HUP; exec sleep 30' >/dev/null & child=$!; echo ACP_TEST_CHILD_PID=$child >&2; exit 17",
1660        );
1661        let (_channel, connection) = crate::ConnectTo::<Client>::into_channel_and_future(agent);
1662        let mut connection = connection.expect("AcpAgent owns its process connection");
1663        let descendant_pid = reported_descendant_pid(&mut connection, &mut pid_rx).await;
1664        let mut cleanup = KillOnDrop(Some(descendant_pid));
1665
1666        let result = tokio::time::timeout(std::time::Duration::from_secs(5), &mut connection)
1667            .await
1668            .expect("connection should observe the launcher exit");
1669        let error = result.expect_err("nonzero launcher exit should be an error");
1670        let detail = error
1671            .data
1672            .as_ref()
1673            .and_then(serde_json::Value::as_str)
1674            .unwrap_or_default();
1675        assert!(
1676            detail.contains("ACP_TEST_CHILD_PID="),
1677            "launcher stderr should be preserved: {error:?}"
1678        );
1679        assert_process_exits(descendant_pid).await;
1680        cleanup.disarm();
1681    }
1682
1683    /// Files a shutdown-test agent script uses to report its PID and whether
1684    /// its `TERM` trap ran. Removed on drop.
1685    #[cfg(unix)]
1686    struct ShutdownMarkers {
1687        dir: PathBuf,
1688    }
1689
1690    #[cfg(unix)]
1691    impl ShutdownMarkers {
1692        fn new(name: &str) -> Self {
1693            static NEXT: AtomicUsize = AtomicUsize::new(0);
1694            let dir = std::env::temp_dir().join(format!(
1695                "acp-shutdown-{name}-{}-{}",
1696                std::process::id(),
1697                NEXT.fetch_add(1, Ordering::Relaxed)
1698            ));
1699            std::fs::create_dir_all(&dir).unwrap();
1700            Self { dir }
1701        }
1702
1703        fn pid_path(&self) -> PathBuf {
1704            self.dir.join("pid")
1705        }
1706
1707        fn term_path(&self) -> PathBuf {
1708            self.dir.join("term")
1709        }
1710
1711        /// An agent running `script` as `sh -c script sh <pid file> <term file>`.
1712        ///
1713        /// Scripts install their `TERM` trap before writing the PID file, since
1714        /// [`shut_down_once_started`] starts shutdown as soon as that file exists.
1715        fn agent(&self, script: &str, timeouts: ShutdownTimeouts) -> AcpAgent {
1716            let mut agent = AcpAgent::from_args([
1717                "/bin/sh",
1718                "-c",
1719                script,
1720                "sh",
1721                self.pid_path().to_str().unwrap(),
1722                self.term_path().to_str().unwrap(),
1723            ])
1724            .unwrap();
1725            agent.shutdown_timeouts = timeouts;
1726            agent
1727        }
1728
1729        fn pid(&self) -> rustix::process::Pid {
1730            let pid = std::fs::read_to_string(self.pid_path()).expect("agent wrote its PID");
1731            rustix::process::Pid::from_raw(pid.trim().parse().unwrap()).unwrap()
1732        }
1733
1734        fn received_sigterm(&self) -> bool {
1735            self.term_path().exists()
1736        }
1737    }
1738
1739    #[cfg(unix)]
1740    impl Drop for ShutdownMarkers {
1741        fn drop(&mut self) {
1742            let _result = std::fs::remove_dir_all(&self.dir);
1743        }
1744    }
1745
1746    /// Connects to `agent`, waits until its script has started (written its
1747    /// PID), then ends the protocol from the client side. Returns the result
1748    /// and how long shutdown took after the foreground returned.
1749    #[cfg(unix)]
1750    async fn shut_down_once_started(
1751        agent: AcpAgent,
1752        markers: &ShutdownMarkers,
1753    ) -> (Result<(), crate::Error>, Duration) {
1754        let pid_path = markers.pid_path();
1755        let (stopped_tx, stopped_rx) = futures::channel::oneshot::channel();
1756        let connection = Client.v1().connect_with(agent, async move |_cx| {
1757            while !pid_path.exists() {
1758                tokio::time::sleep(Duration::from_millis(10)).await;
1759            }
1760            let _sent = stopped_tx.send(std::time::Instant::now());
1761            Ok(())
1762        });
1763        let result = tokio::time::timeout(Duration::from_secs(10), connection)
1764            .await
1765            .expect("agent shutdown should be bounded");
1766        let stopped = stopped_rx.await.expect("the foreground ran");
1767        (result, stopped.elapsed())
1768    }
1769
1770    #[cfg(unix)]
1771    #[tokio::test]
1772    async fn graceful_shutdown_child_exiting_on_stdin_eof_never_receives_sigterm() {
1773        let markers = ShutdownMarkers::new("eof");
1774        let agent = markers.agent(
1775            r#"trap 'echo term > "$2"' TERM; echo $$ > "$1"; cat >/dev/null; echo eof > "$1.eof"; exit 0"#,
1776            ShutdownTimeouts {
1777                exit: Duration::from_secs(5),
1778                terminate: Duration::from_secs(5),
1779            },
1780        );
1781
1782        let (result, elapsed) = shut_down_once_started(agent, &markers).await;
1783
1784        result.expect("an agent that exits on stdin EOF shuts down cleanly");
1785        assert!(
1786            elapsed < Duration::from_secs(5),
1787            "the agent should exit on stdin EOF, not after the grace period ({elapsed:?})"
1788        );
1789        assert!(!markers.received_sigterm(), "the agent received SIGTERM");
1790        assert!(
1791            markers.dir.join("pid.eof").exists(),
1792            "the agent should have read EOF on its stdin"
1793        );
1794        assert_process_exits(markers.pid()).await;
1795    }
1796
1797    #[cfg(unix)]
1798    #[tokio::test]
1799    async fn graceful_shutdown_sigterms_child_ignoring_stdin_eof() {
1800        let markers = ShutdownMarkers::new("term");
1801        let agent = markers.agent(
1802            r#"trap 'echo term > "$2"; exit 0' TERM; echo $$ > "$1"; while :; do sleep 1; done"#,
1803            ShutdownTimeouts {
1804                exit: Duration::from_millis(200),
1805                terminate: Duration::from_secs(5),
1806            },
1807        );
1808
1809        let (result, elapsed) = shut_down_once_started(agent, &markers).await;
1810        let pid = markers.pid();
1811        let mut cleanup = KillOnDrop(Some(pid));
1812
1813        result.expect("an agent stopped by SIGTERM shuts down cleanly");
1814        assert!(markers.received_sigterm(), "the agent did not get SIGTERM");
1815        assert!(
1816            elapsed < Duration::from_secs(5),
1817            "the agent should exit on SIGTERM, not wait for SIGKILL ({elapsed:?})"
1818        );
1819        assert_process_exits(pid).await;
1820        cleanup.disarm();
1821    }
1822
1823    #[cfg(unix)]
1824    #[tokio::test]
1825    async fn graceful_shutdown_sigkills_child_ignoring_sigterm() {
1826        let markers = ShutdownMarkers::new("kill");
1827        // The trap records SIGTERM but keeps running; `sleep` itself is
1828        // killed by the group SIGTERM, and the loop starts another one. The
1829        // trap is installed before the PID is written, so shutdown cannot
1830        // start before it. The terminate window leaves a loaded machine time
1831        // to run the trap before SIGKILL.
1832        let agent = markers.agent(
1833            r#"trap 'echo term > "$2"' TERM; echo $$ > "$1"; while :; do sleep 1; done"#,
1834            ShutdownTimeouts {
1835                exit: Duration::from_millis(100),
1836                terminate: Duration::from_secs(1),
1837            },
1838        );
1839
1840        let (result, elapsed) = shut_down_once_started(agent, &markers).await;
1841        let pid = markers.pid();
1842        let mut cleanup = KillOnDrop(Some(pid));
1843
1844        result.expect("an agent killed during shutdown is not an error");
1845        assert!(markers.received_sigterm(), "SIGTERM should precede SIGKILL");
1846        assert!(
1847            elapsed >= Duration::from_millis(1100),
1848            "SIGKILL should wait for the SIGTERM grace period ({elapsed:?})"
1849        );
1850        assert_process_exits(pid).await;
1851        cleanup.disarm();
1852    }
1853
1854    #[test]
1855    fn test_parse_simple_command() {
1856        let agent = AcpAgent::from_str("python agent.py").unwrap();
1857        let config = agent.config();
1858        assert_eq!(config.command(), Path::new("python"));
1859        assert_eq!(config.arguments(), ["agent.py"]);
1860        assert!(config.environment().is_empty());
1861    }
1862
1863    #[test]
1864    fn test_parse_environment_from_args() {
1865        let agent =
1866            AcpAgent::from_args(["RUST_LOG=debug", "NO_COLOR=1", "python", "agent.py"]).unwrap();
1867        let config = agent.config();
1868
1869        assert_eq!(config.command(), Path::new("python"));
1870        assert_eq!(config.arguments(), ["agent.py"]);
1871        assert_eq!(
1872            config.environment(),
1873            &BTreeMap::from([
1874                ("NO_COLOR".to_owned(), "1".to_owned()),
1875                ("RUST_LOG".to_owned(), "debug".to_owned()),
1876            ])
1877        );
1878    }
1879
1880    #[test]
1881    fn test_new_accepts_agent_configuration() {
1882        let config = AcpAgentConfig::new("/usr/bin/agent")
1883            .arg("--verbose")
1884            .env("RUST_LOG", "debug");
1885        let agent = AcpAgent::new(config.clone());
1886
1887        assert_eq!(agent.into_config(), config);
1888    }
1889
1890    #[test]
1891    fn test_parse_command_with_args() {
1892        let agent = AcpAgent::from_str("node server.js --port 8080 --verbose").unwrap();
1893        let config = agent.config();
1894        assert_eq!(config.command(), Path::new("node"));
1895        assert_eq!(
1896            config.arguments(),
1897            ["server.js", "--port", "8080", "--verbose"]
1898        );
1899        assert!(config.environment().is_empty());
1900    }
1901
1902    #[test]
1903    fn test_parse_command_with_quotes() {
1904        let agent = AcpAgent::from_str(r#"python "my agent.py" --name "Test Agent""#).unwrap();
1905        let config = agent.into_config();
1906        assert_eq!(config.command(), Path::new("python"));
1907        assert_eq!(config.arguments(), ["my agent.py", "--name", "Test Agent"]);
1908        assert!(config.environment().is_empty());
1909    }
1910
1911    #[test]
1912    fn test_parse_json_config() {
1913        let json = r#"{
1914            "command": "/usr/bin/python",
1915            "args": ["agent.py", "--verbose"],
1916            "env": {"RUST_LOG": "debug"}
1917        }"#;
1918        let agent = AcpAgent::from_str(json).unwrap();
1919        let config = agent.config();
1920        assert_eq!(config.command(), Path::new("/usr/bin/python"));
1921        assert_eq!(config.arguments(), ["agent.py", "--verbose"]);
1922        assert_eq!(
1923            config.environment().get("RUST_LOG").map(String::as_str),
1924            Some("debug")
1925        );
1926    }
1927
1928    #[test]
1929    fn test_config_json_round_trip() {
1930        let config = AcpAgentConfig::new("agent")
1931            .args(["--mode", "fast"])
1932            .envs([("RUST_LOG", "debug"), ("NO_COLOR", "1")]);
1933
1934        let json = serde_json::to_value(&config).unwrap();
1935        assert_eq!(
1936            json,
1937            serde_json::json!({
1938                "command": "agent",
1939                "args": ["--mode", "fast"],
1940                "env": {
1941                    "NO_COLOR": "1",
1942                    "RUST_LOG": "debug"
1943                }
1944            })
1945        );
1946        assert_eq!(
1947            serde_json::from_value::<AcpAgentConfig>(json).unwrap(),
1948            config
1949        );
1950    }
1951
1952    #[test]
1953    fn test_config_json_defaults_and_omits_empty_collections() {
1954        let config = serde_json::from_value::<AcpAgentConfig>(serde_json::json!({
1955            "command": "agent"
1956        }))
1957        .unwrap();
1958
1959        assert_eq!(config.arguments(), &[] as &[String]);
1960        assert!(config.environment().is_empty());
1961        assert_eq!(
1962            serde_json::to_value(config).unwrap(),
1963            serde_json::json!({ "command": "agent" })
1964        );
1965    }
1966
1967    #[test]
1968    fn test_reject_mcp_server_json() {
1969        let json = r#"{
1970            "type": "stdio",
1971            "name": "my-agent",
1972            "command": "/usr/bin/python",
1973            "args": ["agent.py"],
1974            "env": []
1975        }"#;
1976        let error = AcpAgent::from_str(json).unwrap_err();
1977        assert!(
1978            error
1979                .data
1980                .as_ref()
1981                .and_then(serde_json::Value::as_str)
1982                .is_some_and(|message| message.contains("unknown field")),
1983            "unexpected error: {error:?}"
1984        );
1985    }
1986}