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, log, 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 fallback: Option<Box<Agent>>,
47 resolved: OnceLock<PathBuf>,
48}
49
50impl std::fmt::Debug for Agent {
51 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52 write!(f, "<{} {}>", self.spec.name, self.spec.describe())
53 }
54}
55
56impl Agent {
57 pub fn new(spec: AgentSpec) -> Self {
58 let fallback = spec
59 .fallback
60 .clone()
61 .map(|backup| Box::new(Agent::new(*backup)));
62 Self {
63 spec,
64 fallback,
65 resolved: OnceLock::new(),
66 }
67 }
68
69 pub fn name(&self) -> &str {
70 &self.spec.name
71 }
72
73 pub fn fallback(&self) -> Option<&Agent> {
75 self.fallback.as_deref()
76 }
77
78 pub fn program(&self) -> &str {
81 match self.spec.command.first() {
82 Some(CommandPart::One(program)) => program,
83 _ => self.name(),
84 }
85 }
86
87 pub fn env_key(&self) -> String {
89 format!(
90 "SPAR_{}_BIN",
91 self.spec.name.to_uppercase().replace('-', "_")
92 )
93 }
94
95 #[doc(hidden)]
98 pub fn with_bin(spec: AgentSpec, bin: impl Into<PathBuf>) -> Self {
99 let agent = Self::new(spec);
100 let _ = agent.resolved.set(bin.into());
101 agent
102 }
103
104 pub fn resolve_bin(&self) -> Result<&Path> {
111 if let Some(found) = self.resolved.get() {
112 return Ok(found.as_path());
113 }
114 let found = self.locate()?;
115 let _ = self.resolved.set(found);
116 Ok(self.resolved.get().expect("just set").as_path())
117 }
118
119 fn locate(&self) -> Result<PathBuf> {
120 let wanted = match self.spec.command.first() {
121 Some(CommandPart::One(program)) => program.clone(),
122 _ => bail!("agent '{}' has no command configured", self.spec.name),
123 };
124
125 let env_key = self.env_key();
126 let env_override = std::env::var(&env_key)
127 .ok()
128 .filter(|v| !v.trim().is_empty());
129
130 let mut tried: Vec<String> = Vec::new();
131
132 for candidate in env_override
133 .iter()
134 .map(String::as_str)
135 .chain([wanted.as_str()])
136 {
137 let path = Path::new(candidate);
138 if path.is_absolute() || candidate.contains(std::path::MAIN_SEPARATOR) {
139 let expanded = proc::expand_tilde(candidate);
140 tried.push(expanded.display().to_string());
141 if proc::is_executable(&expanded) {
142 return Ok(expanded);
143 }
144 } else {
145 tried.push(format!("{candidate} (PATH)"));
146 if let Some(found) = proc::which(candidate) {
147 return Ok(found);
148 }
149 }
150 }
151
152 for base in &self.spec.search_paths {
153 let base = proc::expand_tilde(base);
154 let candidate = if base.file_name().and_then(|n| n.to_str()) == Some(wanted.as_str()) {
155 base
156 } else {
157 base.join(&wanted)
158 };
159 tried.push(candidate.display().to_string());
160 if proc::is_executable(&candidate) {
161 return Ok(candidate);
162 }
163 }
164
165 Err(spar_err!(
166 "could not find the binary for agent '{}'. Tried:\n {}\nSet agents.{}.command[0] to \
167 an absolute path, or {}=/path/to/binary.",
168 self.spec.name,
169 tried.join("\n "),
170 self.spec.name,
171 env_key
172 ))
173 }
174
175 pub fn render(&self, values: &Placeholders) -> Result<Vec<String>> {
181 let mut out = vec![self.resolve_bin()?.display().to_string()];
182 for part in self.spec.command.iter().skip(1) {
183 let mut rendered = Vec::new();
184 let mut skip = false;
185 for arg in part.args() {
186 match values.substitute(arg) {
187 Some(text) => rendered.push(text),
188 None => {
189 skip = true;
190 break;
191 }
192 }
193 }
194 if !skip {
195 out.extend(rendered);
196 }
197 }
198 Ok(out)
199 }
200
201 pub fn supports_schema(&self) -> bool {
207 self.spec
208 .command
209 .iter()
210 .flat_map(|p| p.args())
211 .any(|a| a.contains("{schema_file}") || a.contains("{schema}"))
212 }
213
214 pub fn extract(&self, stdout: &str) -> Result<String> {
217 match self.spec.output {
218 OutputMode::Text | OutputMode::Json => Ok(stdout.trim().to_string()),
219 OutputMode::Jsonl => self.extract_jsonl(stdout),
220 }
221 }
222
223 fn extract_jsonl(&self, stdout: &str) -> Result<String> {
224 let mut messages: Vec<String> = Vec::new();
225 let mut errors: Vec<String> = Vec::new();
226
227 for line in stdout.lines() {
228 let line = line.trim();
229 if !line.starts_with('{') {
230 continue;
231 }
232 let Ok(event) = serde_json::from_str::<Value>(line) else {
233 continue;
234 };
235 if matches(&event, &self.spec.message_match) {
236 if let Some(text) = dig(&event, self.spec.message_path.as_deref().unwrap_or("")) {
237 if let Some(text) = as_text(text) {
238 messages.push(text);
239 }
240 }
241 } else if matches!(
242 event.get("type").and_then(Value::as_str),
243 Some("turn.failed") | Some("error")
244 ) {
245 errors.push(truncate(&event.to_string(), 400));
246 }
247 }
248
249 if messages.is_empty() && !errors.is_empty() {
250 bail!("agent '{}' failed: {}", self.spec.name, errors.join("; "));
251 }
252 Ok(messages.join("\n").trim().to_string())
253 }
254
255 pub fn ask(&self, prompt: &str, cwd: &Path, effort: Option<&str>) -> Result<String> {
258 match self.ask_inner(prompt, cwd, effort, None, None) {
259 Ok(text) => Ok(text),
260 Err(e) => self.hand_over(e, |backup| backup.ask(prompt, cwd, None)),
261 }
262 }
263
264 fn hand_over<T>(&self, primary: SparError, run: impl FnOnce(&Agent) -> Result<T>) -> Result<T> {
275 let Some(backup) = self.fallback() else {
276 return Err(primary);
277 };
278 logwarn!(
279 "{} could not answer. Handing the call to {}.\n{primary}",
280 self.name(),
281 backup.name()
282 );
283 match run(backup) {
284 Ok(answer) => {
285 log!("{} answered in place of {}", backup.name(), self.name());
286 Ok(answer)
287 }
288 Err(second) => Err(spar_err!(
292 "agent '{}' failed and its fallback '{}' could not stand in.\n{}\n\n{}: {}",
293 self.name(),
294 backup.name(),
295 primary.message(),
296 backup.name(),
297 second.message()
298 )),
299 }
300 }
301
302 fn ask_inner(
303 &self,
304 prompt: &str,
305 cwd: &Path,
306 effort: Option<&str>,
307 schema_file: Option<&Path>,
308 schema: Option<&str>,
309 ) -> Result<String> {
310 let body = match self.spec.system_via {
311 SystemVia::Placeholder => prompt.to_string(),
312 SystemVia::Prompt => format!("{STYLE_RULES}\n\n{prompt}"),
313 };
314 let values = Placeholders {
315 prompt: Some(body),
316 system: Some(STYLE_RULES.to_string()),
317 model: self.spec.model.clone(),
318 effort: effort
319 .map(str::to_string)
320 .or_else(|| self.spec.effort.clone()),
321 cwd: Some(cwd.display().to_string()),
322 schema_file: schema_file.map(|p| p.display().to_string()),
323 schema: schema.map(str::to_string),
324 };
325 let argv = self.render(&values)?;
326 let opts = ExecOpts::new().cwd(cwd).timeout_secs(self.spec.timeout);
327 let stdout = proc::run(&argv, &opts)?;
328 self.extract(&stdout)
329 }
330
331 pub fn ask_json<T: serde::de::DeserializeOwned>(
335 &self,
336 prompt: &str,
337 schema: &Value,
338 cwd: &Path,
339 effort: Option<&str>,
340 ) -> Result<T> {
341 match self.ask_json_retrying(prompt, schema, cwd, effort) {
342 Ok(parsed) => Ok(parsed),
343 Err(e) => self.hand_over(e, |backup| {
344 backup.ask_json_retrying::<T>(prompt, schema, cwd, None)
345 }),
346 }
347 }
348
349 fn ask_json_retrying<T: serde::de::DeserializeOwned>(
351 &self,
352 prompt: &str,
353 schema: &Value,
354 cwd: &Path,
355 effort: Option<&str>,
356 ) -> Result<T> {
357 const ATTEMPTS: usize = 2;
363 let mut last: Option<SparError> = None;
364
365 for attempt in 1..=ATTEMPTS {
366 let asked = match &last {
367 None => prompt.to_string(),
368 Some(e) => format!(
369 "{prompt}\n\nYour previous answer could not be used: {}\nReturn the whole \
370 object this time, exactly matching the schema, and nothing else.",
371 e.first_line()
372 ),
373 };
374 match self.ask_json_once::<T>(&asked, schema, cwd, effort) {
375 Ok(parsed) => {
376 if attempt > 1 {
377 logdim!("{} answered on the retry", self.spec.name);
378 }
379 return Ok(parsed);
380 }
381 Err(e) if !e.worth_retrying() => return Err(e),
385 Err(e) => {
386 if attempt < ATTEMPTS {
387 logwarn!("{} failed, asking again.\n{e}", self.spec.name);
392 }
393 last = Some(e);
394 }
395 }
396 }
397 Err(spar_err!(
398 "agent '{}' returned an unusable answer twice: {}",
399 self.spec.name,
400 last.expect("at least one attempt").message()
401 ))
402 }
403
404 fn ask_json_once<T: serde::de::DeserializeOwned>(
405 &self,
406 prompt: &str,
407 schema: &Value,
408 cwd: &Path,
409 effort: Option<&str>,
410 ) -> Result<T> {
411 let text = if self.supports_schema() {
412 let inline = serde_json::to_string(schema).unwrap_or_default();
413 let file = TempJson::write(schema)?;
414 self.ask_inner(prompt, cwd, effort, Some(file.path()), Some(&inline))?
415 } else {
416 let full = format!(
417 "{prompt}\n\n{JSON_INSTRUCTION}\n{}",
418 serde_json::to_string_pretty(schema).unwrap_or_default()
419 );
420 self.ask_inner(&full, cwd, effort, None, None)?
421 };
422 jsonx::extract_into(&text)
423 }
424
425 pub fn review<T: serde::de::DeserializeOwned>(
433 &self,
434 base: &str,
435 prompt: &str,
436 schema: &Value,
437 cwd: &Path,
438 effort: Option<&str>,
439 ) -> Result<T> {
440 let scoped = format!(
441 "{prompt}\n\nThe changes under review are the diff between `{base}` and HEAD in your \
442 working directory. Inspect them with git, then read the surrounding code before \
443 judging. Do not review only the diff."
444 );
445 self.ask_json(&scoped, schema, cwd, effort)
446 }
447}
448
449#[derive(Debug, Default, Clone)]
454pub struct Placeholders {
455 pub prompt: Option<String>,
456 pub system: Option<String>,
457 pub model: Option<String>,
458 pub effort: Option<String>,
459 pub cwd: Option<String>,
460 pub schema_file: Option<String>,
462 pub schema: Option<String>,
464}
465
466impl Placeholders {
467 fn get(&self, key: &str) -> Option<&str> {
468 let value = match key {
469 "prompt" => self.prompt.as_deref(),
470 "system" => self.system.as_deref(),
471 "model" => self.model.as_deref(),
472 "effort" => self.effort.as_deref(),
473 "cwd" => self.cwd.as_deref(),
474 "schema_file" => self.schema_file.as_deref(),
475 "schema" => self.schema.as_deref(),
476 _ => None,
477 };
478 value.filter(|v| !v.is_empty())
479 }
480
481 fn substitute(&self, arg: &str) -> Option<String> {
484 const KEYS: [&str; 7] = [
485 "prompt",
486 "system",
487 "model",
488 "effort",
489 "cwd",
490 "schema_file",
491 "schema",
492 ];
493 let mut out = arg.to_string();
494 for key in KEYS {
495 let token = format!("{{{key}}}");
496 if out.contains(&token) {
497 let value = self.get(key)?;
498 out = out.replace(&token, value);
499 }
500 }
501 Some(out)
502 }
503}
504
505fn dig<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
511 if path.is_empty() {
512 return None;
513 }
514 let mut node = value;
515 for part in path.split('.') {
516 node = node.as_object()?.get(part)?;
517 }
518 Some(node)
519}
520
521fn matches(event: &Value, wanted: &BTreeMap<String, String>) -> bool {
522 if wanted.is_empty() {
523 return false;
524 }
525 wanted
526 .iter()
527 .all(|(path, expected)| dig(event, path).and_then(Value::as_str) == Some(expected.as_str()))
528}
529
530fn as_text(value: &Value) -> Option<String> {
531 match value {
532 Value::String(s) => Some(s.clone()),
533 Value::Null => None,
534 other => Some(other.to_string()),
535 }
536}
537
538fn truncate(text: &str, max: usize) -> String {
539 text.chars().take(max).collect()
540}
541
542struct TempJson {
549 path: PathBuf,
550}
551
552impl TempJson {
553 fn write(value: &Value) -> Result<Self> {
554 use std::sync::atomic::{AtomicU64, Ordering};
555 static COUNTER: AtomicU64 = AtomicU64::new(0);
556
557 let nanos = std::time::SystemTime::now()
558 .duration_since(std::time::UNIX_EPOCH)
559 .map(|d| d.as_nanos())
560 .unwrap_or(0);
561 let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
562 let path = std::env::temp_dir().join(format!(
563 "spar-schema-{}-{nanos}-{unique}.json",
564 std::process::id()
565 ));
566 std::fs::write(&path, serde_json::to_vec_pretty(value)?)
567 .map_err(|e| spar_err!("could not write a schema file to {}: {e}", path.display()))?;
568 Ok(Self { path })
569 }
570
571 fn path(&self) -> &Path {
572 &self.path
573 }
574}
575
576impl Drop for TempJson {
577 fn drop(&mut self) {
578 let _ = std::fs::remove_file(&self.path);
579 }
580}
581
582fn same_executable(a: &Path, b: &Path) -> bool {
593 #[cfg(unix)]
594 {
595 use std::os::unix::fs::MetadataExt;
596 if let (Ok(x), Ok(y)) = (std::fs::metadata(a), std::fs::metadata(b)) {
597 return x.dev() == y.dev() && x.ino() == y.ino();
598 }
599 }
600 match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
601 (Ok(x), Ok(y)) => x == y,
602 _ => a == b,
603 }
604}
605
606pub fn correlation_warning(agents: &[Agent]) -> Option<String> {
612 for i in 0..agents.len() {
613 for j in (i + 1)..agents.len() {
614 let (a, b) = (&agents[i], &agents[j]);
615 let (Ok(pa), Ok(pb)) = (a.resolve_bin(), b.resolve_bin()) else {
616 continue;
617 };
618 if !same_executable(pa, pb) || a.spec.model_key() != b.spec.model_key() {
619 continue;
620 }
621 let model = if a.spec.model_key().is_empty() {
622 "the CLI's default".to_string()
623 } else {
624 a.spec.model_key()
625 };
626 let where_at = if pa == pb {
627 pa.display().to_string()
628 } else {
629 format!(
630 "the same executable ({} and {} are the same file)",
631 pa.display(),
632 pb.display()
633 )
634 };
635 return Some(format!(
636 "agents '{}' and '{}' both resolve to {where_at} at model {model}. Review \
637 findings will be correlated: the same model reviewing itself shares the blind \
638 spots of the model that wrote the code, so it is far less likely to catch what \
639 the implementer missed. That produces an approval indistinguishable from a real \
640 review, which is worse than no review at all. Give the two agents different \
641 CLIs or different models.",
642 a.name(),
643 b.name()
644 ));
645 }
646 }
647 None
648}
649
650pub fn build(cfg: &crate::config::Config) -> Result<Vec<Agent>> {
653 let agents: Vec<Agent> = cfg.agents.iter().cloned().map(Agent::new).collect();
654 for agent in &agents {
655 agent.resolve_bin()?;
656 if let Some(backup) = agent.fallback() {
660 if backup.resolve_bin().is_err() {
661 logwarn!(
662 "{} has a fallback ({}) that is not installed, so it will not stand in",
663 agent.name(),
664 backup.program()
665 );
666 }
667 }
668 }
669 Ok(agents)
670}
671
672pub fn find<'a>(agents: &'a [Agent], name: &str) -> Result<&'a Agent> {
674 agents.iter().find(|a| a.name() == name).ok_or_else(|| {
675 SparError::new(format!(
676 "no agent named '{name}' ({})",
677 agents
678 .iter()
679 .map(Agent::name)
680 .collect::<Vec<_>>()
681 .join(", ")
682 ))
683 })
684}
685
686#[cfg(test)]
687mod tests {
688 use super::*;
689 use crate::config::{OutputMode, SystemVia};
690
691 fn spec(command: Vec<CommandPart>) -> AgentSpec {
692 AgentSpec {
693 name: "test".into(),
694 command,
695 model: None,
696 effort: None,
697 output: OutputMode::Text,
698 message_match: BTreeMap::new(),
699 message_path: None,
700 search_paths: vec![],
701 system_via: SystemVia::Prompt,
702 timeout: 60,
703 fallback: None,
704 models: vec![],
705 efforts: vec![],
706 options_note: None,
707 }
708 }
709
710 fn one(s: &str) -> CommandPart {
711 CommandPart::One(s.into())
712 }
713
714 fn group(parts: &[&str]) -> CommandPart {
715 CommandPart::Group(parts.iter().map(|s| s.to_string()).collect())
716 }
717
718 fn agent(command: Vec<CommandPart>) -> Agent {
719 Agent::with_bin(spec(command), "/fake/bin")
720 }
721
722 fn values() -> Placeholders {
723 Placeholders {
724 prompt: Some("hi".into()),
725 ..Default::default()
726 }
727 }
728
729 #[test]
732 fn placeholders_are_substituted() {
733 let a = agent(vec![one("x"), group(&["-m", "{model}"]), one("{prompt}")]);
734 let v = Placeholders {
735 model: Some("m1".into()),
736 ..values()
737 };
738 assert_eq!(vec!["/fake/bin", "-m", "m1", "hi"], a.render(&v).unwrap());
739 }
740
741 #[test]
742 fn an_unset_placeholder_drops_the_whole_group() {
743 let a = agent(vec![one("x"), group(&["-m", "{model}"]), one("{prompt}")]);
744 assert_eq!(vec!["/fake/bin", "hi"], a.render(&values()).unwrap());
745 }
746
747 #[test]
748 fn an_empty_string_drops_the_group_too() {
749 let a = agent(vec![one("x"), group(&["-e", "{effort}"]), one("{prompt}")]);
750 let v = Placeholders {
751 effort: Some(String::new()),
752 ..values()
753 };
754 assert_eq!(vec!["/fake/bin", "hi"], a.render(&v).unwrap());
755 }
756
757 #[test]
758 fn a_bare_arg_with_an_unset_placeholder_drops() {
759 let a = agent(vec![one("x"), one("{model}"), one("{prompt}")]);
760 assert_eq!(vec!["/fake/bin", "hi"], a.render(&values()).unwrap());
761 }
762
763 #[test]
764 fn literal_args_survive() {
765 let a = agent(vec![
766 one("x"),
767 one("exec"),
768 one("--json"),
769 one("--"),
770 one("{prompt}"),
771 ]);
772 assert_eq!(
773 vec!["/fake/bin", "exec", "--json", "--", "hi"],
774 a.render(&values()).unwrap()
775 );
776 }
777
778 #[test]
779 fn an_embedded_placeholder_substitutes_in_place() {
780 let a = agent(vec![
781 one("x"),
782 group(&["-c", "model_reasoning_effort={effort}"]),
783 ]);
784 let v = Placeholders {
785 effort: Some("ultra".into()),
786 ..Default::default()
787 };
788 assert_eq!(
789 vec!["/fake/bin", "-c", "model_reasoning_effort=ultra"],
790 a.render(&v).unwrap()
791 );
792 }
793
794 #[test]
795 fn a_group_with_two_placeholders_needs_both() {
796 let a = agent(vec![
797 one("x"),
798 group(&["--a", "{model}", "--b", "{effort}"]),
799 ]);
800 let v = Placeholders {
801 model: Some("m".into()),
802 ..Default::default()
803 };
804 assert_eq!(vec!["/fake/bin"], a.render(&v).unwrap());
805 }
806
807 #[test]
808 fn supports_schema_detects_the_placeholder() {
809 assert!(agent(vec![one("x"), group(&["--schema", "{schema_file}"])]).supports_schema());
810 assert!(!agent(vec![one("x"), one("{prompt}")]).supports_schema());
811 }
812
813 #[test]
816 fn text_passes_through_trimmed() {
817 assert_eq!("hello", agent(vec![one("x")]).extract(" hello\n").unwrap());
818 }
819
820 #[test]
821 fn jsonl_picks_the_matching_event() {
822 let mut spec = spec(vec![one("x")]);
823 spec.output = OutputMode::Jsonl;
824 spec.message_path = Some("item.text".into());
825 spec.message_match = BTreeMap::from([
826 ("type".to_string(), "item.completed".to_string()),
827 ("item.type".to_string(), "agent_message".to_string()),
828 ]);
829 let a = Agent::with_bin(spec, "/fake/bin");
830 let stream = [
831 r#"{"type":"thread.started","thread_id":"t1"}"#,
832 r#"{"type":"item.completed","item":{"type":"command_execution","text":"ls"}}"#,
833 r#"{"type":"item.completed","item":{"type":"agent_message","text":"the answer"}}"#,
834 "not json at all",
835 ]
836 .join("\n");
837 assert_eq!("the answer", a.extract(&stream).unwrap());
838 }
839
840 #[test]
841 fn jsonl_raises_on_an_error_with_no_message() {
842 let mut spec = spec(vec![one("x")]);
843 spec.output = OutputMode::Jsonl;
844 spec.message_path = Some("item.text".into());
845 spec.message_match = BTreeMap::from([("type".into(), "item.completed".into())]);
846 let a = Agent::with_bin(spec, "/fake/bin");
847 assert!(a
848 .extract(r#"{"type":"turn.failed","error":"boom"}"#)
849 .is_err());
850 }
851
852 #[test]
853 fn jsonl_joins_several_agent_messages() {
854 let mut spec = spec(vec![one("x")]);
855 spec.output = OutputMode::Jsonl;
856 spec.message_path = Some("text".into());
857 spec.message_match = BTreeMap::from([("type".into(), "msg".into())]);
858 let a = Agent::with_bin(spec, "/fake/bin");
859 let stream = "{\"type\":\"msg\",\"text\":\"one\"}\n{\"type\":\"msg\",\"text\":\"two\"}";
860 assert_eq!("one\ntwo", a.extract(stream).unwrap());
861 }
862
863 #[test]
864 fn dig_walks_a_dotted_path() {
865 let v: Value = serde_json::from_str(r#"{"a":{"b":{"c":1}}}"#).unwrap();
866 assert_eq!(Some(&Value::from(1)), dig(&v, "a.b.c"));
867 assert_eq!(None, dig(&v, "a.b.missing"));
868 assert_eq!(None, dig(&v, ""));
869 }
870
871 #[test]
874 fn a_missing_binary_lists_everywhere_it_looked() {
875 let mut s = spec(vec![one("definitely-not-installed-xyz")]);
876 s.search_paths = vec!["/nowhere/at/all".into()];
877 s.name = "codex".into();
878 let err = Agent::new(s).resolve_bin().unwrap_err().to_string();
879 assert!(err.contains("definitely-not-installed-xyz (PATH)"), "{err}");
880 assert!(
881 err.contains("/nowhere/at/all/definitely-not-installed-xyz"),
882 "{err}"
883 );
884 assert!(err.contains("SPAR_CODEX_BIN"), "{err}");
885 }
886
887 #[test]
888 fn a_search_path_that_already_names_the_binary_is_used_as_is() {
889 let dir = std::env::temp_dir().join(format!("spar-test-{}", std::process::id()));
890 std::fs::create_dir_all(&dir).unwrap();
891 let bin = dir.join("mytool");
892 std::fs::write(&bin, "#!/bin/sh\n").unwrap();
893 #[cfg(unix)]
894 {
895 use std::os::unix::fs::PermissionsExt;
896 std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
897 }
898 let mut s = spec(vec![one("mytool")]);
899 s.search_paths = vec![bin.display().to_string()];
900 assert_eq!(bin, Agent::new(s).resolve_bin().unwrap());
901 let _ = std::fs::remove_dir_all(&dir);
902 }
903
904 fn named(name: &str, bin: &str, model: Option<&str>) -> Agent {
907 let mut s = spec(vec![one("prog")]);
908 s.name = name.into();
909 s.model = model.map(str::to_string);
910 Agent::with_bin(s, bin)
911 }
912
913 #[test]
914 fn same_bin_same_model_warns() {
915 let agents = vec![
916 named("alpha", "/usr/local/bin/claude", Some("fable")),
917 named("beta", "/usr/local/bin/claude", Some("fable")),
918 ];
919 let msg = correlation_warning(&agents).expect("should warn");
920 assert!(msg.contains("alpha") && msg.contains("beta"), "{msg}");
921 }
922
923 #[test]
924 fn different_model_does_not_warn() {
925 let agents = vec![
926 named("a", "/usr/local/bin/claude", Some("fable")),
927 named("b", "/usr/local/bin/claude", Some("opus")),
928 ];
929 assert!(correlation_warning(&agents).is_none());
930 }
931
932 #[test]
933 fn different_bin_does_not_warn() {
934 let agents = vec![
935 named("a", "/usr/local/bin/claude", Some("fable")),
936 named("b", "/usr/local/bin/codex", Some("fable")),
937 ];
938 assert!(correlation_warning(&agents).is_none());
939 }
940
941 #[test]
942 fn unset_and_empty_model_both_mean_the_default_and_warn() {
943 let agents = vec![
944 named("a", "/usr/local/bin/claude", None),
945 named("b", "/usr/local/bin/claude", Some("")),
946 ];
947 let msg = correlation_warning(&agents).expect("should warn");
948 assert!(msg.contains("the CLI's default"), "{msg}");
949 }
950
951 #[test]
952 fn a_padded_model_still_warns() {
953 let agents = vec![
954 named("a", "/usr/local/bin/claude", Some("fable")),
955 named("b", "/usr/local/bin/claude", Some(" fable ")),
956 ];
957 assert!(correlation_warning(&agents).is_some());
958 }
959
960 #[test]
961 fn an_empty_model_against_a_named_one_does_not_warn() {
962 let agents = vec![
963 named("a", "/usr/local/bin/claude", Some("")),
964 named("b", "/usr/local/bin/claude", Some("fable")),
965 ];
966 assert!(correlation_warning(&agents).is_none());
967 }
968
969 #[cfg(unix)]
970 #[test]
971 fn a_symlinked_binary_warns_and_names_both_paths() {
972 use std::os::unix::fs::PermissionsExt;
973 let dir = std::env::temp_dir().join(format!("spar-link-{}", std::process::id()));
974 let _ = std::fs::remove_dir_all(&dir);
975 std::fs::create_dir_all(&dir).unwrap();
976 let real = dir.join("claude");
977 let link = dir.join("claude-alias");
978 std::fs::write(&real, "#!/bin/sh\n").unwrap();
979 std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o755)).unwrap();
980 std::os::unix::fs::symlink(&real, &link).unwrap();
981
982 let agents = vec![
983 named("alpha", real.to_str().unwrap(), Some("fable")),
984 named("beta", link.to_str().unwrap(), Some("fable")),
985 ];
986 let msg = correlation_warning(&agents).expect("should warn");
987 assert!(msg.contains(real.to_str().unwrap()), "{msg}");
988 assert!(msg.contains(link.to_str().unwrap()), "{msg}");
989 let _ = std::fs::remove_dir_all(&dir);
990 }
991
992 #[cfg(unix)]
993 #[test]
994 fn two_distinct_real_binaries_stay_quiet() {
995 use std::os::unix::fs::PermissionsExt;
996 let dir = std::env::temp_dir().join(format!("spar-distinct-{}", std::process::id()));
997 let _ = std::fs::remove_dir_all(&dir);
998 std::fs::create_dir_all(&dir).unwrap();
999 let mut paths = Vec::new();
1000 for name in ["claude", "codex"] {
1001 let path = dir.join(name);
1002 std::fs::write(&path, "#!/bin/sh\n").unwrap();
1003 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
1004 paths.push(path);
1005 }
1006 let agents = vec![
1007 named("a", paths[0].to_str().unwrap(), Some("fable")),
1008 named("b", paths[1].to_str().unwrap(), Some("fable")),
1009 ];
1010 assert!(correlation_warning(&agents).is_none());
1011 let _ = std::fs::remove_dir_all(&dir);
1012 }
1013
1014 #[test]
1015 fn the_style_rules_ask_for_brevity_and_no_attribution() {
1016 let lower = STYLE_RULES.to_lowercase();
1017 assert!(lower.contains("brief"));
1018 assert!(lower.contains("co-authored-by"));
1019 assert!(lower.contains("em-dash"));
1020 }
1021
1022 fn shell(name: &str, line: &str) -> AgentSpec {
1027 let mut spec = spec(vec![one("sh"), one("-c"), one(line)]);
1028 spec.name = name.into();
1029 spec
1030 }
1031
1032 fn with_fallback(mut primary: AgentSpec, backup: AgentSpec) -> Agent {
1033 primary.fallback = Some(Box::new(backup));
1034 Agent::with_bin(primary, "/bin/sh")
1035 }
1036
1037 #[test]
1038 fn a_failed_call_is_answered_by_the_fallback() {
1039 let agent = with_fallback(
1040 shell("primary", "echo refused >&2; exit 1"),
1041 shell("backup", "echo stood in"),
1042 );
1043 let answer = agent.ask("hi", Path::new("."), None).expect("fallback");
1044 assert_eq!("stood in", answer);
1045 }
1046
1047 #[test]
1048 fn without_a_fallback_the_original_error_is_what_surfaces() {
1049 let agent = Agent::with_bin(shell("primary", "echo refused >&2; exit 1"), "/bin/sh");
1050 let err = agent
1051 .ask("hi", Path::new("."), None)
1052 .expect_err("no backup");
1053 assert!(err.message().contains("refused"), "{err}");
1054 }
1055
1056 #[test]
1059 fn both_failing_reports_the_primary_first() {
1060 let agent = with_fallback(
1061 shell("primary", "echo policy refusal >&2; exit 1"),
1062 shell("backup", "echo out of quota >&2; exit 1"),
1063 );
1064 let err = agent
1065 .ask("hi", Path::new("."), None)
1066 .expect_err("both fail");
1067 let text = err.message();
1068 let primary_at = text.find("policy refusal").expect("primary reason");
1069 let backup_at = text.find("out of quota").expect("backup reason");
1070 assert!(primary_at < backup_at, "{text}");
1071 assert!(
1072 text.contains("primary") && text.contains("backup"),
1073 "{text}"
1074 );
1075 }
1076
1077 #[test]
1081 fn a_timeout_still_reaches_the_fallback() {
1082 let mut primary = shell("primary", "sleep 30");
1083 primary.timeout = 1;
1084 let agent = with_fallback(primary, shell("backup", "echo stood in"));
1085 assert_eq!(
1086 "stood in",
1087 agent.ask("hi", Path::new("."), None).expect("fallback")
1088 );
1089 }
1090
1091 #[test]
1094 fn the_fallback_is_built_alongside_the_agent() {
1095 let mut primary = shell("primary", "true");
1096 primary.fallback = Some(Box::new(shell("backup", "true")));
1097 let agent = Agent::new(primary);
1098 assert_eq!(Some("backup"), agent.fallback().map(Agent::name));
1099 assert!(Agent::new(shell("solo", "true")).fallback().is_none());
1100 }
1101}
1102
1103#[cfg(test)]
1104mod schema_placeholder_tests {
1105 use super::*;
1106 use crate::config::{OutputMode, SystemVia};
1107
1108 fn spec_with(command: Vec<CommandPart>) -> AgentSpec {
1109 AgentSpec {
1110 name: "claude".into(),
1111 command,
1112 model: None,
1113 effort: None,
1114 output: OutputMode::Text,
1115 message_match: BTreeMap::new(),
1116 message_path: None,
1117 search_paths: vec![],
1118 system_via: SystemVia::Prompt,
1119 timeout: 60,
1120 fallback: None,
1121 models: vec![],
1122 efforts: vec![],
1123 options_note: None,
1124 }
1125 }
1126
1127 fn one(s: &str) -> CommandPart {
1128 CommandPart::One(s.into())
1129 }
1130 fn group(parts: &[&str]) -> CommandPart {
1131 CommandPart::Group(parts.iter().map(|s| s.to_string()).collect())
1132 }
1133
1134 #[test]
1137 fn either_schema_form_counts_as_native_support() {
1138 let inline = Agent::with_bin(
1139 spec_with(vec![one("x"), group(&["--json-schema", "{schema}"])]),
1140 "/b",
1141 );
1142 let byfile = Agent::with_bin(
1143 spec_with(vec![one("x"), group(&["--output-schema", "{schema_file}"])]),
1144 "/b",
1145 );
1146 let neither = Agent::with_bin(spec_with(vec![one("x"), one("{prompt}")]), "/b");
1147 assert!(inline.supports_schema());
1148 assert!(byfile.supports_schema());
1149 assert!(!neither.supports_schema());
1150 }
1151
1152 #[test]
1153 fn the_inline_schema_is_substituted_whole() {
1154 let agent = Agent::with_bin(
1155 spec_with(vec![
1156 one("x"),
1157 group(&["--json-schema", "{schema}"]),
1158 one("{prompt}"),
1159 ]),
1160 "/b",
1161 );
1162 let values = Placeholders {
1163 prompt: Some("review it".into()),
1164 schema: Some(r#"{"type":"object"}"#.into()),
1165 ..Default::default()
1166 };
1167 assert_eq!(
1168 vec!["/b", "--json-schema", r#"{"type":"object"}"#, "review it"],
1169 agent.render(&values).unwrap()
1170 );
1171 }
1172
1173 #[test]
1176 fn the_schema_flag_drops_when_no_schema_is_wanted() {
1177 let agent = Agent::with_bin(
1178 spec_with(vec![
1179 one("x"),
1180 group(&["--json-schema", "{schema}"]),
1181 one("{prompt}"),
1182 ]),
1183 "/b",
1184 );
1185 let values = Placeholders {
1186 prompt: Some("implement it".into()),
1187 ..Default::default()
1188 };
1189 assert_eq!(vec!["/b", "implement it"], agent.render(&values).unwrap());
1190 }
1191
1192 #[test]
1194 fn the_two_schema_placeholders_do_not_collide() {
1195 let agent = Agent::with_bin(
1196 spec_with(vec![one("x"), group(&["--output-schema", "{schema_file}"])]),
1197 "/b",
1198 );
1199 let values = Placeholders {
1200 schema: Some("INLINE".into()),
1201 schema_file: Some("/tmp/s.json".into()),
1202 ..Default::default()
1203 };
1204 assert_eq!(
1205 vec!["/b", "--output-schema", "/tmp/s.json"],
1206 agent.render(&values).unwrap()
1207 );
1208 }
1209
1210 #[test]
1212 fn the_shipped_claude_preset_now_has_native_structured_output() {
1213 let raw = crate::config::load_preset("claude").unwrap();
1214 let table = raw.as_table().cloned().unwrap();
1215 let mut spec: AgentSpec = toml::Value::Table(table).try_into().unwrap();
1216 spec.name = "claude".into();
1217 assert!(
1218 Agent::with_bin(spec, "/b").supports_schema(),
1219 "without this a long review is parsed out of prose and truncates"
1220 );
1221 }
1222}