Skip to main content

spar/
agent.rs

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