Files
huskies/server/src/config.rs
T

1164 lines
38 KiB
Rust

//! Project configuration — parses `project.toml` for agents, components, and server settings.
use crate::slog;
use serde::Deserialize;
use std::collections::HashSet;
use std::path::Path;
#[derive(Debug, Clone, Deserialize)]
pub struct ProjectConfig {
#[serde(default)]
pub component: Vec<ComponentConfig>,
#[serde(default)]
pub agent: Vec<AgentConfig>,
#[serde(default)]
pub watcher: WatcherConfig,
/// Project-wide default QA mode: "server", "agent", or "human".
/// Per-story `qa` front matter overrides this. Default: "server".
#[serde(default = "default_qa")]
pub default_qa: String,
/// Default model for coder-stage agents (e.g. "sonnet").
/// When set, `find_free_agent_for_stage` only considers coder agents whose
/// model matches this value, so opus agents are only used when explicitly
/// requested via story front matter `agent:` field.
#[serde(default)]
pub default_coder_model: Option<String>,
/// Maximum number of concurrent coder-stage agents.
/// When set, `auto_assign_available_work` will not start more than this many
/// coder agents at once. Stories wait in `2_current/` until a slot frees up.
#[serde(default)]
pub max_coders: Option<usize>,
/// Maximum number of retries per story per pipeline stage before marking as blocked.
/// Default: 2. Set to 0 to disable retry limits.
#[serde(default = "default_max_retries")]
pub max_retries: u32,
/// Optional base branch name (e.g. "main", "master", "develop").
/// When set, overrides the auto-detection logic (`detect_base_branch`) for all
/// worktree creation, merge operations, and agent prompt `{{base_branch}}` substitution.
/// When not set, the system falls back to `detect_base_branch` (reads current HEAD).
#[serde(default)]
pub base_branch: Option<String>,
/// Whether to send `RateLimitWarning` chat notifications.
/// Set to `false` to suppress noisy soft rate-limit warnings while still
/// receiving `RateLimitHardBlock` and `StoryBlocked` notifications.
/// Default: `true`.
#[serde(default = "default_rate_limit_notifications")]
pub rate_limit_notifications: bool,
/// Whether the web UI WebSocket consumer subscribes to the status broadcaster.
/// Set to `false` to disable status event forwarding to the web UI without
/// affecting other consumers (chat transports, agent context).
/// Default: `true`.
#[serde(default = "default_web_ui_status_consumer")]
pub web_ui_status_consumer: bool,
/// IANA timezone name (e.g. `"Europe/London"`, `"America/New_York"`).
/// When set, timer HH:MM inputs are interpreted in this timezone instead
/// of the container/host local time. Falls back to `chrono::Local` when absent.
#[serde(default)]
pub timezone: Option<String>,
/// WebSocket URL of a remote huskies node to sync CRDT state with.
/// Example: `rendezvous = "ws://server:3001/crdt-sync"`
/// When set, this node connects to the remote and exchanges CRDT ops
/// so both machines see the same pipeline state in real-time.
#[serde(default)]
pub rendezvous: Option<String>,
/// Hex-encoded Ed25519 public keys of trusted peers for WebSocket mutual auth.
/// When present, only peers whose pubkey is in this list are allowed to connect.
/// When empty or missing, all peers are rejected (closed-by-default).
#[serde(default)]
pub trusted_keys: Vec<String>,
/// When `true`, `/crdt-sync` WebSocket connections must supply a valid
/// `?token=<bearer-token>` query parameter or receive HTTP 401.
/// Defaults to `false` so trusted-network deployments keep the current
/// open behaviour.
#[serde(default)]
pub crdt_require_token: bool,
/// Static bearer tokens accepted for `/crdt-sync` connections.
/// Each entry is a raw token string; tokens expire 30 days after the
/// server starts. Only meaningful when `crdt_require_token` is `true`.
#[serde(default)]
pub crdt_tokens: Vec<String>,
/// Maximum number of supplementary mesh peer connections an agent opens.
/// The mesh discovery loop reads the CRDT `nodes` list and connects to up
/// to this many alive peers in addition to the primary rendezvous connection.
/// Default: 3. Set to 0 to disable mesh discovery entirely.
#[serde(default = "default_max_mesh_peers")]
pub max_mesh_peers: usize,
}
/// Configuration for the filesystem watcher's sweep behaviour.
///
/// Controls how often the watcher checks `5_done/` for items to promote to
/// `6_archived/`, and how long items must remain in `5_done/` before promotion.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct WatcherConfig {
/// How often (in seconds) to check `5_done/` for items to archive.
/// Default: 60 seconds.
#[serde(default = "default_sweep_interval_secs")]
pub sweep_interval_secs: u64,
/// How long (in seconds) an item must remain in `5_done/` before being
/// moved to `6_archived/`. Default: 14400 (4 hours).
#[serde(default = "default_done_retention_secs")]
pub done_retention_secs: u64,
}
impl Default for WatcherConfig {
fn default() -> Self {
Self {
sweep_interval_secs: default_sweep_interval_secs(),
done_retention_secs: default_done_retention_secs(),
}
}
}
fn default_sweep_interval_secs() -> u64 {
60
}
fn default_done_retention_secs() -> u64 {
4 * 60 * 60 // 4 hours
}
fn default_qa() -> String {
"server".to_string()
}
fn default_max_retries() -> u32 {
2
}
fn default_rate_limit_notifications() -> bool {
true
}
fn default_web_ui_status_consumer() -> bool {
true
}
fn default_max_mesh_peers() -> usize {
3
}
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct ComponentConfig {
pub name: String,
#[serde(default = "default_path")]
pub path: String,
#[serde(default)]
pub setup: Vec<String>,
#[serde(default)]
pub teardown: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AgentConfig {
#[serde(default = "default_agent_name")]
pub name: String,
#[serde(default)]
pub role: String,
#[serde(default = "default_agent_command")]
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default = "default_agent_prompt")]
pub prompt: String,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub allowed_tools: Option<Vec<String>>,
#[serde(default)]
pub max_turns: Option<u32>,
#[serde(default)]
pub max_budget_usd: Option<f64>,
#[serde(default)]
pub system_prompt: Option<String>,
/// Pipeline stage this agent belongs to. Supported values: "coder", "qa",
/// "mergemaster", "other". When set, overrides the legacy name-based
/// detection used by `pipeline_stage()`.
#[serde(default)]
pub stage: Option<String>,
/// Inactivity timeout in seconds for the PTY read loop.
/// If no output is received within this duration, the agent process is killed
/// and marked as Failed. Default: 300 (5 minutes). Set to 0 to disable.
#[serde(default = "default_inactivity_timeout_secs")]
pub inactivity_timeout_secs: u64,
/// Agent runtime backend. Controls how the agent process is spawned and
/// how events are streamed. Default: `"claude-code"` (spawns the `claude`
/// CLI in a PTY). Future values: `"openai"`, `"gemini"`.
#[serde(default)]
pub runtime: Option<String>,
}
fn default_path() -> String {
".".to_string()
}
fn default_agent_name() -> String {
"default".to_string()
}
fn default_inactivity_timeout_secs() -> u64 {
300
}
fn default_agent_command() -> String {
"claude".to_string()
}
fn default_agent_prompt() -> String {
"You are working in a git worktree on story {{story_id}}. \
Read .huskies/README.md to understand the dev process, then pick up the story. \
Commit all your work when done — the server will automatically run acceptance \
gates (cargo clippy + tests) when your process exits."
.to_string()
}
/// Legacy config format with `agent` as an optional single table (`[agent]`).
#[derive(Debug, Deserialize)]
struct LegacyProjectConfig {
#[serde(default)]
component: Vec<ComponentConfig>,
agent: Option<AgentConfig>,
#[serde(default)]
watcher: WatcherConfig,
#[serde(default = "default_qa")]
default_qa: String,
#[serde(default)]
default_coder_model: Option<String>,
#[serde(default)]
max_coders: Option<usize>,
#[serde(default = "default_max_retries")]
max_retries: u32,
#[serde(default)]
base_branch: Option<String>,
#[serde(default = "default_rate_limit_notifications")]
rate_limit_notifications: bool,
#[serde(default)]
timezone: Option<String>,
}
impl Default for ProjectConfig {
fn default() -> Self {
Self {
component: Vec::new(),
agent: vec![AgentConfig {
name: default_agent_name(),
role: String::new(),
command: default_agent_command(),
args: vec![],
prompt: default_agent_prompt(),
model: None,
allowed_tools: None,
max_turns: None,
max_budget_usd: None,
system_prompt: None,
stage: None,
inactivity_timeout_secs: default_inactivity_timeout_secs(),
runtime: None,
}],
watcher: WatcherConfig::default(),
default_qa: default_qa(),
default_coder_model: None,
max_coders: None,
max_retries: default_max_retries(),
base_branch: None,
rate_limit_notifications: default_rate_limit_notifications(),
web_ui_status_consumer: default_web_ui_status_consumer(),
timezone: None,
rendezvous: None,
trusted_keys: Vec::new(),
crdt_require_token: false,
crdt_tokens: Vec::new(),
max_mesh_peers: default_max_mesh_peers(),
}
}
}
/// Config parsed from `.huskies/agents.toml` — agent definitions only.
#[derive(Debug, Deserialize)]
struct AgentsConfig {
#[serde(default)]
agent: Vec<AgentConfig>,
}
impl ProjectConfig {
/// Load from `.huskies/project.toml` relative to the given root,
/// then overlay agents from `.huskies/agents.toml` if present.
///
/// Loading order:
/// 1. Project settings (watcher, default_qa, etc.) always come from `project.toml`.
/// 2. Agent definitions come from `agents.toml` when that file exists.
/// 3. Falls back to inline `[[agent]]` blocks in `project.toml` for backwards
/// compatibility with projects that haven't migrated yet.
/// 4. Falls back to a single default agent when neither file defines agents.
pub fn load(project_root: &Path) -> Result<Self, String> {
let config_path = project_root.join(".huskies/project.toml");
let mut config = if config_path.exists() {
let content =
std::fs::read_to_string(&config_path).map_err(|e| format!("Read config: {e}"))?;
Self::parse(&content)?
} else {
Self::default()
};
// agents.toml takes priority over inline [[agent]] in project.toml.
let agents_path = project_root.join(".huskies/agents.toml");
if agents_path.exists() {
let content = std::fs::read_to_string(&agents_path)
.map_err(|e| format!("Read agents.toml: {e}"))?;
let agents_cfg: AgentsConfig =
toml::from_str(&content).map_err(|e| format!("Parse agents.toml: {e}"))?;
if !agents_cfg.agent.is_empty() {
validate_agents(&agents_cfg.agent)?;
config.agent = agents_cfg.agent;
}
}
Ok(config)
}
/// Parse config from a TOML string, supporting both new and legacy formats.
pub fn parse(content: &str) -> Result<Self, String> {
// Try new format first (agent as array of tables)
match toml::from_str::<ProjectConfig>(content) {
Ok(config) if !config.agent.is_empty() => {
validate_agents(&config.agent)?;
Ok(config)
}
Ok(config) => {
// Parsed successfully but no agents — could be legacy or no agent section.
// Try legacy format.
if let Ok(legacy) = toml::from_str::<LegacyProjectConfig>(content)
&& let Some(agent) = legacy.agent
{
slog!(
"[config] Warning: [agent] table is deprecated. \
Use [[agent]] array format instead."
);
let config = ProjectConfig {
component: legacy.component,
agent: vec![agent],
watcher: legacy.watcher,
default_qa: legacy.default_qa,
default_coder_model: legacy.default_coder_model,
max_coders: legacy.max_coders,
max_retries: legacy.max_retries,
base_branch: legacy.base_branch,
rate_limit_notifications: legacy.rate_limit_notifications,
web_ui_status_consumer: default_web_ui_status_consumer(),
timezone: legacy.timezone,
rendezvous: None,
trusted_keys: Vec::new(),
crdt_require_token: false,
crdt_tokens: Vec::new(),
max_mesh_peers: default_max_mesh_peers(),
};
validate_agents(&config.agent)?;
return Ok(config);
}
// No agent section at all
Ok(config)
}
Err(_) => {
// New format failed — try legacy
let legacy: LegacyProjectConfig =
toml::from_str(content).map_err(|e| format!("Parse config: {e}"))?;
if let Some(agent) = legacy.agent {
slog!(
"[config] Warning: [agent] table is deprecated. \
Use [[agent]] array format instead."
);
let config = ProjectConfig {
component: legacy.component,
agent: vec![agent],
watcher: legacy.watcher,
default_qa: legacy.default_qa,
default_coder_model: legacy.default_coder_model,
max_coders: legacy.max_coders,
max_retries: legacy.max_retries,
base_branch: legacy.base_branch,
rate_limit_notifications: legacy.rate_limit_notifications,
web_ui_status_consumer: default_web_ui_status_consumer(),
timezone: legacy.timezone,
rendezvous: None,
trusted_keys: Vec::new(),
crdt_require_token: false,
crdt_tokens: Vec::new(),
max_mesh_peers: default_max_mesh_peers(),
};
validate_agents(&config.agent)?;
Ok(config)
} else {
Ok(ProjectConfig {
component: legacy.component,
agent: Vec::new(),
watcher: legacy.watcher,
default_qa: legacy.default_qa,
default_coder_model: legacy.default_coder_model,
max_coders: legacy.max_coders,
max_retries: legacy.max_retries,
base_branch: legacy.base_branch,
rate_limit_notifications: legacy.rate_limit_notifications,
web_ui_status_consumer: default_web_ui_status_consumer(),
timezone: legacy.timezone,
rendezvous: None,
trusted_keys: Vec::new(),
crdt_require_token: false,
crdt_tokens: Vec::new(),
max_mesh_peers: default_max_mesh_peers(),
})
}
}
}
}
/// Return the project-wide default QA mode parsed from `default_qa`.
/// Falls back to `Server` if the value is unrecognised.
pub fn default_qa_mode(&self) -> crate::io::story_metadata::QaMode {
crate::io::story_metadata::QaMode::from_str(&self.default_qa)
.unwrap_or(crate::io::story_metadata::QaMode::Server)
}
/// Look up an agent config by name.
pub fn find_agent(&self, name: &str) -> Option<&AgentConfig> {
self.agent.iter().find(|a| a.name == name)
}
/// Get the default (first) agent config.
pub fn default_agent(&self) -> Option<&AgentConfig> {
self.agent.first()
}
/// Render template variables in agent args and prompt for the given agent.
/// If `agent_name` is None, uses the first (default) agent.
pub fn render_agent_args(
&self,
worktree_path: &str,
story_id: &str,
agent_name: Option<&str>,
base_branch: Option<&str>,
) -> Result<(String, Vec<String>, String), String> {
let agent = match agent_name {
Some(name) => self
.find_agent(name)
.ok_or_else(|| format!("No agent named '{name}' in config"))?,
None => self
.default_agent()
.ok_or_else(|| "No agents configured".to_string())?,
};
let bb = base_branch
.or(self.base_branch.as_deref())
.unwrap_or("master");
let aname = agent.name.as_str();
let render = |s: &str| {
s.replace("{{worktree_path}}", worktree_path)
.replace("{{story_id}}", story_id)
.replace("{{base_branch}}", bb)
.replace("{{agent_name}}", aname)
};
let command = render(&agent.command);
let mut args: Vec<String> = agent.args.iter().map(|a| render(a)).collect();
let prompt = render(&agent.prompt);
// Append structured CLI flags
if let Some(ref model) = agent.model {
args.push("--model".to_string());
args.push(model.clone());
}
if let Some(ref tools) = agent.allowed_tools
&& !tools.is_empty()
{
args.push("--allowedTools".to_string());
args.push(tools.join(","));
}
if let Some(turns) = agent.max_turns {
args.push("--max-turns".to_string());
args.push(turns.to_string());
}
if let Some(budget) = agent.max_budget_usd {
args.push("--max-budget-usd".to_string());
args.push(budget.to_string());
}
if let Some(ref sp) = agent.system_prompt {
args.push("--append-system-prompt".to_string());
args.push(render(sp));
}
Ok((command, args, prompt))
}
}
/// Validate agent configs: no duplicate names, no empty names, positive budgets/turns.
fn validate_agents(agents: &[AgentConfig]) -> Result<(), String> {
let mut names = HashSet::new();
for agent in agents {
if agent.name.trim().is_empty() {
return Err("Agent name must not be empty".to_string());
}
if !names.insert(&agent.name) {
return Err(format!("Duplicate agent name: '{}'", agent.name));
}
if let Some(budget) = agent.max_budget_usd
&& budget <= 0.0
{
return Err(format!(
"Agent '{}': max_budget_usd must be positive, got {budget}",
agent.name
));
}
if let Some(turns) = agent.max_turns
&& turns == 0
{
return Err(format!(
"Agent '{}': max_turns must be positive, got 0",
agent.name
));
}
if let Some(ref runtime) = agent.runtime {
match runtime.as_str() {
"claude-code" | "gemini" => {}
other => {
return Err(format!(
"Agent '{}': unknown runtime '{other}'. Supported: 'claude-code', 'gemini'",
agent.name
));
}
}
}
}
Ok(())
}
#[cfg(test)]
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()
);
}
}