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