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