1pub 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
24pub trait AgentClient {
26 fn detect(&self, kind: AgentKind) -> Result<Option<DetectedAgent>>;
29
30 fn run(
34 &self,
35 kind: AgentKind,
36 prompt: &str,
37 dir: &Path,
38 opts: &AgentOptions,
39 ) -> Result<AgentRun>;
40
41 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#[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
71fn 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
86fn 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
102fn 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 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 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 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 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 drop(out_reader);
175 drop(err_reader);
176 Err(Error::AgentTimeout {
177 binary: binary.to_string(),
178 seconds: limit.as_secs().max(1),
181 })
182 }
183 }
184}
185
186const POLL_INTERVAL: Duration = Duration::from_millis(10);
189
190fn 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
199fn 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
209fn 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 const MISSING: &str = "wt-nonexistent-agent-binary-xyzzy";
226
227 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 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 assert!(RealAgent.detect(AgentKind::Claude).is_ok());
312 }
313
314 #[cfg(unix)]
317 mod unix {
318 use super::*;
319
320 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 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 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 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 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 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}