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::{ErrorKind, 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
226 for line in stdout.lines() {
227 let line = line.trim();
228 if !line.starts_with('{') {
229 continue;
230 }
231 let Ok(event) = serde_json::from_str::<Value>(line) else {
232 continue;
233 };
234 if matches(&event, &self.spec.message_match) {
235 if let Some(text) = dig(&event, self.spec.message_path.as_deref().unwrap_or("")) {
236 if let Some(text) = as_text(text) {
237 messages.push(text);
238 }
239 }
240 }
241 }
242
243 if messages.is_empty() {
244 let reasons = self.error_events(stdout);
245 if !reasons.is_empty() {
246 return Err(SparError::call_failed(format!(
249 "agent '{}' failed: {}",
250 self.spec.name,
251 reasons.join("; ")
252 )));
253 }
254 }
255 Ok(messages.join("\n").trim().to_string())
256 }
257
258 fn error_events(&self, stdout: &str) -> Vec<String> {
268 let mut reasons: Vec<String> = Vec::new();
269 for line in stdout.lines() {
270 let line = line.trim();
271 if !line.starts_with('{') {
272 continue;
273 }
274 let Ok(event) = serde_json::from_str::<Value>(line) else {
275 continue;
276 };
277 if !matches!(
278 event.get("type").and_then(Value::as_str),
279 Some("turn.failed") | Some("error")
280 ) {
281 continue;
282 }
283 let reason = dig(&event, "message")
284 .or_else(|| dig(&event, "error.message"))
285 .and_then(as_text)
286 .unwrap_or_else(|| truncate(&event.to_string(), 400));
287 if !reason.trim().is_empty() && !reasons.contains(&reason) {
288 reasons.push(reason);
289 }
290 }
291 reasons
292 }
293
294 fn call_failure(&self, argv: &[String], out: &proc::Output) -> SparError {
306 if self.spec.output != OutputMode::Jsonl {
307 return SparError::call_failed(proc::failure_message(argv, out));
308 }
309 let reasons = self.error_events(&out.stdout);
310 if reasons.is_empty() {
311 return SparError::call_failed(proc::failure_message(argv, out));
312 }
313 let mut text = format!(
314 "agent '{}' could not answer (exit {}): {}",
315 self.spec.name,
316 out.code,
317 reasons.join("; ")
318 );
319 let stderr = out.stderr.trim();
320 if !stderr.is_empty() {
321 text.push_str(&format!("\n--- stderr ---\n{stderr}"));
322 }
323 text.push_str(&format!("\n--- command ---\n{}", proc::abbreviate(argv)));
324 SparError::call_failed(text)
325 }
326
327 pub fn ask(&self, prompt: &str, cwd: &Path, effort: Option<&str>) -> Result<String> {
330 match self.ask_inner(prompt, cwd, effort, None, None) {
331 Ok(text) => Ok(text),
332 Err(e) => self.hand_over(e, |backup| backup.ask(prompt, cwd, None)),
333 }
334 }
335
336 fn hand_over<T>(&self, primary: SparError, run: impl FnOnce(&Agent) -> Result<T>) -> Result<T> {
347 let Some(backup) = self.fallback() else {
348 return Err(primary);
349 };
350 logwarn!(
351 "{} could not answer. Handing the call to {}.\n{primary}",
352 self.name(),
353 backup.name()
354 );
355 match run(backup) {
356 Ok(answer) => {
357 log!("{} answered in place of {}", backup.name(), self.name());
358 Ok(answer)
359 }
360 Err(second) => Err(spar_err!(
364 "agent '{}' failed and its fallback '{}' could not stand in.\n{}\n\n{}: {}",
365 self.name(),
366 backup.name(),
367 primary.message(),
368 backup.name(),
369 second.message()
370 )),
371 }
372 }
373
374 fn ask_inner(
375 &self,
376 prompt: &str,
377 cwd: &Path,
378 effort: Option<&str>,
379 schema_file: Option<&Path>,
380 schema: Option<&str>,
381 ) -> Result<String> {
382 let body = match self.spec.system_via {
383 SystemVia::Placeholder => prompt.to_string(),
384 SystemVia::Prompt => format!("{STYLE_RULES}\n\n{prompt}"),
385 };
386 let values = Placeholders {
387 prompt: Some(body),
388 system: Some(STYLE_RULES.to_string()),
389 model: self.spec.model.clone(),
390 effort: effort
391 .map(str::to_string)
392 .or_else(|| self.spec.effort.clone()),
393 cwd: Some(cwd.display().to_string()),
394 schema_file: schema_file.map(|p| p.display().to_string()),
395 schema: schema.map(str::to_string),
396 };
397 let argv = self.render(&values)?;
398 let opts = ExecOpts::new()
402 .cwd(cwd)
403 .timeout_secs(self.spec.timeout)
404 .check(false);
405 let out = proc::exec(&argv, &opts)?;
406 if !out.ok() {
407 return Err(self.call_failure(&argv, &out));
408 }
409 self.extract(&out.stdout)
410 }
411
412 pub fn ask_json<T: serde::de::DeserializeOwned>(
416 &self,
417 prompt: &str,
418 schema: &Value,
419 cwd: &Path,
420 effort: Option<&str>,
421 ) -> Result<T> {
422 match self.ask_json_retrying(prompt, schema, cwd, effort) {
423 Ok(parsed) => Ok(parsed),
424 Err(e) => self.hand_over(e, |backup| {
425 backup.ask_json_retrying::<T>(prompt, schema, cwd, None)
426 }),
427 }
428 }
429
430 fn worth_asking_again(&self, e: &SparError) -> bool {
443 match e.kind() {
444 ErrorKind::TimedOut => false,
445 ErrorKind::CallFailed => self.fallback().is_none(),
446 ErrorKind::Other => true,
447 }
448 }
449
450 fn ask_json_retrying<T: serde::de::DeserializeOwned>(
452 &self,
453 prompt: &str,
454 schema: &Value,
455 cwd: &Path,
456 effort: Option<&str>,
457 ) -> Result<T> {
458 const ATTEMPTS: usize = 2;
464 let mut last: Option<SparError> = None;
465
466 for attempt in 1..=ATTEMPTS {
467 let asked = match &last {
468 None => prompt.to_string(),
469 Some(e) => format!(
470 "{prompt}\n\nYour previous answer could not be used: {}\nReturn the whole \
471 object this time, exactly matching the schema, and nothing else.",
472 e.first_line()
473 ),
474 };
475 match self.ask_json_once::<T>(&asked, schema, cwd, effort) {
476 Ok(parsed) => {
477 if attempt > 1 {
478 logdim!("{} answered on the retry", self.spec.name);
479 }
480 return Ok(parsed);
481 }
482 Err(e) if !self.worth_asking_again(&e) => return Err(e),
486 Err(e) => {
487 if attempt < ATTEMPTS {
488 logwarn!("{} failed, asking again.\n{e}", self.spec.name);
493 }
494 last = Some(e);
495 }
496 }
497 }
498 Err(spar_err!(
499 "agent '{}' returned an unusable answer twice: {}",
500 self.spec.name,
501 last.expect("at least one attempt").message()
502 ))
503 }
504
505 fn ask_json_once<T: serde::de::DeserializeOwned>(
506 &self,
507 prompt: &str,
508 schema: &Value,
509 cwd: &Path,
510 effort: Option<&str>,
511 ) -> Result<T> {
512 let text = if self.supports_schema() {
513 let inline = serde_json::to_string(schema).unwrap_or_default();
514 let file = TempJson::write(schema)?;
515 self.ask_inner(prompt, cwd, effort, Some(file.path()), Some(&inline))?
516 } else {
517 let full = format!(
518 "{prompt}\n\n{JSON_INSTRUCTION}\n{}",
519 serde_json::to_string_pretty(schema).unwrap_or_default()
520 );
521 self.ask_inner(&full, cwd, effort, None, None)?
522 };
523 jsonx::extract_into(&text)
524 }
525
526 pub fn review<T: serde::de::DeserializeOwned>(
534 &self,
535 base: &str,
536 prompt: &str,
537 schema: &Value,
538 cwd: &Path,
539 effort: Option<&str>,
540 ) -> Result<T> {
541 let scoped = format!(
542 "{prompt}\n\nThe changes under review are the diff between `{base}` and HEAD in your \
543 working directory. Inspect them with git, then read the surrounding code before \
544 judging. Do not review only the diff."
545 );
546 self.ask_json(&scoped, schema, cwd, effort)
547 }
548}
549
550#[derive(Debug, Default, Clone)]
555pub struct Placeholders {
556 pub prompt: Option<String>,
557 pub system: Option<String>,
558 pub model: Option<String>,
559 pub effort: Option<String>,
560 pub cwd: Option<String>,
561 pub schema_file: Option<String>,
563 pub schema: Option<String>,
565}
566
567impl Placeholders {
568 fn get(&self, key: &str) -> Option<&str> {
569 let value = match key {
570 "prompt" => self.prompt.as_deref(),
571 "system" => self.system.as_deref(),
572 "model" => self.model.as_deref(),
573 "effort" => self.effort.as_deref(),
574 "cwd" => self.cwd.as_deref(),
575 "schema_file" => self.schema_file.as_deref(),
576 "schema" => self.schema.as_deref(),
577 _ => None,
578 };
579 value.filter(|v| !v.is_empty())
580 }
581
582 fn substitute(&self, arg: &str) -> Option<String> {
585 const KEYS: [&str; 7] = [
586 "prompt",
587 "system",
588 "model",
589 "effort",
590 "cwd",
591 "schema_file",
592 "schema",
593 ];
594 let mut out = arg.to_string();
595 for key in KEYS {
596 let token = format!("{{{key}}}");
597 if out.contains(&token) {
598 let value = self.get(key)?;
599 out = out.replace(&token, value);
600 }
601 }
602 Some(out)
603 }
604}
605
606fn dig<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
612 if path.is_empty() {
613 return None;
614 }
615 let mut node = value;
616 for part in path.split('.') {
617 node = node.as_object()?.get(part)?;
618 }
619 Some(node)
620}
621
622fn matches(event: &Value, wanted: &BTreeMap<String, String>) -> bool {
623 if wanted.is_empty() {
624 return false;
625 }
626 wanted
627 .iter()
628 .all(|(path, expected)| dig(event, path).and_then(Value::as_str) == Some(expected.as_str()))
629}
630
631fn as_text(value: &Value) -> Option<String> {
632 match value {
633 Value::String(s) => Some(s.clone()),
634 Value::Null => None,
635 other => Some(other.to_string()),
636 }
637}
638
639fn truncate(text: &str, max: usize) -> String {
640 text.chars().take(max).collect()
641}
642
643struct TempJson {
650 path: PathBuf,
651}
652
653impl TempJson {
654 fn write(value: &Value) -> Result<Self> {
655 use std::sync::atomic::{AtomicU64, Ordering};
656 static COUNTER: AtomicU64 = AtomicU64::new(0);
657
658 let nanos = std::time::SystemTime::now()
659 .duration_since(std::time::UNIX_EPOCH)
660 .map(|d| d.as_nanos())
661 .unwrap_or(0);
662 let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
663 let path = std::env::temp_dir().join(format!(
664 "spar-schema-{}-{nanos}-{unique}.json",
665 std::process::id()
666 ));
667 std::fs::write(&path, serde_json::to_vec_pretty(value)?)
668 .map_err(|e| spar_err!("could not write a schema file to {}: {e}", path.display()))?;
669 Ok(Self { path })
670 }
671
672 fn path(&self) -> &Path {
673 &self.path
674 }
675}
676
677impl Drop for TempJson {
678 fn drop(&mut self) {
679 let _ = std::fs::remove_file(&self.path);
680 }
681}
682
683fn same_executable(a: &Path, b: &Path) -> bool {
694 #[cfg(unix)]
695 {
696 use std::os::unix::fs::MetadataExt;
697 if let (Ok(x), Ok(y)) = (std::fs::metadata(a), std::fs::metadata(b)) {
698 return x.dev() == y.dev() && x.ino() == y.ino();
699 }
700 }
701 match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
702 (Ok(x), Ok(y)) => x == y,
703 _ => a == b,
704 }
705}
706
707pub fn correlation_warning(agents: &[Agent]) -> Option<String> {
713 for i in 0..agents.len() {
714 for j in (i + 1)..agents.len() {
715 let (a, b) = (&agents[i], &agents[j]);
716 let (Ok(pa), Ok(pb)) = (a.resolve_bin(), b.resolve_bin()) else {
717 continue;
718 };
719 if !same_executable(pa, pb) || a.spec.model_key() != b.spec.model_key() {
720 continue;
721 }
722 let model = if a.spec.model_key().is_empty() {
723 "the CLI's default".to_string()
724 } else {
725 a.spec.model_key()
726 };
727 let where_at = if pa == pb {
728 pa.display().to_string()
729 } else {
730 format!(
731 "the same executable ({} and {} are the same file)",
732 pa.display(),
733 pb.display()
734 )
735 };
736 return Some(format!(
737 "agents '{}' and '{}' both resolve to {where_at} at model {model}. Review \
738 findings will be correlated: the same model reviewing itself shares the blind \
739 spots of the model that wrote the code, so it is far less likely to catch what \
740 the implementer missed. That produces an approval indistinguishable from a real \
741 review, which is worse than no review at all. Give the two agents different \
742 CLIs or different models.",
743 a.name(),
744 b.name()
745 ));
746 }
747 }
748 None
749}
750
751pub fn build(cfg: &crate::config::Config) -> Result<Vec<Agent>> {
754 let agents: Vec<Agent> = cfg.agents.iter().cloned().map(Agent::new).collect();
755 for agent in &agents {
756 agent.resolve_bin()?;
757 if let Some(backup) = agent.fallback() {
761 if backup.resolve_bin().is_err() {
762 logwarn!(
763 "{} has a fallback ({}) that is not installed, so it will not stand in",
764 agent.name(),
765 backup.program()
766 );
767 }
768 }
769 }
770 Ok(agents)
771}
772
773pub fn find<'a>(agents: &'a [Agent], name: &str) -> Result<&'a Agent> {
775 agents.iter().find(|a| a.name() == name).ok_or_else(|| {
776 SparError::new(format!(
777 "no agent named '{name}' ({})",
778 agents
779 .iter()
780 .map(Agent::name)
781 .collect::<Vec<_>>()
782 .join(", ")
783 ))
784 })
785}
786
787#[cfg(test)]
788mod tests {
789 use super::*;
790 use crate::config::{OutputMode, SystemVia};
791
792 fn spec(command: Vec<CommandPart>) -> AgentSpec {
793 AgentSpec {
794 name: "test".into(),
795 command,
796 model: None,
797 effort: None,
798 output: OutputMode::Text,
799 message_match: BTreeMap::new(),
800 message_path: None,
801 search_paths: vec![],
802 system_via: SystemVia::Prompt,
803 timeout: 60,
804 fallback: None,
805 models: vec![],
806 efforts: vec![],
807 options_note: None,
808 }
809 }
810
811 fn one(s: &str) -> CommandPart {
812 CommandPart::One(s.into())
813 }
814
815 fn group(parts: &[&str]) -> CommandPart {
816 CommandPart::Group(parts.iter().map(|s| s.to_string()).collect())
817 }
818
819 fn agent(command: Vec<CommandPart>) -> Agent {
820 Agent::with_bin(spec(command), "/fake/bin")
821 }
822
823 fn values() -> Placeholders {
824 Placeholders {
825 prompt: Some("hi".into()),
826 ..Default::default()
827 }
828 }
829
830 #[test]
833 fn placeholders_are_substituted() {
834 let a = agent(vec![one("x"), group(&["-m", "{model}"]), one("{prompt}")]);
835 let v = Placeholders {
836 model: Some("m1".into()),
837 ..values()
838 };
839 assert_eq!(vec!["/fake/bin", "-m", "m1", "hi"], a.render(&v).unwrap());
840 }
841
842 #[test]
843 fn an_unset_placeholder_drops_the_whole_group() {
844 let a = agent(vec![one("x"), group(&["-m", "{model}"]), one("{prompt}")]);
845 assert_eq!(vec!["/fake/bin", "hi"], a.render(&values()).unwrap());
846 }
847
848 #[test]
849 fn an_empty_string_drops_the_group_too() {
850 let a = agent(vec![one("x"), group(&["-e", "{effort}"]), one("{prompt}")]);
851 let v = Placeholders {
852 effort: Some(String::new()),
853 ..values()
854 };
855 assert_eq!(vec!["/fake/bin", "hi"], a.render(&v).unwrap());
856 }
857
858 #[test]
859 fn a_bare_arg_with_an_unset_placeholder_drops() {
860 let a = agent(vec![one("x"), one("{model}"), one("{prompt}")]);
861 assert_eq!(vec!["/fake/bin", "hi"], a.render(&values()).unwrap());
862 }
863
864 #[test]
865 fn literal_args_survive() {
866 let a = agent(vec![
867 one("x"),
868 one("exec"),
869 one("--json"),
870 one("--"),
871 one("{prompt}"),
872 ]);
873 assert_eq!(
874 vec!["/fake/bin", "exec", "--json", "--", "hi"],
875 a.render(&values()).unwrap()
876 );
877 }
878
879 #[test]
880 fn an_embedded_placeholder_substitutes_in_place() {
881 let a = agent(vec![
882 one("x"),
883 group(&["-c", "model_reasoning_effort={effort}"]),
884 ]);
885 let v = Placeholders {
886 effort: Some("ultra".into()),
887 ..Default::default()
888 };
889 assert_eq!(
890 vec!["/fake/bin", "-c", "model_reasoning_effort=ultra"],
891 a.render(&v).unwrap()
892 );
893 }
894
895 #[test]
896 fn a_group_with_two_placeholders_needs_both() {
897 let a = agent(vec![
898 one("x"),
899 group(&["--a", "{model}", "--b", "{effort}"]),
900 ]);
901 let v = Placeholders {
902 model: Some("m".into()),
903 ..Default::default()
904 };
905 assert_eq!(vec!["/fake/bin"], a.render(&v).unwrap());
906 }
907
908 #[test]
909 fn supports_schema_detects_the_placeholder() {
910 assert!(agent(vec![one("x"), group(&["--schema", "{schema_file}"])]).supports_schema());
911 assert!(!agent(vec![one("x"), one("{prompt}")]).supports_schema());
912 }
913
914 #[test]
917 fn text_passes_through_trimmed() {
918 assert_eq!("hello", agent(vec![one("x")]).extract(" hello\n").unwrap());
919 }
920
921 #[test]
922 fn jsonl_picks_the_matching_event() {
923 let mut spec = spec(vec![one("x")]);
924 spec.output = OutputMode::Jsonl;
925 spec.message_path = Some("item.text".into());
926 spec.message_match = BTreeMap::from([
927 ("type".to_string(), "item.completed".to_string()),
928 ("item.type".to_string(), "agent_message".to_string()),
929 ]);
930 let a = Agent::with_bin(spec, "/fake/bin");
931 let stream = [
932 r#"{"type":"thread.started","thread_id":"t1"}"#,
933 r#"{"type":"item.completed","item":{"type":"command_execution","text":"ls"}}"#,
934 r#"{"type":"item.completed","item":{"type":"agent_message","text":"the answer"}}"#,
935 "not json at all",
936 ]
937 .join("\n");
938 assert_eq!("the answer", a.extract(&stream).unwrap());
939 }
940
941 #[test]
942 fn jsonl_raises_on_an_error_with_no_message() {
943 let mut spec = spec(vec![one("x")]);
944 spec.output = OutputMode::Jsonl;
945 spec.message_path = Some("item.text".into());
946 spec.message_match = BTreeMap::from([("type".into(), "item.completed".into())]);
947 let a = Agent::with_bin(spec, "/fake/bin");
948 assert!(a
949 .extract(r#"{"type":"turn.failed","error":"boom"}"#)
950 .is_err());
951 }
952
953 #[test]
954 fn jsonl_joins_several_agent_messages() {
955 let mut spec = spec(vec![one("x")]);
956 spec.output = OutputMode::Jsonl;
957 spec.message_path = Some("text".into());
958 spec.message_match = BTreeMap::from([("type".into(), "msg".into())]);
959 let a = Agent::with_bin(spec, "/fake/bin");
960 let stream = "{\"type\":\"msg\",\"text\":\"one\"}\n{\"type\":\"msg\",\"text\":\"two\"}";
961 assert_eq!("one\ntwo", a.extract(stream).unwrap());
962 }
963
964 #[test]
965 fn dig_walks_a_dotted_path() {
966 let v: Value = serde_json::from_str(r#"{"a":{"b":{"c":1}}}"#).unwrap();
967 assert_eq!(Some(&Value::from(1)), dig(&v, "a.b.c"));
968 assert_eq!(None, dig(&v, "a.b.missing"));
969 assert_eq!(None, dig(&v, ""));
970 }
971
972 #[test]
975 fn a_missing_binary_lists_everywhere_it_looked() {
976 let mut s = spec(vec![one("definitely-not-installed-xyz")]);
977 s.search_paths = vec!["/nowhere/at/all".into()];
978 s.name = "codex".into();
979 let err = Agent::new(s).resolve_bin().unwrap_err().to_string();
980 assert!(err.contains("definitely-not-installed-xyz (PATH)"), "{err}");
981 assert!(
982 err.contains("/nowhere/at/all/definitely-not-installed-xyz"),
983 "{err}"
984 );
985 assert!(err.contains("SPAR_CODEX_BIN"), "{err}");
986 }
987
988 #[test]
989 fn a_search_path_that_already_names_the_binary_is_used_as_is() {
990 let dir = std::env::temp_dir().join(format!("spar-test-{}", std::process::id()));
991 std::fs::create_dir_all(&dir).unwrap();
992 let bin = dir.join("mytool");
993 std::fs::write(&bin, "#!/bin/sh\n").unwrap();
994 #[cfg(unix)]
995 {
996 use std::os::unix::fs::PermissionsExt;
997 std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
998 }
999 let mut s = spec(vec![one("mytool")]);
1000 s.search_paths = vec![bin.display().to_string()];
1001 assert_eq!(bin, Agent::new(s).resolve_bin().unwrap());
1002 let _ = std::fs::remove_dir_all(&dir);
1003 }
1004
1005 fn named(name: &str, bin: &str, model: Option<&str>) -> Agent {
1008 let mut s = spec(vec![one("prog")]);
1009 s.name = name.into();
1010 s.model = model.map(str::to_string);
1011 Agent::with_bin(s, bin)
1012 }
1013
1014 #[test]
1015 fn same_bin_same_model_warns() {
1016 let agents = vec![
1017 named("alpha", "/usr/local/bin/claude", Some("fable")),
1018 named("beta", "/usr/local/bin/claude", Some("fable")),
1019 ];
1020 let msg = correlation_warning(&agents).expect("should warn");
1021 assert!(msg.contains("alpha") && msg.contains("beta"), "{msg}");
1022 }
1023
1024 #[test]
1025 fn different_model_does_not_warn() {
1026 let agents = vec![
1027 named("a", "/usr/local/bin/claude", Some("fable")),
1028 named("b", "/usr/local/bin/claude", Some("opus")),
1029 ];
1030 assert!(correlation_warning(&agents).is_none());
1031 }
1032
1033 #[test]
1034 fn different_bin_does_not_warn() {
1035 let agents = vec![
1036 named("a", "/usr/local/bin/claude", Some("fable")),
1037 named("b", "/usr/local/bin/codex", Some("fable")),
1038 ];
1039 assert!(correlation_warning(&agents).is_none());
1040 }
1041
1042 #[test]
1043 fn unset_and_empty_model_both_mean_the_default_and_warn() {
1044 let agents = vec![
1045 named("a", "/usr/local/bin/claude", None),
1046 named("b", "/usr/local/bin/claude", Some("")),
1047 ];
1048 let msg = correlation_warning(&agents).expect("should warn");
1049 assert!(msg.contains("the CLI's default"), "{msg}");
1050 }
1051
1052 #[test]
1053 fn a_padded_model_still_warns() {
1054 let agents = vec![
1055 named("a", "/usr/local/bin/claude", Some("fable")),
1056 named("b", "/usr/local/bin/claude", Some(" fable ")),
1057 ];
1058 assert!(correlation_warning(&agents).is_some());
1059 }
1060
1061 #[test]
1062 fn an_empty_model_against_a_named_one_does_not_warn() {
1063 let agents = vec![
1064 named("a", "/usr/local/bin/claude", Some("")),
1065 named("b", "/usr/local/bin/claude", Some("fable")),
1066 ];
1067 assert!(correlation_warning(&agents).is_none());
1068 }
1069
1070 #[cfg(unix)]
1071 #[test]
1072 fn a_symlinked_binary_warns_and_names_both_paths() {
1073 use std::os::unix::fs::PermissionsExt;
1074 let dir = std::env::temp_dir().join(format!("spar-link-{}", std::process::id()));
1075 let _ = std::fs::remove_dir_all(&dir);
1076 std::fs::create_dir_all(&dir).unwrap();
1077 let real = dir.join("claude");
1078 let link = dir.join("claude-alias");
1079 std::fs::write(&real, "#!/bin/sh\n").unwrap();
1080 std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o755)).unwrap();
1081 std::os::unix::fs::symlink(&real, &link).unwrap();
1082
1083 let agents = vec![
1084 named("alpha", real.to_str().unwrap(), Some("fable")),
1085 named("beta", link.to_str().unwrap(), Some("fable")),
1086 ];
1087 let msg = correlation_warning(&agents).expect("should warn");
1088 assert!(msg.contains(real.to_str().unwrap()), "{msg}");
1089 assert!(msg.contains(link.to_str().unwrap()), "{msg}");
1090 let _ = std::fs::remove_dir_all(&dir);
1091 }
1092
1093 #[cfg(unix)]
1094 #[test]
1095 fn two_distinct_real_binaries_stay_quiet() {
1096 use std::os::unix::fs::PermissionsExt;
1097 let dir = std::env::temp_dir().join(format!("spar-distinct-{}", std::process::id()));
1098 let _ = std::fs::remove_dir_all(&dir);
1099 std::fs::create_dir_all(&dir).unwrap();
1100 let mut paths = Vec::new();
1101 for name in ["claude", "codex"] {
1102 let path = dir.join(name);
1103 std::fs::write(&path, "#!/bin/sh\n").unwrap();
1104 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
1105 paths.push(path);
1106 }
1107 let agents = vec![
1108 named("a", paths[0].to_str().unwrap(), Some("fable")),
1109 named("b", paths[1].to_str().unwrap(), Some("fable")),
1110 ];
1111 assert!(correlation_warning(&agents).is_none());
1112 let _ = std::fs::remove_dir_all(&dir);
1113 }
1114
1115 #[test]
1116 fn the_style_rules_ask_for_brevity_and_no_attribution() {
1117 let lower = STYLE_RULES.to_lowercase();
1118 assert!(lower.contains("brief"));
1119 assert!(lower.contains("co-authored-by"));
1120 assert!(lower.contains("em-dash"));
1121 }
1122
1123 fn refusal_stream() -> String {
1128 let noise = "{\"type\":\"item.completed\",\"item\":{\"id\":\"i\",\"type\":\"command_execution\",\"output\":\"".to_string()
1129 + &"const x = 1;\\n".repeat(200)
1130 + "\"}}";
1131 [
1132 noise.as_str(),
1133 r#"{"type":"error","message":"This content was flagged for possible cybersecurity risk."}"#,
1134 r#"{"type":"error","message":"This content was flagged for possible cybersecurity risk."}"#,
1135 r#"{"type":"turn.failed","error":{"message":"This content was flagged for possible cybersecurity risk."}}"#,
1136 ]
1137 .join("\n")
1138 }
1139
1140 fn jsonl_agent(name: &str) -> Agent {
1141 let mut spec = spec(vec![one("codex")]);
1142 spec.name = name.into();
1143 spec.output = OutputMode::Jsonl;
1144 spec.message_path = Some("item.text".into());
1145 Agent::with_bin(spec, "/fake/codex")
1146 }
1147
1148 fn failed(stdout: &str, stderr: &str) -> proc::Output {
1149 proc::Output {
1150 stdout: stdout.to_string(),
1151 stderr: stderr.to_string(),
1152 code: 1,
1153 }
1154 }
1155
1156 #[test]
1159 fn a_jsonl_failure_reports_the_reason_and_not_the_stream() {
1160 let agent = jsonl_agent("codex");
1161 let err = agent.call_failure(&["codex".to_string()], &failed(&refusal_stream(), ""));
1162 let text = err.message();
1163 assert!(
1164 text.contains("flagged for possible cybersecurity risk"),
1165 "{text}"
1166 );
1167 assert!(
1168 !text.contains("const x = 1;"),
1169 "the stream leaked in:\n{text}"
1170 );
1171 assert!(text.len() < 400, "still {} characters:\n{text}", text.len());
1172 }
1173
1174 #[test]
1176 fn the_same_reason_reported_three_times_is_said_once() {
1177 let agent = jsonl_agent("codex");
1178 let err = agent.call_failure(&["codex".to_string()], &failed(&refusal_stream(), ""));
1179 assert_eq!(
1180 1,
1181 err.message().matches("flagged for possible").count(),
1182 "{}",
1183 err.message()
1184 );
1185 }
1186
1187 #[test]
1190 fn stderr_is_kept_because_it_is_where_the_other_half_arrives() {
1191 let agent = jsonl_agent("codex");
1192 let err = agent.call_failure(
1193 &["codex".to_string()],
1194 &failed(
1195 &refusal_stream(),
1196 "ERROR router: agent thread limit reached",
1197 ),
1198 );
1199 assert!(
1200 err.message().contains("agent thread limit reached"),
1201 "{}",
1202 err.message()
1203 );
1204 }
1205
1206 #[test]
1209 fn a_stream_with_no_error_event_falls_back_to_the_raw_output() {
1210 let agent = jsonl_agent("codex");
1211 let err = agent.call_failure(
1212 &["codex".to_string()],
1213 &failed("{\"type\":\"system\"}", "segmentation fault"),
1214 );
1215 assert!(
1216 err.message().contains("segmentation fault"),
1217 "{}",
1218 err.message()
1219 );
1220 assert!(
1221 err.message().starts_with("command failed"),
1222 "{}",
1223 err.message()
1224 );
1225 }
1226
1227 #[test]
1229 fn a_text_agent_is_reported_exactly_as_before() {
1230 let agent = agent(vec![one("mytool")]);
1231 let err = agent.call_failure(&["mytool".to_string()], &failed("some prose", "boom"));
1232 assert!(
1233 err.message().starts_with("command failed"),
1234 "{}",
1235 err.message()
1236 );
1237 assert!(err.message().contains("some prose"), "{}", err.message());
1238 }
1239
1240 #[test]
1243 fn a_reworded_failure_is_still_a_failed_call() {
1244 let agent = jsonl_agent("codex");
1245 let err = agent.call_failure(&["codex".to_string()], &failed(&refusal_stream(), ""));
1246 assert_eq!(ErrorKind::CallFailed, err.kind());
1247 }
1248
1249 fn shell(name: &str, line: &str) -> AgentSpec {
1254 let mut spec = spec(vec![one("sh"), one("-c"), one(line)]);
1255 spec.name = name.into();
1256 spec
1257 }
1258
1259 fn with_fallback(mut primary: AgentSpec, backup: AgentSpec) -> Agent {
1260 primary.fallback = Some(Box::new(backup));
1261 Agent::with_bin(primary, "/bin/sh")
1262 }
1263
1264 #[test]
1265 fn a_failed_call_is_answered_by_the_fallback() {
1266 let agent = with_fallback(
1267 shell("primary", "echo refused >&2; exit 1"),
1268 shell("backup", "echo stood in"),
1269 );
1270 let answer = agent.ask("hi", Path::new("."), None).expect("fallback");
1271 assert_eq!("stood in", answer);
1272 }
1273
1274 #[test]
1275 fn without_a_fallback_the_original_error_is_what_surfaces() {
1276 let agent = Agent::with_bin(shell("primary", "echo refused >&2; exit 1"), "/bin/sh");
1277 let err = agent
1278 .ask("hi", Path::new("."), None)
1279 .expect_err("no backup");
1280 assert!(err.message().contains("refused"), "{err}");
1281 }
1282
1283 #[test]
1286 fn both_failing_reports_the_primary_first() {
1287 let agent = with_fallback(
1288 shell("primary", "echo policy refusal >&2; exit 1"),
1289 shell("backup", "echo out of quota >&2; exit 1"),
1290 );
1291 let err = agent
1292 .ask("hi", Path::new("."), None)
1293 .expect_err("both fail");
1294 let text = err.message();
1295 let primary_at = text.find("policy refusal").expect("primary reason");
1296 let backup_at = text.find("out of quota").expect("backup reason");
1297 assert!(primary_at < backup_at, "{text}");
1298 assert!(
1299 text.contains("primary") && text.contains("backup"),
1300 "{text}"
1301 );
1302 }
1303
1304 fn attempts(name: &str) -> (PathBuf, String) {
1307 let path = std::env::temp_dir().join(format!("spar-attempts-{name}"));
1308 let _ = std::fs::remove_file(&path);
1309 let line = format!("echo x >> {}", path.display());
1310 (path, line)
1311 }
1312
1313 fn counted(path: &Path) -> usize {
1314 std::fs::read_to_string(path)
1315 .map(|t| t.lines().count())
1316 .unwrap_or(0)
1317 }
1318
1319 #[test]
1324 fn a_cli_that_could_not_answer_is_not_asked_twice_when_there_is_a_stand_in() {
1325 let (path, count) = attempts("refused-with-standin");
1326 let agent = with_fallback(
1327 shell("primary", &format!("{count}; echo refused >&2; exit 1")),
1328 shell("backup", "echo '{}'"),
1329 );
1330 let answer: Value = agent
1331 .ask_json(
1332 "q",
1333 &serde_json::json!({"type": "object"}),
1334 Path::new("."),
1335 None,
1336 )
1337 .expect("the stand in answers");
1338 assert!(answer.is_object());
1339 assert_eq!(1, counted(&path), "the primary was asked more than once");
1340 }
1341
1342 #[test]
1345 fn with_no_stand_in_a_failed_call_is_still_retried() {
1346 let (path, count) = attempts("refused-alone");
1347 let agent = Agent::with_bin(
1348 shell("solo", &format!("{count}; echo refused >&2; exit 1")),
1349 "/bin/sh",
1350 );
1351 let err = agent
1352 .ask_json::<Value>(
1353 "q",
1354 &serde_json::json!({"type": "object"}),
1355 Path::new("."),
1356 None,
1357 )
1358 .expect_err("nothing answers");
1359 assert!(err.message().contains("twice"), "{err}");
1360 assert_eq!(2, counted(&path));
1361 }
1362
1363 #[test]
1367 fn an_unusable_answer_is_still_worth_asking_again() {
1368 let (path, count) = attempts("unparsable");
1369 let agent = with_fallback(
1370 shell("primary", &format!("{count}; echo not json at all")),
1371 shell("backup", "echo '{}'"),
1372 );
1373 let answer: Value = agent
1374 .ask_json(
1375 "q",
1376 &serde_json::json!({"type": "object"}),
1377 Path::new("."),
1378 None,
1379 )
1380 .expect("the stand in answers in the end");
1381 assert!(answer.is_object());
1382 assert_eq!(2, counted(&path), "a shape error is worth one more ask");
1383 }
1384
1385 #[test]
1389 fn a_timeout_still_reaches_the_fallback() {
1390 let mut primary = shell("primary", "sleep 30");
1391 primary.timeout = 1;
1392 let agent = with_fallback(primary, shell("backup", "echo stood in"));
1393 assert_eq!(
1394 "stood in",
1395 agent.ask("hi", Path::new("."), None).expect("fallback")
1396 );
1397 }
1398
1399 #[test]
1402 fn the_fallback_is_built_alongside_the_agent() {
1403 let mut primary = shell("primary", "true");
1404 primary.fallback = Some(Box::new(shell("backup", "true")));
1405 let agent = Agent::new(primary);
1406 assert_eq!(Some("backup"), agent.fallback().map(Agent::name));
1407 assert!(Agent::new(shell("solo", "true")).fallback().is_none());
1408 }
1409}
1410
1411#[cfg(test)]
1412mod schema_placeholder_tests {
1413 use super::*;
1414 use crate::config::{OutputMode, SystemVia};
1415
1416 fn spec_with(command: Vec<CommandPart>) -> AgentSpec {
1417 AgentSpec {
1418 name: "claude".into(),
1419 command,
1420 model: None,
1421 effort: None,
1422 output: OutputMode::Text,
1423 message_match: BTreeMap::new(),
1424 message_path: None,
1425 search_paths: vec![],
1426 system_via: SystemVia::Prompt,
1427 timeout: 60,
1428 fallback: None,
1429 models: vec![],
1430 efforts: vec![],
1431 options_note: None,
1432 }
1433 }
1434
1435 fn one(s: &str) -> CommandPart {
1436 CommandPart::One(s.into())
1437 }
1438 fn group(parts: &[&str]) -> CommandPart {
1439 CommandPart::Group(parts.iter().map(|s| s.to_string()).collect())
1440 }
1441
1442 #[test]
1445 fn either_schema_form_counts_as_native_support() {
1446 let inline = Agent::with_bin(
1447 spec_with(vec![one("x"), group(&["--json-schema", "{schema}"])]),
1448 "/b",
1449 );
1450 let byfile = Agent::with_bin(
1451 spec_with(vec![one("x"), group(&["--output-schema", "{schema_file}"])]),
1452 "/b",
1453 );
1454 let neither = Agent::with_bin(spec_with(vec![one("x"), one("{prompt}")]), "/b");
1455 assert!(inline.supports_schema());
1456 assert!(byfile.supports_schema());
1457 assert!(!neither.supports_schema());
1458 }
1459
1460 #[test]
1461 fn the_inline_schema_is_substituted_whole() {
1462 let agent = Agent::with_bin(
1463 spec_with(vec![
1464 one("x"),
1465 group(&["--json-schema", "{schema}"]),
1466 one("{prompt}"),
1467 ]),
1468 "/b",
1469 );
1470 let values = Placeholders {
1471 prompt: Some("review it".into()),
1472 schema: Some(r#"{"type":"object"}"#.into()),
1473 ..Default::default()
1474 };
1475 assert_eq!(
1476 vec!["/b", "--json-schema", r#"{"type":"object"}"#, "review it"],
1477 agent.render(&values).unwrap()
1478 );
1479 }
1480
1481 #[test]
1484 fn the_schema_flag_drops_when_no_schema_is_wanted() {
1485 let agent = Agent::with_bin(
1486 spec_with(vec![
1487 one("x"),
1488 group(&["--json-schema", "{schema}"]),
1489 one("{prompt}"),
1490 ]),
1491 "/b",
1492 );
1493 let values = Placeholders {
1494 prompt: Some("implement it".into()),
1495 ..Default::default()
1496 };
1497 assert_eq!(vec!["/b", "implement it"], agent.render(&values).unwrap());
1498 }
1499
1500 #[test]
1502 fn the_two_schema_placeholders_do_not_collide() {
1503 let agent = Agent::with_bin(
1504 spec_with(vec![one("x"), group(&["--output-schema", "{schema_file}"])]),
1505 "/b",
1506 );
1507 let values = Placeholders {
1508 schema: Some("INLINE".into()),
1509 schema_file: Some("/tmp/s.json".into()),
1510 ..Default::default()
1511 };
1512 assert_eq!(
1513 vec!["/b", "--output-schema", "/tmp/s.json"],
1514 agent.render(&values).unwrap()
1515 );
1516 }
1517
1518 #[test]
1520 fn the_shipped_claude_preset_now_has_native_structured_output() {
1521 let raw = crate::config::load_preset("claude").unwrap();
1522 let table = raw.as_table().cloned().unwrap();
1523 let mut spec: AgentSpec = toml::Value::Table(table).try_into().unwrap();
1524 spec.name = "claude".into();
1525 assert!(
1526 Agent::with_bin(spec, "/b").supports_schema(),
1527 "without this a long review is parsed out of prose and truncates"
1528 );
1529 }
1530}