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