1use std::collections::BTreeMap;
11use std::path::{Path, PathBuf};
12
13use serde::{Deserialize, Serialize};
14use toml::Value;
15
16use crate::error::Result;
17use crate::proc::{expand_tilde, home_dir};
18use crate::style::Style;
19use crate::{bail, spar_err};
20
21pub const BUILTIN_PRESETS: &[(&str, &str)] = &[
23 ("aider", include_str!("../presets/aider.toml")),
24 ("claude", include_str!("../presets/claude.toml")),
25 ("codex", include_str!("../presets/codex.toml")),
26 ("gemini", include_str!("../presets/gemini.toml")),
27];
28
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
36#[serde(untagged)]
37pub enum CommandPart {
38 One(String),
39 Group(Vec<String>),
40}
41
42impl CommandPart {
43 pub fn args(&self) -> &[String] {
44 match self {
45 CommandPart::One(s) => std::slice::from_ref(s),
46 CommandPart::Group(v) => v,
47 }
48 }
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "lowercase")]
54pub enum OutputMode {
55 Text,
57 Json,
59 Jsonl,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "lowercase")]
66pub enum SystemVia {
67 Prompt,
69 Placeholder,
71}
72
73fn default_timeout() -> u64 {
74 crate::proc::DEFAULT_TIMEOUT_SECS
75}
76
77fn default_output() -> OutputMode {
78 OutputMode::Text
79}
80
81fn default_system_via() -> SystemVia {
82 SystemVia::Prompt
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
88#[serde(deny_unknown_fields)]
89pub struct AgentSpec {
90 #[serde(skip)]
91 pub name: String,
92 pub command: Vec<CommandPart>,
93 #[serde(default)]
94 pub model: Option<String>,
95 #[serde(default)]
96 pub effort: Option<String>,
97 #[serde(default = "default_output")]
98 pub output: OutputMode,
99 #[serde(default)]
101 pub message_match: BTreeMap<String, String>,
102 #[serde(default)]
104 pub message_path: Option<String>,
105 #[serde(default)]
107 pub search_paths: Vec<String>,
108 #[serde(default = "default_system_via")]
109 pub system_via: SystemVia,
110 #[serde(default = "default_timeout")]
111 pub timeout: u64,
112
113 #[serde(default)]
121 pub models: Vec<String>,
122 #[serde(default)]
124 pub efforts: Vec<String>,
125 #[serde(default)]
127 pub options_note: Option<String>,
128}
129
130impl AgentSpec {
131 pub fn model_key(&self) -> String {
134 self.model.as_deref().unwrap_or("").trim().to_string()
135 }
136
137 pub fn describe(&self) -> String {
138 format!(
139 "{}/{}",
140 self.model.as_deref().unwrap_or("default model"),
141 self.effort.as_deref().unwrap_or("default effort")
142 )
143 }
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
154#[serde(rename_all = "lowercase")]
155pub enum Followups {
156 Issues,
157 Local,
158 None,
159}
160
161impl std::fmt::Display for Followups {
162 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163 f.write_str(match self {
164 Followups::Issues => "issues",
165 Followups::Local => "local",
166 Followups::None => "none",
167 })
168 }
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
178#[serde(rename_all = "lowercase")]
179pub enum PrComments {
180 Outcome,
182 Rounds,
185 None,
187}
188
189impl std::fmt::Display for PrComments {
190 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
191 f.write_str(match self {
192 PrComments::Outcome => "outcome",
193 PrComments::Rounds => "rounds",
194 PrComments::None => "none",
195 })
196 }
197}
198
199#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
203#[serde(rename_all = "lowercase")]
204pub enum StateStore {
205 Local,
206 Pr,
207 Both,
208}
209
210impl StateStore {
211 pub fn writes_local(self) -> bool {
212 matches!(self, StateStore::Local | StateStore::Both)
213 }
214 pub fn writes_pr(self) -> bool {
215 matches!(self, StateStore::Pr | StateStore::Both)
216 }
217}
218
219#[derive(Debug, Clone, Default, Serialize, Deserialize)]
220#[serde(deny_unknown_fields)]
221pub struct EffortSchedule {
222 pub round_1: Option<String>,
224 pub rest: Option<String>,
226}
227
228#[derive(Debug, Clone, Serialize, Deserialize)]
229#[serde(deny_unknown_fields)]
230pub struct LoopCfg {
231 #[serde(default = "three")]
232 pub max_rounds: u32,
233 #[serde(default)]
234 pub auto_merge: bool,
235 #[serde(default)]
236 pub first_implementor: Option<String>,
237 #[serde(default = "main_branch")]
238 pub base_branch: String,
239 #[serde(default = "yes")]
240 pub worktrees: bool,
241 #[serde(default)]
242 pub keep_worktrees: bool,
243 #[serde(default = "store_local")]
244 pub state_store: StateStore,
245 #[serde(default)]
246 pub branch_prefix: String,
247 #[serde(default = "followups_local")]
248 pub followups: Followups,
249 #[serde(default)]
257 pub file_non_blocking: bool,
258 #[serde(default = "five")]
261 pub max_followups: usize,
262 #[serde(default)]
266 pub file_nits: bool,
267 #[serde(default = "yes")]
270 pub close_skipped: bool,
271 #[serde(default = "yes")]
274 pub parallel_triage: bool,
275 #[serde(default)]
283 pub min_number: i64,
284 #[serde(default)]
291 pub absorb_new_issues: u32,
292 #[serde(default)]
293 pub effort_schedule: EffortSchedule,
294}
295
296fn three() -> u32 {
297 3
298}
299fn main_branch() -> String {
300 "main".to_string()
301}
302fn yes() -> bool {
303 true
304}
305fn store_local() -> StateStore {
306 StateStore::Local
307}
308fn followups_local() -> Followups {
309 Followups::Local
310}
311fn five() -> usize {
312 5
313}
314
315impl Default for LoopCfg {
316 fn default() -> Self {
317 Self {
318 max_rounds: 3,
319 auto_merge: false,
320 first_implementor: None,
321 base_branch: "main".into(),
322 worktrees: true,
323 keep_worktrees: false,
324 state_store: StateStore::Local,
325 branch_prefix: String::new(),
326 followups: Followups::Local,
327 file_non_blocking: false,
328 max_followups: 5,
329 file_nits: false,
330 close_skipped: true,
331 parallel_triage: true,
332 min_number: 0,
333 absorb_new_issues: 0,
334 effort_schedule: EffortSchedule::default(),
335 }
336 }
337}
338
339#[derive(Debug, Clone, Serialize, Deserialize)]
340#[serde(deny_unknown_fields)]
341pub struct StyleCfg {
342 #[serde(default = "yes")]
343 pub ban_em_dash: bool,
344 #[serde(default = "yes")]
345 pub ban_ai_attribution: bool,
346 #[serde(default = "yes")]
347 pub terse: bool,
348 #[serde(default = "d320")]
349 pub max_detail_chars: usize,
350 #[serde(default = "d200")]
351 pub max_summary_chars: usize,
352 #[serde(default = "d900")]
353 pub max_body_chars: usize,
354 #[serde(default = "d4000")]
358 pub max_issue_body_chars: usize,
359 #[serde(default = "d90")]
360 pub max_title_chars: usize,
361 #[serde(default = "outcome_only")]
362 pub pr_comments: PrComments,
363}
364
365fn outcome_only() -> PrComments {
366 PrComments::Outcome
367}
368
369fn d320() -> usize {
370 2000
371}
372fn d200() -> usize {
373 1200
374}
375fn d900() -> usize {
376 2000
377}
378fn d4000() -> usize {
379 8000
380}
381fn d90() -> usize {
382 140
383}
384
385impl Default for StyleCfg {
386 fn default() -> Self {
387 Self {
388 ban_em_dash: true,
389 ban_ai_attribution: true,
390 terse: true,
391 max_detail_chars: 2000,
392 max_summary_chars: 1200,
393 max_body_chars: 2000,
394 max_issue_body_chars: 8000,
395 max_title_chars: 140,
396 pr_comments: PrComments::Outcome,
397 }
398 }
399}
400
401impl StyleCfg {
402 pub fn to_style(&self) -> Style {
403 Style {
404 ban_em_dash: self.ban_em_dash,
405 ban_ai_attribution: self.ban_ai_attribution,
406 terse: self.terse,
407 max_detail_chars: self.max_detail_chars,
408 max_summary_chars: self.max_summary_chars,
409 max_body_chars: self.max_body_chars,
410 max_issue_body_chars: self.max_issue_body_chars,
411 max_title_chars: self.max_title_chars,
412 pr_comments: self.pr_comments,
413 }
414 }
415}
416
417#[derive(Debug, Clone)]
422pub struct Config {
423 pub agents: Vec<AgentSpec>,
425 pub loop_cfg: LoopCfg,
426 pub style: Style,
427 pub first_implementor: String,
429 pub source: Option<PathBuf>,
431}
432
433impl Config {
434 pub fn agent_names(&self) -> Vec<String> {
435 self.agents.iter().map(|a| a.name.clone()).collect()
436 }
437
438 pub fn has_agent(&self, name: &str) -> bool {
439 self.agents.iter().any(|a| a.name == name)
440 }
441
442 pub fn spec(&self, name: &str) -> Result<&AgentSpec> {
443 self.agents.iter().find(|a| a.name == name).ok_or_else(|| {
444 spar_err!(
445 "no agent named '{name}' ({})",
446 self.agent_names().join(", ")
447 )
448 })
449 }
450
451 pub fn other(&self, name: &str) -> String {
454 let names = self.agent_names();
455 if names.first().map(String::as_str) == Some(name) {
456 names.get(1).cloned().unwrap_or_else(|| name.to_string())
457 } else {
458 names.first().cloned().unwrap_or_else(|| name.to_string())
459 }
460 }
461
462 pub fn effort_for_round(&self, spec: &AgentSpec, round: u32) -> Option<String> {
465 let scheduled = if round <= 1 {
466 self.loop_cfg.effort_schedule.round_1.clone()
467 } else {
468 self.loop_cfg.effort_schedule.rest.clone()
469 };
470 scheduled
471 .filter(|s| !s.trim().is_empty())
472 .or_else(|| spec.effort.clone())
473 }
474
475 pub fn base_branch(&self) -> &str {
476 &self.loop_cfg.base_branch
477 }
478}
479
480#[derive(Debug, Deserialize)]
481#[serde(deny_unknown_fields)]
482struct RawConfig {
483 #[serde(default)]
484 agents: toml::Table,
485 #[serde(default)]
486 #[serde(rename = "loop")]
487 loop_cfg: Option<LoopCfg>,
488 #[serde(default)]
489 style: Option<StyleCfg>,
490}
491
492pub fn preset_dirs() -> Vec<PathBuf> {
505 let mut dirs = Vec::new();
506 if let Some(custom) = std::env::var_os("SPAR_PRESET_DIR") {
507 dirs.push(PathBuf::from(custom));
508 }
509 dirs.push(PathBuf::from(".spar").join("presets"));
510 if let Some(home) = home_dir() {
511 dirs.push(home.join(".config").join("spar").join("presets"));
512 }
513 dirs
514}
515
516pub fn available_presets() -> Vec<String> {
518 let mut names: Vec<String> = BUILTIN_PRESETS.iter().map(|(n, _)| n.to_string()).collect();
519 for dir in preset_dirs() {
520 if let Ok(entries) = std::fs::read_dir(&dir) {
521 for entry in entries.flatten() {
522 let path = entry.path();
523 if path.extension().and_then(|e| e.to_str()) == Some("toml") {
524 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
525 names.push(stem.to_string());
526 }
527 }
528 }
529 }
530 }
531 names.sort();
532 names.dedup();
533 names
534}
535
536fn parse_document(text: &str, what: &str) -> Result<Value> {
541 let table: toml::Table =
542 toml::from_str(text).map_err(|e| spar_err!("{what} is not valid TOML: {e}"))?;
543 Ok(Value::Table(table))
544}
545
546pub fn load_preset(name: &str) -> Result<Value> {
549 for dir in preset_dirs() {
550 let path = dir.join(format!("{name}.toml"));
551 if path.is_file() {
552 let text = std::fs::read_to_string(&path)
553 .map_err(|e| spar_err!("could not read preset {}: {e}", path.display()))?;
554 return parse_document(&text, &format!("preset {}", path.display()));
555 }
556 }
557 for (builtin, text) in BUILTIN_PRESETS {
558 if *builtin == name {
559 return parse_document(text, &format!("built in preset {name}"));
560 }
561 }
562 Err(spar_err!(
563 "unknown preset '{name}'. Available: {}",
564 available_presets().join(", ")
565 ))
566}
567
568fn merge(base: &Value, over: &Value) -> Value {
571 match (base, over) {
572 (Value::Table(b), Value::Table(o)) => {
573 let mut out = b.clone();
574 for (key, value) in o {
575 let merged = match out.get(key) {
576 Some(existing) => merge(existing, value),
577 None => value.clone(),
578 };
579 out.insert(key.clone(), merged);
580 }
581 Value::Table(out)
582 }
583 _ => over.clone(),
584 }
585}
586
587fn build_spec(name: &str, raw: &Value) -> Result<AgentSpec> {
588 let table = raw
589 .as_table()
590 .ok_or_else(|| spar_err!("agent '{name}' must be a table"))?;
591
592 let merged = match table.get("preset").and_then(Value::as_str) {
593 Some(preset) => merge(&load_preset(preset)?, raw),
594 None => raw.clone(),
595 };
596
597 let mut merged_table = merged
598 .as_table()
599 .cloned()
600 .ok_or_else(|| spar_err!("agent '{name}' must be a table"))?;
601 merged_table.remove("preset");
602
603 if !merged_table.contains_key("command") {
604 bail!(
605 "agent '{name}' has no command and no preset. Set one of them, or pick a preset: {}",
606 available_presets().join(", ")
607 );
608 }
609
610 let mut spec: AgentSpec = Value::Table(merged_table)
611 .try_into()
612 .map_err(|e| spar_err!("agent '{name}': {e}"))?;
613 spec.name = name.to_string();
614
615 if spec.command.is_empty() {
616 bail!("agent '{name}' has an empty command");
617 }
618 if matches!(spec.command.first(), Some(CommandPart::Group(_))) {
619 bail!("agent '{name}': the first command element must be the program name, not a group");
620 }
621 if spec.output == OutputMode::Jsonl && spec.message_path.as_deref().unwrap_or("").is_empty() {
622 bail!(
623 "agent '{name}': output = \"jsonl\" needs a message_path saying where the answer lives"
624 );
625 }
626 Ok(spec)
627}
628
629#[derive(Debug, Clone)]
635pub struct OptionInfo {
636 pub section: &'static str,
637 pub key: String,
638 pub default: String,
639}
640
641pub fn known_options() -> Vec<OptionInfo> {
647 fn lines<T: Serialize>(section: &'static str, value: &T) -> Vec<OptionInfo> {
648 toml::to_string(value)
649 .unwrap_or_default()
650 .lines()
651 .filter_map(|line| line.split_once(" = "))
652 .map(|(key, default)| OptionInfo {
653 section,
654 key: key.trim().to_string(),
655 default: default.trim().to_string(),
656 })
657 .collect()
658 }
659 let mut out = lines("loop", &LoopCfg::default());
660 out.extend(lines("style", &StyleCfg::default()));
661 out.extend(lines(
662 "loop.effort_schedule",
663 &EffortSchedule {
664 round_1: Some("high".into()),
665 rest: Some("low".into()),
666 },
667 ));
668 out
669}
670
671pub fn mentions(config_text: &str, key: &str) -> bool {
673 config_text.lines().any(|line| {
674 let bare = line.trim_start().trim_start_matches('#').trim_start();
675 bare.starts_with(&format!("{key} ")) || bare.starts_with(&format!("{key}="))
676 })
677}
678
679pub fn unmentioned_options(config_text: &str) -> Vec<OptionInfo> {
682 known_options()
683 .into_iter()
684 .filter(|o| !mentions(config_text, &o.key))
685 .collect()
686}
687
688pub const CONFIG_NAMES: &[&str] = &["spar.toml", ".spar.toml"];
689
690pub fn find_config(explicit: Option<&Path>) -> Result<Option<PathBuf>> {
693 if let Some(path) = explicit {
694 if !path.is_file() {
695 bail!("config not found: {}", path.display());
696 }
697 return Ok(Some(path.to_path_buf()));
698 }
699 for name in CONFIG_NAMES {
700 let path = PathBuf::from(name);
701 if path.is_file() {
702 return Ok(Some(path));
703 }
704 }
705 if let Some(home) = home_dir() {
706 let path = home.join(".config").join("spar").join("spar.toml");
707 if path.is_file() {
708 return Ok(Some(path));
709 }
710 }
711 Ok(None)
712}
713
714pub fn load(explicit: Option<&Path>) -> Result<Config> {
715 let Some(path) = find_config(explicit)? else {
716 bail!(
717 "no spar.toml found. Run `spar init` to generate one from the CLIs you have installed."
718 );
719 };
720 let text = std::fs::read_to_string(&path)
721 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
722 let mut cfg = parse(&text).map_err(|e| spar_err!("{}: {e}", path.display()))?;
723 cfg.source = Some(path);
724 Ok(cfg)
725}
726
727pub fn parse(text: &str) -> Result<Config> {
728 let raw: RawConfig = toml::from_str(text)?;
729
730 if raw.agents.len() != 2 {
731 bail!(
732 "spar needs exactly two agents, found {}. The whole design is one reviewing the other.",
733 raw.agents.len()
734 );
735 }
736
737 let mut agents = Vec::new();
738 for (name, value) in raw.agents.iter() {
739 agents.push(build_spec(name, value)?);
740 }
741
742 let loop_cfg = raw.loop_cfg.unwrap_or_default();
743 let style = raw.style.unwrap_or_default().to_style();
744
745 if loop_cfg.max_rounds == 0 {
746 bail!("max_rounds must be at least 1");
747 }
748
749 let first = match &loop_cfg.first_implementor {
750 Some(name) if !name.trim().is_empty() => name.trim().to_string(),
751 _ => agents[0].name.clone(),
752 };
753 if !agents.iter().any(|a| a.name == first) {
754 bail!(
755 "first_implementor '{first}' is not a configured agent ({})",
756 agents
757 .iter()
758 .map(|a| a.name.as_str())
759 .collect::<Vec<_>>()
760 .join(", ")
761 );
762 }
763
764 Ok(Config {
765 agents,
766 loop_cfg,
767 style,
768 first_implementor: first,
769 source: None,
770 })
771}
772
773pub fn resolve_search_path(raw: &str) -> PathBuf {
775 expand_tilde(raw)
776}
777
778#[cfg(test)]
779mod tests {
780 use super::*;
781
782 const TWO_AGENTS: &str = r#"
783[agents.claude]
784preset = "claude"
785model = "fable"
786
787[agents.codex]
788preset = "codex"
789model = "gpt-5.6-sol"
790"#;
791
792 #[test]
793 fn every_builtin_preset_parses() {
794 for (name, _) in BUILTIN_PRESETS {
795 let value = load_preset(name).unwrap_or_else(|e| panic!("{name}: {e}"));
796 assert!(value.get("command").is_some(), "{name} has no command");
797 }
798 }
799
800 #[test]
801 fn every_builtin_preset_builds_a_spec() {
802 for (name, _) in BUILTIN_PRESETS {
803 let raw = parse_document(&format!("preset = \"{name}\""), "test").unwrap();
804 build_spec(name, &raw).unwrap_or_else(|e| panic!("{name}: {e}"));
805 }
806 }
807
808 #[test]
812 fn claude_preset_uses_the_equals_form_for_allowed_tools() {
813 let spec = build_spec(
814 "claude",
815 &parse_document("preset = \"claude\"", "test").unwrap(),
816 )
817 .unwrap();
818 let flat: Vec<&String> = spec.command.iter().flat_map(|p| p.args()).collect();
819 assert!(flat.iter().any(|a| a.starts_with("--allowedTools=")));
820 assert!(!flat.iter().any(|a| a.as_str() == "--allowedTools"));
821 }
822
823 #[test]
824 fn codex_preset_declares_where_its_answer_lives() {
825 let spec = build_spec(
826 "codex",
827 &parse_document("preset = \"codex\"", "test").unwrap(),
828 )
829 .unwrap();
830 assert_eq!(OutputMode::Jsonl, spec.output);
831 assert_eq!(Some("item.text"), spec.message_path.as_deref());
832 assert!(!spec.message_match.is_empty());
833 }
834
835 #[test]
836 fn agent_order_follows_declaration_order() {
837 let cfg = parse(TWO_AGENTS).unwrap();
838 assert_eq!(vec!["claude", "codex"], cfg.agent_names());
839 assert_eq!("claude", cfg.first_implementor);
840 }
841
842 #[test]
843 fn other_alternates() {
844 let cfg = parse(TWO_AGENTS).unwrap();
845 assert_eq!("codex", cfg.other("claude"));
846 assert_eq!("claude", cfg.other("codex"));
847 }
848
849 #[test]
850 fn a_config_block_overrides_one_preset_field() {
851 let cfg = parse(TWO_AGENTS).unwrap();
852 let claude = cfg.spec("claude").unwrap();
853 assert_eq!(Some("fable"), claude.model.as_deref());
854 assert!(claude.command.len() > 1, "the preset command survived");
855 }
856
857 #[test]
858 fn exactly_two_agents_are_required() {
859 let one = "[agents.claude]\npreset = \"claude\"\n";
860 assert!(parse(one).unwrap_err().to_string().contains("exactly two"));
861 }
862
863 #[test]
864 fn an_unknown_agent_option_is_named() {
865 let text = "[agents.a]\ncommand = [\"x\"]\nwidget = 3\n[agents.b]\ncommand = [\"y\"]\n";
866 let err = parse(text).unwrap_err().to_string();
867 assert!(err.contains("widget"), "{err}");
868 }
869
870 #[test]
871 fn an_unknown_loop_option_is_named() {
872 let text = format!("{TWO_AGENTS}\n[loop]\nmax_round = 4\n");
873 let err = parse(&text).unwrap_err().to_string();
874 assert!(err.contains("max_round"), "{err}");
875 }
876
877 #[test]
878 fn an_agent_with_no_command_and_no_preset_is_rejected() {
879 let text = "[agents.a]\nmodel = \"x\"\n[agents.b]\ncommand = [\"y\"]\n";
880 let err = parse(text).unwrap_err().to_string();
881 assert!(err.contains("no command and no preset"), "{err}");
882 }
883
884 #[test]
885 fn jsonl_without_a_message_path_is_rejected() {
886 let text =
887 "[agents.a]\ncommand = [\"x\"]\noutput = \"jsonl\"\n[agents.b]\ncommand = [\"y\"]\n";
888 let err = parse(text).unwrap_err().to_string();
889 assert!(err.contains("message_path"), "{err}");
890 }
891
892 #[test]
893 fn first_implementor_must_name_a_configured_agent() {
894 let text = format!("{TWO_AGENTS}\n[loop]\nfirst_implementor = \"nobody\"\n");
895 let err = parse(&text).unwrap_err().to_string();
896 assert!(err.contains("not a configured agent"), "{err}");
897 }
898
899 #[test]
900 fn defaults_are_the_conservative_ones() {
901 let cfg = parse(TWO_AGENTS).unwrap();
902 assert!(
903 !cfg.loop_cfg.auto_merge,
904 "auto_merge must be off by default"
905 );
906 assert!(cfg.loop_cfg.worktrees);
907 assert!(
908 !cfg.loop_cfg.file_nits,
909 "a filed nit is somebody else's triage queue"
910 );
911 assert_eq!(3, cfg.loop_cfg.max_rounds);
912 assert_eq!(
913 Followups::Local,
914 cfg.loop_cfg.followups,
915 "the tracker is somebody's queue; the default must not write to it"
916 );
917 assert!(
918 !cfg.loop_cfg.file_non_blocking,
919 "a suggestion is not a tracker item"
920 );
921 assert_eq!(StateStore::Local, cfg.loop_cfg.state_store);
922 assert!(cfg.style.terse);
923 }
924
925 #[test]
926 fn effort_schedule_splits_round_one_from_the_rest() {
927 let text =
928 format!("{TWO_AGENTS}\n[loop.effort_schedule]\nround_1 = \"ultra\"\nrest = \"high\"\n");
929 let cfg = parse(&text).unwrap();
930 let spec = cfg.spec("claude").unwrap();
931 assert_eq!(Some("ultra".into()), cfg.effort_for_round(spec, 1));
932 assert_eq!(Some("high".into()), cfg.effort_for_round(spec, 2));
933 assert_eq!(Some("high".into()), cfg.effort_for_round(spec, 9));
934 }
935
936 #[test]
937 fn effort_falls_back_to_the_agents_own_setting() {
938 let text = format!("{TWO_AGENTS}effort = \"low\"\n");
939 let cfg = parse(&text).unwrap();
940 let spec = cfg.spec("codex").unwrap();
941 assert_eq!(Some("low".into()), cfg.effort_for_round(spec, 1));
942 }
943
944 #[test]
945 fn an_unset_model_and_an_empty_model_normalise_the_same() {
946 let a = AgentSpec {
947 name: "a".into(),
948 command: vec![CommandPart::One("x".into())],
949 model: None,
950 effort: None,
951 output: OutputMode::Text,
952 message_match: BTreeMap::new(),
953 message_path: None,
954 search_paths: vec![],
955 system_via: SystemVia::Prompt,
956 timeout: 60,
957 models: vec![],
958 efforts: vec![],
959 options_note: None,
960 };
961 let b = AgentSpec {
962 model: Some(" ".into()),
963 ..a.clone()
964 };
965 assert_eq!(a.model_key(), b.model_key());
966 }
967
968 #[test]
969 fn max_rounds_zero_is_rejected() {
970 let text = format!("{TWO_AGENTS}\n[loop]\nmax_rounds = 0\n");
971 assert!(parse(&text).is_err());
972 }
973
974 #[test]
975 fn an_inline_command_needs_no_preset() {
976 let text = r#"
977[agents.custom]
978command = ["mytool", ["-m", "{model}"], "--prompt", "{prompt}"]
979output = "text"
980
981[agents.other]
982command = ["othertool", "{prompt}"]
983"#;
984 let cfg = parse(text).unwrap();
985 assert_eq!(4, cfg.spec("custom").unwrap().command.len());
986 }
987
988 #[test]
989 fn style_budgets_are_configurable() {
990 let text = format!("{TWO_AGENTS}\n[style]\nterse = false\nmax_detail_chars = 40\n");
991 let cfg = parse(&text).unwrap();
992 assert!(!cfg.style.terse);
993 assert_eq!(40, cfg.style.max_detail_chars);
994 }
995}