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