huskies: merge 685_refactor_decompose_server_src_config_rs_1223_lines
This commit is contained in:
@@ -591,633 +591,4 @@ fn validate_agents(agents: &[AgentConfig]) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests;
|
||||||
use super::*;
|
|
||||||
use std::fs;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn default_config_when_missing() {
|
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
|
||||||
let config = ProjectConfig::load(tmp.path()).unwrap();
|
|
||||||
assert_eq!(config.agent.len(), 1);
|
|
||||||
assert_eq!(config.agent[0].name, "default");
|
|
||||||
assert!(config.component.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_multi_agent_toml() {
|
|
||||||
let toml_str = r#"
|
|
||||||
[[component]]
|
|
||||||
name = "server"
|
|
||||||
path = "."
|
|
||||||
setup = ["cargo check"]
|
|
||||||
|
|
||||||
[[agent]]
|
|
||||||
name = "supervisor"
|
|
||||||
role = "Coordinates work"
|
|
||||||
model = "opus"
|
|
||||||
max_turns = 50
|
|
||||||
max_budget_usd = 10.00
|
|
||||||
system_prompt = "You are a senior engineer"
|
|
||||||
|
|
||||||
[[agent]]
|
|
||||||
name = "coder-1"
|
|
||||||
role = "Full-stack engineer"
|
|
||||||
model = "sonnet"
|
|
||||||
max_turns = 30
|
|
||||||
max_budget_usd = 5.00
|
|
||||||
"#;
|
|
||||||
|
|
||||||
let config = ProjectConfig::parse(toml_str).unwrap();
|
|
||||||
assert_eq!(config.agent.len(), 2);
|
|
||||||
assert_eq!(config.agent[0].name, "supervisor");
|
|
||||||
assert_eq!(config.agent[0].role, "Coordinates work");
|
|
||||||
assert_eq!(config.agent[0].model, Some("opus".to_string()));
|
|
||||||
assert_eq!(config.agent[0].max_turns, Some(50));
|
|
||||||
assert_eq!(config.agent[0].max_budget_usd, Some(10.0));
|
|
||||||
assert_eq!(
|
|
||||||
config.agent[0].system_prompt,
|
|
||||||
Some("You are a senior engineer".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(config.agent[1].name, "coder-1");
|
|
||||||
assert_eq!(config.agent[1].model, Some("sonnet".to_string()));
|
|
||||||
assert_eq!(config.component.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_legacy_single_agent() {
|
|
||||||
let toml_str = r#"
|
|
||||||
[[component]]
|
|
||||||
name = "server"
|
|
||||||
path = "."
|
|
||||||
|
|
||||||
[agent]
|
|
||||||
command = "claude"
|
|
||||||
args = ["--print", "--directory", "{{worktree_path}}"]
|
|
||||||
prompt = "Pick up story {{story_id}}"
|
|
||||||
"#;
|
|
||||||
|
|
||||||
let config = ProjectConfig::parse(toml_str).unwrap();
|
|
||||||
assert_eq!(config.agent.len(), 1);
|
|
||||||
assert_eq!(config.agent[0].name, "default");
|
|
||||||
assert_eq!(config.agent[0].command, "claude");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn validate_duplicate_names() {
|
|
||||||
let toml_str = r#"
|
|
||||||
[[agent]]
|
|
||||||
name = "coder"
|
|
||||||
role = "Engineer"
|
|
||||||
|
|
||||||
[[agent]]
|
|
||||||
name = "coder"
|
|
||||||
role = "Another engineer"
|
|
||||||
"#;
|
|
||||||
|
|
||||||
let err = ProjectConfig::parse(toml_str).unwrap_err();
|
|
||||||
assert!(err.contains("Duplicate agent name: 'coder'"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn validate_empty_name() {
|
|
||||||
let toml_str = r#"
|
|
||||||
[[agent]]
|
|
||||||
name = ""
|
|
||||||
role = "Engineer"
|
|
||||||
"#;
|
|
||||||
|
|
||||||
let err = ProjectConfig::parse(toml_str).unwrap_err();
|
|
||||||
assert!(err.contains("Agent name must not be empty"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn validate_non_positive_budget() {
|
|
||||||
let toml_str = r#"
|
|
||||||
[[agent]]
|
|
||||||
name = "coder"
|
|
||||||
max_budget_usd = -1.0
|
|
||||||
"#;
|
|
||||||
|
|
||||||
let err = ProjectConfig::parse(toml_str).unwrap_err();
|
|
||||||
assert!(err.contains("must be positive"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn validate_zero_max_turns() {
|
|
||||||
let toml_str = r#"
|
|
||||||
[[agent]]
|
|
||||||
name = "coder"
|
|
||||||
max_turns = 0
|
|
||||||
"#;
|
|
||||||
|
|
||||||
let err = ProjectConfig::parse(toml_str).unwrap_err();
|
|
||||||
assert!(err.contains("max_turns must be positive"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn render_agent_args_default() {
|
|
||||||
let config = ProjectConfig::default();
|
|
||||||
let (cmd, args, prompt) = config
|
|
||||||
.render_agent_args("/tmp/wt", "42_foo", None, None)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(cmd, "claude");
|
|
||||||
assert!(args.is_empty());
|
|
||||||
assert!(prompt.contains("42_foo"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn render_agent_args_by_name() {
|
|
||||||
let toml_str = r#"
|
|
||||||
[[agent]]
|
|
||||||
name = "supervisor"
|
|
||||||
model = "opus"
|
|
||||||
max_turns = 50
|
|
||||||
max_budget_usd = 10.00
|
|
||||||
system_prompt = "You lead story {{story_id}}"
|
|
||||||
allowed_tools = ["Read", "Write", "Bash"]
|
|
||||||
|
|
||||||
[[agent]]
|
|
||||||
name = "coder"
|
|
||||||
model = "sonnet"
|
|
||||||
max_turns = 30
|
|
||||||
"#;
|
|
||||||
|
|
||||||
let config = ProjectConfig::parse(toml_str).unwrap();
|
|
||||||
let (cmd, args, prompt) = config
|
|
||||||
.render_agent_args("/tmp/wt", "42_foo", Some("supervisor"), Some("master"))
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(cmd, "claude");
|
|
||||||
assert!(args.contains(&"--model".to_string()));
|
|
||||||
assert!(args.contains(&"opus".to_string()));
|
|
||||||
assert!(args.contains(&"--max-turns".to_string()));
|
|
||||||
assert!(args.contains(&"50".to_string()));
|
|
||||||
assert!(args.contains(&"--max-budget-usd".to_string()));
|
|
||||||
assert!(args.contains(&"10".to_string()));
|
|
||||||
assert!(args.contains(&"--allowedTools".to_string()));
|
|
||||||
assert!(args.contains(&"Read,Write,Bash".to_string()));
|
|
||||||
assert!(args.contains(&"--append-system-prompt".to_string()));
|
|
||||||
// System prompt should have template rendered
|
|
||||||
assert!(args.contains(&"You lead story 42_foo".to_string()));
|
|
||||||
assert!(prompt.contains("42_foo"));
|
|
||||||
|
|
||||||
// Render for coder
|
|
||||||
let (_, coder_args, _) = config
|
|
||||||
.render_agent_args("/tmp/wt", "42_foo", Some("coder"), Some("master"))
|
|
||||||
.unwrap();
|
|
||||||
assert!(coder_args.contains(&"sonnet".to_string()));
|
|
||||||
assert!(coder_args.contains(&"30".to_string()));
|
|
||||||
assert!(!coder_args.contains(&"--max-budget-usd".to_string()));
|
|
||||||
assert!(!coder_args.contains(&"--append-system-prompt".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn render_agent_args_not_found() {
|
|
||||||
let config = ProjectConfig::default();
|
|
||||||
let result = config.render_agent_args("/tmp/wt", "42_foo", Some("nonexistent"), None);
|
|
||||||
assert!(result.is_err());
|
|
||||||
assert!(result.unwrap_err().contains("No agent named 'nonexistent'"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn find_agent_and_default() {
|
|
||||||
let toml_str = r#"
|
|
||||||
[[agent]]
|
|
||||||
name = "first"
|
|
||||||
|
|
||||||
[[agent]]
|
|
||||||
name = "second"
|
|
||||||
"#;
|
|
||||||
|
|
||||||
let config = ProjectConfig::parse(toml_str).unwrap();
|
|
||||||
assert_eq!(config.default_agent().unwrap().name, "first");
|
|
||||||
assert_eq!(config.find_agent("second").unwrap().name, "second");
|
|
||||||
assert!(config.find_agent("missing").is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_project_toml_from_file() {
|
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
|
||||||
let sk = tmp.path().join(".huskies");
|
|
||||||
fs::create_dir_all(&sk).unwrap();
|
|
||||||
fs::write(
|
|
||||||
sk.join("project.toml"),
|
|
||||||
r#"
|
|
||||||
[[component]]
|
|
||||||
name = "server"
|
|
||||||
path = "."
|
|
||||||
setup = ["cargo check"]
|
|
||||||
teardown = []
|
|
||||||
|
|
||||||
[[component]]
|
|
||||||
name = "frontend"
|
|
||||||
path = "frontend"
|
|
||||||
setup = ["pnpm install"]
|
|
||||||
|
|
||||||
[[agent]]
|
|
||||||
name = "main"
|
|
||||||
command = "claude"
|
|
||||||
args = ["--print", "--directory", "{{worktree_path}}"]
|
|
||||||
prompt = "Pick up story {{story_id}}"
|
|
||||||
model = "sonnet"
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let config = ProjectConfig::load(tmp.path()).unwrap();
|
|
||||||
assert_eq!(config.component.len(), 2);
|
|
||||||
assert_eq!(config.component[0].name, "server");
|
|
||||||
assert_eq!(config.component[1].setup, vec!["pnpm install"]);
|
|
||||||
assert_eq!(config.agent.len(), 1);
|
|
||||||
assert_eq!(config.agent[0].name, "main");
|
|
||||||
assert_eq!(config.agent[0].model, Some("sonnet".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn agents_toml_overrides_project_toml_agents() {
|
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
|
||||||
let sk = tmp.path().join(".huskies");
|
|
||||||
fs::create_dir_all(&sk).unwrap();
|
|
||||||
// project.toml has inline agents
|
|
||||||
fs::write(
|
|
||||||
sk.join("project.toml"),
|
|
||||||
r#"
|
|
||||||
[[agent]]
|
|
||||||
name = "from-project-toml"
|
|
||||||
model = "sonnet"
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
// agents.toml overrides with different agents
|
|
||||||
fs::write(
|
|
||||||
sk.join("agents.toml"),
|
|
||||||
r#"
|
|
||||||
[[agent]]
|
|
||||||
name = "from-agents-toml"
|
|
||||||
model = "opus"
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let config = ProjectConfig::load(tmp.path()).unwrap();
|
|
||||||
assert_eq!(config.agent.len(), 1);
|
|
||||||
assert_eq!(config.agent[0].name, "from-agents-toml");
|
|
||||||
assert_eq!(config.agent[0].model, Some("opus".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn agents_toml_absent_falls_back_to_project_toml() {
|
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
|
||||||
let sk = tmp.path().join(".huskies");
|
|
||||||
fs::create_dir_all(&sk).unwrap();
|
|
||||||
fs::write(
|
|
||||||
sk.join("project.toml"),
|
|
||||||
r#"
|
|
||||||
[[agent]]
|
|
||||||
name = "inline-agent"
|
|
||||||
model = "sonnet"
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
// No agents.toml — should use inline agents from project.toml
|
|
||||||
|
|
||||||
let config = ProjectConfig::load(tmp.path()).unwrap();
|
|
||||||
assert_eq!(config.agent.len(), 1);
|
|
||||||
assert_eq!(config.agent[0].name, "inline-agent");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── WatcherConfig ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn watcher_config_defaults_when_omitted() {
|
|
||||||
let toml_str = r#"
|
|
||||||
[[agent]]
|
|
||||||
name = "coder"
|
|
||||||
"#;
|
|
||||||
let config = ProjectConfig::parse(toml_str).unwrap();
|
|
||||||
assert_eq!(config.watcher.sweep_interval_secs, 60);
|
|
||||||
assert_eq!(config.watcher.done_retention_secs, 4 * 60 * 60);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn watcher_config_custom_values() {
|
|
||||||
let toml_str = r#"
|
|
||||||
[watcher]
|
|
||||||
sweep_interval_secs = 30
|
|
||||||
done_retention_secs = 7200
|
|
||||||
|
|
||||||
[[agent]]
|
|
||||||
name = "coder"
|
|
||||||
"#;
|
|
||||||
let config = ProjectConfig::parse(toml_str).unwrap();
|
|
||||||
assert_eq!(config.watcher.sweep_interval_secs, 30);
|
|
||||||
assert_eq!(config.watcher.done_retention_secs, 7200);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn watcher_config_partial_override() {
|
|
||||||
let toml_str = r#"
|
|
||||||
[watcher]
|
|
||||||
sweep_interval_secs = 10
|
|
||||||
|
|
||||||
[[agent]]
|
|
||||||
name = "coder"
|
|
||||||
"#;
|
|
||||||
let config = ProjectConfig::parse(toml_str).unwrap();
|
|
||||||
assert_eq!(config.watcher.sweep_interval_secs, 10);
|
|
||||||
// done_retention_secs should fall back to the default (4 hours).
|
|
||||||
assert_eq!(config.watcher.done_retention_secs, 4 * 60 * 60);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn watcher_config_from_file() {
|
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
|
||||||
let sk = tmp.path().join(".huskies");
|
|
||||||
fs::create_dir_all(&sk).unwrap();
|
|
||||||
fs::write(
|
|
||||||
sk.join("project.toml"),
|
|
||||||
r#"
|
|
||||||
[watcher]
|
|
||||||
sweep_interval_secs = 120
|
|
||||||
done_retention_secs = 3600
|
|
||||||
|
|
||||||
[[agent]]
|
|
||||||
name = "coder"
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let config = ProjectConfig::load(tmp.path()).unwrap();
|
|
||||||
assert_eq!(config.watcher.sweep_interval_secs, 120);
|
|
||||||
assert_eq!(config.watcher.done_retention_secs, 3600);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn watcher_config_default_when_no_file() {
|
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
|
||||||
let config = ProjectConfig::load(tmp.path()).unwrap();
|
|
||||||
assert_eq!(config.watcher, WatcherConfig::default());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn coder_agents_have_root_cause_guidance() {
|
|
||||||
// Load the actual project.toml and verify all coder-stage agents
|
|
||||||
// include root cause investigation guidance for bugs.
|
|
||||||
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
|
||||||
let project_root = manifest_dir.parent().unwrap();
|
|
||||||
let config = ProjectConfig::load(project_root).unwrap();
|
|
||||||
|
|
||||||
let coder_agents: Vec<_> = config
|
|
||||||
.agent
|
|
||||||
.iter()
|
|
||||||
.filter(|a| a.stage.as_deref() == Some("coder"))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
!coder_agents.is_empty(),
|
|
||||||
"Expected at least one coder-stage agent in project.toml"
|
|
||||||
);
|
|
||||||
|
|
||||||
for agent in coder_agents {
|
|
||||||
let prompt = &agent.prompt;
|
|
||||||
let system_prompt = agent.system_prompt.as_deref().unwrap_or("");
|
|
||||||
let combined = format!("{prompt} {system_prompt}");
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
combined.contains("Bug Workflow") || combined.contains("trust the story"),
|
|
||||||
"Coder agent '{}' must include bug workflow guidance in prompt or system_prompt",
|
|
||||||
agent.name
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
combined.contains("surgical") || combined.to_lowercase().contains("minimal"),
|
|
||||||
"Coder agent '{}' must discourage adding abstractions/workarounds",
|
|
||||||
agent.name
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn watcher_config_preserved_in_legacy_format() {
|
|
||||||
let toml_str = r#"
|
|
||||||
[watcher]
|
|
||||||
sweep_interval_secs = 15
|
|
||||||
done_retention_secs = 900
|
|
||||||
|
|
||||||
[agent]
|
|
||||||
command = "claude"
|
|
||||||
"#;
|
|
||||||
let config = ProjectConfig::parse(toml_str).unwrap();
|
|
||||||
assert_eq!(config.watcher.sweep_interval_secs, 15);
|
|
||||||
assert_eq!(config.watcher.done_retention_secs, 900);
|
|
||||||
assert_eq!(config.agent.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── default_coder_model & max_coders ─────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_default_coder_model_and_max_coders() {
|
|
||||||
let toml_str = r#"
|
|
||||||
default_coder_model = "sonnet"
|
|
||||||
max_coders = 3
|
|
||||||
|
|
||||||
[[agent]]
|
|
||||||
name = "coder-1"
|
|
||||||
stage = "coder"
|
|
||||||
model = "sonnet"
|
|
||||||
|
|
||||||
[[agent]]
|
|
||||||
name = "coder-opus"
|
|
||||||
stage = "coder"
|
|
||||||
model = "opus"
|
|
||||||
"#;
|
|
||||||
let config = ProjectConfig::parse(toml_str).unwrap();
|
|
||||||
assert_eq!(config.default_coder_model, Some("sonnet".to_string()));
|
|
||||||
assert_eq!(config.max_coders, Some(3));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn default_coder_model_and_max_coders_default_to_none() {
|
|
||||||
let toml_str = r#"
|
|
||||||
[[agent]]
|
|
||||||
name = "coder-1"
|
|
||||||
"#;
|
|
||||||
let config = ProjectConfig::parse(toml_str).unwrap();
|
|
||||||
assert_eq!(config.default_coder_model, None);
|
|
||||||
assert_eq!(config.max_coders, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn project_toml_has_default_coder_model_and_max_coders() {
|
|
||||||
// Verify the actual project.toml has the new settings.
|
|
||||||
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
|
||||||
let project_root = manifest_dir.parent().unwrap();
|
|
||||||
let config = ProjectConfig::load(project_root).unwrap();
|
|
||||||
assert_eq!(config.default_coder_model, Some("sonnet".to_string()));
|
|
||||||
assert_eq!(config.max_coders, Some(3));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── runtime config ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn runtime_defaults_to_none() {
|
|
||||||
let toml_str = r#"
|
|
||||||
[[agent]]
|
|
||||||
name = "coder"
|
|
||||||
"#;
|
|
||||||
let config = ProjectConfig::parse(toml_str).unwrap();
|
|
||||||
assert_eq!(config.agent[0].runtime, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn runtime_claude_code_accepted() {
|
|
||||||
let toml_str = r#"
|
|
||||||
[[agent]]
|
|
||||||
name = "coder"
|
|
||||||
runtime = "claude-code"
|
|
||||||
"#;
|
|
||||||
let config = ProjectConfig::parse(toml_str).unwrap();
|
|
||||||
assert_eq!(config.agent[0].runtime, Some("claude-code".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn runtime_gemini_accepted() {
|
|
||||||
let toml_str = r#"
|
|
||||||
[[agent]]
|
|
||||||
name = "coder"
|
|
||||||
runtime = "gemini"
|
|
||||||
model = "gemini-2.5-pro"
|
|
||||||
"#;
|
|
||||||
let config = ProjectConfig::parse(toml_str).unwrap();
|
|
||||||
assert_eq!(config.agent[0].runtime, Some("gemini".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn runtime_unknown_rejected() {
|
|
||||||
let toml_str = r#"
|
|
||||||
[[agent]]
|
|
||||||
name = "coder"
|
|
||||||
runtime = "openai"
|
|
||||||
"#;
|
|
||||||
let err = ProjectConfig::parse(toml_str).unwrap_err();
|
|
||||||
assert!(err.contains("unknown runtime 'openai'"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── base_branch config ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn base_branch_defaults_to_none() {
|
|
||||||
let toml_str = r#"
|
|
||||||
[[agent]]
|
|
||||||
name = "coder"
|
|
||||||
"#;
|
|
||||||
let config = ProjectConfig::parse(toml_str).unwrap();
|
|
||||||
assert_eq!(config.base_branch, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn base_branch_parsed_when_set() {
|
|
||||||
let toml_str = r#"
|
|
||||||
base_branch = "main"
|
|
||||||
|
|
||||||
[[agent]]
|
|
||||||
name = "coder"
|
|
||||||
"#;
|
|
||||||
let config = ProjectConfig::parse(toml_str).unwrap();
|
|
||||||
assert_eq!(config.base_branch, Some("main".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn render_agent_args_uses_config_base_branch_when_caller_passes_none() {
|
|
||||||
let toml_str = r#"
|
|
||||||
base_branch = "develop"
|
|
||||||
|
|
||||||
[[agent]]
|
|
||||||
name = "coder"
|
|
||||||
prompt = "git difftool {{base_branch}}...HEAD"
|
|
||||||
"#;
|
|
||||||
let config = ProjectConfig::parse(toml_str).unwrap();
|
|
||||||
let (_, _, prompt) = config
|
|
||||||
.render_agent_args("/tmp/wt", "42_foo", None, None)
|
|
||||||
.unwrap();
|
|
||||||
assert!(
|
|
||||||
prompt.contains("develop"),
|
|
||||||
"Expected 'develop' in prompt, got: {prompt}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn render_agent_args_caller_base_branch_takes_precedence_over_config() {
|
|
||||||
let toml_str = r#"
|
|
||||||
base_branch = "develop"
|
|
||||||
|
|
||||||
[[agent]]
|
|
||||||
name = "coder"
|
|
||||||
prompt = "git difftool {{base_branch}}...HEAD"
|
|
||||||
"#;
|
|
||||||
let config = ProjectConfig::parse(toml_str).unwrap();
|
|
||||||
let (_, _, prompt) = config
|
|
||||||
.render_agent_args("/tmp/wt", "42_foo", None, Some("feature-x"))
|
|
||||||
.unwrap();
|
|
||||||
assert!(
|
|
||||||
prompt.contains("feature-x"),
|
|
||||||
"Caller-supplied base_branch should win, got: {prompt}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn project_toml_has_base_branch_master() {
|
|
||||||
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
|
||||||
let project_root = manifest_dir.parent().unwrap();
|
|
||||||
let config = ProjectConfig::load(project_root).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
config.base_branch,
|
|
||||||
Some("master".to_string()),
|
|
||||||
"project.toml must have base_branch = \"master\""
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rate_limit_notifications_defaults_to_true() {
|
|
||||||
let toml_str = r#"
|
|
||||||
[[agent]]
|
|
||||||
name = "coder"
|
|
||||||
"#;
|
|
||||||
let config = ProjectConfig::parse(toml_str).unwrap();
|
|
||||||
assert!(
|
|
||||||
config.rate_limit_notifications,
|
|
||||||
"rate_limit_notifications should default to true"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rate_limit_notifications_can_be_disabled() {
|
|
||||||
let toml_str = r#"
|
|
||||||
rate_limit_notifications = false
|
|
||||||
|
|
||||||
[[agent]]
|
|
||||||
name = "coder"
|
|
||||||
"#;
|
|
||||||
let config = ProjectConfig::parse(toml_str).unwrap();
|
|
||||||
assert!(!config.rate_limit_notifications);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn project_toml_has_three_sonnet_coders() {
|
|
||||||
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
|
||||||
let project_root = manifest_dir.parent().unwrap();
|
|
||||||
let config = ProjectConfig::load(project_root).unwrap();
|
|
||||||
|
|
||||||
let sonnet_coders: Vec<_> = config
|
|
||||||
.agent
|
|
||||||
.iter()
|
|
||||||
.filter(|a| a.stage.as_deref() == Some("coder") && a.model.as_deref() == Some("sonnet"))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
sonnet_coders.len(),
|
|
||||||
3,
|
|
||||||
"Expected 3 sonnet coders (coder-1, coder-2, coder-3), found {}",
|
|
||||||
sonnet_coders.len()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,628 @@
|
|||||||
|
use super::*;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_config_when_missing() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let config = ProjectConfig::load(tmp.path()).unwrap();
|
||||||
|
assert_eq!(config.agent.len(), 1);
|
||||||
|
assert_eq!(config.agent[0].name, "default");
|
||||||
|
assert!(config.component.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_multi_agent_toml() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[[component]]
|
||||||
|
name = "server"
|
||||||
|
path = "."
|
||||||
|
setup = ["cargo check"]
|
||||||
|
|
||||||
|
[[agent]]
|
||||||
|
name = "supervisor"
|
||||||
|
role = "Coordinates work"
|
||||||
|
model = "opus"
|
||||||
|
max_turns = 50
|
||||||
|
max_budget_usd = 10.00
|
||||||
|
system_prompt = "You are a senior engineer"
|
||||||
|
|
||||||
|
[[agent]]
|
||||||
|
name = "coder-1"
|
||||||
|
role = "Full-stack engineer"
|
||||||
|
model = "sonnet"
|
||||||
|
max_turns = 30
|
||||||
|
max_budget_usd = 5.00
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let config = ProjectConfig::parse(toml_str).unwrap();
|
||||||
|
assert_eq!(config.agent.len(), 2);
|
||||||
|
assert_eq!(config.agent[0].name, "supervisor");
|
||||||
|
assert_eq!(config.agent[0].role, "Coordinates work");
|
||||||
|
assert_eq!(config.agent[0].model, Some("opus".to_string()));
|
||||||
|
assert_eq!(config.agent[0].max_turns, Some(50));
|
||||||
|
assert_eq!(config.agent[0].max_budget_usd, Some(10.0));
|
||||||
|
assert_eq!(
|
||||||
|
config.agent[0].system_prompt,
|
||||||
|
Some("You are a senior engineer".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(config.agent[1].name, "coder-1");
|
||||||
|
assert_eq!(config.agent[1].model, Some("sonnet".to_string()));
|
||||||
|
assert_eq!(config.component.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_legacy_single_agent() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[[component]]
|
||||||
|
name = "server"
|
||||||
|
path = "."
|
||||||
|
|
||||||
|
[agent]
|
||||||
|
command = "claude"
|
||||||
|
args = ["--print", "--directory", "{{worktree_path}}"]
|
||||||
|
prompt = "Pick up story {{story_id}}"
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let config = ProjectConfig::parse(toml_str).unwrap();
|
||||||
|
assert_eq!(config.agent.len(), 1);
|
||||||
|
assert_eq!(config.agent[0].name, "default");
|
||||||
|
assert_eq!(config.agent[0].command, "claude");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_duplicate_names() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[[agent]]
|
||||||
|
name = "coder"
|
||||||
|
role = "Engineer"
|
||||||
|
|
||||||
|
[[agent]]
|
||||||
|
name = "coder"
|
||||||
|
role = "Another engineer"
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let err = ProjectConfig::parse(toml_str).unwrap_err();
|
||||||
|
assert!(err.contains("Duplicate agent name: 'coder'"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_empty_name() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[[agent]]
|
||||||
|
name = ""
|
||||||
|
role = "Engineer"
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let err = ProjectConfig::parse(toml_str).unwrap_err();
|
||||||
|
assert!(err.contains("Agent name must not be empty"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_non_positive_budget() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[[agent]]
|
||||||
|
name = "coder"
|
||||||
|
max_budget_usd = -1.0
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let err = ProjectConfig::parse(toml_str).unwrap_err();
|
||||||
|
assert!(err.contains("must be positive"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_zero_max_turns() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[[agent]]
|
||||||
|
name = "coder"
|
||||||
|
max_turns = 0
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let err = ProjectConfig::parse(toml_str).unwrap_err();
|
||||||
|
assert!(err.contains("max_turns must be positive"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_agent_args_default() {
|
||||||
|
let config = ProjectConfig::default();
|
||||||
|
let (cmd, args, prompt) = config
|
||||||
|
.render_agent_args("/tmp/wt", "42_foo", None, None)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(cmd, "claude");
|
||||||
|
assert!(args.is_empty());
|
||||||
|
assert!(prompt.contains("42_foo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_agent_args_by_name() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[[agent]]
|
||||||
|
name = "supervisor"
|
||||||
|
model = "opus"
|
||||||
|
max_turns = 50
|
||||||
|
max_budget_usd = 10.00
|
||||||
|
system_prompt = "You lead story {{story_id}}"
|
||||||
|
allowed_tools = ["Read", "Write", "Bash"]
|
||||||
|
|
||||||
|
[[agent]]
|
||||||
|
name = "coder"
|
||||||
|
model = "sonnet"
|
||||||
|
max_turns = 30
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let config = ProjectConfig::parse(toml_str).unwrap();
|
||||||
|
let (cmd, args, prompt) = config
|
||||||
|
.render_agent_args("/tmp/wt", "42_foo", Some("supervisor"), Some("master"))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(cmd, "claude");
|
||||||
|
assert!(args.contains(&"--model".to_string()));
|
||||||
|
assert!(args.contains(&"opus".to_string()));
|
||||||
|
assert!(args.contains(&"--max-turns".to_string()));
|
||||||
|
assert!(args.contains(&"50".to_string()));
|
||||||
|
assert!(args.contains(&"--max-budget-usd".to_string()));
|
||||||
|
assert!(args.contains(&"10".to_string()));
|
||||||
|
assert!(args.contains(&"--allowedTools".to_string()));
|
||||||
|
assert!(args.contains(&"Read,Write,Bash".to_string()));
|
||||||
|
assert!(args.contains(&"--append-system-prompt".to_string()));
|
||||||
|
// System prompt should have template rendered
|
||||||
|
assert!(args.contains(&"You lead story 42_foo".to_string()));
|
||||||
|
assert!(prompt.contains("42_foo"));
|
||||||
|
|
||||||
|
// Render for coder
|
||||||
|
let (_, coder_args, _) = config
|
||||||
|
.render_agent_args("/tmp/wt", "42_foo", Some("coder"), Some("master"))
|
||||||
|
.unwrap();
|
||||||
|
assert!(coder_args.contains(&"sonnet".to_string()));
|
||||||
|
assert!(coder_args.contains(&"30".to_string()));
|
||||||
|
assert!(!coder_args.contains(&"--max-budget-usd".to_string()));
|
||||||
|
assert!(!coder_args.contains(&"--append-system-prompt".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_agent_args_not_found() {
|
||||||
|
let config = ProjectConfig::default();
|
||||||
|
let result = config.render_agent_args("/tmp/wt", "42_foo", Some("nonexistent"), None);
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().contains("No agent named 'nonexistent'"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_agent_and_default() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[[agent]]
|
||||||
|
name = "first"
|
||||||
|
|
||||||
|
[[agent]]
|
||||||
|
name = "second"
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let config = ProjectConfig::parse(toml_str).unwrap();
|
||||||
|
assert_eq!(config.default_agent().unwrap().name, "first");
|
||||||
|
assert_eq!(config.find_agent("second").unwrap().name, "second");
|
||||||
|
assert!(config.find_agent("missing").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_project_toml_from_file() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let sk = tmp.path().join(".huskies");
|
||||||
|
fs::create_dir_all(&sk).unwrap();
|
||||||
|
fs::write(
|
||||||
|
sk.join("project.toml"),
|
||||||
|
r#"
|
||||||
|
[[component]]
|
||||||
|
name = "server"
|
||||||
|
path = "."
|
||||||
|
setup = ["cargo check"]
|
||||||
|
teardown = []
|
||||||
|
|
||||||
|
[[component]]
|
||||||
|
name = "frontend"
|
||||||
|
path = "frontend"
|
||||||
|
setup = ["pnpm install"]
|
||||||
|
|
||||||
|
[[agent]]
|
||||||
|
name = "main"
|
||||||
|
command = "claude"
|
||||||
|
args = ["--print", "--directory", "{{worktree_path}}"]
|
||||||
|
prompt = "Pick up story {{story_id}}"
|
||||||
|
model = "sonnet"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let config = ProjectConfig::load(tmp.path()).unwrap();
|
||||||
|
assert_eq!(config.component.len(), 2);
|
||||||
|
assert_eq!(config.component[0].name, "server");
|
||||||
|
assert_eq!(config.component[1].setup, vec!["pnpm install"]);
|
||||||
|
assert_eq!(config.agent.len(), 1);
|
||||||
|
assert_eq!(config.agent[0].name, "main");
|
||||||
|
assert_eq!(config.agent[0].model, Some("sonnet".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agents_toml_overrides_project_toml_agents() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let sk = tmp.path().join(".huskies");
|
||||||
|
fs::create_dir_all(&sk).unwrap();
|
||||||
|
// project.toml has inline agents
|
||||||
|
fs::write(
|
||||||
|
sk.join("project.toml"),
|
||||||
|
r#"
|
||||||
|
[[agent]]
|
||||||
|
name = "from-project-toml"
|
||||||
|
model = "sonnet"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
// agents.toml overrides with different agents
|
||||||
|
fs::write(
|
||||||
|
sk.join("agents.toml"),
|
||||||
|
r#"
|
||||||
|
[[agent]]
|
||||||
|
name = "from-agents-toml"
|
||||||
|
model = "opus"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let config = ProjectConfig::load(tmp.path()).unwrap();
|
||||||
|
assert_eq!(config.agent.len(), 1);
|
||||||
|
assert_eq!(config.agent[0].name, "from-agents-toml");
|
||||||
|
assert_eq!(config.agent[0].model, Some("opus".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agents_toml_absent_falls_back_to_project_toml() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let sk = tmp.path().join(".huskies");
|
||||||
|
fs::create_dir_all(&sk).unwrap();
|
||||||
|
fs::write(
|
||||||
|
sk.join("project.toml"),
|
||||||
|
r#"
|
||||||
|
[[agent]]
|
||||||
|
name = "inline-agent"
|
||||||
|
model = "sonnet"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
// No agents.toml — should use inline agents from project.toml
|
||||||
|
|
||||||
|
let config = ProjectConfig::load(tmp.path()).unwrap();
|
||||||
|
assert_eq!(config.agent.len(), 1);
|
||||||
|
assert_eq!(config.agent[0].name, "inline-agent");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── WatcherConfig ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn watcher_config_defaults_when_omitted() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[[agent]]
|
||||||
|
name = "coder"
|
||||||
|
"#;
|
||||||
|
let config = ProjectConfig::parse(toml_str).unwrap();
|
||||||
|
assert_eq!(config.watcher.sweep_interval_secs, 60);
|
||||||
|
assert_eq!(config.watcher.done_retention_secs, 4 * 60 * 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn watcher_config_custom_values() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[watcher]
|
||||||
|
sweep_interval_secs = 30
|
||||||
|
done_retention_secs = 7200
|
||||||
|
|
||||||
|
[[agent]]
|
||||||
|
name = "coder"
|
||||||
|
"#;
|
||||||
|
let config = ProjectConfig::parse(toml_str).unwrap();
|
||||||
|
assert_eq!(config.watcher.sweep_interval_secs, 30);
|
||||||
|
assert_eq!(config.watcher.done_retention_secs, 7200);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn watcher_config_partial_override() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[watcher]
|
||||||
|
sweep_interval_secs = 10
|
||||||
|
|
||||||
|
[[agent]]
|
||||||
|
name = "coder"
|
||||||
|
"#;
|
||||||
|
let config = ProjectConfig::parse(toml_str).unwrap();
|
||||||
|
assert_eq!(config.watcher.sweep_interval_secs, 10);
|
||||||
|
// done_retention_secs should fall back to the default (4 hours).
|
||||||
|
assert_eq!(config.watcher.done_retention_secs, 4 * 60 * 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn watcher_config_from_file() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let sk = tmp.path().join(".huskies");
|
||||||
|
fs::create_dir_all(&sk).unwrap();
|
||||||
|
fs::write(
|
||||||
|
sk.join("project.toml"),
|
||||||
|
r#"
|
||||||
|
[watcher]
|
||||||
|
sweep_interval_secs = 120
|
||||||
|
done_retention_secs = 3600
|
||||||
|
|
||||||
|
[[agent]]
|
||||||
|
name = "coder"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let config = ProjectConfig::load(tmp.path()).unwrap();
|
||||||
|
assert_eq!(config.watcher.sweep_interval_secs, 120);
|
||||||
|
assert_eq!(config.watcher.done_retention_secs, 3600);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn watcher_config_default_when_no_file() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let config = ProjectConfig::load(tmp.path()).unwrap();
|
||||||
|
assert_eq!(config.watcher, WatcherConfig::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn coder_agents_have_root_cause_guidance() {
|
||||||
|
// Load the actual project.toml and verify all coder-stage agents
|
||||||
|
// include root cause investigation guidance for bugs.
|
||||||
|
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||||
|
let project_root = manifest_dir.parent().unwrap();
|
||||||
|
let config = ProjectConfig::load(project_root).unwrap();
|
||||||
|
|
||||||
|
let coder_agents: Vec<_> = config
|
||||||
|
.agent
|
||||||
|
.iter()
|
||||||
|
.filter(|a| a.stage.as_deref() == Some("coder"))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!coder_agents.is_empty(),
|
||||||
|
"Expected at least one coder-stage agent in project.toml"
|
||||||
|
);
|
||||||
|
|
||||||
|
for agent in coder_agents {
|
||||||
|
let prompt = &agent.prompt;
|
||||||
|
let system_prompt = agent.system_prompt.as_deref().unwrap_or("");
|
||||||
|
let combined = format!("{prompt} {system_prompt}");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
combined.contains("Bug Workflow") || combined.contains("trust the story"),
|
||||||
|
"Coder agent '{}' must include bug workflow guidance in prompt or system_prompt",
|
||||||
|
agent.name
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
combined.contains("surgical") || combined.to_lowercase().contains("minimal"),
|
||||||
|
"Coder agent '{}' must discourage adding abstractions/workarounds",
|
||||||
|
agent.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn watcher_config_preserved_in_legacy_format() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[watcher]
|
||||||
|
sweep_interval_secs = 15
|
||||||
|
done_retention_secs = 900
|
||||||
|
|
||||||
|
[agent]
|
||||||
|
command = "claude"
|
||||||
|
"#;
|
||||||
|
let config = ProjectConfig::parse(toml_str).unwrap();
|
||||||
|
assert_eq!(config.watcher.sweep_interval_secs, 15);
|
||||||
|
assert_eq!(config.watcher.done_retention_secs, 900);
|
||||||
|
assert_eq!(config.agent.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── default_coder_model & max_coders ─────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_default_coder_model_and_max_coders() {
|
||||||
|
let toml_str = r#"
|
||||||
|
default_coder_model = "sonnet"
|
||||||
|
max_coders = 3
|
||||||
|
|
||||||
|
[[agent]]
|
||||||
|
name = "coder-1"
|
||||||
|
stage = "coder"
|
||||||
|
model = "sonnet"
|
||||||
|
|
||||||
|
[[agent]]
|
||||||
|
name = "coder-opus"
|
||||||
|
stage = "coder"
|
||||||
|
model = "opus"
|
||||||
|
"#;
|
||||||
|
let config = ProjectConfig::parse(toml_str).unwrap();
|
||||||
|
assert_eq!(config.default_coder_model, Some("sonnet".to_string()));
|
||||||
|
assert_eq!(config.max_coders, Some(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_coder_model_and_max_coders_default_to_none() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[[agent]]
|
||||||
|
name = "coder-1"
|
||||||
|
"#;
|
||||||
|
let config = ProjectConfig::parse(toml_str).unwrap();
|
||||||
|
assert_eq!(config.default_coder_model, None);
|
||||||
|
assert_eq!(config.max_coders, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn project_toml_has_default_coder_model_and_max_coders() {
|
||||||
|
// Verify the actual project.toml has the new settings.
|
||||||
|
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||||
|
let project_root = manifest_dir.parent().unwrap();
|
||||||
|
let config = ProjectConfig::load(project_root).unwrap();
|
||||||
|
assert_eq!(config.default_coder_model, Some("sonnet".to_string()));
|
||||||
|
assert_eq!(config.max_coders, Some(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── runtime config ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_defaults_to_none() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[[agent]]
|
||||||
|
name = "coder"
|
||||||
|
"#;
|
||||||
|
let config = ProjectConfig::parse(toml_str).unwrap();
|
||||||
|
assert_eq!(config.agent[0].runtime, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_claude_code_accepted() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[[agent]]
|
||||||
|
name = "coder"
|
||||||
|
runtime = "claude-code"
|
||||||
|
"#;
|
||||||
|
let config = ProjectConfig::parse(toml_str).unwrap();
|
||||||
|
assert_eq!(config.agent[0].runtime, Some("claude-code".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_gemini_accepted() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[[agent]]
|
||||||
|
name = "coder"
|
||||||
|
runtime = "gemini"
|
||||||
|
model = "gemini-2.5-pro"
|
||||||
|
"#;
|
||||||
|
let config = ProjectConfig::parse(toml_str).unwrap();
|
||||||
|
assert_eq!(config.agent[0].runtime, Some("gemini".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_unknown_rejected() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[[agent]]
|
||||||
|
name = "coder"
|
||||||
|
runtime = "openai"
|
||||||
|
"#;
|
||||||
|
let err = ProjectConfig::parse(toml_str).unwrap_err();
|
||||||
|
assert!(err.contains("unknown runtime 'openai'"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── base_branch config ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn base_branch_defaults_to_none() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[[agent]]
|
||||||
|
name = "coder"
|
||||||
|
"#;
|
||||||
|
let config = ProjectConfig::parse(toml_str).unwrap();
|
||||||
|
assert_eq!(config.base_branch, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn base_branch_parsed_when_set() {
|
||||||
|
let toml_str = r#"
|
||||||
|
base_branch = "main"
|
||||||
|
|
||||||
|
[[agent]]
|
||||||
|
name = "coder"
|
||||||
|
"#;
|
||||||
|
let config = ProjectConfig::parse(toml_str).unwrap();
|
||||||
|
assert_eq!(config.base_branch, Some("main".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_agent_args_uses_config_base_branch_when_caller_passes_none() {
|
||||||
|
let toml_str = r#"
|
||||||
|
base_branch = "develop"
|
||||||
|
|
||||||
|
[[agent]]
|
||||||
|
name = "coder"
|
||||||
|
prompt = "git difftool {{base_branch}}...HEAD"
|
||||||
|
"#;
|
||||||
|
let config = ProjectConfig::parse(toml_str).unwrap();
|
||||||
|
let (_, _, prompt) = config
|
||||||
|
.render_agent_args("/tmp/wt", "42_foo", None, None)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
prompt.contains("develop"),
|
||||||
|
"Expected 'develop' in prompt, got: {prompt}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_agent_args_caller_base_branch_takes_precedence_over_config() {
|
||||||
|
let toml_str = r#"
|
||||||
|
base_branch = "develop"
|
||||||
|
|
||||||
|
[[agent]]
|
||||||
|
name = "coder"
|
||||||
|
prompt = "git difftool {{base_branch}}...HEAD"
|
||||||
|
"#;
|
||||||
|
let config = ProjectConfig::parse(toml_str).unwrap();
|
||||||
|
let (_, _, prompt) = config
|
||||||
|
.render_agent_args("/tmp/wt", "42_foo", None, Some("feature-x"))
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
prompt.contains("feature-x"),
|
||||||
|
"Caller-supplied base_branch should win, got: {prompt}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn project_toml_has_base_branch_master() {
|
||||||
|
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||||
|
let project_root = manifest_dir.parent().unwrap();
|
||||||
|
let config = ProjectConfig::load(project_root).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
config.base_branch,
|
||||||
|
Some("master".to_string()),
|
||||||
|
"project.toml must have base_branch = \"master\""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rate_limit_notifications_defaults_to_true() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[[agent]]
|
||||||
|
name = "coder"
|
||||||
|
"#;
|
||||||
|
let config = ProjectConfig::parse(toml_str).unwrap();
|
||||||
|
assert!(
|
||||||
|
config.rate_limit_notifications,
|
||||||
|
"rate_limit_notifications should default to true"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rate_limit_notifications_can_be_disabled() {
|
||||||
|
let toml_str = r#"
|
||||||
|
rate_limit_notifications = false
|
||||||
|
|
||||||
|
[[agent]]
|
||||||
|
name = "coder"
|
||||||
|
"#;
|
||||||
|
let config = ProjectConfig::parse(toml_str).unwrap();
|
||||||
|
assert!(!config.rate_limit_notifications);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn project_toml_has_three_sonnet_coders() {
|
||||||
|
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||||
|
let project_root = manifest_dir.parent().unwrap();
|
||||||
|
let config = ProjectConfig::load(project_root).unwrap();
|
||||||
|
|
||||||
|
let sonnet_coders: Vec<_> = config
|
||||||
|
.agent
|
||||||
|
.iter()
|
||||||
|
.filter(|a| a.stage.as_deref() == Some("coder") && a.model.as_deref() == Some("sonnet"))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
sonnet_coders.len(),
|
||||||
|
3,
|
||||||
|
"Expected 3 sonnet coders (coder-1, coder-2, coder-3), found {}",
|
||||||
|
sonnet_coders.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user