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