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