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