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::UncertainWrite => false,
512 ErrorKind::CallFailed => self.fallback().is_none(),
513 ErrorKind::Other => true,
514 }
515 }
516
517 fn ask_json_retrying<T: serde::de::DeserializeOwned>(
519 &self,
520 prompt: &str,
521 schema: &Value,
522 cwd: &Path,
523 effort: Option<&str>,
524 ) -> Result<T> {
525 const ATTEMPTS: usize = 2;
531 let mut last: Option<SparError> = None;
532
533 for attempt in 1..=ATTEMPTS {
534 let asked = match &last {
535 None => prompt.to_string(),
536 Some(e) => format!(
537 "{prompt}\n\nYour previous answer could not be used: {}\nReturn the whole \
538 object this time, exactly matching the schema, and nothing else.",
539 e.first_line()
540 ),
541 };
542 match self.ask_json_once::<T>(&asked, schema, cwd, effort) {
543 Ok(parsed) => {
544 if attempt > 1 {
545 logdim!("{} answered on the retry", self.spec.name);
546 }
547 return Ok(parsed);
548 }
549 Err(e) if !self.worth_asking_again(&e) => return Err(e),
553 Err(e) => {
554 if attempt < ATTEMPTS {
555 logwarn!("{} failed, asking again.\n{e}", self.spec.name);
560 }
561 last = Some(e);
562 }
563 }
564 }
565 Err(spar_err!(
566 "agent '{}' returned an unusable answer twice: {}",
567 self.spec.name,
568 last.expect("at least one attempt").message()
569 ))
570 }
571
572 fn ask_json_once<T: serde::de::DeserializeOwned>(
573 &self,
574 prompt: &str,
575 schema: &Value,
576 cwd: &Path,
577 effort: Option<&str>,
578 ) -> Result<T> {
579 let text = if self.supports_schema() {
580 let inline = serde_json::to_string(schema).unwrap_or_default();
581 let file = TempJson::write(schema)?;
582 self.ask_inner(prompt, cwd, effort, Some(file.path()), Some(&inline))?
583 } else {
584 let full = format!(
585 "{prompt}\n\n{JSON_INSTRUCTION}\n{}",
586 serde_json::to_string_pretty(schema).unwrap_or_default()
587 );
588 self.ask_inner(&full, cwd, effort, None, None)?
589 };
590 jsonx::extract_into(&text)
591 }
592
593 pub fn review<T: serde::de::DeserializeOwned>(
607 &self,
608 base: &str,
609 prompt: &str,
610 schema: &Value,
611 cwd: &Path,
612 effort: Option<&str>,
613 ) -> Result<T> {
614 let scoped = format!(
615 "{prompt}\n\nThe changes under review are the diff between `{base}` and HEAD in your \
616 working directory. Inspect them with git, then read the surrounding code before \
617 judging. Do not review only the diff.\n\nThis call is a review and nothing else. Do \
618 not edit the code under review, do not commit, and do not push: somebody else acts \
619 on what you find, and a reviewer that writes ends up reviewing its own work. Writing \
620 a scratch file to check a claim is fine, and anything else you leave behind is rolled \
621 back."
622 );
623 self.ask_json(&scoped, schema, cwd, effort)
624 }
625}
626
627#[derive(Debug, Default, Clone)]
632pub struct Placeholders {
633 pub prompt: Option<String>,
634 pub system: Option<String>,
635 pub model: Option<String>,
636 pub effort: Option<String>,
637 pub cwd: Option<String>,
638 pub schema_file: Option<String>,
640 pub schema: Option<String>,
642}
643
644impl Placeholders {
645 fn get(&self, key: &str) -> Option<&str> {
646 let value = match key {
647 "prompt" => self.prompt.as_deref(),
648 "system" => self.system.as_deref(),
649 "model" => self.model.as_deref(),
650 "effort" => self.effort.as_deref(),
651 "cwd" => self.cwd.as_deref(),
652 "schema_file" => self.schema_file.as_deref(),
653 "schema" => self.schema.as_deref(),
654 _ => None,
655 };
656 value.filter(|v| !v.is_empty())
657 }
658
659 fn substitute(&self, arg: &str) -> Option<String> {
662 const KEYS: [&str; 7] = [
663 "prompt",
664 "system",
665 "model",
666 "effort",
667 "cwd",
668 "schema_file",
669 "schema",
670 ];
671 let mut out = arg.to_string();
672 for key in KEYS {
673 let token = format!("{{{key}}}");
674 if out.contains(&token) {
675 let value = self.get(key)?;
676 out = out.replace(&token, value);
677 }
678 }
679 Some(out)
680 }
681}
682
683fn dig<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
689 if path.is_empty() {
690 return None;
691 }
692 let mut node = value;
693 for part in path.split('.') {
694 node = node.as_object()?.get(part)?;
695 }
696 Some(node)
697}
698
699fn matches(event: &Value, wanted: &BTreeMap<String, String>) -> bool {
700 if wanted.is_empty() {
701 return false;
702 }
703 wanted
704 .iter()
705 .all(|(path, expected)| dig(event, path).and_then(Value::as_str) == Some(expected.as_str()))
706}
707
708fn as_text(value: &Value) -> Option<String> {
709 match value {
710 Value::String(s) => Some(s.clone()),
711 Value::Null => None,
712 other => Some(other.to_string()),
713 }
714}
715
716fn truncate(text: &str, max: usize) -> String {
717 text.chars().take(max).collect()
718}
719
720struct TempJson {
727 path: PathBuf,
728}
729
730impl TempJson {
731 fn write(value: &Value) -> Result<Self> {
732 use std::sync::atomic::{AtomicU64, Ordering};
733 static COUNTER: AtomicU64 = AtomicU64::new(0);
734
735 let nanos = std::time::SystemTime::now()
736 .duration_since(std::time::UNIX_EPOCH)
737 .map(|d| d.as_nanos())
738 .unwrap_or(0);
739 let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
740 let path = std::env::temp_dir().join(format!(
741 "spar-schema-{}-{nanos}-{unique}.json",
742 std::process::id()
743 ));
744 std::fs::write(&path, serde_json::to_vec_pretty(value)?)
745 .map_err(|e| spar_err!("could not write a schema file to {}: {e}", path.display()))?;
746 Ok(Self { path })
747 }
748
749 fn path(&self) -> &Path {
750 &self.path
751 }
752}
753
754impl Drop for TempJson {
755 fn drop(&mut self) {
756 let _ = std::fs::remove_file(&self.path);
757 }
758}
759
760fn same_executable(a: &Path, b: &Path) -> bool {
771 #[cfg(unix)]
772 {
773 use std::os::unix::fs::MetadataExt;
774 if let (Ok(x), Ok(y)) = (std::fs::metadata(a), std::fs::metadata(b)) {
775 return x.dev() == y.dev() && x.ino() == y.ino();
776 }
777 }
778 match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
779 (Ok(x), Ok(y)) => x == y,
780 _ => a == b,
781 }
782}
783
784pub fn correlation_warning(agents: &[Agent]) -> Option<String> {
790 for i in 0..agents.len() {
791 for j in (i + 1)..agents.len() {
792 let (a, b) = (&agents[i], &agents[j]);
793 let (Ok(pa), Ok(pb)) = (a.resolve_bin(), b.resolve_bin()) else {
794 continue;
795 };
796 if !same_executable(pa, pb) || a.spec.model_key() != b.spec.model_key() {
797 continue;
798 }
799 let model = if a.spec.model_key().is_empty() {
800 "the CLI's default".to_string()
801 } else {
802 a.spec.model_key()
803 };
804 let where_at = if pa == pb {
805 pa.display().to_string()
806 } else {
807 format!(
808 "the same executable ({} and {} are the same file)",
809 pa.display(),
810 pb.display()
811 )
812 };
813 return Some(format!(
814 "agents '{}' and '{}' both resolve to {where_at} at model {model}. Review \
815 findings will be correlated: the same model reviewing itself shares the blind \
816 spots of the model that wrote the code, so it is far less likely to catch what \
817 the implementer missed. That produces an approval indistinguishable from a real \
818 review, which is worse than no review at all. Give the two agents different \
819 CLIs or different models.",
820 a.name(),
821 b.name()
822 ));
823 }
824 }
825 None
826}
827
828pub fn build(cfg: &crate::config::Config) -> Result<Vec<Agent>> {
831 let agents: Vec<Agent> = cfg
832 .agents
833 .iter()
834 .cloned()
835 .map(Agent::new)
836 .map(|agent| agent.with_instructions(&cfg.loop_cfg.instructions))
837 .collect();
838 for agent in &agents {
839 agent.resolve_bin()?;
840 if let Some(backup) = agent.fallback() {
844 if backup.resolve_bin().is_err() {
845 logwarn!(
846 "{} has a fallback ({}) that is not installed, so it will not stand in",
847 agent.name(),
848 backup.program()
849 );
850 }
851 }
852 }
853 Ok(agents)
854}
855
856pub fn find<'a>(agents: &'a [Agent], name: &str) -> Result<&'a Agent> {
858 agents.iter().find(|a| a.name() == name).ok_or_else(|| {
859 SparError::new(format!(
860 "no agent named '{name}' ({})",
861 agents
862 .iter()
863 .map(Agent::name)
864 .collect::<Vec<_>>()
865 .join(", ")
866 ))
867 })
868}
869
870#[cfg(test)]
871mod tests {
872 use super::*;
873 use crate::config::{OutputMode, SystemVia};
874
875 fn spec(command: Vec<CommandPart>) -> AgentSpec {
876 AgentSpec {
877 name: "test".into(),
878 command,
879 model: None,
880 effort: None,
881 output: OutputMode::Text,
882 message_match: BTreeMap::new(),
883 message_path: None,
884 search_paths: vec![],
885 system_via: SystemVia::Prompt,
886 timeout: 60,
887 fallback: None,
888 models: vec![],
889 efforts: vec![],
890 options_note: None,
891 }
892 }
893
894 fn one(s: &str) -> CommandPart {
895 CommandPart::One(s.into())
896 }
897
898 fn group(parts: &[&str]) -> CommandPart {
899 CommandPart::Group(parts.iter().map(|s| s.to_string()).collect())
900 }
901
902 fn agent(command: Vec<CommandPart>) -> Agent {
903 Agent::with_bin(spec(command), "/fake/bin")
904 }
905
906 fn values() -> Placeholders {
907 Placeholders {
908 prompt: Some("hi".into()),
909 ..Default::default()
910 }
911 }
912
913 #[test]
916 fn placeholders_are_substituted() {
917 let a = agent(vec![one("x"), group(&["-m", "{model}"]), one("{prompt}")]);
918 let v = Placeholders {
919 model: Some("m1".into()),
920 ..values()
921 };
922 assert_eq!(vec!["/fake/bin", "-m", "m1", "hi"], a.render(&v).unwrap());
923 }
924
925 #[test]
926 fn an_unset_placeholder_drops_the_whole_group() {
927 let a = agent(vec![one("x"), group(&["-m", "{model}"]), one("{prompt}")]);
928 assert_eq!(vec!["/fake/bin", "hi"], a.render(&values()).unwrap());
929 }
930
931 #[test]
932 fn an_empty_string_drops_the_group_too() {
933 let a = agent(vec![one("x"), group(&["-e", "{effort}"]), one("{prompt}")]);
934 let v = Placeholders {
935 effort: Some(String::new()),
936 ..values()
937 };
938 assert_eq!(vec!["/fake/bin", "hi"], a.render(&v).unwrap());
939 }
940
941 #[test]
942 fn a_bare_arg_with_an_unset_placeholder_drops() {
943 let a = agent(vec![one("x"), one("{model}"), one("{prompt}")]);
944 assert_eq!(vec!["/fake/bin", "hi"], a.render(&values()).unwrap());
945 }
946
947 #[test]
948 fn literal_args_survive() {
949 let a = agent(vec![
950 one("x"),
951 one("exec"),
952 one("--json"),
953 one("--"),
954 one("{prompt}"),
955 ]);
956 assert_eq!(
957 vec!["/fake/bin", "exec", "--json", "--", "hi"],
958 a.render(&values()).unwrap()
959 );
960 }
961
962 #[test]
963 fn an_embedded_placeholder_substitutes_in_place() {
964 let a = agent(vec![
965 one("x"),
966 group(&["-c", "model_reasoning_effort={effort}"]),
967 ]);
968 let v = Placeholders {
969 effort: Some("ultra".into()),
970 ..Default::default()
971 };
972 assert_eq!(
973 vec!["/fake/bin", "-c", "model_reasoning_effort=ultra"],
974 a.render(&v).unwrap()
975 );
976 }
977
978 #[test]
979 fn a_group_with_two_placeholders_needs_both() {
980 let a = agent(vec![
981 one("x"),
982 group(&["--a", "{model}", "--b", "{effort}"]),
983 ]);
984 let v = Placeholders {
985 model: Some("m".into()),
986 ..Default::default()
987 };
988 assert_eq!(vec!["/fake/bin"], a.render(&v).unwrap());
989 }
990
991 #[test]
992 fn supports_schema_detects_the_placeholder() {
993 assert!(agent(vec![one("x"), group(&["--schema", "{schema_file}"])]).supports_schema());
994 assert!(!agent(vec![one("x"), one("{prompt}")]).supports_schema());
995 }
996
997 #[test]
1000 fn text_passes_through_trimmed() {
1001 assert_eq!("hello", agent(vec![one("x")]).extract(" hello\n").unwrap());
1002 }
1003
1004 #[test]
1005 fn jsonl_picks_the_matching_event() {
1006 let mut spec = spec(vec![one("x")]);
1007 spec.output = OutputMode::Jsonl;
1008 spec.message_path = Some("item.text".into());
1009 spec.message_match = BTreeMap::from([
1010 ("type".to_string(), "item.completed".to_string()),
1011 ("item.type".to_string(), "agent_message".to_string()),
1012 ]);
1013 let a = Agent::with_bin(spec, "/fake/bin");
1014 let stream = [
1015 r#"{"type":"thread.started","thread_id":"t1"}"#,
1016 r#"{"type":"item.completed","item":{"type":"command_execution","text":"ls"}}"#,
1017 r#"{"type":"item.completed","item":{"type":"agent_message","text":"the answer"}}"#,
1018 "not json at all",
1019 ]
1020 .join("\n");
1021 assert_eq!("the answer", a.extract(&stream).unwrap());
1022 }
1023
1024 #[test]
1025 fn jsonl_raises_on_an_error_with_no_message() {
1026 let mut spec = spec(vec![one("x")]);
1027 spec.output = OutputMode::Jsonl;
1028 spec.message_path = Some("item.text".into());
1029 spec.message_match = BTreeMap::from([("type".into(), "item.completed".into())]);
1030 let a = Agent::with_bin(spec, "/fake/bin");
1031 assert!(a
1032 .extract(r#"{"type":"turn.failed","error":"boom"}"#)
1033 .is_err());
1034 }
1035
1036 #[test]
1037 fn jsonl_joins_several_agent_messages() {
1038 let mut spec = spec(vec![one("x")]);
1039 spec.output = OutputMode::Jsonl;
1040 spec.message_path = Some("text".into());
1041 spec.message_match = BTreeMap::from([("type".into(), "msg".into())]);
1042 let a = Agent::with_bin(spec, "/fake/bin");
1043 let stream = "{\"type\":\"msg\",\"text\":\"one\"}\n{\"type\":\"msg\",\"text\":\"two\"}";
1044 assert_eq!("one\ntwo", a.extract(stream).unwrap());
1045 }
1046
1047 #[test]
1048 fn dig_walks_a_dotted_path() {
1049 let v: Value = serde_json::from_str(r#"{"a":{"b":{"c":1}}}"#).unwrap();
1050 assert_eq!(Some(&Value::from(1)), dig(&v, "a.b.c"));
1051 assert_eq!(None, dig(&v, "a.b.missing"));
1052 assert_eq!(None, dig(&v, ""));
1053 }
1054
1055 #[test]
1058 fn a_missing_binary_lists_everywhere_it_looked() {
1059 let mut s = spec(vec![one("definitely-not-installed-xyz")]);
1060 s.search_paths = vec!["/nowhere/at/all".into()];
1061 s.name = "codex".into();
1062 let err = Agent::new(s).resolve_bin().unwrap_err().to_string();
1063 assert!(err.contains("definitely-not-installed-xyz (PATH)"), "{err}");
1064 assert!(
1065 err.contains("/nowhere/at/all/definitely-not-installed-xyz"),
1066 "{err}"
1067 );
1068 assert!(err.contains("SPAR_CODEX_BIN"), "{err}");
1069 }
1070
1071 #[test]
1072 fn a_search_path_that_already_names_the_binary_is_used_as_is() {
1073 let dir = std::env::temp_dir().join(format!("spar-test-{}", std::process::id()));
1074 std::fs::create_dir_all(&dir).unwrap();
1075 let bin = dir.join("mytool");
1076 std::fs::write(&bin, "#!/bin/sh\n").unwrap();
1077 #[cfg(unix)]
1078 {
1079 use std::os::unix::fs::PermissionsExt;
1080 std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
1081 }
1082 let mut s = spec(vec![one("mytool")]);
1083 s.search_paths = vec![bin.display().to_string()];
1084 assert_eq!(bin, Agent::new(s).resolve_bin().unwrap());
1085 let _ = std::fs::remove_dir_all(&dir);
1086 }
1087
1088 fn named(name: &str, bin: &str, model: Option<&str>) -> Agent {
1091 let mut s = spec(vec![one("prog")]);
1092 s.name = name.into();
1093 s.model = model.map(str::to_string);
1094 Agent::with_bin(s, bin)
1095 }
1096
1097 #[test]
1098 fn same_bin_same_model_warns() {
1099 let agents = vec![
1100 named("alpha", "/usr/local/bin/claude", Some("fable")),
1101 named("beta", "/usr/local/bin/claude", Some("fable")),
1102 ];
1103 let msg = correlation_warning(&agents).expect("should warn");
1104 assert!(msg.contains("alpha") && msg.contains("beta"), "{msg}");
1105 }
1106
1107 #[test]
1108 fn different_model_does_not_warn() {
1109 let agents = vec![
1110 named("a", "/usr/local/bin/claude", Some("fable")),
1111 named("b", "/usr/local/bin/claude", Some("opus")),
1112 ];
1113 assert!(correlation_warning(&agents).is_none());
1114 }
1115
1116 #[test]
1117 fn different_bin_does_not_warn() {
1118 let agents = vec![
1119 named("a", "/usr/local/bin/claude", Some("fable")),
1120 named("b", "/usr/local/bin/codex", Some("fable")),
1121 ];
1122 assert!(correlation_warning(&agents).is_none());
1123 }
1124
1125 #[test]
1126 fn unset_and_empty_model_both_mean_the_default_and_warn() {
1127 let agents = vec![
1128 named("a", "/usr/local/bin/claude", None),
1129 named("b", "/usr/local/bin/claude", Some("")),
1130 ];
1131 let msg = correlation_warning(&agents).expect("should warn");
1132 assert!(msg.contains("the CLI's default"), "{msg}");
1133 }
1134
1135 #[test]
1136 fn a_padded_model_still_warns() {
1137 let agents = vec![
1138 named("a", "/usr/local/bin/claude", Some("fable")),
1139 named("b", "/usr/local/bin/claude", Some(" fable ")),
1140 ];
1141 assert!(correlation_warning(&agents).is_some());
1142 }
1143
1144 #[test]
1145 fn an_empty_model_against_a_named_one_does_not_warn() {
1146 let agents = vec![
1147 named("a", "/usr/local/bin/claude", Some("")),
1148 named("b", "/usr/local/bin/claude", Some("fable")),
1149 ];
1150 assert!(correlation_warning(&agents).is_none());
1151 }
1152
1153 #[cfg(unix)]
1154 #[test]
1155 fn a_symlinked_binary_warns_and_names_both_paths() {
1156 use std::os::unix::fs::PermissionsExt;
1157 let dir = std::env::temp_dir().join(format!("spar-link-{}", std::process::id()));
1158 let _ = std::fs::remove_dir_all(&dir);
1159 std::fs::create_dir_all(&dir).unwrap();
1160 let real = dir.join("claude");
1161 let link = dir.join("claude-alias");
1162 std::fs::write(&real, "#!/bin/sh\n").unwrap();
1163 std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o755)).unwrap();
1164 std::os::unix::fs::symlink(&real, &link).unwrap();
1165
1166 let agents = vec![
1167 named("alpha", real.to_str().unwrap(), Some("fable")),
1168 named("beta", link.to_str().unwrap(), Some("fable")),
1169 ];
1170 let msg = correlation_warning(&agents).expect("should warn");
1171 assert!(msg.contains(real.to_str().unwrap()), "{msg}");
1172 assert!(msg.contains(link.to_str().unwrap()), "{msg}");
1173 let _ = std::fs::remove_dir_all(&dir);
1174 }
1175
1176 #[cfg(unix)]
1177 #[test]
1178 fn two_distinct_real_binaries_stay_quiet() {
1179 use std::os::unix::fs::PermissionsExt;
1180 let dir = std::env::temp_dir().join(format!("spar-distinct-{}", std::process::id()));
1181 let _ = std::fs::remove_dir_all(&dir);
1182 std::fs::create_dir_all(&dir).unwrap();
1183 let mut paths = Vec::new();
1184 for name in ["claude", "codex"] {
1185 let path = dir.join(name);
1186 std::fs::write(&path, "#!/bin/sh\n").unwrap();
1187 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
1188 paths.push(path);
1189 }
1190 let agents = vec![
1191 named("a", paths[0].to_str().unwrap(), Some("fable")),
1192 named("b", paths[1].to_str().unwrap(), Some("fable")),
1193 ];
1194 assert!(correlation_warning(&agents).is_none());
1195 let _ = std::fs::remove_dir_all(&dir);
1196 }
1197
1198 #[test]
1199 fn the_style_rules_ask_for_brevity_and_no_attribution() {
1200 let lower = STYLE_RULES.to_lowercase();
1201 assert!(lower.contains("brief"));
1202 assert!(lower.contains("co-authored-by"));
1203 assert!(lower.contains("em-dash"));
1204 }
1205
1206 #[test]
1211 fn brevity_is_about_facts_per_sentence_not_sentence_count() {
1212 let lower = STYLE_RULES.to_lowercase();
1213 assert!(lower.contains("saying fewer things"), "{STYLE_RULES}");
1214 assert!(
1215 !lower.contains("one sentence beats one paragraph"),
1216 "the rule that produced the density is still there"
1217 );
1218 }
1219
1220 #[test]
1225 fn the_style_rules_reach_the_code_and_not_only_what_is_posted() {
1226 let lower = STYLE_RULES.to_lowercase();
1227 assert!(lower.contains("comments in code you write"), "not in scope");
1228 assert!(
1229 lower.contains("comment code for the reason"),
1230 "no rule for it"
1231 );
1232 }
1233
1234 fn refusal_stream() -> String {
1239 let noise = "{\"type\":\"item.completed\",\"item\":{\"id\":\"i\",\"type\":\"command_execution\",\"output\":\"".to_string()
1240 + &"const x = 1;\\n".repeat(200)
1241 + "\"}}";
1242 [
1243 noise.as_str(),
1244 r#"{"type":"error","message":"This content was flagged for possible cybersecurity risk."}"#,
1245 r#"{"type":"error","message":"This content was flagged for possible cybersecurity risk."}"#,
1246 r#"{"type":"turn.failed","error":{"message":"This content was flagged for possible cybersecurity risk."}}"#,
1247 ]
1248 .join("\n")
1249 }
1250
1251 fn jsonl_agent(name: &str) -> Agent {
1252 let mut spec = spec(vec![one("codex")]);
1253 spec.name = name.into();
1254 spec.output = OutputMode::Jsonl;
1255 spec.message_path = Some("item.text".into());
1256 Agent::with_bin(spec, "/fake/codex")
1257 }
1258
1259 fn failed(stdout: &str, stderr: &str) -> proc::Output {
1260 proc::Output {
1261 stdout: stdout.to_string(),
1262 stderr: stderr.to_string(),
1263 code: 1,
1264 }
1265 }
1266
1267 #[test]
1270 fn a_jsonl_failure_reports_the_reason_and_not_the_stream() {
1271 let agent = jsonl_agent("codex");
1272 let err = agent.call_failure(&["codex".to_string()], &failed(&refusal_stream(), ""));
1273 let text = err.message();
1274 assert!(
1275 text.contains("flagged for possible cybersecurity risk"),
1276 "{text}"
1277 );
1278 assert!(
1279 !text.contains("const x = 1;"),
1280 "the stream leaked in:\n{text}"
1281 );
1282 assert!(text.len() < 400, "still {} characters:\n{text}", text.len());
1283 }
1284
1285 #[test]
1287 fn the_same_reason_reported_three_times_is_said_once() {
1288 let agent = jsonl_agent("codex");
1289 let err = agent.call_failure(&["codex".to_string()], &failed(&refusal_stream(), ""));
1290 assert_eq!(
1291 1,
1292 err.message().matches("flagged for possible").count(),
1293 "{}",
1294 err.message()
1295 );
1296 }
1297
1298 #[test]
1301 fn stderr_is_kept_because_it_is_where_the_other_half_arrives() {
1302 let agent = jsonl_agent("codex");
1303 let err = agent.call_failure(
1304 &["codex".to_string()],
1305 &failed(
1306 &refusal_stream(),
1307 "ERROR router: agent thread limit reached",
1308 ),
1309 );
1310 assert!(
1311 err.message().contains("agent thread limit reached"),
1312 "{}",
1313 err.message()
1314 );
1315 }
1316
1317 #[test]
1320 fn a_stream_with_no_error_event_falls_back_to_the_raw_output() {
1321 let agent = jsonl_agent("codex");
1322 let err = agent.call_failure(
1323 &["codex".to_string()],
1324 &failed("{\"type\":\"system\"}", "segmentation fault"),
1325 );
1326 assert!(
1327 err.message().contains("segmentation fault"),
1328 "{}",
1329 err.message()
1330 );
1331 assert!(
1332 err.message().starts_with("command failed"),
1333 "{}",
1334 err.message()
1335 );
1336 }
1337
1338 #[test]
1340 fn a_text_agent_is_reported_exactly_as_before() {
1341 let agent = agent(vec![one("mytool")]);
1342 let err = agent.call_failure(&["mytool".to_string()], &failed("some prose", "boom"));
1343 assert!(
1344 err.message().starts_with("command failed"),
1345 "{}",
1346 err.message()
1347 );
1348 assert!(err.message().contains("some prose"), "{}", err.message());
1349 }
1350
1351 #[test]
1354 fn a_reworded_failure_is_still_a_failed_call() {
1355 let agent = jsonl_agent("codex");
1356 let err = agent.call_failure(&["codex".to_string()], &failed(&refusal_stream(), ""));
1357 assert_eq!(ErrorKind::CallFailed, err.kind());
1358 }
1359
1360 #[test]
1363 fn a_request_carries_the_instructions_after_the_task() {
1364 let agent = Agent::with_bin(shell("a", "true"), "/bin/sh")
1365 .with_instructions("Do not wait for CI. Pick it up next pass.");
1366 let asked = agent.instructed("Review the changes on this branch.");
1367 assert!(
1368 asked.starts_with("Review the changes on this branch."),
1369 "{asked}"
1370 );
1371 assert!(asked.contains("Do not wait for CI"), "{asked}");
1372 }
1373
1374 #[test]
1378 fn the_instructions_arrive_subordinate_to_the_request() {
1379 let agent = Agent::with_bin(shell("a", "true"), "/bin/sh").with_instructions("Be quick.");
1380 let asked = agent.instructed("Do the work.").to_lowercase();
1381 assert!(
1382 asked.contains("from the person who started this run"),
1383 "{asked}"
1384 );
1385 assert!(asked.contains("not the shape of your answer"), "{asked}");
1386 }
1387
1388 #[test]
1389 fn nothing_is_added_when_there_are_none() {
1390 let agent = Agent::with_bin(shell("a", "true"), "/bin/sh");
1391 assert_eq!("Do the work.", agent.instructed("Do the work."));
1392 let blank = Agent::with_bin(shell("b", "true"), "/bin/sh").with_instructions(" \n ");
1394 assert_eq!("Do the work.", blank.instructed("Do the work."));
1395 }
1396
1397 #[test]
1400 fn the_stand_in_carries_them_too() {
1401 let agent = with_fallback(shell("primary", "true"), shell("backup", "true"))
1402 .with_instructions("Do not wait for CI.");
1403 let backup = agent.fallback().expect("a stand in");
1404 assert!(backup
1405 .instructed("Do the work.")
1406 .contains("Do not wait for CI."));
1407 }
1408
1409 fn shell(name: &str, line: &str) -> AgentSpec {
1414 let mut spec = spec(vec![one("sh"), one("-c"), one(line)]);
1415 spec.name = name.into();
1416 spec
1417 }
1418
1419 fn with_fallback(mut primary: AgentSpec, backup: AgentSpec) -> Agent {
1420 primary.fallback = Some(Box::new(backup));
1421 Agent::with_bin(primary, "/bin/sh")
1422 }
1423
1424 #[test]
1425 fn a_failed_call_is_answered_by_the_fallback() {
1426 let agent = with_fallback(
1427 shell("primary", "echo refused >&2; exit 1"),
1428 shell("backup", "echo stood in"),
1429 );
1430 let answer = agent.ask("hi", Path::new("."), None).expect("fallback");
1431 assert_eq!("stood in", answer);
1432 }
1433
1434 #[test]
1435 fn without_a_fallback_the_original_error_is_what_surfaces() {
1436 let agent = Agent::with_bin(shell("primary", "echo refused >&2; exit 1"), "/bin/sh");
1437 let err = agent
1438 .ask("hi", Path::new("."), None)
1439 .expect_err("no backup");
1440 assert!(err.message().contains("refused"), "{err}");
1441 }
1442
1443 #[test]
1446 fn both_failing_reports_the_primary_first() {
1447 let agent = with_fallback(
1448 shell("primary", "echo policy refusal >&2; exit 1"),
1449 shell("backup", "echo out of quota >&2; exit 1"),
1450 );
1451 let err = agent
1452 .ask("hi", Path::new("."), None)
1453 .expect_err("both fail");
1454 let text = err.message();
1455 let primary_at = text.find("policy refusal").expect("primary reason");
1456 let backup_at = text.find("out of quota").expect("backup reason");
1457 assert!(primary_at < backup_at, "{text}");
1458 assert!(
1459 text.contains("primary") && text.contains("backup"),
1460 "{text}"
1461 );
1462 }
1463
1464 fn attempts(name: &str) -> (PathBuf, String) {
1467 let path = std::env::temp_dir().join(format!("spar-attempts-{name}"));
1468 let _ = std::fs::remove_file(&path);
1469 let line = format!("echo x >> {}", path.display());
1470 (path, line)
1471 }
1472
1473 fn counted(path: &Path) -> usize {
1474 std::fs::read_to_string(path)
1475 .map(|t| t.lines().count())
1476 .unwrap_or(0)
1477 }
1478
1479 #[test]
1484 fn a_cli_that_could_not_answer_is_not_asked_twice_when_there_is_a_stand_in() {
1485 let (path, count) = attempts("refused-with-standin");
1486 let agent = with_fallback(
1487 shell("primary", &format!("{count}; echo refused >&2; exit 1")),
1488 shell("backup", "echo '{}'"),
1489 );
1490 let answer: Value = agent
1491 .ask_json(
1492 "q",
1493 &serde_json::json!({"type": "object"}),
1494 Path::new("."),
1495 None,
1496 )
1497 .expect("the stand in answers");
1498 assert!(answer.is_object());
1499 assert_eq!(1, counted(&path), "the primary was asked more than once");
1500 }
1501
1502 #[test]
1505 fn with_no_stand_in_a_failed_call_is_still_retried() {
1506 let (path, count) = attempts("refused-alone");
1507 let agent = Agent::with_bin(
1508 shell("solo", &format!("{count}; echo refused >&2; exit 1")),
1509 "/bin/sh",
1510 );
1511 let err = agent
1512 .ask_json::<Value>(
1513 "q",
1514 &serde_json::json!({"type": "object"}),
1515 Path::new("."),
1516 None,
1517 )
1518 .expect_err("nothing answers");
1519 assert!(err.message().contains("twice"), "{err}");
1520 assert_eq!(2, counted(&path));
1521 }
1522
1523 #[test]
1527 fn an_unusable_answer_is_still_worth_asking_again() {
1528 let (path, count) = attempts("unparsable");
1529 let agent = with_fallback(
1530 shell("primary", &format!("{count}; echo not json at all")),
1531 shell("backup", "echo '{}'"),
1532 );
1533 let answer: Value = agent
1534 .ask_json(
1535 "q",
1536 &serde_json::json!({"type": "object"}),
1537 Path::new("."),
1538 None,
1539 )
1540 .expect("the stand in answers in the end");
1541 assert!(answer.is_object());
1542 assert_eq!(2, counted(&path), "a shape error is worth one more ask");
1543 }
1544
1545 #[test]
1549 fn a_timeout_still_reaches_the_fallback() {
1550 let mut primary = shell("primary", "sleep 30");
1551 primary.timeout = 1;
1552 let agent = with_fallback(primary, shell("backup", "echo stood in"));
1553 assert_eq!(
1554 "stood in",
1555 agent.ask("hi", Path::new("."), None).expect("fallback")
1556 );
1557 }
1558
1559 #[test]
1562 fn the_fallback_is_built_alongside_the_agent() {
1563 let mut primary = shell("primary", "true");
1564 primary.fallback = Some(Box::new(shell("backup", "true")));
1565 let agent = Agent::new(primary);
1566 assert_eq!(Some("backup"), agent.fallback().map(Agent::name));
1567 assert!(Agent::new(shell("solo", "true")).fallback().is_none());
1568 }
1569}
1570
1571#[cfg(test)]
1572mod schema_placeholder_tests {
1573 use super::*;
1574 use crate::config::{OutputMode, SystemVia};
1575
1576 fn spec_with(command: Vec<CommandPart>) -> AgentSpec {
1577 AgentSpec {
1578 name: "claude".into(),
1579 command,
1580 model: None,
1581 effort: None,
1582 output: OutputMode::Text,
1583 message_match: BTreeMap::new(),
1584 message_path: None,
1585 search_paths: vec![],
1586 system_via: SystemVia::Prompt,
1587 timeout: 60,
1588 fallback: None,
1589 models: vec![],
1590 efforts: vec![],
1591 options_note: None,
1592 }
1593 }
1594
1595 fn one(s: &str) -> CommandPart {
1596 CommandPart::One(s.into())
1597 }
1598 fn group(parts: &[&str]) -> CommandPart {
1599 CommandPart::Group(parts.iter().map(|s| s.to_string()).collect())
1600 }
1601
1602 #[test]
1605 fn either_schema_form_counts_as_native_support() {
1606 let inline = Agent::with_bin(
1607 spec_with(vec![one("x"), group(&["--json-schema", "{schema}"])]),
1608 "/b",
1609 );
1610 let byfile = Agent::with_bin(
1611 spec_with(vec![one("x"), group(&["--output-schema", "{schema_file}"])]),
1612 "/b",
1613 );
1614 let neither = Agent::with_bin(spec_with(vec![one("x"), one("{prompt}")]), "/b");
1615 assert!(inline.supports_schema());
1616 assert!(byfile.supports_schema());
1617 assert!(!neither.supports_schema());
1618 }
1619
1620 #[test]
1621 fn the_inline_schema_is_substituted_whole() {
1622 let agent = Agent::with_bin(
1623 spec_with(vec![
1624 one("x"),
1625 group(&["--json-schema", "{schema}"]),
1626 one("{prompt}"),
1627 ]),
1628 "/b",
1629 );
1630 let values = Placeholders {
1631 prompt: Some("review it".into()),
1632 schema: Some(r#"{"type":"object"}"#.into()),
1633 ..Default::default()
1634 };
1635 assert_eq!(
1636 vec!["/b", "--json-schema", r#"{"type":"object"}"#, "review it"],
1637 agent.render(&values).unwrap()
1638 );
1639 }
1640
1641 #[test]
1644 fn the_schema_flag_drops_when_no_schema_is_wanted() {
1645 let agent = Agent::with_bin(
1646 spec_with(vec![
1647 one("x"),
1648 group(&["--json-schema", "{schema}"]),
1649 one("{prompt}"),
1650 ]),
1651 "/b",
1652 );
1653 let values = Placeholders {
1654 prompt: Some("implement it".into()),
1655 ..Default::default()
1656 };
1657 assert_eq!(vec!["/b", "implement it"], agent.render(&values).unwrap());
1658 }
1659
1660 #[test]
1662 fn the_two_schema_placeholders_do_not_collide() {
1663 let agent = Agent::with_bin(
1664 spec_with(vec![one("x"), group(&["--output-schema", "{schema_file}"])]),
1665 "/b",
1666 );
1667 let values = Placeholders {
1668 schema: Some("INLINE".into()),
1669 schema_file: Some("/tmp/s.json".into()),
1670 ..Default::default()
1671 };
1672 assert_eq!(
1673 vec!["/b", "--output-schema", "/tmp/s.json"],
1674 agent.render(&values).unwrap()
1675 );
1676 }
1677
1678 #[test]
1680 fn the_shipped_claude_preset_now_has_native_structured_output() {
1681 let raw = crate::config::load_preset("claude").unwrap();
1682 let table = raw.as_table().cloned().unwrap();
1683 let mut spec: AgentSpec = toml::Value::Table(table).try_into().unwrap();
1684 spec.name = "claude".into();
1685 assert!(
1686 Agent::with_bin(spec, "/b").supports_schema(),
1687 "without this a long review is parsed out of prose and truncates"
1688 );
1689 }
1690}