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