1use std::collections::BTreeMap;
9use std::io::Write;
10use std::path::{Path, PathBuf};
11use std::sync::OnceLock;
12
13use serde_json::Value;
14
15use crate::config::{AgentSpec, CommandPart, OutputMode, SystemVia};
16use crate::error::{ErrorKind, Result, SparError};
17use crate::jsonx;
18use crate::proc::{self, ExecOpts};
19use crate::repo::{
20 attribute_state, git_state, ignored_untracked_state, join_reasons, list_paths,
21 note_ignored_leavings, probe_again_on_failure, safe_git_state, uncertain_worktree_change,
22 AttributeState, GitState, IgnoredState, EDITED_GIT_MARKER as GIT_MARKER_RECOVERY,
23};
24use crate::{bail, log, logdim, logwarn, spar_err};
25
26pub const STYLE_RULES: &str = "\
39Style rules for every artifact you produce (commits, PR titles, PR bodies, issue
40titles, issue bodies, review comments, and the comments in code you write):
41- Never use em-dashes or en-dashes. Use commas, colons, or parentheses.
42- Never mention Claude, Codex, OpenAI, ChatGPT, Anthropic, AI, or any tooling
43 used to produce the work.
44- Never add a Co-Authored-By trailer or a \"Generated with\" footer to commits.
45- Be brief. A human engineer with other work has to read this. Lead with the
46 point, cut the preamble, stop when you are done. Do not restate the task, do
47 not announce what you are about to do, do not summarise what the diff already
48 shows.
49- Brief means saying fewer things, never packing more into a sentence. Two
50 plain sentences beat one that has to be read twice. Split a sentence that
51 carries three facts, and split one that makes the reader hold an identifier
52 in their head to parse the rest of the clause. A comma splice joining two
53 ideas to save a full stop costs the reader more than the full stop would.
54- No headings, bullet lists, or bold text in anything only a few sentences long.
55- Comment code for the reason, not the change. A comment earns its length from
56 what the code cannot say for itself: a constraint that is not local, an
57 alternative that was tried and does not work, a surprise the next reader would
58 otherwise trip on. Write the reason that holds now, not the investigation that
59 found it. A paragraph above a three line change is almost always the debugging
60 story, and the reader wants the conclusion of it.
61Write as a human engineer would, because the reader neither knows nor cares what
62produced the work.";
63
64fn deadline_rule(timeout_secs: u64, now_secs: u64) -> String {
67 format!(
68 "This call is stopped at {}, {} after it started, and a call stopped \
69 before it answers loses its whole answer. Leave time to answer. When a \
70 full test suite or a benchmark would not finish well before then, run \
71 the tests that cover the change instead and say what you left out.",
72 crate::clock::date_line(now_secs.saturating_add(timeout_secs)),
73 crate::spend::clock(std::time::Duration::from_secs(timeout_secs)),
74 )
75}
76
77const JSON_INSTRUCTION: &str = "Respond with ONLY a JSON object matching this \
78schema. No prose, no markdown fences, no commentary before or after:";
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81enum Access {
82 Read,
83 ReadAlongside,
88 Edit,
89}
90
91const INSTRUCTIONS_HEADER: &str = "Additional instructions from the person who \
98started this run. They change how you work, not what was asked for above and \
99not the shape of your answer:";
100
101pub struct Agent {
102 pub spec: AgentSpec,
103 fallback: Option<Box<Agent>>,
107 instructions: Option<String>,
109 resolved: OnceLock<PathBuf>,
110}
111
112impl std::fmt::Debug for Agent {
113 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114 write!(f, "<{} {}>", self.spec.name, self.spec.describe())
115 }
116}
117
118#[derive(Debug, Clone, Default, PartialEq, Eq)]
125pub struct Effort {
126 pub primary: Option<String>,
127 pub fallback: Option<String>,
128 pub kind: &'static str,
132 pub subject: Option<i64>,
134 retry: bool,
137 standing_in: bool,
138}
139
140impl Effort {
141 pub fn just(primary: Option<String>) -> Self {
143 Self {
144 primary,
145 ..Self::default()
146 }
147 }
148
149 pub fn about(mut self, subject: i64) -> Self {
151 self.subject = Some(subject);
152 self
153 }
154
155 fn asked(&self) -> Option<&str> {
156 self.primary.as_deref()
157 }
158
159 fn handed_over(&self) -> Effort {
161 Effort {
162 primary: self.fallback.clone(),
163 fallback: None,
164 kind: self.kind,
165 subject: self.subject,
166 retry: false,
167 standing_in: true,
168 }
169 }
170
171 pub fn describe(&self) -> &str {
173 self.primary.as_deref().unwrap_or("default effort")
174 }
175
176 fn asked_again(&self) -> Effort {
178 Effort {
179 retry: true,
180 ..self.clone()
181 }
182 }
183}
184
185fn values_effort(effort: &Effort, spec: &AgentSpec) -> String {
188 effort
189 .asked()
190 .map(str::to_string)
191 .or_else(|| spec.effort.clone())
192 .unwrap_or_default()
193}
194
195fn tokens_used(stdout: &str) -> Option<u64> {
201 let mut found = None;
202 for line in stdout.lines().rev().take(200) {
203 let line = line.trim();
204 if !line.starts_with('{') || !line.contains("token") {
205 continue;
206 }
207 let Ok(value) = serde_json::from_str::<Value>(line) else {
208 continue;
209 };
210 found = total_tokens(&value);
211 if found.is_some() {
212 break;
213 }
214 }
215 found
216}
217
218fn total_tokens(value: &Value) -> Option<u64> {
219 if let Some(total) = value.get("total_tokens").and_then(Value::as_u64) {
220 return Some(total);
221 }
222 match value {
223 Value::Object(fields) => fields.values().find_map(total_tokens),
224 _ => None,
225 }
226}
227
228impl Agent {
229 pub fn new(spec: AgentSpec) -> Self {
230 let fallback = spec
231 .fallback
232 .clone()
233 .map(|backup| Box::new(Agent::new(*backup)));
234 Self {
235 spec,
236 fallback,
237 instructions: None,
238 resolved: OnceLock::new(),
239 }
240 }
241
242 pub fn with_instructions(mut self, text: &str) -> Self {
248 let text = text.trim();
249 if text.is_empty() {
250 return self;
251 }
252 if let Some(backup) = self.fallback.take() {
253 self.fallback = Some(Box::new(backup.with_instructions(text)));
254 }
255 self.instructions = Some(text.to_string());
256 self
257 }
258
259 fn instructed(&self, prompt: &str) -> String {
265 match &self.instructions {
266 Some(extra) => format!("{prompt}\n\n{INSTRUCTIONS_HEADER}\n{extra}"),
267 None => prompt.to_string(),
268 }
269 }
270
271 pub fn name(&self) -> &str {
272 &self.spec.name
273 }
274
275 pub fn fallback(&self) -> Option<&Agent> {
277 self.fallback.as_deref()
278 }
279
280 pub fn program(&self) -> &str {
283 match self.spec.command.first() {
284 Some(CommandPart::One(program)) => program,
285 _ => self.name(),
286 }
287 }
288
289 pub fn env_key(&self) -> String {
291 format!(
292 "SPAR_{}_BIN",
293 self.spec.name.to_uppercase().replace('-', "_")
294 )
295 }
296
297 #[doc(hidden)]
300 pub fn with_bin(spec: AgentSpec, bin: impl Into<PathBuf>) -> Self {
301 let agent = Self::new(spec);
302 let _ = agent.resolved.set(bin.into());
303 agent
304 }
305
306 pub fn resolve_bin(&self) -> Result<&Path> {
313 if let Some(found) = self.resolved.get() {
314 return Ok(found.as_path());
315 }
316 let found = self.locate()?;
317 let _ = self.resolved.set(found);
318 Ok(self.resolved.get().expect("just set").as_path())
319 }
320
321 fn locate(&self) -> Result<PathBuf> {
322 let wanted = match self.spec.command.first() {
323 Some(CommandPart::One(program)) => program.clone(),
324 _ => bail!("agent '{}' has no command configured", self.spec.name),
325 };
326
327 let env_key = self.env_key();
328 let env_override = std::env::var(&env_key)
329 .ok()
330 .filter(|v| !v.trim().is_empty());
331
332 let mut tried: Vec<String> = Vec::new();
333
334 for candidate in env_override
335 .iter()
336 .map(String::as_str)
337 .chain([wanted.as_str()])
338 {
339 let path = Path::new(candidate);
340 if path.is_absolute() || candidate.contains(std::path::MAIN_SEPARATOR) {
341 let expanded = proc::expand_tilde(candidate);
342 tried.push(expanded.display().to_string());
343 if proc::is_executable(&expanded) {
344 return Ok(expanded);
345 }
346 } else {
347 tried.push(format!("{candidate} (PATH)"));
348 if let Some(found) = proc::which(candidate) {
349 return Ok(found);
350 }
351 }
352 }
353
354 for base in &self.spec.search_paths {
355 let base = proc::expand_tilde(base);
356 let candidate = if base.file_name().and_then(|n| n.to_str()) == Some(wanted.as_str()) {
357 base
358 } else {
359 base.join(&wanted)
360 };
361 tried.push(candidate.display().to_string());
362 if proc::is_executable(&candidate) {
363 return Ok(candidate);
364 }
365 }
366
367 Err(spar_err!(
368 "could not find the binary for agent '{}'. Tried:\n {}\nSet agents.{}.command[0] to \
369 an absolute path, or {}=/path/to/binary.",
370 self.spec.name,
371 tried.join("\n "),
372 self.spec.name,
373 env_key
374 ))
375 }
376
377 pub fn render(&self, values: &Placeholders) -> Result<Vec<String>> {
383 let mut out = vec![self.resolve_bin()?.display().to_string()];
384 for part in self.spec.command.iter().skip(1) {
385 let mut rendered = Vec::new();
386 let mut skip = false;
387 for arg in part.args() {
388 match values.substitute(arg) {
389 Some(text) => rendered.push(text),
390 None => {
391 skip = true;
392 break;
393 }
394 }
395 }
396 if !skip {
397 out.extend(rendered);
398 }
399 }
400 Ok(out)
401 }
402
403 pub fn supports_schema(&self) -> bool {
409 self.spec
410 .command
411 .iter()
412 .flat_map(|p| p.args())
413 .any(|a| a.contains("{schema_file}") || a.contains("{schema}"))
414 }
415
416 pub fn extract(&self, stdout: &str) -> Result<String> {
419 match self.spec.output {
420 OutputMode::Text | OutputMode::Json => Ok(stdout.trim().to_string()),
421 OutputMode::Jsonl => self.extract_jsonl(stdout),
422 }
423 }
424
425 fn extract_jsonl(&self, stdout: &str) -> Result<String> {
426 let mut messages: Vec<String> = Vec::new();
427
428 for line in stdout.lines() {
429 let line = line.trim();
430 if !line.starts_with('{') {
431 continue;
432 }
433 let Ok(event) = serde_json::from_str::<Value>(line) else {
434 continue;
435 };
436 if matches(&event, &self.spec.message_match) {
437 if let Some(text) = dig(&event, self.spec.message_path.as_deref().unwrap_or("")) {
438 if let Some(text) = as_text(text) {
439 messages.push(text);
440 }
441 }
442 }
443 }
444
445 if messages.is_empty() {
446 let reasons = self.error_events(stdout);
447 if !reasons.is_empty() {
448 return Err(SparError::call_failed(format!(
451 "agent '{}' failed: {}",
452 self.spec.name,
453 reasons.join("; ")
454 )));
455 }
456 }
457 Ok(messages.join("\n").trim().to_string())
458 }
459
460 fn error_events(&self, stdout: &str) -> Vec<String> {
470 let mut reasons: Vec<String> = Vec::new();
471 for line in stdout.lines() {
472 let line = line.trim();
473 if !line.starts_with('{') {
474 continue;
475 }
476 let Ok(event) = serde_json::from_str::<Value>(line) else {
477 continue;
478 };
479 if !matches!(
480 event.get("type").and_then(Value::as_str),
481 Some("turn.failed") | Some("error")
482 ) {
483 continue;
484 }
485 let reason = dig(&event, "message")
486 .or_else(|| dig(&event, "error.message"))
487 .and_then(as_text)
488 .unwrap_or_else(|| truncate(&event.to_string(), 400));
489 if !reason.trim().is_empty() && !reasons.contains(&reason) {
490 reasons.push(reason);
491 }
492 }
493 reasons
494 }
495
496 fn call_failure(&self, argv: &[String], out: &proc::Output) -> SparError {
508 if self.spec.output != OutputMode::Jsonl {
509 return SparError::call_failed(proc::failure_message(argv, out));
510 }
511 let reasons = self.error_events(&out.stdout);
512 if reasons.is_empty() {
513 return SparError::call_failed(proc::failure_message(argv, out));
514 }
515 let mut text = format!(
516 "agent '{}' could not answer (exit {}): {}",
517 self.spec.name,
518 out.code,
519 reasons.join("; ")
520 );
521 let stderr = out.stderr.trim();
522 if !stderr.is_empty() {
523 text.push_str(&format!("\n--- stderr ---\n{stderr}"));
524 }
525 text.push_str(&format!("\n--- command ---\n{}", proc::abbreviate(argv)));
526 SparError::call_failed(text)
527 }
528
529 pub fn ask(&self, prompt: &str, cwd: &Path, effort: &Effort) -> Result<String> {
532 let baseline = EditBaseline::capture(cwd)?;
533 self.ask_with_access(prompt, cwd, effort, Access::Read, Some(&baseline))
534 }
535
536 pub fn edit(&self, prompt: &str, cwd: &Path, effort: &Effort) -> Result<String> {
540 let baseline = EditBaseline::capture(cwd)?;
541 self.ask_with_access(prompt, cwd, effort, Access::Edit, Some(&baseline))
542 }
543
544 fn ask_with_access(
545 &self,
546 prompt: &str,
547 cwd: &Path,
548 effort: &Effort,
549 access: Access,
550 baseline: Option<&EditBaseline>,
551 ) -> Result<String> {
552 let prompt = &self.instructed(prompt);
553 let result = match self.ask_inner(prompt, cwd, effort, None, None, access) {
554 Ok(text) => Ok(text),
555 Err(e) => match recovery_error(&e, baseline, cwd, access) {
556 Some(recovery) => Err(recovery),
557 None => self.hand_over(e, |backup| {
558 backup.ask_with_access(prompt, cwd, &effort.handed_over(), access, baseline)
559 }),
560 },
561 };
562 finish_call(result, access, baseline, cwd)
563 }
564
565 pub fn effort(&self, cfg: &crate::config::Config, call: crate::config::Call) -> Effort {
571 Effort {
572 primary: cfg.effort_for(&self.spec, call),
573 fallback: self
574 .spec
575 .fallback
576 .as_deref()
577 .and_then(|spec| cfg.effort_for(spec, call)),
578 kind: call.key(),
579 subject: None,
580 retry: false,
581 standing_in: false,
582 }
583 }
584
585 fn hand_over<T>(&self, primary: SparError, run: impl FnOnce(&Agent) -> Result<T>) -> Result<T> {
598 if primary.kind() == ErrorKind::Interrupted {
601 return Err(primary);
602 }
603 let Some(backup) = self.fallback() else {
604 return Err(primary);
605 };
606 logwarn!(
607 "{} could not answer. Handing the call to {}.\n{primary}",
608 self.name(),
609 backup.name()
610 );
611 match run(backup) {
612 Ok(answer) => {
613 log!("{} answered in place of {}", backup.name(), self.name());
614 Ok(answer)
615 }
616 Err(second) => {
620 let message = format!(
621 "agent '{}' failed and its fallback '{}' could not stand in.\n{}\n\n{}: {}",
622 self.name(),
623 backup.name(),
624 primary.message(),
625 backup.name(),
626 second.message()
627 );
628 Err(second.with_message(message))
629 }
630 }
631 }
632
633 fn ask_inner(
634 &self,
635 prompt: &str,
636 cwd: &Path,
637 effort: &Effort,
638 schema_file: Option<&Path>,
639 schema: Option<&str>,
640 access: Access,
641 ) -> Result<String> {
642 let system = format!(
643 "{STYLE_RULES}\n\n{}",
644 deadline_rule(self.spec.timeout, crate::clock::now_secs())
645 );
646 let body = match self.spec.system_via {
647 SystemVia::Placeholder => prompt.to_string(),
648 SystemVia::Prompt => format!("{system}\n\n{prompt}"),
649 };
650 let values = Placeholders {
651 prompt: Some(body),
652 system: Some(system),
653 model: self.spec.model.clone(),
654 effort: effort
655 .asked()
656 .map(str::to_string)
657 .or_else(|| self.spec.effort.clone()),
658 cwd: Some(cwd.display().to_string()),
659 schema_file: schema_file.map(|p| p.display().to_string()),
660 schema: schema.map(str::to_string),
661 };
662 let argv = self.render(&values)?;
663 let opts = ExecOpts::new()
667 .cwd(cwd)
668 .timeout_secs(self.spec.timeout)
669 .stop_descendants(true)
670 .check(false);
671 let git_file = match access {
674 Access::Read | Access::ReadAlongside | Access::Edit => GitFile::capture(cwd)?,
675 };
676 let started = std::time::Instant::now();
677 let called = proc::exec(&argv, &opts);
678 crate::spend::record(crate::spend::Spent {
681 agent: self.name().to_string(),
682 kind: effort.kind.to_string(),
683 effort: values_effort(effort, &self.spec),
684 subject: effort.subject,
685 seconds: started.elapsed().as_secs_f64(),
686 retry: effort.retry,
687 fallback: effort.standing_in,
688 tokens: called
689 .as_ref()
690 .ok()
691 .and_then(|out| tokens_used(&out.stdout)),
692 ok: called.as_ref().is_ok_and(|out| out.ok()),
693 });
694 after_call_is_quiet(&called, || {
695 if let Some(git_file) = git_file {
696 git_file.restore_if_changed(cwd)?;
697 }
698 Ok(())
699 })?;
700 let out = called?;
701 if !out.ok() {
702 return Err(self.call_failure(&argv, &out));
703 }
704 self.extract(&out.stdout)
705 }
706
707 pub fn ask_json<T: serde::de::DeserializeOwned>(
711 &self,
712 prompt: &str,
713 schema: &Value,
714 cwd: &Path,
715 effort: &Effort,
716 ) -> Result<T> {
717 let baseline = EditBaseline::capture(cwd)?;
718 self.ask_json_with_access(prompt, schema, cwd, effort, Access::Read, Some(&baseline))
719 }
720
721 pub fn ask_json_alongside<T: serde::de::DeserializeOwned>(
731 &self,
732 prompt: &str,
733 schema: &Value,
734 cwd: &Path,
735 effort: &Effort,
736 ) -> Result<T> {
737 let baseline = EditBaseline::capture(cwd)?;
738 self.ask_json_with_access(
739 prompt,
740 schema,
741 cwd,
742 effort,
743 Access::ReadAlongside,
744 Some(&baseline),
745 )
746 }
747
748 pub fn edit_json<T: serde::de::DeserializeOwned>(
752 &self,
753 prompt: &str,
754 schema: &Value,
755 cwd: &Path,
756 effort: &Effort,
757 ) -> Result<T> {
758 let baseline = EditBaseline::capture(cwd)?;
759 self.ask_json_with_access(prompt, schema, cwd, effort, Access::Edit, Some(&baseline))
760 }
761
762 fn ask_json_with_access<T: serde::de::DeserializeOwned>(
763 &self,
764 prompt: &str,
765 schema: &Value,
766 cwd: &Path,
767 effort: &Effort,
768 access: Access,
769 baseline: Option<&EditBaseline>,
770 ) -> Result<T> {
771 let prompt = &self.instructed(prompt);
774 let result = match self.ask_json_retrying(prompt, schema, cwd, effort, access, baseline) {
775 Ok(parsed) => Ok(parsed),
776 Err(e) => match recovery_error(&e, baseline, cwd, access) {
777 Some(recovery) => Err(recovery),
778 None => self.hand_over(e, |backup| {
779 backup.ask_json_retrying::<T>(
780 prompt,
781 schema,
782 cwd,
783 &effort.handed_over(),
784 access,
785 baseline,
786 )
787 }),
788 },
789 };
790 finish_call(result, access, baseline, cwd)
791 }
792
793 fn worth_asking_again(&self, e: &SparError) -> bool {
806 match e.kind() {
807 ErrorKind::TimedOut => false,
808 ErrorKind::UncertainWrite => false,
809 ErrorKind::Interrupted => false,
810 ErrorKind::CallFailed => self.fallback().is_none(),
811 ErrorKind::Other => true,
812 }
813 }
814
815 fn ask_json_retrying<T: serde::de::DeserializeOwned>(
817 &self,
818 prompt: &str,
819 schema: &Value,
820 cwd: &Path,
821 effort: &Effort,
822 access: Access,
823 baseline: Option<&EditBaseline>,
824 ) -> Result<T> {
825 const ATTEMPTS: usize = 2;
831 let mut last: Option<SparError> = None;
832
833 for attempt in 1..=ATTEMPTS {
834 let asked = match &last {
835 None => prompt.to_string(),
836 Some(e) => format!(
837 "{prompt}\n\nYour previous answer could not be used: {}\nReturn the whole \
838 object this time, exactly matching the schema, and nothing else.",
839 e.first_line()
840 ),
841 };
842 let effort = if attempt > 1 {
843 effort.asked_again()
844 } else {
845 effort.clone()
846 };
847 match self.ask_json_once::<T>(&asked, schema, cwd, &effort, access) {
848 Ok(parsed) => {
849 if attempt > 1 {
850 logdim!("{} answered on the retry", self.spec.name);
851 }
852 return Ok(parsed);
853 }
854 Err(e) => {
858 if let Some(recovery) = recovery_error(&e, baseline, cwd, access) {
859 return Err(recovery);
860 }
861 if !self.worth_asking_again(&e) {
862 return Err(e);
863 }
864 if attempt < ATTEMPTS {
865 logwarn!("{} failed, asking again.\n{e}", self.spec.name);
870 }
871 last = Some(e);
872 }
873 }
874 }
875 Err(spar_err!(
876 "agent '{}' returned an unusable answer twice: {}",
877 self.spec.name,
878 last.expect("at least one attempt").message()
879 ))
880 }
881
882 fn ask_json_once<T: serde::de::DeserializeOwned>(
883 &self,
884 prompt: &str,
885 schema: &Value,
886 cwd: &Path,
887 effort: &Effort,
888 access: Access,
889 ) -> Result<T> {
890 let text = if self.supports_schema() {
891 let inline = serde_json::to_string(schema).unwrap_or_default();
892 let file = TempJson::write(schema)?;
893 self.ask_inner(
894 prompt,
895 cwd,
896 effort,
897 Some(file.path()),
898 Some(&inline),
899 access,
900 )?
901 } else {
902 let full = format!(
903 "{prompt}\n\n{JSON_INSTRUCTION}\n{}",
904 serde_json::to_string_pretty(schema).unwrap_or_default()
905 );
906 self.ask_inner(&full, cwd, effort, None, None, access)?
907 };
908 jsonx::extract_into(&text)
909 }
910
911 pub fn review<T: serde::de::DeserializeOwned>(
925 &self,
926 base: &str,
927 prompt: &str,
928 schema: &Value,
929 cwd: &Path,
930 effort: &Effort,
931 ) -> Result<T> {
932 self.ask_json(&scoped_review(base, prompt), schema, cwd, effort)
933 }
934
935 pub fn review_alongside<T: serde::de::DeserializeOwned>(
938 &self,
939 base: &str,
940 prompt: &str,
941 schema: &Value,
942 cwd: &Path,
943 effort: &Effort,
944 ) -> Result<T> {
945 self.ask_json_alongside(&scoped_review(base, prompt), schema, cwd, effort)
946 }
947}
948
949fn scoped_review(base: &str, prompt: &str) -> String {
952 format!(
953 "{prompt}\n\nThe changes under review are the diff between `{base}` and HEAD in your \
954 working directory. Inspect them with git, then read the surrounding code before \
955 judging. Do not review only the diff.\n\nThis call is a review and nothing else. Do \
956 not edit the code under review, do not commit, and do not push: somebody else acts \
957 on what you find, and a reviewer that writes ends up reviewing its own work. Put any \
958 scratch file under the system temporary directory, not in the working tree."
959 )
960}
961
962struct EditBaseline {
968 attributes: AttributeState,
969 git_state: GitState,
970 git_entry: GitEntry,
971 ignored_untracked: IgnoredState,
972}
973
974impl EditBaseline {
975 fn capture(cwd: &Path) -> Result<Self> {
976 let git_entry = GitEntry::capture(cwd)?;
977 if matches!(&git_entry, GitEntry::File) {
978 ensure_recovery_path_clear(cwd)?;
979 }
980 let attributes = attribute_state(cwd).map_err(|e| {
981 e.with_message(format!(
982 "could not record attribute files before a call in {}: {}",
983 cwd.display(),
984 e.last_line()
985 ))
986 })?;
987 let git_state = safe_git_state(cwd).map_err(|e| {
988 e.with_message(format!(
989 "could not record a safe Git state before editing {}: {}",
990 cwd.display(),
991 e.last_line()
992 ))
993 })?;
994 let ignored_untracked = ignored_untracked_state(cwd).map_err(|e| {
995 e.with_message(format!(
996 "could not record ignored files before editing {}: {}",
997 cwd.display(),
998 e.last_line()
999 ))
1000 })?;
1001 Ok(Self {
1002 attributes,
1003 git_state,
1004 git_entry,
1005 ignored_untracked,
1006 })
1007 }
1008
1009 fn recovery_needed(&self, cwd: &Path, access: Access) -> Result<Option<String>> {
1012 if !self.git_entry.still_matches(cwd)? {
1013 return Err(uncertain_worktree_change(
1014 cwd,
1015 format!(
1016 "the Git entry at {} changed type during the editing call. No Git recovery \
1017 probe was run. Inspect the worktree before retrying.",
1018 cwd.join(".git").display()
1019 ),
1020 ));
1021 }
1022 let attributes = probe_again_on_failure(|| attribute_state(cwd)).map_err(|e| {
1023 uncertain_worktree_change(
1024 cwd,
1025 format!(
1026 "could not check attribute files after a call in {}: {}. Inspect the \
1027 worktree before retrying.",
1028 cwd.display(),
1029 e.last_line()
1030 ),
1031 )
1032 })?;
1033 if attributes != self.attributes {
1034 return Err(uncertain_worktree_change(
1035 cwd,
1036 format!(
1037 "an attribute file changed during a call in {}. The worktree was kept before \
1038 running any Git operation that could select a new filter.",
1039 cwd.display()
1040 ),
1041 ));
1042 }
1043 let current = probe_again_on_failure(|| git_state(cwd))
1044 .map_err(|e| recovery_probe_error(cwd, "state", &e))?;
1045 let ignored = probe_again_on_failure(|| ignored_untracked_state(cwd))
1046 .map_err(|e| recovery_probe_error(cwd, "ignored files", &e))?;
1047 let untracked = match access {
1052 Access::Read | Access::ReadAlongside => {
1053 let mut leavings = self.ignored_untracked.read_only_leavings(&ignored);
1054 note_ignored_leavings(cwd, "call", &leavings.tolerated);
1057 if access == Access::ReadAlongside {
1061 let created = self.ignored_untracked.new_ordinary_paths(&ignored);
1062 leavings.condemning.retain(|path| !created.contains(path));
1063 }
1064 (!leavings.condemning.is_empty()).then(|| {
1065 format!(
1066 "untracked file(s) changed: {}",
1067 list_paths(&leavings.condemning)
1068 )
1069 })
1070 }
1071 Access::Edit => self
1072 .ignored_untracked
1073 .changed_beyond_generated(&ignored)
1074 .then(|| "untracked or ignored file(s) changed".to_string()),
1075 };
1076 let tracked = match (access, &self.git_entry) {
1082 (Access::Read | Access::ReadAlongside, GitEntry::Directory(_)) => {
1083 self.git_state.read_only_difference(¤t, cwd)
1084 }
1085 _ => self.git_state.describe_difference(¤t),
1086 };
1087 Ok(join_reasons(tracked, untracked))
1088 }
1089}
1090
1091#[derive(Clone, PartialEq, Eq)]
1092enum GitEntry {
1093 Directory(GitDirectory),
1094 File,
1095}
1096
1097#[derive(Clone, PartialEq, Eq)]
1098struct GitDirectory {
1099 #[cfg(unix)]
1100 device: u64,
1101 #[cfg(unix)]
1102 inode: u64,
1103 #[cfg(not(unix))]
1104 created: Option<std::time::SystemTime>,
1105}
1106
1107impl GitEntry {
1108 fn capture(cwd: &Path) -> Result<Self> {
1109 let path = cwd.join(".git");
1110 match std::fs::symlink_metadata(&path) {
1111 Ok(meta) if meta.is_dir() => {
1112 #[cfg(unix)]
1113 {
1114 use std::os::unix::fs::MetadataExt;
1115 Ok(Self::Directory(GitDirectory {
1116 device: meta.dev(),
1117 inode: meta.ino(),
1118 }))
1119 }
1120 #[cfg(not(unix))]
1121 {
1122 Ok(Self::Directory(GitDirectory {
1123 created: meta.created().ok(),
1124 }))
1125 }
1126 }
1127 Ok(meta) if meta.is_file() => Ok(Self::File),
1128 Ok(_) => bail!(
1129 "{} is not a regular Git directory or marker",
1130 path.display()
1131 ),
1132 Err(e) => Err(spar_err!("could not inspect {}: {e}", path.display())),
1133 }
1134 }
1135
1136 fn still_matches(&self, cwd: &Path) -> Result<bool> {
1137 let path = cwd.join(".git");
1138 match (self, std::fs::symlink_metadata(path)) {
1139 (Self::Directory(before), Ok(meta)) if meta.is_dir() => {
1140 #[cfg(unix)]
1141 {
1142 use std::os::unix::fs::MetadataExt;
1143 Ok(before.device == meta.dev() && before.inode == meta.ino())
1144 }
1145 #[cfg(not(unix))]
1146 {
1147 Ok(before.created == meta.created().ok())
1148 }
1149 }
1150 (Self::Directory(_), Ok(_)) => Ok(false),
1151 (Self::File, Ok(meta)) => Ok(meta.is_file()),
1152 (_, Err(e)) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
1153 (_, Err(e)) => Err(SparError::uncertain_write(format!(
1154 "could not inspect the Git entry at {} after the editing call: {e}",
1155 cwd.join(".git").display()
1156 ))),
1157 }
1158 }
1159}
1160
1161fn recovery_probe_error(cwd: &Path, probe: &str, error: &SparError) -> SparError {
1162 uncertain_worktree_change(
1163 cwd,
1164 format!(
1165 "could not check the Git {probe} after an editing call in {}: {}. Inspect the \
1166 worktree before retrying.",
1167 cwd.display(),
1168 error.last_line()
1169 ),
1170 )
1171}
1172
1173fn recovery_error(
1174 error: &SparError,
1175 baseline: Option<&EditBaseline>,
1176 cwd: &Path,
1177 access: Access,
1178) -> Option<SparError> {
1179 if error.kind() == ErrorKind::UncertainWrite {
1182 return Some(error.clone());
1183 }
1184 let baseline = baseline?;
1185 match baseline.recovery_needed(cwd, access) {
1186 Ok(None) => None,
1187 Ok(Some(_)) if access == Access::Edit => Some(changed_edit_failure(error)),
1188 Ok(Some(reason)) => Some(uncertain_worktree_change(
1189 cwd,
1190 format!(
1191 "{}\nA read-only call changed the worktree before it failed: {reason}. Its \
1192 answer was discarded and the worktree was kept for recovery.",
1193 error.message()
1194 ),
1195 )),
1196 Err(recovery) => Some(SparError::uncertain_write(format!(
1197 "{}\n{}",
1198 error.message(),
1199 recovery.message()
1200 ))),
1201 }
1202}
1203
1204fn changed_edit_failure(error: &SparError) -> SparError {
1205 const NOTE: &str =
1206 "The call changed the worktree before it failed. It was not retried or handed to a fallback.";
1207 if error.message().contains(NOTE) {
1208 return error.clone();
1209 }
1210 error.with_message(format!("{}\n{NOTE}", error.message()))
1211}
1212
1213fn finish_call<T>(
1214 result: Result<T>,
1215 access: Access,
1216 baseline: Option<&EditBaseline>,
1217 cwd: &Path,
1218) -> Result<T> {
1219 let value = result?;
1220 let Some(baseline) = baseline else {
1221 return Ok(value);
1222 };
1223 if access == Access::Edit {
1224 if baseline.git_entry.still_matches(cwd)? {
1225 return Ok(value);
1226 }
1227 return Err(uncertain_worktree_change(
1228 cwd,
1229 format!(
1230 "the Git entry at {} was replaced during an editing call. The result was \
1231 discarded before any Git operation ran.",
1232 cwd.join(".git").display()
1233 ),
1234 ));
1235 }
1236 match baseline.recovery_needed(cwd, access) {
1237 Ok(None) => Ok(value),
1238 Ok(Some(reason)) => Err(uncertain_worktree_change(
1239 cwd,
1240 format!(
1241 "a read-only call changed the worktree: {reason}. Its answer was discarded and \
1242 the worktree was kept for recovery."
1243 ),
1244 )),
1245 Err(error) => Err(error),
1246 }
1247}
1248
1249struct GitFile {
1254 bytes: Vec<u8>,
1255}
1256
1257impl GitFile {
1258 fn capture(cwd: &Path) -> Result<Option<Self>> {
1259 let path = cwd.join(".git");
1260 match std::fs::symlink_metadata(&path) {
1261 Ok(meta) if meta.is_dir() => Ok(None),
1262 Ok(meta) if meta.is_file() => {
1263 ensure_recovery_path_clear(cwd)?;
1264 let bytes = std::fs::read(&path)
1265 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
1266 Ok(Some(Self { bytes }))
1267 }
1268 Ok(_) => bail!(
1269 "{} is not a regular Git marker. Refusing to run an editing call.",
1270 path.display()
1271 ),
1272 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
1273 Err(e) => Err(spar_err!("could not inspect {}: {e}", path.display())),
1274 }
1275 }
1276
1277 fn restore_if_changed(self, cwd: &Path) -> Result<()> {
1278 let path = cwd.join(".git");
1279 let unchanged = std::fs::symlink_metadata(&path)
1280 .ok()
1281 .filter(|meta| meta.is_file())
1282 .and_then(|_| std::fs::read(&path).ok())
1283 .is_some_and(|bytes| bytes == self.bytes);
1284 let recovery = cwd.join(GIT_MARKER_RECOVERY);
1285 if unchanged {
1286 return match std::fs::symlink_metadata(&recovery) {
1287 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
1288 Ok(_) => Err(SparError::uncertain_write(format!(
1289 "the agent call created the reserved recovery path at {}. The Git marker was \
1290 unchanged. Inspect or remove the recovery path before retrying.",
1291 recovery.display()
1292 ))),
1293 Err(e) => Err(SparError::uncertain_write(format!(
1294 "could not inspect {} after the agent call: {e}",
1295 recovery.display()
1296 ))),
1297 };
1298 }
1299
1300 let retained = match std::fs::symlink_metadata(&path) {
1301 Ok(_) => match std::fs::symlink_metadata(&recovery) {
1302 Ok(_) => {
1303 return Err(SparError::uncertain_write(format!(
1304 "the agent call changed the linked worktree Git marker at {}, but the \
1305 changed entry could not be retained because {} already exists. The \
1306 original marker was not restored, so no changed entry was deleted. \
1307 Inspect the worktree before retrying.",
1308 path.display(),
1309 recovery.display()
1310 )));
1311 }
1312 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1313 match std::fs::rename(&path, &recovery) {
1314 Ok(()) => true,
1315 Err(e) => {
1316 return Err(SparError::uncertain_write(format!(
1317 "the agent call changed the linked worktree Git marker at {}, but \
1318 the changed entry could not be moved to {}: {e}. The original \
1319 marker was not restored, so no changed entry was deleted. Inspect \
1320 the worktree before retrying.",
1321 path.display(),
1322 recovery.display()
1323 )));
1324 }
1325 }
1326 }
1327 Err(e) => {
1328 return Err(SparError::uncertain_write(format!(
1329 "the agent call changed the linked worktree Git marker at {}, but {} could \
1330 not be inspected: {e}. The original marker was not restored, so no changed \
1331 entry was deleted. Inspect the worktree before retrying.",
1332 path.display(),
1333 recovery.display()
1334 )));
1335 }
1336 },
1337 Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
1338 Err(e) => {
1339 return Err(SparError::uncertain_write(format!(
1340 "the agent call changed the linked worktree Git marker at {}, but the changed \
1341 entry could not be inspected: {e}. The original marker was not restored. \
1342 Inspect the worktree before retrying.",
1343 path.display()
1344 )));
1345 }
1346 };
1347
1348 let restored = replace_git_marker(&path, &self.bytes);
1349 let restore_note = match &restored {
1350 Ok(()) => "The original marker was restored.".to_string(),
1351 Err(e) => format!("The original marker could not be restored: {e}."),
1352 };
1353 let retain_note = if retained {
1354 format!("The changed marker was kept at {}.", recovery.display())
1355 } else {
1356 "The agent call deleted the changed marker, so there was nothing to retain.".to_string()
1357 };
1358 Err(SparError::uncertain_write(format!(
1359 "the agent call changed the linked worktree Git marker at {}. {restore_note} \
1360 {retain_note} Inspect the worktree before retrying.",
1361 path.display()
1362 )))
1363 }
1364}
1365
1366fn after_call_is_quiet<T>(called: &Result<T>, recover: impl FnOnce() -> Result<()>) -> Result<()> {
1369 if let Err(error) = called {
1370 if error.kind() == ErrorKind::UncertainWrite {
1371 return Err(error.clone());
1372 }
1373 }
1374 recover()
1375}
1376
1377fn replace_git_marker(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
1378 let mut marker = std::fs::OpenOptions::new()
1379 .write(true)
1380 .create_new(true)
1381 .open(path)?;
1382 marker.write_all(bytes)
1383}
1384
1385fn ensure_recovery_path_clear(cwd: &Path) -> Result<()> {
1386 let recovery = cwd.join(GIT_MARKER_RECOVERY);
1387 match std::fs::symlink_metadata(&recovery) {
1388 Ok(_) => Err(SparError::uncertain_write(format!(
1389 "{} already exists. Recover or remove it before another agent call.",
1390 recovery.display()
1391 ))),
1392 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
1393 Err(e) => Err(SparError::uncertain_write(format!(
1394 "could not inspect {} before the agent call: {e}",
1395 recovery.display()
1396 ))),
1397 }
1398}
1399
1400#[derive(Debug, Default, Clone)]
1405pub struct Placeholders {
1406 pub prompt: Option<String>,
1407 pub system: Option<String>,
1408 pub model: Option<String>,
1409 pub effort: Option<String>,
1410 pub cwd: Option<String>,
1411 pub schema_file: Option<String>,
1413 pub schema: Option<String>,
1415}
1416
1417impl Placeholders {
1418 fn get(&self, key: &str) -> Option<&str> {
1419 let value = match key {
1420 "prompt" => self.prompt.as_deref(),
1421 "system" => self.system.as_deref(),
1422 "model" => self.model.as_deref(),
1423 "effort" => self.effort.as_deref(),
1424 "cwd" => self.cwd.as_deref(),
1425 "schema_file" => self.schema_file.as_deref(),
1426 "schema" => self.schema.as_deref(),
1427 _ => None,
1428 };
1429 value.filter(|v| !v.is_empty())
1430 }
1431
1432 fn substitute(&self, arg: &str) -> Option<String> {
1439 const KEYS: [&str; 7] = [
1440 "prompt",
1441 "system",
1442 "model",
1443 "effort",
1444 "cwd",
1445 "schema_file",
1446 "schema",
1447 ];
1448 let mut out = String::with_capacity(arg.len());
1449 let mut rest = arg;
1450 while let Some(open) = rest.find('{') {
1451 out.push_str(&rest[..open]);
1452 rest = &rest[open..];
1453 let token = KEYS.iter().find_map(|key| {
1454 let after = rest.strip_prefix('{')?.strip_prefix(key)?;
1455 after.starts_with('}').then_some((*key, key.len() + 2))
1456 });
1457 match token {
1458 Some((key, len)) => {
1459 out.push_str(self.get(key)?);
1460 rest = &rest[len..];
1461 }
1462 None => {
1463 out.push('{');
1464 rest = &rest[1..];
1465 }
1466 }
1467 }
1468 out.push_str(rest);
1469 Some(out)
1470 }
1471}
1472
1473fn dig<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
1479 if path.is_empty() {
1480 return None;
1481 }
1482 let mut node = value;
1483 for part in path.split('.') {
1484 node = node.as_object()?.get(part)?;
1485 }
1486 Some(node)
1487}
1488
1489fn matches(event: &Value, wanted: &BTreeMap<String, String>) -> bool {
1490 if wanted.is_empty() {
1491 return false;
1492 }
1493 wanted
1494 .iter()
1495 .all(|(path, expected)| dig(event, path).and_then(Value::as_str) == Some(expected.as_str()))
1496}
1497
1498fn as_text(value: &Value) -> Option<String> {
1499 match value {
1500 Value::String(s) => Some(s.clone()),
1501 Value::Null => None,
1502 other => Some(other.to_string()),
1503 }
1504}
1505
1506fn truncate(text: &str, max: usize) -> String {
1507 text.chars().take(max).collect()
1508}
1509
1510struct TempJson {
1517 path: PathBuf,
1518}
1519
1520impl TempJson {
1521 fn write(value: &Value) -> Result<Self> {
1522 use std::sync::atomic::{AtomicU64, Ordering};
1523 static COUNTER: AtomicU64 = AtomicU64::new(0);
1524
1525 let nanos = std::time::SystemTime::now()
1526 .duration_since(std::time::UNIX_EPOCH)
1527 .map(|d| d.as_nanos())
1528 .unwrap_or(0);
1529 let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
1530 let path = std::env::temp_dir().join(format!(
1531 "spar-schema-{}-{nanos}-{unique}.json",
1532 std::process::id()
1533 ));
1534 std::fs::write(&path, serde_json::to_vec_pretty(value)?)
1535 .map_err(|e| spar_err!("could not write a schema file to {}: {e}", path.display()))?;
1536 Ok(Self { path })
1537 }
1538
1539 fn path(&self) -> &Path {
1540 &self.path
1541 }
1542}
1543
1544impl Drop for TempJson {
1545 fn drop(&mut self) {
1546 let _ = std::fs::remove_file(&self.path);
1547 }
1548}
1549
1550fn same_executable(a: &Path, b: &Path) -> bool {
1561 #[cfg(unix)]
1562 {
1563 use std::os::unix::fs::MetadataExt;
1564 if let (Ok(x), Ok(y)) = (std::fs::metadata(a), std::fs::metadata(b)) {
1565 return x.dev() == y.dev() && x.ino() == y.ino();
1566 }
1567 }
1568 match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
1569 (Ok(x), Ok(y)) => x == y,
1570 _ => a == b,
1571 }
1572}
1573
1574pub fn correlation_warning(agents: &[Agent]) -> Option<String> {
1580 for i in 0..agents.len() {
1581 for j in (i + 1)..agents.len() {
1582 let (a, b) = (&agents[i], &agents[j]);
1583 let (Ok(pa), Ok(pb)) = (a.resolve_bin(), b.resolve_bin()) else {
1584 continue;
1585 };
1586 if !same_executable(pa, pb) || a.spec.model_key() != b.spec.model_key() {
1587 continue;
1588 }
1589 let model = if a.spec.model_key().is_empty() {
1590 "the CLI's default".to_string()
1591 } else {
1592 a.spec.model_key()
1593 };
1594 let where_at = if pa == pb {
1595 pa.display().to_string()
1596 } else {
1597 format!(
1598 "the same executable ({} and {} are the same file)",
1599 pa.display(),
1600 pb.display()
1601 )
1602 };
1603 return Some(format!(
1604 "agents '{}' and '{}' both resolve to {where_at} at model {model}. Review \
1605 findings will be correlated: the same model reviewing itself shares the blind \
1606 spots of the model that wrote the code, so it is far less likely to catch what \
1607 the implementer missed. That produces an approval indistinguishable from a real \
1608 review, which is worse than no review at all. Give the two agents different \
1609 CLIs or different models.",
1610 a.name(),
1611 b.name()
1612 ));
1613 }
1614 }
1615 None
1616}
1617
1618pub fn build(cfg: &crate::config::Config) -> Result<Vec<Agent>> {
1621 let agents: Vec<Agent> = cfg
1622 .agents
1623 .iter()
1624 .cloned()
1625 .map(Agent::new)
1626 .map(|agent| agent.with_instructions(&cfg.loop_cfg.instructions))
1627 .collect();
1628 for agent in &agents {
1629 agent.resolve_bin()?;
1630 if let Some(backup) = agent.fallback() {
1634 if backup.resolve_bin().is_err() {
1635 logwarn!(
1636 "{} has a fallback ({}) that is not installed, so it will not stand in",
1637 agent.name(),
1638 backup.program()
1639 );
1640 }
1641 }
1642 }
1643 Ok(agents)
1644}
1645
1646pub fn find<'a>(agents: &'a [Agent], name: &str) -> Result<&'a Agent> {
1648 agents.iter().find(|a| a.name() == name).ok_or_else(|| {
1649 SparError::new(format!(
1650 "no agent named '{name}' ({})",
1651 agents
1652 .iter()
1653 .map(Agent::name)
1654 .collect::<Vec<_>>()
1655 .join(", ")
1656 ))
1657 })
1658}
1659
1660#[cfg(test)]
1661mod tests {
1662 use super::*;
1663 use crate::config::{OutputMode, SystemVia};
1664
1665 fn spec(command: Vec<CommandPart>) -> AgentSpec {
1666 AgentSpec {
1667 name: "test".into(),
1668 command,
1669 model: None,
1670 effort: None,
1671 effort_schedule: crate::config::EffortSchedule::default(),
1672 output: OutputMode::Text,
1673 message_match: BTreeMap::new(),
1674 message_path: None,
1675 search_paths: vec![],
1676 system_via: SystemVia::Prompt,
1677 timeout: 60,
1678 fallback: None,
1679 models: vec![],
1680 efforts: vec![],
1681 options_note: None,
1682 }
1683 }
1684
1685 fn one(s: &str) -> CommandPart {
1686 CommandPart::One(s.into())
1687 }
1688
1689 fn group(parts: &[&str]) -> CommandPart {
1690 CommandPart::Group(parts.iter().map(|s| s.to_string()).collect())
1691 }
1692
1693 #[test]
1694 fn an_uncertain_process_result_skips_post_call_recovery() {
1695 let called: Result<()> = Err(SparError::uncertain_write("descendants may still write"));
1696 let recovered = std::cell::Cell::new(false);
1697
1698 let error = after_call_is_quiet(&called, || {
1699 recovered.set(true);
1700 Ok(())
1701 })
1702 .unwrap_err();
1703
1704 assert_eq!(ErrorKind::UncertainWrite, error.kind());
1705 assert!(!recovered.get());
1706 }
1707
1708 fn agent(command: Vec<CommandPart>) -> Agent {
1709 Agent::with_bin(spec(command), "/fake/bin")
1710 }
1711
1712 fn values() -> Placeholders {
1713 Placeholders {
1714 prompt: Some("hi".into()),
1715 ..Default::default()
1716 }
1717 }
1718
1719 #[test]
1722 fn placeholders_are_substituted() {
1723 let a = agent(vec![one("x"), group(&["-m", "{model}"]), one("{prompt}")]);
1724 let v = Placeholders {
1725 model: Some("m1".into()),
1726 ..values()
1727 };
1728 assert_eq!(vec!["/fake/bin", "-m", "m1", "hi"], a.render(&v).unwrap());
1729 }
1730
1731 #[test]
1732 fn an_unset_placeholder_drops_the_whole_group() {
1733 let a = agent(vec![one("x"), group(&["-m", "{model}"]), one("{prompt}")]);
1734 assert_eq!(vec!["/fake/bin", "hi"], a.render(&values()).unwrap());
1735 }
1736
1737 #[test]
1738 fn an_empty_string_drops_the_group_too() {
1739 let a = agent(vec![one("x"), group(&["-e", "{effort}"]), one("{prompt}")]);
1740 let v = Placeholders {
1741 effort: Some(String::new()),
1742 ..values()
1743 };
1744 assert_eq!(vec!["/fake/bin", "hi"], a.render(&v).unwrap());
1745 }
1746
1747 #[test]
1748 fn a_bare_arg_with_an_unset_placeholder_drops() {
1749 let a = agent(vec![one("x"), one("{model}"), one("{prompt}")]);
1750 assert_eq!(vec!["/fake/bin", "hi"], a.render(&values()).unwrap());
1751 }
1752
1753 #[test]
1754 fn literal_args_survive() {
1755 let a = agent(vec![
1756 one("x"),
1757 one("exec"),
1758 one("--json"),
1759 one("--"),
1760 one("{prompt}"),
1761 ]);
1762 assert_eq!(
1763 vec!["/fake/bin", "exec", "--json", "--", "hi"],
1764 a.render(&values()).unwrap()
1765 );
1766 }
1767
1768 #[test]
1769 fn an_embedded_placeholder_substitutes_in_place() {
1770 let a = agent(vec![
1771 one("x"),
1772 group(&["-c", "model_reasoning_effort={effort}"]),
1773 ]);
1774 let v = Placeholders {
1775 effort: Some("ultra".into()),
1776 ..Default::default()
1777 };
1778 assert_eq!(
1779 vec!["/fake/bin", "-c", "model_reasoning_effort=ultra"],
1780 a.render(&v).unwrap()
1781 );
1782 }
1783
1784 #[test]
1785 fn a_group_with_two_placeholders_needs_both() {
1786 let a = agent(vec![
1787 one("x"),
1788 group(&["--a", "{model}", "--b", "{effort}"]),
1789 ]);
1790 let v = Placeholders {
1791 model: Some("m".into()),
1792 ..Default::default()
1793 };
1794 assert_eq!(vec!["/fake/bin"], a.render(&v).unwrap());
1795 }
1796
1797 #[test]
1799 fn a_placeholder_quoted_in_the_prompt_is_kept_when_set() {
1800 let a = agent(vec![one("x"), group(&["-m", "{model}"]), one("{prompt}")]);
1801 let v = Placeholders {
1802 prompt: Some("Issue: Render {model} verbatim".into()),
1803 model: Some("m1".into()),
1804 ..Default::default()
1805 };
1806 assert_eq!(
1807 vec!["/fake/bin", "-m", "m1", "Issue: Render {model} verbatim"],
1808 a.render(&v).unwrap()
1809 );
1810 }
1811
1812 #[test]
1813 fn a_placeholder_quoted_in_the_prompt_is_kept_when_unset() {
1814 let a = agent(vec![one("x"), group(&["-p", "{prompt}"])]);
1815 let v = Placeholders {
1816 prompt: Some("Render {model} and {effort} verbatim".into()),
1817 ..Default::default()
1818 };
1819 assert_eq!(
1820 vec!["/fake/bin", "-p", "Render {model} and {effort} verbatim"],
1821 a.render(&v).unwrap()
1822 );
1823 }
1824
1825 #[test]
1827 fn an_inserted_value_is_not_rescanned_within_its_argument() {
1828 let a = agent(vec![one("x"), one("{system}:{model}")]);
1829 let v = Placeholders {
1830 system: Some("{effort}".into()),
1831 model: Some("m1".into()),
1832 ..Default::default()
1833 };
1834 assert_eq!(vec!["/fake/bin", "{effort}:m1"], a.render(&v).unwrap());
1835 }
1836
1837 #[test]
1838 fn braces_that_are_not_placeholders_survive() {
1839 let a = agent(vec![one("x"), one("{{model}} {other} {"), one("{model")]);
1840 let v = Placeholders {
1841 model: Some("m1".into()),
1842 ..Default::default()
1843 };
1844 assert_eq!(
1845 vec!["/fake/bin", "{m1} {other} {", "{model"],
1846 a.render(&v).unwrap()
1847 );
1848 }
1849
1850 #[test]
1851 fn supports_schema_detects_the_placeholder() {
1852 assert!(agent(vec![one("x"), group(&["--schema", "{schema_file}"])]).supports_schema());
1853 assert!(!agent(vec![one("x"), one("{prompt}")]).supports_schema());
1854 }
1855
1856 #[test]
1859 fn text_passes_through_trimmed() {
1860 assert_eq!("hello", agent(vec![one("x")]).extract(" hello\n").unwrap());
1861 }
1862
1863 #[test]
1864 fn jsonl_picks_the_matching_event() {
1865 let mut spec = spec(vec![one("x")]);
1866 spec.output = OutputMode::Jsonl;
1867 spec.message_path = Some("item.text".into());
1868 spec.message_match = BTreeMap::from([
1869 ("type".to_string(), "item.completed".to_string()),
1870 ("item.type".to_string(), "agent_message".to_string()),
1871 ]);
1872 let a = Agent::with_bin(spec, "/fake/bin");
1873 let stream = [
1874 r#"{"type":"thread.started","thread_id":"t1"}"#,
1875 r#"{"type":"item.completed","item":{"type":"command_execution","text":"ls"}}"#,
1876 r#"{"type":"item.completed","item":{"type":"agent_message","text":"the answer"}}"#,
1877 "not json at all",
1878 ]
1879 .join("\n");
1880 assert_eq!("the answer", a.extract(&stream).unwrap());
1881 }
1882
1883 #[test]
1884 fn jsonl_raises_on_an_error_with_no_message() {
1885 let mut spec = spec(vec![one("x")]);
1886 spec.output = OutputMode::Jsonl;
1887 spec.message_path = Some("item.text".into());
1888 spec.message_match = BTreeMap::from([("type".into(), "item.completed".into())]);
1889 let a = Agent::with_bin(spec, "/fake/bin");
1890 assert!(a
1891 .extract(r#"{"type":"turn.failed","error":"boom"}"#)
1892 .is_err());
1893 }
1894
1895 #[test]
1896 fn jsonl_joins_several_agent_messages() {
1897 let mut spec = spec(vec![one("x")]);
1898 spec.output = OutputMode::Jsonl;
1899 spec.message_path = Some("text".into());
1900 spec.message_match = BTreeMap::from([("type".into(), "msg".into())]);
1901 let a = Agent::with_bin(spec, "/fake/bin");
1902 let stream = "{\"type\":\"msg\",\"text\":\"one\"}\n{\"type\":\"msg\",\"text\":\"two\"}";
1903 assert_eq!("one\ntwo", a.extract(stream).unwrap());
1904 }
1905
1906 #[test]
1907 fn dig_walks_a_dotted_path() {
1908 let v: Value = serde_json::from_str(r#"{"a":{"b":{"c":1}}}"#).unwrap();
1909 assert_eq!(Some(&Value::from(1)), dig(&v, "a.b.c"));
1910 assert_eq!(None, dig(&v, "a.b.missing"));
1911 assert_eq!(None, dig(&v, ""));
1912 }
1913
1914 #[test]
1917 fn a_missing_binary_lists_everywhere_it_looked() {
1918 let mut s = spec(vec![one("definitely-not-installed-xyz")]);
1919 s.search_paths = vec!["/nowhere/at/all".into()];
1920 s.name = "codex".into();
1921 let err = Agent::new(s).resolve_bin().unwrap_err().to_string();
1922 assert!(err.contains("definitely-not-installed-xyz (PATH)"), "{err}");
1923 assert!(
1924 err.contains("/nowhere/at/all/definitely-not-installed-xyz"),
1925 "{err}"
1926 );
1927 assert!(err.contains("SPAR_CODEX_BIN"), "{err}");
1928 }
1929
1930 #[test]
1931 fn a_search_path_that_already_names_the_binary_is_used_as_is() {
1932 let dir = std::env::temp_dir().join(format!("spar-test-{}", std::process::id()));
1933 std::fs::create_dir_all(&dir).unwrap();
1934 let bin = dir.join("mytool");
1935 std::fs::write(&bin, "#!/bin/sh\n").unwrap();
1936 #[cfg(unix)]
1937 {
1938 use std::os::unix::fs::PermissionsExt;
1939 std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
1940 }
1941 let mut s = spec(vec![one("mytool")]);
1942 s.search_paths = vec![bin.display().to_string()];
1943 assert_eq!(bin, Agent::new(s).resolve_bin().unwrap());
1944 let _ = std::fs::remove_dir_all(&dir);
1945 }
1946
1947 fn named(name: &str, bin: &str, model: Option<&str>) -> Agent {
1950 let mut s = spec(vec![one("prog")]);
1951 s.name = name.into();
1952 s.model = model.map(str::to_string);
1953 Agent::with_bin(s, bin)
1954 }
1955
1956 #[test]
1957 fn same_bin_same_model_warns() {
1958 let agents = vec![
1959 named("alpha", "/usr/local/bin/claude", Some("fable")),
1960 named("beta", "/usr/local/bin/claude", Some("fable")),
1961 ];
1962 let msg = correlation_warning(&agents).expect("should warn");
1963 assert!(msg.contains("alpha") && msg.contains("beta"), "{msg}");
1964 }
1965
1966 #[test]
1967 fn different_model_does_not_warn() {
1968 let agents = vec![
1969 named("a", "/usr/local/bin/claude", Some("fable")),
1970 named("b", "/usr/local/bin/claude", Some("opus")),
1971 ];
1972 assert!(correlation_warning(&agents).is_none());
1973 }
1974
1975 #[test]
1976 fn different_bin_does_not_warn() {
1977 let agents = vec![
1978 named("a", "/usr/local/bin/claude", Some("fable")),
1979 named("b", "/usr/local/bin/codex", Some("fable")),
1980 ];
1981 assert!(correlation_warning(&agents).is_none());
1982 }
1983
1984 #[test]
1985 fn unset_and_empty_model_both_mean_the_default_and_warn() {
1986 let agents = vec![
1987 named("a", "/usr/local/bin/claude", None),
1988 named("b", "/usr/local/bin/claude", Some("")),
1989 ];
1990 let msg = correlation_warning(&agents).expect("should warn");
1991 assert!(msg.contains("the CLI's default"), "{msg}");
1992 }
1993
1994 #[test]
1995 fn a_padded_model_still_warns() {
1996 let agents = vec![
1997 named("a", "/usr/local/bin/claude", Some("fable")),
1998 named("b", "/usr/local/bin/claude", Some(" fable ")),
1999 ];
2000 assert!(correlation_warning(&agents).is_some());
2001 }
2002
2003 #[test]
2004 fn an_empty_model_against_a_named_one_does_not_warn() {
2005 let agents = vec![
2006 named("a", "/usr/local/bin/claude", Some("")),
2007 named("b", "/usr/local/bin/claude", Some("fable")),
2008 ];
2009 assert!(correlation_warning(&agents).is_none());
2010 }
2011
2012 #[cfg(unix)]
2013 #[test]
2014 fn a_symlinked_binary_warns_and_names_both_paths() {
2015 use std::os::unix::fs::PermissionsExt;
2016 let dir = std::env::temp_dir().join(format!("spar-link-{}", std::process::id()));
2017 let _ = std::fs::remove_dir_all(&dir);
2018 std::fs::create_dir_all(&dir).unwrap();
2019 let real = dir.join("claude");
2020 let link = dir.join("claude-alias");
2021 std::fs::write(&real, "#!/bin/sh\n").unwrap();
2022 std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o755)).unwrap();
2023 std::os::unix::fs::symlink(&real, &link).unwrap();
2024
2025 let agents = vec![
2026 named("alpha", real.to_str().unwrap(), Some("fable")),
2027 named("beta", link.to_str().unwrap(), Some("fable")),
2028 ];
2029 let msg = correlation_warning(&agents).expect("should warn");
2030 assert!(msg.contains(real.to_str().unwrap()), "{msg}");
2031 assert!(msg.contains(link.to_str().unwrap()), "{msg}");
2032 let _ = std::fs::remove_dir_all(&dir);
2033 }
2034
2035 #[cfg(unix)]
2036 #[test]
2037 fn two_distinct_real_binaries_stay_quiet() {
2038 use std::os::unix::fs::PermissionsExt;
2039 let dir = std::env::temp_dir().join(format!("spar-distinct-{}", std::process::id()));
2040 let _ = std::fs::remove_dir_all(&dir);
2041 std::fs::create_dir_all(&dir).unwrap();
2042 let mut paths = Vec::new();
2043 for name in ["claude", "codex"] {
2044 let path = dir.join(name);
2045 std::fs::write(&path, "#!/bin/sh\n").unwrap();
2046 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
2047 paths.push(path);
2048 }
2049 let agents = vec![
2050 named("a", paths[0].to_str().unwrap(), Some("fable")),
2051 named("b", paths[1].to_str().unwrap(), Some("fable")),
2052 ];
2053 assert!(correlation_warning(&agents).is_none());
2054 let _ = std::fs::remove_dir_all(&dir);
2055 }
2056
2057 #[test]
2058 fn the_style_rules_ask_for_brevity_and_no_attribution() {
2059 let lower = STYLE_RULES.to_lowercase();
2060 assert!(lower.contains("brief"));
2061 assert!(lower.contains("co-authored-by"));
2062 assert!(lower.contains("em-dash"));
2063 }
2064
2065 #[test]
2070 fn brevity_is_about_facts_per_sentence_not_sentence_count() {
2071 let lower = STYLE_RULES.to_lowercase();
2072 assert!(lower.contains("saying fewer things"), "{STYLE_RULES}");
2073 assert!(
2074 !lower.contains("one sentence beats one paragraph"),
2075 "the rule that produced the density is still there"
2076 );
2077 }
2078
2079 #[test]
2084 fn the_style_rules_reach_the_code_and_not_only_what_is_posted() {
2085 let lower = STYLE_RULES.to_lowercase();
2086 assert!(lower.contains("comments in code you write"), "not in scope");
2087 assert!(
2088 lower.contains("comment code for the reason"),
2089 "no rule for it"
2090 );
2091 }
2092
2093 #[test]
2094 fn the_deadline_names_the_moment_and_how_long_the_call_has() {
2095 let rule = deadline_rule(3600, 1_792_892_673);
2097 assert!(rule.contains("stopped at 2026-10-25 02:44 UTC"), "{rule}");
2098 assert!(rule.contains("60m00s after it started"), "{rule}");
2099 assert!(rule.contains("tests that cover the change"), "{rule}");
2100 assert!(!rule.contains(['\u{2013}', '\u{2014}']), "{rule}");
2101 }
2102
2103 #[test]
2106 fn the_deadline_reaches_the_cli_whichever_way_it_takes_the_system_text() {
2107 let echo = |what: &str, via: SystemVia, timeout: u64| {
2108 let mut spec = spec(vec![
2109 one("sh"),
2110 one("-c"),
2111 one("printf %s \"$1\""),
2112 one("sh"),
2113 one(what),
2114 ]);
2115 spec.system_via = via;
2116 spec.timeout = timeout;
2117 Agent::with_bin(spec, "/bin/sh")
2118 .ask("the prompt", Path::new("."), &Effort::default())
2119 .unwrap()
2120 };
2121
2122 let prompted = echo("{prompt}", SystemVia::Prompt, 60);
2123 assert!(prompted.starts_with("Style rules"), "{prompted}");
2124 assert!(prompted.contains("60s after it started"), "{prompted}");
2125 assert!(prompted.ends_with("the prompt"), "{prompted}");
2126
2127 let placed = echo("{system}", SystemVia::Placeholder, 1800);
2128 assert!(placed.starts_with("Style rules"), "{placed}");
2129 assert!(placed.contains("30m00s after it started"), "{placed}");
2130 }
2131
2132 fn refusal_stream() -> String {
2137 let noise = "{\"type\":\"item.completed\",\"item\":{\"id\":\"i\",\"type\":\"command_execution\",\"output\":\"".to_string()
2138 + &"const x = 1;\\n".repeat(200)
2139 + "\"}}";
2140 [
2141 noise.as_str(),
2142 r#"{"type":"error","message":"This content was flagged for possible cybersecurity risk."}"#,
2143 r#"{"type":"error","message":"This content was flagged for possible cybersecurity risk."}"#,
2144 r#"{"type":"turn.failed","error":{"message":"This content was flagged for possible cybersecurity risk."}}"#,
2145 ]
2146 .join("\n")
2147 }
2148
2149 fn jsonl_agent(name: &str) -> Agent {
2150 let mut spec = spec(vec![one("codex")]);
2151 spec.name = name.into();
2152 spec.output = OutputMode::Jsonl;
2153 spec.message_path = Some("item.text".into());
2154 Agent::with_bin(spec, "/fake/codex")
2155 }
2156
2157 fn failed(stdout: &str, stderr: &str) -> proc::Output {
2158 proc::Output {
2159 stdout: stdout.to_string(),
2160 stdout_bytes: stdout.as_bytes().to_vec(),
2161 stderr: stderr.to_string(),
2162 code: 1,
2163 }
2164 }
2165
2166 #[test]
2169 fn a_jsonl_failure_reports_the_reason_and_not_the_stream() {
2170 let agent = jsonl_agent("codex");
2171 let err = agent.call_failure(&["codex".to_string()], &failed(&refusal_stream(), ""));
2172 let text = err.message();
2173 assert!(
2174 text.contains("flagged for possible cybersecurity risk"),
2175 "{text}"
2176 );
2177 assert!(
2178 !text.contains("const x = 1;"),
2179 "the stream leaked in:\n{text}"
2180 );
2181 assert!(text.len() < 400, "still {} characters:\n{text}", text.len());
2182 }
2183
2184 #[test]
2186 fn the_same_reason_reported_three_times_is_said_once() {
2187 let agent = jsonl_agent("codex");
2188 let err = agent.call_failure(&["codex".to_string()], &failed(&refusal_stream(), ""));
2189 assert_eq!(
2190 1,
2191 err.message().matches("flagged for possible").count(),
2192 "{}",
2193 err.message()
2194 );
2195 }
2196
2197 #[test]
2200 fn stderr_is_kept_because_it_is_where_the_other_half_arrives() {
2201 let agent = jsonl_agent("codex");
2202 let err = agent.call_failure(
2203 &["codex".to_string()],
2204 &failed(
2205 &refusal_stream(),
2206 "ERROR router: agent thread limit reached",
2207 ),
2208 );
2209 assert!(
2210 err.message().contains("agent thread limit reached"),
2211 "{}",
2212 err.message()
2213 );
2214 }
2215
2216 #[test]
2219 fn a_stream_with_no_error_event_falls_back_to_the_raw_output() {
2220 let agent = jsonl_agent("codex");
2221 let err = agent.call_failure(
2222 &["codex".to_string()],
2223 &failed("{\"type\":\"system\"}", "segmentation fault"),
2224 );
2225 assert!(
2226 err.message().contains("segmentation fault"),
2227 "{}",
2228 err.message()
2229 );
2230 assert!(
2231 err.message().starts_with("command failed"),
2232 "{}",
2233 err.message()
2234 );
2235 }
2236
2237 #[test]
2239 fn a_text_agent_is_reported_exactly_as_before() {
2240 let agent = agent(vec![one("mytool")]);
2241 let err = agent.call_failure(&["mytool".to_string()], &failed("some prose", "boom"));
2242 assert!(
2243 err.message().starts_with("command failed"),
2244 "{}",
2245 err.message()
2246 );
2247 assert!(err.message().contains("some prose"), "{}", err.message());
2248 }
2249
2250 #[test]
2253 fn a_reworded_failure_is_still_a_failed_call() {
2254 let agent = jsonl_agent("codex");
2255 let err = agent.call_failure(&["codex".to_string()], &failed(&refusal_stream(), ""));
2256 assert_eq!(ErrorKind::CallFailed, err.kind());
2257 }
2258
2259 #[test]
2262 fn a_request_carries_the_instructions_after_the_task() {
2263 let agent = Agent::with_bin(shell("a", "true"), "/bin/sh")
2264 .with_instructions("Do not wait for CI. Pick it up next pass.");
2265 let asked = agent.instructed("Review the changes on this branch.");
2266 assert!(
2267 asked.starts_with("Review the changes on this branch."),
2268 "{asked}"
2269 );
2270 assert!(asked.contains("Do not wait for CI"), "{asked}");
2271 }
2272
2273 #[test]
2277 fn the_instructions_arrive_subordinate_to_the_request() {
2278 let agent = Agent::with_bin(shell("a", "true"), "/bin/sh").with_instructions("Be quick.");
2279 let asked = agent.instructed("Do the work.").to_lowercase();
2280 assert!(
2281 asked.contains("from the person who started this run"),
2282 "{asked}"
2283 );
2284 assert!(asked.contains("not the shape of your answer"), "{asked}");
2285 }
2286
2287 #[test]
2288 fn nothing_is_added_when_there_are_none() {
2289 let agent = Agent::with_bin(shell("a", "true"), "/bin/sh");
2290 assert_eq!("Do the work.", agent.instructed("Do the work."));
2291 let blank = Agent::with_bin(shell("b", "true"), "/bin/sh").with_instructions(" \n ");
2293 assert_eq!("Do the work.", blank.instructed("Do the work."));
2294 }
2295
2296 #[test]
2299 fn the_stand_in_carries_them_too() {
2300 let agent = with_fallback(shell("primary", "true"), shell("backup", "true"))
2301 .with_instructions("Do not wait for CI.");
2302 let backup = agent.fallback().expect("a stand in");
2303 assert!(backup
2304 .instructed("Do the work.")
2305 .contains("Do not wait for CI."));
2306 }
2307
2308 fn shell(name: &str, line: &str) -> AgentSpec {
2313 let mut spec = spec(vec![one("sh"), one("-c"), one(line)]);
2314 spec.name = name.into();
2315 spec
2316 }
2317
2318 fn with_fallback(mut primary: AgentSpec, backup: AgentSpec) -> Agent {
2319 primary.fallback = Some(Box::new(backup));
2320 Agent::with_bin(primary, "/bin/sh")
2321 }
2322
2323 fn git_at(cwd: &Path, args: &[&str]) -> String {
2324 let out = std::process::Command::new("git")
2325 .args(args)
2326 .current_dir(cwd)
2327 .output()
2328 .unwrap();
2329 assert!(
2330 out.status.success(),
2331 "{}",
2332 String::from_utf8_lossy(&out.stderr)
2333 );
2334 String::from_utf8_lossy(&out.stdout).trim().to_string()
2335 }
2336
2337 fn committed_repo(name: &str) -> PathBuf {
2338 let dir = std::env::temp_dir().join(format!(
2339 "spar-agent-{name}-{}-{}",
2340 std::process::id(),
2341 std::thread::current().name().unwrap_or("test")
2342 ));
2343 let _ = std::fs::remove_dir_all(&dir);
2344 std::fs::create_dir_all(&dir).unwrap();
2345 git_at(&dir, &["init", "-q", "-b", "main"]);
2346 git_at(&dir, &["config", "user.email", "spar@example.invalid"]);
2347 git_at(&dir, &["config", "user.name", "spar test"]);
2348 git_at(&dir, &["config", "commit.gpgsign", "false"]);
2349 std::fs::write(dir.join("README.md"), "seed\n").unwrap();
2350 git_at(&dir, &["add", "README.md"]);
2351 git_at(&dir, &["commit", "-q", "-m", "seed"]);
2352 dir
2353 }
2354
2355 fn linked_worktree(name: &str) -> (PathBuf, PathBuf, PathBuf, Vec<u8>) {
2356 let root = std::env::temp_dir().join(format!(
2357 "spar-agent-{name}-{}-{}",
2358 std::process::id(),
2359 std::thread::current().name().unwrap_or("test")
2360 ));
2361 let _ = std::fs::remove_dir_all(&root);
2362 let main = root.join("main");
2363 std::fs::create_dir_all(&main).unwrap();
2364 git_at(&main, &["init", "-q", "-b", "main"]);
2365 git_at(&main, &["config", "user.email", "spar@example.invalid"]);
2366 git_at(&main, &["config", "user.name", "spar test"]);
2367 git_at(&main, &["config", "commit.gpgsign", "false"]);
2368 std::fs::write(main.join("README.md"), "seed\n").unwrap();
2369 git_at(&main, &["add", "README.md"]);
2370 git_at(&main, &["commit", "-q", "-m", "seed"]);
2371 let linked = root.join("linked");
2372 git_at(
2373 &main,
2374 &[
2375 "worktree",
2376 "add",
2377 "-q",
2378 "-b",
2379 "issue-test",
2380 linked.to_str().unwrap(),
2381 ],
2382 );
2383 let marker = std::fs::read(linked.join(".git")).unwrap();
2384 (root, main, linked, marker)
2385 }
2386
2387 #[test]
2388 fn a_failed_call_is_answered_by_the_fallback() {
2389 let agent = with_fallback(
2390 shell("primary", "echo refused >&2; exit 1"),
2391 shell("backup", "echo stood in"),
2392 );
2393 let answer = agent
2394 .ask("hi", Path::new("."), &Effort::default())
2395 .expect("fallback");
2396 assert_eq!("stood in", answer);
2397 }
2398
2399 #[test]
2400 fn a_failed_edit_with_files_left_does_not_run_the_fallback() {
2401 let dir = committed_repo("dirty-edit-recovery");
2402
2403 let agent = with_fallback(
2404 shell(
2405 "primary",
2406 "printf 'recover me\\n' > README.md; printf refused >&2; exit 1",
2407 ),
2408 shell("backup", "printf 'fallback ran\\n' > fallback.txt"),
2409 );
2410 let err = agent
2411 .edit("change it", &dir, &Effort::default())
2412 .unwrap_err();
2413
2414 assert!(err.message().contains("refused"), "{err}");
2415 assert_eq!(
2416 1,
2417 err.message()
2418 .matches("The call changed the worktree before it failed")
2419 .count(),
2420 "{err}"
2421 );
2422 assert_eq!(
2423 "recover me\n",
2424 std::fs::read_to_string(dir.join("README.md")).unwrap()
2425 );
2426 assert!(!dir.join("fallback.txt").exists());
2427 let _ = std::fs::remove_dir_all(&dir);
2428 }
2429
2430 #[test]
2431 fn a_failed_edit_with_a_new_ignored_file_does_not_run_the_fallback() {
2432 let dir = committed_repo("ignored-edit-recovery");
2433 std::fs::write(dir.join(".gitignore"), "ignored.txt\n").unwrap();
2434 git_at(&dir, &["add", ".gitignore"]);
2435 git_at(&dir, &["commit", "-q", "-m", "ignore fixture"]);
2436 let agent = with_fallback(
2437 shell(
2438 "primary",
2439 "printf 'recover me\n' > ignored.txt; printf refused >&2; exit 1",
2440 ),
2441 shell("backup", "printf 'fallback ran\n' > fallback.txt"),
2442 );
2443
2444 let err = agent
2445 .edit("change it", &dir, &Effort::default())
2446 .unwrap_err();
2447
2448 assert!(err.message().contains("refused"), "{err}");
2449 assert_eq!(
2450 "recover me\n",
2451 std::fs::read_to_string(dir.join("ignored.txt")).unwrap()
2452 );
2453 assert!(!dir.join("fallback.txt").exists());
2454 let _ = std::fs::remove_dir_all(&dir);
2455 }
2456
2457 #[test]
2458 fn a_failed_edit_that_overwrites_an_ignored_file_does_not_run_the_fallback() {
2459 let dir = committed_repo("changed-ignored-edit-recovery");
2460 std::fs::write(dir.join(".gitignore"), "ignored.txt\n").unwrap();
2461 git_at(&dir, &["add", ".gitignore"]);
2462 git_at(&dir, &["commit", "-q", "-m", "ignore fixture"]);
2463 std::fs::write(dir.join("ignored.txt"), "before\n").unwrap();
2464 let agent = with_fallback(
2465 shell(
2466 "primary",
2467 "printf 'after!\\n' > ignored.txt; printf refused >&2; exit 1",
2468 ),
2469 shell("backup", "printf 'fallback ran\n' > fallback.txt"),
2470 );
2471
2472 let err = agent
2473 .edit("change it", &dir, &Effort::default())
2474 .unwrap_err();
2475
2476 assert!(err.message().contains("refused"), "{err}");
2477 assert_eq!(
2478 "after!\n",
2479 std::fs::read_to_string(dir.join("ignored.txt")).unwrap()
2480 );
2481 assert!(!dir.join("fallback.txt").exists());
2482 let _ = std::fs::remove_dir_all(&dir);
2483 }
2484
2485 #[test]
2486 fn a_failed_edit_with_a_commit_does_not_run_the_fallback() {
2487 let dir = committed_repo("committed-edit-recovery");
2488 let agent = with_fallback(
2489 shell(
2490 "primary",
2491 "printf 'recover me\n' > README.md; git add README.md; \
2492 git commit -q -m preserved; printf refused >&2; exit 1",
2493 ),
2494 shell("backup", "printf 'fallback ran\n' > fallback.txt"),
2495 );
2496
2497 let err = agent
2498 .edit("change it", &dir, &Effort::default())
2499 .unwrap_err();
2500
2501 assert!(err.message().contains("refused"), "{err}");
2502 assert_eq!("preserved", git_at(&dir, &["log", "-1", "--pretty=%s"]));
2503 assert_eq!(
2504 "recover me\n",
2505 std::fs::read_to_string(dir.join("README.md")).unwrap()
2506 );
2507 assert!(!dir.join("fallback.txt").exists());
2508 let _ = std::fs::remove_dir_all(&dir);
2509 }
2510
2511 #[test]
2512 fn a_committed_edit_with_malformed_json_is_not_retried_or_handed_over() {
2513 let dir = committed_repo("committed-malformed-edit");
2514 let agent = with_fallback(
2515 shell(
2516 "primary",
2517 "printf 'once\n' >> attempts.txt; printf 'recover me\n' > README.md; \
2518 git add -A; git commit -q -m preserved; printf 'not json\n'",
2519 ),
2520 shell(
2521 "backup",
2522 "printf 'fallback ran\n' > fallback.txt; printf '{}\n'",
2523 ),
2524 );
2525
2526 let err = agent
2527 .edit_json::<Value>(
2528 "change it",
2529 &serde_json::json!({"type": "object"}),
2530 &dir,
2531 &Effort::default(),
2532 )
2533 .unwrap_err();
2534
2535 assert!(err.message().contains("JSON"), "{err}");
2536 assert_eq!("preserved", git_at(&dir, &["log", "-1", "--pretty=%s"]));
2537 assert_eq!(
2538 1,
2539 std::fs::read_to_string(dir.join("attempts.txt"))
2540 .unwrap()
2541 .lines()
2542 .count()
2543 );
2544 assert!(!dir.join("fallback.txt").exists());
2545 let _ = std::fs::remove_dir_all(&dir);
2546 }
2547
2548 #[test]
2549 fn a_failed_edit_recovery_probe_does_not_retry_or_run_the_fallback() {
2550 let dir = committed_repo("failed-edit-probe");
2551 let agent = with_fallback(
2552 shell(
2553 "primary",
2554 "printf 'once\n' >> attempts.txt; mv .git .git-away; printf 'not json\n'",
2555 ),
2556 shell(
2557 "backup",
2558 "printf 'fallback ran\n' > fallback.txt; printf '{}\n'",
2559 ),
2560 );
2561
2562 let err = agent
2563 .edit_json::<Value>(
2564 "change it",
2565 &serde_json::json!({"type": "object"}),
2566 &dir,
2567 &Effort::default(),
2568 )
2569 .unwrap_err();
2570
2571 std::fs::rename(dir.join(".git-away"), dir.join(".git")).unwrap();
2572 assert_eq!(ErrorKind::UncertainWrite, err.kind());
2573 assert!(err.message().contains("JSON"), "{err}");
2574 assert_eq!(
2575 1,
2576 std::fs::read_to_string(dir.join("attempts.txt"))
2577 .unwrap()
2578 .lines()
2579 .count()
2580 );
2581 assert!(!dir.join("fallback.txt").exists());
2582 assert!(dir.join(".git").is_dir());
2583 let _ = std::fs::remove_dir_all(&dir);
2584 }
2585
2586 #[test]
2587 fn a_linked_worktree_git_marker_is_restored_after_an_edit() {
2588 let (root, main, linked, marker) = linked_worktree("changed-git-marker");
2589 let agent = Agent::with_bin(
2590 shell(
2591 "editor",
2592 "printf 'gitdir: /tmp/not-the-repo\\n' > .git; echo done",
2593 ),
2594 "/bin/sh",
2595 );
2596
2597 let err = agent
2598 .edit("change it", &linked, &Effort::default())
2599 .unwrap_err();
2600
2601 assert_eq!(ErrorKind::UncertainWrite, err.kind());
2602 assert!(err.message().contains("Git marker"), "{err}");
2603 assert_eq!(marker, std::fs::read(linked.join(".git")).unwrap());
2604 assert_eq!(
2605 b"gitdir: /tmp/not-the-repo\n",
2606 std::fs::read(linked.join(GIT_MARKER_RECOVERY))
2607 .unwrap()
2608 .as_slice()
2609 );
2610 git_at(
2611 &main,
2612 &["worktree", "remove", "--force", linked.to_str().unwrap()],
2613 );
2614 let _ = std::fs::remove_dir_all(&root);
2615 }
2616
2617 #[test]
2618 fn a_read_call_also_restores_a_linked_worktree_git_marker() {
2619 let (root, main, linked, marker) = linked_worktree("read-call-git-marker");
2620 let agent = with_fallback(
2621 shell(
2622 "reader",
2623 "printf 'gitdir: /tmp/not-the-repo\n' > .git; echo done",
2624 ),
2625 shell("backup", "printf 'fallback ran\n' > fallback.txt"),
2626 );
2627
2628 let err = agent
2629 .ask("read it", &linked, &Effort::default())
2630 .unwrap_err();
2631
2632 assert_eq!(ErrorKind::UncertainWrite, err.kind());
2633 assert_eq!(marker, std::fs::read(linked.join(".git")).unwrap());
2634 assert!(!linked.join("fallback.txt").exists());
2635 git_at(
2636 &main,
2637 &["worktree", "remove", "--force", linked.to_str().unwrap()],
2638 );
2639 let _ = std::fs::remove_dir_all(&root);
2640 }
2641
2642 #[test]
2643 fn a_successful_read_that_writes_is_discarded() {
2644 let dir = committed_repo("successful-read-write");
2645 let agent = Agent::with_bin(
2646 shell("reader", "printf 'recover me\n' > README.md; echo reviewed"),
2647 "/bin/sh",
2648 );
2649
2650 let err = agent.ask("read it", &dir, &Effort::default()).unwrap_err();
2651
2652 assert_eq!(ErrorKind::UncertainWrite, err.kind());
2653 assert!(err.message().contains("read-only call"), "{err}");
2654 assert_eq!(
2655 "recover me\n",
2656 std::fs::read_to_string(dir.join("README.md")).unwrap()
2657 );
2658 assert!(std::fs::read_dir(&dir).unwrap().flatten().any(|entry| entry
2659 .file_name()
2660 .to_string_lossy()
2661 .starts_with(".spar-recovery-needed-")));
2662 let _ = std::fs::remove_dir_all(&dir);
2663 }
2664
2665 fn marked(dir: &Path) -> bool {
2666 std::fs::read_dir(dir).unwrap().flatten().any(|entry| {
2667 entry
2668 .file_name()
2669 .to_string_lossy()
2670 .starts_with(".spar-recovery-needed-")
2671 })
2672 }
2673
2674 #[test]
2678 fn a_read_alongside_another_is_not_judged_on_a_new_file() {
2679 let (root, main, linked, _marker) = linked_worktree("alongside-new-file");
2680 let agent = Agent::with_bin(
2681 shell("reader", "printf 'repro\\n' > zz-repro.test.txt; echo '{}'"),
2682 "/bin/sh",
2683 );
2684 let schema = serde_json::json!({"type": "object"});
2685
2686 let answer: Value = agent
2687 .ask_json_alongside("read it", &schema, &linked, &Effort::default())
2688 .expect("the answer stands");
2689
2690 assert!(answer.is_object());
2691 assert!(!marked(&linked));
2692 assert_eq!(
2693 "repro\n",
2694 std::fs::read_to_string(linked.join("zz-repro.test.txt")).unwrap()
2695 );
2696
2697 std::fs::remove_file(linked.join("zz-repro.test.txt")).unwrap();
2699 let err = agent
2700 .ask_json::<Value>("read it", &schema, &linked, &Effort::default())
2701 .unwrap_err();
2702 assert_eq!(ErrorKind::UncertainWrite, err.kind());
2703 assert!(err.message().contains("zz-repro.test.txt"), "{err}");
2704
2705 git_at(
2706 &main,
2707 &["worktree", "remove", "--force", linked.to_str().unwrap()],
2708 );
2709 let _ = std::fs::remove_dir_all(&root);
2710 }
2711
2712 #[test]
2715 fn a_read_alongside_another_still_answers_for_what_was_there() {
2716 let (root, main, linked, _marker) = linked_worktree("alongside-existing");
2717 std::fs::write(linked.join("notes.txt"), "before\n").unwrap();
2718 let schema = serde_json::json!({"type": "object"});
2719
2720 for (line, path) in [
2721 ("printf 'rewritten\\n' > README.md; echo '{}'", "README.md"),
2722 ("printf 'after\\n' > notes.txt; echo '{}'", "notes.txt"),
2723 ] {
2724 let agent = Agent::with_bin(shell("reader", line), "/bin/sh");
2725
2726 let err = agent
2727 .ask_json_alongside::<Value>("read it", &schema, &linked, &Effort::default())
2728 .unwrap_err();
2729
2730 assert_eq!(ErrorKind::UncertainWrite, err.kind());
2731 assert!(err.message().contains(path), "{err}");
2732 git_at(&linked, &["checkout", "--", "README.md"]);
2733 for entry in std::fs::read_dir(&linked).unwrap().flatten() {
2734 if entry
2735 .file_name()
2736 .to_string_lossy()
2737 .starts_with(".spar-recovery-needed-")
2738 {
2739 std::fs::remove_file(entry.path()).unwrap();
2740 }
2741 }
2742 }
2743
2744 git_at(
2745 &main,
2746 &["worktree", "remove", "--force", linked.to_str().unwrap()],
2747 );
2748 let _ = std::fs::remove_dir_all(&root);
2749 }
2750
2751 fn behind_repo(name: &str) -> PathBuf {
2755 let dir = committed_repo(name);
2756 git_at(&dir, &["checkout", "-q", "-b", "advanced"]);
2757 std::fs::write(dir.join("README.md"), "moved on\n").unwrap();
2758 std::fs::write(dir.join("CHANGELOG.md"), "new file\n").unwrap();
2759 git_at(&dir, &["add", "README.md", "CHANGELOG.md"]);
2760 git_at(&dir, &["commit", "-q", "-m", "advance"]);
2761 git_at(&dir, &["checkout", "-q", "main"]);
2762 dir
2763 }
2764
2765 #[test]
2766 fn a_read_survives_the_branch_moving_under_it() {
2767 let dir = behind_repo("read-branch-moved");
2768 let agent = Agent::with_bin(
2769 shell("reader", "git merge -q --ff-only advanced; echo reviewed"),
2770 "/bin/sh",
2771 );
2772
2773 let answer = agent.ask("read it", &dir, &Effort::default()).unwrap();
2774
2775 assert_eq!("reviewed", answer.trim());
2776 assert_eq!(
2777 "moved on\n",
2778 std::fs::read_to_string(dir.join("README.md")).unwrap()
2779 );
2780 assert!(
2781 !std::fs::read_dir(&dir).unwrap().flatten().any(|entry| entry
2782 .file_name()
2783 .to_string_lossy()
2784 .starts_with(".spar-recovery-needed-"))
2785 );
2786 let _ = std::fs::remove_dir_all(&dir);
2787 }
2788
2789 #[test]
2790 fn a_read_that_writes_while_the_branch_moves_is_still_discarded() {
2791 let dir = behind_repo("read-branch-moved-and-written");
2792 let agent = Agent::with_bin(
2793 shell(
2794 "reader",
2795 "git merge -q --ff-only advanced; printf 'and mine\n' >> README.md; echo reviewed",
2796 ),
2797 "/bin/sh",
2798 );
2799
2800 let err = agent.ask("read it", &dir, &Effort::default()).unwrap_err();
2801
2802 assert_eq!(ErrorKind::UncertainWrite, err.kind());
2803 assert!(err.message().contains("read-only call"), "{err}");
2804 let _ = std::fs::remove_dir_all(&dir);
2805 }
2806
2807 #[test]
2808 fn a_read_in_a_linked_worktree_still_answers_for_a_moved_head() {
2809 let (root, main, linked, _) = linked_worktree("read-worktree-commit");
2810 let agent = Agent::with_bin(
2811 shell(
2812 "reader",
2813 "printf 'mine\n' > README.md; git add -A; git commit -q -m mine; echo reviewed",
2814 ),
2815 "/bin/sh",
2816 );
2817
2818 let err = agent
2819 .ask("read it", &linked, &Effort::default())
2820 .unwrap_err();
2821
2822 assert_eq!(ErrorKind::UncertainWrite, err.kind());
2823 assert!(err.message().contains("read-only call"), "{err}");
2824 git_at(
2825 &main,
2826 &["worktree", "remove", "--force", linked.to_str().unwrap()],
2827 );
2828 let _ = std::fs::remove_dir_all(&root);
2829 }
2830
2831 fn built_repo(name: &str) -> PathBuf {
2833 let dir = committed_repo(name);
2834 std::fs::write(dir.join(".gitignore"), "dist/\nlocal.env\n").unwrap();
2835 git_at(&dir, &["add", ".gitignore"]);
2836 git_at(&dir, &["commit", "-q", "-m", "ignore build output"]);
2837 std::fs::create_dir_all(dir.join("dist")).unwrap();
2838 std::fs::write(dir.join("dist/index.js"), "first build\n").unwrap();
2839 dir
2840 }
2841
2842 #[test]
2843 fn a_read_that_only_rebuilds_generated_output_keeps_its_answer() {
2844 let dir = built_repo("successful-read-build");
2845 let agent = Agent::with_bin(
2846 shell(
2847 "reader",
2848 "printf 'second build\n' > dist/index.js; printf 'more\n' > dist/extra.js; \
2849 echo reviewed",
2850 ),
2851 "/bin/sh",
2852 );
2853
2854 let answer = agent
2855 .ask("read it", &dir, &Effort::default())
2856 .expect("answer kept");
2857
2858 assert_eq!("reviewed", answer.trim());
2859 assert_eq!(
2860 "second build\n",
2861 std::fs::read_to_string(dir.join("dist/index.js")).unwrap()
2862 );
2863 assert!(
2864 !std::fs::read_dir(&dir).unwrap().flatten().any(|entry| entry
2865 .file_name()
2866 .to_string_lossy()
2867 .starts_with(".spar-recovery-needed-"))
2868 );
2869 let _ = std::fs::remove_dir_all(&dir);
2870 }
2871
2872 #[test]
2876 fn a_read_that_writes_a_new_ignored_file_elsewhere_keeps_its_answer() {
2877 let dir = built_repo("successful-read-ignored");
2878 let agent = Agent::with_bin(
2879 shell(
2880 "reader",
2881 "printf 'second build\n' > dist/index.js; printf 'TOKEN=x\n' > local.env; \
2882 echo reviewed",
2883 ),
2884 "/bin/sh",
2885 );
2886
2887 let answer = agent
2888 .ask("read it", &dir, &Effort::default())
2889 .expect("answer kept");
2890
2891 assert_eq!("reviewed", answer.trim());
2892 assert_eq!(
2893 "TOKEN=x\n",
2894 std::fs::read_to_string(dir.join("local.env")).unwrap()
2895 );
2896 assert!(
2897 !std::fs::read_dir(&dir).unwrap().flatten().any(|entry| entry
2898 .file_name()
2899 .to_string_lossy()
2900 .starts_with(".spar-recovery-needed-"))
2901 );
2902 let _ = std::fs::remove_dir_all(&dir);
2903 }
2904
2905 #[test]
2906 fn a_read_that_rewrites_an_existing_ignored_file_elsewhere_is_kept() {
2907 let dir = built_repo("successful-read-existing-ignored");
2908 std::fs::write(dir.join("local.env"), "TOKEN=before\n").unwrap();
2909 let agent = Agent::with_bin(
2910 shell(
2911 "reader",
2912 "printf 'TOKEN=after\n' > local.env; echo reviewed",
2913 ),
2914 "/bin/sh",
2915 );
2916
2917 let answer = agent
2918 .ask("read it", &dir, &Effort::default())
2919 .expect("answer kept");
2920
2921 assert_eq!("reviewed", answer.trim());
2922 assert_eq!(
2923 "TOKEN=after\n",
2924 std::fs::read_to_string(dir.join("local.env")).unwrap()
2925 );
2926 assert!(
2927 !std::fs::read_dir(&dir).unwrap().flatten().any(|entry| entry
2928 .file_name()
2929 .to_string_lossy()
2930 .starts_with(".spar-recovery-needed-"))
2931 );
2932 let _ = std::fs::remove_dir_all(&dir);
2933 }
2934
2935 #[test]
2936 fn a_read_that_deletes_an_existing_ignored_file_is_kept() {
2937 let dir = built_repo("successful-read-deleted-ignored");
2938 std::fs::write(dir.join("local.env"), "TOKEN=before\n").unwrap();
2939 let agent = Agent::with_bin(shell("reader", "rm local.env; echo reviewed"), "/bin/sh");
2940
2941 let answer = agent
2942 .ask("read it", &dir, &Effort::default())
2943 .expect("answer kept");
2944
2945 assert_eq!("reviewed", answer.trim());
2946 assert!(!dir.join("local.env").exists());
2947 let _ = std::fs::remove_dir_all(&dir);
2948 }
2949
2950 #[test]
2951 fn a_read_that_rewrites_an_existing_ordinary_untracked_file_is_discarded() {
2952 let dir = built_repo("successful-read-existing-ordinary");
2953 std::fs::write(dir.join("notes.md"), "before\n").unwrap();
2954 let agent = Agent::with_bin(
2955 shell("reader", "printf 'after\n' > notes.md; echo reviewed"),
2956 "/bin/sh",
2957 );
2958
2959 let err = agent.ask("read it", &dir, &Effort::default()).unwrap_err();
2960
2961 assert_eq!(ErrorKind::UncertainWrite, err.kind());
2962 assert!(err.message().contains("read-only call"), "{err}");
2963 assert!(err.message().contains("notes.md"), "{err}");
2964 let _ = std::fs::remove_dir_all(&dir);
2965 }
2966
2967 #[test]
2968 fn a_read_that_writes_under_spars_own_state_is_not_a_change() {
2969 let dir = built_repo("successful-read-spar-state");
2970 let agent = Agent::with_bin(
2971 shell(
2972 "reader",
2973 "mkdir -p .spar/state; printf '[]' > .spar/state/spend.json; echo reviewed",
2974 ),
2975 "/bin/sh",
2976 );
2977
2978 let answer = agent
2979 .ask("read it", &dir, &Effort::default())
2980 .expect("answer kept");
2981
2982 assert_eq!("reviewed", answer.trim());
2983 let _ = std::fs::remove_dir_all(&dir);
2984 }
2985
2986 #[test]
2987 fn a_read_that_changes_a_tracked_file_is_still_discarded() {
2988 let dir = built_repo("successful-read-tracked");
2989 let agent = Agent::with_bin(
2990 shell(
2991 "reader",
2992 "printf 'second build\n' > dist/index.js; printf 'recover me\n' > README.md; \
2993 echo reviewed",
2994 ),
2995 "/bin/sh",
2996 );
2997
2998 let err = agent.ask("read it", &dir, &Effort::default()).unwrap_err();
2999
3000 assert_eq!(ErrorKind::UncertainWrite, err.kind());
3001 assert!(err.message().contains("read-only call"), "{err}");
3002 assert!(err.message().contains("README.md"), "{err}");
3003 assert_eq!(
3004 "recover me\n",
3005 std::fs::read_to_string(dir.join("README.md")).unwrap()
3006 );
3007 let _ = std::fs::remove_dir_all(&dir);
3008 }
3009
3010 #[test]
3011 fn a_successful_edit_cannot_replace_the_git_directory() {
3012 let dir = committed_repo("replaced-git-directory");
3013 let agent = Agent::with_bin(
3014 shell("editor", "mv .git .git-original; mkdir .git; echo done"),
3015 "/bin/sh",
3016 );
3017
3018 let err = agent
3019 .edit("change it", &dir, &Effort::default())
3020 .unwrap_err();
3021
3022 assert_eq!(ErrorKind::UncertainWrite, err.kind());
3023 assert!(err.message().contains("Git entry"), "{err}");
3024 assert!(dir.join(".git-original").is_dir());
3025 std::fs::remove_dir(dir.join(".git")).unwrap();
3026 std::fs::rename(dir.join(".git-original"), dir.join(".git")).unwrap();
3027 let _ = std::fs::remove_dir_all(&dir);
3028 }
3029
3030 #[test]
3031 fn a_deleted_linked_worktree_git_marker_is_restored_without_a_fallback() {
3032 let (root, main, linked, marker) = linked_worktree("deleted-git-marker");
3033 let agent = with_fallback(
3034 shell("editor", "rm .git; echo done"),
3035 shell("backup", "printf 'fallback ran\n' > fallback.txt"),
3036 );
3037
3038 let err = agent
3039 .edit("change it", &linked, &Effort::default())
3040 .unwrap_err();
3041
3042 assert_eq!(ErrorKind::UncertainWrite, err.kind());
3043 assert!(err.message().contains("deleted"), "{err}");
3044 assert_eq!(marker, std::fs::read(linked.join(".git")).unwrap());
3045 assert!(!linked.join(GIT_MARKER_RECOVERY).exists());
3046 assert!(!linked.join("fallback.txt").exists());
3047 git_at(
3048 &main,
3049 &["worktree", "remove", "--force", linked.to_str().unwrap()],
3050 );
3051 let _ = std::fs::remove_dir_all(&root);
3052 }
3053
3054 #[test]
3055 fn an_occupied_marker_recovery_path_keeps_the_changed_marker() {
3056 let (root, main, linked, marker) = linked_worktree("occupied-marker-recovery");
3057 let agent = with_fallback(
3058 shell(
3059 "editor",
3060 "mkdir .spar-edited-git-marker; \
3061 printf 'gitdir: /tmp/not-the-repo\n' > .git; echo done",
3062 ),
3063 shell("backup", "printf 'fallback ran\n' > fallback.txt"),
3064 );
3065
3066 let err = agent
3067 .edit("change it", &linked, &Effort::default())
3068 .unwrap_err();
3069
3070 assert_eq!(ErrorKind::UncertainWrite, err.kind());
3071 assert!(err.message().contains("already exists"), "{err}");
3072 assert_eq!(
3073 b"gitdir: /tmp/not-the-repo\n",
3074 std::fs::read(linked.join(".git")).unwrap().as_slice()
3075 );
3076 assert!(linked.join(GIT_MARKER_RECOVERY).is_dir());
3077 assert!(!linked.join("fallback.txt").exists());
3078 std::fs::write(linked.join(".git"), marker).unwrap();
3079 git_at(
3080 &main,
3081 &["worktree", "remove", "--force", linked.to_str().unwrap()],
3082 );
3083 let _ = std::fs::remove_dir_all(&root);
3084 }
3085
3086 #[test]
3087 fn without_a_fallback_the_original_error_is_what_surfaces() {
3088 let agent = Agent::with_bin(shell("primary", "echo refused >&2; exit 1"), "/bin/sh");
3089 let err = agent
3090 .ask("hi", Path::new("."), &Effort::default())
3091 .expect_err("no backup");
3092 assert!(err.message().contains("refused"), "{err}");
3093 }
3094
3095 #[test]
3098 fn both_failing_reports_the_primary_first() {
3099 let agent = with_fallback(
3100 shell("primary", "echo policy refusal >&2; exit 1"),
3101 shell("backup", "echo out of quota >&2; exit 1"),
3102 );
3103 let err = agent
3104 .ask("hi", Path::new("."), &Effort::default())
3105 .expect_err("both fail");
3106 let text = err.message();
3107 let primary_at = text.find("policy refusal").expect("primary reason");
3108 let backup_at = text.find("out of quota").expect("backup reason");
3109 assert!(primary_at < backup_at, "{text}");
3110 assert!(
3111 text.contains("primary") && text.contains("backup"),
3112 "{text}"
3113 );
3114 }
3115
3116 fn attempts(name: &str) -> (PathBuf, String) {
3119 let path = std::env::temp_dir().join(format!("spar-attempts-{name}"));
3120 let _ = std::fs::remove_file(&path);
3121 let line = format!("echo x >> {}", path.display());
3122 (path, line)
3123 }
3124
3125 fn counted(path: &Path) -> usize {
3126 std::fs::read_to_string(path)
3127 .map(|t| t.lines().count())
3128 .unwrap_or(0)
3129 }
3130
3131 #[test]
3136 fn a_cli_that_could_not_answer_is_not_asked_twice_when_there_is_a_stand_in() {
3137 let (path, count) = attempts("refused-with-standin");
3138 let agent = with_fallback(
3139 shell("primary", &format!("{count}; echo refused >&2; exit 1")),
3140 shell("backup", "echo '{}'"),
3141 );
3142 let answer: Value = agent
3143 .ask_json(
3144 "q",
3145 &serde_json::json!({"type": "object"}),
3146 Path::new("."),
3147 &Effort::default(),
3148 )
3149 .expect("the stand in answers");
3150 assert!(answer.is_object());
3151 assert_eq!(1, counted(&path), "the primary was asked more than once");
3152 }
3153
3154 #[test]
3157 fn with_no_stand_in_a_failed_call_is_still_retried() {
3158 let (path, count) = attempts("refused-alone");
3159 let agent = Agent::with_bin(
3160 shell("solo", &format!("{count}; echo refused >&2; exit 1")),
3161 "/bin/sh",
3162 );
3163 let err = agent
3164 .ask_json::<Value>(
3165 "q",
3166 &serde_json::json!({"type": "object"}),
3167 Path::new("."),
3168 &Effort::default(),
3169 )
3170 .expect_err("nothing answers");
3171 assert!(err.message().contains("twice"), "{err}");
3172 assert_eq!(2, counted(&path));
3173 }
3174
3175 #[test]
3179 fn an_unusable_answer_is_still_worth_asking_again() {
3180 let (path, count) = attempts("unparsable");
3181 let agent = with_fallback(
3182 shell("primary", &format!("{count}; echo not json at all")),
3183 shell("backup", "echo '{}'"),
3184 );
3185 let answer: Value = agent
3186 .ask_json(
3187 "q",
3188 &serde_json::json!({"type": "object"}),
3189 Path::new("."),
3190 &Effort::default(),
3191 )
3192 .expect("the stand in answers in the end");
3193 assert!(answer.is_object());
3194 assert_eq!(2, counted(&path), "a shape error is worth one more ask");
3195 }
3196
3197 #[test]
3201 fn a_timeout_still_reaches_the_fallback() {
3202 let mut primary = shell("primary", "sleep 30");
3203 primary.timeout = 1;
3204 let agent = with_fallback(primary, shell("backup", "echo stood in"));
3205 assert_eq!(
3206 "stood in",
3207 agent
3208 .ask("hi", Path::new("."), &Effort::default())
3209 .expect("fallback")
3210 );
3211 }
3212
3213 #[test]
3216 fn the_fallback_is_built_alongside_the_agent() {
3217 let mut primary = shell("primary", "true");
3218 primary.fallback = Some(Box::new(shell("backup", "true")));
3219 let agent = Agent::new(primary);
3220 assert_eq!(Some("backup"), agent.fallback().map(Agent::name));
3221 assert!(Agent::new(shell("solo", "true")).fallback().is_none());
3222 }
3223}
3224
3225#[cfg(test)]
3226mod schema_placeholder_tests {
3227 use super::*;
3228 use crate::config::{OutputMode, SystemVia};
3229
3230 fn spec_with(command: Vec<CommandPart>) -> AgentSpec {
3231 AgentSpec {
3232 name: "claude".into(),
3233 command,
3234 model: None,
3235 effort: None,
3236 effort_schedule: crate::config::EffortSchedule::default(),
3237 output: OutputMode::Text,
3238 message_match: BTreeMap::new(),
3239 message_path: None,
3240 search_paths: vec![],
3241 system_via: SystemVia::Prompt,
3242 timeout: 60,
3243 fallback: None,
3244 models: vec![],
3245 efforts: vec![],
3246 options_note: None,
3247 }
3248 }
3249
3250 fn one(s: &str) -> CommandPart {
3251 CommandPart::One(s.into())
3252 }
3253 fn group(parts: &[&str]) -> CommandPart {
3254 CommandPart::Group(parts.iter().map(|s| s.to_string()).collect())
3255 }
3256
3257 #[test]
3260 fn either_schema_form_counts_as_native_support() {
3261 let inline = Agent::with_bin(
3262 spec_with(vec![one("x"), group(&["--json-schema", "{schema}"])]),
3263 "/b",
3264 );
3265 let byfile = Agent::with_bin(
3266 spec_with(vec![one("x"), group(&["--output-schema", "{schema_file}"])]),
3267 "/b",
3268 );
3269 let neither = Agent::with_bin(spec_with(vec![one("x"), one("{prompt}")]), "/b");
3270 assert!(inline.supports_schema());
3271 assert!(byfile.supports_schema());
3272 assert!(!neither.supports_schema());
3273 }
3274
3275 #[test]
3276 fn the_inline_schema_is_substituted_whole() {
3277 let agent = Agent::with_bin(
3278 spec_with(vec![
3279 one("x"),
3280 group(&["--json-schema", "{schema}"]),
3281 one("{prompt}"),
3282 ]),
3283 "/b",
3284 );
3285 let values = Placeholders {
3286 prompt: Some("review it".into()),
3287 schema: Some(r#"{"type":"object"}"#.into()),
3288 ..Default::default()
3289 };
3290 assert_eq!(
3291 vec!["/b", "--json-schema", r#"{"type":"object"}"#, "review it"],
3292 agent.render(&values).unwrap()
3293 );
3294 }
3295
3296 #[test]
3299 fn the_schema_flag_drops_when_no_schema_is_wanted() {
3300 let agent = Agent::with_bin(
3301 spec_with(vec![
3302 one("x"),
3303 group(&["--json-schema", "{schema}"]),
3304 one("{prompt}"),
3305 ]),
3306 "/b",
3307 );
3308 let values = Placeholders {
3309 prompt: Some("implement it".into()),
3310 ..Default::default()
3311 };
3312 assert_eq!(vec!["/b", "implement it"], agent.render(&values).unwrap());
3313 }
3314
3315 #[test]
3317 fn the_two_schema_placeholders_do_not_collide() {
3318 let agent = Agent::with_bin(
3319 spec_with(vec![one("x"), group(&["--output-schema", "{schema_file}"])]),
3320 "/b",
3321 );
3322 let values = Placeholders {
3323 schema: Some("INLINE".into()),
3324 schema_file: Some("/tmp/s.json".into()),
3325 ..Default::default()
3326 };
3327 assert_eq!(
3328 vec!["/b", "--output-schema", "/tmp/s.json"],
3329 agent.render(&values).unwrap()
3330 );
3331 }
3332
3333 #[test]
3335 fn the_shipped_claude_preset_now_has_native_structured_output() {
3336 let raw = crate::config::load_preset("claude").unwrap();
3337 let table = raw.as_table().cloned().unwrap();
3338 let mut spec: AgentSpec = toml::Value::Table(table).try_into().unwrap();
3339 spec.name = "claude".into();
3340 assert!(
3341 Agent::with_bin(spec, "/b").supports_schema(),
3342 "without this a long review is parsed out of prose and truncates"
3343 );
3344 }
3345}