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