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, probe_again_on_failure, safe_git_state,
21 uncertain_worktree_change, AttributeState, GitState, IgnoredState,
22 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<bool> {
937 if !self.git_entry.still_matches(cwd)? {
938 return Err(uncertain_worktree_change(
939 cwd,
940 format!(
941 "the Git entry at {} changed type during the editing call. No Git recovery \
942 probe was run. Inspect the worktree before retrying.",
943 cwd.join(".git").display()
944 ),
945 ));
946 }
947 let attributes = probe_again_on_failure(|| attribute_state(cwd)).map_err(|e| {
948 uncertain_worktree_change(
949 cwd,
950 format!(
951 "could not check attribute files after a call in {}: {}. Inspect the \
952 worktree before retrying.",
953 cwd.display(),
954 e.last_line()
955 ),
956 )
957 })?;
958 if attributes != self.attributes {
959 return Err(uncertain_worktree_change(
960 cwd,
961 format!(
962 "an attribute file changed during a call in {}. The worktree was kept before \
963 running any Git operation that could select a new filter.",
964 cwd.display()
965 ),
966 ));
967 }
968 let current = probe_again_on_failure(|| git_state(cwd))
969 .map_err(|e| recovery_probe_error(cwd, "state", &e))?;
970 let ignored = probe_again_on_failure(|| ignored_untracked_state(cwd))
971 .map_err(|e| recovery_probe_error(cwd, "ignored files", &e))?;
972 let untracked = match access {
977 Access::Read => self.ignored_untracked.changed_beyond_ignored(&ignored),
978 Access::Edit => self.ignored_untracked.changed_beyond_generated(&ignored),
979 };
980 Ok(current != self.git_state || untracked)
981 }
982}
983
984#[derive(Clone, PartialEq, Eq)]
985enum GitEntry {
986 Directory(GitDirectory),
987 File,
988}
989
990#[derive(Clone, PartialEq, Eq)]
991struct GitDirectory {
992 #[cfg(unix)]
993 device: u64,
994 #[cfg(unix)]
995 inode: u64,
996 #[cfg(not(unix))]
997 created: Option<std::time::SystemTime>,
998}
999
1000impl GitEntry {
1001 fn capture(cwd: &Path) -> Result<Self> {
1002 let path = cwd.join(".git");
1003 match std::fs::symlink_metadata(&path) {
1004 Ok(meta) if meta.is_dir() => {
1005 #[cfg(unix)]
1006 {
1007 use std::os::unix::fs::MetadataExt;
1008 Ok(Self::Directory(GitDirectory {
1009 device: meta.dev(),
1010 inode: meta.ino(),
1011 }))
1012 }
1013 #[cfg(not(unix))]
1014 {
1015 Ok(Self::Directory(GitDirectory {
1016 created: meta.created().ok(),
1017 }))
1018 }
1019 }
1020 Ok(meta) if meta.is_file() => Ok(Self::File),
1021 Ok(_) => bail!(
1022 "{} is not a regular Git directory or marker",
1023 path.display()
1024 ),
1025 Err(e) => Err(spar_err!("could not inspect {}: {e}", path.display())),
1026 }
1027 }
1028
1029 fn still_matches(&self, cwd: &Path) -> Result<bool> {
1030 let path = cwd.join(".git");
1031 match (self, std::fs::symlink_metadata(path)) {
1032 (Self::Directory(before), Ok(meta)) if meta.is_dir() => {
1033 #[cfg(unix)]
1034 {
1035 use std::os::unix::fs::MetadataExt;
1036 Ok(before.device == meta.dev() && before.inode == meta.ino())
1037 }
1038 #[cfg(not(unix))]
1039 {
1040 Ok(before.created == meta.created().ok())
1041 }
1042 }
1043 (Self::Directory(_), Ok(_)) => Ok(false),
1044 (Self::File, Ok(meta)) => Ok(meta.is_file()),
1045 (_, Err(e)) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
1046 (_, Err(e)) => Err(SparError::uncertain_write(format!(
1047 "could not inspect the Git entry at {} after the editing call: {e}",
1048 cwd.join(".git").display()
1049 ))),
1050 }
1051 }
1052}
1053
1054fn recovery_probe_error(cwd: &Path, probe: &str, error: &SparError) -> SparError {
1055 uncertain_worktree_change(
1056 cwd,
1057 format!(
1058 "could not check the Git {probe} after an editing call in {}: {}. Inspect the \
1059 worktree before retrying.",
1060 cwd.display(),
1061 error.last_line()
1062 ),
1063 )
1064}
1065
1066fn recovery_error(
1067 error: &SparError,
1068 baseline: Option<&EditBaseline>,
1069 cwd: &Path,
1070 access: Access,
1071) -> Option<SparError> {
1072 if error.kind() == ErrorKind::UncertainWrite {
1075 return Some(error.clone());
1076 }
1077 let baseline = baseline?;
1078 match baseline.recovery_needed(cwd, access) {
1079 Ok(false) => None,
1080 Ok(true) if access == Access::Edit => Some(changed_edit_failure(error)),
1081 Ok(true) => Some(uncertain_worktree_change(
1082 cwd,
1083 format!(
1084 "{}\nA read-only call changed the worktree before it failed. Its answer was \
1085 discarded and the worktree was kept for recovery.",
1086 error.message()
1087 ),
1088 )),
1089 Err(recovery) => Some(SparError::uncertain_write(format!(
1090 "{}\n{}",
1091 error.message(),
1092 recovery.message()
1093 ))),
1094 }
1095}
1096
1097fn changed_edit_failure(error: &SparError) -> SparError {
1098 const NOTE: &str =
1099 "The call changed the worktree before it failed. It was not retried or handed to a fallback.";
1100 if error.message().contains(NOTE) {
1101 return error.clone();
1102 }
1103 error.with_message(format!("{}\n{NOTE}", error.message()))
1104}
1105
1106fn finish_call<T>(
1107 result: Result<T>,
1108 access: Access,
1109 baseline: Option<&EditBaseline>,
1110 cwd: &Path,
1111) -> Result<T> {
1112 let value = result?;
1113 let Some(baseline) = baseline else {
1114 return Ok(value);
1115 };
1116 if access == Access::Edit {
1117 if baseline.git_entry.still_matches(cwd)? {
1118 return Ok(value);
1119 }
1120 return Err(uncertain_worktree_change(
1121 cwd,
1122 format!(
1123 "the Git entry at {} was replaced during an editing call. The result was \
1124 discarded before any Git operation ran.",
1125 cwd.join(".git").display()
1126 ),
1127 ));
1128 }
1129 match baseline.recovery_needed(cwd, access) {
1130 Ok(false) => Ok(value),
1131 Ok(true) => Err(uncertain_worktree_change(
1132 cwd,
1133 "a read-only call changed the worktree. Its answer was discarded and the worktree \
1134 was kept for recovery.",
1135 )),
1136 Err(error) => Err(error),
1137 }
1138}
1139
1140struct GitFile {
1145 bytes: Vec<u8>,
1146}
1147
1148impl GitFile {
1149 fn capture(cwd: &Path) -> Result<Option<Self>> {
1150 let path = cwd.join(".git");
1151 match std::fs::symlink_metadata(&path) {
1152 Ok(meta) if meta.is_dir() => Ok(None),
1153 Ok(meta) if meta.is_file() => {
1154 ensure_recovery_path_clear(cwd)?;
1155 let bytes = std::fs::read(&path)
1156 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
1157 Ok(Some(Self { bytes }))
1158 }
1159 Ok(_) => bail!(
1160 "{} is not a regular Git marker. Refusing to run an editing call.",
1161 path.display()
1162 ),
1163 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
1164 Err(e) => Err(spar_err!("could not inspect {}: {e}", path.display())),
1165 }
1166 }
1167
1168 fn restore_if_changed(self, cwd: &Path) -> Result<()> {
1169 let path = cwd.join(".git");
1170 let unchanged = std::fs::symlink_metadata(&path)
1171 .ok()
1172 .filter(|meta| meta.is_file())
1173 .and_then(|_| std::fs::read(&path).ok())
1174 .is_some_and(|bytes| bytes == self.bytes);
1175 let recovery = cwd.join(GIT_MARKER_RECOVERY);
1176 if unchanged {
1177 return match std::fs::symlink_metadata(&recovery) {
1178 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
1179 Ok(_) => Err(SparError::uncertain_write(format!(
1180 "the agent call created the reserved recovery path at {}. The Git marker was \
1181 unchanged. Inspect or remove the recovery path before retrying.",
1182 recovery.display()
1183 ))),
1184 Err(e) => Err(SparError::uncertain_write(format!(
1185 "could not inspect {} after the agent call: {e}",
1186 recovery.display()
1187 ))),
1188 };
1189 }
1190
1191 let retained = match std::fs::symlink_metadata(&path) {
1192 Ok(_) => match std::fs::symlink_metadata(&recovery) {
1193 Ok(_) => {
1194 return Err(SparError::uncertain_write(format!(
1195 "the agent call changed the linked worktree Git marker at {}, but the \
1196 changed entry could not be retained because {} already exists. The \
1197 original marker was not restored, so no changed entry was deleted. \
1198 Inspect the worktree before retrying.",
1199 path.display(),
1200 recovery.display()
1201 )));
1202 }
1203 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1204 match std::fs::rename(&path, &recovery) {
1205 Ok(()) => true,
1206 Err(e) => {
1207 return Err(SparError::uncertain_write(format!(
1208 "the agent call changed the linked worktree Git marker at {}, but \
1209 the changed entry could not be moved to {}: {e}. The original \
1210 marker was not restored, so no changed entry was deleted. Inspect \
1211 the worktree before retrying.",
1212 path.display(),
1213 recovery.display()
1214 )));
1215 }
1216 }
1217 }
1218 Err(e) => {
1219 return Err(SparError::uncertain_write(format!(
1220 "the agent call changed the linked worktree Git marker at {}, but {} could \
1221 not be inspected: {e}. The original marker was not restored, so no changed \
1222 entry was deleted. Inspect the worktree before retrying.",
1223 path.display(),
1224 recovery.display()
1225 )));
1226 }
1227 },
1228 Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
1229 Err(e) => {
1230 return Err(SparError::uncertain_write(format!(
1231 "the agent call changed the linked worktree Git marker at {}, but the changed \
1232 entry could not be inspected: {e}. The original marker was not restored. \
1233 Inspect the worktree before retrying.",
1234 path.display()
1235 )));
1236 }
1237 };
1238
1239 let restored = replace_git_marker(&path, &self.bytes);
1240 let restore_note = match &restored {
1241 Ok(()) => "The original marker was restored.".to_string(),
1242 Err(e) => format!("The original marker could not be restored: {e}."),
1243 };
1244 let retain_note = if retained {
1245 format!("The changed marker was kept at {}.", recovery.display())
1246 } else {
1247 "The agent call deleted the changed marker, so there was nothing to retain.".to_string()
1248 };
1249 Err(SparError::uncertain_write(format!(
1250 "the agent call changed the linked worktree Git marker at {}. {restore_note} \
1251 {retain_note} Inspect the worktree before retrying.",
1252 path.display()
1253 )))
1254 }
1255}
1256
1257fn after_call_is_quiet<T>(called: &Result<T>, recover: impl FnOnce() -> Result<()>) -> Result<()> {
1260 if let Err(error) = called {
1261 if error.kind() == ErrorKind::UncertainWrite {
1262 return Err(error.clone());
1263 }
1264 }
1265 recover()
1266}
1267
1268fn replace_git_marker(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
1269 let mut marker = std::fs::OpenOptions::new()
1270 .write(true)
1271 .create_new(true)
1272 .open(path)?;
1273 marker.write_all(bytes)
1274}
1275
1276fn ensure_recovery_path_clear(cwd: &Path) -> Result<()> {
1277 let recovery = cwd.join(GIT_MARKER_RECOVERY);
1278 match std::fs::symlink_metadata(&recovery) {
1279 Ok(_) => Err(SparError::uncertain_write(format!(
1280 "{} already exists. Recover or remove it before another agent call.",
1281 recovery.display()
1282 ))),
1283 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
1284 Err(e) => Err(SparError::uncertain_write(format!(
1285 "could not inspect {} before the agent call: {e}",
1286 recovery.display()
1287 ))),
1288 }
1289}
1290
1291#[derive(Debug, Default, Clone)]
1296pub struct Placeholders {
1297 pub prompt: Option<String>,
1298 pub system: Option<String>,
1299 pub model: Option<String>,
1300 pub effort: Option<String>,
1301 pub cwd: Option<String>,
1302 pub schema_file: Option<String>,
1304 pub schema: Option<String>,
1306}
1307
1308impl Placeholders {
1309 fn get(&self, key: &str) -> Option<&str> {
1310 let value = match key {
1311 "prompt" => self.prompt.as_deref(),
1312 "system" => self.system.as_deref(),
1313 "model" => self.model.as_deref(),
1314 "effort" => self.effort.as_deref(),
1315 "cwd" => self.cwd.as_deref(),
1316 "schema_file" => self.schema_file.as_deref(),
1317 "schema" => self.schema.as_deref(),
1318 _ => None,
1319 };
1320 value.filter(|v| !v.is_empty())
1321 }
1322
1323 fn substitute(&self, arg: &str) -> Option<String> {
1326 const KEYS: [&str; 7] = [
1327 "prompt",
1328 "system",
1329 "model",
1330 "effort",
1331 "cwd",
1332 "schema_file",
1333 "schema",
1334 ];
1335 let mut out = arg.to_string();
1336 for key in KEYS {
1337 let token = format!("{{{key}}}");
1338 if out.contains(&token) {
1339 let value = self.get(key)?;
1340 out = out.replace(&token, value);
1341 }
1342 }
1343 Some(out)
1344 }
1345}
1346
1347fn dig<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
1353 if path.is_empty() {
1354 return None;
1355 }
1356 let mut node = value;
1357 for part in path.split('.') {
1358 node = node.as_object()?.get(part)?;
1359 }
1360 Some(node)
1361}
1362
1363fn matches(event: &Value, wanted: &BTreeMap<String, String>) -> bool {
1364 if wanted.is_empty() {
1365 return false;
1366 }
1367 wanted
1368 .iter()
1369 .all(|(path, expected)| dig(event, path).and_then(Value::as_str) == Some(expected.as_str()))
1370}
1371
1372fn as_text(value: &Value) -> Option<String> {
1373 match value {
1374 Value::String(s) => Some(s.clone()),
1375 Value::Null => None,
1376 other => Some(other.to_string()),
1377 }
1378}
1379
1380fn truncate(text: &str, max: usize) -> String {
1381 text.chars().take(max).collect()
1382}
1383
1384struct TempJson {
1391 path: PathBuf,
1392}
1393
1394impl TempJson {
1395 fn write(value: &Value) -> Result<Self> {
1396 use std::sync::atomic::{AtomicU64, Ordering};
1397 static COUNTER: AtomicU64 = AtomicU64::new(0);
1398
1399 let nanos = std::time::SystemTime::now()
1400 .duration_since(std::time::UNIX_EPOCH)
1401 .map(|d| d.as_nanos())
1402 .unwrap_or(0);
1403 let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
1404 let path = std::env::temp_dir().join(format!(
1405 "spar-schema-{}-{nanos}-{unique}.json",
1406 std::process::id()
1407 ));
1408 std::fs::write(&path, serde_json::to_vec_pretty(value)?)
1409 .map_err(|e| spar_err!("could not write a schema file to {}: {e}", path.display()))?;
1410 Ok(Self { path })
1411 }
1412
1413 fn path(&self) -> &Path {
1414 &self.path
1415 }
1416}
1417
1418impl Drop for TempJson {
1419 fn drop(&mut self) {
1420 let _ = std::fs::remove_file(&self.path);
1421 }
1422}
1423
1424fn same_executable(a: &Path, b: &Path) -> bool {
1435 #[cfg(unix)]
1436 {
1437 use std::os::unix::fs::MetadataExt;
1438 if let (Ok(x), Ok(y)) = (std::fs::metadata(a), std::fs::metadata(b)) {
1439 return x.dev() == y.dev() && x.ino() == y.ino();
1440 }
1441 }
1442 match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
1443 (Ok(x), Ok(y)) => x == y,
1444 _ => a == b,
1445 }
1446}
1447
1448pub fn correlation_warning(agents: &[Agent]) -> Option<String> {
1454 for i in 0..agents.len() {
1455 for j in (i + 1)..agents.len() {
1456 let (a, b) = (&agents[i], &agents[j]);
1457 let (Ok(pa), Ok(pb)) = (a.resolve_bin(), b.resolve_bin()) else {
1458 continue;
1459 };
1460 if !same_executable(pa, pb) || a.spec.model_key() != b.spec.model_key() {
1461 continue;
1462 }
1463 let model = if a.spec.model_key().is_empty() {
1464 "the CLI's default".to_string()
1465 } else {
1466 a.spec.model_key()
1467 };
1468 let where_at = if pa == pb {
1469 pa.display().to_string()
1470 } else {
1471 format!(
1472 "the same executable ({} and {} are the same file)",
1473 pa.display(),
1474 pb.display()
1475 )
1476 };
1477 return Some(format!(
1478 "agents '{}' and '{}' both resolve to {where_at} at model {model}. Review \
1479 findings will be correlated: the same model reviewing itself shares the blind \
1480 spots of the model that wrote the code, so it is far less likely to catch what \
1481 the implementer missed. That produces an approval indistinguishable from a real \
1482 review, which is worse than no review at all. Give the two agents different \
1483 CLIs or different models.",
1484 a.name(),
1485 b.name()
1486 ));
1487 }
1488 }
1489 None
1490}
1491
1492pub fn build(cfg: &crate::config::Config) -> Result<Vec<Agent>> {
1495 let agents: Vec<Agent> = cfg
1496 .agents
1497 .iter()
1498 .cloned()
1499 .map(Agent::new)
1500 .map(|agent| agent.with_instructions(&cfg.loop_cfg.instructions))
1501 .collect();
1502 for agent in &agents {
1503 agent.resolve_bin()?;
1504 if let Some(backup) = agent.fallback() {
1508 if backup.resolve_bin().is_err() {
1509 logwarn!(
1510 "{} has a fallback ({}) that is not installed, so it will not stand in",
1511 agent.name(),
1512 backup.program()
1513 );
1514 }
1515 }
1516 }
1517 Ok(agents)
1518}
1519
1520pub fn find<'a>(agents: &'a [Agent], name: &str) -> Result<&'a Agent> {
1522 agents.iter().find(|a| a.name() == name).ok_or_else(|| {
1523 SparError::new(format!(
1524 "no agent named '{name}' ({})",
1525 agents
1526 .iter()
1527 .map(Agent::name)
1528 .collect::<Vec<_>>()
1529 .join(", ")
1530 ))
1531 })
1532}
1533
1534#[cfg(test)]
1535mod tests {
1536 use super::*;
1537 use crate::config::{OutputMode, SystemVia};
1538
1539 fn spec(command: Vec<CommandPart>) -> AgentSpec {
1540 AgentSpec {
1541 name: "test".into(),
1542 command,
1543 model: None,
1544 effort: None,
1545 effort_schedule: crate::config::EffortSchedule::default(),
1546 output: OutputMode::Text,
1547 message_match: BTreeMap::new(),
1548 message_path: None,
1549 search_paths: vec![],
1550 system_via: SystemVia::Prompt,
1551 timeout: 60,
1552 fallback: None,
1553 models: vec![],
1554 efforts: vec![],
1555 options_note: None,
1556 }
1557 }
1558
1559 fn one(s: &str) -> CommandPart {
1560 CommandPart::One(s.into())
1561 }
1562
1563 fn group(parts: &[&str]) -> CommandPart {
1564 CommandPart::Group(parts.iter().map(|s| s.to_string()).collect())
1565 }
1566
1567 #[test]
1568 fn an_uncertain_process_result_skips_post_call_recovery() {
1569 let called: Result<()> = Err(SparError::uncertain_write("descendants may still write"));
1570 let recovered = std::cell::Cell::new(false);
1571
1572 let error = after_call_is_quiet(&called, || {
1573 recovered.set(true);
1574 Ok(())
1575 })
1576 .unwrap_err();
1577
1578 assert_eq!(ErrorKind::UncertainWrite, error.kind());
1579 assert!(!recovered.get());
1580 }
1581
1582 fn agent(command: Vec<CommandPart>) -> Agent {
1583 Agent::with_bin(spec(command), "/fake/bin")
1584 }
1585
1586 fn values() -> Placeholders {
1587 Placeholders {
1588 prompt: Some("hi".into()),
1589 ..Default::default()
1590 }
1591 }
1592
1593 #[test]
1596 fn placeholders_are_substituted() {
1597 let a = agent(vec![one("x"), group(&["-m", "{model}"]), one("{prompt}")]);
1598 let v = Placeholders {
1599 model: Some("m1".into()),
1600 ..values()
1601 };
1602 assert_eq!(vec!["/fake/bin", "-m", "m1", "hi"], a.render(&v).unwrap());
1603 }
1604
1605 #[test]
1606 fn an_unset_placeholder_drops_the_whole_group() {
1607 let a = agent(vec![one("x"), group(&["-m", "{model}"]), one("{prompt}")]);
1608 assert_eq!(vec!["/fake/bin", "hi"], a.render(&values()).unwrap());
1609 }
1610
1611 #[test]
1612 fn an_empty_string_drops_the_group_too() {
1613 let a = agent(vec![one("x"), group(&["-e", "{effort}"]), one("{prompt}")]);
1614 let v = Placeholders {
1615 effort: Some(String::new()),
1616 ..values()
1617 };
1618 assert_eq!(vec!["/fake/bin", "hi"], a.render(&v).unwrap());
1619 }
1620
1621 #[test]
1622 fn a_bare_arg_with_an_unset_placeholder_drops() {
1623 let a = agent(vec![one("x"), one("{model}"), one("{prompt}")]);
1624 assert_eq!(vec!["/fake/bin", "hi"], a.render(&values()).unwrap());
1625 }
1626
1627 #[test]
1628 fn literal_args_survive() {
1629 let a = agent(vec![
1630 one("x"),
1631 one("exec"),
1632 one("--json"),
1633 one("--"),
1634 one("{prompt}"),
1635 ]);
1636 assert_eq!(
1637 vec!["/fake/bin", "exec", "--json", "--", "hi"],
1638 a.render(&values()).unwrap()
1639 );
1640 }
1641
1642 #[test]
1643 fn an_embedded_placeholder_substitutes_in_place() {
1644 let a = agent(vec![
1645 one("x"),
1646 group(&["-c", "model_reasoning_effort={effort}"]),
1647 ]);
1648 let v = Placeholders {
1649 effort: Some("ultra".into()),
1650 ..Default::default()
1651 };
1652 assert_eq!(
1653 vec!["/fake/bin", "-c", "model_reasoning_effort=ultra"],
1654 a.render(&v).unwrap()
1655 );
1656 }
1657
1658 #[test]
1659 fn a_group_with_two_placeholders_needs_both() {
1660 let a = agent(vec![
1661 one("x"),
1662 group(&["--a", "{model}", "--b", "{effort}"]),
1663 ]);
1664 let v = Placeholders {
1665 model: Some("m".into()),
1666 ..Default::default()
1667 };
1668 assert_eq!(vec!["/fake/bin"], a.render(&v).unwrap());
1669 }
1670
1671 #[test]
1672 fn supports_schema_detects_the_placeholder() {
1673 assert!(agent(vec![one("x"), group(&["--schema", "{schema_file}"])]).supports_schema());
1674 assert!(!agent(vec![one("x"), one("{prompt}")]).supports_schema());
1675 }
1676
1677 #[test]
1680 fn text_passes_through_trimmed() {
1681 assert_eq!("hello", agent(vec![one("x")]).extract(" hello\n").unwrap());
1682 }
1683
1684 #[test]
1685 fn jsonl_picks_the_matching_event() {
1686 let mut spec = spec(vec![one("x")]);
1687 spec.output = OutputMode::Jsonl;
1688 spec.message_path = Some("item.text".into());
1689 spec.message_match = BTreeMap::from([
1690 ("type".to_string(), "item.completed".to_string()),
1691 ("item.type".to_string(), "agent_message".to_string()),
1692 ]);
1693 let a = Agent::with_bin(spec, "/fake/bin");
1694 let stream = [
1695 r#"{"type":"thread.started","thread_id":"t1"}"#,
1696 r#"{"type":"item.completed","item":{"type":"command_execution","text":"ls"}}"#,
1697 r#"{"type":"item.completed","item":{"type":"agent_message","text":"the answer"}}"#,
1698 "not json at all",
1699 ]
1700 .join("\n");
1701 assert_eq!("the answer", a.extract(&stream).unwrap());
1702 }
1703
1704 #[test]
1705 fn jsonl_raises_on_an_error_with_no_message() {
1706 let mut spec = spec(vec![one("x")]);
1707 spec.output = OutputMode::Jsonl;
1708 spec.message_path = Some("item.text".into());
1709 spec.message_match = BTreeMap::from([("type".into(), "item.completed".into())]);
1710 let a = Agent::with_bin(spec, "/fake/bin");
1711 assert!(a
1712 .extract(r#"{"type":"turn.failed","error":"boom"}"#)
1713 .is_err());
1714 }
1715
1716 #[test]
1717 fn jsonl_joins_several_agent_messages() {
1718 let mut spec = spec(vec![one("x")]);
1719 spec.output = OutputMode::Jsonl;
1720 spec.message_path = Some("text".into());
1721 spec.message_match = BTreeMap::from([("type".into(), "msg".into())]);
1722 let a = Agent::with_bin(spec, "/fake/bin");
1723 let stream = "{\"type\":\"msg\",\"text\":\"one\"}\n{\"type\":\"msg\",\"text\":\"two\"}";
1724 assert_eq!("one\ntwo", a.extract(stream).unwrap());
1725 }
1726
1727 #[test]
1728 fn dig_walks_a_dotted_path() {
1729 let v: Value = serde_json::from_str(r#"{"a":{"b":{"c":1}}}"#).unwrap();
1730 assert_eq!(Some(&Value::from(1)), dig(&v, "a.b.c"));
1731 assert_eq!(None, dig(&v, "a.b.missing"));
1732 assert_eq!(None, dig(&v, ""));
1733 }
1734
1735 #[test]
1738 fn a_missing_binary_lists_everywhere_it_looked() {
1739 let mut s = spec(vec![one("definitely-not-installed-xyz")]);
1740 s.search_paths = vec!["/nowhere/at/all".into()];
1741 s.name = "codex".into();
1742 let err = Agent::new(s).resolve_bin().unwrap_err().to_string();
1743 assert!(err.contains("definitely-not-installed-xyz (PATH)"), "{err}");
1744 assert!(
1745 err.contains("/nowhere/at/all/definitely-not-installed-xyz"),
1746 "{err}"
1747 );
1748 assert!(err.contains("SPAR_CODEX_BIN"), "{err}");
1749 }
1750
1751 #[test]
1752 fn a_search_path_that_already_names_the_binary_is_used_as_is() {
1753 let dir = std::env::temp_dir().join(format!("spar-test-{}", std::process::id()));
1754 std::fs::create_dir_all(&dir).unwrap();
1755 let bin = dir.join("mytool");
1756 std::fs::write(&bin, "#!/bin/sh\n").unwrap();
1757 #[cfg(unix)]
1758 {
1759 use std::os::unix::fs::PermissionsExt;
1760 std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
1761 }
1762 let mut s = spec(vec![one("mytool")]);
1763 s.search_paths = vec![bin.display().to_string()];
1764 assert_eq!(bin, Agent::new(s).resolve_bin().unwrap());
1765 let _ = std::fs::remove_dir_all(&dir);
1766 }
1767
1768 fn named(name: &str, bin: &str, model: Option<&str>) -> Agent {
1771 let mut s = spec(vec![one("prog")]);
1772 s.name = name.into();
1773 s.model = model.map(str::to_string);
1774 Agent::with_bin(s, bin)
1775 }
1776
1777 #[test]
1778 fn same_bin_same_model_warns() {
1779 let agents = vec![
1780 named("alpha", "/usr/local/bin/claude", Some("fable")),
1781 named("beta", "/usr/local/bin/claude", Some("fable")),
1782 ];
1783 let msg = correlation_warning(&agents).expect("should warn");
1784 assert!(msg.contains("alpha") && msg.contains("beta"), "{msg}");
1785 }
1786
1787 #[test]
1788 fn different_model_does_not_warn() {
1789 let agents = vec![
1790 named("a", "/usr/local/bin/claude", Some("fable")),
1791 named("b", "/usr/local/bin/claude", Some("opus")),
1792 ];
1793 assert!(correlation_warning(&agents).is_none());
1794 }
1795
1796 #[test]
1797 fn different_bin_does_not_warn() {
1798 let agents = vec![
1799 named("a", "/usr/local/bin/claude", Some("fable")),
1800 named("b", "/usr/local/bin/codex", Some("fable")),
1801 ];
1802 assert!(correlation_warning(&agents).is_none());
1803 }
1804
1805 #[test]
1806 fn unset_and_empty_model_both_mean_the_default_and_warn() {
1807 let agents = vec![
1808 named("a", "/usr/local/bin/claude", None),
1809 named("b", "/usr/local/bin/claude", Some("")),
1810 ];
1811 let msg = correlation_warning(&agents).expect("should warn");
1812 assert!(msg.contains("the CLI's default"), "{msg}");
1813 }
1814
1815 #[test]
1816 fn a_padded_model_still_warns() {
1817 let agents = vec![
1818 named("a", "/usr/local/bin/claude", Some("fable")),
1819 named("b", "/usr/local/bin/claude", Some(" fable ")),
1820 ];
1821 assert!(correlation_warning(&agents).is_some());
1822 }
1823
1824 #[test]
1825 fn an_empty_model_against_a_named_one_does_not_warn() {
1826 let agents = vec![
1827 named("a", "/usr/local/bin/claude", Some("")),
1828 named("b", "/usr/local/bin/claude", Some("fable")),
1829 ];
1830 assert!(correlation_warning(&agents).is_none());
1831 }
1832
1833 #[cfg(unix)]
1834 #[test]
1835 fn a_symlinked_binary_warns_and_names_both_paths() {
1836 use std::os::unix::fs::PermissionsExt;
1837 let dir = std::env::temp_dir().join(format!("spar-link-{}", std::process::id()));
1838 let _ = std::fs::remove_dir_all(&dir);
1839 std::fs::create_dir_all(&dir).unwrap();
1840 let real = dir.join("claude");
1841 let link = dir.join("claude-alias");
1842 std::fs::write(&real, "#!/bin/sh\n").unwrap();
1843 std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o755)).unwrap();
1844 std::os::unix::fs::symlink(&real, &link).unwrap();
1845
1846 let agents = vec![
1847 named("alpha", real.to_str().unwrap(), Some("fable")),
1848 named("beta", link.to_str().unwrap(), Some("fable")),
1849 ];
1850 let msg = correlation_warning(&agents).expect("should warn");
1851 assert!(msg.contains(real.to_str().unwrap()), "{msg}");
1852 assert!(msg.contains(link.to_str().unwrap()), "{msg}");
1853 let _ = std::fs::remove_dir_all(&dir);
1854 }
1855
1856 #[cfg(unix)]
1857 #[test]
1858 fn two_distinct_real_binaries_stay_quiet() {
1859 use std::os::unix::fs::PermissionsExt;
1860 let dir = std::env::temp_dir().join(format!("spar-distinct-{}", std::process::id()));
1861 let _ = std::fs::remove_dir_all(&dir);
1862 std::fs::create_dir_all(&dir).unwrap();
1863 let mut paths = Vec::new();
1864 for name in ["claude", "codex"] {
1865 let path = dir.join(name);
1866 std::fs::write(&path, "#!/bin/sh\n").unwrap();
1867 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
1868 paths.push(path);
1869 }
1870 let agents = vec![
1871 named("a", paths[0].to_str().unwrap(), Some("fable")),
1872 named("b", paths[1].to_str().unwrap(), Some("fable")),
1873 ];
1874 assert!(correlation_warning(&agents).is_none());
1875 let _ = std::fs::remove_dir_all(&dir);
1876 }
1877
1878 #[test]
1879 fn the_style_rules_ask_for_brevity_and_no_attribution() {
1880 let lower = STYLE_RULES.to_lowercase();
1881 assert!(lower.contains("brief"));
1882 assert!(lower.contains("co-authored-by"));
1883 assert!(lower.contains("em-dash"));
1884 }
1885
1886 #[test]
1891 fn brevity_is_about_facts_per_sentence_not_sentence_count() {
1892 let lower = STYLE_RULES.to_lowercase();
1893 assert!(lower.contains("saying fewer things"), "{STYLE_RULES}");
1894 assert!(
1895 !lower.contains("one sentence beats one paragraph"),
1896 "the rule that produced the density is still there"
1897 );
1898 }
1899
1900 #[test]
1905 fn the_style_rules_reach_the_code_and_not_only_what_is_posted() {
1906 let lower = STYLE_RULES.to_lowercase();
1907 assert!(lower.contains("comments in code you write"), "not in scope");
1908 assert!(
1909 lower.contains("comment code for the reason"),
1910 "no rule for it"
1911 );
1912 }
1913
1914 fn refusal_stream() -> String {
1919 let noise = "{\"type\":\"item.completed\",\"item\":{\"id\":\"i\",\"type\":\"command_execution\",\"output\":\"".to_string()
1920 + &"const x = 1;\\n".repeat(200)
1921 + "\"}}";
1922 [
1923 noise.as_str(),
1924 r#"{"type":"error","message":"This content was flagged for possible cybersecurity risk."}"#,
1925 r#"{"type":"error","message":"This content was flagged for possible cybersecurity risk."}"#,
1926 r#"{"type":"turn.failed","error":{"message":"This content was flagged for possible cybersecurity risk."}}"#,
1927 ]
1928 .join("\n")
1929 }
1930
1931 fn jsonl_agent(name: &str) -> Agent {
1932 let mut spec = spec(vec![one("codex")]);
1933 spec.name = name.into();
1934 spec.output = OutputMode::Jsonl;
1935 spec.message_path = Some("item.text".into());
1936 Agent::with_bin(spec, "/fake/codex")
1937 }
1938
1939 fn failed(stdout: &str, stderr: &str) -> proc::Output {
1940 proc::Output {
1941 stdout: stdout.to_string(),
1942 stdout_bytes: stdout.as_bytes().to_vec(),
1943 stderr: stderr.to_string(),
1944 code: 1,
1945 }
1946 }
1947
1948 #[test]
1951 fn a_jsonl_failure_reports_the_reason_and_not_the_stream() {
1952 let agent = jsonl_agent("codex");
1953 let err = agent.call_failure(&["codex".to_string()], &failed(&refusal_stream(), ""));
1954 let text = err.message();
1955 assert!(
1956 text.contains("flagged for possible cybersecurity risk"),
1957 "{text}"
1958 );
1959 assert!(
1960 !text.contains("const x = 1;"),
1961 "the stream leaked in:\n{text}"
1962 );
1963 assert!(text.len() < 400, "still {} characters:\n{text}", text.len());
1964 }
1965
1966 #[test]
1968 fn the_same_reason_reported_three_times_is_said_once() {
1969 let agent = jsonl_agent("codex");
1970 let err = agent.call_failure(&["codex".to_string()], &failed(&refusal_stream(), ""));
1971 assert_eq!(
1972 1,
1973 err.message().matches("flagged for possible").count(),
1974 "{}",
1975 err.message()
1976 );
1977 }
1978
1979 #[test]
1982 fn stderr_is_kept_because_it_is_where_the_other_half_arrives() {
1983 let agent = jsonl_agent("codex");
1984 let err = agent.call_failure(
1985 &["codex".to_string()],
1986 &failed(
1987 &refusal_stream(),
1988 "ERROR router: agent thread limit reached",
1989 ),
1990 );
1991 assert!(
1992 err.message().contains("agent thread limit reached"),
1993 "{}",
1994 err.message()
1995 );
1996 }
1997
1998 #[test]
2001 fn a_stream_with_no_error_event_falls_back_to_the_raw_output() {
2002 let agent = jsonl_agent("codex");
2003 let err = agent.call_failure(
2004 &["codex".to_string()],
2005 &failed("{\"type\":\"system\"}", "segmentation fault"),
2006 );
2007 assert!(
2008 err.message().contains("segmentation fault"),
2009 "{}",
2010 err.message()
2011 );
2012 assert!(
2013 err.message().starts_with("command failed"),
2014 "{}",
2015 err.message()
2016 );
2017 }
2018
2019 #[test]
2021 fn a_text_agent_is_reported_exactly_as_before() {
2022 let agent = agent(vec![one("mytool")]);
2023 let err = agent.call_failure(&["mytool".to_string()], &failed("some prose", "boom"));
2024 assert!(
2025 err.message().starts_with("command failed"),
2026 "{}",
2027 err.message()
2028 );
2029 assert!(err.message().contains("some prose"), "{}", err.message());
2030 }
2031
2032 #[test]
2035 fn a_reworded_failure_is_still_a_failed_call() {
2036 let agent = jsonl_agent("codex");
2037 let err = agent.call_failure(&["codex".to_string()], &failed(&refusal_stream(), ""));
2038 assert_eq!(ErrorKind::CallFailed, err.kind());
2039 }
2040
2041 #[test]
2044 fn a_request_carries_the_instructions_after_the_task() {
2045 let agent = Agent::with_bin(shell("a", "true"), "/bin/sh")
2046 .with_instructions("Do not wait for CI. Pick it up next pass.");
2047 let asked = agent.instructed("Review the changes on this branch.");
2048 assert!(
2049 asked.starts_with("Review the changes on this branch."),
2050 "{asked}"
2051 );
2052 assert!(asked.contains("Do not wait for CI"), "{asked}");
2053 }
2054
2055 #[test]
2059 fn the_instructions_arrive_subordinate_to_the_request() {
2060 let agent = Agent::with_bin(shell("a", "true"), "/bin/sh").with_instructions("Be quick.");
2061 let asked = agent.instructed("Do the work.").to_lowercase();
2062 assert!(
2063 asked.contains("from the person who started this run"),
2064 "{asked}"
2065 );
2066 assert!(asked.contains("not the shape of your answer"), "{asked}");
2067 }
2068
2069 #[test]
2070 fn nothing_is_added_when_there_are_none() {
2071 let agent = Agent::with_bin(shell("a", "true"), "/bin/sh");
2072 assert_eq!("Do the work.", agent.instructed("Do the work."));
2073 let blank = Agent::with_bin(shell("b", "true"), "/bin/sh").with_instructions(" \n ");
2075 assert_eq!("Do the work.", blank.instructed("Do the work."));
2076 }
2077
2078 #[test]
2081 fn the_stand_in_carries_them_too() {
2082 let agent = with_fallback(shell("primary", "true"), shell("backup", "true"))
2083 .with_instructions("Do not wait for CI.");
2084 let backup = agent.fallback().expect("a stand in");
2085 assert!(backup
2086 .instructed("Do the work.")
2087 .contains("Do not wait for CI."));
2088 }
2089
2090 fn shell(name: &str, line: &str) -> AgentSpec {
2095 let mut spec = spec(vec![one("sh"), one("-c"), one(line)]);
2096 spec.name = name.into();
2097 spec
2098 }
2099
2100 fn with_fallback(mut primary: AgentSpec, backup: AgentSpec) -> Agent {
2101 primary.fallback = Some(Box::new(backup));
2102 Agent::with_bin(primary, "/bin/sh")
2103 }
2104
2105 fn git_at(cwd: &Path, args: &[&str]) -> String {
2106 let out = std::process::Command::new("git")
2107 .args(args)
2108 .current_dir(cwd)
2109 .output()
2110 .unwrap();
2111 assert!(
2112 out.status.success(),
2113 "{}",
2114 String::from_utf8_lossy(&out.stderr)
2115 );
2116 String::from_utf8_lossy(&out.stdout).trim().to_string()
2117 }
2118
2119 fn committed_repo(name: &str) -> PathBuf {
2120 let dir = std::env::temp_dir().join(format!(
2121 "spar-agent-{name}-{}-{}",
2122 std::process::id(),
2123 std::thread::current().name().unwrap_or("test")
2124 ));
2125 let _ = std::fs::remove_dir_all(&dir);
2126 std::fs::create_dir_all(&dir).unwrap();
2127 git_at(&dir, &["init", "-q", "-b", "main"]);
2128 git_at(&dir, &["config", "user.email", "spar@example.invalid"]);
2129 git_at(&dir, &["config", "user.name", "spar test"]);
2130 git_at(&dir, &["config", "commit.gpgsign", "false"]);
2131 std::fs::write(dir.join("README.md"), "seed\n").unwrap();
2132 git_at(&dir, &["add", "README.md"]);
2133 git_at(&dir, &["commit", "-q", "-m", "seed"]);
2134 dir
2135 }
2136
2137 fn linked_worktree(name: &str) -> (PathBuf, PathBuf, PathBuf, Vec<u8>) {
2138 let root = std::env::temp_dir().join(format!(
2139 "spar-agent-{name}-{}-{}",
2140 std::process::id(),
2141 std::thread::current().name().unwrap_or("test")
2142 ));
2143 let _ = std::fs::remove_dir_all(&root);
2144 let main = root.join("main");
2145 std::fs::create_dir_all(&main).unwrap();
2146 git_at(&main, &["init", "-q", "-b", "main"]);
2147 git_at(&main, &["config", "user.email", "spar@example.invalid"]);
2148 git_at(&main, &["config", "user.name", "spar test"]);
2149 git_at(&main, &["config", "commit.gpgsign", "false"]);
2150 std::fs::write(main.join("README.md"), "seed\n").unwrap();
2151 git_at(&main, &["add", "README.md"]);
2152 git_at(&main, &["commit", "-q", "-m", "seed"]);
2153 let linked = root.join("linked");
2154 git_at(
2155 &main,
2156 &[
2157 "worktree",
2158 "add",
2159 "-q",
2160 "-b",
2161 "issue-test",
2162 linked.to_str().unwrap(),
2163 ],
2164 );
2165 let marker = std::fs::read(linked.join(".git")).unwrap();
2166 (root, main, linked, marker)
2167 }
2168
2169 #[test]
2170 fn a_failed_call_is_answered_by_the_fallback() {
2171 let agent = with_fallback(
2172 shell("primary", "echo refused >&2; exit 1"),
2173 shell("backup", "echo stood in"),
2174 );
2175 let answer = agent
2176 .ask("hi", Path::new("."), &Effort::default())
2177 .expect("fallback");
2178 assert_eq!("stood in", answer);
2179 }
2180
2181 #[test]
2182 fn a_failed_edit_with_files_left_does_not_run_the_fallback() {
2183 let dir = committed_repo("dirty-edit-recovery");
2184
2185 let agent = with_fallback(
2186 shell(
2187 "primary",
2188 "printf 'recover me\\n' > README.md; printf refused >&2; exit 1",
2189 ),
2190 shell("backup", "printf 'fallback ran\\n' > fallback.txt"),
2191 );
2192 let err = agent
2193 .edit("change it", &dir, &Effort::default())
2194 .unwrap_err();
2195
2196 assert!(err.message().contains("refused"), "{err}");
2197 assert_eq!(
2198 1,
2199 err.message()
2200 .matches("The call changed the worktree before it failed")
2201 .count(),
2202 "{err}"
2203 );
2204 assert_eq!(
2205 "recover me\n",
2206 std::fs::read_to_string(dir.join("README.md")).unwrap()
2207 );
2208 assert!(!dir.join("fallback.txt").exists());
2209 let _ = std::fs::remove_dir_all(&dir);
2210 }
2211
2212 #[test]
2213 fn a_failed_edit_with_a_new_ignored_file_does_not_run_the_fallback() {
2214 let dir = committed_repo("ignored-edit-recovery");
2215 std::fs::write(dir.join(".gitignore"), "ignored.txt\n").unwrap();
2216 git_at(&dir, &["add", ".gitignore"]);
2217 git_at(&dir, &["commit", "-q", "-m", "ignore fixture"]);
2218 let agent = with_fallback(
2219 shell(
2220 "primary",
2221 "printf 'recover me\n' > ignored.txt; printf refused >&2; exit 1",
2222 ),
2223 shell("backup", "printf 'fallback ran\n' > fallback.txt"),
2224 );
2225
2226 let err = agent
2227 .edit("change it", &dir, &Effort::default())
2228 .unwrap_err();
2229
2230 assert!(err.message().contains("refused"), "{err}");
2231 assert_eq!(
2232 "recover me\n",
2233 std::fs::read_to_string(dir.join("ignored.txt")).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_that_overwrites_an_ignored_file_does_not_run_the_fallback() {
2241 let dir = committed_repo("changed-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 std::fs::write(dir.join("ignored.txt"), "before\n").unwrap();
2246 let agent = with_fallback(
2247 shell(
2248 "primary",
2249 "printf 'after!\\n' > ignored.txt; printf refused >&2; exit 1",
2250 ),
2251 shell("backup", "printf 'fallback ran\n' > fallback.txt"),
2252 );
2253
2254 let err = agent
2255 .edit("change it", &dir, &Effort::default())
2256 .unwrap_err();
2257
2258 assert!(err.message().contains("refused"), "{err}");
2259 assert_eq!(
2260 "after!\n",
2261 std::fs::read_to_string(dir.join("ignored.txt")).unwrap()
2262 );
2263 assert!(!dir.join("fallback.txt").exists());
2264 let _ = std::fs::remove_dir_all(&dir);
2265 }
2266
2267 #[test]
2268 fn a_failed_edit_with_a_commit_does_not_run_the_fallback() {
2269 let dir = committed_repo("committed-edit-recovery");
2270 let agent = with_fallback(
2271 shell(
2272 "primary",
2273 "printf 'recover me\n' > README.md; git add README.md; \
2274 git commit -q -m preserved; printf refused >&2; exit 1",
2275 ),
2276 shell("backup", "printf 'fallback ran\n' > fallback.txt"),
2277 );
2278
2279 let err = agent
2280 .edit("change it", &dir, &Effort::default())
2281 .unwrap_err();
2282
2283 assert!(err.message().contains("refused"), "{err}");
2284 assert_eq!("preserved", git_at(&dir, &["log", "-1", "--pretty=%s"]));
2285 assert_eq!(
2286 "recover me\n",
2287 std::fs::read_to_string(dir.join("README.md")).unwrap()
2288 );
2289 assert!(!dir.join("fallback.txt").exists());
2290 let _ = std::fs::remove_dir_all(&dir);
2291 }
2292
2293 #[test]
2294 fn a_committed_edit_with_malformed_json_is_not_retried_or_handed_over() {
2295 let dir = committed_repo("committed-malformed-edit");
2296 let agent = with_fallback(
2297 shell(
2298 "primary",
2299 "printf 'once\n' >> attempts.txt; printf 'recover me\n' > README.md; \
2300 git add -A; git commit -q -m preserved; printf 'not json\n'",
2301 ),
2302 shell(
2303 "backup",
2304 "printf 'fallback ran\n' > fallback.txt; printf '{}\n'",
2305 ),
2306 );
2307
2308 let err = agent
2309 .edit_json::<Value>(
2310 "change it",
2311 &serde_json::json!({"type": "object"}),
2312 &dir,
2313 &Effort::default(),
2314 )
2315 .unwrap_err();
2316
2317 assert!(err.message().contains("JSON"), "{err}");
2318 assert_eq!("preserved", git_at(&dir, &["log", "-1", "--pretty=%s"]));
2319 assert_eq!(
2320 1,
2321 std::fs::read_to_string(dir.join("attempts.txt"))
2322 .unwrap()
2323 .lines()
2324 .count()
2325 );
2326 assert!(!dir.join("fallback.txt").exists());
2327 let _ = std::fs::remove_dir_all(&dir);
2328 }
2329
2330 #[test]
2331 fn a_failed_edit_recovery_probe_does_not_retry_or_run_the_fallback() {
2332 let dir = committed_repo("failed-edit-probe");
2333 let agent = with_fallback(
2334 shell(
2335 "primary",
2336 "printf 'once\n' >> attempts.txt; mv .git .git-away; printf 'not json\n'",
2337 ),
2338 shell(
2339 "backup",
2340 "printf 'fallback ran\n' > fallback.txt; printf '{}\n'",
2341 ),
2342 );
2343
2344 let err = agent
2345 .edit_json::<Value>(
2346 "change it",
2347 &serde_json::json!({"type": "object"}),
2348 &dir,
2349 &Effort::default(),
2350 )
2351 .unwrap_err();
2352
2353 std::fs::rename(dir.join(".git-away"), dir.join(".git")).unwrap();
2354 assert_eq!(ErrorKind::UncertainWrite, err.kind());
2355 assert!(err.message().contains("JSON"), "{err}");
2356 assert_eq!(
2357 1,
2358 std::fs::read_to_string(dir.join("attempts.txt"))
2359 .unwrap()
2360 .lines()
2361 .count()
2362 );
2363 assert!(!dir.join("fallback.txt").exists());
2364 assert!(dir.join(".git").is_dir());
2365 let _ = std::fs::remove_dir_all(&dir);
2366 }
2367
2368 #[test]
2369 fn a_linked_worktree_git_marker_is_restored_after_an_edit() {
2370 let (root, main, linked, marker) = linked_worktree("changed-git-marker");
2371 let agent = Agent::with_bin(
2372 shell(
2373 "editor",
2374 "printf 'gitdir: /tmp/not-the-repo\\n' > .git; echo done",
2375 ),
2376 "/bin/sh",
2377 );
2378
2379 let err = agent
2380 .edit("change it", &linked, &Effort::default())
2381 .unwrap_err();
2382
2383 assert_eq!(ErrorKind::UncertainWrite, err.kind());
2384 assert!(err.message().contains("Git marker"), "{err}");
2385 assert_eq!(marker, std::fs::read(linked.join(".git")).unwrap());
2386 assert_eq!(
2387 b"gitdir: /tmp/not-the-repo\n",
2388 std::fs::read(linked.join(GIT_MARKER_RECOVERY))
2389 .unwrap()
2390 .as_slice()
2391 );
2392 git_at(
2393 &main,
2394 &["worktree", "remove", "--force", linked.to_str().unwrap()],
2395 );
2396 let _ = std::fs::remove_dir_all(&root);
2397 }
2398
2399 #[test]
2400 fn a_read_call_also_restores_a_linked_worktree_git_marker() {
2401 let (root, main, linked, marker) = linked_worktree("read-call-git-marker");
2402 let agent = with_fallback(
2403 shell(
2404 "reader",
2405 "printf 'gitdir: /tmp/not-the-repo\n' > .git; echo done",
2406 ),
2407 shell("backup", "printf 'fallback ran\n' > fallback.txt"),
2408 );
2409
2410 let err = agent
2411 .ask("read it", &linked, &Effort::default())
2412 .unwrap_err();
2413
2414 assert_eq!(ErrorKind::UncertainWrite, err.kind());
2415 assert_eq!(marker, std::fs::read(linked.join(".git")).unwrap());
2416 assert!(!linked.join("fallback.txt").exists());
2417 git_at(
2418 &main,
2419 &["worktree", "remove", "--force", linked.to_str().unwrap()],
2420 );
2421 let _ = std::fs::remove_dir_all(&root);
2422 }
2423
2424 #[test]
2425 fn a_successful_read_that_writes_is_discarded() {
2426 let dir = committed_repo("successful-read-write");
2427 let agent = Agent::with_bin(
2428 shell("reader", "printf 'recover me\n' > README.md; echo reviewed"),
2429 "/bin/sh",
2430 );
2431
2432 let err = agent.ask("read it", &dir, &Effort::default()).unwrap_err();
2433
2434 assert_eq!(ErrorKind::UncertainWrite, err.kind());
2435 assert!(err.message().contains("read-only call"), "{err}");
2436 assert_eq!(
2437 "recover me\n",
2438 std::fs::read_to_string(dir.join("README.md")).unwrap()
2439 );
2440 assert!(std::fs::read_dir(&dir).unwrap().flatten().any(|entry| entry
2441 .file_name()
2442 .to_string_lossy()
2443 .starts_with(".spar-recovery-needed-")));
2444 let _ = std::fs::remove_dir_all(&dir);
2445 }
2446
2447 fn built_repo(name: &str) -> PathBuf {
2449 let dir = committed_repo(name);
2450 std::fs::write(dir.join(".gitignore"), "dist/\nlocal.env\n").unwrap();
2451 git_at(&dir, &["add", ".gitignore"]);
2452 git_at(&dir, &["commit", "-q", "-m", "ignore build output"]);
2453 std::fs::create_dir_all(dir.join("dist")).unwrap();
2454 std::fs::write(dir.join("dist/index.js"), "first build\n").unwrap();
2455 dir
2456 }
2457
2458 #[test]
2459 fn a_read_that_only_rebuilds_generated_output_keeps_its_answer() {
2460 let dir = built_repo("successful-read-build");
2461 let agent = Agent::with_bin(
2462 shell(
2463 "reader",
2464 "printf 'second build\n' > dist/index.js; printf 'more\n' > dist/extra.js; \
2465 echo reviewed",
2466 ),
2467 "/bin/sh",
2468 );
2469
2470 let answer = agent
2471 .ask("read it", &dir, &Effort::default())
2472 .expect("answer kept");
2473
2474 assert_eq!("reviewed", answer.trim());
2475 assert_eq!(
2476 "second build\n",
2477 std::fs::read_to_string(dir.join("dist/index.js")).unwrap()
2478 );
2479 assert!(
2480 !std::fs::read_dir(&dir).unwrap().flatten().any(|entry| entry
2481 .file_name()
2482 .to_string_lossy()
2483 .starts_with(".spar-recovery-needed-"))
2484 );
2485 let _ = std::fs::remove_dir_all(&dir);
2486 }
2487
2488 #[test]
2492 fn a_read_that_writes_a_new_ignored_file_elsewhere_keeps_its_answer() {
2493 let dir = built_repo("successful-read-ignored");
2494 let agent = Agent::with_bin(
2495 shell(
2496 "reader",
2497 "printf 'second build\n' > dist/index.js; printf 'TOKEN=x\n' > local.env; \
2498 echo reviewed",
2499 ),
2500 "/bin/sh",
2501 );
2502
2503 let answer = agent
2504 .ask("read it", &dir, &Effort::default())
2505 .expect("answer kept");
2506
2507 assert_eq!("reviewed", answer.trim());
2508 assert_eq!(
2509 "TOKEN=x\n",
2510 std::fs::read_to_string(dir.join("local.env")).unwrap()
2511 );
2512 assert!(
2513 !std::fs::read_dir(&dir).unwrap().flatten().any(|entry| entry
2514 .file_name()
2515 .to_string_lossy()
2516 .starts_with(".spar-recovery-needed-"))
2517 );
2518 let _ = std::fs::remove_dir_all(&dir);
2519 }
2520
2521 #[test]
2522 fn a_read_that_rewrites_an_existing_ignored_file_elsewhere_is_discarded() {
2523 let dir = built_repo("successful-read-existing-ignored");
2524 std::fs::write(dir.join("local.env"), "TOKEN=before\n").unwrap();
2525 let agent = Agent::with_bin(
2526 shell(
2527 "reader",
2528 "printf 'TOKEN=after\n' > local.env; echo reviewed",
2529 ),
2530 "/bin/sh",
2531 );
2532
2533 let err = agent.ask("read it", &dir, &Effort::default()).unwrap_err();
2534
2535 assert_eq!(ErrorKind::UncertainWrite, err.kind());
2536 assert!(err.message().contains("read-only call"), "{err}");
2537 assert_eq!(
2538 "TOKEN=after\n",
2539 std::fs::read_to_string(dir.join("local.env")).unwrap()
2540 );
2541 let _ = std::fs::remove_dir_all(&dir);
2542 }
2543
2544 #[test]
2545 fn a_read_that_changes_a_tracked_file_is_still_discarded() {
2546 let dir = built_repo("successful-read-tracked");
2547 let agent = Agent::with_bin(
2548 shell(
2549 "reader",
2550 "printf 'second build\n' > dist/index.js; printf 'recover me\n' > README.md; \
2551 echo reviewed",
2552 ),
2553 "/bin/sh",
2554 );
2555
2556 let err = agent.ask("read it", &dir, &Effort::default()).unwrap_err();
2557
2558 assert_eq!(ErrorKind::UncertainWrite, err.kind());
2559 assert!(err.message().contains("read-only call"), "{err}");
2560 assert_eq!(
2561 "recover me\n",
2562 std::fs::read_to_string(dir.join("README.md")).unwrap()
2563 );
2564 let _ = std::fs::remove_dir_all(&dir);
2565 }
2566
2567 #[test]
2568 fn a_successful_edit_cannot_replace_the_git_directory() {
2569 let dir = committed_repo("replaced-git-directory");
2570 let agent = Agent::with_bin(
2571 shell("editor", "mv .git .git-original; mkdir .git; echo done"),
2572 "/bin/sh",
2573 );
2574
2575 let err = agent
2576 .edit("change it", &dir, &Effort::default())
2577 .unwrap_err();
2578
2579 assert_eq!(ErrorKind::UncertainWrite, err.kind());
2580 assert!(err.message().contains("Git entry"), "{err}");
2581 assert!(dir.join(".git-original").is_dir());
2582 std::fs::remove_dir(dir.join(".git")).unwrap();
2583 std::fs::rename(dir.join(".git-original"), dir.join(".git")).unwrap();
2584 let _ = std::fs::remove_dir_all(&dir);
2585 }
2586
2587 #[test]
2588 fn a_deleted_linked_worktree_git_marker_is_restored_without_a_fallback() {
2589 let (root, main, linked, marker) = linked_worktree("deleted-git-marker");
2590 let agent = with_fallback(
2591 shell("editor", "rm .git; echo done"),
2592 shell("backup", "printf 'fallback ran\n' > fallback.txt"),
2593 );
2594
2595 let err = agent
2596 .edit("change it", &linked, &Effort::default())
2597 .unwrap_err();
2598
2599 assert_eq!(ErrorKind::UncertainWrite, err.kind());
2600 assert!(err.message().contains("deleted"), "{err}");
2601 assert_eq!(marker, std::fs::read(linked.join(".git")).unwrap());
2602 assert!(!linked.join(GIT_MARKER_RECOVERY).exists());
2603 assert!(!linked.join("fallback.txt").exists());
2604 git_at(
2605 &main,
2606 &["worktree", "remove", "--force", linked.to_str().unwrap()],
2607 );
2608 let _ = std::fs::remove_dir_all(&root);
2609 }
2610
2611 #[test]
2612 fn an_occupied_marker_recovery_path_keeps_the_changed_marker() {
2613 let (root, main, linked, marker) = linked_worktree("occupied-marker-recovery");
2614 let agent = with_fallback(
2615 shell(
2616 "editor",
2617 "mkdir .spar-edited-git-marker; \
2618 printf 'gitdir: /tmp/not-the-repo\n' > .git; echo done",
2619 ),
2620 shell("backup", "printf 'fallback ran\n' > fallback.txt"),
2621 );
2622
2623 let err = agent
2624 .edit("change it", &linked, &Effort::default())
2625 .unwrap_err();
2626
2627 assert_eq!(ErrorKind::UncertainWrite, err.kind());
2628 assert!(err.message().contains("already exists"), "{err}");
2629 assert_eq!(
2630 b"gitdir: /tmp/not-the-repo\n",
2631 std::fs::read(linked.join(".git")).unwrap().as_slice()
2632 );
2633 assert!(linked.join(GIT_MARKER_RECOVERY).is_dir());
2634 assert!(!linked.join("fallback.txt").exists());
2635 std::fs::write(linked.join(".git"), marker).unwrap();
2636 git_at(
2637 &main,
2638 &["worktree", "remove", "--force", linked.to_str().unwrap()],
2639 );
2640 let _ = std::fs::remove_dir_all(&root);
2641 }
2642
2643 #[test]
2644 fn without_a_fallback_the_original_error_is_what_surfaces() {
2645 let agent = Agent::with_bin(shell("primary", "echo refused >&2; exit 1"), "/bin/sh");
2646 let err = agent
2647 .ask("hi", Path::new("."), &Effort::default())
2648 .expect_err("no backup");
2649 assert!(err.message().contains("refused"), "{err}");
2650 }
2651
2652 #[test]
2655 fn both_failing_reports_the_primary_first() {
2656 let agent = with_fallback(
2657 shell("primary", "echo policy refusal >&2; exit 1"),
2658 shell("backup", "echo out of quota >&2; exit 1"),
2659 );
2660 let err = agent
2661 .ask("hi", Path::new("."), &Effort::default())
2662 .expect_err("both fail");
2663 let text = err.message();
2664 let primary_at = text.find("policy refusal").expect("primary reason");
2665 let backup_at = text.find("out of quota").expect("backup reason");
2666 assert!(primary_at < backup_at, "{text}");
2667 assert!(
2668 text.contains("primary") && text.contains("backup"),
2669 "{text}"
2670 );
2671 }
2672
2673 fn attempts(name: &str) -> (PathBuf, String) {
2676 let path = std::env::temp_dir().join(format!("spar-attempts-{name}"));
2677 let _ = std::fs::remove_file(&path);
2678 let line = format!("echo x >> {}", path.display());
2679 (path, line)
2680 }
2681
2682 fn counted(path: &Path) -> usize {
2683 std::fs::read_to_string(path)
2684 .map(|t| t.lines().count())
2685 .unwrap_or(0)
2686 }
2687
2688 #[test]
2693 fn a_cli_that_could_not_answer_is_not_asked_twice_when_there_is_a_stand_in() {
2694 let (path, count) = attempts("refused-with-standin");
2695 let agent = with_fallback(
2696 shell("primary", &format!("{count}; echo refused >&2; exit 1")),
2697 shell("backup", "echo '{}'"),
2698 );
2699 let answer: Value = agent
2700 .ask_json(
2701 "q",
2702 &serde_json::json!({"type": "object"}),
2703 Path::new("."),
2704 &Effort::default(),
2705 )
2706 .expect("the stand in answers");
2707 assert!(answer.is_object());
2708 assert_eq!(1, counted(&path), "the primary was asked more than once");
2709 }
2710
2711 #[test]
2714 fn with_no_stand_in_a_failed_call_is_still_retried() {
2715 let (path, count) = attempts("refused-alone");
2716 let agent = Agent::with_bin(
2717 shell("solo", &format!("{count}; echo refused >&2; exit 1")),
2718 "/bin/sh",
2719 );
2720 let err = agent
2721 .ask_json::<Value>(
2722 "q",
2723 &serde_json::json!({"type": "object"}),
2724 Path::new("."),
2725 &Effort::default(),
2726 )
2727 .expect_err("nothing answers");
2728 assert!(err.message().contains("twice"), "{err}");
2729 assert_eq!(2, counted(&path));
2730 }
2731
2732 #[test]
2736 fn an_unusable_answer_is_still_worth_asking_again() {
2737 let (path, count) = attempts("unparsable");
2738 let agent = with_fallback(
2739 shell("primary", &format!("{count}; echo not json at all")),
2740 shell("backup", "echo '{}'"),
2741 );
2742 let answer: Value = agent
2743 .ask_json(
2744 "q",
2745 &serde_json::json!({"type": "object"}),
2746 Path::new("."),
2747 &Effort::default(),
2748 )
2749 .expect("the stand in answers in the end");
2750 assert!(answer.is_object());
2751 assert_eq!(2, counted(&path), "a shape error is worth one more ask");
2752 }
2753
2754 #[test]
2758 fn a_timeout_still_reaches_the_fallback() {
2759 let mut primary = shell("primary", "sleep 30");
2760 primary.timeout = 1;
2761 let agent = with_fallback(primary, shell("backup", "echo stood in"));
2762 assert_eq!(
2763 "stood in",
2764 agent
2765 .ask("hi", Path::new("."), &Effort::default())
2766 .expect("fallback")
2767 );
2768 }
2769
2770 #[test]
2773 fn the_fallback_is_built_alongside_the_agent() {
2774 let mut primary = shell("primary", "true");
2775 primary.fallback = Some(Box::new(shell("backup", "true")));
2776 let agent = Agent::new(primary);
2777 assert_eq!(Some("backup"), agent.fallback().map(Agent::name));
2778 assert!(Agent::new(shell("solo", "true")).fallback().is_none());
2779 }
2780}
2781
2782#[cfg(test)]
2783mod schema_placeholder_tests {
2784 use super::*;
2785 use crate::config::{OutputMode, SystemVia};
2786
2787 fn spec_with(command: Vec<CommandPart>) -> AgentSpec {
2788 AgentSpec {
2789 name: "claude".into(),
2790 command,
2791 model: None,
2792 effort: None,
2793 effort_schedule: crate::config::EffortSchedule::default(),
2794 output: OutputMode::Text,
2795 message_match: BTreeMap::new(),
2796 message_path: None,
2797 search_paths: vec![],
2798 system_via: SystemVia::Prompt,
2799 timeout: 60,
2800 fallback: None,
2801 models: vec![],
2802 efforts: vec![],
2803 options_note: None,
2804 }
2805 }
2806
2807 fn one(s: &str) -> CommandPart {
2808 CommandPart::One(s.into())
2809 }
2810 fn group(parts: &[&str]) -> CommandPart {
2811 CommandPart::Group(parts.iter().map(|s| s.to_string()).collect())
2812 }
2813
2814 #[test]
2817 fn either_schema_form_counts_as_native_support() {
2818 let inline = Agent::with_bin(
2819 spec_with(vec![one("x"), group(&["--json-schema", "{schema}"])]),
2820 "/b",
2821 );
2822 let byfile = Agent::with_bin(
2823 spec_with(vec![one("x"), group(&["--output-schema", "{schema_file}"])]),
2824 "/b",
2825 );
2826 let neither = Agent::with_bin(spec_with(vec![one("x"), one("{prompt}")]), "/b");
2827 assert!(inline.supports_schema());
2828 assert!(byfile.supports_schema());
2829 assert!(!neither.supports_schema());
2830 }
2831
2832 #[test]
2833 fn the_inline_schema_is_substituted_whole() {
2834 let agent = Agent::with_bin(
2835 spec_with(vec![
2836 one("x"),
2837 group(&["--json-schema", "{schema}"]),
2838 one("{prompt}"),
2839 ]),
2840 "/b",
2841 );
2842 let values = Placeholders {
2843 prompt: Some("review it".into()),
2844 schema: Some(r#"{"type":"object"}"#.into()),
2845 ..Default::default()
2846 };
2847 assert_eq!(
2848 vec!["/b", "--json-schema", r#"{"type":"object"}"#, "review it"],
2849 agent.render(&values).unwrap()
2850 );
2851 }
2852
2853 #[test]
2856 fn the_schema_flag_drops_when_no_schema_is_wanted() {
2857 let agent = Agent::with_bin(
2858 spec_with(vec![
2859 one("x"),
2860 group(&["--json-schema", "{schema}"]),
2861 one("{prompt}"),
2862 ]),
2863 "/b",
2864 );
2865 let values = Placeholders {
2866 prompt: Some("implement it".into()),
2867 ..Default::default()
2868 };
2869 assert_eq!(vec!["/b", "implement it"], agent.render(&values).unwrap());
2870 }
2871
2872 #[test]
2874 fn the_two_schema_placeholders_do_not_collide() {
2875 let agent = Agent::with_bin(
2876 spec_with(vec![one("x"), group(&["--output-schema", "{schema_file}"])]),
2877 "/b",
2878 );
2879 let values = Placeholders {
2880 schema: Some("INLINE".into()),
2881 schema_file: Some("/tmp/s.json".into()),
2882 ..Default::default()
2883 };
2884 assert_eq!(
2885 vec!["/b", "--output-schema", "/tmp/s.json"],
2886 agent.render(&values).unwrap()
2887 );
2888 }
2889
2890 #[test]
2892 fn the_shipped_claude_preset_now_has_native_structured_output() {
2893 let raw = crate::config::load_preset("claude").unwrap();
2894 let table = raw.as_table().cloned().unwrap();
2895 let mut spec: AgentSpec = toml::Value::Table(table).try_into().unwrap();
2896 spec.name = "claude".into();
2897 assert!(
2898 Agent::with_bin(spec, "/b").supports_schema(),
2899 "without this a long review is parsed out of prose and truncates"
2900 );
2901 }
2902}