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                Err(e) => {
297                    if attempt < ATTEMPTS {
298                        // The whole error, not its first line. The first line is
299                        // the command; the reason is in the stderr underneath
300                        // it, and printing only the first line made a retry
301                        // impossible to diagnose from the log.
302                        logwarn!("{} failed, asking again.\n{e}", self.spec.name);
303                    }
304                    last = Some(e);
305                }
306            }
307        }
308        Err(spar_err!(
309            "agent '{}' returned an unusable answer twice: {}",
310            self.spec.name,
311            last.expect("at least one attempt").message()
312        ))
313    }
314
315    fn ask_json_once<T: serde::de::DeserializeOwned>(
316        &self,
317        prompt: &str,
318        schema: &Value,
319        cwd: &Path,
320        effort: Option<&str>,
321    ) -> Result<T> {
322        let text = if self.supports_schema() {
323            let inline = serde_json::to_string(schema).unwrap_or_default();
324            let file = TempJson::write(schema)?;
325            self.ask_inner(prompt, cwd, effort, Some(file.path()), Some(&inline))?
326        } else {
327            let full = format!(
328                "{prompt}\n\n{JSON_INSTRUCTION}\n{}",
329                serde_json::to_string_pretty(schema).unwrap_or_default()
330            );
331            self.ask_inner(&full, cwd, effort, None, None)?
332        };
333        jsonx::extract_into(&text)
334    }
335
336    /// Review the branch against `base`.
337    ///
338    /// Deliberately generic. `codex exec review` was tried and rejected: it
339    /// refuses a custom prompt alongside `--base` and returns prose regardless
340    /// of `--output-schema`, so it cannot yield a machine checkable verdict.
341    /// Running inside the worktree is what makes an agent repo aware, not a
342    /// subcommand.
343    pub fn review<T: serde::de::DeserializeOwned>(
344        &self,
345        base: &str,
346        prompt: &str,
347        schema: &Value,
348        cwd: &Path,
349        effort: Option<&str>,
350    ) -> Result<T> {
351        let scoped = format!(
352            "{prompt}\n\nThe changes under review are the diff between `{base}` and HEAD in your \
353             working directory. Inspect them with git, then read the surrounding code before \
354             judging. Do not review only the diff."
355        );
356        self.ask_json(&scoped, schema, cwd, effort)
357    }
358}
359
360// ---------------------------------------------------------------------------
361// Placeholders
362// ---------------------------------------------------------------------------
363
364#[derive(Debug, Default, Clone)]
365pub struct Placeholders {
366    pub prompt: Option<String>,
367    pub system: Option<String>,
368    pub model: Option<String>,
369    pub effort: Option<String>,
370    pub cwd: Option<String>,
371    /// A path to the schema, for a CLI that reads one from disk.
372    pub schema_file: Option<String>,
373    /// The schema itself, for a CLI that takes it as an argument.
374    pub schema: Option<String>,
375}
376
377impl Placeholders {
378    fn get(&self, key: &str) -> Option<&str> {
379        let value = match key {
380            "prompt" => self.prompt.as_deref(),
381            "system" => self.system.as_deref(),
382            "model" => self.model.as_deref(),
383            "effort" => self.effort.as_deref(),
384            "cwd" => self.cwd.as_deref(),
385            "schema_file" => self.schema_file.as_deref(),
386            "schema" => self.schema.as_deref(),
387            _ => None,
388        };
389        value.filter(|v| !v.is_empty())
390    }
391
392    /// Substitute every placeholder in one argument. `None` means a placeholder
393    /// in this argument had no value, so the whole group is dropped.
394    fn substitute(&self, arg: &str) -> Option<String> {
395        const KEYS: [&str; 7] = [
396            "prompt",
397            "system",
398            "model",
399            "effort",
400            "cwd",
401            "schema_file",
402            "schema",
403        ];
404        let mut out = arg.to_string();
405        for key in KEYS {
406            let token = format!("{{{key}}}");
407            if out.contains(&token) {
408                let value = self.get(key)?;
409                out = out.replace(&token, value);
410            }
411        }
412        Some(out)
413    }
414}
415
416// ---------------------------------------------------------------------------
417// JSONL helpers
418// ---------------------------------------------------------------------------
419
420/// Follow a dotted path, returning None if any hop is missing.
421fn dig<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
422    if path.is_empty() {
423        return None;
424    }
425    let mut node = value;
426    for part in path.split('.') {
427        node = node.as_object()?.get(part)?;
428    }
429    Some(node)
430}
431
432fn matches(event: &Value, wanted: &BTreeMap<String, String>) -> bool {
433    if wanted.is_empty() {
434        return false;
435    }
436    wanted
437        .iter()
438        .all(|(path, expected)| dig(event, path).and_then(Value::as_str) == Some(expected.as_str()))
439}
440
441fn as_text(value: &Value) -> Option<String> {
442    match value {
443        Value::String(s) => Some(s.clone()),
444        Value::Null => None,
445        other => Some(other.to_string()),
446    }
447}
448
449fn truncate(text: &str, max: usize) -> String {
450    text.chars().take(max).collect()
451}
452
453// ---------------------------------------------------------------------------
454// Temporary schema file
455// ---------------------------------------------------------------------------
456
457/// A schema written somewhere the CLI can read it, removed when it goes out of
458/// scope even if the agent call fails.
459struct TempJson {
460    path: PathBuf,
461}
462
463impl TempJson {
464    fn write(value: &Value) -> Result<Self> {
465        use std::sync::atomic::{AtomicU64, Ordering};
466        static COUNTER: AtomicU64 = AtomicU64::new(0);
467
468        let nanos = std::time::SystemTime::now()
469            .duration_since(std::time::UNIX_EPOCH)
470            .map(|d| d.as_nanos())
471            .unwrap_or(0);
472        let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
473        let path = std::env::temp_dir().join(format!(
474            "spar-schema-{}-{nanos}-{unique}.json",
475            std::process::id()
476        ));
477        std::fs::write(&path, serde_json::to_vec_pretty(value)?)
478            .map_err(|e| spar_err!("could not write a schema file to {}: {e}", path.display()))?;
479        Ok(Self { path })
480    }
481
482    fn path(&self) -> &Path {
483        &self.path
484    }
485}
486
487impl Drop for TempJson {
488    fn drop(&mut self) {
489        let _ = std::fs::remove_file(&self.path);
490    }
491}
492
493// ---------------------------------------------------------------------------
494// Correlation
495// ---------------------------------------------------------------------------
496
497/// Whether two paths name the same executable.
498///
499/// Comparing the raw strings misses aliases: a symlink or a hard link points at
500/// the same binary under a different path, which would let two agents run the
501/// identical CLI without tripping the warning below. Device and inode see
502/// through both.
503fn same_executable(a: &Path, b: &Path) -> bool {
504    #[cfg(unix)]
505    {
506        use std::os::unix::fs::MetadataExt;
507        if let (Ok(x), Ok(y)) = (std::fs::metadata(a), std::fs::metadata(b)) {
508            return x.dev() == y.dev() && x.ino() == y.ino();
509        }
510    }
511    match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
512        (Ok(x), Ok(y)) => x == y,
513        _ => a == b,
514    }
515}
516
517/// Two agents are only an independent review if they can actually disagree.
518///
519/// Config keys are arbitrary, so `alpha` and `beta` can both be Claude on the
520/// same model. Compare what actually runs: the resolved binary and the
521/// configured model, never the names.
522pub fn correlation_warning(agents: &[Agent]) -> Option<String> {
523    for i in 0..agents.len() {
524        for j in (i + 1)..agents.len() {
525            let (a, b) = (&agents[i], &agents[j]);
526            let (Ok(pa), Ok(pb)) = (a.resolve_bin(), b.resolve_bin()) else {
527                continue;
528            };
529            if !same_executable(pa, pb) || a.spec.model_key() != b.spec.model_key() {
530                continue;
531            }
532            let model = if a.spec.model_key().is_empty() {
533                "the CLI's default".to_string()
534            } else {
535                a.spec.model_key()
536            };
537            let where_at = if pa == pb {
538                pa.display().to_string()
539            } else {
540                format!(
541                    "the same executable ({} and {} are the same file)",
542                    pa.display(),
543                    pb.display()
544                )
545            };
546            return Some(format!(
547                "agents '{}' and '{}' both resolve to {where_at} at model {model}. Review \
548                 findings will be correlated: the same model reviewing itself shares the blind \
549                 spots of the model that wrote the code, so it is far less likely to catch what \
550                 the implementer missed. That produces an approval indistinguishable from a real \
551                 review, which is worse than no review at all. Give the two agents different \
552                 CLIs or different models.",
553                a.name(),
554                b.name()
555            ));
556        }
557    }
558    None
559}
560
561/// Build every configured agent, resolving each binary up front so a missing
562/// CLI fails before any model is billed.
563pub fn build(cfg: &crate::config::Config) -> Result<Vec<Agent>> {
564    let agents: Vec<Agent> = cfg.agents.iter().cloned().map(Agent::new).collect();
565    for agent in &agents {
566        agent.resolve_bin()?;
567    }
568    Ok(agents)
569}
570
571/// Look an agent up by name in a built list.
572pub fn find<'a>(agents: &'a [Agent], name: &str) -> Result<&'a Agent> {
573    agents.iter().find(|a| a.name() == name).ok_or_else(|| {
574        SparError::new(format!(
575            "no agent named '{name}' ({})",
576            agents
577                .iter()
578                .map(Agent::name)
579                .collect::<Vec<_>>()
580                .join(", ")
581        ))
582    })
583}
584
585#[cfg(test)]
586mod tests {
587    use super::*;
588    use crate::config::{OutputMode, SystemVia};
589
590    fn spec(command: Vec<CommandPart>) -> AgentSpec {
591        AgentSpec {
592            name: "test".into(),
593            command,
594            model: None,
595            effort: None,
596            output: OutputMode::Text,
597            message_match: BTreeMap::new(),
598            message_path: None,
599            search_paths: vec![],
600            system_via: SystemVia::Prompt,
601            timeout: 60,
602            models: vec![],
603            efforts: vec![],
604            options_note: None,
605        }
606    }
607
608    fn one(s: &str) -> CommandPart {
609        CommandPart::One(s.into())
610    }
611
612    fn group(parts: &[&str]) -> CommandPart {
613        CommandPart::Group(parts.iter().map(|s| s.to_string()).collect())
614    }
615
616    fn agent(command: Vec<CommandPart>) -> Agent {
617        Agent::with_bin(spec(command), "/fake/bin")
618    }
619
620    fn values() -> Placeholders {
621        Placeholders {
622            prompt: Some("hi".into()),
623            ..Default::default()
624        }
625    }
626
627    // -- rendering -------------------------------------------------------
628
629    #[test]
630    fn placeholders_are_substituted() {
631        let a = agent(vec![one("x"), group(&["-m", "{model}"]), one("{prompt}")]);
632        let v = Placeholders {
633            model: Some("m1".into()),
634            ..values()
635        };
636        assert_eq!(vec!["/fake/bin", "-m", "m1", "hi"], a.render(&v).unwrap());
637    }
638
639    #[test]
640    fn an_unset_placeholder_drops_the_whole_group() {
641        let a = agent(vec![one("x"), group(&["-m", "{model}"]), one("{prompt}")]);
642        assert_eq!(vec!["/fake/bin", "hi"], a.render(&values()).unwrap());
643    }
644
645    #[test]
646    fn an_empty_string_drops_the_group_too() {
647        let a = agent(vec![one("x"), group(&["-e", "{effort}"]), one("{prompt}")]);
648        let v = Placeholders {
649            effort: Some(String::new()),
650            ..values()
651        };
652        assert_eq!(vec!["/fake/bin", "hi"], a.render(&v).unwrap());
653    }
654
655    #[test]
656    fn a_bare_arg_with_an_unset_placeholder_drops() {
657        let a = agent(vec![one("x"), one("{model}"), one("{prompt}")]);
658        assert_eq!(vec!["/fake/bin", "hi"], a.render(&values()).unwrap());
659    }
660
661    #[test]
662    fn literal_args_survive() {
663        let a = agent(vec![
664            one("x"),
665            one("exec"),
666            one("--json"),
667            one("--"),
668            one("{prompt}"),
669        ]);
670        assert_eq!(
671            vec!["/fake/bin", "exec", "--json", "--", "hi"],
672            a.render(&values()).unwrap()
673        );
674    }
675
676    #[test]
677    fn an_embedded_placeholder_substitutes_in_place() {
678        let a = agent(vec![
679            one("x"),
680            group(&["-c", "model_reasoning_effort={effort}"]),
681        ]);
682        let v = Placeholders {
683            effort: Some("ultra".into()),
684            ..Default::default()
685        };
686        assert_eq!(
687            vec!["/fake/bin", "-c", "model_reasoning_effort=ultra"],
688            a.render(&v).unwrap()
689        );
690    }
691
692    #[test]
693    fn a_group_with_two_placeholders_needs_both() {
694        let a = agent(vec![
695            one("x"),
696            group(&["--a", "{model}", "--b", "{effort}"]),
697        ]);
698        let v = Placeholders {
699            model: Some("m".into()),
700            ..Default::default()
701        };
702        assert_eq!(vec!["/fake/bin"], a.render(&v).unwrap());
703    }
704
705    #[test]
706    fn supports_schema_detects_the_placeholder() {
707        assert!(agent(vec![one("x"), group(&["--schema", "{schema_file}"])]).supports_schema());
708        assert!(!agent(vec![one("x"), one("{prompt}")]).supports_schema());
709    }
710
711    // -- output adapters -------------------------------------------------
712
713    #[test]
714    fn text_passes_through_trimmed() {
715        assert_eq!("hello", agent(vec![one("x")]).extract("  hello\n").unwrap());
716    }
717
718    #[test]
719    fn jsonl_picks_the_matching_event() {
720        let mut spec = spec(vec![one("x")]);
721        spec.output = OutputMode::Jsonl;
722        spec.message_path = Some("item.text".into());
723        spec.message_match = BTreeMap::from([
724            ("type".to_string(), "item.completed".to_string()),
725            ("item.type".to_string(), "agent_message".to_string()),
726        ]);
727        let a = Agent::with_bin(spec, "/fake/bin");
728        let stream = [
729            r#"{"type":"thread.started","thread_id":"t1"}"#,
730            r#"{"type":"item.completed","item":{"type":"command_execution","text":"ls"}}"#,
731            r#"{"type":"item.completed","item":{"type":"agent_message","text":"the answer"}}"#,
732            "not json at all",
733        ]
734        .join("\n");
735        assert_eq!("the answer", a.extract(&stream).unwrap());
736    }
737
738    #[test]
739    fn jsonl_raises_on_an_error_with_no_message() {
740        let mut spec = spec(vec![one("x")]);
741        spec.output = OutputMode::Jsonl;
742        spec.message_path = Some("item.text".into());
743        spec.message_match = BTreeMap::from([("type".into(), "item.completed".into())]);
744        let a = Agent::with_bin(spec, "/fake/bin");
745        assert!(a
746            .extract(r#"{"type":"turn.failed","error":"boom"}"#)
747            .is_err());
748    }
749
750    #[test]
751    fn jsonl_joins_several_agent_messages() {
752        let mut spec = spec(vec![one("x")]);
753        spec.output = OutputMode::Jsonl;
754        spec.message_path = Some("text".into());
755        spec.message_match = BTreeMap::from([("type".into(), "msg".into())]);
756        let a = Agent::with_bin(spec, "/fake/bin");
757        let stream = "{\"type\":\"msg\",\"text\":\"one\"}\n{\"type\":\"msg\",\"text\":\"two\"}";
758        assert_eq!("one\ntwo", a.extract(stream).unwrap());
759    }
760
761    #[test]
762    fn dig_walks_a_dotted_path() {
763        let v: Value = serde_json::from_str(r#"{"a":{"b":{"c":1}}}"#).unwrap();
764        assert_eq!(Some(&Value::from(1)), dig(&v, "a.b.c"));
765        assert_eq!(None, dig(&v, "a.b.missing"));
766        assert_eq!(None, dig(&v, ""));
767    }
768
769    // -- binary resolution -----------------------------------------------
770
771    #[test]
772    fn a_missing_binary_lists_everywhere_it_looked() {
773        let mut s = spec(vec![one("definitely-not-installed-xyz")]);
774        s.search_paths = vec!["/nowhere/at/all".into()];
775        s.name = "codex".into();
776        let err = Agent::new(s).resolve_bin().unwrap_err().to_string();
777        assert!(err.contains("definitely-not-installed-xyz (PATH)"), "{err}");
778        assert!(
779            err.contains("/nowhere/at/all/definitely-not-installed-xyz"),
780            "{err}"
781        );
782        assert!(err.contains("SPAR_CODEX_BIN"), "{err}");
783    }
784
785    #[test]
786    fn a_search_path_that_already_names_the_binary_is_used_as_is() {
787        let dir = std::env::temp_dir().join(format!("spar-test-{}", std::process::id()));
788        std::fs::create_dir_all(&dir).unwrap();
789        let bin = dir.join("mytool");
790        std::fs::write(&bin, "#!/bin/sh\n").unwrap();
791        #[cfg(unix)]
792        {
793            use std::os::unix::fs::PermissionsExt;
794            std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
795        }
796        let mut s = spec(vec![one("mytool")]);
797        s.search_paths = vec![bin.display().to_string()];
798        assert_eq!(bin, Agent::new(s).resolve_bin().unwrap());
799        let _ = std::fs::remove_dir_all(&dir);
800    }
801
802    // -- correlation -----------------------------------------------------
803
804    fn named(name: &str, bin: &str, model: Option<&str>) -> Agent {
805        let mut s = spec(vec![one("prog")]);
806        s.name = name.into();
807        s.model = model.map(str::to_string);
808        Agent::with_bin(s, bin)
809    }
810
811    #[test]
812    fn same_bin_same_model_warns() {
813        let agents = vec![
814            named("alpha", "/usr/local/bin/claude", Some("fable")),
815            named("beta", "/usr/local/bin/claude", Some("fable")),
816        ];
817        let msg = correlation_warning(&agents).expect("should warn");
818        assert!(msg.contains("alpha") && msg.contains("beta"), "{msg}");
819    }
820
821    #[test]
822    fn different_model_does_not_warn() {
823        let agents = vec![
824            named("a", "/usr/local/bin/claude", Some("fable")),
825            named("b", "/usr/local/bin/claude", Some("opus")),
826        ];
827        assert!(correlation_warning(&agents).is_none());
828    }
829
830    #[test]
831    fn different_bin_does_not_warn() {
832        let agents = vec![
833            named("a", "/usr/local/bin/claude", Some("fable")),
834            named("b", "/usr/local/bin/codex", Some("fable")),
835        ];
836        assert!(correlation_warning(&agents).is_none());
837    }
838
839    #[test]
840    fn unset_and_empty_model_both_mean_the_default_and_warn() {
841        let agents = vec![
842            named("a", "/usr/local/bin/claude", None),
843            named("b", "/usr/local/bin/claude", Some("")),
844        ];
845        let msg = correlation_warning(&agents).expect("should warn");
846        assert!(msg.contains("the CLI's default"), "{msg}");
847    }
848
849    #[test]
850    fn a_padded_model_still_warns() {
851        let agents = vec![
852            named("a", "/usr/local/bin/claude", Some("fable")),
853            named("b", "/usr/local/bin/claude", Some(" fable ")),
854        ];
855        assert!(correlation_warning(&agents).is_some());
856    }
857
858    #[test]
859    fn an_empty_model_against_a_named_one_does_not_warn() {
860        let agents = vec![
861            named("a", "/usr/local/bin/claude", Some("")),
862            named("b", "/usr/local/bin/claude", Some("fable")),
863        ];
864        assert!(correlation_warning(&agents).is_none());
865    }
866
867    #[cfg(unix)]
868    #[test]
869    fn a_symlinked_binary_warns_and_names_both_paths() {
870        use std::os::unix::fs::PermissionsExt;
871        let dir = std::env::temp_dir().join(format!("spar-link-{}", std::process::id()));
872        let _ = std::fs::remove_dir_all(&dir);
873        std::fs::create_dir_all(&dir).unwrap();
874        let real = dir.join("claude");
875        let link = dir.join("claude-alias");
876        std::fs::write(&real, "#!/bin/sh\n").unwrap();
877        std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o755)).unwrap();
878        std::os::unix::fs::symlink(&real, &link).unwrap();
879
880        let agents = vec![
881            named("alpha", real.to_str().unwrap(), Some("fable")),
882            named("beta", link.to_str().unwrap(), Some("fable")),
883        ];
884        let msg = correlation_warning(&agents).expect("should warn");
885        assert!(msg.contains(real.to_str().unwrap()), "{msg}");
886        assert!(msg.contains(link.to_str().unwrap()), "{msg}");
887        let _ = std::fs::remove_dir_all(&dir);
888    }
889
890    #[cfg(unix)]
891    #[test]
892    fn two_distinct_real_binaries_stay_quiet() {
893        use std::os::unix::fs::PermissionsExt;
894        let dir = std::env::temp_dir().join(format!("spar-distinct-{}", std::process::id()));
895        let _ = std::fs::remove_dir_all(&dir);
896        std::fs::create_dir_all(&dir).unwrap();
897        let mut paths = Vec::new();
898        for name in ["claude", "codex"] {
899            let path = dir.join(name);
900            std::fs::write(&path, "#!/bin/sh\n").unwrap();
901            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
902            paths.push(path);
903        }
904        let agents = vec![
905            named("a", paths[0].to_str().unwrap(), Some("fable")),
906            named("b", paths[1].to_str().unwrap(), Some("fable")),
907        ];
908        assert!(correlation_warning(&agents).is_none());
909        let _ = std::fs::remove_dir_all(&dir);
910    }
911
912    #[test]
913    fn the_style_rules_ask_for_brevity_and_no_attribution() {
914        let lower = STYLE_RULES.to_lowercase();
915        assert!(lower.contains("brief"));
916        assert!(lower.contains("co-authored-by"));
917        assert!(lower.contains("em-dash"));
918    }
919}
920
921#[cfg(test)]
922mod schema_placeholder_tests {
923    use super::*;
924    use crate::config::{OutputMode, SystemVia};
925
926    fn spec_with(command: Vec<CommandPart>) -> AgentSpec {
927        AgentSpec {
928            name: "claude".into(),
929            command,
930            model: None,
931            effort: None,
932            output: OutputMode::Text,
933            message_match: BTreeMap::new(),
934            message_path: None,
935            search_paths: vec![],
936            system_via: SystemVia::Prompt,
937            timeout: 60,
938            models: vec![],
939            efforts: vec![],
940            options_note: None,
941        }
942    }
943
944    fn one(s: &str) -> CommandPart {
945        CommandPart::One(s.into())
946    }
947    fn group(parts: &[&str]) -> CommandPart {
948        CommandPart::Group(parts.iter().map(|s| s.to_string()).collect())
949    }
950
951    /// Claude Code takes the schema as an argument, not a path, so the file
952    /// form alone was not enough to give it native structured output.
953    #[test]
954    fn either_schema_form_counts_as_native_support() {
955        let inline = Agent::with_bin(
956            spec_with(vec![one("x"), group(&["--json-schema", "{schema}"])]),
957            "/b",
958        );
959        let byfile = Agent::with_bin(
960            spec_with(vec![one("x"), group(&["--output-schema", "{schema_file}"])]),
961            "/b",
962        );
963        let neither = Agent::with_bin(spec_with(vec![one("x"), one("{prompt}")]), "/b");
964        assert!(inline.supports_schema());
965        assert!(byfile.supports_schema());
966        assert!(!neither.supports_schema());
967    }
968
969    #[test]
970    fn the_inline_schema_is_substituted_whole() {
971        let agent = Agent::with_bin(
972            spec_with(vec![
973                one("x"),
974                group(&["--json-schema", "{schema}"]),
975                one("{prompt}"),
976            ]),
977            "/b",
978        );
979        let values = Placeholders {
980            prompt: Some("review it".into()),
981            schema: Some(r#"{"type":"object"}"#.into()),
982            ..Default::default()
983        };
984        assert_eq!(
985            vec!["/b", "--json-schema", r#"{"type":"object"}"#, "review it"],
986            agent.render(&values).unwrap()
987        );
988    }
989
990    /// A call that wants prose back passes no schema, and the flag must go with
991    /// it rather than being handed an empty string.
992    #[test]
993    fn the_schema_flag_drops_when_no_schema_is_wanted() {
994        let agent = Agent::with_bin(
995            spec_with(vec![
996                one("x"),
997                group(&["--json-schema", "{schema}"]),
998                one("{prompt}"),
999            ]),
1000            "/b",
1001        );
1002        let values = Placeholders {
1003            prompt: Some("implement it".into()),
1004            ..Default::default()
1005        };
1006        assert_eq!(vec!["/b", "implement it"], agent.render(&values).unwrap());
1007    }
1008
1009    /// `{schema_file}` must not be mistaken for `{schema}`.
1010    #[test]
1011    fn the_two_schema_placeholders_do_not_collide() {
1012        let agent = Agent::with_bin(
1013            spec_with(vec![one("x"), group(&["--output-schema", "{schema_file}"])]),
1014            "/b",
1015        );
1016        let values = Placeholders {
1017            schema: Some("INLINE".into()),
1018            schema_file: Some("/tmp/s.json".into()),
1019            ..Default::default()
1020        };
1021        assert_eq!(
1022            vec!["/b", "--output-schema", "/tmp/s.json"],
1023            agent.render(&values).unwrap()
1024        );
1025    }
1026
1027    /// The preset that was failing in the field.
1028    #[test]
1029    fn the_shipped_claude_preset_now_has_native_structured_output() {
1030        let raw = crate::config::load_preset("claude").unwrap();
1031        let table = raw.as_table().cloned().unwrap();
1032        let mut spec: AgentSpec = toml::Value::Table(table).try_into().unwrap();
1033        spec.name = "claude".into();
1034        assert!(
1035            Agent::with_bin(spec, "/b").supports_schema(),
1036            "without this a long review is parsed out of prose and truncates"
1037        );
1038    }
1039}