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