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