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