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.
43- Brief means saying fewer things, never packing more into a sentence. Two
44 plain sentences beat one that has to be read twice. Split a sentence that
45 carries three facts, and split one that makes the reader hold an identifier
46 in their head to parse the rest of the clause. A comma splice joining two
47 ideas to save a full stop costs the reader more than the full stop would.
48- No headings, bullet lists, or bold text in anything only a few sentences long.
49- Comment code for the reason, not the change. A comment earns its length from
50 what the code cannot say for itself: a constraint that is not local, an
51 alternative that was tried and does not work, a surprise the next reader would
52 otherwise trip on. Write the reason that holds now, not the investigation that
53 found it. A paragraph above a three line change is almost always the debugging
54 story, and the reader wants the conclusion of it.
55Write as a human engineer would, because the reader neither knows nor cares what
56produced the work.";
57
58const JSON_INSTRUCTION: &str = "Respond with ONLY a JSON object matching this \
59schema. No prose, no markdown fences, no commentary before or after:";
60
61const INSTRUCTIONS_HEADER: &str = "Additional instructions from the person who \
68started this run. They change how you work, not what was asked for above and \
69not the shape of your answer:";
70
71pub struct Agent {
72 pub spec: AgentSpec,
73 fallback: Option<Box<Agent>>,
77 instructions: Option<String>,
79 resolved: OnceLock<PathBuf>,
80}
81
82impl std::fmt::Debug for Agent {
83 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84 write!(f, "<{} {}>", self.spec.name, self.spec.describe())
85 }
86}
87
88impl Agent {
89 pub fn new(spec: AgentSpec) -> Self {
90 let fallback = spec
91 .fallback
92 .clone()
93 .map(|backup| Box::new(Agent::new(*backup)));
94 Self {
95 spec,
96 fallback,
97 instructions: None,
98 resolved: OnceLock::new(),
99 }
100 }
101
102 pub fn with_instructions(mut self, text: &str) -> Self {
108 let text = text.trim();
109 if text.is_empty() {
110 return self;
111 }
112 if let Some(backup) = self.fallback.take() {
113 self.fallback = Some(Box::new(backup.with_instructions(text)));
114 }
115 self.instructions = Some(text.to_string());
116 self
117 }
118
119 fn instructed(&self, prompt: &str) -> String {
125 match &self.instructions {
126 Some(extra) => format!("{prompt}\n\n{INSTRUCTIONS_HEADER}\n{extra}"),
127 None => prompt.to_string(),
128 }
129 }
130
131 pub fn name(&self) -> &str {
132 &self.spec.name
133 }
134
135 pub fn fallback(&self) -> Option<&Agent> {
137 self.fallback.as_deref()
138 }
139
140 pub fn program(&self) -> &str {
143 match self.spec.command.first() {
144 Some(CommandPart::One(program)) => program,
145 _ => self.name(),
146 }
147 }
148
149 pub fn env_key(&self) -> String {
151 format!(
152 "SPAR_{}_BIN",
153 self.spec.name.to_uppercase().replace('-', "_")
154 )
155 }
156
157 #[doc(hidden)]
160 pub fn with_bin(spec: AgentSpec, bin: impl Into<PathBuf>) -> Self {
161 let agent = Self::new(spec);
162 let _ = agent.resolved.set(bin.into());
163 agent
164 }
165
166 pub fn resolve_bin(&self) -> Result<&Path> {
173 if let Some(found) = self.resolved.get() {
174 return Ok(found.as_path());
175 }
176 let found = self.locate()?;
177 let _ = self.resolved.set(found);
178 Ok(self.resolved.get().expect("just set").as_path())
179 }
180
181 fn locate(&self) -> Result<PathBuf> {
182 let wanted = match self.spec.command.first() {
183 Some(CommandPart::One(program)) => program.clone(),
184 _ => bail!("agent '{}' has no command configured", self.spec.name),
185 };
186
187 let env_key = self.env_key();
188 let env_override = std::env::var(&env_key)
189 .ok()
190 .filter(|v| !v.trim().is_empty());
191
192 let mut tried: Vec<String> = Vec::new();
193
194 for candidate in env_override
195 .iter()
196 .map(String::as_str)
197 .chain([wanted.as_str()])
198 {
199 let path = Path::new(candidate);
200 if path.is_absolute() || candidate.contains(std::path::MAIN_SEPARATOR) {
201 let expanded = proc::expand_tilde(candidate);
202 tried.push(expanded.display().to_string());
203 if proc::is_executable(&expanded) {
204 return Ok(expanded);
205 }
206 } else {
207 tried.push(format!("{candidate} (PATH)"));
208 if let Some(found) = proc::which(candidate) {
209 return Ok(found);
210 }
211 }
212 }
213
214 for base in &self.spec.search_paths {
215 let base = proc::expand_tilde(base);
216 let candidate = if base.file_name().and_then(|n| n.to_str()) == Some(wanted.as_str()) {
217 base
218 } else {
219 base.join(&wanted)
220 };
221 tried.push(candidate.display().to_string());
222 if proc::is_executable(&candidate) {
223 return Ok(candidate);
224 }
225 }
226
227 Err(spar_err!(
228 "could not find the binary for agent '{}'. Tried:\n {}\nSet agents.{}.command[0] to \
229 an absolute path, or {}=/path/to/binary.",
230 self.spec.name,
231 tried.join("\n "),
232 self.spec.name,
233 env_key
234 ))
235 }
236
237 pub fn render(&self, values: &Placeholders) -> Result<Vec<String>> {
243 let mut out = vec![self.resolve_bin()?.display().to_string()];
244 for part in self.spec.command.iter().skip(1) {
245 let mut rendered = Vec::new();
246 let mut skip = false;
247 for arg in part.args() {
248 match values.substitute(arg) {
249 Some(text) => rendered.push(text),
250 None => {
251 skip = true;
252 break;
253 }
254 }
255 }
256 if !skip {
257 out.extend(rendered);
258 }
259 }
260 Ok(out)
261 }
262
263 pub fn supports_schema(&self) -> bool {
269 self.spec
270 .command
271 .iter()
272 .flat_map(|p| p.args())
273 .any(|a| a.contains("{schema_file}") || a.contains("{schema}"))
274 }
275
276 pub fn extract(&self, stdout: &str) -> Result<String> {
279 match self.spec.output {
280 OutputMode::Text | OutputMode::Json => Ok(stdout.trim().to_string()),
281 OutputMode::Jsonl => self.extract_jsonl(stdout),
282 }
283 }
284
285 fn extract_jsonl(&self, stdout: &str) -> Result<String> {
286 let mut messages: Vec<String> = Vec::new();
287
288 for line in stdout.lines() {
289 let line = line.trim();
290 if !line.starts_with('{') {
291 continue;
292 }
293 let Ok(event) = serde_json::from_str::<Value>(line) else {
294 continue;
295 };
296 if matches(&event, &self.spec.message_match) {
297 if let Some(text) = dig(&event, self.spec.message_path.as_deref().unwrap_or("")) {
298 if let Some(text) = as_text(text) {
299 messages.push(text);
300 }
301 }
302 }
303 }
304
305 if messages.is_empty() {
306 let reasons = self.error_events(stdout);
307 if !reasons.is_empty() {
308 return Err(SparError::call_failed(format!(
311 "agent '{}' failed: {}",
312 self.spec.name,
313 reasons.join("; ")
314 )));
315 }
316 }
317 Ok(messages.join("\n").trim().to_string())
318 }
319
320 fn error_events(&self, stdout: &str) -> Vec<String> {
330 let mut reasons: Vec<String> = Vec::new();
331 for line in stdout.lines() {
332 let line = line.trim();
333 if !line.starts_with('{') {
334 continue;
335 }
336 let Ok(event) = serde_json::from_str::<Value>(line) else {
337 continue;
338 };
339 if !matches!(
340 event.get("type").and_then(Value::as_str),
341 Some("turn.failed") | Some("error")
342 ) {
343 continue;
344 }
345 let reason = dig(&event, "message")
346 .or_else(|| dig(&event, "error.message"))
347 .and_then(as_text)
348 .unwrap_or_else(|| truncate(&event.to_string(), 400));
349 if !reason.trim().is_empty() && !reasons.contains(&reason) {
350 reasons.push(reason);
351 }
352 }
353 reasons
354 }
355
356 fn call_failure(&self, argv: &[String], out: &proc::Output) -> SparError {
368 if self.spec.output != OutputMode::Jsonl {
369 return SparError::call_failed(proc::failure_message(argv, out));
370 }
371 let reasons = self.error_events(&out.stdout);
372 if reasons.is_empty() {
373 return SparError::call_failed(proc::failure_message(argv, out));
374 }
375 let mut text = format!(
376 "agent '{}' could not answer (exit {}): {}",
377 self.spec.name,
378 out.code,
379 reasons.join("; ")
380 );
381 let stderr = out.stderr.trim();
382 if !stderr.is_empty() {
383 text.push_str(&format!("\n--- stderr ---\n{stderr}"));
384 }
385 text.push_str(&format!("\n--- command ---\n{}", proc::abbreviate(argv)));
386 SparError::call_failed(text)
387 }
388
389 pub fn ask(&self, prompt: &str, cwd: &Path, effort: Option<&str>) -> Result<String> {
392 let prompt = &self.instructed(prompt);
393 match self.ask_inner(prompt, cwd, effort, None, None) {
394 Ok(text) => Ok(text),
395 Err(e) => self.hand_over(e, |backup| backup.ask(prompt, cwd, None)),
396 }
397 }
398
399 fn hand_over<T>(&self, primary: SparError, run: impl FnOnce(&Agent) -> Result<T>) -> Result<T> {
410 let Some(backup) = self.fallback() else {
411 return Err(primary);
412 };
413 logwarn!(
414 "{} could not answer. Handing the call to {}.\n{primary}",
415 self.name(),
416 backup.name()
417 );
418 match run(backup) {
419 Ok(answer) => {
420 log!("{} answered in place of {}", backup.name(), self.name());
421 Ok(answer)
422 }
423 Err(second) => Err(spar_err!(
427 "agent '{}' failed and its fallback '{}' could not stand in.\n{}\n\n{}: {}",
428 self.name(),
429 backup.name(),
430 primary.message(),
431 backup.name(),
432 second.message()
433 )),
434 }
435 }
436
437 fn ask_inner(
438 &self,
439 prompt: &str,
440 cwd: &Path,
441 effort: Option<&str>,
442 schema_file: Option<&Path>,
443 schema: Option<&str>,
444 ) -> Result<String> {
445 let body = match self.spec.system_via {
446 SystemVia::Placeholder => prompt.to_string(),
447 SystemVia::Prompt => format!("{STYLE_RULES}\n\n{prompt}"),
448 };
449 let values = Placeholders {
450 prompt: Some(body),
451 system: Some(STYLE_RULES.to_string()),
452 model: self.spec.model.clone(),
453 effort: effort
454 .map(str::to_string)
455 .or_else(|| self.spec.effort.clone()),
456 cwd: Some(cwd.display().to_string()),
457 schema_file: schema_file.map(|p| p.display().to_string()),
458 schema: schema.map(str::to_string),
459 };
460 let argv = self.render(&values)?;
461 let opts = ExecOpts::new()
465 .cwd(cwd)
466 .timeout_secs(self.spec.timeout)
467 .check(false);
468 let out = proc::exec(&argv, &opts)?;
469 if !out.ok() {
470 return Err(self.call_failure(&argv, &out));
471 }
472 self.extract(&out.stdout)
473 }
474
475 pub fn ask_json<T: serde::de::DeserializeOwned>(
479 &self,
480 prompt: &str,
481 schema: &Value,
482 cwd: &Path,
483 effort: Option<&str>,
484 ) -> Result<T> {
485 let prompt = &self.instructed(prompt);
488 match self.ask_json_retrying(prompt, schema, cwd, effort) {
489 Ok(parsed) => Ok(parsed),
490 Err(e) => self.hand_over(e, |backup| {
491 backup.ask_json_retrying::<T>(prompt, schema, cwd, None)
492 }),
493 }
494 }
495
496 fn worth_asking_again(&self, e: &SparError) -> bool {
509 match e.kind() {
510 ErrorKind::TimedOut => false,
511 ErrorKind::CallFailed => self.fallback().is_none(),
512 ErrorKind::Other => true,
513 }
514 }
515
516 fn ask_json_retrying<T: serde::de::DeserializeOwned>(
518 &self,
519 prompt: &str,
520 schema: &Value,
521 cwd: &Path,
522 effort: Option<&str>,
523 ) -> Result<T> {
524 const ATTEMPTS: usize = 2;
530 let mut last: Option<SparError> = None;
531
532 for attempt in 1..=ATTEMPTS {
533 let asked = match &last {
534 None => prompt.to_string(),
535 Some(e) => format!(
536 "{prompt}\n\nYour previous answer could not be used: {}\nReturn the whole \
537 object this time, exactly matching the schema, and nothing else.",
538 e.first_line()
539 ),
540 };
541 match self.ask_json_once::<T>(&asked, schema, cwd, effort) {
542 Ok(parsed) => {
543 if attempt > 1 {
544 logdim!("{} answered on the retry", self.spec.name);
545 }
546 return Ok(parsed);
547 }
548 Err(e) if !self.worth_asking_again(&e) => return Err(e),
552 Err(e) => {
553 if attempt < ATTEMPTS {
554 logwarn!("{} failed, asking again.\n{e}", self.spec.name);
559 }
560 last = Some(e);
561 }
562 }
563 }
564 Err(spar_err!(
565 "agent '{}' returned an unusable answer twice: {}",
566 self.spec.name,
567 last.expect("at least one attempt").message()
568 ))
569 }
570
571 fn ask_json_once<T: serde::de::DeserializeOwned>(
572 &self,
573 prompt: &str,
574 schema: &Value,
575 cwd: &Path,
576 effort: Option<&str>,
577 ) -> Result<T> {
578 let text = if self.supports_schema() {
579 let inline = serde_json::to_string(schema).unwrap_or_default();
580 let file = TempJson::write(schema)?;
581 self.ask_inner(prompt, cwd, effort, Some(file.path()), Some(&inline))?
582 } else {
583 let full = format!(
584 "{prompt}\n\n{JSON_INSTRUCTION}\n{}",
585 serde_json::to_string_pretty(schema).unwrap_or_default()
586 );
587 self.ask_inner(&full, cwd, effort, None, None)?
588 };
589 jsonx::extract_into(&text)
590 }
591
592 pub fn review<T: serde::de::DeserializeOwned>(
600 &self,
601 base: &str,
602 prompt: &str,
603 schema: &Value,
604 cwd: &Path,
605 effort: Option<&str>,
606 ) -> Result<T> {
607 let scoped = format!(
608 "{prompt}\n\nThe changes under review are the diff between `{base}` and HEAD in your \
609 working directory. Inspect them with git, then read the surrounding code before \
610 judging. Do not review only the diff."
611 );
612 self.ask_json(&scoped, schema, cwd, effort)
613 }
614}
615
616#[derive(Debug, Default, Clone)]
621pub struct Placeholders {
622 pub prompt: Option<String>,
623 pub system: Option<String>,
624 pub model: Option<String>,
625 pub effort: Option<String>,
626 pub cwd: Option<String>,
627 pub schema_file: Option<String>,
629 pub schema: Option<String>,
631}
632
633impl Placeholders {
634 fn get(&self, key: &str) -> Option<&str> {
635 let value = match key {
636 "prompt" => self.prompt.as_deref(),
637 "system" => self.system.as_deref(),
638 "model" => self.model.as_deref(),
639 "effort" => self.effort.as_deref(),
640 "cwd" => self.cwd.as_deref(),
641 "schema_file" => self.schema_file.as_deref(),
642 "schema" => self.schema.as_deref(),
643 _ => None,
644 };
645 value.filter(|v| !v.is_empty())
646 }
647
648 fn substitute(&self, arg: &str) -> Option<String> {
651 const KEYS: [&str; 7] = [
652 "prompt",
653 "system",
654 "model",
655 "effort",
656 "cwd",
657 "schema_file",
658 "schema",
659 ];
660 let mut out = arg.to_string();
661 for key in KEYS {
662 let token = format!("{{{key}}}");
663 if out.contains(&token) {
664 let value = self.get(key)?;
665 out = out.replace(&token, value);
666 }
667 }
668 Some(out)
669 }
670}
671
672fn dig<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
678 if path.is_empty() {
679 return None;
680 }
681 let mut node = value;
682 for part in path.split('.') {
683 node = node.as_object()?.get(part)?;
684 }
685 Some(node)
686}
687
688fn matches(event: &Value, wanted: &BTreeMap<String, String>) -> bool {
689 if wanted.is_empty() {
690 return false;
691 }
692 wanted
693 .iter()
694 .all(|(path, expected)| dig(event, path).and_then(Value::as_str) == Some(expected.as_str()))
695}
696
697fn as_text(value: &Value) -> Option<String> {
698 match value {
699 Value::String(s) => Some(s.clone()),
700 Value::Null => None,
701 other => Some(other.to_string()),
702 }
703}
704
705fn truncate(text: &str, max: usize) -> String {
706 text.chars().take(max).collect()
707}
708
709struct TempJson {
716 path: PathBuf,
717}
718
719impl TempJson {
720 fn write(value: &Value) -> Result<Self> {
721 use std::sync::atomic::{AtomicU64, Ordering};
722 static COUNTER: AtomicU64 = AtomicU64::new(0);
723
724 let nanos = std::time::SystemTime::now()
725 .duration_since(std::time::UNIX_EPOCH)
726 .map(|d| d.as_nanos())
727 .unwrap_or(0);
728 let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
729 let path = std::env::temp_dir().join(format!(
730 "spar-schema-{}-{nanos}-{unique}.json",
731 std::process::id()
732 ));
733 std::fs::write(&path, serde_json::to_vec_pretty(value)?)
734 .map_err(|e| spar_err!("could not write a schema file to {}: {e}", path.display()))?;
735 Ok(Self { path })
736 }
737
738 fn path(&self) -> &Path {
739 &self.path
740 }
741}
742
743impl Drop for TempJson {
744 fn drop(&mut self) {
745 let _ = std::fs::remove_file(&self.path);
746 }
747}
748
749fn same_executable(a: &Path, b: &Path) -> bool {
760 #[cfg(unix)]
761 {
762 use std::os::unix::fs::MetadataExt;
763 if let (Ok(x), Ok(y)) = (std::fs::metadata(a), std::fs::metadata(b)) {
764 return x.dev() == y.dev() && x.ino() == y.ino();
765 }
766 }
767 match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
768 (Ok(x), Ok(y)) => x == y,
769 _ => a == b,
770 }
771}
772
773pub fn correlation_warning(agents: &[Agent]) -> Option<String> {
779 for i in 0..agents.len() {
780 for j in (i + 1)..agents.len() {
781 let (a, b) = (&agents[i], &agents[j]);
782 let (Ok(pa), Ok(pb)) = (a.resolve_bin(), b.resolve_bin()) else {
783 continue;
784 };
785 if !same_executable(pa, pb) || a.spec.model_key() != b.spec.model_key() {
786 continue;
787 }
788 let model = if a.spec.model_key().is_empty() {
789 "the CLI's default".to_string()
790 } else {
791 a.spec.model_key()
792 };
793 let where_at = if pa == pb {
794 pa.display().to_string()
795 } else {
796 format!(
797 "the same executable ({} and {} are the same file)",
798 pa.display(),
799 pb.display()
800 )
801 };
802 return Some(format!(
803 "agents '{}' and '{}' both resolve to {where_at} at model {model}. Review \
804 findings will be correlated: the same model reviewing itself shares the blind \
805 spots of the model that wrote the code, so it is far less likely to catch what \
806 the implementer missed. That produces an approval indistinguishable from a real \
807 review, which is worse than no review at all. Give the two agents different \
808 CLIs or different models.",
809 a.name(),
810 b.name()
811 ));
812 }
813 }
814 None
815}
816
817pub fn build(cfg: &crate::config::Config) -> Result<Vec<Agent>> {
820 let agents: Vec<Agent> = cfg
821 .agents
822 .iter()
823 .cloned()
824 .map(Agent::new)
825 .map(|agent| agent.with_instructions(&cfg.loop_cfg.instructions))
826 .collect();
827 for agent in &agents {
828 agent.resolve_bin()?;
829 if let Some(backup) = agent.fallback() {
833 if backup.resolve_bin().is_err() {
834 logwarn!(
835 "{} has a fallback ({}) that is not installed, so it will not stand in",
836 agent.name(),
837 backup.program()
838 );
839 }
840 }
841 }
842 Ok(agents)
843}
844
845pub fn find<'a>(agents: &'a [Agent], name: &str) -> Result<&'a Agent> {
847 agents.iter().find(|a| a.name() == name).ok_or_else(|| {
848 SparError::new(format!(
849 "no agent named '{name}' ({})",
850 agents
851 .iter()
852 .map(Agent::name)
853 .collect::<Vec<_>>()
854 .join(", ")
855 ))
856 })
857}
858
859#[cfg(test)]
860mod tests {
861 use super::*;
862 use crate::config::{OutputMode, SystemVia};
863
864 fn spec(command: Vec<CommandPart>) -> AgentSpec {
865 AgentSpec {
866 name: "test".into(),
867 command,
868 model: None,
869 effort: None,
870 output: OutputMode::Text,
871 message_match: BTreeMap::new(),
872 message_path: None,
873 search_paths: vec![],
874 system_via: SystemVia::Prompt,
875 timeout: 60,
876 fallback: None,
877 models: vec![],
878 efforts: vec![],
879 options_note: None,
880 }
881 }
882
883 fn one(s: &str) -> CommandPart {
884 CommandPart::One(s.into())
885 }
886
887 fn group(parts: &[&str]) -> CommandPart {
888 CommandPart::Group(parts.iter().map(|s| s.to_string()).collect())
889 }
890
891 fn agent(command: Vec<CommandPart>) -> Agent {
892 Agent::with_bin(spec(command), "/fake/bin")
893 }
894
895 fn values() -> Placeholders {
896 Placeholders {
897 prompt: Some("hi".into()),
898 ..Default::default()
899 }
900 }
901
902 #[test]
905 fn placeholders_are_substituted() {
906 let a = agent(vec![one("x"), group(&["-m", "{model}"]), one("{prompt}")]);
907 let v = Placeholders {
908 model: Some("m1".into()),
909 ..values()
910 };
911 assert_eq!(vec!["/fake/bin", "-m", "m1", "hi"], a.render(&v).unwrap());
912 }
913
914 #[test]
915 fn an_unset_placeholder_drops_the_whole_group() {
916 let a = agent(vec![one("x"), group(&["-m", "{model}"]), one("{prompt}")]);
917 assert_eq!(vec!["/fake/bin", "hi"], a.render(&values()).unwrap());
918 }
919
920 #[test]
921 fn an_empty_string_drops_the_group_too() {
922 let a = agent(vec![one("x"), group(&["-e", "{effort}"]), one("{prompt}")]);
923 let v = Placeholders {
924 effort: Some(String::new()),
925 ..values()
926 };
927 assert_eq!(vec!["/fake/bin", "hi"], a.render(&v).unwrap());
928 }
929
930 #[test]
931 fn a_bare_arg_with_an_unset_placeholder_drops() {
932 let a = agent(vec![one("x"), one("{model}"), one("{prompt}")]);
933 assert_eq!(vec!["/fake/bin", "hi"], a.render(&values()).unwrap());
934 }
935
936 #[test]
937 fn literal_args_survive() {
938 let a = agent(vec![
939 one("x"),
940 one("exec"),
941 one("--json"),
942 one("--"),
943 one("{prompt}"),
944 ]);
945 assert_eq!(
946 vec!["/fake/bin", "exec", "--json", "--", "hi"],
947 a.render(&values()).unwrap()
948 );
949 }
950
951 #[test]
952 fn an_embedded_placeholder_substitutes_in_place() {
953 let a = agent(vec![
954 one("x"),
955 group(&["-c", "model_reasoning_effort={effort}"]),
956 ]);
957 let v = Placeholders {
958 effort: Some("ultra".into()),
959 ..Default::default()
960 };
961 assert_eq!(
962 vec!["/fake/bin", "-c", "model_reasoning_effort=ultra"],
963 a.render(&v).unwrap()
964 );
965 }
966
967 #[test]
968 fn a_group_with_two_placeholders_needs_both() {
969 let a = agent(vec![
970 one("x"),
971 group(&["--a", "{model}", "--b", "{effort}"]),
972 ]);
973 let v = Placeholders {
974 model: Some("m".into()),
975 ..Default::default()
976 };
977 assert_eq!(vec!["/fake/bin"], a.render(&v).unwrap());
978 }
979
980 #[test]
981 fn supports_schema_detects_the_placeholder() {
982 assert!(agent(vec![one("x"), group(&["--schema", "{schema_file}"])]).supports_schema());
983 assert!(!agent(vec![one("x"), one("{prompt}")]).supports_schema());
984 }
985
986 #[test]
989 fn text_passes_through_trimmed() {
990 assert_eq!("hello", agent(vec![one("x")]).extract(" hello\n").unwrap());
991 }
992
993 #[test]
994 fn jsonl_picks_the_matching_event() {
995 let mut spec = spec(vec![one("x")]);
996 spec.output = OutputMode::Jsonl;
997 spec.message_path = Some("item.text".into());
998 spec.message_match = BTreeMap::from([
999 ("type".to_string(), "item.completed".to_string()),
1000 ("item.type".to_string(), "agent_message".to_string()),
1001 ]);
1002 let a = Agent::with_bin(spec, "/fake/bin");
1003 let stream = [
1004 r#"{"type":"thread.started","thread_id":"t1"}"#,
1005 r#"{"type":"item.completed","item":{"type":"command_execution","text":"ls"}}"#,
1006 r#"{"type":"item.completed","item":{"type":"agent_message","text":"the answer"}}"#,
1007 "not json at all",
1008 ]
1009 .join("\n");
1010 assert_eq!("the answer", a.extract(&stream).unwrap());
1011 }
1012
1013 #[test]
1014 fn jsonl_raises_on_an_error_with_no_message() {
1015 let mut spec = spec(vec![one("x")]);
1016 spec.output = OutputMode::Jsonl;
1017 spec.message_path = Some("item.text".into());
1018 spec.message_match = BTreeMap::from([("type".into(), "item.completed".into())]);
1019 let a = Agent::with_bin(spec, "/fake/bin");
1020 assert!(a
1021 .extract(r#"{"type":"turn.failed","error":"boom"}"#)
1022 .is_err());
1023 }
1024
1025 #[test]
1026 fn jsonl_joins_several_agent_messages() {
1027 let mut spec = spec(vec![one("x")]);
1028 spec.output = OutputMode::Jsonl;
1029 spec.message_path = Some("text".into());
1030 spec.message_match = BTreeMap::from([("type".into(), "msg".into())]);
1031 let a = Agent::with_bin(spec, "/fake/bin");
1032 let stream = "{\"type\":\"msg\",\"text\":\"one\"}\n{\"type\":\"msg\",\"text\":\"two\"}";
1033 assert_eq!("one\ntwo", a.extract(stream).unwrap());
1034 }
1035
1036 #[test]
1037 fn dig_walks_a_dotted_path() {
1038 let v: Value = serde_json::from_str(r#"{"a":{"b":{"c":1}}}"#).unwrap();
1039 assert_eq!(Some(&Value::from(1)), dig(&v, "a.b.c"));
1040 assert_eq!(None, dig(&v, "a.b.missing"));
1041 assert_eq!(None, dig(&v, ""));
1042 }
1043
1044 #[test]
1047 fn a_missing_binary_lists_everywhere_it_looked() {
1048 let mut s = spec(vec![one("definitely-not-installed-xyz")]);
1049 s.search_paths = vec!["/nowhere/at/all".into()];
1050 s.name = "codex".into();
1051 let err = Agent::new(s).resolve_bin().unwrap_err().to_string();
1052 assert!(err.contains("definitely-not-installed-xyz (PATH)"), "{err}");
1053 assert!(
1054 err.contains("/nowhere/at/all/definitely-not-installed-xyz"),
1055 "{err}"
1056 );
1057 assert!(err.contains("SPAR_CODEX_BIN"), "{err}");
1058 }
1059
1060 #[test]
1061 fn a_search_path_that_already_names_the_binary_is_used_as_is() {
1062 let dir = std::env::temp_dir().join(format!("spar-test-{}", std::process::id()));
1063 std::fs::create_dir_all(&dir).unwrap();
1064 let bin = dir.join("mytool");
1065 std::fs::write(&bin, "#!/bin/sh\n").unwrap();
1066 #[cfg(unix)]
1067 {
1068 use std::os::unix::fs::PermissionsExt;
1069 std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
1070 }
1071 let mut s = spec(vec![one("mytool")]);
1072 s.search_paths = vec![bin.display().to_string()];
1073 assert_eq!(bin, Agent::new(s).resolve_bin().unwrap());
1074 let _ = std::fs::remove_dir_all(&dir);
1075 }
1076
1077 fn named(name: &str, bin: &str, model: Option<&str>) -> Agent {
1080 let mut s = spec(vec![one("prog")]);
1081 s.name = name.into();
1082 s.model = model.map(str::to_string);
1083 Agent::with_bin(s, bin)
1084 }
1085
1086 #[test]
1087 fn same_bin_same_model_warns() {
1088 let agents = vec![
1089 named("alpha", "/usr/local/bin/claude", Some("fable")),
1090 named("beta", "/usr/local/bin/claude", Some("fable")),
1091 ];
1092 let msg = correlation_warning(&agents).expect("should warn");
1093 assert!(msg.contains("alpha") && msg.contains("beta"), "{msg}");
1094 }
1095
1096 #[test]
1097 fn different_model_does_not_warn() {
1098 let agents = vec![
1099 named("a", "/usr/local/bin/claude", Some("fable")),
1100 named("b", "/usr/local/bin/claude", Some("opus")),
1101 ];
1102 assert!(correlation_warning(&agents).is_none());
1103 }
1104
1105 #[test]
1106 fn different_bin_does_not_warn() {
1107 let agents = vec![
1108 named("a", "/usr/local/bin/claude", Some("fable")),
1109 named("b", "/usr/local/bin/codex", Some("fable")),
1110 ];
1111 assert!(correlation_warning(&agents).is_none());
1112 }
1113
1114 #[test]
1115 fn unset_and_empty_model_both_mean_the_default_and_warn() {
1116 let agents = vec![
1117 named("a", "/usr/local/bin/claude", None),
1118 named("b", "/usr/local/bin/claude", Some("")),
1119 ];
1120 let msg = correlation_warning(&agents).expect("should warn");
1121 assert!(msg.contains("the CLI's default"), "{msg}");
1122 }
1123
1124 #[test]
1125 fn a_padded_model_still_warns() {
1126 let agents = vec![
1127 named("a", "/usr/local/bin/claude", Some("fable")),
1128 named("b", "/usr/local/bin/claude", Some(" fable ")),
1129 ];
1130 assert!(correlation_warning(&agents).is_some());
1131 }
1132
1133 #[test]
1134 fn an_empty_model_against_a_named_one_does_not_warn() {
1135 let agents = vec![
1136 named("a", "/usr/local/bin/claude", Some("")),
1137 named("b", "/usr/local/bin/claude", Some("fable")),
1138 ];
1139 assert!(correlation_warning(&agents).is_none());
1140 }
1141
1142 #[cfg(unix)]
1143 #[test]
1144 fn a_symlinked_binary_warns_and_names_both_paths() {
1145 use std::os::unix::fs::PermissionsExt;
1146 let dir = std::env::temp_dir().join(format!("spar-link-{}", std::process::id()));
1147 let _ = std::fs::remove_dir_all(&dir);
1148 std::fs::create_dir_all(&dir).unwrap();
1149 let real = dir.join("claude");
1150 let link = dir.join("claude-alias");
1151 std::fs::write(&real, "#!/bin/sh\n").unwrap();
1152 std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o755)).unwrap();
1153 std::os::unix::fs::symlink(&real, &link).unwrap();
1154
1155 let agents = vec![
1156 named("alpha", real.to_str().unwrap(), Some("fable")),
1157 named("beta", link.to_str().unwrap(), Some("fable")),
1158 ];
1159 let msg = correlation_warning(&agents).expect("should warn");
1160 assert!(msg.contains(real.to_str().unwrap()), "{msg}");
1161 assert!(msg.contains(link.to_str().unwrap()), "{msg}");
1162 let _ = std::fs::remove_dir_all(&dir);
1163 }
1164
1165 #[cfg(unix)]
1166 #[test]
1167 fn two_distinct_real_binaries_stay_quiet() {
1168 use std::os::unix::fs::PermissionsExt;
1169 let dir = std::env::temp_dir().join(format!("spar-distinct-{}", std::process::id()));
1170 let _ = std::fs::remove_dir_all(&dir);
1171 std::fs::create_dir_all(&dir).unwrap();
1172 let mut paths = Vec::new();
1173 for name in ["claude", "codex"] {
1174 let path = dir.join(name);
1175 std::fs::write(&path, "#!/bin/sh\n").unwrap();
1176 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
1177 paths.push(path);
1178 }
1179 let agents = vec![
1180 named("a", paths[0].to_str().unwrap(), Some("fable")),
1181 named("b", paths[1].to_str().unwrap(), Some("fable")),
1182 ];
1183 assert!(correlation_warning(&agents).is_none());
1184 let _ = std::fs::remove_dir_all(&dir);
1185 }
1186
1187 #[test]
1188 fn the_style_rules_ask_for_brevity_and_no_attribution() {
1189 let lower = STYLE_RULES.to_lowercase();
1190 assert!(lower.contains("brief"));
1191 assert!(lower.contains("co-authored-by"));
1192 assert!(lower.contains("em-dash"));
1193 }
1194
1195 #[test]
1200 fn brevity_is_about_facts_per_sentence_not_sentence_count() {
1201 let lower = STYLE_RULES.to_lowercase();
1202 assert!(lower.contains("saying fewer things"), "{STYLE_RULES}");
1203 assert!(
1204 !lower.contains("one sentence beats one paragraph"),
1205 "the rule that produced the density is still there"
1206 );
1207 }
1208
1209 #[test]
1214 fn the_style_rules_reach_the_code_and_not_only_what_is_posted() {
1215 let lower = STYLE_RULES.to_lowercase();
1216 assert!(lower.contains("comments in code you write"), "not in scope");
1217 assert!(
1218 lower.contains("comment code for the reason"),
1219 "no rule for it"
1220 );
1221 }
1222
1223 fn refusal_stream() -> String {
1228 let noise = "{\"type\":\"item.completed\",\"item\":{\"id\":\"i\",\"type\":\"command_execution\",\"output\":\"".to_string()
1229 + &"const x = 1;\\n".repeat(200)
1230 + "\"}}";
1231 [
1232 noise.as_str(),
1233 r#"{"type":"error","message":"This content was flagged for possible cybersecurity risk."}"#,
1234 r#"{"type":"error","message":"This content was flagged for possible cybersecurity risk."}"#,
1235 r#"{"type":"turn.failed","error":{"message":"This content was flagged for possible cybersecurity risk."}}"#,
1236 ]
1237 .join("\n")
1238 }
1239
1240 fn jsonl_agent(name: &str) -> Agent {
1241 let mut spec = spec(vec![one("codex")]);
1242 spec.name = name.into();
1243 spec.output = OutputMode::Jsonl;
1244 spec.message_path = Some("item.text".into());
1245 Agent::with_bin(spec, "/fake/codex")
1246 }
1247
1248 fn failed(stdout: &str, stderr: &str) -> proc::Output {
1249 proc::Output {
1250 stdout: stdout.to_string(),
1251 stderr: stderr.to_string(),
1252 code: 1,
1253 }
1254 }
1255
1256 #[test]
1259 fn a_jsonl_failure_reports_the_reason_and_not_the_stream() {
1260 let agent = jsonl_agent("codex");
1261 let err = agent.call_failure(&["codex".to_string()], &failed(&refusal_stream(), ""));
1262 let text = err.message();
1263 assert!(
1264 text.contains("flagged for possible cybersecurity risk"),
1265 "{text}"
1266 );
1267 assert!(
1268 !text.contains("const x = 1;"),
1269 "the stream leaked in:\n{text}"
1270 );
1271 assert!(text.len() < 400, "still {} characters:\n{text}", text.len());
1272 }
1273
1274 #[test]
1276 fn the_same_reason_reported_three_times_is_said_once() {
1277 let agent = jsonl_agent("codex");
1278 let err = agent.call_failure(&["codex".to_string()], &failed(&refusal_stream(), ""));
1279 assert_eq!(
1280 1,
1281 err.message().matches("flagged for possible").count(),
1282 "{}",
1283 err.message()
1284 );
1285 }
1286
1287 #[test]
1290 fn stderr_is_kept_because_it_is_where_the_other_half_arrives() {
1291 let agent = jsonl_agent("codex");
1292 let err = agent.call_failure(
1293 &["codex".to_string()],
1294 &failed(
1295 &refusal_stream(),
1296 "ERROR router: agent thread limit reached",
1297 ),
1298 );
1299 assert!(
1300 err.message().contains("agent thread limit reached"),
1301 "{}",
1302 err.message()
1303 );
1304 }
1305
1306 #[test]
1309 fn a_stream_with_no_error_event_falls_back_to_the_raw_output() {
1310 let agent = jsonl_agent("codex");
1311 let err = agent.call_failure(
1312 &["codex".to_string()],
1313 &failed("{\"type\":\"system\"}", "segmentation fault"),
1314 );
1315 assert!(
1316 err.message().contains("segmentation fault"),
1317 "{}",
1318 err.message()
1319 );
1320 assert!(
1321 err.message().starts_with("command failed"),
1322 "{}",
1323 err.message()
1324 );
1325 }
1326
1327 #[test]
1329 fn a_text_agent_is_reported_exactly_as_before() {
1330 let agent = agent(vec![one("mytool")]);
1331 let err = agent.call_failure(&["mytool".to_string()], &failed("some prose", "boom"));
1332 assert!(
1333 err.message().starts_with("command failed"),
1334 "{}",
1335 err.message()
1336 );
1337 assert!(err.message().contains("some prose"), "{}", err.message());
1338 }
1339
1340 #[test]
1343 fn a_reworded_failure_is_still_a_failed_call() {
1344 let agent = jsonl_agent("codex");
1345 let err = agent.call_failure(&["codex".to_string()], &failed(&refusal_stream(), ""));
1346 assert_eq!(ErrorKind::CallFailed, err.kind());
1347 }
1348
1349 #[test]
1352 fn a_request_carries_the_instructions_after_the_task() {
1353 let agent = Agent::with_bin(shell("a", "true"), "/bin/sh")
1354 .with_instructions("Do not wait for CI. Pick it up next pass.");
1355 let asked = agent.instructed("Review the changes on this branch.");
1356 assert!(
1357 asked.starts_with("Review the changes on this branch."),
1358 "{asked}"
1359 );
1360 assert!(asked.contains("Do not wait for CI"), "{asked}");
1361 }
1362
1363 #[test]
1367 fn the_instructions_arrive_subordinate_to_the_request() {
1368 let agent = Agent::with_bin(shell("a", "true"), "/bin/sh").with_instructions("Be quick.");
1369 let asked = agent.instructed("Do the work.").to_lowercase();
1370 assert!(
1371 asked.contains("from the person who started this run"),
1372 "{asked}"
1373 );
1374 assert!(asked.contains("not the shape of your answer"), "{asked}");
1375 }
1376
1377 #[test]
1378 fn nothing_is_added_when_there_are_none() {
1379 let agent = Agent::with_bin(shell("a", "true"), "/bin/sh");
1380 assert_eq!("Do the work.", agent.instructed("Do the work."));
1381 let blank = Agent::with_bin(shell("b", "true"), "/bin/sh").with_instructions(" \n ");
1383 assert_eq!("Do the work.", blank.instructed("Do the work."));
1384 }
1385
1386 #[test]
1389 fn the_stand_in_carries_them_too() {
1390 let agent = with_fallback(shell("primary", "true"), shell("backup", "true"))
1391 .with_instructions("Do not wait for CI.");
1392 let backup = agent.fallback().expect("a stand in");
1393 assert!(backup
1394 .instructed("Do the work.")
1395 .contains("Do not wait for CI."));
1396 }
1397
1398 fn shell(name: &str, line: &str) -> AgentSpec {
1403 let mut spec = spec(vec![one("sh"), one("-c"), one(line)]);
1404 spec.name = name.into();
1405 spec
1406 }
1407
1408 fn with_fallback(mut primary: AgentSpec, backup: AgentSpec) -> Agent {
1409 primary.fallback = Some(Box::new(backup));
1410 Agent::with_bin(primary, "/bin/sh")
1411 }
1412
1413 #[test]
1414 fn a_failed_call_is_answered_by_the_fallback() {
1415 let agent = with_fallback(
1416 shell("primary", "echo refused >&2; exit 1"),
1417 shell("backup", "echo stood in"),
1418 );
1419 let answer = agent.ask("hi", Path::new("."), None).expect("fallback");
1420 assert_eq!("stood in", answer);
1421 }
1422
1423 #[test]
1424 fn without_a_fallback_the_original_error_is_what_surfaces() {
1425 let agent = Agent::with_bin(shell("primary", "echo refused >&2; exit 1"), "/bin/sh");
1426 let err = agent
1427 .ask("hi", Path::new("."), None)
1428 .expect_err("no backup");
1429 assert!(err.message().contains("refused"), "{err}");
1430 }
1431
1432 #[test]
1435 fn both_failing_reports_the_primary_first() {
1436 let agent = with_fallback(
1437 shell("primary", "echo policy refusal >&2; exit 1"),
1438 shell("backup", "echo out of quota >&2; exit 1"),
1439 );
1440 let err = agent
1441 .ask("hi", Path::new("."), None)
1442 .expect_err("both fail");
1443 let text = err.message();
1444 let primary_at = text.find("policy refusal").expect("primary reason");
1445 let backup_at = text.find("out of quota").expect("backup reason");
1446 assert!(primary_at < backup_at, "{text}");
1447 assert!(
1448 text.contains("primary") && text.contains("backup"),
1449 "{text}"
1450 );
1451 }
1452
1453 fn attempts(name: &str) -> (PathBuf, String) {
1456 let path = std::env::temp_dir().join(format!("spar-attempts-{name}"));
1457 let _ = std::fs::remove_file(&path);
1458 let line = format!("echo x >> {}", path.display());
1459 (path, line)
1460 }
1461
1462 fn counted(path: &Path) -> usize {
1463 std::fs::read_to_string(path)
1464 .map(|t| t.lines().count())
1465 .unwrap_or(0)
1466 }
1467
1468 #[test]
1473 fn a_cli_that_could_not_answer_is_not_asked_twice_when_there_is_a_stand_in() {
1474 let (path, count) = attempts("refused-with-standin");
1475 let agent = with_fallback(
1476 shell("primary", &format!("{count}; echo refused >&2; exit 1")),
1477 shell("backup", "echo '{}'"),
1478 );
1479 let answer: Value = agent
1480 .ask_json(
1481 "q",
1482 &serde_json::json!({"type": "object"}),
1483 Path::new("."),
1484 None,
1485 )
1486 .expect("the stand in answers");
1487 assert!(answer.is_object());
1488 assert_eq!(1, counted(&path), "the primary was asked more than once");
1489 }
1490
1491 #[test]
1494 fn with_no_stand_in_a_failed_call_is_still_retried() {
1495 let (path, count) = attempts("refused-alone");
1496 let agent = Agent::with_bin(
1497 shell("solo", &format!("{count}; echo refused >&2; exit 1")),
1498 "/bin/sh",
1499 );
1500 let err = agent
1501 .ask_json::<Value>(
1502 "q",
1503 &serde_json::json!({"type": "object"}),
1504 Path::new("."),
1505 None,
1506 )
1507 .expect_err("nothing answers");
1508 assert!(err.message().contains("twice"), "{err}");
1509 assert_eq!(2, counted(&path));
1510 }
1511
1512 #[test]
1516 fn an_unusable_answer_is_still_worth_asking_again() {
1517 let (path, count) = attempts("unparsable");
1518 let agent = with_fallback(
1519 shell("primary", &format!("{count}; echo not json at all")),
1520 shell("backup", "echo '{}'"),
1521 );
1522 let answer: Value = agent
1523 .ask_json(
1524 "q",
1525 &serde_json::json!({"type": "object"}),
1526 Path::new("."),
1527 None,
1528 )
1529 .expect("the stand in answers in the end");
1530 assert!(answer.is_object());
1531 assert_eq!(2, counted(&path), "a shape error is worth one more ask");
1532 }
1533
1534 #[test]
1538 fn a_timeout_still_reaches_the_fallback() {
1539 let mut primary = shell("primary", "sleep 30");
1540 primary.timeout = 1;
1541 let agent = with_fallback(primary, shell("backup", "echo stood in"));
1542 assert_eq!(
1543 "stood in",
1544 agent.ask("hi", Path::new("."), None).expect("fallback")
1545 );
1546 }
1547
1548 #[test]
1551 fn the_fallback_is_built_alongside_the_agent() {
1552 let mut primary = shell("primary", "true");
1553 primary.fallback = Some(Box::new(shell("backup", "true")));
1554 let agent = Agent::new(primary);
1555 assert_eq!(Some("backup"), agent.fallback().map(Agent::name));
1556 assert!(Agent::new(shell("solo", "true")).fallback().is_none());
1557 }
1558}
1559
1560#[cfg(test)]
1561mod schema_placeholder_tests {
1562 use super::*;
1563 use crate::config::{OutputMode, SystemVia};
1564
1565 fn spec_with(command: Vec<CommandPart>) -> AgentSpec {
1566 AgentSpec {
1567 name: "claude".into(),
1568 command,
1569 model: None,
1570 effort: None,
1571 output: OutputMode::Text,
1572 message_match: BTreeMap::new(),
1573 message_path: None,
1574 search_paths: vec![],
1575 system_via: SystemVia::Prompt,
1576 timeout: 60,
1577 fallback: None,
1578 models: vec![],
1579 efforts: vec![],
1580 options_note: None,
1581 }
1582 }
1583
1584 fn one(s: &str) -> CommandPart {
1585 CommandPart::One(s.into())
1586 }
1587 fn group(parts: &[&str]) -> CommandPart {
1588 CommandPart::Group(parts.iter().map(|s| s.to_string()).collect())
1589 }
1590
1591 #[test]
1594 fn either_schema_form_counts_as_native_support() {
1595 let inline = Agent::with_bin(
1596 spec_with(vec![one("x"), group(&["--json-schema", "{schema}"])]),
1597 "/b",
1598 );
1599 let byfile = Agent::with_bin(
1600 spec_with(vec![one("x"), group(&["--output-schema", "{schema_file}"])]),
1601 "/b",
1602 );
1603 let neither = Agent::with_bin(spec_with(vec![one("x"), one("{prompt}")]), "/b");
1604 assert!(inline.supports_schema());
1605 assert!(byfile.supports_schema());
1606 assert!(!neither.supports_schema());
1607 }
1608
1609 #[test]
1610 fn the_inline_schema_is_substituted_whole() {
1611 let agent = Agent::with_bin(
1612 spec_with(vec![
1613 one("x"),
1614 group(&["--json-schema", "{schema}"]),
1615 one("{prompt}"),
1616 ]),
1617 "/b",
1618 );
1619 let values = Placeholders {
1620 prompt: Some("review it".into()),
1621 schema: Some(r#"{"type":"object"}"#.into()),
1622 ..Default::default()
1623 };
1624 assert_eq!(
1625 vec!["/b", "--json-schema", r#"{"type":"object"}"#, "review it"],
1626 agent.render(&values).unwrap()
1627 );
1628 }
1629
1630 #[test]
1633 fn the_schema_flag_drops_when_no_schema_is_wanted() {
1634 let agent = Agent::with_bin(
1635 spec_with(vec![
1636 one("x"),
1637 group(&["--json-schema", "{schema}"]),
1638 one("{prompt}"),
1639 ]),
1640 "/b",
1641 );
1642 let values = Placeholders {
1643 prompt: Some("implement it".into()),
1644 ..Default::default()
1645 };
1646 assert_eq!(vec!["/b", "implement it"], agent.render(&values).unwrap());
1647 }
1648
1649 #[test]
1651 fn the_two_schema_placeholders_do_not_collide() {
1652 let agent = Agent::with_bin(
1653 spec_with(vec![one("x"), group(&["--output-schema", "{schema_file}"])]),
1654 "/b",
1655 );
1656 let values = Placeholders {
1657 schema: Some("INLINE".into()),
1658 schema_file: Some("/tmp/s.json".into()),
1659 ..Default::default()
1660 };
1661 assert_eq!(
1662 vec!["/b", "--output-schema", "/tmp/s.json"],
1663 agent.render(&values).unwrap()
1664 );
1665 }
1666
1667 #[test]
1669 fn the_shipped_claude_preset_now_has_native_structured_output() {
1670 let raw = crate::config::load_preset("claude").unwrap();
1671 let table = raw.as_table().cloned().unwrap();
1672 let mut spec: AgentSpec = toml::Value::Table(table).try_into().unwrap();
1673 spec.name = "claude".into();
1674 assert!(
1675 Agent::with_bin(spec, "/b").supports_schema(),
1676 "without this a long review is parsed out of prose and truncates"
1677 );
1678 }
1679}