Skip to main content

spar/
agent.rs

1//! Driving one CLI.
2//!
3//! An agent is a command template plus an output adapter, not a class.
4//! Supporting a new CLI is a preset file, which is the difference between a
5//! tool that works for its author and one that works for anyone who installs
6//! it.
7
8use std::collections::BTreeMap;
9use std::path::{Path, PathBuf};
10use std::sync::OnceLock;
11
12use serde_json::Value;
13
14use crate::config::{AgentSpec, CommandPart, OutputMode, SystemVia};
15use crate::error::{ErrorKind, Result, SparError};
16use crate::jsonx;
17use crate::proc::{self, ExecOpts};
18use crate::{bail, log, logdim, logwarn, spar_err};
19
20/// Injected into every request. Prompting alone is not sufficient for any of
21/// these, which is why each one is also enforced deterministically on the way
22/// out, but a model that was asked produces less for the gate to fix.
23pub const STYLE_RULES: &str = "\
24Style rules for every artifact you produce (commits, PR titles, PR bodies, issue
25titles, issue bodies, review comments):
26- Never use em-dashes or en-dashes. Use commas, colons, or parentheses.
27- Never mention Claude, Codex, OpenAI, ChatGPT, Anthropic, AI, or any tooling
28  used to produce the work.
29- Never add a Co-Authored-By trailer or a \"Generated with\" footer to commits.
30- Be brief. A human engineer with other work has to read this. Lead with the
31  point, cut the preamble, stop when you are done. Do not restate the task, do
32  not announce what you are about to do, do not summarise what the diff already
33  shows. One sentence beats one paragraph.
34- No headings, bullet lists, or bold text in anything only a few sentences long.
35Write as a human engineer would, because the reader neither knows nor cares what
36produced the work.";
37
38const JSON_INSTRUCTION: &str = "Respond with ONLY a JSON object matching this \
39schema. No prose, no markdown fences, no commentary before or after:";
40
41pub struct Agent {
42    pub spec: AgentSpec,
43    /// Answers in this agent's place when it cannot answer at all. Never
44    /// alongside it: the pair is still two, and the fallback only ever holds
45    /// the turn the failed agent was already holding.
46    fallback: Option<Box<Agent>>,
47    resolved: OnceLock<PathBuf>,
48}
49
50impl std::fmt::Debug for Agent {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        write!(f, "<{} {}>", self.spec.name, self.spec.describe())
53    }
54}
55
56impl Agent {
57    pub fn new(spec: AgentSpec) -> Self {
58        let fallback = spec
59            .fallback
60            .clone()
61            .map(|backup| Box::new(Agent::new(*backup)));
62        Self {
63            spec,
64            fallback,
65            resolved: OnceLock::new(),
66        }
67    }
68
69    pub fn name(&self) -> &str {
70        &self.spec.name
71    }
72
73    /// The stand in, if one is configured.
74    pub fn fallback(&self) -> Option<&Agent> {
75        self.fallback.as_deref()
76    }
77
78    /// The program the template names, before any resolution. What somebody
79    /// has to install when spar reports it missing.
80    pub fn program(&self) -> &str {
81        match self.spec.command.first() {
82            Some(CommandPart::One(program)) => program,
83            _ => self.name(),
84        }
85    }
86
87    /// The environment variable that points this agent's binary somewhere else.
88    pub fn env_key(&self) -> String {
89        format!(
90            "SPAR_{}_BIN",
91            self.spec.name.to_uppercase().replace('-', "_")
92        )
93    }
94
95    /// Used by the tests, and by `doctor` when it wants to report a path it
96    /// already knows.
97    #[doc(hidden)]
98    pub fn with_bin(spec: AgentSpec, bin: impl Into<PathBuf>) -> Self {
99        let agent = Self::new(spec);
100        let _ = agent.resolved.set(bin.into());
101        agent
102    }
103
104    // -- binary resolution -------------------------------------------------
105
106    /// `SPAR_<NAME>_BIN` first, then the template's own program name on PATH or
107    /// as an absolute path, then the preset's search paths. Never guess
108    /// silently: a miss reports every location tried, because a tool that
109    /// quietly runs the wrong binary is worse than one that fails.
110    pub fn resolve_bin(&self) -> Result<&Path> {
111        if let Some(found) = self.resolved.get() {
112            return Ok(found.as_path());
113        }
114        let found = self.locate()?;
115        let _ = self.resolved.set(found);
116        Ok(self.resolved.get().expect("just set").as_path())
117    }
118
119    fn locate(&self) -> Result<PathBuf> {
120        let wanted = match self.spec.command.first() {
121            Some(CommandPart::One(program)) => program.clone(),
122            _ => bail!("agent '{}' has no command configured", self.spec.name),
123        };
124
125        let env_key = self.env_key();
126        let env_override = std::env::var(&env_key)
127            .ok()
128            .filter(|v| !v.trim().is_empty());
129
130        let mut tried: Vec<String> = Vec::new();
131
132        for candidate in env_override
133            .iter()
134            .map(String::as_str)
135            .chain([wanted.as_str()])
136        {
137            let path = Path::new(candidate);
138            if path.is_absolute() || candidate.contains(std::path::MAIN_SEPARATOR) {
139                let expanded = proc::expand_tilde(candidate);
140                tried.push(expanded.display().to_string());
141                if proc::is_executable(&expanded) {
142                    return Ok(expanded);
143                }
144            } else {
145                tried.push(format!("{candidate} (PATH)"));
146                if let Some(found) = proc::which(candidate) {
147                    return Ok(found);
148                }
149            }
150        }
151
152        for base in &self.spec.search_paths {
153            let base = proc::expand_tilde(base);
154            let candidate = if base.file_name().and_then(|n| n.to_str()) == Some(wanted.as_str()) {
155                base
156            } else {
157                base.join(&wanted)
158            };
159            tried.push(candidate.display().to_string());
160            if proc::is_executable(&candidate) {
161                return Ok(candidate);
162            }
163        }
164
165        Err(spar_err!(
166            "could not find the binary for agent '{}'. Tried:\n  {}\nSet agents.{}.command[0] to \
167             an absolute path, or {}=/path/to/binary.",
168            self.spec.name,
169            tried.join("\n  "),
170            self.spec.name,
171            env_key
172        ))
173    }
174
175    // -- command rendering -------------------------------------------------
176
177    /// Substitute placeholders. A group whose placeholder is unset is dropped
178    /// whole, so omitting `model` drops `--model` with it rather than passing
179    /// an empty string that the CLI would reject or, worse, accept.
180    pub fn render(&self, values: &Placeholders) -> Result<Vec<String>> {
181        let mut out = vec![self.resolve_bin()?.display().to_string()];
182        for part in self.spec.command.iter().skip(1) {
183            let mut rendered = Vec::new();
184            let mut skip = false;
185            for arg in part.args() {
186                match values.substitute(arg) {
187                    Some(text) => rendered.push(text),
188                    None => {
189                        skip = true;
190                        break;
191                    }
192                }
193            }
194            if !skip {
195                out.extend(rendered);
196            }
197        }
198        Ok(out)
199    }
200
201    /// True when the template has somewhere to put a schema, meaning the CLI can
202    /// do structured output natively rather than being asked in the prompt.
203    ///
204    /// Either form counts: a path for a CLI that reads the schema from disk, or
205    /// the schema itself for one that takes it as an argument.
206    pub fn supports_schema(&self) -> bool {
207        self.spec
208            .command
209            .iter()
210            .flat_map(|p| p.args())
211            .any(|a| a.contains("{schema_file}") || a.contains("{schema}"))
212    }
213
214    // -- output adapters ---------------------------------------------------
215
216    pub fn extract(&self, stdout: &str) -> Result<String> {
217        match self.spec.output {
218            OutputMode::Text | OutputMode::Json => Ok(stdout.trim().to_string()),
219            OutputMode::Jsonl => self.extract_jsonl(stdout),
220        }
221    }
222
223    fn extract_jsonl(&self, stdout: &str) -> Result<String> {
224        let mut messages: Vec<String> = Vec::new();
225
226        for line in stdout.lines() {
227            let line = line.trim();
228            if !line.starts_with('{') {
229                continue;
230            }
231            let Ok(event) = serde_json::from_str::<Value>(line) else {
232                continue;
233            };
234            if matches(&event, &self.spec.message_match) {
235                if let Some(text) = dig(&event, self.spec.message_path.as_deref().unwrap_or("")) {
236                    if let Some(text) = as_text(text) {
237                        messages.push(text);
238                    }
239                }
240            }
241        }
242
243        if messages.is_empty() {
244            let reasons = self.error_events(stdout);
245            if !reasons.is_empty() {
246                // The CLI reported failure rather than answering badly, so this
247                // is not something asking again corrects.
248                return Err(SparError::call_failed(format!(
249                    "agent '{}' failed: {}",
250                    self.spec.name,
251                    reasons.join("; ")
252                )));
253            }
254        }
255        Ok(messages.join("\n").trim().to_string())
256    }
257
258    /// Why an event stream says it failed, in words rather than as JSON.
259    ///
260    /// The reason a CLI gives is a field inside the event, not the event, and
261    /// printing the object around it is what made a failure unreadable. Two
262    /// shapes cover what the CLIs here emit: a `message` on the event, and a
263    /// `message` on an `error` inside it.
264    ///
265    /// Deduplicated, because one refusal reported as an `error` twice and a
266    /// `turn.failed` once is one reason and not three.
267    fn error_events(&self, stdout: &str) -> Vec<String> {
268        let mut reasons: Vec<String> = Vec::new();
269        for line in stdout.lines() {
270            let line = line.trim();
271            if !line.starts_with('{') {
272                continue;
273            }
274            let Ok(event) = serde_json::from_str::<Value>(line) else {
275                continue;
276            };
277            if !matches!(
278                event.get("type").and_then(Value::as_str),
279                Some("turn.failed") | Some("error")
280            ) {
281                continue;
282            }
283            let reason = dig(&event, "message")
284                .or_else(|| dig(&event, "error.message"))
285                .and_then(as_text)
286                .unwrap_or_else(|| truncate(&event.to_string(), 400));
287            if !reason.trim().is_empty() && !reasons.contains(&reason) {
288                reasons.push(reason);
289            }
290        }
291        reasons
292    }
293
294    /// Why the call failed, said in the agent's own terms.
295    ///
296    /// For a `jsonl` agent the streams are an event log, and `proc` tailing
297    /// 1500 characters of one starts mid object: the reason is in there, after
298    /// a thousand characters of whatever a tool call happened to return. The
299    /// adapter already knows how to find the error events, so it finds them
300    /// here too and the raw dump is what happens when there are none.
301    ///
302    /// stderr is kept either way. It is short, and it is where one CLI reports
303    /// the condition that led to the refusal while the refusal itself goes to
304    /// stdout.
305    fn call_failure(&self, argv: &[String], out: &proc::Output) -> SparError {
306        if self.spec.output != OutputMode::Jsonl {
307            return SparError::call_failed(proc::failure_message(argv, out));
308        }
309        let reasons = self.error_events(&out.stdout);
310        if reasons.is_empty() {
311            return SparError::call_failed(proc::failure_message(argv, out));
312        }
313        let mut text = format!(
314            "agent '{}' could not answer (exit {}): {}",
315            self.spec.name,
316            out.code,
317            reasons.join("; ")
318        );
319        let stderr = out.stderr.trim();
320        if !stderr.is_empty() {
321            text.push_str(&format!("\n--- stderr ---\n{stderr}"));
322        }
323        text.push_str(&format!("\n--- command ---\n{}", proc::abbreviate(argv)));
324        SparError::call_failed(text)
325    }
326
327    // -- the two operations everything else is built from -------------------
328
329    pub fn ask(&self, prompt: &str, cwd: &Path, effort: Option<&str>) -> Result<String> {
330        match self.ask_inner(prompt, cwd, effort, None, None) {
331            Ok(text) => Ok(text),
332            Err(e) => self.hand_over(e, |backup| backup.ask(prompt, cwd, None)),
333        }
334    }
335
336    /// Give a failed call to the fallback, if there is one.
337    ///
338    /// Every failure qualifies, a deadline included. Asking the same CLI again
339    /// after a timeout buys another wait of the same length for the same
340    /// answer, which is why `ask_json` does not; asking a different CLI is a
341    /// different question, and the alternative here is losing the run.
342    ///
343    /// The scheduled effort is deliberately not passed on. Effort words are
344    /// each CLI's own vocabulary, and the one in hand belongs to the agent that
345    /// just failed, so the fallback uses whatever its own config asked for.
346    fn hand_over<T>(&self, primary: SparError, run: impl FnOnce(&Agent) -> Result<T>) -> Result<T> {
347        let Some(backup) = self.fallback() else {
348            return Err(primary);
349        };
350        logwarn!(
351            "{} could not answer. Handing the call to {}.\n{primary}",
352            self.name(),
353            backup.name()
354        );
355        match run(backup) {
356            Ok(answer) => {
357                log!("{} answered in place of {}", backup.name(), self.name());
358                Ok(answer)
359            }
360            // Both messages, primary first. The fallback's failure is usually
361            // the less interesting of the two, and is often just "not
362            // installed", which explains nothing about why the run stopped.
363            Err(second) => Err(spar_err!(
364                "agent '{}' failed and its fallback '{}' could not stand in.\n{}\n\n{}: {}",
365                self.name(),
366                backup.name(),
367                primary.message(),
368                backup.name(),
369                second.message()
370            )),
371        }
372    }
373
374    fn ask_inner(
375        &self,
376        prompt: &str,
377        cwd: &Path,
378        effort: Option<&str>,
379        schema_file: Option<&Path>,
380        schema: Option<&str>,
381    ) -> Result<String> {
382        let body = match self.spec.system_via {
383            SystemVia::Placeholder => prompt.to_string(),
384            SystemVia::Prompt => format!("{STYLE_RULES}\n\n{prompt}"),
385        };
386        let values = Placeholders {
387            prompt: Some(body),
388            system: Some(STYLE_RULES.to_string()),
389            model: self.spec.model.clone(),
390            effort: effort
391                .map(str::to_string)
392                .or_else(|| self.spec.effort.clone()),
393            cwd: Some(cwd.display().to_string()),
394            schema_file: schema_file.map(|p| p.display().to_string()),
395            schema: schema.map(str::to_string),
396        };
397        let argv = self.render(&values)?;
398        // `check(false)` so the whole output is still in hand when the call
399        // fails: `proc::run` would hand back a tail of it as a message, and a
400        // tail of an event stream is the part this agent can read least.
401        let opts = ExecOpts::new()
402            .cwd(cwd)
403            .timeout_secs(self.spec.timeout)
404            .check(false);
405        let out = proc::exec(&argv, &opts)?;
406        if !out.ok() {
407            return Err(self.call_failure(&argv, &out));
408        }
409        self.extract(&out.stdout)
410    }
411
412    /// Structured output through the CLI's own mechanism when the template
413    /// exposes one, otherwise by asking for JSON in the prompt and parsing it
414    /// back out.
415    pub fn ask_json<T: serde::de::DeserializeOwned>(
416        &self,
417        prompt: &str,
418        schema: &Value,
419        cwd: &Path,
420        effort: Option<&str>,
421    ) -> Result<T> {
422        match self.ask_json_retrying(prompt, schema, cwd, effort) {
423            Ok(parsed) => Ok(parsed),
424            Err(e) => self.hand_over(e, |backup| {
425                backup.ask_json_retrying::<T>(prompt, schema, cwd, None)
426            }),
427        }
428    }
429
430    /// Whether to spend a second call on this same agent.
431    ///
432    /// The retry exists for an answer that arrived and could not be parsed.
433    /// Models correct a shape error readily when told what was wrong, which is
434    /// why the parser's own complaint goes back with the question.
435    ///
436    /// Two failures are not that. A deadline never is: the wait is the same
437    /// length for the same answer. And a failure the CLI itself reported is not
438    /// either, once there is a stand in to send the call to, because a
439    /// different CLI is a different question while the same one twice is a
440    /// refusal, a quota, or a crash repeated at full price. With no stand in
441    /// configured the retry is the only thing left, so it still happens.
442    fn worth_asking_again(&self, e: &SparError) -> bool {
443        match e.kind() {
444            ErrorKind::TimedOut => false,
445            ErrorKind::CallFailed => self.fallback().is_none(),
446            ErrorKind::Other => true,
447        }
448    }
449
450    /// The same question, asked at most twice of this agent alone.
451    fn ask_json_retrying<T: serde::de::DeserializeOwned>(
452        &self,
453        prompt: &str,
454        schema: &Value,
455        cwd: &Path,
456        effort: Option<&str>,
457    ) -> Result<T> {
458        // One retry, with the parser's own complaint handed back.
459        //
460        // A single malformed answer used to cost half a review: the other agent
461        // carried on alone, which is the one thing this design exists to avoid.
462        // Models correct a shape error readily when told what was wrong.
463        const ATTEMPTS: usize = 2;
464        let mut last: Option<SparError> = None;
465
466        for attempt in 1..=ATTEMPTS {
467            let asked = match &last {
468                None => prompt.to_string(),
469                Some(e) => format!(
470                    "{prompt}\n\nYour previous answer could not be used: {}\nReturn the whole \
471                     object this time, exactly matching the schema, and nothing else.",
472                    e.first_line()
473                ),
474            };
475            match self.ask_json_once::<T>(&asked, schema, cwd, effort) {
476                Ok(parsed) => {
477                    if attempt > 1 {
478                        logdim!("{} answered on the retry", self.spec.name);
479                    }
480                    return Ok(parsed);
481                }
482                // A deadline is not a bad answer. Asking again buys another
483                // wait of exactly the same length, which on a long review is
484                // the most expensive way to learn nothing.
485                Err(e) if !self.worth_asking_again(&e) => return Err(e),
486                Err(e) => {
487                    if attempt < ATTEMPTS {
488                        // The whole error, not its first line. The first line is
489                        // the command; the reason is in the stderr underneath
490                        // it, and printing only the first line made a retry
491                        // impossible to diagnose from the log.
492                        logwarn!("{} failed, asking again.\n{e}", self.spec.name);
493                    }
494                    last = Some(e);
495                }
496            }
497        }
498        Err(spar_err!(
499            "agent '{}' returned an unusable answer twice: {}",
500            self.spec.name,
501            last.expect("at least one attempt").message()
502        ))
503    }
504
505    fn ask_json_once<T: serde::de::DeserializeOwned>(
506        &self,
507        prompt: &str,
508        schema: &Value,
509        cwd: &Path,
510        effort: Option<&str>,
511    ) -> Result<T> {
512        let text = if self.supports_schema() {
513            let inline = serde_json::to_string(schema).unwrap_or_default();
514            let file = TempJson::write(schema)?;
515            self.ask_inner(prompt, cwd, effort, Some(file.path()), Some(&inline))?
516        } else {
517            let full = format!(
518                "{prompt}\n\n{JSON_INSTRUCTION}\n{}",
519                serde_json::to_string_pretty(schema).unwrap_or_default()
520            );
521            self.ask_inner(&full, cwd, effort, None, None)?
522        };
523        jsonx::extract_into(&text)
524    }
525
526    /// Review the branch against `base`.
527    ///
528    /// Deliberately generic. `codex exec review` was tried and rejected: it
529    /// refuses a custom prompt alongside `--base` and returns prose regardless
530    /// of `--output-schema`, so it cannot yield a machine checkable verdict.
531    /// Running inside the worktree is what makes an agent repo aware, not a
532    /// subcommand.
533    pub fn review<T: serde::de::DeserializeOwned>(
534        &self,
535        base: &str,
536        prompt: &str,
537        schema: &Value,
538        cwd: &Path,
539        effort: Option<&str>,
540    ) -> Result<T> {
541        let scoped = format!(
542            "{prompt}\n\nThe changes under review are the diff between `{base}` and HEAD in your \
543             working directory. Inspect them with git, then read the surrounding code before \
544             judging. Do not review only the diff."
545        );
546        self.ask_json(&scoped, schema, cwd, effort)
547    }
548}
549
550// ---------------------------------------------------------------------------
551// Placeholders
552// ---------------------------------------------------------------------------
553
554#[derive(Debug, Default, Clone)]
555pub struct Placeholders {
556    pub prompt: Option<String>,
557    pub system: Option<String>,
558    pub model: Option<String>,
559    pub effort: Option<String>,
560    pub cwd: Option<String>,
561    /// A path to the schema, for a CLI that reads one from disk.
562    pub schema_file: Option<String>,
563    /// The schema itself, for a CLI that takes it as an argument.
564    pub schema: Option<String>,
565}
566
567impl Placeholders {
568    fn get(&self, key: &str) -> Option<&str> {
569        let value = match key {
570            "prompt" => self.prompt.as_deref(),
571            "system" => self.system.as_deref(),
572            "model" => self.model.as_deref(),
573            "effort" => self.effort.as_deref(),
574            "cwd" => self.cwd.as_deref(),
575            "schema_file" => self.schema_file.as_deref(),
576            "schema" => self.schema.as_deref(),
577            _ => None,
578        };
579        value.filter(|v| !v.is_empty())
580    }
581
582    /// Substitute every placeholder in one argument. `None` means a placeholder
583    /// in this argument had no value, so the whole group is dropped.
584    fn substitute(&self, arg: &str) -> Option<String> {
585        const KEYS: [&str; 7] = [
586            "prompt",
587            "system",
588            "model",
589            "effort",
590            "cwd",
591            "schema_file",
592            "schema",
593        ];
594        let mut out = arg.to_string();
595        for key in KEYS {
596            let token = format!("{{{key}}}");
597            if out.contains(&token) {
598                let value = self.get(key)?;
599                out = out.replace(&token, value);
600            }
601        }
602        Some(out)
603    }
604}
605
606// ---------------------------------------------------------------------------
607// JSONL helpers
608// ---------------------------------------------------------------------------
609
610/// Follow a dotted path, returning None if any hop is missing.
611fn dig<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
612    if path.is_empty() {
613        return None;
614    }
615    let mut node = value;
616    for part in path.split('.') {
617        node = node.as_object()?.get(part)?;
618    }
619    Some(node)
620}
621
622fn matches(event: &Value, wanted: &BTreeMap<String, String>) -> bool {
623    if wanted.is_empty() {
624        return false;
625    }
626    wanted
627        .iter()
628        .all(|(path, expected)| dig(event, path).and_then(Value::as_str) == Some(expected.as_str()))
629}
630
631fn as_text(value: &Value) -> Option<String> {
632    match value {
633        Value::String(s) => Some(s.clone()),
634        Value::Null => None,
635        other => Some(other.to_string()),
636    }
637}
638
639fn truncate(text: &str, max: usize) -> String {
640    text.chars().take(max).collect()
641}
642
643// ---------------------------------------------------------------------------
644// Temporary schema file
645// ---------------------------------------------------------------------------
646
647/// A schema written somewhere the CLI can read it, removed when it goes out of
648/// scope even if the agent call fails.
649struct TempJson {
650    path: PathBuf,
651}
652
653impl TempJson {
654    fn write(value: &Value) -> Result<Self> {
655        use std::sync::atomic::{AtomicU64, Ordering};
656        static COUNTER: AtomicU64 = AtomicU64::new(0);
657
658        let nanos = std::time::SystemTime::now()
659            .duration_since(std::time::UNIX_EPOCH)
660            .map(|d| d.as_nanos())
661            .unwrap_or(0);
662        let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
663        let path = std::env::temp_dir().join(format!(
664            "spar-schema-{}-{nanos}-{unique}.json",
665            std::process::id()
666        ));
667        std::fs::write(&path, serde_json::to_vec_pretty(value)?)
668            .map_err(|e| spar_err!("could not write a schema file to {}: {e}", path.display()))?;
669        Ok(Self { path })
670    }
671
672    fn path(&self) -> &Path {
673        &self.path
674    }
675}
676
677impl Drop for TempJson {
678    fn drop(&mut self) {
679        let _ = std::fs::remove_file(&self.path);
680    }
681}
682
683// ---------------------------------------------------------------------------
684// Correlation
685// ---------------------------------------------------------------------------
686
687/// Whether two paths name the same executable.
688///
689/// Comparing the raw strings misses aliases: a symlink or a hard link points at
690/// the same binary under a different path, which would let two agents run the
691/// identical CLI without tripping the warning below. Device and inode see
692/// through both.
693fn same_executable(a: &Path, b: &Path) -> bool {
694    #[cfg(unix)]
695    {
696        use std::os::unix::fs::MetadataExt;
697        if let (Ok(x), Ok(y)) = (std::fs::metadata(a), std::fs::metadata(b)) {
698            return x.dev() == y.dev() && x.ino() == y.ino();
699        }
700    }
701    match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
702        (Ok(x), Ok(y)) => x == y,
703        _ => a == b,
704    }
705}
706
707/// Two agents are only an independent review if they can actually disagree.
708///
709/// Config keys are arbitrary, so `alpha` and `beta` can both be Claude on the
710/// same model. Compare what actually runs: the resolved binary and the
711/// configured model, never the names.
712pub fn correlation_warning(agents: &[Agent]) -> Option<String> {
713    for i in 0..agents.len() {
714        for j in (i + 1)..agents.len() {
715            let (a, b) = (&agents[i], &agents[j]);
716            let (Ok(pa), Ok(pb)) = (a.resolve_bin(), b.resolve_bin()) else {
717                continue;
718            };
719            if !same_executable(pa, pb) || a.spec.model_key() != b.spec.model_key() {
720                continue;
721            }
722            let model = if a.spec.model_key().is_empty() {
723                "the CLI's default".to_string()
724            } else {
725                a.spec.model_key()
726            };
727            let where_at = if pa == pb {
728                pa.display().to_string()
729            } else {
730                format!(
731                    "the same executable ({} and {} are the same file)",
732                    pa.display(),
733                    pb.display()
734                )
735            };
736            return Some(format!(
737                "agents '{}' and '{}' both resolve to {where_at} at model {model}. Review \
738                 findings will be correlated: the same model reviewing itself shares the blind \
739                 spots of the model that wrote the code, so it is far less likely to catch what \
740                 the implementer missed. That produces an approval indistinguishable from a real \
741                 review, which is worse than no review at all. Give the two agents different \
742                 CLIs or different models.",
743                a.name(),
744                b.name()
745            ));
746        }
747    }
748    None
749}
750
751/// Build every configured agent, resolving each binary up front so a missing
752/// CLI fails before any model is billed.
753pub fn build(cfg: &crate::config::Config) -> Result<Vec<Agent>> {
754    let agents: Vec<Agent> = cfg.agents.iter().cloned().map(Agent::new).collect();
755    for agent in &agents {
756        agent.resolve_bin()?;
757        // A backup that is not installed must not stop a run whose pair is
758        // fine. Said once here, at the start, rather than an hour in at the
759        // moment it was needed and could not be reached.
760        if let Some(backup) = agent.fallback() {
761            if backup.resolve_bin().is_err() {
762                logwarn!(
763                    "{} has a fallback ({}) that is not installed, so it will not stand in",
764                    agent.name(),
765                    backup.program()
766                );
767            }
768        }
769    }
770    Ok(agents)
771}
772
773/// Look an agent up by name in a built list.
774pub fn find<'a>(agents: &'a [Agent], name: &str) -> Result<&'a Agent> {
775    agents.iter().find(|a| a.name() == name).ok_or_else(|| {
776        SparError::new(format!(
777            "no agent named '{name}' ({})",
778            agents
779                .iter()
780                .map(Agent::name)
781                .collect::<Vec<_>>()
782                .join(", ")
783        ))
784    })
785}
786
787#[cfg(test)]
788mod tests {
789    use super::*;
790    use crate::config::{OutputMode, SystemVia};
791
792    fn spec(command: Vec<CommandPart>) -> AgentSpec {
793        AgentSpec {
794            name: "test".into(),
795            command,
796            model: None,
797            effort: None,
798            output: OutputMode::Text,
799            message_match: BTreeMap::new(),
800            message_path: None,
801            search_paths: vec![],
802            system_via: SystemVia::Prompt,
803            timeout: 60,
804            fallback: None,
805            models: vec![],
806            efforts: vec![],
807            options_note: None,
808        }
809    }
810
811    fn one(s: &str) -> CommandPart {
812        CommandPart::One(s.into())
813    }
814
815    fn group(parts: &[&str]) -> CommandPart {
816        CommandPart::Group(parts.iter().map(|s| s.to_string()).collect())
817    }
818
819    fn agent(command: Vec<CommandPart>) -> Agent {
820        Agent::with_bin(spec(command), "/fake/bin")
821    }
822
823    fn values() -> Placeholders {
824        Placeholders {
825            prompt: Some("hi".into()),
826            ..Default::default()
827        }
828    }
829
830    // -- rendering -------------------------------------------------------
831
832    #[test]
833    fn placeholders_are_substituted() {
834        let a = agent(vec![one("x"), group(&["-m", "{model}"]), one("{prompt}")]);
835        let v = Placeholders {
836            model: Some("m1".into()),
837            ..values()
838        };
839        assert_eq!(vec!["/fake/bin", "-m", "m1", "hi"], a.render(&v).unwrap());
840    }
841
842    #[test]
843    fn an_unset_placeholder_drops_the_whole_group() {
844        let a = agent(vec![one("x"), group(&["-m", "{model}"]), one("{prompt}")]);
845        assert_eq!(vec!["/fake/bin", "hi"], a.render(&values()).unwrap());
846    }
847
848    #[test]
849    fn an_empty_string_drops_the_group_too() {
850        let a = agent(vec![one("x"), group(&["-e", "{effort}"]), one("{prompt}")]);
851        let v = Placeholders {
852            effort: Some(String::new()),
853            ..values()
854        };
855        assert_eq!(vec!["/fake/bin", "hi"], a.render(&v).unwrap());
856    }
857
858    #[test]
859    fn a_bare_arg_with_an_unset_placeholder_drops() {
860        let a = agent(vec![one("x"), one("{model}"), one("{prompt}")]);
861        assert_eq!(vec!["/fake/bin", "hi"], a.render(&values()).unwrap());
862    }
863
864    #[test]
865    fn literal_args_survive() {
866        let a = agent(vec![
867            one("x"),
868            one("exec"),
869            one("--json"),
870            one("--"),
871            one("{prompt}"),
872        ]);
873        assert_eq!(
874            vec!["/fake/bin", "exec", "--json", "--", "hi"],
875            a.render(&values()).unwrap()
876        );
877    }
878
879    #[test]
880    fn an_embedded_placeholder_substitutes_in_place() {
881        let a = agent(vec![
882            one("x"),
883            group(&["-c", "model_reasoning_effort={effort}"]),
884        ]);
885        let v = Placeholders {
886            effort: Some("ultra".into()),
887            ..Default::default()
888        };
889        assert_eq!(
890            vec!["/fake/bin", "-c", "model_reasoning_effort=ultra"],
891            a.render(&v).unwrap()
892        );
893    }
894
895    #[test]
896    fn a_group_with_two_placeholders_needs_both() {
897        let a = agent(vec![
898            one("x"),
899            group(&["--a", "{model}", "--b", "{effort}"]),
900        ]);
901        let v = Placeholders {
902            model: Some("m".into()),
903            ..Default::default()
904        };
905        assert_eq!(vec!["/fake/bin"], a.render(&v).unwrap());
906    }
907
908    #[test]
909    fn supports_schema_detects_the_placeholder() {
910        assert!(agent(vec![one("x"), group(&["--schema", "{schema_file}"])]).supports_schema());
911        assert!(!agent(vec![one("x"), one("{prompt}")]).supports_schema());
912    }
913
914    // -- output adapters -------------------------------------------------
915
916    #[test]
917    fn text_passes_through_trimmed() {
918        assert_eq!("hello", agent(vec![one("x")]).extract("  hello\n").unwrap());
919    }
920
921    #[test]
922    fn jsonl_picks_the_matching_event() {
923        let mut spec = spec(vec![one("x")]);
924        spec.output = OutputMode::Jsonl;
925        spec.message_path = Some("item.text".into());
926        spec.message_match = BTreeMap::from([
927            ("type".to_string(), "item.completed".to_string()),
928            ("item.type".to_string(), "agent_message".to_string()),
929        ]);
930        let a = Agent::with_bin(spec, "/fake/bin");
931        let stream = [
932            r#"{"type":"thread.started","thread_id":"t1"}"#,
933            r#"{"type":"item.completed","item":{"type":"command_execution","text":"ls"}}"#,
934            r#"{"type":"item.completed","item":{"type":"agent_message","text":"the answer"}}"#,
935            "not json at all",
936        ]
937        .join("\n");
938        assert_eq!("the answer", a.extract(&stream).unwrap());
939    }
940
941    #[test]
942    fn jsonl_raises_on_an_error_with_no_message() {
943        let mut spec = spec(vec![one("x")]);
944        spec.output = OutputMode::Jsonl;
945        spec.message_path = Some("item.text".into());
946        spec.message_match = BTreeMap::from([("type".into(), "item.completed".into())]);
947        let a = Agent::with_bin(spec, "/fake/bin");
948        assert!(a
949            .extract(r#"{"type":"turn.failed","error":"boom"}"#)
950            .is_err());
951    }
952
953    #[test]
954    fn jsonl_joins_several_agent_messages() {
955        let mut spec = spec(vec![one("x")]);
956        spec.output = OutputMode::Jsonl;
957        spec.message_path = Some("text".into());
958        spec.message_match = BTreeMap::from([("type".into(), "msg".into())]);
959        let a = Agent::with_bin(spec, "/fake/bin");
960        let stream = "{\"type\":\"msg\",\"text\":\"one\"}\n{\"type\":\"msg\",\"text\":\"two\"}";
961        assert_eq!("one\ntwo", a.extract(stream).unwrap());
962    }
963
964    #[test]
965    fn dig_walks_a_dotted_path() {
966        let v: Value = serde_json::from_str(r#"{"a":{"b":{"c":1}}}"#).unwrap();
967        assert_eq!(Some(&Value::from(1)), dig(&v, "a.b.c"));
968        assert_eq!(None, dig(&v, "a.b.missing"));
969        assert_eq!(None, dig(&v, ""));
970    }
971
972    // -- binary resolution -----------------------------------------------
973
974    #[test]
975    fn a_missing_binary_lists_everywhere_it_looked() {
976        let mut s = spec(vec![one("definitely-not-installed-xyz")]);
977        s.search_paths = vec!["/nowhere/at/all".into()];
978        s.name = "codex".into();
979        let err = Agent::new(s).resolve_bin().unwrap_err().to_string();
980        assert!(err.contains("definitely-not-installed-xyz (PATH)"), "{err}");
981        assert!(
982            err.contains("/nowhere/at/all/definitely-not-installed-xyz"),
983            "{err}"
984        );
985        assert!(err.contains("SPAR_CODEX_BIN"), "{err}");
986    }
987
988    #[test]
989    fn a_search_path_that_already_names_the_binary_is_used_as_is() {
990        let dir = std::env::temp_dir().join(format!("spar-test-{}", std::process::id()));
991        std::fs::create_dir_all(&dir).unwrap();
992        let bin = dir.join("mytool");
993        std::fs::write(&bin, "#!/bin/sh\n").unwrap();
994        #[cfg(unix)]
995        {
996            use std::os::unix::fs::PermissionsExt;
997            std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
998        }
999        let mut s = spec(vec![one("mytool")]);
1000        s.search_paths = vec![bin.display().to_string()];
1001        assert_eq!(bin, Agent::new(s).resolve_bin().unwrap());
1002        let _ = std::fs::remove_dir_all(&dir);
1003    }
1004
1005    // -- correlation -----------------------------------------------------
1006
1007    fn named(name: &str, bin: &str, model: Option<&str>) -> Agent {
1008        let mut s = spec(vec![one("prog")]);
1009        s.name = name.into();
1010        s.model = model.map(str::to_string);
1011        Agent::with_bin(s, bin)
1012    }
1013
1014    #[test]
1015    fn same_bin_same_model_warns() {
1016        let agents = vec![
1017            named("alpha", "/usr/local/bin/claude", Some("fable")),
1018            named("beta", "/usr/local/bin/claude", Some("fable")),
1019        ];
1020        let msg = correlation_warning(&agents).expect("should warn");
1021        assert!(msg.contains("alpha") && msg.contains("beta"), "{msg}");
1022    }
1023
1024    #[test]
1025    fn different_model_does_not_warn() {
1026        let agents = vec![
1027            named("a", "/usr/local/bin/claude", Some("fable")),
1028            named("b", "/usr/local/bin/claude", Some("opus")),
1029        ];
1030        assert!(correlation_warning(&agents).is_none());
1031    }
1032
1033    #[test]
1034    fn different_bin_does_not_warn() {
1035        let agents = vec![
1036            named("a", "/usr/local/bin/claude", Some("fable")),
1037            named("b", "/usr/local/bin/codex", Some("fable")),
1038        ];
1039        assert!(correlation_warning(&agents).is_none());
1040    }
1041
1042    #[test]
1043    fn unset_and_empty_model_both_mean_the_default_and_warn() {
1044        let agents = vec![
1045            named("a", "/usr/local/bin/claude", None),
1046            named("b", "/usr/local/bin/claude", Some("")),
1047        ];
1048        let msg = correlation_warning(&agents).expect("should warn");
1049        assert!(msg.contains("the CLI's default"), "{msg}");
1050    }
1051
1052    #[test]
1053    fn a_padded_model_still_warns() {
1054        let agents = vec![
1055            named("a", "/usr/local/bin/claude", Some("fable")),
1056            named("b", "/usr/local/bin/claude", Some(" fable ")),
1057        ];
1058        assert!(correlation_warning(&agents).is_some());
1059    }
1060
1061    #[test]
1062    fn an_empty_model_against_a_named_one_does_not_warn() {
1063        let agents = vec![
1064            named("a", "/usr/local/bin/claude", Some("")),
1065            named("b", "/usr/local/bin/claude", Some("fable")),
1066        ];
1067        assert!(correlation_warning(&agents).is_none());
1068    }
1069
1070    #[cfg(unix)]
1071    #[test]
1072    fn a_symlinked_binary_warns_and_names_both_paths() {
1073        use std::os::unix::fs::PermissionsExt;
1074        let dir = std::env::temp_dir().join(format!("spar-link-{}", std::process::id()));
1075        let _ = std::fs::remove_dir_all(&dir);
1076        std::fs::create_dir_all(&dir).unwrap();
1077        let real = dir.join("claude");
1078        let link = dir.join("claude-alias");
1079        std::fs::write(&real, "#!/bin/sh\n").unwrap();
1080        std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o755)).unwrap();
1081        std::os::unix::fs::symlink(&real, &link).unwrap();
1082
1083        let agents = vec![
1084            named("alpha", real.to_str().unwrap(), Some("fable")),
1085            named("beta", link.to_str().unwrap(), Some("fable")),
1086        ];
1087        let msg = correlation_warning(&agents).expect("should warn");
1088        assert!(msg.contains(real.to_str().unwrap()), "{msg}");
1089        assert!(msg.contains(link.to_str().unwrap()), "{msg}");
1090        let _ = std::fs::remove_dir_all(&dir);
1091    }
1092
1093    #[cfg(unix)]
1094    #[test]
1095    fn two_distinct_real_binaries_stay_quiet() {
1096        use std::os::unix::fs::PermissionsExt;
1097        let dir = std::env::temp_dir().join(format!("spar-distinct-{}", std::process::id()));
1098        let _ = std::fs::remove_dir_all(&dir);
1099        std::fs::create_dir_all(&dir).unwrap();
1100        let mut paths = Vec::new();
1101        for name in ["claude", "codex"] {
1102            let path = dir.join(name);
1103            std::fs::write(&path, "#!/bin/sh\n").unwrap();
1104            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
1105            paths.push(path);
1106        }
1107        let agents = vec![
1108            named("a", paths[0].to_str().unwrap(), Some("fable")),
1109            named("b", paths[1].to_str().unwrap(), Some("fable")),
1110        ];
1111        assert!(correlation_warning(&agents).is_none());
1112        let _ = std::fs::remove_dir_all(&dir);
1113    }
1114
1115    #[test]
1116    fn the_style_rules_ask_for_brevity_and_no_attribution() {
1117        let lower = STYLE_RULES.to_lowercase();
1118        assert!(lower.contains("brief"));
1119        assert!(lower.contains("co-authored-by"));
1120        assert!(lower.contains("em-dash"));
1121    }
1122
1123    // -- a failure said in the agent's own terms ---------------------------
1124
1125    /// The event stream from the run that prompted this: a wall of file
1126    /// contents a tool call returned, with the reason as the last two lines.
1127    fn refusal_stream() -> String {
1128        let noise = "{\"type\":\"item.completed\",\"item\":{\"id\":\"i\",\"type\":\"command_execution\",\"output\":\"".to_string()
1129            + &"const x = 1;\\n".repeat(200)
1130            + "\"}}";
1131        [
1132            noise.as_str(),
1133            r#"{"type":"error","message":"This content was flagged for possible cybersecurity risk."}"#,
1134            r#"{"type":"error","message":"This content was flagged for possible cybersecurity risk."}"#,
1135            r#"{"type":"turn.failed","error":{"message":"This content was flagged for possible cybersecurity risk."}}"#,
1136        ]
1137        .join("\n")
1138    }
1139
1140    fn jsonl_agent(name: &str) -> Agent {
1141        let mut spec = spec(vec![one("codex")]);
1142        spec.name = name.into();
1143        spec.output = OutputMode::Jsonl;
1144        spec.message_path = Some("item.text".into());
1145        Agent::with_bin(spec, "/fake/codex")
1146    }
1147
1148    fn failed(stdout: &str, stderr: &str) -> proc::Output {
1149        proc::Output {
1150            stdout: stdout.to_string(),
1151            stderr: stderr.to_string(),
1152            code: 1,
1153        }
1154    }
1155
1156    /// The reason a CLI gives is a field inside the event, not the event.
1157    /// Printing the object around it is what buried it.
1158    #[test]
1159    fn a_jsonl_failure_reports_the_reason_and_not_the_stream() {
1160        let agent = jsonl_agent("codex");
1161        let err = agent.call_failure(&["codex".to_string()], &failed(&refusal_stream(), ""));
1162        let text = err.message();
1163        assert!(
1164            text.contains("flagged for possible cybersecurity risk"),
1165            "{text}"
1166        );
1167        assert!(
1168            !text.contains("const x = 1;"),
1169            "the stream leaked in:\n{text}"
1170        );
1171        assert!(text.len() < 400, "still {} characters:\n{text}", text.len());
1172    }
1173
1174    /// One refusal reported as two errors and a turn.failed is one reason.
1175    #[test]
1176    fn the_same_reason_reported_three_times_is_said_once() {
1177        let agent = jsonl_agent("codex");
1178        let err = agent.call_failure(&["codex".to_string()], &failed(&refusal_stream(), ""));
1179        assert_eq!(
1180            1,
1181            err.message().matches("flagged for possible").count(),
1182            "{}",
1183            err.message()
1184        );
1185    }
1186
1187    /// stderr is where one CLI reports the condition behind the refusal, and it
1188    /// is short, so it survives whatever stdout turns out to hold.
1189    #[test]
1190    fn stderr_is_kept_because_it_is_where_the_other_half_arrives() {
1191        let agent = jsonl_agent("codex");
1192        let err = agent.call_failure(
1193            &["codex".to_string()],
1194            &failed(
1195                &refusal_stream(),
1196                "ERROR router: agent thread limit reached",
1197            ),
1198        );
1199        assert!(
1200            err.message().contains("agent thread limit reached"),
1201            "{}",
1202            err.message()
1203        );
1204    }
1205
1206    /// A CLI that dies without emitting an error event leaves nothing else to
1207    /// go on, so the raw dump is still what happens.
1208    #[test]
1209    fn a_stream_with_no_error_event_falls_back_to_the_raw_output() {
1210        let agent = jsonl_agent("codex");
1211        let err = agent.call_failure(
1212            &["codex".to_string()],
1213            &failed("{\"type\":\"system\"}", "segmentation fault"),
1214        );
1215        assert!(
1216            err.message().contains("segmentation fault"),
1217            "{}",
1218            err.message()
1219        );
1220        assert!(
1221            err.message().starts_with("command failed"),
1222            "{}",
1223            err.message()
1224        );
1225    }
1226
1227    /// A text agent has no events to read, so nothing changes for it.
1228    #[test]
1229    fn a_text_agent_is_reported_exactly_as_before() {
1230        let agent = agent(vec![one("mytool")]);
1231        let err = agent.call_failure(&["mytool".to_string()], &failed("some prose", "boom"));
1232        assert!(
1233            err.message().starts_with("command failed"),
1234            "{}",
1235            err.message()
1236        );
1237        assert!(err.message().contains("some prose"), "{}", err.message());
1238    }
1239
1240    /// Whatever the shape, it is still the CLI failing rather than answering
1241    /// badly, so it still goes straight to the stand in.
1242    #[test]
1243    fn a_reworded_failure_is_still_a_failed_call() {
1244        let agent = jsonl_agent("codex");
1245        let err = agent.call_failure(&["codex".to_string()], &failed(&refusal_stream(), ""));
1246        assert_eq!(ErrorKind::CallFailed, err.kind());
1247    }
1248
1249    // -- fallback --------------------------------------------------------
1250
1251    /// An agent whose command is a literal shell line, so a test can make the
1252    /// call succeed or fail on purpose.
1253    fn shell(name: &str, line: &str) -> AgentSpec {
1254        let mut spec = spec(vec![one("sh"), one("-c"), one(line)]);
1255        spec.name = name.into();
1256        spec
1257    }
1258
1259    fn with_fallback(mut primary: AgentSpec, backup: AgentSpec) -> Agent {
1260        primary.fallback = Some(Box::new(backup));
1261        Agent::with_bin(primary, "/bin/sh")
1262    }
1263
1264    #[test]
1265    fn a_failed_call_is_answered_by_the_fallback() {
1266        let agent = with_fallback(
1267            shell("primary", "echo refused >&2; exit 1"),
1268            shell("backup", "echo stood in"),
1269        );
1270        let answer = agent.ask("hi", Path::new("."), None).expect("fallback");
1271        assert_eq!("stood in", answer);
1272    }
1273
1274    #[test]
1275    fn without_a_fallback_the_original_error_is_what_surfaces() {
1276        let agent = Agent::with_bin(shell("primary", "echo refused >&2; exit 1"), "/bin/sh");
1277        let err = agent
1278            .ask("hi", Path::new("."), None)
1279            .expect_err("no backup");
1280        assert!(err.message().contains("refused"), "{err}");
1281    }
1282
1283    /// The reason the run stopped is the primary's, not the backup's, so it
1284    /// leads. A backup that is simply not installed explains nothing.
1285    #[test]
1286    fn both_failing_reports_the_primary_first() {
1287        let agent = with_fallback(
1288            shell("primary", "echo policy refusal >&2; exit 1"),
1289            shell("backup", "echo out of quota >&2; exit 1"),
1290        );
1291        let err = agent
1292            .ask("hi", Path::new("."), None)
1293            .expect_err("both fail");
1294        let text = err.message();
1295        let primary_at = text.find("policy refusal").expect("primary reason");
1296        let backup_at = text.find("out of quota").expect("backup reason");
1297        assert!(primary_at < backup_at, "{text}");
1298        assert!(
1299            text.contains("primary") && text.contains("backup"),
1300            "{text}"
1301        );
1302    }
1303
1304    /// A counter file, so a test can say how many times the CLI was actually
1305    /// run rather than only what came back.
1306    fn attempts(name: &str) -> (PathBuf, String) {
1307        let path = std::env::temp_dir().join(format!("spar-attempts-{name}"));
1308        let _ = std::fs::remove_file(&path);
1309        let line = format!("echo x >> {}", path.display());
1310        (path, line)
1311    }
1312
1313    fn counted(path: &Path) -> usize {
1314        std::fs::read_to_string(path)
1315            .map(|t| t.lines().count())
1316            .unwrap_or(0)
1317    }
1318
1319    /// The failure that prompted this. A policy refusal came back twice, at
1320    /// full effort, before the stand in was given the call. The second was
1321    /// never going to be different: nothing about a refusal, a quota, or a
1322    /// crash is corrected by being asked the same thing again.
1323    #[test]
1324    fn a_cli_that_could_not_answer_is_not_asked_twice_when_there_is_a_stand_in() {
1325        let (path, count) = attempts("refused-with-standin");
1326        let agent = with_fallback(
1327            shell("primary", &format!("{count}; echo refused >&2; exit 1")),
1328            shell("backup", "echo '{}'"),
1329        );
1330        let answer: Value = agent
1331            .ask_json(
1332                "q",
1333                &serde_json::json!({"type": "object"}),
1334                Path::new("."),
1335                None,
1336            )
1337            .expect("the stand in answers");
1338        assert!(answer.is_object());
1339        assert_eq!(1, counted(&path), "the primary was asked more than once");
1340    }
1341
1342    /// With nowhere to send the call, the retry is the only thing left, so it
1343    /// still happens. A transient failure is the case it was there for.
1344    #[test]
1345    fn with_no_stand_in_a_failed_call_is_still_retried() {
1346        let (path, count) = attempts("refused-alone");
1347        let agent = Agent::with_bin(
1348            shell("solo", &format!("{count}; echo refused >&2; exit 1")),
1349            "/bin/sh",
1350        );
1351        let err = agent
1352            .ask_json::<Value>(
1353                "q",
1354                &serde_json::json!({"type": "object"}),
1355                Path::new("."),
1356                None,
1357            )
1358            .expect_err("nothing answers");
1359        assert!(err.message().contains("twice"), "{err}");
1360        assert_eq!(2, counted(&path));
1361    }
1362
1363    /// The retry that must survive. An answer that arrived and could not be
1364    /// parsed is exactly what it is for, and a model corrects a shape error
1365    /// readily when handed the parser's complaint.
1366    #[test]
1367    fn an_unusable_answer_is_still_worth_asking_again() {
1368        let (path, count) = attempts("unparsable");
1369        let agent = with_fallback(
1370            shell("primary", &format!("{count}; echo not json at all")),
1371            shell("backup", "echo '{}'"),
1372        );
1373        let answer: Value = agent
1374            .ask_json(
1375                "q",
1376                &serde_json::json!({"type": "object"}),
1377                Path::new("."),
1378                None,
1379            )
1380            .expect("the stand in answers in the end");
1381        assert!(answer.is_object());
1382        assert_eq!(2, counted(&path), "a shape error is worth one more ask");
1383    }
1384
1385    /// A deadline is not worth asking the same CLI again, and `ask_json` does
1386    /// not. A different CLI is a different question, and the alternative is
1387    /// losing the run.
1388    #[test]
1389    fn a_timeout_still_reaches_the_fallback() {
1390        let mut primary = shell("primary", "sleep 30");
1391        primary.timeout = 1;
1392        let agent = with_fallback(primary, shell("backup", "echo stood in"));
1393        assert_eq!(
1394            "stood in",
1395            agent.ask("hi", Path::new("."), None).expect("fallback")
1396        );
1397    }
1398
1399    /// The fallback is built with the agent, not looked up later, so a spec
1400    /// that carries one produces an agent that carries one.
1401    #[test]
1402    fn the_fallback_is_built_alongside_the_agent() {
1403        let mut primary = shell("primary", "true");
1404        primary.fallback = Some(Box::new(shell("backup", "true")));
1405        let agent = Agent::new(primary);
1406        assert_eq!(Some("backup"), agent.fallback().map(Agent::name));
1407        assert!(Agent::new(shell("solo", "true")).fallback().is_none());
1408    }
1409}
1410
1411#[cfg(test)]
1412mod schema_placeholder_tests {
1413    use super::*;
1414    use crate::config::{OutputMode, SystemVia};
1415
1416    fn spec_with(command: Vec<CommandPart>) -> AgentSpec {
1417        AgentSpec {
1418            name: "claude".into(),
1419            command,
1420            model: None,
1421            effort: None,
1422            output: OutputMode::Text,
1423            message_match: BTreeMap::new(),
1424            message_path: None,
1425            search_paths: vec![],
1426            system_via: SystemVia::Prompt,
1427            timeout: 60,
1428            fallback: None,
1429            models: vec![],
1430            efforts: vec![],
1431            options_note: None,
1432        }
1433    }
1434
1435    fn one(s: &str) -> CommandPart {
1436        CommandPart::One(s.into())
1437    }
1438    fn group(parts: &[&str]) -> CommandPart {
1439        CommandPart::Group(parts.iter().map(|s| s.to_string()).collect())
1440    }
1441
1442    /// Claude Code takes the schema as an argument, not a path, so the file
1443    /// form alone was not enough to give it native structured output.
1444    #[test]
1445    fn either_schema_form_counts_as_native_support() {
1446        let inline = Agent::with_bin(
1447            spec_with(vec![one("x"), group(&["--json-schema", "{schema}"])]),
1448            "/b",
1449        );
1450        let byfile = Agent::with_bin(
1451            spec_with(vec![one("x"), group(&["--output-schema", "{schema_file}"])]),
1452            "/b",
1453        );
1454        let neither = Agent::with_bin(spec_with(vec![one("x"), one("{prompt}")]), "/b");
1455        assert!(inline.supports_schema());
1456        assert!(byfile.supports_schema());
1457        assert!(!neither.supports_schema());
1458    }
1459
1460    #[test]
1461    fn the_inline_schema_is_substituted_whole() {
1462        let agent = Agent::with_bin(
1463            spec_with(vec![
1464                one("x"),
1465                group(&["--json-schema", "{schema}"]),
1466                one("{prompt}"),
1467            ]),
1468            "/b",
1469        );
1470        let values = Placeholders {
1471            prompt: Some("review it".into()),
1472            schema: Some(r#"{"type":"object"}"#.into()),
1473            ..Default::default()
1474        };
1475        assert_eq!(
1476            vec!["/b", "--json-schema", r#"{"type":"object"}"#, "review it"],
1477            agent.render(&values).unwrap()
1478        );
1479    }
1480
1481    /// A call that wants prose back passes no schema, and the flag must go with
1482    /// it rather than being handed an empty string.
1483    #[test]
1484    fn the_schema_flag_drops_when_no_schema_is_wanted() {
1485        let agent = Agent::with_bin(
1486            spec_with(vec![
1487                one("x"),
1488                group(&["--json-schema", "{schema}"]),
1489                one("{prompt}"),
1490            ]),
1491            "/b",
1492        );
1493        let values = Placeholders {
1494            prompt: Some("implement it".into()),
1495            ..Default::default()
1496        };
1497        assert_eq!(vec!["/b", "implement it"], agent.render(&values).unwrap());
1498    }
1499
1500    /// `{schema_file}` must not be mistaken for `{schema}`.
1501    #[test]
1502    fn the_two_schema_placeholders_do_not_collide() {
1503        let agent = Agent::with_bin(
1504            spec_with(vec![one("x"), group(&["--output-schema", "{schema_file}"])]),
1505            "/b",
1506        );
1507        let values = Placeholders {
1508            schema: Some("INLINE".into()),
1509            schema_file: Some("/tmp/s.json".into()),
1510            ..Default::default()
1511        };
1512        assert_eq!(
1513            vec!["/b", "--output-schema", "/tmp/s.json"],
1514            agent.render(&values).unwrap()
1515        );
1516    }
1517
1518    /// The preset that was failing in the field.
1519    #[test]
1520    fn the_shipped_claude_preset_now_has_native_structured_output() {
1521        let raw = crate::config::load_preset("claude").unwrap();
1522        let table = raw.as_table().cloned().unwrap();
1523        let mut spec: AgentSpec = toml::Value::Table(table).try_into().unwrap();
1524        spec.name = "claude".into();
1525        assert!(
1526            Agent::with_bin(spec, "/b").supports_schema(),
1527            "without this a long review is parsed out of prose and truncates"
1528        );
1529    }
1530}