1use 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
20pub 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 #[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 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 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 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 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 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 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 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 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#[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 pub schema_file: Option<String>,
369 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 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
412fn 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
449struct 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
489fn 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
513pub 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
557pub 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
567pub 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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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}