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