mod agent;
mod daemon;
mod guardrails;
mod memory;
mod persona;
mod provider;
mod sensor;
pub use agent::{
AgentConfig, AgentProviderConfig, ContextStrategyConfig, DispatchMode, McpResourceMode,
McpServerEntry, OrchestratorConfig, SessionPruneConfigToml, SpawnConfig,
};
pub use daemon::{
ActiveHoursConfig, AuthConfig, DaemonAuditConfig, DaemonConfig, DaemonMcpServerConfig,
DaemonMemoryConfig, HeartbitPulseConfig, IdempotencyConfig, KafkaConfig, MetricsConfig,
ScheduleEntry, TokenExchangeConfig, WsConfig,
};
pub use guardrails::{
ActionBudgetConfig, ActionBudgetRuleConfig, BehavioralConfig, BehavioralRuleConfig,
GuardrailsConfig, InjectionConfig, InputConstraintConfig, LlmJudgeConfig, PiiConfig,
SecretPatternConfig, SecretScanConfig, ToolPolicyConfig, ToolPolicyRuleConfig,
};
pub use memory::{
EmbeddingConfig, KnowledgeConfig, KnowledgeSourceConfig, LspConfig, MemoryConfig,
RestateConfig, TelemetryConfig, WorkspaceConfig,
};
pub use persona::{PersonaConfig, PersonaPhase};
pub use provider::{
CascadeConfig, CascadeGateConfig, CascadeTierConfig, ProviderCircuitConfig, ProviderConfig,
RetryProviderConfig,
};
pub use sensor::{
SalienceConfig, SensorConfig, SensorRoutingConfig, SensorSourceConfig, StoryCorrelationConfig,
TokenBudgetConfig,
};
pub use crate::agent::routing::RoutingMode;
use serde::{Deserialize, Serialize};
use crate::Error;
use crate::agent::permission::PermissionRule;
use crate::agent::tool_filter::ToolProfile;
use crate::llm::types::ReasoningEffort;
pub const KNOWN_BUILTINS: &[&str] = &[
"bash",
"read",
"write",
"edit",
"grep",
"glob",
"list",
"patch",
"webfetch",
"websearch",
"image_generate",
"tts",
"skill",
"todoread",
"todowrite",
"question",
"twitter_post",
"todo_manage",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SensorModality {
Text,
Image,
Audio,
Structured,
}
impl std::fmt::Display for SensorModality {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SensorModality::Text => write!(f, "text"),
SensorModality::Image => write!(f, "image"),
SensorModality::Audio => write!(f, "audio"),
SensorModality::Structured => write!(f, "structured"),
}
}
}
pub use crate::types::TrustLevel;
pub fn parse_workflow_type(s: &str) -> Result<crate::agent::workflow::WorkflowType, Error> {
use crate::agent::workflow::WorkflowType;
match s.to_lowercase().as_str() {
"dag" => Ok(WorkflowType::Dag),
"sequential" => Ok(WorkflowType::Sequential),
"parallel" => Ok(WorkflowType::Parallel),
"loop" => Ok(WorkflowType::Loop),
"debate" => Ok(WorkflowType::Debate),
"voting" => Ok(WorkflowType::Voting),
"mixture" => Ok(WorkflowType::Mixture),
_ => Err(Error::Config(format!(
"invalid workflow_type '{}': must be dag, sequential, parallel, loop, debate, voting, or mixture",
s
))),
}
}
pub fn parse_tool_profile(s: &str) -> Result<ToolProfile, Error> {
match s.to_lowercase().as_str() {
"conversational" => Ok(ToolProfile::Conversational),
"standard" => Ok(ToolProfile::Standard),
"full" => Ok(ToolProfile::Full),
_ => Err(Error::Config(format!(
"invalid tool_profile '{}': must be conversational, standard, or full",
s
))),
}
}
pub fn parse_reasoning_effort(s: &str) -> Result<ReasoningEffort, Error> {
match s.to_lowercase().as_str() {
"high" => Ok(ReasoningEffort::High),
"medium" => Ok(ReasoningEffort::Medium),
"low" => Ok(ReasoningEffort::Low),
"none" => Ok(ReasoningEffort::None),
_ => Err(Error::Config(format!(
"invalid reasoning_effort '{}': must be high, medium, low, or none",
s
))),
}
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct SandboxConfig {
#[serde(default)]
pub allowed_dirs: Vec<std::path::PathBuf>,
#[serde(default)]
pub deny_globs: Vec<String>,
}
#[derive(Debug, Deserialize)]
pub struct HeartbitConfig {
#[serde(default)]
pub provider: ProviderConfig,
#[serde(default)]
pub orchestrator: OrchestratorConfig,
#[serde(default)]
pub agents: Vec<AgentConfig>,
#[serde(default)]
pub variables: std::collections::HashMap<String, String>,
pub restate: Option<RestateConfig>,
pub telemetry: Option<TelemetryConfig>,
pub memory: Option<MemoryConfig>,
pub knowledge: Option<KnowledgeConfig>,
#[serde(default)]
pub permissions: Vec<PermissionRule>,
pub lsp: Option<LspConfig>,
pub daemon: Option<DaemonConfig>,
pub workspace: Option<WorkspaceConfig>,
#[serde(default)]
pub guardrails: Option<GuardrailsConfig>,
#[serde(default)]
pub sandbox: Option<SandboxConfig>,
#[serde(default, rename = "persona")]
pub personas: Vec<PersonaConfig>,
}
impl HeartbitConfig {
pub fn from_toml(content: &str) -> Result<Self, Error> {
let config: Self = toml::from_str(content).map_err(|e| Error::Config(e.to_string()))?;
config.validate()?;
Ok(config)
}
pub fn from_file(path: &std::path::Path) -> Result<Self, Error> {
let content = std::fs::read_to_string(path)
.map_err(|e| Error::Config(format!("failed to read {}: {e}", path.display())))?;
Self::from_toml(&content)
}
fn validate(&self) -> Result<(), Error> {
let daemon_only = self.daemon.is_some() && self.agents.is_empty();
if !daemon_only {
if self.provider.name.is_empty() {
return Err(Error::Config("provider.name must not be empty".into()));
}
if self.provider.model.is_empty() {
return Err(Error::Config("provider.model must not be empty".into()));
}
}
if self.orchestrator.max_turns == 0 {
return Err(Error::Config(
"orchestrator.max_turns must be at least 1".into(),
));
}
if self.orchestrator.max_tokens == 0 {
return Err(Error::Config(
"orchestrator.max_tokens must be at least 1".into(),
));
}
match &self.orchestrator.context_strategy {
Some(ContextStrategyConfig::SlidingWindow { max_tokens }) if *max_tokens == 0 => {
return Err(Error::Config(
"orchestrator.context_strategy.max_tokens must be at least 1".into(),
));
}
Some(ContextStrategyConfig::Summarize { threshold }) if *threshold == 0 => {
return Err(Error::Config(
"orchestrator.context_strategy.threshold must be at least 1".into(),
));
}
_ => {}
}
if self.orchestrator.summarize_threshold == Some(0) {
return Err(Error::Config(
"orchestrator.summarize_threshold must be at least 1".into(),
));
}
if matches!(
self.orchestrator.context_strategy,
Some(ContextStrategyConfig::Summarize { .. })
| Some(ContextStrategyConfig::SlidingWindow { .. })
) && self.orchestrator.summarize_threshold.is_some()
{
return Err(Error::Config(
"orchestrator: cannot set both context_strategy \
and summarize_threshold; use one or the other"
.into(),
));
}
if self.orchestrator.tool_timeout_seconds == Some(0) {
return Err(Error::Config(
"orchestrator.tool_timeout_seconds must be at least 1".into(),
));
}
if self.orchestrator.max_tool_output_bytes == Some(0) {
return Err(Error::Config(
"orchestrator.max_tool_output_bytes must be at least 1".into(),
));
}
if self.orchestrator.run_timeout_seconds == Some(0) {
return Err(Error::Config(
"orchestrator.run_timeout_seconds must be at least 1".into(),
));
}
if let Some(ref effort) = self.orchestrator.reasoning_effort {
parse_reasoning_effort(effort)?;
}
if self.orchestrator.tool_output_compression_threshold == Some(0) {
return Err(Error::Config(
"orchestrator.tool_output_compression_threshold must be at least 1".into(),
));
}
if self.orchestrator.max_tools_per_turn == Some(0) {
return Err(Error::Config(
"orchestrator.max_tools_per_turn must be at least 1".into(),
));
}
if let Some(ref profile) = self.orchestrator.tool_profile {
parse_tool_profile(profile)?;
}
if self.orchestrator.max_identical_tool_calls == Some(0) {
return Err(Error::Config(
"orchestrator.max_identical_tool_calls must be at least 1".into(),
));
}
if self.orchestrator.max_fuzzy_identical_tool_calls == Some(0) {
return Err(Error::Config(
"orchestrator.max_fuzzy_identical_tool_calls must be at least 1".into(),
));
}
if self.orchestrator.max_tool_calls_per_turn == Some(0) {
return Err(Error::Config(
"orchestrator.max_tool_calls_per_turn must be at least 1".into(),
));
}
if let Some(ref spawn) = self.orchestrator.spawn {
if spawn.max_spawned_agents == 0 {
return Err(Error::Config(
"orchestrator.spawn.max_spawned_agents must be at least 1".into(),
));
}
if spawn.max_turns == 0 {
return Err(Error::Config(
"orchestrator.spawn.max_turns must be at least 1".into(),
));
}
if spawn.max_tokens == 0 {
return Err(Error::Config(
"orchestrator.spawn.max_tokens must be at least 1".into(),
));
}
if spawn.max_total_tokens == 0 {
return Err(Error::Config(
"orchestrator.spawn.max_total_tokens must be at least 1".into(),
));
}
if spawn.tool_allowlist.is_empty() {
tracing::warn!(
"orchestrator.spawn.tool_allowlist is empty — spawned agents will be reasoning-only"
);
}
}
if let Some(ref cascade) = self.provider.cascade
&& cascade.enabled
&& cascade.tiers.is_empty()
{
return Err(Error::Config(
"provider.cascade.enabled is true but no tiers are configured; \
add at least one [[provider.cascade.tiers]] entry"
.into(),
));
}
if let Some(ref cascade) = self.provider.cascade {
for (i, tier) in cascade.tiers.iter().enumerate() {
if tier.model.is_empty() {
return Err(Error::Config(format!(
"provider.cascade.tiers[{i}].model must not be empty"
)));
}
}
}
if let Some(ref retry) = self.provider.retry
&& retry.base_delay_ms > retry.max_delay_ms
{
return Err(Error::Config(format!(
"provider.retry.base_delay_ms ({}) must not exceed max_delay_ms ({})",
retry.base_delay_ms, retry.max_delay_ms
)));
}
if let Some(0) = self.provider.circuit.failure_threshold {
return Err(Error::Config(
"provider.circuit.failure_threshold must be > 0".into(),
));
}
if let Some(0) = self.provider.circuit.initial_open_duration_seconds {
return Err(Error::Config(
"provider.circuit.initial_open_duration_seconds must be > 0".into(),
));
}
if let Some(0) = self.provider.circuit.max_open_duration_seconds {
return Err(Error::Config(
"provider.circuit.max_open_duration_seconds must be > 0".into(),
));
}
if let Some(m) = self.provider.circuit.backoff_multiplier
&& (!m.is_finite() || m <= 0.0)
{
return Err(Error::Config(
"provider.circuit.backoff_multiplier must be > 0 and finite".into(),
));
}
if let Some(0) = self.orchestrator.max_tokens_in_flight_per_tenant {
return Err(Error::Config(
"orchestrator.max_tokens_in_flight_per_tenant must be > 0".into(),
));
}
let mut seen = std::collections::HashSet::new();
for agent in &self.agents {
if agent.name.is_empty() {
return Err(Error::Config("agent name must not be empty".into()));
}
if !seen.insert(&agent.name) {
return Err(Error::Config(format!(
"duplicate agent name: '{}'",
agent.name
)));
}
match &agent.context_strategy {
Some(ContextStrategyConfig::SlidingWindow { max_tokens }) if *max_tokens == 0 => {
return Err(Error::Config(format!(
"agent '{}': context_strategy.max_tokens must be at least 1",
agent.name
)));
}
Some(ContextStrategyConfig::Summarize { threshold }) if *threshold == 0 => {
return Err(Error::Config(format!(
"agent '{}': context_strategy.threshold must be at least 1",
agent.name
)));
}
_ => {}
}
if agent.max_turns == Some(0) {
return Err(Error::Config(format!(
"agent '{}': max_turns must be at least 1",
agent.name
)));
}
if agent.max_tokens == Some(0) {
return Err(Error::Config(format!(
"agent '{}': max_tokens must be at least 1",
agent.name
)));
}
if agent.tool_timeout_seconds == Some(0) {
return Err(Error::Config(format!(
"agent '{}': tool_timeout_seconds must be at least 1",
agent.name
)));
}
if agent.max_tool_output_bytes == Some(0) {
return Err(Error::Config(format!(
"agent '{}': max_tool_output_bytes must be at least 1",
agent.name
)));
}
if agent.run_timeout_seconds == Some(0) {
return Err(Error::Config(format!(
"agent '{}': run_timeout_seconds must be at least 1",
agent.name
)));
}
if let Some(ref p) = agent.provider {
if p.name.is_empty() {
return Err(Error::Config(format!(
"agent '{}': provider.name must not be empty",
agent.name
)));
}
if p.model.is_empty() {
return Err(Error::Config(format!(
"agent '{}': provider.model must not be empty",
agent.name
)));
}
}
for (i, entry) in agent.mcp_servers.iter().enumerate() {
if entry.url().is_empty() {
return Err(Error::Config(format!(
"agent '{}': mcp_servers[{i}].url must not be empty",
agent.name
)));
}
}
for (i, entry) in agent.a2a_agents.iter().enumerate() {
if entry.url().is_empty() {
return Err(Error::Config(format!(
"agent '{}': a2a_agents[{i}].url must not be empty",
agent.name
)));
}
}
if agent.summarize_threshold == Some(0) {
return Err(Error::Config(format!(
"agent '{}': summarize_threshold must be at least 1",
agent.name
)));
}
if matches!(
agent.context_strategy,
Some(ContextStrategyConfig::Summarize { .. })
| Some(ContextStrategyConfig::SlidingWindow { .. })
) && agent.summarize_threshold.is_some()
{
return Err(Error::Config(format!(
"agent '{}': cannot set both context_strategy and summarize_threshold; \
use one or the other",
agent.name
)));
}
if let Some(ref effort) = agent.reasoning_effort {
parse_reasoning_effort(effort).map_err(|_| {
Error::Config(format!(
"agent '{}': invalid reasoning_effort '{}': must be high, medium, low, or none",
agent.name, effort
))
})?;
}
if agent.tool_output_compression_threshold == Some(0) {
return Err(Error::Config(format!(
"agent '{}': tool_output_compression_threshold must be at least 1",
agent.name
)));
}
if agent.max_tools_per_turn == Some(0) {
return Err(Error::Config(format!(
"agent '{}': max_tools_per_turn must be at least 1",
agent.name
)));
}
if agent.max_identical_tool_calls == Some(0) {
return Err(Error::Config(format!(
"agent '{}': max_identical_tool_calls must be at least 1",
agent.name
)));
}
if agent.max_fuzzy_identical_tool_calls == Some(0) {
return Err(Error::Config(format!(
"agent '{}': max_fuzzy_identical_tool_calls must be at least 1",
agent.name
)));
}
if agent.max_tool_calls_per_turn == Some(0) {
return Err(Error::Config(format!(
"agent '{}': max_tool_calls_per_turn must be at least 1",
agent.name
)));
}
if agent.max_total_tokens == Some(0) {
return Err(Error::Config(format!(
"agent '{}': max_total_tokens must be at least 1",
agent.name
)));
}
if let Some(ref profile) = agent.tool_profile {
parse_tool_profile(profile).map_err(|_| {
Error::Config(format!(
"agent '{}': invalid tool_profile '{}': must be conversational, standard, or full",
agent.name, profile
))
})?;
}
if let Some(ref bt) = agent.builtin_tools {
for name in bt {
if !KNOWN_BUILTINS.contains(&name.as_str()) {
return Err(Error::Config(format!(
"agent '{}': unknown builtin tool '{}'; known builtins: {}",
agent.name,
name,
KNOWN_BUILTINS.join(", ")
)));
}
}
}
}
if let Some(ref knowledge) = self.knowledge {
if knowledge.chunk_size == 0 {
return Err(Error::Config(
"knowledge.chunk_size must be at least 1".into(),
));
}
if knowledge.chunk_overlap >= knowledge.chunk_size {
return Err(Error::Config(format!(
"knowledge.chunk_overlap ({}) must be less than chunk_size ({})",
knowledge.chunk_overlap, knowledge.chunk_size
)));
}
}
if let Some(ref daemon) = self.daemon {
if daemon.max_concurrent_tasks == 0 {
return Err(Error::Config(
"daemon.max_concurrent_tasks must be at least 1".into(),
));
}
if daemon.audit.prune_interval_minutes == Some(0) {
return Err(Error::Config(
"daemon.audit.prune_interval_minutes must be at least 1".into(),
));
}
if daemon.audit.retain_days == Some(0) {
return Err(Error::Config(
"daemon.audit.retain_days must be at least 1 if set".into(),
));
}
if daemon.idempotency.ttl_hours == Some(0) {
return Err(Error::Config(
"daemon.idempotency.ttl_hours must be at least 1 if set".into(),
));
}
if daemon.idempotency.sweep_interval_minutes == Some(0) {
return Err(Error::Config(
"daemon.idempotency.sweep_interval_minutes must be at least 1 if set".into(),
));
}
if let Some(ref kafka) = daemon.kafka {
if kafka.brokers.is_empty() {
return Err(Error::Config(
"daemon.kafka.brokers must not be empty".into(),
));
}
if kafka.consumer_group.is_empty() {
return Err(Error::Config(
"daemon.kafka.consumer_group must not be empty".into(),
));
}
if kafka.commands_topic.is_empty() {
return Err(Error::Config(
"daemon.kafka.commands_topic must not be empty".into(),
));
}
if kafka.events_topic.is_empty() {
return Err(Error::Config(
"daemon.kafka.events_topic must not be empty".into(),
));
}
}
if let Some(ref auth) = daemon.auth {
if auth.bearer_tokens.is_empty() && auth.jwks_url.is_none() {
return Err(Error::Config(
"daemon.auth requires at least bearer_tokens or jwks_url".into(),
));
}
for (i, token) in auth.bearer_tokens.iter().enumerate() {
if token.is_empty() {
return Err(Error::Config(format!(
"daemon.auth.bearer_tokens[{i}] must not be empty"
)));
}
}
if let Some(ref url) = auth.jwks_url
&& url.is_empty()
{
return Err(Error::Config(
"daemon.auth.jwks_url must not be empty".into(),
));
}
if let Some(ref te) = auth.token_exchange {
if te.exchange_url.is_empty() {
return Err(Error::Config(
"daemon.auth.token_exchange.exchange_url must not be empty".into(),
));
}
if te.client_id.is_empty() {
return Err(Error::Config(
"daemon.auth.token_exchange.client_id must not be empty".into(),
));
}
if te.client_secret.is_empty() {
return Err(Error::Config(
"daemon.auth.token_exchange.client_secret must not be empty".into(),
));
}
if te.tenant_id.is_none() && te.agent_token.is_empty() {
return Err(Error::Config(
"daemon.auth.token_exchange: set tenant_id for auto-fetch, or provide a static agent_token".into(),
));
}
}
}
}
let mut seen_persona_names = std::collections::HashSet::new();
for persona in &self.personas {
persona.validate()?;
if !seen_persona_names.insert(persona.name.clone()) {
return Err(Error::Config(format!(
"duplicate persona name: '{}'",
persona.name
)));
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
#[allow(unused_imports)]
use super::guardrails::default_pii_detectors;
use super::*;
#[test]
fn parse_full_config() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[orchestrator]
max_turns = 15
max_tokens = 8192
[[agents]]
name = "researcher"
description = "Research specialist"
system_prompt = "You are a research specialist."
[[agents]]
name = "coder"
description = "Coding expert"
system_prompt = "You are a coding expert."
mcp_servers = ["http://localhost:8000/mcp"]
[restate]
endpoint = "http://localhost:9070"
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert_eq!(config.provider.name, "anthropic");
assert_eq!(config.provider.model, "claude-sonnet-4-20250514");
assert_eq!(config.orchestrator.max_turns, 15);
assert_eq!(config.orchestrator.max_tokens, 8192);
assert_eq!(config.agents.len(), 2);
assert_eq!(config.agents[0].name, "researcher");
assert_eq!(config.agents[0].mcp_servers.len(), 0);
assert_eq!(config.agents[1].name, "coder");
assert_eq!(
config.agents[1].mcp_servers,
vec![McpServerEntry::Simple("http://localhost:8000/mcp".into())]
);
let restate = config.restate.unwrap();
assert_eq!(restate.endpoint, "http://localhost:9070");
}
#[test]
fn parse_minimal_config() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert_eq!(config.provider.name, "anthropic");
assert_eq!(config.orchestrator.max_turns, 10);
assert_eq!(config.orchestrator.max_tokens, 4096);
assert!(config.agents.is_empty());
assert!(config.restate.is_none());
}
#[test]
fn missing_required_provider_field() {
let toml = r#"
[provider]
name = "anthropic"
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("model"),
"error should mention missing field: {msg}"
);
}
#[test]
fn missing_provider_section() {
let toml = r#"
[[agents]]
name = "researcher"
description = "Research"
system_prompt = "You research."
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("provider"),
"error should mention missing section: {msg}"
);
}
#[test]
fn invalid_toml_syntax() {
let toml = "this is not valid toml {{{";
let err = HeartbitConfig::from_toml(toml).unwrap_err();
assert!(matches!(err, Error::Config(_)));
}
#[test]
fn from_file_nonexistent_path() {
let err = HeartbitConfig::from_file(std::path::Path::new("/nonexistent/heartbit.toml"))
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("failed to read"), "error: {msg}");
}
#[test]
fn orchestrator_defaults_applied() {
let toml = r#"
[provider]
name = "openrouter"
model = "anthropic/claude-sonnet-4"
[orchestrator]
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert_eq!(config.orchestrator.max_turns, 10);
assert_eq!(config.orchestrator.max_tokens, 4096);
assert!(config.orchestrator.context_strategy.is_none());
assert!(config.orchestrator.summarize_threshold.is_none());
assert!(config.orchestrator.tool_timeout_seconds.is_none());
assert!(config.orchestrator.max_tool_output_bytes.is_none());
}
#[test]
fn orchestrator_context_strategy_parses() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[orchestrator.context_strategy]
type = "sliding_window"
max_tokens = 16000
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert_eq!(
config.orchestrator.context_strategy,
Some(ContextStrategyConfig::SlidingWindow { max_tokens: 16000 })
);
assert!(config.orchestrator.summarize_threshold.is_none());
}
#[test]
fn agent_config_mcp_servers_default_empty() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "basic"
description = "Basic agent"
system_prompt = "You are basic."
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert!(config.agents[0].mcp_servers.is_empty());
}
#[test]
fn agent_max_total_tokens_parses() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "quoter"
description = "Quoter agent"
system_prompt = "You quote."
max_total_tokens = 100000
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert_eq!(config.agents[0].max_total_tokens, Some(100000));
}
#[test]
fn agent_max_total_tokens_defaults_none() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "basic"
description = "Basic agent"
system_prompt = "You are basic."
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert!(config.agents[0].max_total_tokens.is_none());
}
#[test]
fn config_rejects_zero_agent_max_total_tokens() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "quoter"
description = "Quoter"
system_prompt = "Quote."
max_total_tokens = 0
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
assert!(
err.to_string()
.contains("max_total_tokens must be at least 1"),
"error: {err}"
);
}
#[test]
fn parse_context_strategy_unlimited() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "test"
description = "Test"
system_prompt = "You test."
context_strategy = { type = "unlimited" }
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert_eq!(
config.agents[0].context_strategy,
Some(ContextStrategyConfig::Unlimited)
);
}
#[test]
fn parse_context_strategy_sliding_window() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "test"
description = "Test"
system_prompt = "You test."
context_strategy = { type = "sliding_window", max_tokens = 100000 }
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert_eq!(
config.agents[0].context_strategy,
Some(ContextStrategyConfig::SlidingWindow { max_tokens: 100000 })
);
}
#[test]
fn parse_context_strategy_summarize() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "test"
description = "Test"
system_prompt = "You test."
context_strategy = { type = "summarize", threshold = 80000 }
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert_eq!(
config.agents[0].context_strategy,
Some(ContextStrategyConfig::Summarize { threshold: 80000 })
);
}
#[test]
fn context_strategy_defaults_to_none() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "test"
description = "Test"
system_prompt = "You test."
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert!(config.agents[0].context_strategy.is_none());
}
#[test]
fn parse_memory_config_in_memory() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[memory]
type = "in_memory"
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert!(matches!(config.memory, Some(MemoryConfig::InMemory)));
}
#[test]
fn parse_memory_config_postgres() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[memory]
type = "postgres"
database_url = "postgresql://localhost/heartbit"
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
match &config.memory {
Some(MemoryConfig::Postgres {
database_url,
embedding,
}) => {
assert_eq!(database_url, "postgresql://localhost/heartbit");
assert!(embedding.is_none(), "embedding should default to None");
}
other => panic!("expected Postgres config, got: {other:?}"),
}
}
#[test]
fn parse_memory_config_postgres_with_embedding() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[memory]
type = "postgres"
database_url = "postgresql://localhost/heartbit"
[memory.embedding]
provider = "openai"
model = "text-embedding-3-large"
api_key_env = "MY_OPENAI_KEY"
base_url = "https://custom-api.example.com"
dimension = 3072
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
match &config.memory {
Some(MemoryConfig::Postgres {
database_url,
embedding,
}) => {
assert_eq!(database_url, "postgresql://localhost/heartbit");
let emb = embedding.as_ref().expect("embedding config should be set");
assert_eq!(emb.provider, "openai");
assert_eq!(emb.model, "text-embedding-3-large");
assert_eq!(emb.api_key_env, "MY_OPENAI_KEY");
assert_eq!(
emb.base_url.as_deref(),
Some("https://custom-api.example.com")
);
assert_eq!(emb.dimension, Some(3072));
}
other => panic!("expected Postgres config, got: {other:?}"),
}
}
#[test]
fn parse_memory_config_embedding_defaults() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[memory]
type = "postgres"
database_url = "postgresql://localhost/heartbit"
[memory.embedding]
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
match &config.memory {
Some(MemoryConfig::Postgres { embedding, .. }) => {
let emb = embedding.as_ref().expect("embedding config should be set");
assert_eq!(emb.provider, "none");
assert_eq!(emb.model, "text-embedding-3-small");
assert_eq!(emb.api_key_env, "OPENAI_API_KEY");
assert!(emb.base_url.is_none());
assert!(emb.dimension.is_none());
}
other => panic!("expected Postgres config, got: {other:?}"),
}
}
#[test]
fn memory_config_defaults_to_none() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert!(config.memory.is_none());
}
#[test]
fn zero_max_turns_rejected() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[orchestrator]
max_turns = 0
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("max_turns must be at least 1"), "error: {msg}");
}
#[test]
fn zero_max_tokens_rejected() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[orchestrator]
max_tokens = 0
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("max_tokens must be at least 1"),
"error: {msg}"
);
}
#[test]
fn parse_tool_timeout_seconds() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "test"
description = "Test"
system_prompt = "You test."
tool_timeout_seconds = 60
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert_eq!(config.agents[0].tool_timeout_seconds, Some(60));
}
#[test]
fn tool_timeout_defaults_to_none() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "test"
description = "Test"
system_prompt = "You test."
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert!(config.agents[0].tool_timeout_seconds.is_none());
}
#[test]
fn parse_max_tool_output_bytes() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "test"
description = "Test"
system_prompt = "You test."
max_tool_output_bytes = 16384
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert_eq!(config.agents[0].max_tool_output_bytes, Some(16384));
}
#[test]
fn max_tool_output_bytes_defaults_to_none() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "test"
description = "Test"
system_prompt = "You test."
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert!(config.agents[0].max_tool_output_bytes.is_none());
}
#[test]
fn parse_per_agent_max_turns() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "browser"
description = "Browser"
system_prompt = "Browse."
max_turns = 20
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert_eq!(config.agents[0].max_turns, Some(20));
}
#[test]
fn parse_per_agent_max_tokens() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "writer"
description = "Writer"
system_prompt = "Write."
max_tokens = 16384
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert_eq!(config.agents[0].max_tokens, Some(16384));
}
#[test]
fn per_agent_limits_default_to_none() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "test"
description = "Test"
system_prompt = "You test."
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert!(config.agents[0].max_turns.is_none());
assert!(config.agents[0].max_tokens.is_none());
}
#[test]
fn per_agent_zero_max_turns_rejected() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "test"
description = "Test"
system_prompt = "You test."
max_turns = 0
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("max_turns must be at least 1"), "error: {msg}");
}
#[test]
fn per_agent_zero_max_tokens_rejected() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "test"
description = "Test"
system_prompt = "You test."
max_tokens = 0
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("max_tokens must be at least 1"),
"error: {msg}"
);
}
#[test]
fn parse_response_schema() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "analyst"
description = "Analyst"
system_prompt = "Analyze."
[agents.response_schema]
type = "object"
[agents.response_schema.properties.score]
type = "number"
[agents.response_schema.properties.summary]
type = "string"
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
let schema = config.agents[0].response_schema.as_ref().unwrap();
assert_eq!(schema["type"], "object");
assert_eq!(schema["properties"]["score"]["type"], "number");
assert_eq!(schema["properties"]["summary"]["type"], "string");
}
#[test]
fn response_schema_defaults_to_none() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "test"
description = "Test"
system_prompt = "Test."
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert!(config.agents[0].response_schema.is_none());
}
#[test]
fn rejects_duplicate_persona_names() {
let toml_text = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[persona]]
name = "x"
recipe = "heartbit-ghost:x"
[[persona]]
name = "x"
recipe = "heartbit-ghost:x"
"#;
let err = HeartbitConfig::from_toml(toml_text).unwrap_err();
let msg = format!("{:?}", err);
assert!(msg.contains("duplicate persona name"), "got: {}", msg);
}
#[test]
fn parses_persona_block_round_trip() {
let toml_text = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[persona]]
name = "x"
recipe = "heartbit-ghost:x"
authorship_mode = "autonomous_undisclosed"
phase = "calibration"
"#;
let config = HeartbitConfig::from_toml(toml_text).expect("parses");
assert_eq!(config.personas.len(), 1);
assert_eq!(config.personas[0].name, "x");
assert_eq!(config.personas[0].recipe, "heartbit-ghost:x");
}
#[test]
fn duplicate_agent_names_rejected() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "researcher"
description = "First"
system_prompt = "First."
[[agents]]
name = "researcher"
description = "Second"
system_prompt = "Second."
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("duplicate agent name"), "error: {msg}");
}
#[test]
fn per_agent_zero_summarize_threshold_rejected() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "test"
description = "Test"
system_prompt = "Test."
summarize_threshold = 0
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("summarize_threshold must be at least 1"),
"error: {msg}"
);
}
#[test]
fn per_agent_summarize_threshold_with_context_strategy_rejected() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "test"
description = "Test"
system_prompt = "Test."
summarize_threshold = 8000
[agents.context_strategy]
type = "sliding_window"
max_tokens = 50000
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("cannot set both context_strategy and summarize_threshold"),
"error: {msg}"
);
}
#[test]
fn per_agent_summarize_threshold_parses() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "test"
description = "Test"
system_prompt = "Test."
summarize_threshold = 8000
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert_eq!(config.agents[0].summarize_threshold, Some(8000));
}
#[test]
fn parse_retry_config() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[provider.retry]
max_retries = 5
base_delay_ms = 1000
max_delay_ms = 60000
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
let retry = config.provider.retry.unwrap();
assert_eq!(retry.max_retries, 5);
assert_eq!(retry.base_delay_ms, 1000);
assert_eq!(retry.max_delay_ms, 60000);
}
#[test]
fn retry_config_defaults_to_none() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert!(config.provider.retry.is_none());
}
#[test]
fn retry_config_uses_defaults_for_missing_fields() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[provider.retry]
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
let retry = config.provider.retry.unwrap();
assert_eq!(retry.max_retries, 3);
assert_eq!(retry.base_delay_ms, 500);
assert_eq!(retry.max_delay_ms, 30000);
}
#[test]
fn zero_context_strategy_max_tokens_rejected() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "test"
description = "Test"
system_prompt = "You test."
context_strategy = { type = "sliding_window", max_tokens = 0 }
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("context_strategy.max_tokens must be at least 1"),
"error: {msg}"
);
}
#[test]
fn zero_summarize_threshold_rejected() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "test"
description = "Test"
system_prompt = "You test."
context_strategy = { type = "summarize", threshold = 0 }
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("context_strategy.threshold must be at least 1"),
"error: {msg}"
);
}
#[test]
fn retry_base_delay_exceeds_max_delay_rejected() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[provider.retry]
base_delay_ms = 60000
max_delay_ms = 1000
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("base_delay_ms") && msg.contains("max_delay_ms"),
"error: {msg}"
);
}
#[test]
fn retry_base_delay_equals_max_delay_accepted() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[provider.retry]
base_delay_ms = 5000
max_delay_ms = 5000
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
let retry = config.provider.retry.unwrap();
assert_eq!(retry.base_delay_ms, 5000);
assert_eq!(retry.max_delay_ms, 5000);
}
#[test]
fn zero_tool_timeout_seconds_rejected() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "test"
description = "Test"
system_prompt = "You test."
tool_timeout_seconds = 0
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("tool_timeout_seconds must be at least 1"),
"error: {msg}"
);
}
#[test]
fn zero_max_tool_output_bytes_rejected() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "test"
description = "Test"
system_prompt = "You test."
max_tool_output_bytes = 0
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("max_tool_output_bytes must be at least 1"),
"error: {msg}"
);
}
#[test]
fn empty_agent_name_rejected() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = ""
description = "Test"
system_prompt = "You test."
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("agent name must not be empty"), "error: {msg}");
}
#[test]
fn parse_knowledge_config_with_all_source_types() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[knowledge]
chunk_size = 2000
chunk_overlap = 400
[[knowledge.sources]]
type = "file"
path = "README.md"
[[knowledge.sources]]
type = "glob"
pattern = "docs/**/*.md"
[[knowledge.sources]]
type = "url"
url = "https://docs.example.com/api"
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
let knowledge = config.knowledge.unwrap();
assert_eq!(knowledge.chunk_size, 2000);
assert_eq!(knowledge.chunk_overlap, 400);
assert_eq!(knowledge.sources.len(), 3);
assert!(matches!(
knowledge.sources[0],
KnowledgeSourceConfig::File { .. }
));
assert!(matches!(
knowledge.sources[1],
KnowledgeSourceConfig::Glob { .. }
));
assert!(matches!(
knowledge.sources[2],
KnowledgeSourceConfig::Url { .. }
));
}
#[test]
fn knowledge_config_defaults() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[knowledge]
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
let knowledge = config.knowledge.unwrap();
assert_eq!(knowledge.chunk_size, 1000);
assert_eq!(knowledge.chunk_overlap, 200);
assert!(knowledge.sources.is_empty());
}
#[test]
fn knowledge_config_defaults_to_none() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert!(config.knowledge.is_none());
}
#[test]
fn knowledge_zero_chunk_size_rejected() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[knowledge]
chunk_size = 0
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("chunk_size must be at least 1"),
"error: {msg}"
);
}
#[test]
fn knowledge_overlap_exceeds_chunk_size_rejected() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[knowledge]
chunk_size = 100
chunk_overlap = 100
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("chunk_overlap") && msg.contains("less than chunk_size"),
"error: {msg}"
);
}
#[test]
fn prompt_caching_defaults_false() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert!(!config.provider.prompt_caching);
}
#[test]
fn prompt_caching_parses_true() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
prompt_caching = true
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert!(config.provider.prompt_caching);
}
#[test]
fn prompt_caching_backward_compat() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[provider.retry]
max_retries = 3
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert!(!config.provider.prompt_caching);
assert!(config.provider.retry.is_some());
}
#[test]
fn orchestrator_zero_context_strategy_max_tokens_rejected() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[orchestrator.context_strategy]
type = "sliding_window"
max_tokens = 0
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("orchestrator.context_strategy.max_tokens must be at least 1"),
"error: {msg}"
);
}
#[test]
fn orchestrator_zero_context_strategy_threshold_rejected() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[orchestrator.context_strategy]
type = "summarize"
threshold = 0
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("orchestrator.context_strategy.threshold must be at least 1"),
"error: {msg}"
);
}
#[test]
fn orchestrator_zero_summarize_threshold_rejected() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[orchestrator]
summarize_threshold = 0
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("orchestrator.summarize_threshold must be at least 1"),
"error: {msg}"
);
}
#[test]
fn orchestrator_summarize_conflict_rejected() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[orchestrator]
summarize_threshold = 8000
[orchestrator.context_strategy]
type = "summarize"
threshold = 16000
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("cannot set both"), "error: {msg}");
}
#[test]
fn orchestrator_sliding_window_plus_summarize_threshold_rejected() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[orchestrator]
summarize_threshold = 8000
[orchestrator.context_strategy]
type = "sliding_window"
max_tokens = 16000
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("cannot set both"), "error: {msg}");
}
#[test]
fn orchestrator_unlimited_plus_summarize_threshold_allowed() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[orchestrator]
summarize_threshold = 8000
[orchestrator.context_strategy]
type = "unlimited"
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert_eq!(config.orchestrator.summarize_threshold, Some(8000));
}
#[test]
fn orchestrator_zero_tool_timeout_seconds_rejected() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[orchestrator]
tool_timeout_seconds = 0
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("orchestrator.tool_timeout_seconds must be at least 1"),
"error: {msg}"
);
}
#[test]
fn orchestrator_zero_max_tool_output_bytes_rejected() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[orchestrator]
max_tool_output_bytes = 0
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("orchestrator.max_tool_output_bytes must be at least 1"),
"error: {msg}"
);
}
#[test]
fn orchestrator_tool_timeout_parses() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[orchestrator]
tool_timeout_seconds = 120
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert_eq!(config.orchestrator.tool_timeout_seconds, Some(120));
}
#[test]
fn orchestrator_max_tool_output_bytes_parses() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[orchestrator]
max_tool_output_bytes = 32768
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert_eq!(config.orchestrator.max_tool_output_bytes, Some(32768));
}
#[test]
fn knowledge_overlap_less_than_chunk_size_accepted() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[knowledge]
chunk_size = 100
chunk_overlap = 50
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
let knowledge = config.knowledge.unwrap();
assert_eq!(knowledge.chunk_size, 100);
assert_eq!(knowledge.chunk_overlap, 50);
}
#[test]
fn mcp_server_entry_simple_string() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "test"
description = "Test"
system_prompt = "Test."
mcp_servers = ["http://localhost:8000/mcp"]
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert_eq!(config.agents[0].mcp_servers.len(), 1);
assert_eq!(
config.agents[0].mcp_servers[0],
McpServerEntry::Simple("http://localhost:8000/mcp".into())
);
assert_eq!(
config.agents[0].mcp_servers[0].url(),
"http://localhost:8000/mcp"
);
assert!(config.agents[0].mcp_servers[0].auth_header().is_none());
}
#[test]
fn mcp_server_entry_full_with_auth() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "test"
description = "Test"
system_prompt = "Test."
mcp_servers = [{ url = "http://gateway:8080/mcp", auth_header = "Bearer tok_xxx" }]
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert_eq!(config.agents[0].mcp_servers.len(), 1);
assert_eq!(
config.agents[0].mcp_servers[0].url(),
"http://gateway:8080/mcp"
);
assert_eq!(
config.agents[0].mcp_servers[0].auth_header(),
Some("Bearer tok_xxx")
);
}
#[test]
fn mcp_server_entry_full_without_auth() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "test"
description = "Test"
system_prompt = "Test."
mcp_servers = [{ url = "http://localhost:8000/mcp" }]
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert_eq!(
config.agents[0].mcp_servers[0].url(),
"http://localhost:8000/mcp"
);
assert!(config.agents[0].mcp_servers[0].auth_header().is_none());
}
#[test]
fn mcp_server_entry_mixed_simple_and_full() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "test"
description = "Test"
system_prompt = "Test."
mcp_servers = [
"http://localhost:8000/mcp",
{ url = "http://gateway:8080/mcp", auth_header = "Bearer tok_xxx" }
]
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert_eq!(config.agents[0].mcp_servers.len(), 2);
assert!(config.agents[0].mcp_servers[0].auth_header().is_none());
assert_eq!(
config.agents[0].mcp_servers[1].auth_header(),
Some("Bearer tok_xxx")
);
}
#[test]
fn mcp_server_entry_full_empty_url_rejected() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "test"
description = "Test"
system_prompt = "Test."
mcp_servers = [{ url = "" }]
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("url must not be empty"), "error: {msg}");
}
#[test]
fn mcp_server_entry_roundtrip() {
let simple = McpServerEntry::Simple("http://localhost/mcp".into());
let json = serde_json::to_string(&simple).unwrap();
let parsed: McpServerEntry = serde_json::from_str(&json).unwrap();
assert_eq!(simple, parsed);
let full = McpServerEntry::Full {
url: "http://gateway/mcp".into(),
auth_header: Some("Bearer tok".into()),
resource: None,
scopes: None,
};
let json = serde_json::to_string(&full).unwrap();
let parsed: McpServerEntry = serde_json::from_str(&json).unwrap();
assert_eq!(full, parsed);
}
#[test]
fn mcp_server_entry_scopes_resource_roundtrip() {
let full = McpServerEntry::Full {
url: "http://gmail-mcp.example.com/mcp".into(),
auth_header: None,
resource: Some("https://gmail.googleapis.com".into()),
scopes: Some(vec!["gmail.readonly".into(), "gmail.send".into()]),
};
let json = serde_json::to_string(&full).unwrap();
let parsed: McpServerEntry = serde_json::from_str(&json).unwrap();
assert_eq!(full, parsed);
let no_resource = McpServerEntry::Full {
url: "http://mcp.example.com".into(),
auth_header: None,
resource: None,
scopes: None,
};
assert_eq!(no_resource.resource(), Some("http://mcp.example.com"));
assert_eq!(full.resource(), Some("https://gmail.googleapis.com"));
assert_eq!(
full.scopes(),
Some(["gmail.readonly".to_string(), "gmail.send".to_string()].as_slice())
);
let simple = McpServerEntry::Simple("http://localhost/mcp".into());
assert_eq!(simple.resource(), Some("http://localhost/mcp"));
assert_eq!(simple.scopes(), None);
let stdio = McpServerEntry::Stdio {
command: "npx".into(),
args: vec![],
env: Default::default(),
};
assert_eq!(stdio.resource(), None);
assert_eq!(stdio.scopes(), None);
}
#[test]
fn orchestrator_run_timeout_parses() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[orchestrator]
run_timeout_seconds = 300
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert_eq!(config.orchestrator.run_timeout_seconds, Some(300));
}
#[test]
fn orchestrator_run_timeout_defaults_to_none() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert!(config.orchestrator.run_timeout_seconds.is_none());
}
#[test]
fn orchestrator_zero_run_timeout_rejected() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[orchestrator]
run_timeout_seconds = 0
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("run_timeout_seconds must be at least 1"),
"error: {msg}"
);
}
#[test]
fn agent_run_timeout_parses() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "test"
description = "Test"
system_prompt = "Test."
run_timeout_seconds = 120
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert_eq!(config.agents[0].run_timeout_seconds, Some(120));
}
#[test]
fn agent_run_timeout_defaults_to_none() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "test"
description = "Test"
system_prompt = "Test."
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert!(config.agents[0].run_timeout_seconds.is_none());
}
#[test]
fn agent_zero_run_timeout_rejected() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "test"
description = "Test"
system_prompt = "Test."
run_timeout_seconds = 0
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("run_timeout_seconds must be at least 1"),
"error: {msg}"
);
}
#[test]
fn mcp_server_backward_compat_bare_strings() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "coder"
description = "Coding expert"
system_prompt = "You code."
mcp_servers = ["http://localhost:8000/mcp", "http://localhost:9000/mcp"]
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert_eq!(config.agents[0].mcp_servers.len(), 2);
assert_eq!(
config.agents[0].mcp_servers[0].url(),
"http://localhost:8000/mcp"
);
assert_eq!(
config.agents[0].mcp_servers[1].url(),
"http://localhost:9000/mcp"
);
}
#[test]
fn per_agent_provider_parses() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-opus-4-20250514"
[[agents]]
name = "researcher"
description = "Research"
system_prompt = "Research."
[agents.provider]
name = "anthropic"
model = "claude-haiku-4-5-20251001"
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
let agent_provider = config.agents[0].provider.as_ref().unwrap();
assert_eq!(agent_provider.name, "anthropic");
assert_eq!(agent_provider.model, "claude-haiku-4-5-20251001");
assert!(!agent_provider.prompt_caching);
}
#[test]
fn per_agent_provider_with_prompt_caching() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-opus-4-20250514"
[[agents]]
name = "researcher"
description = "Research"
system_prompt = "Research."
[agents.provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
prompt_caching = true
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
let agent_provider = config.agents[0].provider.as_ref().unwrap();
assert!(agent_provider.prompt_caching);
}
#[test]
fn per_agent_provider_defaults_to_none() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "test"
description = "Test"
system_prompt = "Test."
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert!(config.agents[0].provider.is_none());
}
#[test]
fn per_agent_provider_empty_model_rejected() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "test"
description = "Test"
system_prompt = "Test."
[agents.provider]
name = "anthropic"
model = ""
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("provider.model must not be empty"),
"error: {msg}"
);
}
#[test]
fn per_agent_provider_openrouter() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-opus-4-20250514"
[[agents]]
name = "cheap"
description = "Cheap agent"
system_prompt = "Be frugal."
[agents.provider]
name = "openrouter"
model = "anthropic/claude-haiku-4-5"
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
let p = config.agents[0].provider.as_ref().unwrap();
assert_eq!(p.name, "openrouter");
assert_eq!(p.model, "anthropic/claude-haiku-4-5");
}
#[test]
fn mixed_agents_with_and_without_provider() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-opus-4-20250514"
[[agents]]
name = "researcher"
description = "Research"
system_prompt = "Research."
[agents.provider]
name = "anthropic"
model = "claude-haiku-4-5-20251001"
[[agents]]
name = "coder"
description = "Coding"
system_prompt = "Code."
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert!(config.agents[0].provider.is_some());
assert!(config.agents[1].provider.is_none());
}
#[test]
fn per_agent_provider_empty_name_rejected() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "test"
description = "Test"
system_prompt = "Test."
[agents.provider]
name = ""
model = "claude-haiku-4-5-20251001"
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("provider.name must not be empty"),
"error: {msg}"
);
}
#[test]
fn enable_squads_config_parsed() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[orchestrator]
enable_squads = false
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert_eq!(config.orchestrator.enable_squads, Some(false));
}
#[test]
fn enable_squads_default_auto() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert!(
config.orchestrator.enable_squads.is_none(),
"enable_squads should default to None (auto)"
);
}
#[test]
fn enable_squads_true_parsed() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[orchestrator]
enable_squads = true
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert_eq!(config.orchestrator.enable_squads, Some(true));
}
#[test]
fn a2a_agents_defaults_empty() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "test"
description = "Test"
system_prompt = "Test."
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert!(config.agents[0].a2a_agents.is_empty());
}
#[test]
fn a2a_agents_parses_simple() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "test"
description = "Test"
system_prompt = "Test."
a2a_agents = ["http://localhost:9000"]
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert_eq!(config.agents[0].a2a_agents.len(), 1);
assert_eq!(
config.agents[0].a2a_agents[0].url(),
"http://localhost:9000"
);
assert!(config.agents[0].a2a_agents[0].auth_header().is_none());
}
#[test]
fn a2a_agents_parses_full_with_auth() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "test"
description = "Test"
system_prompt = "Test."
a2a_agents = [{ url = "http://gateway:8080", auth_header = "Bearer tok_a2a" }]
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert_eq!(config.agents[0].a2a_agents.len(), 1);
assert_eq!(config.agents[0].a2a_agents[0].url(), "http://gateway:8080");
assert_eq!(
config.agents[0].a2a_agents[0].auth_header(),
Some("Bearer tok_a2a")
);
}
#[test]
fn a2a_agents_empty_url_rejected() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "test"
description = "Test"
system_prompt = "Test."
a2a_agents = [""]
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("a2a_agents") && msg.contains("url must not be empty"),
"error: {msg}"
);
}
#[test]
fn a2a_agents_mixed_with_mcp_servers() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "hybrid"
description = "Hybrid agent"
system_prompt = "You are hybrid."
mcp_servers = ["http://localhost:8000/mcp"]
a2a_agents = ["http://localhost:9000"]
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert_eq!(config.agents[0].mcp_servers.len(), 1);
assert_eq!(config.agents[0].a2a_agents.len(), 1);
}
#[test]
fn mcp_server_entry_simple_empty_url_rejected() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "test"
description = "Test"
system_prompt = "Test."
mcp_servers = [""]
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("url must not be empty"), "error: {msg}");
}
#[test]
fn config_enable_reflection_orchestrator() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[orchestrator]
enable_reflection = true
[[agents]]
name = "a"
description = "A"
system_prompt = "s"
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert_eq!(config.orchestrator.enable_reflection, Some(true));
}
#[test]
fn config_enable_reflection_per_agent() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "reflective"
description = "R"
system_prompt = "s"
enable_reflection = true
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert_eq!(config.agents[0].enable_reflection, Some(true));
}
#[test]
fn config_rejects_zero_compression_threshold() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[orchestrator]
tool_output_compression_threshold = 0
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
assert!(
err.to_string()
.contains("tool_output_compression_threshold")
);
}
#[test]
fn config_rejects_zero_max_tools_per_turn() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[orchestrator]
max_tools_per_turn = 0
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
assert!(err.to_string().contains("max_tools_per_turn"));
}
#[test]
fn config_rejects_zero_agent_compression_threshold() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "a"
description = "d"
system_prompt = "s"
tool_output_compression_threshold = 0
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
assert!(
err.to_string()
.contains("tool_output_compression_threshold"),
"error: {err}"
);
}
#[test]
fn config_rejects_zero_agent_max_tools_per_turn() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "a"
description = "d"
system_prompt = "s"
max_tools_per_turn = 0
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
assert!(
err.to_string().contains("max_tools_per_turn"),
"error: {err}"
);
}
#[test]
fn config_rejects_zero_orchestrator_max_identical_tool_calls() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[orchestrator]
max_identical_tool_calls = 0
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
assert!(
err.to_string().contains("max_identical_tool_calls"),
"error: {err}"
);
}
#[test]
fn config_rejects_zero_agent_max_identical_tool_calls() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "a"
description = "d"
system_prompt = "s"
max_identical_tool_calls = 0
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
assert!(
err.to_string().contains("max_identical_tool_calls"),
"error: {err}"
);
}
#[test]
fn config_parses_max_identical_tool_calls() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[orchestrator]
max_identical_tool_calls = 5
[[agents]]
name = "a"
description = "d"
system_prompt = "s"
max_identical_tool_calls = 3
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert_eq!(config.orchestrator.max_identical_tool_calls, Some(5));
assert_eq!(config.agents[0].max_identical_tool_calls, Some(3));
}
#[test]
fn config_max_identical_tool_calls_defaults_to_none() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "a"
description = "d"
system_prompt = "s"
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert!(config.orchestrator.max_identical_tool_calls.is_none());
assert!(config.agents[0].max_identical_tool_calls.is_none());
}
#[test]
fn config_rejects_zero_orchestrator_max_tool_calls_per_turn() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[orchestrator]
max_tool_calls_per_turn = 0
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
assert!(
err.to_string().contains("max_tool_calls_per_turn"),
"error: {err}"
);
}
#[test]
fn config_rejects_zero_agent_max_tool_calls_per_turn() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "a"
description = "d"
system_prompt = "s"
max_tool_calls_per_turn = 0
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
assert!(
err.to_string().contains("max_tool_calls_per_turn"),
"error: {err}"
);
}
#[test]
fn config_parses_max_tool_calls_per_turn() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[orchestrator]
max_tool_calls_per_turn = 5
[[agents]]
name = "a"
description = "d"
system_prompt = "s"
max_tool_calls_per_turn = 3
"#;
let cfg = HeartbitConfig::from_toml(toml).unwrap();
assert_eq!(cfg.orchestrator.max_tool_calls_per_turn, Some(5));
assert_eq!(cfg.agents[0].max_tool_calls_per_turn, Some(3));
}
#[test]
fn config_parses_sandbox_section() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "a"
description = "d"
system_prompt = "s"
[sandbox]
allowed_dirs = ["/workspace", "/tmp/agent"]
deny_globs = ["**/.env", "**/secrets/**"]
"#;
let cfg = HeartbitConfig::from_toml(toml).unwrap();
let sb = cfg.sandbox.unwrap();
assert_eq!(sb.allowed_dirs.len(), 2);
assert_eq!(sb.deny_globs.len(), 2);
assert_eq!(sb.deny_globs[0], "**/.env");
}
#[test]
fn config_parses_daemon_audit_section() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "a"
description = "d"
system_prompt = "s"
[daemon]
bind = "127.0.0.1:3000"
[daemon.audit]
retain_days = 30
prune_interval_minutes = 120
"#;
let cfg = HeartbitConfig::from_toml(toml).unwrap();
let daemon = cfg.daemon.unwrap();
assert_eq!(daemon.audit.retain_days, Some(30));
assert_eq!(daemon.audit.prune_interval_minutes, Some(120));
}
#[test]
fn config_rejects_zero_prune_interval_minutes() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[daemon]
bind = "127.0.0.1:3000"
[daemon.audit]
prune_interval_minutes = 0
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
assert!(
err.to_string().contains("prune_interval_minutes"),
"error: {err}"
);
}
#[test]
fn config_rejects_zero_retain_days() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[daemon]
bind = "127.0.0.1:3000"
[daemon.audit]
retain_days = 0
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
assert!(err.to_string().contains("retain_days"), "error: {err}");
}
#[test]
fn idempotency_config_defaults_are_none() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[daemon]
bind = "127.0.0.1:3000"
"#;
let cfg = HeartbitConfig::from_toml(toml).unwrap();
let daemon = cfg.daemon.unwrap();
assert!(daemon.idempotency.ttl_hours.is_none());
assert!(daemon.idempotency.sweep_interval_minutes.is_none());
}
#[test]
fn config_rejects_zero_idempotency_ttl_hours() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[daemon]
bind = "127.0.0.1:3000"
[daemon.idempotency]
ttl_hours = 0
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
assert!(err.to_string().contains("ttl_hours"), "error: {err}");
}
#[test]
fn config_rejects_zero_idempotency_sweep_interval() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[daemon]
bind = "127.0.0.1:3000"
[daemon.idempotency]
sweep_interval_minutes = 0
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
assert!(
err.to_string().contains("sweep_interval_minutes"),
"error: {err}"
);
}
#[test]
fn config_parses_permission_rules() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "a"
description = "d"
system_prompt = "s"
[[permissions]]
tool = "read_file"
action = "allow"
[[permissions]]
tool = "bash"
pattern = "rm *"
action = "deny"
[[permissions]]
tool = "*"
pattern = "*.env*"
action = "deny"
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert_eq!(config.permissions.len(), 3);
assert_eq!(config.permissions[0].tool, "read_file");
assert_eq!(config.permissions[0].pattern, "*"); assert_eq!(
config.permissions[0].action,
crate::agent::permission::PermissionAction::Allow
);
assert_eq!(config.permissions[1].tool, "bash");
assert_eq!(config.permissions[1].pattern, "rm *");
assert_eq!(
config.permissions[1].action,
crate::agent::permission::PermissionAction::Deny
);
assert_eq!(config.permissions[2].tool, "*");
assert_eq!(config.permissions[2].pattern, "*.env*");
}
#[test]
fn config_defaults_to_empty_permissions() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "a"
description = "d"
system_prompt = "s"
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert!(config.permissions.is_empty());
}
#[test]
fn lsp_config_defaults_to_none() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert!(config.lsp.is_none());
}
#[test]
fn lsp_config_enabled_defaults_true() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[lsp]
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
let lsp = config.lsp.unwrap();
assert!(lsp.enabled);
}
#[test]
fn lsp_config_disabled() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[lsp]
enabled = false
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
let lsp = config.lsp.unwrap();
assert!(!lsp.enabled);
}
#[test]
fn parse_session_prune_with_preserve_task() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "test"
description = "Test"
system_prompt = "You test."
[agents.session_prune]
keep_recent_n = 3
pruned_tool_result_max_bytes = 100
preserve_task = false
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
let sp = config.agents[0].session_prune.as_ref().unwrap();
assert_eq!(sp.keep_recent_n, 3);
assert_eq!(sp.pruned_tool_result_max_bytes, 100);
assert!(!sp.preserve_task);
}
#[test]
fn config_telemetry_parses_observability_mode() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[telemetry]
otlp_endpoint = "http://localhost:4317"
observability_mode = "analysis"
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
let telemetry = config.telemetry.unwrap();
assert_eq!(telemetry.observability_mode.as_deref(), Some("analysis"));
}
#[test]
fn config_telemetry_observability_mode_defaults_to_none() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[telemetry]
otlp_endpoint = "http://localhost:4317"
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
let telemetry = config.telemetry.unwrap();
assert!(telemetry.observability_mode.is_none());
}
#[test]
fn dispatch_mode_defaults_to_none() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert!(config.orchestrator.dispatch_mode.is_none());
}
#[test]
fn dispatch_mode_sequential_parses() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[orchestrator]
dispatch_mode = "sequential"
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert_eq!(
config.orchestrator.dispatch_mode,
Some(DispatchMode::Sequential)
);
}
#[test]
fn dispatch_mode_parallel_parses() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[orchestrator]
dispatch_mode = "parallel"
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert_eq!(
config.orchestrator.dispatch_mode,
Some(DispatchMode::Parallel)
);
}
#[test]
fn dispatch_mode_invalid_rejected() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[orchestrator]
dispatch_mode = "bananas"
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
assert!(matches!(err, Error::Config(_)));
}
#[test]
fn session_prune_preserve_task_defaults_to_true() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "test"
description = "Test"
system_prompt = "You test."
[agents.session_prune]
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
let sp = config.agents[0].session_prune.as_ref().unwrap();
assert_eq!(sp.keep_recent_n, 2); assert_eq!(sp.pruned_tool_result_max_bytes, 200); assert!(sp.preserve_task); }
#[test]
fn daemon_config_parses() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[daemon]
bind = "0.0.0.0:8080"
max_concurrent_tasks = 8
[daemon.kafka]
brokers = "localhost:9092"
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
let daemon = config.daemon.unwrap();
assert_eq!(daemon.bind, "0.0.0.0:8080");
assert_eq!(daemon.max_concurrent_tasks, 8);
let kafka = daemon.kafka.unwrap();
assert_eq!(kafka.brokers, "localhost:9092");
assert_eq!(kafka.consumer_group, "heartbit-daemon");
assert_eq!(kafka.commands_topic, "heartbit.commands");
assert_eq!(kafka.events_topic, "heartbit.events");
assert_eq!(kafka.dead_letter_topic, "heartbit.dead-letter");
}
#[test]
fn daemon_config_defaults() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[daemon.kafka]
brokers = "localhost:9092"
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
let daemon = config.daemon.unwrap();
assert_eq!(daemon.bind, "127.0.0.1:3000");
assert_eq!(daemon.max_concurrent_tasks, 4);
}
#[test]
fn daemon_config_defaults_to_none() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert!(config.daemon.is_none());
}
#[test]
fn daemon_zero_max_concurrent_rejected() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[daemon]
max_concurrent_tasks = 0
[daemon.kafka]
brokers = "localhost:9092"
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("max_concurrent_tasks must be at least 1"),
"error: {msg}"
);
}
#[test]
fn daemon_empty_brokers_rejected() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[daemon.kafka]
brokers = ""
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("brokers must not be empty"), "error: {msg}");
}
#[test]
fn daemon_config_metrics_defaults_to_none() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-3-5-sonnet"
[daemon.kafka]
brokers = "localhost:9092"
"#;
let config: HeartbitConfig = toml::from_str(toml).unwrap();
let daemon = config.daemon.unwrap();
assert!(daemon.metrics.is_none());
}
#[test]
fn daemon_config_metrics_enabled_explicit() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-3-5-sonnet"
[daemon.kafka]
brokers = "localhost:9092"
[daemon.metrics]
enabled = true
"#;
let config: HeartbitConfig = toml::from_str(toml).unwrap();
let daemon = config.daemon.unwrap();
let metrics = daemon.metrics.unwrap();
assert!(metrics.enabled);
}
#[test]
fn daemon_config_metrics_disabled() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-3-5-sonnet"
[daemon.kafka]
brokers = "localhost:9092"
[daemon.metrics]
enabled = false
"#;
let config: HeartbitConfig = toml::from_str(toml).unwrap();
let daemon = config.daemon.unwrap();
let metrics = daemon.metrics.unwrap();
assert!(!metrics.enabled);
}
#[test]
fn daemon_config_metrics_section_present_defaults_enabled() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-3-5-sonnet"
[daemon.kafka]
brokers = "localhost:9092"
[daemon.metrics]
"#;
let config: HeartbitConfig = toml::from_str(toml).unwrap();
let daemon = config.daemon.unwrap();
let metrics = daemon.metrics.unwrap();
assert!(metrics.enabled);
}
#[test]
fn sensor_source_name() {
let rss = SensorSourceConfig::Rss {
name: "tech_rss".into(),
feeds: vec!["https://example.com/feed".into()],
interest_keywords: vec![],
poll_interval_seconds: 900,
};
assert_eq!(rss.name(), "tech_rss");
let webhook = SensorSourceConfig::Webhook {
name: "github_events".into(),
path: "/webhooks/github".into(),
secret_env: None,
};
assert_eq!(webhook.name(), "github_events");
}
#[test]
fn kafka_dead_letter_topic_custom() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-3-5-sonnet"
[daemon.kafka]
brokers = "localhost:9092"
dead_letter_topic = "my.custom.dead-letter"
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
let daemon = config.daemon.unwrap();
assert_eq!(
daemon.kafka.unwrap().dead_letter_topic,
"my.custom.dead-letter"
);
}
#[test]
fn daemon_database_url_default_none() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-3-5-sonnet"
[daemon.kafka]
brokers = "localhost:9092"
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert!(config.daemon.unwrap().database_url.is_none());
}
#[test]
fn daemon_database_url_present() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-3-5-sonnet"
[daemon]
database_url = "postgresql://localhost/heartbit_tasks"
[daemon.kafka]
brokers = "localhost:9092"
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert_eq!(
config.daemon.unwrap().database_url.as_deref(),
Some("postgresql://localhost/heartbit_tasks")
);
}
#[test]
fn workspace_config_explicit_root() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[orchestrator]
max_turns = 5
max_tokens = 4096
[workspace]
root = "/custom/workspaces"
[[agents]]
name = "test"
description = "test"
system_prompt = "test"
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
let ws = config.workspace.unwrap();
assert_eq!(ws.root, "/custom/workspaces");
}
#[test]
fn workspace_config_default_root() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[orchestrator]
max_turns = 5
max_tokens = 4096
[workspace]
[[agents]]
name = "test"
description = "test"
system_prompt = "test"
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
let ws = config.workspace.unwrap();
assert!(ws.root.contains(".heartbit/workspaces"));
}
#[test]
fn workspace_config_absent() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[orchestrator]
max_turns = 5
max_tokens = 4096
[[agents]]
name = "test"
description = "test"
system_prompt = "test"
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert!(config.workspace.is_none());
}
#[test]
fn active_hours_parse_valid() {
let ah = ActiveHoursConfig {
start: "08:30".into(),
end: "22:00".into(),
};
assert_eq!(ah.parse_start().unwrap(), (8, 30));
assert_eq!(ah.parse_end().unwrap(), (22, 0));
}
#[test]
fn active_hours_parse_midnight() {
let ah = ActiveHoursConfig {
start: "00:00".into(),
end: "23:59".into(),
};
assert_eq!(ah.parse_start().unwrap(), (0, 0));
assert_eq!(ah.parse_end().unwrap(), (23, 59));
}
#[test]
fn active_hours_parse_invalid_format() {
let ah = ActiveHoursConfig {
start: "8am".into(),
end: "22:00".into(),
};
assert!(ah.parse_start().is_err());
}
#[test]
fn active_hours_parse_out_of_range() {
let ah = ActiveHoursConfig {
start: "25:00".into(),
end: "22:00".into(),
};
assert!(ah.parse_start().is_err());
}
#[test]
fn routing_defaults_to_auto_when_missing() {
let toml_str = r#"
[provider]
name = "anthropic"
model = "claude-3-5-sonnet"
[[agents]]
name = "worker"
description = "worker agent"
system_prompt = "you are a worker"
"#;
let config = HeartbitConfig::from_toml(toml_str).unwrap();
assert_eq!(config.orchestrator.routing, RoutingMode::Auto);
assert!(config.orchestrator.escalation);
}
#[test]
fn routing_parses_always_orchestrate() {
let toml_str = r#"
[provider]
name = "anthropic"
model = "claude-3-5-sonnet"
[orchestrator]
routing = "always_orchestrate"
[[agents]]
name = "worker"
description = "worker agent"
system_prompt = "you are a worker"
"#;
let config = HeartbitConfig::from_toml(toml_str).unwrap();
assert_eq!(config.orchestrator.routing, RoutingMode::AlwaysOrchestrate);
}
#[test]
fn routing_parses_single_agent() {
let toml_str = r#"
[provider]
name = "anthropic"
model = "claude-3-5-sonnet"
[orchestrator]
routing = "single_agent"
[[agents]]
name = "worker"
description = "worker agent"
system_prompt = "you are a worker"
"#;
let config = HeartbitConfig::from_toml(toml_str).unwrap();
assert_eq!(config.orchestrator.routing, RoutingMode::SingleAgent);
}
#[test]
fn escalation_defaults_to_true() {
let toml_str = r#"
[provider]
name = "anthropic"
model = "claude-3-5-sonnet"
[[agents]]
name = "worker"
description = "worker agent"
system_prompt = "you are a worker"
"#;
let config = HeartbitConfig::from_toml(toml_str).unwrap();
assert!(config.orchestrator.escalation);
}
#[test]
fn escalation_can_be_disabled() {
let toml_str = r#"
[provider]
name = "anthropic"
model = "claude-3-5-sonnet"
[orchestrator]
escalation = false
[[agents]]
name = "worker"
description = "worker agent"
system_prompt = "you are a worker"
"#;
let config = HeartbitConfig::from_toml(toml_str).unwrap();
assert!(!config.orchestrator.escalation);
}
#[test]
fn auth_config_valid() {
let toml_str = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[daemon.kafka]
brokers = "localhost:9092"
[daemon.auth]
bearer_tokens = ["my-secret-key", "rotation-key-2"]
"#;
let config = HeartbitConfig::from_toml(toml_str).unwrap();
let auth = config.daemon.unwrap().auth.unwrap();
assert_eq!(auth.bearer_tokens.len(), 2);
assert_eq!(auth.bearer_tokens[0], "my-secret-key");
}
#[test]
fn auth_config_empty_tokens_rejected() {
let toml_str = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[daemon.kafka]
brokers = "localhost:9092"
[daemon.auth]
bearer_tokens = []
"#;
let err = HeartbitConfig::from_toml(toml_str).unwrap_err();
assert!(
err.to_string()
.contains("daemon.auth requires at least bearer_tokens or jwks_url"),
"got: {err}"
);
}
#[test]
fn auth_config_empty_token_string_rejected() {
let toml_str = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[daemon.kafka]
brokers = "localhost:9092"
[daemon.auth]
bearer_tokens = [""]
"#;
let err = HeartbitConfig::from_toml(toml_str).unwrap_err();
assert!(
err.to_string()
.contains("daemon.auth.bearer_tokens[0] must not be empty"),
"got: {err}"
);
}
#[test]
fn auth_config_none_is_valid() {
let toml_str = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[daemon.kafka]
brokers = "localhost:9092"
"#;
let config = HeartbitConfig::from_toml(toml_str).unwrap();
assert!(config.daemon.unwrap().auth.is_none());
}
#[test]
fn auth_config_jwks_only_is_valid() {
let toml_str = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[daemon.kafka]
brokers = "localhost:9092"
[daemon.auth]
jwks_url = "https://idp.example.com/.well-known/jwks.json"
issuer = "https://idp.example.com"
audience = "heartbit-api"
"#;
let config = HeartbitConfig::from_toml(toml_str).unwrap();
let auth = config.daemon.unwrap().auth.unwrap();
assert!(auth.bearer_tokens.is_empty());
assert_eq!(
auth.jwks_url.as_deref(),
Some("https://idp.example.com/.well-known/jwks.json")
);
assert_eq!(auth.issuer.as_deref(), Some("https://idp.example.com"));
assert_eq!(auth.audience.as_deref(), Some("heartbit-api"));
}
#[test]
fn auth_config_empty_jwks_url_rejected() {
let toml_str = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[daemon.kafka]
brokers = "localhost:9092"
[daemon.auth]
bearer_tokens = ["valid-token"]
jwks_url = ""
"#;
let err = HeartbitConfig::from_toml(toml_str).unwrap_err();
assert!(
err.to_string()
.contains("daemon.auth.jwks_url must not be empty"),
"got: {err}"
);
}
#[test]
fn auth_config_no_tokens_no_jwks_rejected() {
let toml_str = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[daemon.kafka]
brokers = "localhost:9092"
[daemon.auth]
issuer = "https://idp.example.com"
"#;
let err = HeartbitConfig::from_toml(toml_str).unwrap_err();
assert!(
err.to_string()
.contains("daemon.auth requires at least bearer_tokens or jwks_url"),
"got: {err}"
);
}
#[test]
fn token_exchange_config_valid() {
let toml_str = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[daemon.kafka]
brokers = "localhost:9092"
[daemon.auth]
jwks_url = "https://idp.example.com/.well-known/jwks.json"
[daemon.auth.token_exchange]
exchange_url = "https://idp.example.com/oauth/token"
client_id = "heartbit-agent"
client_secret = "secret123"
agent_token = "agent-cred-token"
scopes = ["crm:read", "crm:write"]
"#;
let config = HeartbitConfig::from_toml(toml_str).unwrap();
let te = config.daemon.unwrap().auth.unwrap().token_exchange.unwrap();
assert_eq!(te.exchange_url, "https://idp.example.com/oauth/token");
assert_eq!(te.client_id, "heartbit-agent");
assert_eq!(te.client_secret, "secret123");
assert_eq!(te.agent_token, "agent-cred-token");
assert_eq!(te.scopes, vec!["crm:read", "crm:write"]);
}
#[test]
fn token_exchange_empty_exchange_url_rejected() {
let toml_str = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[daemon.kafka]
brokers = "localhost:9092"
[daemon.auth]
jwks_url = "https://idp.example.com/.well-known/jwks.json"
[daemon.auth.token_exchange]
exchange_url = ""
client_id = "heartbit-agent"
client_secret = "secret123"
agent_token = "agent-cred-token"
"#;
let err = HeartbitConfig::from_toml(toml_str).unwrap_err();
assert!(
err.to_string()
.contains("daemon.auth.token_exchange.exchange_url must not be empty"),
"got: {err}"
);
}
#[test]
fn token_exchange_empty_client_id_rejected() {
let toml_str = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[daemon.kafka]
brokers = "localhost:9092"
[daemon.auth]
jwks_url = "https://idp.example.com/.well-known/jwks.json"
[daemon.auth.token_exchange]
exchange_url = "https://idp.example.com/oauth/token"
client_id = ""
client_secret = "secret123"
agent_token = "agent-cred-token"
"#;
let err = HeartbitConfig::from_toml(toml_str).unwrap_err();
assert!(
err.to_string()
.contains("daemon.auth.token_exchange.client_id must not be empty"),
"got: {err}"
);
}
#[test]
fn token_exchange_empty_agent_token_rejected() {
let toml_str = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[daemon.kafka]
brokers = "localhost:9092"
[daemon.auth]
jwks_url = "https://idp.example.com/.well-known/jwks.json"
[daemon.auth.token_exchange]
exchange_url = "https://idp.example.com/oauth/token"
client_id = "heartbit-agent"
client_secret = "secret123"
agent_token = ""
"#;
let err = HeartbitConfig::from_toml(toml_str).unwrap_err();
assert!(
err.to_string()
.contains("daemon.auth.token_exchange: set tenant_id for auto-fetch"),
"got: {err}"
);
}
#[test]
fn token_exchange_none_is_valid() {
let toml_str = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[daemon.kafka]
brokers = "localhost:9092"
[daemon.auth]
jwks_url = "https://idp.example.com/.well-known/jwks.json"
"#;
let config = HeartbitConfig::from_toml(toml_str).unwrap();
assert!(
config
.daemon
.unwrap()
.auth
.unwrap()
.token_exchange
.is_none()
);
}
#[test]
fn cascade_config_parses_full() {
let toml_str = r#"
[provider]
name = "openrouter"
model = "anthropic/claude-sonnet-4"
[provider.cascade]
enabled = true
[[provider.cascade.tiers]]
model = "anthropic/claude-3.5-haiku"
[provider.cascade.gate]
type = "heuristic"
min_output_tokens = 10
accept_tool_calls = false
escalate_on_max_tokens = false
"#;
let config = HeartbitConfig::from_toml(toml_str).unwrap();
let cascade = config.provider.cascade.unwrap();
assert!(cascade.enabled);
assert_eq!(cascade.tiers.len(), 1);
assert_eq!(cascade.tiers[0].model, "anthropic/claude-3.5-haiku");
match &cascade.gate {
CascadeGateConfig::Heuristic {
min_output_tokens,
accept_tool_calls,
escalate_on_max_tokens,
} => {
assert_eq!(*min_output_tokens, 10);
assert!(!accept_tool_calls);
assert!(!escalate_on_max_tokens);
}
}
}
#[test]
fn cascade_config_defaults_when_absent() {
let toml_str = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
"#;
let config = HeartbitConfig::from_toml(toml_str).unwrap();
assert!(config.provider.cascade.is_none());
}
#[test]
fn cascade_config_gate_defaults() {
let toml_str = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[provider.cascade]
enabled = true
[[provider.cascade.tiers]]
model = "claude-3.5-haiku"
"#;
let config = HeartbitConfig::from_toml(toml_str).unwrap();
let cascade = config.provider.cascade.unwrap();
match &cascade.gate {
CascadeGateConfig::Heuristic {
min_output_tokens,
accept_tool_calls,
escalate_on_max_tokens,
} => {
assert_eq!(*min_output_tokens, 5);
assert!(accept_tool_calls);
assert!(escalate_on_max_tokens);
}
}
}
#[test]
fn validate_rejects_cascade_enabled_without_tiers() {
let toml_str = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[provider.cascade]
enabled = true
"#;
let err = HeartbitConfig::from_toml(toml_str).unwrap_err();
assert!(
err.to_string().contains("no tiers are configured"),
"error: {err}"
);
}
#[test]
fn cascade_disabled_with_tiers_is_valid() {
let toml_str = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[provider.cascade]
enabled = false
[[provider.cascade.tiers]]
model = "claude-3.5-haiku"
"#;
let config = HeartbitConfig::from_toml(toml_str).unwrap();
let cascade = config.provider.cascade.unwrap();
assert!(!cascade.enabled);
}
#[test]
fn agent_provider_cascade_config_parses() {
let toml_str = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "researcher"
description = "Research agent"
system_prompt = "You are a researcher."
[agents.provider]
name = "openrouter"
model = "anthropic/claude-sonnet-4"
[agents.provider.cascade]
enabled = true
[[agents.provider.cascade.tiers]]
model = "anthropic/claude-3.5-haiku"
"#;
let config = HeartbitConfig::from_toml(toml_str).unwrap();
let agent_cascade = config.agents[0]
.provider
.as_ref()
.unwrap()
.cascade
.as_ref()
.unwrap();
assert!(agent_cascade.enabled);
assert_eq!(agent_cascade.tiers.len(), 1);
}
#[test]
fn validate_rejects_empty_provider_name() {
let toml_str = r#"
[provider]
name = ""
model = "claude-sonnet-4-20250514"
"#;
let err = HeartbitConfig::from_toml(toml_str).unwrap_err();
assert!(
err.to_string().contains("provider.name must not be empty"),
"error: {err}"
);
}
#[test]
fn validate_rejects_empty_provider_model() {
let toml_str = r#"
[provider]
name = "anthropic"
model = ""
"#;
let err = HeartbitConfig::from_toml(toml_str).unwrap_err();
assert!(
err.to_string().contains("provider.model must not be empty"),
"error: {err}"
);
}
#[test]
fn validate_rejects_empty_cascade_tier_model() {
let toml_str = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[provider.cascade]
enabled = true
[[provider.cascade.tiers]]
model = ""
"#;
let err = HeartbitConfig::from_toml(toml_str).unwrap_err();
assert!(
err.to_string()
.contains("provider.cascade.tiers[0].model must not be empty"),
"error: {err}"
);
}
#[test]
fn mcp_server_entry_stdio_roundtrip() {
let stdio = McpServerEntry::Stdio {
command: "npx".into(),
args: vec!["-y".into(), "my-mcp-server".into()],
env: std::collections::HashMap::from([("KEY".into(), "val".into())]),
};
let json = serde_json::to_string(&stdio).unwrap();
let parsed: McpServerEntry = serde_json::from_str(&json).unwrap();
assert_eq!(stdio, parsed);
}
#[test]
fn mcp_server_entry_display_name() {
let simple = McpServerEntry::Simple("http://localhost/mcp".into());
assert_eq!(simple.display_name(), "http://localhost/mcp");
let full = McpServerEntry::Full {
url: "http://gateway/mcp".into(),
auth_header: Some("Bearer tok".into()),
resource: None,
scopes: None,
};
assert_eq!(full.display_name(), "http://gateway/mcp");
let stdio = McpServerEntry::Stdio {
command: "npx".into(),
args: vec!["-y".into(), "server".into()],
env: Default::default(),
};
assert_eq!(stdio.display_name(), "npx -y server");
let stdio_no_args = McpServerEntry::Stdio {
command: "my-server".into(),
args: vec![],
env: Default::default(),
};
assert_eq!(stdio_no_args.display_name(), "my-server");
}
#[test]
fn validate_daemon_empty_consumer_group() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[daemon.kafka]
brokers = "localhost:9092"
consumer_group = ""
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
assert!(
err.to_string().contains("consumer_group must not be empty"),
"got: {err}"
);
}
#[test]
fn validate_daemon_empty_commands_topic() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[daemon.kafka]
brokers = "localhost:9092"
commands_topic = ""
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
assert!(
err.to_string().contains("commands_topic must not be empty"),
"got: {err}"
);
}
#[test]
fn validate_daemon_empty_events_topic() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[daemon.kafka]
brokers = "localhost:9092"
events_topic = ""
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
assert!(
err.to_string().contains("events_topic must not be empty"),
"got: {err}"
);
}
#[test]
fn sensor_modality_serde_roundtrip() {
for modality in [
SensorModality::Text,
SensorModality::Image,
SensorModality::Audio,
SensorModality::Structured,
] {
let json = serde_json::to_string(&modality).unwrap();
let back: SensorModality = serde_json::from_str(&json).unwrap();
assert_eq!(back, modality);
}
}
#[test]
fn sensor_modality_snake_case() {
assert_eq!(
serde_json::to_string(&SensorModality::Text).unwrap(),
r#""text""#
);
assert_eq!(
serde_json::to_string(&SensorModality::Image).unwrap(),
r#""image""#
);
assert_eq!(
serde_json::to_string(&SensorModality::Audio).unwrap(),
r#""audio""#
);
assert_eq!(
serde_json::to_string(&SensorModality::Structured).unwrap(),
r#""structured""#
);
}
#[test]
fn sensor_modality_display() {
assert_eq!(SensorModality::Text.to_string(), "text");
assert_eq!(SensorModality::Image.to_string(), "image");
assert_eq!(SensorModality::Audio.to_string(), "audio");
assert_eq!(SensorModality::Structured.to_string(), "structured");
}
#[test]
fn trust_level_default_is_unknown() {
assert_eq!(TrustLevel::default(), TrustLevel::Unknown);
}
#[test]
fn trust_level_ordering() {
assert!(TrustLevel::Quarantined < TrustLevel::Unknown);
assert!(TrustLevel::Unknown < TrustLevel::Known);
assert!(TrustLevel::Known < TrustLevel::Verified);
assert!(TrustLevel::Verified < TrustLevel::Owner);
}
#[test]
fn trust_level_serde_roundtrip() {
for t in [
TrustLevel::Quarantined,
TrustLevel::Unknown,
TrustLevel::Known,
TrustLevel::Verified,
TrustLevel::Owner,
] {
let json = serde_json::to_string(&t).unwrap();
let parsed: TrustLevel = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, t);
}
}
#[test]
fn trust_level_display() {
assert_eq!(TrustLevel::Quarantined.to_string(), "quarantined");
assert_eq!(TrustLevel::Unknown.to_string(), "unknown");
assert_eq!(TrustLevel::Known.to_string(), "known");
assert_eq!(TrustLevel::Verified.to_string(), "verified");
assert_eq!(TrustLevel::Owner.to_string(), "owner");
}
#[test]
fn trust_level_resolve_owner() {
let trust = TrustLevel::resolve(
Some("owner@example.com"),
&["owner@example.com".into()],
&[],
&[],
);
assert_eq!(trust, TrustLevel::Owner);
}
#[test]
fn trust_level_resolve_verified() {
let trust = TrustLevel::resolve(
Some("alice@example.com"),
&[],
&["alice@example.com".into()],
&[],
);
assert_eq!(trust, TrustLevel::Verified);
}
#[test]
fn trust_level_resolve_blocked() {
let trust = TrustLevel::resolve(
Some("spammer@evil.com"),
&[],
&[],
&["spammer@evil.com".into()],
);
assert_eq!(trust, TrustLevel::Quarantined);
}
#[test]
fn trust_level_resolve_unknown() {
let trust = TrustLevel::resolve(Some("stranger@example.com"), &[], &[], &[]);
assert_eq!(trust, TrustLevel::Unknown);
}
#[test]
fn trust_level_resolve_none_sender() {
let trust = TrustLevel::resolve(None, &[], &[], &[]);
assert_eq!(trust, TrustLevel::Unknown);
}
#[test]
fn trust_level_owner_trumps_blocked() {
let trust = TrustLevel::resolve(
Some("owner@example.com"),
&["owner@example.com".into()],
&[],
&["owner@example.com".into()],
);
assert_eq!(trust, TrustLevel::Owner);
}
#[test]
fn trust_level_resolve_case_insensitive() {
let trust = TrustLevel::resolve(
Some("Owner@Example.COM"),
&["owner@example.com".into()],
&[],
&[],
);
assert_eq!(trust, TrustLevel::Owner);
}
#[test]
fn guardrails_config_default_empty() {
let config: GuardrailsConfig = toml::from_str("").unwrap();
assert!(config.injection.is_none());
assert!(config.pii.is_none());
assert!(config.tool_policy.is_none());
}
#[test]
fn guardrails_config_roundtrip() {
let toml_str = r#"
[injection]
threshold = 0.3
mode = "warn"
[pii]
action = "redact"
detectors = ["email", "ssn"]
[tool_policy]
default_action = "allow"
[[tool_policy.rules]]
tool = "bash"
action = "deny"
input_constraints = []
[[tool_policy.rules]]
tool = "gmail_send_*"
action = "warn"
input_constraints = []
"#;
let config: GuardrailsConfig = toml::from_str(toml_str).unwrap();
let inj = config.injection.as_ref().unwrap();
assert!((inj.threshold - 0.3).abs() < 0.01);
assert_eq!(inj.mode, "warn");
let pii = config.pii.as_ref().unwrap();
assert_eq!(pii.action, "redact");
assert_eq!(pii.detectors, vec!["email", "ssn"]);
let tp = config.tool_policy.as_ref().unwrap();
assert_eq!(tp.default_action, "allow");
assert_eq!(tp.rules.len(), 2);
assert_eq!(tp.rules[0].tool, "bash");
assert_eq!(tp.rules[0].action, "deny");
assert_eq!(tp.rules[1].tool, "gmail_send_*");
assert_eq!(tp.rules[1].action, "warn");
let serialized = toml::to_string(&config).unwrap();
let _back: GuardrailsConfig = toml::from_str(&serialized).unwrap();
}
#[test]
fn guardrails_config_with_input_constraints() {
let toml_str = r#"
[tool_policy]
default_action = "deny"
[[tool_policy.rules]]
tool = "read"
action = "allow"
[[tool_policy.rules.input_constraints]]
path = "path"
deny_pattern = "^/etc/"
"#;
let config: GuardrailsConfig = toml::from_str(toml_str).unwrap();
let tp = config.tool_policy.unwrap();
assert_eq!(tp.default_action, "deny");
assert_eq!(tp.rules.len(), 1);
assert_eq!(tp.rules[0].input_constraints.len(), 1);
assert_eq!(tp.rules[0].input_constraints[0].path, "path");
assert_eq!(
tp.rules[0].input_constraints[0].deny_pattern.as_deref(),
Some("^/etc/")
);
}
#[test]
fn heartbit_config_with_guardrails() {
let toml_str = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[guardrails.injection]
threshold = 0.5
mode = "deny"
[guardrails.pii]
action = "redact"
"#;
let config: HeartbitConfig = toml::from_str(toml_str).unwrap();
let guardrails = config.guardrails.unwrap();
assert!(guardrails.injection.is_some());
assert!(guardrails.pii.is_some());
assert!(guardrails.tool_policy.is_none());
}
#[test]
fn guardrails_config_build_empty() {
let config = GuardrailsConfig::default();
assert!(config.is_empty());
let guardrails = config.build().unwrap();
assert!(guardrails.is_empty());
}
#[test]
fn guardrails_config_build_injection() {
let config = GuardrailsConfig {
injection: Some(InjectionConfig {
threshold: 0.3,
mode: "warn".into(),
}),
..Default::default()
};
let guardrails = config.build().unwrap();
assert_eq!(guardrails.len(), 1);
}
#[test]
fn guardrails_config_build_pii() {
let config = GuardrailsConfig {
pii: Some(PiiConfig {
action: "redact".into(),
detectors: vec!["email".into(), "phone".into()],
}),
..Default::default()
};
let guardrails = config.build().unwrap();
assert_eq!(guardrails.len(), 1);
}
#[test]
fn guardrails_config_build_tool_policy() {
let config = GuardrailsConfig {
tool_policy: Some(ToolPolicyConfig {
default_action: "allow".into(),
rules: vec![ToolPolicyRuleConfig {
tool: "bash".into(),
action: "deny".into(),
input_constraints: vec![],
}],
}),
..Default::default()
};
let guardrails = config.build().unwrap();
assert_eq!(guardrails.len(), 1);
}
#[test]
fn guardrails_config_build_all_three() {
let config = GuardrailsConfig {
injection: Some(InjectionConfig {
threshold: 0.5,
mode: "deny".into(),
}),
pii: Some(PiiConfig {
action: "warn".into(),
detectors: default_pii_detectors(),
}),
tool_policy: Some(ToolPolicyConfig {
default_action: "allow".into(),
rules: vec![],
}),
llm_judge: None,
secret_scan: None,
behavioral: None,
action_budget: None,
};
assert!(!config.is_empty());
let guardrails = config.build().unwrap();
assert_eq!(guardrails.len(), 3);
}
#[test]
fn guardrails_config_build_invalid_mode_errors() {
let config = GuardrailsConfig {
injection: Some(InjectionConfig {
threshold: 0.5,
mode: "invalid".into(),
}),
..Default::default()
};
let err = config.build().err().expect("should fail");
assert!(
err.to_string().contains("invalid injection mode"),
"error: {err}"
);
}
#[test]
fn guardrails_config_build_invalid_pii_action_errors() {
let config = GuardrailsConfig {
pii: Some(PiiConfig {
action: "destroy".into(),
detectors: vec!["email".into()],
}),
..Default::default()
};
let err = config.build().err().expect("should fail");
assert!(
err.to_string().contains("invalid PII action"),
"error: {err}"
);
}
#[test]
fn guardrails_config_build_invalid_detector_errors() {
let config = GuardrailsConfig {
pii: Some(PiiConfig {
action: "redact".into(),
detectors: vec!["dna_sequence".into()],
}),
..Default::default()
};
let err = config.build().err().expect("should fail");
assert!(
err.to_string().contains("unknown PII detector"),
"error: {err}"
);
}
#[test]
fn guardrails_config_build_invalid_regex_errors() {
let config = GuardrailsConfig {
tool_policy: Some(ToolPolicyConfig {
default_action: "allow".into(),
rules: vec![ToolPolicyRuleConfig {
tool: "bash".into(),
action: "allow".into(),
input_constraints: vec![InputConstraintConfig {
path: "command".into(),
deny_pattern: Some("[invalid".into()),
max_length: None,
}],
}],
}),
..Default::default()
};
let err = config.build().err().expect("should fail");
assert!(
err.to_string().contains("invalid deny_pattern"),
"error: {err}"
);
}
#[test]
fn guardrails_config_build_with_input_constraints() {
let config = GuardrailsConfig {
tool_policy: Some(ToolPolicyConfig {
default_action: "deny".into(),
rules: vec![ToolPolicyRuleConfig {
tool: "bash".into(),
action: "allow".into(),
input_constraints: vec![
InputConstraintConfig {
path: "command".into(),
deny_pattern: Some(r"rm\s+-rf".into()),
max_length: None,
},
InputConstraintConfig {
path: "command".into(),
deny_pattern: None,
max_length: Some(1024),
},
],
}],
}),
..Default::default()
};
let guardrails = config.build().unwrap();
assert_eq!(guardrails.len(), 1);
}
#[test]
fn guardrails_config_build_from_toml() {
let toml_str = r#"
[injection]
threshold = 0.4
mode = "warn"
[pii]
action = "deny"
detectors = ["email", "ssn"]
[tool_policy]
default_action = "allow"
[[tool_policy.rules]]
tool = "bash"
action = "deny"
[[tool_policy.rules]]
tool = "gmail_send_*"
action = "warn"
"#;
let config: GuardrailsConfig = toml::from_str(toml_str).unwrap();
let guardrails = config.build().unwrap();
assert_eq!(guardrails.len(), 3);
}
#[test]
fn guardrails_config_llm_judge_from_toml() {
let toml_str = r#"
[llm_judge]
criteria = ["no harmful content", "no personal attacks"]
evaluate_tool_inputs = true
timeout_seconds = 15
max_judge_tokens = 512
"#;
let config: GuardrailsConfig = toml::from_str(toml_str).unwrap();
let judge_cfg = config.llm_judge.as_ref().expect("llm_judge should be set");
assert_eq!(judge_cfg.criteria.len(), 2);
assert!(judge_cfg.evaluate_tool_inputs);
assert_eq!(judge_cfg.timeout_seconds, 15);
assert_eq!(judge_cfg.max_judge_tokens, 512);
}
#[test]
fn guardrails_config_llm_judge_defaults() {
let toml_str = r#"
[llm_judge]
criteria = ["safety"]
"#;
let config: GuardrailsConfig = toml::from_str(toml_str).unwrap();
let judge_cfg = config.llm_judge.as_ref().expect("llm_judge should be set");
assert!(!judge_cfg.evaluate_tool_inputs);
assert_eq!(judge_cfg.timeout_seconds, 10);
assert_eq!(judge_cfg.max_judge_tokens, 256);
}
#[test]
fn guardrails_config_build_skips_judge_without_provider() {
let config = GuardrailsConfig {
llm_judge: Some(LlmJudgeConfig {
criteria: vec!["safety".into()],
evaluate_tool_inputs: false,
timeout_seconds: 10,
max_judge_tokens: 256,
}),
..Default::default()
};
let guardrails = config.build().unwrap();
assert_eq!(guardrails.len(), 0);
}
#[test]
fn guardrails_config_is_empty_with_only_llm_judge() {
let config = GuardrailsConfig {
llm_judge: Some(LlmJudgeConfig {
criteria: vec!["safety".into()],
evaluate_tool_inputs: false,
timeout_seconds: 10,
max_judge_tokens: 256,
}),
..Default::default()
};
assert!(!config.is_empty());
}
#[test]
fn parse_local_embedding_config() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "test"
description = "Test agent"
system_prompt = "You are a test agent."
[memory]
type = "postgres"
database_url = "postgresql://localhost/heartbit"
[memory.embedding]
provider = "local"
model = "all-MiniLM-L6-v2"
cache_dir = "/tmp/fastembed"
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
let memory = config.memory.expect("memory should be present");
match memory {
MemoryConfig::Postgres { embedding, .. } => {
let emb = embedding.expect("embedding config should be present");
assert_eq!(emb.provider, "local");
assert_eq!(emb.model, "all-MiniLM-L6-v2");
assert_eq!(emb.cache_dir.as_deref(), Some("/tmp/fastembed"));
}
_ => panic!("expected Postgres memory config"),
}
}
#[test]
fn parse_local_embedding_config_defaults() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "test"
description = "Test agent"
system_prompt = "You are a test agent."
[memory]
type = "postgres"
database_url = "postgresql://localhost/heartbit"
[memory.embedding]
provider = "local"
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
let memory = config.memory.expect("memory should be present");
match memory {
MemoryConfig::Postgres { embedding, .. } => {
let emb = embedding.expect("embedding config should be present");
assert_eq!(emb.provider, "local");
assert_eq!(emb.model, "text-embedding-3-small");
assert!(emb.cache_dir.is_none());
assert!(emb.base_url.is_none());
assert!(emb.dimension.is_none());
}
_ => panic!("expected Postgres memory config"),
}
}
#[test]
fn auth_config_backward_compat() {
let toml_str = r#"
bearer_tokens = ["tok-abc", "tok-xyz"]
"#;
let auth: AuthConfig = toml::from_str(toml_str).unwrap();
assert_eq!(auth.bearer_tokens, vec!["tok-abc", "tok-xyz"]);
assert!(auth.jwks_url.is_none());
assert!(auth.issuer.is_none());
assert!(auth.audience.is_none());
assert!(auth.user_id_claim.is_none());
assert!(auth.tenant_id_claim.is_none());
assert!(auth.roles_claim.is_none());
assert!(auth.token_exchange.is_none());
}
#[test]
fn auth_config_with_jwks() {
let toml_str = r#"
bearer_tokens = ["tok-1"]
jwks_url = "https://idp.example.com/.well-known/jwks.json"
issuer = "https://idp.example.com"
audience = "heartbit-api"
user_id_claim = "sub"
tenant_id_claim = "org_id"
roles_claim = "permissions"
"#;
let auth: AuthConfig = toml::from_str(toml_str).unwrap();
assert_eq!(auth.bearer_tokens, vec!["tok-1"]);
assert_eq!(
auth.jwks_url.as_deref(),
Some("https://idp.example.com/.well-known/jwks.json")
);
assert_eq!(auth.issuer.as_deref(), Some("https://idp.example.com"));
assert_eq!(auth.audience.as_deref(), Some("heartbit-api"));
assert_eq!(auth.user_id_claim.as_deref(), Some("sub"));
assert_eq!(auth.tenant_id_claim.as_deref(), Some("org_id"));
assert_eq!(auth.roles_claim.as_deref(), Some("permissions"));
}
#[test]
fn auth_config_empty_is_valid() {
let toml_str = "";
let auth: AuthConfig = toml::from_str(toml_str).unwrap();
assert!(auth.bearer_tokens.is_empty());
assert!(auth.jwks_url.is_none());
assert!(auth.issuer.is_none());
assert!(auth.audience.is_none());
assert!(auth.user_id_claim.is_none());
assert!(auth.tenant_id_claim.is_none());
assert!(auth.roles_claim.is_none());
}
#[test]
fn auth_config_mixed() {
let toml_str = r#"
bearer_tokens = ["static-key"]
jwks_url = "https://auth.corp.io/.well-known/jwks.json"
audience = "heartbit"
"#;
let auth: AuthConfig = toml::from_str(toml_str).unwrap();
assert_eq!(auth.bearer_tokens, vec!["static-key"]);
assert_eq!(
auth.jwks_url.as_deref(),
Some("https://auth.corp.io/.well-known/jwks.json")
);
assert!(auth.issuer.is_none());
assert_eq!(auth.audience.as_deref(), Some("heartbit"));
assert!(auth.user_id_claim.is_none());
assert!(auth.tenant_id_claim.is_none());
assert!(auth.roles_claim.is_none());
}
#[test]
fn mcp_resource_mode_default_is_tools() {
let mode: McpResourceMode = Default::default();
assert_eq!(mode, McpResourceMode::Tools);
}
#[test]
fn mcp_resource_mode_deserialize() {
#[derive(Deserialize)]
struct Wrapper {
mode: McpResourceMode,
}
let w: Wrapper = toml::from_str(r#"mode = "tools""#).unwrap();
assert_eq!(w.mode, McpResourceMode::Tools);
let w: Wrapper = toml::from_str(r#"mode = "context""#).unwrap();
assert_eq!(w.mode, McpResourceMode::Context);
let w: Wrapper = toml::from_str(r#"mode = "none""#).unwrap();
assert_eq!(w.mode, McpResourceMode::None);
}
#[test]
fn agent_config_mcp_resources_default() {
let toml_str = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[orchestrator]
max_turns = 10
[[agents]]
name = "test"
description = "A test agent"
system_prompt = "You are a test."
"#;
let config: HeartbitConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.agents[0].mcp_resources, McpResourceMode::Tools);
}
#[test]
fn agent_config_mcp_resources_explicit() {
let toml_str = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[orchestrator]
max_turns = 10
[[agents]]
name = "test"
description = "A test agent"
system_prompt = "You are a test."
mcp_resources = "none"
"#;
let config: HeartbitConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.agents[0].mcp_resources, McpResourceMode::None);
}
#[test]
fn parse_workflow_type_valid() {
use crate::agent::workflow::WorkflowType;
assert_eq!(parse_workflow_type("dag").unwrap(), WorkflowType::Dag);
assert_eq!(parse_workflow_type("DAG").unwrap(), WorkflowType::Dag);
assert_eq!(
parse_workflow_type("sequential").unwrap(),
WorkflowType::Sequential
);
assert_eq!(
parse_workflow_type("parallel").unwrap(),
WorkflowType::Parallel
);
assert_eq!(parse_workflow_type("loop").unwrap(), WorkflowType::Loop);
assert_eq!(parse_workflow_type("debate").unwrap(), WorkflowType::Debate);
assert_eq!(parse_workflow_type("voting").unwrap(), WorkflowType::Voting);
assert_eq!(
parse_workflow_type("mixture").unwrap(),
WorkflowType::Mixture
);
}
#[test]
fn parse_workflow_type_invalid() {
assert!(parse_workflow_type("").is_err());
assert!(parse_workflow_type("unknown").is_err());
assert!(parse_workflow_type("rm -rf /").is_err());
}
#[test]
fn validate_rejects_unknown_builtin() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "researcher"
description = "Research specialist"
builtin_tools = ["websearch", "nonexistent"]
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("unknown builtin tool 'nonexistent'"),
"expected unknown builtin error, got: {msg}"
);
}
#[test]
fn agent_config_builtin_tools_deserialization() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "researcher"
description = "Research specialist"
builtin_tools = ["websearch", "webfetch"]
[[agents]]
name = "publisher"
description = "Publisher"
builtin_tools = []
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert_eq!(
config.agents[0].builtin_tools,
Some(vec!["websearch".into(), "webfetch".into()])
);
assert_eq!(config.agents[1].builtin_tools, Some(vec![]));
}
#[test]
fn agent_config_builtin_tools_absent_is_none() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[[agents]]
name = "researcher"
description = "Research specialist"
"#;
let config = HeartbitConfig::from_toml(toml).unwrap();
assert_eq!(config.agents[0].builtin_tools, None);
}
#[test]
fn b5b_full_config_parses() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[provider.circuit]
failure_threshold = 5
initial_open_duration_seconds = 30
max_open_duration_seconds = 300
backoff_multiplier = 2.0
[orchestrator]
max_tokens_in_flight_per_tenant = 1000000
"#;
let cfg = HeartbitConfig::from_toml(toml).unwrap();
assert_eq!(cfg.provider.circuit.failure_threshold, Some(5));
assert_eq!(cfg.provider.circuit.initial_open_duration_seconds, Some(30));
assert_eq!(cfg.provider.circuit.max_open_duration_seconds, Some(300));
assert_eq!(cfg.provider.circuit.backoff_multiplier, Some(2.0));
assert_eq!(
cfg.orchestrator.max_tokens_in_flight_per_tenant,
Some(1_000_000)
);
}
#[test]
fn b5b_config_zero_failure_threshold_rejected() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[provider.circuit]
failure_threshold = 0
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
assert!(
err.to_string()
.contains("provider.circuit.failure_threshold must be > 0"),
"error: {err}"
);
}
#[test]
fn b5b_config_zero_initial_open_duration_rejected() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[provider.circuit]
initial_open_duration_seconds = 0
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
assert!(
err.to_string()
.contains("provider.circuit.initial_open_duration_seconds must be > 0"),
"error: {err}"
);
}
#[test]
fn b5b_config_zero_max_open_duration_rejected() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[provider.circuit]
max_open_duration_seconds = 0
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
assert!(
err.to_string()
.contains("provider.circuit.max_open_duration_seconds must be > 0"),
"error: {err}"
);
}
#[test]
fn b5b_config_zero_max_tokens_in_flight_rejected() {
let toml = r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[orchestrator]
max_tokens_in_flight_per_tenant = 0
"#;
let err = HeartbitConfig::from_toml(toml).unwrap_err();
assert!(
err.to_string()
.contains("orchestrator.max_tokens_in_flight_per_tenant must be > 0"),
"error: {err}"
);
}
#[test]
fn b5b_config_invalid_backoff_multiplier_rejected() {
for bad in ["0.0", "-0.0", "-1.0", "nan", "inf", "-inf"] {
let toml = format!(
r#"
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[provider.circuit]
backoff_multiplier = {bad}
"#
);
let err = match HeartbitConfig::from_toml(&toml) {
Ok(cfg) => panic!("backoff_multiplier = {bad} must be rejected, got: {cfg:?}"),
Err(e) => e,
};
assert!(
err.to_string()
.contains("provider.circuit.backoff_multiplier must be > 0 and finite"),
"value {bad} produced unexpected error: {err}"
);
}
}
#[test]
fn b5b_circuit_config_from_provider_circuit_config() {
use crate::llm::circuit::CircuitConfig;
let pcc = ProviderCircuitConfig {
failure_threshold: Some(3),
initial_open_duration_seconds: Some(10),
max_open_duration_seconds: Some(120),
backoff_multiplier: Some(1.5),
};
let cc = CircuitConfig::from(&pcc);
assert_eq!(cc.failure_threshold, 3);
assert_eq!(cc.initial_open_duration, std::time::Duration::from_secs(10));
assert_eq!(cc.max_open_duration, std::time::Duration::from_secs(120));
assert_eq!(cc.backoff_multiplier, 1.5);
}
#[test]
fn b5b_circuit_config_defaults_when_absent() {
use crate::llm::circuit::CircuitConfig;
let default_cc = CircuitConfig::default();
let pcc = ProviderCircuitConfig::default();
let cc = CircuitConfig::from(&pcc);
assert_eq!(cc.failure_threshold, default_cc.failure_threshold);
assert_eq!(cc.initial_open_duration, default_cc.initial_open_duration);
assert_eq!(cc.max_open_duration, default_cc.max_open_duration);
assert_eq!(cc.backoff_multiplier, default_cc.backoff_multiplier);
}
}