use super::*;
fn load_from_str(dir: &Path, content: &str) -> MpmConfig {
std::fs::write(dir.join("config.toml"), content).unwrap();
MpmConfig::load(dir)
}
#[test]
fn config_absent_yields_defaults() {
let dir = tempfile::TempDir::new().unwrap();
let cfg = MpmConfig::load(dir.path());
assert_eq!(cfg, MpmConfig::default());
assert!(cfg.agents.sources.is_empty());
assert!(cfg.models.agents.is_empty());
assert!(!cfg.session_manager.enabled);
assert!(cfg.hooks.prompt_context);
}
#[test]
fn config_hooks_defaults_to_enabled() {
let dir = tempfile::TempDir::new().unwrap();
assert!(load_from_str(dir.path(), "[hooks]\n").hooks.prompt_context);
assert!(
load_from_str(dir.path(), "[pm]\ncircuit_breaker = true\n")
.hooks
.prompt_context
);
assert!(HooksConfig::default().prompt_context);
}
#[test]
fn config_session_refs_defaults_to_enabled() {
let dir = tempfile::TempDir::new().unwrap();
assert!(MpmConfig::load(dir.path()).session_refs.enabled);
assert!(
load_from_str(dir.path(), "[session_refs]\n")
.session_refs
.enabled
);
assert!(
load_from_str(dir.path(), "[hooks]\nprompt_context = false\n")
.session_refs
.enabled
);
assert!(SessionRefsConfig::default().enabled);
}
#[test]
fn config_session_refs_can_be_disabled() {
let dir = tempfile::TempDir::new().unwrap();
assert!(
!load_from_str(dir.path(), "[session_refs]\nenabled = false\n")
.session_refs
.enabled
);
}
#[test]
fn config_pm_guard_defaults_to_enabled() {
let dir = tempfile::TempDir::new().unwrap();
assert!(MpmConfig::load(dir.path()).pm_guard.enabled);
assert!(load_from_str(dir.path(), "[pm_guard]\n").pm_guard.enabled);
assert!(
load_from_str(dir.path(), "[hooks]\nprompt_context = false\n")
.pm_guard
.enabled
);
}
#[test]
fn config_pm_guard_can_be_disabled() {
let dir = tempfile::TempDir::new().unwrap();
let cfg = load_from_str(dir.path(), "[pm_guard]\nenabled = false\n");
assert!(!cfg.pm_guard.enabled);
assert_eq!(cfg.hooks, HooksConfig::default());
}
#[test]
fn config_pm_guard_operator_checkout_lists() {
let dir = tempfile::TempDir::new().unwrap();
let cfg = load_from_str(
dir.path(),
"[pm_guard]\nruntime_checkouts = [\"/srv/cron\"]\ndocuments_repos = [\"/w\"]\n",
);
assert_eq!(
cfg.pm_guard.runtime_checkouts,
vec![PathBuf::from("/srv/cron")]
);
assert_eq!(cfg.pm_guard.documents_repos, vec![PathBuf::from("/w")]);
assert!(cfg.pm_guard.enabled);
let bad = load_from_str(dir.path(), "[pm_guard]\nruntime_checkouts = \"/srv\"\n");
assert!(bad.pm_guard.runtime_checkouts.is_empty());
assert!(MpmConfig::default().pm_guard.documents_repos.is_empty());
for raw in [
"[pm_guard]\ndocuments_repos = \"/w\"\n",
"[pm_guard]\ndocuments_repos = [1]\n",
"[pm_guard\ndocuments_repos = [\"/w\"]\n",
] {
let cfg = load_from_str(dir.path(), raw);
assert!(cfg.pm_guard.documents_repos.is_empty(), "listed: {raw}");
assert!(cfg.pm_guard.enabled, "guard off: {raw}");
}
}
#[test]
fn config_pm_guard_malformed_file_stays_enabled() {
let dir = tempfile::TempDir::new().unwrap();
assert!(
load_from_str(dir.path(), "[pm_guard]\nenabled = \"no\"\n")
.pm_guard
.enabled
);
assert!(
load_from_str(dir.path(), "[pm_guard\nenabled = false\n")
.pm_guard
.enabled
);
let unreadable = tempfile::TempDir::new().unwrap();
std::fs::create_dir(unreadable.path().join("config.toml")).unwrap();
assert!(MpmConfig::load(unreadable.path()).pm_guard.enabled);
}
#[test]
fn config_prompt_self_improvement_defaults_to_none() {
let dir = tempfile::TempDir::new().unwrap();
assert!(
load_from_str(dir.path(), "[pm]\ncircuit_breaker = true\n")
.pm
.prompt_self_improvement
.is_none()
);
assert!(
load_from_str(dir.path(), "")
.pm
.prompt_self_improvement
.is_none()
);
}
#[test]
fn config_prompt_self_improvement_parses() {
let dir = tempfile::TempDir::new().unwrap();
assert_eq!(
load_from_str(dir.path(), "[pm]\nprompt_self_improvement = true\n")
.pm
.prompt_self_improvement,
Some(true)
);
assert_eq!(
load_from_str(dir.path(), "[pm]\nprompt_self_improvement = false\n")
.pm
.prompt_self_improvement,
Some(false)
);
}
#[test]
fn config_hooks_prompt_context_off() {
let dir = tempfile::TempDir::new().unwrap();
let cfg = load_from_str(
dir.path(),
r#"
[models.tiers]
opus = "claude-opus-5"
[hooks]
prompt_context = false
"#,
);
assert!(!cfg.hooks.prompt_context);
assert_eq!(cfg.models.tiers.opus.as_deref(), Some("claude-opus-5"));
assert_eq!(cfg.pm, PmConfig::default());
assert_eq!(cfg.catchup, CatchupConfig::default());
}
#[test]
fn config_valid_parsed() {
let dir = tempfile::TempDir::new().unwrap();
let toml = r#"
[agents]
sources = ["bundled", "user"]
[models]
default = "sonnet"
[models.agents]
engineer = "haiku"
rust-engineer = "opus"
[models.tiers]
haiku = "claude-haiku-4-5"
sonnet = "claude-sonnet-4-5"
opus = "claude-opus-4-5"
[skills]
sources = ["bundled"]
[pm]
circuit_breaker = true
[session_manager]
enabled = true
[session_manager.inference]
provider = "anthropic"
sm_model = "anthropic/claude-sonnet-4-6"
temperature = 0.4
[session_manager.memory]
palace = "session-manager"
recall_top_k = 8
[session_manager.rounds]
window = 12
"#;
let cfg = load_from_str(dir.path(), toml);
assert_eq!(cfg.agents.sources, vec!["bundled", "user"]);
assert_eq!(
cfg.models.agents.get("engineer").map(|s| s.as_str()),
Some("haiku")
);
assert_eq!(
cfg.models.agents.get("rust-engineer").map(|s| s.as_str()),
Some("opus")
);
assert_eq!(cfg.models.tiers.haiku.as_deref(), Some("claude-haiku-4-5"));
assert_eq!(cfg.models.default.as_deref(), Some("sonnet"));
assert_eq!(cfg.skills.sources, vec!["bundled"]);
assert_eq!(cfg.pm.circuit_breaker, Some(true));
assert!(cfg.session_manager.enabled);
assert_eq!(cfg.session_manager.inference.provider, "anthropic");
assert_eq!(cfg.session_manager.inference.temperature, 0.4);
assert_eq!(cfg.session_manager.memory.recall_top_k, 8);
assert_eq!(cfg.session_manager.rounds.window, 12);
assert_eq!(cfg.session_manager.inference.context_token_budget, 24_000);
}
#[test]
fn config_session_manager_partial_takes_defaults() {
let dir = tempfile::TempDir::new().unwrap();
let toml = r#"
[session_manager]
enabled = true
"#;
let cfg = load_from_str(dir.path(), toml);
assert!(cfg.session_manager.enabled);
assert_eq!(cfg.session_manager.inference.provider, "auto");
assert_eq!(cfg.session_manager.memory.palace, "session-manager");
assert_eq!(cfg.session_manager.rounds.window, 10);
}
#[test]
fn config_session_manager_absent_is_noop_default() {
let dir = tempfile::TempDir::new().unwrap();
let toml = r#"
[models.agents]
engineer = "haiku"
"#;
let cfg = load_from_str(dir.path(), toml);
assert_eq!(
cfg.session_manager,
crate::core::sm::config::SessionManagerConfig::default()
);
assert!(!cfg.session_manager.enabled);
}
#[test]
fn config_manifest_section_parses() {
let dir = tempfile::TempDir::new().unwrap();
let toml = r#"
[manifest]
repo = "https://github.com/me/my-fork"
git_ref = "dev"
ttl_hours = 6
"#;
let cfg = load_from_str(dir.path(), toml);
assert_eq!(
cfg.manifest.repo.as_deref(),
Some("https://github.com/me/my-fork")
);
assert_eq!(cfg.manifest.git_ref.as_deref(), Some("dev"));
assert_eq!(cfg.manifest.ttl_hours, Some(6));
let empty = MpmConfig::load(tempfile::TempDir::new().unwrap().path());
assert_eq!(empty.manifest, ManifestConfig::default());
}
#[test]
fn config_malformed_falls_back() {
let dir = tempfile::TempDir::new().unwrap();
let cfg = load_from_str(dir.path(), "this is not toml {{{{");
assert_eq!(cfg, MpmConfig::default());
}
#[test]
fn config_partial_sections_are_fine() {
let dir = tempfile::TempDir::new().unwrap();
let toml = r#"
[models.agents]
engineer = "haiku"
"#;
let cfg = load_from_str(dir.path(), toml);
assert_eq!(
cfg.models.agents.get("engineer").map(|s| s.as_str()),
Some("haiku")
);
assert!(cfg.agents.sources.is_empty());
assert!(cfg.pm.circuit_breaker.is_none());
}
#[test]
fn tier_alias_expansion() {
let dir = tempfile::TempDir::new().unwrap();
let toml = r#"
[models.tiers]
haiku = "claude-haiku-4-5"
sonnet = "claude-sonnet-4-7"
opus = "claude-opus-4-7"
"#;
let cfg = load_from_str(dir.path(), toml);
assert_eq!(cfg.expand_model_alias("haiku"), "claude-haiku-4-5");
assert_eq!(cfg.expand_model_alias("sonnet"), "claude-sonnet-4-7");
assert_eq!(cfg.expand_model_alias("opus"), "claude-opus-4-7");
assert_eq!(cfg.expand_model_alias("claude-opus-4-7"), "claude-opus-4-7");
assert_eq!(cfg.expand_model_alias("auto"), "claude-sonnet-4-5");
}
#[test]
fn tier_alias_defaults_when_not_configured() {
let cfg = MpmConfig::default();
assert_eq!(cfg.expand_model_alias("haiku"), "claude-haiku-4-5");
assert_eq!(cfg.expand_model_alias("sonnet"), "claude-sonnet-4-5");
assert_eq!(cfg.expand_model_alias("opus"), "claude-opus-4-5");
}
#[test]
fn config_catchup_defaults_parse() {
let dir = tempfile::TempDir::new().unwrap();
let cfg = MpmConfig::load(dir.path());
assert!(cfg.catchup.auto, "auto defaults to true");
assert!(cfg.catchup.include_git, "include_git defaults to true");
assert!(
cfg.catchup.include_palace,
"include_palace defaults to true"
);
assert_eq!(cfg.catchup.git_limit, 50);
assert_eq!(cfg.catchup.drawer_limit, 15);
}
#[test]
fn config_catchup_section_parses() {
let dir = tempfile::TempDir::new().unwrap();
let toml = r#"
[catchup]
auto = false
include_git = false
include_palace = true
git_limit = 25
drawer_limit = 5
"#;
let cfg = load_from_str(dir.path(), toml);
assert!(!cfg.catchup.auto);
assert!(!cfg.catchup.include_git);
assert!(cfg.catchup.include_palace);
assert_eq!(cfg.catchup.git_limit, 25);
assert_eq!(cfg.catchup.drawer_limit, 5);
}
#[test]
fn config_idle_auto_stop_defaults() {
let dir = tempfile::TempDir::new().unwrap();
let cfg = MpmConfig::load(dir.path());
assert!(
!cfg.idle_auto_stop.enabled,
"idle_auto_stop must default to disabled"
);
assert!(
cfg.idle_auto_stop.dry_run,
"idle_auto_stop must default to report-only (dry_run = true) — #1783"
);
assert_eq!(cfg.idle_auto_stop.poll_interval_secs, 300);
assert_eq!(cfg.idle_auto_stop.idle_consecutive_threshold, 3);
assert_eq!(cfg.idle_auto_stop.done_consecutive_threshold, 1);
}
#[test]
fn config_idle_auto_stop_section_parses() {
let dir = tempfile::TempDir::new().unwrap();
let toml = r#"
[idle_auto_stop]
enabled = true
dry_run = false
poll_interval_secs = 120
idle_consecutive_threshold = 5
done_consecutive_threshold = 2
"#;
let cfg = load_from_str(dir.path(), toml);
assert!(cfg.idle_auto_stop.enabled);
assert!(
!cfg.idle_auto_stop.dry_run,
"explicit dry_run = false must disable report-only mode"
);
assert_eq!(cfg.idle_auto_stop.poll_interval_secs, 120);
assert_eq!(cfg.idle_auto_stop.idle_consecutive_threshold, 5);
assert_eq!(cfg.idle_auto_stop.done_consecutive_threshold, 2);
}
#[test]
fn config_idle_auto_stop_enabled_only_keeps_defaults() {
let dir = tempfile::TempDir::new().unwrap();
let toml = r#"
[idle_auto_stop]
enabled = true
"#;
let cfg = load_from_str(dir.path(), toml);
assert!(cfg.idle_auto_stop.enabled);
assert!(
cfg.idle_auto_stop.dry_run,
"enabling the loop alone must NOT enact teardown — dry_run stays true (#1783)"
);
assert_eq!(cfg.idle_auto_stop.poll_interval_secs, 300);
assert_eq!(cfg.idle_auto_stop.idle_consecutive_threshold, 3);
assert_eq!(cfg.idle_auto_stop.done_consecutive_threshold, 1);
}
#[test]
fn config_idle_nudge_section_parses() {
let dir = tempfile::TempDir::new().unwrap();
let absent = load_from_str(dir.path(), "");
assert!(!absent.idle_nudge.enabled, "must default to disabled");
assert_eq!(absent.idle_nudge.max_nudges_per_session, 2);
assert_eq!(absent.idle_nudge.cooldown_secs, 300);
assert_eq!(
absent.idle_nudge.message,
crate::core::idle_nudge::DEFAULT_NUDGE_MESSAGE
);
let full = load_from_str(
dir.path(),
"[idle_nudge]\nenabled = true\nmax_nudges_per_session = 1\ncooldown_secs = 60\nmessage = \"resume now\"\n",
);
assert!(full.idle_nudge.enabled);
assert_eq!(full.idle_nudge.max_nudges_per_session, 1);
assert_eq!(full.idle_nudge.cooldown_secs, 60);
assert_eq!(full.idle_nudge.message, "resume now");
let partial = load_from_str(dir.path(), "[idle_nudge]\nenabled = true\n");
assert!(partial.idle_nudge.enabled);
assert_eq!(partial.idle_nudge.max_nudges_per_session, 2);
assert_eq!(partial.idle_nudge.cooldown_secs, 300);
assert_eq!(
partial.idle_nudge.message,
crate::core::idle_nudge::DEFAULT_NUDGE_MESSAGE
);
}
#[test]
fn model_resolution_precedence() {
let dir = tempfile::TempDir::new().unwrap();
let toml = r#"
[models]
default = "sonnet"
[models.agents]
engineer = "haiku"
"#;
let cfg = load_from_str(dir.path(), toml);
let m = resolve_agent_model(&cfg, "engineer", Some("opus"), Some("claude-opus-4-5"));
assert_eq!(m, "claude-opus-4-5");
let m = resolve_agent_model(&cfg, "engineer", Some("opus"), None);
assert_eq!(m, "claude-haiku-4-5");
let m = resolve_agent_model(&cfg, "unknown-agent", Some("opus"), None);
assert_eq!(m, "claude-opus-4-5");
let m = resolve_agent_model(&cfg, "unknown-agent", None, None);
assert_eq!(m, "claude-sonnet-4-5");
let cfg_empty = MpmConfig::default();
let m = resolve_agent_model(&cfg_empty, "nobody", None, None);
assert_eq!(m, "claude-sonnet-4-5");
}
#[test]
fn load_effective_applies_the_project_layer() {
let root = tempfile::TempDir::new().unwrap();
let project = tempfile::TempDir::new().unwrap();
std::fs::write(
root.path().join("config.toml"),
"[models]\ndefault = \"haiku\"\n",
)
.unwrap();
std::fs::write(
project
.path()
.join(crate::core::project_config::PROJECT_CONFIG_FILE),
"default_model = \"opus\"\n",
)
.unwrap();
let cfg = MpmConfig::load_effective(root.path(), Some(project.path()));
assert_eq!(
resolve_agent_model(&cfg, "nobody", None, None),
"claude-opus-4-5",
"the project file's default_model must beat the root config.toml's"
);
}
#[test]
fn project_default_model_tops_the_chain() {
let cfg = MpmConfig {
models: ModelsConfig {
default: Some("haiku".into()),
..ModelsConfig::default()
},
..MpmConfig::default()
}
.with_default_model_layers(Some("opus"), Some("sonnet"));
assert_eq!(
resolve_agent_model(&cfg, "nobody", None, None),
"claude-opus-4-5",
"the project layer must beat both host layers"
);
}
#[test]
fn yaml_default_model_beats_toml_default() {
let cfg = MpmConfig {
models: ModelsConfig {
default: Some("haiku".into()),
..ModelsConfig::default()
},
..MpmConfig::default()
}
.with_default_model_layers(None, Some("opus"));
assert_eq!(
resolve_agent_model(&cfg, "nobody", None, None),
"claude-opus-4-5"
);
}
#[test]
fn default_model_layers_are_a_no_op_when_unset() {
let base = MpmConfig {
models: ModelsConfig {
default: Some("haiku".into()),
..ModelsConfig::default()
},
..MpmConfig::default()
};
let layered = base.clone().with_default_model_layers(None, None);
assert_eq!(base, layered, "absent layers must change nothing");
}
#[test]
fn project_default_model_still_loses_to_more_specific_settings() {
let mut models = ModelsConfig::default();
models.agents.insert("engineer".into(), "haiku".into());
let cfg = MpmConfig {
models,
..MpmConfig::default()
}
.with_default_model_layers(Some("opus"), None);
assert_eq!(
resolve_agent_model(&cfg, "engineer", None, Some("sonnet")),
"claude-sonnet-4-5",
"an explicit --model must beat the project default"
);
assert_eq!(
resolve_agent_model(&cfg, "engineer", None, None),
"claude-haiku-4-5",
"a per-agent pin must beat the project default"
);
}