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> {
1435 const KEYS: [&str; 7] = [
1436 "prompt",
1437 "system",
1438 "model",
1439 "effort",
1440 "cwd",
1441 "schema_file",
1442 "schema",
1443 ];
1444 let mut out = arg.to_string();
1445 for key in KEYS {
1446 let token = format!("{{{key}}}");
1447 if out.contains(&token) {
1448 let value = self.get(key)?;
1449 out = out.replace(&token, value);
1450 }
1451 }
1452 Some(out)
1453 }
1454}
1455
1456fn dig<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
1462 if path.is_empty() {
1463 return None;
1464 }
1465 let mut node = value;
1466 for part in path.split('.') {
1467 node = node.as_object()?.get(part)?;
1468 }
1469 Some(node)
1470}
1471
1472fn matches(event: &Value, wanted: &BTreeMap<String, String>) -> bool {
1473 if wanted.is_empty() {
1474 return false;
1475 }
1476 wanted
1477 .iter()
1478 .all(|(path, expected)| dig(event, path).and_then(Value::as_str) == Some(expected.as_str()))
1479}
1480
1481fn as_text(value: &Value) -> Option<String> {
1482 match value {
1483 Value::String(s) => Some(s.clone()),
1484 Value::Null => None,
1485 other => Some(other.to_string()),
1486 }
1487}
1488
1489fn truncate(text: &str, max: usize) -> String {
1490 text.chars().take(max).collect()
1491}
1492
1493struct TempJson {
1500 path: PathBuf,
1501}
1502
1503impl TempJson {
1504 fn write(value: &Value) -> Result<Self> {
1505 use std::sync::atomic::{AtomicU64, Ordering};
1506 static COUNTER: AtomicU64 = AtomicU64::new(0);
1507
1508 let nanos = std::time::SystemTime::now()
1509 .duration_since(std::time::UNIX_EPOCH)
1510 .map(|d| d.as_nanos())
1511 .unwrap_or(0);
1512 let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
1513 let path = std::env::temp_dir().join(format!(
1514 "spar-schema-{}-{nanos}-{unique}.json",
1515 std::process::id()
1516 ));
1517 std::fs::write(&path, serde_json::to_vec_pretty(value)?)
1518 .map_err(|e| spar_err!("could not write a schema file to {}: {e}", path.display()))?;
1519 Ok(Self { path })
1520 }
1521
1522 fn path(&self) -> &Path {
1523 &self.path
1524 }
1525}
1526
1527impl Drop for TempJson {
1528 fn drop(&mut self) {
1529 let _ = std::fs::remove_file(&self.path);
1530 }
1531}
1532
1533fn same_executable(a: &Path, b: &Path) -> bool {
1544 #[cfg(unix)]
1545 {
1546 use std::os::unix::fs::MetadataExt;
1547 if let (Ok(x), Ok(y)) = (std::fs::metadata(a), std::fs::metadata(b)) {
1548 return x.dev() == y.dev() && x.ino() == y.ino();
1549 }
1550 }
1551 match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
1552 (Ok(x), Ok(y)) => x == y,
1553 _ => a == b,
1554 }
1555}
1556
1557pub fn correlation_warning(agents: &[Agent]) -> Option<String> {
1563 for i in 0..agents.len() {
1564 for j in (i + 1)..agents.len() {
1565 let (a, b) = (&agents[i], &agents[j]);
1566 let (Ok(pa), Ok(pb)) = (a.resolve_bin(), b.resolve_bin()) else {
1567 continue;
1568 };
1569 if !same_executable(pa, pb) || a.spec.model_key() != b.spec.model_key() {
1570 continue;
1571 }
1572 let model = if a.spec.model_key().is_empty() {
1573 "the CLI's default".to_string()
1574 } else {
1575 a.spec.model_key()
1576 };
1577 let where_at = if pa == pb {
1578 pa.display().to_string()
1579 } else {
1580 format!(
1581 "the same executable ({} and {} are the same file)",
1582 pa.display(),
1583 pb.display()
1584 )
1585 };
1586 return Some(format!(
1587 "agents '{}' and '{}' both resolve to {where_at} at model {model}. Review \
1588 findings will be correlated: the same model reviewing itself shares the blind \
1589 spots of the model that wrote the code, so it is far less likely to catch what \
1590 the implementer missed. That produces an approval indistinguishable from a real \
1591 review, which is worse than no review at all. Give the two agents different \
1592 CLIs or different models.",
1593 a.name(),
1594 b.name()
1595 ));
1596 }
1597 }
1598 None
1599}
1600
1601pub fn build(cfg: &crate::config::Config) -> Result<Vec<Agent>> {
1604 let agents: Vec<Agent> = cfg
1605 .agents
1606 .iter()
1607 .cloned()
1608 .map(Agent::new)
1609 .map(|agent| agent.with_instructions(&cfg.loop_cfg.instructions))
1610 .collect();
1611 for agent in &agents {
1612 agent.resolve_bin()?;
1613 if let Some(backup) = agent.fallback() {
1617 if backup.resolve_bin().is_err() {
1618 logwarn!(
1619 "{} has a fallback ({}) that is not installed, so it will not stand in",
1620 agent.name(),
1621 backup.program()
1622 );
1623 }
1624 }
1625 }
1626 Ok(agents)
1627}
1628
1629pub fn find<'a>(agents: &'a [Agent], name: &str) -> Result<&'a Agent> {
1631 agents.iter().find(|a| a.name() == name).ok_or_else(|| {
1632 SparError::new(format!(
1633 "no agent named '{name}' ({})",
1634 agents
1635 .iter()
1636 .map(Agent::name)
1637 .collect::<Vec<_>>()
1638 .join(", ")
1639 ))
1640 })
1641}
1642
1643#[cfg(test)]
1644mod tests {
1645 use super::*;
1646 use crate::config::{OutputMode, SystemVia};
1647
1648 fn spec(command: Vec<CommandPart>) -> AgentSpec {
1649 AgentSpec {
1650 name: "test".into(),
1651 command,
1652 model: None,
1653 effort: None,
1654 effort_schedule: crate::config::EffortSchedule::default(),
1655 output: OutputMode::Text,
1656 message_match: BTreeMap::new(),
1657 message_path: None,
1658 search_paths: vec![],
1659 system_via: SystemVia::Prompt,
1660 timeout: 60,
1661 fallback: None,
1662 models: vec![],
1663 efforts: vec![],
1664 options_note: None,
1665 }
1666 }
1667
1668 fn one(s: &str) -> CommandPart {
1669 CommandPart::One(s.into())
1670 }
1671
1672 fn group(parts: &[&str]) -> CommandPart {
1673 CommandPart::Group(parts.iter().map(|s| s.to_string()).collect())
1674 }
1675
1676 #[test]
1677 fn an_uncertain_process_result_skips_post_call_recovery() {
1678 let called: Result<()> = Err(SparError::uncertain_write("descendants may still write"));
1679 let recovered = std::cell::Cell::new(false);
1680
1681 let error = after_call_is_quiet(&called, || {
1682 recovered.set(true);
1683 Ok(())
1684 })
1685 .unwrap_err();
1686
1687 assert_eq!(ErrorKind::UncertainWrite, error.kind());
1688 assert!(!recovered.get());
1689 }
1690
1691 fn agent(command: Vec<CommandPart>) -> Agent {
1692 Agent::with_bin(spec(command), "/fake/bin")
1693 }
1694
1695 fn values() -> Placeholders {
1696 Placeholders {
1697 prompt: Some("hi".into()),
1698 ..Default::default()
1699 }
1700 }
1701
1702 #[test]
1705 fn placeholders_are_substituted() {
1706 let a = agent(vec![one("x"), group(&["-m", "{model}"]), one("{prompt}")]);
1707 let v = Placeholders {
1708 model: Some("m1".into()),
1709 ..values()
1710 };
1711 assert_eq!(vec!["/fake/bin", "-m", "m1", "hi"], a.render(&v).unwrap());
1712 }
1713
1714 #[test]
1715 fn an_unset_placeholder_drops_the_whole_group() {
1716 let a = agent(vec![one("x"), group(&["-m", "{model}"]), one("{prompt}")]);
1717 assert_eq!(vec!["/fake/bin", "hi"], a.render(&values()).unwrap());
1718 }
1719
1720 #[test]
1721 fn an_empty_string_drops_the_group_too() {
1722 let a = agent(vec![one("x"), group(&["-e", "{effort}"]), one("{prompt}")]);
1723 let v = Placeholders {
1724 effort: Some(String::new()),
1725 ..values()
1726 };
1727 assert_eq!(vec!["/fake/bin", "hi"], a.render(&v).unwrap());
1728 }
1729
1730 #[test]
1731 fn a_bare_arg_with_an_unset_placeholder_drops() {
1732 let a = agent(vec![one("x"), one("{model}"), one("{prompt}")]);
1733 assert_eq!(vec!["/fake/bin", "hi"], a.render(&values()).unwrap());
1734 }
1735
1736 #[test]
1737 fn literal_args_survive() {
1738 let a = agent(vec![
1739 one("x"),
1740 one("exec"),
1741 one("--json"),
1742 one("--"),
1743 one("{prompt}"),
1744 ]);
1745 assert_eq!(
1746 vec!["/fake/bin", "exec", "--json", "--", "hi"],
1747 a.render(&values()).unwrap()
1748 );
1749 }
1750
1751 #[test]
1752 fn an_embedded_placeholder_substitutes_in_place() {
1753 let a = agent(vec![
1754 one("x"),
1755 group(&["-c", "model_reasoning_effort={effort}"]),
1756 ]);
1757 let v = Placeholders {
1758 effort: Some("ultra".into()),
1759 ..Default::default()
1760 };
1761 assert_eq!(
1762 vec!["/fake/bin", "-c", "model_reasoning_effort=ultra"],
1763 a.render(&v).unwrap()
1764 );
1765 }
1766
1767 #[test]
1768 fn a_group_with_two_placeholders_needs_both() {
1769 let a = agent(vec![
1770 one("x"),
1771 group(&["--a", "{model}", "--b", "{effort}"]),
1772 ]);
1773 let v = Placeholders {
1774 model: Some("m".into()),
1775 ..Default::default()
1776 };
1777 assert_eq!(vec!["/fake/bin"], a.render(&v).unwrap());
1778 }
1779
1780 #[test]
1781 fn supports_schema_detects_the_placeholder() {
1782 assert!(agent(vec![one("x"), group(&["--schema", "{schema_file}"])]).supports_schema());
1783 assert!(!agent(vec![one("x"), one("{prompt}")]).supports_schema());
1784 }
1785
1786 #[test]
1789 fn text_passes_through_trimmed() {
1790 assert_eq!("hello", agent(vec![one("x")]).extract(" hello\n").unwrap());
1791 }
1792
1793 #[test]
1794 fn jsonl_picks_the_matching_event() {
1795 let mut spec = spec(vec![one("x")]);
1796 spec.output = OutputMode::Jsonl;
1797 spec.message_path = Some("item.text".into());
1798 spec.message_match = BTreeMap::from([
1799 ("type".to_string(), "item.completed".to_string()),
1800 ("item.type".to_string(), "agent_message".to_string()),
1801 ]);
1802 let a = Agent::with_bin(spec, "/fake/bin");
1803 let stream = [
1804 r#"{"type":"thread.started","thread_id":"t1"}"#,
1805 r#"{"type":"item.completed","item":{"type":"command_execution","text":"ls"}}"#,
1806 r#"{"type":"item.completed","item":{"type":"agent_message","text":"the answer"}}"#,
1807 "not json at all",
1808 ]
1809 .join("\n");
1810 assert_eq!("the answer", a.extract(&stream).unwrap());
1811 }
1812
1813 #[test]
1814 fn jsonl_raises_on_an_error_with_no_message() {
1815 let mut spec = spec(vec![one("x")]);
1816 spec.output = OutputMode::Jsonl;
1817 spec.message_path = Some("item.text".into());
1818 spec.message_match = BTreeMap::from([("type".into(), "item.completed".into())]);
1819 let a = Agent::with_bin(spec, "/fake/bin");
1820 assert!(a
1821 .extract(r#"{"type":"turn.failed","error":"boom"}"#)
1822 .is_err());
1823 }
1824
1825 #[test]
1826 fn jsonl_joins_several_agent_messages() {
1827 let mut spec = spec(vec![one("x")]);
1828 spec.output = OutputMode::Jsonl;
1829 spec.message_path = Some("text".into());
1830 spec.message_match = BTreeMap::from([("type".into(), "msg".into())]);
1831 let a = Agent::with_bin(spec, "/fake/bin");
1832 let stream = "{\"type\":\"msg\",\"text\":\"one\"}\n{\"type\":\"msg\",\"text\":\"two\"}";
1833 assert_eq!("one\ntwo", a.extract(stream).unwrap());
1834 }
1835
1836 #[test]
1837 fn dig_walks_a_dotted_path() {
1838 let v: Value = serde_json::from_str(r#"{"a":{"b":{"c":1}}}"#).unwrap();
1839 assert_eq!(Some(&Value::from(1)), dig(&v, "a.b.c"));
1840 assert_eq!(None, dig(&v, "a.b.missing"));
1841 assert_eq!(None, dig(&v, ""));
1842 }
1843
1844 #[test]
1847 fn a_missing_binary_lists_everywhere_it_looked() {
1848 let mut s = spec(vec![one("definitely-not-installed-xyz")]);
1849 s.search_paths = vec!["/nowhere/at/all".into()];
1850 s.name = "codex".into();
1851 let err = Agent::new(s).resolve_bin().unwrap_err().to_string();
1852 assert!(err.contains("definitely-not-installed-xyz (PATH)"), "{err}");
1853 assert!(
1854 err.contains("/nowhere/at/all/definitely-not-installed-xyz"),
1855 "{err}"
1856 );
1857 assert!(err.contains("SPAR_CODEX_BIN"), "{err}");
1858 }
1859
1860 #[test]
1861 fn a_search_path_that_already_names_the_binary_is_used_as_is() {
1862 let dir = std::env::temp_dir().join(format!("spar-test-{}", std::process::id()));
1863 std::fs::create_dir_all(&dir).unwrap();
1864 let bin = dir.join("mytool");
1865 std::fs::write(&bin, "#!/bin/sh\n").unwrap();
1866 #[cfg(unix)]
1867 {
1868 use std::os::unix::fs::PermissionsExt;
1869 std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
1870 }
1871 let mut s = spec(vec![one("mytool")]);
1872 s.search_paths = vec![bin.display().to_string()];
1873 assert_eq!(bin, Agent::new(s).resolve_bin().unwrap());
1874 let _ = std::fs::remove_dir_all(&dir);
1875 }
1876
1877 fn named(name: &str, bin: &str, model: Option<&str>) -> Agent {
1880 let mut s = spec(vec![one("prog")]);
1881 s.name = name.into();
1882 s.model = model.map(str::to_string);
1883 Agent::with_bin(s, bin)
1884 }
1885
1886 #[test]
1887 fn same_bin_same_model_warns() {
1888 let agents = vec![
1889 named("alpha", "/usr/local/bin/claude", Some("fable")),
1890 named("beta", "/usr/local/bin/claude", Some("fable")),
1891 ];
1892 let msg = correlation_warning(&agents).expect("should warn");
1893 assert!(msg.contains("alpha") && msg.contains("beta"), "{msg}");
1894 }
1895
1896 #[test]
1897 fn different_model_does_not_warn() {
1898 let agents = vec![
1899 named("a", "/usr/local/bin/claude", Some("fable")),
1900 named("b", "/usr/local/bin/claude", Some("opus")),
1901 ];
1902 assert!(correlation_warning(&agents).is_none());
1903 }
1904
1905 #[test]
1906 fn different_bin_does_not_warn() {
1907 let agents = vec![
1908 named("a", "/usr/local/bin/claude", Some("fable")),
1909 named("b", "/usr/local/bin/codex", Some("fable")),
1910 ];
1911 assert!(correlation_warning(&agents).is_none());
1912 }
1913
1914 #[test]
1915 fn unset_and_empty_model_both_mean_the_default_and_warn() {
1916 let agents = vec![
1917 named("a", "/usr/local/bin/claude", None),
1918 named("b", "/usr/local/bin/claude", Some("")),
1919 ];
1920 let msg = correlation_warning(&agents).expect("should warn");
1921 assert!(msg.contains("the CLI's default"), "{msg}");
1922 }
1923
1924 #[test]
1925 fn a_padded_model_still_warns() {
1926 let agents = vec![
1927 named("a", "/usr/local/bin/claude", Some("fable")),
1928 named("b", "/usr/local/bin/claude", Some(" fable ")),
1929 ];
1930 assert!(correlation_warning(&agents).is_some());
1931 }
1932
1933 #[test]
1934 fn an_empty_model_against_a_named_one_does_not_warn() {
1935 let agents = vec![
1936 named("a", "/usr/local/bin/claude", Some("")),
1937 named("b", "/usr/local/bin/claude", Some("fable")),
1938 ];
1939 assert!(correlation_warning(&agents).is_none());
1940 }
1941
1942 #[cfg(unix)]
1943 #[test]
1944 fn a_symlinked_binary_warns_and_names_both_paths() {
1945 use std::os::unix::fs::PermissionsExt;
1946 let dir = std::env::temp_dir().join(format!("spar-link-{}", std::process::id()));
1947 let _ = std::fs::remove_dir_all(&dir);
1948 std::fs::create_dir_all(&dir).unwrap();
1949 let real = dir.join("claude");
1950 let link = dir.join("claude-alias");
1951 std::fs::write(&real, "#!/bin/sh\n").unwrap();
1952 std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o755)).unwrap();
1953 std::os::unix::fs::symlink(&real, &link).unwrap();
1954
1955 let agents = vec![
1956 named("alpha", real.to_str().unwrap(), Some("fable")),
1957 named("beta", link.to_str().unwrap(), Some("fable")),
1958 ];
1959 let msg = correlation_warning(&agents).expect("should warn");
1960 assert!(msg.contains(real.to_str().unwrap()), "{msg}");
1961 assert!(msg.contains(link.to_str().unwrap()), "{msg}");
1962 let _ = std::fs::remove_dir_all(&dir);
1963 }
1964
1965 #[cfg(unix)]
1966 #[test]
1967 fn two_distinct_real_binaries_stay_quiet() {
1968 use std::os::unix::fs::PermissionsExt;
1969 let dir = std::env::temp_dir().join(format!("spar-distinct-{}", std::process::id()));
1970 let _ = std::fs::remove_dir_all(&dir);
1971 std::fs::create_dir_all(&dir).unwrap();
1972 let mut paths = Vec::new();
1973 for name in ["claude", "codex"] {
1974 let path = dir.join(name);
1975 std::fs::write(&path, "#!/bin/sh\n").unwrap();
1976 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
1977 paths.push(path);
1978 }
1979 let agents = vec![
1980 named("a", paths[0].to_str().unwrap(), Some("fable")),
1981 named("b", paths[1].to_str().unwrap(), Some("fable")),
1982 ];
1983 assert!(correlation_warning(&agents).is_none());
1984 let _ = std::fs::remove_dir_all(&dir);
1985 }
1986
1987 #[test]
1988 fn the_style_rules_ask_for_brevity_and_no_attribution() {
1989 let lower = STYLE_RULES.to_lowercase();
1990 assert!(lower.contains("brief"));
1991 assert!(lower.contains("co-authored-by"));
1992 assert!(lower.contains("em-dash"));
1993 }
1994
1995 #[test]
2000 fn brevity_is_about_facts_per_sentence_not_sentence_count() {
2001 let lower = STYLE_RULES.to_lowercase();
2002 assert!(lower.contains("saying fewer things"), "{STYLE_RULES}");
2003 assert!(
2004 !lower.contains("one sentence beats one paragraph"),
2005 "the rule that produced the density is still there"
2006 );
2007 }
2008
2009 #[test]
2014 fn the_style_rules_reach_the_code_and_not_only_what_is_posted() {
2015 let lower = STYLE_RULES.to_lowercase();
2016 assert!(lower.contains("comments in code you write"), "not in scope");
2017 assert!(
2018 lower.contains("comment code for the reason"),
2019 "no rule for it"
2020 );
2021 }
2022
2023 #[test]
2024 fn the_deadline_names_the_moment_and_how_long_the_call_has() {
2025 let rule = deadline_rule(3600, 1_792_892_673);
2027 assert!(rule.contains("stopped at 2026-10-25 02:44 UTC"), "{rule}");
2028 assert!(rule.contains("60m00s after it started"), "{rule}");
2029 assert!(rule.contains("tests that cover the change"), "{rule}");
2030 assert!(!rule.contains(['\u{2013}', '\u{2014}']), "{rule}");
2031 }
2032
2033 #[test]
2036 fn the_deadline_reaches_the_cli_whichever_way_it_takes_the_system_text() {
2037 let echo = |what: &str, via: SystemVia, timeout: u64| {
2038 let mut spec = spec(vec![
2039 one("sh"),
2040 one("-c"),
2041 one("printf %s \"$1\""),
2042 one("sh"),
2043 one(what),
2044 ]);
2045 spec.system_via = via;
2046 spec.timeout = timeout;
2047 Agent::with_bin(spec, "/bin/sh")
2048 .ask("the prompt", Path::new("."), &Effort::default())
2049 .unwrap()
2050 };
2051
2052 let prompted = echo("{prompt}", SystemVia::Prompt, 60);
2053 assert!(prompted.starts_with("Style rules"), "{prompted}");
2054 assert!(prompted.contains("60s after it started"), "{prompted}");
2055 assert!(prompted.ends_with("the prompt"), "{prompted}");
2056
2057 let placed = echo("{system}", SystemVia::Placeholder, 1800);
2058 assert!(placed.starts_with("Style rules"), "{placed}");
2059 assert!(placed.contains("30m00s after it started"), "{placed}");
2060 }
2061
2062 fn refusal_stream() -> String {
2067 let noise = "{\"type\":\"item.completed\",\"item\":{\"id\":\"i\",\"type\":\"command_execution\",\"output\":\"".to_string()
2068 + &"const x = 1;\\n".repeat(200)
2069 + "\"}}";
2070 [
2071 noise.as_str(),
2072 r#"{"type":"error","message":"This content was flagged for possible cybersecurity risk."}"#,
2073 r#"{"type":"error","message":"This content was flagged for possible cybersecurity risk."}"#,
2074 r#"{"type":"turn.failed","error":{"message":"This content was flagged for possible cybersecurity risk."}}"#,
2075 ]
2076 .join("\n")
2077 }
2078
2079 fn jsonl_agent(name: &str) -> Agent {
2080 let mut spec = spec(vec![one("codex")]);
2081 spec.name = name.into();
2082 spec.output = OutputMode::Jsonl;
2083 spec.message_path = Some("item.text".into());
2084 Agent::with_bin(spec, "/fake/codex")
2085 }
2086
2087 fn failed(stdout: &str, stderr: &str) -> proc::Output {
2088 proc::Output {
2089 stdout: stdout.to_string(),
2090 stdout_bytes: stdout.as_bytes().to_vec(),
2091 stderr: stderr.to_string(),
2092 code: 1,
2093 }
2094 }
2095
2096 #[test]
2099 fn a_jsonl_failure_reports_the_reason_and_not_the_stream() {
2100 let agent = jsonl_agent("codex");
2101 let err = agent.call_failure(&["codex".to_string()], &failed(&refusal_stream(), ""));
2102 let text = err.message();
2103 assert!(
2104 text.contains("flagged for possible cybersecurity risk"),
2105 "{text}"
2106 );
2107 assert!(
2108 !text.contains("const x = 1;"),
2109 "the stream leaked in:\n{text}"
2110 );
2111 assert!(text.len() < 400, "still {} characters:\n{text}", text.len());
2112 }
2113
2114 #[test]
2116 fn the_same_reason_reported_three_times_is_said_once() {
2117 let agent = jsonl_agent("codex");
2118 let err = agent.call_failure(&["codex".to_string()], &failed(&refusal_stream(), ""));
2119 assert_eq!(
2120 1,
2121 err.message().matches("flagged for possible").count(),
2122 "{}",
2123 err.message()
2124 );
2125 }
2126
2127 #[test]
2130 fn stderr_is_kept_because_it_is_where_the_other_half_arrives() {
2131 let agent = jsonl_agent("codex");
2132 let err = agent.call_failure(
2133 &["codex".to_string()],
2134 &failed(
2135 &refusal_stream(),
2136 "ERROR router: agent thread limit reached",
2137 ),
2138 );
2139 assert!(
2140 err.message().contains("agent thread limit reached"),
2141 "{}",
2142 err.message()
2143 );
2144 }
2145
2146 #[test]
2149 fn a_stream_with_no_error_event_falls_back_to_the_raw_output() {
2150 let agent = jsonl_agent("codex");
2151 let err = agent.call_failure(
2152 &["codex".to_string()],
2153 &failed("{\"type\":\"system\"}", "segmentation fault"),
2154 );
2155 assert!(
2156 err.message().contains("segmentation fault"),
2157 "{}",
2158 err.message()
2159 );
2160 assert!(
2161 err.message().starts_with("command failed"),
2162 "{}",
2163 err.message()
2164 );
2165 }
2166
2167 #[test]
2169 fn a_text_agent_is_reported_exactly_as_before() {
2170 let agent = agent(vec![one("mytool")]);
2171 let err = agent.call_failure(&["mytool".to_string()], &failed("some prose", "boom"));
2172 assert!(
2173 err.message().starts_with("command failed"),
2174 "{}",
2175 err.message()
2176 );
2177 assert!(err.message().contains("some prose"), "{}", err.message());
2178 }
2179
2180 #[test]
2183 fn a_reworded_failure_is_still_a_failed_call() {
2184 let agent = jsonl_agent("codex");
2185 let err = agent.call_failure(&["codex".to_string()], &failed(&refusal_stream(), ""));
2186 assert_eq!(ErrorKind::CallFailed, err.kind());
2187 }
2188
2189 #[test]
2192 fn a_request_carries_the_instructions_after_the_task() {
2193 let agent = Agent::with_bin(shell("a", "true"), "/bin/sh")
2194 .with_instructions("Do not wait for CI. Pick it up next pass.");
2195 let asked = agent.instructed("Review the changes on this branch.");
2196 assert!(
2197 asked.starts_with("Review the changes on this branch."),
2198 "{asked}"
2199 );
2200 assert!(asked.contains("Do not wait for CI"), "{asked}");
2201 }
2202
2203 #[test]
2207 fn the_instructions_arrive_subordinate_to_the_request() {
2208 let agent = Agent::with_bin(shell("a", "true"), "/bin/sh").with_instructions("Be quick.");
2209 let asked = agent.instructed("Do the work.").to_lowercase();
2210 assert!(
2211 asked.contains("from the person who started this run"),
2212 "{asked}"
2213 );
2214 assert!(asked.contains("not the shape of your answer"), "{asked}");
2215 }
2216
2217 #[test]
2218 fn nothing_is_added_when_there_are_none() {
2219 let agent = Agent::with_bin(shell("a", "true"), "/bin/sh");
2220 assert_eq!("Do the work.", agent.instructed("Do the work."));
2221 let blank = Agent::with_bin(shell("b", "true"), "/bin/sh").with_instructions(" \n ");
2223 assert_eq!("Do the work.", blank.instructed("Do the work."));
2224 }
2225
2226 #[test]
2229 fn the_stand_in_carries_them_too() {
2230 let agent = with_fallback(shell("primary", "true"), shell("backup", "true"))
2231 .with_instructions("Do not wait for CI.");
2232 let backup = agent.fallback().expect("a stand in");
2233 assert!(backup
2234 .instructed("Do the work.")
2235 .contains("Do not wait for CI."));
2236 }
2237
2238 fn shell(name: &str, line: &str) -> AgentSpec {
2243 let mut spec = spec(vec![one("sh"), one("-c"), one(line)]);
2244 spec.name = name.into();
2245 spec
2246 }
2247
2248 fn with_fallback(mut primary: AgentSpec, backup: AgentSpec) -> Agent {
2249 primary.fallback = Some(Box::new(backup));
2250 Agent::with_bin(primary, "/bin/sh")
2251 }
2252
2253 fn git_at(cwd: &Path, args: &[&str]) -> String {
2254 let out = std::process::Command::new("git")
2255 .args(args)
2256 .current_dir(cwd)
2257 .output()
2258 .unwrap();
2259 assert!(
2260 out.status.success(),
2261 "{}",
2262 String::from_utf8_lossy(&out.stderr)
2263 );
2264 String::from_utf8_lossy(&out.stdout).trim().to_string()
2265 }
2266
2267 fn committed_repo(name: &str) -> PathBuf {
2268 let dir = std::env::temp_dir().join(format!(
2269 "spar-agent-{name}-{}-{}",
2270 std::process::id(),
2271 std::thread::current().name().unwrap_or("test")
2272 ));
2273 let _ = std::fs::remove_dir_all(&dir);
2274 std::fs::create_dir_all(&dir).unwrap();
2275 git_at(&dir, &["init", "-q", "-b", "main"]);
2276 git_at(&dir, &["config", "user.email", "spar@example.invalid"]);
2277 git_at(&dir, &["config", "user.name", "spar test"]);
2278 git_at(&dir, &["config", "commit.gpgsign", "false"]);
2279 std::fs::write(dir.join("README.md"), "seed\n").unwrap();
2280 git_at(&dir, &["add", "README.md"]);
2281 git_at(&dir, &["commit", "-q", "-m", "seed"]);
2282 dir
2283 }
2284
2285 fn linked_worktree(name: &str) -> (PathBuf, PathBuf, PathBuf, Vec<u8>) {
2286 let root = std::env::temp_dir().join(format!(
2287 "spar-agent-{name}-{}-{}",
2288 std::process::id(),
2289 std::thread::current().name().unwrap_or("test")
2290 ));
2291 let _ = std::fs::remove_dir_all(&root);
2292 let main = root.join("main");
2293 std::fs::create_dir_all(&main).unwrap();
2294 git_at(&main, &["init", "-q", "-b", "main"]);
2295 git_at(&main, &["config", "user.email", "spar@example.invalid"]);
2296 git_at(&main, &["config", "user.name", "spar test"]);
2297 git_at(&main, &["config", "commit.gpgsign", "false"]);
2298 std::fs::write(main.join("README.md"), "seed\n").unwrap();
2299 git_at(&main, &["add", "README.md"]);
2300 git_at(&main, &["commit", "-q", "-m", "seed"]);
2301 let linked = root.join("linked");
2302 git_at(
2303 &main,
2304 &[
2305 "worktree",
2306 "add",
2307 "-q",
2308 "-b",
2309 "issue-test",
2310 linked.to_str().unwrap(),
2311 ],
2312 );
2313 let marker = std::fs::read(linked.join(".git")).unwrap();
2314 (root, main, linked, marker)
2315 }
2316
2317 #[test]
2318 fn a_failed_call_is_answered_by_the_fallback() {
2319 let agent = with_fallback(
2320 shell("primary", "echo refused >&2; exit 1"),
2321 shell("backup", "echo stood in"),
2322 );
2323 let answer = agent
2324 .ask("hi", Path::new("."), &Effort::default())
2325 .expect("fallback");
2326 assert_eq!("stood in", answer);
2327 }
2328
2329 #[test]
2330 fn a_failed_edit_with_files_left_does_not_run_the_fallback() {
2331 let dir = committed_repo("dirty-edit-recovery");
2332
2333 let agent = with_fallback(
2334 shell(
2335 "primary",
2336 "printf 'recover me\\n' > README.md; printf refused >&2; exit 1",
2337 ),
2338 shell("backup", "printf 'fallback ran\\n' > fallback.txt"),
2339 );
2340 let err = agent
2341 .edit("change it", &dir, &Effort::default())
2342 .unwrap_err();
2343
2344 assert!(err.message().contains("refused"), "{err}");
2345 assert_eq!(
2346 1,
2347 err.message()
2348 .matches("The call changed the worktree before it failed")
2349 .count(),
2350 "{err}"
2351 );
2352 assert_eq!(
2353 "recover me\n",
2354 std::fs::read_to_string(dir.join("README.md")).unwrap()
2355 );
2356 assert!(!dir.join("fallback.txt").exists());
2357 let _ = std::fs::remove_dir_all(&dir);
2358 }
2359
2360 #[test]
2361 fn a_failed_edit_with_a_new_ignored_file_does_not_run_the_fallback() {
2362 let dir = committed_repo("ignored-edit-recovery");
2363 std::fs::write(dir.join(".gitignore"), "ignored.txt\n").unwrap();
2364 git_at(&dir, &["add", ".gitignore"]);
2365 git_at(&dir, &["commit", "-q", "-m", "ignore fixture"]);
2366 let agent = with_fallback(
2367 shell(
2368 "primary",
2369 "printf 'recover me\n' > ignored.txt; printf refused >&2; exit 1",
2370 ),
2371 shell("backup", "printf 'fallback ran\n' > fallback.txt"),
2372 );
2373
2374 let err = agent
2375 .edit("change it", &dir, &Effort::default())
2376 .unwrap_err();
2377
2378 assert!(err.message().contains("refused"), "{err}");
2379 assert_eq!(
2380 "recover me\n",
2381 std::fs::read_to_string(dir.join("ignored.txt")).unwrap()
2382 );
2383 assert!(!dir.join("fallback.txt").exists());
2384 let _ = std::fs::remove_dir_all(&dir);
2385 }
2386
2387 #[test]
2388 fn a_failed_edit_that_overwrites_an_ignored_file_does_not_run_the_fallback() {
2389 let dir = committed_repo("changed-ignored-edit-recovery");
2390 std::fs::write(dir.join(".gitignore"), "ignored.txt\n").unwrap();
2391 git_at(&dir, &["add", ".gitignore"]);
2392 git_at(&dir, &["commit", "-q", "-m", "ignore fixture"]);
2393 std::fs::write(dir.join("ignored.txt"), "before\n").unwrap();
2394 let agent = with_fallback(
2395 shell(
2396 "primary",
2397 "printf 'after!\\n' > ignored.txt; printf refused >&2; exit 1",
2398 ),
2399 shell("backup", "printf 'fallback ran\n' > fallback.txt"),
2400 );
2401
2402 let err = agent
2403 .edit("change it", &dir, &Effort::default())
2404 .unwrap_err();
2405
2406 assert!(err.message().contains("refused"), "{err}");
2407 assert_eq!(
2408 "after!\n",
2409 std::fs::read_to_string(dir.join("ignored.txt")).unwrap()
2410 );
2411 assert!(!dir.join("fallback.txt").exists());
2412 let _ = std::fs::remove_dir_all(&dir);
2413 }
2414
2415 #[test]
2416 fn a_failed_edit_with_a_commit_does_not_run_the_fallback() {
2417 let dir = committed_repo("committed-edit-recovery");
2418 let agent = with_fallback(
2419 shell(
2420 "primary",
2421 "printf 'recover me\n' > README.md; git add README.md; \
2422 git commit -q -m preserved; printf refused >&2; exit 1",
2423 ),
2424 shell("backup", "printf 'fallback ran\n' > fallback.txt"),
2425 );
2426
2427 let err = agent
2428 .edit("change it", &dir, &Effort::default())
2429 .unwrap_err();
2430
2431 assert!(err.message().contains("refused"), "{err}");
2432 assert_eq!("preserved", git_at(&dir, &["log", "-1", "--pretty=%s"]));
2433 assert_eq!(
2434 "recover me\n",
2435 std::fs::read_to_string(dir.join("README.md")).unwrap()
2436 );
2437 assert!(!dir.join("fallback.txt").exists());
2438 let _ = std::fs::remove_dir_all(&dir);
2439 }
2440
2441 #[test]
2442 fn a_committed_edit_with_malformed_json_is_not_retried_or_handed_over() {
2443 let dir = committed_repo("committed-malformed-edit");
2444 let agent = with_fallback(
2445 shell(
2446 "primary",
2447 "printf 'once\n' >> attempts.txt; printf 'recover me\n' > README.md; \
2448 git add -A; git commit -q -m preserved; printf 'not json\n'",
2449 ),
2450 shell(
2451 "backup",
2452 "printf 'fallback ran\n' > fallback.txt; printf '{}\n'",
2453 ),
2454 );
2455
2456 let err = agent
2457 .edit_json::<Value>(
2458 "change it",
2459 &serde_json::json!({"type": "object"}),
2460 &dir,
2461 &Effort::default(),
2462 )
2463 .unwrap_err();
2464
2465 assert!(err.message().contains("JSON"), "{err}");
2466 assert_eq!("preserved", git_at(&dir, &["log", "-1", "--pretty=%s"]));
2467 assert_eq!(
2468 1,
2469 std::fs::read_to_string(dir.join("attempts.txt"))
2470 .unwrap()
2471 .lines()
2472 .count()
2473 );
2474 assert!(!dir.join("fallback.txt").exists());
2475 let _ = std::fs::remove_dir_all(&dir);
2476 }
2477
2478 #[test]
2479 fn a_failed_edit_recovery_probe_does_not_retry_or_run_the_fallback() {
2480 let dir = committed_repo("failed-edit-probe");
2481 let agent = with_fallback(
2482 shell(
2483 "primary",
2484 "printf 'once\n' >> attempts.txt; mv .git .git-away; printf 'not json\n'",
2485 ),
2486 shell(
2487 "backup",
2488 "printf 'fallback ran\n' > fallback.txt; printf '{}\n'",
2489 ),
2490 );
2491
2492 let err = agent
2493 .edit_json::<Value>(
2494 "change it",
2495 &serde_json::json!({"type": "object"}),
2496 &dir,
2497 &Effort::default(),
2498 )
2499 .unwrap_err();
2500
2501 std::fs::rename(dir.join(".git-away"), dir.join(".git")).unwrap();
2502 assert_eq!(ErrorKind::UncertainWrite, err.kind());
2503 assert!(err.message().contains("JSON"), "{err}");
2504 assert_eq!(
2505 1,
2506 std::fs::read_to_string(dir.join("attempts.txt"))
2507 .unwrap()
2508 .lines()
2509 .count()
2510 );
2511 assert!(!dir.join("fallback.txt").exists());
2512 assert!(dir.join(".git").is_dir());
2513 let _ = std::fs::remove_dir_all(&dir);
2514 }
2515
2516 #[test]
2517 fn a_linked_worktree_git_marker_is_restored_after_an_edit() {
2518 let (root, main, linked, marker) = linked_worktree("changed-git-marker");
2519 let agent = Agent::with_bin(
2520 shell(
2521 "editor",
2522 "printf 'gitdir: /tmp/not-the-repo\\n' > .git; echo done",
2523 ),
2524 "/bin/sh",
2525 );
2526
2527 let err = agent
2528 .edit("change it", &linked, &Effort::default())
2529 .unwrap_err();
2530
2531 assert_eq!(ErrorKind::UncertainWrite, err.kind());
2532 assert!(err.message().contains("Git marker"), "{err}");
2533 assert_eq!(marker, std::fs::read(linked.join(".git")).unwrap());
2534 assert_eq!(
2535 b"gitdir: /tmp/not-the-repo\n",
2536 std::fs::read(linked.join(GIT_MARKER_RECOVERY))
2537 .unwrap()
2538 .as_slice()
2539 );
2540 git_at(
2541 &main,
2542 &["worktree", "remove", "--force", linked.to_str().unwrap()],
2543 );
2544 let _ = std::fs::remove_dir_all(&root);
2545 }
2546
2547 #[test]
2548 fn a_read_call_also_restores_a_linked_worktree_git_marker() {
2549 let (root, main, linked, marker) = linked_worktree("read-call-git-marker");
2550 let agent = with_fallback(
2551 shell(
2552 "reader",
2553 "printf 'gitdir: /tmp/not-the-repo\n' > .git; echo done",
2554 ),
2555 shell("backup", "printf 'fallback ran\n' > fallback.txt"),
2556 );
2557
2558 let err = agent
2559 .ask("read it", &linked, &Effort::default())
2560 .unwrap_err();
2561
2562 assert_eq!(ErrorKind::UncertainWrite, err.kind());
2563 assert_eq!(marker, std::fs::read(linked.join(".git")).unwrap());
2564 assert!(!linked.join("fallback.txt").exists());
2565 git_at(
2566 &main,
2567 &["worktree", "remove", "--force", linked.to_str().unwrap()],
2568 );
2569 let _ = std::fs::remove_dir_all(&root);
2570 }
2571
2572 #[test]
2573 fn a_successful_read_that_writes_is_discarded() {
2574 let dir = committed_repo("successful-read-write");
2575 let agent = Agent::with_bin(
2576 shell("reader", "printf 'recover me\n' > README.md; echo reviewed"),
2577 "/bin/sh",
2578 );
2579
2580 let err = agent.ask("read it", &dir, &Effort::default()).unwrap_err();
2581
2582 assert_eq!(ErrorKind::UncertainWrite, err.kind());
2583 assert!(err.message().contains("read-only call"), "{err}");
2584 assert_eq!(
2585 "recover me\n",
2586 std::fs::read_to_string(dir.join("README.md")).unwrap()
2587 );
2588 assert!(std::fs::read_dir(&dir).unwrap().flatten().any(|entry| entry
2589 .file_name()
2590 .to_string_lossy()
2591 .starts_with(".spar-recovery-needed-")));
2592 let _ = std::fs::remove_dir_all(&dir);
2593 }
2594
2595 fn marked(dir: &Path) -> bool {
2596 std::fs::read_dir(dir).unwrap().flatten().any(|entry| {
2597 entry
2598 .file_name()
2599 .to_string_lossy()
2600 .starts_with(".spar-recovery-needed-")
2601 })
2602 }
2603
2604 #[test]
2608 fn a_read_alongside_another_is_not_judged_on_a_new_file() {
2609 let (root, main, linked, _marker) = linked_worktree("alongside-new-file");
2610 let agent = Agent::with_bin(
2611 shell("reader", "printf 'repro\\n' > zz-repro.test.txt; echo '{}'"),
2612 "/bin/sh",
2613 );
2614 let schema = serde_json::json!({"type": "object"});
2615
2616 let answer: Value = agent
2617 .ask_json_alongside("read it", &schema, &linked, &Effort::default())
2618 .expect("the answer stands");
2619
2620 assert!(answer.is_object());
2621 assert!(!marked(&linked));
2622 assert_eq!(
2623 "repro\n",
2624 std::fs::read_to_string(linked.join("zz-repro.test.txt")).unwrap()
2625 );
2626
2627 std::fs::remove_file(linked.join("zz-repro.test.txt")).unwrap();
2629 let err = agent
2630 .ask_json::<Value>("read it", &schema, &linked, &Effort::default())
2631 .unwrap_err();
2632 assert_eq!(ErrorKind::UncertainWrite, err.kind());
2633 assert!(err.message().contains("zz-repro.test.txt"), "{err}");
2634
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]
2645 fn a_read_alongside_another_still_answers_for_what_was_there() {
2646 let (root, main, linked, _marker) = linked_worktree("alongside-existing");
2647 std::fs::write(linked.join("notes.txt"), "before\n").unwrap();
2648 let schema = serde_json::json!({"type": "object"});
2649
2650 for (line, path) in [
2651 ("printf 'rewritten\\n' > README.md; echo '{}'", "README.md"),
2652 ("printf 'after\\n' > notes.txt; echo '{}'", "notes.txt"),
2653 ] {
2654 let agent = Agent::with_bin(shell("reader", line), "/bin/sh");
2655
2656 let err = agent
2657 .ask_json_alongside::<Value>("read it", &schema, &linked, &Effort::default())
2658 .unwrap_err();
2659
2660 assert_eq!(ErrorKind::UncertainWrite, err.kind());
2661 assert!(err.message().contains(path), "{err}");
2662 git_at(&linked, &["checkout", "--", "README.md"]);
2663 for entry in std::fs::read_dir(&linked).unwrap().flatten() {
2664 if entry
2665 .file_name()
2666 .to_string_lossy()
2667 .starts_with(".spar-recovery-needed-")
2668 {
2669 std::fs::remove_file(entry.path()).unwrap();
2670 }
2671 }
2672 }
2673
2674 git_at(
2675 &main,
2676 &["worktree", "remove", "--force", linked.to_str().unwrap()],
2677 );
2678 let _ = std::fs::remove_dir_all(&root);
2679 }
2680
2681 fn behind_repo(name: &str) -> PathBuf {
2685 let dir = committed_repo(name);
2686 git_at(&dir, &["checkout", "-q", "-b", "advanced"]);
2687 std::fs::write(dir.join("README.md"), "moved on\n").unwrap();
2688 std::fs::write(dir.join("CHANGELOG.md"), "new file\n").unwrap();
2689 git_at(&dir, &["add", "README.md", "CHANGELOG.md"]);
2690 git_at(&dir, &["commit", "-q", "-m", "advance"]);
2691 git_at(&dir, &["checkout", "-q", "main"]);
2692 dir
2693 }
2694
2695 #[test]
2696 fn a_read_survives_the_branch_moving_under_it() {
2697 let dir = behind_repo("read-branch-moved");
2698 let agent = Agent::with_bin(
2699 shell("reader", "git merge -q --ff-only advanced; echo reviewed"),
2700 "/bin/sh",
2701 );
2702
2703 let answer = agent.ask("read it", &dir, &Effort::default()).unwrap();
2704
2705 assert_eq!("reviewed", answer.trim());
2706 assert_eq!(
2707 "moved on\n",
2708 std::fs::read_to_string(dir.join("README.md")).unwrap()
2709 );
2710 assert!(
2711 !std::fs::read_dir(&dir).unwrap().flatten().any(|entry| entry
2712 .file_name()
2713 .to_string_lossy()
2714 .starts_with(".spar-recovery-needed-"))
2715 );
2716 let _ = std::fs::remove_dir_all(&dir);
2717 }
2718
2719 #[test]
2720 fn a_read_that_writes_while_the_branch_moves_is_still_discarded() {
2721 let dir = behind_repo("read-branch-moved-and-written");
2722 let agent = Agent::with_bin(
2723 shell(
2724 "reader",
2725 "git merge -q --ff-only advanced; printf 'and mine\n' >> README.md; echo reviewed",
2726 ),
2727 "/bin/sh",
2728 );
2729
2730 let err = agent.ask("read it", &dir, &Effort::default()).unwrap_err();
2731
2732 assert_eq!(ErrorKind::UncertainWrite, err.kind());
2733 assert!(err.message().contains("read-only call"), "{err}");
2734 let _ = std::fs::remove_dir_all(&dir);
2735 }
2736
2737 #[test]
2738 fn a_read_in_a_linked_worktree_still_answers_for_a_moved_head() {
2739 let (root, main, linked, _) = linked_worktree("read-worktree-commit");
2740 let agent = Agent::with_bin(
2741 shell(
2742 "reader",
2743 "printf 'mine\n' > README.md; git add -A; git commit -q -m mine; echo reviewed",
2744 ),
2745 "/bin/sh",
2746 );
2747
2748 let err = agent
2749 .ask("read it", &linked, &Effort::default())
2750 .unwrap_err();
2751
2752 assert_eq!(ErrorKind::UncertainWrite, err.kind());
2753 assert!(err.message().contains("read-only call"), "{err}");
2754 git_at(
2755 &main,
2756 &["worktree", "remove", "--force", linked.to_str().unwrap()],
2757 );
2758 let _ = std::fs::remove_dir_all(&root);
2759 }
2760
2761 fn built_repo(name: &str) -> PathBuf {
2763 let dir = committed_repo(name);
2764 std::fs::write(dir.join(".gitignore"), "dist/\nlocal.env\n").unwrap();
2765 git_at(&dir, &["add", ".gitignore"]);
2766 git_at(&dir, &["commit", "-q", "-m", "ignore build output"]);
2767 std::fs::create_dir_all(dir.join("dist")).unwrap();
2768 std::fs::write(dir.join("dist/index.js"), "first build\n").unwrap();
2769 dir
2770 }
2771
2772 #[test]
2773 fn a_read_that_only_rebuilds_generated_output_keeps_its_answer() {
2774 let dir = built_repo("successful-read-build");
2775 let agent = Agent::with_bin(
2776 shell(
2777 "reader",
2778 "printf 'second build\n' > dist/index.js; printf 'more\n' > dist/extra.js; \
2779 echo reviewed",
2780 ),
2781 "/bin/sh",
2782 );
2783
2784 let answer = agent
2785 .ask("read it", &dir, &Effort::default())
2786 .expect("answer kept");
2787
2788 assert_eq!("reviewed", answer.trim());
2789 assert_eq!(
2790 "second build\n",
2791 std::fs::read_to_string(dir.join("dist/index.js")).unwrap()
2792 );
2793 assert!(
2794 !std::fs::read_dir(&dir).unwrap().flatten().any(|entry| entry
2795 .file_name()
2796 .to_string_lossy()
2797 .starts_with(".spar-recovery-needed-"))
2798 );
2799 let _ = std::fs::remove_dir_all(&dir);
2800 }
2801
2802 #[test]
2806 fn a_read_that_writes_a_new_ignored_file_elsewhere_keeps_its_answer() {
2807 let dir = built_repo("successful-read-ignored");
2808 let agent = Agent::with_bin(
2809 shell(
2810 "reader",
2811 "printf 'second build\n' > dist/index.js; printf 'TOKEN=x\n' > local.env; \
2812 echo reviewed",
2813 ),
2814 "/bin/sh",
2815 );
2816
2817 let answer = agent
2818 .ask("read it", &dir, &Effort::default())
2819 .expect("answer kept");
2820
2821 assert_eq!("reviewed", answer.trim());
2822 assert_eq!(
2823 "TOKEN=x\n",
2824 std::fs::read_to_string(dir.join("local.env")).unwrap()
2825 );
2826 assert!(
2827 !std::fs::read_dir(&dir).unwrap().flatten().any(|entry| entry
2828 .file_name()
2829 .to_string_lossy()
2830 .starts_with(".spar-recovery-needed-"))
2831 );
2832 let _ = std::fs::remove_dir_all(&dir);
2833 }
2834
2835 #[test]
2836 fn a_read_that_rewrites_an_existing_ignored_file_elsewhere_is_kept() {
2837 let dir = built_repo("successful-read-existing-ignored");
2838 std::fs::write(dir.join("local.env"), "TOKEN=before\n").unwrap();
2839 let agent = Agent::with_bin(
2840 shell(
2841 "reader",
2842 "printf 'TOKEN=after\n' > local.env; echo reviewed",
2843 ),
2844 "/bin/sh",
2845 );
2846
2847 let answer = agent
2848 .ask("read it", &dir, &Effort::default())
2849 .expect("answer kept");
2850
2851 assert_eq!("reviewed", answer.trim());
2852 assert_eq!(
2853 "TOKEN=after\n",
2854 std::fs::read_to_string(dir.join("local.env")).unwrap()
2855 );
2856 assert!(
2857 !std::fs::read_dir(&dir).unwrap().flatten().any(|entry| entry
2858 .file_name()
2859 .to_string_lossy()
2860 .starts_with(".spar-recovery-needed-"))
2861 );
2862 let _ = std::fs::remove_dir_all(&dir);
2863 }
2864
2865 #[test]
2866 fn a_read_that_deletes_an_existing_ignored_file_is_kept() {
2867 let dir = built_repo("successful-read-deleted-ignored");
2868 std::fs::write(dir.join("local.env"), "TOKEN=before\n").unwrap();
2869 let agent = Agent::with_bin(shell("reader", "rm local.env; echo reviewed"), "/bin/sh");
2870
2871 let answer = agent
2872 .ask("read it", &dir, &Effort::default())
2873 .expect("answer kept");
2874
2875 assert_eq!("reviewed", answer.trim());
2876 assert!(!dir.join("local.env").exists());
2877 let _ = std::fs::remove_dir_all(&dir);
2878 }
2879
2880 #[test]
2881 fn a_read_that_rewrites_an_existing_ordinary_untracked_file_is_discarded() {
2882 let dir = built_repo("successful-read-existing-ordinary");
2883 std::fs::write(dir.join("notes.md"), "before\n").unwrap();
2884 let agent = Agent::with_bin(
2885 shell("reader", "printf 'after\n' > notes.md; echo reviewed"),
2886 "/bin/sh",
2887 );
2888
2889 let err = agent.ask("read it", &dir, &Effort::default()).unwrap_err();
2890
2891 assert_eq!(ErrorKind::UncertainWrite, err.kind());
2892 assert!(err.message().contains("read-only call"), "{err}");
2893 assert!(err.message().contains("notes.md"), "{err}");
2894 let _ = std::fs::remove_dir_all(&dir);
2895 }
2896
2897 #[test]
2898 fn a_read_that_writes_under_spars_own_state_is_not_a_change() {
2899 let dir = built_repo("successful-read-spar-state");
2900 let agent = Agent::with_bin(
2901 shell(
2902 "reader",
2903 "mkdir -p .spar/state; printf '[]' > .spar/state/spend.json; echo reviewed",
2904 ),
2905 "/bin/sh",
2906 );
2907
2908 let answer = agent
2909 .ask("read it", &dir, &Effort::default())
2910 .expect("answer kept");
2911
2912 assert_eq!("reviewed", answer.trim());
2913 let _ = std::fs::remove_dir_all(&dir);
2914 }
2915
2916 #[test]
2917 fn a_read_that_changes_a_tracked_file_is_still_discarded() {
2918 let dir = built_repo("successful-read-tracked");
2919 let agent = Agent::with_bin(
2920 shell(
2921 "reader",
2922 "printf 'second build\n' > dist/index.js; printf 'recover me\n' > README.md; \
2923 echo reviewed",
2924 ),
2925 "/bin/sh",
2926 );
2927
2928 let err = agent.ask("read it", &dir, &Effort::default()).unwrap_err();
2929
2930 assert_eq!(ErrorKind::UncertainWrite, err.kind());
2931 assert!(err.message().contains("read-only call"), "{err}");
2932 assert!(err.message().contains("README.md"), "{err}");
2933 assert_eq!(
2934 "recover me\n",
2935 std::fs::read_to_string(dir.join("README.md")).unwrap()
2936 );
2937 let _ = std::fs::remove_dir_all(&dir);
2938 }
2939
2940 #[test]
2941 fn a_successful_edit_cannot_replace_the_git_directory() {
2942 let dir = committed_repo("replaced-git-directory");
2943 let agent = Agent::with_bin(
2944 shell("editor", "mv .git .git-original; mkdir .git; echo done"),
2945 "/bin/sh",
2946 );
2947
2948 let err = agent
2949 .edit("change it", &dir, &Effort::default())
2950 .unwrap_err();
2951
2952 assert_eq!(ErrorKind::UncertainWrite, err.kind());
2953 assert!(err.message().contains("Git entry"), "{err}");
2954 assert!(dir.join(".git-original").is_dir());
2955 std::fs::remove_dir(dir.join(".git")).unwrap();
2956 std::fs::rename(dir.join(".git-original"), dir.join(".git")).unwrap();
2957 let _ = std::fs::remove_dir_all(&dir);
2958 }
2959
2960 #[test]
2961 fn a_deleted_linked_worktree_git_marker_is_restored_without_a_fallback() {
2962 let (root, main, linked, marker) = linked_worktree("deleted-git-marker");
2963 let agent = with_fallback(
2964 shell("editor", "rm .git; echo done"),
2965 shell("backup", "printf 'fallback ran\n' > fallback.txt"),
2966 );
2967
2968 let err = agent
2969 .edit("change it", &linked, &Effort::default())
2970 .unwrap_err();
2971
2972 assert_eq!(ErrorKind::UncertainWrite, err.kind());
2973 assert!(err.message().contains("deleted"), "{err}");
2974 assert_eq!(marker, std::fs::read(linked.join(".git")).unwrap());
2975 assert!(!linked.join(GIT_MARKER_RECOVERY).exists());
2976 assert!(!linked.join("fallback.txt").exists());
2977 git_at(
2978 &main,
2979 &["worktree", "remove", "--force", linked.to_str().unwrap()],
2980 );
2981 let _ = std::fs::remove_dir_all(&root);
2982 }
2983
2984 #[test]
2985 fn an_occupied_marker_recovery_path_keeps_the_changed_marker() {
2986 let (root, main, linked, marker) = linked_worktree("occupied-marker-recovery");
2987 let agent = with_fallback(
2988 shell(
2989 "editor",
2990 "mkdir .spar-edited-git-marker; \
2991 printf 'gitdir: /tmp/not-the-repo\n' > .git; echo done",
2992 ),
2993 shell("backup", "printf 'fallback ran\n' > fallback.txt"),
2994 );
2995
2996 let err = agent
2997 .edit("change it", &linked, &Effort::default())
2998 .unwrap_err();
2999
3000 assert_eq!(ErrorKind::UncertainWrite, err.kind());
3001 assert!(err.message().contains("already exists"), "{err}");
3002 assert_eq!(
3003 b"gitdir: /tmp/not-the-repo\n",
3004 std::fs::read(linked.join(".git")).unwrap().as_slice()
3005 );
3006 assert!(linked.join(GIT_MARKER_RECOVERY).is_dir());
3007 assert!(!linked.join("fallback.txt").exists());
3008 std::fs::write(linked.join(".git"), marker).unwrap();
3009 git_at(
3010 &main,
3011 &["worktree", "remove", "--force", linked.to_str().unwrap()],
3012 );
3013 let _ = std::fs::remove_dir_all(&root);
3014 }
3015
3016 #[test]
3017 fn without_a_fallback_the_original_error_is_what_surfaces() {
3018 let agent = Agent::with_bin(shell("primary", "echo refused >&2; exit 1"), "/bin/sh");
3019 let err = agent
3020 .ask("hi", Path::new("."), &Effort::default())
3021 .expect_err("no backup");
3022 assert!(err.message().contains("refused"), "{err}");
3023 }
3024
3025 #[test]
3028 fn both_failing_reports_the_primary_first() {
3029 let agent = with_fallback(
3030 shell("primary", "echo policy refusal >&2; exit 1"),
3031 shell("backup", "echo out of quota >&2; exit 1"),
3032 );
3033 let err = agent
3034 .ask("hi", Path::new("."), &Effort::default())
3035 .expect_err("both fail");
3036 let text = err.message();
3037 let primary_at = text.find("policy refusal").expect("primary reason");
3038 let backup_at = text.find("out of quota").expect("backup reason");
3039 assert!(primary_at < backup_at, "{text}");
3040 assert!(
3041 text.contains("primary") && text.contains("backup"),
3042 "{text}"
3043 );
3044 }
3045
3046 fn attempts(name: &str) -> (PathBuf, String) {
3049 let path = std::env::temp_dir().join(format!("spar-attempts-{name}"));
3050 let _ = std::fs::remove_file(&path);
3051 let line = format!("echo x >> {}", path.display());
3052 (path, line)
3053 }
3054
3055 fn counted(path: &Path) -> usize {
3056 std::fs::read_to_string(path)
3057 .map(|t| t.lines().count())
3058 .unwrap_or(0)
3059 }
3060
3061 #[test]
3066 fn a_cli_that_could_not_answer_is_not_asked_twice_when_there_is_a_stand_in() {
3067 let (path, count) = attempts("refused-with-standin");
3068 let agent = with_fallback(
3069 shell("primary", &format!("{count}; echo refused >&2; exit 1")),
3070 shell("backup", "echo '{}'"),
3071 );
3072 let answer: Value = agent
3073 .ask_json(
3074 "q",
3075 &serde_json::json!({"type": "object"}),
3076 Path::new("."),
3077 &Effort::default(),
3078 )
3079 .expect("the stand in answers");
3080 assert!(answer.is_object());
3081 assert_eq!(1, counted(&path), "the primary was asked more than once");
3082 }
3083
3084 #[test]
3087 fn with_no_stand_in_a_failed_call_is_still_retried() {
3088 let (path, count) = attempts("refused-alone");
3089 let agent = Agent::with_bin(
3090 shell("solo", &format!("{count}; echo refused >&2; exit 1")),
3091 "/bin/sh",
3092 );
3093 let err = agent
3094 .ask_json::<Value>(
3095 "q",
3096 &serde_json::json!({"type": "object"}),
3097 Path::new("."),
3098 &Effort::default(),
3099 )
3100 .expect_err("nothing answers");
3101 assert!(err.message().contains("twice"), "{err}");
3102 assert_eq!(2, counted(&path));
3103 }
3104
3105 #[test]
3109 fn an_unusable_answer_is_still_worth_asking_again() {
3110 let (path, count) = attempts("unparsable");
3111 let agent = with_fallback(
3112 shell("primary", &format!("{count}; echo not json at all")),
3113 shell("backup", "echo '{}'"),
3114 );
3115 let answer: Value = agent
3116 .ask_json(
3117 "q",
3118 &serde_json::json!({"type": "object"}),
3119 Path::new("."),
3120 &Effort::default(),
3121 )
3122 .expect("the stand in answers in the end");
3123 assert!(answer.is_object());
3124 assert_eq!(2, counted(&path), "a shape error is worth one more ask");
3125 }
3126
3127 #[test]
3131 fn a_timeout_still_reaches_the_fallback() {
3132 let mut primary = shell("primary", "sleep 30");
3133 primary.timeout = 1;
3134 let agent = with_fallback(primary, shell("backup", "echo stood in"));
3135 assert_eq!(
3136 "stood in",
3137 agent
3138 .ask("hi", Path::new("."), &Effort::default())
3139 .expect("fallback")
3140 );
3141 }
3142
3143 #[test]
3146 fn the_fallback_is_built_alongside_the_agent() {
3147 let mut primary = shell("primary", "true");
3148 primary.fallback = Some(Box::new(shell("backup", "true")));
3149 let agent = Agent::new(primary);
3150 assert_eq!(Some("backup"), agent.fallback().map(Agent::name));
3151 assert!(Agent::new(shell("solo", "true")).fallback().is_none());
3152 }
3153}
3154
3155#[cfg(test)]
3156mod schema_placeholder_tests {
3157 use super::*;
3158 use crate::config::{OutputMode, SystemVia};
3159
3160 fn spec_with(command: Vec<CommandPart>) -> AgentSpec {
3161 AgentSpec {
3162 name: "claude".into(),
3163 command,
3164 model: None,
3165 effort: None,
3166 effort_schedule: crate::config::EffortSchedule::default(),
3167 output: OutputMode::Text,
3168 message_match: BTreeMap::new(),
3169 message_path: None,
3170 search_paths: vec![],
3171 system_via: SystemVia::Prompt,
3172 timeout: 60,
3173 fallback: None,
3174 models: vec![],
3175 efforts: vec![],
3176 options_note: None,
3177 }
3178 }
3179
3180 fn one(s: &str) -> CommandPart {
3181 CommandPart::One(s.into())
3182 }
3183 fn group(parts: &[&str]) -> CommandPart {
3184 CommandPart::Group(parts.iter().map(|s| s.to_string()).collect())
3185 }
3186
3187 #[test]
3190 fn either_schema_form_counts_as_native_support() {
3191 let inline = Agent::with_bin(
3192 spec_with(vec![one("x"), group(&["--json-schema", "{schema}"])]),
3193 "/b",
3194 );
3195 let byfile = Agent::with_bin(
3196 spec_with(vec![one("x"), group(&["--output-schema", "{schema_file}"])]),
3197 "/b",
3198 );
3199 let neither = Agent::with_bin(spec_with(vec![one("x"), one("{prompt}")]), "/b");
3200 assert!(inline.supports_schema());
3201 assert!(byfile.supports_schema());
3202 assert!(!neither.supports_schema());
3203 }
3204
3205 #[test]
3206 fn the_inline_schema_is_substituted_whole() {
3207 let agent = Agent::with_bin(
3208 spec_with(vec![
3209 one("x"),
3210 group(&["--json-schema", "{schema}"]),
3211 one("{prompt}"),
3212 ]),
3213 "/b",
3214 );
3215 let values = Placeholders {
3216 prompt: Some("review it".into()),
3217 schema: Some(r#"{"type":"object"}"#.into()),
3218 ..Default::default()
3219 };
3220 assert_eq!(
3221 vec!["/b", "--json-schema", r#"{"type":"object"}"#, "review it"],
3222 agent.render(&values).unwrap()
3223 );
3224 }
3225
3226 #[test]
3229 fn the_schema_flag_drops_when_no_schema_is_wanted() {
3230 let agent = Agent::with_bin(
3231 spec_with(vec![
3232 one("x"),
3233 group(&["--json-schema", "{schema}"]),
3234 one("{prompt}"),
3235 ]),
3236 "/b",
3237 );
3238 let values = Placeholders {
3239 prompt: Some("implement it".into()),
3240 ..Default::default()
3241 };
3242 assert_eq!(vec!["/b", "implement it"], agent.render(&values).unwrap());
3243 }
3244
3245 #[test]
3247 fn the_two_schema_placeholders_do_not_collide() {
3248 let agent = Agent::with_bin(
3249 spec_with(vec![one("x"), group(&["--output-schema", "{schema_file}"])]),
3250 "/b",
3251 );
3252 let values = Placeholders {
3253 schema: Some("INLINE".into()),
3254 schema_file: Some("/tmp/s.json".into()),
3255 ..Default::default()
3256 };
3257 assert_eq!(
3258 vec!["/b", "--output-schema", "/tmp/s.json"],
3259 agent.render(&values).unwrap()
3260 );
3261 }
3262
3263 #[test]
3265 fn the_shipped_claude_preset_now_has_native_structured_output() {
3266 let raw = crate::config::load_preset("claude").unwrap();
3267 let table = raw.as_table().cloned().unwrap();
3268 let mut spec: AgentSpec = toml::Value::Table(table).try_into().unwrap();
3269 spec.name = "claude".into();
3270 assert!(
3271 Agent::with_bin(spec, "/b").supports_schema(),
3272 "without this a long review is parsed out of prose and truncates"
3273 );
3274 }
3275}