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