Skip to main content

wt/agent/
mod.rs

1//! The code-agent boundary (issue #11): detect installed agent CLIs and drive
2//! them in their JSON output mode. [`AgentClient`] isolates the subprocess work
3//! so callers can inject a fake; [`RealAgent`] spawns the real binaries. A
4//! missing binary yields [`Error::AgentUnavailable`]; a non-zero exit yields
5//! [`Error::Subprocess`].
6//!
7//! Subprocess calls are synchronous (`std::process::Command`), matching the
8//! other CLI boundaries (`git`, `gh`, hooks).
9
10pub mod model;
11pub mod spec;
12pub mod types;
13
14use std::io::Read;
15use std::path::Path;
16use std::process::{Command, ExitStatus, Stdio};
17use std::time::{Duration, Instant};
18
19use crate::error::{Error, Result};
20pub use model::{AgentModel, AgentOptions, Effort};
21pub use spec::{AGENTS, AgentKind, AgentSpec, ResultFormat};
22pub use types::{AgentRun, AgentVersion, DetectedAgent};
23
24/// Detects and drives code-agent CLIs.
25pub trait AgentClient {
26    /// Probes one agent on `PATH`. Returns `Ok(None)` if it is not installed,
27    /// or `Err` if an installed binary fails to run.
28    fn detect(&self, kind: AgentKind) -> Result<Option<DetectedAgent>>;
29
30    /// Runs `kind` non-interactively on `prompt` in `dir`, in the agent's JSON
31    /// output mode, with the selected model and effort (`opts`), and returns the
32    /// normalized result.
33    fn run(
34        &self,
35        kind: AgentKind,
36        prompt: &str,
37        dir: &Path,
38        opts: &AgentOptions,
39    ) -> Result<AgentRun>;
40
41    /// Probes every known agent on `PATH`, returning those found. Agents that
42    /// are not installed are omitted (that is not an error).
43    fn detect_all(&self) -> Vec<DetectedAgent> {
44        AgentKind::all()
45            .iter()
46            .filter_map(|&kind| self.detect(kind).ok().flatten())
47            .collect()
48    }
49}
50
51/// The production [`AgentClient`] that spawns the real agent binaries.
52#[derive(Debug, Clone, Copy, Default)]
53pub struct RealAgent;
54
55impl AgentClient for RealAgent {
56    fn detect(&self, kind: AgentKind) -> Result<Option<DetectedAgent>> {
57        detect_with(kind.spec().binary, kind, kind.spec())
58    }
59
60    fn run(
61        &self,
62        kind: AgentKind,
63        prompt: &str,
64        dir: &Path,
65        opts: &AgentOptions,
66    ) -> Result<AgentRun> {
67        run_with(kind.spec().binary, kind, kind.spec(), prompt, dir, opts)
68    }
69}
70
71/// Detects `kind` by running `binary` with the spec's version args. Split from
72/// [`RealAgent::detect`] so tests can drive every branch with a stand-in
73/// binary. A missing binary maps to `Ok(None)`; other failures propagate.
74fn detect_with(binary: &str, kind: AgentKind, spec: &AgentSpec) -> Result<Option<DetectedAgent>> {
75    match run_agent(binary, None, &spec::version_argv(spec), None) {
76        Ok(stdout) => Ok(Some(DetectedAgent {
77            kind,
78            binary: binary.to_string(),
79            version: spec::parse_version(&stdout),
80        })),
81        Err(Error::AgentUnavailable(_)) => Ok(None),
82        Err(e) => Err(e),
83    }
84}
85
86/// Runs `binary` on `prompt` in `dir` per `spec`, parsing the JSON result.
87/// Split from [`RealAgent::run`] for the same testability reason.
88fn run_with(
89    binary: &str,
90    kind: AgentKind,
91    spec: &AgentSpec,
92    prompt: &str,
93    dir: &Path,
94    opts: &AgentOptions,
95) -> Result<AgentRun> {
96    let prompt = spec::apply_effort(opts.effort, prompt);
97    let argv = spec::prompt_argv(spec, &prompt, opts.model);
98    let stdout = run_agent(binary, Some(dir), &argv, opts.timeout)?;
99    spec::parse_result(kind, spec.result_format, &stdout)
100}
101
102/// Runs an agent `binary` (optionally in `dir`), mapping a missing binary to
103/// [`Error::AgentUnavailable`] and a non-zero exit to [`Error::Subprocess`].
104/// Mirrors `gh`'s `run_gh` helper.
105///
106/// With `timeout` set, the child is killed and [`Error::AgentTimeout`] returned
107/// once the deadline passes. `None` waits indefinitely — the historical
108/// behaviour, kept for version detection and for callers that pass no deadline.
109fn run_agent(
110    binary: &str,
111    dir: Option<&Path>,
112    args: &[String],
113    timeout: Option<Duration>,
114) -> Result<String> {
115    let mut cmd = Command::new(binary);
116    if let Some(dir) = dir {
117        cmd.current_dir(dir);
118    }
119    cmd.args(args);
120
121    let Some(limit) = timeout else {
122        return match cmd.output() {
123            Ok(output) => finish(binary, output.status, &output.stdout, &output.stderr),
124            Err(e) => Err(spawn_error(binary, &e)),
125        };
126    };
127
128    // `Command::output()` blocks until the pipes close, so it cannot be given a
129    // deadline. Read the pipes on their own threads and keep the `Child` here,
130    // so this thread still owns the handle it needs to kill.
131    cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
132    let mut child = match cmd.spawn() {
133        Ok(child) => child,
134        Err(e) => return Err(spawn_error(binary, &e)),
135    };
136    let mut out_pipe = child.stdout.take();
137    let mut err_pipe = child.stderr.take();
138    // Draining both pipes concurrently matters: a child that fills the stderr
139    // pipe buffer blocks forever if only stdout is being read.
140    let out_reader = std::thread::spawn(move || read_pipe(out_pipe.as_mut()));
141    let err_reader = std::thread::spawn(move || read_pipe(err_pipe.as_mut()));
142
143    let deadline = Instant::now() + limit;
144    let status = loop {
145        match child.try_wait() {
146            Ok(Some(status)) => break Some(status),
147            Ok(None) => {}
148            Err(e) => return Err(spawn_error(binary, &e)),
149        }
150        if Instant::now() >= deadline {
151            // Killing closes the child's pipes, which is what lets the reader
152            // threads below finish rather than block forever.
153            let _ = child.kill();
154            let _ = child.wait();
155            break None;
156        }
157        std::thread::sleep(POLL_INTERVAL);
158    };
159
160    match status {
161        Some(status) => {
162            // A panicking reader yields no output rather than poisoning the run.
163            let stdout = out_reader.join().unwrap_or_default();
164            let stderr = err_reader.join().unwrap_or_default();
165            finish(binary, status, &stdout, &stderr)
166        }
167        None => {
168            // Deliberately *not* joined. Killing the child does not close the
169            // pipes if it left a grandchild holding them — an agent CLI that is
170            // a wrapper script is exactly that shape — so a reader would block
171            // for as long as the grandchild lives, which is precisely what the
172            // deadline exists to prevent. The output is unwanted anyway, so the
173            // readers are detached; they end on their own when the pipes close.
174            drop(out_reader);
175            drop(err_reader);
176            Err(Error::AgentTimeout {
177                binary: binary.to_string(),
178                // Sub-second deadlines still report `1s`; the message is for
179                // humans, and "did not respond within 0s" reads as a bug.
180                seconds: limit.as_secs().max(1),
181            })
182        }
183    }
184}
185
186/// How often the deadline loop checks whether the child has exited. Short
187/// enough that a fast agent is not held up perceptibly, long enough not to spin.
188const POLL_INTERVAL: Duration = Duration::from_millis(10);
189
190/// Drains a child pipe to end, yielding empty bytes if it is absent or fails.
191fn read_pipe(pipe: Option<&mut impl Read>) -> Vec<u8> {
192    let mut buf = Vec::new();
193    if let Some(pipe) = pipe {
194        let _ = pipe.read_to_end(&mut buf);
195    }
196    buf
197}
198
199/// Maps a spawn failure to [`Error::AgentUnavailable`], distinguishing a missing
200/// binary from any other launch failure.
201fn spawn_error(binary: &str, e: &std::io::Error) -> Error {
202    if e.kind() == std::io::ErrorKind::NotFound {
203        Error::AgentUnavailable(format!("{binary} is not installed or not on PATH"))
204    } else {
205        Error::AgentUnavailable(format!("failed to run {binary}: {e}"))
206    }
207}
208
209/// Maps a finished process to its stdout, or to [`Error::Subprocess`].
210fn finish(binary: &str, status: ExitStatus, stdout: &[u8], stderr: &[u8]) -> Result<String> {
211    if status.success() {
212        return Ok(String::from_utf8_lossy(stdout).into_owned());
213    }
214    Err(Error::Subprocess {
215        program: binary.to_string(),
216        stderr: String::from_utf8_lossy(stderr).trim().to_string(),
217    })
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    /// A nonexistent binary name, used to exercise the not-found path.
225    const MISSING: &str = "wt-nonexistent-agent-binary-xyzzy";
226
227    /// Behaviors for the in-test [`AgentClient`] fake, to cover `detect_all`.
228    enum Behavior {
229        Found,
230        Missing,
231        Failing,
232    }
233
234    struct Fake(Behavior);
235
236    impl AgentClient for Fake {
237        fn detect(&self, kind: AgentKind) -> Result<Option<DetectedAgent>> {
238            match self.0 {
239                Behavior::Found => Ok(Some(DetectedAgent {
240                    kind,
241                    binary: kind.as_str().to_string(),
242                    version: AgentVersion {
243                        version: None,
244                        raw: String::new(),
245                    },
246                })),
247                Behavior::Missing => Ok(None),
248                Behavior::Failing => Err(Error::operation("boom")),
249            }
250        }
251
252        fn run(
253            &self,
254            kind: AgentKind,
255            prompt: &str,
256            _dir: &Path,
257            _opts: &AgentOptions,
258        ) -> Result<AgentRun> {
259            Ok(AgentRun {
260                kind,
261                is_error: false,
262                result: prompt.to_string(),
263                raw: serde_json::Value::Null,
264            })
265        }
266    }
267
268    #[test]
269    fn detect_all_keeps_found_drops_missing_and_failing() {
270        assert_eq!(
271            Fake(Behavior::Found).detect_all().len(),
272            AgentKind::all().len()
273        );
274        assert!(Fake(Behavior::Missing).detect_all().is_empty());
275        // An installed-but-erroring agent is dropped by `detect_all` (errors
276        // surface only through `detect`).
277        assert!(Fake(Behavior::Failing).detect_all().is_empty());
278    }
279
280    #[test]
281    fn fake_run_returns_normalized_result() {
282        let dir = tempfile::tempdir().unwrap();
283        let run = Fake(Behavior::Found)
284            .run(
285                AgentKind::Claude,
286                "hi",
287                dir.path(),
288                &AgentOptions::default(),
289            )
290            .unwrap();
291        assert_eq!(run.result, "hi");
292        assert!(!run.is_error);
293    }
294
295    #[test]
296    fn run_agent_maps_missing_binary_to_unavailable() {
297        let err = run_agent(MISSING, None, &["--version".to_string()], None).unwrap_err();
298        assert!(matches!(err, Error::AgentUnavailable(_)));
299    }
300
301    #[test]
302    fn detect_with_returns_none_for_missing_binary() {
303        let result = detect_with(MISSING, AgentKind::Claude, AgentKind::Claude.spec()).unwrap();
304        assert!(result.is_none());
305    }
306
307    #[test]
308    fn real_agent_detect_claude_does_not_error() {
309        // `claude` may or may not be installed in the test environment; either
310        // way detection must not error (absent => Ok(None)).
311        assert!(RealAgent.detect(AgentKind::Claude).is_ok());
312    }
313
314    // The real-subprocess paths below shell out to `sh`, which the existing
315    // hook tests also rely on; they run on the Unix CI where coverage is taken.
316    #[cfg(unix)]
317    mod unix {
318        use super::*;
319
320        /// A spec that drives `sh` to print a version-shaped line.
321        const SH_VERSION: AgentSpec = AgentSpec {
322            kind: AgentKind::Claude,
323            binary: "sh",
324            version_args: &["-c", "echo '9.9.9 (test agent)'"],
325            run_args: &["-c", "printf '{\"is_error\":false,\"result\":\"ok\"}'"],
326            prompt_positional: true,
327            json_args: &[],
328            model_flag: "",
329            result_format: ResultFormat::SingleObject,
330        };
331
332        /// A spec whose version probe exits non-zero.
333        const SH_FAIL: AgentSpec = AgentSpec {
334            kind: AgentKind::Claude,
335            binary: "sh",
336            version_args: &["-c", "exit 1"],
337            run_args: &["-c", "true"],
338            prompt_positional: true,
339            json_args: &[],
340            model_flag: "",
341            result_format: ResultFormat::SingleObject,
342        };
343
344        #[test]
345        fn run_agent_returns_stdout_on_success() {
346            let out = run_agent(
347                "sh",
348                None,
349                &["-c".to_string(), "printf hello".to_string()],
350                None,
351            )
352            .unwrap();
353            assert_eq!(out, "hello");
354        }
355
356        #[test]
357        fn run_agent_maps_nonzero_exit_to_subprocess() {
358            let err =
359                run_agent("sh", None, &["-c".to_string(), "exit 3".to_string()], None).unwrap_err();
360            match err {
361                Error::Subprocess { program, .. } => assert_eq!(program, "sh"),
362                other => panic!("expected subprocess error, got {other:?}"),
363            }
364        }
365
366        #[test]
367        fn run_agent_kills_a_child_that_outlives_its_deadline() {
368            // Two defects in one test.
369            //
370            // First: `Command::output()` waits forever, so an agent that hangs
371            // used to hang `wt`. Sleeping far longer than the deadline proves the
372            // deadline — not the sleep — is what ends the run.
373            //
374            // Second, and the subtler one: `sleep 30 & wait` makes `sh` fork a
375            // *grandchild* that inherits the stdout/stderr pipes. Killing the
376            // child does not close them, so joining the reader threads blocks
377            // until the grandchild dies — reintroducing the full 30s wait behind
378            // a timeout that appears to work. An agent CLI that is a wrapper
379            // script has exactly this shape, so the plain `sleep 30` this test
380            // first used was too weak: it passes on a shell that `exec`s.
381            let started = Instant::now();
382            let err = run_agent(
383                "sh",
384                None,
385                &["-c".to_string(), "sleep 30 & wait".to_string()],
386                Some(Duration::from_millis(100)),
387            )
388            .unwrap_err();
389            let elapsed = started.elapsed();
390            match err {
391                Error::AgentTimeout { binary, seconds } => {
392                    assert_eq!(binary, "sh");
393                    // Sub-second deadlines still report a whole second.
394                    assert_eq!(seconds, 1);
395                }
396                other => panic!("expected a timeout, got {other:?}"),
397            }
398            assert!(
399                elapsed < Duration::from_secs(10),
400                "returned after {elapsed:?}; the child was not killed"
401            );
402        }
403
404        #[test]
405        fn a_deadline_does_not_disturb_a_process_that_finishes() {
406            // The deadline path reads the pipes on separate threads, so prove it
407            // still returns stdout intact rather than only working on timeout.
408            let out = run_agent(
409                "sh",
410                None,
411                &["-c".to_string(), "printf hello".to_string()],
412                Some(Duration::from_secs(30)),
413            )
414            .unwrap();
415            assert_eq!(out, "hello");
416        }
417
418        #[test]
419        fn a_deadline_still_maps_a_nonzero_exit_to_subprocess() {
420            let err = run_agent(
421                "sh",
422                None,
423                &["-c".to_string(), "printf oops >&2; exit 3".to_string()],
424                Some(Duration::from_secs(30)),
425            )
426            .unwrap_err();
427            match err {
428                Error::Subprocess { program, stderr } => {
429                    assert_eq!(program, "sh");
430                    // Proves stderr is drained too, not just stdout.
431                    assert_eq!(stderr, "oops");
432                }
433                other => panic!("expected subprocess error, got {other:?}"),
434            }
435        }
436
437        #[test]
438        fn detect_with_parses_version_from_real_process() {
439            let detected = detect_with("sh", AgentKind::Claude, &SH_VERSION)
440                .unwrap()
441                .unwrap();
442            assert_eq!(detected.binary, "sh");
443            assert_eq!(detected.version.version, Some("9.9.9".to_string()));
444        }
445
446        #[test]
447        fn detect_with_propagates_non_unavailable_errors() {
448            let err = detect_with("sh", AgentKind::Claude, &SH_FAIL).unwrap_err();
449            assert!(matches!(err, Error::Subprocess { .. }));
450        }
451
452        #[test]
453        fn run_with_invokes_and_parses_result() {
454            let dir = tempfile::tempdir().unwrap();
455            let run = run_with(
456                "sh",
457                AgentKind::Claude,
458                &SH_VERSION,
459                "my prompt",
460                dir.path(),
461                &AgentOptions::default(),
462            )
463            .unwrap();
464            assert!(!run.is_error);
465            assert_eq!(run.result, "ok");
466        }
467    }
468}