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 = "\
33Style rules for every artifact you produce (commits, PR titles, PR bodies, issue
34titles, issue bodies, review comments, and the comments in code you write):
35- Never use em-dashes or en-dashes. Use commas, colons, or parentheses.
36- Never mention Claude, Codex, OpenAI, ChatGPT, Anthropic, AI, or any tooling
37 used to produce the work.
38- Never add a Co-Authored-By trailer or a \"Generated with\" footer to commits.
39- Be brief. A human engineer with other work has to read this. Lead with the
40 point, cut the preamble, stop when you are done. Do not restate the task, do
41 not announce what you are about to do, do not summarise what the diff already
42 shows. One sentence beats one paragraph.
43- No headings, bullet lists, or bold text in anything only a few sentences long.
44- Comment code for the reason, not the change. A comment earns its length from
45 what the code cannot say for itself: a constraint that is not local, an
46 alternative that was tried and does not work, a surprise the next reader would
47 otherwise trip on. Write the reason that holds now, not the investigation that
48 found it. A paragraph above a three line change is almost always the debugging
49 story, and the reader wants the conclusion of it.
50Write as a human engineer would, because the reader neither knows nor cares what
51produced the work.";
52
53const JSON_INSTRUCTION: &str = "Respond with ONLY a JSON object matching this \
54schema. No prose, no markdown fences, no commentary before or after:";
55
56pub struct Agent {
57 pub spec: AgentSpec,
58 fallback: Option<Box<Agent>>,
62 resolved: OnceLock<PathBuf>,
63}
64
65impl std::fmt::Debug for Agent {
66 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67 write!(f, "<{} {}>", self.spec.name, self.spec.describe())
68 }
69}
70
71impl Agent {
72 pub fn new(spec: AgentSpec) -> Self {
73 let fallback = spec
74 .fallback
75 .clone()
76 .map(|backup| Box::new(Agent::new(*backup)));
77 Self {
78 spec,
79 fallback,
80 resolved: OnceLock::new(),
81 }
82 }
83
84 pub fn name(&self) -> &str {
85 &self.spec.name
86 }
87
88 pub fn fallback(&self) -> Option<&Agent> {
90 self.fallback.as_deref()
91 }
92
93 pub fn program(&self) -> &str {
96 match self.spec.command.first() {
97 Some(CommandPart::One(program)) => program,
98 _ => self.name(),
99 }
100 }
101
102 pub fn env_key(&self) -> String {
104 format!(
105 "SPAR_{}_BIN",
106 self.spec.name.to_uppercase().replace('-', "_")
107 )
108 }
109
110 #[doc(hidden)]
113 pub fn with_bin(spec: AgentSpec, bin: impl Into<PathBuf>) -> Self {
114 let agent = Self::new(spec);
115 let _ = agent.resolved.set(bin.into());
116 agent
117 }
118
119 pub fn resolve_bin(&self) -> Result<&Path> {
126 if let Some(found) = self.resolved.get() {
127 return Ok(found.as_path());
128 }
129 let found = self.locate()?;
130 let _ = self.resolved.set(found);
131 Ok(self.resolved.get().expect("just set").as_path())
132 }
133
134 fn locate(&self) -> Result<PathBuf> {
135 let wanted = match self.spec.command.first() {
136 Some(CommandPart::One(program)) => program.clone(),
137 _ => bail!("agent '{}' has no command configured", self.spec.name),
138 };
139
140 let env_key = self.env_key();
141 let env_override = std::env::var(&env_key)
142 .ok()
143 .filter(|v| !v.trim().is_empty());
144
145 let mut tried: Vec<String> = Vec::new();
146
147 for candidate in env_override
148 .iter()
149 .map(String::as_str)
150 .chain([wanted.as_str()])
151 {
152 let path = Path::new(candidate);
153 if path.is_absolute() || candidate.contains(std::path::MAIN_SEPARATOR) {
154 let expanded = proc::expand_tilde(candidate);
155 tried.push(expanded.display().to_string());
156 if proc::is_executable(&expanded) {
157 return Ok(expanded);
158 }
159 } else {
160 tried.push(format!("{candidate} (PATH)"));
161 if let Some(found) = proc::which(candidate) {
162 return Ok(found);
163 }
164 }
165 }
166
167 for base in &self.spec.search_paths {
168 let base = proc::expand_tilde(base);
169 let candidate = if base.file_name().and_then(|n| n.to_str()) == Some(wanted.as_str()) {
170 base
171 } else {
172 base.join(&wanted)
173 };
174 tried.push(candidate.display().to_string());
175 if proc::is_executable(&candidate) {
176 return Ok(candidate);
177 }
178 }
179
180 Err(spar_err!(
181 "could not find the binary for agent '{}'. Tried:\n {}\nSet agents.{}.command[0] to \
182 an absolute path, or {}=/path/to/binary.",
183 self.spec.name,
184 tried.join("\n "),
185 self.spec.name,
186 env_key
187 ))
188 }
189
190 pub fn render(&self, values: &Placeholders) -> Result<Vec<String>> {
196 let mut out = vec![self.resolve_bin()?.display().to_string()];
197 for part in self.spec.command.iter().skip(1) {
198 let mut rendered = Vec::new();
199 let mut skip = false;
200 for arg in part.args() {
201 match values.substitute(arg) {
202 Some(text) => rendered.push(text),
203 None => {
204 skip = true;
205 break;
206 }
207 }
208 }
209 if !skip {
210 out.extend(rendered);
211 }
212 }
213 Ok(out)
214 }
215
216 pub fn supports_schema(&self) -> bool {
222 self.spec
223 .command
224 .iter()
225 .flat_map(|p| p.args())
226 .any(|a| a.contains("{schema_file}") || a.contains("{schema}"))
227 }
228
229 pub fn extract(&self, stdout: &str) -> Result<String> {
232 match self.spec.output {
233 OutputMode::Text | OutputMode::Json => Ok(stdout.trim().to_string()),
234 OutputMode::Jsonl => self.extract_jsonl(stdout),
235 }
236 }
237
238 fn extract_jsonl(&self, stdout: &str) -> Result<String> {
239 let mut messages: Vec<String> = Vec::new();
240
241 for line in stdout.lines() {
242 let line = line.trim();
243 if !line.starts_with('{') {
244 continue;
245 }
246 let Ok(event) = serde_json::from_str::<Value>(line) else {
247 continue;
248 };
249 if matches(&event, &self.spec.message_match) {
250 if let Some(text) = dig(&event, self.spec.message_path.as_deref().unwrap_or("")) {
251 if let Some(text) = as_text(text) {
252 messages.push(text);
253 }
254 }
255 }
256 }
257
258 if messages.is_empty() {
259 let reasons = self.error_events(stdout);
260 if !reasons.is_empty() {
261 return Err(SparError::call_failed(format!(
264 "agent '{}' failed: {}",
265 self.spec.name,
266 reasons.join("; ")
267 )));
268 }
269 }
270 Ok(messages.join("\n").trim().to_string())
271 }
272
273 fn error_events(&self, stdout: &str) -> Vec<String> {
283 let mut reasons: Vec<String> = Vec::new();
284 for line in stdout.lines() {
285 let line = line.trim();
286 if !line.starts_with('{') {
287 continue;
288 }
289 let Ok(event) = serde_json::from_str::<Value>(line) else {
290 continue;
291 };
292 if !matches!(
293 event.get("type").and_then(Value::as_str),
294 Some("turn.failed") | Some("error")
295 ) {
296 continue;
297 }
298 let reason = dig(&event, "message")
299 .or_else(|| dig(&event, "error.message"))
300 .and_then(as_text)
301 .unwrap_or_else(|| truncate(&event.to_string(), 400));
302 if !reason.trim().is_empty() && !reasons.contains(&reason) {
303 reasons.push(reason);
304 }
305 }
306 reasons
307 }
308
309 fn call_failure(&self, argv: &[String], out: &proc::Output) -> SparError {
321 if self.spec.output != OutputMode::Jsonl {
322 return SparError::call_failed(proc::failure_message(argv, out));
323 }
324 let reasons = self.error_events(&out.stdout);
325 if reasons.is_empty() {
326 return SparError::call_failed(proc::failure_message(argv, out));
327 }
328 let mut text = format!(
329 "agent '{}' could not answer (exit {}): {}",
330 self.spec.name,
331 out.code,
332 reasons.join("; ")
333 );
334 let stderr = out.stderr.trim();
335 if !stderr.is_empty() {
336 text.push_str(&format!("\n--- stderr ---\n{stderr}"));
337 }
338 text.push_str(&format!("\n--- command ---\n{}", proc::abbreviate(argv)));
339 SparError::call_failed(text)
340 }
341
342 pub fn ask(&self, prompt: &str, cwd: &Path, effort: Option<&str>) -> Result<String> {
345 match self.ask_inner(prompt, cwd, effort, None, None) {
346 Ok(text) => Ok(text),
347 Err(e) => self.hand_over(e, |backup| backup.ask(prompt, cwd, None)),
348 }
349 }
350
351 fn hand_over<T>(&self, primary: SparError, run: impl FnOnce(&Agent) -> Result<T>) -> Result<T> {
362 let Some(backup) = self.fallback() else {
363 return Err(primary);
364 };
365 logwarn!(
366 "{} could not answer. Handing the call to {}.\n{primary}",
367 self.name(),
368 backup.name()
369 );
370 match run(backup) {
371 Ok(answer) => {
372 log!("{} answered in place of {}", backup.name(), self.name());
373 Ok(answer)
374 }
375 Err(second) => Err(spar_err!(
379 "agent '{}' failed and its fallback '{}' could not stand in.\n{}\n\n{}: {}",
380 self.name(),
381 backup.name(),
382 primary.message(),
383 backup.name(),
384 second.message()
385 )),
386 }
387 }
388
389 fn ask_inner(
390 &self,
391 prompt: &str,
392 cwd: &Path,
393 effort: Option<&str>,
394 schema_file: Option<&Path>,
395 schema: Option<&str>,
396 ) -> Result<String> {
397 let body = match self.spec.system_via {
398 SystemVia::Placeholder => prompt.to_string(),
399 SystemVia::Prompt => format!("{STYLE_RULES}\n\n{prompt}"),
400 };
401 let values = Placeholders {
402 prompt: Some(body),
403 system: Some(STYLE_RULES.to_string()),
404 model: self.spec.model.clone(),
405 effort: effort
406 .map(str::to_string)
407 .or_else(|| self.spec.effort.clone()),
408 cwd: Some(cwd.display().to_string()),
409 schema_file: schema_file.map(|p| p.display().to_string()),
410 schema: schema.map(str::to_string),
411 };
412 let argv = self.render(&values)?;
413 let opts = ExecOpts::new()
417 .cwd(cwd)
418 .timeout_secs(self.spec.timeout)
419 .check(false);
420 let out = proc::exec(&argv, &opts)?;
421 if !out.ok() {
422 return Err(self.call_failure(&argv, &out));
423 }
424 self.extract(&out.stdout)
425 }
426
427 pub fn ask_json<T: serde::de::DeserializeOwned>(
431 &self,
432 prompt: &str,
433 schema: &Value,
434 cwd: &Path,
435 effort: Option<&str>,
436 ) -> Result<T> {
437 match self.ask_json_retrying(prompt, schema, cwd, effort) {
438 Ok(parsed) => Ok(parsed),
439 Err(e) => self.hand_over(e, |backup| {
440 backup.ask_json_retrying::<T>(prompt, schema, cwd, None)
441 }),
442 }
443 }
444
445 fn worth_asking_again(&self, e: &SparError) -> bool {
458 match e.kind() {
459 ErrorKind::TimedOut => false,
460 ErrorKind::CallFailed => self.fallback().is_none(),
461 ErrorKind::Other => true,
462 }
463 }
464
465 fn ask_json_retrying<T: serde::de::DeserializeOwned>(
467 &self,
468 prompt: &str,
469 schema: &Value,
470 cwd: &Path,
471 effort: Option<&str>,
472 ) -> Result<T> {
473 const ATTEMPTS: usize = 2;
479 let mut last: Option<SparError> = None;
480
481 for attempt in 1..=ATTEMPTS {
482 let asked = match &last {
483 None => prompt.to_string(),
484 Some(e) => format!(
485 "{prompt}\n\nYour previous answer could not be used: {}\nReturn the whole \
486 object this time, exactly matching the schema, and nothing else.",
487 e.first_line()
488 ),
489 };
490 match self.ask_json_once::<T>(&asked, schema, cwd, effort) {
491 Ok(parsed) => {
492 if attempt > 1 {
493 logdim!("{} answered on the retry", self.spec.name);
494 }
495 return Ok(parsed);
496 }
497 Err(e) if !self.worth_asking_again(&e) => return Err(e),
501 Err(e) => {
502 if attempt < ATTEMPTS {
503 logwarn!("{} failed, asking again.\n{e}", self.spec.name);
508 }
509 last = Some(e);
510 }
511 }
512 }
513 Err(spar_err!(
514 "agent '{}' returned an unusable answer twice: {}",
515 self.spec.name,
516 last.expect("at least one attempt").message()
517 ))
518 }
519
520 fn ask_json_once<T: serde::de::DeserializeOwned>(
521 &self,
522 prompt: &str,
523 schema: &Value,
524 cwd: &Path,
525 effort: Option<&str>,
526 ) -> Result<T> {
527 let text = if self.supports_schema() {
528 let inline = serde_json::to_string(schema).unwrap_or_default();
529 let file = TempJson::write(schema)?;
530 self.ask_inner(prompt, cwd, effort, Some(file.path()), Some(&inline))?
531 } else {
532 let full = format!(
533 "{prompt}\n\n{JSON_INSTRUCTION}\n{}",
534 serde_json::to_string_pretty(schema).unwrap_or_default()
535 );
536 self.ask_inner(&full, cwd, effort, None, None)?
537 };
538 jsonx::extract_into(&text)
539 }
540
541 pub fn review<T: serde::de::DeserializeOwned>(
549 &self,
550 base: &str,
551 prompt: &str,
552 schema: &Value,
553 cwd: &Path,
554 effort: Option<&str>,
555 ) -> Result<T> {
556 let scoped = format!(
557 "{prompt}\n\nThe changes under review are the diff between `{base}` and HEAD in your \
558 working directory. Inspect them with git, then read the surrounding code before \
559 judging. Do not review only the diff."
560 );
561 self.ask_json(&scoped, schema, cwd, effort)
562 }
563}
564
565#[derive(Debug, Default, Clone)]
570pub struct Placeholders {
571 pub prompt: Option<String>,
572 pub system: Option<String>,
573 pub model: Option<String>,
574 pub effort: Option<String>,
575 pub cwd: Option<String>,
576 pub schema_file: Option<String>,
578 pub schema: Option<String>,
580}
581
582impl Placeholders {
583 fn get(&self, key: &str) -> Option<&str> {
584 let value = match key {
585 "prompt" => self.prompt.as_deref(),
586 "system" => self.system.as_deref(),
587 "model" => self.model.as_deref(),
588 "effort" => self.effort.as_deref(),
589 "cwd" => self.cwd.as_deref(),
590 "schema_file" => self.schema_file.as_deref(),
591 "schema" => self.schema.as_deref(),
592 _ => None,
593 };
594 value.filter(|v| !v.is_empty())
595 }
596
597 fn substitute(&self, arg: &str) -> Option<String> {
600 const KEYS: [&str; 7] = [
601 "prompt",
602 "system",
603 "model",
604 "effort",
605 "cwd",
606 "schema_file",
607 "schema",
608 ];
609 let mut out = arg.to_string();
610 for key in KEYS {
611 let token = format!("{{{key}}}");
612 if out.contains(&token) {
613 let value = self.get(key)?;
614 out = out.replace(&token, value);
615 }
616 }
617 Some(out)
618 }
619}
620
621fn dig<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
627 if path.is_empty() {
628 return None;
629 }
630 let mut node = value;
631 for part in path.split('.') {
632 node = node.as_object()?.get(part)?;
633 }
634 Some(node)
635}
636
637fn matches(event: &Value, wanted: &BTreeMap<String, String>) -> bool {
638 if wanted.is_empty() {
639 return false;
640 }
641 wanted
642 .iter()
643 .all(|(path, expected)| dig(event, path).and_then(Value::as_str) == Some(expected.as_str()))
644}
645
646fn as_text(value: &Value) -> Option<String> {
647 match value {
648 Value::String(s) => Some(s.clone()),
649 Value::Null => None,
650 other => Some(other.to_string()),
651 }
652}
653
654fn truncate(text: &str, max: usize) -> String {
655 text.chars().take(max).collect()
656}
657
658struct TempJson {
665 path: PathBuf,
666}
667
668impl TempJson {
669 fn write(value: &Value) -> Result<Self> {
670 use std::sync::atomic::{AtomicU64, Ordering};
671 static COUNTER: AtomicU64 = AtomicU64::new(0);
672
673 let nanos = std::time::SystemTime::now()
674 .duration_since(std::time::UNIX_EPOCH)
675 .map(|d| d.as_nanos())
676 .unwrap_or(0);
677 let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
678 let path = std::env::temp_dir().join(format!(
679 "spar-schema-{}-{nanos}-{unique}.json",
680 std::process::id()
681 ));
682 std::fs::write(&path, serde_json::to_vec_pretty(value)?)
683 .map_err(|e| spar_err!("could not write a schema file to {}: {e}", path.display()))?;
684 Ok(Self { path })
685 }
686
687 fn path(&self) -> &Path {
688 &self.path
689 }
690}
691
692impl Drop for TempJson {
693 fn drop(&mut self) {
694 let _ = std::fs::remove_file(&self.path);
695 }
696}
697
698fn same_executable(a: &Path, b: &Path) -> bool {
709 #[cfg(unix)]
710 {
711 use std::os::unix::fs::MetadataExt;
712 if let (Ok(x), Ok(y)) = (std::fs::metadata(a), std::fs::metadata(b)) {
713 return x.dev() == y.dev() && x.ino() == y.ino();
714 }
715 }
716 match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
717 (Ok(x), Ok(y)) => x == y,
718 _ => a == b,
719 }
720}
721
722pub fn correlation_warning(agents: &[Agent]) -> Option<String> {
728 for i in 0..agents.len() {
729 for j in (i + 1)..agents.len() {
730 let (a, b) = (&agents[i], &agents[j]);
731 let (Ok(pa), Ok(pb)) = (a.resolve_bin(), b.resolve_bin()) else {
732 continue;
733 };
734 if !same_executable(pa, pb) || a.spec.model_key() != b.spec.model_key() {
735 continue;
736 }
737 let model = if a.spec.model_key().is_empty() {
738 "the CLI's default".to_string()
739 } else {
740 a.spec.model_key()
741 };
742 let where_at = if pa == pb {
743 pa.display().to_string()
744 } else {
745 format!(
746 "the same executable ({} and {} are the same file)",
747 pa.display(),
748 pb.display()
749 )
750 };
751 return Some(format!(
752 "agents '{}' and '{}' both resolve to {where_at} at model {model}. Review \
753 findings will be correlated: the same model reviewing itself shares the blind \
754 spots of the model that wrote the code, so it is far less likely to catch what \
755 the implementer missed. That produces an approval indistinguishable from a real \
756 review, which is worse than no review at all. Give the two agents different \
757 CLIs or different models.",
758 a.name(),
759 b.name()
760 ));
761 }
762 }
763 None
764}
765
766pub fn build(cfg: &crate::config::Config) -> Result<Vec<Agent>> {
769 let agents: Vec<Agent> = cfg.agents.iter().cloned().map(Agent::new).collect();
770 for agent in &agents {
771 agent.resolve_bin()?;
772 if let Some(backup) = agent.fallback() {
776 if backup.resolve_bin().is_err() {
777 logwarn!(
778 "{} has a fallback ({}) that is not installed, so it will not stand in",
779 agent.name(),
780 backup.program()
781 );
782 }
783 }
784 }
785 Ok(agents)
786}
787
788pub fn find<'a>(agents: &'a [Agent], name: &str) -> Result<&'a Agent> {
790 agents.iter().find(|a| a.name() == name).ok_or_else(|| {
791 SparError::new(format!(
792 "no agent named '{name}' ({})",
793 agents
794 .iter()
795 .map(Agent::name)
796 .collect::<Vec<_>>()
797 .join(", ")
798 ))
799 })
800}
801
802#[cfg(test)]
803mod tests {
804 use super::*;
805 use crate::config::{OutputMode, SystemVia};
806
807 fn spec(command: Vec<CommandPart>) -> AgentSpec {
808 AgentSpec {
809 name: "test".into(),
810 command,
811 model: None,
812 effort: None,
813 output: OutputMode::Text,
814 message_match: BTreeMap::new(),
815 message_path: None,
816 search_paths: vec![],
817 system_via: SystemVia::Prompt,
818 timeout: 60,
819 fallback: None,
820 models: vec![],
821 efforts: vec![],
822 options_note: None,
823 }
824 }
825
826 fn one(s: &str) -> CommandPart {
827 CommandPart::One(s.into())
828 }
829
830 fn group(parts: &[&str]) -> CommandPart {
831 CommandPart::Group(parts.iter().map(|s| s.to_string()).collect())
832 }
833
834 fn agent(command: Vec<CommandPart>) -> Agent {
835 Agent::with_bin(spec(command), "/fake/bin")
836 }
837
838 fn values() -> Placeholders {
839 Placeholders {
840 prompt: Some("hi".into()),
841 ..Default::default()
842 }
843 }
844
845 #[test]
848 fn placeholders_are_substituted() {
849 let a = agent(vec![one("x"), group(&["-m", "{model}"]), one("{prompt}")]);
850 let v = Placeholders {
851 model: Some("m1".into()),
852 ..values()
853 };
854 assert_eq!(vec!["/fake/bin", "-m", "m1", "hi"], a.render(&v).unwrap());
855 }
856
857 #[test]
858 fn an_unset_placeholder_drops_the_whole_group() {
859 let a = agent(vec![one("x"), group(&["-m", "{model}"]), one("{prompt}")]);
860 assert_eq!(vec!["/fake/bin", "hi"], a.render(&values()).unwrap());
861 }
862
863 #[test]
864 fn an_empty_string_drops_the_group_too() {
865 let a = agent(vec![one("x"), group(&["-e", "{effort}"]), one("{prompt}")]);
866 let v = Placeholders {
867 effort: Some(String::new()),
868 ..values()
869 };
870 assert_eq!(vec!["/fake/bin", "hi"], a.render(&v).unwrap());
871 }
872
873 #[test]
874 fn a_bare_arg_with_an_unset_placeholder_drops() {
875 let a = agent(vec![one("x"), one("{model}"), one("{prompt}")]);
876 assert_eq!(vec!["/fake/bin", "hi"], a.render(&values()).unwrap());
877 }
878
879 #[test]
880 fn literal_args_survive() {
881 let a = agent(vec![
882 one("x"),
883 one("exec"),
884 one("--json"),
885 one("--"),
886 one("{prompt}"),
887 ]);
888 assert_eq!(
889 vec!["/fake/bin", "exec", "--json", "--", "hi"],
890 a.render(&values()).unwrap()
891 );
892 }
893
894 #[test]
895 fn an_embedded_placeholder_substitutes_in_place() {
896 let a = agent(vec![
897 one("x"),
898 group(&["-c", "model_reasoning_effort={effort}"]),
899 ]);
900 let v = Placeholders {
901 effort: Some("ultra".into()),
902 ..Default::default()
903 };
904 assert_eq!(
905 vec!["/fake/bin", "-c", "model_reasoning_effort=ultra"],
906 a.render(&v).unwrap()
907 );
908 }
909
910 #[test]
911 fn a_group_with_two_placeholders_needs_both() {
912 let a = agent(vec![
913 one("x"),
914 group(&["--a", "{model}", "--b", "{effort}"]),
915 ]);
916 let v = Placeholders {
917 model: Some("m".into()),
918 ..Default::default()
919 };
920 assert_eq!(vec!["/fake/bin"], a.render(&v).unwrap());
921 }
922
923 #[test]
924 fn supports_schema_detects_the_placeholder() {
925 assert!(agent(vec![one("x"), group(&["--schema", "{schema_file}"])]).supports_schema());
926 assert!(!agent(vec![one("x"), one("{prompt}")]).supports_schema());
927 }
928
929 #[test]
932 fn text_passes_through_trimmed() {
933 assert_eq!("hello", agent(vec![one("x")]).extract(" hello\n").unwrap());
934 }
935
936 #[test]
937 fn jsonl_picks_the_matching_event() {
938 let mut spec = spec(vec![one("x")]);
939 spec.output = OutputMode::Jsonl;
940 spec.message_path = Some("item.text".into());
941 spec.message_match = BTreeMap::from([
942 ("type".to_string(), "item.completed".to_string()),
943 ("item.type".to_string(), "agent_message".to_string()),
944 ]);
945 let a = Agent::with_bin(spec, "/fake/bin");
946 let stream = [
947 r#"{"type":"thread.started","thread_id":"t1"}"#,
948 r#"{"type":"item.completed","item":{"type":"command_execution","text":"ls"}}"#,
949 r#"{"type":"item.completed","item":{"type":"agent_message","text":"the answer"}}"#,
950 "not json at all",
951 ]
952 .join("\n");
953 assert_eq!("the answer", a.extract(&stream).unwrap());
954 }
955
956 #[test]
957 fn jsonl_raises_on_an_error_with_no_message() {
958 let mut spec = spec(vec![one("x")]);
959 spec.output = OutputMode::Jsonl;
960 spec.message_path = Some("item.text".into());
961 spec.message_match = BTreeMap::from([("type".into(), "item.completed".into())]);
962 let a = Agent::with_bin(spec, "/fake/bin");
963 assert!(a
964 .extract(r#"{"type":"turn.failed","error":"boom"}"#)
965 .is_err());
966 }
967
968 #[test]
969 fn jsonl_joins_several_agent_messages() {
970 let mut spec = spec(vec![one("x")]);
971 spec.output = OutputMode::Jsonl;
972 spec.message_path = Some("text".into());
973 spec.message_match = BTreeMap::from([("type".into(), "msg".into())]);
974 let a = Agent::with_bin(spec, "/fake/bin");
975 let stream = "{\"type\":\"msg\",\"text\":\"one\"}\n{\"type\":\"msg\",\"text\":\"two\"}";
976 assert_eq!("one\ntwo", a.extract(stream).unwrap());
977 }
978
979 #[test]
980 fn dig_walks_a_dotted_path() {
981 let v: Value = serde_json::from_str(r#"{"a":{"b":{"c":1}}}"#).unwrap();
982 assert_eq!(Some(&Value::from(1)), dig(&v, "a.b.c"));
983 assert_eq!(None, dig(&v, "a.b.missing"));
984 assert_eq!(None, dig(&v, ""));
985 }
986
987 #[test]
990 fn a_missing_binary_lists_everywhere_it_looked() {
991 let mut s = spec(vec![one("definitely-not-installed-xyz")]);
992 s.search_paths = vec!["/nowhere/at/all".into()];
993 s.name = "codex".into();
994 let err = Agent::new(s).resolve_bin().unwrap_err().to_string();
995 assert!(err.contains("definitely-not-installed-xyz (PATH)"), "{err}");
996 assert!(
997 err.contains("/nowhere/at/all/definitely-not-installed-xyz"),
998 "{err}"
999 );
1000 assert!(err.contains("SPAR_CODEX_BIN"), "{err}");
1001 }
1002
1003 #[test]
1004 fn a_search_path_that_already_names_the_binary_is_used_as_is() {
1005 let dir = std::env::temp_dir().join(format!("spar-test-{}", std::process::id()));
1006 std::fs::create_dir_all(&dir).unwrap();
1007 let bin = dir.join("mytool");
1008 std::fs::write(&bin, "#!/bin/sh\n").unwrap();
1009 #[cfg(unix)]
1010 {
1011 use std::os::unix::fs::PermissionsExt;
1012 std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
1013 }
1014 let mut s = spec(vec![one("mytool")]);
1015 s.search_paths = vec![bin.display().to_string()];
1016 assert_eq!(bin, Agent::new(s).resolve_bin().unwrap());
1017 let _ = std::fs::remove_dir_all(&dir);
1018 }
1019
1020 fn named(name: &str, bin: &str, model: Option<&str>) -> Agent {
1023 let mut s = spec(vec![one("prog")]);
1024 s.name = name.into();
1025 s.model = model.map(str::to_string);
1026 Agent::with_bin(s, bin)
1027 }
1028
1029 #[test]
1030 fn same_bin_same_model_warns() {
1031 let agents = vec![
1032 named("alpha", "/usr/local/bin/claude", Some("fable")),
1033 named("beta", "/usr/local/bin/claude", Some("fable")),
1034 ];
1035 let msg = correlation_warning(&agents).expect("should warn");
1036 assert!(msg.contains("alpha") && msg.contains("beta"), "{msg}");
1037 }
1038
1039 #[test]
1040 fn different_model_does_not_warn() {
1041 let agents = vec![
1042 named("a", "/usr/local/bin/claude", Some("fable")),
1043 named("b", "/usr/local/bin/claude", Some("opus")),
1044 ];
1045 assert!(correlation_warning(&agents).is_none());
1046 }
1047
1048 #[test]
1049 fn different_bin_does_not_warn() {
1050 let agents = vec![
1051 named("a", "/usr/local/bin/claude", Some("fable")),
1052 named("b", "/usr/local/bin/codex", Some("fable")),
1053 ];
1054 assert!(correlation_warning(&agents).is_none());
1055 }
1056
1057 #[test]
1058 fn unset_and_empty_model_both_mean_the_default_and_warn() {
1059 let agents = vec![
1060 named("a", "/usr/local/bin/claude", None),
1061 named("b", "/usr/local/bin/claude", Some("")),
1062 ];
1063 let msg = correlation_warning(&agents).expect("should warn");
1064 assert!(msg.contains("the CLI's default"), "{msg}");
1065 }
1066
1067 #[test]
1068 fn a_padded_model_still_warns() {
1069 let agents = vec![
1070 named("a", "/usr/local/bin/claude", Some("fable")),
1071 named("b", "/usr/local/bin/claude", Some(" fable ")),
1072 ];
1073 assert!(correlation_warning(&agents).is_some());
1074 }
1075
1076 #[test]
1077 fn an_empty_model_against_a_named_one_does_not_warn() {
1078 let agents = vec![
1079 named("a", "/usr/local/bin/claude", Some("")),
1080 named("b", "/usr/local/bin/claude", Some("fable")),
1081 ];
1082 assert!(correlation_warning(&agents).is_none());
1083 }
1084
1085 #[cfg(unix)]
1086 #[test]
1087 fn a_symlinked_binary_warns_and_names_both_paths() {
1088 use std::os::unix::fs::PermissionsExt;
1089 let dir = std::env::temp_dir().join(format!("spar-link-{}", std::process::id()));
1090 let _ = std::fs::remove_dir_all(&dir);
1091 std::fs::create_dir_all(&dir).unwrap();
1092 let real = dir.join("claude");
1093 let link = dir.join("claude-alias");
1094 std::fs::write(&real, "#!/bin/sh\n").unwrap();
1095 std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o755)).unwrap();
1096 std::os::unix::fs::symlink(&real, &link).unwrap();
1097
1098 let agents = vec![
1099 named("alpha", real.to_str().unwrap(), Some("fable")),
1100 named("beta", link.to_str().unwrap(), Some("fable")),
1101 ];
1102 let msg = correlation_warning(&agents).expect("should warn");
1103 assert!(msg.contains(real.to_str().unwrap()), "{msg}");
1104 assert!(msg.contains(link.to_str().unwrap()), "{msg}");
1105 let _ = std::fs::remove_dir_all(&dir);
1106 }
1107
1108 #[cfg(unix)]
1109 #[test]
1110 fn two_distinct_real_binaries_stay_quiet() {
1111 use std::os::unix::fs::PermissionsExt;
1112 let dir = std::env::temp_dir().join(format!("spar-distinct-{}", std::process::id()));
1113 let _ = std::fs::remove_dir_all(&dir);
1114 std::fs::create_dir_all(&dir).unwrap();
1115 let mut paths = Vec::new();
1116 for name in ["claude", "codex"] {
1117 let path = dir.join(name);
1118 std::fs::write(&path, "#!/bin/sh\n").unwrap();
1119 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
1120 paths.push(path);
1121 }
1122 let agents = vec![
1123 named("a", paths[0].to_str().unwrap(), Some("fable")),
1124 named("b", paths[1].to_str().unwrap(), Some("fable")),
1125 ];
1126 assert!(correlation_warning(&agents).is_none());
1127 let _ = std::fs::remove_dir_all(&dir);
1128 }
1129
1130 #[test]
1131 fn the_style_rules_ask_for_brevity_and_no_attribution() {
1132 let lower = STYLE_RULES.to_lowercase();
1133 assert!(lower.contains("brief"));
1134 assert!(lower.contains("co-authored-by"));
1135 assert!(lower.contains("em-dash"));
1136 }
1137
1138 #[test]
1143 fn the_style_rules_reach_the_code_and_not_only_what_is_posted() {
1144 let lower = STYLE_RULES.to_lowercase();
1145 assert!(lower.contains("comments in code you write"), "not in scope");
1146 assert!(
1147 lower.contains("comment code for the reason"),
1148 "no rule for it"
1149 );
1150 }
1151
1152 fn refusal_stream() -> String {
1157 let noise = "{\"type\":\"item.completed\",\"item\":{\"id\":\"i\",\"type\":\"command_execution\",\"output\":\"".to_string()
1158 + &"const x = 1;\\n".repeat(200)
1159 + "\"}}";
1160 [
1161 noise.as_str(),
1162 r#"{"type":"error","message":"This content was flagged for possible cybersecurity risk."}"#,
1163 r#"{"type":"error","message":"This content was flagged for possible cybersecurity risk."}"#,
1164 r#"{"type":"turn.failed","error":{"message":"This content was flagged for possible cybersecurity risk."}}"#,
1165 ]
1166 .join("\n")
1167 }
1168
1169 fn jsonl_agent(name: &str) -> Agent {
1170 let mut spec = spec(vec![one("codex")]);
1171 spec.name = name.into();
1172 spec.output = OutputMode::Jsonl;
1173 spec.message_path = Some("item.text".into());
1174 Agent::with_bin(spec, "/fake/codex")
1175 }
1176
1177 fn failed(stdout: &str, stderr: &str) -> proc::Output {
1178 proc::Output {
1179 stdout: stdout.to_string(),
1180 stderr: stderr.to_string(),
1181 code: 1,
1182 }
1183 }
1184
1185 #[test]
1188 fn a_jsonl_failure_reports_the_reason_and_not_the_stream() {
1189 let agent = jsonl_agent("codex");
1190 let err = agent.call_failure(&["codex".to_string()], &failed(&refusal_stream(), ""));
1191 let text = err.message();
1192 assert!(
1193 text.contains("flagged for possible cybersecurity risk"),
1194 "{text}"
1195 );
1196 assert!(
1197 !text.contains("const x = 1;"),
1198 "the stream leaked in:\n{text}"
1199 );
1200 assert!(text.len() < 400, "still {} characters:\n{text}", text.len());
1201 }
1202
1203 #[test]
1205 fn the_same_reason_reported_three_times_is_said_once() {
1206 let agent = jsonl_agent("codex");
1207 let err = agent.call_failure(&["codex".to_string()], &failed(&refusal_stream(), ""));
1208 assert_eq!(
1209 1,
1210 err.message().matches("flagged for possible").count(),
1211 "{}",
1212 err.message()
1213 );
1214 }
1215
1216 #[test]
1219 fn stderr_is_kept_because_it_is_where_the_other_half_arrives() {
1220 let agent = jsonl_agent("codex");
1221 let err = agent.call_failure(
1222 &["codex".to_string()],
1223 &failed(
1224 &refusal_stream(),
1225 "ERROR router: agent thread limit reached",
1226 ),
1227 );
1228 assert!(
1229 err.message().contains("agent thread limit reached"),
1230 "{}",
1231 err.message()
1232 );
1233 }
1234
1235 #[test]
1238 fn a_stream_with_no_error_event_falls_back_to_the_raw_output() {
1239 let agent = jsonl_agent("codex");
1240 let err = agent.call_failure(
1241 &["codex".to_string()],
1242 &failed("{\"type\":\"system\"}", "segmentation fault"),
1243 );
1244 assert!(
1245 err.message().contains("segmentation fault"),
1246 "{}",
1247 err.message()
1248 );
1249 assert!(
1250 err.message().starts_with("command failed"),
1251 "{}",
1252 err.message()
1253 );
1254 }
1255
1256 #[test]
1258 fn a_text_agent_is_reported_exactly_as_before() {
1259 let agent = agent(vec![one("mytool")]);
1260 let err = agent.call_failure(&["mytool".to_string()], &failed("some prose", "boom"));
1261 assert!(
1262 err.message().starts_with("command failed"),
1263 "{}",
1264 err.message()
1265 );
1266 assert!(err.message().contains("some prose"), "{}", err.message());
1267 }
1268
1269 #[test]
1272 fn a_reworded_failure_is_still_a_failed_call() {
1273 let agent = jsonl_agent("codex");
1274 let err = agent.call_failure(&["codex".to_string()], &failed(&refusal_stream(), ""));
1275 assert_eq!(ErrorKind::CallFailed, err.kind());
1276 }
1277
1278 fn shell(name: &str, line: &str) -> AgentSpec {
1283 let mut spec = spec(vec![one("sh"), one("-c"), one(line)]);
1284 spec.name = name.into();
1285 spec
1286 }
1287
1288 fn with_fallback(mut primary: AgentSpec, backup: AgentSpec) -> Agent {
1289 primary.fallback = Some(Box::new(backup));
1290 Agent::with_bin(primary, "/bin/sh")
1291 }
1292
1293 #[test]
1294 fn a_failed_call_is_answered_by_the_fallback() {
1295 let agent = with_fallback(
1296 shell("primary", "echo refused >&2; exit 1"),
1297 shell("backup", "echo stood in"),
1298 );
1299 let answer = agent.ask("hi", Path::new("."), None).expect("fallback");
1300 assert_eq!("stood in", answer);
1301 }
1302
1303 #[test]
1304 fn without_a_fallback_the_original_error_is_what_surfaces() {
1305 let agent = Agent::with_bin(shell("primary", "echo refused >&2; exit 1"), "/bin/sh");
1306 let err = agent
1307 .ask("hi", Path::new("."), None)
1308 .expect_err("no backup");
1309 assert!(err.message().contains("refused"), "{err}");
1310 }
1311
1312 #[test]
1315 fn both_failing_reports_the_primary_first() {
1316 let agent = with_fallback(
1317 shell("primary", "echo policy refusal >&2; exit 1"),
1318 shell("backup", "echo out of quota >&2; exit 1"),
1319 );
1320 let err = agent
1321 .ask("hi", Path::new("."), None)
1322 .expect_err("both fail");
1323 let text = err.message();
1324 let primary_at = text.find("policy refusal").expect("primary reason");
1325 let backup_at = text.find("out of quota").expect("backup reason");
1326 assert!(primary_at < backup_at, "{text}");
1327 assert!(
1328 text.contains("primary") && text.contains("backup"),
1329 "{text}"
1330 );
1331 }
1332
1333 fn attempts(name: &str) -> (PathBuf, String) {
1336 let path = std::env::temp_dir().join(format!("spar-attempts-{name}"));
1337 let _ = std::fs::remove_file(&path);
1338 let line = format!("echo x >> {}", path.display());
1339 (path, line)
1340 }
1341
1342 fn counted(path: &Path) -> usize {
1343 std::fs::read_to_string(path)
1344 .map(|t| t.lines().count())
1345 .unwrap_or(0)
1346 }
1347
1348 #[test]
1353 fn a_cli_that_could_not_answer_is_not_asked_twice_when_there_is_a_stand_in() {
1354 let (path, count) = attempts("refused-with-standin");
1355 let agent = with_fallback(
1356 shell("primary", &format!("{count}; echo refused >&2; exit 1")),
1357 shell("backup", "echo '{}'"),
1358 );
1359 let answer: Value = agent
1360 .ask_json(
1361 "q",
1362 &serde_json::json!({"type": "object"}),
1363 Path::new("."),
1364 None,
1365 )
1366 .expect("the stand in answers");
1367 assert!(answer.is_object());
1368 assert_eq!(1, counted(&path), "the primary was asked more than once");
1369 }
1370
1371 #[test]
1374 fn with_no_stand_in_a_failed_call_is_still_retried() {
1375 let (path, count) = attempts("refused-alone");
1376 let agent = Agent::with_bin(
1377 shell("solo", &format!("{count}; echo refused >&2; exit 1")),
1378 "/bin/sh",
1379 );
1380 let err = agent
1381 .ask_json::<Value>(
1382 "q",
1383 &serde_json::json!({"type": "object"}),
1384 Path::new("."),
1385 None,
1386 )
1387 .expect_err("nothing answers");
1388 assert!(err.message().contains("twice"), "{err}");
1389 assert_eq!(2, counted(&path));
1390 }
1391
1392 #[test]
1396 fn an_unusable_answer_is_still_worth_asking_again() {
1397 let (path, count) = attempts("unparsable");
1398 let agent = with_fallback(
1399 shell("primary", &format!("{count}; echo not json at all")),
1400 shell("backup", "echo '{}'"),
1401 );
1402 let answer: Value = agent
1403 .ask_json(
1404 "q",
1405 &serde_json::json!({"type": "object"}),
1406 Path::new("."),
1407 None,
1408 )
1409 .expect("the stand in answers in the end");
1410 assert!(answer.is_object());
1411 assert_eq!(2, counted(&path), "a shape error is worth one more ask");
1412 }
1413
1414 #[test]
1418 fn a_timeout_still_reaches_the_fallback() {
1419 let mut primary = shell("primary", "sleep 30");
1420 primary.timeout = 1;
1421 let agent = with_fallback(primary, shell("backup", "echo stood in"));
1422 assert_eq!(
1423 "stood in",
1424 agent.ask("hi", Path::new("."), None).expect("fallback")
1425 );
1426 }
1427
1428 #[test]
1431 fn the_fallback_is_built_alongside_the_agent() {
1432 let mut primary = shell("primary", "true");
1433 primary.fallback = Some(Box::new(shell("backup", "true")));
1434 let agent = Agent::new(primary);
1435 assert_eq!(Some("backup"), agent.fallback().map(Agent::name));
1436 assert!(Agent::new(shell("solo", "true")).fallback().is_none());
1437 }
1438}
1439
1440#[cfg(test)]
1441mod schema_placeholder_tests {
1442 use super::*;
1443 use crate::config::{OutputMode, SystemVia};
1444
1445 fn spec_with(command: Vec<CommandPart>) -> AgentSpec {
1446 AgentSpec {
1447 name: "claude".into(),
1448 command,
1449 model: None,
1450 effort: None,
1451 output: OutputMode::Text,
1452 message_match: BTreeMap::new(),
1453 message_path: None,
1454 search_paths: vec![],
1455 system_via: SystemVia::Prompt,
1456 timeout: 60,
1457 fallback: None,
1458 models: vec![],
1459 efforts: vec![],
1460 options_note: None,
1461 }
1462 }
1463
1464 fn one(s: &str) -> CommandPart {
1465 CommandPart::One(s.into())
1466 }
1467 fn group(parts: &[&str]) -> CommandPart {
1468 CommandPart::Group(parts.iter().map(|s| s.to_string()).collect())
1469 }
1470
1471 #[test]
1474 fn either_schema_form_counts_as_native_support() {
1475 let inline = Agent::with_bin(
1476 spec_with(vec![one("x"), group(&["--json-schema", "{schema}"])]),
1477 "/b",
1478 );
1479 let byfile = Agent::with_bin(
1480 spec_with(vec![one("x"), group(&["--output-schema", "{schema_file}"])]),
1481 "/b",
1482 );
1483 let neither = Agent::with_bin(spec_with(vec![one("x"), one("{prompt}")]), "/b");
1484 assert!(inline.supports_schema());
1485 assert!(byfile.supports_schema());
1486 assert!(!neither.supports_schema());
1487 }
1488
1489 #[test]
1490 fn the_inline_schema_is_substituted_whole() {
1491 let agent = Agent::with_bin(
1492 spec_with(vec![
1493 one("x"),
1494 group(&["--json-schema", "{schema}"]),
1495 one("{prompt}"),
1496 ]),
1497 "/b",
1498 );
1499 let values = Placeholders {
1500 prompt: Some("review it".into()),
1501 schema: Some(r#"{"type":"object"}"#.into()),
1502 ..Default::default()
1503 };
1504 assert_eq!(
1505 vec!["/b", "--json-schema", r#"{"type":"object"}"#, "review it"],
1506 agent.render(&values).unwrap()
1507 );
1508 }
1509
1510 #[test]
1513 fn the_schema_flag_drops_when_no_schema_is_wanted() {
1514 let agent = Agent::with_bin(
1515 spec_with(vec![
1516 one("x"),
1517 group(&["--json-schema", "{schema}"]),
1518 one("{prompt}"),
1519 ]),
1520 "/b",
1521 );
1522 let values = Placeholders {
1523 prompt: Some("implement it".into()),
1524 ..Default::default()
1525 };
1526 assert_eq!(vec!["/b", "implement it"], agent.render(&values).unwrap());
1527 }
1528
1529 #[test]
1531 fn the_two_schema_placeholders_do_not_collide() {
1532 let agent = Agent::with_bin(
1533 spec_with(vec![one("x"), group(&["--output-schema", "{schema_file}"])]),
1534 "/b",
1535 );
1536 let values = Placeholders {
1537 schema: Some("INLINE".into()),
1538 schema_file: Some("/tmp/s.json".into()),
1539 ..Default::default()
1540 };
1541 assert_eq!(
1542 vec!["/b", "--output-schema", "/tmp/s.json"],
1543 agent.render(&values).unwrap()
1544 );
1545 }
1546
1547 #[test]
1549 fn the_shipped_claude_preset_now_has_native_structured_output() {
1550 let raw = crate::config::load_preset("claude").unwrap();
1551 let table = raw.as_table().cloned().unwrap();
1552 let mut spec: AgentSpec = toml::Value::Table(table).try_into().unwrap();
1553 spec.name = "claude".into();
1554 assert!(
1555 Agent::with_bin(spec, "/b").supports_schema(),
1556 "without this a long review is parsed out of prose and truncates"
1557 );
1558 }
1559}