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