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::io::Write;
10use std::path::{Path, PathBuf};
11use std::sync::OnceLock;
12
13use serde_json::Value;
14
15use crate::config::{AgentSpec, CommandPart, OutputMode, SystemVia};
16use crate::error::{ErrorKind, Result, SparError};
17use crate::jsonx;
18use crate::proc::{self, ExecOpts};
19use crate::repo::{
20    attribute_state, git_state, ignored_untracked_state, probe_again_on_failure, safe_git_state,
21    uncertain_worktree_change, AttributeState, GitState, IgnoredState,
22    EDITED_GIT_MARKER as GIT_MARKER_RECOVERY,
23};
24use crate::{bail, log, logdim, logwarn, spar_err};
25
26/// Injected into every request.
27///
28/// Prompting alone is not sufficient, which is why the rules about what spar
29/// posts are also enforced deterministically on the way out; a model that was
30/// asked leaves the gate less to fix.
31///
32/// The rule about comments in the code is the exception, and it is worth being
33/// honest that it is one. Nothing can mechanically judge whether a comment
34/// earned its length, so that rule is only ever asked for. It is here rather
35/// than in the implement prompt because a reviewer that fixes a finding itself
36/// writes code too, and a rule that applies to one and not the other produces a
37/// file commented two ways.
38pub const STYLE_RULES: &str = "\
39Style rules for every artifact you produce (commits, PR titles, PR bodies, issue
40titles, issue bodies, review comments, and the comments in code you write):
41- Never use em-dashes or en-dashes. Use commas, colons, or parentheses.
42- Never mention Claude, Codex, OpenAI, ChatGPT, Anthropic, AI, or any tooling
43  used to produce the work.
44- Never add a Co-Authored-By trailer or a \"Generated with\" footer to commits.
45- Be brief. A human engineer with other work has to read this. Lead with the
46  point, cut the preamble, stop when you are done. Do not restate the task, do
47  not announce what you are about to do, do not summarise what the diff already
48  shows.
49- Brief means saying fewer things, never packing more into a sentence. Two
50  plain sentences beat one that has to be read twice. Split a sentence that
51  carries three facts, and split one that makes the reader hold an identifier
52  in their head to parse the rest of the clause. A comma splice joining two
53  ideas to save a full stop costs the reader more than the full stop would.
54- No headings, bullet lists, or bold text in anything only a few sentences long.
55- Comment code for the reason, not the change. A comment earns its length from
56  what the code cannot say for itself: a constraint that is not local, an
57  alternative that was tried and does not work, a surprise the next reader would
58  otherwise trip on. Write the reason that holds now, not the investigation that
59  found it. A paragraph above a three line change is almost always the debugging
60  story, and the reader wants the conclusion of it.
61Write as a human engineer would, because the reader neither knows nor cares what
62produced the work.";
63
64const JSON_INSTRUCTION: &str = "Respond with ONLY a JSON object matching this \
65schema. No prose, no markdown fences, no commentary before or after:";
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68enum Access {
69    Read,
70    Edit,
71}
72
73/// What a run's own instructions arrive under.
74///
75/// Subordinate on purpose. A person adding "do not wait for CI" should not be
76/// able to talk an agent out of the schema it was asked for, and a model told
77/// where an instruction came from weighs it against the request rather than
78/// over it.
79const INSTRUCTIONS_HEADER: &str = "Additional instructions from the person who \
80started this run. They change how you work, not what was asked for above and \
81not the shape of your answer:";
82
83pub struct Agent {
84    pub spec: AgentSpec,
85    /// Answers in this agent's place when it cannot answer at all. Never
86    /// alongside it: the pair is still two, and the fallback only ever holds
87    /// the turn the failed agent was already holding.
88    fallback: Option<Box<Agent>>,
89    /// Extra instructions for this run, carried onto every request.
90    instructions: Option<String>,
91    resolved: OnceLock<PathBuf>,
92}
93
94impl std::fmt::Debug for Agent {
95    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96        write!(f, "<{} {}>", self.spec.name, self.spec.describe())
97    }
98}
99
100/// What one call asks for, and what its stand in would ask for.
101///
102/// Two values rather than one because a fallback runs a different CLI, whose
103/// effort words are its own. Handing the primary's word to it is wrong, and
104/// dropping the schedule at the handover, which is what happened before, makes
105/// a fallback ignore a configuration it has its own answer for.
106#[derive(Debug, Clone, Default, PartialEq, Eq)]
107pub struct Effort {
108    pub primary: Option<String>,
109    pub fallback: Option<String>,
110    /// The kind of call, as the effort schedule names it. Carried here because
111    /// every call site already builds one of these, and the accounting needs to
112    /// know what it was asked, not only who was asked.
113    pub kind: &'static str,
114    /// The issue or pull request the call is about, when it is about one.
115    pub subject: Option<i64>,
116    /// Set on the second ask after an unusable answer, and on a stand in, so
117    /// the accounting can say which calls were not the first attempt.
118    retry: bool,
119    standing_in: bool,
120}
121
122impl Effort {
123    /// One value, for a call with no fallback to think about.
124    pub fn just(primary: Option<String>) -> Self {
125        Self {
126            primary,
127            ..Self::default()
128        }
129    }
130
131    /// Say which issue or pull request this call is about.
132    pub fn about(mut self, subject: i64) -> Self {
133        self.subject = Some(subject);
134        self
135    }
136
137    fn asked(&self) -> Option<&str> {
138        self.primary.as_deref()
139    }
140
141    /// What the stand in is asked for, once it is the one answering.
142    fn handed_over(&self) -> Effort {
143        Effort {
144            primary: self.fallback.clone(),
145            fallback: None,
146            kind: self.kind,
147            subject: self.subject,
148            retry: false,
149            standing_in: true,
150        }
151    }
152
153    /// For a log line naming what a call was asked for.
154    pub fn describe(&self) -> &str {
155        self.primary.as_deref().unwrap_or("default effort")
156    }
157
158    /// The same call, asked again after an unusable answer.
159    fn asked_again(&self) -> Effort {
160        Effort {
161            retry: true,
162            ..self.clone()
163        }
164    }
165}
166
167/// What the call was actually asked for, which is the schedule's word or the
168/// agent's own.
169fn values_effort(effort: &Effort, spec: &AgentSpec) -> String {
170    effort
171        .asked()
172        .map(str::to_string)
173        .or_else(|| spec.effort.clone())
174        .unwrap_or_default()
175}
176
177/// Tokens, where a CLI's event stream reports them.
178///
179/// Best effort by design: codex's `--json` carries a usage event and most CLIs
180/// carry nothing, so this reads the last total it can find and leaves the field
181/// blank otherwise rather than guessing.
182fn tokens_used(stdout: &str) -> Option<u64> {
183    let mut found = None;
184    for line in stdout.lines().rev().take(200) {
185        let line = line.trim();
186        if !line.starts_with('{') || !line.contains("token") {
187            continue;
188        }
189        let Ok(value) = serde_json::from_str::<Value>(line) else {
190            continue;
191        };
192        found = total_tokens(&value);
193        if found.is_some() {
194            break;
195        }
196    }
197    found
198}
199
200fn total_tokens(value: &Value) -> Option<u64> {
201    if let Some(total) = value.get("total_tokens").and_then(Value::as_u64) {
202        return Some(total);
203    }
204    match value {
205        Value::Object(fields) => fields.values().find_map(total_tokens),
206        _ => None,
207    }
208}
209
210impl Agent {
211    pub fn new(spec: AgentSpec) -> Self {
212        let fallback = spec
213            .fallback
214            .clone()
215            .map(|backup| Box::new(Agent::new(*backup)));
216        Self {
217            spec,
218            fallback,
219            instructions: None,
220            resolved: OnceLock::new(),
221        }
222    }
223
224    /// Carry this run's instructions, here and on the stand in.
225    ///
226    /// The fallback gets them too. It answers in this agent's place, so a run
227    /// told not to wait on something should not start waiting the moment the
228    /// primary hands over.
229    pub fn with_instructions(mut self, text: &str) -> Self {
230        let text = text.trim();
231        if text.is_empty() {
232            return self;
233        }
234        if let Some(backup) = self.fallback.take() {
235            self.fallback = Some(Box::new(backup.with_instructions(text)));
236        }
237        self.instructions = Some(text.to_string());
238        self
239    }
240
241    /// The request with this run's instructions after it.
242    ///
243    /// After, because the task is what the agent is doing and these modify how.
244    /// Before the schema, which `ask_json` appends afterwards, so the shape of
245    /// the answer stays the last thing read.
246    fn instructed(&self, prompt: &str) -> String {
247        match &self.instructions {
248            Some(extra) => format!("{prompt}\n\n{INSTRUCTIONS_HEADER}\n{extra}"),
249            None => prompt.to_string(),
250        }
251    }
252
253    pub fn name(&self) -> &str {
254        &self.spec.name
255    }
256
257    /// The stand in, if one is configured.
258    pub fn fallback(&self) -> Option<&Agent> {
259        self.fallback.as_deref()
260    }
261
262    /// The program the template names, before any resolution. What somebody
263    /// has to install when spar reports it missing.
264    pub fn program(&self) -> &str {
265        match self.spec.command.first() {
266            Some(CommandPart::One(program)) => program,
267            _ => self.name(),
268        }
269    }
270
271    /// The environment variable that points this agent's binary somewhere else.
272    pub fn env_key(&self) -> String {
273        format!(
274            "SPAR_{}_BIN",
275            self.spec.name.to_uppercase().replace('-', "_")
276        )
277    }
278
279    /// Used by the tests, and by `doctor` when it wants to report a path it
280    /// already knows.
281    #[doc(hidden)]
282    pub fn with_bin(spec: AgentSpec, bin: impl Into<PathBuf>) -> Self {
283        let agent = Self::new(spec);
284        let _ = agent.resolved.set(bin.into());
285        agent
286    }
287
288    // -- binary resolution -------------------------------------------------
289
290    /// `SPAR_<NAME>_BIN` first, then the template's own program name on PATH or
291    /// as an absolute path, then the preset's search paths. Never guess
292    /// silently: a miss reports every location tried, because a tool that
293    /// quietly runs the wrong binary is worse than one that fails.
294    pub fn resolve_bin(&self) -> Result<&Path> {
295        if let Some(found) = self.resolved.get() {
296            return Ok(found.as_path());
297        }
298        let found = self.locate()?;
299        let _ = self.resolved.set(found);
300        Ok(self.resolved.get().expect("just set").as_path())
301    }
302
303    fn locate(&self) -> Result<PathBuf> {
304        let wanted = match self.spec.command.first() {
305            Some(CommandPart::One(program)) => program.clone(),
306            _ => bail!("agent '{}' has no command configured", self.spec.name),
307        };
308
309        let env_key = self.env_key();
310        let env_override = std::env::var(&env_key)
311            .ok()
312            .filter(|v| !v.trim().is_empty());
313
314        let mut tried: Vec<String> = Vec::new();
315
316        for candidate in env_override
317            .iter()
318            .map(String::as_str)
319            .chain([wanted.as_str()])
320        {
321            let path = Path::new(candidate);
322            if path.is_absolute() || candidate.contains(std::path::MAIN_SEPARATOR) {
323                let expanded = proc::expand_tilde(candidate);
324                tried.push(expanded.display().to_string());
325                if proc::is_executable(&expanded) {
326                    return Ok(expanded);
327                }
328            } else {
329                tried.push(format!("{candidate} (PATH)"));
330                if let Some(found) = proc::which(candidate) {
331                    return Ok(found);
332                }
333            }
334        }
335
336        for base in &self.spec.search_paths {
337            let base = proc::expand_tilde(base);
338            let candidate = if base.file_name().and_then(|n| n.to_str()) == Some(wanted.as_str()) {
339                base
340            } else {
341                base.join(&wanted)
342            };
343            tried.push(candidate.display().to_string());
344            if proc::is_executable(&candidate) {
345                return Ok(candidate);
346            }
347        }
348
349        Err(spar_err!(
350            "could not find the binary for agent '{}'. Tried:\n  {}\nSet agents.{}.command[0] to \
351             an absolute path, or {}=/path/to/binary.",
352            self.spec.name,
353            tried.join("\n  "),
354            self.spec.name,
355            env_key
356        ))
357    }
358
359    // -- command rendering -------------------------------------------------
360
361    /// Substitute placeholders. A group whose placeholder is unset is dropped
362    /// whole, so omitting `model` drops `--model` with it rather than passing
363    /// an empty string that the CLI would reject or, worse, accept.
364    pub fn render(&self, values: &Placeholders) -> Result<Vec<String>> {
365        let mut out = vec![self.resolve_bin()?.display().to_string()];
366        for part in self.spec.command.iter().skip(1) {
367            let mut rendered = Vec::new();
368            let mut skip = false;
369            for arg in part.args() {
370                match values.substitute(arg) {
371                    Some(text) => rendered.push(text),
372                    None => {
373                        skip = true;
374                        break;
375                    }
376                }
377            }
378            if !skip {
379                out.extend(rendered);
380            }
381        }
382        Ok(out)
383    }
384
385    /// True when the template has somewhere to put a schema, meaning the CLI can
386    /// do structured output natively rather than being asked in the prompt.
387    ///
388    /// Either form counts: a path for a CLI that reads the schema from disk, or
389    /// the schema itself for one that takes it as an argument.
390    pub fn supports_schema(&self) -> bool {
391        self.spec
392            .command
393            .iter()
394            .flat_map(|p| p.args())
395            .any(|a| a.contains("{schema_file}") || a.contains("{schema}"))
396    }
397
398    // -- output adapters ---------------------------------------------------
399
400    pub fn extract(&self, stdout: &str) -> Result<String> {
401        match self.spec.output {
402            OutputMode::Text | OutputMode::Json => Ok(stdout.trim().to_string()),
403            OutputMode::Jsonl => self.extract_jsonl(stdout),
404        }
405    }
406
407    fn extract_jsonl(&self, stdout: &str) -> Result<String> {
408        let mut messages: Vec<String> = Vec::new();
409
410        for line in stdout.lines() {
411            let line = line.trim();
412            if !line.starts_with('{') {
413                continue;
414            }
415            let Ok(event) = serde_json::from_str::<Value>(line) else {
416                continue;
417            };
418            if matches(&event, &self.spec.message_match) {
419                if let Some(text) = dig(&event, self.spec.message_path.as_deref().unwrap_or("")) {
420                    if let Some(text) = as_text(text) {
421                        messages.push(text);
422                    }
423                }
424            }
425        }
426
427        if messages.is_empty() {
428            let reasons = self.error_events(stdout);
429            if !reasons.is_empty() {
430                // The CLI reported failure rather than answering badly, so this
431                // is not something asking again corrects.
432                return Err(SparError::call_failed(format!(
433                    "agent '{}' failed: {}",
434                    self.spec.name,
435                    reasons.join("; ")
436                )));
437            }
438        }
439        Ok(messages.join("\n").trim().to_string())
440    }
441
442    /// Why an event stream says it failed, in words rather than as JSON.
443    ///
444    /// The reason a CLI gives is a field inside the event, not the event, and
445    /// printing the object around it is what made a failure unreadable. Two
446    /// shapes cover what the CLIs here emit: a `message` on the event, and a
447    /// `message` on an `error` inside it.
448    ///
449    /// Deduplicated, because one refusal reported as an `error` twice and a
450    /// `turn.failed` once is one reason and not three.
451    fn error_events(&self, stdout: &str) -> Vec<String> {
452        let mut reasons: Vec<String> = Vec::new();
453        for line in stdout.lines() {
454            let line = line.trim();
455            if !line.starts_with('{') {
456                continue;
457            }
458            let Ok(event) = serde_json::from_str::<Value>(line) else {
459                continue;
460            };
461            if !matches!(
462                event.get("type").and_then(Value::as_str),
463                Some("turn.failed") | Some("error")
464            ) {
465                continue;
466            }
467            let reason = dig(&event, "message")
468                .or_else(|| dig(&event, "error.message"))
469                .and_then(as_text)
470                .unwrap_or_else(|| truncate(&event.to_string(), 400));
471            if !reason.trim().is_empty() && !reasons.contains(&reason) {
472                reasons.push(reason);
473            }
474        }
475        reasons
476    }
477
478    /// Why the call failed, said in the agent's own terms.
479    ///
480    /// For a `jsonl` agent the streams are an event log, and `proc` tailing
481    /// 1500 characters of one starts mid object: the reason is in there, after
482    /// a thousand characters of whatever a tool call happened to return. The
483    /// adapter already knows how to find the error events, so it finds them
484    /// here too and the raw dump is what happens when there are none.
485    ///
486    /// stderr is kept either way. It is short, and it is where one CLI reports
487    /// the condition that led to the refusal while the refusal itself goes to
488    /// stdout.
489    fn call_failure(&self, argv: &[String], out: &proc::Output) -> SparError {
490        if self.spec.output != OutputMode::Jsonl {
491            return SparError::call_failed(proc::failure_message(argv, out));
492        }
493        let reasons = self.error_events(&out.stdout);
494        if reasons.is_empty() {
495            return SparError::call_failed(proc::failure_message(argv, out));
496        }
497        let mut text = format!(
498            "agent '{}' could not answer (exit {}): {}",
499            self.spec.name,
500            out.code,
501            reasons.join("; ")
502        );
503        let stderr = out.stderr.trim();
504        if !stderr.is_empty() {
505            text.push_str(&format!("\n--- stderr ---\n{stderr}"));
506        }
507        text.push_str(&format!("\n--- command ---\n{}", proc::abbreviate(argv)));
508        SparError::call_failed(text)
509    }
510
511    // -- the two operations everything else is built from -------------------
512
513    pub fn ask(&self, prompt: &str, cwd: &Path, effort: &Effort) -> Result<String> {
514        let baseline = EditBaseline::capture(cwd)?;
515        self.ask_with_access(prompt, cwd, effort, Access::Read, Some(&baseline))
516    }
517
518    /// Ask for a call that may modify the working tree.
519    ///
520    /// The caller commits accepted edits after validating the response.
521    pub fn edit(&self, prompt: &str, cwd: &Path, effort: &Effort) -> Result<String> {
522        let baseline = EditBaseline::capture(cwd)?;
523        self.ask_with_access(prompt, cwd, effort, Access::Edit, Some(&baseline))
524    }
525
526    fn ask_with_access(
527        &self,
528        prompt: &str,
529        cwd: &Path,
530        effort: &Effort,
531        access: Access,
532        baseline: Option<&EditBaseline>,
533    ) -> Result<String> {
534        let prompt = &self.instructed(prompt);
535        let result = match self.ask_inner(prompt, cwd, effort, None, None, access) {
536            Ok(text) => Ok(text),
537            Err(e) => match recovery_error(&e, baseline, cwd, access) {
538                Some(recovery) => Err(recovery),
539                None => self.hand_over(e, |backup| {
540                    backup.ask_with_access(prompt, cwd, &effort.handed_over(), access, baseline)
541                }),
542            },
543        };
544        finish_call(result, access, baseline, cwd)
545    }
546
547    /// What this agent, and its stand in, are asked for on one call.
548    ///
549    /// Both at once, because the fallback runs a different CLI: its effort word
550    /// has to come from its own configuration rather than from the agent that
551    /// just failed.
552    pub fn effort(&self, cfg: &crate::config::Config, call: crate::config::Call) -> Effort {
553        Effort {
554            primary: cfg.effort_for(&self.spec, call),
555            fallback: self
556                .spec
557                .fallback
558                .as_deref()
559                .and_then(|spec| cfg.effort_for(spec, call)),
560            kind: call.key(),
561            subject: None,
562            retry: false,
563            standing_in: false,
564        }
565    }
566
567    /// Give a failed call to the fallback, if there is one.
568    ///
569    /// Every failure qualifies, a deadline included. Asking the same CLI again
570    /// after a timeout buys another wait of the same length for the same
571    /// answer, which is why `ask_json` does not; asking a different CLI is a
572    /// different question, and the alternative here is losing the run.
573    ///
574    /// The primary's effort word is never passed on: effort words are each
575    /// CLI's own vocabulary, and the one in hand belongs to the agent that just
576    /// failed. What the fallback is asked for is resolved from its own
577    /// configuration for this same call, so a schedule reaches a stand in
578    /// rather than being dropped at the handover.
579    fn hand_over<T>(&self, primary: SparError, run: impl FnOnce(&Agent) -> Result<T>) -> Result<T> {
580        let Some(backup) = self.fallback() else {
581            return Err(primary);
582        };
583        logwarn!(
584            "{} could not answer. Handing the call to {}.\n{primary}",
585            self.name(),
586            backup.name()
587        );
588        match run(backup) {
589            Ok(answer) => {
590                log!("{} answered in place of {}", backup.name(), self.name());
591                Ok(answer)
592            }
593            // Both messages, primary first. The fallback's failure is usually
594            // the less interesting of the two, and is often just "not
595            // installed", which explains nothing about why the run stopped.
596            Err(second) => {
597                let message = format!(
598                    "agent '{}' failed and its fallback '{}' could not stand in.\n{}\n\n{}: {}",
599                    self.name(),
600                    backup.name(),
601                    primary.message(),
602                    backup.name(),
603                    second.message()
604                );
605                Err(second.with_message(message))
606            }
607        }
608    }
609
610    fn ask_inner(
611        &self,
612        prompt: &str,
613        cwd: &Path,
614        effort: &Effort,
615        schema_file: Option<&Path>,
616        schema: Option<&str>,
617        access: Access,
618    ) -> Result<String> {
619        let body = match self.spec.system_via {
620            SystemVia::Placeholder => prompt.to_string(),
621            SystemVia::Prompt => format!("{STYLE_RULES}\n\n{prompt}"),
622        };
623        let values = Placeholders {
624            prompt: Some(body),
625            system: Some(STYLE_RULES.to_string()),
626            model: self.spec.model.clone(),
627            effort: effort
628                .asked()
629                .map(str::to_string)
630                .or_else(|| self.spec.effort.clone()),
631            cwd: Some(cwd.display().to_string()),
632            schema_file: schema_file.map(|p| p.display().to_string()),
633            schema: schema.map(str::to_string),
634        };
635        let argv = self.render(&values)?;
636        // `check(false)` so the whole output is still in hand when the call
637        // fails: `proc::run` would hand back a tail of it as a message, and a
638        // tail of an event stream is the part this agent can read least.
639        let opts = ExecOpts::new()
640            .cwd(cwd)
641            .timeout_secs(self.spec.timeout)
642            .stop_descendants(true)
643            .check(false);
644        // Supported review commands can still write despite being asked not
645        // to, so linked-worktree marker recovery applies to every agent call.
646        let git_file = match access {
647            Access::Read | Access::Edit => GitFile::capture(cwd)?,
648        };
649        let started = std::time::Instant::now();
650        let called = proc::exec(&argv, &opts);
651        // Recorded whatever happened, including the failures: a run whose cost
652        // was two timeouts and a hand-over is a run whose report should say so.
653        crate::spend::record(crate::spend::Spent {
654            agent: self.name().to_string(),
655            kind: effort.kind.to_string(),
656            effort: values_effort(effort, &self.spec),
657            subject: effort.subject,
658            seconds: started.elapsed().as_secs_f64(),
659            retry: effort.retry,
660            fallback: effort.standing_in,
661            tokens: called
662                .as_ref()
663                .ok()
664                .and_then(|out| tokens_used(&out.stdout)),
665            ok: called.as_ref().is_ok_and(|out| out.ok()),
666        });
667        after_call_is_quiet(&called, || {
668            if let Some(git_file) = git_file {
669                git_file.restore_if_changed(cwd)?;
670            }
671            Ok(())
672        })?;
673        let out = called?;
674        if !out.ok() {
675            return Err(self.call_failure(&argv, &out));
676        }
677        self.extract(&out.stdout)
678    }
679
680    /// Structured output through the CLI's own mechanism when the template
681    /// exposes one, otherwise by asking for JSON in the prompt and parsing it
682    /// back out.
683    pub fn ask_json<T: serde::de::DeserializeOwned>(
684        &self,
685        prompt: &str,
686        schema: &Value,
687        cwd: &Path,
688        effort: &Effort,
689    ) -> Result<T> {
690        let baseline = EditBaseline::capture(cwd)?;
691        self.ask_json_with_access(prompt, schema, cwd, effort, Access::Read, Some(&baseline))
692    }
693
694    /// Ask for a structured call that may modify the working tree.
695    ///
696    /// The caller commits accepted edits after validating the response.
697    pub fn edit_json<T: serde::de::DeserializeOwned>(
698        &self,
699        prompt: &str,
700        schema: &Value,
701        cwd: &Path,
702        effort: &Effort,
703    ) -> Result<T> {
704        let baseline = EditBaseline::capture(cwd)?;
705        self.ask_json_with_access(prompt, schema, cwd, effort, Access::Edit, Some(&baseline))
706    }
707
708    fn ask_json_with_access<T: serde::de::DeserializeOwned>(
709        &self,
710        prompt: &str,
711        schema: &Value,
712        cwd: &Path,
713        effort: &Effort,
714        access: Access,
715        baseline: Option<&EditBaseline>,
716    ) -> Result<T> {
717        // Once here, not inside the retry, so the second ask carries the same
718        // instructions as the first alongside the parser's complaint.
719        let prompt = &self.instructed(prompt);
720        let result = match self.ask_json_retrying(prompt, schema, cwd, effort, access, baseline) {
721            Ok(parsed) => Ok(parsed),
722            Err(e) => match recovery_error(&e, baseline, cwd, access) {
723                Some(recovery) => Err(recovery),
724                None => self.hand_over(e, |backup| {
725                    backup.ask_json_retrying::<T>(
726                        prompt,
727                        schema,
728                        cwd,
729                        &effort.handed_over(),
730                        access,
731                        baseline,
732                    )
733                }),
734            },
735        };
736        finish_call(result, access, baseline, cwd)
737    }
738
739    /// Whether to spend a second call on this same agent.
740    ///
741    /// The retry exists for an answer that arrived and could not be parsed.
742    /// Models correct a shape error readily when told what was wrong, which is
743    /// why the parser's own complaint goes back with the question.
744    ///
745    /// Two failures are not that. A deadline never is: the wait is the same
746    /// length for the same answer. And a failure the CLI itself reported is not
747    /// either, once there is a stand in to send the call to, because a
748    /// different CLI is a different question while the same one twice is a
749    /// refusal, a quota, or a crash repeated at full price. With no stand in
750    /// configured the retry is the only thing left, so it still happens.
751    fn worth_asking_again(&self, e: &SparError) -> bool {
752        match e.kind() {
753            ErrorKind::TimedOut => false,
754            ErrorKind::UncertainWrite => false,
755            ErrorKind::CallFailed => self.fallback().is_none(),
756            ErrorKind::Other => true,
757        }
758    }
759
760    /// The same question, asked at most twice of this agent alone.
761    fn ask_json_retrying<T: serde::de::DeserializeOwned>(
762        &self,
763        prompt: &str,
764        schema: &Value,
765        cwd: &Path,
766        effort: &Effort,
767        access: Access,
768        baseline: Option<&EditBaseline>,
769    ) -> Result<T> {
770        // One retry, with the parser's own complaint handed back.
771        //
772        // A single malformed answer used to cost half a review: the other agent
773        // carried on alone, which is the one thing this design exists to avoid.
774        // Models correct a shape error readily when told what was wrong.
775        const ATTEMPTS: usize = 2;
776        let mut last: Option<SparError> = None;
777
778        for attempt in 1..=ATTEMPTS {
779            let asked = match &last {
780                None => prompt.to_string(),
781                Some(e) => format!(
782                    "{prompt}\n\nYour previous answer could not be used: {}\nReturn the whole \
783                     object this time, exactly matching the schema, and nothing else.",
784                    e.first_line()
785                ),
786            };
787            let effort = if attempt > 1 {
788                effort.asked_again()
789            } else {
790                effort.clone()
791            };
792            match self.ask_json_once::<T>(&asked, schema, cwd, &effort, access) {
793                Ok(parsed) => {
794                    if attempt > 1 {
795                        logdim!("{} answered on the retry", self.spec.name);
796                    }
797                    return Ok(parsed);
798                }
799                // A deadline is not a bad answer. Asking again buys another
800                // wait of exactly the same length, which on a long review is
801                // the most expensive way to learn nothing.
802                Err(e) => {
803                    if let Some(recovery) = recovery_error(&e, baseline, cwd, access) {
804                        return Err(recovery);
805                    }
806                    if !self.worth_asking_again(&e) {
807                        return Err(e);
808                    }
809                    if attempt < ATTEMPTS {
810                        // The whole error, not its first line. The first line is
811                        // the command; the reason is in the stderr underneath
812                        // it, and printing only the first line made a retry
813                        // impossible to diagnose from the log.
814                        logwarn!("{} failed, asking again.\n{e}", self.spec.name);
815                    }
816                    last = Some(e);
817                }
818            }
819        }
820        Err(spar_err!(
821            "agent '{}' returned an unusable answer twice: {}",
822            self.spec.name,
823            last.expect("at least one attempt").message()
824        ))
825    }
826
827    fn ask_json_once<T: serde::de::DeserializeOwned>(
828        &self,
829        prompt: &str,
830        schema: &Value,
831        cwd: &Path,
832        effort: &Effort,
833        access: Access,
834    ) -> Result<T> {
835        let text = if self.supports_schema() {
836            let inline = serde_json::to_string(schema).unwrap_or_default();
837            let file = TempJson::write(schema)?;
838            self.ask_inner(
839                prompt,
840                cwd,
841                effort,
842                Some(file.path()),
843                Some(&inline),
844                access,
845            )?
846        } else {
847            let full = format!(
848                "{prompt}\n\n{JSON_INSTRUCTION}\n{}",
849                serde_json::to_string_pretty(schema).unwrap_or_default()
850            );
851            self.ask_inner(&full, cwd, effort, None, None, access)?
852        };
853        jsonx::extract_into(&text)
854    }
855
856    /// Review the branch against `base`.
857    ///
858    /// Deliberately generic. `codex exec review` was tried and rejected: it
859    /// refuses a custom prompt alongside `--base` and returns prose regardless
860    /// of `--output-schema`, so it cannot yield a machine checkable verdict.
861    /// Running inside the worktree is what makes an agent repo aware, not a
862    /// subcommand.
863    ///
864    /// The call has write access, so the paragraph saying not to write is not
865    /// decoration: an agent that commits while reviewing ends up holding the
866    /// head it is about to be handed back, which is the one thing the
867    /// alternating loop exists to prevent. `review::review_loop` rolls back
868    /// what this asks for anyway, because a prompt is not a permission.
869    pub fn review<T: serde::de::DeserializeOwned>(
870        &self,
871        base: &str,
872        prompt: &str,
873        schema: &Value,
874        cwd: &Path,
875        effort: &Effort,
876    ) -> Result<T> {
877        let scoped = format!(
878            "{prompt}\n\nThe changes under review are the diff between `{base}` and HEAD in your \
879             working directory. Inspect them with git, then read the surrounding code before \
880             judging. Do not review only the diff.\n\nThis call is a review and nothing else. Do \
881             not edit the code under review, do not commit, and do not push: somebody else acts \
882             on what you find, and a reviewer that writes ends up reviewing its own work. Put any \
883             scratch file under the system temporary directory, not in the working tree."
884        );
885        self.ask_json(&scoped, schema, cwd, effort)
886    }
887}
888
889/// The repository state before a logical editing operation begins.
890///
891/// This is recovery tracking, not an operating-system security boundary. It
892/// prevents a failed call, retry, or fallback from silently building on work
893/// the same operation already left behind.
894struct EditBaseline {
895    attributes: AttributeState,
896    git_state: GitState,
897    git_entry: GitEntry,
898    ignored_untracked: IgnoredState,
899}
900
901impl EditBaseline {
902    fn capture(cwd: &Path) -> Result<Self> {
903        let git_entry = GitEntry::capture(cwd)?;
904        if matches!(&git_entry, GitEntry::File) {
905            ensure_recovery_path_clear(cwd)?;
906        }
907        let attributes = attribute_state(cwd).map_err(|e| {
908            e.with_message(format!(
909                "could not record attribute files before a call in {}: {}",
910                cwd.display(),
911                e.last_line()
912            ))
913        })?;
914        let git_state = safe_git_state(cwd).map_err(|e| {
915            e.with_message(format!(
916                "could not record a safe Git state before editing {}: {}",
917                cwd.display(),
918                e.last_line()
919            ))
920        })?;
921        let ignored_untracked = ignored_untracked_state(cwd).map_err(|e| {
922            e.with_message(format!(
923                "could not record ignored files before editing {}: {}",
924                cwd.display(),
925                e.last_line()
926            ))
927        })?;
928        Ok(Self {
929            attributes,
930            git_state,
931            git_entry,
932            ignored_untracked,
933        })
934    }
935
936    fn recovery_needed(&self, cwd: &Path, access: Access) -> Result<bool> {
937        if !self.git_entry.still_matches(cwd)? {
938            return Err(uncertain_worktree_change(
939                cwd,
940                format!(
941                    "the Git entry at {} changed type during the editing call. No Git recovery \
942                     probe was run. Inspect the worktree before retrying.",
943                    cwd.join(".git").display()
944                ),
945            ));
946        }
947        let attributes = probe_again_on_failure(|| attribute_state(cwd)).map_err(|e| {
948            uncertain_worktree_change(
949                cwd,
950                format!(
951                    "could not check attribute files after a call in {}: {}. Inspect the \
952                     worktree before retrying.",
953                    cwd.display(),
954                    e.last_line()
955                ),
956            )
957        })?;
958        if attributes != self.attributes {
959            return Err(uncertain_worktree_change(
960                cwd,
961                format!(
962                    "an attribute file changed during a call in {}. The worktree was kept before \
963                     running any Git operation that could select a new filter.",
964                    cwd.display()
965                ),
966            ));
967        }
968        let current = probe_again_on_failure(|| git_state(cwd))
969            .map_err(|e| recovery_probe_error(cwd, "state", &e))?;
970        let ignored = probe_again_on_failure(|| ignored_untracked_state(cwd))
971            .map_err(|e| recovery_probe_error(cwd, "ignored files", &e))?;
972        // A call asked only to read is judged on what it left behind, and the
973        // project's own ignore rules say which of that is build output. An
974        // editing call is held to the stricter rule, because what it writes is
975        // about to be staged.
976        let untracked = match access {
977            Access::Read => self.ignored_untracked.changed_beyond_ignored(&ignored),
978            Access::Edit => self.ignored_untracked.changed_beyond_generated(&ignored),
979        };
980        Ok(current != self.git_state || untracked)
981    }
982}
983
984#[derive(Clone, PartialEq, Eq)]
985enum GitEntry {
986    Directory(GitDirectory),
987    File,
988}
989
990#[derive(Clone, PartialEq, Eq)]
991struct GitDirectory {
992    #[cfg(unix)]
993    device: u64,
994    #[cfg(unix)]
995    inode: u64,
996    #[cfg(not(unix))]
997    created: Option<std::time::SystemTime>,
998}
999
1000impl GitEntry {
1001    fn capture(cwd: &Path) -> Result<Self> {
1002        let path = cwd.join(".git");
1003        match std::fs::symlink_metadata(&path) {
1004            Ok(meta) if meta.is_dir() => {
1005                #[cfg(unix)]
1006                {
1007                    use std::os::unix::fs::MetadataExt;
1008                    Ok(Self::Directory(GitDirectory {
1009                        device: meta.dev(),
1010                        inode: meta.ino(),
1011                    }))
1012                }
1013                #[cfg(not(unix))]
1014                {
1015                    Ok(Self::Directory(GitDirectory {
1016                        created: meta.created().ok(),
1017                    }))
1018                }
1019            }
1020            Ok(meta) if meta.is_file() => Ok(Self::File),
1021            Ok(_) => bail!(
1022                "{} is not a regular Git directory or marker",
1023                path.display()
1024            ),
1025            Err(e) => Err(spar_err!("could not inspect {}: {e}", path.display())),
1026        }
1027    }
1028
1029    fn still_matches(&self, cwd: &Path) -> Result<bool> {
1030        let path = cwd.join(".git");
1031        match (self, std::fs::symlink_metadata(path)) {
1032            (Self::Directory(before), Ok(meta)) if meta.is_dir() => {
1033                #[cfg(unix)]
1034                {
1035                    use std::os::unix::fs::MetadataExt;
1036                    Ok(before.device == meta.dev() && before.inode == meta.ino())
1037                }
1038                #[cfg(not(unix))]
1039                {
1040                    Ok(before.created == meta.created().ok())
1041                }
1042            }
1043            (Self::Directory(_), Ok(_)) => Ok(false),
1044            (Self::File, Ok(meta)) => Ok(meta.is_file()),
1045            (_, Err(e)) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
1046            (_, Err(e)) => Err(SparError::uncertain_write(format!(
1047                "could not inspect the Git entry at {} after the editing call: {e}",
1048                cwd.join(".git").display()
1049            ))),
1050        }
1051    }
1052}
1053
1054fn recovery_probe_error(cwd: &Path, probe: &str, error: &SparError) -> SparError {
1055    uncertain_worktree_change(
1056        cwd,
1057        format!(
1058            "could not check the Git {probe} after an editing call in {}: {}. Inspect the \
1059             worktree before retrying.",
1060            cwd.display(),
1061            error.last_line()
1062        ),
1063    )
1064}
1065
1066fn recovery_error(
1067    error: &SparError,
1068    baseline: Option<&EditBaseline>,
1069    cwd: &Path,
1070    access: Access,
1071) -> Option<SparError> {
1072    // A marker restoration failure must not be followed by a Git command. The
1073    // marker is exactly what Git would use to choose its metadata and config.
1074    if error.kind() == ErrorKind::UncertainWrite {
1075        return Some(error.clone());
1076    }
1077    let baseline = baseline?;
1078    match baseline.recovery_needed(cwd, access) {
1079        Ok(false) => None,
1080        Ok(true) if access == Access::Edit => Some(changed_edit_failure(error)),
1081        Ok(true) => Some(uncertain_worktree_change(
1082            cwd,
1083            format!(
1084                "{}\nA read-only call changed the worktree before it failed. Its answer was \
1085                 discarded and the worktree was kept for recovery.",
1086                error.message()
1087            ),
1088        )),
1089        Err(recovery) => Some(SparError::uncertain_write(format!(
1090            "{}\n{}",
1091            error.message(),
1092            recovery.message()
1093        ))),
1094    }
1095}
1096
1097fn changed_edit_failure(error: &SparError) -> SparError {
1098    const NOTE: &str =
1099        "The call changed the worktree before it failed. It was not retried or handed to a fallback.";
1100    if error.message().contains(NOTE) {
1101        return error.clone();
1102    }
1103    error.with_message(format!("{}\n{NOTE}", error.message()))
1104}
1105
1106fn finish_call<T>(
1107    result: Result<T>,
1108    access: Access,
1109    baseline: Option<&EditBaseline>,
1110    cwd: &Path,
1111) -> Result<T> {
1112    let value = result?;
1113    let Some(baseline) = baseline else {
1114        return Ok(value);
1115    };
1116    if access == Access::Edit {
1117        if baseline.git_entry.still_matches(cwd)? {
1118            return Ok(value);
1119        }
1120        return Err(uncertain_worktree_change(
1121            cwd,
1122            format!(
1123                "the Git entry at {} was replaced during an editing call. The result was \
1124                 discarded before any Git operation ran.",
1125                cwd.join(".git").display()
1126            ),
1127        ));
1128    }
1129    match baseline.recovery_needed(cwd, access) {
1130        Ok(false) => Ok(value),
1131        Ok(true) => Err(uncertain_worktree_change(
1132            cwd,
1133            "a read-only call changed the worktree. Its answer was discarded and the worktree \
1134             was kept for recovery.",
1135        )),
1136        Err(error) => Err(error),
1137    }
1138}
1139
1140/// The original linked-worktree marker for one editing attempt.
1141///
1142/// Restoring this file makes an accidental marker edit recoverable. It does not
1143/// confine the child or remove any Git metadata authority the child already has.
1144struct GitFile {
1145    bytes: Vec<u8>,
1146}
1147
1148impl GitFile {
1149    fn capture(cwd: &Path) -> Result<Option<Self>> {
1150        let path = cwd.join(".git");
1151        match std::fs::symlink_metadata(&path) {
1152            Ok(meta) if meta.is_dir() => Ok(None),
1153            Ok(meta) if meta.is_file() => {
1154                ensure_recovery_path_clear(cwd)?;
1155                let bytes = std::fs::read(&path)
1156                    .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
1157                Ok(Some(Self { bytes }))
1158            }
1159            Ok(_) => bail!(
1160                "{} is not a regular Git marker. Refusing to run an editing call.",
1161                path.display()
1162            ),
1163            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
1164            Err(e) => Err(spar_err!("could not inspect {}: {e}", path.display())),
1165        }
1166    }
1167
1168    fn restore_if_changed(self, cwd: &Path) -> Result<()> {
1169        let path = cwd.join(".git");
1170        let unchanged = std::fs::symlink_metadata(&path)
1171            .ok()
1172            .filter(|meta| meta.is_file())
1173            .and_then(|_| std::fs::read(&path).ok())
1174            .is_some_and(|bytes| bytes == self.bytes);
1175        let recovery = cwd.join(GIT_MARKER_RECOVERY);
1176        if unchanged {
1177            return match std::fs::symlink_metadata(&recovery) {
1178                Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
1179                Ok(_) => Err(SparError::uncertain_write(format!(
1180                    "the agent call created the reserved recovery path at {}. The Git marker was \
1181                     unchanged. Inspect or remove the recovery path before retrying.",
1182                    recovery.display()
1183                ))),
1184                Err(e) => Err(SparError::uncertain_write(format!(
1185                    "could not inspect {} after the agent call: {e}",
1186                    recovery.display()
1187                ))),
1188            };
1189        }
1190
1191        let retained = match std::fs::symlink_metadata(&path) {
1192            Ok(_) => match std::fs::symlink_metadata(&recovery) {
1193                Ok(_) => {
1194                    return Err(SparError::uncertain_write(format!(
1195                        "the agent call changed the linked worktree Git marker at {}, but the \
1196                         changed entry could not be retained because {} already exists. The \
1197                         original marker was not restored, so no changed entry was deleted. \
1198                         Inspect the worktree before retrying.",
1199                        path.display(),
1200                        recovery.display()
1201                    )));
1202                }
1203                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1204                    match std::fs::rename(&path, &recovery) {
1205                        Ok(()) => true,
1206                        Err(e) => {
1207                            return Err(SparError::uncertain_write(format!(
1208                                "the agent call changed the linked worktree Git marker at {}, but \
1209                                 the changed entry could not be moved to {}: {e}. The original \
1210                                 marker was not restored, so no changed entry was deleted. Inspect \
1211                                 the worktree before retrying.",
1212                                path.display(),
1213                                recovery.display()
1214                            )));
1215                        }
1216                    }
1217                }
1218                Err(e) => {
1219                    return Err(SparError::uncertain_write(format!(
1220                        "the agent call changed the linked worktree Git marker at {}, but {} could \
1221                         not be inspected: {e}. The original marker was not restored, so no changed \
1222                         entry was deleted. Inspect the worktree before retrying.",
1223                        path.display(),
1224                        recovery.display()
1225                    )));
1226                }
1227            },
1228            Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
1229            Err(e) => {
1230                return Err(SparError::uncertain_write(format!(
1231                    "the agent call changed the linked worktree Git marker at {}, but the changed \
1232                     entry could not be inspected: {e}. The original marker was not restored. \
1233                     Inspect the worktree before retrying.",
1234                    path.display()
1235                )));
1236            }
1237        };
1238
1239        let restored = replace_git_marker(&path, &self.bytes);
1240        let restore_note = match &restored {
1241            Ok(()) => "The original marker was restored.".to_string(),
1242            Err(e) => format!("The original marker could not be restored: {e}."),
1243        };
1244        let retain_note = if retained {
1245            format!("The changed marker was kept at {}.", recovery.display())
1246        } else {
1247            "The agent call deleted the changed marker, so there was nothing to retain.".to_string()
1248        };
1249        Err(SparError::uncertain_write(format!(
1250            "the agent call changed the linked worktree Git marker at {}. {restore_note} \
1251             {retain_note} Inspect the worktree before retrying.",
1252            path.display()
1253        )))
1254    }
1255}
1256
1257/// Run post-call filesystem recovery only after the process runner confirms
1258/// that no descendant may still be writing.
1259fn after_call_is_quiet<T>(called: &Result<T>, recover: impl FnOnce() -> Result<()>) -> Result<()> {
1260    if let Err(error) = called {
1261        if error.kind() == ErrorKind::UncertainWrite {
1262            return Err(error.clone());
1263        }
1264    }
1265    recover()
1266}
1267
1268fn replace_git_marker(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
1269    let mut marker = std::fs::OpenOptions::new()
1270        .write(true)
1271        .create_new(true)
1272        .open(path)?;
1273    marker.write_all(bytes)
1274}
1275
1276fn ensure_recovery_path_clear(cwd: &Path) -> Result<()> {
1277    let recovery = cwd.join(GIT_MARKER_RECOVERY);
1278    match std::fs::symlink_metadata(&recovery) {
1279        Ok(_) => Err(SparError::uncertain_write(format!(
1280            "{} already exists. Recover or remove it before another agent call.",
1281            recovery.display()
1282        ))),
1283        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
1284        Err(e) => Err(SparError::uncertain_write(format!(
1285            "could not inspect {} before the agent call: {e}",
1286            recovery.display()
1287        ))),
1288    }
1289}
1290
1291// ---------------------------------------------------------------------------
1292// Placeholders
1293// ---------------------------------------------------------------------------
1294
1295#[derive(Debug, Default, Clone)]
1296pub struct Placeholders {
1297    pub prompt: Option<String>,
1298    pub system: Option<String>,
1299    pub model: Option<String>,
1300    pub effort: Option<String>,
1301    pub cwd: Option<String>,
1302    /// A path to the schema, for a CLI that reads one from disk.
1303    pub schema_file: Option<String>,
1304    /// The schema itself, for a CLI that takes it as an argument.
1305    pub schema: Option<String>,
1306}
1307
1308impl Placeholders {
1309    fn get(&self, key: &str) -> Option<&str> {
1310        let value = match key {
1311            "prompt" => self.prompt.as_deref(),
1312            "system" => self.system.as_deref(),
1313            "model" => self.model.as_deref(),
1314            "effort" => self.effort.as_deref(),
1315            "cwd" => self.cwd.as_deref(),
1316            "schema_file" => self.schema_file.as_deref(),
1317            "schema" => self.schema.as_deref(),
1318            _ => None,
1319        };
1320        value.filter(|v| !v.is_empty())
1321    }
1322
1323    /// Substitute every placeholder in one argument. `None` means a placeholder
1324    /// in this argument had no value, so the whole group is dropped.
1325    fn substitute(&self, arg: &str) -> Option<String> {
1326        const KEYS: [&str; 7] = [
1327            "prompt",
1328            "system",
1329            "model",
1330            "effort",
1331            "cwd",
1332            "schema_file",
1333            "schema",
1334        ];
1335        let mut out = arg.to_string();
1336        for key in KEYS {
1337            let token = format!("{{{key}}}");
1338            if out.contains(&token) {
1339                let value = self.get(key)?;
1340                out = out.replace(&token, value);
1341            }
1342        }
1343        Some(out)
1344    }
1345}
1346
1347// ---------------------------------------------------------------------------
1348// JSONL helpers
1349// ---------------------------------------------------------------------------
1350
1351/// Follow a dotted path, returning None if any hop is missing.
1352fn dig<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
1353    if path.is_empty() {
1354        return None;
1355    }
1356    let mut node = value;
1357    for part in path.split('.') {
1358        node = node.as_object()?.get(part)?;
1359    }
1360    Some(node)
1361}
1362
1363fn matches(event: &Value, wanted: &BTreeMap<String, String>) -> bool {
1364    if wanted.is_empty() {
1365        return false;
1366    }
1367    wanted
1368        .iter()
1369        .all(|(path, expected)| dig(event, path).and_then(Value::as_str) == Some(expected.as_str()))
1370}
1371
1372fn as_text(value: &Value) -> Option<String> {
1373    match value {
1374        Value::String(s) => Some(s.clone()),
1375        Value::Null => None,
1376        other => Some(other.to_string()),
1377    }
1378}
1379
1380fn truncate(text: &str, max: usize) -> String {
1381    text.chars().take(max).collect()
1382}
1383
1384// ---------------------------------------------------------------------------
1385// Temporary schema file
1386// ---------------------------------------------------------------------------
1387
1388/// A schema written somewhere the CLI can read it, removed when it goes out of
1389/// scope even if the agent call fails.
1390struct TempJson {
1391    path: PathBuf,
1392}
1393
1394impl TempJson {
1395    fn write(value: &Value) -> Result<Self> {
1396        use std::sync::atomic::{AtomicU64, Ordering};
1397        static COUNTER: AtomicU64 = AtomicU64::new(0);
1398
1399        let nanos = std::time::SystemTime::now()
1400            .duration_since(std::time::UNIX_EPOCH)
1401            .map(|d| d.as_nanos())
1402            .unwrap_or(0);
1403        let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
1404        let path = std::env::temp_dir().join(format!(
1405            "spar-schema-{}-{nanos}-{unique}.json",
1406            std::process::id()
1407        ));
1408        std::fs::write(&path, serde_json::to_vec_pretty(value)?)
1409            .map_err(|e| spar_err!("could not write a schema file to {}: {e}", path.display()))?;
1410        Ok(Self { path })
1411    }
1412
1413    fn path(&self) -> &Path {
1414        &self.path
1415    }
1416}
1417
1418impl Drop for TempJson {
1419    fn drop(&mut self) {
1420        let _ = std::fs::remove_file(&self.path);
1421    }
1422}
1423
1424// ---------------------------------------------------------------------------
1425// Correlation
1426// ---------------------------------------------------------------------------
1427
1428/// Whether two paths name the same executable.
1429///
1430/// Comparing the raw strings misses aliases: a symlink or a hard link points at
1431/// the same binary under a different path, which would let two agents run the
1432/// identical CLI without tripping the warning below. Device and inode see
1433/// through both.
1434fn same_executable(a: &Path, b: &Path) -> bool {
1435    #[cfg(unix)]
1436    {
1437        use std::os::unix::fs::MetadataExt;
1438        if let (Ok(x), Ok(y)) = (std::fs::metadata(a), std::fs::metadata(b)) {
1439            return x.dev() == y.dev() && x.ino() == y.ino();
1440        }
1441    }
1442    match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
1443        (Ok(x), Ok(y)) => x == y,
1444        _ => a == b,
1445    }
1446}
1447
1448/// Two agents are only an independent review if they can actually disagree.
1449///
1450/// Config keys are arbitrary, so `alpha` and `beta` can both be Claude on the
1451/// same model. Compare what actually runs: the resolved binary and the
1452/// configured model, never the names.
1453pub fn correlation_warning(agents: &[Agent]) -> Option<String> {
1454    for i in 0..agents.len() {
1455        for j in (i + 1)..agents.len() {
1456            let (a, b) = (&agents[i], &agents[j]);
1457            let (Ok(pa), Ok(pb)) = (a.resolve_bin(), b.resolve_bin()) else {
1458                continue;
1459            };
1460            if !same_executable(pa, pb) || a.spec.model_key() != b.spec.model_key() {
1461                continue;
1462            }
1463            let model = if a.spec.model_key().is_empty() {
1464                "the CLI's default".to_string()
1465            } else {
1466                a.spec.model_key()
1467            };
1468            let where_at = if pa == pb {
1469                pa.display().to_string()
1470            } else {
1471                format!(
1472                    "the same executable ({} and {} are the same file)",
1473                    pa.display(),
1474                    pb.display()
1475                )
1476            };
1477            return Some(format!(
1478                "agents '{}' and '{}' both resolve to {where_at} at model {model}. Review \
1479                 findings will be correlated: the same model reviewing itself shares the blind \
1480                 spots of the model that wrote the code, so it is far less likely to catch what \
1481                 the implementer missed. That produces an approval indistinguishable from a real \
1482                 review, which is worse than no review at all. Give the two agents different \
1483                 CLIs or different models.",
1484                a.name(),
1485                b.name()
1486            ));
1487        }
1488    }
1489    None
1490}
1491
1492/// Build every configured agent, resolving each binary up front so a missing
1493/// CLI fails before any model is billed.
1494pub fn build(cfg: &crate::config::Config) -> Result<Vec<Agent>> {
1495    let agents: Vec<Agent> = cfg
1496        .agents
1497        .iter()
1498        .cloned()
1499        .map(Agent::new)
1500        .map(|agent| agent.with_instructions(&cfg.loop_cfg.instructions))
1501        .collect();
1502    for agent in &agents {
1503        agent.resolve_bin()?;
1504        // A backup that is not installed must not stop a run whose pair is
1505        // fine. Said once here, at the start, rather than an hour in at the
1506        // moment it was needed and could not be reached.
1507        if let Some(backup) = agent.fallback() {
1508            if backup.resolve_bin().is_err() {
1509                logwarn!(
1510                    "{} has a fallback ({}) that is not installed, so it will not stand in",
1511                    agent.name(),
1512                    backup.program()
1513                );
1514            }
1515        }
1516    }
1517    Ok(agents)
1518}
1519
1520/// Look an agent up by name in a built list.
1521pub fn find<'a>(agents: &'a [Agent], name: &str) -> Result<&'a Agent> {
1522    agents.iter().find(|a| a.name() == name).ok_or_else(|| {
1523        SparError::new(format!(
1524            "no agent named '{name}' ({})",
1525            agents
1526                .iter()
1527                .map(Agent::name)
1528                .collect::<Vec<_>>()
1529                .join(", ")
1530        ))
1531    })
1532}
1533
1534#[cfg(test)]
1535mod tests {
1536    use super::*;
1537    use crate::config::{OutputMode, SystemVia};
1538
1539    fn spec(command: Vec<CommandPart>) -> AgentSpec {
1540        AgentSpec {
1541            name: "test".into(),
1542            command,
1543            model: None,
1544            effort: None,
1545            effort_schedule: crate::config::EffortSchedule::default(),
1546            output: OutputMode::Text,
1547            message_match: BTreeMap::new(),
1548            message_path: None,
1549            search_paths: vec![],
1550            system_via: SystemVia::Prompt,
1551            timeout: 60,
1552            fallback: None,
1553            models: vec![],
1554            efforts: vec![],
1555            options_note: None,
1556        }
1557    }
1558
1559    fn one(s: &str) -> CommandPart {
1560        CommandPart::One(s.into())
1561    }
1562
1563    fn group(parts: &[&str]) -> CommandPart {
1564        CommandPart::Group(parts.iter().map(|s| s.to_string()).collect())
1565    }
1566
1567    #[test]
1568    fn an_uncertain_process_result_skips_post_call_recovery() {
1569        let called: Result<()> = Err(SparError::uncertain_write("descendants may still write"));
1570        let recovered = std::cell::Cell::new(false);
1571
1572        let error = after_call_is_quiet(&called, || {
1573            recovered.set(true);
1574            Ok(())
1575        })
1576        .unwrap_err();
1577
1578        assert_eq!(ErrorKind::UncertainWrite, error.kind());
1579        assert!(!recovered.get());
1580    }
1581
1582    fn agent(command: Vec<CommandPart>) -> Agent {
1583        Agent::with_bin(spec(command), "/fake/bin")
1584    }
1585
1586    fn values() -> Placeholders {
1587        Placeholders {
1588            prompt: Some("hi".into()),
1589            ..Default::default()
1590        }
1591    }
1592
1593    // -- rendering -------------------------------------------------------
1594
1595    #[test]
1596    fn placeholders_are_substituted() {
1597        let a = agent(vec![one("x"), group(&["-m", "{model}"]), one("{prompt}")]);
1598        let v = Placeholders {
1599            model: Some("m1".into()),
1600            ..values()
1601        };
1602        assert_eq!(vec!["/fake/bin", "-m", "m1", "hi"], a.render(&v).unwrap());
1603    }
1604
1605    #[test]
1606    fn an_unset_placeholder_drops_the_whole_group() {
1607        let a = agent(vec![one("x"), group(&["-m", "{model}"]), one("{prompt}")]);
1608        assert_eq!(vec!["/fake/bin", "hi"], a.render(&values()).unwrap());
1609    }
1610
1611    #[test]
1612    fn an_empty_string_drops_the_group_too() {
1613        let a = agent(vec![one("x"), group(&["-e", "{effort}"]), one("{prompt}")]);
1614        let v = Placeholders {
1615            effort: Some(String::new()),
1616            ..values()
1617        };
1618        assert_eq!(vec!["/fake/bin", "hi"], a.render(&v).unwrap());
1619    }
1620
1621    #[test]
1622    fn a_bare_arg_with_an_unset_placeholder_drops() {
1623        let a = agent(vec![one("x"), one("{model}"), one("{prompt}")]);
1624        assert_eq!(vec!["/fake/bin", "hi"], a.render(&values()).unwrap());
1625    }
1626
1627    #[test]
1628    fn literal_args_survive() {
1629        let a = agent(vec![
1630            one("x"),
1631            one("exec"),
1632            one("--json"),
1633            one("--"),
1634            one("{prompt}"),
1635        ]);
1636        assert_eq!(
1637            vec!["/fake/bin", "exec", "--json", "--", "hi"],
1638            a.render(&values()).unwrap()
1639        );
1640    }
1641
1642    #[test]
1643    fn an_embedded_placeholder_substitutes_in_place() {
1644        let a = agent(vec![
1645            one("x"),
1646            group(&["-c", "model_reasoning_effort={effort}"]),
1647        ]);
1648        let v = Placeholders {
1649            effort: Some("ultra".into()),
1650            ..Default::default()
1651        };
1652        assert_eq!(
1653            vec!["/fake/bin", "-c", "model_reasoning_effort=ultra"],
1654            a.render(&v).unwrap()
1655        );
1656    }
1657
1658    #[test]
1659    fn a_group_with_two_placeholders_needs_both() {
1660        let a = agent(vec![
1661            one("x"),
1662            group(&["--a", "{model}", "--b", "{effort}"]),
1663        ]);
1664        let v = Placeholders {
1665            model: Some("m".into()),
1666            ..Default::default()
1667        };
1668        assert_eq!(vec!["/fake/bin"], a.render(&v).unwrap());
1669    }
1670
1671    #[test]
1672    fn supports_schema_detects_the_placeholder() {
1673        assert!(agent(vec![one("x"), group(&["--schema", "{schema_file}"])]).supports_schema());
1674        assert!(!agent(vec![one("x"), one("{prompt}")]).supports_schema());
1675    }
1676
1677    // -- output adapters -------------------------------------------------
1678
1679    #[test]
1680    fn text_passes_through_trimmed() {
1681        assert_eq!("hello", agent(vec![one("x")]).extract("  hello\n").unwrap());
1682    }
1683
1684    #[test]
1685    fn jsonl_picks_the_matching_event() {
1686        let mut spec = spec(vec![one("x")]);
1687        spec.output = OutputMode::Jsonl;
1688        spec.message_path = Some("item.text".into());
1689        spec.message_match = BTreeMap::from([
1690            ("type".to_string(), "item.completed".to_string()),
1691            ("item.type".to_string(), "agent_message".to_string()),
1692        ]);
1693        let a = Agent::with_bin(spec, "/fake/bin");
1694        let stream = [
1695            r#"{"type":"thread.started","thread_id":"t1"}"#,
1696            r#"{"type":"item.completed","item":{"type":"command_execution","text":"ls"}}"#,
1697            r#"{"type":"item.completed","item":{"type":"agent_message","text":"the answer"}}"#,
1698            "not json at all",
1699        ]
1700        .join("\n");
1701        assert_eq!("the answer", a.extract(&stream).unwrap());
1702    }
1703
1704    #[test]
1705    fn jsonl_raises_on_an_error_with_no_message() {
1706        let mut spec = spec(vec![one("x")]);
1707        spec.output = OutputMode::Jsonl;
1708        spec.message_path = Some("item.text".into());
1709        spec.message_match = BTreeMap::from([("type".into(), "item.completed".into())]);
1710        let a = Agent::with_bin(spec, "/fake/bin");
1711        assert!(a
1712            .extract(r#"{"type":"turn.failed","error":"boom"}"#)
1713            .is_err());
1714    }
1715
1716    #[test]
1717    fn jsonl_joins_several_agent_messages() {
1718        let mut spec = spec(vec![one("x")]);
1719        spec.output = OutputMode::Jsonl;
1720        spec.message_path = Some("text".into());
1721        spec.message_match = BTreeMap::from([("type".into(), "msg".into())]);
1722        let a = Agent::with_bin(spec, "/fake/bin");
1723        let stream = "{\"type\":\"msg\",\"text\":\"one\"}\n{\"type\":\"msg\",\"text\":\"two\"}";
1724        assert_eq!("one\ntwo", a.extract(stream).unwrap());
1725    }
1726
1727    #[test]
1728    fn dig_walks_a_dotted_path() {
1729        let v: Value = serde_json::from_str(r#"{"a":{"b":{"c":1}}}"#).unwrap();
1730        assert_eq!(Some(&Value::from(1)), dig(&v, "a.b.c"));
1731        assert_eq!(None, dig(&v, "a.b.missing"));
1732        assert_eq!(None, dig(&v, ""));
1733    }
1734
1735    // -- binary resolution -----------------------------------------------
1736
1737    #[test]
1738    fn a_missing_binary_lists_everywhere_it_looked() {
1739        let mut s = spec(vec![one("definitely-not-installed-xyz")]);
1740        s.search_paths = vec!["/nowhere/at/all".into()];
1741        s.name = "codex".into();
1742        let err = Agent::new(s).resolve_bin().unwrap_err().to_string();
1743        assert!(err.contains("definitely-not-installed-xyz (PATH)"), "{err}");
1744        assert!(
1745            err.contains("/nowhere/at/all/definitely-not-installed-xyz"),
1746            "{err}"
1747        );
1748        assert!(err.contains("SPAR_CODEX_BIN"), "{err}");
1749    }
1750
1751    #[test]
1752    fn a_search_path_that_already_names_the_binary_is_used_as_is() {
1753        let dir = std::env::temp_dir().join(format!("spar-test-{}", std::process::id()));
1754        std::fs::create_dir_all(&dir).unwrap();
1755        let bin = dir.join("mytool");
1756        std::fs::write(&bin, "#!/bin/sh\n").unwrap();
1757        #[cfg(unix)]
1758        {
1759            use std::os::unix::fs::PermissionsExt;
1760            std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
1761        }
1762        let mut s = spec(vec![one("mytool")]);
1763        s.search_paths = vec![bin.display().to_string()];
1764        assert_eq!(bin, Agent::new(s).resolve_bin().unwrap());
1765        let _ = std::fs::remove_dir_all(&dir);
1766    }
1767
1768    // -- correlation -----------------------------------------------------
1769
1770    fn named(name: &str, bin: &str, model: Option<&str>) -> Agent {
1771        let mut s = spec(vec![one("prog")]);
1772        s.name = name.into();
1773        s.model = model.map(str::to_string);
1774        Agent::with_bin(s, bin)
1775    }
1776
1777    #[test]
1778    fn same_bin_same_model_warns() {
1779        let agents = vec![
1780            named("alpha", "/usr/local/bin/claude", Some("fable")),
1781            named("beta", "/usr/local/bin/claude", Some("fable")),
1782        ];
1783        let msg = correlation_warning(&agents).expect("should warn");
1784        assert!(msg.contains("alpha") && msg.contains("beta"), "{msg}");
1785    }
1786
1787    #[test]
1788    fn different_model_does_not_warn() {
1789        let agents = vec![
1790            named("a", "/usr/local/bin/claude", Some("fable")),
1791            named("b", "/usr/local/bin/claude", Some("opus")),
1792        ];
1793        assert!(correlation_warning(&agents).is_none());
1794    }
1795
1796    #[test]
1797    fn different_bin_does_not_warn() {
1798        let agents = vec![
1799            named("a", "/usr/local/bin/claude", Some("fable")),
1800            named("b", "/usr/local/bin/codex", Some("fable")),
1801        ];
1802        assert!(correlation_warning(&agents).is_none());
1803    }
1804
1805    #[test]
1806    fn unset_and_empty_model_both_mean_the_default_and_warn() {
1807        let agents = vec![
1808            named("a", "/usr/local/bin/claude", None),
1809            named("b", "/usr/local/bin/claude", Some("")),
1810        ];
1811        let msg = correlation_warning(&agents).expect("should warn");
1812        assert!(msg.contains("the CLI's default"), "{msg}");
1813    }
1814
1815    #[test]
1816    fn a_padded_model_still_warns() {
1817        let agents = vec![
1818            named("a", "/usr/local/bin/claude", Some("fable")),
1819            named("b", "/usr/local/bin/claude", Some(" fable ")),
1820        ];
1821        assert!(correlation_warning(&agents).is_some());
1822    }
1823
1824    #[test]
1825    fn an_empty_model_against_a_named_one_does_not_warn() {
1826        let agents = vec![
1827            named("a", "/usr/local/bin/claude", Some("")),
1828            named("b", "/usr/local/bin/claude", Some("fable")),
1829        ];
1830        assert!(correlation_warning(&agents).is_none());
1831    }
1832
1833    #[cfg(unix)]
1834    #[test]
1835    fn a_symlinked_binary_warns_and_names_both_paths() {
1836        use std::os::unix::fs::PermissionsExt;
1837        let dir = std::env::temp_dir().join(format!("spar-link-{}", std::process::id()));
1838        let _ = std::fs::remove_dir_all(&dir);
1839        std::fs::create_dir_all(&dir).unwrap();
1840        let real = dir.join("claude");
1841        let link = dir.join("claude-alias");
1842        std::fs::write(&real, "#!/bin/sh\n").unwrap();
1843        std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o755)).unwrap();
1844        std::os::unix::fs::symlink(&real, &link).unwrap();
1845
1846        let agents = vec![
1847            named("alpha", real.to_str().unwrap(), Some("fable")),
1848            named("beta", link.to_str().unwrap(), Some("fable")),
1849        ];
1850        let msg = correlation_warning(&agents).expect("should warn");
1851        assert!(msg.contains(real.to_str().unwrap()), "{msg}");
1852        assert!(msg.contains(link.to_str().unwrap()), "{msg}");
1853        let _ = std::fs::remove_dir_all(&dir);
1854    }
1855
1856    #[cfg(unix)]
1857    #[test]
1858    fn two_distinct_real_binaries_stay_quiet() {
1859        use std::os::unix::fs::PermissionsExt;
1860        let dir = std::env::temp_dir().join(format!("spar-distinct-{}", std::process::id()));
1861        let _ = std::fs::remove_dir_all(&dir);
1862        std::fs::create_dir_all(&dir).unwrap();
1863        let mut paths = Vec::new();
1864        for name in ["claude", "codex"] {
1865            let path = dir.join(name);
1866            std::fs::write(&path, "#!/bin/sh\n").unwrap();
1867            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
1868            paths.push(path);
1869        }
1870        let agents = vec![
1871            named("a", paths[0].to_str().unwrap(), Some("fable")),
1872            named("b", paths[1].to_str().unwrap(), Some("fable")),
1873        ];
1874        assert!(correlation_warning(&agents).is_none());
1875        let _ = std::fs::remove_dir_all(&dir);
1876    }
1877
1878    #[test]
1879    fn the_style_rules_ask_for_brevity_and_no_attribution() {
1880        let lower = STYLE_RULES.to_lowercase();
1881        assert!(lower.contains("brief"));
1882        assert!(lower.contains("co-authored-by"));
1883        assert!(lower.contains("em-dash"));
1884    }
1885
1886    /// Brevity was measured in sentences, and "one sentence beats one paragraph"
1887    /// is what a model satisfies by joining three facts with commas. A summary
1888    /// came back as a changelog line the reader had to decipher, which is
1889    /// shorter and worse.
1890    #[test]
1891    fn brevity_is_about_facts_per_sentence_not_sentence_count() {
1892        let lower = STYLE_RULES.to_lowercase();
1893        assert!(lower.contains("saying fewer things"), "{STYLE_RULES}");
1894        assert!(
1895            !lower.contains("one sentence beats one paragraph"),
1896            "the rule that produced the density is still there"
1897        );
1898    }
1899
1900    /// The rules were scoped to what spar posts, so nothing had ever asked an
1901    /// agent for anything about the comments it writes in the code. A three
1902    /// line change came back under eight lines of comment, most of it the
1903    /// debugging story rather than the reason.
1904    #[test]
1905    fn the_style_rules_reach_the_code_and_not_only_what_is_posted() {
1906        let lower = STYLE_RULES.to_lowercase();
1907        assert!(lower.contains("comments in code you write"), "not in scope");
1908        assert!(
1909            lower.contains("comment code for the reason"),
1910            "no rule for it"
1911        );
1912    }
1913
1914    // -- a failure said in the agent's own terms ---------------------------
1915
1916    /// The event stream from the run that prompted this: a wall of file
1917    /// contents a tool call returned, with the reason as the last two lines.
1918    fn refusal_stream() -> String {
1919        let noise = "{\"type\":\"item.completed\",\"item\":{\"id\":\"i\",\"type\":\"command_execution\",\"output\":\"".to_string()
1920            + &"const x = 1;\\n".repeat(200)
1921            + "\"}}";
1922        [
1923            noise.as_str(),
1924            r#"{"type":"error","message":"This content was flagged for possible cybersecurity risk."}"#,
1925            r#"{"type":"error","message":"This content was flagged for possible cybersecurity risk."}"#,
1926            r#"{"type":"turn.failed","error":{"message":"This content was flagged for possible cybersecurity risk."}}"#,
1927        ]
1928        .join("\n")
1929    }
1930
1931    fn jsonl_agent(name: &str) -> Agent {
1932        let mut spec = spec(vec![one("codex")]);
1933        spec.name = name.into();
1934        spec.output = OutputMode::Jsonl;
1935        spec.message_path = Some("item.text".into());
1936        Agent::with_bin(spec, "/fake/codex")
1937    }
1938
1939    fn failed(stdout: &str, stderr: &str) -> proc::Output {
1940        proc::Output {
1941            stdout: stdout.to_string(),
1942            stdout_bytes: stdout.as_bytes().to_vec(),
1943            stderr: stderr.to_string(),
1944            code: 1,
1945        }
1946    }
1947
1948    /// The reason a CLI gives is a field inside the event, not the event.
1949    /// Printing the object around it is what buried it.
1950    #[test]
1951    fn a_jsonl_failure_reports_the_reason_and_not_the_stream() {
1952        let agent = jsonl_agent("codex");
1953        let err = agent.call_failure(&["codex".to_string()], &failed(&refusal_stream(), ""));
1954        let text = err.message();
1955        assert!(
1956            text.contains("flagged for possible cybersecurity risk"),
1957            "{text}"
1958        );
1959        assert!(
1960            !text.contains("const x = 1;"),
1961            "the stream leaked in:\n{text}"
1962        );
1963        assert!(text.len() < 400, "still {} characters:\n{text}", text.len());
1964    }
1965
1966    /// One refusal reported as two errors and a turn.failed is one reason.
1967    #[test]
1968    fn the_same_reason_reported_three_times_is_said_once() {
1969        let agent = jsonl_agent("codex");
1970        let err = agent.call_failure(&["codex".to_string()], &failed(&refusal_stream(), ""));
1971        assert_eq!(
1972            1,
1973            err.message().matches("flagged for possible").count(),
1974            "{}",
1975            err.message()
1976        );
1977    }
1978
1979    /// stderr is where one CLI reports the condition behind the refusal, and it
1980    /// is short, so it survives whatever stdout turns out to hold.
1981    #[test]
1982    fn stderr_is_kept_because_it_is_where_the_other_half_arrives() {
1983        let agent = jsonl_agent("codex");
1984        let err = agent.call_failure(
1985            &["codex".to_string()],
1986            &failed(
1987                &refusal_stream(),
1988                "ERROR router: agent thread limit reached",
1989            ),
1990        );
1991        assert!(
1992            err.message().contains("agent thread limit reached"),
1993            "{}",
1994            err.message()
1995        );
1996    }
1997
1998    /// A CLI that dies without emitting an error event leaves nothing else to
1999    /// go on, so the raw dump is still what happens.
2000    #[test]
2001    fn a_stream_with_no_error_event_falls_back_to_the_raw_output() {
2002        let agent = jsonl_agent("codex");
2003        let err = agent.call_failure(
2004            &["codex".to_string()],
2005            &failed("{\"type\":\"system\"}", "segmentation fault"),
2006        );
2007        assert!(
2008            err.message().contains("segmentation fault"),
2009            "{}",
2010            err.message()
2011        );
2012        assert!(
2013            err.message().starts_with("command failed"),
2014            "{}",
2015            err.message()
2016        );
2017    }
2018
2019    /// A text agent has no events to read, so nothing changes for it.
2020    #[test]
2021    fn a_text_agent_is_reported_exactly_as_before() {
2022        let agent = agent(vec![one("mytool")]);
2023        let err = agent.call_failure(&["mytool".to_string()], &failed("some prose", "boom"));
2024        assert!(
2025            err.message().starts_with("command failed"),
2026            "{}",
2027            err.message()
2028        );
2029        assert!(err.message().contains("some prose"), "{}", err.message());
2030    }
2031
2032    /// Whatever the shape, it is still the CLI failing rather than answering
2033    /// badly, so it still goes straight to the stand in.
2034    #[test]
2035    fn a_reworded_failure_is_still_a_failed_call() {
2036        let agent = jsonl_agent("codex");
2037        let err = agent.call_failure(&["codex".to_string()], &failed(&refusal_stream(), ""));
2038        assert_eq!(ErrorKind::CallFailed, err.kind());
2039    }
2040
2041    // -- this run's own instructions --------------------------------------
2042
2043    #[test]
2044    fn a_request_carries_the_instructions_after_the_task() {
2045        let agent = Agent::with_bin(shell("a", "true"), "/bin/sh")
2046            .with_instructions("Do not wait for CI. Pick it up next pass.");
2047        let asked = agent.instructed("Review the changes on this branch.");
2048        assert!(
2049            asked.starts_with("Review the changes on this branch."),
2050            "{asked}"
2051        );
2052        assert!(asked.contains("Do not wait for CI"), "{asked}");
2053    }
2054
2055    /// A person adding an instruction should not be able to talk an agent out
2056    /// of the schema it was asked for, so where the instruction came from is
2057    /// said rather than left to read as part of the request.
2058    #[test]
2059    fn the_instructions_arrive_subordinate_to_the_request() {
2060        let agent = Agent::with_bin(shell("a", "true"), "/bin/sh").with_instructions("Be quick.");
2061        let asked = agent.instructed("Do the work.").to_lowercase();
2062        assert!(
2063            asked.contains("from the person who started this run"),
2064            "{asked}"
2065        );
2066        assert!(asked.contains("not the shape of your answer"), "{asked}");
2067    }
2068
2069    #[test]
2070    fn nothing_is_added_when_there_are_none() {
2071        let agent = Agent::with_bin(shell("a", "true"), "/bin/sh");
2072        assert_eq!("Do the work.", agent.instructed("Do the work."));
2073        // Whitespace is not an instruction.
2074        let blank = Agent::with_bin(shell("b", "true"), "/bin/sh").with_instructions("   \n  ");
2075        assert_eq!("Do the work.", blank.instructed("Do the work."));
2076    }
2077
2078    /// The stand in answers in this agent's place, so a run told not to wait on
2079    /// something must not start waiting the moment the primary hands over.
2080    #[test]
2081    fn the_stand_in_carries_them_too() {
2082        let agent = with_fallback(shell("primary", "true"), shell("backup", "true"))
2083            .with_instructions("Do not wait for CI.");
2084        let backup = agent.fallback().expect("a stand in");
2085        assert!(backup
2086            .instructed("Do the work.")
2087            .contains("Do not wait for CI."));
2088    }
2089
2090    // -- fallback --------------------------------------------------------
2091
2092    /// An agent whose command is a literal shell line, so a test can make the
2093    /// call succeed or fail on purpose.
2094    fn shell(name: &str, line: &str) -> AgentSpec {
2095        let mut spec = spec(vec![one("sh"), one("-c"), one(line)]);
2096        spec.name = name.into();
2097        spec
2098    }
2099
2100    fn with_fallback(mut primary: AgentSpec, backup: AgentSpec) -> Agent {
2101        primary.fallback = Some(Box::new(backup));
2102        Agent::with_bin(primary, "/bin/sh")
2103    }
2104
2105    fn git_at(cwd: &Path, args: &[&str]) -> String {
2106        let out = std::process::Command::new("git")
2107            .args(args)
2108            .current_dir(cwd)
2109            .output()
2110            .unwrap();
2111        assert!(
2112            out.status.success(),
2113            "{}",
2114            String::from_utf8_lossy(&out.stderr)
2115        );
2116        String::from_utf8_lossy(&out.stdout).trim().to_string()
2117    }
2118
2119    fn committed_repo(name: &str) -> PathBuf {
2120        let dir = std::env::temp_dir().join(format!(
2121            "spar-agent-{name}-{}-{}",
2122            std::process::id(),
2123            std::thread::current().name().unwrap_or("test")
2124        ));
2125        let _ = std::fs::remove_dir_all(&dir);
2126        std::fs::create_dir_all(&dir).unwrap();
2127        git_at(&dir, &["init", "-q", "-b", "main"]);
2128        git_at(&dir, &["config", "user.email", "spar@example.invalid"]);
2129        git_at(&dir, &["config", "user.name", "spar test"]);
2130        git_at(&dir, &["config", "commit.gpgsign", "false"]);
2131        std::fs::write(dir.join("README.md"), "seed\n").unwrap();
2132        git_at(&dir, &["add", "README.md"]);
2133        git_at(&dir, &["commit", "-q", "-m", "seed"]);
2134        dir
2135    }
2136
2137    fn linked_worktree(name: &str) -> (PathBuf, PathBuf, PathBuf, Vec<u8>) {
2138        let root = std::env::temp_dir().join(format!(
2139            "spar-agent-{name}-{}-{}",
2140            std::process::id(),
2141            std::thread::current().name().unwrap_or("test")
2142        ));
2143        let _ = std::fs::remove_dir_all(&root);
2144        let main = root.join("main");
2145        std::fs::create_dir_all(&main).unwrap();
2146        git_at(&main, &["init", "-q", "-b", "main"]);
2147        git_at(&main, &["config", "user.email", "spar@example.invalid"]);
2148        git_at(&main, &["config", "user.name", "spar test"]);
2149        git_at(&main, &["config", "commit.gpgsign", "false"]);
2150        std::fs::write(main.join("README.md"), "seed\n").unwrap();
2151        git_at(&main, &["add", "README.md"]);
2152        git_at(&main, &["commit", "-q", "-m", "seed"]);
2153        let linked = root.join("linked");
2154        git_at(
2155            &main,
2156            &[
2157                "worktree",
2158                "add",
2159                "-q",
2160                "-b",
2161                "issue-test",
2162                linked.to_str().unwrap(),
2163            ],
2164        );
2165        let marker = std::fs::read(linked.join(".git")).unwrap();
2166        (root, main, linked, marker)
2167    }
2168
2169    #[test]
2170    fn a_failed_call_is_answered_by_the_fallback() {
2171        let agent = with_fallback(
2172            shell("primary", "echo refused >&2; exit 1"),
2173            shell("backup", "echo stood in"),
2174        );
2175        let answer = agent
2176            .ask("hi", Path::new("."), &Effort::default())
2177            .expect("fallback");
2178        assert_eq!("stood in", answer);
2179    }
2180
2181    #[test]
2182    fn a_failed_edit_with_files_left_does_not_run_the_fallback() {
2183        let dir = committed_repo("dirty-edit-recovery");
2184
2185        let agent = with_fallback(
2186            shell(
2187                "primary",
2188                "printf 'recover me\\n' > README.md; printf refused >&2; exit 1",
2189            ),
2190            shell("backup", "printf 'fallback ran\\n' > fallback.txt"),
2191        );
2192        let err = agent
2193            .edit("change it", &dir, &Effort::default())
2194            .unwrap_err();
2195
2196        assert!(err.message().contains("refused"), "{err}");
2197        assert_eq!(
2198            1,
2199            err.message()
2200                .matches("The call changed the worktree before it failed")
2201                .count(),
2202            "{err}"
2203        );
2204        assert_eq!(
2205            "recover me\n",
2206            std::fs::read_to_string(dir.join("README.md")).unwrap()
2207        );
2208        assert!(!dir.join("fallback.txt").exists());
2209        let _ = std::fs::remove_dir_all(&dir);
2210    }
2211
2212    #[test]
2213    fn a_failed_edit_with_a_new_ignored_file_does_not_run_the_fallback() {
2214        let dir = committed_repo("ignored-edit-recovery");
2215        std::fs::write(dir.join(".gitignore"), "ignored.txt\n").unwrap();
2216        git_at(&dir, &["add", ".gitignore"]);
2217        git_at(&dir, &["commit", "-q", "-m", "ignore fixture"]);
2218        let agent = with_fallback(
2219            shell(
2220                "primary",
2221                "printf 'recover me\n' > ignored.txt; printf refused >&2; exit 1",
2222            ),
2223            shell("backup", "printf 'fallback ran\n' > fallback.txt"),
2224        );
2225
2226        let err = agent
2227            .edit("change it", &dir, &Effort::default())
2228            .unwrap_err();
2229
2230        assert!(err.message().contains("refused"), "{err}");
2231        assert_eq!(
2232            "recover me\n",
2233            std::fs::read_to_string(dir.join("ignored.txt")).unwrap()
2234        );
2235        assert!(!dir.join("fallback.txt").exists());
2236        let _ = std::fs::remove_dir_all(&dir);
2237    }
2238
2239    #[test]
2240    fn a_failed_edit_that_overwrites_an_ignored_file_does_not_run_the_fallback() {
2241        let dir = committed_repo("changed-ignored-edit-recovery");
2242        std::fs::write(dir.join(".gitignore"), "ignored.txt\n").unwrap();
2243        git_at(&dir, &["add", ".gitignore"]);
2244        git_at(&dir, &["commit", "-q", "-m", "ignore fixture"]);
2245        std::fs::write(dir.join("ignored.txt"), "before\n").unwrap();
2246        let agent = with_fallback(
2247            shell(
2248                "primary",
2249                "printf 'after!\\n' > ignored.txt; printf refused >&2; exit 1",
2250            ),
2251            shell("backup", "printf 'fallback ran\n' > fallback.txt"),
2252        );
2253
2254        let err = agent
2255            .edit("change it", &dir, &Effort::default())
2256            .unwrap_err();
2257
2258        assert!(err.message().contains("refused"), "{err}");
2259        assert_eq!(
2260            "after!\n",
2261            std::fs::read_to_string(dir.join("ignored.txt")).unwrap()
2262        );
2263        assert!(!dir.join("fallback.txt").exists());
2264        let _ = std::fs::remove_dir_all(&dir);
2265    }
2266
2267    #[test]
2268    fn a_failed_edit_with_a_commit_does_not_run_the_fallback() {
2269        let dir = committed_repo("committed-edit-recovery");
2270        let agent = with_fallback(
2271            shell(
2272                "primary",
2273                "printf 'recover me\n' > README.md; git add README.md; \
2274                 git commit -q -m preserved; printf refused >&2; exit 1",
2275            ),
2276            shell("backup", "printf 'fallback ran\n' > fallback.txt"),
2277        );
2278
2279        let err = agent
2280            .edit("change it", &dir, &Effort::default())
2281            .unwrap_err();
2282
2283        assert!(err.message().contains("refused"), "{err}");
2284        assert_eq!("preserved", git_at(&dir, &["log", "-1", "--pretty=%s"]));
2285        assert_eq!(
2286            "recover me\n",
2287            std::fs::read_to_string(dir.join("README.md")).unwrap()
2288        );
2289        assert!(!dir.join("fallback.txt").exists());
2290        let _ = std::fs::remove_dir_all(&dir);
2291    }
2292
2293    #[test]
2294    fn a_committed_edit_with_malformed_json_is_not_retried_or_handed_over() {
2295        let dir = committed_repo("committed-malformed-edit");
2296        let agent = with_fallback(
2297            shell(
2298                "primary",
2299                "printf 'once\n' >> attempts.txt; printf 'recover me\n' > README.md; \
2300                 git add -A; git commit -q -m preserved; printf 'not json\n'",
2301            ),
2302            shell(
2303                "backup",
2304                "printf 'fallback ran\n' > fallback.txt; printf '{}\n'",
2305            ),
2306        );
2307
2308        let err = agent
2309            .edit_json::<Value>(
2310                "change it",
2311                &serde_json::json!({"type": "object"}),
2312                &dir,
2313                &Effort::default(),
2314            )
2315            .unwrap_err();
2316
2317        assert!(err.message().contains("JSON"), "{err}");
2318        assert_eq!("preserved", git_at(&dir, &["log", "-1", "--pretty=%s"]));
2319        assert_eq!(
2320            1,
2321            std::fs::read_to_string(dir.join("attempts.txt"))
2322                .unwrap()
2323                .lines()
2324                .count()
2325        );
2326        assert!(!dir.join("fallback.txt").exists());
2327        let _ = std::fs::remove_dir_all(&dir);
2328    }
2329
2330    #[test]
2331    fn a_failed_edit_recovery_probe_does_not_retry_or_run_the_fallback() {
2332        let dir = committed_repo("failed-edit-probe");
2333        let agent = with_fallback(
2334            shell(
2335                "primary",
2336                "printf 'once\n' >> attempts.txt; mv .git .git-away; printf 'not json\n'",
2337            ),
2338            shell(
2339                "backup",
2340                "printf 'fallback ran\n' > fallback.txt; printf '{}\n'",
2341            ),
2342        );
2343
2344        let err = agent
2345            .edit_json::<Value>(
2346                "change it",
2347                &serde_json::json!({"type": "object"}),
2348                &dir,
2349                &Effort::default(),
2350            )
2351            .unwrap_err();
2352
2353        std::fs::rename(dir.join(".git-away"), dir.join(".git")).unwrap();
2354        assert_eq!(ErrorKind::UncertainWrite, err.kind());
2355        assert!(err.message().contains("JSON"), "{err}");
2356        assert_eq!(
2357            1,
2358            std::fs::read_to_string(dir.join("attempts.txt"))
2359                .unwrap()
2360                .lines()
2361                .count()
2362        );
2363        assert!(!dir.join("fallback.txt").exists());
2364        assert!(dir.join(".git").is_dir());
2365        let _ = std::fs::remove_dir_all(&dir);
2366    }
2367
2368    #[test]
2369    fn a_linked_worktree_git_marker_is_restored_after_an_edit() {
2370        let (root, main, linked, marker) = linked_worktree("changed-git-marker");
2371        let agent = Agent::with_bin(
2372            shell(
2373                "editor",
2374                "printf 'gitdir: /tmp/not-the-repo\\n' > .git; echo done",
2375            ),
2376            "/bin/sh",
2377        );
2378
2379        let err = agent
2380            .edit("change it", &linked, &Effort::default())
2381            .unwrap_err();
2382
2383        assert_eq!(ErrorKind::UncertainWrite, err.kind());
2384        assert!(err.message().contains("Git marker"), "{err}");
2385        assert_eq!(marker, std::fs::read(linked.join(".git")).unwrap());
2386        assert_eq!(
2387            b"gitdir: /tmp/not-the-repo\n",
2388            std::fs::read(linked.join(GIT_MARKER_RECOVERY))
2389                .unwrap()
2390                .as_slice()
2391        );
2392        git_at(
2393            &main,
2394            &["worktree", "remove", "--force", linked.to_str().unwrap()],
2395        );
2396        let _ = std::fs::remove_dir_all(&root);
2397    }
2398
2399    #[test]
2400    fn a_read_call_also_restores_a_linked_worktree_git_marker() {
2401        let (root, main, linked, marker) = linked_worktree("read-call-git-marker");
2402        let agent = with_fallback(
2403            shell(
2404                "reader",
2405                "printf 'gitdir: /tmp/not-the-repo\n' > .git; echo done",
2406            ),
2407            shell("backup", "printf 'fallback ran\n' > fallback.txt"),
2408        );
2409
2410        let err = agent
2411            .ask("read it", &linked, &Effort::default())
2412            .unwrap_err();
2413
2414        assert_eq!(ErrorKind::UncertainWrite, err.kind());
2415        assert_eq!(marker, std::fs::read(linked.join(".git")).unwrap());
2416        assert!(!linked.join("fallback.txt").exists());
2417        git_at(
2418            &main,
2419            &["worktree", "remove", "--force", linked.to_str().unwrap()],
2420        );
2421        let _ = std::fs::remove_dir_all(&root);
2422    }
2423
2424    #[test]
2425    fn a_successful_read_that_writes_is_discarded() {
2426        let dir = committed_repo("successful-read-write");
2427        let agent = Agent::with_bin(
2428            shell("reader", "printf 'recover me\n' > README.md; echo reviewed"),
2429            "/bin/sh",
2430        );
2431
2432        let err = agent.ask("read it", &dir, &Effort::default()).unwrap_err();
2433
2434        assert_eq!(ErrorKind::UncertainWrite, err.kind());
2435        assert!(err.message().contains("read-only call"), "{err}");
2436        assert_eq!(
2437            "recover me\n",
2438            std::fs::read_to_string(dir.join("README.md")).unwrap()
2439        );
2440        assert!(std::fs::read_dir(&dir).unwrap().flatten().any(|entry| entry
2441            .file_name()
2442            .to_string_lossy()
2443            .starts_with(".spar-recovery-needed-")));
2444        let _ = std::fs::remove_dir_all(&dir);
2445    }
2446
2447    /// A repository that ignores build output, with one build already in it.
2448    fn built_repo(name: &str) -> PathBuf {
2449        let dir = committed_repo(name);
2450        std::fs::write(dir.join(".gitignore"), "dist/\nlocal.env\n").unwrap();
2451        git_at(&dir, &["add", ".gitignore"]);
2452        git_at(&dir, &["commit", "-q", "-m", "ignore build output"]);
2453        std::fs::create_dir_all(dir.join("dist")).unwrap();
2454        std::fs::write(dir.join("dist/index.js"), "first build\n").unwrap();
2455        dir
2456    }
2457
2458    #[test]
2459    fn a_read_that_only_rebuilds_generated_output_keeps_its_answer() {
2460        let dir = built_repo("successful-read-build");
2461        let agent = Agent::with_bin(
2462            shell(
2463                "reader",
2464                "printf 'second build\n' > dist/index.js; printf 'more\n' > dist/extra.js; \
2465                 echo reviewed",
2466            ),
2467            "/bin/sh",
2468        );
2469
2470        let answer = agent
2471            .ask("read it", &dir, &Effort::default())
2472            .expect("answer kept");
2473
2474        assert_eq!("reviewed", answer.trim());
2475        assert_eq!(
2476            "second build\n",
2477            std::fs::read_to_string(dir.join("dist/index.js")).unwrap()
2478        );
2479        assert!(
2480            !std::fs::read_dir(&dir).unwrap().flatten().any(|entry| entry
2481                .file_name()
2482                .to_string_lossy()
2483                .starts_with(".spar-recovery-needed-"))
2484        );
2485        let _ = std::fs::remove_dir_all(&dir);
2486    }
2487
2488    /// The list of build directories was always a guess. This is the case that
2489    /// proved it: a generator writing a lockfile beside its own manifest, which
2490    /// the project ignores and no directory name catches.
2491    #[test]
2492    fn a_read_that_writes_a_new_ignored_file_elsewhere_keeps_its_answer() {
2493        let dir = built_repo("successful-read-ignored");
2494        let agent = Agent::with_bin(
2495            shell(
2496                "reader",
2497                "printf 'second build\n' > dist/index.js; printf 'TOKEN=x\n' > local.env; \
2498                 echo reviewed",
2499            ),
2500            "/bin/sh",
2501        );
2502
2503        let answer = agent
2504            .ask("read it", &dir, &Effort::default())
2505            .expect("answer kept");
2506
2507        assert_eq!("reviewed", answer.trim());
2508        assert_eq!(
2509            "TOKEN=x\n",
2510            std::fs::read_to_string(dir.join("local.env")).unwrap()
2511        );
2512        assert!(
2513            !std::fs::read_dir(&dir).unwrap().flatten().any(|entry| entry
2514                .file_name()
2515                .to_string_lossy()
2516                .starts_with(".spar-recovery-needed-"))
2517        );
2518        let _ = std::fs::remove_dir_all(&dir);
2519    }
2520
2521    #[test]
2522    fn a_read_that_rewrites_an_existing_ignored_file_elsewhere_is_discarded() {
2523        let dir = built_repo("successful-read-existing-ignored");
2524        std::fs::write(dir.join("local.env"), "TOKEN=before\n").unwrap();
2525        let agent = Agent::with_bin(
2526            shell(
2527                "reader",
2528                "printf 'TOKEN=after\n' > local.env; echo reviewed",
2529            ),
2530            "/bin/sh",
2531        );
2532
2533        let err = agent.ask("read it", &dir, &Effort::default()).unwrap_err();
2534
2535        assert_eq!(ErrorKind::UncertainWrite, err.kind());
2536        assert!(err.message().contains("read-only call"), "{err}");
2537        assert_eq!(
2538            "TOKEN=after\n",
2539            std::fs::read_to_string(dir.join("local.env")).unwrap()
2540        );
2541        let _ = std::fs::remove_dir_all(&dir);
2542    }
2543
2544    #[test]
2545    fn a_read_that_changes_a_tracked_file_is_still_discarded() {
2546        let dir = built_repo("successful-read-tracked");
2547        let agent = Agent::with_bin(
2548            shell(
2549                "reader",
2550                "printf 'second build\n' > dist/index.js; printf 'recover me\n' > README.md; \
2551                 echo reviewed",
2552            ),
2553            "/bin/sh",
2554        );
2555
2556        let err = agent.ask("read it", &dir, &Effort::default()).unwrap_err();
2557
2558        assert_eq!(ErrorKind::UncertainWrite, err.kind());
2559        assert!(err.message().contains("read-only call"), "{err}");
2560        assert_eq!(
2561            "recover me\n",
2562            std::fs::read_to_string(dir.join("README.md")).unwrap()
2563        );
2564        let _ = std::fs::remove_dir_all(&dir);
2565    }
2566
2567    #[test]
2568    fn a_successful_edit_cannot_replace_the_git_directory() {
2569        let dir = committed_repo("replaced-git-directory");
2570        let agent = Agent::with_bin(
2571            shell("editor", "mv .git .git-original; mkdir .git; echo done"),
2572            "/bin/sh",
2573        );
2574
2575        let err = agent
2576            .edit("change it", &dir, &Effort::default())
2577            .unwrap_err();
2578
2579        assert_eq!(ErrorKind::UncertainWrite, err.kind());
2580        assert!(err.message().contains("Git entry"), "{err}");
2581        assert!(dir.join(".git-original").is_dir());
2582        std::fs::remove_dir(dir.join(".git")).unwrap();
2583        std::fs::rename(dir.join(".git-original"), dir.join(".git")).unwrap();
2584        let _ = std::fs::remove_dir_all(&dir);
2585    }
2586
2587    #[test]
2588    fn a_deleted_linked_worktree_git_marker_is_restored_without_a_fallback() {
2589        let (root, main, linked, marker) = linked_worktree("deleted-git-marker");
2590        let agent = with_fallback(
2591            shell("editor", "rm .git; echo done"),
2592            shell("backup", "printf 'fallback ran\n' > fallback.txt"),
2593        );
2594
2595        let err = agent
2596            .edit("change it", &linked, &Effort::default())
2597            .unwrap_err();
2598
2599        assert_eq!(ErrorKind::UncertainWrite, err.kind());
2600        assert!(err.message().contains("deleted"), "{err}");
2601        assert_eq!(marker, std::fs::read(linked.join(".git")).unwrap());
2602        assert!(!linked.join(GIT_MARKER_RECOVERY).exists());
2603        assert!(!linked.join("fallback.txt").exists());
2604        git_at(
2605            &main,
2606            &["worktree", "remove", "--force", linked.to_str().unwrap()],
2607        );
2608        let _ = std::fs::remove_dir_all(&root);
2609    }
2610
2611    #[test]
2612    fn an_occupied_marker_recovery_path_keeps_the_changed_marker() {
2613        let (root, main, linked, marker) = linked_worktree("occupied-marker-recovery");
2614        let agent = with_fallback(
2615            shell(
2616                "editor",
2617                "mkdir .spar-edited-git-marker; \
2618                 printf 'gitdir: /tmp/not-the-repo\n' > .git; echo done",
2619            ),
2620            shell("backup", "printf 'fallback ran\n' > fallback.txt"),
2621        );
2622
2623        let err = agent
2624            .edit("change it", &linked, &Effort::default())
2625            .unwrap_err();
2626
2627        assert_eq!(ErrorKind::UncertainWrite, err.kind());
2628        assert!(err.message().contains("already exists"), "{err}");
2629        assert_eq!(
2630            b"gitdir: /tmp/not-the-repo\n",
2631            std::fs::read(linked.join(".git")).unwrap().as_slice()
2632        );
2633        assert!(linked.join(GIT_MARKER_RECOVERY).is_dir());
2634        assert!(!linked.join("fallback.txt").exists());
2635        std::fs::write(linked.join(".git"), marker).unwrap();
2636        git_at(
2637            &main,
2638            &["worktree", "remove", "--force", linked.to_str().unwrap()],
2639        );
2640        let _ = std::fs::remove_dir_all(&root);
2641    }
2642
2643    #[test]
2644    fn without_a_fallback_the_original_error_is_what_surfaces() {
2645        let agent = Agent::with_bin(shell("primary", "echo refused >&2; exit 1"), "/bin/sh");
2646        let err = agent
2647            .ask("hi", Path::new("."), &Effort::default())
2648            .expect_err("no backup");
2649        assert!(err.message().contains("refused"), "{err}");
2650    }
2651
2652    /// The reason the run stopped is the primary's, not the backup's, so it
2653    /// leads. A backup that is simply not installed explains nothing.
2654    #[test]
2655    fn both_failing_reports_the_primary_first() {
2656        let agent = with_fallback(
2657            shell("primary", "echo policy refusal >&2; exit 1"),
2658            shell("backup", "echo out of quota >&2; exit 1"),
2659        );
2660        let err = agent
2661            .ask("hi", Path::new("."), &Effort::default())
2662            .expect_err("both fail");
2663        let text = err.message();
2664        let primary_at = text.find("policy refusal").expect("primary reason");
2665        let backup_at = text.find("out of quota").expect("backup reason");
2666        assert!(primary_at < backup_at, "{text}");
2667        assert!(
2668            text.contains("primary") && text.contains("backup"),
2669            "{text}"
2670        );
2671    }
2672
2673    /// A counter file, so a test can say how many times the CLI was actually
2674    /// run rather than only what came back.
2675    fn attempts(name: &str) -> (PathBuf, String) {
2676        let path = std::env::temp_dir().join(format!("spar-attempts-{name}"));
2677        let _ = std::fs::remove_file(&path);
2678        let line = format!("echo x >> {}", path.display());
2679        (path, line)
2680    }
2681
2682    fn counted(path: &Path) -> usize {
2683        std::fs::read_to_string(path)
2684            .map(|t| t.lines().count())
2685            .unwrap_or(0)
2686    }
2687
2688    /// The failure that prompted this. A policy refusal came back twice, at
2689    /// full effort, before the stand in was given the call. The second was
2690    /// never going to be different: nothing about a refusal, a quota, or a
2691    /// crash is corrected by being asked the same thing again.
2692    #[test]
2693    fn a_cli_that_could_not_answer_is_not_asked_twice_when_there_is_a_stand_in() {
2694        let (path, count) = attempts("refused-with-standin");
2695        let agent = with_fallback(
2696            shell("primary", &format!("{count}; echo refused >&2; exit 1")),
2697            shell("backup", "echo '{}'"),
2698        );
2699        let answer: Value = agent
2700            .ask_json(
2701                "q",
2702                &serde_json::json!({"type": "object"}),
2703                Path::new("."),
2704                &Effort::default(),
2705            )
2706            .expect("the stand in answers");
2707        assert!(answer.is_object());
2708        assert_eq!(1, counted(&path), "the primary was asked more than once");
2709    }
2710
2711    /// With nowhere to send the call, the retry is the only thing left, so it
2712    /// still happens. A transient failure is the case it was there for.
2713    #[test]
2714    fn with_no_stand_in_a_failed_call_is_still_retried() {
2715        let (path, count) = attempts("refused-alone");
2716        let agent = Agent::with_bin(
2717            shell("solo", &format!("{count}; echo refused >&2; exit 1")),
2718            "/bin/sh",
2719        );
2720        let err = agent
2721            .ask_json::<Value>(
2722                "q",
2723                &serde_json::json!({"type": "object"}),
2724                Path::new("."),
2725                &Effort::default(),
2726            )
2727            .expect_err("nothing answers");
2728        assert!(err.message().contains("twice"), "{err}");
2729        assert_eq!(2, counted(&path));
2730    }
2731
2732    /// The retry that must survive. An answer that arrived and could not be
2733    /// parsed is exactly what it is for, and a model corrects a shape error
2734    /// readily when handed the parser's complaint.
2735    #[test]
2736    fn an_unusable_answer_is_still_worth_asking_again() {
2737        let (path, count) = attempts("unparsable");
2738        let agent = with_fallback(
2739            shell("primary", &format!("{count}; echo not json at all")),
2740            shell("backup", "echo '{}'"),
2741        );
2742        let answer: Value = agent
2743            .ask_json(
2744                "q",
2745                &serde_json::json!({"type": "object"}),
2746                Path::new("."),
2747                &Effort::default(),
2748            )
2749            .expect("the stand in answers in the end");
2750        assert!(answer.is_object());
2751        assert_eq!(2, counted(&path), "a shape error is worth one more ask");
2752    }
2753
2754    /// A deadline is not worth asking the same CLI again, and `ask_json` does
2755    /// not. A different CLI is a different question, and the alternative is
2756    /// losing the run.
2757    #[test]
2758    fn a_timeout_still_reaches_the_fallback() {
2759        let mut primary = shell("primary", "sleep 30");
2760        primary.timeout = 1;
2761        let agent = with_fallback(primary, shell("backup", "echo stood in"));
2762        assert_eq!(
2763            "stood in",
2764            agent
2765                .ask("hi", Path::new("."), &Effort::default())
2766                .expect("fallback")
2767        );
2768    }
2769
2770    /// The fallback is built with the agent, not looked up later, so a spec
2771    /// that carries one produces an agent that carries one.
2772    #[test]
2773    fn the_fallback_is_built_alongside_the_agent() {
2774        let mut primary = shell("primary", "true");
2775        primary.fallback = Some(Box::new(shell("backup", "true")));
2776        let agent = Agent::new(primary);
2777        assert_eq!(Some("backup"), agent.fallback().map(Agent::name));
2778        assert!(Agent::new(shell("solo", "true")).fallback().is_none());
2779    }
2780}
2781
2782#[cfg(test)]
2783mod schema_placeholder_tests {
2784    use super::*;
2785    use crate::config::{OutputMode, SystemVia};
2786
2787    fn spec_with(command: Vec<CommandPart>) -> AgentSpec {
2788        AgentSpec {
2789            name: "claude".into(),
2790            command,
2791            model: None,
2792            effort: None,
2793            effort_schedule: crate::config::EffortSchedule::default(),
2794            output: OutputMode::Text,
2795            message_match: BTreeMap::new(),
2796            message_path: None,
2797            search_paths: vec![],
2798            system_via: SystemVia::Prompt,
2799            timeout: 60,
2800            fallback: None,
2801            models: vec![],
2802            efforts: vec![],
2803            options_note: None,
2804        }
2805    }
2806
2807    fn one(s: &str) -> CommandPart {
2808        CommandPart::One(s.into())
2809    }
2810    fn group(parts: &[&str]) -> CommandPart {
2811        CommandPart::Group(parts.iter().map(|s| s.to_string()).collect())
2812    }
2813
2814    /// Claude Code takes the schema as an argument, not a path, so the file
2815    /// form alone was not enough to give it native structured output.
2816    #[test]
2817    fn either_schema_form_counts_as_native_support() {
2818        let inline = Agent::with_bin(
2819            spec_with(vec![one("x"), group(&["--json-schema", "{schema}"])]),
2820            "/b",
2821        );
2822        let byfile = Agent::with_bin(
2823            spec_with(vec![one("x"), group(&["--output-schema", "{schema_file}"])]),
2824            "/b",
2825        );
2826        let neither = Agent::with_bin(spec_with(vec![one("x"), one("{prompt}")]), "/b");
2827        assert!(inline.supports_schema());
2828        assert!(byfile.supports_schema());
2829        assert!(!neither.supports_schema());
2830    }
2831
2832    #[test]
2833    fn the_inline_schema_is_substituted_whole() {
2834        let agent = Agent::with_bin(
2835            spec_with(vec![
2836                one("x"),
2837                group(&["--json-schema", "{schema}"]),
2838                one("{prompt}"),
2839            ]),
2840            "/b",
2841        );
2842        let values = Placeholders {
2843            prompt: Some("review it".into()),
2844            schema: Some(r#"{"type":"object"}"#.into()),
2845            ..Default::default()
2846        };
2847        assert_eq!(
2848            vec!["/b", "--json-schema", r#"{"type":"object"}"#, "review it"],
2849            agent.render(&values).unwrap()
2850        );
2851    }
2852
2853    /// A call that wants prose back passes no schema, and the flag must go with
2854    /// it rather than being handed an empty string.
2855    #[test]
2856    fn the_schema_flag_drops_when_no_schema_is_wanted() {
2857        let agent = Agent::with_bin(
2858            spec_with(vec![
2859                one("x"),
2860                group(&["--json-schema", "{schema}"]),
2861                one("{prompt}"),
2862            ]),
2863            "/b",
2864        );
2865        let values = Placeholders {
2866            prompt: Some("implement it".into()),
2867            ..Default::default()
2868        };
2869        assert_eq!(vec!["/b", "implement it"], agent.render(&values).unwrap());
2870    }
2871
2872    /// `{schema_file}` must not be mistaken for `{schema}`.
2873    #[test]
2874    fn the_two_schema_placeholders_do_not_collide() {
2875        let agent = Agent::with_bin(
2876            spec_with(vec![one("x"), group(&["--output-schema", "{schema_file}"])]),
2877            "/b",
2878        );
2879        let values = Placeholders {
2880            schema: Some("INLINE".into()),
2881            schema_file: Some("/tmp/s.json".into()),
2882            ..Default::default()
2883        };
2884        assert_eq!(
2885            vec!["/b", "--output-schema", "/tmp/s.json"],
2886            agent.render(&values).unwrap()
2887        );
2888    }
2889
2890    /// The preset that was failing in the field.
2891    #[test]
2892    fn the_shipped_claude_preset_now_has_native_structured_output() {
2893        let raw = crate::config::load_preset("claude").unwrap();
2894        let table = raw.as_table().cloned().unwrap();
2895        let mut spec: AgentSpec = toml::Value::Table(table).try_into().unwrap();
2896        spec.name = "claude".into();
2897        assert!(
2898            Agent::with_bin(spec, "/b").supports_schema(),
2899            "without this a long review is parsed out of prose and truncates"
2900        );
2901    }
2902}