1use std::collections::BTreeMap;
9use std::io::Write;
10use std::path::{Path, PathBuf};
11use std::sync::OnceLock;
12
13use serde_json::Value;
14
15use crate::config::{AgentSpec, CommandPart, OutputMode, SystemVia};
16use crate::error::{ErrorKind, Result, SparError};
17use crate::jsonx;
18use crate::proc::{self, ExecOpts};
19use crate::repo::{
20 attribute_state, git_state, ignored_untracked_state, join_reasons, list_paths,
21 note_ignored_leavings, probe_again_on_failure, safe_git_state, uncertain_worktree_change,
22 AttributeState, GitState, IgnoredState, EDITED_GIT_MARKER as GIT_MARKER_RECOVERY,
23};
24use crate::{bail, log, logdim, logwarn, spar_err};
25
26pub const STYLE_RULES: &str = "\
39Style rules for every artifact you produce (commits, PR titles, PR bodies, issue
40titles, issue bodies, review comments, and the comments in code you write):
41- Never use em-dashes or en-dashes. Use commas, colons, or parentheses.
42- Never mention Claude, Codex, OpenAI, ChatGPT, Anthropic, AI, or any tooling
43 used to produce the work.
44- Never add a Co-Authored-By trailer or a \"Generated with\" footer to commits.
45- Be brief. A human engineer with other work has to read this. Lead with the
46 point, cut the preamble, stop when you are done. Do not restate the task, do
47 not announce what you are about to do, do not summarise what the diff already
48 shows.
49- Brief means saying fewer things, never packing more into a sentence. Two
50 plain sentences beat one that has to be read twice. Split a sentence that
51 carries three facts, and split one that makes the reader hold an identifier
52 in their head to parse the rest of the clause. A comma splice joining two
53 ideas to save a full stop costs the reader more than the full stop would.
54- No headings, bullet lists, or bold text in anything only a few sentences long.
55- Comment code for the reason, not the change. A comment earns its length from
56 what the code cannot say for itself: a constraint that is not local, an
57 alternative that was tried and does not work, a surprise the next reader would
58 otherwise trip on. Write the reason that holds now, not the investigation that
59 found it. A paragraph above a three line change is almost always the debugging
60 story, and the reader wants the conclusion of it.
61Write as a human engineer would, because the reader neither knows nor cares what
62produced the work.";
63
64const JSON_INSTRUCTION: &str = "Respond with ONLY a JSON object matching this \
65schema. No prose, no markdown fences, no commentary before or after:";
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68enum Access {
69 Read,
70 Edit,
71}
72
73const INSTRUCTIONS_HEADER: &str = "Additional instructions from the person who \
80started this run. They change how you work, not what was asked for above and \
81not the shape of your answer:";
82
83pub struct Agent {
84 pub spec: AgentSpec,
85 fallback: Option<Box<Agent>>,
89 instructions: Option<String>,
91 resolved: OnceLock<PathBuf>,
92}
93
94impl std::fmt::Debug for Agent {
95 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96 write!(f, "<{} {}>", self.spec.name, self.spec.describe())
97 }
98}
99
100#[derive(Debug, Clone, Default, PartialEq, Eq)]
107pub struct Effort {
108 pub primary: Option<String>,
109 pub fallback: Option<String>,
110 pub kind: &'static str,
114 pub subject: Option<i64>,
116 retry: bool,
119 standing_in: bool,
120}
121
122impl Effort {
123 pub fn just(primary: Option<String>) -> Self {
125 Self {
126 primary,
127 ..Self::default()
128 }
129 }
130
131 pub fn about(mut self, subject: i64) -> Self {
133 self.subject = Some(subject);
134 self
135 }
136
137 fn asked(&self) -> Option<&str> {
138 self.primary.as_deref()
139 }
140
141 fn handed_over(&self) -> Effort {
143 Effort {
144 primary: self.fallback.clone(),
145 fallback: None,
146 kind: self.kind,
147 subject: self.subject,
148 retry: false,
149 standing_in: true,
150 }
151 }
152
153 pub fn describe(&self) -> &str {
155 self.primary.as_deref().unwrap_or("default effort")
156 }
157
158 fn asked_again(&self) -> Effort {
160 Effort {
161 retry: true,
162 ..self.clone()
163 }
164 }
165}
166
167fn values_effort(effort: &Effort, spec: &AgentSpec) -> String {
170 effort
171 .asked()
172 .map(str::to_string)
173 .or_else(|| spec.effort.clone())
174 .unwrap_or_default()
175}
176
177fn tokens_used(stdout: &str) -> Option<u64> {
183 let mut found = None;
184 for line in stdout.lines().rev().take(200) {
185 let line = line.trim();
186 if !line.starts_with('{') || !line.contains("token") {
187 continue;
188 }
189 let Ok(value) = serde_json::from_str::<Value>(line) else {
190 continue;
191 };
192 found = total_tokens(&value);
193 if found.is_some() {
194 break;
195 }
196 }
197 found
198}
199
200fn total_tokens(value: &Value) -> Option<u64> {
201 if let Some(total) = value.get("total_tokens").and_then(Value::as_u64) {
202 return Some(total);
203 }
204 match value {
205 Value::Object(fields) => fields.values().find_map(total_tokens),
206 _ => None,
207 }
208}
209
210impl Agent {
211 pub fn new(spec: AgentSpec) -> Self {
212 let fallback = spec
213 .fallback
214 .clone()
215 .map(|backup| Box::new(Agent::new(*backup)));
216 Self {
217 spec,
218 fallback,
219 instructions: None,
220 resolved: OnceLock::new(),
221 }
222 }
223
224 pub fn with_instructions(mut self, text: &str) -> Self {
230 let text = text.trim();
231 if text.is_empty() {
232 return self;
233 }
234 if let Some(backup) = self.fallback.take() {
235 self.fallback = Some(Box::new(backup.with_instructions(text)));
236 }
237 self.instructions = Some(text.to_string());
238 self
239 }
240
241 fn instructed(&self, prompt: &str) -> String {
247 match &self.instructions {
248 Some(extra) => format!("{prompt}\n\n{INSTRUCTIONS_HEADER}\n{extra}"),
249 None => prompt.to_string(),
250 }
251 }
252
253 pub fn name(&self) -> &str {
254 &self.spec.name
255 }
256
257 pub fn fallback(&self) -> Option<&Agent> {
259 self.fallback.as_deref()
260 }
261
262 pub fn program(&self) -> &str {
265 match self.spec.command.first() {
266 Some(CommandPart::One(program)) => program,
267 _ => self.name(),
268 }
269 }
270
271 pub fn env_key(&self) -> String {
273 format!(
274 "SPAR_{}_BIN",
275 self.spec.name.to_uppercase().replace('-', "_")
276 )
277 }
278
279 #[doc(hidden)]
282 pub fn with_bin(spec: AgentSpec, bin: impl Into<PathBuf>) -> Self {
283 let agent = Self::new(spec);
284 let _ = agent.resolved.set(bin.into());
285 agent
286 }
287
288 pub fn resolve_bin(&self) -> Result<&Path> {
295 if let Some(found) = self.resolved.get() {
296 return Ok(found.as_path());
297 }
298 let found = self.locate()?;
299 let _ = self.resolved.set(found);
300 Ok(self.resolved.get().expect("just set").as_path())
301 }
302
303 fn locate(&self) -> Result<PathBuf> {
304 let wanted = match self.spec.command.first() {
305 Some(CommandPart::One(program)) => program.clone(),
306 _ => bail!("agent '{}' has no command configured", self.spec.name),
307 };
308
309 let env_key = self.env_key();
310 let env_override = std::env::var(&env_key)
311 .ok()
312 .filter(|v| !v.trim().is_empty());
313
314 let mut tried: Vec<String> = Vec::new();
315
316 for candidate in env_override
317 .iter()
318 .map(String::as_str)
319 .chain([wanted.as_str()])
320 {
321 let path = Path::new(candidate);
322 if path.is_absolute() || candidate.contains(std::path::MAIN_SEPARATOR) {
323 let expanded = proc::expand_tilde(candidate);
324 tried.push(expanded.display().to_string());
325 if proc::is_executable(&expanded) {
326 return Ok(expanded);
327 }
328 } else {
329 tried.push(format!("{candidate} (PATH)"));
330 if let Some(found) = proc::which(candidate) {
331 return Ok(found);
332 }
333 }
334 }
335
336 for base in &self.spec.search_paths {
337 let base = proc::expand_tilde(base);
338 let candidate = if base.file_name().and_then(|n| n.to_str()) == Some(wanted.as_str()) {
339 base
340 } else {
341 base.join(&wanted)
342 };
343 tried.push(candidate.display().to_string());
344 if proc::is_executable(&candidate) {
345 return Ok(candidate);
346 }
347 }
348
349 Err(spar_err!(
350 "could not find the binary for agent '{}'. Tried:\n {}\nSet agents.{}.command[0] to \
351 an absolute path, or {}=/path/to/binary.",
352 self.spec.name,
353 tried.join("\n "),
354 self.spec.name,
355 env_key
356 ))
357 }
358
359 pub fn render(&self, values: &Placeholders) -> Result<Vec<String>> {
365 let mut out = vec![self.resolve_bin()?.display().to_string()];
366 for part in self.spec.command.iter().skip(1) {
367 let mut rendered = Vec::new();
368 let mut skip = false;
369 for arg in part.args() {
370 match values.substitute(arg) {
371 Some(text) => rendered.push(text),
372 None => {
373 skip = true;
374 break;
375 }
376 }
377 }
378 if !skip {
379 out.extend(rendered);
380 }
381 }
382 Ok(out)
383 }
384
385 pub fn supports_schema(&self) -> bool {
391 self.spec
392 .command
393 .iter()
394 .flat_map(|p| p.args())
395 .any(|a| a.contains("{schema_file}") || a.contains("{schema}"))
396 }
397
398 pub fn extract(&self, stdout: &str) -> Result<String> {
401 match self.spec.output {
402 OutputMode::Text | OutputMode::Json => Ok(stdout.trim().to_string()),
403 OutputMode::Jsonl => self.extract_jsonl(stdout),
404 }
405 }
406
407 fn extract_jsonl(&self, stdout: &str) -> Result<String> {
408 let mut messages: Vec<String> = Vec::new();
409
410 for line in stdout.lines() {
411 let line = line.trim();
412 if !line.starts_with('{') {
413 continue;
414 }
415 let Ok(event) = serde_json::from_str::<Value>(line) else {
416 continue;
417 };
418 if matches(&event, &self.spec.message_match) {
419 if let Some(text) = dig(&event, self.spec.message_path.as_deref().unwrap_or("")) {
420 if let Some(text) = as_text(text) {
421 messages.push(text);
422 }
423 }
424 }
425 }
426
427 if messages.is_empty() {
428 let reasons = self.error_events(stdout);
429 if !reasons.is_empty() {
430 return Err(SparError::call_failed(format!(
433 "agent '{}' failed: {}",
434 self.spec.name,
435 reasons.join("; ")
436 )));
437 }
438 }
439 Ok(messages.join("\n").trim().to_string())
440 }
441
442 fn error_events(&self, stdout: &str) -> Vec<String> {
452 let mut reasons: Vec<String> = Vec::new();
453 for line in stdout.lines() {
454 let line = line.trim();
455 if !line.starts_with('{') {
456 continue;
457 }
458 let Ok(event) = serde_json::from_str::<Value>(line) else {
459 continue;
460 };
461 if !matches!(
462 event.get("type").and_then(Value::as_str),
463 Some("turn.failed") | Some("error")
464 ) {
465 continue;
466 }
467 let reason = dig(&event, "message")
468 .or_else(|| dig(&event, "error.message"))
469 .and_then(as_text)
470 .unwrap_or_else(|| truncate(&event.to_string(), 400));
471 if !reason.trim().is_empty() && !reasons.contains(&reason) {
472 reasons.push(reason);
473 }
474 }
475 reasons
476 }
477
478 fn call_failure(&self, argv: &[String], out: &proc::Output) -> SparError {
490 if self.spec.output != OutputMode::Jsonl {
491 return SparError::call_failed(proc::failure_message(argv, out));
492 }
493 let reasons = self.error_events(&out.stdout);
494 if reasons.is_empty() {
495 return SparError::call_failed(proc::failure_message(argv, out));
496 }
497 let mut text = format!(
498 "agent '{}' could not answer (exit {}): {}",
499 self.spec.name,
500 out.code,
501 reasons.join("; ")
502 );
503 let stderr = out.stderr.trim();
504 if !stderr.is_empty() {
505 text.push_str(&format!("\n--- stderr ---\n{stderr}"));
506 }
507 text.push_str(&format!("\n--- command ---\n{}", proc::abbreviate(argv)));
508 SparError::call_failed(text)
509 }
510
511 pub fn ask(&self, prompt: &str, cwd: &Path, effort: &Effort) -> Result<String> {
514 let baseline = EditBaseline::capture(cwd)?;
515 self.ask_with_access(prompt, cwd, effort, Access::Read, Some(&baseline))
516 }
517
518 pub fn edit(&self, prompt: &str, cwd: &Path, effort: &Effort) -> Result<String> {
522 let baseline = EditBaseline::capture(cwd)?;
523 self.ask_with_access(prompt, cwd, effort, Access::Edit, Some(&baseline))
524 }
525
526 fn ask_with_access(
527 &self,
528 prompt: &str,
529 cwd: &Path,
530 effort: &Effort,
531 access: Access,
532 baseline: Option<&EditBaseline>,
533 ) -> Result<String> {
534 let prompt = &self.instructed(prompt);
535 let result = match self.ask_inner(prompt, cwd, effort, None, None, access) {
536 Ok(text) => Ok(text),
537 Err(e) => match recovery_error(&e, baseline, cwd, access) {
538 Some(recovery) => Err(recovery),
539 None => self.hand_over(e, |backup| {
540 backup.ask_with_access(prompt, cwd, &effort.handed_over(), access, baseline)
541 }),
542 },
543 };
544 finish_call(result, access, baseline, cwd)
545 }
546
547 pub fn effort(&self, cfg: &crate::config::Config, call: crate::config::Call) -> Effort {
553 Effort {
554 primary: cfg.effort_for(&self.spec, call),
555 fallback: self
556 .spec
557 .fallback
558 .as_deref()
559 .and_then(|spec| cfg.effort_for(spec, call)),
560 kind: call.key(),
561 subject: None,
562 retry: false,
563 standing_in: false,
564 }
565 }
566
567 fn hand_over<T>(&self, primary: SparError, run: impl FnOnce(&Agent) -> Result<T>) -> Result<T> {
580 if primary.kind() == ErrorKind::Interrupted {
583 return Err(primary);
584 }
585 let Some(backup) = self.fallback() else {
586 return Err(primary);
587 };
588 logwarn!(
589 "{} could not answer. Handing the call to {}.\n{primary}",
590 self.name(),
591 backup.name()
592 );
593 match run(backup) {
594 Ok(answer) => {
595 log!("{} answered in place of {}", backup.name(), self.name());
596 Ok(answer)
597 }
598 Err(second) => {
602 let message = format!(
603 "agent '{}' failed and its fallback '{}' could not stand in.\n{}\n\n{}: {}",
604 self.name(),
605 backup.name(),
606 primary.message(),
607 backup.name(),
608 second.message()
609 );
610 Err(second.with_message(message))
611 }
612 }
613 }
614
615 fn ask_inner(
616 &self,
617 prompt: &str,
618 cwd: &Path,
619 effort: &Effort,
620 schema_file: Option<&Path>,
621 schema: Option<&str>,
622 access: Access,
623 ) -> Result<String> {
624 let body = match self.spec.system_via {
625 SystemVia::Placeholder => prompt.to_string(),
626 SystemVia::Prompt => format!("{STYLE_RULES}\n\n{prompt}"),
627 };
628 let values = Placeholders {
629 prompt: Some(body),
630 system: Some(STYLE_RULES.to_string()),
631 model: self.spec.model.clone(),
632 effort: effort
633 .asked()
634 .map(str::to_string)
635 .or_else(|| self.spec.effort.clone()),
636 cwd: Some(cwd.display().to_string()),
637 schema_file: schema_file.map(|p| p.display().to_string()),
638 schema: schema.map(str::to_string),
639 };
640 let argv = self.render(&values)?;
641 let opts = ExecOpts::new()
645 .cwd(cwd)
646 .timeout_secs(self.spec.timeout)
647 .stop_descendants(true)
648 .check(false);
649 let git_file = match access {
652 Access::Read | Access::Edit => GitFile::capture(cwd)?,
653 };
654 let started = std::time::Instant::now();
655 let called = proc::exec(&argv, &opts);
656 crate::spend::record(crate::spend::Spent {
659 agent: self.name().to_string(),
660 kind: effort.kind.to_string(),
661 effort: values_effort(effort, &self.spec),
662 subject: effort.subject,
663 seconds: started.elapsed().as_secs_f64(),
664 retry: effort.retry,
665 fallback: effort.standing_in,
666 tokens: called
667 .as_ref()
668 .ok()
669 .and_then(|out| tokens_used(&out.stdout)),
670 ok: called.as_ref().is_ok_and(|out| out.ok()),
671 });
672 after_call_is_quiet(&called, || {
673 if let Some(git_file) = git_file {
674 git_file.restore_if_changed(cwd)?;
675 }
676 Ok(())
677 })?;
678 let out = called?;
679 if !out.ok() {
680 return Err(self.call_failure(&argv, &out));
681 }
682 self.extract(&out.stdout)
683 }
684
685 pub fn ask_json<T: serde::de::DeserializeOwned>(
689 &self,
690 prompt: &str,
691 schema: &Value,
692 cwd: &Path,
693 effort: &Effort,
694 ) -> Result<T> {
695 let baseline = EditBaseline::capture(cwd)?;
696 self.ask_json_with_access(prompt, schema, cwd, effort, Access::Read, Some(&baseline))
697 }
698
699 pub fn edit_json<T: serde::de::DeserializeOwned>(
703 &self,
704 prompt: &str,
705 schema: &Value,
706 cwd: &Path,
707 effort: &Effort,
708 ) -> Result<T> {
709 let baseline = EditBaseline::capture(cwd)?;
710 self.ask_json_with_access(prompt, schema, cwd, effort, Access::Edit, Some(&baseline))
711 }
712
713 fn ask_json_with_access<T: serde::de::DeserializeOwned>(
714 &self,
715 prompt: &str,
716 schema: &Value,
717 cwd: &Path,
718 effort: &Effort,
719 access: Access,
720 baseline: Option<&EditBaseline>,
721 ) -> Result<T> {
722 let prompt = &self.instructed(prompt);
725 let result = match self.ask_json_retrying(prompt, schema, cwd, effort, access, baseline) {
726 Ok(parsed) => Ok(parsed),
727 Err(e) => match recovery_error(&e, baseline, cwd, access) {
728 Some(recovery) => Err(recovery),
729 None => self.hand_over(e, |backup| {
730 backup.ask_json_retrying::<T>(
731 prompt,
732 schema,
733 cwd,
734 &effort.handed_over(),
735 access,
736 baseline,
737 )
738 }),
739 },
740 };
741 finish_call(result, access, baseline, cwd)
742 }
743
744 fn worth_asking_again(&self, e: &SparError) -> bool {
757 match e.kind() {
758 ErrorKind::TimedOut => false,
759 ErrorKind::UncertainWrite => false,
760 ErrorKind::Interrupted => false,
761 ErrorKind::CallFailed => self.fallback().is_none(),
762 ErrorKind::Other => true,
763 }
764 }
765
766 fn ask_json_retrying<T: serde::de::DeserializeOwned>(
768 &self,
769 prompt: &str,
770 schema: &Value,
771 cwd: &Path,
772 effort: &Effort,
773 access: Access,
774 baseline: Option<&EditBaseline>,
775 ) -> Result<T> {
776 const ATTEMPTS: usize = 2;
782 let mut last: Option<SparError> = None;
783
784 for attempt in 1..=ATTEMPTS {
785 let asked = match &last {
786 None => prompt.to_string(),
787 Some(e) => format!(
788 "{prompt}\n\nYour previous answer could not be used: {}\nReturn the whole \
789 object this time, exactly matching the schema, and nothing else.",
790 e.first_line()
791 ),
792 };
793 let effort = if attempt > 1 {
794 effort.asked_again()
795 } else {
796 effort.clone()
797 };
798 match self.ask_json_once::<T>(&asked, schema, cwd, &effort, access) {
799 Ok(parsed) => {
800 if attempt > 1 {
801 logdim!("{} answered on the retry", self.spec.name);
802 }
803 return Ok(parsed);
804 }
805 Err(e) => {
809 if let Some(recovery) = recovery_error(&e, baseline, cwd, access) {
810 return Err(recovery);
811 }
812 if !self.worth_asking_again(&e) {
813 return Err(e);
814 }
815 if attempt < ATTEMPTS {
816 logwarn!("{} failed, asking again.\n{e}", self.spec.name);
821 }
822 last = Some(e);
823 }
824 }
825 }
826 Err(spar_err!(
827 "agent '{}' returned an unusable answer twice: {}",
828 self.spec.name,
829 last.expect("at least one attempt").message()
830 ))
831 }
832
833 fn ask_json_once<T: serde::de::DeserializeOwned>(
834 &self,
835 prompt: &str,
836 schema: &Value,
837 cwd: &Path,
838 effort: &Effort,
839 access: Access,
840 ) -> Result<T> {
841 let text = if self.supports_schema() {
842 let inline = serde_json::to_string(schema).unwrap_or_default();
843 let file = TempJson::write(schema)?;
844 self.ask_inner(
845 prompt,
846 cwd,
847 effort,
848 Some(file.path()),
849 Some(&inline),
850 access,
851 )?
852 } else {
853 let full = format!(
854 "{prompt}\n\n{JSON_INSTRUCTION}\n{}",
855 serde_json::to_string_pretty(schema).unwrap_or_default()
856 );
857 self.ask_inner(&full, cwd, effort, None, None, access)?
858 };
859 jsonx::extract_into(&text)
860 }
861
862 pub fn review<T: serde::de::DeserializeOwned>(
876 &self,
877 base: &str,
878 prompt: &str,
879 schema: &Value,
880 cwd: &Path,
881 effort: &Effort,
882 ) -> Result<T> {
883 let scoped = format!(
884 "{prompt}\n\nThe changes under review are the diff between `{base}` and HEAD in your \
885 working directory. Inspect them with git, then read the surrounding code before \
886 judging. Do not review only the diff.\n\nThis call is a review and nothing else. Do \
887 not edit the code under review, do not commit, and do not push: somebody else acts \
888 on what you find, and a reviewer that writes ends up reviewing its own work. Put any \
889 scratch file under the system temporary directory, not in the working tree."
890 );
891 self.ask_json(&scoped, schema, cwd, effort)
892 }
893}
894
895struct EditBaseline {
901 attributes: AttributeState,
902 git_state: GitState,
903 git_entry: GitEntry,
904 ignored_untracked: IgnoredState,
905}
906
907impl EditBaseline {
908 fn capture(cwd: &Path) -> Result<Self> {
909 let git_entry = GitEntry::capture(cwd)?;
910 if matches!(&git_entry, GitEntry::File) {
911 ensure_recovery_path_clear(cwd)?;
912 }
913 let attributes = attribute_state(cwd).map_err(|e| {
914 e.with_message(format!(
915 "could not record attribute files before a call in {}: {}",
916 cwd.display(),
917 e.last_line()
918 ))
919 })?;
920 let git_state = safe_git_state(cwd).map_err(|e| {
921 e.with_message(format!(
922 "could not record a safe Git state before editing {}: {}",
923 cwd.display(),
924 e.last_line()
925 ))
926 })?;
927 let ignored_untracked = ignored_untracked_state(cwd).map_err(|e| {
928 e.with_message(format!(
929 "could not record ignored files before editing {}: {}",
930 cwd.display(),
931 e.last_line()
932 ))
933 })?;
934 Ok(Self {
935 attributes,
936 git_state,
937 git_entry,
938 ignored_untracked,
939 })
940 }
941
942 fn recovery_needed(&self, cwd: &Path, access: Access) -> Result<Option<String>> {
945 if !self.git_entry.still_matches(cwd)? {
946 return Err(uncertain_worktree_change(
947 cwd,
948 format!(
949 "the Git entry at {} changed type during the editing call. No Git recovery \
950 probe was run. Inspect the worktree before retrying.",
951 cwd.join(".git").display()
952 ),
953 ));
954 }
955 let attributes = probe_again_on_failure(|| attribute_state(cwd)).map_err(|e| {
956 uncertain_worktree_change(
957 cwd,
958 format!(
959 "could not check attribute files after a call in {}: {}. Inspect the \
960 worktree before retrying.",
961 cwd.display(),
962 e.last_line()
963 ),
964 )
965 })?;
966 if attributes != self.attributes {
967 return Err(uncertain_worktree_change(
968 cwd,
969 format!(
970 "an attribute file changed during a call in {}. The worktree was kept before \
971 running any Git operation that could select a new filter.",
972 cwd.display()
973 ),
974 ));
975 }
976 let current = probe_again_on_failure(|| git_state(cwd))
977 .map_err(|e| recovery_probe_error(cwd, "state", &e))?;
978 let ignored = probe_again_on_failure(|| ignored_untracked_state(cwd))
979 .map_err(|e| recovery_probe_error(cwd, "ignored files", &e))?;
980 let untracked = match access {
985 Access::Read => {
986 let leavings = self.ignored_untracked.read_only_leavings(&ignored);
987 note_ignored_leavings(cwd, "call", &leavings.tolerated);
990 (!leavings.condemning.is_empty()).then(|| {
991 format!(
992 "untracked file(s) changed: {}",
993 list_paths(&leavings.condemning)
994 )
995 })
996 }
997 Access::Edit => self
998 .ignored_untracked
999 .changed_beyond_generated(&ignored)
1000 .then(|| "untracked or ignored file(s) changed".to_string()),
1001 };
1002 let tracked = match (access, &self.git_entry) {
1008 (Access::Read, GitEntry::Directory(_)) => {
1009 self.git_state.read_only_difference(¤t, cwd)
1010 }
1011 _ => self.git_state.describe_difference(¤t),
1012 };
1013 Ok(join_reasons(tracked, untracked))
1014 }
1015}
1016
1017#[derive(Clone, PartialEq, Eq)]
1018enum GitEntry {
1019 Directory(GitDirectory),
1020 File,
1021}
1022
1023#[derive(Clone, PartialEq, Eq)]
1024struct GitDirectory {
1025 #[cfg(unix)]
1026 device: u64,
1027 #[cfg(unix)]
1028 inode: u64,
1029 #[cfg(not(unix))]
1030 created: Option<std::time::SystemTime>,
1031}
1032
1033impl GitEntry {
1034 fn capture(cwd: &Path) -> Result<Self> {
1035 let path = cwd.join(".git");
1036 match std::fs::symlink_metadata(&path) {
1037 Ok(meta) if meta.is_dir() => {
1038 #[cfg(unix)]
1039 {
1040 use std::os::unix::fs::MetadataExt;
1041 Ok(Self::Directory(GitDirectory {
1042 device: meta.dev(),
1043 inode: meta.ino(),
1044 }))
1045 }
1046 #[cfg(not(unix))]
1047 {
1048 Ok(Self::Directory(GitDirectory {
1049 created: meta.created().ok(),
1050 }))
1051 }
1052 }
1053 Ok(meta) if meta.is_file() => Ok(Self::File),
1054 Ok(_) => bail!(
1055 "{} is not a regular Git directory or marker",
1056 path.display()
1057 ),
1058 Err(e) => Err(spar_err!("could not inspect {}: {e}", path.display())),
1059 }
1060 }
1061
1062 fn still_matches(&self, cwd: &Path) -> Result<bool> {
1063 let path = cwd.join(".git");
1064 match (self, std::fs::symlink_metadata(path)) {
1065 (Self::Directory(before), Ok(meta)) if meta.is_dir() => {
1066 #[cfg(unix)]
1067 {
1068 use std::os::unix::fs::MetadataExt;
1069 Ok(before.device == meta.dev() && before.inode == meta.ino())
1070 }
1071 #[cfg(not(unix))]
1072 {
1073 Ok(before.created == meta.created().ok())
1074 }
1075 }
1076 (Self::Directory(_), Ok(_)) => Ok(false),
1077 (Self::File, Ok(meta)) => Ok(meta.is_file()),
1078 (_, Err(e)) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
1079 (_, Err(e)) => Err(SparError::uncertain_write(format!(
1080 "could not inspect the Git entry at {} after the editing call: {e}",
1081 cwd.join(".git").display()
1082 ))),
1083 }
1084 }
1085}
1086
1087fn recovery_probe_error(cwd: &Path, probe: &str, error: &SparError) -> SparError {
1088 uncertain_worktree_change(
1089 cwd,
1090 format!(
1091 "could not check the Git {probe} after an editing call in {}: {}. Inspect the \
1092 worktree before retrying.",
1093 cwd.display(),
1094 error.last_line()
1095 ),
1096 )
1097}
1098
1099fn recovery_error(
1100 error: &SparError,
1101 baseline: Option<&EditBaseline>,
1102 cwd: &Path,
1103 access: Access,
1104) -> Option<SparError> {
1105 if error.kind() == ErrorKind::UncertainWrite {
1108 return Some(error.clone());
1109 }
1110 let baseline = baseline?;
1111 match baseline.recovery_needed(cwd, access) {
1112 Ok(None) => None,
1113 Ok(Some(_)) if access == Access::Edit => Some(changed_edit_failure(error)),
1114 Ok(Some(reason)) => Some(uncertain_worktree_change(
1115 cwd,
1116 format!(
1117 "{}\nA read-only call changed the worktree before it failed: {reason}. Its \
1118 answer was discarded and the worktree was kept for recovery.",
1119 error.message()
1120 ),
1121 )),
1122 Err(recovery) => Some(SparError::uncertain_write(format!(
1123 "{}\n{}",
1124 error.message(),
1125 recovery.message()
1126 ))),
1127 }
1128}
1129
1130fn changed_edit_failure(error: &SparError) -> SparError {
1131 const NOTE: &str =
1132 "The call changed the worktree before it failed. It was not retried or handed to a fallback.";
1133 if error.message().contains(NOTE) {
1134 return error.clone();
1135 }
1136 error.with_message(format!("{}\n{NOTE}", error.message()))
1137}
1138
1139fn finish_call<T>(
1140 result: Result<T>,
1141 access: Access,
1142 baseline: Option<&EditBaseline>,
1143 cwd: &Path,
1144) -> Result<T> {
1145 let value = result?;
1146 let Some(baseline) = baseline else {
1147 return Ok(value);
1148 };
1149 if access == Access::Edit {
1150 if baseline.git_entry.still_matches(cwd)? {
1151 return Ok(value);
1152 }
1153 return Err(uncertain_worktree_change(
1154 cwd,
1155 format!(
1156 "the Git entry at {} was replaced during an editing call. The result was \
1157 discarded before any Git operation ran.",
1158 cwd.join(".git").display()
1159 ),
1160 ));
1161 }
1162 match baseline.recovery_needed(cwd, access) {
1163 Ok(None) => Ok(value),
1164 Ok(Some(reason)) => Err(uncertain_worktree_change(
1165 cwd,
1166 format!(
1167 "a read-only call changed the worktree: {reason}. Its answer was discarded and \
1168 the worktree was kept for recovery."
1169 ),
1170 )),
1171 Err(error) => Err(error),
1172 }
1173}
1174
1175struct GitFile {
1180 bytes: Vec<u8>,
1181}
1182
1183impl GitFile {
1184 fn capture(cwd: &Path) -> Result<Option<Self>> {
1185 let path = cwd.join(".git");
1186 match std::fs::symlink_metadata(&path) {
1187 Ok(meta) if meta.is_dir() => Ok(None),
1188 Ok(meta) if meta.is_file() => {
1189 ensure_recovery_path_clear(cwd)?;
1190 let bytes = std::fs::read(&path)
1191 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
1192 Ok(Some(Self { bytes }))
1193 }
1194 Ok(_) => bail!(
1195 "{} is not a regular Git marker. Refusing to run an editing call.",
1196 path.display()
1197 ),
1198 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
1199 Err(e) => Err(spar_err!("could not inspect {}: {e}", path.display())),
1200 }
1201 }
1202
1203 fn restore_if_changed(self, cwd: &Path) -> Result<()> {
1204 let path = cwd.join(".git");
1205 let unchanged = std::fs::symlink_metadata(&path)
1206 .ok()
1207 .filter(|meta| meta.is_file())
1208 .and_then(|_| std::fs::read(&path).ok())
1209 .is_some_and(|bytes| bytes == self.bytes);
1210 let recovery = cwd.join(GIT_MARKER_RECOVERY);
1211 if unchanged {
1212 return match std::fs::symlink_metadata(&recovery) {
1213 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
1214 Ok(_) => Err(SparError::uncertain_write(format!(
1215 "the agent call created the reserved recovery path at {}. The Git marker was \
1216 unchanged. Inspect or remove the recovery path before retrying.",
1217 recovery.display()
1218 ))),
1219 Err(e) => Err(SparError::uncertain_write(format!(
1220 "could not inspect {} after the agent call: {e}",
1221 recovery.display()
1222 ))),
1223 };
1224 }
1225
1226 let retained = match std::fs::symlink_metadata(&path) {
1227 Ok(_) => match std::fs::symlink_metadata(&recovery) {
1228 Ok(_) => {
1229 return Err(SparError::uncertain_write(format!(
1230 "the agent call changed the linked worktree Git marker at {}, but the \
1231 changed entry could not be retained because {} already exists. The \
1232 original marker was not restored, so no changed entry was deleted. \
1233 Inspect the worktree before retrying.",
1234 path.display(),
1235 recovery.display()
1236 )));
1237 }
1238 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1239 match std::fs::rename(&path, &recovery) {
1240 Ok(()) => true,
1241 Err(e) => {
1242 return Err(SparError::uncertain_write(format!(
1243 "the agent call changed the linked worktree Git marker at {}, but \
1244 the changed entry could not be moved to {}: {e}. The original \
1245 marker was not restored, so no changed entry was deleted. Inspect \
1246 the worktree before retrying.",
1247 path.display(),
1248 recovery.display()
1249 )));
1250 }
1251 }
1252 }
1253 Err(e) => {
1254 return Err(SparError::uncertain_write(format!(
1255 "the agent call changed the linked worktree Git marker at {}, but {} could \
1256 not be inspected: {e}. The original marker was not restored, so no changed \
1257 entry was deleted. Inspect the worktree before retrying.",
1258 path.display(),
1259 recovery.display()
1260 )));
1261 }
1262 },
1263 Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
1264 Err(e) => {
1265 return Err(SparError::uncertain_write(format!(
1266 "the agent call changed the linked worktree Git marker at {}, but the changed \
1267 entry could not be inspected: {e}. The original marker was not restored. \
1268 Inspect the worktree before retrying.",
1269 path.display()
1270 )));
1271 }
1272 };
1273
1274 let restored = replace_git_marker(&path, &self.bytes);
1275 let restore_note = match &restored {
1276 Ok(()) => "The original marker was restored.".to_string(),
1277 Err(e) => format!("The original marker could not be restored: {e}."),
1278 };
1279 let retain_note = if retained {
1280 format!("The changed marker was kept at {}.", recovery.display())
1281 } else {
1282 "The agent call deleted the changed marker, so there was nothing to retain.".to_string()
1283 };
1284 Err(SparError::uncertain_write(format!(
1285 "the agent call changed the linked worktree Git marker at {}. {restore_note} \
1286 {retain_note} Inspect the worktree before retrying.",
1287 path.display()
1288 )))
1289 }
1290}
1291
1292fn after_call_is_quiet<T>(called: &Result<T>, recover: impl FnOnce() -> Result<()>) -> Result<()> {
1295 if let Err(error) = called {
1296 if error.kind() == ErrorKind::UncertainWrite {
1297 return Err(error.clone());
1298 }
1299 }
1300 recover()
1301}
1302
1303fn replace_git_marker(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
1304 let mut marker = std::fs::OpenOptions::new()
1305 .write(true)
1306 .create_new(true)
1307 .open(path)?;
1308 marker.write_all(bytes)
1309}
1310
1311fn ensure_recovery_path_clear(cwd: &Path) -> Result<()> {
1312 let recovery = cwd.join(GIT_MARKER_RECOVERY);
1313 match std::fs::symlink_metadata(&recovery) {
1314 Ok(_) => Err(SparError::uncertain_write(format!(
1315 "{} already exists. Recover or remove it before another agent call.",
1316 recovery.display()
1317 ))),
1318 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
1319 Err(e) => Err(SparError::uncertain_write(format!(
1320 "could not inspect {} before the agent call: {e}",
1321 recovery.display()
1322 ))),
1323 }
1324}
1325
1326#[derive(Debug, Default, Clone)]
1331pub struct Placeholders {
1332 pub prompt: Option<String>,
1333 pub system: Option<String>,
1334 pub model: Option<String>,
1335 pub effort: Option<String>,
1336 pub cwd: Option<String>,
1337 pub schema_file: Option<String>,
1339 pub schema: Option<String>,
1341}
1342
1343impl Placeholders {
1344 fn get(&self, key: &str) -> Option<&str> {
1345 let value = match key {
1346 "prompt" => self.prompt.as_deref(),
1347 "system" => self.system.as_deref(),
1348 "model" => self.model.as_deref(),
1349 "effort" => self.effort.as_deref(),
1350 "cwd" => self.cwd.as_deref(),
1351 "schema_file" => self.schema_file.as_deref(),
1352 "schema" => self.schema.as_deref(),
1353 _ => None,
1354 };
1355 value.filter(|v| !v.is_empty())
1356 }
1357
1358 fn substitute(&self, arg: &str) -> Option<String> {
1361 const KEYS: [&str; 7] = [
1362 "prompt",
1363 "system",
1364 "model",
1365 "effort",
1366 "cwd",
1367 "schema_file",
1368 "schema",
1369 ];
1370 let mut out = arg.to_string();
1371 for key in KEYS {
1372 let token = format!("{{{key}}}");
1373 if out.contains(&token) {
1374 let value = self.get(key)?;
1375 out = out.replace(&token, value);
1376 }
1377 }
1378 Some(out)
1379 }
1380}
1381
1382fn dig<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
1388 if path.is_empty() {
1389 return None;
1390 }
1391 let mut node = value;
1392 for part in path.split('.') {
1393 node = node.as_object()?.get(part)?;
1394 }
1395 Some(node)
1396}
1397
1398fn matches(event: &Value, wanted: &BTreeMap<String, String>) -> bool {
1399 if wanted.is_empty() {
1400 return false;
1401 }
1402 wanted
1403 .iter()
1404 .all(|(path, expected)| dig(event, path).and_then(Value::as_str) == Some(expected.as_str()))
1405}
1406
1407fn as_text(value: &Value) -> Option<String> {
1408 match value {
1409 Value::String(s) => Some(s.clone()),
1410 Value::Null => None,
1411 other => Some(other.to_string()),
1412 }
1413}
1414
1415fn truncate(text: &str, max: usize) -> String {
1416 text.chars().take(max).collect()
1417}
1418
1419struct TempJson {
1426 path: PathBuf,
1427}
1428
1429impl TempJson {
1430 fn write(value: &Value) -> Result<Self> {
1431 use std::sync::atomic::{AtomicU64, Ordering};
1432 static COUNTER: AtomicU64 = AtomicU64::new(0);
1433
1434 let nanos = std::time::SystemTime::now()
1435 .duration_since(std::time::UNIX_EPOCH)
1436 .map(|d| d.as_nanos())
1437 .unwrap_or(0);
1438 let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
1439 let path = std::env::temp_dir().join(format!(
1440 "spar-schema-{}-{nanos}-{unique}.json",
1441 std::process::id()
1442 ));
1443 std::fs::write(&path, serde_json::to_vec_pretty(value)?)
1444 .map_err(|e| spar_err!("could not write a schema file to {}: {e}", path.display()))?;
1445 Ok(Self { path })
1446 }
1447
1448 fn path(&self) -> &Path {
1449 &self.path
1450 }
1451}
1452
1453impl Drop for TempJson {
1454 fn drop(&mut self) {
1455 let _ = std::fs::remove_file(&self.path);
1456 }
1457}
1458
1459fn same_executable(a: &Path, b: &Path) -> bool {
1470 #[cfg(unix)]
1471 {
1472 use std::os::unix::fs::MetadataExt;
1473 if let (Ok(x), Ok(y)) = (std::fs::metadata(a), std::fs::metadata(b)) {
1474 return x.dev() == y.dev() && x.ino() == y.ino();
1475 }
1476 }
1477 match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
1478 (Ok(x), Ok(y)) => x == y,
1479 _ => a == b,
1480 }
1481}
1482
1483pub fn correlation_warning(agents: &[Agent]) -> Option<String> {
1489 for i in 0..agents.len() {
1490 for j in (i + 1)..agents.len() {
1491 let (a, b) = (&agents[i], &agents[j]);
1492 let (Ok(pa), Ok(pb)) = (a.resolve_bin(), b.resolve_bin()) else {
1493 continue;
1494 };
1495 if !same_executable(pa, pb) || a.spec.model_key() != b.spec.model_key() {
1496 continue;
1497 }
1498 let model = if a.spec.model_key().is_empty() {
1499 "the CLI's default".to_string()
1500 } else {
1501 a.spec.model_key()
1502 };
1503 let where_at = if pa == pb {
1504 pa.display().to_string()
1505 } else {
1506 format!(
1507 "the same executable ({} and {} are the same file)",
1508 pa.display(),
1509 pb.display()
1510 )
1511 };
1512 return Some(format!(
1513 "agents '{}' and '{}' both resolve to {where_at} at model {model}. Review \
1514 findings will be correlated: the same model reviewing itself shares the blind \
1515 spots of the model that wrote the code, so it is far less likely to catch what \
1516 the implementer missed. That produces an approval indistinguishable from a real \
1517 review, which is worse than no review at all. Give the two agents different \
1518 CLIs or different models.",
1519 a.name(),
1520 b.name()
1521 ));
1522 }
1523 }
1524 None
1525}
1526
1527pub fn build(cfg: &crate::config::Config) -> Result<Vec<Agent>> {
1530 let agents: Vec<Agent> = cfg
1531 .agents
1532 .iter()
1533 .cloned()
1534 .map(Agent::new)
1535 .map(|agent| agent.with_instructions(&cfg.loop_cfg.instructions))
1536 .collect();
1537 for agent in &agents {
1538 agent.resolve_bin()?;
1539 if let Some(backup) = agent.fallback() {
1543 if backup.resolve_bin().is_err() {
1544 logwarn!(
1545 "{} has a fallback ({}) that is not installed, so it will not stand in",
1546 agent.name(),
1547 backup.program()
1548 );
1549 }
1550 }
1551 }
1552 Ok(agents)
1553}
1554
1555pub fn find<'a>(agents: &'a [Agent], name: &str) -> Result<&'a Agent> {
1557 agents.iter().find(|a| a.name() == name).ok_or_else(|| {
1558 SparError::new(format!(
1559 "no agent named '{name}' ({})",
1560 agents
1561 .iter()
1562 .map(Agent::name)
1563 .collect::<Vec<_>>()
1564 .join(", ")
1565 ))
1566 })
1567}
1568
1569#[cfg(test)]
1570mod tests {
1571 use super::*;
1572 use crate::config::{OutputMode, SystemVia};
1573
1574 fn spec(command: Vec<CommandPart>) -> AgentSpec {
1575 AgentSpec {
1576 name: "test".into(),
1577 command,
1578 model: None,
1579 effort: None,
1580 effort_schedule: crate::config::EffortSchedule::default(),
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
1598 fn group(parts: &[&str]) -> CommandPart {
1599 CommandPart::Group(parts.iter().map(|s| s.to_string()).collect())
1600 }
1601
1602 #[test]
1603 fn an_uncertain_process_result_skips_post_call_recovery() {
1604 let called: Result<()> = Err(SparError::uncertain_write("descendants may still write"));
1605 let recovered = std::cell::Cell::new(false);
1606
1607 let error = after_call_is_quiet(&called, || {
1608 recovered.set(true);
1609 Ok(())
1610 })
1611 .unwrap_err();
1612
1613 assert_eq!(ErrorKind::UncertainWrite, error.kind());
1614 assert!(!recovered.get());
1615 }
1616
1617 fn agent(command: Vec<CommandPart>) -> Agent {
1618 Agent::with_bin(spec(command), "/fake/bin")
1619 }
1620
1621 fn values() -> Placeholders {
1622 Placeholders {
1623 prompt: Some("hi".into()),
1624 ..Default::default()
1625 }
1626 }
1627
1628 #[test]
1631 fn placeholders_are_substituted() {
1632 let a = agent(vec![one("x"), group(&["-m", "{model}"]), one("{prompt}")]);
1633 let v = Placeholders {
1634 model: Some("m1".into()),
1635 ..values()
1636 };
1637 assert_eq!(vec!["/fake/bin", "-m", "m1", "hi"], a.render(&v).unwrap());
1638 }
1639
1640 #[test]
1641 fn an_unset_placeholder_drops_the_whole_group() {
1642 let a = agent(vec![one("x"), group(&["-m", "{model}"]), one("{prompt}")]);
1643 assert_eq!(vec!["/fake/bin", "hi"], a.render(&values()).unwrap());
1644 }
1645
1646 #[test]
1647 fn an_empty_string_drops_the_group_too() {
1648 let a = agent(vec![one("x"), group(&["-e", "{effort}"]), one("{prompt}")]);
1649 let v = Placeholders {
1650 effort: Some(String::new()),
1651 ..values()
1652 };
1653 assert_eq!(vec!["/fake/bin", "hi"], a.render(&v).unwrap());
1654 }
1655
1656 #[test]
1657 fn a_bare_arg_with_an_unset_placeholder_drops() {
1658 let a = agent(vec![one("x"), one("{model}"), one("{prompt}")]);
1659 assert_eq!(vec!["/fake/bin", "hi"], a.render(&values()).unwrap());
1660 }
1661
1662 #[test]
1663 fn literal_args_survive() {
1664 let a = agent(vec![
1665 one("x"),
1666 one("exec"),
1667 one("--json"),
1668 one("--"),
1669 one("{prompt}"),
1670 ]);
1671 assert_eq!(
1672 vec!["/fake/bin", "exec", "--json", "--", "hi"],
1673 a.render(&values()).unwrap()
1674 );
1675 }
1676
1677 #[test]
1678 fn an_embedded_placeholder_substitutes_in_place() {
1679 let a = agent(vec![
1680 one("x"),
1681 group(&["-c", "model_reasoning_effort={effort}"]),
1682 ]);
1683 let v = Placeholders {
1684 effort: Some("ultra".into()),
1685 ..Default::default()
1686 };
1687 assert_eq!(
1688 vec!["/fake/bin", "-c", "model_reasoning_effort=ultra"],
1689 a.render(&v).unwrap()
1690 );
1691 }
1692
1693 #[test]
1694 fn a_group_with_two_placeholders_needs_both() {
1695 let a = agent(vec![
1696 one("x"),
1697 group(&["--a", "{model}", "--b", "{effort}"]),
1698 ]);
1699 let v = Placeholders {
1700 model: Some("m".into()),
1701 ..Default::default()
1702 };
1703 assert_eq!(vec!["/fake/bin"], a.render(&v).unwrap());
1704 }
1705
1706 #[test]
1707 fn supports_schema_detects_the_placeholder() {
1708 assert!(agent(vec![one("x"), group(&["--schema", "{schema_file}"])]).supports_schema());
1709 assert!(!agent(vec![one("x"), one("{prompt}")]).supports_schema());
1710 }
1711
1712 #[test]
1715 fn text_passes_through_trimmed() {
1716 assert_eq!("hello", agent(vec![one("x")]).extract(" hello\n").unwrap());
1717 }
1718
1719 #[test]
1720 fn jsonl_picks_the_matching_event() {
1721 let mut spec = spec(vec![one("x")]);
1722 spec.output = OutputMode::Jsonl;
1723 spec.message_path = Some("item.text".into());
1724 spec.message_match = BTreeMap::from([
1725 ("type".to_string(), "item.completed".to_string()),
1726 ("item.type".to_string(), "agent_message".to_string()),
1727 ]);
1728 let a = Agent::with_bin(spec, "/fake/bin");
1729 let stream = [
1730 r#"{"type":"thread.started","thread_id":"t1"}"#,
1731 r#"{"type":"item.completed","item":{"type":"command_execution","text":"ls"}}"#,
1732 r#"{"type":"item.completed","item":{"type":"agent_message","text":"the answer"}}"#,
1733 "not json at all",
1734 ]
1735 .join("\n");
1736 assert_eq!("the answer", a.extract(&stream).unwrap());
1737 }
1738
1739 #[test]
1740 fn jsonl_raises_on_an_error_with_no_message() {
1741 let mut spec = spec(vec![one("x")]);
1742 spec.output = OutputMode::Jsonl;
1743 spec.message_path = Some("item.text".into());
1744 spec.message_match = BTreeMap::from([("type".into(), "item.completed".into())]);
1745 let a = Agent::with_bin(spec, "/fake/bin");
1746 assert!(a
1747 .extract(r#"{"type":"turn.failed","error":"boom"}"#)
1748 .is_err());
1749 }
1750
1751 #[test]
1752 fn jsonl_joins_several_agent_messages() {
1753 let mut spec = spec(vec![one("x")]);
1754 spec.output = OutputMode::Jsonl;
1755 spec.message_path = Some("text".into());
1756 spec.message_match = BTreeMap::from([("type".into(), "msg".into())]);
1757 let a = Agent::with_bin(spec, "/fake/bin");
1758 let stream = "{\"type\":\"msg\",\"text\":\"one\"}\n{\"type\":\"msg\",\"text\":\"two\"}";
1759 assert_eq!("one\ntwo", a.extract(stream).unwrap());
1760 }
1761
1762 #[test]
1763 fn dig_walks_a_dotted_path() {
1764 let v: Value = serde_json::from_str(r#"{"a":{"b":{"c":1}}}"#).unwrap();
1765 assert_eq!(Some(&Value::from(1)), dig(&v, "a.b.c"));
1766 assert_eq!(None, dig(&v, "a.b.missing"));
1767 assert_eq!(None, dig(&v, ""));
1768 }
1769
1770 #[test]
1773 fn a_missing_binary_lists_everywhere_it_looked() {
1774 let mut s = spec(vec![one("definitely-not-installed-xyz")]);
1775 s.search_paths = vec!["/nowhere/at/all".into()];
1776 s.name = "codex".into();
1777 let err = Agent::new(s).resolve_bin().unwrap_err().to_string();
1778 assert!(err.contains("definitely-not-installed-xyz (PATH)"), "{err}");
1779 assert!(
1780 err.contains("/nowhere/at/all/definitely-not-installed-xyz"),
1781 "{err}"
1782 );
1783 assert!(err.contains("SPAR_CODEX_BIN"), "{err}");
1784 }
1785
1786 #[test]
1787 fn a_search_path_that_already_names_the_binary_is_used_as_is() {
1788 let dir = std::env::temp_dir().join(format!("spar-test-{}", std::process::id()));
1789 std::fs::create_dir_all(&dir).unwrap();
1790 let bin = dir.join("mytool");
1791 std::fs::write(&bin, "#!/bin/sh\n").unwrap();
1792 #[cfg(unix)]
1793 {
1794 use std::os::unix::fs::PermissionsExt;
1795 std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
1796 }
1797 let mut s = spec(vec![one("mytool")]);
1798 s.search_paths = vec![bin.display().to_string()];
1799 assert_eq!(bin, Agent::new(s).resolve_bin().unwrap());
1800 let _ = std::fs::remove_dir_all(&dir);
1801 }
1802
1803 fn named(name: &str, bin: &str, model: Option<&str>) -> Agent {
1806 let mut s = spec(vec![one("prog")]);
1807 s.name = name.into();
1808 s.model = model.map(str::to_string);
1809 Agent::with_bin(s, bin)
1810 }
1811
1812 #[test]
1813 fn same_bin_same_model_warns() {
1814 let agents = vec![
1815 named("alpha", "/usr/local/bin/claude", Some("fable")),
1816 named("beta", "/usr/local/bin/claude", Some("fable")),
1817 ];
1818 let msg = correlation_warning(&agents).expect("should warn");
1819 assert!(msg.contains("alpha") && msg.contains("beta"), "{msg}");
1820 }
1821
1822 #[test]
1823 fn different_model_does_not_warn() {
1824 let agents = vec![
1825 named("a", "/usr/local/bin/claude", Some("fable")),
1826 named("b", "/usr/local/bin/claude", Some("opus")),
1827 ];
1828 assert!(correlation_warning(&agents).is_none());
1829 }
1830
1831 #[test]
1832 fn different_bin_does_not_warn() {
1833 let agents = vec![
1834 named("a", "/usr/local/bin/claude", Some("fable")),
1835 named("b", "/usr/local/bin/codex", Some("fable")),
1836 ];
1837 assert!(correlation_warning(&agents).is_none());
1838 }
1839
1840 #[test]
1841 fn unset_and_empty_model_both_mean_the_default_and_warn() {
1842 let agents = vec![
1843 named("a", "/usr/local/bin/claude", None),
1844 named("b", "/usr/local/bin/claude", Some("")),
1845 ];
1846 let msg = correlation_warning(&agents).expect("should warn");
1847 assert!(msg.contains("the CLI's default"), "{msg}");
1848 }
1849
1850 #[test]
1851 fn a_padded_model_still_warns() {
1852 let agents = vec![
1853 named("a", "/usr/local/bin/claude", Some("fable")),
1854 named("b", "/usr/local/bin/claude", Some(" fable ")),
1855 ];
1856 assert!(correlation_warning(&agents).is_some());
1857 }
1858
1859 #[test]
1860 fn an_empty_model_against_a_named_one_does_not_warn() {
1861 let agents = vec![
1862 named("a", "/usr/local/bin/claude", Some("")),
1863 named("b", "/usr/local/bin/claude", Some("fable")),
1864 ];
1865 assert!(correlation_warning(&agents).is_none());
1866 }
1867
1868 #[cfg(unix)]
1869 #[test]
1870 fn a_symlinked_binary_warns_and_names_both_paths() {
1871 use std::os::unix::fs::PermissionsExt;
1872 let dir = std::env::temp_dir().join(format!("spar-link-{}", std::process::id()));
1873 let _ = std::fs::remove_dir_all(&dir);
1874 std::fs::create_dir_all(&dir).unwrap();
1875 let real = dir.join("claude");
1876 let link = dir.join("claude-alias");
1877 std::fs::write(&real, "#!/bin/sh\n").unwrap();
1878 std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o755)).unwrap();
1879 std::os::unix::fs::symlink(&real, &link).unwrap();
1880
1881 let agents = vec![
1882 named("alpha", real.to_str().unwrap(), Some("fable")),
1883 named("beta", link.to_str().unwrap(), Some("fable")),
1884 ];
1885 let msg = correlation_warning(&agents).expect("should warn");
1886 assert!(msg.contains(real.to_str().unwrap()), "{msg}");
1887 assert!(msg.contains(link.to_str().unwrap()), "{msg}");
1888 let _ = std::fs::remove_dir_all(&dir);
1889 }
1890
1891 #[cfg(unix)]
1892 #[test]
1893 fn two_distinct_real_binaries_stay_quiet() {
1894 use std::os::unix::fs::PermissionsExt;
1895 let dir = std::env::temp_dir().join(format!("spar-distinct-{}", std::process::id()));
1896 let _ = std::fs::remove_dir_all(&dir);
1897 std::fs::create_dir_all(&dir).unwrap();
1898 let mut paths = Vec::new();
1899 for name in ["claude", "codex"] {
1900 let path = dir.join(name);
1901 std::fs::write(&path, "#!/bin/sh\n").unwrap();
1902 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
1903 paths.push(path);
1904 }
1905 let agents = vec![
1906 named("a", paths[0].to_str().unwrap(), Some("fable")),
1907 named("b", paths[1].to_str().unwrap(), Some("fable")),
1908 ];
1909 assert!(correlation_warning(&agents).is_none());
1910 let _ = std::fs::remove_dir_all(&dir);
1911 }
1912
1913 #[test]
1914 fn the_style_rules_ask_for_brevity_and_no_attribution() {
1915 let lower = STYLE_RULES.to_lowercase();
1916 assert!(lower.contains("brief"));
1917 assert!(lower.contains("co-authored-by"));
1918 assert!(lower.contains("em-dash"));
1919 }
1920
1921 #[test]
1926 fn brevity_is_about_facts_per_sentence_not_sentence_count() {
1927 let lower = STYLE_RULES.to_lowercase();
1928 assert!(lower.contains("saying fewer things"), "{STYLE_RULES}");
1929 assert!(
1930 !lower.contains("one sentence beats one paragraph"),
1931 "the rule that produced the density is still there"
1932 );
1933 }
1934
1935 #[test]
1940 fn the_style_rules_reach_the_code_and_not_only_what_is_posted() {
1941 let lower = STYLE_RULES.to_lowercase();
1942 assert!(lower.contains("comments in code you write"), "not in scope");
1943 assert!(
1944 lower.contains("comment code for the reason"),
1945 "no rule for it"
1946 );
1947 }
1948
1949 fn refusal_stream() -> String {
1954 let noise = "{\"type\":\"item.completed\",\"item\":{\"id\":\"i\",\"type\":\"command_execution\",\"output\":\"".to_string()
1955 + &"const x = 1;\\n".repeat(200)
1956 + "\"}}";
1957 [
1958 noise.as_str(),
1959 r#"{"type":"error","message":"This content was flagged for possible cybersecurity risk."}"#,
1960 r#"{"type":"error","message":"This content was flagged for possible cybersecurity risk."}"#,
1961 r#"{"type":"turn.failed","error":{"message":"This content was flagged for possible cybersecurity risk."}}"#,
1962 ]
1963 .join("\n")
1964 }
1965
1966 fn jsonl_agent(name: &str) -> Agent {
1967 let mut spec = spec(vec![one("codex")]);
1968 spec.name = name.into();
1969 spec.output = OutputMode::Jsonl;
1970 spec.message_path = Some("item.text".into());
1971 Agent::with_bin(spec, "/fake/codex")
1972 }
1973
1974 fn failed(stdout: &str, stderr: &str) -> proc::Output {
1975 proc::Output {
1976 stdout: stdout.to_string(),
1977 stdout_bytes: stdout.as_bytes().to_vec(),
1978 stderr: stderr.to_string(),
1979 code: 1,
1980 }
1981 }
1982
1983 #[test]
1986 fn a_jsonl_failure_reports_the_reason_and_not_the_stream() {
1987 let agent = jsonl_agent("codex");
1988 let err = agent.call_failure(&["codex".to_string()], &failed(&refusal_stream(), ""));
1989 let text = err.message();
1990 assert!(
1991 text.contains("flagged for possible cybersecurity risk"),
1992 "{text}"
1993 );
1994 assert!(
1995 !text.contains("const x = 1;"),
1996 "the stream leaked in:\n{text}"
1997 );
1998 assert!(text.len() < 400, "still {} characters:\n{text}", text.len());
1999 }
2000
2001 #[test]
2003 fn the_same_reason_reported_three_times_is_said_once() {
2004 let agent = jsonl_agent("codex");
2005 let err = agent.call_failure(&["codex".to_string()], &failed(&refusal_stream(), ""));
2006 assert_eq!(
2007 1,
2008 err.message().matches("flagged for possible").count(),
2009 "{}",
2010 err.message()
2011 );
2012 }
2013
2014 #[test]
2017 fn stderr_is_kept_because_it_is_where_the_other_half_arrives() {
2018 let agent = jsonl_agent("codex");
2019 let err = agent.call_failure(
2020 &["codex".to_string()],
2021 &failed(
2022 &refusal_stream(),
2023 "ERROR router: agent thread limit reached",
2024 ),
2025 );
2026 assert!(
2027 err.message().contains("agent thread limit reached"),
2028 "{}",
2029 err.message()
2030 );
2031 }
2032
2033 #[test]
2036 fn a_stream_with_no_error_event_falls_back_to_the_raw_output() {
2037 let agent = jsonl_agent("codex");
2038 let err = agent.call_failure(
2039 &["codex".to_string()],
2040 &failed("{\"type\":\"system\"}", "segmentation fault"),
2041 );
2042 assert!(
2043 err.message().contains("segmentation fault"),
2044 "{}",
2045 err.message()
2046 );
2047 assert!(
2048 err.message().starts_with("command failed"),
2049 "{}",
2050 err.message()
2051 );
2052 }
2053
2054 #[test]
2056 fn a_text_agent_is_reported_exactly_as_before() {
2057 let agent = agent(vec![one("mytool")]);
2058 let err = agent.call_failure(&["mytool".to_string()], &failed("some prose", "boom"));
2059 assert!(
2060 err.message().starts_with("command failed"),
2061 "{}",
2062 err.message()
2063 );
2064 assert!(err.message().contains("some prose"), "{}", err.message());
2065 }
2066
2067 #[test]
2070 fn a_reworded_failure_is_still_a_failed_call() {
2071 let agent = jsonl_agent("codex");
2072 let err = agent.call_failure(&["codex".to_string()], &failed(&refusal_stream(), ""));
2073 assert_eq!(ErrorKind::CallFailed, err.kind());
2074 }
2075
2076 #[test]
2079 fn a_request_carries_the_instructions_after_the_task() {
2080 let agent = Agent::with_bin(shell("a", "true"), "/bin/sh")
2081 .with_instructions("Do not wait for CI. Pick it up next pass.");
2082 let asked = agent.instructed("Review the changes on this branch.");
2083 assert!(
2084 asked.starts_with("Review the changes on this branch."),
2085 "{asked}"
2086 );
2087 assert!(asked.contains("Do not wait for CI"), "{asked}");
2088 }
2089
2090 #[test]
2094 fn the_instructions_arrive_subordinate_to_the_request() {
2095 let agent = Agent::with_bin(shell("a", "true"), "/bin/sh").with_instructions("Be quick.");
2096 let asked = agent.instructed("Do the work.").to_lowercase();
2097 assert!(
2098 asked.contains("from the person who started this run"),
2099 "{asked}"
2100 );
2101 assert!(asked.contains("not the shape of your answer"), "{asked}");
2102 }
2103
2104 #[test]
2105 fn nothing_is_added_when_there_are_none() {
2106 let agent = Agent::with_bin(shell("a", "true"), "/bin/sh");
2107 assert_eq!("Do the work.", agent.instructed("Do the work."));
2108 let blank = Agent::with_bin(shell("b", "true"), "/bin/sh").with_instructions(" \n ");
2110 assert_eq!("Do the work.", blank.instructed("Do the work."));
2111 }
2112
2113 #[test]
2116 fn the_stand_in_carries_them_too() {
2117 let agent = with_fallback(shell("primary", "true"), shell("backup", "true"))
2118 .with_instructions("Do not wait for CI.");
2119 let backup = agent.fallback().expect("a stand in");
2120 assert!(backup
2121 .instructed("Do the work.")
2122 .contains("Do not wait for CI."));
2123 }
2124
2125 fn shell(name: &str, line: &str) -> AgentSpec {
2130 let mut spec = spec(vec![one("sh"), one("-c"), one(line)]);
2131 spec.name = name.into();
2132 spec
2133 }
2134
2135 fn with_fallback(mut primary: AgentSpec, backup: AgentSpec) -> Agent {
2136 primary.fallback = Some(Box::new(backup));
2137 Agent::with_bin(primary, "/bin/sh")
2138 }
2139
2140 fn git_at(cwd: &Path, args: &[&str]) -> String {
2141 let out = std::process::Command::new("git")
2142 .args(args)
2143 .current_dir(cwd)
2144 .output()
2145 .unwrap();
2146 assert!(
2147 out.status.success(),
2148 "{}",
2149 String::from_utf8_lossy(&out.stderr)
2150 );
2151 String::from_utf8_lossy(&out.stdout).trim().to_string()
2152 }
2153
2154 fn committed_repo(name: &str) -> PathBuf {
2155 let dir = std::env::temp_dir().join(format!(
2156 "spar-agent-{name}-{}-{}",
2157 std::process::id(),
2158 std::thread::current().name().unwrap_or("test")
2159 ));
2160 let _ = std::fs::remove_dir_all(&dir);
2161 std::fs::create_dir_all(&dir).unwrap();
2162 git_at(&dir, &["init", "-q", "-b", "main"]);
2163 git_at(&dir, &["config", "user.email", "spar@example.invalid"]);
2164 git_at(&dir, &["config", "user.name", "spar test"]);
2165 git_at(&dir, &["config", "commit.gpgsign", "false"]);
2166 std::fs::write(dir.join("README.md"), "seed\n").unwrap();
2167 git_at(&dir, &["add", "README.md"]);
2168 git_at(&dir, &["commit", "-q", "-m", "seed"]);
2169 dir
2170 }
2171
2172 fn linked_worktree(name: &str) -> (PathBuf, PathBuf, PathBuf, Vec<u8>) {
2173 let root = std::env::temp_dir().join(format!(
2174 "spar-agent-{name}-{}-{}",
2175 std::process::id(),
2176 std::thread::current().name().unwrap_or("test")
2177 ));
2178 let _ = std::fs::remove_dir_all(&root);
2179 let main = root.join("main");
2180 std::fs::create_dir_all(&main).unwrap();
2181 git_at(&main, &["init", "-q", "-b", "main"]);
2182 git_at(&main, &["config", "user.email", "spar@example.invalid"]);
2183 git_at(&main, &["config", "user.name", "spar test"]);
2184 git_at(&main, &["config", "commit.gpgsign", "false"]);
2185 std::fs::write(main.join("README.md"), "seed\n").unwrap();
2186 git_at(&main, &["add", "README.md"]);
2187 git_at(&main, &["commit", "-q", "-m", "seed"]);
2188 let linked = root.join("linked");
2189 git_at(
2190 &main,
2191 &[
2192 "worktree",
2193 "add",
2194 "-q",
2195 "-b",
2196 "issue-test",
2197 linked.to_str().unwrap(),
2198 ],
2199 );
2200 let marker = std::fs::read(linked.join(".git")).unwrap();
2201 (root, main, linked, marker)
2202 }
2203
2204 #[test]
2205 fn a_failed_call_is_answered_by_the_fallback() {
2206 let agent = with_fallback(
2207 shell("primary", "echo refused >&2; exit 1"),
2208 shell("backup", "echo stood in"),
2209 );
2210 let answer = agent
2211 .ask("hi", Path::new("."), &Effort::default())
2212 .expect("fallback");
2213 assert_eq!("stood in", answer);
2214 }
2215
2216 #[test]
2217 fn a_failed_edit_with_files_left_does_not_run_the_fallback() {
2218 let dir = committed_repo("dirty-edit-recovery");
2219
2220 let agent = with_fallback(
2221 shell(
2222 "primary",
2223 "printf 'recover me\\n' > README.md; printf refused >&2; exit 1",
2224 ),
2225 shell("backup", "printf 'fallback ran\\n' > fallback.txt"),
2226 );
2227 let err = agent
2228 .edit("change it", &dir, &Effort::default())
2229 .unwrap_err();
2230
2231 assert!(err.message().contains("refused"), "{err}");
2232 assert_eq!(
2233 1,
2234 err.message()
2235 .matches("The call changed the worktree before it failed")
2236 .count(),
2237 "{err}"
2238 );
2239 assert_eq!(
2240 "recover me\n",
2241 std::fs::read_to_string(dir.join("README.md")).unwrap()
2242 );
2243 assert!(!dir.join("fallback.txt").exists());
2244 let _ = std::fs::remove_dir_all(&dir);
2245 }
2246
2247 #[test]
2248 fn a_failed_edit_with_a_new_ignored_file_does_not_run_the_fallback() {
2249 let dir = committed_repo("ignored-edit-recovery");
2250 std::fs::write(dir.join(".gitignore"), "ignored.txt\n").unwrap();
2251 git_at(&dir, &["add", ".gitignore"]);
2252 git_at(&dir, &["commit", "-q", "-m", "ignore fixture"]);
2253 let agent = with_fallback(
2254 shell(
2255 "primary",
2256 "printf 'recover me\n' > ignored.txt; printf refused >&2; exit 1",
2257 ),
2258 shell("backup", "printf 'fallback ran\n' > fallback.txt"),
2259 );
2260
2261 let err = agent
2262 .edit("change it", &dir, &Effort::default())
2263 .unwrap_err();
2264
2265 assert!(err.message().contains("refused"), "{err}");
2266 assert_eq!(
2267 "recover me\n",
2268 std::fs::read_to_string(dir.join("ignored.txt")).unwrap()
2269 );
2270 assert!(!dir.join("fallback.txt").exists());
2271 let _ = std::fs::remove_dir_all(&dir);
2272 }
2273
2274 #[test]
2275 fn a_failed_edit_that_overwrites_an_ignored_file_does_not_run_the_fallback() {
2276 let dir = committed_repo("changed-ignored-edit-recovery");
2277 std::fs::write(dir.join(".gitignore"), "ignored.txt\n").unwrap();
2278 git_at(&dir, &["add", ".gitignore"]);
2279 git_at(&dir, &["commit", "-q", "-m", "ignore fixture"]);
2280 std::fs::write(dir.join("ignored.txt"), "before\n").unwrap();
2281 let agent = with_fallback(
2282 shell(
2283 "primary",
2284 "printf 'after!\\n' > ignored.txt; printf refused >&2; exit 1",
2285 ),
2286 shell("backup", "printf 'fallback ran\n' > fallback.txt"),
2287 );
2288
2289 let err = agent
2290 .edit("change it", &dir, &Effort::default())
2291 .unwrap_err();
2292
2293 assert!(err.message().contains("refused"), "{err}");
2294 assert_eq!(
2295 "after!\n",
2296 std::fs::read_to_string(dir.join("ignored.txt")).unwrap()
2297 );
2298 assert!(!dir.join("fallback.txt").exists());
2299 let _ = std::fs::remove_dir_all(&dir);
2300 }
2301
2302 #[test]
2303 fn a_failed_edit_with_a_commit_does_not_run_the_fallback() {
2304 let dir = committed_repo("committed-edit-recovery");
2305 let agent = with_fallback(
2306 shell(
2307 "primary",
2308 "printf 'recover me\n' > README.md; git add README.md; \
2309 git commit -q -m preserved; printf refused >&2; exit 1",
2310 ),
2311 shell("backup", "printf 'fallback ran\n' > fallback.txt"),
2312 );
2313
2314 let err = agent
2315 .edit("change it", &dir, &Effort::default())
2316 .unwrap_err();
2317
2318 assert!(err.message().contains("refused"), "{err}");
2319 assert_eq!("preserved", git_at(&dir, &["log", "-1", "--pretty=%s"]));
2320 assert_eq!(
2321 "recover me\n",
2322 std::fs::read_to_string(dir.join("README.md")).unwrap()
2323 );
2324 assert!(!dir.join("fallback.txt").exists());
2325 let _ = std::fs::remove_dir_all(&dir);
2326 }
2327
2328 #[test]
2329 fn a_committed_edit_with_malformed_json_is_not_retried_or_handed_over() {
2330 let dir = committed_repo("committed-malformed-edit");
2331 let agent = with_fallback(
2332 shell(
2333 "primary",
2334 "printf 'once\n' >> attempts.txt; printf 'recover me\n' > README.md; \
2335 git add -A; git commit -q -m preserved; printf 'not json\n'",
2336 ),
2337 shell(
2338 "backup",
2339 "printf 'fallback ran\n' > fallback.txt; printf '{}\n'",
2340 ),
2341 );
2342
2343 let err = agent
2344 .edit_json::<Value>(
2345 "change it",
2346 &serde_json::json!({"type": "object"}),
2347 &dir,
2348 &Effort::default(),
2349 )
2350 .unwrap_err();
2351
2352 assert!(err.message().contains("JSON"), "{err}");
2353 assert_eq!("preserved", git_at(&dir, &["log", "-1", "--pretty=%s"]));
2354 assert_eq!(
2355 1,
2356 std::fs::read_to_string(dir.join("attempts.txt"))
2357 .unwrap()
2358 .lines()
2359 .count()
2360 );
2361 assert!(!dir.join("fallback.txt").exists());
2362 let _ = std::fs::remove_dir_all(&dir);
2363 }
2364
2365 #[test]
2366 fn a_failed_edit_recovery_probe_does_not_retry_or_run_the_fallback() {
2367 let dir = committed_repo("failed-edit-probe");
2368 let agent = with_fallback(
2369 shell(
2370 "primary",
2371 "printf 'once\n' >> attempts.txt; mv .git .git-away; printf 'not json\n'",
2372 ),
2373 shell(
2374 "backup",
2375 "printf 'fallback ran\n' > fallback.txt; printf '{}\n'",
2376 ),
2377 );
2378
2379 let err = agent
2380 .edit_json::<Value>(
2381 "change it",
2382 &serde_json::json!({"type": "object"}),
2383 &dir,
2384 &Effort::default(),
2385 )
2386 .unwrap_err();
2387
2388 std::fs::rename(dir.join(".git-away"), dir.join(".git")).unwrap();
2389 assert_eq!(ErrorKind::UncertainWrite, err.kind());
2390 assert!(err.message().contains("JSON"), "{err}");
2391 assert_eq!(
2392 1,
2393 std::fs::read_to_string(dir.join("attempts.txt"))
2394 .unwrap()
2395 .lines()
2396 .count()
2397 );
2398 assert!(!dir.join("fallback.txt").exists());
2399 assert!(dir.join(".git").is_dir());
2400 let _ = std::fs::remove_dir_all(&dir);
2401 }
2402
2403 #[test]
2404 fn a_linked_worktree_git_marker_is_restored_after_an_edit() {
2405 let (root, main, linked, marker) = linked_worktree("changed-git-marker");
2406 let agent = Agent::with_bin(
2407 shell(
2408 "editor",
2409 "printf 'gitdir: /tmp/not-the-repo\\n' > .git; echo done",
2410 ),
2411 "/bin/sh",
2412 );
2413
2414 let err = agent
2415 .edit("change it", &linked, &Effort::default())
2416 .unwrap_err();
2417
2418 assert_eq!(ErrorKind::UncertainWrite, err.kind());
2419 assert!(err.message().contains("Git marker"), "{err}");
2420 assert_eq!(marker, std::fs::read(linked.join(".git")).unwrap());
2421 assert_eq!(
2422 b"gitdir: /tmp/not-the-repo\n",
2423 std::fs::read(linked.join(GIT_MARKER_RECOVERY))
2424 .unwrap()
2425 .as_slice()
2426 );
2427 git_at(
2428 &main,
2429 &["worktree", "remove", "--force", linked.to_str().unwrap()],
2430 );
2431 let _ = std::fs::remove_dir_all(&root);
2432 }
2433
2434 #[test]
2435 fn a_read_call_also_restores_a_linked_worktree_git_marker() {
2436 let (root, main, linked, marker) = linked_worktree("read-call-git-marker");
2437 let agent = with_fallback(
2438 shell(
2439 "reader",
2440 "printf 'gitdir: /tmp/not-the-repo\n' > .git; echo done",
2441 ),
2442 shell("backup", "printf 'fallback ran\n' > fallback.txt"),
2443 );
2444
2445 let err = agent
2446 .ask("read it", &linked, &Effort::default())
2447 .unwrap_err();
2448
2449 assert_eq!(ErrorKind::UncertainWrite, err.kind());
2450 assert_eq!(marker, std::fs::read(linked.join(".git")).unwrap());
2451 assert!(!linked.join("fallback.txt").exists());
2452 git_at(
2453 &main,
2454 &["worktree", "remove", "--force", linked.to_str().unwrap()],
2455 );
2456 let _ = std::fs::remove_dir_all(&root);
2457 }
2458
2459 #[test]
2460 fn a_successful_read_that_writes_is_discarded() {
2461 let dir = committed_repo("successful-read-write");
2462 let agent = Agent::with_bin(
2463 shell("reader", "printf 'recover me\n' > README.md; echo reviewed"),
2464 "/bin/sh",
2465 );
2466
2467 let err = agent.ask("read it", &dir, &Effort::default()).unwrap_err();
2468
2469 assert_eq!(ErrorKind::UncertainWrite, err.kind());
2470 assert!(err.message().contains("read-only call"), "{err}");
2471 assert_eq!(
2472 "recover me\n",
2473 std::fs::read_to_string(dir.join("README.md")).unwrap()
2474 );
2475 assert!(std::fs::read_dir(&dir).unwrap().flatten().any(|entry| entry
2476 .file_name()
2477 .to_string_lossy()
2478 .starts_with(".spar-recovery-needed-")));
2479 let _ = std::fs::remove_dir_all(&dir);
2480 }
2481
2482 fn behind_repo(name: &str) -> PathBuf {
2486 let dir = committed_repo(name);
2487 git_at(&dir, &["checkout", "-q", "-b", "advanced"]);
2488 std::fs::write(dir.join("README.md"), "moved on\n").unwrap();
2489 std::fs::write(dir.join("CHANGELOG.md"), "new file\n").unwrap();
2490 git_at(&dir, &["add", "README.md", "CHANGELOG.md"]);
2491 git_at(&dir, &["commit", "-q", "-m", "advance"]);
2492 git_at(&dir, &["checkout", "-q", "main"]);
2493 dir
2494 }
2495
2496 #[test]
2497 fn a_read_survives_the_branch_moving_under_it() {
2498 let dir = behind_repo("read-branch-moved");
2499 let agent = Agent::with_bin(
2500 shell("reader", "git merge -q --ff-only advanced; echo reviewed"),
2501 "/bin/sh",
2502 );
2503
2504 let answer = agent.ask("read it", &dir, &Effort::default()).unwrap();
2505
2506 assert_eq!("reviewed", answer.trim());
2507 assert_eq!(
2508 "moved on\n",
2509 std::fs::read_to_string(dir.join("README.md")).unwrap()
2510 );
2511 assert!(
2512 !std::fs::read_dir(&dir).unwrap().flatten().any(|entry| entry
2513 .file_name()
2514 .to_string_lossy()
2515 .starts_with(".spar-recovery-needed-"))
2516 );
2517 let _ = std::fs::remove_dir_all(&dir);
2518 }
2519
2520 #[test]
2521 fn a_read_that_writes_while_the_branch_moves_is_still_discarded() {
2522 let dir = behind_repo("read-branch-moved-and-written");
2523 let agent = Agent::with_bin(
2524 shell(
2525 "reader",
2526 "git merge -q --ff-only advanced; printf 'and mine\n' >> README.md; echo reviewed",
2527 ),
2528 "/bin/sh",
2529 );
2530
2531 let err = agent.ask("read it", &dir, &Effort::default()).unwrap_err();
2532
2533 assert_eq!(ErrorKind::UncertainWrite, err.kind());
2534 assert!(err.message().contains("read-only call"), "{err}");
2535 let _ = std::fs::remove_dir_all(&dir);
2536 }
2537
2538 #[test]
2539 fn a_read_in_a_linked_worktree_still_answers_for_a_moved_head() {
2540 let (root, main, linked, _) = linked_worktree("read-worktree-commit");
2541 let agent = Agent::with_bin(
2542 shell(
2543 "reader",
2544 "printf 'mine\n' > README.md; git add -A; git commit -q -m mine; echo reviewed",
2545 ),
2546 "/bin/sh",
2547 );
2548
2549 let err = agent
2550 .ask("read it", &linked, &Effort::default())
2551 .unwrap_err();
2552
2553 assert_eq!(ErrorKind::UncertainWrite, err.kind());
2554 assert!(err.message().contains("read-only call"), "{err}");
2555 git_at(
2556 &main,
2557 &["worktree", "remove", "--force", linked.to_str().unwrap()],
2558 );
2559 let _ = std::fs::remove_dir_all(&root);
2560 }
2561
2562 fn built_repo(name: &str) -> PathBuf {
2564 let dir = committed_repo(name);
2565 std::fs::write(dir.join(".gitignore"), "dist/\nlocal.env\n").unwrap();
2566 git_at(&dir, &["add", ".gitignore"]);
2567 git_at(&dir, &["commit", "-q", "-m", "ignore build output"]);
2568 std::fs::create_dir_all(dir.join("dist")).unwrap();
2569 std::fs::write(dir.join("dist/index.js"), "first build\n").unwrap();
2570 dir
2571 }
2572
2573 #[test]
2574 fn a_read_that_only_rebuilds_generated_output_keeps_its_answer() {
2575 let dir = built_repo("successful-read-build");
2576 let agent = Agent::with_bin(
2577 shell(
2578 "reader",
2579 "printf 'second build\n' > dist/index.js; printf 'more\n' > dist/extra.js; \
2580 echo reviewed",
2581 ),
2582 "/bin/sh",
2583 );
2584
2585 let answer = agent
2586 .ask("read it", &dir, &Effort::default())
2587 .expect("answer kept");
2588
2589 assert_eq!("reviewed", answer.trim());
2590 assert_eq!(
2591 "second build\n",
2592 std::fs::read_to_string(dir.join("dist/index.js")).unwrap()
2593 );
2594 assert!(
2595 !std::fs::read_dir(&dir).unwrap().flatten().any(|entry| entry
2596 .file_name()
2597 .to_string_lossy()
2598 .starts_with(".spar-recovery-needed-"))
2599 );
2600 let _ = std::fs::remove_dir_all(&dir);
2601 }
2602
2603 #[test]
2607 fn a_read_that_writes_a_new_ignored_file_elsewhere_keeps_its_answer() {
2608 let dir = built_repo("successful-read-ignored");
2609 let agent = Agent::with_bin(
2610 shell(
2611 "reader",
2612 "printf 'second build\n' > dist/index.js; printf 'TOKEN=x\n' > local.env; \
2613 echo reviewed",
2614 ),
2615 "/bin/sh",
2616 );
2617
2618 let answer = agent
2619 .ask("read it", &dir, &Effort::default())
2620 .expect("answer kept");
2621
2622 assert_eq!("reviewed", answer.trim());
2623 assert_eq!(
2624 "TOKEN=x\n",
2625 std::fs::read_to_string(dir.join("local.env")).unwrap()
2626 );
2627 assert!(
2628 !std::fs::read_dir(&dir).unwrap().flatten().any(|entry| entry
2629 .file_name()
2630 .to_string_lossy()
2631 .starts_with(".spar-recovery-needed-"))
2632 );
2633 let _ = std::fs::remove_dir_all(&dir);
2634 }
2635
2636 #[test]
2637 fn a_read_that_rewrites_an_existing_ignored_file_elsewhere_is_kept() {
2638 let dir = built_repo("successful-read-existing-ignored");
2639 std::fs::write(dir.join("local.env"), "TOKEN=before\n").unwrap();
2640 let agent = Agent::with_bin(
2641 shell(
2642 "reader",
2643 "printf 'TOKEN=after\n' > local.env; echo reviewed",
2644 ),
2645 "/bin/sh",
2646 );
2647
2648 let answer = agent
2649 .ask("read it", &dir, &Effort::default())
2650 .expect("answer kept");
2651
2652 assert_eq!("reviewed", answer.trim());
2653 assert_eq!(
2654 "TOKEN=after\n",
2655 std::fs::read_to_string(dir.join("local.env")).unwrap()
2656 );
2657 assert!(
2658 !std::fs::read_dir(&dir).unwrap().flatten().any(|entry| entry
2659 .file_name()
2660 .to_string_lossy()
2661 .starts_with(".spar-recovery-needed-"))
2662 );
2663 let _ = std::fs::remove_dir_all(&dir);
2664 }
2665
2666 #[test]
2667 fn a_read_that_deletes_an_existing_ignored_file_is_kept() {
2668 let dir = built_repo("successful-read-deleted-ignored");
2669 std::fs::write(dir.join("local.env"), "TOKEN=before\n").unwrap();
2670 let agent = Agent::with_bin(shell("reader", "rm local.env; echo reviewed"), "/bin/sh");
2671
2672 let answer = agent
2673 .ask("read it", &dir, &Effort::default())
2674 .expect("answer kept");
2675
2676 assert_eq!("reviewed", answer.trim());
2677 assert!(!dir.join("local.env").exists());
2678 let _ = std::fs::remove_dir_all(&dir);
2679 }
2680
2681 #[test]
2682 fn a_read_that_rewrites_an_existing_ordinary_untracked_file_is_discarded() {
2683 let dir = built_repo("successful-read-existing-ordinary");
2684 std::fs::write(dir.join("notes.md"), "before\n").unwrap();
2685 let agent = Agent::with_bin(
2686 shell("reader", "printf 'after\n' > notes.md; echo reviewed"),
2687 "/bin/sh",
2688 );
2689
2690 let err = agent.ask("read it", &dir, &Effort::default()).unwrap_err();
2691
2692 assert_eq!(ErrorKind::UncertainWrite, err.kind());
2693 assert!(err.message().contains("read-only call"), "{err}");
2694 assert!(err.message().contains("notes.md"), "{err}");
2695 let _ = std::fs::remove_dir_all(&dir);
2696 }
2697
2698 #[test]
2699 fn a_read_that_writes_under_spars_own_state_is_not_a_change() {
2700 let dir = built_repo("successful-read-spar-state");
2701 let agent = Agent::with_bin(
2702 shell(
2703 "reader",
2704 "mkdir -p .spar/state; printf '[]' > .spar/state/spend.json; echo reviewed",
2705 ),
2706 "/bin/sh",
2707 );
2708
2709 let answer = agent
2710 .ask("read it", &dir, &Effort::default())
2711 .expect("answer kept");
2712
2713 assert_eq!("reviewed", answer.trim());
2714 let _ = std::fs::remove_dir_all(&dir);
2715 }
2716
2717 #[test]
2718 fn a_read_that_changes_a_tracked_file_is_still_discarded() {
2719 let dir = built_repo("successful-read-tracked");
2720 let agent = Agent::with_bin(
2721 shell(
2722 "reader",
2723 "printf 'second build\n' > dist/index.js; printf 'recover me\n' > README.md; \
2724 echo reviewed",
2725 ),
2726 "/bin/sh",
2727 );
2728
2729 let err = agent.ask("read it", &dir, &Effort::default()).unwrap_err();
2730
2731 assert_eq!(ErrorKind::UncertainWrite, err.kind());
2732 assert!(err.message().contains("read-only call"), "{err}");
2733 assert!(err.message().contains("README.md"), "{err}");
2734 assert_eq!(
2735 "recover me\n",
2736 std::fs::read_to_string(dir.join("README.md")).unwrap()
2737 );
2738 let _ = std::fs::remove_dir_all(&dir);
2739 }
2740
2741 #[test]
2742 fn a_successful_edit_cannot_replace_the_git_directory() {
2743 let dir = committed_repo("replaced-git-directory");
2744 let agent = Agent::with_bin(
2745 shell("editor", "mv .git .git-original; mkdir .git; echo done"),
2746 "/bin/sh",
2747 );
2748
2749 let err = agent
2750 .edit("change it", &dir, &Effort::default())
2751 .unwrap_err();
2752
2753 assert_eq!(ErrorKind::UncertainWrite, err.kind());
2754 assert!(err.message().contains("Git entry"), "{err}");
2755 assert!(dir.join(".git-original").is_dir());
2756 std::fs::remove_dir(dir.join(".git")).unwrap();
2757 std::fs::rename(dir.join(".git-original"), dir.join(".git")).unwrap();
2758 let _ = std::fs::remove_dir_all(&dir);
2759 }
2760
2761 #[test]
2762 fn a_deleted_linked_worktree_git_marker_is_restored_without_a_fallback() {
2763 let (root, main, linked, marker) = linked_worktree("deleted-git-marker");
2764 let agent = with_fallback(
2765 shell("editor", "rm .git; echo done"),
2766 shell("backup", "printf 'fallback ran\n' > fallback.txt"),
2767 );
2768
2769 let err = agent
2770 .edit("change it", &linked, &Effort::default())
2771 .unwrap_err();
2772
2773 assert_eq!(ErrorKind::UncertainWrite, err.kind());
2774 assert!(err.message().contains("deleted"), "{err}");
2775 assert_eq!(marker, std::fs::read(linked.join(".git")).unwrap());
2776 assert!(!linked.join(GIT_MARKER_RECOVERY).exists());
2777 assert!(!linked.join("fallback.txt").exists());
2778 git_at(
2779 &main,
2780 &["worktree", "remove", "--force", linked.to_str().unwrap()],
2781 );
2782 let _ = std::fs::remove_dir_all(&root);
2783 }
2784
2785 #[test]
2786 fn an_occupied_marker_recovery_path_keeps_the_changed_marker() {
2787 let (root, main, linked, marker) = linked_worktree("occupied-marker-recovery");
2788 let agent = with_fallback(
2789 shell(
2790 "editor",
2791 "mkdir .spar-edited-git-marker; \
2792 printf 'gitdir: /tmp/not-the-repo\n' > .git; echo done",
2793 ),
2794 shell("backup", "printf 'fallback ran\n' > fallback.txt"),
2795 );
2796
2797 let err = agent
2798 .edit("change it", &linked, &Effort::default())
2799 .unwrap_err();
2800
2801 assert_eq!(ErrorKind::UncertainWrite, err.kind());
2802 assert!(err.message().contains("already exists"), "{err}");
2803 assert_eq!(
2804 b"gitdir: /tmp/not-the-repo\n",
2805 std::fs::read(linked.join(".git")).unwrap().as_slice()
2806 );
2807 assert!(linked.join(GIT_MARKER_RECOVERY).is_dir());
2808 assert!(!linked.join("fallback.txt").exists());
2809 std::fs::write(linked.join(".git"), marker).unwrap();
2810 git_at(
2811 &main,
2812 &["worktree", "remove", "--force", linked.to_str().unwrap()],
2813 );
2814 let _ = std::fs::remove_dir_all(&root);
2815 }
2816
2817 #[test]
2818 fn without_a_fallback_the_original_error_is_what_surfaces() {
2819 let agent = Agent::with_bin(shell("primary", "echo refused >&2; exit 1"), "/bin/sh");
2820 let err = agent
2821 .ask("hi", Path::new("."), &Effort::default())
2822 .expect_err("no backup");
2823 assert!(err.message().contains("refused"), "{err}");
2824 }
2825
2826 #[test]
2829 fn both_failing_reports_the_primary_first() {
2830 let agent = with_fallback(
2831 shell("primary", "echo policy refusal >&2; exit 1"),
2832 shell("backup", "echo out of quota >&2; exit 1"),
2833 );
2834 let err = agent
2835 .ask("hi", Path::new("."), &Effort::default())
2836 .expect_err("both fail");
2837 let text = err.message();
2838 let primary_at = text.find("policy refusal").expect("primary reason");
2839 let backup_at = text.find("out of quota").expect("backup reason");
2840 assert!(primary_at < backup_at, "{text}");
2841 assert!(
2842 text.contains("primary") && text.contains("backup"),
2843 "{text}"
2844 );
2845 }
2846
2847 fn attempts(name: &str) -> (PathBuf, String) {
2850 let path = std::env::temp_dir().join(format!("spar-attempts-{name}"));
2851 let _ = std::fs::remove_file(&path);
2852 let line = format!("echo x >> {}", path.display());
2853 (path, line)
2854 }
2855
2856 fn counted(path: &Path) -> usize {
2857 std::fs::read_to_string(path)
2858 .map(|t| t.lines().count())
2859 .unwrap_or(0)
2860 }
2861
2862 #[test]
2867 fn a_cli_that_could_not_answer_is_not_asked_twice_when_there_is_a_stand_in() {
2868 let (path, count) = attempts("refused-with-standin");
2869 let agent = with_fallback(
2870 shell("primary", &format!("{count}; echo refused >&2; exit 1")),
2871 shell("backup", "echo '{}'"),
2872 );
2873 let answer: Value = agent
2874 .ask_json(
2875 "q",
2876 &serde_json::json!({"type": "object"}),
2877 Path::new("."),
2878 &Effort::default(),
2879 )
2880 .expect("the stand in answers");
2881 assert!(answer.is_object());
2882 assert_eq!(1, counted(&path), "the primary was asked more than once");
2883 }
2884
2885 #[test]
2888 fn with_no_stand_in_a_failed_call_is_still_retried() {
2889 let (path, count) = attempts("refused-alone");
2890 let agent = Agent::with_bin(
2891 shell("solo", &format!("{count}; echo refused >&2; exit 1")),
2892 "/bin/sh",
2893 );
2894 let err = agent
2895 .ask_json::<Value>(
2896 "q",
2897 &serde_json::json!({"type": "object"}),
2898 Path::new("."),
2899 &Effort::default(),
2900 )
2901 .expect_err("nothing answers");
2902 assert!(err.message().contains("twice"), "{err}");
2903 assert_eq!(2, counted(&path));
2904 }
2905
2906 #[test]
2910 fn an_unusable_answer_is_still_worth_asking_again() {
2911 let (path, count) = attempts("unparsable");
2912 let agent = with_fallback(
2913 shell("primary", &format!("{count}; echo not json at all")),
2914 shell("backup", "echo '{}'"),
2915 );
2916 let answer: Value = agent
2917 .ask_json(
2918 "q",
2919 &serde_json::json!({"type": "object"}),
2920 Path::new("."),
2921 &Effort::default(),
2922 )
2923 .expect("the stand in answers in the end");
2924 assert!(answer.is_object());
2925 assert_eq!(2, counted(&path), "a shape error is worth one more ask");
2926 }
2927
2928 #[test]
2932 fn a_timeout_still_reaches_the_fallback() {
2933 let mut primary = shell("primary", "sleep 30");
2934 primary.timeout = 1;
2935 let agent = with_fallback(primary, shell("backup", "echo stood in"));
2936 assert_eq!(
2937 "stood in",
2938 agent
2939 .ask("hi", Path::new("."), &Effort::default())
2940 .expect("fallback")
2941 );
2942 }
2943
2944 #[test]
2947 fn the_fallback_is_built_alongside_the_agent() {
2948 let mut primary = shell("primary", "true");
2949 primary.fallback = Some(Box::new(shell("backup", "true")));
2950 let agent = Agent::new(primary);
2951 assert_eq!(Some("backup"), agent.fallback().map(Agent::name));
2952 assert!(Agent::new(shell("solo", "true")).fallback().is_none());
2953 }
2954}
2955
2956#[cfg(test)]
2957mod schema_placeholder_tests {
2958 use super::*;
2959 use crate::config::{OutputMode, SystemVia};
2960
2961 fn spec_with(command: Vec<CommandPart>) -> AgentSpec {
2962 AgentSpec {
2963 name: "claude".into(),
2964 command,
2965 model: None,
2966 effort: None,
2967 effort_schedule: crate::config::EffortSchedule::default(),
2968 output: OutputMode::Text,
2969 message_match: BTreeMap::new(),
2970 message_path: None,
2971 search_paths: vec![],
2972 system_via: SystemVia::Prompt,
2973 timeout: 60,
2974 fallback: None,
2975 models: vec![],
2976 efforts: vec![],
2977 options_note: None,
2978 }
2979 }
2980
2981 fn one(s: &str) -> CommandPart {
2982 CommandPart::One(s.into())
2983 }
2984 fn group(parts: &[&str]) -> CommandPart {
2985 CommandPart::Group(parts.iter().map(|s| s.to_string()).collect())
2986 }
2987
2988 #[test]
2991 fn either_schema_form_counts_as_native_support() {
2992 let inline = Agent::with_bin(
2993 spec_with(vec![one("x"), group(&["--json-schema", "{schema}"])]),
2994 "/b",
2995 );
2996 let byfile = Agent::with_bin(
2997 spec_with(vec![one("x"), group(&["--output-schema", "{schema_file}"])]),
2998 "/b",
2999 );
3000 let neither = Agent::with_bin(spec_with(vec![one("x"), one("{prompt}")]), "/b");
3001 assert!(inline.supports_schema());
3002 assert!(byfile.supports_schema());
3003 assert!(!neither.supports_schema());
3004 }
3005
3006 #[test]
3007 fn the_inline_schema_is_substituted_whole() {
3008 let agent = Agent::with_bin(
3009 spec_with(vec![
3010 one("x"),
3011 group(&["--json-schema", "{schema}"]),
3012 one("{prompt}"),
3013 ]),
3014 "/b",
3015 );
3016 let values = Placeholders {
3017 prompt: Some("review it".into()),
3018 schema: Some(r#"{"type":"object"}"#.into()),
3019 ..Default::default()
3020 };
3021 assert_eq!(
3022 vec!["/b", "--json-schema", r#"{"type":"object"}"#, "review it"],
3023 agent.render(&values).unwrap()
3024 );
3025 }
3026
3027 #[test]
3030 fn the_schema_flag_drops_when_no_schema_is_wanted() {
3031 let agent = Agent::with_bin(
3032 spec_with(vec![
3033 one("x"),
3034 group(&["--json-schema", "{schema}"]),
3035 one("{prompt}"),
3036 ]),
3037 "/b",
3038 );
3039 let values = Placeholders {
3040 prompt: Some("implement it".into()),
3041 ..Default::default()
3042 };
3043 assert_eq!(vec!["/b", "implement it"], agent.render(&values).unwrap());
3044 }
3045
3046 #[test]
3048 fn the_two_schema_placeholders_do_not_collide() {
3049 let agent = Agent::with_bin(
3050 spec_with(vec![one("x"), group(&["--output-schema", "{schema_file}"])]),
3051 "/b",
3052 );
3053 let values = Placeholders {
3054 schema: Some("INLINE".into()),
3055 schema_file: Some("/tmp/s.json".into()),
3056 ..Default::default()
3057 };
3058 assert_eq!(
3059 vec!["/b", "--output-schema", "/tmp/s.json"],
3060 agent.render(&values).unwrap()
3061 );
3062 }
3063
3064 #[test]
3066 fn the_shipped_claude_preset_now_has_native_structured_output() {
3067 let raw = crate::config::load_preset("claude").unwrap();
3068 let table = raw.as_table().cloned().unwrap();
3069 let mut spec: AgentSpec = toml::Value::Table(table).try_into().unwrap();
3070 spec.name = "claude".into();
3071 assert!(
3072 Agent::with_bin(spec, "/b").supports_schema(),
3073 "without this a long review is parsed out of prose and truncates"
3074 );
3075 }
3076}