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    ///
1435    /// Tokens are found in the template argument only. An inserted value is
1436    /// copied verbatim, so a prompt that quotes `{model}` reaches the agent as
1437    /// written instead of being rewritten or dropping the argument.
1438    fn substitute(&self, arg: &str) -> Option<String> {
1439        const KEYS: [&str; 7] = [
1440            "prompt",
1441            "system",
1442            "model",
1443            "effort",
1444            "cwd",
1445            "schema_file",
1446            "schema",
1447        ];
1448        let mut out = String::with_capacity(arg.len());
1449        let mut rest = arg;
1450        while let Some(open) = rest.find('{') {
1451            out.push_str(&rest[..open]);
1452            rest = &rest[open..];
1453            let token = KEYS.iter().find_map(|key| {
1454                let after = rest.strip_prefix('{')?.strip_prefix(key)?;
1455                after.starts_with('}').then_some((*key, key.len() + 2))
1456            });
1457            match token {
1458                Some((key, len)) => {
1459                    out.push_str(self.get(key)?);
1460                    rest = &rest[len..];
1461                }
1462                None => {
1463                    out.push('{');
1464                    rest = &rest[1..];
1465                }
1466            }
1467        }
1468        out.push_str(rest);
1469        Some(out)
1470    }
1471}
1472
1473// ---------------------------------------------------------------------------
1474// JSONL helpers
1475// ---------------------------------------------------------------------------
1476
1477/// Follow a dotted path, returning None if any hop is missing.
1478fn dig<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
1479    if path.is_empty() {
1480        return None;
1481    }
1482    let mut node = value;
1483    for part in path.split('.') {
1484        node = node.as_object()?.get(part)?;
1485    }
1486    Some(node)
1487}
1488
1489fn matches(event: &Value, wanted: &BTreeMap<String, String>) -> bool {
1490    if wanted.is_empty() {
1491        return false;
1492    }
1493    wanted
1494        .iter()
1495        .all(|(path, expected)| dig(event, path).and_then(Value::as_str) == Some(expected.as_str()))
1496}
1497
1498fn as_text(value: &Value) -> Option<String> {
1499    match value {
1500        Value::String(s) => Some(s.clone()),
1501        Value::Null => None,
1502        other => Some(other.to_string()),
1503    }
1504}
1505
1506fn truncate(text: &str, max: usize) -> String {
1507    text.chars().take(max).collect()
1508}
1509
1510// ---------------------------------------------------------------------------
1511// Temporary schema file
1512// ---------------------------------------------------------------------------
1513
1514/// A schema written somewhere the CLI can read it, removed when it goes out of
1515/// scope even if the agent call fails.
1516struct TempJson {
1517    path: PathBuf,
1518}
1519
1520impl TempJson {
1521    fn write(value: &Value) -> Result<Self> {
1522        use std::sync::atomic::{AtomicU64, Ordering};
1523        static COUNTER: AtomicU64 = AtomicU64::new(0);
1524
1525        let nanos = std::time::SystemTime::now()
1526            .duration_since(std::time::UNIX_EPOCH)
1527            .map(|d| d.as_nanos())
1528            .unwrap_or(0);
1529        let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
1530        let path = std::env::temp_dir().join(format!(
1531            "spar-schema-{}-{nanos}-{unique}.json",
1532            std::process::id()
1533        ));
1534        std::fs::write(&path, serde_json::to_vec_pretty(value)?)
1535            .map_err(|e| spar_err!("could not write a schema file to {}: {e}", path.display()))?;
1536        Ok(Self { path })
1537    }
1538
1539    fn path(&self) -> &Path {
1540        &self.path
1541    }
1542}
1543
1544impl Drop for TempJson {
1545    fn drop(&mut self) {
1546        let _ = std::fs::remove_file(&self.path);
1547    }
1548}
1549
1550// ---------------------------------------------------------------------------
1551// Correlation
1552// ---------------------------------------------------------------------------
1553
1554/// Whether two paths name the same executable.
1555///
1556/// Comparing the raw strings misses aliases: a symlink or a hard link points at
1557/// the same binary under a different path, which would let two agents run the
1558/// identical CLI without tripping the warning below. Device and inode see
1559/// through both.
1560fn same_executable(a: &Path, b: &Path) -> bool {
1561    #[cfg(unix)]
1562    {
1563        use std::os::unix::fs::MetadataExt;
1564        if let (Ok(x), Ok(y)) = (std::fs::metadata(a), std::fs::metadata(b)) {
1565            return x.dev() == y.dev() && x.ino() == y.ino();
1566        }
1567    }
1568    match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
1569        (Ok(x), Ok(y)) => x == y,
1570        _ => a == b,
1571    }
1572}
1573
1574/// Two agents are only an independent review if they can actually disagree.
1575///
1576/// Config keys are arbitrary, so `alpha` and `beta` can both be Claude on the
1577/// same model. Compare what actually runs: the resolved binary and the
1578/// configured model, never the names.
1579pub fn correlation_warning(agents: &[Agent]) -> Option<String> {
1580    for i in 0..agents.len() {
1581        for j in (i + 1)..agents.len() {
1582            let (a, b) = (&agents[i], &agents[j]);
1583            let (Ok(pa), Ok(pb)) = (a.resolve_bin(), b.resolve_bin()) else {
1584                continue;
1585            };
1586            if !same_executable(pa, pb) || a.spec.model_key() != b.spec.model_key() {
1587                continue;
1588            }
1589            let model = if a.spec.model_key().is_empty() {
1590                "the CLI's default".to_string()
1591            } else {
1592                a.spec.model_key()
1593            };
1594            let where_at = if pa == pb {
1595                pa.display().to_string()
1596            } else {
1597                format!(
1598                    "the same executable ({} and {} are the same file)",
1599                    pa.display(),
1600                    pb.display()
1601                )
1602            };
1603            return Some(format!(
1604                "agents '{}' and '{}' both resolve to {where_at} at model {model}. Review \
1605                 findings will be correlated: the same model reviewing itself shares the blind \
1606                 spots of the model that wrote the code, so it is far less likely to catch what \
1607                 the implementer missed. That produces an approval indistinguishable from a real \
1608                 review, which is worse than no review at all. Give the two agents different \
1609                 CLIs or different models.",
1610                a.name(),
1611                b.name()
1612            ));
1613        }
1614    }
1615    None
1616}
1617
1618/// Build every configured agent, resolving each binary up front so a missing
1619/// CLI fails before any model is billed.
1620pub fn build(cfg: &crate::config::Config) -> Result<Vec<Agent>> {
1621    let agents: Vec<Agent> = cfg
1622        .agents
1623        .iter()
1624        .cloned()
1625        .map(Agent::new)
1626        .map(|agent| agent.with_instructions(&cfg.loop_cfg.instructions))
1627        .collect();
1628    for agent in &agents {
1629        agent.resolve_bin()?;
1630        // A backup that is not installed must not stop a run whose pair is
1631        // fine. Said once here, at the start, rather than an hour in at the
1632        // moment it was needed and could not be reached.
1633        if let Some(backup) = agent.fallback() {
1634            if backup.resolve_bin().is_err() {
1635                logwarn!(
1636                    "{} has a fallback ({}) that is not installed, so it will not stand in",
1637                    agent.name(),
1638                    backup.program()
1639                );
1640            }
1641        }
1642    }
1643    Ok(agents)
1644}
1645
1646/// Look an agent up by name in a built list.
1647pub fn find<'a>(agents: &'a [Agent], name: &str) -> Result<&'a Agent> {
1648    agents.iter().find(|a| a.name() == name).ok_or_else(|| {
1649        SparError::new(format!(
1650            "no agent named '{name}' ({})",
1651            agents
1652                .iter()
1653                .map(Agent::name)
1654                .collect::<Vec<_>>()
1655                .join(", ")
1656        ))
1657    })
1658}
1659
1660#[cfg(test)]
1661mod tests {
1662    use super::*;
1663    use crate::config::{OutputMode, SystemVia};
1664
1665    fn spec(command: Vec<CommandPart>) -> AgentSpec {
1666        AgentSpec {
1667            name: "test".into(),
1668            command,
1669            model: None,
1670            effort: None,
1671            effort_schedule: crate::config::EffortSchedule::default(),
1672            output: OutputMode::Text,
1673            message_match: BTreeMap::new(),
1674            message_path: None,
1675            search_paths: vec![],
1676            system_via: SystemVia::Prompt,
1677            timeout: 60,
1678            fallback: None,
1679            models: vec![],
1680            efforts: vec![],
1681            options_note: None,
1682        }
1683    }
1684
1685    fn one(s: &str) -> CommandPart {
1686        CommandPart::One(s.into())
1687    }
1688
1689    fn group(parts: &[&str]) -> CommandPart {
1690        CommandPart::Group(parts.iter().map(|s| s.to_string()).collect())
1691    }
1692
1693    #[test]
1694    fn an_uncertain_process_result_skips_post_call_recovery() {
1695        let called: Result<()> = Err(SparError::uncertain_write("descendants may still write"));
1696        let recovered = std::cell::Cell::new(false);
1697
1698        let error = after_call_is_quiet(&called, || {
1699            recovered.set(true);
1700            Ok(())
1701        })
1702        .unwrap_err();
1703
1704        assert_eq!(ErrorKind::UncertainWrite, error.kind());
1705        assert!(!recovered.get());
1706    }
1707
1708    fn agent(command: Vec<CommandPart>) -> Agent {
1709        Agent::with_bin(spec(command), "/fake/bin")
1710    }
1711
1712    fn values() -> Placeholders {
1713        Placeholders {
1714            prompt: Some("hi".into()),
1715            ..Default::default()
1716        }
1717    }
1718
1719    // -- rendering -------------------------------------------------------
1720
1721    #[test]
1722    fn placeholders_are_substituted() {
1723        let a = agent(vec![one("x"), group(&["-m", "{model}"]), one("{prompt}")]);
1724        let v = Placeholders {
1725            model: Some("m1".into()),
1726            ..values()
1727        };
1728        assert_eq!(vec!["/fake/bin", "-m", "m1", "hi"], a.render(&v).unwrap());
1729    }
1730
1731    #[test]
1732    fn an_unset_placeholder_drops_the_whole_group() {
1733        let a = agent(vec![one("x"), group(&["-m", "{model}"]), one("{prompt}")]);
1734        assert_eq!(vec!["/fake/bin", "hi"], a.render(&values()).unwrap());
1735    }
1736
1737    #[test]
1738    fn an_empty_string_drops_the_group_too() {
1739        let a = agent(vec![one("x"), group(&["-e", "{effort}"]), one("{prompt}")]);
1740        let v = Placeholders {
1741            effort: Some(String::new()),
1742            ..values()
1743        };
1744        assert_eq!(vec!["/fake/bin", "hi"], a.render(&v).unwrap());
1745    }
1746
1747    #[test]
1748    fn a_bare_arg_with_an_unset_placeholder_drops() {
1749        let a = agent(vec![one("x"), one("{model}"), one("{prompt}")]);
1750        assert_eq!(vec!["/fake/bin", "hi"], a.render(&values()).unwrap());
1751    }
1752
1753    #[test]
1754    fn literal_args_survive() {
1755        let a = agent(vec![
1756            one("x"),
1757            one("exec"),
1758            one("--json"),
1759            one("--"),
1760            one("{prompt}"),
1761        ]);
1762        assert_eq!(
1763            vec!["/fake/bin", "exec", "--json", "--", "hi"],
1764            a.render(&values()).unwrap()
1765        );
1766    }
1767
1768    #[test]
1769    fn an_embedded_placeholder_substitutes_in_place() {
1770        let a = agent(vec![
1771            one("x"),
1772            group(&["-c", "model_reasoning_effort={effort}"]),
1773        ]);
1774        let v = Placeholders {
1775            effort: Some("ultra".into()),
1776            ..Default::default()
1777        };
1778        assert_eq!(
1779            vec!["/fake/bin", "-c", "model_reasoning_effort=ultra"],
1780            a.render(&v).unwrap()
1781        );
1782    }
1783
1784    #[test]
1785    fn a_group_with_two_placeholders_needs_both() {
1786        let a = agent(vec![
1787            one("x"),
1788            group(&["--a", "{model}", "--b", "{effort}"]),
1789        ]);
1790        let v = Placeholders {
1791            model: Some("m".into()),
1792            ..Default::default()
1793        };
1794        assert_eq!(vec!["/fake/bin"], a.render(&v).unwrap());
1795    }
1796
1797    /// Issue text that quotes a placeholder is data, not template.
1798    #[test]
1799    fn a_placeholder_quoted_in_the_prompt_is_kept_when_set() {
1800        let a = agent(vec![one("x"), group(&["-m", "{model}"]), one("{prompt}")]);
1801        let v = Placeholders {
1802            prompt: Some("Issue: Render {model} verbatim".into()),
1803            model: Some("m1".into()),
1804            ..Default::default()
1805        };
1806        assert_eq!(
1807            vec!["/fake/bin", "-m", "m1", "Issue: Render {model} verbatim"],
1808            a.render(&v).unwrap()
1809        );
1810    }
1811
1812    #[test]
1813    fn a_placeholder_quoted_in_the_prompt_is_kept_when_unset() {
1814        let a = agent(vec![one("x"), group(&["-p", "{prompt}"])]);
1815        let v = Placeholders {
1816            prompt: Some("Render {model} and {effort} verbatim".into()),
1817            ..Default::default()
1818        };
1819        assert_eq!(
1820            vec!["/fake/bin", "-p", "Render {model} and {effort} verbatim"],
1821            a.render(&v).unwrap()
1822        );
1823    }
1824
1825    /// One value is not rescanned for the placeholders that follow it either.
1826    #[test]
1827    fn an_inserted_value_is_not_rescanned_within_its_argument() {
1828        let a = agent(vec![one("x"), one("{system}:{model}")]);
1829        let v = Placeholders {
1830            system: Some("{effort}".into()),
1831            model: Some("m1".into()),
1832            ..Default::default()
1833        };
1834        assert_eq!(vec!["/fake/bin", "{effort}:m1"], a.render(&v).unwrap());
1835    }
1836
1837    #[test]
1838    fn braces_that_are_not_placeholders_survive() {
1839        let a = agent(vec![one("x"), one("{{model}} {other} {"), one("{model")]);
1840        let v = Placeholders {
1841            model: Some("m1".into()),
1842            ..Default::default()
1843        };
1844        assert_eq!(
1845            vec!["/fake/bin", "{m1} {other} {", "{model"],
1846            a.render(&v).unwrap()
1847        );
1848    }
1849
1850    #[test]
1851    fn supports_schema_detects_the_placeholder() {
1852        assert!(agent(vec![one("x"), group(&["--schema", "{schema_file}"])]).supports_schema());
1853        assert!(!agent(vec![one("x"), one("{prompt}")]).supports_schema());
1854    }
1855
1856    // -- output adapters -------------------------------------------------
1857
1858    #[test]
1859    fn text_passes_through_trimmed() {
1860        assert_eq!("hello", agent(vec![one("x")]).extract("  hello\n").unwrap());
1861    }
1862
1863    #[test]
1864    fn jsonl_picks_the_matching_event() {
1865        let mut spec = spec(vec![one("x")]);
1866        spec.output = OutputMode::Jsonl;
1867        spec.message_path = Some("item.text".into());
1868        spec.message_match = BTreeMap::from([
1869            ("type".to_string(), "item.completed".to_string()),
1870            ("item.type".to_string(), "agent_message".to_string()),
1871        ]);
1872        let a = Agent::with_bin(spec, "/fake/bin");
1873        let stream = [
1874            r#"{"type":"thread.started","thread_id":"t1"}"#,
1875            r#"{"type":"item.completed","item":{"type":"command_execution","text":"ls"}}"#,
1876            r#"{"type":"item.completed","item":{"type":"agent_message","text":"the answer"}}"#,
1877            "not json at all",
1878        ]
1879        .join("\n");
1880        assert_eq!("the answer", a.extract(&stream).unwrap());
1881    }
1882
1883    #[test]
1884    fn jsonl_raises_on_an_error_with_no_message() {
1885        let mut spec = spec(vec![one("x")]);
1886        spec.output = OutputMode::Jsonl;
1887        spec.message_path = Some("item.text".into());
1888        spec.message_match = BTreeMap::from([("type".into(), "item.completed".into())]);
1889        let a = Agent::with_bin(spec, "/fake/bin");
1890        assert!(a
1891            .extract(r#"{"type":"turn.failed","error":"boom"}"#)
1892            .is_err());
1893    }
1894
1895    #[test]
1896    fn jsonl_joins_several_agent_messages() {
1897        let mut spec = spec(vec![one("x")]);
1898        spec.output = OutputMode::Jsonl;
1899        spec.message_path = Some("text".into());
1900        spec.message_match = BTreeMap::from([("type".into(), "msg".into())]);
1901        let a = Agent::with_bin(spec, "/fake/bin");
1902        let stream = "{\"type\":\"msg\",\"text\":\"one\"}\n{\"type\":\"msg\",\"text\":\"two\"}";
1903        assert_eq!("one\ntwo", a.extract(stream).unwrap());
1904    }
1905
1906    #[test]
1907    fn dig_walks_a_dotted_path() {
1908        let v: Value = serde_json::from_str(r#"{"a":{"b":{"c":1}}}"#).unwrap();
1909        assert_eq!(Some(&Value::from(1)), dig(&v, "a.b.c"));
1910        assert_eq!(None, dig(&v, "a.b.missing"));
1911        assert_eq!(None, dig(&v, ""));
1912    }
1913
1914    // -- binary resolution -----------------------------------------------
1915
1916    #[test]
1917    fn a_missing_binary_lists_everywhere_it_looked() {
1918        let mut s = spec(vec![one("definitely-not-installed-xyz")]);
1919        s.search_paths = vec!["/nowhere/at/all".into()];
1920        s.name = "codex".into();
1921        let err = Agent::new(s).resolve_bin().unwrap_err().to_string();
1922        assert!(err.contains("definitely-not-installed-xyz (PATH)"), "{err}");
1923        assert!(
1924            err.contains("/nowhere/at/all/definitely-not-installed-xyz"),
1925            "{err}"
1926        );
1927        assert!(err.contains("SPAR_CODEX_BIN"), "{err}");
1928    }
1929
1930    #[test]
1931    fn a_search_path_that_already_names_the_binary_is_used_as_is() {
1932        let dir = std::env::temp_dir().join(format!("spar-test-{}", std::process::id()));
1933        std::fs::create_dir_all(&dir).unwrap();
1934        let bin = dir.join("mytool");
1935        std::fs::write(&bin, "#!/bin/sh\n").unwrap();
1936        #[cfg(unix)]
1937        {
1938            use std::os::unix::fs::PermissionsExt;
1939            std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
1940        }
1941        let mut s = spec(vec![one("mytool")]);
1942        s.search_paths = vec![bin.display().to_string()];
1943        assert_eq!(bin, Agent::new(s).resolve_bin().unwrap());
1944        let _ = std::fs::remove_dir_all(&dir);
1945    }
1946
1947    // -- correlation -----------------------------------------------------
1948
1949    fn named(name: &str, bin: &str, model: Option<&str>) -> Agent {
1950        let mut s = spec(vec![one("prog")]);
1951        s.name = name.into();
1952        s.model = model.map(str::to_string);
1953        Agent::with_bin(s, bin)
1954    }
1955
1956    #[test]
1957    fn same_bin_same_model_warns() {
1958        let agents = vec![
1959            named("alpha", "/usr/local/bin/claude", Some("fable")),
1960            named("beta", "/usr/local/bin/claude", Some("fable")),
1961        ];
1962        let msg = correlation_warning(&agents).expect("should warn");
1963        assert!(msg.contains("alpha") && msg.contains("beta"), "{msg}");
1964    }
1965
1966    #[test]
1967    fn different_model_does_not_warn() {
1968        let agents = vec![
1969            named("a", "/usr/local/bin/claude", Some("fable")),
1970            named("b", "/usr/local/bin/claude", Some("opus")),
1971        ];
1972        assert!(correlation_warning(&agents).is_none());
1973    }
1974
1975    #[test]
1976    fn different_bin_does_not_warn() {
1977        let agents = vec![
1978            named("a", "/usr/local/bin/claude", Some("fable")),
1979            named("b", "/usr/local/bin/codex", Some("fable")),
1980        ];
1981        assert!(correlation_warning(&agents).is_none());
1982    }
1983
1984    #[test]
1985    fn unset_and_empty_model_both_mean_the_default_and_warn() {
1986        let agents = vec![
1987            named("a", "/usr/local/bin/claude", None),
1988            named("b", "/usr/local/bin/claude", Some("")),
1989        ];
1990        let msg = correlation_warning(&agents).expect("should warn");
1991        assert!(msg.contains("the CLI's default"), "{msg}");
1992    }
1993
1994    #[test]
1995    fn a_padded_model_still_warns() {
1996        let agents = vec![
1997            named("a", "/usr/local/bin/claude", Some("fable")),
1998            named("b", "/usr/local/bin/claude", Some(" fable ")),
1999        ];
2000        assert!(correlation_warning(&agents).is_some());
2001    }
2002
2003    #[test]
2004    fn an_empty_model_against_a_named_one_does_not_warn() {
2005        let agents = vec![
2006            named("a", "/usr/local/bin/claude", Some("")),
2007            named("b", "/usr/local/bin/claude", Some("fable")),
2008        ];
2009        assert!(correlation_warning(&agents).is_none());
2010    }
2011
2012    #[cfg(unix)]
2013    #[test]
2014    fn a_symlinked_binary_warns_and_names_both_paths() {
2015        use std::os::unix::fs::PermissionsExt;
2016        let dir = std::env::temp_dir().join(format!("spar-link-{}", std::process::id()));
2017        let _ = std::fs::remove_dir_all(&dir);
2018        std::fs::create_dir_all(&dir).unwrap();
2019        let real = dir.join("claude");
2020        let link = dir.join("claude-alias");
2021        std::fs::write(&real, "#!/bin/sh\n").unwrap();
2022        std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o755)).unwrap();
2023        std::os::unix::fs::symlink(&real, &link).unwrap();
2024
2025        let agents = vec![
2026            named("alpha", real.to_str().unwrap(), Some("fable")),
2027            named("beta", link.to_str().unwrap(), Some("fable")),
2028        ];
2029        let msg = correlation_warning(&agents).expect("should warn");
2030        assert!(msg.contains(real.to_str().unwrap()), "{msg}");
2031        assert!(msg.contains(link.to_str().unwrap()), "{msg}");
2032        let _ = std::fs::remove_dir_all(&dir);
2033    }
2034
2035    #[cfg(unix)]
2036    #[test]
2037    fn two_distinct_real_binaries_stay_quiet() {
2038        use std::os::unix::fs::PermissionsExt;
2039        let dir = std::env::temp_dir().join(format!("spar-distinct-{}", std::process::id()));
2040        let _ = std::fs::remove_dir_all(&dir);
2041        std::fs::create_dir_all(&dir).unwrap();
2042        let mut paths = Vec::new();
2043        for name in ["claude", "codex"] {
2044            let path = dir.join(name);
2045            std::fs::write(&path, "#!/bin/sh\n").unwrap();
2046            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
2047            paths.push(path);
2048        }
2049        let agents = vec![
2050            named("a", paths[0].to_str().unwrap(), Some("fable")),
2051            named("b", paths[1].to_str().unwrap(), Some("fable")),
2052        ];
2053        assert!(correlation_warning(&agents).is_none());
2054        let _ = std::fs::remove_dir_all(&dir);
2055    }
2056
2057    #[test]
2058    fn the_style_rules_ask_for_brevity_and_no_attribution() {
2059        let lower = STYLE_RULES.to_lowercase();
2060        assert!(lower.contains("brief"));
2061        assert!(lower.contains("co-authored-by"));
2062        assert!(lower.contains("em-dash"));
2063    }
2064
2065    /// Brevity was measured in sentences, and "one sentence beats one paragraph"
2066    /// is what a model satisfies by joining three facts with commas. A summary
2067    /// came back as a changelog line the reader had to decipher, which is
2068    /// shorter and worse.
2069    #[test]
2070    fn brevity_is_about_facts_per_sentence_not_sentence_count() {
2071        let lower = STYLE_RULES.to_lowercase();
2072        assert!(lower.contains("saying fewer things"), "{STYLE_RULES}");
2073        assert!(
2074            !lower.contains("one sentence beats one paragraph"),
2075            "the rule that produced the density is still there"
2076        );
2077    }
2078
2079    /// The rules were scoped to what spar posts, so nothing had ever asked an
2080    /// agent for anything about the comments it writes in the code. A three
2081    /// line change came back under eight lines of comment, most of it the
2082    /// debugging story rather than the reason.
2083    #[test]
2084    fn the_style_rules_reach_the_code_and_not_only_what_is_posted() {
2085        let lower = STYLE_RULES.to_lowercase();
2086        assert!(lower.contains("comments in code you write"), "not in scope");
2087        assert!(
2088            lower.contains("comment code for the reason"),
2089            "no rule for it"
2090        );
2091    }
2092
2093    #[test]
2094    fn the_deadline_names_the_moment_and_how_long_the_call_has() {
2095        // 2026-10-25 01:44:33 UTC.
2096        let rule = deadline_rule(3600, 1_792_892_673);
2097        assert!(rule.contains("stopped at 2026-10-25 02:44 UTC"), "{rule}");
2098        assert!(rule.contains("60m00s after it started"), "{rule}");
2099        assert!(rule.contains("tests that cover the change"), "{rule}");
2100        assert!(!rule.contains(['\u{2013}', '\u{2014}']), "{rule}");
2101    }
2102
2103    /// The deadline is the timeout of whichever agent answers, so a fallback
2104    /// with a shorter one is told its own.
2105    #[test]
2106    fn the_deadline_reaches_the_cli_whichever_way_it_takes_the_system_text() {
2107        let echo = |what: &str, via: SystemVia, timeout: u64| {
2108            let mut spec = spec(vec![
2109                one("sh"),
2110                one("-c"),
2111                one("printf %s \"$1\""),
2112                one("sh"),
2113                one(what),
2114            ]);
2115            spec.system_via = via;
2116            spec.timeout = timeout;
2117            Agent::with_bin(spec, "/bin/sh")
2118                .ask("the prompt", Path::new("."), &Effort::default())
2119                .unwrap()
2120        };
2121
2122        let prompted = echo("{prompt}", SystemVia::Prompt, 60);
2123        assert!(prompted.starts_with("Style rules"), "{prompted}");
2124        assert!(prompted.contains("60s after it started"), "{prompted}");
2125        assert!(prompted.ends_with("the prompt"), "{prompted}");
2126
2127        let placed = echo("{system}", SystemVia::Placeholder, 1800);
2128        assert!(placed.starts_with("Style rules"), "{placed}");
2129        assert!(placed.contains("30m00s after it started"), "{placed}");
2130    }
2131
2132    // -- a failure said in the agent's own terms ---------------------------
2133
2134    /// The event stream from the run that prompted this: a wall of file
2135    /// contents a tool call returned, with the reason as the last two lines.
2136    fn refusal_stream() -> String {
2137        let noise = "{\"type\":\"item.completed\",\"item\":{\"id\":\"i\",\"type\":\"command_execution\",\"output\":\"".to_string()
2138            + &"const x = 1;\\n".repeat(200)
2139            + "\"}}";
2140        [
2141            noise.as_str(),
2142            r#"{"type":"error","message":"This content was flagged for possible cybersecurity risk."}"#,
2143            r#"{"type":"error","message":"This content was flagged for possible cybersecurity risk."}"#,
2144            r#"{"type":"turn.failed","error":{"message":"This content was flagged for possible cybersecurity risk."}}"#,
2145        ]
2146        .join("\n")
2147    }
2148
2149    fn jsonl_agent(name: &str) -> Agent {
2150        let mut spec = spec(vec![one("codex")]);
2151        spec.name = name.into();
2152        spec.output = OutputMode::Jsonl;
2153        spec.message_path = Some("item.text".into());
2154        Agent::with_bin(spec, "/fake/codex")
2155    }
2156
2157    fn failed(stdout: &str, stderr: &str) -> proc::Output {
2158        proc::Output {
2159            stdout: stdout.to_string(),
2160            stdout_bytes: stdout.as_bytes().to_vec(),
2161            stderr: stderr.to_string(),
2162            code: 1,
2163        }
2164    }
2165
2166    /// The reason a CLI gives is a field inside the event, not the event.
2167    /// Printing the object around it is what buried it.
2168    #[test]
2169    fn a_jsonl_failure_reports_the_reason_and_not_the_stream() {
2170        let agent = jsonl_agent("codex");
2171        let err = agent.call_failure(&["codex".to_string()], &failed(&refusal_stream(), ""));
2172        let text = err.message();
2173        assert!(
2174            text.contains("flagged for possible cybersecurity risk"),
2175            "{text}"
2176        );
2177        assert!(
2178            !text.contains("const x = 1;"),
2179            "the stream leaked in:\n{text}"
2180        );
2181        assert!(text.len() < 400, "still {} characters:\n{text}", text.len());
2182    }
2183
2184    /// One refusal reported as two errors and a turn.failed is one reason.
2185    #[test]
2186    fn the_same_reason_reported_three_times_is_said_once() {
2187        let agent = jsonl_agent("codex");
2188        let err = agent.call_failure(&["codex".to_string()], &failed(&refusal_stream(), ""));
2189        assert_eq!(
2190            1,
2191            err.message().matches("flagged for possible").count(),
2192            "{}",
2193            err.message()
2194        );
2195    }
2196
2197    /// stderr is where one CLI reports the condition behind the refusal, and it
2198    /// is short, so it survives whatever stdout turns out to hold.
2199    #[test]
2200    fn stderr_is_kept_because_it_is_where_the_other_half_arrives() {
2201        let agent = jsonl_agent("codex");
2202        let err = agent.call_failure(
2203            &["codex".to_string()],
2204            &failed(
2205                &refusal_stream(),
2206                "ERROR router: agent thread limit reached",
2207            ),
2208        );
2209        assert!(
2210            err.message().contains("agent thread limit reached"),
2211            "{}",
2212            err.message()
2213        );
2214    }
2215
2216    /// A CLI that dies without emitting an error event leaves nothing else to
2217    /// go on, so the raw dump is still what happens.
2218    #[test]
2219    fn a_stream_with_no_error_event_falls_back_to_the_raw_output() {
2220        let agent = jsonl_agent("codex");
2221        let err = agent.call_failure(
2222            &["codex".to_string()],
2223            &failed("{\"type\":\"system\"}", "segmentation fault"),
2224        );
2225        assert!(
2226            err.message().contains("segmentation fault"),
2227            "{}",
2228            err.message()
2229        );
2230        assert!(
2231            err.message().starts_with("command failed"),
2232            "{}",
2233            err.message()
2234        );
2235    }
2236
2237    /// A text agent has no events to read, so nothing changes for it.
2238    #[test]
2239    fn a_text_agent_is_reported_exactly_as_before() {
2240        let agent = agent(vec![one("mytool")]);
2241        let err = agent.call_failure(&["mytool".to_string()], &failed("some prose", "boom"));
2242        assert!(
2243            err.message().starts_with("command failed"),
2244            "{}",
2245            err.message()
2246        );
2247        assert!(err.message().contains("some prose"), "{}", err.message());
2248    }
2249
2250    /// Whatever the shape, it is still the CLI failing rather than answering
2251    /// badly, so it still goes straight to the stand in.
2252    #[test]
2253    fn a_reworded_failure_is_still_a_failed_call() {
2254        let agent = jsonl_agent("codex");
2255        let err = agent.call_failure(&["codex".to_string()], &failed(&refusal_stream(), ""));
2256        assert_eq!(ErrorKind::CallFailed, err.kind());
2257    }
2258
2259    // -- this run's own instructions --------------------------------------
2260
2261    #[test]
2262    fn a_request_carries_the_instructions_after_the_task() {
2263        let agent = Agent::with_bin(shell("a", "true"), "/bin/sh")
2264            .with_instructions("Do not wait for CI. Pick it up next pass.");
2265        let asked = agent.instructed("Review the changes on this branch.");
2266        assert!(
2267            asked.starts_with("Review the changes on this branch."),
2268            "{asked}"
2269        );
2270        assert!(asked.contains("Do not wait for CI"), "{asked}");
2271    }
2272
2273    /// A person adding an instruction should not be able to talk an agent out
2274    /// of the schema it was asked for, so where the instruction came from is
2275    /// said rather than left to read as part of the request.
2276    #[test]
2277    fn the_instructions_arrive_subordinate_to_the_request() {
2278        let agent = Agent::with_bin(shell("a", "true"), "/bin/sh").with_instructions("Be quick.");
2279        let asked = agent.instructed("Do the work.").to_lowercase();
2280        assert!(
2281            asked.contains("from the person who started this run"),
2282            "{asked}"
2283        );
2284        assert!(asked.contains("not the shape of your answer"), "{asked}");
2285    }
2286
2287    #[test]
2288    fn nothing_is_added_when_there_are_none() {
2289        let agent = Agent::with_bin(shell("a", "true"), "/bin/sh");
2290        assert_eq!("Do the work.", agent.instructed("Do the work."));
2291        // Whitespace is not an instruction.
2292        let blank = Agent::with_bin(shell("b", "true"), "/bin/sh").with_instructions("   \n  ");
2293        assert_eq!("Do the work.", blank.instructed("Do the work."));
2294    }
2295
2296    /// The stand in answers in this agent's place, so a run told not to wait on
2297    /// something must not start waiting the moment the primary hands over.
2298    #[test]
2299    fn the_stand_in_carries_them_too() {
2300        let agent = with_fallback(shell("primary", "true"), shell("backup", "true"))
2301            .with_instructions("Do not wait for CI.");
2302        let backup = agent.fallback().expect("a stand in");
2303        assert!(backup
2304            .instructed("Do the work.")
2305            .contains("Do not wait for CI."));
2306    }
2307
2308    // -- fallback --------------------------------------------------------
2309
2310    /// An agent whose command is a literal shell line, so a test can make the
2311    /// call succeed or fail on purpose.
2312    fn shell(name: &str, line: &str) -> AgentSpec {
2313        let mut spec = spec(vec![one("sh"), one("-c"), one(line)]);
2314        spec.name = name.into();
2315        spec
2316    }
2317
2318    fn with_fallback(mut primary: AgentSpec, backup: AgentSpec) -> Agent {
2319        primary.fallback = Some(Box::new(backup));
2320        Agent::with_bin(primary, "/bin/sh")
2321    }
2322
2323    fn git_at(cwd: &Path, args: &[&str]) -> String {
2324        let out = std::process::Command::new("git")
2325            .args(args)
2326            .current_dir(cwd)
2327            .output()
2328            .unwrap();
2329        assert!(
2330            out.status.success(),
2331            "{}",
2332            String::from_utf8_lossy(&out.stderr)
2333        );
2334        String::from_utf8_lossy(&out.stdout).trim().to_string()
2335    }
2336
2337    fn committed_repo(name: &str) -> PathBuf {
2338        let dir = std::env::temp_dir().join(format!(
2339            "spar-agent-{name}-{}-{}",
2340            std::process::id(),
2341            std::thread::current().name().unwrap_or("test")
2342        ));
2343        let _ = std::fs::remove_dir_all(&dir);
2344        std::fs::create_dir_all(&dir).unwrap();
2345        git_at(&dir, &["init", "-q", "-b", "main"]);
2346        git_at(&dir, &["config", "user.email", "spar@example.invalid"]);
2347        git_at(&dir, &["config", "user.name", "spar test"]);
2348        git_at(&dir, &["config", "commit.gpgsign", "false"]);
2349        std::fs::write(dir.join("README.md"), "seed\n").unwrap();
2350        git_at(&dir, &["add", "README.md"]);
2351        git_at(&dir, &["commit", "-q", "-m", "seed"]);
2352        dir
2353    }
2354
2355    fn linked_worktree(name: &str) -> (PathBuf, PathBuf, PathBuf, Vec<u8>) {
2356        let root = std::env::temp_dir().join(format!(
2357            "spar-agent-{name}-{}-{}",
2358            std::process::id(),
2359            std::thread::current().name().unwrap_or("test")
2360        ));
2361        let _ = std::fs::remove_dir_all(&root);
2362        let main = root.join("main");
2363        std::fs::create_dir_all(&main).unwrap();
2364        git_at(&main, &["init", "-q", "-b", "main"]);
2365        git_at(&main, &["config", "user.email", "spar@example.invalid"]);
2366        git_at(&main, &["config", "user.name", "spar test"]);
2367        git_at(&main, &["config", "commit.gpgsign", "false"]);
2368        std::fs::write(main.join("README.md"), "seed\n").unwrap();
2369        git_at(&main, &["add", "README.md"]);
2370        git_at(&main, &["commit", "-q", "-m", "seed"]);
2371        let linked = root.join("linked");
2372        git_at(
2373            &main,
2374            &[
2375                "worktree",
2376                "add",
2377                "-q",
2378                "-b",
2379                "issue-test",
2380                linked.to_str().unwrap(),
2381            ],
2382        );
2383        let marker = std::fs::read(linked.join(".git")).unwrap();
2384        (root, main, linked, marker)
2385    }
2386
2387    #[test]
2388    fn a_failed_call_is_answered_by_the_fallback() {
2389        let agent = with_fallback(
2390            shell("primary", "echo refused >&2; exit 1"),
2391            shell("backup", "echo stood in"),
2392        );
2393        let answer = agent
2394            .ask("hi", Path::new("."), &Effort::default())
2395            .expect("fallback");
2396        assert_eq!("stood in", answer);
2397    }
2398
2399    #[test]
2400    fn a_failed_edit_with_files_left_does_not_run_the_fallback() {
2401        let dir = committed_repo("dirty-edit-recovery");
2402
2403        let agent = with_fallback(
2404            shell(
2405                "primary",
2406                "printf 'recover me\\n' > README.md; printf refused >&2; exit 1",
2407            ),
2408            shell("backup", "printf 'fallback ran\\n' > fallback.txt"),
2409        );
2410        let err = agent
2411            .edit("change it", &dir, &Effort::default())
2412            .unwrap_err();
2413
2414        assert!(err.message().contains("refused"), "{err}");
2415        assert_eq!(
2416            1,
2417            err.message()
2418                .matches("The call changed the worktree before it failed")
2419                .count(),
2420            "{err}"
2421        );
2422        assert_eq!(
2423            "recover me\n",
2424            std::fs::read_to_string(dir.join("README.md")).unwrap()
2425        );
2426        assert!(!dir.join("fallback.txt").exists());
2427        let _ = std::fs::remove_dir_all(&dir);
2428    }
2429
2430    #[test]
2431    fn a_failed_edit_with_a_new_ignored_file_does_not_run_the_fallback() {
2432        let dir = committed_repo("ignored-edit-recovery");
2433        std::fs::write(dir.join(".gitignore"), "ignored.txt\n").unwrap();
2434        git_at(&dir, &["add", ".gitignore"]);
2435        git_at(&dir, &["commit", "-q", "-m", "ignore fixture"]);
2436        let agent = with_fallback(
2437            shell(
2438                "primary",
2439                "printf 'recover me\n' > ignored.txt; printf refused >&2; exit 1",
2440            ),
2441            shell("backup", "printf 'fallback ran\n' > fallback.txt"),
2442        );
2443
2444        let err = agent
2445            .edit("change it", &dir, &Effort::default())
2446            .unwrap_err();
2447
2448        assert!(err.message().contains("refused"), "{err}");
2449        assert_eq!(
2450            "recover me\n",
2451            std::fs::read_to_string(dir.join("ignored.txt")).unwrap()
2452        );
2453        assert!(!dir.join("fallback.txt").exists());
2454        let _ = std::fs::remove_dir_all(&dir);
2455    }
2456
2457    #[test]
2458    fn a_failed_edit_that_overwrites_an_ignored_file_does_not_run_the_fallback() {
2459        let dir = committed_repo("changed-ignored-edit-recovery");
2460        std::fs::write(dir.join(".gitignore"), "ignored.txt\n").unwrap();
2461        git_at(&dir, &["add", ".gitignore"]);
2462        git_at(&dir, &["commit", "-q", "-m", "ignore fixture"]);
2463        std::fs::write(dir.join("ignored.txt"), "before\n").unwrap();
2464        let agent = with_fallback(
2465            shell(
2466                "primary",
2467                "printf 'after!\\n' > ignored.txt; printf refused >&2; exit 1",
2468            ),
2469            shell("backup", "printf 'fallback ran\n' > fallback.txt"),
2470        );
2471
2472        let err = agent
2473            .edit("change it", &dir, &Effort::default())
2474            .unwrap_err();
2475
2476        assert!(err.message().contains("refused"), "{err}");
2477        assert_eq!(
2478            "after!\n",
2479            std::fs::read_to_string(dir.join("ignored.txt")).unwrap()
2480        );
2481        assert!(!dir.join("fallback.txt").exists());
2482        let _ = std::fs::remove_dir_all(&dir);
2483    }
2484
2485    #[test]
2486    fn a_failed_edit_with_a_commit_does_not_run_the_fallback() {
2487        let dir = committed_repo("committed-edit-recovery");
2488        let agent = with_fallback(
2489            shell(
2490                "primary",
2491                "printf 'recover me\n' > README.md; git add README.md; \
2492                 git commit -q -m preserved; printf refused >&2; exit 1",
2493            ),
2494            shell("backup", "printf 'fallback ran\n' > fallback.txt"),
2495        );
2496
2497        let err = agent
2498            .edit("change it", &dir, &Effort::default())
2499            .unwrap_err();
2500
2501        assert!(err.message().contains("refused"), "{err}");
2502        assert_eq!("preserved", git_at(&dir, &["log", "-1", "--pretty=%s"]));
2503        assert_eq!(
2504            "recover me\n",
2505            std::fs::read_to_string(dir.join("README.md")).unwrap()
2506        );
2507        assert!(!dir.join("fallback.txt").exists());
2508        let _ = std::fs::remove_dir_all(&dir);
2509    }
2510
2511    #[test]
2512    fn a_committed_edit_with_malformed_json_is_not_retried_or_handed_over() {
2513        let dir = committed_repo("committed-malformed-edit");
2514        let agent = with_fallback(
2515            shell(
2516                "primary",
2517                "printf 'once\n' >> attempts.txt; printf 'recover me\n' > README.md; \
2518                 git add -A; git commit -q -m preserved; printf 'not json\n'",
2519            ),
2520            shell(
2521                "backup",
2522                "printf 'fallback ran\n' > fallback.txt; printf '{}\n'",
2523            ),
2524        );
2525
2526        let err = agent
2527            .edit_json::<Value>(
2528                "change it",
2529                &serde_json::json!({"type": "object"}),
2530                &dir,
2531                &Effort::default(),
2532            )
2533            .unwrap_err();
2534
2535        assert!(err.message().contains("JSON"), "{err}");
2536        assert_eq!("preserved", git_at(&dir, &["log", "-1", "--pretty=%s"]));
2537        assert_eq!(
2538            1,
2539            std::fs::read_to_string(dir.join("attempts.txt"))
2540                .unwrap()
2541                .lines()
2542                .count()
2543        );
2544        assert!(!dir.join("fallback.txt").exists());
2545        let _ = std::fs::remove_dir_all(&dir);
2546    }
2547
2548    #[test]
2549    fn a_failed_edit_recovery_probe_does_not_retry_or_run_the_fallback() {
2550        let dir = committed_repo("failed-edit-probe");
2551        let agent = with_fallback(
2552            shell(
2553                "primary",
2554                "printf 'once\n' >> attempts.txt; mv .git .git-away; printf 'not json\n'",
2555            ),
2556            shell(
2557                "backup",
2558                "printf 'fallback ran\n' > fallback.txt; printf '{}\n'",
2559            ),
2560        );
2561
2562        let err = agent
2563            .edit_json::<Value>(
2564                "change it",
2565                &serde_json::json!({"type": "object"}),
2566                &dir,
2567                &Effort::default(),
2568            )
2569            .unwrap_err();
2570
2571        std::fs::rename(dir.join(".git-away"), dir.join(".git")).unwrap();
2572        assert_eq!(ErrorKind::UncertainWrite, err.kind());
2573        assert!(err.message().contains("JSON"), "{err}");
2574        assert_eq!(
2575            1,
2576            std::fs::read_to_string(dir.join("attempts.txt"))
2577                .unwrap()
2578                .lines()
2579                .count()
2580        );
2581        assert!(!dir.join("fallback.txt").exists());
2582        assert!(dir.join(".git").is_dir());
2583        let _ = std::fs::remove_dir_all(&dir);
2584    }
2585
2586    #[test]
2587    fn a_linked_worktree_git_marker_is_restored_after_an_edit() {
2588        let (root, main, linked, marker) = linked_worktree("changed-git-marker");
2589        let agent = Agent::with_bin(
2590            shell(
2591                "editor",
2592                "printf 'gitdir: /tmp/not-the-repo\\n' > .git; echo done",
2593            ),
2594            "/bin/sh",
2595        );
2596
2597        let err = agent
2598            .edit("change it", &linked, &Effort::default())
2599            .unwrap_err();
2600
2601        assert_eq!(ErrorKind::UncertainWrite, err.kind());
2602        assert!(err.message().contains("Git marker"), "{err}");
2603        assert_eq!(marker, std::fs::read(linked.join(".git")).unwrap());
2604        assert_eq!(
2605            b"gitdir: /tmp/not-the-repo\n",
2606            std::fs::read(linked.join(GIT_MARKER_RECOVERY))
2607                .unwrap()
2608                .as_slice()
2609        );
2610        git_at(
2611            &main,
2612            &["worktree", "remove", "--force", linked.to_str().unwrap()],
2613        );
2614        let _ = std::fs::remove_dir_all(&root);
2615    }
2616
2617    #[test]
2618    fn a_read_call_also_restores_a_linked_worktree_git_marker() {
2619        let (root, main, linked, marker) = linked_worktree("read-call-git-marker");
2620        let agent = with_fallback(
2621            shell(
2622                "reader",
2623                "printf 'gitdir: /tmp/not-the-repo\n' > .git; echo done",
2624            ),
2625            shell("backup", "printf 'fallback ran\n' > fallback.txt"),
2626        );
2627
2628        let err = agent
2629            .ask("read it", &linked, &Effort::default())
2630            .unwrap_err();
2631
2632        assert_eq!(ErrorKind::UncertainWrite, err.kind());
2633        assert_eq!(marker, std::fs::read(linked.join(".git")).unwrap());
2634        assert!(!linked.join("fallback.txt").exists());
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    #[test]
2643    fn a_successful_read_that_writes_is_discarded() {
2644        let dir = committed_repo("successful-read-write");
2645        let agent = Agent::with_bin(
2646            shell("reader", "printf 'recover me\n' > README.md; echo reviewed"),
2647            "/bin/sh",
2648        );
2649
2650        let err = agent.ask("read it", &dir, &Effort::default()).unwrap_err();
2651
2652        assert_eq!(ErrorKind::UncertainWrite, err.kind());
2653        assert!(err.message().contains("read-only call"), "{err}");
2654        assert_eq!(
2655            "recover me\n",
2656            std::fs::read_to_string(dir.join("README.md")).unwrap()
2657        );
2658        assert!(std::fs::read_dir(&dir).unwrap().flatten().any(|entry| entry
2659            .file_name()
2660            .to_string_lossy()
2661            .starts_with(".spar-recovery-needed-")));
2662        let _ = std::fs::remove_dir_all(&dir);
2663    }
2664
2665    fn marked(dir: &Path) -> bool {
2666        std::fs::read_dir(dir).unwrap().flatten().any(|entry| {
2667            entry
2668                .file_name()
2669                .to_string_lossy()
2670                .starts_with(".spar-recovery-needed-")
2671        })
2672    }
2673
2674    /// Two reviewers read one checkout at once, and a repro test only one of
2675    /// them made ended the call of the other. Beside another call, a new file
2676    /// is left to whoever checks the checkout once both are done.
2677    #[test]
2678    fn a_read_alongside_another_is_not_judged_on_a_new_file() {
2679        let (root, main, linked, _marker) = linked_worktree("alongside-new-file");
2680        let agent = Agent::with_bin(
2681            shell("reader", "printf 'repro\\n' > zz-repro.test.txt; echo '{}'"),
2682            "/bin/sh",
2683        );
2684        let schema = serde_json::json!({"type": "object"});
2685
2686        let answer: Value = agent
2687            .ask_json_alongside("read it", &schema, &linked, &Effort::default())
2688            .expect("the answer stands");
2689
2690        assert!(answer.is_object());
2691        assert!(!marked(&linked));
2692        assert_eq!(
2693            "repro\n",
2694            std::fs::read_to_string(linked.join("zz-repro.test.txt")).unwrap()
2695        );
2696
2697        // Alone in the checkout, the same call still answers for it.
2698        std::fs::remove_file(linked.join("zz-repro.test.txt")).unwrap();
2699        let err = agent
2700            .ask_json::<Value>("read it", &schema, &linked, &Effort::default())
2701            .unwrap_err();
2702        assert_eq!(ErrorKind::UncertainWrite, err.kind());
2703        assert!(err.message().contains("zz-repro.test.txt"), "{err}");
2704
2705        git_at(
2706            &main,
2707            &["worktree", "remove", "--force", linked.to_str().unwrap()],
2708        );
2709        let _ = std::fs::remove_dir_all(&root);
2710    }
2711
2712    /// Only a new file is left to the caller. Rewriting what was already there,
2713    /// tracked or not, is still the call's to answer for.
2714    #[test]
2715    fn a_read_alongside_another_still_answers_for_what_was_there() {
2716        let (root, main, linked, _marker) = linked_worktree("alongside-existing");
2717        std::fs::write(linked.join("notes.txt"), "before\n").unwrap();
2718        let schema = serde_json::json!({"type": "object"});
2719
2720        for (line, path) in [
2721            ("printf 'rewritten\\n' > README.md; echo '{}'", "README.md"),
2722            ("printf 'after\\n' > notes.txt; echo '{}'", "notes.txt"),
2723        ] {
2724            let agent = Agent::with_bin(shell("reader", line), "/bin/sh");
2725
2726            let err = agent
2727                .ask_json_alongside::<Value>("read it", &schema, &linked, &Effort::default())
2728                .unwrap_err();
2729
2730            assert_eq!(ErrorKind::UncertainWrite, err.kind());
2731            assert!(err.message().contains(path), "{err}");
2732            git_at(&linked, &["checkout", "--", "README.md"]);
2733            for entry in std::fs::read_dir(&linked).unwrap().flatten() {
2734                if entry
2735                    .file_name()
2736                    .to_string_lossy()
2737                    .starts_with(".spar-recovery-needed-")
2738                {
2739                    std::fs::remove_file(entry.path()).unwrap();
2740                }
2741            }
2742        }
2743
2744        git_at(
2745            &main,
2746            &["worktree", "remove", "--force", linked.to_str().unwrap()],
2747        );
2748        let _ = std::fs::remove_dir_all(&root);
2749    }
2750
2751    /// A repository whose `main` is one fast forward behind `advanced`, so a
2752    /// merge inside it moves HEAD and rewrites tracked files without dirtying
2753    /// anything, which is what a person pulling beside a run looks like.
2754    fn behind_repo(name: &str) -> PathBuf {
2755        let dir = committed_repo(name);
2756        git_at(&dir, &["checkout", "-q", "-b", "advanced"]);
2757        std::fs::write(dir.join("README.md"), "moved on\n").unwrap();
2758        std::fs::write(dir.join("CHANGELOG.md"), "new file\n").unwrap();
2759        git_at(&dir, &["add", "README.md", "CHANGELOG.md"]);
2760        git_at(&dir, &["commit", "-q", "-m", "advance"]);
2761        git_at(&dir, &["checkout", "-q", "main"]);
2762        dir
2763    }
2764
2765    #[test]
2766    fn a_read_survives_the_branch_moving_under_it() {
2767        let dir = behind_repo("read-branch-moved");
2768        let agent = Agent::with_bin(
2769            shell("reader", "git merge -q --ff-only advanced; echo reviewed"),
2770            "/bin/sh",
2771        );
2772
2773        let answer = agent.ask("read it", &dir, &Effort::default()).unwrap();
2774
2775        assert_eq!("reviewed", answer.trim());
2776        assert_eq!(
2777            "moved on\n",
2778            std::fs::read_to_string(dir.join("README.md")).unwrap()
2779        );
2780        assert!(
2781            !std::fs::read_dir(&dir).unwrap().flatten().any(|entry| entry
2782                .file_name()
2783                .to_string_lossy()
2784                .starts_with(".spar-recovery-needed-"))
2785        );
2786        let _ = std::fs::remove_dir_all(&dir);
2787    }
2788
2789    #[test]
2790    fn a_read_that_writes_while_the_branch_moves_is_still_discarded() {
2791        let dir = behind_repo("read-branch-moved-and-written");
2792        let agent = Agent::with_bin(
2793            shell(
2794                "reader",
2795                "git merge -q --ff-only advanced; printf 'and mine\n' >> README.md; echo reviewed",
2796            ),
2797            "/bin/sh",
2798        );
2799
2800        let err = agent.ask("read it", &dir, &Effort::default()).unwrap_err();
2801
2802        assert_eq!(ErrorKind::UncertainWrite, err.kind());
2803        assert!(err.message().contains("read-only call"), "{err}");
2804        let _ = std::fs::remove_dir_all(&dir);
2805    }
2806
2807    #[test]
2808    fn a_read_in_a_linked_worktree_still_answers_for_a_moved_head() {
2809        let (root, main, linked, _) = linked_worktree("read-worktree-commit");
2810        let agent = Agent::with_bin(
2811            shell(
2812                "reader",
2813                "printf 'mine\n' > README.md; git add -A; git commit -q -m mine; echo reviewed",
2814            ),
2815            "/bin/sh",
2816        );
2817
2818        let err = agent
2819            .ask("read it", &linked, &Effort::default())
2820            .unwrap_err();
2821
2822        assert_eq!(ErrorKind::UncertainWrite, err.kind());
2823        assert!(err.message().contains("read-only call"), "{err}");
2824        git_at(
2825            &main,
2826            &["worktree", "remove", "--force", linked.to_str().unwrap()],
2827        );
2828        let _ = std::fs::remove_dir_all(&root);
2829    }
2830
2831    /// A repository that ignores build output, with one build already in it.
2832    fn built_repo(name: &str) -> PathBuf {
2833        let dir = committed_repo(name);
2834        std::fs::write(dir.join(".gitignore"), "dist/\nlocal.env\n").unwrap();
2835        git_at(&dir, &["add", ".gitignore"]);
2836        git_at(&dir, &["commit", "-q", "-m", "ignore build output"]);
2837        std::fs::create_dir_all(dir.join("dist")).unwrap();
2838        std::fs::write(dir.join("dist/index.js"), "first build\n").unwrap();
2839        dir
2840    }
2841
2842    #[test]
2843    fn a_read_that_only_rebuilds_generated_output_keeps_its_answer() {
2844        let dir = built_repo("successful-read-build");
2845        let agent = Agent::with_bin(
2846            shell(
2847                "reader",
2848                "printf 'second build\n' > dist/index.js; printf 'more\n' > dist/extra.js; \
2849                 echo reviewed",
2850            ),
2851            "/bin/sh",
2852        );
2853
2854        let answer = agent
2855            .ask("read it", &dir, &Effort::default())
2856            .expect("answer kept");
2857
2858        assert_eq!("reviewed", answer.trim());
2859        assert_eq!(
2860            "second build\n",
2861            std::fs::read_to_string(dir.join("dist/index.js")).unwrap()
2862        );
2863        assert!(
2864            !std::fs::read_dir(&dir).unwrap().flatten().any(|entry| entry
2865                .file_name()
2866                .to_string_lossy()
2867                .starts_with(".spar-recovery-needed-"))
2868        );
2869        let _ = std::fs::remove_dir_all(&dir);
2870    }
2871
2872    /// The list of build directories was always a guess. This is the case that
2873    /// proved it: a generator writing a lockfile beside its own manifest, which
2874    /// the project ignores and no directory name catches.
2875    #[test]
2876    fn a_read_that_writes_a_new_ignored_file_elsewhere_keeps_its_answer() {
2877        let dir = built_repo("successful-read-ignored");
2878        let agent = Agent::with_bin(
2879            shell(
2880                "reader",
2881                "printf 'second build\n' > dist/index.js; printf 'TOKEN=x\n' > local.env; \
2882                 echo reviewed",
2883            ),
2884            "/bin/sh",
2885        );
2886
2887        let answer = agent
2888            .ask("read it", &dir, &Effort::default())
2889            .expect("answer kept");
2890
2891        assert_eq!("reviewed", answer.trim());
2892        assert_eq!(
2893            "TOKEN=x\n",
2894            std::fs::read_to_string(dir.join("local.env")).unwrap()
2895        );
2896        assert!(
2897            !std::fs::read_dir(&dir).unwrap().flatten().any(|entry| entry
2898                .file_name()
2899                .to_string_lossy()
2900                .starts_with(".spar-recovery-needed-"))
2901        );
2902        let _ = std::fs::remove_dir_all(&dir);
2903    }
2904
2905    #[test]
2906    fn a_read_that_rewrites_an_existing_ignored_file_elsewhere_is_kept() {
2907        let dir = built_repo("successful-read-existing-ignored");
2908        std::fs::write(dir.join("local.env"), "TOKEN=before\n").unwrap();
2909        let agent = Agent::with_bin(
2910            shell(
2911                "reader",
2912                "printf 'TOKEN=after\n' > local.env; echo reviewed",
2913            ),
2914            "/bin/sh",
2915        );
2916
2917        let answer = agent
2918            .ask("read it", &dir, &Effort::default())
2919            .expect("answer kept");
2920
2921        assert_eq!("reviewed", answer.trim());
2922        assert_eq!(
2923            "TOKEN=after\n",
2924            std::fs::read_to_string(dir.join("local.env")).unwrap()
2925        );
2926        assert!(
2927            !std::fs::read_dir(&dir).unwrap().flatten().any(|entry| entry
2928                .file_name()
2929                .to_string_lossy()
2930                .starts_with(".spar-recovery-needed-"))
2931        );
2932        let _ = std::fs::remove_dir_all(&dir);
2933    }
2934
2935    #[test]
2936    fn a_read_that_deletes_an_existing_ignored_file_is_kept() {
2937        let dir = built_repo("successful-read-deleted-ignored");
2938        std::fs::write(dir.join("local.env"), "TOKEN=before\n").unwrap();
2939        let agent = Agent::with_bin(shell("reader", "rm local.env; echo reviewed"), "/bin/sh");
2940
2941        let answer = agent
2942            .ask("read it", &dir, &Effort::default())
2943            .expect("answer kept");
2944
2945        assert_eq!("reviewed", answer.trim());
2946        assert!(!dir.join("local.env").exists());
2947        let _ = std::fs::remove_dir_all(&dir);
2948    }
2949
2950    #[test]
2951    fn a_read_that_rewrites_an_existing_ordinary_untracked_file_is_discarded() {
2952        let dir = built_repo("successful-read-existing-ordinary");
2953        std::fs::write(dir.join("notes.md"), "before\n").unwrap();
2954        let agent = Agent::with_bin(
2955            shell("reader", "printf 'after\n' > notes.md; echo reviewed"),
2956            "/bin/sh",
2957        );
2958
2959        let err = agent.ask("read it", &dir, &Effort::default()).unwrap_err();
2960
2961        assert_eq!(ErrorKind::UncertainWrite, err.kind());
2962        assert!(err.message().contains("read-only call"), "{err}");
2963        assert!(err.message().contains("notes.md"), "{err}");
2964        let _ = std::fs::remove_dir_all(&dir);
2965    }
2966
2967    #[test]
2968    fn a_read_that_writes_under_spars_own_state_is_not_a_change() {
2969        let dir = built_repo("successful-read-spar-state");
2970        let agent = Agent::with_bin(
2971            shell(
2972                "reader",
2973                "mkdir -p .spar/state; printf '[]' > .spar/state/spend.json; echo reviewed",
2974            ),
2975            "/bin/sh",
2976        );
2977
2978        let answer = agent
2979            .ask("read it", &dir, &Effort::default())
2980            .expect("answer kept");
2981
2982        assert_eq!("reviewed", answer.trim());
2983        let _ = std::fs::remove_dir_all(&dir);
2984    }
2985
2986    #[test]
2987    fn a_read_that_changes_a_tracked_file_is_still_discarded() {
2988        let dir = built_repo("successful-read-tracked");
2989        let agent = Agent::with_bin(
2990            shell(
2991                "reader",
2992                "printf 'second build\n' > dist/index.js; printf 'recover me\n' > README.md; \
2993                 echo reviewed",
2994            ),
2995            "/bin/sh",
2996        );
2997
2998        let err = agent.ask("read it", &dir, &Effort::default()).unwrap_err();
2999
3000        assert_eq!(ErrorKind::UncertainWrite, err.kind());
3001        assert!(err.message().contains("read-only call"), "{err}");
3002        assert!(err.message().contains("README.md"), "{err}");
3003        assert_eq!(
3004            "recover me\n",
3005            std::fs::read_to_string(dir.join("README.md")).unwrap()
3006        );
3007        let _ = std::fs::remove_dir_all(&dir);
3008    }
3009
3010    #[test]
3011    fn a_successful_edit_cannot_replace_the_git_directory() {
3012        let dir = committed_repo("replaced-git-directory");
3013        let agent = Agent::with_bin(
3014            shell("editor", "mv .git .git-original; mkdir .git; echo done"),
3015            "/bin/sh",
3016        );
3017
3018        let err = agent
3019            .edit("change it", &dir, &Effort::default())
3020            .unwrap_err();
3021
3022        assert_eq!(ErrorKind::UncertainWrite, err.kind());
3023        assert!(err.message().contains("Git entry"), "{err}");
3024        assert!(dir.join(".git-original").is_dir());
3025        std::fs::remove_dir(dir.join(".git")).unwrap();
3026        std::fs::rename(dir.join(".git-original"), dir.join(".git")).unwrap();
3027        let _ = std::fs::remove_dir_all(&dir);
3028    }
3029
3030    #[test]
3031    fn a_deleted_linked_worktree_git_marker_is_restored_without_a_fallback() {
3032        let (root, main, linked, marker) = linked_worktree("deleted-git-marker");
3033        let agent = with_fallback(
3034            shell("editor", "rm .git; echo done"),
3035            shell("backup", "printf 'fallback ran\n' > fallback.txt"),
3036        );
3037
3038        let err = agent
3039            .edit("change it", &linked, &Effort::default())
3040            .unwrap_err();
3041
3042        assert_eq!(ErrorKind::UncertainWrite, err.kind());
3043        assert!(err.message().contains("deleted"), "{err}");
3044        assert_eq!(marker, std::fs::read(linked.join(".git")).unwrap());
3045        assert!(!linked.join(GIT_MARKER_RECOVERY).exists());
3046        assert!(!linked.join("fallback.txt").exists());
3047        git_at(
3048            &main,
3049            &["worktree", "remove", "--force", linked.to_str().unwrap()],
3050        );
3051        let _ = std::fs::remove_dir_all(&root);
3052    }
3053
3054    #[test]
3055    fn an_occupied_marker_recovery_path_keeps_the_changed_marker() {
3056        let (root, main, linked, marker) = linked_worktree("occupied-marker-recovery");
3057        let agent = with_fallback(
3058            shell(
3059                "editor",
3060                "mkdir .spar-edited-git-marker; \
3061                 printf 'gitdir: /tmp/not-the-repo\n' > .git; echo done",
3062            ),
3063            shell("backup", "printf 'fallback ran\n' > fallback.txt"),
3064        );
3065
3066        let err = agent
3067            .edit("change it", &linked, &Effort::default())
3068            .unwrap_err();
3069
3070        assert_eq!(ErrorKind::UncertainWrite, err.kind());
3071        assert!(err.message().contains("already exists"), "{err}");
3072        assert_eq!(
3073            b"gitdir: /tmp/not-the-repo\n",
3074            std::fs::read(linked.join(".git")).unwrap().as_slice()
3075        );
3076        assert!(linked.join(GIT_MARKER_RECOVERY).is_dir());
3077        assert!(!linked.join("fallback.txt").exists());
3078        std::fs::write(linked.join(".git"), marker).unwrap();
3079        git_at(
3080            &main,
3081            &["worktree", "remove", "--force", linked.to_str().unwrap()],
3082        );
3083        let _ = std::fs::remove_dir_all(&root);
3084    }
3085
3086    #[test]
3087    fn without_a_fallback_the_original_error_is_what_surfaces() {
3088        let agent = Agent::with_bin(shell("primary", "echo refused >&2; exit 1"), "/bin/sh");
3089        let err = agent
3090            .ask("hi", Path::new("."), &Effort::default())
3091            .expect_err("no backup");
3092        assert!(err.message().contains("refused"), "{err}");
3093    }
3094
3095    /// The reason the run stopped is the primary's, not the backup's, so it
3096    /// leads. A backup that is simply not installed explains nothing.
3097    #[test]
3098    fn both_failing_reports_the_primary_first() {
3099        let agent = with_fallback(
3100            shell("primary", "echo policy refusal >&2; exit 1"),
3101            shell("backup", "echo out of quota >&2; exit 1"),
3102        );
3103        let err = agent
3104            .ask("hi", Path::new("."), &Effort::default())
3105            .expect_err("both fail");
3106        let text = err.message();
3107        let primary_at = text.find("policy refusal").expect("primary reason");
3108        let backup_at = text.find("out of quota").expect("backup reason");
3109        assert!(primary_at < backup_at, "{text}");
3110        assert!(
3111            text.contains("primary") && text.contains("backup"),
3112            "{text}"
3113        );
3114    }
3115
3116    /// A counter file, so a test can say how many times the CLI was actually
3117    /// run rather than only what came back.
3118    fn attempts(name: &str) -> (PathBuf, String) {
3119        let path = std::env::temp_dir().join(format!("spar-attempts-{name}"));
3120        let _ = std::fs::remove_file(&path);
3121        let line = format!("echo x >> {}", path.display());
3122        (path, line)
3123    }
3124
3125    fn counted(path: &Path) -> usize {
3126        std::fs::read_to_string(path)
3127            .map(|t| t.lines().count())
3128            .unwrap_or(0)
3129    }
3130
3131    /// The failure that prompted this. A policy refusal came back twice, at
3132    /// full effort, before the stand in was given the call. The second was
3133    /// never going to be different: nothing about a refusal, a quota, or a
3134    /// crash is corrected by being asked the same thing again.
3135    #[test]
3136    fn a_cli_that_could_not_answer_is_not_asked_twice_when_there_is_a_stand_in() {
3137        let (path, count) = attempts("refused-with-standin");
3138        let agent = with_fallback(
3139            shell("primary", &format!("{count}; echo refused >&2; exit 1")),
3140            shell("backup", "echo '{}'"),
3141        );
3142        let answer: Value = agent
3143            .ask_json(
3144                "q",
3145                &serde_json::json!({"type": "object"}),
3146                Path::new("."),
3147                &Effort::default(),
3148            )
3149            .expect("the stand in answers");
3150        assert!(answer.is_object());
3151        assert_eq!(1, counted(&path), "the primary was asked more than once");
3152    }
3153
3154    /// With nowhere to send the call, the retry is the only thing left, so it
3155    /// still happens. A transient failure is the case it was there for.
3156    #[test]
3157    fn with_no_stand_in_a_failed_call_is_still_retried() {
3158        let (path, count) = attempts("refused-alone");
3159        let agent = Agent::with_bin(
3160            shell("solo", &format!("{count}; echo refused >&2; exit 1")),
3161            "/bin/sh",
3162        );
3163        let err = agent
3164            .ask_json::<Value>(
3165                "q",
3166                &serde_json::json!({"type": "object"}),
3167                Path::new("."),
3168                &Effort::default(),
3169            )
3170            .expect_err("nothing answers");
3171        assert!(err.message().contains("twice"), "{err}");
3172        assert_eq!(2, counted(&path));
3173    }
3174
3175    /// The retry that must survive. An answer that arrived and could not be
3176    /// parsed is exactly what it is for, and a model corrects a shape error
3177    /// readily when handed the parser's complaint.
3178    #[test]
3179    fn an_unusable_answer_is_still_worth_asking_again() {
3180        let (path, count) = attempts("unparsable");
3181        let agent = with_fallback(
3182            shell("primary", &format!("{count}; echo not json at all")),
3183            shell("backup", "echo '{}'"),
3184        );
3185        let answer: Value = agent
3186            .ask_json(
3187                "q",
3188                &serde_json::json!({"type": "object"}),
3189                Path::new("."),
3190                &Effort::default(),
3191            )
3192            .expect("the stand in answers in the end");
3193        assert!(answer.is_object());
3194        assert_eq!(2, counted(&path), "a shape error is worth one more ask");
3195    }
3196
3197    /// A deadline is not worth asking the same CLI again, and `ask_json` does
3198    /// not. A different CLI is a different question, and the alternative is
3199    /// losing the run.
3200    #[test]
3201    fn a_timeout_still_reaches_the_fallback() {
3202        let mut primary = shell("primary", "sleep 30");
3203        primary.timeout = 1;
3204        let agent = with_fallback(primary, shell("backup", "echo stood in"));
3205        assert_eq!(
3206            "stood in",
3207            agent
3208                .ask("hi", Path::new("."), &Effort::default())
3209                .expect("fallback")
3210        );
3211    }
3212
3213    /// The fallback is built with the agent, not looked up later, so a spec
3214    /// that carries one produces an agent that carries one.
3215    #[test]
3216    fn the_fallback_is_built_alongside_the_agent() {
3217        let mut primary = shell("primary", "true");
3218        primary.fallback = Some(Box::new(shell("backup", "true")));
3219        let agent = Agent::new(primary);
3220        assert_eq!(Some("backup"), agent.fallback().map(Agent::name));
3221        assert!(Agent::new(shell("solo", "true")).fallback().is_none());
3222    }
3223}
3224
3225#[cfg(test)]
3226mod schema_placeholder_tests {
3227    use super::*;
3228    use crate::config::{OutputMode, SystemVia};
3229
3230    fn spec_with(command: Vec<CommandPart>) -> AgentSpec {
3231        AgentSpec {
3232            name: "claude".into(),
3233            command,
3234            model: None,
3235            effort: None,
3236            effort_schedule: crate::config::EffortSchedule::default(),
3237            output: OutputMode::Text,
3238            message_match: BTreeMap::new(),
3239            message_path: None,
3240            search_paths: vec![],
3241            system_via: SystemVia::Prompt,
3242            timeout: 60,
3243            fallback: None,
3244            models: vec![],
3245            efforts: vec![],
3246            options_note: None,
3247        }
3248    }
3249
3250    fn one(s: &str) -> CommandPart {
3251        CommandPart::One(s.into())
3252    }
3253    fn group(parts: &[&str]) -> CommandPart {
3254        CommandPart::Group(parts.iter().map(|s| s.to_string()).collect())
3255    }
3256
3257    /// Claude Code takes the schema as an argument, not a path, so the file
3258    /// form alone was not enough to give it native structured output.
3259    #[test]
3260    fn either_schema_form_counts_as_native_support() {
3261        let inline = Agent::with_bin(
3262            spec_with(vec![one("x"), group(&["--json-schema", "{schema}"])]),
3263            "/b",
3264        );
3265        let byfile = Agent::with_bin(
3266            spec_with(vec![one("x"), group(&["--output-schema", "{schema_file}"])]),
3267            "/b",
3268        );
3269        let neither = Agent::with_bin(spec_with(vec![one("x"), one("{prompt}")]), "/b");
3270        assert!(inline.supports_schema());
3271        assert!(byfile.supports_schema());
3272        assert!(!neither.supports_schema());
3273    }
3274
3275    #[test]
3276    fn the_inline_schema_is_substituted_whole() {
3277        let agent = Agent::with_bin(
3278            spec_with(vec![
3279                one("x"),
3280                group(&["--json-schema", "{schema}"]),
3281                one("{prompt}"),
3282            ]),
3283            "/b",
3284        );
3285        let values = Placeholders {
3286            prompt: Some("review it".into()),
3287            schema: Some(r#"{"type":"object"}"#.into()),
3288            ..Default::default()
3289        };
3290        assert_eq!(
3291            vec!["/b", "--json-schema", r#"{"type":"object"}"#, "review it"],
3292            agent.render(&values).unwrap()
3293        );
3294    }
3295
3296    /// A call that wants prose back passes no schema, and the flag must go with
3297    /// it rather than being handed an empty string.
3298    #[test]
3299    fn the_schema_flag_drops_when_no_schema_is_wanted() {
3300        let agent = Agent::with_bin(
3301            spec_with(vec![
3302                one("x"),
3303                group(&["--json-schema", "{schema}"]),
3304                one("{prompt}"),
3305            ]),
3306            "/b",
3307        );
3308        let values = Placeholders {
3309            prompt: Some("implement it".into()),
3310            ..Default::default()
3311        };
3312        assert_eq!(vec!["/b", "implement it"], agent.render(&values).unwrap());
3313    }
3314
3315    /// `{schema_file}` must not be mistaken for `{schema}`.
3316    #[test]
3317    fn the_two_schema_placeholders_do_not_collide() {
3318        let agent = Agent::with_bin(
3319            spec_with(vec![one("x"), group(&["--output-schema", "{schema_file}"])]),
3320            "/b",
3321        );
3322        let values = Placeholders {
3323            schema: Some("INLINE".into()),
3324            schema_file: Some("/tmp/s.json".into()),
3325            ..Default::default()
3326        };
3327        assert_eq!(
3328            vec!["/b", "--output-schema", "/tmp/s.json"],
3329            agent.render(&values).unwrap()
3330        );
3331    }
3332
3333    /// The preset that was failing in the field.
3334    #[test]
3335    fn the_shipped_claude_preset_now_has_native_structured_output() {
3336        let raw = crate::config::load_preset("claude").unwrap();
3337        let table = raw.as_table().cloned().unwrap();
3338        let mut spec: AgentSpec = toml::Value::Table(table).try_into().unwrap();
3339        spec.name = "claude".into();
3340        assert!(
3341            Agent::with_bin(spec, "/b").supports_schema(),
3342            "without this a long review is parsed out of prose and truncates"
3343        );
3344    }
3345}