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::CallFailed => self.fallback().is_none(),
512            ErrorKind::Other => true,
513        }
514    }
515
516    /// The same question, asked at most twice of this agent alone.
517    fn ask_json_retrying<T: serde::de::DeserializeOwned>(
518        &self,
519        prompt: &str,
520        schema: &Value,
521        cwd: &Path,
522        effort: Option<&str>,
523    ) -> Result<T> {
524        // One retry, with the parser's own complaint handed back.
525        //
526        // A single malformed answer used to cost half a review: the other agent
527        // carried on alone, which is the one thing this design exists to avoid.
528        // Models correct a shape error readily when told what was wrong.
529        const ATTEMPTS: usize = 2;
530        let mut last: Option<SparError> = None;
531
532        for attempt in 1..=ATTEMPTS {
533            let asked = match &last {
534                None => prompt.to_string(),
535                Some(e) => format!(
536                    "{prompt}\n\nYour previous answer could not be used: {}\nReturn the whole \
537                     object this time, exactly matching the schema, and nothing else.",
538                    e.first_line()
539                ),
540            };
541            match self.ask_json_once::<T>(&asked, schema, cwd, effort) {
542                Ok(parsed) => {
543                    if attempt > 1 {
544                        logdim!("{} answered on the retry", self.spec.name);
545                    }
546                    return Ok(parsed);
547                }
548                // A deadline is not a bad answer. Asking again buys another
549                // wait of exactly the same length, which on a long review is
550                // the most expensive way to learn nothing.
551                Err(e) if !self.worth_asking_again(&e) => return Err(e),
552                Err(e) => {
553                    if attempt < ATTEMPTS {
554                        // The whole error, not its first line. The first line is
555                        // the command; the reason is in the stderr underneath
556                        // it, and printing only the first line made a retry
557                        // impossible to diagnose from the log.
558                        logwarn!("{} failed, asking again.\n{e}", self.spec.name);
559                    }
560                    last = Some(e);
561                }
562            }
563        }
564        Err(spar_err!(
565            "agent '{}' returned an unusable answer twice: {}",
566            self.spec.name,
567            last.expect("at least one attempt").message()
568        ))
569    }
570
571    fn ask_json_once<T: serde::de::DeserializeOwned>(
572        &self,
573        prompt: &str,
574        schema: &Value,
575        cwd: &Path,
576        effort: Option<&str>,
577    ) -> Result<T> {
578        let text = if self.supports_schema() {
579            let inline = serde_json::to_string(schema).unwrap_or_default();
580            let file = TempJson::write(schema)?;
581            self.ask_inner(prompt, cwd, effort, Some(file.path()), Some(&inline))?
582        } else {
583            let full = format!(
584                "{prompt}\n\n{JSON_INSTRUCTION}\n{}",
585                serde_json::to_string_pretty(schema).unwrap_or_default()
586            );
587            self.ask_inner(&full, cwd, effort, None, None)?
588        };
589        jsonx::extract_into(&text)
590    }
591
592    /// Review the branch against `base`.
593    ///
594    /// Deliberately generic. `codex exec review` was tried and rejected: it
595    /// refuses a custom prompt alongside `--base` and returns prose regardless
596    /// of `--output-schema`, so it cannot yield a machine checkable verdict.
597    /// Running inside the worktree is what makes an agent repo aware, not a
598    /// subcommand.
599    ///
600    /// The call has write access, so the paragraph saying not to write is not
601    /// decoration: an agent that commits while reviewing ends up holding the
602    /// head it is about to be handed back, which is the one thing the
603    /// alternating loop exists to prevent. `review::review_loop` rolls back
604    /// what this asks for anyway, because a prompt is not a permission.
605    pub fn review<T: serde::de::DeserializeOwned>(
606        &self,
607        base: &str,
608        prompt: &str,
609        schema: &Value,
610        cwd: &Path,
611        effort: Option<&str>,
612    ) -> Result<T> {
613        let scoped = format!(
614            "{prompt}\n\nThe changes under review are the diff between `{base}` and HEAD in your \
615             working directory. Inspect them with git, then read the surrounding code before \
616             judging. Do not review only the diff.\n\nThis call is a review and nothing else. Do \
617             not edit the code under review, do not commit, and do not push: somebody else acts \
618             on what you find, and a reviewer that writes ends up reviewing its own work. Writing \
619             a scratch file to check a claim is fine, and anything else you leave behind is rolled \
620             back."
621        );
622        self.ask_json(&scoped, schema, cwd, effort)
623    }
624}
625
626// ---------------------------------------------------------------------------
627// Placeholders
628// ---------------------------------------------------------------------------
629
630#[derive(Debug, Default, Clone)]
631pub struct Placeholders {
632    pub prompt: Option<String>,
633    pub system: Option<String>,
634    pub model: Option<String>,
635    pub effort: Option<String>,
636    pub cwd: Option<String>,
637    /// A path to the schema, for a CLI that reads one from disk.
638    pub schema_file: Option<String>,
639    /// The schema itself, for a CLI that takes it as an argument.
640    pub schema: Option<String>,
641}
642
643impl Placeholders {
644    fn get(&self, key: &str) -> Option<&str> {
645        let value = match key {
646            "prompt" => self.prompt.as_deref(),
647            "system" => self.system.as_deref(),
648            "model" => self.model.as_deref(),
649            "effort" => self.effort.as_deref(),
650            "cwd" => self.cwd.as_deref(),
651            "schema_file" => self.schema_file.as_deref(),
652            "schema" => self.schema.as_deref(),
653            _ => None,
654        };
655        value.filter(|v| !v.is_empty())
656    }
657
658    /// Substitute every placeholder in one argument. `None` means a placeholder
659    /// in this argument had no value, so the whole group is dropped.
660    fn substitute(&self, arg: &str) -> Option<String> {
661        const KEYS: [&str; 7] = [
662            "prompt",
663            "system",
664            "model",
665            "effort",
666            "cwd",
667            "schema_file",
668            "schema",
669        ];
670        let mut out = arg.to_string();
671        for key in KEYS {
672            let token = format!("{{{key}}}");
673            if out.contains(&token) {
674                let value = self.get(key)?;
675                out = out.replace(&token, value);
676            }
677        }
678        Some(out)
679    }
680}
681
682// ---------------------------------------------------------------------------
683// JSONL helpers
684// ---------------------------------------------------------------------------
685
686/// Follow a dotted path, returning None if any hop is missing.
687fn dig<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
688    if path.is_empty() {
689        return None;
690    }
691    let mut node = value;
692    for part in path.split('.') {
693        node = node.as_object()?.get(part)?;
694    }
695    Some(node)
696}
697
698fn matches(event: &Value, wanted: &BTreeMap<String, String>) -> bool {
699    if wanted.is_empty() {
700        return false;
701    }
702    wanted
703        .iter()
704        .all(|(path, expected)| dig(event, path).and_then(Value::as_str) == Some(expected.as_str()))
705}
706
707fn as_text(value: &Value) -> Option<String> {
708    match value {
709        Value::String(s) => Some(s.clone()),
710        Value::Null => None,
711        other => Some(other.to_string()),
712    }
713}
714
715fn truncate(text: &str, max: usize) -> String {
716    text.chars().take(max).collect()
717}
718
719// ---------------------------------------------------------------------------
720// Temporary schema file
721// ---------------------------------------------------------------------------
722
723/// A schema written somewhere the CLI can read it, removed when it goes out of
724/// scope even if the agent call fails.
725struct TempJson {
726    path: PathBuf,
727}
728
729impl TempJson {
730    fn write(value: &Value) -> Result<Self> {
731        use std::sync::atomic::{AtomicU64, Ordering};
732        static COUNTER: AtomicU64 = AtomicU64::new(0);
733
734        let nanos = std::time::SystemTime::now()
735            .duration_since(std::time::UNIX_EPOCH)
736            .map(|d| d.as_nanos())
737            .unwrap_or(0);
738        let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
739        let path = std::env::temp_dir().join(format!(
740            "spar-schema-{}-{nanos}-{unique}.json",
741            std::process::id()
742        ));
743        std::fs::write(&path, serde_json::to_vec_pretty(value)?)
744            .map_err(|e| spar_err!("could not write a schema file to {}: {e}", path.display()))?;
745        Ok(Self { path })
746    }
747
748    fn path(&self) -> &Path {
749        &self.path
750    }
751}
752
753impl Drop for TempJson {
754    fn drop(&mut self) {
755        let _ = std::fs::remove_file(&self.path);
756    }
757}
758
759// ---------------------------------------------------------------------------
760// Correlation
761// ---------------------------------------------------------------------------
762
763/// Whether two paths name the same executable.
764///
765/// Comparing the raw strings misses aliases: a symlink or a hard link points at
766/// the same binary under a different path, which would let two agents run the
767/// identical CLI without tripping the warning below. Device and inode see
768/// through both.
769fn same_executable(a: &Path, b: &Path) -> bool {
770    #[cfg(unix)]
771    {
772        use std::os::unix::fs::MetadataExt;
773        if let (Ok(x), Ok(y)) = (std::fs::metadata(a), std::fs::metadata(b)) {
774            return x.dev() == y.dev() && x.ino() == y.ino();
775        }
776    }
777    match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
778        (Ok(x), Ok(y)) => x == y,
779        _ => a == b,
780    }
781}
782
783/// Two agents are only an independent review if they can actually disagree.
784///
785/// Config keys are arbitrary, so `alpha` and `beta` can both be Claude on the
786/// same model. Compare what actually runs: the resolved binary and the
787/// configured model, never the names.
788pub fn correlation_warning(agents: &[Agent]) -> Option<String> {
789    for i in 0..agents.len() {
790        for j in (i + 1)..agents.len() {
791            let (a, b) = (&agents[i], &agents[j]);
792            let (Ok(pa), Ok(pb)) = (a.resolve_bin(), b.resolve_bin()) else {
793                continue;
794            };
795            if !same_executable(pa, pb) || a.spec.model_key() != b.spec.model_key() {
796                continue;
797            }
798            let model = if a.spec.model_key().is_empty() {
799                "the CLI's default".to_string()
800            } else {
801                a.spec.model_key()
802            };
803            let where_at = if pa == pb {
804                pa.display().to_string()
805            } else {
806                format!(
807                    "the same executable ({} and {} are the same file)",
808                    pa.display(),
809                    pb.display()
810                )
811            };
812            return Some(format!(
813                "agents '{}' and '{}' both resolve to {where_at} at model {model}. Review \
814                 findings will be correlated: the same model reviewing itself shares the blind \
815                 spots of the model that wrote the code, so it is far less likely to catch what \
816                 the implementer missed. That produces an approval indistinguishable from a real \
817                 review, which is worse than no review at all. Give the two agents different \
818                 CLIs or different models.",
819                a.name(),
820                b.name()
821            ));
822        }
823    }
824    None
825}
826
827/// Build every configured agent, resolving each binary up front so a missing
828/// CLI fails before any model is billed.
829pub fn build(cfg: &crate::config::Config) -> Result<Vec<Agent>> {
830    let agents: Vec<Agent> = cfg
831        .agents
832        .iter()
833        .cloned()
834        .map(Agent::new)
835        .map(|agent| agent.with_instructions(&cfg.loop_cfg.instructions))
836        .collect();
837    for agent in &agents {
838        agent.resolve_bin()?;
839        // A backup that is not installed must not stop a run whose pair is
840        // fine. Said once here, at the start, rather than an hour in at the
841        // moment it was needed and could not be reached.
842        if let Some(backup) = agent.fallback() {
843            if backup.resolve_bin().is_err() {
844                logwarn!(
845                    "{} has a fallback ({}) that is not installed, so it will not stand in",
846                    agent.name(),
847                    backup.program()
848                );
849            }
850        }
851    }
852    Ok(agents)
853}
854
855/// Look an agent up by name in a built list.
856pub fn find<'a>(agents: &'a [Agent], name: &str) -> Result<&'a Agent> {
857    agents.iter().find(|a| a.name() == name).ok_or_else(|| {
858        SparError::new(format!(
859            "no agent named '{name}' ({})",
860            agents
861                .iter()
862                .map(Agent::name)
863                .collect::<Vec<_>>()
864                .join(", ")
865        ))
866    })
867}
868
869#[cfg(test)]
870mod tests {
871    use super::*;
872    use crate::config::{OutputMode, SystemVia};
873
874    fn spec(command: Vec<CommandPart>) -> AgentSpec {
875        AgentSpec {
876            name: "test".into(),
877            command,
878            model: None,
879            effort: None,
880            output: OutputMode::Text,
881            message_match: BTreeMap::new(),
882            message_path: None,
883            search_paths: vec![],
884            system_via: SystemVia::Prompt,
885            timeout: 60,
886            fallback: None,
887            models: vec![],
888            efforts: vec![],
889            options_note: None,
890        }
891    }
892
893    fn one(s: &str) -> CommandPart {
894        CommandPart::One(s.into())
895    }
896
897    fn group(parts: &[&str]) -> CommandPart {
898        CommandPart::Group(parts.iter().map(|s| s.to_string()).collect())
899    }
900
901    fn agent(command: Vec<CommandPart>) -> Agent {
902        Agent::with_bin(spec(command), "/fake/bin")
903    }
904
905    fn values() -> Placeholders {
906        Placeholders {
907            prompt: Some("hi".into()),
908            ..Default::default()
909        }
910    }
911
912    // -- rendering -------------------------------------------------------
913
914    #[test]
915    fn placeholders_are_substituted() {
916        let a = agent(vec![one("x"), group(&["-m", "{model}"]), one("{prompt}")]);
917        let v = Placeholders {
918            model: Some("m1".into()),
919            ..values()
920        };
921        assert_eq!(vec!["/fake/bin", "-m", "m1", "hi"], a.render(&v).unwrap());
922    }
923
924    #[test]
925    fn an_unset_placeholder_drops_the_whole_group() {
926        let a = agent(vec![one("x"), group(&["-m", "{model}"]), one("{prompt}")]);
927        assert_eq!(vec!["/fake/bin", "hi"], a.render(&values()).unwrap());
928    }
929
930    #[test]
931    fn an_empty_string_drops_the_group_too() {
932        let a = agent(vec![one("x"), group(&["-e", "{effort}"]), one("{prompt}")]);
933        let v = Placeholders {
934            effort: Some(String::new()),
935            ..values()
936        };
937        assert_eq!(vec!["/fake/bin", "hi"], a.render(&v).unwrap());
938    }
939
940    #[test]
941    fn a_bare_arg_with_an_unset_placeholder_drops() {
942        let a = agent(vec![one("x"), one("{model}"), one("{prompt}")]);
943        assert_eq!(vec!["/fake/bin", "hi"], a.render(&values()).unwrap());
944    }
945
946    #[test]
947    fn literal_args_survive() {
948        let a = agent(vec![
949            one("x"),
950            one("exec"),
951            one("--json"),
952            one("--"),
953            one("{prompt}"),
954        ]);
955        assert_eq!(
956            vec!["/fake/bin", "exec", "--json", "--", "hi"],
957            a.render(&values()).unwrap()
958        );
959    }
960
961    #[test]
962    fn an_embedded_placeholder_substitutes_in_place() {
963        let a = agent(vec![
964            one("x"),
965            group(&["-c", "model_reasoning_effort={effort}"]),
966        ]);
967        let v = Placeholders {
968            effort: Some("ultra".into()),
969            ..Default::default()
970        };
971        assert_eq!(
972            vec!["/fake/bin", "-c", "model_reasoning_effort=ultra"],
973            a.render(&v).unwrap()
974        );
975    }
976
977    #[test]
978    fn a_group_with_two_placeholders_needs_both() {
979        let a = agent(vec![
980            one("x"),
981            group(&["--a", "{model}", "--b", "{effort}"]),
982        ]);
983        let v = Placeholders {
984            model: Some("m".into()),
985            ..Default::default()
986        };
987        assert_eq!(vec!["/fake/bin"], a.render(&v).unwrap());
988    }
989
990    #[test]
991    fn supports_schema_detects_the_placeholder() {
992        assert!(agent(vec![one("x"), group(&["--schema", "{schema_file}"])]).supports_schema());
993        assert!(!agent(vec![one("x"), one("{prompt}")]).supports_schema());
994    }
995
996    // -- output adapters -------------------------------------------------
997
998    #[test]
999    fn text_passes_through_trimmed() {
1000        assert_eq!("hello", agent(vec![one("x")]).extract("  hello\n").unwrap());
1001    }
1002
1003    #[test]
1004    fn jsonl_picks_the_matching_event() {
1005        let mut spec = spec(vec![one("x")]);
1006        spec.output = OutputMode::Jsonl;
1007        spec.message_path = Some("item.text".into());
1008        spec.message_match = BTreeMap::from([
1009            ("type".to_string(), "item.completed".to_string()),
1010            ("item.type".to_string(), "agent_message".to_string()),
1011        ]);
1012        let a = Agent::with_bin(spec, "/fake/bin");
1013        let stream = [
1014            r#"{"type":"thread.started","thread_id":"t1"}"#,
1015            r#"{"type":"item.completed","item":{"type":"command_execution","text":"ls"}}"#,
1016            r#"{"type":"item.completed","item":{"type":"agent_message","text":"the answer"}}"#,
1017            "not json at all",
1018        ]
1019        .join("\n");
1020        assert_eq!("the answer", a.extract(&stream).unwrap());
1021    }
1022
1023    #[test]
1024    fn jsonl_raises_on_an_error_with_no_message() {
1025        let mut spec = spec(vec![one("x")]);
1026        spec.output = OutputMode::Jsonl;
1027        spec.message_path = Some("item.text".into());
1028        spec.message_match = BTreeMap::from([("type".into(), "item.completed".into())]);
1029        let a = Agent::with_bin(spec, "/fake/bin");
1030        assert!(a
1031            .extract(r#"{"type":"turn.failed","error":"boom"}"#)
1032            .is_err());
1033    }
1034
1035    #[test]
1036    fn jsonl_joins_several_agent_messages() {
1037        let mut spec = spec(vec![one("x")]);
1038        spec.output = OutputMode::Jsonl;
1039        spec.message_path = Some("text".into());
1040        spec.message_match = BTreeMap::from([("type".into(), "msg".into())]);
1041        let a = Agent::with_bin(spec, "/fake/bin");
1042        let stream = "{\"type\":\"msg\",\"text\":\"one\"}\n{\"type\":\"msg\",\"text\":\"two\"}";
1043        assert_eq!("one\ntwo", a.extract(stream).unwrap());
1044    }
1045
1046    #[test]
1047    fn dig_walks_a_dotted_path() {
1048        let v: Value = serde_json::from_str(r#"{"a":{"b":{"c":1}}}"#).unwrap();
1049        assert_eq!(Some(&Value::from(1)), dig(&v, "a.b.c"));
1050        assert_eq!(None, dig(&v, "a.b.missing"));
1051        assert_eq!(None, dig(&v, ""));
1052    }
1053
1054    // -- binary resolution -----------------------------------------------
1055
1056    #[test]
1057    fn a_missing_binary_lists_everywhere_it_looked() {
1058        let mut s = spec(vec![one("definitely-not-installed-xyz")]);
1059        s.search_paths = vec!["/nowhere/at/all".into()];
1060        s.name = "codex".into();
1061        let err = Agent::new(s).resolve_bin().unwrap_err().to_string();
1062        assert!(err.contains("definitely-not-installed-xyz (PATH)"), "{err}");
1063        assert!(
1064            err.contains("/nowhere/at/all/definitely-not-installed-xyz"),
1065            "{err}"
1066        );
1067        assert!(err.contains("SPAR_CODEX_BIN"), "{err}");
1068    }
1069
1070    #[test]
1071    fn a_search_path_that_already_names_the_binary_is_used_as_is() {
1072        let dir = std::env::temp_dir().join(format!("spar-test-{}", std::process::id()));
1073        std::fs::create_dir_all(&dir).unwrap();
1074        let bin = dir.join("mytool");
1075        std::fs::write(&bin, "#!/bin/sh\n").unwrap();
1076        #[cfg(unix)]
1077        {
1078            use std::os::unix::fs::PermissionsExt;
1079            std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
1080        }
1081        let mut s = spec(vec![one("mytool")]);
1082        s.search_paths = vec![bin.display().to_string()];
1083        assert_eq!(bin, Agent::new(s).resolve_bin().unwrap());
1084        let _ = std::fs::remove_dir_all(&dir);
1085    }
1086
1087    // -- correlation -----------------------------------------------------
1088
1089    fn named(name: &str, bin: &str, model: Option<&str>) -> Agent {
1090        let mut s = spec(vec![one("prog")]);
1091        s.name = name.into();
1092        s.model = model.map(str::to_string);
1093        Agent::with_bin(s, bin)
1094    }
1095
1096    #[test]
1097    fn same_bin_same_model_warns() {
1098        let agents = vec![
1099            named("alpha", "/usr/local/bin/claude", Some("fable")),
1100            named("beta", "/usr/local/bin/claude", Some("fable")),
1101        ];
1102        let msg = correlation_warning(&agents).expect("should warn");
1103        assert!(msg.contains("alpha") && msg.contains("beta"), "{msg}");
1104    }
1105
1106    #[test]
1107    fn different_model_does_not_warn() {
1108        let agents = vec![
1109            named("a", "/usr/local/bin/claude", Some("fable")),
1110            named("b", "/usr/local/bin/claude", Some("opus")),
1111        ];
1112        assert!(correlation_warning(&agents).is_none());
1113    }
1114
1115    #[test]
1116    fn different_bin_does_not_warn() {
1117        let agents = vec![
1118            named("a", "/usr/local/bin/claude", Some("fable")),
1119            named("b", "/usr/local/bin/codex", Some("fable")),
1120        ];
1121        assert!(correlation_warning(&agents).is_none());
1122    }
1123
1124    #[test]
1125    fn unset_and_empty_model_both_mean_the_default_and_warn() {
1126        let agents = vec![
1127            named("a", "/usr/local/bin/claude", None),
1128            named("b", "/usr/local/bin/claude", Some("")),
1129        ];
1130        let msg = correlation_warning(&agents).expect("should warn");
1131        assert!(msg.contains("the CLI's default"), "{msg}");
1132    }
1133
1134    #[test]
1135    fn a_padded_model_still_warns() {
1136        let agents = vec![
1137            named("a", "/usr/local/bin/claude", Some("fable")),
1138            named("b", "/usr/local/bin/claude", Some(" fable ")),
1139        ];
1140        assert!(correlation_warning(&agents).is_some());
1141    }
1142
1143    #[test]
1144    fn an_empty_model_against_a_named_one_does_not_warn() {
1145        let agents = vec![
1146            named("a", "/usr/local/bin/claude", Some("")),
1147            named("b", "/usr/local/bin/claude", Some("fable")),
1148        ];
1149        assert!(correlation_warning(&agents).is_none());
1150    }
1151
1152    #[cfg(unix)]
1153    #[test]
1154    fn a_symlinked_binary_warns_and_names_both_paths() {
1155        use std::os::unix::fs::PermissionsExt;
1156        let dir = std::env::temp_dir().join(format!("spar-link-{}", std::process::id()));
1157        let _ = std::fs::remove_dir_all(&dir);
1158        std::fs::create_dir_all(&dir).unwrap();
1159        let real = dir.join("claude");
1160        let link = dir.join("claude-alias");
1161        std::fs::write(&real, "#!/bin/sh\n").unwrap();
1162        std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o755)).unwrap();
1163        std::os::unix::fs::symlink(&real, &link).unwrap();
1164
1165        let agents = vec![
1166            named("alpha", real.to_str().unwrap(), Some("fable")),
1167            named("beta", link.to_str().unwrap(), Some("fable")),
1168        ];
1169        let msg = correlation_warning(&agents).expect("should warn");
1170        assert!(msg.contains(real.to_str().unwrap()), "{msg}");
1171        assert!(msg.contains(link.to_str().unwrap()), "{msg}");
1172        let _ = std::fs::remove_dir_all(&dir);
1173    }
1174
1175    #[cfg(unix)]
1176    #[test]
1177    fn two_distinct_real_binaries_stay_quiet() {
1178        use std::os::unix::fs::PermissionsExt;
1179        let dir = std::env::temp_dir().join(format!("spar-distinct-{}", std::process::id()));
1180        let _ = std::fs::remove_dir_all(&dir);
1181        std::fs::create_dir_all(&dir).unwrap();
1182        let mut paths = Vec::new();
1183        for name in ["claude", "codex"] {
1184            let path = dir.join(name);
1185            std::fs::write(&path, "#!/bin/sh\n").unwrap();
1186            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
1187            paths.push(path);
1188        }
1189        let agents = vec![
1190            named("a", paths[0].to_str().unwrap(), Some("fable")),
1191            named("b", paths[1].to_str().unwrap(), Some("fable")),
1192        ];
1193        assert!(correlation_warning(&agents).is_none());
1194        let _ = std::fs::remove_dir_all(&dir);
1195    }
1196
1197    #[test]
1198    fn the_style_rules_ask_for_brevity_and_no_attribution() {
1199        let lower = STYLE_RULES.to_lowercase();
1200        assert!(lower.contains("brief"));
1201        assert!(lower.contains("co-authored-by"));
1202        assert!(lower.contains("em-dash"));
1203    }
1204
1205    /// Brevity was measured in sentences, and "one sentence beats one paragraph"
1206    /// is what a model satisfies by joining three facts with commas. A summary
1207    /// came back as a changelog line the reader had to decipher, which is
1208    /// shorter and worse.
1209    #[test]
1210    fn brevity_is_about_facts_per_sentence_not_sentence_count() {
1211        let lower = STYLE_RULES.to_lowercase();
1212        assert!(lower.contains("saying fewer things"), "{STYLE_RULES}");
1213        assert!(
1214            !lower.contains("one sentence beats one paragraph"),
1215            "the rule that produced the density is still there"
1216        );
1217    }
1218
1219    /// The rules were scoped to what spar posts, so nothing had ever asked an
1220    /// agent for anything about the comments it writes in the code. A three
1221    /// line change came back under eight lines of comment, most of it the
1222    /// debugging story rather than the reason.
1223    #[test]
1224    fn the_style_rules_reach_the_code_and_not_only_what_is_posted() {
1225        let lower = STYLE_RULES.to_lowercase();
1226        assert!(lower.contains("comments in code you write"), "not in scope");
1227        assert!(
1228            lower.contains("comment code for the reason"),
1229            "no rule for it"
1230        );
1231    }
1232
1233    // -- a failure said in the agent's own terms ---------------------------
1234
1235    /// The event stream from the run that prompted this: a wall of file
1236    /// contents a tool call returned, with the reason as the last two lines.
1237    fn refusal_stream() -> String {
1238        let noise = "{\"type\":\"item.completed\",\"item\":{\"id\":\"i\",\"type\":\"command_execution\",\"output\":\"".to_string()
1239            + &"const x = 1;\\n".repeat(200)
1240            + "\"}}";
1241        [
1242            noise.as_str(),
1243            r#"{"type":"error","message":"This content was flagged for possible cybersecurity risk."}"#,
1244            r#"{"type":"error","message":"This content was flagged for possible cybersecurity risk."}"#,
1245            r#"{"type":"turn.failed","error":{"message":"This content was flagged for possible cybersecurity risk."}}"#,
1246        ]
1247        .join("\n")
1248    }
1249
1250    fn jsonl_agent(name: &str) -> Agent {
1251        let mut spec = spec(vec![one("codex")]);
1252        spec.name = name.into();
1253        spec.output = OutputMode::Jsonl;
1254        spec.message_path = Some("item.text".into());
1255        Agent::with_bin(spec, "/fake/codex")
1256    }
1257
1258    fn failed(stdout: &str, stderr: &str) -> proc::Output {
1259        proc::Output {
1260            stdout: stdout.to_string(),
1261            stderr: stderr.to_string(),
1262            code: 1,
1263        }
1264    }
1265
1266    /// The reason a CLI gives is a field inside the event, not the event.
1267    /// Printing the object around it is what buried it.
1268    #[test]
1269    fn a_jsonl_failure_reports_the_reason_and_not_the_stream() {
1270        let agent = jsonl_agent("codex");
1271        let err = agent.call_failure(&["codex".to_string()], &failed(&refusal_stream(), ""));
1272        let text = err.message();
1273        assert!(
1274            text.contains("flagged for possible cybersecurity risk"),
1275            "{text}"
1276        );
1277        assert!(
1278            !text.contains("const x = 1;"),
1279            "the stream leaked in:\n{text}"
1280        );
1281        assert!(text.len() < 400, "still {} characters:\n{text}", text.len());
1282    }
1283
1284    /// One refusal reported as two errors and a turn.failed is one reason.
1285    #[test]
1286    fn the_same_reason_reported_three_times_is_said_once() {
1287        let agent = jsonl_agent("codex");
1288        let err = agent.call_failure(&["codex".to_string()], &failed(&refusal_stream(), ""));
1289        assert_eq!(
1290            1,
1291            err.message().matches("flagged for possible").count(),
1292            "{}",
1293            err.message()
1294        );
1295    }
1296
1297    /// stderr is where one CLI reports the condition behind the refusal, and it
1298    /// is short, so it survives whatever stdout turns out to hold.
1299    #[test]
1300    fn stderr_is_kept_because_it_is_where_the_other_half_arrives() {
1301        let agent = jsonl_agent("codex");
1302        let err = agent.call_failure(
1303            &["codex".to_string()],
1304            &failed(
1305                &refusal_stream(),
1306                "ERROR router: agent thread limit reached",
1307            ),
1308        );
1309        assert!(
1310            err.message().contains("agent thread limit reached"),
1311            "{}",
1312            err.message()
1313        );
1314    }
1315
1316    /// A CLI that dies without emitting an error event leaves nothing else to
1317    /// go on, so the raw dump is still what happens.
1318    #[test]
1319    fn a_stream_with_no_error_event_falls_back_to_the_raw_output() {
1320        let agent = jsonl_agent("codex");
1321        let err = agent.call_failure(
1322            &["codex".to_string()],
1323            &failed("{\"type\":\"system\"}", "segmentation fault"),
1324        );
1325        assert!(
1326            err.message().contains("segmentation fault"),
1327            "{}",
1328            err.message()
1329        );
1330        assert!(
1331            err.message().starts_with("command failed"),
1332            "{}",
1333            err.message()
1334        );
1335    }
1336
1337    /// A text agent has no events to read, so nothing changes for it.
1338    #[test]
1339    fn a_text_agent_is_reported_exactly_as_before() {
1340        let agent = agent(vec![one("mytool")]);
1341        let err = agent.call_failure(&["mytool".to_string()], &failed("some prose", "boom"));
1342        assert!(
1343            err.message().starts_with("command failed"),
1344            "{}",
1345            err.message()
1346        );
1347        assert!(err.message().contains("some prose"), "{}", err.message());
1348    }
1349
1350    /// Whatever the shape, it is still the CLI failing rather than answering
1351    /// badly, so it still goes straight to the stand in.
1352    #[test]
1353    fn a_reworded_failure_is_still_a_failed_call() {
1354        let agent = jsonl_agent("codex");
1355        let err = agent.call_failure(&["codex".to_string()], &failed(&refusal_stream(), ""));
1356        assert_eq!(ErrorKind::CallFailed, err.kind());
1357    }
1358
1359    // -- this run's own instructions --------------------------------------
1360
1361    #[test]
1362    fn a_request_carries_the_instructions_after_the_task() {
1363        let agent = Agent::with_bin(shell("a", "true"), "/bin/sh")
1364            .with_instructions("Do not wait for CI. Pick it up next pass.");
1365        let asked = agent.instructed("Review the changes on this branch.");
1366        assert!(
1367            asked.starts_with("Review the changes on this branch."),
1368            "{asked}"
1369        );
1370        assert!(asked.contains("Do not wait for CI"), "{asked}");
1371    }
1372
1373    /// A person adding an instruction should not be able to talk an agent out
1374    /// of the schema it was asked for, so where the instruction came from is
1375    /// said rather than left to read as part of the request.
1376    #[test]
1377    fn the_instructions_arrive_subordinate_to_the_request() {
1378        let agent = Agent::with_bin(shell("a", "true"), "/bin/sh").with_instructions("Be quick.");
1379        let asked = agent.instructed("Do the work.").to_lowercase();
1380        assert!(
1381            asked.contains("from the person who started this run"),
1382            "{asked}"
1383        );
1384        assert!(asked.contains("not the shape of your answer"), "{asked}");
1385    }
1386
1387    #[test]
1388    fn nothing_is_added_when_there_are_none() {
1389        let agent = Agent::with_bin(shell("a", "true"), "/bin/sh");
1390        assert_eq!("Do the work.", agent.instructed("Do the work."));
1391        // Whitespace is not an instruction.
1392        let blank = Agent::with_bin(shell("b", "true"), "/bin/sh").with_instructions("   \n  ");
1393        assert_eq!("Do the work.", blank.instructed("Do the work."));
1394    }
1395
1396    /// The stand in answers in this agent's place, so a run told not to wait on
1397    /// something must not start waiting the moment the primary hands over.
1398    #[test]
1399    fn the_stand_in_carries_them_too() {
1400        let agent = with_fallback(shell("primary", "true"), shell("backup", "true"))
1401            .with_instructions("Do not wait for CI.");
1402        let backup = agent.fallback().expect("a stand in");
1403        assert!(backup
1404            .instructed("Do the work.")
1405            .contains("Do not wait for CI."));
1406    }
1407
1408    // -- fallback --------------------------------------------------------
1409
1410    /// An agent whose command is a literal shell line, so a test can make the
1411    /// call succeed or fail on purpose.
1412    fn shell(name: &str, line: &str) -> AgentSpec {
1413        let mut spec = spec(vec![one("sh"), one("-c"), one(line)]);
1414        spec.name = name.into();
1415        spec
1416    }
1417
1418    fn with_fallback(mut primary: AgentSpec, backup: AgentSpec) -> Agent {
1419        primary.fallback = Some(Box::new(backup));
1420        Agent::with_bin(primary, "/bin/sh")
1421    }
1422
1423    #[test]
1424    fn a_failed_call_is_answered_by_the_fallback() {
1425        let agent = with_fallback(
1426            shell("primary", "echo refused >&2; exit 1"),
1427            shell("backup", "echo stood in"),
1428        );
1429        let answer = agent.ask("hi", Path::new("."), None).expect("fallback");
1430        assert_eq!("stood in", answer);
1431    }
1432
1433    #[test]
1434    fn without_a_fallback_the_original_error_is_what_surfaces() {
1435        let agent = Agent::with_bin(shell("primary", "echo refused >&2; exit 1"), "/bin/sh");
1436        let err = agent
1437            .ask("hi", Path::new("."), None)
1438            .expect_err("no backup");
1439        assert!(err.message().contains("refused"), "{err}");
1440    }
1441
1442    /// The reason the run stopped is the primary's, not the backup's, so it
1443    /// leads. A backup that is simply not installed explains nothing.
1444    #[test]
1445    fn both_failing_reports_the_primary_first() {
1446        let agent = with_fallback(
1447            shell("primary", "echo policy refusal >&2; exit 1"),
1448            shell("backup", "echo out of quota >&2; exit 1"),
1449        );
1450        let err = agent
1451            .ask("hi", Path::new("."), None)
1452            .expect_err("both fail");
1453        let text = err.message();
1454        let primary_at = text.find("policy refusal").expect("primary reason");
1455        let backup_at = text.find("out of quota").expect("backup reason");
1456        assert!(primary_at < backup_at, "{text}");
1457        assert!(
1458            text.contains("primary") && text.contains("backup"),
1459            "{text}"
1460        );
1461    }
1462
1463    /// A counter file, so a test can say how many times the CLI was actually
1464    /// run rather than only what came back.
1465    fn attempts(name: &str) -> (PathBuf, String) {
1466        let path = std::env::temp_dir().join(format!("spar-attempts-{name}"));
1467        let _ = std::fs::remove_file(&path);
1468        let line = format!("echo x >> {}", path.display());
1469        (path, line)
1470    }
1471
1472    fn counted(path: &Path) -> usize {
1473        std::fs::read_to_string(path)
1474            .map(|t| t.lines().count())
1475            .unwrap_or(0)
1476    }
1477
1478    /// The failure that prompted this. A policy refusal came back twice, at
1479    /// full effort, before the stand in was given the call. The second was
1480    /// never going to be different: nothing about a refusal, a quota, or a
1481    /// crash is corrected by being asked the same thing again.
1482    #[test]
1483    fn a_cli_that_could_not_answer_is_not_asked_twice_when_there_is_a_stand_in() {
1484        let (path, count) = attempts("refused-with-standin");
1485        let agent = with_fallback(
1486            shell("primary", &format!("{count}; echo refused >&2; exit 1")),
1487            shell("backup", "echo '{}'"),
1488        );
1489        let answer: Value = agent
1490            .ask_json(
1491                "q",
1492                &serde_json::json!({"type": "object"}),
1493                Path::new("."),
1494                None,
1495            )
1496            .expect("the stand in answers");
1497        assert!(answer.is_object());
1498        assert_eq!(1, counted(&path), "the primary was asked more than once");
1499    }
1500
1501    /// With nowhere to send the call, the retry is the only thing left, so it
1502    /// still happens. A transient failure is the case it was there for.
1503    #[test]
1504    fn with_no_stand_in_a_failed_call_is_still_retried() {
1505        let (path, count) = attempts("refused-alone");
1506        let agent = Agent::with_bin(
1507            shell("solo", &format!("{count}; echo refused >&2; exit 1")),
1508            "/bin/sh",
1509        );
1510        let err = agent
1511            .ask_json::<Value>(
1512                "q",
1513                &serde_json::json!({"type": "object"}),
1514                Path::new("."),
1515                None,
1516            )
1517            .expect_err("nothing answers");
1518        assert!(err.message().contains("twice"), "{err}");
1519        assert_eq!(2, counted(&path));
1520    }
1521
1522    /// The retry that must survive. An answer that arrived and could not be
1523    /// parsed is exactly what it is for, and a model corrects a shape error
1524    /// readily when handed the parser's complaint.
1525    #[test]
1526    fn an_unusable_answer_is_still_worth_asking_again() {
1527        let (path, count) = attempts("unparsable");
1528        let agent = with_fallback(
1529            shell("primary", &format!("{count}; echo not json at all")),
1530            shell("backup", "echo '{}'"),
1531        );
1532        let answer: Value = agent
1533            .ask_json(
1534                "q",
1535                &serde_json::json!({"type": "object"}),
1536                Path::new("."),
1537                None,
1538            )
1539            .expect("the stand in answers in the end");
1540        assert!(answer.is_object());
1541        assert_eq!(2, counted(&path), "a shape error is worth one more ask");
1542    }
1543
1544    /// A deadline is not worth asking the same CLI again, and `ask_json` does
1545    /// not. A different CLI is a different question, and the alternative is
1546    /// losing the run.
1547    #[test]
1548    fn a_timeout_still_reaches_the_fallback() {
1549        let mut primary = shell("primary", "sleep 30");
1550        primary.timeout = 1;
1551        let agent = with_fallback(primary, shell("backup", "echo stood in"));
1552        assert_eq!(
1553            "stood in",
1554            agent.ask("hi", Path::new("."), None).expect("fallback")
1555        );
1556    }
1557
1558    /// The fallback is built with the agent, not looked up later, so a spec
1559    /// that carries one produces an agent that carries one.
1560    #[test]
1561    fn the_fallback_is_built_alongside_the_agent() {
1562        let mut primary = shell("primary", "true");
1563        primary.fallback = Some(Box::new(shell("backup", "true")));
1564        let agent = Agent::new(primary);
1565        assert_eq!(Some("backup"), agent.fallback().map(Agent::name));
1566        assert!(Agent::new(shell("solo", "true")).fallback().is_none());
1567    }
1568}
1569
1570#[cfg(test)]
1571mod schema_placeholder_tests {
1572    use super::*;
1573    use crate::config::{OutputMode, SystemVia};
1574
1575    fn spec_with(command: Vec<CommandPart>) -> AgentSpec {
1576        AgentSpec {
1577            name: "claude".into(),
1578            command,
1579            model: None,
1580            effort: None,
1581            output: OutputMode::Text,
1582            message_match: BTreeMap::new(),
1583            message_path: None,
1584            search_paths: vec![],
1585            system_via: SystemVia::Prompt,
1586            timeout: 60,
1587            fallback: None,
1588            models: vec![],
1589            efforts: vec![],
1590            options_note: None,
1591        }
1592    }
1593
1594    fn one(s: &str) -> CommandPart {
1595        CommandPart::One(s.into())
1596    }
1597    fn group(parts: &[&str]) -> CommandPart {
1598        CommandPart::Group(parts.iter().map(|s| s.to_string()).collect())
1599    }
1600
1601    /// Claude Code takes the schema as an argument, not a path, so the file
1602    /// form alone was not enough to give it native structured output.
1603    #[test]
1604    fn either_schema_form_counts_as_native_support() {
1605        let inline = Agent::with_bin(
1606            spec_with(vec![one("x"), group(&["--json-schema", "{schema}"])]),
1607            "/b",
1608        );
1609        let byfile = Agent::with_bin(
1610            spec_with(vec![one("x"), group(&["--output-schema", "{schema_file}"])]),
1611            "/b",
1612        );
1613        let neither = Agent::with_bin(spec_with(vec![one("x"), one("{prompt}")]), "/b");
1614        assert!(inline.supports_schema());
1615        assert!(byfile.supports_schema());
1616        assert!(!neither.supports_schema());
1617    }
1618
1619    #[test]
1620    fn the_inline_schema_is_substituted_whole() {
1621        let agent = Agent::with_bin(
1622            spec_with(vec![
1623                one("x"),
1624                group(&["--json-schema", "{schema}"]),
1625                one("{prompt}"),
1626            ]),
1627            "/b",
1628        );
1629        let values = Placeholders {
1630            prompt: Some("review it".into()),
1631            schema: Some(r#"{"type":"object"}"#.into()),
1632            ..Default::default()
1633        };
1634        assert_eq!(
1635            vec!["/b", "--json-schema", r#"{"type":"object"}"#, "review it"],
1636            agent.render(&values).unwrap()
1637        );
1638    }
1639
1640    /// A call that wants prose back passes no schema, and the flag must go with
1641    /// it rather than being handed an empty string.
1642    #[test]
1643    fn the_schema_flag_drops_when_no_schema_is_wanted() {
1644        let agent = Agent::with_bin(
1645            spec_with(vec![
1646                one("x"),
1647                group(&["--json-schema", "{schema}"]),
1648                one("{prompt}"),
1649            ]),
1650            "/b",
1651        );
1652        let values = Placeholders {
1653            prompt: Some("implement it".into()),
1654            ..Default::default()
1655        };
1656        assert_eq!(vec!["/b", "implement it"], agent.render(&values).unwrap());
1657    }
1658
1659    /// `{schema_file}` must not be mistaken for `{schema}`.
1660    #[test]
1661    fn the_two_schema_placeholders_do_not_collide() {
1662        let agent = Agent::with_bin(
1663            spec_with(vec![one("x"), group(&["--output-schema", "{schema_file}"])]),
1664            "/b",
1665        );
1666        let values = Placeholders {
1667            schema: Some("INLINE".into()),
1668            schema_file: Some("/tmp/s.json".into()),
1669            ..Default::default()
1670        };
1671        assert_eq!(
1672            vec!["/b", "--output-schema", "/tmp/s.json"],
1673            agent.render(&values).unwrap()
1674        );
1675    }
1676
1677    /// The preset that was failing in the field.
1678    #[test]
1679    fn the_shipped_claude_preset_now_has_native_structured_output() {
1680        let raw = crate::config::load_preset("claude").unwrap();
1681        let table = raw.as_table().cloned().unwrap();
1682        let mut spec: AgentSpec = toml::Value::Table(table).try_into().unwrap();
1683        spec.name = "claude".into();
1684        assert!(
1685            Agent::with_bin(spec, "/b").supports_schema(),
1686            "without this a long review is parsed out of prose and truncates"
1687        );
1688    }
1689}