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, safe_git_state, uncertain_worktree_change,
21 AttributeState, GitState, IgnoredState,
22};
23use crate::{bail, log, logdim, logwarn, spar_err};
24
25pub const STYLE_RULES: &str = "\
38Style rules for every artifact you produce (commits, PR titles, PR bodies, issue
39titles, issue bodies, review comments, and the comments in code you write):
40- Never use em-dashes or en-dashes. Use commas, colons, or parentheses.
41- Never mention Claude, Codex, OpenAI, ChatGPT, Anthropic, AI, or any tooling
42 used to produce the work.
43- Never add a Co-Authored-By trailer or a \"Generated with\" footer to commits.
44- Be brief. A human engineer with other work has to read this. Lead with the
45 point, cut the preamble, stop when you are done. Do not restate the task, do
46 not announce what you are about to do, do not summarise what the diff already
47 shows.
48- Brief means saying fewer things, never packing more into a sentence. Two
49 plain sentences beat one that has to be read twice. Split a sentence that
50 carries three facts, and split one that makes the reader hold an identifier
51 in their head to parse the rest of the clause. A comma splice joining two
52 ideas to save a full stop costs the reader more than the full stop would.
53- No headings, bullet lists, or bold text in anything only a few sentences long.
54- Comment code for the reason, not the change. A comment earns its length from
55 what the code cannot say for itself: a constraint that is not local, an
56 alternative that was tried and does not work, a surprise the next reader would
57 otherwise trip on. Write the reason that holds now, not the investigation that
58 found it. A paragraph above a three line change is almost always the debugging
59 story, and the reader wants the conclusion of it.
60Write as a human engineer would, because the reader neither knows nor cares what
61produced the work.";
62
63const JSON_INSTRUCTION: &str = "Respond with ONLY a JSON object matching this \
64schema. No prose, no markdown fences, no commentary before or after:";
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67enum Access {
68 Read,
69 Edit,
70}
71
72const INSTRUCTIONS_HEADER: &str = "Additional instructions from the person who \
79started this run. They change how you work, not what was asked for above and \
80not the shape of your answer:";
81
82pub struct Agent {
83 pub spec: AgentSpec,
84 fallback: Option<Box<Agent>>,
88 instructions: Option<String>,
90 resolved: OnceLock<PathBuf>,
91}
92
93impl std::fmt::Debug for Agent {
94 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95 write!(f, "<{} {}>", self.spec.name, self.spec.describe())
96 }
97}
98
99impl Agent {
100 pub fn new(spec: AgentSpec) -> Self {
101 let fallback = spec
102 .fallback
103 .clone()
104 .map(|backup| Box::new(Agent::new(*backup)));
105 Self {
106 spec,
107 fallback,
108 instructions: None,
109 resolved: OnceLock::new(),
110 }
111 }
112
113 pub fn with_instructions(mut self, text: &str) -> Self {
119 let text = text.trim();
120 if text.is_empty() {
121 return self;
122 }
123 if let Some(backup) = self.fallback.take() {
124 self.fallback = Some(Box::new(backup.with_instructions(text)));
125 }
126 self.instructions = Some(text.to_string());
127 self
128 }
129
130 fn instructed(&self, prompt: &str) -> String {
136 match &self.instructions {
137 Some(extra) => format!("{prompt}\n\n{INSTRUCTIONS_HEADER}\n{extra}"),
138 None => prompt.to_string(),
139 }
140 }
141
142 pub fn name(&self) -> &str {
143 &self.spec.name
144 }
145
146 pub fn fallback(&self) -> Option<&Agent> {
148 self.fallback.as_deref()
149 }
150
151 pub fn program(&self) -> &str {
154 match self.spec.command.first() {
155 Some(CommandPart::One(program)) => program,
156 _ => self.name(),
157 }
158 }
159
160 pub fn env_key(&self) -> String {
162 format!(
163 "SPAR_{}_BIN",
164 self.spec.name.to_uppercase().replace('-', "_")
165 )
166 }
167
168 #[doc(hidden)]
171 pub fn with_bin(spec: AgentSpec, bin: impl Into<PathBuf>) -> Self {
172 let agent = Self::new(spec);
173 let _ = agent.resolved.set(bin.into());
174 agent
175 }
176
177 pub fn resolve_bin(&self) -> Result<&Path> {
184 if let Some(found) = self.resolved.get() {
185 return Ok(found.as_path());
186 }
187 let found = self.locate()?;
188 let _ = self.resolved.set(found);
189 Ok(self.resolved.get().expect("just set").as_path())
190 }
191
192 fn locate(&self) -> Result<PathBuf> {
193 let wanted = match self.spec.command.first() {
194 Some(CommandPart::One(program)) => program.clone(),
195 _ => bail!("agent '{}' has no command configured", self.spec.name),
196 };
197
198 let env_key = self.env_key();
199 let env_override = std::env::var(&env_key)
200 .ok()
201 .filter(|v| !v.trim().is_empty());
202
203 let mut tried: Vec<String> = Vec::new();
204
205 for candidate in env_override
206 .iter()
207 .map(String::as_str)
208 .chain([wanted.as_str()])
209 {
210 let path = Path::new(candidate);
211 if path.is_absolute() || candidate.contains(std::path::MAIN_SEPARATOR) {
212 let expanded = proc::expand_tilde(candidate);
213 tried.push(expanded.display().to_string());
214 if proc::is_executable(&expanded) {
215 return Ok(expanded);
216 }
217 } else {
218 tried.push(format!("{candidate} (PATH)"));
219 if let Some(found) = proc::which(candidate) {
220 return Ok(found);
221 }
222 }
223 }
224
225 for base in &self.spec.search_paths {
226 let base = proc::expand_tilde(base);
227 let candidate = if base.file_name().and_then(|n| n.to_str()) == Some(wanted.as_str()) {
228 base
229 } else {
230 base.join(&wanted)
231 };
232 tried.push(candidate.display().to_string());
233 if proc::is_executable(&candidate) {
234 return Ok(candidate);
235 }
236 }
237
238 Err(spar_err!(
239 "could not find the binary for agent '{}'. Tried:\n {}\nSet agents.{}.command[0] to \
240 an absolute path, or {}=/path/to/binary.",
241 self.spec.name,
242 tried.join("\n "),
243 self.spec.name,
244 env_key
245 ))
246 }
247
248 pub fn render(&self, values: &Placeholders) -> Result<Vec<String>> {
254 let mut out = vec![self.resolve_bin()?.display().to_string()];
255 for part in self.spec.command.iter().skip(1) {
256 let mut rendered = Vec::new();
257 let mut skip = false;
258 for arg in part.args() {
259 match values.substitute(arg) {
260 Some(text) => rendered.push(text),
261 None => {
262 skip = true;
263 break;
264 }
265 }
266 }
267 if !skip {
268 out.extend(rendered);
269 }
270 }
271 Ok(out)
272 }
273
274 pub fn supports_schema(&self) -> bool {
280 self.spec
281 .command
282 .iter()
283 .flat_map(|p| p.args())
284 .any(|a| a.contains("{schema_file}") || a.contains("{schema}"))
285 }
286
287 pub fn extract(&self, stdout: &str) -> Result<String> {
290 match self.spec.output {
291 OutputMode::Text | OutputMode::Json => Ok(stdout.trim().to_string()),
292 OutputMode::Jsonl => self.extract_jsonl(stdout),
293 }
294 }
295
296 fn extract_jsonl(&self, stdout: &str) -> Result<String> {
297 let mut messages: Vec<String> = Vec::new();
298
299 for line in stdout.lines() {
300 let line = line.trim();
301 if !line.starts_with('{') {
302 continue;
303 }
304 let Ok(event) = serde_json::from_str::<Value>(line) else {
305 continue;
306 };
307 if matches(&event, &self.spec.message_match) {
308 if let Some(text) = dig(&event, self.spec.message_path.as_deref().unwrap_or("")) {
309 if let Some(text) = as_text(text) {
310 messages.push(text);
311 }
312 }
313 }
314 }
315
316 if messages.is_empty() {
317 let reasons = self.error_events(stdout);
318 if !reasons.is_empty() {
319 return Err(SparError::call_failed(format!(
322 "agent '{}' failed: {}",
323 self.spec.name,
324 reasons.join("; ")
325 )));
326 }
327 }
328 Ok(messages.join("\n").trim().to_string())
329 }
330
331 fn error_events(&self, stdout: &str) -> Vec<String> {
341 let mut reasons: Vec<String> = Vec::new();
342 for line in stdout.lines() {
343 let line = line.trim();
344 if !line.starts_with('{') {
345 continue;
346 }
347 let Ok(event) = serde_json::from_str::<Value>(line) else {
348 continue;
349 };
350 if !matches!(
351 event.get("type").and_then(Value::as_str),
352 Some("turn.failed") | Some("error")
353 ) {
354 continue;
355 }
356 let reason = dig(&event, "message")
357 .or_else(|| dig(&event, "error.message"))
358 .and_then(as_text)
359 .unwrap_or_else(|| truncate(&event.to_string(), 400));
360 if !reason.trim().is_empty() && !reasons.contains(&reason) {
361 reasons.push(reason);
362 }
363 }
364 reasons
365 }
366
367 fn call_failure(&self, argv: &[String], out: &proc::Output) -> SparError {
379 if self.spec.output != OutputMode::Jsonl {
380 return SparError::call_failed(proc::failure_message(argv, out));
381 }
382 let reasons = self.error_events(&out.stdout);
383 if reasons.is_empty() {
384 return SparError::call_failed(proc::failure_message(argv, out));
385 }
386 let mut text = format!(
387 "agent '{}' could not answer (exit {}): {}",
388 self.spec.name,
389 out.code,
390 reasons.join("; ")
391 );
392 let stderr = out.stderr.trim();
393 if !stderr.is_empty() {
394 text.push_str(&format!("\n--- stderr ---\n{stderr}"));
395 }
396 text.push_str(&format!("\n--- command ---\n{}", proc::abbreviate(argv)));
397 SparError::call_failed(text)
398 }
399
400 pub fn ask(&self, prompt: &str, cwd: &Path, effort: Option<&str>) -> Result<String> {
403 let baseline = EditBaseline::capture(cwd)?;
404 self.ask_with_access(prompt, cwd, effort, Access::Read, Some(&baseline))
405 }
406
407 pub fn edit(&self, prompt: &str, cwd: &Path, effort: Option<&str>) -> Result<String> {
411 let baseline = EditBaseline::capture(cwd)?;
412 self.ask_with_access(prompt, cwd, effort, Access::Edit, Some(&baseline))
413 }
414
415 fn ask_with_access(
416 &self,
417 prompt: &str,
418 cwd: &Path,
419 effort: Option<&str>,
420 access: Access,
421 baseline: Option<&EditBaseline>,
422 ) -> Result<String> {
423 let prompt = &self.instructed(prompt);
424 let result = match self.ask_inner(prompt, cwd, effort, None, None, access) {
425 Ok(text) => Ok(text),
426 Err(e) => match recovery_error(&e, baseline, cwd, access) {
427 Some(recovery) => Err(recovery),
428 None => self.hand_over(e, |backup| {
429 backup.ask_with_access(prompt, cwd, None, access, baseline)
430 }),
431 },
432 };
433 finish_call(result, access, baseline, cwd)
434 }
435
436 fn hand_over<T>(&self, primary: SparError, run: impl FnOnce(&Agent) -> Result<T>) -> Result<T> {
447 let Some(backup) = self.fallback() else {
448 return Err(primary);
449 };
450 logwarn!(
451 "{} could not answer. Handing the call to {}.\n{primary}",
452 self.name(),
453 backup.name()
454 );
455 match run(backup) {
456 Ok(answer) => {
457 log!("{} answered in place of {}", backup.name(), self.name());
458 Ok(answer)
459 }
460 Err(second) => {
464 let message = format!(
465 "agent '{}' failed and its fallback '{}' could not stand in.\n{}\n\n{}: {}",
466 self.name(),
467 backup.name(),
468 primary.message(),
469 backup.name(),
470 second.message()
471 );
472 Err(second.with_message(message))
473 }
474 }
475 }
476
477 fn ask_inner(
478 &self,
479 prompt: &str,
480 cwd: &Path,
481 effort: Option<&str>,
482 schema_file: Option<&Path>,
483 schema: Option<&str>,
484 access: Access,
485 ) -> Result<String> {
486 let body = match self.spec.system_via {
487 SystemVia::Placeholder => prompt.to_string(),
488 SystemVia::Prompt => format!("{STYLE_RULES}\n\n{prompt}"),
489 };
490 let values = Placeholders {
491 prompt: Some(body),
492 system: Some(STYLE_RULES.to_string()),
493 model: self.spec.model.clone(),
494 effort: effort
495 .map(str::to_string)
496 .or_else(|| self.spec.effort.clone()),
497 cwd: Some(cwd.display().to_string()),
498 schema_file: schema_file.map(|p| p.display().to_string()),
499 schema: schema.map(str::to_string),
500 };
501 let argv = self.render(&values)?;
502 let opts = ExecOpts::new()
506 .cwd(cwd)
507 .timeout_secs(self.spec.timeout)
508 .stop_descendants(true)
509 .check(false);
510 let git_file = match access {
513 Access::Read | Access::Edit => GitFile::capture(cwd)?,
514 };
515 let called = proc::exec(&argv, &opts);
516 after_call_is_quiet(&called, || {
517 if let Some(git_file) = git_file {
518 git_file.restore_if_changed(cwd)?;
519 }
520 Ok(())
521 })?;
522 let out = called?;
523 if !out.ok() {
524 return Err(self.call_failure(&argv, &out));
525 }
526 self.extract(&out.stdout)
527 }
528
529 pub fn ask_json<T: serde::de::DeserializeOwned>(
533 &self,
534 prompt: &str,
535 schema: &Value,
536 cwd: &Path,
537 effort: Option<&str>,
538 ) -> Result<T> {
539 let baseline = EditBaseline::capture(cwd)?;
540 self.ask_json_with_access(prompt, schema, cwd, effort, Access::Read, Some(&baseline))
541 }
542
543 pub fn edit_json<T: serde::de::DeserializeOwned>(
547 &self,
548 prompt: &str,
549 schema: &Value,
550 cwd: &Path,
551 effort: Option<&str>,
552 ) -> Result<T> {
553 let baseline = EditBaseline::capture(cwd)?;
554 self.ask_json_with_access(prompt, schema, cwd, effort, Access::Edit, Some(&baseline))
555 }
556
557 fn ask_json_with_access<T: serde::de::DeserializeOwned>(
558 &self,
559 prompt: &str,
560 schema: &Value,
561 cwd: &Path,
562 effort: Option<&str>,
563 access: Access,
564 baseline: Option<&EditBaseline>,
565 ) -> Result<T> {
566 let prompt = &self.instructed(prompt);
569 let result = match self.ask_json_retrying(prompt, schema, cwd, effort, access, baseline) {
570 Ok(parsed) => Ok(parsed),
571 Err(e) => match recovery_error(&e, baseline, cwd, access) {
572 Some(recovery) => Err(recovery),
573 None => self.hand_over(e, |backup| {
574 backup.ask_json_retrying::<T>(prompt, schema, cwd, None, access, baseline)
575 }),
576 },
577 };
578 finish_call(result, access, baseline, cwd)
579 }
580
581 fn worth_asking_again(&self, e: &SparError) -> bool {
594 match e.kind() {
595 ErrorKind::TimedOut => false,
596 ErrorKind::UncertainWrite => false,
597 ErrorKind::CallFailed => self.fallback().is_none(),
598 ErrorKind::Other => true,
599 }
600 }
601
602 fn ask_json_retrying<T: serde::de::DeserializeOwned>(
604 &self,
605 prompt: &str,
606 schema: &Value,
607 cwd: &Path,
608 effort: Option<&str>,
609 access: Access,
610 baseline: Option<&EditBaseline>,
611 ) -> Result<T> {
612 const ATTEMPTS: usize = 2;
618 let mut last: Option<SparError> = None;
619
620 for attempt in 1..=ATTEMPTS {
621 let asked = match &last {
622 None => prompt.to_string(),
623 Some(e) => format!(
624 "{prompt}\n\nYour previous answer could not be used: {}\nReturn the whole \
625 object this time, exactly matching the schema, and nothing else.",
626 e.first_line()
627 ),
628 };
629 match self.ask_json_once::<T>(&asked, schema, cwd, effort, access) {
630 Ok(parsed) => {
631 if attempt > 1 {
632 logdim!("{} answered on the retry", self.spec.name);
633 }
634 return Ok(parsed);
635 }
636 Err(e) => {
640 if let Some(recovery) = recovery_error(&e, baseline, cwd, access) {
641 return Err(recovery);
642 }
643 if !self.worth_asking_again(&e) {
644 return Err(e);
645 }
646 if attempt < ATTEMPTS {
647 logwarn!("{} failed, asking again.\n{e}", self.spec.name);
652 }
653 last = Some(e);
654 }
655 }
656 }
657 Err(spar_err!(
658 "agent '{}' returned an unusable answer twice: {}",
659 self.spec.name,
660 last.expect("at least one attempt").message()
661 ))
662 }
663
664 fn ask_json_once<T: serde::de::DeserializeOwned>(
665 &self,
666 prompt: &str,
667 schema: &Value,
668 cwd: &Path,
669 effort: Option<&str>,
670 access: Access,
671 ) -> Result<T> {
672 let text = if self.supports_schema() {
673 let inline = serde_json::to_string(schema).unwrap_or_default();
674 let file = TempJson::write(schema)?;
675 self.ask_inner(
676 prompt,
677 cwd,
678 effort,
679 Some(file.path()),
680 Some(&inline),
681 access,
682 )?
683 } else {
684 let full = format!(
685 "{prompt}\n\n{JSON_INSTRUCTION}\n{}",
686 serde_json::to_string_pretty(schema).unwrap_or_default()
687 );
688 self.ask_inner(&full, cwd, effort, None, None, access)?
689 };
690 jsonx::extract_into(&text)
691 }
692
693 pub fn review<T: serde::de::DeserializeOwned>(
707 &self,
708 base: &str,
709 prompt: &str,
710 schema: &Value,
711 cwd: &Path,
712 effort: Option<&str>,
713 ) -> Result<T> {
714 let scoped = format!(
715 "{prompt}\n\nThe changes under review are the diff between `{base}` and HEAD in your \
716 working directory. Inspect them with git, then read the surrounding code before \
717 judging. Do not review only the diff.\n\nThis call is a review and nothing else. Do \
718 not edit the code under review, do not commit, and do not push: somebody else acts \
719 on what you find, and a reviewer that writes ends up reviewing its own work. Put any \
720 scratch file under the system temporary directory, not in the working tree."
721 );
722 self.ask_json(&scoped, schema, cwd, effort)
723 }
724}
725
726const GIT_MARKER_RECOVERY: &str = ".spar-edited-git-marker";
727
728struct EditBaseline {
734 attributes: AttributeState,
735 git_state: GitState,
736 git_entry: GitEntry,
737 ignored_untracked: IgnoredState,
738}
739
740impl EditBaseline {
741 fn capture(cwd: &Path) -> Result<Self> {
742 let git_entry = GitEntry::capture(cwd)?;
743 if matches!(&git_entry, GitEntry::File) {
744 ensure_recovery_path_clear(cwd)?;
745 }
746 let attributes = attribute_state(cwd).map_err(|e| {
747 e.with_message(format!(
748 "could not record attribute files before a call in {}: {}",
749 cwd.display(),
750 e.last_line()
751 ))
752 })?;
753 let git_state = safe_git_state(cwd).map_err(|e| {
754 e.with_message(format!(
755 "could not record a safe Git state before editing {}: {}",
756 cwd.display(),
757 e.last_line()
758 ))
759 })?;
760 let ignored_untracked = ignored_untracked_state(cwd).map_err(|e| {
761 e.with_message(format!(
762 "could not record ignored files before editing {}: {}",
763 cwd.display(),
764 e.last_line()
765 ))
766 })?;
767 Ok(Self {
768 attributes,
769 git_state,
770 git_entry,
771 ignored_untracked,
772 })
773 }
774
775 fn recovery_needed(&self, cwd: &Path) -> Result<bool> {
776 if !self.git_entry.still_matches(cwd)? {
777 return Err(uncertain_worktree_change(
778 cwd,
779 format!(
780 "the Git entry at {} changed type during the editing call. No Git recovery \
781 probe was run. Inspect the worktree before retrying.",
782 cwd.join(".git").display()
783 ),
784 ));
785 }
786 let attributes = attribute_state(cwd).map_err(|e| {
787 uncertain_worktree_change(
788 cwd,
789 format!(
790 "could not check attribute files after a call in {}: {}. Inspect the \
791 worktree before retrying.",
792 cwd.display(),
793 e.last_line()
794 ),
795 )
796 })?;
797 if attributes != self.attributes {
798 return Err(uncertain_worktree_change(
799 cwd,
800 format!(
801 "an attribute file changed during a call in {}. The worktree was kept before \
802 running any Git operation that could select a new filter.",
803 cwd.display()
804 ),
805 ));
806 }
807 let current = git_state(cwd).map_err(|e| recovery_probe_error(cwd, "state", &e))?;
808 let ignored = ignored_untracked_state(cwd)
809 .map_err(|e| recovery_probe_error(cwd, "ignored files", &e))?;
810 Ok(current != self.git_state || ignored != self.ignored_untracked)
811 }
812}
813
814#[derive(Clone, PartialEq, Eq)]
815enum GitEntry {
816 Directory(GitDirectory),
817 File,
818}
819
820#[derive(Clone, PartialEq, Eq)]
821struct GitDirectory {
822 #[cfg(unix)]
823 device: u64,
824 #[cfg(unix)]
825 inode: u64,
826 #[cfg(not(unix))]
827 created: Option<std::time::SystemTime>,
828}
829
830impl GitEntry {
831 fn capture(cwd: &Path) -> Result<Self> {
832 let path = cwd.join(".git");
833 match std::fs::symlink_metadata(&path) {
834 Ok(meta) if meta.is_dir() => {
835 #[cfg(unix)]
836 {
837 use std::os::unix::fs::MetadataExt;
838 Ok(Self::Directory(GitDirectory {
839 device: meta.dev(),
840 inode: meta.ino(),
841 }))
842 }
843 #[cfg(not(unix))]
844 {
845 Ok(Self::Directory(GitDirectory {
846 created: meta.created().ok(),
847 }))
848 }
849 }
850 Ok(meta) if meta.is_file() => Ok(Self::File),
851 Ok(_) => bail!(
852 "{} is not a regular Git directory or marker",
853 path.display()
854 ),
855 Err(e) => Err(spar_err!("could not inspect {}: {e}", path.display())),
856 }
857 }
858
859 fn still_matches(&self, cwd: &Path) -> Result<bool> {
860 let path = cwd.join(".git");
861 match (self, std::fs::symlink_metadata(path)) {
862 (Self::Directory(before), Ok(meta)) if meta.is_dir() => {
863 #[cfg(unix)]
864 {
865 use std::os::unix::fs::MetadataExt;
866 Ok(before.device == meta.dev() && before.inode == meta.ino())
867 }
868 #[cfg(not(unix))]
869 {
870 Ok(before.created == meta.created().ok())
871 }
872 }
873 (Self::Directory(_), Ok(_)) => Ok(false),
874 (Self::File, Ok(meta)) => Ok(meta.is_file()),
875 (_, Err(e)) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
876 (_, Err(e)) => Err(SparError::uncertain_write(format!(
877 "could not inspect the Git entry at {} after the editing call: {e}",
878 cwd.join(".git").display()
879 ))),
880 }
881 }
882}
883
884fn recovery_probe_error(cwd: &Path, probe: &str, error: &SparError) -> SparError {
885 uncertain_worktree_change(
886 cwd,
887 format!(
888 "could not check the Git {probe} after an editing call in {}: {}. Inspect the \
889 worktree before retrying.",
890 cwd.display(),
891 error.last_line()
892 ),
893 )
894}
895
896fn recovery_error(
897 error: &SparError,
898 baseline: Option<&EditBaseline>,
899 cwd: &Path,
900 access: Access,
901) -> Option<SparError> {
902 if error.kind() == ErrorKind::UncertainWrite {
905 return Some(error.clone());
906 }
907 let baseline = baseline?;
908 match baseline.recovery_needed(cwd) {
909 Ok(false) => None,
910 Ok(true) if access == Access::Edit => Some(changed_edit_failure(error)),
911 Ok(true) => Some(uncertain_worktree_change(
912 cwd,
913 format!(
914 "{}\nA read-only call changed the worktree before it failed. Its answer was \
915 discarded and the worktree was kept for recovery.",
916 error.message()
917 ),
918 )),
919 Err(recovery) => Some(SparError::uncertain_write(format!(
920 "{}\n{}",
921 error.message(),
922 recovery.message()
923 ))),
924 }
925}
926
927fn changed_edit_failure(error: &SparError) -> SparError {
928 const NOTE: &str =
929 "The call changed the worktree before it failed. It was not retried or handed to a fallback.";
930 if error.message().contains(NOTE) {
931 return error.clone();
932 }
933 error.with_message(format!("{}\n{NOTE}", error.message()))
934}
935
936fn finish_call<T>(
937 result: Result<T>,
938 access: Access,
939 baseline: Option<&EditBaseline>,
940 cwd: &Path,
941) -> Result<T> {
942 let value = result?;
943 let Some(baseline) = baseline else {
944 return Ok(value);
945 };
946 if access == Access::Edit {
947 if baseline.git_entry.still_matches(cwd)? {
948 return Ok(value);
949 }
950 return Err(uncertain_worktree_change(
951 cwd,
952 format!(
953 "the Git entry at {} was replaced during an editing call. The result was \
954 discarded before any Git operation ran.",
955 cwd.join(".git").display()
956 ),
957 ));
958 }
959 match baseline.recovery_needed(cwd) {
960 Ok(false) => Ok(value),
961 Ok(true) => Err(uncertain_worktree_change(
962 cwd,
963 "a read-only call changed the worktree. Its answer was discarded and the worktree \
964 was kept for recovery.",
965 )),
966 Err(error) => Err(error),
967 }
968}
969
970struct GitFile {
975 bytes: Vec<u8>,
976}
977
978impl GitFile {
979 fn capture(cwd: &Path) -> Result<Option<Self>> {
980 let path = cwd.join(".git");
981 match std::fs::symlink_metadata(&path) {
982 Ok(meta) if meta.is_dir() => Ok(None),
983 Ok(meta) if meta.is_file() => {
984 ensure_recovery_path_clear(cwd)?;
985 let bytes = std::fs::read(&path)
986 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
987 Ok(Some(Self { bytes }))
988 }
989 Ok(_) => bail!(
990 "{} is not a regular Git marker. Refusing to run an editing call.",
991 path.display()
992 ),
993 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
994 Err(e) => Err(spar_err!("could not inspect {}: {e}", path.display())),
995 }
996 }
997
998 fn restore_if_changed(self, cwd: &Path) -> Result<()> {
999 let path = cwd.join(".git");
1000 let unchanged = std::fs::symlink_metadata(&path)
1001 .ok()
1002 .filter(|meta| meta.is_file())
1003 .and_then(|_| std::fs::read(&path).ok())
1004 .is_some_and(|bytes| bytes == self.bytes);
1005 let recovery = cwd.join(GIT_MARKER_RECOVERY);
1006 if unchanged {
1007 return match std::fs::symlink_metadata(&recovery) {
1008 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
1009 Ok(_) => Err(SparError::uncertain_write(format!(
1010 "the agent call created the reserved recovery path at {}. The Git marker was \
1011 unchanged. Inspect or remove the recovery path before retrying.",
1012 recovery.display()
1013 ))),
1014 Err(e) => Err(SparError::uncertain_write(format!(
1015 "could not inspect {} after the agent call: {e}",
1016 recovery.display()
1017 ))),
1018 };
1019 }
1020
1021 let retained = match std::fs::symlink_metadata(&path) {
1022 Ok(_) => match std::fs::symlink_metadata(&recovery) {
1023 Ok(_) => {
1024 return Err(SparError::uncertain_write(format!(
1025 "the agent call changed the linked worktree Git marker at {}, but the \
1026 changed entry could not be retained because {} already exists. The \
1027 original marker was not restored, so no changed entry was deleted. \
1028 Inspect the worktree before retrying.",
1029 path.display(),
1030 recovery.display()
1031 )));
1032 }
1033 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1034 match std::fs::rename(&path, &recovery) {
1035 Ok(()) => true,
1036 Err(e) => {
1037 return Err(SparError::uncertain_write(format!(
1038 "the agent call changed the linked worktree Git marker at {}, but \
1039 the changed entry could not be moved to {}: {e}. The original \
1040 marker was not restored, so no changed entry was deleted. Inspect \
1041 the worktree before retrying.",
1042 path.display(),
1043 recovery.display()
1044 )));
1045 }
1046 }
1047 }
1048 Err(e) => {
1049 return Err(SparError::uncertain_write(format!(
1050 "the agent call changed the linked worktree Git marker at {}, but {} could \
1051 not be inspected: {e}. The original marker was not restored, so no changed \
1052 entry was deleted. Inspect the worktree before retrying.",
1053 path.display(),
1054 recovery.display()
1055 )));
1056 }
1057 },
1058 Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
1059 Err(e) => {
1060 return Err(SparError::uncertain_write(format!(
1061 "the agent call changed the linked worktree Git marker at {}, but the changed \
1062 entry could not be inspected: {e}. The original marker was not restored. \
1063 Inspect the worktree before retrying.",
1064 path.display()
1065 )));
1066 }
1067 };
1068
1069 let restored = replace_git_marker(&path, &self.bytes);
1070 let restore_note = match &restored {
1071 Ok(()) => "The original marker was restored.".to_string(),
1072 Err(e) => format!("The original marker could not be restored: {e}."),
1073 };
1074 let retain_note = if retained {
1075 format!("The changed marker was kept at {}.", recovery.display())
1076 } else {
1077 "The agent call deleted the changed marker, so there was nothing to retain.".to_string()
1078 };
1079 Err(SparError::uncertain_write(format!(
1080 "the agent call changed the linked worktree Git marker at {}. {restore_note} \
1081 {retain_note} Inspect the worktree before retrying.",
1082 path.display()
1083 )))
1084 }
1085}
1086
1087fn after_call_is_quiet<T>(called: &Result<T>, recover: impl FnOnce() -> Result<()>) -> Result<()> {
1090 if let Err(error) = called {
1091 if error.kind() == ErrorKind::UncertainWrite {
1092 return Err(error.clone());
1093 }
1094 }
1095 recover()
1096}
1097
1098fn replace_git_marker(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
1099 let mut marker = std::fs::OpenOptions::new()
1100 .write(true)
1101 .create_new(true)
1102 .open(path)?;
1103 marker.write_all(bytes)
1104}
1105
1106fn ensure_recovery_path_clear(cwd: &Path) -> Result<()> {
1107 let recovery = cwd.join(GIT_MARKER_RECOVERY);
1108 match std::fs::symlink_metadata(&recovery) {
1109 Ok(_) => Err(SparError::uncertain_write(format!(
1110 "{} already exists. Recover or remove it before another agent call.",
1111 recovery.display()
1112 ))),
1113 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
1114 Err(e) => Err(SparError::uncertain_write(format!(
1115 "could not inspect {} before the agent call: {e}",
1116 recovery.display()
1117 ))),
1118 }
1119}
1120
1121#[derive(Debug, Default, Clone)]
1126pub struct Placeholders {
1127 pub prompt: Option<String>,
1128 pub system: Option<String>,
1129 pub model: Option<String>,
1130 pub effort: Option<String>,
1131 pub cwd: Option<String>,
1132 pub schema_file: Option<String>,
1134 pub schema: Option<String>,
1136}
1137
1138impl Placeholders {
1139 fn get(&self, key: &str) -> Option<&str> {
1140 let value = match key {
1141 "prompt" => self.prompt.as_deref(),
1142 "system" => self.system.as_deref(),
1143 "model" => self.model.as_deref(),
1144 "effort" => self.effort.as_deref(),
1145 "cwd" => self.cwd.as_deref(),
1146 "schema_file" => self.schema_file.as_deref(),
1147 "schema" => self.schema.as_deref(),
1148 _ => None,
1149 };
1150 value.filter(|v| !v.is_empty())
1151 }
1152
1153 fn substitute(&self, arg: &str) -> Option<String> {
1156 const KEYS: [&str; 7] = [
1157 "prompt",
1158 "system",
1159 "model",
1160 "effort",
1161 "cwd",
1162 "schema_file",
1163 "schema",
1164 ];
1165 let mut out = arg.to_string();
1166 for key in KEYS {
1167 let token = format!("{{{key}}}");
1168 if out.contains(&token) {
1169 let value = self.get(key)?;
1170 out = out.replace(&token, value);
1171 }
1172 }
1173 Some(out)
1174 }
1175}
1176
1177fn dig<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
1183 if path.is_empty() {
1184 return None;
1185 }
1186 let mut node = value;
1187 for part in path.split('.') {
1188 node = node.as_object()?.get(part)?;
1189 }
1190 Some(node)
1191}
1192
1193fn matches(event: &Value, wanted: &BTreeMap<String, String>) -> bool {
1194 if wanted.is_empty() {
1195 return false;
1196 }
1197 wanted
1198 .iter()
1199 .all(|(path, expected)| dig(event, path).and_then(Value::as_str) == Some(expected.as_str()))
1200}
1201
1202fn as_text(value: &Value) -> Option<String> {
1203 match value {
1204 Value::String(s) => Some(s.clone()),
1205 Value::Null => None,
1206 other => Some(other.to_string()),
1207 }
1208}
1209
1210fn truncate(text: &str, max: usize) -> String {
1211 text.chars().take(max).collect()
1212}
1213
1214struct TempJson {
1221 path: PathBuf,
1222}
1223
1224impl TempJson {
1225 fn write(value: &Value) -> Result<Self> {
1226 use std::sync::atomic::{AtomicU64, Ordering};
1227 static COUNTER: AtomicU64 = AtomicU64::new(0);
1228
1229 let nanos = std::time::SystemTime::now()
1230 .duration_since(std::time::UNIX_EPOCH)
1231 .map(|d| d.as_nanos())
1232 .unwrap_or(0);
1233 let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
1234 let path = std::env::temp_dir().join(format!(
1235 "spar-schema-{}-{nanos}-{unique}.json",
1236 std::process::id()
1237 ));
1238 std::fs::write(&path, serde_json::to_vec_pretty(value)?)
1239 .map_err(|e| spar_err!("could not write a schema file to {}: {e}", path.display()))?;
1240 Ok(Self { path })
1241 }
1242
1243 fn path(&self) -> &Path {
1244 &self.path
1245 }
1246}
1247
1248impl Drop for TempJson {
1249 fn drop(&mut self) {
1250 let _ = std::fs::remove_file(&self.path);
1251 }
1252}
1253
1254fn same_executable(a: &Path, b: &Path) -> bool {
1265 #[cfg(unix)]
1266 {
1267 use std::os::unix::fs::MetadataExt;
1268 if let (Ok(x), Ok(y)) = (std::fs::metadata(a), std::fs::metadata(b)) {
1269 return x.dev() == y.dev() && x.ino() == y.ino();
1270 }
1271 }
1272 match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
1273 (Ok(x), Ok(y)) => x == y,
1274 _ => a == b,
1275 }
1276}
1277
1278pub fn correlation_warning(agents: &[Agent]) -> Option<String> {
1284 for i in 0..agents.len() {
1285 for j in (i + 1)..agents.len() {
1286 let (a, b) = (&agents[i], &agents[j]);
1287 let (Ok(pa), Ok(pb)) = (a.resolve_bin(), b.resolve_bin()) else {
1288 continue;
1289 };
1290 if !same_executable(pa, pb) || a.spec.model_key() != b.spec.model_key() {
1291 continue;
1292 }
1293 let model = if a.spec.model_key().is_empty() {
1294 "the CLI's default".to_string()
1295 } else {
1296 a.spec.model_key()
1297 };
1298 let where_at = if pa == pb {
1299 pa.display().to_string()
1300 } else {
1301 format!(
1302 "the same executable ({} and {} are the same file)",
1303 pa.display(),
1304 pb.display()
1305 )
1306 };
1307 return Some(format!(
1308 "agents '{}' and '{}' both resolve to {where_at} at model {model}. Review \
1309 findings will be correlated: the same model reviewing itself shares the blind \
1310 spots of the model that wrote the code, so it is far less likely to catch what \
1311 the implementer missed. That produces an approval indistinguishable from a real \
1312 review, which is worse than no review at all. Give the two agents different \
1313 CLIs or different models.",
1314 a.name(),
1315 b.name()
1316 ));
1317 }
1318 }
1319 None
1320}
1321
1322pub fn build(cfg: &crate::config::Config) -> Result<Vec<Agent>> {
1325 let agents: Vec<Agent> = cfg
1326 .agents
1327 .iter()
1328 .cloned()
1329 .map(Agent::new)
1330 .map(|agent| agent.with_instructions(&cfg.loop_cfg.instructions))
1331 .collect();
1332 for agent in &agents {
1333 agent.resolve_bin()?;
1334 if let Some(backup) = agent.fallback() {
1338 if backup.resolve_bin().is_err() {
1339 logwarn!(
1340 "{} has a fallback ({}) that is not installed, so it will not stand in",
1341 agent.name(),
1342 backup.program()
1343 );
1344 }
1345 }
1346 }
1347 Ok(agents)
1348}
1349
1350pub fn find<'a>(agents: &'a [Agent], name: &str) -> Result<&'a Agent> {
1352 agents.iter().find(|a| a.name() == name).ok_or_else(|| {
1353 SparError::new(format!(
1354 "no agent named '{name}' ({})",
1355 agents
1356 .iter()
1357 .map(Agent::name)
1358 .collect::<Vec<_>>()
1359 .join(", ")
1360 ))
1361 })
1362}
1363
1364#[cfg(test)]
1365mod tests {
1366 use super::*;
1367 use crate::config::{OutputMode, SystemVia};
1368
1369 fn spec(command: Vec<CommandPart>) -> AgentSpec {
1370 AgentSpec {
1371 name: "test".into(),
1372 command,
1373 model: None,
1374 effort: None,
1375 output: OutputMode::Text,
1376 message_match: BTreeMap::new(),
1377 message_path: None,
1378 search_paths: vec![],
1379 system_via: SystemVia::Prompt,
1380 timeout: 60,
1381 fallback: None,
1382 models: vec![],
1383 efforts: vec![],
1384 options_note: None,
1385 }
1386 }
1387
1388 fn one(s: &str) -> CommandPart {
1389 CommandPart::One(s.into())
1390 }
1391
1392 fn group(parts: &[&str]) -> CommandPart {
1393 CommandPart::Group(parts.iter().map(|s| s.to_string()).collect())
1394 }
1395
1396 #[test]
1397 fn an_uncertain_process_result_skips_post_call_recovery() {
1398 let called: Result<()> = Err(SparError::uncertain_write("descendants may still write"));
1399 let recovered = std::cell::Cell::new(false);
1400
1401 let error = after_call_is_quiet(&called, || {
1402 recovered.set(true);
1403 Ok(())
1404 })
1405 .unwrap_err();
1406
1407 assert_eq!(ErrorKind::UncertainWrite, error.kind());
1408 assert!(!recovered.get());
1409 }
1410
1411 fn agent(command: Vec<CommandPart>) -> Agent {
1412 Agent::with_bin(spec(command), "/fake/bin")
1413 }
1414
1415 fn values() -> Placeholders {
1416 Placeholders {
1417 prompt: Some("hi".into()),
1418 ..Default::default()
1419 }
1420 }
1421
1422 #[test]
1425 fn placeholders_are_substituted() {
1426 let a = agent(vec![one("x"), group(&["-m", "{model}"]), one("{prompt}")]);
1427 let v = Placeholders {
1428 model: Some("m1".into()),
1429 ..values()
1430 };
1431 assert_eq!(vec!["/fake/bin", "-m", "m1", "hi"], a.render(&v).unwrap());
1432 }
1433
1434 #[test]
1435 fn an_unset_placeholder_drops_the_whole_group() {
1436 let a = agent(vec![one("x"), group(&["-m", "{model}"]), one("{prompt}")]);
1437 assert_eq!(vec!["/fake/bin", "hi"], a.render(&values()).unwrap());
1438 }
1439
1440 #[test]
1441 fn an_empty_string_drops_the_group_too() {
1442 let a = agent(vec![one("x"), group(&["-e", "{effort}"]), one("{prompt}")]);
1443 let v = Placeholders {
1444 effort: Some(String::new()),
1445 ..values()
1446 };
1447 assert_eq!(vec!["/fake/bin", "hi"], a.render(&v).unwrap());
1448 }
1449
1450 #[test]
1451 fn a_bare_arg_with_an_unset_placeholder_drops() {
1452 let a = agent(vec![one("x"), one("{model}"), one("{prompt}")]);
1453 assert_eq!(vec!["/fake/bin", "hi"], a.render(&values()).unwrap());
1454 }
1455
1456 #[test]
1457 fn literal_args_survive() {
1458 let a = agent(vec![
1459 one("x"),
1460 one("exec"),
1461 one("--json"),
1462 one("--"),
1463 one("{prompt}"),
1464 ]);
1465 assert_eq!(
1466 vec!["/fake/bin", "exec", "--json", "--", "hi"],
1467 a.render(&values()).unwrap()
1468 );
1469 }
1470
1471 #[test]
1472 fn an_embedded_placeholder_substitutes_in_place() {
1473 let a = agent(vec![
1474 one("x"),
1475 group(&["-c", "model_reasoning_effort={effort}"]),
1476 ]);
1477 let v = Placeholders {
1478 effort: Some("ultra".into()),
1479 ..Default::default()
1480 };
1481 assert_eq!(
1482 vec!["/fake/bin", "-c", "model_reasoning_effort=ultra"],
1483 a.render(&v).unwrap()
1484 );
1485 }
1486
1487 #[test]
1488 fn a_group_with_two_placeholders_needs_both() {
1489 let a = agent(vec![
1490 one("x"),
1491 group(&["--a", "{model}", "--b", "{effort}"]),
1492 ]);
1493 let v = Placeholders {
1494 model: Some("m".into()),
1495 ..Default::default()
1496 };
1497 assert_eq!(vec!["/fake/bin"], a.render(&v).unwrap());
1498 }
1499
1500 #[test]
1501 fn supports_schema_detects_the_placeholder() {
1502 assert!(agent(vec![one("x"), group(&["--schema", "{schema_file}"])]).supports_schema());
1503 assert!(!agent(vec![one("x"), one("{prompt}")]).supports_schema());
1504 }
1505
1506 #[test]
1509 fn text_passes_through_trimmed() {
1510 assert_eq!("hello", agent(vec![one("x")]).extract(" hello\n").unwrap());
1511 }
1512
1513 #[test]
1514 fn jsonl_picks_the_matching_event() {
1515 let mut spec = spec(vec![one("x")]);
1516 spec.output = OutputMode::Jsonl;
1517 spec.message_path = Some("item.text".into());
1518 spec.message_match = BTreeMap::from([
1519 ("type".to_string(), "item.completed".to_string()),
1520 ("item.type".to_string(), "agent_message".to_string()),
1521 ]);
1522 let a = Agent::with_bin(spec, "/fake/bin");
1523 let stream = [
1524 r#"{"type":"thread.started","thread_id":"t1"}"#,
1525 r#"{"type":"item.completed","item":{"type":"command_execution","text":"ls"}}"#,
1526 r#"{"type":"item.completed","item":{"type":"agent_message","text":"the answer"}}"#,
1527 "not json at all",
1528 ]
1529 .join("\n");
1530 assert_eq!("the answer", a.extract(&stream).unwrap());
1531 }
1532
1533 #[test]
1534 fn jsonl_raises_on_an_error_with_no_message() {
1535 let mut spec = spec(vec![one("x")]);
1536 spec.output = OutputMode::Jsonl;
1537 spec.message_path = Some("item.text".into());
1538 spec.message_match = BTreeMap::from([("type".into(), "item.completed".into())]);
1539 let a = Agent::with_bin(spec, "/fake/bin");
1540 assert!(a
1541 .extract(r#"{"type":"turn.failed","error":"boom"}"#)
1542 .is_err());
1543 }
1544
1545 #[test]
1546 fn jsonl_joins_several_agent_messages() {
1547 let mut spec = spec(vec![one("x")]);
1548 spec.output = OutputMode::Jsonl;
1549 spec.message_path = Some("text".into());
1550 spec.message_match = BTreeMap::from([("type".into(), "msg".into())]);
1551 let a = Agent::with_bin(spec, "/fake/bin");
1552 let stream = "{\"type\":\"msg\",\"text\":\"one\"}\n{\"type\":\"msg\",\"text\":\"two\"}";
1553 assert_eq!("one\ntwo", a.extract(stream).unwrap());
1554 }
1555
1556 #[test]
1557 fn dig_walks_a_dotted_path() {
1558 let v: Value = serde_json::from_str(r#"{"a":{"b":{"c":1}}}"#).unwrap();
1559 assert_eq!(Some(&Value::from(1)), dig(&v, "a.b.c"));
1560 assert_eq!(None, dig(&v, "a.b.missing"));
1561 assert_eq!(None, dig(&v, ""));
1562 }
1563
1564 #[test]
1567 fn a_missing_binary_lists_everywhere_it_looked() {
1568 let mut s = spec(vec![one("definitely-not-installed-xyz")]);
1569 s.search_paths = vec!["/nowhere/at/all".into()];
1570 s.name = "codex".into();
1571 let err = Agent::new(s).resolve_bin().unwrap_err().to_string();
1572 assert!(err.contains("definitely-not-installed-xyz (PATH)"), "{err}");
1573 assert!(
1574 err.contains("/nowhere/at/all/definitely-not-installed-xyz"),
1575 "{err}"
1576 );
1577 assert!(err.contains("SPAR_CODEX_BIN"), "{err}");
1578 }
1579
1580 #[test]
1581 fn a_search_path_that_already_names_the_binary_is_used_as_is() {
1582 let dir = std::env::temp_dir().join(format!("spar-test-{}", std::process::id()));
1583 std::fs::create_dir_all(&dir).unwrap();
1584 let bin = dir.join("mytool");
1585 std::fs::write(&bin, "#!/bin/sh\n").unwrap();
1586 #[cfg(unix)]
1587 {
1588 use std::os::unix::fs::PermissionsExt;
1589 std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
1590 }
1591 let mut s = spec(vec![one("mytool")]);
1592 s.search_paths = vec![bin.display().to_string()];
1593 assert_eq!(bin, Agent::new(s).resolve_bin().unwrap());
1594 let _ = std::fs::remove_dir_all(&dir);
1595 }
1596
1597 fn named(name: &str, bin: &str, model: Option<&str>) -> Agent {
1600 let mut s = spec(vec![one("prog")]);
1601 s.name = name.into();
1602 s.model = model.map(str::to_string);
1603 Agent::with_bin(s, bin)
1604 }
1605
1606 #[test]
1607 fn same_bin_same_model_warns() {
1608 let agents = vec![
1609 named("alpha", "/usr/local/bin/claude", Some("fable")),
1610 named("beta", "/usr/local/bin/claude", Some("fable")),
1611 ];
1612 let msg = correlation_warning(&agents).expect("should warn");
1613 assert!(msg.contains("alpha") && msg.contains("beta"), "{msg}");
1614 }
1615
1616 #[test]
1617 fn different_model_does_not_warn() {
1618 let agents = vec![
1619 named("a", "/usr/local/bin/claude", Some("fable")),
1620 named("b", "/usr/local/bin/claude", Some("opus")),
1621 ];
1622 assert!(correlation_warning(&agents).is_none());
1623 }
1624
1625 #[test]
1626 fn different_bin_does_not_warn() {
1627 let agents = vec![
1628 named("a", "/usr/local/bin/claude", Some("fable")),
1629 named("b", "/usr/local/bin/codex", Some("fable")),
1630 ];
1631 assert!(correlation_warning(&agents).is_none());
1632 }
1633
1634 #[test]
1635 fn unset_and_empty_model_both_mean_the_default_and_warn() {
1636 let agents = vec![
1637 named("a", "/usr/local/bin/claude", None),
1638 named("b", "/usr/local/bin/claude", Some("")),
1639 ];
1640 let msg = correlation_warning(&agents).expect("should warn");
1641 assert!(msg.contains("the CLI's default"), "{msg}");
1642 }
1643
1644 #[test]
1645 fn a_padded_model_still_warns() {
1646 let agents = vec![
1647 named("a", "/usr/local/bin/claude", Some("fable")),
1648 named("b", "/usr/local/bin/claude", Some(" fable ")),
1649 ];
1650 assert!(correlation_warning(&agents).is_some());
1651 }
1652
1653 #[test]
1654 fn an_empty_model_against_a_named_one_does_not_warn() {
1655 let agents = vec![
1656 named("a", "/usr/local/bin/claude", Some("")),
1657 named("b", "/usr/local/bin/claude", Some("fable")),
1658 ];
1659 assert!(correlation_warning(&agents).is_none());
1660 }
1661
1662 #[cfg(unix)]
1663 #[test]
1664 fn a_symlinked_binary_warns_and_names_both_paths() {
1665 use std::os::unix::fs::PermissionsExt;
1666 let dir = std::env::temp_dir().join(format!("spar-link-{}", std::process::id()));
1667 let _ = std::fs::remove_dir_all(&dir);
1668 std::fs::create_dir_all(&dir).unwrap();
1669 let real = dir.join("claude");
1670 let link = dir.join("claude-alias");
1671 std::fs::write(&real, "#!/bin/sh\n").unwrap();
1672 std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o755)).unwrap();
1673 std::os::unix::fs::symlink(&real, &link).unwrap();
1674
1675 let agents = vec![
1676 named("alpha", real.to_str().unwrap(), Some("fable")),
1677 named("beta", link.to_str().unwrap(), Some("fable")),
1678 ];
1679 let msg = correlation_warning(&agents).expect("should warn");
1680 assert!(msg.contains(real.to_str().unwrap()), "{msg}");
1681 assert!(msg.contains(link.to_str().unwrap()), "{msg}");
1682 let _ = std::fs::remove_dir_all(&dir);
1683 }
1684
1685 #[cfg(unix)]
1686 #[test]
1687 fn two_distinct_real_binaries_stay_quiet() {
1688 use std::os::unix::fs::PermissionsExt;
1689 let dir = std::env::temp_dir().join(format!("spar-distinct-{}", std::process::id()));
1690 let _ = std::fs::remove_dir_all(&dir);
1691 std::fs::create_dir_all(&dir).unwrap();
1692 let mut paths = Vec::new();
1693 for name in ["claude", "codex"] {
1694 let path = dir.join(name);
1695 std::fs::write(&path, "#!/bin/sh\n").unwrap();
1696 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
1697 paths.push(path);
1698 }
1699 let agents = vec![
1700 named("a", paths[0].to_str().unwrap(), Some("fable")),
1701 named("b", paths[1].to_str().unwrap(), Some("fable")),
1702 ];
1703 assert!(correlation_warning(&agents).is_none());
1704 let _ = std::fs::remove_dir_all(&dir);
1705 }
1706
1707 #[test]
1708 fn the_style_rules_ask_for_brevity_and_no_attribution() {
1709 let lower = STYLE_RULES.to_lowercase();
1710 assert!(lower.contains("brief"));
1711 assert!(lower.contains("co-authored-by"));
1712 assert!(lower.contains("em-dash"));
1713 }
1714
1715 #[test]
1720 fn brevity_is_about_facts_per_sentence_not_sentence_count() {
1721 let lower = STYLE_RULES.to_lowercase();
1722 assert!(lower.contains("saying fewer things"), "{STYLE_RULES}");
1723 assert!(
1724 !lower.contains("one sentence beats one paragraph"),
1725 "the rule that produced the density is still there"
1726 );
1727 }
1728
1729 #[test]
1734 fn the_style_rules_reach_the_code_and_not_only_what_is_posted() {
1735 let lower = STYLE_RULES.to_lowercase();
1736 assert!(lower.contains("comments in code you write"), "not in scope");
1737 assert!(
1738 lower.contains("comment code for the reason"),
1739 "no rule for it"
1740 );
1741 }
1742
1743 fn refusal_stream() -> String {
1748 let noise = "{\"type\":\"item.completed\",\"item\":{\"id\":\"i\",\"type\":\"command_execution\",\"output\":\"".to_string()
1749 + &"const x = 1;\\n".repeat(200)
1750 + "\"}}";
1751 [
1752 noise.as_str(),
1753 r#"{"type":"error","message":"This content was flagged for possible cybersecurity risk."}"#,
1754 r#"{"type":"error","message":"This content was flagged for possible cybersecurity risk."}"#,
1755 r#"{"type":"turn.failed","error":{"message":"This content was flagged for possible cybersecurity risk."}}"#,
1756 ]
1757 .join("\n")
1758 }
1759
1760 fn jsonl_agent(name: &str) -> Agent {
1761 let mut spec = spec(vec![one("codex")]);
1762 spec.name = name.into();
1763 spec.output = OutputMode::Jsonl;
1764 spec.message_path = Some("item.text".into());
1765 Agent::with_bin(spec, "/fake/codex")
1766 }
1767
1768 fn failed(stdout: &str, stderr: &str) -> proc::Output {
1769 proc::Output {
1770 stdout: stdout.to_string(),
1771 stdout_bytes: stdout.as_bytes().to_vec(),
1772 stderr: stderr.to_string(),
1773 code: 1,
1774 }
1775 }
1776
1777 #[test]
1780 fn a_jsonl_failure_reports_the_reason_and_not_the_stream() {
1781 let agent = jsonl_agent("codex");
1782 let err = agent.call_failure(&["codex".to_string()], &failed(&refusal_stream(), ""));
1783 let text = err.message();
1784 assert!(
1785 text.contains("flagged for possible cybersecurity risk"),
1786 "{text}"
1787 );
1788 assert!(
1789 !text.contains("const x = 1;"),
1790 "the stream leaked in:\n{text}"
1791 );
1792 assert!(text.len() < 400, "still {} characters:\n{text}", text.len());
1793 }
1794
1795 #[test]
1797 fn the_same_reason_reported_three_times_is_said_once() {
1798 let agent = jsonl_agent("codex");
1799 let err = agent.call_failure(&["codex".to_string()], &failed(&refusal_stream(), ""));
1800 assert_eq!(
1801 1,
1802 err.message().matches("flagged for possible").count(),
1803 "{}",
1804 err.message()
1805 );
1806 }
1807
1808 #[test]
1811 fn stderr_is_kept_because_it_is_where_the_other_half_arrives() {
1812 let agent = jsonl_agent("codex");
1813 let err = agent.call_failure(
1814 &["codex".to_string()],
1815 &failed(
1816 &refusal_stream(),
1817 "ERROR router: agent thread limit reached",
1818 ),
1819 );
1820 assert!(
1821 err.message().contains("agent thread limit reached"),
1822 "{}",
1823 err.message()
1824 );
1825 }
1826
1827 #[test]
1830 fn a_stream_with_no_error_event_falls_back_to_the_raw_output() {
1831 let agent = jsonl_agent("codex");
1832 let err = agent.call_failure(
1833 &["codex".to_string()],
1834 &failed("{\"type\":\"system\"}", "segmentation fault"),
1835 );
1836 assert!(
1837 err.message().contains("segmentation fault"),
1838 "{}",
1839 err.message()
1840 );
1841 assert!(
1842 err.message().starts_with("command failed"),
1843 "{}",
1844 err.message()
1845 );
1846 }
1847
1848 #[test]
1850 fn a_text_agent_is_reported_exactly_as_before() {
1851 let agent = agent(vec![one("mytool")]);
1852 let err = agent.call_failure(&["mytool".to_string()], &failed("some prose", "boom"));
1853 assert!(
1854 err.message().starts_with("command failed"),
1855 "{}",
1856 err.message()
1857 );
1858 assert!(err.message().contains("some prose"), "{}", err.message());
1859 }
1860
1861 #[test]
1864 fn a_reworded_failure_is_still_a_failed_call() {
1865 let agent = jsonl_agent("codex");
1866 let err = agent.call_failure(&["codex".to_string()], &failed(&refusal_stream(), ""));
1867 assert_eq!(ErrorKind::CallFailed, err.kind());
1868 }
1869
1870 #[test]
1873 fn a_request_carries_the_instructions_after_the_task() {
1874 let agent = Agent::with_bin(shell("a", "true"), "/bin/sh")
1875 .with_instructions("Do not wait for CI. Pick it up next pass.");
1876 let asked = agent.instructed("Review the changes on this branch.");
1877 assert!(
1878 asked.starts_with("Review the changes on this branch."),
1879 "{asked}"
1880 );
1881 assert!(asked.contains("Do not wait for CI"), "{asked}");
1882 }
1883
1884 #[test]
1888 fn the_instructions_arrive_subordinate_to_the_request() {
1889 let agent = Agent::with_bin(shell("a", "true"), "/bin/sh").with_instructions("Be quick.");
1890 let asked = agent.instructed("Do the work.").to_lowercase();
1891 assert!(
1892 asked.contains("from the person who started this run"),
1893 "{asked}"
1894 );
1895 assert!(asked.contains("not the shape of your answer"), "{asked}");
1896 }
1897
1898 #[test]
1899 fn nothing_is_added_when_there_are_none() {
1900 let agent = Agent::with_bin(shell("a", "true"), "/bin/sh");
1901 assert_eq!("Do the work.", agent.instructed("Do the work."));
1902 let blank = Agent::with_bin(shell("b", "true"), "/bin/sh").with_instructions(" \n ");
1904 assert_eq!("Do the work.", blank.instructed("Do the work."));
1905 }
1906
1907 #[test]
1910 fn the_stand_in_carries_them_too() {
1911 let agent = with_fallback(shell("primary", "true"), shell("backup", "true"))
1912 .with_instructions("Do not wait for CI.");
1913 let backup = agent.fallback().expect("a stand in");
1914 assert!(backup
1915 .instructed("Do the work.")
1916 .contains("Do not wait for CI."));
1917 }
1918
1919 fn shell(name: &str, line: &str) -> AgentSpec {
1924 let mut spec = spec(vec![one("sh"), one("-c"), one(line)]);
1925 spec.name = name.into();
1926 spec
1927 }
1928
1929 fn with_fallback(mut primary: AgentSpec, backup: AgentSpec) -> Agent {
1930 primary.fallback = Some(Box::new(backup));
1931 Agent::with_bin(primary, "/bin/sh")
1932 }
1933
1934 fn git_at(cwd: &Path, args: &[&str]) -> String {
1935 let out = std::process::Command::new("git")
1936 .args(args)
1937 .current_dir(cwd)
1938 .output()
1939 .unwrap();
1940 assert!(
1941 out.status.success(),
1942 "{}",
1943 String::from_utf8_lossy(&out.stderr)
1944 );
1945 String::from_utf8_lossy(&out.stdout).trim().to_string()
1946 }
1947
1948 fn committed_repo(name: &str) -> PathBuf {
1949 let dir = std::env::temp_dir().join(format!(
1950 "spar-agent-{name}-{}-{}",
1951 std::process::id(),
1952 std::thread::current().name().unwrap_or("test")
1953 ));
1954 let _ = std::fs::remove_dir_all(&dir);
1955 std::fs::create_dir_all(&dir).unwrap();
1956 git_at(&dir, &["init", "-q", "-b", "main"]);
1957 git_at(&dir, &["config", "user.email", "spar@example.invalid"]);
1958 git_at(&dir, &["config", "user.name", "spar test"]);
1959 git_at(&dir, &["config", "commit.gpgsign", "false"]);
1960 std::fs::write(dir.join("README.md"), "seed\n").unwrap();
1961 git_at(&dir, &["add", "README.md"]);
1962 git_at(&dir, &["commit", "-q", "-m", "seed"]);
1963 dir
1964 }
1965
1966 fn linked_worktree(name: &str) -> (PathBuf, PathBuf, PathBuf, Vec<u8>) {
1967 let root = std::env::temp_dir().join(format!(
1968 "spar-agent-{name}-{}-{}",
1969 std::process::id(),
1970 std::thread::current().name().unwrap_or("test")
1971 ));
1972 let _ = std::fs::remove_dir_all(&root);
1973 let main = root.join("main");
1974 std::fs::create_dir_all(&main).unwrap();
1975 git_at(&main, &["init", "-q", "-b", "main"]);
1976 git_at(&main, &["config", "user.email", "spar@example.invalid"]);
1977 git_at(&main, &["config", "user.name", "spar test"]);
1978 git_at(&main, &["config", "commit.gpgsign", "false"]);
1979 std::fs::write(main.join("README.md"), "seed\n").unwrap();
1980 git_at(&main, &["add", "README.md"]);
1981 git_at(&main, &["commit", "-q", "-m", "seed"]);
1982 let linked = root.join("linked");
1983 git_at(
1984 &main,
1985 &[
1986 "worktree",
1987 "add",
1988 "-q",
1989 "-b",
1990 "issue-test",
1991 linked.to_str().unwrap(),
1992 ],
1993 );
1994 let marker = std::fs::read(linked.join(".git")).unwrap();
1995 (root, main, linked, marker)
1996 }
1997
1998 #[test]
1999 fn a_failed_call_is_answered_by_the_fallback() {
2000 let agent = with_fallback(
2001 shell("primary", "echo refused >&2; exit 1"),
2002 shell("backup", "echo stood in"),
2003 );
2004 let answer = agent.ask("hi", Path::new("."), None).expect("fallback");
2005 assert_eq!("stood in", answer);
2006 }
2007
2008 #[test]
2009 fn a_failed_edit_with_files_left_does_not_run_the_fallback() {
2010 let dir = committed_repo("dirty-edit-recovery");
2011
2012 let agent = with_fallback(
2013 shell(
2014 "primary",
2015 "printf 'recover me\\n' > README.md; printf refused >&2; exit 1",
2016 ),
2017 shell("backup", "printf 'fallback ran\\n' > fallback.txt"),
2018 );
2019 let err = agent.edit("change it", &dir, None).unwrap_err();
2020
2021 assert!(err.message().contains("refused"), "{err}");
2022 assert_eq!(
2023 1,
2024 err.message()
2025 .matches("The call changed the worktree before it failed")
2026 .count(),
2027 "{err}"
2028 );
2029 assert_eq!(
2030 "recover me\n",
2031 std::fs::read_to_string(dir.join("README.md")).unwrap()
2032 );
2033 assert!(!dir.join("fallback.txt").exists());
2034 let _ = std::fs::remove_dir_all(&dir);
2035 }
2036
2037 #[test]
2038 fn a_failed_edit_with_a_new_ignored_file_does_not_run_the_fallback() {
2039 let dir = committed_repo("ignored-edit-recovery");
2040 std::fs::write(dir.join(".gitignore"), "ignored.txt\n").unwrap();
2041 git_at(&dir, &["add", ".gitignore"]);
2042 git_at(&dir, &["commit", "-q", "-m", "ignore fixture"]);
2043 let agent = with_fallback(
2044 shell(
2045 "primary",
2046 "printf 'recover me\n' > ignored.txt; printf refused >&2; exit 1",
2047 ),
2048 shell("backup", "printf 'fallback ran\n' > fallback.txt"),
2049 );
2050
2051 let err = agent.edit("change it", &dir, None).unwrap_err();
2052
2053 assert!(err.message().contains("refused"), "{err}");
2054 assert_eq!(
2055 "recover me\n",
2056 std::fs::read_to_string(dir.join("ignored.txt")).unwrap()
2057 );
2058 assert!(!dir.join("fallback.txt").exists());
2059 let _ = std::fs::remove_dir_all(&dir);
2060 }
2061
2062 #[test]
2063 fn a_failed_edit_that_overwrites_an_ignored_file_does_not_run_the_fallback() {
2064 let dir = committed_repo("changed-ignored-edit-recovery");
2065 std::fs::write(dir.join(".gitignore"), "ignored.txt\n").unwrap();
2066 git_at(&dir, &["add", ".gitignore"]);
2067 git_at(&dir, &["commit", "-q", "-m", "ignore fixture"]);
2068 std::fs::write(dir.join("ignored.txt"), "before\n").unwrap();
2069 let agent = with_fallback(
2070 shell(
2071 "primary",
2072 "printf 'after!\\n' > ignored.txt; printf refused >&2; exit 1",
2073 ),
2074 shell("backup", "printf 'fallback ran\n' > fallback.txt"),
2075 );
2076
2077 let err = agent.edit("change it", &dir, None).unwrap_err();
2078
2079 assert!(err.message().contains("refused"), "{err}");
2080 assert_eq!(
2081 "after!\n",
2082 std::fs::read_to_string(dir.join("ignored.txt")).unwrap()
2083 );
2084 assert!(!dir.join("fallback.txt").exists());
2085 let _ = std::fs::remove_dir_all(&dir);
2086 }
2087
2088 #[test]
2089 fn a_failed_edit_with_a_commit_does_not_run_the_fallback() {
2090 let dir = committed_repo("committed-edit-recovery");
2091 let agent = with_fallback(
2092 shell(
2093 "primary",
2094 "printf 'recover me\n' > README.md; git add README.md; \
2095 git commit -q -m preserved; printf refused >&2; exit 1",
2096 ),
2097 shell("backup", "printf 'fallback ran\n' > fallback.txt"),
2098 );
2099
2100 let err = agent.edit("change it", &dir, None).unwrap_err();
2101
2102 assert!(err.message().contains("refused"), "{err}");
2103 assert_eq!("preserved", git_at(&dir, &["log", "-1", "--pretty=%s"]));
2104 assert_eq!(
2105 "recover me\n",
2106 std::fs::read_to_string(dir.join("README.md")).unwrap()
2107 );
2108 assert!(!dir.join("fallback.txt").exists());
2109 let _ = std::fs::remove_dir_all(&dir);
2110 }
2111
2112 #[test]
2113 fn a_committed_edit_with_malformed_json_is_not_retried_or_handed_over() {
2114 let dir = committed_repo("committed-malformed-edit");
2115 let agent = with_fallback(
2116 shell(
2117 "primary",
2118 "printf 'once\n' >> attempts.txt; printf 'recover me\n' > README.md; \
2119 git add -A; git commit -q -m preserved; printf 'not json\n'",
2120 ),
2121 shell(
2122 "backup",
2123 "printf 'fallback ran\n' > fallback.txt; printf '{}\n'",
2124 ),
2125 );
2126
2127 let err = agent
2128 .edit_json::<Value>(
2129 "change it",
2130 &serde_json::json!({"type": "object"}),
2131 &dir,
2132 None,
2133 )
2134 .unwrap_err();
2135
2136 assert!(err.message().contains("JSON"), "{err}");
2137 assert_eq!("preserved", git_at(&dir, &["log", "-1", "--pretty=%s"]));
2138 assert_eq!(
2139 1,
2140 std::fs::read_to_string(dir.join("attempts.txt"))
2141 .unwrap()
2142 .lines()
2143 .count()
2144 );
2145 assert!(!dir.join("fallback.txt").exists());
2146 let _ = std::fs::remove_dir_all(&dir);
2147 }
2148
2149 #[test]
2150 fn a_failed_edit_recovery_probe_does_not_retry_or_run_the_fallback() {
2151 let dir = committed_repo("failed-edit-probe");
2152 let agent = with_fallback(
2153 shell(
2154 "primary",
2155 "printf 'once\n' >> attempts.txt; mv .git .git-away; printf 'not json\n'",
2156 ),
2157 shell(
2158 "backup",
2159 "printf 'fallback ran\n' > fallback.txt; printf '{}\n'",
2160 ),
2161 );
2162
2163 let err = agent
2164 .edit_json::<Value>(
2165 "change it",
2166 &serde_json::json!({"type": "object"}),
2167 &dir,
2168 None,
2169 )
2170 .unwrap_err();
2171
2172 std::fs::rename(dir.join(".git-away"), dir.join(".git")).unwrap();
2173 assert_eq!(ErrorKind::UncertainWrite, err.kind());
2174 assert!(err.message().contains("JSON"), "{err}");
2175 assert_eq!(
2176 1,
2177 std::fs::read_to_string(dir.join("attempts.txt"))
2178 .unwrap()
2179 .lines()
2180 .count()
2181 );
2182 assert!(!dir.join("fallback.txt").exists());
2183 assert!(dir.join(".git").is_dir());
2184 let _ = std::fs::remove_dir_all(&dir);
2185 }
2186
2187 #[test]
2188 fn a_linked_worktree_git_marker_is_restored_after_an_edit() {
2189 let (root, main, linked, marker) = linked_worktree("changed-git-marker");
2190 let agent = Agent::with_bin(
2191 shell(
2192 "editor",
2193 "printf 'gitdir: /tmp/not-the-repo\\n' > .git; echo done",
2194 ),
2195 "/bin/sh",
2196 );
2197
2198 let err = agent.edit("change it", &linked, None).unwrap_err();
2199
2200 assert_eq!(ErrorKind::UncertainWrite, err.kind());
2201 assert!(err.message().contains("Git marker"), "{err}");
2202 assert_eq!(marker, std::fs::read(linked.join(".git")).unwrap());
2203 assert_eq!(
2204 b"gitdir: /tmp/not-the-repo\n",
2205 std::fs::read(linked.join(GIT_MARKER_RECOVERY))
2206 .unwrap()
2207 .as_slice()
2208 );
2209 git_at(
2210 &main,
2211 &["worktree", "remove", "--force", linked.to_str().unwrap()],
2212 );
2213 let _ = std::fs::remove_dir_all(&root);
2214 }
2215
2216 #[test]
2217 fn a_read_call_also_restores_a_linked_worktree_git_marker() {
2218 let (root, main, linked, marker) = linked_worktree("read-call-git-marker");
2219 let agent = with_fallback(
2220 shell(
2221 "reader",
2222 "printf 'gitdir: /tmp/not-the-repo\n' > .git; echo done",
2223 ),
2224 shell("backup", "printf 'fallback ran\n' > fallback.txt"),
2225 );
2226
2227 let err = agent.ask("read it", &linked, None).unwrap_err();
2228
2229 assert_eq!(ErrorKind::UncertainWrite, err.kind());
2230 assert_eq!(marker, std::fs::read(linked.join(".git")).unwrap());
2231 assert!(!linked.join("fallback.txt").exists());
2232 git_at(
2233 &main,
2234 &["worktree", "remove", "--force", linked.to_str().unwrap()],
2235 );
2236 let _ = std::fs::remove_dir_all(&root);
2237 }
2238
2239 #[test]
2240 fn a_successful_read_that_writes_is_discarded() {
2241 let dir = committed_repo("successful-read-write");
2242 let agent = Agent::with_bin(
2243 shell("reader", "printf 'recover me\n' > README.md; echo reviewed"),
2244 "/bin/sh",
2245 );
2246
2247 let err = agent.ask("read it", &dir, None).unwrap_err();
2248
2249 assert_eq!(ErrorKind::UncertainWrite, err.kind());
2250 assert!(err.message().contains("read-only call"), "{err}");
2251 assert_eq!(
2252 "recover me\n",
2253 std::fs::read_to_string(dir.join("README.md")).unwrap()
2254 );
2255 assert!(std::fs::read_dir(&dir).unwrap().flatten().any(|entry| entry
2256 .file_name()
2257 .to_string_lossy()
2258 .starts_with(".spar-recovery-needed-")));
2259 let _ = std::fs::remove_dir_all(&dir);
2260 }
2261
2262 #[test]
2263 fn a_successful_edit_cannot_replace_the_git_directory() {
2264 let dir = committed_repo("replaced-git-directory");
2265 let agent = Agent::with_bin(
2266 shell("editor", "mv .git .git-original; mkdir .git; echo done"),
2267 "/bin/sh",
2268 );
2269
2270 let err = agent.edit("change it", &dir, None).unwrap_err();
2271
2272 assert_eq!(ErrorKind::UncertainWrite, err.kind());
2273 assert!(err.message().contains("Git entry"), "{err}");
2274 assert!(dir.join(".git-original").is_dir());
2275 std::fs::remove_dir(dir.join(".git")).unwrap();
2276 std::fs::rename(dir.join(".git-original"), dir.join(".git")).unwrap();
2277 let _ = std::fs::remove_dir_all(&dir);
2278 }
2279
2280 #[test]
2281 fn a_deleted_linked_worktree_git_marker_is_restored_without_a_fallback() {
2282 let (root, main, linked, marker) = linked_worktree("deleted-git-marker");
2283 let agent = with_fallback(
2284 shell("editor", "rm .git; echo done"),
2285 shell("backup", "printf 'fallback ran\n' > fallback.txt"),
2286 );
2287
2288 let err = agent.edit("change it", &linked, None).unwrap_err();
2289
2290 assert_eq!(ErrorKind::UncertainWrite, err.kind());
2291 assert!(err.message().contains("deleted"), "{err}");
2292 assert_eq!(marker, std::fs::read(linked.join(".git")).unwrap());
2293 assert!(!linked.join(GIT_MARKER_RECOVERY).exists());
2294 assert!(!linked.join("fallback.txt").exists());
2295 git_at(
2296 &main,
2297 &["worktree", "remove", "--force", linked.to_str().unwrap()],
2298 );
2299 let _ = std::fs::remove_dir_all(&root);
2300 }
2301
2302 #[test]
2303 fn an_occupied_marker_recovery_path_keeps_the_changed_marker() {
2304 let (root, main, linked, marker) = linked_worktree("occupied-marker-recovery");
2305 let agent = with_fallback(
2306 shell(
2307 "editor",
2308 "mkdir .spar-edited-git-marker; \
2309 printf 'gitdir: /tmp/not-the-repo\n' > .git; echo done",
2310 ),
2311 shell("backup", "printf 'fallback ran\n' > fallback.txt"),
2312 );
2313
2314 let err = agent.edit("change it", &linked, None).unwrap_err();
2315
2316 assert_eq!(ErrorKind::UncertainWrite, err.kind());
2317 assert!(err.message().contains("already exists"), "{err}");
2318 assert_eq!(
2319 b"gitdir: /tmp/not-the-repo\n",
2320 std::fs::read(linked.join(".git")).unwrap().as_slice()
2321 );
2322 assert!(linked.join(GIT_MARKER_RECOVERY).is_dir());
2323 assert!(!linked.join("fallback.txt").exists());
2324 std::fs::write(linked.join(".git"), marker).unwrap();
2325 git_at(
2326 &main,
2327 &["worktree", "remove", "--force", linked.to_str().unwrap()],
2328 );
2329 let _ = std::fs::remove_dir_all(&root);
2330 }
2331
2332 #[test]
2333 fn without_a_fallback_the_original_error_is_what_surfaces() {
2334 let agent = Agent::with_bin(shell("primary", "echo refused >&2; exit 1"), "/bin/sh");
2335 let err = agent
2336 .ask("hi", Path::new("."), None)
2337 .expect_err("no backup");
2338 assert!(err.message().contains("refused"), "{err}");
2339 }
2340
2341 #[test]
2344 fn both_failing_reports_the_primary_first() {
2345 let agent = with_fallback(
2346 shell("primary", "echo policy refusal >&2; exit 1"),
2347 shell("backup", "echo out of quota >&2; exit 1"),
2348 );
2349 let err = agent
2350 .ask("hi", Path::new("."), None)
2351 .expect_err("both fail");
2352 let text = err.message();
2353 let primary_at = text.find("policy refusal").expect("primary reason");
2354 let backup_at = text.find("out of quota").expect("backup reason");
2355 assert!(primary_at < backup_at, "{text}");
2356 assert!(
2357 text.contains("primary") && text.contains("backup"),
2358 "{text}"
2359 );
2360 }
2361
2362 fn attempts(name: &str) -> (PathBuf, String) {
2365 let path = std::env::temp_dir().join(format!("spar-attempts-{name}"));
2366 let _ = std::fs::remove_file(&path);
2367 let line = format!("echo x >> {}", path.display());
2368 (path, line)
2369 }
2370
2371 fn counted(path: &Path) -> usize {
2372 std::fs::read_to_string(path)
2373 .map(|t| t.lines().count())
2374 .unwrap_or(0)
2375 }
2376
2377 #[test]
2382 fn a_cli_that_could_not_answer_is_not_asked_twice_when_there_is_a_stand_in() {
2383 let (path, count) = attempts("refused-with-standin");
2384 let agent = with_fallback(
2385 shell("primary", &format!("{count}; echo refused >&2; exit 1")),
2386 shell("backup", "echo '{}'"),
2387 );
2388 let answer: Value = agent
2389 .ask_json(
2390 "q",
2391 &serde_json::json!({"type": "object"}),
2392 Path::new("."),
2393 None,
2394 )
2395 .expect("the stand in answers");
2396 assert!(answer.is_object());
2397 assert_eq!(1, counted(&path), "the primary was asked more than once");
2398 }
2399
2400 #[test]
2403 fn with_no_stand_in_a_failed_call_is_still_retried() {
2404 let (path, count) = attempts("refused-alone");
2405 let agent = Agent::with_bin(
2406 shell("solo", &format!("{count}; echo refused >&2; exit 1")),
2407 "/bin/sh",
2408 );
2409 let err = agent
2410 .ask_json::<Value>(
2411 "q",
2412 &serde_json::json!({"type": "object"}),
2413 Path::new("."),
2414 None,
2415 )
2416 .expect_err("nothing answers");
2417 assert!(err.message().contains("twice"), "{err}");
2418 assert_eq!(2, counted(&path));
2419 }
2420
2421 #[test]
2425 fn an_unusable_answer_is_still_worth_asking_again() {
2426 let (path, count) = attempts("unparsable");
2427 let agent = with_fallback(
2428 shell("primary", &format!("{count}; echo not json at all")),
2429 shell("backup", "echo '{}'"),
2430 );
2431 let answer: Value = agent
2432 .ask_json(
2433 "q",
2434 &serde_json::json!({"type": "object"}),
2435 Path::new("."),
2436 None,
2437 )
2438 .expect("the stand in answers in the end");
2439 assert!(answer.is_object());
2440 assert_eq!(2, counted(&path), "a shape error is worth one more ask");
2441 }
2442
2443 #[test]
2447 fn a_timeout_still_reaches_the_fallback() {
2448 let mut primary = shell("primary", "sleep 30");
2449 primary.timeout = 1;
2450 let agent = with_fallback(primary, shell("backup", "echo stood in"));
2451 assert_eq!(
2452 "stood in",
2453 agent.ask("hi", Path::new("."), None).expect("fallback")
2454 );
2455 }
2456
2457 #[test]
2460 fn the_fallback_is_built_alongside_the_agent() {
2461 let mut primary = shell("primary", "true");
2462 primary.fallback = Some(Box::new(shell("backup", "true")));
2463 let agent = Agent::new(primary);
2464 assert_eq!(Some("backup"), agent.fallback().map(Agent::name));
2465 assert!(Agent::new(shell("solo", "true")).fallback().is_none());
2466 }
2467}
2468
2469#[cfg(test)]
2470mod schema_placeholder_tests {
2471 use super::*;
2472 use crate::config::{OutputMode, SystemVia};
2473
2474 fn spec_with(command: Vec<CommandPart>) -> AgentSpec {
2475 AgentSpec {
2476 name: "claude".into(),
2477 command,
2478 model: None,
2479 effort: None,
2480 output: OutputMode::Text,
2481 message_match: BTreeMap::new(),
2482 message_path: None,
2483 search_paths: vec![],
2484 system_via: SystemVia::Prompt,
2485 timeout: 60,
2486 fallback: None,
2487 models: vec![],
2488 efforts: vec![],
2489 options_note: None,
2490 }
2491 }
2492
2493 fn one(s: &str) -> CommandPart {
2494 CommandPart::One(s.into())
2495 }
2496 fn group(parts: &[&str]) -> CommandPart {
2497 CommandPart::Group(parts.iter().map(|s| s.to_string()).collect())
2498 }
2499
2500 #[test]
2503 fn either_schema_form_counts_as_native_support() {
2504 let inline = Agent::with_bin(
2505 spec_with(vec![one("x"), group(&["--json-schema", "{schema}"])]),
2506 "/b",
2507 );
2508 let byfile = Agent::with_bin(
2509 spec_with(vec![one("x"), group(&["--output-schema", "{schema_file}"])]),
2510 "/b",
2511 );
2512 let neither = Agent::with_bin(spec_with(vec![one("x"), one("{prompt}")]), "/b");
2513 assert!(inline.supports_schema());
2514 assert!(byfile.supports_schema());
2515 assert!(!neither.supports_schema());
2516 }
2517
2518 #[test]
2519 fn the_inline_schema_is_substituted_whole() {
2520 let agent = Agent::with_bin(
2521 spec_with(vec![
2522 one("x"),
2523 group(&["--json-schema", "{schema}"]),
2524 one("{prompt}"),
2525 ]),
2526 "/b",
2527 );
2528 let values = Placeholders {
2529 prompt: Some("review it".into()),
2530 schema: Some(r#"{"type":"object"}"#.into()),
2531 ..Default::default()
2532 };
2533 assert_eq!(
2534 vec!["/b", "--json-schema", r#"{"type":"object"}"#, "review it"],
2535 agent.render(&values).unwrap()
2536 );
2537 }
2538
2539 #[test]
2542 fn the_schema_flag_drops_when_no_schema_is_wanted() {
2543 let agent = Agent::with_bin(
2544 spec_with(vec![
2545 one("x"),
2546 group(&["--json-schema", "{schema}"]),
2547 one("{prompt}"),
2548 ]),
2549 "/b",
2550 );
2551 let values = Placeholders {
2552 prompt: Some("implement it".into()),
2553 ..Default::default()
2554 };
2555 assert_eq!(vec!["/b", "implement it"], agent.render(&values).unwrap());
2556 }
2557
2558 #[test]
2560 fn the_two_schema_placeholders_do_not_collide() {
2561 let agent = Agent::with_bin(
2562 spec_with(vec![one("x"), group(&["--output-schema", "{schema_file}"])]),
2563 "/b",
2564 );
2565 let values = Placeholders {
2566 schema: Some("INLINE".into()),
2567 schema_file: Some("/tmp/s.json".into()),
2568 ..Default::default()
2569 };
2570 assert_eq!(
2571 vec!["/b", "--output-schema", "/tmp/s.json"],
2572 agent.render(&values).unwrap()
2573 );
2574 }
2575
2576 #[test]
2578 fn the_shipped_claude_preset_now_has_native_structured_output() {
2579 let raw = crate::config::load_preset("claude").unwrap();
2580 let table = raw.as_table().cloned().unwrap();
2581 let mut spec: AgentSpec = toml::Value::Table(table).try_into().unwrap();
2582 spec.name = "claude".into();
2583 assert!(
2584 Agent::with_bin(spec, "/b").supports_schema(),
2585 "without this a long review is parsed out of prose and truncates"
2586 );
2587 }
2588}