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