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::path::{Path, PathBuf};
10use std::sync::OnceLock;
11
12use serde_json::Value;
13
14use crate::config::{AgentSpec, CommandPart, OutputMode, SystemVia};
15use crate::error::{Result, SparError};
16use crate::jsonx;
17use crate::proc::{self, ExecOpts};
18use crate::{bail, log, logdim, logwarn, spar_err};
19
20/// Injected into every request. Prompting alone is not sufficient for any of
21/// these, which is why each one is also enforced deterministically on the way
22/// out, but a model that was asked produces less for the gate to fix.
23pub const STYLE_RULES: &str = "\
24Style rules for every artifact you produce (commits, PR titles, PR bodies, issue
25titles, issue bodies, review comments):
26- Never use em-dashes or en-dashes. Use commas, colons, or parentheses.
27- Never mention Claude, Codex, OpenAI, ChatGPT, Anthropic, AI, or any tooling
28  used to produce the work.
29- Never add a Co-Authored-By trailer or a \"Generated with\" footer to commits.
30- Be brief. A human engineer with other work has to read this. Lead with the
31  point, cut the preamble, stop when you are done. Do not restate the task, do
32  not announce what you are about to do, do not summarise what the diff already
33  shows. One sentence beats one paragraph.
34- No headings, bullet lists, or bold text in anything only a few sentences long.
35Write as a human engineer would, because the reader neither knows nor cares what
36produced the work.";
37
38const JSON_INSTRUCTION: &str = "Respond with ONLY a JSON object matching this \
39schema. No prose, no markdown fences, no commentary before or after:";
40
41pub struct Agent {
42    pub spec: AgentSpec,
43    /// Answers in this agent's place when it cannot answer at all. Never
44    /// alongside it: the pair is still two, and the fallback only ever holds
45    /// the turn the failed agent was already holding.
46    fallback: Option<Box<Agent>>,
47    resolved: OnceLock<PathBuf>,
48}
49
50impl std::fmt::Debug for Agent {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        write!(f, "<{} {}>", self.spec.name, self.spec.describe())
53    }
54}
55
56impl Agent {
57    pub fn new(spec: AgentSpec) -> Self {
58        let fallback = spec
59            .fallback
60            .clone()
61            .map(|backup| Box::new(Agent::new(*backup)));
62        Self {
63            spec,
64            fallback,
65            resolved: OnceLock::new(),
66        }
67    }
68
69    pub fn name(&self) -> &str {
70        &self.spec.name
71    }
72
73    /// The stand in, if one is configured.
74    pub fn fallback(&self) -> Option<&Agent> {
75        self.fallback.as_deref()
76    }
77
78    /// The program the template names, before any resolution. What somebody
79    /// has to install when spar reports it missing.
80    pub fn program(&self) -> &str {
81        match self.spec.command.first() {
82            Some(CommandPart::One(program)) => program,
83            _ => self.name(),
84        }
85    }
86
87    /// The environment variable that points this agent's binary somewhere else.
88    pub fn env_key(&self) -> String {
89        format!(
90            "SPAR_{}_BIN",
91            self.spec.name.to_uppercase().replace('-', "_")
92        )
93    }
94
95    /// Used by the tests, and by `doctor` when it wants to report a path it
96    /// already knows.
97    #[doc(hidden)]
98    pub fn with_bin(spec: AgentSpec, bin: impl Into<PathBuf>) -> Self {
99        let agent = Self::new(spec);
100        let _ = agent.resolved.set(bin.into());
101        agent
102    }
103
104    // -- binary resolution -------------------------------------------------
105
106    /// `SPAR_<NAME>_BIN` first, then the template's own program name on PATH or
107    /// as an absolute path, then the preset's search paths. Never guess
108    /// silently: a miss reports every location tried, because a tool that
109    /// quietly runs the wrong binary is worse than one that fails.
110    pub fn resolve_bin(&self) -> Result<&Path> {
111        if let Some(found) = self.resolved.get() {
112            return Ok(found.as_path());
113        }
114        let found = self.locate()?;
115        let _ = self.resolved.set(found);
116        Ok(self.resolved.get().expect("just set").as_path())
117    }
118
119    fn locate(&self) -> Result<PathBuf> {
120        let wanted = match self.spec.command.first() {
121            Some(CommandPart::One(program)) => program.clone(),
122            _ => bail!("agent '{}' has no command configured", self.spec.name),
123        };
124
125        let env_key = self.env_key();
126        let env_override = std::env::var(&env_key)
127            .ok()
128            .filter(|v| !v.trim().is_empty());
129
130        let mut tried: Vec<String> = Vec::new();
131
132        for candidate in env_override
133            .iter()
134            .map(String::as_str)
135            .chain([wanted.as_str()])
136        {
137            let path = Path::new(candidate);
138            if path.is_absolute() || candidate.contains(std::path::MAIN_SEPARATOR) {
139                let expanded = proc::expand_tilde(candidate);
140                tried.push(expanded.display().to_string());
141                if proc::is_executable(&expanded) {
142                    return Ok(expanded);
143                }
144            } else {
145                tried.push(format!("{candidate} (PATH)"));
146                if let Some(found) = proc::which(candidate) {
147                    return Ok(found);
148                }
149            }
150        }
151
152        for base in &self.spec.search_paths {
153            let base = proc::expand_tilde(base);
154            let candidate = if base.file_name().and_then(|n| n.to_str()) == Some(wanted.as_str()) {
155                base
156            } else {
157                base.join(&wanted)
158            };
159            tried.push(candidate.display().to_string());
160            if proc::is_executable(&candidate) {
161                return Ok(candidate);
162            }
163        }
164
165        Err(spar_err!(
166            "could not find the binary for agent '{}'. Tried:\n  {}\nSet agents.{}.command[0] to \
167             an absolute path, or {}=/path/to/binary.",
168            self.spec.name,
169            tried.join("\n  "),
170            self.spec.name,
171            env_key
172        ))
173    }
174
175    // -- command rendering -------------------------------------------------
176
177    /// Substitute placeholders. A group whose placeholder is unset is dropped
178    /// whole, so omitting `model` drops `--model` with it rather than passing
179    /// an empty string that the CLI would reject or, worse, accept.
180    pub fn render(&self, values: &Placeholders) -> Result<Vec<String>> {
181        let mut out = vec![self.resolve_bin()?.display().to_string()];
182        for part in self.spec.command.iter().skip(1) {
183            let mut rendered = Vec::new();
184            let mut skip = false;
185            for arg in part.args() {
186                match values.substitute(arg) {
187                    Some(text) => rendered.push(text),
188                    None => {
189                        skip = true;
190                        break;
191                    }
192                }
193            }
194            if !skip {
195                out.extend(rendered);
196            }
197        }
198        Ok(out)
199    }
200
201    /// True when the template has somewhere to put a schema, meaning the CLI can
202    /// do structured output natively rather than being asked in the prompt.
203    ///
204    /// Either form counts: a path for a CLI that reads the schema from disk, or
205    /// the schema itself for one that takes it as an argument.
206    pub fn supports_schema(&self) -> bool {
207        self.spec
208            .command
209            .iter()
210            .flat_map(|p| p.args())
211            .any(|a| a.contains("{schema_file}") || a.contains("{schema}"))
212    }
213
214    // -- output adapters ---------------------------------------------------
215
216    pub fn extract(&self, stdout: &str) -> Result<String> {
217        match self.spec.output {
218            OutputMode::Text | OutputMode::Json => Ok(stdout.trim().to_string()),
219            OutputMode::Jsonl => self.extract_jsonl(stdout),
220        }
221    }
222
223    fn extract_jsonl(&self, stdout: &str) -> Result<String> {
224        let mut messages: Vec<String> = Vec::new();
225        let mut errors: Vec<String> = Vec::new();
226
227        for line in stdout.lines() {
228            let line = line.trim();
229            if !line.starts_with('{') {
230                continue;
231            }
232            let Ok(event) = serde_json::from_str::<Value>(line) else {
233                continue;
234            };
235            if matches(&event, &self.spec.message_match) {
236                if let Some(text) = dig(&event, self.spec.message_path.as_deref().unwrap_or("")) {
237                    if let Some(text) = as_text(text) {
238                        messages.push(text);
239                    }
240                }
241            } else if matches!(
242                event.get("type").and_then(Value::as_str),
243                Some("turn.failed") | Some("error")
244            ) {
245                errors.push(truncate(&event.to_string(), 400));
246            }
247        }
248
249        if messages.is_empty() && !errors.is_empty() {
250            bail!("agent '{}' failed: {}", self.spec.name, errors.join("; "));
251        }
252        Ok(messages.join("\n").trim().to_string())
253    }
254
255    // -- the two operations everything else is built from -------------------
256
257    pub fn ask(&self, prompt: &str, cwd: &Path, effort: Option<&str>) -> Result<String> {
258        match self.ask_inner(prompt, cwd, effort, None, None) {
259            Ok(text) => Ok(text),
260            Err(e) => self.hand_over(e, |backup| backup.ask(prompt, cwd, None)),
261        }
262    }
263
264    /// Give a failed call to the fallback, if there is one.
265    ///
266    /// Every failure qualifies, a deadline included. Asking the same CLI again
267    /// after a timeout buys another wait of the same length for the same
268    /// answer, which is why `ask_json` does not; asking a different CLI is a
269    /// different question, and the alternative here is losing the run.
270    ///
271    /// The scheduled effort is deliberately not passed on. Effort words are
272    /// each CLI's own vocabulary, and the one in hand belongs to the agent that
273    /// just failed, so the fallback uses whatever its own config asked for.
274    fn hand_over<T>(&self, primary: SparError, run: impl FnOnce(&Agent) -> Result<T>) -> Result<T> {
275        let Some(backup) = self.fallback() else {
276            return Err(primary);
277        };
278        logwarn!(
279            "{} could not answer. Handing the call to {}.\n{primary}",
280            self.name(),
281            backup.name()
282        );
283        match run(backup) {
284            Ok(answer) => {
285                log!("{} answered in place of {}", backup.name(), self.name());
286                Ok(answer)
287            }
288            // Both messages, primary first. The fallback's failure is usually
289            // the less interesting of the two, and is often just "not
290            // installed", which explains nothing about why the run stopped.
291            Err(second) => Err(spar_err!(
292                "agent '{}' failed and its fallback '{}' could not stand in.\n{}\n\n{}: {}",
293                self.name(),
294                backup.name(),
295                primary.message(),
296                backup.name(),
297                second.message()
298            )),
299        }
300    }
301
302    fn ask_inner(
303        &self,
304        prompt: &str,
305        cwd: &Path,
306        effort: Option<&str>,
307        schema_file: Option<&Path>,
308        schema: Option<&str>,
309    ) -> Result<String> {
310        let body = match self.spec.system_via {
311            SystemVia::Placeholder => prompt.to_string(),
312            SystemVia::Prompt => format!("{STYLE_RULES}\n\n{prompt}"),
313        };
314        let values = Placeholders {
315            prompt: Some(body),
316            system: Some(STYLE_RULES.to_string()),
317            model: self.spec.model.clone(),
318            effort: effort
319                .map(str::to_string)
320                .or_else(|| self.spec.effort.clone()),
321            cwd: Some(cwd.display().to_string()),
322            schema_file: schema_file.map(|p| p.display().to_string()),
323            schema: schema.map(str::to_string),
324        };
325        let argv = self.render(&values)?;
326        let opts = ExecOpts::new().cwd(cwd).timeout_secs(self.spec.timeout);
327        let stdout = proc::run(&argv, &opts)?;
328        self.extract(&stdout)
329    }
330
331    /// Structured output through the CLI's own mechanism when the template
332    /// exposes one, otherwise by asking for JSON in the prompt and parsing it
333    /// back out.
334    pub fn ask_json<T: serde::de::DeserializeOwned>(
335        &self,
336        prompt: &str,
337        schema: &Value,
338        cwd: &Path,
339        effort: Option<&str>,
340    ) -> Result<T> {
341        match self.ask_json_retrying(prompt, schema, cwd, effort) {
342            Ok(parsed) => Ok(parsed),
343            Err(e) => self.hand_over(e, |backup| {
344                backup.ask_json_retrying::<T>(prompt, schema, cwd, None)
345            }),
346        }
347    }
348
349    /// The same question, asked at most twice of this agent alone.
350    fn ask_json_retrying<T: serde::de::DeserializeOwned>(
351        &self,
352        prompt: &str,
353        schema: &Value,
354        cwd: &Path,
355        effort: Option<&str>,
356    ) -> Result<T> {
357        // One retry, with the parser's own complaint handed back.
358        //
359        // A single malformed answer used to cost half a review: the other agent
360        // carried on alone, which is the one thing this design exists to avoid.
361        // Models correct a shape error readily when told what was wrong.
362        const ATTEMPTS: usize = 2;
363        let mut last: Option<SparError> = None;
364
365        for attempt in 1..=ATTEMPTS {
366            let asked = match &last {
367                None => prompt.to_string(),
368                Some(e) => format!(
369                    "{prompt}\n\nYour previous answer could not be used: {}\nReturn the whole \
370                     object this time, exactly matching the schema, and nothing else.",
371                    e.first_line()
372                ),
373            };
374            match self.ask_json_once::<T>(&asked, schema, cwd, effort) {
375                Ok(parsed) => {
376                    if attempt > 1 {
377                        logdim!("{} answered on the retry", self.spec.name);
378                    }
379                    return Ok(parsed);
380                }
381                // A deadline is not a bad answer. Asking again buys another
382                // wait of exactly the same length, which on a long review is
383                // the most expensive way to learn nothing.
384                Err(e) if !e.worth_retrying() => return Err(e),
385                Err(e) => {
386                    if attempt < ATTEMPTS {
387                        // The whole error, not its first line. The first line is
388                        // the command; the reason is in the stderr underneath
389                        // it, and printing only the first line made a retry
390                        // impossible to diagnose from the log.
391                        logwarn!("{} failed, asking again.\n{e}", self.spec.name);
392                    }
393                    last = Some(e);
394                }
395            }
396        }
397        Err(spar_err!(
398            "agent '{}' returned an unusable answer twice: {}",
399            self.spec.name,
400            last.expect("at least one attempt").message()
401        ))
402    }
403
404    fn ask_json_once<T: serde::de::DeserializeOwned>(
405        &self,
406        prompt: &str,
407        schema: &Value,
408        cwd: &Path,
409        effort: Option<&str>,
410    ) -> Result<T> {
411        let text = if self.supports_schema() {
412            let inline = serde_json::to_string(schema).unwrap_or_default();
413            let file = TempJson::write(schema)?;
414            self.ask_inner(prompt, cwd, effort, Some(file.path()), Some(&inline))?
415        } else {
416            let full = format!(
417                "{prompt}\n\n{JSON_INSTRUCTION}\n{}",
418                serde_json::to_string_pretty(schema).unwrap_or_default()
419            );
420            self.ask_inner(&full, cwd, effort, None, None)?
421        };
422        jsonx::extract_into(&text)
423    }
424
425    /// Review the branch against `base`.
426    ///
427    /// Deliberately generic. `codex exec review` was tried and rejected: it
428    /// refuses a custom prompt alongside `--base` and returns prose regardless
429    /// of `--output-schema`, so it cannot yield a machine checkable verdict.
430    /// Running inside the worktree is what makes an agent repo aware, not a
431    /// subcommand.
432    pub fn review<T: serde::de::DeserializeOwned>(
433        &self,
434        base: &str,
435        prompt: &str,
436        schema: &Value,
437        cwd: &Path,
438        effort: Option<&str>,
439    ) -> Result<T> {
440        let scoped = format!(
441            "{prompt}\n\nThe changes under review are the diff between `{base}` and HEAD in your \
442             working directory. Inspect them with git, then read the surrounding code before \
443             judging. Do not review only the diff."
444        );
445        self.ask_json(&scoped, schema, cwd, effort)
446    }
447}
448
449// ---------------------------------------------------------------------------
450// Placeholders
451// ---------------------------------------------------------------------------
452
453#[derive(Debug, Default, Clone)]
454pub struct Placeholders {
455    pub prompt: Option<String>,
456    pub system: Option<String>,
457    pub model: Option<String>,
458    pub effort: Option<String>,
459    pub cwd: Option<String>,
460    /// A path to the schema, for a CLI that reads one from disk.
461    pub schema_file: Option<String>,
462    /// The schema itself, for a CLI that takes it as an argument.
463    pub schema: Option<String>,
464}
465
466impl Placeholders {
467    fn get(&self, key: &str) -> Option<&str> {
468        let value = match key {
469            "prompt" => self.prompt.as_deref(),
470            "system" => self.system.as_deref(),
471            "model" => self.model.as_deref(),
472            "effort" => self.effort.as_deref(),
473            "cwd" => self.cwd.as_deref(),
474            "schema_file" => self.schema_file.as_deref(),
475            "schema" => self.schema.as_deref(),
476            _ => None,
477        };
478        value.filter(|v| !v.is_empty())
479    }
480
481    /// Substitute every placeholder in one argument. `None` means a placeholder
482    /// in this argument had no value, so the whole group is dropped.
483    fn substitute(&self, arg: &str) -> Option<String> {
484        const KEYS: [&str; 7] = [
485            "prompt",
486            "system",
487            "model",
488            "effort",
489            "cwd",
490            "schema_file",
491            "schema",
492        ];
493        let mut out = arg.to_string();
494        for key in KEYS {
495            let token = format!("{{{key}}}");
496            if out.contains(&token) {
497                let value = self.get(key)?;
498                out = out.replace(&token, value);
499            }
500        }
501        Some(out)
502    }
503}
504
505// ---------------------------------------------------------------------------
506// JSONL helpers
507// ---------------------------------------------------------------------------
508
509/// Follow a dotted path, returning None if any hop is missing.
510fn dig<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
511    if path.is_empty() {
512        return None;
513    }
514    let mut node = value;
515    for part in path.split('.') {
516        node = node.as_object()?.get(part)?;
517    }
518    Some(node)
519}
520
521fn matches(event: &Value, wanted: &BTreeMap<String, String>) -> bool {
522    if wanted.is_empty() {
523        return false;
524    }
525    wanted
526        .iter()
527        .all(|(path, expected)| dig(event, path).and_then(Value::as_str) == Some(expected.as_str()))
528}
529
530fn as_text(value: &Value) -> Option<String> {
531    match value {
532        Value::String(s) => Some(s.clone()),
533        Value::Null => None,
534        other => Some(other.to_string()),
535    }
536}
537
538fn truncate(text: &str, max: usize) -> String {
539    text.chars().take(max).collect()
540}
541
542// ---------------------------------------------------------------------------
543// Temporary schema file
544// ---------------------------------------------------------------------------
545
546/// A schema written somewhere the CLI can read it, removed when it goes out of
547/// scope even if the agent call fails.
548struct TempJson {
549    path: PathBuf,
550}
551
552impl TempJson {
553    fn write(value: &Value) -> Result<Self> {
554        use std::sync::atomic::{AtomicU64, Ordering};
555        static COUNTER: AtomicU64 = AtomicU64::new(0);
556
557        let nanos = std::time::SystemTime::now()
558            .duration_since(std::time::UNIX_EPOCH)
559            .map(|d| d.as_nanos())
560            .unwrap_or(0);
561        let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
562        let path = std::env::temp_dir().join(format!(
563            "spar-schema-{}-{nanos}-{unique}.json",
564            std::process::id()
565        ));
566        std::fs::write(&path, serde_json::to_vec_pretty(value)?)
567            .map_err(|e| spar_err!("could not write a schema file to {}: {e}", path.display()))?;
568        Ok(Self { path })
569    }
570
571    fn path(&self) -> &Path {
572        &self.path
573    }
574}
575
576impl Drop for TempJson {
577    fn drop(&mut self) {
578        let _ = std::fs::remove_file(&self.path);
579    }
580}
581
582// ---------------------------------------------------------------------------
583// Correlation
584// ---------------------------------------------------------------------------
585
586/// Whether two paths name the same executable.
587///
588/// Comparing the raw strings misses aliases: a symlink or a hard link points at
589/// the same binary under a different path, which would let two agents run the
590/// identical CLI without tripping the warning below. Device and inode see
591/// through both.
592fn same_executable(a: &Path, b: &Path) -> bool {
593    #[cfg(unix)]
594    {
595        use std::os::unix::fs::MetadataExt;
596        if let (Ok(x), Ok(y)) = (std::fs::metadata(a), std::fs::metadata(b)) {
597            return x.dev() == y.dev() && x.ino() == y.ino();
598        }
599    }
600    match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
601        (Ok(x), Ok(y)) => x == y,
602        _ => a == b,
603    }
604}
605
606/// Two agents are only an independent review if they can actually disagree.
607///
608/// Config keys are arbitrary, so `alpha` and `beta` can both be Claude on the
609/// same model. Compare what actually runs: the resolved binary and the
610/// configured model, never the names.
611pub fn correlation_warning(agents: &[Agent]) -> Option<String> {
612    for i in 0..agents.len() {
613        for j in (i + 1)..agents.len() {
614            let (a, b) = (&agents[i], &agents[j]);
615            let (Ok(pa), Ok(pb)) = (a.resolve_bin(), b.resolve_bin()) else {
616                continue;
617            };
618            if !same_executable(pa, pb) || a.spec.model_key() != b.spec.model_key() {
619                continue;
620            }
621            let model = if a.spec.model_key().is_empty() {
622                "the CLI's default".to_string()
623            } else {
624                a.spec.model_key()
625            };
626            let where_at = if pa == pb {
627                pa.display().to_string()
628            } else {
629                format!(
630                    "the same executable ({} and {} are the same file)",
631                    pa.display(),
632                    pb.display()
633                )
634            };
635            return Some(format!(
636                "agents '{}' and '{}' both resolve to {where_at} at model {model}. Review \
637                 findings will be correlated: the same model reviewing itself shares the blind \
638                 spots of the model that wrote the code, so it is far less likely to catch what \
639                 the implementer missed. That produces an approval indistinguishable from a real \
640                 review, which is worse than no review at all. Give the two agents different \
641                 CLIs or different models.",
642                a.name(),
643                b.name()
644            ));
645        }
646    }
647    None
648}
649
650/// Build every configured agent, resolving each binary up front so a missing
651/// CLI fails before any model is billed.
652pub fn build(cfg: &crate::config::Config) -> Result<Vec<Agent>> {
653    let agents: Vec<Agent> = cfg.agents.iter().cloned().map(Agent::new).collect();
654    for agent in &agents {
655        agent.resolve_bin()?;
656        // A backup that is not installed must not stop a run whose pair is
657        // fine. Said once here, at the start, rather than an hour in at the
658        // moment it was needed and could not be reached.
659        if let Some(backup) = agent.fallback() {
660            if backup.resolve_bin().is_err() {
661                logwarn!(
662                    "{} has a fallback ({}) that is not installed, so it will not stand in",
663                    agent.name(),
664                    backup.program()
665                );
666            }
667        }
668    }
669    Ok(agents)
670}
671
672/// Look an agent up by name in a built list.
673pub fn find<'a>(agents: &'a [Agent], name: &str) -> Result<&'a Agent> {
674    agents.iter().find(|a| a.name() == name).ok_or_else(|| {
675        SparError::new(format!(
676            "no agent named '{name}' ({})",
677            agents
678                .iter()
679                .map(Agent::name)
680                .collect::<Vec<_>>()
681                .join(", ")
682        ))
683    })
684}
685
686#[cfg(test)]
687mod tests {
688    use super::*;
689    use crate::config::{OutputMode, SystemVia};
690
691    fn spec(command: Vec<CommandPart>) -> AgentSpec {
692        AgentSpec {
693            name: "test".into(),
694            command,
695            model: None,
696            effort: None,
697            output: OutputMode::Text,
698            message_match: BTreeMap::new(),
699            message_path: None,
700            search_paths: vec![],
701            system_via: SystemVia::Prompt,
702            timeout: 60,
703            fallback: None,
704            models: vec![],
705            efforts: vec![],
706            options_note: None,
707        }
708    }
709
710    fn one(s: &str) -> CommandPart {
711        CommandPart::One(s.into())
712    }
713
714    fn group(parts: &[&str]) -> CommandPart {
715        CommandPart::Group(parts.iter().map(|s| s.to_string()).collect())
716    }
717
718    fn agent(command: Vec<CommandPart>) -> Agent {
719        Agent::with_bin(spec(command), "/fake/bin")
720    }
721
722    fn values() -> Placeholders {
723        Placeholders {
724            prompt: Some("hi".into()),
725            ..Default::default()
726        }
727    }
728
729    // -- rendering -------------------------------------------------------
730
731    #[test]
732    fn placeholders_are_substituted() {
733        let a = agent(vec![one("x"), group(&["-m", "{model}"]), one("{prompt}")]);
734        let v = Placeholders {
735            model: Some("m1".into()),
736            ..values()
737        };
738        assert_eq!(vec!["/fake/bin", "-m", "m1", "hi"], a.render(&v).unwrap());
739    }
740
741    #[test]
742    fn an_unset_placeholder_drops_the_whole_group() {
743        let a = agent(vec![one("x"), group(&["-m", "{model}"]), one("{prompt}")]);
744        assert_eq!(vec!["/fake/bin", "hi"], a.render(&values()).unwrap());
745    }
746
747    #[test]
748    fn an_empty_string_drops_the_group_too() {
749        let a = agent(vec![one("x"), group(&["-e", "{effort}"]), one("{prompt}")]);
750        let v = Placeholders {
751            effort: Some(String::new()),
752            ..values()
753        };
754        assert_eq!(vec!["/fake/bin", "hi"], a.render(&v).unwrap());
755    }
756
757    #[test]
758    fn a_bare_arg_with_an_unset_placeholder_drops() {
759        let a = agent(vec![one("x"), one("{model}"), one("{prompt}")]);
760        assert_eq!(vec!["/fake/bin", "hi"], a.render(&values()).unwrap());
761    }
762
763    #[test]
764    fn literal_args_survive() {
765        let a = agent(vec![
766            one("x"),
767            one("exec"),
768            one("--json"),
769            one("--"),
770            one("{prompt}"),
771        ]);
772        assert_eq!(
773            vec!["/fake/bin", "exec", "--json", "--", "hi"],
774            a.render(&values()).unwrap()
775        );
776    }
777
778    #[test]
779    fn an_embedded_placeholder_substitutes_in_place() {
780        let a = agent(vec![
781            one("x"),
782            group(&["-c", "model_reasoning_effort={effort}"]),
783        ]);
784        let v = Placeholders {
785            effort: Some("ultra".into()),
786            ..Default::default()
787        };
788        assert_eq!(
789            vec!["/fake/bin", "-c", "model_reasoning_effort=ultra"],
790            a.render(&v).unwrap()
791        );
792    }
793
794    #[test]
795    fn a_group_with_two_placeholders_needs_both() {
796        let a = agent(vec![
797            one("x"),
798            group(&["--a", "{model}", "--b", "{effort}"]),
799        ]);
800        let v = Placeholders {
801            model: Some("m".into()),
802            ..Default::default()
803        };
804        assert_eq!(vec!["/fake/bin"], a.render(&v).unwrap());
805    }
806
807    #[test]
808    fn supports_schema_detects_the_placeholder() {
809        assert!(agent(vec![one("x"), group(&["--schema", "{schema_file}"])]).supports_schema());
810        assert!(!agent(vec![one("x"), one("{prompt}")]).supports_schema());
811    }
812
813    // -- output adapters -------------------------------------------------
814
815    #[test]
816    fn text_passes_through_trimmed() {
817        assert_eq!("hello", agent(vec![one("x")]).extract("  hello\n").unwrap());
818    }
819
820    #[test]
821    fn jsonl_picks_the_matching_event() {
822        let mut spec = spec(vec![one("x")]);
823        spec.output = OutputMode::Jsonl;
824        spec.message_path = Some("item.text".into());
825        spec.message_match = BTreeMap::from([
826            ("type".to_string(), "item.completed".to_string()),
827            ("item.type".to_string(), "agent_message".to_string()),
828        ]);
829        let a = Agent::with_bin(spec, "/fake/bin");
830        let stream = [
831            r#"{"type":"thread.started","thread_id":"t1"}"#,
832            r#"{"type":"item.completed","item":{"type":"command_execution","text":"ls"}}"#,
833            r#"{"type":"item.completed","item":{"type":"agent_message","text":"the answer"}}"#,
834            "not json at all",
835        ]
836        .join("\n");
837        assert_eq!("the answer", a.extract(&stream).unwrap());
838    }
839
840    #[test]
841    fn jsonl_raises_on_an_error_with_no_message() {
842        let mut spec = spec(vec![one("x")]);
843        spec.output = OutputMode::Jsonl;
844        spec.message_path = Some("item.text".into());
845        spec.message_match = BTreeMap::from([("type".into(), "item.completed".into())]);
846        let a = Agent::with_bin(spec, "/fake/bin");
847        assert!(a
848            .extract(r#"{"type":"turn.failed","error":"boom"}"#)
849            .is_err());
850    }
851
852    #[test]
853    fn jsonl_joins_several_agent_messages() {
854        let mut spec = spec(vec![one("x")]);
855        spec.output = OutputMode::Jsonl;
856        spec.message_path = Some("text".into());
857        spec.message_match = BTreeMap::from([("type".into(), "msg".into())]);
858        let a = Agent::with_bin(spec, "/fake/bin");
859        let stream = "{\"type\":\"msg\",\"text\":\"one\"}\n{\"type\":\"msg\",\"text\":\"two\"}";
860        assert_eq!("one\ntwo", a.extract(stream).unwrap());
861    }
862
863    #[test]
864    fn dig_walks_a_dotted_path() {
865        let v: Value = serde_json::from_str(r#"{"a":{"b":{"c":1}}}"#).unwrap();
866        assert_eq!(Some(&Value::from(1)), dig(&v, "a.b.c"));
867        assert_eq!(None, dig(&v, "a.b.missing"));
868        assert_eq!(None, dig(&v, ""));
869    }
870
871    // -- binary resolution -----------------------------------------------
872
873    #[test]
874    fn a_missing_binary_lists_everywhere_it_looked() {
875        let mut s = spec(vec![one("definitely-not-installed-xyz")]);
876        s.search_paths = vec!["/nowhere/at/all".into()];
877        s.name = "codex".into();
878        let err = Agent::new(s).resolve_bin().unwrap_err().to_string();
879        assert!(err.contains("definitely-not-installed-xyz (PATH)"), "{err}");
880        assert!(
881            err.contains("/nowhere/at/all/definitely-not-installed-xyz"),
882            "{err}"
883        );
884        assert!(err.contains("SPAR_CODEX_BIN"), "{err}");
885    }
886
887    #[test]
888    fn a_search_path_that_already_names_the_binary_is_used_as_is() {
889        let dir = std::env::temp_dir().join(format!("spar-test-{}", std::process::id()));
890        std::fs::create_dir_all(&dir).unwrap();
891        let bin = dir.join("mytool");
892        std::fs::write(&bin, "#!/bin/sh\n").unwrap();
893        #[cfg(unix)]
894        {
895            use std::os::unix::fs::PermissionsExt;
896            std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
897        }
898        let mut s = spec(vec![one("mytool")]);
899        s.search_paths = vec![bin.display().to_string()];
900        assert_eq!(bin, Agent::new(s).resolve_bin().unwrap());
901        let _ = std::fs::remove_dir_all(&dir);
902    }
903
904    // -- correlation -----------------------------------------------------
905
906    fn named(name: &str, bin: &str, model: Option<&str>) -> Agent {
907        let mut s = spec(vec![one("prog")]);
908        s.name = name.into();
909        s.model = model.map(str::to_string);
910        Agent::with_bin(s, bin)
911    }
912
913    #[test]
914    fn same_bin_same_model_warns() {
915        let agents = vec![
916            named("alpha", "/usr/local/bin/claude", Some("fable")),
917            named("beta", "/usr/local/bin/claude", Some("fable")),
918        ];
919        let msg = correlation_warning(&agents).expect("should warn");
920        assert!(msg.contains("alpha") && msg.contains("beta"), "{msg}");
921    }
922
923    #[test]
924    fn different_model_does_not_warn() {
925        let agents = vec![
926            named("a", "/usr/local/bin/claude", Some("fable")),
927            named("b", "/usr/local/bin/claude", Some("opus")),
928        ];
929        assert!(correlation_warning(&agents).is_none());
930    }
931
932    #[test]
933    fn different_bin_does_not_warn() {
934        let agents = vec![
935            named("a", "/usr/local/bin/claude", Some("fable")),
936            named("b", "/usr/local/bin/codex", Some("fable")),
937        ];
938        assert!(correlation_warning(&agents).is_none());
939    }
940
941    #[test]
942    fn unset_and_empty_model_both_mean_the_default_and_warn() {
943        let agents = vec![
944            named("a", "/usr/local/bin/claude", None),
945            named("b", "/usr/local/bin/claude", Some("")),
946        ];
947        let msg = correlation_warning(&agents).expect("should warn");
948        assert!(msg.contains("the CLI's default"), "{msg}");
949    }
950
951    #[test]
952    fn a_padded_model_still_warns() {
953        let agents = vec![
954            named("a", "/usr/local/bin/claude", Some("fable")),
955            named("b", "/usr/local/bin/claude", Some(" fable ")),
956        ];
957        assert!(correlation_warning(&agents).is_some());
958    }
959
960    #[test]
961    fn an_empty_model_against_a_named_one_does_not_warn() {
962        let agents = vec![
963            named("a", "/usr/local/bin/claude", Some("")),
964            named("b", "/usr/local/bin/claude", Some("fable")),
965        ];
966        assert!(correlation_warning(&agents).is_none());
967    }
968
969    #[cfg(unix)]
970    #[test]
971    fn a_symlinked_binary_warns_and_names_both_paths() {
972        use std::os::unix::fs::PermissionsExt;
973        let dir = std::env::temp_dir().join(format!("spar-link-{}", std::process::id()));
974        let _ = std::fs::remove_dir_all(&dir);
975        std::fs::create_dir_all(&dir).unwrap();
976        let real = dir.join("claude");
977        let link = dir.join("claude-alias");
978        std::fs::write(&real, "#!/bin/sh\n").unwrap();
979        std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o755)).unwrap();
980        std::os::unix::fs::symlink(&real, &link).unwrap();
981
982        let agents = vec![
983            named("alpha", real.to_str().unwrap(), Some("fable")),
984            named("beta", link.to_str().unwrap(), Some("fable")),
985        ];
986        let msg = correlation_warning(&agents).expect("should warn");
987        assert!(msg.contains(real.to_str().unwrap()), "{msg}");
988        assert!(msg.contains(link.to_str().unwrap()), "{msg}");
989        let _ = std::fs::remove_dir_all(&dir);
990    }
991
992    #[cfg(unix)]
993    #[test]
994    fn two_distinct_real_binaries_stay_quiet() {
995        use std::os::unix::fs::PermissionsExt;
996        let dir = std::env::temp_dir().join(format!("spar-distinct-{}", std::process::id()));
997        let _ = std::fs::remove_dir_all(&dir);
998        std::fs::create_dir_all(&dir).unwrap();
999        let mut paths = Vec::new();
1000        for name in ["claude", "codex"] {
1001            let path = dir.join(name);
1002            std::fs::write(&path, "#!/bin/sh\n").unwrap();
1003            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
1004            paths.push(path);
1005        }
1006        let agents = vec![
1007            named("a", paths[0].to_str().unwrap(), Some("fable")),
1008            named("b", paths[1].to_str().unwrap(), Some("fable")),
1009        ];
1010        assert!(correlation_warning(&agents).is_none());
1011        let _ = std::fs::remove_dir_all(&dir);
1012    }
1013
1014    #[test]
1015    fn the_style_rules_ask_for_brevity_and_no_attribution() {
1016        let lower = STYLE_RULES.to_lowercase();
1017        assert!(lower.contains("brief"));
1018        assert!(lower.contains("co-authored-by"));
1019        assert!(lower.contains("em-dash"));
1020    }
1021
1022    // -- fallback --------------------------------------------------------
1023
1024    /// An agent whose command is a literal shell line, so a test can make the
1025    /// call succeed or fail on purpose.
1026    fn shell(name: &str, line: &str) -> AgentSpec {
1027        let mut spec = spec(vec![one("sh"), one("-c"), one(line)]);
1028        spec.name = name.into();
1029        spec
1030    }
1031
1032    fn with_fallback(mut primary: AgentSpec, backup: AgentSpec) -> Agent {
1033        primary.fallback = Some(Box::new(backup));
1034        Agent::with_bin(primary, "/bin/sh")
1035    }
1036
1037    #[test]
1038    fn a_failed_call_is_answered_by_the_fallback() {
1039        let agent = with_fallback(
1040            shell("primary", "echo refused >&2; exit 1"),
1041            shell("backup", "echo stood in"),
1042        );
1043        let answer = agent.ask("hi", Path::new("."), None).expect("fallback");
1044        assert_eq!("stood in", answer);
1045    }
1046
1047    #[test]
1048    fn without_a_fallback_the_original_error_is_what_surfaces() {
1049        let agent = Agent::with_bin(shell("primary", "echo refused >&2; exit 1"), "/bin/sh");
1050        let err = agent
1051            .ask("hi", Path::new("."), None)
1052            .expect_err("no backup");
1053        assert!(err.message().contains("refused"), "{err}");
1054    }
1055
1056    /// The reason the run stopped is the primary's, not the backup's, so it
1057    /// leads. A backup that is simply not installed explains nothing.
1058    #[test]
1059    fn both_failing_reports_the_primary_first() {
1060        let agent = with_fallback(
1061            shell("primary", "echo policy refusal >&2; exit 1"),
1062            shell("backup", "echo out of quota >&2; exit 1"),
1063        );
1064        let err = agent
1065            .ask("hi", Path::new("."), None)
1066            .expect_err("both fail");
1067        let text = err.message();
1068        let primary_at = text.find("policy refusal").expect("primary reason");
1069        let backup_at = text.find("out of quota").expect("backup reason");
1070        assert!(primary_at < backup_at, "{text}");
1071        assert!(
1072            text.contains("primary") && text.contains("backup"),
1073            "{text}"
1074        );
1075    }
1076
1077    /// A deadline is not worth asking the same CLI again, and `ask_json` does
1078    /// not. A different CLI is a different question, and the alternative is
1079    /// losing the run.
1080    #[test]
1081    fn a_timeout_still_reaches_the_fallback() {
1082        let mut primary = shell("primary", "sleep 30");
1083        primary.timeout = 1;
1084        let agent = with_fallback(primary, shell("backup", "echo stood in"));
1085        assert_eq!(
1086            "stood in",
1087            agent.ask("hi", Path::new("."), None).expect("fallback")
1088        );
1089    }
1090
1091    /// The fallback is built with the agent, not looked up later, so a spec
1092    /// that carries one produces an agent that carries one.
1093    #[test]
1094    fn the_fallback_is_built_alongside_the_agent() {
1095        let mut primary = shell("primary", "true");
1096        primary.fallback = Some(Box::new(shell("backup", "true")));
1097        let agent = Agent::new(primary);
1098        assert_eq!(Some("backup"), agent.fallback().map(Agent::name));
1099        assert!(Agent::new(shell("solo", "true")).fallback().is_none());
1100    }
1101}
1102
1103#[cfg(test)]
1104mod schema_placeholder_tests {
1105    use super::*;
1106    use crate::config::{OutputMode, SystemVia};
1107
1108    fn spec_with(command: Vec<CommandPart>) -> AgentSpec {
1109        AgentSpec {
1110            name: "claude".into(),
1111            command,
1112            model: None,
1113            effort: None,
1114            output: OutputMode::Text,
1115            message_match: BTreeMap::new(),
1116            message_path: None,
1117            search_paths: vec![],
1118            system_via: SystemVia::Prompt,
1119            timeout: 60,
1120            fallback: None,
1121            models: vec![],
1122            efforts: vec![],
1123            options_note: None,
1124        }
1125    }
1126
1127    fn one(s: &str) -> CommandPart {
1128        CommandPart::One(s.into())
1129    }
1130    fn group(parts: &[&str]) -> CommandPart {
1131        CommandPart::Group(parts.iter().map(|s| s.to_string()).collect())
1132    }
1133
1134    /// Claude Code takes the schema as an argument, not a path, so the file
1135    /// form alone was not enough to give it native structured output.
1136    #[test]
1137    fn either_schema_form_counts_as_native_support() {
1138        let inline = Agent::with_bin(
1139            spec_with(vec![one("x"), group(&["--json-schema", "{schema}"])]),
1140            "/b",
1141        );
1142        let byfile = Agent::with_bin(
1143            spec_with(vec![one("x"), group(&["--output-schema", "{schema_file}"])]),
1144            "/b",
1145        );
1146        let neither = Agent::with_bin(spec_with(vec![one("x"), one("{prompt}")]), "/b");
1147        assert!(inline.supports_schema());
1148        assert!(byfile.supports_schema());
1149        assert!(!neither.supports_schema());
1150    }
1151
1152    #[test]
1153    fn the_inline_schema_is_substituted_whole() {
1154        let agent = Agent::with_bin(
1155            spec_with(vec![
1156                one("x"),
1157                group(&["--json-schema", "{schema}"]),
1158                one("{prompt}"),
1159            ]),
1160            "/b",
1161        );
1162        let values = Placeholders {
1163            prompt: Some("review it".into()),
1164            schema: Some(r#"{"type":"object"}"#.into()),
1165            ..Default::default()
1166        };
1167        assert_eq!(
1168            vec!["/b", "--json-schema", r#"{"type":"object"}"#, "review it"],
1169            agent.render(&values).unwrap()
1170        );
1171    }
1172
1173    /// A call that wants prose back passes no schema, and the flag must go with
1174    /// it rather than being handed an empty string.
1175    #[test]
1176    fn the_schema_flag_drops_when_no_schema_is_wanted() {
1177        let agent = Agent::with_bin(
1178            spec_with(vec![
1179                one("x"),
1180                group(&["--json-schema", "{schema}"]),
1181                one("{prompt}"),
1182            ]),
1183            "/b",
1184        );
1185        let values = Placeholders {
1186            prompt: Some("implement it".into()),
1187            ..Default::default()
1188        };
1189        assert_eq!(vec!["/b", "implement it"], agent.render(&values).unwrap());
1190    }
1191
1192    /// `{schema_file}` must not be mistaken for `{schema}`.
1193    #[test]
1194    fn the_two_schema_placeholders_do_not_collide() {
1195        let agent = Agent::with_bin(
1196            spec_with(vec![one("x"), group(&["--output-schema", "{schema_file}"])]),
1197            "/b",
1198        );
1199        let values = Placeholders {
1200            schema: Some("INLINE".into()),
1201            schema_file: Some("/tmp/s.json".into()),
1202            ..Default::default()
1203        };
1204        assert_eq!(
1205            vec!["/b", "--output-schema", "/tmp/s.json"],
1206            agent.render(&values).unwrap()
1207        );
1208    }
1209
1210    /// The preset that was failing in the field.
1211    #[test]
1212    fn the_shipped_claude_preset_now_has_native_structured_output() {
1213        let raw = crate::config::load_preset("claude").unwrap();
1214        let table = raw.as_table().cloned().unwrap();
1215        let mut spec: AgentSpec = toml::Value::Table(table).try_into().unwrap();
1216        spec.name = "claude".into();
1217        assert!(
1218            Agent::with_bin(spec, "/b").supports_schema(),
1219            "without this a long review is parsed out of prose and truncates"
1220        );
1221    }
1222}