Skip to main content

spar/
agent.rs

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