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