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::path::Path;
15use std::process::Command;
16
17use crate::error::{Error, Result};
18pub use model::{AgentModel, AgentOptions, Effort};
19pub use spec::{AGENTS, AgentKind, AgentSpec, ResultFormat};
20pub use types::{AgentRun, AgentVersion, DetectedAgent};
21
22/// Detects and drives code-agent CLIs.
23pub trait AgentClient {
24    /// Probes one agent on `PATH`. Returns `Ok(None)` if it is not installed,
25    /// or `Err` if an installed binary fails to run.
26    fn detect(&self, kind: AgentKind) -> Result<Option<DetectedAgent>>;
27
28    /// Runs `kind` non-interactively on `prompt` in `dir`, in the agent's JSON
29    /// output mode, with the selected model and effort (`opts`), and returns the
30    /// normalized result.
31    fn run(
32        &self,
33        kind: AgentKind,
34        prompt: &str,
35        dir: &Path,
36        opts: &AgentOptions,
37    ) -> Result<AgentRun>;
38
39    /// Probes every known agent on `PATH`, returning those found. Agents that
40    /// are not installed are omitted (that is not an error).
41    fn detect_all(&self) -> Vec<DetectedAgent> {
42        AgentKind::all()
43            .iter()
44            .filter_map(|&kind| self.detect(kind).ok().flatten())
45            .collect()
46    }
47}
48
49/// The production [`AgentClient`] that spawns the real agent binaries.
50#[derive(Debug, Clone, Copy, Default)]
51pub struct RealAgent;
52
53impl AgentClient for RealAgent {
54    fn detect(&self, kind: AgentKind) -> Result<Option<DetectedAgent>> {
55        detect_with(kind.spec().binary, kind, kind.spec())
56    }
57
58    fn run(
59        &self,
60        kind: AgentKind,
61        prompt: &str,
62        dir: &Path,
63        opts: &AgentOptions,
64    ) -> Result<AgentRun> {
65        run_with(kind.spec().binary, kind, kind.spec(), prompt, dir, opts)
66    }
67}
68
69/// Detects `kind` by running `binary` with the spec's version args. Split from
70/// [`RealAgent::detect`] so tests can drive every branch with a stand-in
71/// binary. A missing binary maps to `Ok(None)`; other failures propagate.
72fn detect_with(binary: &str, kind: AgentKind, spec: &AgentSpec) -> Result<Option<DetectedAgent>> {
73    match run_agent(binary, None, &spec::version_argv(spec)) {
74        Ok(stdout) => Ok(Some(DetectedAgent {
75            kind,
76            binary: binary.to_string(),
77            version: spec::parse_version(&stdout),
78        })),
79        Err(Error::AgentUnavailable(_)) => Ok(None),
80        Err(e) => Err(e),
81    }
82}
83
84/// Runs `binary` on `prompt` in `dir` per `spec`, parsing the JSON result.
85/// Split from [`RealAgent::run`] for the same testability reason.
86fn run_with(
87    binary: &str,
88    kind: AgentKind,
89    spec: &AgentSpec,
90    prompt: &str,
91    dir: &Path,
92    opts: &AgentOptions,
93) -> Result<AgentRun> {
94    let prompt = spec::apply_effort(opts.effort, prompt);
95    let argv = spec::prompt_argv(spec, &prompt, opts.model);
96    let stdout = run_agent(binary, Some(dir), &argv)?;
97    spec::parse_result(kind, spec.result_format, &stdout)
98}
99
100/// Runs an agent `binary` (optionally in `dir`), mapping a missing binary to
101/// [`Error::AgentUnavailable`] and a non-zero exit to [`Error::Subprocess`].
102/// Mirrors `gh`'s `run_gh` helper.
103fn run_agent(binary: &str, dir: Option<&Path>, args: &[String]) -> Result<String> {
104    let mut cmd = Command::new(binary);
105    if let Some(dir) = dir {
106        cmd.current_dir(dir);
107    }
108    cmd.args(args);
109    let output = match cmd.output() {
110        Ok(output) => output,
111        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
112            return Err(Error::AgentUnavailable(format!(
113                "{binary} is not installed or not on PATH"
114            )));
115        }
116        Err(e) => {
117            return Err(Error::AgentUnavailable(format!(
118                "failed to run {binary}: {e}"
119            )));
120        }
121    };
122    if output.status.success() {
123        return Ok(String::from_utf8_lossy(&output.stdout).into_owned());
124    }
125    Err(Error::Subprocess {
126        program: binary.to_string(),
127        stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
128    })
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    /// A nonexistent binary name, used to exercise the not-found path.
136    const MISSING: &str = "wt-nonexistent-agent-binary-xyzzy";
137
138    /// Behaviors for the in-test [`AgentClient`] fake, to cover `detect_all`.
139    enum Behavior {
140        Found,
141        Missing,
142        Failing,
143    }
144
145    struct Fake(Behavior);
146
147    impl AgentClient for Fake {
148        fn detect(&self, kind: AgentKind) -> Result<Option<DetectedAgent>> {
149            match self.0 {
150                Behavior::Found => Ok(Some(DetectedAgent {
151                    kind,
152                    binary: kind.as_str().to_string(),
153                    version: AgentVersion {
154                        version: None,
155                        raw: String::new(),
156                    },
157                })),
158                Behavior::Missing => Ok(None),
159                Behavior::Failing => Err(Error::operation("boom")),
160            }
161        }
162
163        fn run(
164            &self,
165            kind: AgentKind,
166            prompt: &str,
167            _dir: &Path,
168            _opts: &AgentOptions,
169        ) -> Result<AgentRun> {
170            Ok(AgentRun {
171                kind,
172                is_error: false,
173                result: prompt.to_string(),
174                raw: serde_json::Value::Null,
175            })
176        }
177    }
178
179    #[test]
180    fn detect_all_keeps_found_drops_missing_and_failing() {
181        assert_eq!(
182            Fake(Behavior::Found).detect_all().len(),
183            AgentKind::all().len()
184        );
185        assert!(Fake(Behavior::Missing).detect_all().is_empty());
186        // An installed-but-erroring agent is dropped by `detect_all` (errors
187        // surface only through `detect`).
188        assert!(Fake(Behavior::Failing).detect_all().is_empty());
189    }
190
191    #[test]
192    fn fake_run_returns_normalized_result() {
193        let dir = tempfile::tempdir().unwrap();
194        let run = Fake(Behavior::Found)
195            .run(
196                AgentKind::Claude,
197                "hi",
198                dir.path(),
199                &AgentOptions::default(),
200            )
201            .unwrap();
202        assert_eq!(run.result, "hi");
203        assert!(!run.is_error);
204    }
205
206    #[test]
207    fn run_agent_maps_missing_binary_to_unavailable() {
208        let err = run_agent(MISSING, None, &["--version".to_string()]).unwrap_err();
209        assert!(matches!(err, Error::AgentUnavailable(_)));
210    }
211
212    #[test]
213    fn detect_with_returns_none_for_missing_binary() {
214        let result = detect_with(MISSING, AgentKind::Claude, AgentKind::Claude.spec()).unwrap();
215        assert!(result.is_none());
216    }
217
218    #[test]
219    fn real_agent_detect_claude_does_not_error() {
220        // `claude` may or may not be installed in the test environment; either
221        // way detection must not error (absent => Ok(None)).
222        assert!(RealAgent.detect(AgentKind::Claude).is_ok());
223    }
224
225    // The real-subprocess paths below shell out to `sh`, which the existing
226    // hook tests also rely on; they run on the Unix CI where coverage is taken.
227    #[cfg(unix)]
228    mod unix {
229        use super::*;
230
231        /// A spec that drives `sh` to print a version-shaped line.
232        const SH_VERSION: AgentSpec = AgentSpec {
233            kind: AgentKind::Claude,
234            binary: "sh",
235            version_args: &["-c", "echo '9.9.9 (test agent)'"],
236            run_args: &["-c", "printf '{\"is_error\":false,\"result\":\"ok\"}'"],
237            prompt_positional: true,
238            json_args: &[],
239            model_flag: "",
240            result_format: ResultFormat::SingleObject,
241        };
242
243        /// A spec whose version probe exits non-zero.
244        const SH_FAIL: AgentSpec = AgentSpec {
245            kind: AgentKind::Claude,
246            binary: "sh",
247            version_args: &["-c", "exit 1"],
248            run_args: &["-c", "true"],
249            prompt_positional: true,
250            json_args: &[],
251            model_flag: "",
252            result_format: ResultFormat::SingleObject,
253        };
254
255        #[test]
256        fn run_agent_returns_stdout_on_success() {
257            let out =
258                run_agent("sh", None, &["-c".to_string(), "printf hello".to_string()]).unwrap();
259            assert_eq!(out, "hello");
260        }
261
262        #[test]
263        fn run_agent_maps_nonzero_exit_to_subprocess() {
264            let err = run_agent("sh", None, &["-c".to_string(), "exit 3".to_string()]).unwrap_err();
265            match err {
266                Error::Subprocess { program, .. } => assert_eq!(program, "sh"),
267                other => panic!("expected subprocess error, got {other:?}"),
268            }
269        }
270
271        #[test]
272        fn detect_with_parses_version_from_real_process() {
273            let detected = detect_with("sh", AgentKind::Claude, &SH_VERSION)
274                .unwrap()
275                .unwrap();
276            assert_eq!(detected.binary, "sh");
277            assert_eq!(detected.version.version, Some("9.9.9".to_string()));
278        }
279
280        #[test]
281        fn detect_with_propagates_non_unavailable_errors() {
282            let err = detect_with("sh", AgentKind::Claude, &SH_FAIL).unwrap_err();
283            assert!(matches!(err, Error::Subprocess { .. }));
284        }
285
286        #[test]
287        fn run_with_invokes_and_parses_result() {
288            let dir = tempfile::tempdir().unwrap();
289            let run = run_with(
290                "sh",
291                AgentKind::Claude,
292                &SH_VERSION,
293                "my prompt",
294                dir.path(),
295                &AgentOptions::default(),
296            )
297            .unwrap();
298            assert!(!run.is_error);
299            assert_eq!(run.result, "ok");
300        }
301    }
302}