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