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