Skip to main content

heartbit_core/config/
mod.rs

1//! TOML configuration types for agents, orchestrators, LLM providers, and integrations.
2
3mod agent;
4mod daemon;
5mod guardrails;
6mod memory;
7mod persona;
8mod provider;
9mod sensor;
10
11// Re-export everything so the public API doesn't change.
12pub use agent::{
13    AgentConfig, AgentProviderConfig, ContextStrategyConfig, DispatchMode, McpResourceMode,
14    McpServerEntry, OrchestratorConfig, SessionPruneConfigToml, SpawnConfig,
15};
16pub use daemon::{
17    ActiveHoursConfig, AuthConfig, DaemonAuditConfig, DaemonConfig, DaemonMcpServerConfig,
18    DaemonMemoryConfig, HeartbitPulseConfig, IdempotencyConfig, KafkaConfig, MetricsConfig,
19    ScheduleEntry, TokenExchangeConfig, WsConfig,
20};
21
22#[cfg(feature = "ghost-domain-config")]
23pub use daemon::{
24    GithubReadmeConfig, ImageSource, PersonaBlogConfig, PersonaMentionsConfig, PersonaPostsConfig,
25    PersonaQuotesConfig, XAnnounceConfig,
26};
27pub use guardrails::{
28    ActionBudgetConfig, ActionBudgetRuleConfig, BehavioralConfig, BehavioralRuleConfig,
29    GuardrailsConfig, InjectionConfig, InputConstraintConfig, LlmJudgeConfig, PiiConfig,
30    SecretPatternConfig, SecretScanConfig, ToolPolicyConfig, ToolPolicyRuleConfig,
31};
32pub use memory::{
33    EmbeddingConfig, KnowledgeConfig, KnowledgeSourceConfig, LspConfig, MemoryConfig,
34    RestateConfig, TelemetryConfig, WorkspaceConfig,
35};
36pub use persona::{PersonaConfig, PersonaPhase};
37pub use provider::{
38    CascadeConfig, CascadeGateConfig, CascadeTierConfig, ProviderCircuitConfig, ProviderConfig,
39    RetryProviderConfig,
40};
41pub use sensor::{
42    SalienceConfig, SensorConfig, SensorRoutingConfig, SensorSourceConfig, StoryCorrelationConfig,
43    TokenBudgetConfig,
44};
45
46pub use crate::agent::routing::RoutingMode;
47
48use serde::{Deserialize, Serialize};
49
50use crate::Error;
51use crate::agent::permission::PermissionRule;
52use crate::agent::tool_filter::ToolProfile;
53use crate::llm::types::ReasoningEffort;
54
55/// Known builtin tool names for validation of `builtin_tools` allowlists.
56pub const KNOWN_BUILTINS: &[&str] = &[
57    "bash",
58    "read",
59    "write",
60    "edit",
61    "grep",
62    "glob",
63    "list",
64    "patch",
65    "webfetch",
66    "websearch",
67    "image_generate",
68    "tts",
69    "skill",
70    "todoread",
71    "todowrite",
72    "question",
73    "twitter_post",
74    "todo_manage",
75];
76
77/// Sensory modality — the type of information a sensor captures.
78///
79/// Defined in config so it's always available for TOML deserialization
80/// even when the `sensor` feature is disabled. Re-exported from `sensor` module.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
82#[serde(rename_all = "snake_case")]
83pub enum SensorModality {
84    /// Email body, RSS content, chat messages.
85    Text,
86    /// Photos, screenshots, documents-as-images.
87    Image,
88    /// Voice notes, calls, podcasts.
89    Audio,
90    /// Weather JSON, GPS coordinates, API responses.
91    Structured,
92}
93
94impl std::fmt::Display for SensorModality {
95    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96        match self {
97            SensorModality::Text => write!(f, "text"),
98            SensorModality::Image => write!(f, "image"),
99            SensorModality::Audio => write!(f, "audio"),
100            SensorModality::Structured => write!(f, "structured"),
101        }
102    }
103}
104
105pub use crate::types::TrustLevel;
106
107/// Parse a workflow type string into the enum.
108pub fn parse_workflow_type(s: &str) -> Result<crate::agent::workflow::WorkflowType, Error> {
109    use crate::agent::workflow::WorkflowType;
110    match s.to_lowercase().as_str() {
111        "dag" => Ok(WorkflowType::Dag),
112        "sequential" => Ok(WorkflowType::Sequential),
113        "parallel" => Ok(WorkflowType::Parallel),
114        "loop" => Ok(WorkflowType::Loop),
115        "debate" => Ok(WorkflowType::Debate),
116        "voting" => Ok(WorkflowType::Voting),
117        "mixture" => Ok(WorkflowType::Mixture),
118        _ => Err(Error::Config(format!(
119            "invalid workflow_type '{}': must be dag, sequential, parallel, loop, debate, voting, or mixture",
120            s
121        ))),
122    }
123}
124
125/// Parse a tool profile string into the enum.
126pub fn parse_tool_profile(s: &str) -> Result<ToolProfile, Error> {
127    match s.to_lowercase().as_str() {
128        "conversational" => Ok(ToolProfile::Conversational),
129        "standard" => Ok(ToolProfile::Standard),
130        "full" => Ok(ToolProfile::Full),
131        _ => Err(Error::Config(format!(
132            "invalid tool_profile '{}': must be conversational, standard, or full",
133            s
134        ))),
135    }
136}
137
138/// Parse a reasoning effort string into the enum.
139pub fn parse_reasoning_effort(s: &str) -> Result<ReasoningEffort, Error> {
140    match s.to_lowercase().as_str() {
141        "high" => Ok(ReasoningEffort::High),
142        "medium" => Ok(ReasoningEffort::Medium),
143        "low" => Ok(ReasoningEffort::Low),
144        "none" => Ok(ReasoningEffort::None),
145        _ => Err(Error::Config(format!(
146            "invalid reasoning_effort '{}': must be high, medium, low, or none",
147            s
148        ))),
149    }
150}
151
152/// Shared default helper used by multiple submodules.
153fn default_true() -> bool {
154    true
155}
156
157/// Application-layer filesystem sandbox configuration.
158///
159/// When present, a `CorePathPolicy` is built from `allowed_dirs` and
160/// `deny_globs` and applied to all filesystem builtins (read, write, edit,
161/// patch). On Linux with the `sandbox` feature, bash also gets a wrapped
162/// `SandboxPolicy` for Landlock kernel enforcement.
163#[derive(Debug, Clone, Default, Deserialize)]
164pub struct SandboxConfig {
165    /// Directories the agent is allowed to access. When empty, the policy
166    /// denies all directory-level access (files may still pass glob checks).
167    #[serde(default)]
168    pub allowed_dirs: Vec<std::path::PathBuf>,
169    /// Glob patterns for paths to deny regardless of `allowed_dirs`.
170    /// Example: `["**/.env", "**/secrets/**"]`.
171    #[serde(default)]
172    pub deny_globs: Vec<String>,
173}
174
175/// Top-level configuration loaded from `heartbit.toml`.
176#[derive(Debug, Deserialize)]
177pub struct HeartbitConfig {
178    /// LLM provider selection and API key configuration.
179    #[serde(default)]
180    pub provider: ProviderConfig,
181    /// Orchestrator-level defaults applied to all sub-agents.
182    #[serde(default)]
183    pub orchestrator: OrchestratorConfig,
184    /// Per-agent configurations. Each entry defines one sub-agent.
185    #[serde(default)]
186    pub agents: Vec<AgentConfig>,
187    /// Custom variables for template prompt substitution.
188    /// Available as `{var_name}` in system_prompt, template prompts, and skill content.
189    #[serde(default)]
190    pub variables: std::collections::HashMap<String, String>,
191    /// Restate durable workflow configuration.
192    pub restate: Option<RestateConfig>,
193    /// OpenTelemetry tracing configuration.
194    pub telemetry: Option<TelemetryConfig>,
195    /// Persistent memory backend configuration.
196    pub memory: Option<MemoryConfig>,
197    /// Knowledge base (RAG) configuration.
198    pub knowledge: Option<KnowledgeConfig>,
199    /// Declarative permission rules applied to all agents.
200    /// Rules are evaluated in order — first match wins.
201    #[serde(default)]
202    pub permissions: Vec<PermissionRule>,
203    /// Optional LSP integration for diagnostics after file-modifying tools.
204    pub lsp: Option<LspConfig>,
205    /// Daemon mode configuration for Kafka-backed long-running execution.
206    pub daemon: Option<DaemonConfig>,
207    /// Optional workspace configuration for agent home directories.
208    pub workspace: Option<WorkspaceConfig>,
209    /// Guardrails configuration for injection detection, PII, and tool policies.
210    #[serde(default)]
211    pub guardrails: Option<GuardrailsConfig>,
212    /// Application-layer filesystem sandbox. When set, restricts file access
213    /// to `allowed_dirs` and blocks `deny_globs`. On Linux + sandbox feature,
214    /// bash subprocess also gets Landlock enforcement.
215    #[serde(default)]
216    pub sandbox: Option<SandboxConfig>,
217    /// Persona instances declared in this config (Phase 0: parsed and
218    /// lexically validated; the registry lookup happens at daemon startup
219    /// once persona crates are loaded).
220    #[serde(default, rename = "persona")]
221    pub personas: Vec<PersonaConfig>,
222}
223
224impl HeartbitConfig {
225    /// Parse a TOML string into a `HeartbitConfig` and validate it.
226    ///
227    /// Returns `Err(Error::Config)` when the TOML is malformed or when a
228    /// field violates an invariant (e.g. zero `max_turns`, missing provider
229    /// when not running in cloud-delegated daemon mode, duplicate agent names).
230    ///
231    /// # Example
232    ///
233    /// ```rust
234    /// use heartbit_core::config::HeartbitConfig;
235    ///
236    /// let toml_text = r#"
237    /// [provider]
238    /// name = "anthropic"
239    /// model = "claude-sonnet-4-20250514"
240    ///
241    /// [[agents]]
242    /// name = "assistant"
243    /// description = "A helpful general-purpose assistant."
244    /// system_prompt = "You are a helpful assistant."
245    /// max_turns = 10
246    /// max_tokens = 4096
247    /// "#;
248    ///
249    /// let config = HeartbitConfig::from_toml(toml_text).expect("valid config");
250    /// assert_eq!(config.agents.len(), 1);
251    /// assert_eq!(config.agents[0].name, "assistant");
252    /// ```
253    pub fn from_toml(content: &str) -> Result<Self, Error> {
254        let config: Self = toml::from_str(content).map_err(|e| Error::Config(e.to_string()))?;
255        config.validate()?;
256        Ok(config)
257    }
258
259    /// Read and parse a TOML config file.
260    pub fn from_file(path: &std::path::Path) -> Result<Self, Error> {
261        let content = std::fs::read_to_string(path)
262            .map_err(|e| Error::Config(format!("failed to read {}: {e}", path.display())))?;
263        Self::from_toml(&content)
264    }
265
266    fn validate(&self) -> Result<(), Error> {
267        // Validate top-level provider (skip when running as cloud-delegated runtime
268        // where per-request provider keys are used instead of a global config).
269        let daemon_only = self.daemon.is_some() && self.agents.is_empty();
270        if !daemon_only {
271            if self.provider.name.is_empty() {
272                return Err(Error::Config("provider.name must not be empty".into()));
273            }
274            if self.provider.model.is_empty() {
275                return Err(Error::Config("provider.model must not be empty".into()));
276            }
277        }
278        if self.orchestrator.max_turns == 0 {
279            return Err(Error::Config(
280                "orchestrator.max_turns must be at least 1".into(),
281            ));
282        }
283        if self.orchestrator.max_tokens == 0 {
284            return Err(Error::Config(
285                "orchestrator.max_tokens must be at least 1".into(),
286            ));
287        }
288        // Validate orchestrator context strategy
289        match &self.orchestrator.context_strategy {
290            Some(ContextStrategyConfig::SlidingWindow { max_tokens }) if *max_tokens == 0 => {
291                return Err(Error::Config(
292                    "orchestrator.context_strategy.max_tokens must be at least 1".into(),
293                ));
294            }
295            Some(ContextStrategyConfig::Summarize { threshold }) if *threshold == 0 => {
296                return Err(Error::Config(
297                    "orchestrator.context_strategy.threshold must be at least 1".into(),
298                ));
299            }
300            _ => {}
301        }
302        if self.orchestrator.summarize_threshold == Some(0) {
303            return Err(Error::Config(
304                "orchestrator.summarize_threshold must be at least 1".into(),
305            ));
306        }
307        if matches!(
308            self.orchestrator.context_strategy,
309            Some(ContextStrategyConfig::Summarize { .. })
310                | Some(ContextStrategyConfig::SlidingWindow { .. })
311        ) && self.orchestrator.summarize_threshold.is_some()
312        {
313            return Err(Error::Config(
314                "orchestrator: cannot set both context_strategy \
315                 and summarize_threshold; use one or the other"
316                    .into(),
317            ));
318        }
319        if self.orchestrator.tool_timeout_seconds == Some(0) {
320            return Err(Error::Config(
321                "orchestrator.tool_timeout_seconds must be at least 1".into(),
322            ));
323        }
324        if self.orchestrator.max_tool_output_bytes == Some(0) {
325            return Err(Error::Config(
326                "orchestrator.max_tool_output_bytes must be at least 1".into(),
327            ));
328        }
329        if self.orchestrator.run_timeout_seconds == Some(0) {
330            return Err(Error::Config(
331                "orchestrator.run_timeout_seconds must be at least 1".into(),
332            ));
333        }
334        if let Some(ref effort) = self.orchestrator.reasoning_effort {
335            parse_reasoning_effort(effort)?;
336        }
337        if self.orchestrator.tool_output_compression_threshold == Some(0) {
338            return Err(Error::Config(
339                "orchestrator.tool_output_compression_threshold must be at least 1".into(),
340            ));
341        }
342        if self.orchestrator.max_tools_per_turn == Some(0) {
343            return Err(Error::Config(
344                "orchestrator.max_tools_per_turn must be at least 1".into(),
345            ));
346        }
347        if let Some(ref profile) = self.orchestrator.tool_profile {
348            parse_tool_profile(profile)?;
349        }
350        if self.orchestrator.max_identical_tool_calls == Some(0) {
351            return Err(Error::Config(
352                "orchestrator.max_identical_tool_calls must be at least 1".into(),
353            ));
354        }
355        if self.orchestrator.max_fuzzy_identical_tool_calls == Some(0) {
356            return Err(Error::Config(
357                "orchestrator.max_fuzzy_identical_tool_calls must be at least 1".into(),
358            ));
359        }
360        if self.orchestrator.max_tool_calls_per_turn == Some(0) {
361            return Err(Error::Config(
362                "orchestrator.max_tool_calls_per_turn must be at least 1".into(),
363            ));
364        }
365
366        // Validate spawn config
367        if let Some(ref spawn) = self.orchestrator.spawn {
368            if spawn.max_spawned_agents == 0 {
369                return Err(Error::Config(
370                    "orchestrator.spawn.max_spawned_agents must be at least 1".into(),
371                ));
372            }
373            if spawn.max_turns == 0 {
374                return Err(Error::Config(
375                    "orchestrator.spawn.max_turns must be at least 1".into(),
376                ));
377            }
378            if spawn.max_tokens == 0 {
379                return Err(Error::Config(
380                    "orchestrator.spawn.max_tokens must be at least 1".into(),
381                ));
382            }
383            if spawn.max_total_tokens == 0 {
384                return Err(Error::Config(
385                    "orchestrator.spawn.max_total_tokens must be at least 1".into(),
386                ));
387            }
388            if spawn.tool_allowlist.is_empty() {
389                tracing::warn!(
390                    "orchestrator.spawn.tool_allowlist is empty — spawned agents will be reasoning-only"
391                );
392            }
393        }
394
395        // Validate cascade config: enabled requires at least one tier
396        if let Some(ref cascade) = self.provider.cascade
397            && cascade.enabled
398            && cascade.tiers.is_empty()
399        {
400            return Err(Error::Config(
401                "provider.cascade.enabled is true but no tiers are configured; \
402                 add at least one [[provider.cascade.tiers]] entry"
403                    .into(),
404            ));
405        }
406        // Validate cascade tier models are non-empty
407        if let Some(ref cascade) = self.provider.cascade {
408            for (i, tier) in cascade.tiers.iter().enumerate() {
409                if tier.model.is_empty() {
410                    return Err(Error::Config(format!(
411                        "provider.cascade.tiers[{i}].model must not be empty"
412                    )));
413                }
414            }
415        }
416
417        // Validate retry config: base_delay_ms <= max_delay_ms
418        if let Some(ref retry) = self.provider.retry
419            && retry.base_delay_ms > retry.max_delay_ms
420        {
421            return Err(Error::Config(format!(
422                "provider.retry.base_delay_ms ({}) must not exceed max_delay_ms ({})",
423                retry.base_delay_ms, retry.max_delay_ms
424            )));
425        }
426
427        // Validate circuit breaker config (B5b)
428        if let Some(0) = self.provider.circuit.failure_threshold {
429            return Err(Error::Config(
430                "provider.circuit.failure_threshold must be > 0".into(),
431            ));
432        }
433        if let Some(0) = self.provider.circuit.initial_open_duration_seconds {
434            return Err(Error::Config(
435                "provider.circuit.initial_open_duration_seconds must be > 0".into(),
436            ));
437        }
438        if let Some(0) = self.provider.circuit.max_open_duration_seconds {
439            return Err(Error::Config(
440                "provider.circuit.max_open_duration_seconds must be > 0".into(),
441            ));
442        }
443        // backoff_multiplier must be finite and strictly positive.
444        // - 0.0 makes record_failure's HalfOpen branch produce a zero-duration backoff (degenerate).
445        // - Negative values panic in Duration::from_secs_f64.
446        // - NaN/Inf panic in Duration::from_secs_f64.
447        if let Some(m) = self.provider.circuit.backoff_multiplier
448            && (!m.is_finite() || m <= 0.0)
449        {
450            return Err(Error::Config(
451                "provider.circuit.backoff_multiplier must be > 0 and finite".into(),
452            ));
453        }
454        if let Some(0) = self.orchestrator.max_tokens_in_flight_per_tenant {
455            return Err(Error::Config(
456                "orchestrator.max_tokens_in_flight_per_tenant must be > 0".into(),
457            ));
458        }
459
460        // Ensure agent names are unique
461        let mut seen = std::collections::HashSet::new();
462        for agent in &self.agents {
463            if agent.name.is_empty() {
464                return Err(Error::Config("agent name must not be empty".into()));
465            }
466            if !seen.insert(&agent.name) {
467                return Err(Error::Config(format!(
468                    "duplicate agent name: '{}'",
469                    agent.name
470                )));
471            }
472            // Validate context strategy max_tokens > 0
473            match &agent.context_strategy {
474                Some(ContextStrategyConfig::SlidingWindow { max_tokens }) if *max_tokens == 0 => {
475                    return Err(Error::Config(format!(
476                        "agent '{}': context_strategy.max_tokens must be at least 1",
477                        agent.name
478                    )));
479                }
480                Some(ContextStrategyConfig::Summarize { threshold }) if *threshold == 0 => {
481                    return Err(Error::Config(format!(
482                        "agent '{}': context_strategy.threshold must be at least 1",
483                        agent.name
484                    )));
485                }
486                _ => {}
487            }
488            if agent.max_turns == Some(0) {
489                return Err(Error::Config(format!(
490                    "agent '{}': max_turns must be at least 1",
491                    agent.name
492                )));
493            }
494            if agent.max_tokens == Some(0) {
495                return Err(Error::Config(format!(
496                    "agent '{}': max_tokens must be at least 1",
497                    agent.name
498                )));
499            }
500            if agent.tool_timeout_seconds == Some(0) {
501                return Err(Error::Config(format!(
502                    "agent '{}': tool_timeout_seconds must be at least 1",
503                    agent.name
504                )));
505            }
506            if agent.max_tool_output_bytes == Some(0) {
507                return Err(Error::Config(format!(
508                    "agent '{}': max_tool_output_bytes must be at least 1",
509                    agent.name
510                )));
511            }
512            if agent.run_timeout_seconds == Some(0) {
513                return Err(Error::Config(format!(
514                    "agent '{}': run_timeout_seconds must be at least 1",
515                    agent.name
516                )));
517            }
518            // Validate per-agent provider config
519            if let Some(ref p) = agent.provider {
520                if p.name.is_empty() {
521                    return Err(Error::Config(format!(
522                        "agent '{}': provider.name must not be empty",
523                        agent.name
524                    )));
525                }
526                if p.model.is_empty() {
527                    return Err(Error::Config(format!(
528                        "agent '{}': provider.model must not be empty",
529                        agent.name
530                    )));
531                }
532            }
533            // Validate MCP server entries
534            for (i, entry) in agent.mcp_servers.iter().enumerate() {
535                if entry.url().is_empty() {
536                    return Err(Error::Config(format!(
537                        "agent '{}': mcp_servers[{i}].url must not be empty",
538                        agent.name
539                    )));
540                }
541            }
542            // Validate A2A agent entries
543            for (i, entry) in agent.a2a_agents.iter().enumerate() {
544                if entry.url().is_empty() {
545                    return Err(Error::Config(format!(
546                        "agent '{}': a2a_agents[{i}].url must not be empty",
547                        agent.name
548                    )));
549                }
550            }
551            if agent.summarize_threshold == Some(0) {
552                return Err(Error::Config(format!(
553                    "agent '{}': summarize_threshold must be at least 1",
554                    agent.name
555                )));
556            }
557            if matches!(
558                agent.context_strategy,
559                Some(ContextStrategyConfig::Summarize { .. })
560                    | Some(ContextStrategyConfig::SlidingWindow { .. })
561            ) && agent.summarize_threshold.is_some()
562            {
563                return Err(Error::Config(format!(
564                    "agent '{}': cannot set both context_strategy and summarize_threshold; \
565                     use one or the other",
566                    agent.name
567                )));
568            }
569            if let Some(ref effort) = agent.reasoning_effort {
570                parse_reasoning_effort(effort).map_err(|_| {
571                    Error::Config(format!(
572                        "agent '{}': invalid reasoning_effort '{}': must be high, medium, low, or none",
573                        agent.name, effort
574                    ))
575                })?;
576            }
577            if agent.tool_output_compression_threshold == Some(0) {
578                return Err(Error::Config(format!(
579                    "agent '{}': tool_output_compression_threshold must be at least 1",
580                    agent.name
581                )));
582            }
583            if agent.max_tools_per_turn == Some(0) {
584                return Err(Error::Config(format!(
585                    "agent '{}': max_tools_per_turn must be at least 1",
586                    agent.name
587                )));
588            }
589            if agent.max_identical_tool_calls == Some(0) {
590                return Err(Error::Config(format!(
591                    "agent '{}': max_identical_tool_calls must be at least 1",
592                    agent.name
593                )));
594            }
595            if agent.max_fuzzy_identical_tool_calls == Some(0) {
596                return Err(Error::Config(format!(
597                    "agent '{}': max_fuzzy_identical_tool_calls must be at least 1",
598                    agent.name
599                )));
600            }
601            if agent.max_tool_calls_per_turn == Some(0) {
602                return Err(Error::Config(format!(
603                    "agent '{}': max_tool_calls_per_turn must be at least 1",
604                    agent.name
605                )));
606            }
607            if agent.max_total_tokens == Some(0) {
608                return Err(Error::Config(format!(
609                    "agent '{}': max_total_tokens must be at least 1",
610                    agent.name
611                )));
612            }
613            if let Some(ref profile) = agent.tool_profile {
614                parse_tool_profile(profile).map_err(|_| {
615                    Error::Config(format!(
616                        "agent '{}': invalid tool_profile '{}': must be conversational, standard, or full",
617                        agent.name, profile
618                    ))
619                })?;
620            }
621            if let Some(ref bt) = agent.builtin_tools {
622                for name in bt {
623                    if !KNOWN_BUILTINS.contains(&name.as_str()) {
624                        return Err(Error::Config(format!(
625                            "agent '{}': unknown builtin tool '{}'; known builtins: {}",
626                            agent.name,
627                            name,
628                            KNOWN_BUILTINS.join(", ")
629                        )));
630                    }
631                }
632            }
633        }
634
635        // Validate knowledge config
636        if let Some(ref knowledge) = self.knowledge {
637            if knowledge.chunk_size == 0 {
638                return Err(Error::Config(
639                    "knowledge.chunk_size must be at least 1".into(),
640                ));
641            }
642            if knowledge.chunk_overlap >= knowledge.chunk_size {
643                return Err(Error::Config(format!(
644                    "knowledge.chunk_overlap ({}) must be less than chunk_size ({})",
645                    knowledge.chunk_overlap, knowledge.chunk_size
646                )));
647            }
648        }
649
650        // Validate daemon config
651        if let Some(ref daemon) = self.daemon {
652            if daemon.max_concurrent_tasks == 0 {
653                return Err(Error::Config(
654                    "daemon.max_concurrent_tasks must be at least 1".into(),
655                ));
656            }
657            if daemon.audit.prune_interval_minutes == Some(0) {
658                return Err(Error::Config(
659                    "daemon.audit.prune_interval_minutes must be at least 1".into(),
660                ));
661            }
662            if daemon.audit.retain_days == Some(0) {
663                return Err(Error::Config(
664                    "daemon.audit.retain_days must be at least 1 if set".into(),
665                ));
666            }
667            if daemon.idempotency.ttl_hours == Some(0) {
668                return Err(Error::Config(
669                    "daemon.idempotency.ttl_hours must be at least 1 if set".into(),
670                ));
671            }
672            if daemon.idempotency.sweep_interval_minutes == Some(0) {
673                return Err(Error::Config(
674                    "daemon.idempotency.sweep_interval_minutes must be at least 1 if set".into(),
675                ));
676            }
677            if let Some(ref kafka) = daemon.kafka {
678                if kafka.brokers.is_empty() {
679                    return Err(Error::Config(
680                        "daemon.kafka.brokers must not be empty".into(),
681                    ));
682                }
683                if kafka.consumer_group.is_empty() {
684                    return Err(Error::Config(
685                        "daemon.kafka.consumer_group must not be empty".into(),
686                    ));
687                }
688                if kafka.commands_topic.is_empty() {
689                    return Err(Error::Config(
690                        "daemon.kafka.commands_topic must not be empty".into(),
691                    ));
692                }
693                if kafka.events_topic.is_empty() {
694                    return Err(Error::Config(
695                        "daemon.kafka.events_topic must not be empty".into(),
696                    ));
697                }
698            }
699            // Validate auth config
700            if let Some(ref auth) = daemon.auth {
701                if auth.bearer_tokens.is_empty() && auth.jwks_url.is_none() {
702                    return Err(Error::Config(
703                        "daemon.auth requires at least bearer_tokens or jwks_url".into(),
704                    ));
705                }
706                for (i, token) in auth.bearer_tokens.iter().enumerate() {
707                    if token.is_empty() {
708                        return Err(Error::Config(format!(
709                            "daemon.auth.bearer_tokens[{i}] must not be empty"
710                        )));
711                    }
712                }
713                if let Some(ref url) = auth.jwks_url
714                    && url.is_empty()
715                {
716                    return Err(Error::Config(
717                        "daemon.auth.jwks_url must not be empty".into(),
718                    ));
719                }
720                if let Some(ref te) = auth.token_exchange {
721                    if te.exchange_url.is_empty() {
722                        return Err(Error::Config(
723                            "daemon.auth.token_exchange.exchange_url must not be empty".into(),
724                        ));
725                    }
726                    if te.client_id.is_empty() {
727                        return Err(Error::Config(
728                            "daemon.auth.token_exchange.client_id must not be empty".into(),
729                        ));
730                    }
731                    if te.client_secret.is_empty() {
732                        return Err(Error::Config(
733                            "daemon.auth.token_exchange.client_secret must not be empty".into(),
734                        ));
735                    }
736                    if te.tenant_id.is_none() && te.agent_token.is_empty() {
737                        return Err(Error::Config(
738                            "daemon.auth.token_exchange: set tenant_id for auto-fetch, or provide a static agent_token".into(),
739                        ));
740                    }
741                }
742            }
743        }
744
745        // Persona blocks: lexical validation + duplicate-name check.
746        let mut seen_persona_names = std::collections::HashSet::new();
747        for persona in &self.personas {
748            persona.validate()?;
749            if !seen_persona_names.insert(persona.name.clone()) {
750                return Err(Error::Config(format!(
751                    "duplicate persona name: '{}'",
752                    persona.name
753                )));
754            }
755        }
756
757        Ok(())
758    }
759}
760
761#[cfg(test)]
762mod tests {
763    #[allow(unused_imports)]
764    use super::guardrails::default_pii_detectors;
765    use super::*;
766
767    #[test]
768    fn parse_full_config() {
769        let toml = r#"
770[provider]
771name = "anthropic"
772model = "claude-sonnet-4-20250514"
773
774[orchestrator]
775max_turns = 15
776max_tokens = 8192
777
778[[agents]]
779name = "researcher"
780description = "Research specialist"
781system_prompt = "You are a research specialist."
782
783[[agents]]
784name = "coder"
785description = "Coding expert"
786system_prompt = "You are a coding expert."
787mcp_servers = ["http://localhost:8000/mcp"]
788
789[restate]
790endpoint = "http://localhost:9070"
791"#;
792
793        let config = HeartbitConfig::from_toml(toml).unwrap();
794
795        assert_eq!(config.provider.name, "anthropic");
796        assert_eq!(config.provider.model, "claude-sonnet-4-20250514");
797        assert_eq!(config.orchestrator.max_turns, 15);
798        assert_eq!(config.orchestrator.max_tokens, 8192);
799        assert_eq!(config.agents.len(), 2);
800        assert_eq!(config.agents[0].name, "researcher");
801        assert_eq!(config.agents[0].mcp_servers.len(), 0);
802        assert_eq!(config.agents[1].name, "coder");
803        assert_eq!(
804            config.agents[1].mcp_servers,
805            vec![McpServerEntry::Simple("http://localhost:8000/mcp".into())]
806        );
807
808        let restate = config.restate.unwrap();
809        assert_eq!(restate.endpoint, "http://localhost:9070");
810    }
811
812    #[test]
813    fn parse_minimal_config() {
814        let toml = r#"
815[provider]
816name = "anthropic"
817model = "claude-sonnet-4-20250514"
818"#;
819
820        let config = HeartbitConfig::from_toml(toml).unwrap();
821
822        assert_eq!(config.provider.name, "anthropic");
823        assert_eq!(config.orchestrator.max_turns, 10);
824        assert_eq!(config.orchestrator.max_tokens, 4096);
825        assert!(config.agents.is_empty());
826        assert!(config.restate.is_none());
827    }
828
829    #[test]
830    fn missing_required_provider_field() {
831        let toml = r#"
832[provider]
833name = "anthropic"
834"#;
835        let err = HeartbitConfig::from_toml(toml).unwrap_err();
836        let msg = err.to_string();
837        assert!(
838            msg.contains("model"),
839            "error should mention missing field: {msg}"
840        );
841    }
842
843    #[test]
844    fn missing_provider_section() {
845        let toml = r#"
846[[agents]]
847name = "researcher"
848description = "Research"
849system_prompt = "You research."
850"#;
851        let err = HeartbitConfig::from_toml(toml).unwrap_err();
852        let msg = err.to_string();
853        assert!(
854            msg.contains("provider"),
855            "error should mention missing section: {msg}"
856        );
857    }
858
859    #[test]
860    fn invalid_toml_syntax() {
861        let toml = "this is not valid toml {{{";
862        let err = HeartbitConfig::from_toml(toml).unwrap_err();
863        assert!(matches!(err, Error::Config(_)));
864    }
865
866    #[test]
867    fn from_file_nonexistent_path() {
868        let err = HeartbitConfig::from_file(std::path::Path::new("/nonexistent/heartbit.toml"))
869            .unwrap_err();
870        let msg = err.to_string();
871        assert!(msg.contains("failed to read"), "error: {msg}");
872    }
873
874    #[test]
875    fn orchestrator_defaults_applied() {
876        let toml = r#"
877[provider]
878name = "openrouter"
879model = "anthropic/claude-sonnet-4"
880
881[orchestrator]
882"#;
883        let config = HeartbitConfig::from_toml(toml).unwrap();
884        assert_eq!(config.orchestrator.max_turns, 10);
885        assert_eq!(config.orchestrator.max_tokens, 4096);
886        assert!(config.orchestrator.context_strategy.is_none());
887        assert!(config.orchestrator.summarize_threshold.is_none());
888        assert!(config.orchestrator.tool_timeout_seconds.is_none());
889        assert!(config.orchestrator.max_tool_output_bytes.is_none());
890    }
891
892    #[test]
893    fn orchestrator_context_strategy_parses() {
894        let toml = r#"
895[provider]
896name = "anthropic"
897model = "claude-sonnet-4-20250514"
898
899[orchestrator.context_strategy]
900type = "sliding_window"
901max_tokens = 16000
902"#;
903        let config = HeartbitConfig::from_toml(toml).unwrap();
904        assert_eq!(
905            config.orchestrator.context_strategy,
906            Some(ContextStrategyConfig::SlidingWindow { max_tokens: 16000 })
907        );
908        assert!(config.orchestrator.summarize_threshold.is_none());
909    }
910
911    #[test]
912    fn agent_config_mcp_servers_default_empty() {
913        let toml = r#"
914[provider]
915name = "anthropic"
916model = "claude-sonnet-4-20250514"
917
918[[agents]]
919name = "basic"
920description = "Basic agent"
921system_prompt = "You are basic."
922"#;
923        let config = HeartbitConfig::from_toml(toml).unwrap();
924        assert!(config.agents[0].mcp_servers.is_empty());
925    }
926
927    #[test]
928    fn agent_max_total_tokens_parses() {
929        let toml = r#"
930[provider]
931name = "anthropic"
932model = "claude-sonnet-4-20250514"
933
934[[agents]]
935name = "quoter"
936description = "Quoter agent"
937system_prompt = "You quote."
938max_total_tokens = 100000
939"#;
940        let config = HeartbitConfig::from_toml(toml).unwrap();
941        assert_eq!(config.agents[0].max_total_tokens, Some(100000));
942    }
943
944    #[test]
945    fn agent_max_total_tokens_defaults_none() {
946        let toml = r#"
947[provider]
948name = "anthropic"
949model = "claude-sonnet-4-20250514"
950
951[[agents]]
952name = "basic"
953description = "Basic agent"
954system_prompt = "You are basic."
955"#;
956        let config = HeartbitConfig::from_toml(toml).unwrap();
957        assert!(config.agents[0].max_total_tokens.is_none());
958    }
959
960    #[test]
961    fn config_rejects_zero_agent_max_total_tokens() {
962        let toml = r#"
963[provider]
964name = "anthropic"
965model = "claude-sonnet-4-20250514"
966
967[[agents]]
968name = "quoter"
969description = "Quoter"
970system_prompt = "Quote."
971max_total_tokens = 0
972"#;
973        let err = HeartbitConfig::from_toml(toml).unwrap_err();
974        assert!(
975            err.to_string()
976                .contains("max_total_tokens must be at least 1"),
977            "error: {err}"
978        );
979    }
980
981    #[test]
982    fn parse_context_strategy_unlimited() {
983        let toml = r#"
984[provider]
985name = "anthropic"
986model = "claude-sonnet-4-20250514"
987
988[[agents]]
989name = "test"
990description = "Test"
991system_prompt = "You test."
992context_strategy = { type = "unlimited" }
993"#;
994        let config = HeartbitConfig::from_toml(toml).unwrap();
995        assert_eq!(
996            config.agents[0].context_strategy,
997            Some(ContextStrategyConfig::Unlimited)
998        );
999    }
1000
1001    #[test]
1002    fn parse_context_strategy_sliding_window() {
1003        let toml = r#"
1004[provider]
1005name = "anthropic"
1006model = "claude-sonnet-4-20250514"
1007
1008[[agents]]
1009name = "test"
1010description = "Test"
1011system_prompt = "You test."
1012context_strategy = { type = "sliding_window", max_tokens = 100000 }
1013"#;
1014        let config = HeartbitConfig::from_toml(toml).unwrap();
1015        assert_eq!(
1016            config.agents[0].context_strategy,
1017            Some(ContextStrategyConfig::SlidingWindow { max_tokens: 100000 })
1018        );
1019    }
1020
1021    #[test]
1022    fn parse_context_strategy_summarize() {
1023        let toml = r#"
1024[provider]
1025name = "anthropic"
1026model = "claude-sonnet-4-20250514"
1027
1028[[agents]]
1029name = "test"
1030description = "Test"
1031system_prompt = "You test."
1032context_strategy = { type = "summarize", threshold = 80000 }
1033"#;
1034        let config = HeartbitConfig::from_toml(toml).unwrap();
1035        assert_eq!(
1036            config.agents[0].context_strategy,
1037            Some(ContextStrategyConfig::Summarize { threshold: 80000 })
1038        );
1039    }
1040
1041    #[test]
1042    fn context_strategy_defaults_to_none() {
1043        let toml = r#"
1044[provider]
1045name = "anthropic"
1046model = "claude-sonnet-4-20250514"
1047
1048[[agents]]
1049name = "test"
1050description = "Test"
1051system_prompt = "You test."
1052"#;
1053        let config = HeartbitConfig::from_toml(toml).unwrap();
1054        assert!(config.agents[0].context_strategy.is_none());
1055    }
1056
1057    #[test]
1058    fn parse_memory_config_in_memory() {
1059        let toml = r#"
1060[provider]
1061name = "anthropic"
1062model = "claude-sonnet-4-20250514"
1063
1064[memory]
1065type = "in_memory"
1066"#;
1067        let config = HeartbitConfig::from_toml(toml).unwrap();
1068        assert!(matches!(config.memory, Some(MemoryConfig::InMemory)));
1069    }
1070
1071    #[test]
1072    fn parse_memory_config_postgres() {
1073        let toml = r#"
1074[provider]
1075name = "anthropic"
1076model = "claude-sonnet-4-20250514"
1077
1078[memory]
1079type = "postgres"
1080database_url = "postgresql://localhost/heartbit"
1081"#;
1082        let config = HeartbitConfig::from_toml(toml).unwrap();
1083        match &config.memory {
1084            Some(MemoryConfig::Postgres {
1085                database_url,
1086                embedding,
1087            }) => {
1088                assert_eq!(database_url, "postgresql://localhost/heartbit");
1089                assert!(embedding.is_none(), "embedding should default to None");
1090            }
1091            other => panic!("expected Postgres config, got: {other:?}"),
1092        }
1093    }
1094
1095    #[test]
1096    fn parse_memory_config_postgres_with_embedding() {
1097        let toml = r#"
1098[provider]
1099name = "anthropic"
1100model = "claude-sonnet-4-20250514"
1101
1102[memory]
1103type = "postgres"
1104database_url = "postgresql://localhost/heartbit"
1105
1106[memory.embedding]
1107provider = "openai"
1108model = "text-embedding-3-large"
1109api_key_env = "MY_OPENAI_KEY"
1110base_url = "https://custom-api.example.com"
1111dimension = 3072
1112"#;
1113        let config = HeartbitConfig::from_toml(toml).unwrap();
1114        match &config.memory {
1115            Some(MemoryConfig::Postgres {
1116                database_url,
1117                embedding,
1118            }) => {
1119                assert_eq!(database_url, "postgresql://localhost/heartbit");
1120                let emb = embedding.as_ref().expect("embedding config should be set");
1121                assert_eq!(emb.provider, "openai");
1122                assert_eq!(emb.model, "text-embedding-3-large");
1123                assert_eq!(emb.api_key_env, "MY_OPENAI_KEY");
1124                assert_eq!(
1125                    emb.base_url.as_deref(),
1126                    Some("https://custom-api.example.com")
1127                );
1128                assert_eq!(emb.dimension, Some(3072));
1129            }
1130            other => panic!("expected Postgres config, got: {other:?}"),
1131        }
1132    }
1133
1134    #[test]
1135    fn parse_memory_config_embedding_defaults() {
1136        let toml = r#"
1137[provider]
1138name = "anthropic"
1139model = "claude-sonnet-4-20250514"
1140
1141[memory]
1142type = "postgres"
1143database_url = "postgresql://localhost/heartbit"
1144
1145[memory.embedding]
1146"#;
1147        let config = HeartbitConfig::from_toml(toml).unwrap();
1148        match &config.memory {
1149            Some(MemoryConfig::Postgres { embedding, .. }) => {
1150                let emb = embedding.as_ref().expect("embedding config should be set");
1151                assert_eq!(emb.provider, "none");
1152                assert_eq!(emb.model, "text-embedding-3-small");
1153                assert_eq!(emb.api_key_env, "OPENAI_API_KEY");
1154                assert!(emb.base_url.is_none());
1155                assert!(emb.dimension.is_none());
1156            }
1157            other => panic!("expected Postgres config, got: {other:?}"),
1158        }
1159    }
1160
1161    #[test]
1162    fn memory_config_defaults_to_none() {
1163        let toml = r#"
1164[provider]
1165name = "anthropic"
1166model = "claude-sonnet-4-20250514"
1167"#;
1168        let config = HeartbitConfig::from_toml(toml).unwrap();
1169        assert!(config.memory.is_none());
1170    }
1171
1172    #[test]
1173    fn zero_max_turns_rejected() {
1174        let toml = r#"
1175[provider]
1176name = "anthropic"
1177model = "claude-sonnet-4-20250514"
1178
1179[orchestrator]
1180max_turns = 0
1181"#;
1182        let err = HeartbitConfig::from_toml(toml).unwrap_err();
1183        let msg = err.to_string();
1184        assert!(msg.contains("max_turns must be at least 1"), "error: {msg}");
1185    }
1186
1187    #[test]
1188    fn zero_max_tokens_rejected() {
1189        let toml = r#"
1190[provider]
1191name = "anthropic"
1192model = "claude-sonnet-4-20250514"
1193
1194[orchestrator]
1195max_tokens = 0
1196"#;
1197        let err = HeartbitConfig::from_toml(toml).unwrap_err();
1198        let msg = err.to_string();
1199        assert!(
1200            msg.contains("max_tokens must be at least 1"),
1201            "error: {msg}"
1202        );
1203    }
1204
1205    #[test]
1206    fn parse_tool_timeout_seconds() {
1207        let toml = r#"
1208[provider]
1209name = "anthropic"
1210model = "claude-sonnet-4-20250514"
1211
1212[[agents]]
1213name = "test"
1214description = "Test"
1215system_prompt = "You test."
1216tool_timeout_seconds = 60
1217"#;
1218        let config = HeartbitConfig::from_toml(toml).unwrap();
1219        assert_eq!(config.agents[0].tool_timeout_seconds, Some(60));
1220    }
1221
1222    #[test]
1223    fn tool_timeout_defaults_to_none() {
1224        let toml = r#"
1225[provider]
1226name = "anthropic"
1227model = "claude-sonnet-4-20250514"
1228
1229[[agents]]
1230name = "test"
1231description = "Test"
1232system_prompt = "You test."
1233"#;
1234        let config = HeartbitConfig::from_toml(toml).unwrap();
1235        assert!(config.agents[0].tool_timeout_seconds.is_none());
1236    }
1237
1238    #[test]
1239    fn parse_max_tool_output_bytes() {
1240        let toml = r#"
1241[provider]
1242name = "anthropic"
1243model = "claude-sonnet-4-20250514"
1244
1245[[agents]]
1246name = "test"
1247description = "Test"
1248system_prompt = "You test."
1249max_tool_output_bytes = 16384
1250"#;
1251        let config = HeartbitConfig::from_toml(toml).unwrap();
1252        assert_eq!(config.agents[0].max_tool_output_bytes, Some(16384));
1253    }
1254
1255    #[test]
1256    fn max_tool_output_bytes_defaults_to_none() {
1257        let toml = r#"
1258[provider]
1259name = "anthropic"
1260model = "claude-sonnet-4-20250514"
1261
1262[[agents]]
1263name = "test"
1264description = "Test"
1265system_prompt = "You test."
1266"#;
1267        let config = HeartbitConfig::from_toml(toml).unwrap();
1268        assert!(config.agents[0].max_tool_output_bytes.is_none());
1269    }
1270
1271    #[test]
1272    fn parse_per_agent_max_turns() {
1273        let toml = r#"
1274[provider]
1275name = "anthropic"
1276model = "claude-sonnet-4-20250514"
1277
1278[[agents]]
1279name = "browser"
1280description = "Browser"
1281system_prompt = "Browse."
1282max_turns = 20
1283"#;
1284        let config = HeartbitConfig::from_toml(toml).unwrap();
1285        assert_eq!(config.agents[0].max_turns, Some(20));
1286    }
1287
1288    #[test]
1289    fn parse_per_agent_max_tokens() {
1290        let toml = r#"
1291[provider]
1292name = "anthropic"
1293model = "claude-sonnet-4-20250514"
1294
1295[[agents]]
1296name = "writer"
1297description = "Writer"
1298system_prompt = "Write."
1299max_tokens = 16384
1300"#;
1301        let config = HeartbitConfig::from_toml(toml).unwrap();
1302        assert_eq!(config.agents[0].max_tokens, Some(16384));
1303    }
1304
1305    #[test]
1306    fn per_agent_limits_default_to_none() {
1307        let toml = r#"
1308[provider]
1309name = "anthropic"
1310model = "claude-sonnet-4-20250514"
1311
1312[[agents]]
1313name = "test"
1314description = "Test"
1315system_prompt = "You test."
1316"#;
1317        let config = HeartbitConfig::from_toml(toml).unwrap();
1318        assert!(config.agents[0].max_turns.is_none());
1319        assert!(config.agents[0].max_tokens.is_none());
1320    }
1321
1322    #[test]
1323    fn per_agent_zero_max_turns_rejected() {
1324        let toml = r#"
1325[provider]
1326name = "anthropic"
1327model = "claude-sonnet-4-20250514"
1328
1329[[agents]]
1330name = "test"
1331description = "Test"
1332system_prompt = "You test."
1333max_turns = 0
1334"#;
1335        let err = HeartbitConfig::from_toml(toml).unwrap_err();
1336        let msg = err.to_string();
1337        assert!(msg.contains("max_turns must be at least 1"), "error: {msg}");
1338    }
1339
1340    #[test]
1341    fn per_agent_zero_max_tokens_rejected() {
1342        let toml = r#"
1343[provider]
1344name = "anthropic"
1345model = "claude-sonnet-4-20250514"
1346
1347[[agents]]
1348name = "test"
1349description = "Test"
1350system_prompt = "You test."
1351max_tokens = 0
1352"#;
1353        let err = HeartbitConfig::from_toml(toml).unwrap_err();
1354        let msg = err.to_string();
1355        assert!(
1356            msg.contains("max_tokens must be at least 1"),
1357            "error: {msg}"
1358        );
1359    }
1360
1361    #[test]
1362    fn parse_response_schema() {
1363        let toml = r#"
1364[provider]
1365name = "anthropic"
1366model = "claude-sonnet-4-20250514"
1367
1368[[agents]]
1369name = "analyst"
1370description = "Analyst"
1371system_prompt = "Analyze."
1372
1373[agents.response_schema]
1374type = "object"
1375
1376[agents.response_schema.properties.score]
1377type = "number"
1378
1379[agents.response_schema.properties.summary]
1380type = "string"
1381"#;
1382        let config = HeartbitConfig::from_toml(toml).unwrap();
1383        let schema = config.agents[0].response_schema.as_ref().unwrap();
1384        assert_eq!(schema["type"], "object");
1385        assert_eq!(schema["properties"]["score"]["type"], "number");
1386        assert_eq!(schema["properties"]["summary"]["type"], "string");
1387    }
1388
1389    #[test]
1390    fn response_schema_defaults_to_none() {
1391        let toml = r#"
1392[provider]
1393name = "anthropic"
1394model = "claude-sonnet-4-20250514"
1395
1396[[agents]]
1397name = "test"
1398description = "Test"
1399system_prompt = "Test."
1400"#;
1401        let config = HeartbitConfig::from_toml(toml).unwrap();
1402        assert!(config.agents[0].response_schema.is_none());
1403    }
1404
1405    #[test]
1406    fn rejects_duplicate_persona_names() {
1407        let toml_text = r#"
1408            [provider]
1409            name = "anthropic"
1410            model = "claude-sonnet-4-20250514"
1411
1412            [[persona]]
1413            name = "x"
1414            recipe = "heartbit-ghost:x"
1415
1416            [[persona]]
1417            name = "x"
1418            recipe = "heartbit-ghost:x"
1419        "#;
1420        let err = HeartbitConfig::from_toml(toml_text).unwrap_err();
1421        let msg = format!("{:?}", err);
1422        assert!(msg.contains("duplicate persona name"), "got: {}", msg);
1423    }
1424
1425    #[test]
1426    fn parses_persona_block_round_trip() {
1427        let toml_text = r#"
1428            [provider]
1429            name = "anthropic"
1430            model = "claude-sonnet-4-20250514"
1431
1432            [[persona]]
1433            name = "x"
1434            recipe = "heartbit-ghost:x"
1435            authorship_mode = "autonomous_undisclosed"
1436            phase = "calibration"
1437        "#;
1438        let config = HeartbitConfig::from_toml(toml_text).expect("parses");
1439        assert_eq!(config.personas.len(), 1);
1440        assert_eq!(config.personas[0].name, "x");
1441        assert_eq!(config.personas[0].recipe, "heartbit-ghost:x");
1442    }
1443
1444    #[test]
1445    fn duplicate_agent_names_rejected() {
1446        let toml = r#"
1447[provider]
1448name = "anthropic"
1449model = "claude-sonnet-4-20250514"
1450
1451[[agents]]
1452name = "researcher"
1453description = "First"
1454system_prompt = "First."
1455
1456[[agents]]
1457name = "researcher"
1458description = "Second"
1459system_prompt = "Second."
1460"#;
1461        let err = HeartbitConfig::from_toml(toml).unwrap_err();
1462        let msg = err.to_string();
1463        assert!(msg.contains("duplicate agent name"), "error: {msg}");
1464    }
1465
1466    #[test]
1467    fn per_agent_zero_summarize_threshold_rejected() {
1468        let toml = r#"
1469[provider]
1470name = "anthropic"
1471model = "claude-sonnet-4-20250514"
1472
1473[[agents]]
1474name = "test"
1475description = "Test"
1476system_prompt = "Test."
1477summarize_threshold = 0
1478"#;
1479        let err = HeartbitConfig::from_toml(toml).unwrap_err();
1480        let msg = err.to_string();
1481        assert!(
1482            msg.contains("summarize_threshold must be at least 1"),
1483            "error: {msg}"
1484        );
1485    }
1486
1487    #[test]
1488    fn per_agent_summarize_threshold_with_context_strategy_rejected() {
1489        let toml = r#"
1490[provider]
1491name = "anthropic"
1492model = "claude-sonnet-4-20250514"
1493
1494[[agents]]
1495name = "test"
1496description = "Test"
1497system_prompt = "Test."
1498summarize_threshold = 8000
1499
1500[agents.context_strategy]
1501type = "sliding_window"
1502max_tokens = 50000
1503"#;
1504        let err = HeartbitConfig::from_toml(toml).unwrap_err();
1505        let msg = err.to_string();
1506        assert!(
1507            msg.contains("cannot set both context_strategy and summarize_threshold"),
1508            "error: {msg}"
1509        );
1510    }
1511
1512    #[test]
1513    fn per_agent_summarize_threshold_parses() {
1514        let toml = r#"
1515[provider]
1516name = "anthropic"
1517model = "claude-sonnet-4-20250514"
1518
1519[[agents]]
1520name = "test"
1521description = "Test"
1522system_prompt = "Test."
1523summarize_threshold = 8000
1524"#;
1525        let config = HeartbitConfig::from_toml(toml).unwrap();
1526        assert_eq!(config.agents[0].summarize_threshold, Some(8000));
1527    }
1528
1529    #[test]
1530    fn parse_retry_config() {
1531        let toml = r#"
1532[provider]
1533name = "anthropic"
1534model = "claude-sonnet-4-20250514"
1535
1536[provider.retry]
1537max_retries = 5
1538base_delay_ms = 1000
1539max_delay_ms = 60000
1540"#;
1541        let config = HeartbitConfig::from_toml(toml).unwrap();
1542        let retry = config.provider.retry.unwrap();
1543        assert_eq!(retry.max_retries, 5);
1544        assert_eq!(retry.base_delay_ms, 1000);
1545        assert_eq!(retry.max_delay_ms, 60000);
1546    }
1547
1548    #[test]
1549    fn retry_config_defaults_to_none() {
1550        let toml = r#"
1551[provider]
1552name = "anthropic"
1553model = "claude-sonnet-4-20250514"
1554"#;
1555        let config = HeartbitConfig::from_toml(toml).unwrap();
1556        assert!(config.provider.retry.is_none());
1557    }
1558
1559    #[test]
1560    fn retry_config_uses_defaults_for_missing_fields() {
1561        let toml = r#"
1562[provider]
1563name = "anthropic"
1564model = "claude-sonnet-4-20250514"
1565
1566[provider.retry]
1567"#;
1568        let config = HeartbitConfig::from_toml(toml).unwrap();
1569        let retry = config.provider.retry.unwrap();
1570        assert_eq!(retry.max_retries, 3);
1571        assert_eq!(retry.base_delay_ms, 500);
1572        assert_eq!(retry.max_delay_ms, 30000);
1573    }
1574
1575    #[test]
1576    fn zero_context_strategy_max_tokens_rejected() {
1577        let toml = r#"
1578[provider]
1579name = "anthropic"
1580model = "claude-sonnet-4-20250514"
1581
1582[[agents]]
1583name = "test"
1584description = "Test"
1585system_prompt = "You test."
1586context_strategy = { type = "sliding_window", max_tokens = 0 }
1587"#;
1588        let err = HeartbitConfig::from_toml(toml).unwrap_err();
1589        let msg = err.to_string();
1590        assert!(
1591            msg.contains("context_strategy.max_tokens must be at least 1"),
1592            "error: {msg}"
1593        );
1594    }
1595
1596    #[test]
1597    fn zero_summarize_threshold_rejected() {
1598        let toml = r#"
1599[provider]
1600name = "anthropic"
1601model = "claude-sonnet-4-20250514"
1602
1603[[agents]]
1604name = "test"
1605description = "Test"
1606system_prompt = "You test."
1607context_strategy = { type = "summarize", threshold = 0 }
1608"#;
1609        let err = HeartbitConfig::from_toml(toml).unwrap_err();
1610        let msg = err.to_string();
1611        assert!(
1612            msg.contains("context_strategy.threshold must be at least 1"),
1613            "error: {msg}"
1614        );
1615    }
1616
1617    #[test]
1618    fn retry_base_delay_exceeds_max_delay_rejected() {
1619        let toml = r#"
1620[provider]
1621name = "anthropic"
1622model = "claude-sonnet-4-20250514"
1623
1624[provider.retry]
1625base_delay_ms = 60000
1626max_delay_ms = 1000
1627"#;
1628        let err = HeartbitConfig::from_toml(toml).unwrap_err();
1629        let msg = err.to_string();
1630        assert!(
1631            msg.contains("base_delay_ms") && msg.contains("max_delay_ms"),
1632            "error: {msg}"
1633        );
1634    }
1635
1636    #[test]
1637    fn retry_base_delay_equals_max_delay_accepted() {
1638        let toml = r#"
1639[provider]
1640name = "anthropic"
1641model = "claude-sonnet-4-20250514"
1642
1643[provider.retry]
1644base_delay_ms = 5000
1645max_delay_ms = 5000
1646"#;
1647        let config = HeartbitConfig::from_toml(toml).unwrap();
1648        let retry = config.provider.retry.unwrap();
1649        assert_eq!(retry.base_delay_ms, 5000);
1650        assert_eq!(retry.max_delay_ms, 5000);
1651    }
1652
1653    #[test]
1654    fn zero_tool_timeout_seconds_rejected() {
1655        let toml = r#"
1656[provider]
1657name = "anthropic"
1658model = "claude-sonnet-4-20250514"
1659
1660[[agents]]
1661name = "test"
1662description = "Test"
1663system_prompt = "You test."
1664tool_timeout_seconds = 0
1665"#;
1666        let err = HeartbitConfig::from_toml(toml).unwrap_err();
1667        let msg = err.to_string();
1668        assert!(
1669            msg.contains("tool_timeout_seconds must be at least 1"),
1670            "error: {msg}"
1671        );
1672    }
1673
1674    #[test]
1675    fn zero_max_tool_output_bytes_rejected() {
1676        let toml = r#"
1677[provider]
1678name = "anthropic"
1679model = "claude-sonnet-4-20250514"
1680
1681[[agents]]
1682name = "test"
1683description = "Test"
1684system_prompt = "You test."
1685max_tool_output_bytes = 0
1686"#;
1687        let err = HeartbitConfig::from_toml(toml).unwrap_err();
1688        let msg = err.to_string();
1689        assert!(
1690            msg.contains("max_tool_output_bytes must be at least 1"),
1691            "error: {msg}"
1692        );
1693    }
1694
1695    #[test]
1696    fn empty_agent_name_rejected() {
1697        let toml = r#"
1698[provider]
1699name = "anthropic"
1700model = "claude-sonnet-4-20250514"
1701
1702[[agents]]
1703name = ""
1704description = "Test"
1705system_prompt = "You test."
1706"#;
1707        let err = HeartbitConfig::from_toml(toml).unwrap_err();
1708        let msg = err.to_string();
1709        assert!(msg.contains("agent name must not be empty"), "error: {msg}");
1710    }
1711
1712    #[test]
1713    fn parse_knowledge_config_with_all_source_types() {
1714        let toml = r#"
1715[provider]
1716name = "anthropic"
1717model = "claude-sonnet-4-20250514"
1718
1719[knowledge]
1720chunk_size = 2000
1721chunk_overlap = 400
1722
1723[[knowledge.sources]]
1724type = "file"
1725path = "README.md"
1726
1727[[knowledge.sources]]
1728type = "glob"
1729pattern = "docs/**/*.md"
1730
1731[[knowledge.sources]]
1732type = "url"
1733url = "https://docs.example.com/api"
1734"#;
1735        let config = HeartbitConfig::from_toml(toml).unwrap();
1736        let knowledge = config.knowledge.unwrap();
1737        assert_eq!(knowledge.chunk_size, 2000);
1738        assert_eq!(knowledge.chunk_overlap, 400);
1739        assert_eq!(knowledge.sources.len(), 3);
1740        assert!(matches!(
1741            knowledge.sources[0],
1742            KnowledgeSourceConfig::File { .. }
1743        ));
1744        assert!(matches!(
1745            knowledge.sources[1],
1746            KnowledgeSourceConfig::Glob { .. }
1747        ));
1748        assert!(matches!(
1749            knowledge.sources[2],
1750            KnowledgeSourceConfig::Url { .. }
1751        ));
1752    }
1753
1754    #[test]
1755    fn knowledge_config_defaults() {
1756        let toml = r#"
1757[provider]
1758name = "anthropic"
1759model = "claude-sonnet-4-20250514"
1760
1761[knowledge]
1762"#;
1763        let config = HeartbitConfig::from_toml(toml).unwrap();
1764        let knowledge = config.knowledge.unwrap();
1765        assert_eq!(knowledge.chunk_size, 1000);
1766        assert_eq!(knowledge.chunk_overlap, 200);
1767        assert!(knowledge.sources.is_empty());
1768    }
1769
1770    #[test]
1771    fn knowledge_config_defaults_to_none() {
1772        let toml = r#"
1773[provider]
1774name = "anthropic"
1775model = "claude-sonnet-4-20250514"
1776"#;
1777        let config = HeartbitConfig::from_toml(toml).unwrap();
1778        assert!(config.knowledge.is_none());
1779    }
1780
1781    #[test]
1782    fn knowledge_zero_chunk_size_rejected() {
1783        let toml = r#"
1784[provider]
1785name = "anthropic"
1786model = "claude-sonnet-4-20250514"
1787
1788[knowledge]
1789chunk_size = 0
1790"#;
1791        let err = HeartbitConfig::from_toml(toml).unwrap_err();
1792        let msg = err.to_string();
1793        assert!(
1794            msg.contains("chunk_size must be at least 1"),
1795            "error: {msg}"
1796        );
1797    }
1798
1799    #[test]
1800    fn knowledge_overlap_exceeds_chunk_size_rejected() {
1801        let toml = r#"
1802[provider]
1803name = "anthropic"
1804model = "claude-sonnet-4-20250514"
1805
1806[knowledge]
1807chunk_size = 100
1808chunk_overlap = 100
1809"#;
1810        let err = HeartbitConfig::from_toml(toml).unwrap_err();
1811        let msg = err.to_string();
1812        assert!(
1813            msg.contains("chunk_overlap") && msg.contains("less than chunk_size"),
1814            "error: {msg}"
1815        );
1816    }
1817
1818    #[test]
1819    fn prompt_caching_defaults_false() {
1820        let toml = r#"
1821[provider]
1822name = "anthropic"
1823model = "claude-sonnet-4-20250514"
1824"#;
1825        let config = HeartbitConfig::from_toml(toml).unwrap();
1826        assert!(!config.provider.prompt_caching);
1827    }
1828
1829    #[test]
1830    fn prompt_caching_parses_true() {
1831        let toml = r#"
1832[provider]
1833name = "anthropic"
1834model = "claude-sonnet-4-20250514"
1835prompt_caching = true
1836"#;
1837        let config = HeartbitConfig::from_toml(toml).unwrap();
1838        assert!(config.provider.prompt_caching);
1839    }
1840
1841    #[test]
1842    fn prompt_caching_backward_compat() {
1843        // Old config without prompt_caching should parse fine
1844        let toml = r#"
1845[provider]
1846name = "anthropic"
1847model = "claude-sonnet-4-20250514"
1848
1849[provider.retry]
1850max_retries = 3
1851"#;
1852        let config = HeartbitConfig::from_toml(toml).unwrap();
1853        assert!(!config.provider.prompt_caching);
1854        assert!(config.provider.retry.is_some());
1855    }
1856
1857    #[test]
1858    fn orchestrator_zero_context_strategy_max_tokens_rejected() {
1859        let toml = r#"
1860[provider]
1861name = "anthropic"
1862model = "claude-sonnet-4-20250514"
1863
1864[orchestrator.context_strategy]
1865type = "sliding_window"
1866max_tokens = 0
1867"#;
1868        let err = HeartbitConfig::from_toml(toml).unwrap_err();
1869        let msg = err.to_string();
1870        assert!(
1871            msg.contains("orchestrator.context_strategy.max_tokens must be at least 1"),
1872            "error: {msg}"
1873        );
1874    }
1875
1876    #[test]
1877    fn orchestrator_zero_context_strategy_threshold_rejected() {
1878        let toml = r#"
1879[provider]
1880name = "anthropic"
1881model = "claude-sonnet-4-20250514"
1882
1883[orchestrator.context_strategy]
1884type = "summarize"
1885threshold = 0
1886"#;
1887        let err = HeartbitConfig::from_toml(toml).unwrap_err();
1888        let msg = err.to_string();
1889        assert!(
1890            msg.contains("orchestrator.context_strategy.threshold must be at least 1"),
1891            "error: {msg}"
1892        );
1893    }
1894
1895    #[test]
1896    fn orchestrator_zero_summarize_threshold_rejected() {
1897        let toml = r#"
1898[provider]
1899name = "anthropic"
1900model = "claude-sonnet-4-20250514"
1901
1902[orchestrator]
1903summarize_threshold = 0
1904"#;
1905        let err = HeartbitConfig::from_toml(toml).unwrap_err();
1906        let msg = err.to_string();
1907        assert!(
1908            msg.contains("orchestrator.summarize_threshold must be at least 1"),
1909            "error: {msg}"
1910        );
1911    }
1912
1913    #[test]
1914    fn orchestrator_summarize_conflict_rejected() {
1915        let toml = r#"
1916[provider]
1917name = "anthropic"
1918model = "claude-sonnet-4-20250514"
1919
1920[orchestrator]
1921summarize_threshold = 8000
1922
1923[orchestrator.context_strategy]
1924type = "summarize"
1925threshold = 16000
1926"#;
1927        let err = HeartbitConfig::from_toml(toml).unwrap_err();
1928        let msg = err.to_string();
1929        assert!(msg.contains("cannot set both"), "error: {msg}");
1930    }
1931
1932    #[test]
1933    fn orchestrator_sliding_window_plus_summarize_threshold_rejected() {
1934        let toml = r#"
1935[provider]
1936name = "anthropic"
1937model = "claude-sonnet-4-20250514"
1938
1939[orchestrator]
1940summarize_threshold = 8000
1941
1942[orchestrator.context_strategy]
1943type = "sliding_window"
1944max_tokens = 16000
1945"#;
1946        let err = HeartbitConfig::from_toml(toml).unwrap_err();
1947        let msg = err.to_string();
1948        assert!(msg.contains("cannot set both"), "error: {msg}");
1949    }
1950
1951    #[test]
1952    fn orchestrator_unlimited_plus_summarize_threshold_allowed() {
1953        let toml = r#"
1954[provider]
1955name = "anthropic"
1956model = "claude-sonnet-4-20250514"
1957
1958[orchestrator]
1959summarize_threshold = 8000
1960
1961[orchestrator.context_strategy]
1962type = "unlimited"
1963"#;
1964        let config = HeartbitConfig::from_toml(toml).unwrap();
1965        assert_eq!(config.orchestrator.summarize_threshold, Some(8000));
1966    }
1967
1968    #[test]
1969    fn orchestrator_zero_tool_timeout_seconds_rejected() {
1970        let toml = r#"
1971[provider]
1972name = "anthropic"
1973model = "claude-sonnet-4-20250514"
1974
1975[orchestrator]
1976tool_timeout_seconds = 0
1977"#;
1978        let err = HeartbitConfig::from_toml(toml).unwrap_err();
1979        let msg = err.to_string();
1980        assert!(
1981            msg.contains("orchestrator.tool_timeout_seconds must be at least 1"),
1982            "error: {msg}"
1983        );
1984    }
1985
1986    #[test]
1987    fn orchestrator_zero_max_tool_output_bytes_rejected() {
1988        let toml = r#"
1989[provider]
1990name = "anthropic"
1991model = "claude-sonnet-4-20250514"
1992
1993[orchestrator]
1994max_tool_output_bytes = 0
1995"#;
1996        let err = HeartbitConfig::from_toml(toml).unwrap_err();
1997        let msg = err.to_string();
1998        assert!(
1999            msg.contains("orchestrator.max_tool_output_bytes must be at least 1"),
2000            "error: {msg}"
2001        );
2002    }
2003
2004    #[test]
2005    fn orchestrator_tool_timeout_parses() {
2006        let toml = r#"
2007[provider]
2008name = "anthropic"
2009model = "claude-sonnet-4-20250514"
2010
2011[orchestrator]
2012tool_timeout_seconds = 120
2013"#;
2014        let config = HeartbitConfig::from_toml(toml).unwrap();
2015        assert_eq!(config.orchestrator.tool_timeout_seconds, Some(120));
2016    }
2017
2018    #[test]
2019    fn orchestrator_max_tool_output_bytes_parses() {
2020        let toml = r#"
2021[provider]
2022name = "anthropic"
2023model = "claude-sonnet-4-20250514"
2024
2025[orchestrator]
2026max_tool_output_bytes = 32768
2027"#;
2028        let config = HeartbitConfig::from_toml(toml).unwrap();
2029        assert_eq!(config.orchestrator.max_tool_output_bytes, Some(32768));
2030    }
2031
2032    #[test]
2033    fn knowledge_overlap_less_than_chunk_size_accepted() {
2034        let toml = r#"
2035[provider]
2036name = "anthropic"
2037model = "claude-sonnet-4-20250514"
2038
2039[knowledge]
2040chunk_size = 100
2041chunk_overlap = 50
2042"#;
2043        let config = HeartbitConfig::from_toml(toml).unwrap();
2044        let knowledge = config.knowledge.unwrap();
2045        assert_eq!(knowledge.chunk_size, 100);
2046        assert_eq!(knowledge.chunk_overlap, 50);
2047    }
2048
2049    #[test]
2050    fn mcp_server_entry_simple_string() {
2051        let toml = r#"
2052[provider]
2053name = "anthropic"
2054model = "claude-sonnet-4-20250514"
2055
2056[[agents]]
2057name = "test"
2058description = "Test"
2059system_prompt = "Test."
2060mcp_servers = ["http://localhost:8000/mcp"]
2061"#;
2062        let config = HeartbitConfig::from_toml(toml).unwrap();
2063        assert_eq!(config.agents[0].mcp_servers.len(), 1);
2064        assert_eq!(
2065            config.agents[0].mcp_servers[0],
2066            McpServerEntry::Simple("http://localhost:8000/mcp".into())
2067        );
2068        assert_eq!(
2069            config.agents[0].mcp_servers[0].url(),
2070            "http://localhost:8000/mcp"
2071        );
2072        assert!(config.agents[0].mcp_servers[0].auth_header().is_none());
2073    }
2074
2075    #[test]
2076    fn mcp_server_entry_full_with_auth() {
2077        let toml = r#"
2078[provider]
2079name = "anthropic"
2080model = "claude-sonnet-4-20250514"
2081
2082[[agents]]
2083name = "test"
2084description = "Test"
2085system_prompt = "Test."
2086mcp_servers = [{ url = "http://gateway:8080/mcp", auth_header = "Bearer tok_xxx" }]
2087"#;
2088        let config = HeartbitConfig::from_toml(toml).unwrap();
2089        assert_eq!(config.agents[0].mcp_servers.len(), 1);
2090        assert_eq!(
2091            config.agents[0].mcp_servers[0].url(),
2092            "http://gateway:8080/mcp"
2093        );
2094        assert_eq!(
2095            config.agents[0].mcp_servers[0].auth_header(),
2096            Some("Bearer tok_xxx")
2097        );
2098    }
2099
2100    #[test]
2101    fn mcp_server_entry_full_without_auth() {
2102        let toml = r#"
2103[provider]
2104name = "anthropic"
2105model = "claude-sonnet-4-20250514"
2106
2107[[agents]]
2108name = "test"
2109description = "Test"
2110system_prompt = "Test."
2111mcp_servers = [{ url = "http://localhost:8000/mcp" }]
2112"#;
2113        let config = HeartbitConfig::from_toml(toml).unwrap();
2114        assert_eq!(
2115            config.agents[0].mcp_servers[0].url(),
2116            "http://localhost:8000/mcp"
2117        );
2118        assert!(config.agents[0].mcp_servers[0].auth_header().is_none());
2119    }
2120
2121    #[test]
2122    fn mcp_server_entry_mixed_simple_and_full() {
2123        let toml = r#"
2124[provider]
2125name = "anthropic"
2126model = "claude-sonnet-4-20250514"
2127
2128[[agents]]
2129name = "test"
2130description = "Test"
2131system_prompt = "Test."
2132mcp_servers = [
2133    "http://localhost:8000/mcp",
2134    { url = "http://gateway:8080/mcp", auth_header = "Bearer tok_xxx" }
2135]
2136"#;
2137        let config = HeartbitConfig::from_toml(toml).unwrap();
2138        assert_eq!(config.agents[0].mcp_servers.len(), 2);
2139        assert!(config.agents[0].mcp_servers[0].auth_header().is_none());
2140        assert_eq!(
2141            config.agents[0].mcp_servers[1].auth_header(),
2142            Some("Bearer tok_xxx")
2143        );
2144    }
2145
2146    #[test]
2147    fn mcp_server_entry_full_empty_url_rejected() {
2148        let toml = r#"
2149[provider]
2150name = "anthropic"
2151model = "claude-sonnet-4-20250514"
2152
2153[[agents]]
2154name = "test"
2155description = "Test"
2156system_prompt = "Test."
2157mcp_servers = [{ url = "" }]
2158"#;
2159        let err = HeartbitConfig::from_toml(toml).unwrap_err();
2160        let msg = err.to_string();
2161        assert!(msg.contains("url must not be empty"), "error: {msg}");
2162    }
2163
2164    #[test]
2165    fn mcp_server_entry_roundtrip() {
2166        let simple = McpServerEntry::Simple("http://localhost/mcp".into());
2167        let json = serde_json::to_string(&simple).unwrap();
2168        let parsed: McpServerEntry = serde_json::from_str(&json).unwrap();
2169        assert_eq!(simple, parsed);
2170
2171        let full = McpServerEntry::Full {
2172            url: "http://gateway/mcp".into(),
2173            auth_header: Some("Bearer tok".into()),
2174            resource: None,
2175            scopes: None,
2176        };
2177        let json = serde_json::to_string(&full).unwrap();
2178        let parsed: McpServerEntry = serde_json::from_str(&json).unwrap();
2179        assert_eq!(full, parsed);
2180    }
2181
2182    #[test]
2183    fn mcp_server_entry_scopes_resource_roundtrip() {
2184        let full = McpServerEntry::Full {
2185            url: "http://gmail-mcp.example.com/mcp".into(),
2186            auth_header: None,
2187            resource: Some("https://gmail.googleapis.com".into()),
2188            scopes: Some(vec!["gmail.readonly".into(), "gmail.send".into()]),
2189        };
2190        let json = serde_json::to_string(&full).unwrap();
2191        let parsed: McpServerEntry = serde_json::from_str(&json).unwrap();
2192        assert_eq!(full, parsed);
2193
2194        // resource() falls back to url when resource is None
2195        let no_resource = McpServerEntry::Full {
2196            url: "http://mcp.example.com".into(),
2197            auth_header: None,
2198            resource: None,
2199            scopes: None,
2200        };
2201        assert_eq!(no_resource.resource(), Some("http://mcp.example.com"));
2202
2203        // resource() returns explicit resource when set
2204        assert_eq!(full.resource(), Some("https://gmail.googleapis.com"));
2205
2206        // scopes() returns the configured scopes
2207        assert_eq!(
2208            full.scopes(),
2209            Some(["gmail.readonly".to_string(), "gmail.send".to_string()].as_slice())
2210        );
2211
2212        // Simple variant has resource = url, no scopes
2213        let simple = McpServerEntry::Simple("http://localhost/mcp".into());
2214        assert_eq!(simple.resource(), Some("http://localhost/mcp"));
2215        assert_eq!(simple.scopes(), None);
2216
2217        // Stdio variant has no resource, no scopes
2218        let stdio = McpServerEntry::Stdio {
2219            command: "npx".into(),
2220            args: vec![],
2221            env: Default::default(),
2222        };
2223        assert_eq!(stdio.resource(), None);
2224        assert_eq!(stdio.scopes(), None);
2225    }
2226
2227    #[test]
2228    fn orchestrator_run_timeout_parses() {
2229        let toml = r#"
2230[provider]
2231name = "anthropic"
2232model = "claude-sonnet-4-20250514"
2233
2234[orchestrator]
2235run_timeout_seconds = 300
2236"#;
2237        let config = HeartbitConfig::from_toml(toml).unwrap();
2238        assert_eq!(config.orchestrator.run_timeout_seconds, Some(300));
2239    }
2240
2241    #[test]
2242    fn orchestrator_run_timeout_defaults_to_none() {
2243        let toml = r#"
2244[provider]
2245name = "anthropic"
2246model = "claude-sonnet-4-20250514"
2247"#;
2248        let config = HeartbitConfig::from_toml(toml).unwrap();
2249        assert!(config.orchestrator.run_timeout_seconds.is_none());
2250    }
2251
2252    #[test]
2253    fn orchestrator_zero_run_timeout_rejected() {
2254        let toml = r#"
2255[provider]
2256name = "anthropic"
2257model = "claude-sonnet-4-20250514"
2258
2259[orchestrator]
2260run_timeout_seconds = 0
2261"#;
2262        let err = HeartbitConfig::from_toml(toml).unwrap_err();
2263        let msg = err.to_string();
2264        assert!(
2265            msg.contains("run_timeout_seconds must be at least 1"),
2266            "error: {msg}"
2267        );
2268    }
2269
2270    #[test]
2271    fn agent_run_timeout_parses() {
2272        let toml = r#"
2273[provider]
2274name = "anthropic"
2275model = "claude-sonnet-4-20250514"
2276
2277[[agents]]
2278name = "test"
2279description = "Test"
2280system_prompt = "Test."
2281run_timeout_seconds = 120
2282"#;
2283        let config = HeartbitConfig::from_toml(toml).unwrap();
2284        assert_eq!(config.agents[0].run_timeout_seconds, Some(120));
2285    }
2286
2287    #[test]
2288    fn agent_run_timeout_defaults_to_none() {
2289        let toml = r#"
2290[provider]
2291name = "anthropic"
2292model = "claude-sonnet-4-20250514"
2293
2294[[agents]]
2295name = "test"
2296description = "Test"
2297system_prompt = "Test."
2298"#;
2299        let config = HeartbitConfig::from_toml(toml).unwrap();
2300        assert!(config.agents[0].run_timeout_seconds.is_none());
2301    }
2302
2303    #[test]
2304    fn agent_zero_run_timeout_rejected() {
2305        let toml = r#"
2306[provider]
2307name = "anthropic"
2308model = "claude-sonnet-4-20250514"
2309
2310[[agents]]
2311name = "test"
2312description = "Test"
2313system_prompt = "Test."
2314run_timeout_seconds = 0
2315"#;
2316        let err = HeartbitConfig::from_toml(toml).unwrap_err();
2317        let msg = err.to_string();
2318        assert!(
2319            msg.contains("run_timeout_seconds must be at least 1"),
2320            "error: {msg}"
2321        );
2322    }
2323
2324    #[test]
2325    fn mcp_server_backward_compat_bare_strings() {
2326        // Existing configs with bare string arrays must keep working
2327        let toml = r#"
2328[provider]
2329name = "anthropic"
2330model = "claude-sonnet-4-20250514"
2331
2332[[agents]]
2333name = "coder"
2334description = "Coding expert"
2335system_prompt = "You code."
2336mcp_servers = ["http://localhost:8000/mcp", "http://localhost:9000/mcp"]
2337"#;
2338        let config = HeartbitConfig::from_toml(toml).unwrap();
2339        assert_eq!(config.agents[0].mcp_servers.len(), 2);
2340        assert_eq!(
2341            config.agents[0].mcp_servers[0].url(),
2342            "http://localhost:8000/mcp"
2343        );
2344        assert_eq!(
2345            config.agents[0].mcp_servers[1].url(),
2346            "http://localhost:9000/mcp"
2347        );
2348    }
2349
2350    #[test]
2351    fn per_agent_provider_parses() {
2352        let toml = r#"
2353[provider]
2354name = "anthropic"
2355model = "claude-opus-4-20250514"
2356
2357[[agents]]
2358name = "researcher"
2359description = "Research"
2360system_prompt = "Research."
2361
2362[agents.provider]
2363name = "anthropic"
2364model = "claude-haiku-4-5-20251001"
2365"#;
2366        let config = HeartbitConfig::from_toml(toml).unwrap();
2367        let agent_provider = config.agents[0].provider.as_ref().unwrap();
2368        assert_eq!(agent_provider.name, "anthropic");
2369        assert_eq!(agent_provider.model, "claude-haiku-4-5-20251001");
2370        assert!(!agent_provider.prompt_caching);
2371    }
2372
2373    #[test]
2374    fn per_agent_provider_with_prompt_caching() {
2375        let toml = r#"
2376[provider]
2377name = "anthropic"
2378model = "claude-opus-4-20250514"
2379
2380[[agents]]
2381name = "researcher"
2382description = "Research"
2383system_prompt = "Research."
2384
2385[agents.provider]
2386name = "anthropic"
2387model = "claude-sonnet-4-20250514"
2388prompt_caching = true
2389"#;
2390        let config = HeartbitConfig::from_toml(toml).unwrap();
2391        let agent_provider = config.agents[0].provider.as_ref().unwrap();
2392        assert!(agent_provider.prompt_caching);
2393    }
2394
2395    #[test]
2396    fn per_agent_provider_defaults_to_none() {
2397        let toml = r#"
2398[provider]
2399name = "anthropic"
2400model = "claude-sonnet-4-20250514"
2401
2402[[agents]]
2403name = "test"
2404description = "Test"
2405system_prompt = "Test."
2406"#;
2407        let config = HeartbitConfig::from_toml(toml).unwrap();
2408        assert!(config.agents[0].provider.is_none());
2409    }
2410
2411    #[test]
2412    fn per_agent_provider_empty_model_rejected() {
2413        let toml = r#"
2414[provider]
2415name = "anthropic"
2416model = "claude-sonnet-4-20250514"
2417
2418[[agents]]
2419name = "test"
2420description = "Test"
2421system_prompt = "Test."
2422
2423[agents.provider]
2424name = "anthropic"
2425model = ""
2426"#;
2427        let err = HeartbitConfig::from_toml(toml).unwrap_err();
2428        let msg = err.to_string();
2429        assert!(
2430            msg.contains("provider.model must not be empty"),
2431            "error: {msg}"
2432        );
2433    }
2434
2435    #[test]
2436    fn per_agent_provider_openrouter() {
2437        let toml = r#"
2438[provider]
2439name = "anthropic"
2440model = "claude-opus-4-20250514"
2441
2442[[agents]]
2443name = "cheap"
2444description = "Cheap agent"
2445system_prompt = "Be frugal."
2446
2447[agents.provider]
2448name = "openrouter"
2449model = "anthropic/claude-haiku-4-5"
2450"#;
2451        let config = HeartbitConfig::from_toml(toml).unwrap();
2452        let p = config.agents[0].provider.as_ref().unwrap();
2453        assert_eq!(p.name, "openrouter");
2454        assert_eq!(p.model, "anthropic/claude-haiku-4-5");
2455    }
2456
2457    #[test]
2458    fn mixed_agents_with_and_without_provider() {
2459        let toml = r#"
2460[provider]
2461name = "anthropic"
2462model = "claude-opus-4-20250514"
2463
2464[[agents]]
2465name = "researcher"
2466description = "Research"
2467system_prompt = "Research."
2468
2469[agents.provider]
2470name = "anthropic"
2471model = "claude-haiku-4-5-20251001"
2472
2473[[agents]]
2474name = "coder"
2475description = "Coding"
2476system_prompt = "Code."
2477"#;
2478        let config = HeartbitConfig::from_toml(toml).unwrap();
2479        assert!(config.agents[0].provider.is_some());
2480        assert!(config.agents[1].provider.is_none());
2481    }
2482
2483    #[test]
2484    fn per_agent_provider_empty_name_rejected() {
2485        let toml = r#"
2486[provider]
2487name = "anthropic"
2488model = "claude-sonnet-4-20250514"
2489
2490[[agents]]
2491name = "test"
2492description = "Test"
2493system_prompt = "Test."
2494
2495[agents.provider]
2496name = ""
2497model = "claude-haiku-4-5-20251001"
2498"#;
2499        let err = HeartbitConfig::from_toml(toml).unwrap_err();
2500        let msg = err.to_string();
2501        assert!(
2502            msg.contains("provider.name must not be empty"),
2503            "error: {msg}"
2504        );
2505    }
2506
2507    #[test]
2508    fn enable_squads_config_parsed() {
2509        let toml = r#"
2510[provider]
2511name = "anthropic"
2512model = "claude-sonnet-4-20250514"
2513
2514[orchestrator]
2515enable_squads = false
2516"#;
2517        let config = HeartbitConfig::from_toml(toml).unwrap();
2518        assert_eq!(config.orchestrator.enable_squads, Some(false));
2519    }
2520
2521    #[test]
2522    fn enable_squads_default_auto() {
2523        let toml = r#"
2524[provider]
2525name = "anthropic"
2526model = "claude-sonnet-4-20250514"
2527"#;
2528        let config = HeartbitConfig::from_toml(toml).unwrap();
2529        assert!(
2530            config.orchestrator.enable_squads.is_none(),
2531            "enable_squads should default to None (auto)"
2532        );
2533    }
2534
2535    #[test]
2536    fn enable_squads_true_parsed() {
2537        let toml = r#"
2538[provider]
2539name = "anthropic"
2540model = "claude-sonnet-4-20250514"
2541
2542[orchestrator]
2543enable_squads = true
2544"#;
2545        let config = HeartbitConfig::from_toml(toml).unwrap();
2546        assert_eq!(config.orchestrator.enable_squads, Some(true));
2547    }
2548
2549    #[test]
2550    fn a2a_agents_defaults_empty() {
2551        let toml = r#"
2552[provider]
2553name = "anthropic"
2554model = "claude-sonnet-4-20250514"
2555
2556[[agents]]
2557name = "test"
2558description = "Test"
2559system_prompt = "Test."
2560"#;
2561        let config = HeartbitConfig::from_toml(toml).unwrap();
2562        assert!(config.agents[0].a2a_agents.is_empty());
2563    }
2564
2565    #[test]
2566    fn a2a_agents_parses_simple() {
2567        let toml = r#"
2568[provider]
2569name = "anthropic"
2570model = "claude-sonnet-4-20250514"
2571
2572[[agents]]
2573name = "test"
2574description = "Test"
2575system_prompt = "Test."
2576a2a_agents = ["http://localhost:9000"]
2577"#;
2578        let config = HeartbitConfig::from_toml(toml).unwrap();
2579        assert_eq!(config.agents[0].a2a_agents.len(), 1);
2580        assert_eq!(
2581            config.agents[0].a2a_agents[0].url(),
2582            "http://localhost:9000"
2583        );
2584        assert!(config.agents[0].a2a_agents[0].auth_header().is_none());
2585    }
2586
2587    #[test]
2588    fn a2a_agents_parses_full_with_auth() {
2589        let toml = r#"
2590[provider]
2591name = "anthropic"
2592model = "claude-sonnet-4-20250514"
2593
2594[[agents]]
2595name = "test"
2596description = "Test"
2597system_prompt = "Test."
2598a2a_agents = [{ url = "http://gateway:8080", auth_header = "Bearer tok_a2a" }]
2599"#;
2600        let config = HeartbitConfig::from_toml(toml).unwrap();
2601        assert_eq!(config.agents[0].a2a_agents.len(), 1);
2602        assert_eq!(config.agents[0].a2a_agents[0].url(), "http://gateway:8080");
2603        assert_eq!(
2604            config.agents[0].a2a_agents[0].auth_header(),
2605            Some("Bearer tok_a2a")
2606        );
2607    }
2608
2609    #[test]
2610    fn a2a_agents_empty_url_rejected() {
2611        let toml = r#"
2612[provider]
2613name = "anthropic"
2614model = "claude-sonnet-4-20250514"
2615
2616[[agents]]
2617name = "test"
2618description = "Test"
2619system_prompt = "Test."
2620a2a_agents = [""]
2621"#;
2622        let err = HeartbitConfig::from_toml(toml).unwrap_err();
2623        let msg = err.to_string();
2624        assert!(
2625            msg.contains("a2a_agents") && msg.contains("url must not be empty"),
2626            "error: {msg}"
2627        );
2628    }
2629
2630    #[test]
2631    fn a2a_agents_mixed_with_mcp_servers() {
2632        let toml = r#"
2633[provider]
2634name = "anthropic"
2635model = "claude-sonnet-4-20250514"
2636
2637[[agents]]
2638name = "hybrid"
2639description = "Hybrid agent"
2640system_prompt = "You are hybrid."
2641mcp_servers = ["http://localhost:8000/mcp"]
2642a2a_agents = ["http://localhost:9000"]
2643"#;
2644        let config = HeartbitConfig::from_toml(toml).unwrap();
2645        assert_eq!(config.agents[0].mcp_servers.len(), 1);
2646        assert_eq!(config.agents[0].a2a_agents.len(), 1);
2647    }
2648
2649    #[test]
2650    fn mcp_server_entry_simple_empty_url_rejected() {
2651        let toml = r#"
2652[provider]
2653name = "anthropic"
2654model = "claude-sonnet-4-20250514"
2655
2656[[agents]]
2657name = "test"
2658description = "Test"
2659system_prompt = "Test."
2660mcp_servers = [""]
2661"#;
2662        let err = HeartbitConfig::from_toml(toml).unwrap_err();
2663        let msg = err.to_string();
2664        assert!(msg.contains("url must not be empty"), "error: {msg}");
2665    }
2666
2667    #[test]
2668    fn config_enable_reflection_orchestrator() {
2669        let toml = r#"
2670[provider]
2671name = "anthropic"
2672model = "claude-sonnet-4-20250514"
2673
2674[orchestrator]
2675enable_reflection = true
2676
2677[[agents]]
2678name = "a"
2679description = "A"
2680system_prompt = "s"
2681"#;
2682        let config = HeartbitConfig::from_toml(toml).unwrap();
2683        assert_eq!(config.orchestrator.enable_reflection, Some(true));
2684    }
2685
2686    #[test]
2687    fn config_enable_reflection_per_agent() {
2688        let toml = r#"
2689[provider]
2690name = "anthropic"
2691model = "claude-sonnet-4-20250514"
2692
2693[[agents]]
2694name = "reflective"
2695description = "R"
2696system_prompt = "s"
2697enable_reflection = true
2698"#;
2699        let config = HeartbitConfig::from_toml(toml).unwrap();
2700        assert_eq!(config.agents[0].enable_reflection, Some(true));
2701    }
2702
2703    #[test]
2704    fn config_rejects_zero_compression_threshold() {
2705        let toml = r#"
2706[provider]
2707name = "anthropic"
2708model = "claude-sonnet-4-20250514"
2709
2710[orchestrator]
2711tool_output_compression_threshold = 0
2712"#;
2713        let err = HeartbitConfig::from_toml(toml).unwrap_err();
2714        assert!(
2715            err.to_string()
2716                .contains("tool_output_compression_threshold")
2717        );
2718    }
2719
2720    #[test]
2721    fn config_rejects_zero_max_tools_per_turn() {
2722        let toml = r#"
2723[provider]
2724name = "anthropic"
2725model = "claude-sonnet-4-20250514"
2726
2727[orchestrator]
2728max_tools_per_turn = 0
2729"#;
2730        let err = HeartbitConfig::from_toml(toml).unwrap_err();
2731        assert!(err.to_string().contains("max_tools_per_turn"));
2732    }
2733
2734    #[test]
2735    fn config_rejects_zero_agent_compression_threshold() {
2736        let toml = r#"
2737[provider]
2738name = "anthropic"
2739model = "claude-sonnet-4-20250514"
2740
2741[[agents]]
2742name = "a"
2743description = "d"
2744system_prompt = "s"
2745tool_output_compression_threshold = 0
2746"#;
2747        let err = HeartbitConfig::from_toml(toml).unwrap_err();
2748        assert!(
2749            err.to_string()
2750                .contains("tool_output_compression_threshold"),
2751            "error: {err}"
2752        );
2753    }
2754
2755    #[test]
2756    fn config_rejects_zero_agent_max_tools_per_turn() {
2757        let toml = r#"
2758[provider]
2759name = "anthropic"
2760model = "claude-sonnet-4-20250514"
2761
2762[[agents]]
2763name = "a"
2764description = "d"
2765system_prompt = "s"
2766max_tools_per_turn = 0
2767"#;
2768        let err = HeartbitConfig::from_toml(toml).unwrap_err();
2769        assert!(
2770            err.to_string().contains("max_tools_per_turn"),
2771            "error: {err}"
2772        );
2773    }
2774
2775    #[test]
2776    fn config_rejects_zero_orchestrator_max_identical_tool_calls() {
2777        let toml = r#"
2778[provider]
2779name = "anthropic"
2780model = "claude-sonnet-4-20250514"
2781
2782[orchestrator]
2783max_identical_tool_calls = 0
2784"#;
2785        let err = HeartbitConfig::from_toml(toml).unwrap_err();
2786        assert!(
2787            err.to_string().contains("max_identical_tool_calls"),
2788            "error: {err}"
2789        );
2790    }
2791
2792    #[test]
2793    fn config_rejects_zero_agent_max_identical_tool_calls() {
2794        let toml = r#"
2795[provider]
2796name = "anthropic"
2797model = "claude-sonnet-4-20250514"
2798
2799[[agents]]
2800name = "a"
2801description = "d"
2802system_prompt = "s"
2803max_identical_tool_calls = 0
2804"#;
2805        let err = HeartbitConfig::from_toml(toml).unwrap_err();
2806        assert!(
2807            err.to_string().contains("max_identical_tool_calls"),
2808            "error: {err}"
2809        );
2810    }
2811
2812    #[test]
2813    fn config_parses_max_identical_tool_calls() {
2814        let toml = r#"
2815[provider]
2816name = "anthropic"
2817model = "claude-sonnet-4-20250514"
2818
2819[orchestrator]
2820max_identical_tool_calls = 5
2821
2822[[agents]]
2823name = "a"
2824description = "d"
2825system_prompt = "s"
2826max_identical_tool_calls = 3
2827"#;
2828        let config = HeartbitConfig::from_toml(toml).unwrap();
2829        assert_eq!(config.orchestrator.max_identical_tool_calls, Some(5));
2830        assert_eq!(config.agents[0].max_identical_tool_calls, Some(3));
2831    }
2832
2833    #[test]
2834    fn config_max_identical_tool_calls_defaults_to_none() {
2835        let toml = r#"
2836[provider]
2837name = "anthropic"
2838model = "claude-sonnet-4-20250514"
2839
2840[[agents]]
2841name = "a"
2842description = "d"
2843system_prompt = "s"
2844"#;
2845        let config = HeartbitConfig::from_toml(toml).unwrap();
2846        assert!(config.orchestrator.max_identical_tool_calls.is_none());
2847        assert!(config.agents[0].max_identical_tool_calls.is_none());
2848    }
2849
2850    // --- max_tool_calls_per_turn Config Tests ---
2851
2852    #[test]
2853    fn config_rejects_zero_orchestrator_max_tool_calls_per_turn() {
2854        let toml = r#"
2855[provider]
2856name = "anthropic"
2857model = "claude-sonnet-4-20250514"
2858
2859[orchestrator]
2860max_tool_calls_per_turn = 0
2861"#;
2862        let err = HeartbitConfig::from_toml(toml).unwrap_err();
2863        assert!(
2864            err.to_string().contains("max_tool_calls_per_turn"),
2865            "error: {err}"
2866        );
2867    }
2868
2869    #[test]
2870    fn config_rejects_zero_agent_max_tool_calls_per_turn() {
2871        let toml = r#"
2872[provider]
2873name = "anthropic"
2874model = "claude-sonnet-4-20250514"
2875
2876[[agents]]
2877name = "a"
2878description = "d"
2879system_prompt = "s"
2880max_tool_calls_per_turn = 0
2881"#;
2882        let err = HeartbitConfig::from_toml(toml).unwrap_err();
2883        assert!(
2884            err.to_string().contains("max_tool_calls_per_turn"),
2885            "error: {err}"
2886        );
2887    }
2888
2889    #[test]
2890    fn config_parses_max_tool_calls_per_turn() {
2891        let toml = r#"
2892[provider]
2893name = "anthropic"
2894model = "claude-sonnet-4-20250514"
2895
2896[orchestrator]
2897max_tool_calls_per_turn = 5
2898
2899[[agents]]
2900name = "a"
2901description = "d"
2902system_prompt = "s"
2903max_tool_calls_per_turn = 3
2904"#;
2905        let cfg = HeartbitConfig::from_toml(toml).unwrap();
2906        assert_eq!(cfg.orchestrator.max_tool_calls_per_turn, Some(5));
2907        assert_eq!(cfg.agents[0].max_tool_calls_per_turn, Some(3));
2908    }
2909
2910    #[test]
2911    fn config_parses_sandbox_section() {
2912        let toml = r#"
2913[provider]
2914name = "anthropic"
2915model = "claude-sonnet-4-20250514"
2916
2917[[agents]]
2918name = "a"
2919description = "d"
2920system_prompt = "s"
2921
2922[sandbox]
2923allowed_dirs = ["/workspace", "/tmp/agent"]
2924deny_globs = ["**/.env", "**/secrets/**"]
2925"#;
2926        let cfg = HeartbitConfig::from_toml(toml).unwrap();
2927        let sb = cfg.sandbox.unwrap();
2928        assert_eq!(sb.allowed_dirs.len(), 2);
2929        assert_eq!(sb.deny_globs.len(), 2);
2930        assert_eq!(sb.deny_globs[0], "**/.env");
2931    }
2932
2933    #[test]
2934    fn config_parses_daemon_audit_section() {
2935        let toml = r#"
2936[provider]
2937name = "anthropic"
2938model = "claude-sonnet-4-20250514"
2939
2940[[agents]]
2941name = "a"
2942description = "d"
2943system_prompt = "s"
2944
2945[daemon]
2946bind = "127.0.0.1:3000"
2947
2948[daemon.audit]
2949retain_days = 30
2950prune_interval_minutes = 120
2951"#;
2952        let cfg = HeartbitConfig::from_toml(toml).unwrap();
2953        let daemon = cfg.daemon.unwrap();
2954        assert_eq!(daemon.audit.retain_days, Some(30));
2955        assert_eq!(daemon.audit.prune_interval_minutes, Some(120));
2956    }
2957
2958    #[test]
2959    fn config_rejects_zero_prune_interval_minutes() {
2960        let toml = r#"
2961[provider]
2962name = "anthropic"
2963model = "claude-sonnet-4-20250514"
2964
2965[daemon]
2966bind = "127.0.0.1:3000"
2967
2968[daemon.audit]
2969prune_interval_minutes = 0
2970"#;
2971        let err = HeartbitConfig::from_toml(toml).unwrap_err();
2972        assert!(
2973            err.to_string().contains("prune_interval_minutes"),
2974            "error: {err}"
2975        );
2976    }
2977
2978    #[test]
2979    fn config_rejects_zero_retain_days() {
2980        let toml = r#"
2981[provider]
2982name = "anthropic"
2983model = "claude-sonnet-4-20250514"
2984
2985[daemon]
2986bind = "127.0.0.1:3000"
2987
2988[daemon.audit]
2989retain_days = 0
2990"#;
2991        let err = HeartbitConfig::from_toml(toml).unwrap_err();
2992        assert!(err.to_string().contains("retain_days"), "error: {err}");
2993    }
2994
2995    #[test]
2996    fn idempotency_config_defaults_are_none() {
2997        let toml = r#"
2998[provider]
2999name = "anthropic"
3000model = "claude-sonnet-4-20250514"
3001
3002[daemon]
3003bind = "127.0.0.1:3000"
3004"#;
3005        let cfg = HeartbitConfig::from_toml(toml).unwrap();
3006        let daemon = cfg.daemon.unwrap();
3007        assert!(daemon.idempotency.ttl_hours.is_none());
3008        assert!(daemon.idempotency.sweep_interval_minutes.is_none());
3009    }
3010
3011    #[test]
3012    fn config_rejects_zero_idempotency_ttl_hours() {
3013        let toml = r#"
3014[provider]
3015name = "anthropic"
3016model = "claude-sonnet-4-20250514"
3017
3018[daemon]
3019bind = "127.0.0.1:3000"
3020
3021[daemon.idempotency]
3022ttl_hours = 0
3023"#;
3024        let err = HeartbitConfig::from_toml(toml).unwrap_err();
3025        assert!(err.to_string().contains("ttl_hours"), "error: {err}");
3026    }
3027
3028    #[test]
3029    fn config_rejects_zero_idempotency_sweep_interval() {
3030        let toml = r#"
3031[provider]
3032name = "anthropic"
3033model = "claude-sonnet-4-20250514"
3034
3035[daemon]
3036bind = "127.0.0.1:3000"
3037
3038[daemon.idempotency]
3039sweep_interval_minutes = 0
3040"#;
3041        let err = HeartbitConfig::from_toml(toml).unwrap_err();
3042        assert!(
3043            err.to_string().contains("sweep_interval_minutes"),
3044            "error: {err}"
3045        );
3046    }
3047
3048    // --- Permission Rules Config Tests ---
3049
3050    #[test]
3051    fn config_parses_permission_rules() {
3052        let toml = r#"
3053[provider]
3054name = "anthropic"
3055model = "claude-sonnet-4-20250514"
3056
3057[[agents]]
3058name = "a"
3059description = "d"
3060system_prompt = "s"
3061
3062[[permissions]]
3063tool = "read_file"
3064action = "allow"
3065
3066[[permissions]]
3067tool = "bash"
3068pattern = "rm *"
3069action = "deny"
3070
3071[[permissions]]
3072tool = "*"
3073pattern = "*.env*"
3074action = "deny"
3075"#;
3076        let config = HeartbitConfig::from_toml(toml).unwrap();
3077        assert_eq!(config.permissions.len(), 3);
3078        assert_eq!(config.permissions[0].tool, "read_file");
3079        assert_eq!(config.permissions[0].pattern, "*"); // default
3080        assert_eq!(
3081            config.permissions[0].action,
3082            crate::agent::permission::PermissionAction::Allow
3083        );
3084        assert_eq!(config.permissions[1].tool, "bash");
3085        assert_eq!(config.permissions[1].pattern, "rm *");
3086        assert_eq!(
3087            config.permissions[1].action,
3088            crate::agent::permission::PermissionAction::Deny
3089        );
3090        assert_eq!(config.permissions[2].tool, "*");
3091        assert_eq!(config.permissions[2].pattern, "*.env*");
3092    }
3093
3094    #[test]
3095    fn config_defaults_to_empty_permissions() {
3096        let toml = r#"
3097[provider]
3098name = "anthropic"
3099model = "claude-sonnet-4-20250514"
3100
3101[[agents]]
3102name = "a"
3103description = "d"
3104system_prompt = "s"
3105"#;
3106        let config = HeartbitConfig::from_toml(toml).unwrap();
3107        assert!(config.permissions.is_empty());
3108    }
3109
3110    #[test]
3111    fn lsp_config_defaults_to_none() {
3112        let toml = r#"
3113[provider]
3114name = "anthropic"
3115model = "claude-sonnet-4-20250514"
3116"#;
3117        let config = HeartbitConfig::from_toml(toml).unwrap();
3118        assert!(config.lsp.is_none());
3119    }
3120
3121    #[test]
3122    fn lsp_config_enabled_defaults_true() {
3123        let toml = r#"
3124[provider]
3125name = "anthropic"
3126model = "claude-sonnet-4-20250514"
3127
3128[lsp]
3129"#;
3130        let config = HeartbitConfig::from_toml(toml).unwrap();
3131        let lsp = config.lsp.unwrap();
3132        assert!(lsp.enabled);
3133    }
3134
3135    #[test]
3136    fn lsp_config_disabled() {
3137        let toml = r#"
3138[provider]
3139name = "anthropic"
3140model = "claude-sonnet-4-20250514"
3141
3142[lsp]
3143enabled = false
3144"#;
3145        let config = HeartbitConfig::from_toml(toml).unwrap();
3146        let lsp = config.lsp.unwrap();
3147        assert!(!lsp.enabled);
3148    }
3149
3150    #[test]
3151    fn parse_session_prune_with_preserve_task() {
3152        let toml = r#"
3153[provider]
3154name = "anthropic"
3155model = "claude-sonnet-4-20250514"
3156
3157[[agents]]
3158name = "test"
3159description = "Test"
3160system_prompt = "You test."
3161
3162[agents.session_prune]
3163keep_recent_n = 3
3164pruned_tool_result_max_bytes = 100
3165preserve_task = false
3166"#;
3167        let config = HeartbitConfig::from_toml(toml).unwrap();
3168        let sp = config.agents[0].session_prune.as_ref().unwrap();
3169        assert_eq!(sp.keep_recent_n, 3);
3170        assert_eq!(sp.pruned_tool_result_max_bytes, 100);
3171        assert!(!sp.preserve_task);
3172    }
3173
3174    #[test]
3175    fn config_telemetry_parses_observability_mode() {
3176        let toml = r#"
3177[provider]
3178name = "anthropic"
3179model = "claude-sonnet-4-20250514"
3180
3181[telemetry]
3182otlp_endpoint = "http://localhost:4317"
3183observability_mode = "analysis"
3184"#;
3185        let config = HeartbitConfig::from_toml(toml).unwrap();
3186        let telemetry = config.telemetry.unwrap();
3187        assert_eq!(telemetry.observability_mode.as_deref(), Some("analysis"));
3188    }
3189
3190    #[test]
3191    fn config_telemetry_observability_mode_defaults_to_none() {
3192        let toml = r#"
3193[provider]
3194name = "anthropic"
3195model = "claude-sonnet-4-20250514"
3196
3197[telemetry]
3198otlp_endpoint = "http://localhost:4317"
3199"#;
3200        let config = HeartbitConfig::from_toml(toml).unwrap();
3201        let telemetry = config.telemetry.unwrap();
3202        assert!(telemetry.observability_mode.is_none());
3203    }
3204
3205    #[test]
3206    fn dispatch_mode_defaults_to_none() {
3207        let toml = r#"
3208[provider]
3209name = "anthropic"
3210model = "claude-sonnet-4-20250514"
3211"#;
3212        let config = HeartbitConfig::from_toml(toml).unwrap();
3213        assert!(config.orchestrator.dispatch_mode.is_none());
3214    }
3215
3216    #[test]
3217    fn dispatch_mode_sequential_parses() {
3218        let toml = r#"
3219[provider]
3220name = "anthropic"
3221model = "claude-sonnet-4-20250514"
3222
3223[orchestrator]
3224dispatch_mode = "sequential"
3225"#;
3226        let config = HeartbitConfig::from_toml(toml).unwrap();
3227        assert_eq!(
3228            config.orchestrator.dispatch_mode,
3229            Some(DispatchMode::Sequential)
3230        );
3231    }
3232
3233    #[test]
3234    fn dispatch_mode_parallel_parses() {
3235        let toml = r#"
3236[provider]
3237name = "anthropic"
3238model = "claude-sonnet-4-20250514"
3239
3240[orchestrator]
3241dispatch_mode = "parallel"
3242"#;
3243        let config = HeartbitConfig::from_toml(toml).unwrap();
3244        assert_eq!(
3245            config.orchestrator.dispatch_mode,
3246            Some(DispatchMode::Parallel)
3247        );
3248    }
3249
3250    #[test]
3251    fn dispatch_mode_invalid_rejected() {
3252        let toml = r#"
3253[provider]
3254name = "anthropic"
3255model = "claude-sonnet-4-20250514"
3256
3257[orchestrator]
3258dispatch_mode = "bananas"
3259"#;
3260        let err = HeartbitConfig::from_toml(toml).unwrap_err();
3261        assert!(matches!(err, Error::Config(_)));
3262    }
3263
3264    #[test]
3265    fn session_prune_preserve_task_defaults_to_true() {
3266        let toml = r#"
3267[provider]
3268name = "anthropic"
3269model = "claude-sonnet-4-20250514"
3270
3271[[agents]]
3272name = "test"
3273description = "Test"
3274system_prompt = "You test."
3275
3276[agents.session_prune]
3277"#;
3278        let config = HeartbitConfig::from_toml(toml).unwrap();
3279        let sp = config.agents[0].session_prune.as_ref().unwrap();
3280        assert_eq!(sp.keep_recent_n, 2); // default
3281        assert_eq!(sp.pruned_tool_result_max_bytes, 200); // default
3282        assert!(sp.preserve_task); // default true
3283    }
3284
3285    #[test]
3286    fn daemon_config_parses() {
3287        let toml = r#"
3288[provider]
3289name = "anthropic"
3290model = "claude-sonnet-4-20250514"
3291
3292[daemon]
3293bind = "0.0.0.0:8080"
3294max_concurrent_tasks = 8
3295
3296[daemon.kafka]
3297brokers = "localhost:9092"
3298"#;
3299        let config = HeartbitConfig::from_toml(toml).unwrap();
3300        let daemon = config.daemon.unwrap();
3301        assert_eq!(daemon.bind, "0.0.0.0:8080");
3302        assert_eq!(daemon.max_concurrent_tasks, 8);
3303        let kafka = daemon.kafka.unwrap();
3304        assert_eq!(kafka.brokers, "localhost:9092");
3305        assert_eq!(kafka.consumer_group, "heartbit-daemon");
3306        assert_eq!(kafka.commands_topic, "heartbit.commands");
3307        assert_eq!(kafka.events_topic, "heartbit.events");
3308        assert_eq!(kafka.dead_letter_topic, "heartbit.dead-letter");
3309    }
3310
3311    #[test]
3312    fn daemon_config_defaults() {
3313        let toml = r#"
3314[provider]
3315name = "anthropic"
3316model = "claude-sonnet-4-20250514"
3317
3318[daemon.kafka]
3319brokers = "localhost:9092"
3320"#;
3321        let config = HeartbitConfig::from_toml(toml).unwrap();
3322        let daemon = config.daemon.unwrap();
3323        assert_eq!(daemon.bind, "127.0.0.1:3000");
3324        assert_eq!(daemon.max_concurrent_tasks, 4);
3325    }
3326
3327    #[test]
3328    fn daemon_config_defaults_to_none() {
3329        let toml = r#"
3330[provider]
3331name = "anthropic"
3332model = "claude-sonnet-4-20250514"
3333"#;
3334        let config = HeartbitConfig::from_toml(toml).unwrap();
3335        assert!(config.daemon.is_none());
3336    }
3337
3338    #[test]
3339    fn daemon_zero_max_concurrent_rejected() {
3340        let toml = r#"
3341[provider]
3342name = "anthropic"
3343model = "claude-sonnet-4-20250514"
3344
3345[daemon]
3346max_concurrent_tasks = 0
3347
3348[daemon.kafka]
3349brokers = "localhost:9092"
3350"#;
3351        let err = HeartbitConfig::from_toml(toml).unwrap_err();
3352        let msg = err.to_string();
3353        assert!(
3354            msg.contains("max_concurrent_tasks must be at least 1"),
3355            "error: {msg}"
3356        );
3357    }
3358
3359    #[test]
3360    fn daemon_empty_brokers_rejected() {
3361        let toml = r#"
3362[provider]
3363name = "anthropic"
3364model = "claude-sonnet-4-20250514"
3365
3366[daemon.kafka]
3367brokers = ""
3368"#;
3369        let err = HeartbitConfig::from_toml(toml).unwrap_err();
3370        let msg = err.to_string();
3371        assert!(msg.contains("brokers must not be empty"), "error: {msg}");
3372    }
3373
3374    #[test]
3375    fn daemon_config_metrics_defaults_to_none() {
3376        let toml = r#"
3377[provider]
3378name = "anthropic"
3379model = "claude-3-5-sonnet"
3380
3381[daemon.kafka]
3382brokers = "localhost:9092"
3383"#;
3384        let config: HeartbitConfig = toml::from_str(toml).unwrap();
3385        let daemon = config.daemon.unwrap();
3386        assert!(daemon.metrics.is_none());
3387    }
3388
3389    #[test]
3390    fn daemon_config_metrics_enabled_explicit() {
3391        let toml = r#"
3392[provider]
3393name = "anthropic"
3394model = "claude-3-5-sonnet"
3395
3396[daemon.kafka]
3397brokers = "localhost:9092"
3398
3399[daemon.metrics]
3400enabled = true
3401"#;
3402        let config: HeartbitConfig = toml::from_str(toml).unwrap();
3403        let daemon = config.daemon.unwrap();
3404        let metrics = daemon.metrics.unwrap();
3405        assert!(metrics.enabled);
3406    }
3407
3408    #[test]
3409    fn daemon_config_metrics_disabled() {
3410        let toml = r#"
3411[provider]
3412name = "anthropic"
3413model = "claude-3-5-sonnet"
3414
3415[daemon.kafka]
3416brokers = "localhost:9092"
3417
3418[daemon.metrics]
3419enabled = false
3420"#;
3421        let config: HeartbitConfig = toml::from_str(toml).unwrap();
3422        let daemon = config.daemon.unwrap();
3423        let metrics = daemon.metrics.unwrap();
3424        assert!(!metrics.enabled);
3425    }
3426
3427    #[test]
3428    fn daemon_config_metrics_section_present_defaults_enabled() {
3429        // When [daemon.metrics] is present but `enabled` is omitted, defaults to true
3430        let toml = r#"
3431[provider]
3432name = "anthropic"
3433model = "claude-3-5-sonnet"
3434
3435[daemon.kafka]
3436brokers = "localhost:9092"
3437
3438[daemon.metrics]
3439"#;
3440        let config: HeartbitConfig = toml::from_str(toml).unwrap();
3441        let daemon = config.daemon.unwrap();
3442        let metrics = daemon.metrics.unwrap();
3443        assert!(metrics.enabled);
3444    }
3445
3446    #[test]
3447    fn sensor_source_name() {
3448        let rss = SensorSourceConfig::Rss {
3449            name: "tech_rss".into(),
3450            feeds: vec!["https://example.com/feed".into()],
3451            interest_keywords: vec![],
3452            poll_interval_seconds: 900,
3453        };
3454        assert_eq!(rss.name(), "tech_rss");
3455
3456        let webhook = SensorSourceConfig::Webhook {
3457            name: "github_events".into(),
3458            path: "/webhooks/github".into(),
3459            secret_env: None,
3460        };
3461        assert_eq!(webhook.name(), "github_events");
3462    }
3463
3464    // Sensor validation tests removed — sensors field moved to gateway crate.
3465
3466    // (sensor_duplicate_source_names removed — field moved to gateway)
3467
3468    #[test]
3469    fn kafka_dead_letter_topic_custom() {
3470        let toml = r#"
3471[provider]
3472name = "anthropic"
3473model = "claude-3-5-sonnet"
3474
3475[daemon.kafka]
3476brokers = "localhost:9092"
3477dead_letter_topic = "my.custom.dead-letter"
3478"#;
3479        let config = HeartbitConfig::from_toml(toml).unwrap();
3480        let daemon = config.daemon.unwrap();
3481        assert_eq!(
3482            daemon.kafka.unwrap().dead_letter_topic,
3483            "my.custom.dead-letter"
3484        );
3485    }
3486
3487    // ws_config_* tests removed — ws field moved to gateway crate.
3488
3489    #[test]
3490    fn daemon_database_url_default_none() {
3491        let toml = r#"
3492[provider]
3493name = "anthropic"
3494model = "claude-3-5-sonnet"
3495
3496[daemon.kafka]
3497brokers = "localhost:9092"
3498"#;
3499        let config = HeartbitConfig::from_toml(toml).unwrap();
3500        assert!(config.daemon.unwrap().database_url.is_none());
3501    }
3502
3503    #[test]
3504    fn daemon_database_url_present() {
3505        let toml = r#"
3506[provider]
3507name = "anthropic"
3508model = "claude-3-5-sonnet"
3509
3510[daemon]
3511database_url = "postgresql://localhost/heartbit_tasks"
3512
3513[daemon.kafka]
3514brokers = "localhost:9092"
3515"#;
3516        let config = HeartbitConfig::from_toml(toml).unwrap();
3517        assert_eq!(
3518            config.daemon.unwrap().database_url.as_deref(),
3519            Some("postgresql://localhost/heartbit_tasks")
3520        );
3521    }
3522
3523    #[test]
3524    fn workspace_config_explicit_root() {
3525        let toml = r#"
3526[provider]
3527name = "anthropic"
3528model = "claude-sonnet-4-20250514"
3529
3530[orchestrator]
3531max_turns = 5
3532max_tokens = 4096
3533
3534[workspace]
3535root = "/custom/workspaces"
3536
3537[[agents]]
3538name = "test"
3539description = "test"
3540system_prompt = "test"
3541"#;
3542        let config = HeartbitConfig::from_toml(toml).unwrap();
3543        let ws = config.workspace.unwrap();
3544        assert_eq!(ws.root, "/custom/workspaces");
3545    }
3546
3547    #[test]
3548    fn workspace_config_default_root() {
3549        let toml = r#"
3550[provider]
3551name = "anthropic"
3552model = "claude-sonnet-4-20250514"
3553
3554[orchestrator]
3555max_turns = 5
3556max_tokens = 4096
3557
3558[workspace]
3559
3560[[agents]]
3561name = "test"
3562description = "test"
3563system_prompt = "test"
3564"#;
3565        let config = HeartbitConfig::from_toml(toml).unwrap();
3566        let ws = config.workspace.unwrap();
3567        // Should use the default path
3568        assert!(ws.root.contains(".heartbit/workspaces"));
3569    }
3570
3571    #[test]
3572    fn workspace_config_absent() {
3573        let toml = r#"
3574[provider]
3575name = "anthropic"
3576model = "claude-sonnet-4-20250514"
3577
3578[orchestrator]
3579max_turns = 5
3580max_tokens = 4096
3581
3582[[agents]]
3583name = "test"
3584description = "test"
3585system_prompt = "test"
3586"#;
3587        let config = HeartbitConfig::from_toml(toml).unwrap();
3588        assert!(config.workspace.is_none());
3589    }
3590
3591    // heartbit_pulse config tests removed — field moved to gateway crate.
3592
3593    #[test]
3594    fn active_hours_parse_valid() {
3595        let ah = ActiveHoursConfig {
3596            start: "08:30".into(),
3597            end: "22:00".into(),
3598        };
3599        assert_eq!(ah.parse_start().unwrap(), (8, 30));
3600        assert_eq!(ah.parse_end().unwrap(), (22, 0));
3601    }
3602
3603    #[test]
3604    fn active_hours_parse_midnight() {
3605        let ah = ActiveHoursConfig {
3606            start: "00:00".into(),
3607            end: "23:59".into(),
3608        };
3609        assert_eq!(ah.parse_start().unwrap(), (0, 0));
3610        assert_eq!(ah.parse_end().unwrap(), (23, 59));
3611    }
3612
3613    #[test]
3614    fn active_hours_parse_invalid_format() {
3615        let ah = ActiveHoursConfig {
3616            start: "8am".into(),
3617            end: "22:00".into(),
3618        };
3619        assert!(ah.parse_start().is_err());
3620    }
3621
3622    #[test]
3623    fn active_hours_parse_out_of_range() {
3624        let ah = ActiveHoursConfig {
3625            start: "25:00".into(),
3626            end: "22:00".into(),
3627        };
3628        assert!(ah.parse_start().is_err());
3629    }
3630
3631    // validate_rejects_pulse tests removed — heartbit_pulse field moved to gateway crate.
3632
3633    #[test]
3634    fn routing_defaults_to_auto_when_missing() {
3635        let toml_str = r#"
3636[provider]
3637name = "anthropic"
3638model = "claude-3-5-sonnet"
3639
3640[[agents]]
3641name = "worker"
3642description = "worker agent"
3643system_prompt = "you are a worker"
3644"#;
3645        let config = HeartbitConfig::from_toml(toml_str).unwrap();
3646        assert_eq!(config.orchestrator.routing, RoutingMode::Auto);
3647        assert!(config.orchestrator.escalation);
3648    }
3649
3650    #[test]
3651    fn routing_parses_always_orchestrate() {
3652        let toml_str = r#"
3653[provider]
3654name = "anthropic"
3655model = "claude-3-5-sonnet"
3656
3657[orchestrator]
3658routing = "always_orchestrate"
3659
3660[[agents]]
3661name = "worker"
3662description = "worker agent"
3663system_prompt = "you are a worker"
3664"#;
3665        let config = HeartbitConfig::from_toml(toml_str).unwrap();
3666        assert_eq!(config.orchestrator.routing, RoutingMode::AlwaysOrchestrate);
3667    }
3668
3669    #[test]
3670    fn routing_parses_single_agent() {
3671        let toml_str = r#"
3672[provider]
3673name = "anthropic"
3674model = "claude-3-5-sonnet"
3675
3676[orchestrator]
3677routing = "single_agent"
3678
3679[[agents]]
3680name = "worker"
3681description = "worker agent"
3682system_prompt = "you are a worker"
3683"#;
3684        let config = HeartbitConfig::from_toml(toml_str).unwrap();
3685        assert_eq!(config.orchestrator.routing, RoutingMode::SingleAgent);
3686    }
3687
3688    #[test]
3689    fn escalation_defaults_to_true() {
3690        let toml_str = r#"
3691[provider]
3692name = "anthropic"
3693model = "claude-3-5-sonnet"
3694
3695[[agents]]
3696name = "worker"
3697description = "worker agent"
3698system_prompt = "you are a worker"
3699"#;
3700        let config = HeartbitConfig::from_toml(toml_str).unwrap();
3701        assert!(config.orchestrator.escalation);
3702    }
3703
3704    #[test]
3705    fn escalation_can_be_disabled() {
3706        let toml_str = r#"
3707[provider]
3708name = "anthropic"
3709model = "claude-3-5-sonnet"
3710
3711[orchestrator]
3712escalation = false
3713
3714[[agents]]
3715name = "worker"
3716description = "worker agent"
3717system_prompt = "you are a worker"
3718"#;
3719        let config = HeartbitConfig::from_toml(toml_str).unwrap();
3720        assert!(!config.orchestrator.escalation);
3721    }
3722
3723    #[test]
3724    fn auth_config_valid() {
3725        let toml_str = r#"
3726[provider]
3727name = "anthropic"
3728model = "claude-sonnet-4-20250514"
3729
3730[daemon.kafka]
3731brokers = "localhost:9092"
3732
3733[daemon.auth]
3734bearer_tokens = ["my-secret-key", "rotation-key-2"]
3735"#;
3736        let config = HeartbitConfig::from_toml(toml_str).unwrap();
3737        let auth = config.daemon.unwrap().auth.unwrap();
3738        assert_eq!(auth.bearer_tokens.len(), 2);
3739        assert_eq!(auth.bearer_tokens[0], "my-secret-key");
3740    }
3741
3742    #[test]
3743    fn auth_config_empty_tokens_rejected() {
3744        let toml_str = r#"
3745[provider]
3746name = "anthropic"
3747model = "claude-sonnet-4-20250514"
3748
3749[daemon.kafka]
3750brokers = "localhost:9092"
3751
3752[daemon.auth]
3753bearer_tokens = []
3754"#;
3755        let err = HeartbitConfig::from_toml(toml_str).unwrap_err();
3756        assert!(
3757            err.to_string()
3758                .contains("daemon.auth requires at least bearer_tokens or jwks_url"),
3759            "got: {err}"
3760        );
3761    }
3762
3763    #[test]
3764    fn auth_config_empty_token_string_rejected() {
3765        let toml_str = r#"
3766[provider]
3767name = "anthropic"
3768model = "claude-sonnet-4-20250514"
3769
3770[daemon.kafka]
3771brokers = "localhost:9092"
3772
3773[daemon.auth]
3774bearer_tokens = [""]
3775"#;
3776        let err = HeartbitConfig::from_toml(toml_str).unwrap_err();
3777        assert!(
3778            err.to_string()
3779                .contains("daemon.auth.bearer_tokens[0] must not be empty"),
3780            "got: {err}"
3781        );
3782    }
3783
3784    #[test]
3785    fn auth_config_none_is_valid() {
3786        let toml_str = r#"
3787[provider]
3788name = "anthropic"
3789model = "claude-sonnet-4-20250514"
3790
3791[daemon.kafka]
3792brokers = "localhost:9092"
3793"#;
3794        let config = HeartbitConfig::from_toml(toml_str).unwrap();
3795        assert!(config.daemon.unwrap().auth.is_none());
3796    }
3797
3798    #[test]
3799    fn auth_config_jwks_only_is_valid() {
3800        let toml_str = r#"
3801[provider]
3802name = "anthropic"
3803model = "claude-sonnet-4-20250514"
3804
3805[daemon.kafka]
3806brokers = "localhost:9092"
3807
3808[daemon.auth]
3809jwks_url = "https://idp.example.com/.well-known/jwks.json"
3810issuer = "https://idp.example.com"
3811audience = "heartbit-api"
3812"#;
3813        let config = HeartbitConfig::from_toml(toml_str).unwrap();
3814        let auth = config.daemon.unwrap().auth.unwrap();
3815        assert!(auth.bearer_tokens.is_empty());
3816        assert_eq!(
3817            auth.jwks_url.as_deref(),
3818            Some("https://idp.example.com/.well-known/jwks.json")
3819        );
3820        assert_eq!(auth.issuer.as_deref(), Some("https://idp.example.com"));
3821        assert_eq!(auth.audience.as_deref(), Some("heartbit-api"));
3822    }
3823
3824    #[test]
3825    fn auth_config_empty_jwks_url_rejected() {
3826        let toml_str = r#"
3827[provider]
3828name = "anthropic"
3829model = "claude-sonnet-4-20250514"
3830
3831[daemon.kafka]
3832brokers = "localhost:9092"
3833
3834[daemon.auth]
3835bearer_tokens = ["valid-token"]
3836jwks_url = ""
3837"#;
3838        let err = HeartbitConfig::from_toml(toml_str).unwrap_err();
3839        assert!(
3840            err.to_string()
3841                .contains("daemon.auth.jwks_url must not be empty"),
3842            "got: {err}"
3843        );
3844    }
3845
3846    #[test]
3847    fn auth_config_no_tokens_no_jwks_rejected() {
3848        let toml_str = r#"
3849[provider]
3850name = "anthropic"
3851model = "claude-sonnet-4-20250514"
3852
3853[daemon.kafka]
3854brokers = "localhost:9092"
3855
3856[daemon.auth]
3857issuer = "https://idp.example.com"
3858"#;
3859        let err = HeartbitConfig::from_toml(toml_str).unwrap_err();
3860        assert!(
3861            err.to_string()
3862                .contains("daemon.auth requires at least bearer_tokens or jwks_url"),
3863            "got: {err}"
3864        );
3865    }
3866
3867    // --- Token exchange config ---
3868
3869    #[test]
3870    fn token_exchange_config_valid() {
3871        let toml_str = r#"
3872[provider]
3873name = "anthropic"
3874model = "claude-sonnet-4-20250514"
3875
3876[daemon.kafka]
3877brokers = "localhost:9092"
3878
3879[daemon.auth]
3880jwks_url = "https://idp.example.com/.well-known/jwks.json"
3881
3882[daemon.auth.token_exchange]
3883exchange_url = "https://idp.example.com/oauth/token"
3884client_id = "heartbit-agent"
3885client_secret = "secret123"
3886agent_token = "agent-cred-token"
3887scopes = ["crm:read", "crm:write"]
3888"#;
3889        let config = HeartbitConfig::from_toml(toml_str).unwrap();
3890        let te = config.daemon.unwrap().auth.unwrap().token_exchange.unwrap();
3891        assert_eq!(te.exchange_url, "https://idp.example.com/oauth/token");
3892        assert_eq!(te.client_id, "heartbit-agent");
3893        assert_eq!(te.client_secret, "secret123");
3894        assert_eq!(te.agent_token, "agent-cred-token");
3895        assert_eq!(te.scopes, vec!["crm:read", "crm:write"]);
3896    }
3897
3898    #[test]
3899    fn token_exchange_empty_exchange_url_rejected() {
3900        let toml_str = r#"
3901[provider]
3902name = "anthropic"
3903model = "claude-sonnet-4-20250514"
3904
3905[daemon.kafka]
3906brokers = "localhost:9092"
3907
3908[daemon.auth]
3909jwks_url = "https://idp.example.com/.well-known/jwks.json"
3910
3911[daemon.auth.token_exchange]
3912exchange_url = ""
3913client_id = "heartbit-agent"
3914client_secret = "secret123"
3915agent_token = "agent-cred-token"
3916"#;
3917        let err = HeartbitConfig::from_toml(toml_str).unwrap_err();
3918        assert!(
3919            err.to_string()
3920                .contains("daemon.auth.token_exchange.exchange_url must not be empty"),
3921            "got: {err}"
3922        );
3923    }
3924
3925    #[test]
3926    fn token_exchange_empty_client_id_rejected() {
3927        let toml_str = r#"
3928[provider]
3929name = "anthropic"
3930model = "claude-sonnet-4-20250514"
3931
3932[daemon.kafka]
3933brokers = "localhost:9092"
3934
3935[daemon.auth]
3936jwks_url = "https://idp.example.com/.well-known/jwks.json"
3937
3938[daemon.auth.token_exchange]
3939exchange_url = "https://idp.example.com/oauth/token"
3940client_id = ""
3941client_secret = "secret123"
3942agent_token = "agent-cred-token"
3943"#;
3944        let err = HeartbitConfig::from_toml(toml_str).unwrap_err();
3945        assert!(
3946            err.to_string()
3947                .contains("daemon.auth.token_exchange.client_id must not be empty"),
3948            "got: {err}"
3949        );
3950    }
3951
3952    #[test]
3953    fn token_exchange_empty_agent_token_rejected() {
3954        let toml_str = r#"
3955[provider]
3956name = "anthropic"
3957model = "claude-sonnet-4-20250514"
3958
3959[daemon.kafka]
3960brokers = "localhost:9092"
3961
3962[daemon.auth]
3963jwks_url = "https://idp.example.com/.well-known/jwks.json"
3964
3965[daemon.auth.token_exchange]
3966exchange_url = "https://idp.example.com/oauth/token"
3967client_id = "heartbit-agent"
3968client_secret = "secret123"
3969agent_token = ""
3970"#;
3971        let err = HeartbitConfig::from_toml(toml_str).unwrap_err();
3972        assert!(
3973            err.to_string()
3974                .contains("daemon.auth.token_exchange: set tenant_id for auto-fetch"),
3975            "got: {err}"
3976        );
3977    }
3978
3979    #[test]
3980    fn token_exchange_none_is_valid() {
3981        let toml_str = r#"
3982[provider]
3983name = "anthropic"
3984model = "claude-sonnet-4-20250514"
3985
3986[daemon.kafka]
3987brokers = "localhost:9092"
3988
3989[daemon.auth]
3990jwks_url = "https://idp.example.com/.well-known/jwks.json"
3991"#;
3992        let config = HeartbitConfig::from_toml(toml_str).unwrap();
3993        assert!(
3994            config
3995                .daemon
3996                .unwrap()
3997                .auth
3998                .unwrap()
3999                .token_exchange
4000                .is_none()
4001        );
4002    }
4003
4004    // --- Cascade config ---
4005
4006    #[test]
4007    fn cascade_config_parses_full() {
4008        let toml_str = r#"
4009[provider]
4010name = "openrouter"
4011model = "anthropic/claude-sonnet-4"
4012
4013[provider.cascade]
4014enabled = true
4015
4016[[provider.cascade.tiers]]
4017model = "anthropic/claude-3.5-haiku"
4018
4019[provider.cascade.gate]
4020type = "heuristic"
4021min_output_tokens = 10
4022accept_tool_calls = false
4023escalate_on_max_tokens = false
4024"#;
4025        let config = HeartbitConfig::from_toml(toml_str).unwrap();
4026        let cascade = config.provider.cascade.unwrap();
4027        assert!(cascade.enabled);
4028        assert_eq!(cascade.tiers.len(), 1);
4029        assert_eq!(cascade.tiers[0].model, "anthropic/claude-3.5-haiku");
4030        match &cascade.gate {
4031            CascadeGateConfig::Heuristic {
4032                min_output_tokens,
4033                accept_tool_calls,
4034                escalate_on_max_tokens,
4035            } => {
4036                assert_eq!(*min_output_tokens, 10);
4037                assert!(!accept_tool_calls);
4038                assert!(!escalate_on_max_tokens);
4039            }
4040        }
4041    }
4042
4043    #[test]
4044    fn cascade_config_defaults_when_absent() {
4045        let toml_str = r#"
4046[provider]
4047name = "anthropic"
4048model = "claude-sonnet-4-20250514"
4049"#;
4050        let config = HeartbitConfig::from_toml(toml_str).unwrap();
4051        assert!(config.provider.cascade.is_none());
4052    }
4053
4054    #[test]
4055    fn cascade_config_gate_defaults() {
4056        let toml_str = r#"
4057[provider]
4058name = "anthropic"
4059model = "claude-sonnet-4-20250514"
4060
4061[provider.cascade]
4062enabled = true
4063
4064[[provider.cascade.tiers]]
4065model = "claude-3.5-haiku"
4066"#;
4067        let config = HeartbitConfig::from_toml(toml_str).unwrap();
4068        let cascade = config.provider.cascade.unwrap();
4069        match &cascade.gate {
4070            CascadeGateConfig::Heuristic {
4071                min_output_tokens,
4072                accept_tool_calls,
4073                escalate_on_max_tokens,
4074            } => {
4075                assert_eq!(*min_output_tokens, 5);
4076                assert!(accept_tool_calls);
4077                assert!(escalate_on_max_tokens);
4078            }
4079        }
4080    }
4081
4082    #[test]
4083    fn validate_rejects_cascade_enabled_without_tiers() {
4084        let toml_str = r#"
4085[provider]
4086name = "anthropic"
4087model = "claude-sonnet-4-20250514"
4088
4089[provider.cascade]
4090enabled = true
4091"#;
4092        let err = HeartbitConfig::from_toml(toml_str).unwrap_err();
4093        assert!(
4094            err.to_string().contains("no tiers are configured"),
4095            "error: {err}"
4096        );
4097    }
4098
4099    #[test]
4100    fn cascade_disabled_with_tiers_is_valid() {
4101        let toml_str = r#"
4102[provider]
4103name = "anthropic"
4104model = "claude-sonnet-4-20250514"
4105
4106[provider.cascade]
4107enabled = false
4108
4109[[provider.cascade.tiers]]
4110model = "claude-3.5-haiku"
4111"#;
4112        // enabled=false means the tiers are ignored; should not error
4113        let config = HeartbitConfig::from_toml(toml_str).unwrap();
4114        let cascade = config.provider.cascade.unwrap();
4115        assert!(!cascade.enabled);
4116    }
4117
4118    #[test]
4119    fn agent_provider_cascade_config_parses() {
4120        let toml_str = r#"
4121[provider]
4122name = "anthropic"
4123model = "claude-sonnet-4-20250514"
4124
4125[[agents]]
4126name = "researcher"
4127description = "Research agent"
4128system_prompt = "You are a researcher."
4129
4130[agents.provider]
4131name = "openrouter"
4132model = "anthropic/claude-sonnet-4"
4133
4134[agents.provider.cascade]
4135enabled = true
4136
4137[[agents.provider.cascade.tiers]]
4138model = "anthropic/claude-3.5-haiku"
4139"#;
4140        let config = HeartbitConfig::from_toml(toml_str).unwrap();
4141        let agent_cascade = config.agents[0]
4142            .provider
4143            .as_ref()
4144            .unwrap()
4145            .cascade
4146            .as_ref()
4147            .unwrap();
4148        assert!(agent_cascade.enabled);
4149        assert_eq!(agent_cascade.tiers.len(), 1);
4150    }
4151
4152    #[test]
4153    fn validate_rejects_empty_provider_name() {
4154        let toml_str = r#"
4155[provider]
4156name = ""
4157model = "claude-sonnet-4-20250514"
4158"#;
4159        let err = HeartbitConfig::from_toml(toml_str).unwrap_err();
4160        assert!(
4161            err.to_string().contains("provider.name must not be empty"),
4162            "error: {err}"
4163        );
4164    }
4165
4166    #[test]
4167    fn validate_rejects_empty_provider_model() {
4168        let toml_str = r#"
4169[provider]
4170name = "anthropic"
4171model = ""
4172"#;
4173        let err = HeartbitConfig::from_toml(toml_str).unwrap_err();
4174        assert!(
4175            err.to_string().contains("provider.model must not be empty"),
4176            "error: {err}"
4177        );
4178    }
4179
4180    #[test]
4181    fn validate_rejects_empty_cascade_tier_model() {
4182        let toml_str = r#"
4183[provider]
4184name = "anthropic"
4185model = "claude-sonnet-4-20250514"
4186
4187[provider.cascade]
4188enabled = true
4189
4190[[provider.cascade.tiers]]
4191model = ""
4192"#;
4193        let err = HeartbitConfig::from_toml(toml_str).unwrap_err();
4194        assert!(
4195            err.to_string()
4196                .contains("provider.cascade.tiers[0].model must not be empty"),
4197            "error: {err}"
4198        );
4199    }
4200
4201    // MCP sensor config tests removed — sensors field moved to gateway crate.
4202    // sensor_source_mcp_serde, sensor_source_mcp_defaults, sensor_source_mcp_with_enrichment,
4203    // sensor_source_mcp_with_auth_header, sensor_source_mcp_simple_server,
4204    // sensor_source_mcp_empty_tool_name_rejected, sensor_source_mcp_empty_kafka_topic_rejected,
4205    // sensor_source_mcp_stdio_server, sensor_source_mcp_stdio_empty_command_rejected,
4206    // sensor_source_mcp_stdio_defaults
4207
4208    #[test]
4209    fn mcp_server_entry_stdio_roundtrip() {
4210        let stdio = McpServerEntry::Stdio {
4211            command: "npx".into(),
4212            args: vec!["-y".into(), "my-mcp-server".into()],
4213            env: std::collections::HashMap::from([("KEY".into(), "val".into())]),
4214        };
4215        let json = serde_json::to_string(&stdio).unwrap();
4216        let parsed: McpServerEntry = serde_json::from_str(&json).unwrap();
4217        assert_eq!(stdio, parsed);
4218    }
4219
4220    #[test]
4221    fn mcp_server_entry_display_name() {
4222        let simple = McpServerEntry::Simple("http://localhost/mcp".into());
4223        assert_eq!(simple.display_name(), "http://localhost/mcp");
4224
4225        let full = McpServerEntry::Full {
4226            url: "http://gateway/mcp".into(),
4227            auth_header: Some("Bearer tok".into()),
4228            resource: None,
4229            scopes: None,
4230        };
4231        assert_eq!(full.display_name(), "http://gateway/mcp");
4232
4233        let stdio = McpServerEntry::Stdio {
4234            command: "npx".into(),
4235            args: vec!["-y".into(), "server".into()],
4236            env: Default::default(),
4237        };
4238        assert_eq!(stdio.display_name(), "npx -y server");
4239
4240        let stdio_no_args = McpServerEntry::Stdio {
4241            command: "my-server".into(),
4242            args: vec![],
4243            env: Default::default(),
4244        };
4245        assert_eq!(stdio_no_args.display_name(), "my-server");
4246    }
4247
4248    // --- Daemon validation tests ---
4249
4250    #[test]
4251    fn validate_daemon_empty_consumer_group() {
4252        let toml = r#"
4253[provider]
4254name = "anthropic"
4255model = "claude-sonnet-4-20250514"
4256
4257[daemon.kafka]
4258brokers = "localhost:9092"
4259consumer_group = ""
4260"#;
4261        let err = HeartbitConfig::from_toml(toml).unwrap_err();
4262        assert!(
4263            err.to_string().contains("consumer_group must not be empty"),
4264            "got: {err}"
4265        );
4266    }
4267
4268    #[test]
4269    fn validate_daemon_empty_commands_topic() {
4270        let toml = r#"
4271[provider]
4272name = "anthropic"
4273model = "claude-sonnet-4-20250514"
4274
4275[daemon.kafka]
4276brokers = "localhost:9092"
4277commands_topic = ""
4278"#;
4279        let err = HeartbitConfig::from_toml(toml).unwrap_err();
4280        assert!(
4281            err.to_string().contains("commands_topic must not be empty"),
4282            "got: {err}"
4283        );
4284    }
4285
4286    #[test]
4287    fn validate_daemon_empty_events_topic() {
4288        let toml = r#"
4289[provider]
4290name = "anthropic"
4291model = "claude-sonnet-4-20250514"
4292
4293[daemon.kafka]
4294brokers = "localhost:9092"
4295events_topic = ""
4296"#;
4297        let err = HeartbitConfig::from_toml(toml).unwrap_err();
4298        assert!(
4299            err.to_string().contains("events_topic must not be empty"),
4300            "got: {err}"
4301        );
4302    }
4303
4304    // --- SensorModality tests (always available, not gated on `sensor` feature) ---
4305
4306    #[test]
4307    fn sensor_modality_serde_roundtrip() {
4308        for modality in [
4309            SensorModality::Text,
4310            SensorModality::Image,
4311            SensorModality::Audio,
4312            SensorModality::Structured,
4313        ] {
4314            let json = serde_json::to_string(&modality).unwrap();
4315            let back: SensorModality = serde_json::from_str(&json).unwrap();
4316            assert_eq!(back, modality);
4317        }
4318    }
4319
4320    #[test]
4321    fn sensor_modality_snake_case() {
4322        assert_eq!(
4323            serde_json::to_string(&SensorModality::Text).unwrap(),
4324            r#""text""#
4325        );
4326        assert_eq!(
4327            serde_json::to_string(&SensorModality::Image).unwrap(),
4328            r#""image""#
4329        );
4330        assert_eq!(
4331            serde_json::to_string(&SensorModality::Audio).unwrap(),
4332            r#""audio""#
4333        );
4334        assert_eq!(
4335            serde_json::to_string(&SensorModality::Structured).unwrap(),
4336            r#""structured""#
4337        );
4338    }
4339
4340    #[test]
4341    fn sensor_modality_display() {
4342        assert_eq!(SensorModality::Text.to_string(), "text");
4343        assert_eq!(SensorModality::Image.to_string(), "image");
4344        assert_eq!(SensorModality::Audio.to_string(), "audio");
4345        assert_eq!(SensorModality::Structured.to_string(), "structured");
4346    }
4347
4348    // --- TrustLevel tests (always available, not gated on `sensor` feature) ---
4349
4350    #[test]
4351    fn trust_level_default_is_unknown() {
4352        assert_eq!(TrustLevel::default(), TrustLevel::Unknown);
4353    }
4354
4355    #[test]
4356    fn trust_level_ordering() {
4357        assert!(TrustLevel::Quarantined < TrustLevel::Unknown);
4358        assert!(TrustLevel::Unknown < TrustLevel::Known);
4359        assert!(TrustLevel::Known < TrustLevel::Verified);
4360        assert!(TrustLevel::Verified < TrustLevel::Owner);
4361    }
4362
4363    #[test]
4364    fn trust_level_serde_roundtrip() {
4365        for t in [
4366            TrustLevel::Quarantined,
4367            TrustLevel::Unknown,
4368            TrustLevel::Known,
4369            TrustLevel::Verified,
4370            TrustLevel::Owner,
4371        ] {
4372            let json = serde_json::to_string(&t).unwrap();
4373            let parsed: TrustLevel = serde_json::from_str(&json).unwrap();
4374            assert_eq!(parsed, t);
4375        }
4376    }
4377
4378    #[test]
4379    fn trust_level_display() {
4380        assert_eq!(TrustLevel::Quarantined.to_string(), "quarantined");
4381        assert_eq!(TrustLevel::Unknown.to_string(), "unknown");
4382        assert_eq!(TrustLevel::Known.to_string(), "known");
4383        assert_eq!(TrustLevel::Verified.to_string(), "verified");
4384        assert_eq!(TrustLevel::Owner.to_string(), "owner");
4385    }
4386
4387    #[test]
4388    fn trust_level_resolve_owner() {
4389        let trust = TrustLevel::resolve(
4390            Some("owner@example.com"),
4391            &["owner@example.com".into()],
4392            &[],
4393            &[],
4394        );
4395        assert_eq!(trust, TrustLevel::Owner);
4396    }
4397
4398    #[test]
4399    fn trust_level_resolve_verified() {
4400        let trust = TrustLevel::resolve(
4401            Some("alice@example.com"),
4402            &[],
4403            &["alice@example.com".into()],
4404            &[],
4405        );
4406        assert_eq!(trust, TrustLevel::Verified);
4407    }
4408
4409    #[test]
4410    fn trust_level_resolve_blocked() {
4411        let trust = TrustLevel::resolve(
4412            Some("spammer@evil.com"),
4413            &[],
4414            &[],
4415            &["spammer@evil.com".into()],
4416        );
4417        assert_eq!(trust, TrustLevel::Quarantined);
4418    }
4419
4420    #[test]
4421    fn trust_level_resolve_unknown() {
4422        let trust = TrustLevel::resolve(Some("stranger@example.com"), &[], &[], &[]);
4423        assert_eq!(trust, TrustLevel::Unknown);
4424    }
4425
4426    #[test]
4427    fn trust_level_resolve_none_sender() {
4428        let trust = TrustLevel::resolve(None, &[], &[], &[]);
4429        assert_eq!(trust, TrustLevel::Unknown);
4430    }
4431
4432    #[test]
4433    fn trust_level_owner_trumps_blocked() {
4434        let trust = TrustLevel::resolve(
4435            Some("owner@example.com"),
4436            &["owner@example.com".into()],
4437            &[],
4438            &["owner@example.com".into()],
4439        );
4440        assert_eq!(trust, TrustLevel::Owner);
4441    }
4442
4443    #[test]
4444    fn trust_level_resolve_case_insensitive() {
4445        let trust = TrustLevel::resolve(
4446            Some("Owner@Example.COM"),
4447            &["owner@example.com".into()],
4448            &[],
4449            &[],
4450        );
4451        assert_eq!(trust, TrustLevel::Owner);
4452    }
4453
4454    // --- Guardrails config tests ---
4455
4456    #[test]
4457    fn guardrails_config_default_empty() {
4458        let config: GuardrailsConfig = toml::from_str("").unwrap();
4459        assert!(config.injection.is_none());
4460        assert!(config.pii.is_none());
4461        assert!(config.tool_policy.is_none());
4462    }
4463
4464    #[test]
4465    fn guardrails_config_roundtrip() {
4466        let toml_str = r#"
4467[injection]
4468threshold = 0.3
4469mode = "warn"
4470
4471[pii]
4472action = "redact"
4473detectors = ["email", "ssn"]
4474
4475[tool_policy]
4476default_action = "allow"
4477
4478[[tool_policy.rules]]
4479tool = "bash"
4480action = "deny"
4481input_constraints = []
4482
4483[[tool_policy.rules]]
4484tool = "gmail_send_*"
4485action = "warn"
4486input_constraints = []
4487"#;
4488        let config: GuardrailsConfig = toml::from_str(toml_str).unwrap();
4489
4490        // Injection
4491        let inj = config.injection.as_ref().unwrap();
4492        assert!((inj.threshold - 0.3).abs() < 0.01);
4493        assert_eq!(inj.mode, "warn");
4494
4495        // PII
4496        let pii = config.pii.as_ref().unwrap();
4497        assert_eq!(pii.action, "redact");
4498        assert_eq!(pii.detectors, vec!["email", "ssn"]);
4499
4500        // Tool policy
4501        let tp = config.tool_policy.as_ref().unwrap();
4502        assert_eq!(tp.default_action, "allow");
4503        assert_eq!(tp.rules.len(), 2);
4504        assert_eq!(tp.rules[0].tool, "bash");
4505        assert_eq!(tp.rules[0].action, "deny");
4506        assert_eq!(tp.rules[1].tool, "gmail_send_*");
4507        assert_eq!(tp.rules[1].action, "warn");
4508
4509        // Verify serialization roundtrip
4510        let serialized = toml::to_string(&config).unwrap();
4511        let _back: GuardrailsConfig = toml::from_str(&serialized).unwrap();
4512    }
4513
4514    #[test]
4515    fn guardrails_config_with_input_constraints() {
4516        let toml_str = r#"
4517[tool_policy]
4518default_action = "deny"
4519
4520[[tool_policy.rules]]
4521tool = "read"
4522action = "allow"
4523
4524[[tool_policy.rules.input_constraints]]
4525path = "path"
4526deny_pattern = "^/etc/"
4527"#;
4528        let config: GuardrailsConfig = toml::from_str(toml_str).unwrap();
4529        let tp = config.tool_policy.unwrap();
4530        assert_eq!(tp.default_action, "deny");
4531        assert_eq!(tp.rules.len(), 1);
4532        assert_eq!(tp.rules[0].input_constraints.len(), 1);
4533        assert_eq!(tp.rules[0].input_constraints[0].path, "path");
4534        assert_eq!(
4535            tp.rules[0].input_constraints[0].deny_pattern.as_deref(),
4536            Some("^/etc/")
4537        );
4538    }
4539
4540    #[test]
4541    fn heartbit_config_with_guardrails() {
4542        let toml_str = r#"
4543[provider]
4544name = "anthropic"
4545model = "claude-sonnet-4-20250514"
4546
4547[guardrails.injection]
4548threshold = 0.5
4549mode = "deny"
4550
4551[guardrails.pii]
4552action = "redact"
4553"#;
4554        let config: HeartbitConfig = toml::from_str(toml_str).unwrap();
4555        let guardrails = config.guardrails.unwrap();
4556        assert!(guardrails.injection.is_some());
4557        assert!(guardrails.pii.is_some());
4558        assert!(guardrails.tool_policy.is_none());
4559    }
4560
4561    #[test]
4562    fn guardrails_config_build_empty() {
4563        let config = GuardrailsConfig::default();
4564        assert!(config.is_empty());
4565        let guardrails = config.build().unwrap();
4566        assert!(guardrails.is_empty());
4567    }
4568
4569    #[test]
4570    fn guardrails_config_build_injection() {
4571        let config = GuardrailsConfig {
4572            injection: Some(InjectionConfig {
4573                threshold: 0.3,
4574                mode: "warn".into(),
4575            }),
4576            ..Default::default()
4577        };
4578        let guardrails = config.build().unwrap();
4579        assert_eq!(guardrails.len(), 1);
4580    }
4581
4582    #[test]
4583    fn guardrails_config_build_pii() {
4584        let config = GuardrailsConfig {
4585            pii: Some(PiiConfig {
4586                action: "redact".into(),
4587                detectors: vec!["email".into(), "phone".into()],
4588            }),
4589            ..Default::default()
4590        };
4591        let guardrails = config.build().unwrap();
4592        assert_eq!(guardrails.len(), 1);
4593    }
4594
4595    #[test]
4596    fn guardrails_config_build_tool_policy() {
4597        let config = GuardrailsConfig {
4598            tool_policy: Some(ToolPolicyConfig {
4599                default_action: "allow".into(),
4600                rules: vec![ToolPolicyRuleConfig {
4601                    tool: "bash".into(),
4602                    action: "deny".into(),
4603                    input_constraints: vec![],
4604                }],
4605            }),
4606            ..Default::default()
4607        };
4608        let guardrails = config.build().unwrap();
4609        assert_eq!(guardrails.len(), 1);
4610    }
4611
4612    #[test]
4613    fn guardrails_config_build_all_three() {
4614        let config = GuardrailsConfig {
4615            injection: Some(InjectionConfig {
4616                threshold: 0.5,
4617                mode: "deny".into(),
4618            }),
4619            pii: Some(PiiConfig {
4620                action: "warn".into(),
4621                detectors: default_pii_detectors(),
4622            }),
4623            tool_policy: Some(ToolPolicyConfig {
4624                default_action: "allow".into(),
4625                rules: vec![],
4626            }),
4627            llm_judge: None,
4628            secret_scan: None,
4629            behavioral: None,
4630            action_budget: None,
4631        };
4632        assert!(!config.is_empty());
4633        let guardrails = config.build().unwrap();
4634        assert_eq!(guardrails.len(), 3);
4635    }
4636
4637    #[test]
4638    fn guardrails_config_build_invalid_mode_errors() {
4639        let config = GuardrailsConfig {
4640            injection: Some(InjectionConfig {
4641                threshold: 0.5,
4642                mode: "invalid".into(),
4643            }),
4644            ..Default::default()
4645        };
4646        let err = config.build().err().expect("should fail");
4647        assert!(
4648            err.to_string().contains("invalid injection mode"),
4649            "error: {err}"
4650        );
4651    }
4652
4653    #[test]
4654    fn guardrails_config_build_invalid_pii_action_errors() {
4655        let config = GuardrailsConfig {
4656            pii: Some(PiiConfig {
4657                action: "destroy".into(),
4658                detectors: vec!["email".into()],
4659            }),
4660            ..Default::default()
4661        };
4662        let err = config.build().err().expect("should fail");
4663        assert!(
4664            err.to_string().contains("invalid PII action"),
4665            "error: {err}"
4666        );
4667    }
4668
4669    #[test]
4670    fn guardrails_config_build_invalid_detector_errors() {
4671        let config = GuardrailsConfig {
4672            pii: Some(PiiConfig {
4673                action: "redact".into(),
4674                detectors: vec!["dna_sequence".into()],
4675            }),
4676            ..Default::default()
4677        };
4678        let err = config.build().err().expect("should fail");
4679        assert!(
4680            err.to_string().contains("unknown PII detector"),
4681            "error: {err}"
4682        );
4683    }
4684
4685    #[test]
4686    fn guardrails_config_build_invalid_regex_errors() {
4687        let config = GuardrailsConfig {
4688            tool_policy: Some(ToolPolicyConfig {
4689                default_action: "allow".into(),
4690                rules: vec![ToolPolicyRuleConfig {
4691                    tool: "bash".into(),
4692                    action: "allow".into(),
4693                    input_constraints: vec![InputConstraintConfig {
4694                        path: "command".into(),
4695                        deny_pattern: Some("[invalid".into()),
4696                        max_length: None,
4697                    }],
4698                }],
4699            }),
4700            ..Default::default()
4701        };
4702        let err = config.build().err().expect("should fail");
4703        assert!(
4704            err.to_string().contains("invalid deny_pattern"),
4705            "error: {err}"
4706        );
4707    }
4708
4709    #[test]
4710    fn guardrails_config_build_with_input_constraints() {
4711        let config = GuardrailsConfig {
4712            tool_policy: Some(ToolPolicyConfig {
4713                default_action: "deny".into(),
4714                rules: vec![ToolPolicyRuleConfig {
4715                    tool: "bash".into(),
4716                    action: "allow".into(),
4717                    input_constraints: vec![
4718                        InputConstraintConfig {
4719                            path: "command".into(),
4720                            deny_pattern: Some(r"rm\s+-rf".into()),
4721                            max_length: None,
4722                        },
4723                        InputConstraintConfig {
4724                            path: "command".into(),
4725                            deny_pattern: None,
4726                            max_length: Some(1024),
4727                        },
4728                    ],
4729                }],
4730            }),
4731            ..Default::default()
4732        };
4733        let guardrails = config.build().unwrap();
4734        assert_eq!(guardrails.len(), 1);
4735    }
4736
4737    #[test]
4738    fn guardrails_config_build_from_toml() {
4739        let toml_str = r#"
4740[injection]
4741threshold = 0.4
4742mode = "warn"
4743
4744[pii]
4745action = "deny"
4746detectors = ["email", "ssn"]
4747
4748[tool_policy]
4749default_action = "allow"
4750
4751[[tool_policy.rules]]
4752tool = "bash"
4753action = "deny"
4754
4755[[tool_policy.rules]]
4756tool = "gmail_send_*"
4757action = "warn"
4758"#;
4759        let config: GuardrailsConfig = toml::from_str(toml_str).unwrap();
4760        let guardrails = config.build().unwrap();
4761        assert_eq!(guardrails.len(), 3);
4762    }
4763
4764    #[test]
4765    fn guardrails_config_llm_judge_from_toml() {
4766        let toml_str = r#"
4767[llm_judge]
4768criteria = ["no harmful content", "no personal attacks"]
4769evaluate_tool_inputs = true
4770timeout_seconds = 15
4771max_judge_tokens = 512
4772"#;
4773        let config: GuardrailsConfig = toml::from_str(toml_str).unwrap();
4774        let judge_cfg = config.llm_judge.as_ref().expect("llm_judge should be set");
4775        assert_eq!(judge_cfg.criteria.len(), 2);
4776        assert!(judge_cfg.evaluate_tool_inputs);
4777        assert_eq!(judge_cfg.timeout_seconds, 15);
4778        assert_eq!(judge_cfg.max_judge_tokens, 512);
4779    }
4780
4781    #[test]
4782    fn guardrails_config_llm_judge_defaults() {
4783        let toml_str = r#"
4784[llm_judge]
4785criteria = ["safety"]
4786"#;
4787        let config: GuardrailsConfig = toml::from_str(toml_str).unwrap();
4788        let judge_cfg = config.llm_judge.as_ref().expect("llm_judge should be set");
4789        assert!(!judge_cfg.evaluate_tool_inputs);
4790        assert_eq!(judge_cfg.timeout_seconds, 10);
4791        assert_eq!(judge_cfg.max_judge_tokens, 256);
4792    }
4793
4794    #[test]
4795    fn guardrails_config_build_skips_judge_without_provider() {
4796        let config = GuardrailsConfig {
4797            llm_judge: Some(LlmJudgeConfig {
4798                criteria: vec!["safety".into()],
4799                evaluate_tool_inputs: false,
4800                timeout_seconds: 10,
4801                max_judge_tokens: 256,
4802            }),
4803            ..Default::default()
4804        };
4805        // build() delegates to build_with_judge(None) — skips LLM judge
4806        let guardrails = config.build().unwrap();
4807        assert_eq!(guardrails.len(), 0);
4808    }
4809
4810    #[test]
4811    fn guardrails_config_is_empty_with_only_llm_judge() {
4812        let config = GuardrailsConfig {
4813            llm_judge: Some(LlmJudgeConfig {
4814                criteria: vec!["safety".into()],
4815                evaluate_tool_inputs: false,
4816                timeout_seconds: 10,
4817                max_judge_tokens: 256,
4818            }),
4819            ..Default::default()
4820        };
4821        assert!(!config.is_empty());
4822    }
4823
4824    #[test]
4825    fn parse_local_embedding_config() {
4826        let toml = r#"
4827[provider]
4828name = "anthropic"
4829model = "claude-sonnet-4-20250514"
4830
4831[[agents]]
4832name = "test"
4833description = "Test agent"
4834system_prompt = "You are a test agent."
4835
4836[memory]
4837type = "postgres"
4838database_url = "postgresql://localhost/heartbit"
4839
4840[memory.embedding]
4841provider = "local"
4842model = "all-MiniLM-L6-v2"
4843cache_dir = "/tmp/fastembed"
4844"#;
4845
4846        let config = HeartbitConfig::from_toml(toml).unwrap();
4847        let memory = config.memory.expect("memory should be present");
4848        match memory {
4849            MemoryConfig::Postgres { embedding, .. } => {
4850                let emb = embedding.expect("embedding config should be present");
4851                assert_eq!(emb.provider, "local");
4852                assert_eq!(emb.model, "all-MiniLM-L6-v2");
4853                assert_eq!(emb.cache_dir.as_deref(), Some("/tmp/fastembed"));
4854            }
4855            _ => panic!("expected Postgres memory config"),
4856        }
4857    }
4858
4859    #[test]
4860    fn parse_local_embedding_config_defaults() {
4861        // provider = "local" without model or cache_dir — should use defaults
4862        let toml = r#"
4863[provider]
4864name = "anthropic"
4865model = "claude-sonnet-4-20250514"
4866
4867[[agents]]
4868name = "test"
4869description = "Test agent"
4870system_prompt = "You are a test agent."
4871
4872[memory]
4873type = "postgres"
4874database_url = "postgresql://localhost/heartbit"
4875
4876[memory.embedding]
4877provider = "local"
4878"#;
4879
4880        let config = HeartbitConfig::from_toml(toml).unwrap();
4881        let memory = config.memory.expect("memory should be present");
4882        match memory {
4883            MemoryConfig::Postgres { embedding, .. } => {
4884                let emb = embedding.expect("embedding config should be present");
4885                assert_eq!(emb.provider, "local");
4886                // model defaults to "text-embedding-3-small" (OpenAI default)
4887                // CLI handles this by treating it as "unset" → uses AllMiniLML6V2
4888                assert_eq!(emb.model, "text-embedding-3-small");
4889                assert!(emb.cache_dir.is_none());
4890                assert!(emb.base_url.is_none());
4891                assert!(emb.dimension.is_none());
4892            }
4893            _ => panic!("expected Postgres memory config"),
4894        }
4895    }
4896
4897    #[test]
4898    fn auth_config_backward_compat() {
4899        let toml_str = r#"
4900            bearer_tokens = ["tok-abc", "tok-xyz"]
4901        "#;
4902        let auth: AuthConfig = toml::from_str(toml_str).unwrap();
4903        assert_eq!(auth.bearer_tokens, vec!["tok-abc", "tok-xyz"]);
4904        assert!(auth.jwks_url.is_none());
4905        assert!(auth.issuer.is_none());
4906        assert!(auth.audience.is_none());
4907        assert!(auth.user_id_claim.is_none());
4908        assert!(auth.tenant_id_claim.is_none());
4909        assert!(auth.roles_claim.is_none());
4910        assert!(auth.token_exchange.is_none());
4911    }
4912
4913    #[test]
4914    fn auth_config_with_jwks() {
4915        let toml_str = r#"
4916            bearer_tokens = ["tok-1"]
4917            jwks_url = "https://idp.example.com/.well-known/jwks.json"
4918            issuer = "https://idp.example.com"
4919            audience = "heartbit-api"
4920            user_id_claim = "sub"
4921            tenant_id_claim = "org_id"
4922            roles_claim = "permissions"
4923        "#;
4924        let auth: AuthConfig = toml::from_str(toml_str).unwrap();
4925        assert_eq!(auth.bearer_tokens, vec!["tok-1"]);
4926        assert_eq!(
4927            auth.jwks_url.as_deref(),
4928            Some("https://idp.example.com/.well-known/jwks.json")
4929        );
4930        assert_eq!(auth.issuer.as_deref(), Some("https://idp.example.com"));
4931        assert_eq!(auth.audience.as_deref(), Some("heartbit-api"));
4932        assert_eq!(auth.user_id_claim.as_deref(), Some("sub"));
4933        assert_eq!(auth.tenant_id_claim.as_deref(), Some("org_id"));
4934        assert_eq!(auth.roles_claim.as_deref(), Some("permissions"));
4935    }
4936
4937    #[test]
4938    fn auth_config_empty_is_valid() {
4939        let toml_str = "";
4940        let auth: AuthConfig = toml::from_str(toml_str).unwrap();
4941        assert!(auth.bearer_tokens.is_empty());
4942        assert!(auth.jwks_url.is_none());
4943        assert!(auth.issuer.is_none());
4944        assert!(auth.audience.is_none());
4945        assert!(auth.user_id_claim.is_none());
4946        assert!(auth.tenant_id_claim.is_none());
4947        assert!(auth.roles_claim.is_none());
4948    }
4949
4950    #[test]
4951    fn auth_config_mixed() {
4952        let toml_str = r#"
4953            bearer_tokens = ["static-key"]
4954            jwks_url = "https://auth.corp.io/.well-known/jwks.json"
4955            audience = "heartbit"
4956        "#;
4957        let auth: AuthConfig = toml::from_str(toml_str).unwrap();
4958        assert_eq!(auth.bearer_tokens, vec!["static-key"]);
4959        assert_eq!(
4960            auth.jwks_url.as_deref(),
4961            Some("https://auth.corp.io/.well-known/jwks.json")
4962        );
4963        assert!(auth.issuer.is_none());
4964        assert_eq!(auth.audience.as_deref(), Some("heartbit"));
4965        assert!(auth.user_id_claim.is_none());
4966        assert!(auth.tenant_id_claim.is_none());
4967        assert!(auth.roles_claim.is_none());
4968    }
4969
4970    #[test]
4971    fn mcp_resource_mode_default_is_tools() {
4972        let mode: McpResourceMode = Default::default();
4973        assert_eq!(mode, McpResourceMode::Tools);
4974    }
4975
4976    #[test]
4977    fn mcp_resource_mode_deserialize() {
4978        #[derive(Deserialize)]
4979        struct Wrapper {
4980            mode: McpResourceMode,
4981        }
4982        let w: Wrapper = toml::from_str(r#"mode = "tools""#).unwrap();
4983        assert_eq!(w.mode, McpResourceMode::Tools);
4984        let w: Wrapper = toml::from_str(r#"mode = "context""#).unwrap();
4985        assert_eq!(w.mode, McpResourceMode::Context);
4986        let w: Wrapper = toml::from_str(r#"mode = "none""#).unwrap();
4987        assert_eq!(w.mode, McpResourceMode::None);
4988    }
4989
4990    #[test]
4991    fn agent_config_mcp_resources_default() {
4992        let toml_str = r#"
4993[provider]
4994name = "anthropic"
4995model = "claude-sonnet-4-20250514"
4996
4997[orchestrator]
4998max_turns = 10
4999
5000[[agents]]
5001name = "test"
5002description = "A test agent"
5003system_prompt = "You are a test."
5004"#;
5005        let config: HeartbitConfig = toml::from_str(toml_str).unwrap();
5006        assert_eq!(config.agents[0].mcp_resources, McpResourceMode::Tools);
5007    }
5008
5009    #[test]
5010    fn agent_config_mcp_resources_explicit() {
5011        let toml_str = r#"
5012[provider]
5013name = "anthropic"
5014model = "claude-sonnet-4-20250514"
5015
5016[orchestrator]
5017max_turns = 10
5018
5019[[agents]]
5020name = "test"
5021description = "A test agent"
5022system_prompt = "You are a test."
5023mcp_resources = "none"
5024"#;
5025        let config: HeartbitConfig = toml::from_str(toml_str).unwrap();
5026        assert_eq!(config.agents[0].mcp_resources, McpResourceMode::None);
5027    }
5028
5029    // daemon_mcp_server tests removed — mcp_server field moved to gateway crate.
5030
5031    #[test]
5032    fn parse_workflow_type_valid() {
5033        use crate::agent::workflow::WorkflowType;
5034        assert_eq!(parse_workflow_type("dag").unwrap(), WorkflowType::Dag);
5035        assert_eq!(parse_workflow_type("DAG").unwrap(), WorkflowType::Dag);
5036        assert_eq!(
5037            parse_workflow_type("sequential").unwrap(),
5038            WorkflowType::Sequential
5039        );
5040        assert_eq!(
5041            parse_workflow_type("parallel").unwrap(),
5042            WorkflowType::Parallel
5043        );
5044        assert_eq!(parse_workflow_type("loop").unwrap(), WorkflowType::Loop);
5045        assert_eq!(parse_workflow_type("debate").unwrap(), WorkflowType::Debate);
5046        assert_eq!(parse_workflow_type("voting").unwrap(), WorkflowType::Voting);
5047        assert_eq!(
5048            parse_workflow_type("mixture").unwrap(),
5049            WorkflowType::Mixture
5050        );
5051    }
5052
5053    #[test]
5054    fn parse_workflow_type_invalid() {
5055        assert!(parse_workflow_type("").is_err());
5056        assert!(parse_workflow_type("unknown").is_err());
5057        assert!(parse_workflow_type("rm -rf /").is_err());
5058    }
5059
5060    #[test]
5061    fn validate_rejects_unknown_builtin() {
5062        let toml = r#"
5063[provider]
5064name = "anthropic"
5065model = "claude-sonnet-4-20250514"
5066
5067[[agents]]
5068name = "researcher"
5069description = "Research specialist"
5070builtin_tools = ["websearch", "nonexistent"]
5071"#;
5072        let err = HeartbitConfig::from_toml(toml).unwrap_err();
5073        let msg = format!("{err}");
5074        assert!(
5075            msg.contains("unknown builtin tool 'nonexistent'"),
5076            "expected unknown builtin error, got: {msg}"
5077        );
5078    }
5079
5080    #[test]
5081    fn agent_config_builtin_tools_deserialization() {
5082        let toml = r#"
5083[provider]
5084name = "anthropic"
5085model = "claude-sonnet-4-20250514"
5086
5087[[agents]]
5088name = "researcher"
5089description = "Research specialist"
5090builtin_tools = ["websearch", "webfetch"]
5091
5092[[agents]]
5093name = "publisher"
5094description = "Publisher"
5095builtin_tools = []
5096"#;
5097        let config = HeartbitConfig::from_toml(toml).unwrap();
5098        assert_eq!(
5099            config.agents[0].builtin_tools,
5100            Some(vec!["websearch".into(), "webfetch".into()])
5101        );
5102        assert_eq!(config.agents[1].builtin_tools, Some(vec![]));
5103    }
5104
5105    #[test]
5106    fn agent_config_builtin_tools_absent_is_none() {
5107        let toml = r#"
5108[provider]
5109name = "anthropic"
5110model = "claude-sonnet-4-20250514"
5111
5112[[agents]]
5113name = "researcher"
5114description = "Research specialist"
5115"#;
5116        let config = HeartbitConfig::from_toml(toml).unwrap();
5117        assert_eq!(config.agents[0].builtin_tools, None);
5118    }
5119
5120    // B5b: circuit breaker + tenant tracker config tests
5121    #[test]
5122    fn b5b_full_config_parses() {
5123        let toml = r#"
5124[provider]
5125name = "anthropic"
5126model = "claude-sonnet-4-20250514"
5127
5128[provider.circuit]
5129failure_threshold = 5
5130initial_open_duration_seconds = 30
5131max_open_duration_seconds = 300
5132backoff_multiplier = 2.0
5133
5134[orchestrator]
5135max_tokens_in_flight_per_tenant = 1000000
5136"#;
5137        let cfg = HeartbitConfig::from_toml(toml).unwrap();
5138        assert_eq!(cfg.provider.circuit.failure_threshold, Some(5));
5139        assert_eq!(cfg.provider.circuit.initial_open_duration_seconds, Some(30));
5140        assert_eq!(cfg.provider.circuit.max_open_duration_seconds, Some(300));
5141        assert_eq!(cfg.provider.circuit.backoff_multiplier, Some(2.0));
5142        assert_eq!(
5143            cfg.orchestrator.max_tokens_in_flight_per_tenant,
5144            Some(1_000_000)
5145        );
5146    }
5147
5148    #[test]
5149    fn b5b_config_zero_failure_threshold_rejected() {
5150        let toml = r#"
5151[provider]
5152name = "anthropic"
5153model = "claude-sonnet-4-20250514"
5154
5155[provider.circuit]
5156failure_threshold = 0
5157"#;
5158        let err = HeartbitConfig::from_toml(toml).unwrap_err();
5159        assert!(
5160            err.to_string()
5161                .contains("provider.circuit.failure_threshold must be > 0"),
5162            "error: {err}"
5163        );
5164    }
5165
5166    #[test]
5167    fn b5b_config_zero_initial_open_duration_rejected() {
5168        let toml = r#"
5169[provider]
5170name = "anthropic"
5171model = "claude-sonnet-4-20250514"
5172
5173[provider.circuit]
5174initial_open_duration_seconds = 0
5175"#;
5176        let err = HeartbitConfig::from_toml(toml).unwrap_err();
5177        assert!(
5178            err.to_string()
5179                .contains("provider.circuit.initial_open_duration_seconds must be > 0"),
5180            "error: {err}"
5181        );
5182    }
5183
5184    #[test]
5185    fn b5b_config_zero_max_open_duration_rejected() {
5186        let toml = r#"
5187[provider]
5188name = "anthropic"
5189model = "claude-sonnet-4-20250514"
5190
5191[provider.circuit]
5192max_open_duration_seconds = 0
5193"#;
5194        let err = HeartbitConfig::from_toml(toml).unwrap_err();
5195        assert!(
5196            err.to_string()
5197                .contains("provider.circuit.max_open_duration_seconds must be > 0"),
5198            "error: {err}"
5199        );
5200    }
5201
5202    #[test]
5203    fn b5b_config_zero_max_tokens_in_flight_rejected() {
5204        let toml = r#"
5205[provider]
5206name = "anthropic"
5207model = "claude-sonnet-4-20250514"
5208
5209[orchestrator]
5210max_tokens_in_flight_per_tenant = 0
5211"#;
5212        let err = HeartbitConfig::from_toml(toml).unwrap_err();
5213        assert!(
5214            err.to_string()
5215                .contains("orchestrator.max_tokens_in_flight_per_tenant must be > 0"),
5216            "error: {err}"
5217        );
5218    }
5219
5220    #[test]
5221    fn b5b_config_invalid_backoff_multiplier_rejected() {
5222        // TOML uses lowercase `nan` / `inf` literals; Rust's f64 Display formats
5223        // f64::NAN as "NaN" and f64::INFINITY as "inf", so we hard-code the TOML
5224        // literal forms here rather than `format!("{}", f64::NAN)`.
5225        for bad in ["0.0", "-0.0", "-1.0", "nan", "inf", "-inf"] {
5226            let toml = format!(
5227                r#"
5228[provider]
5229name = "anthropic"
5230model = "claude-sonnet-4-20250514"
5231
5232[provider.circuit]
5233backoff_multiplier = {bad}
5234"#
5235            );
5236            let err = match HeartbitConfig::from_toml(&toml) {
5237                Ok(cfg) => panic!("backoff_multiplier = {bad} must be rejected, got: {cfg:?}"),
5238                Err(e) => e,
5239            };
5240            assert!(
5241                err.to_string()
5242                    .contains("provider.circuit.backoff_multiplier must be > 0 and finite"),
5243                "value {bad} produced unexpected error: {err}"
5244            );
5245        }
5246    }
5247
5248    #[test]
5249    fn b5b_circuit_config_from_provider_circuit_config() {
5250        use crate::llm::circuit::CircuitConfig;
5251        let pcc = ProviderCircuitConfig {
5252            failure_threshold: Some(3),
5253            initial_open_duration_seconds: Some(10),
5254            max_open_duration_seconds: Some(120),
5255            backoff_multiplier: Some(1.5),
5256        };
5257        let cc = CircuitConfig::from(&pcc);
5258        assert_eq!(cc.failure_threshold, 3);
5259        assert_eq!(cc.initial_open_duration, std::time::Duration::from_secs(10));
5260        assert_eq!(cc.max_open_duration, std::time::Duration::from_secs(120));
5261        assert_eq!(cc.backoff_multiplier, 1.5);
5262    }
5263
5264    #[test]
5265    fn b5b_circuit_config_defaults_when_absent() {
5266        use crate::llm::circuit::CircuitConfig;
5267        let default_cc = CircuitConfig::default();
5268        let pcc = ProviderCircuitConfig::default();
5269        let cc = CircuitConfig::from(&pcc);
5270        assert_eq!(cc.failure_threshold, default_cc.failure_threshold);
5271        assert_eq!(cc.initial_open_duration, default_cc.initial_open_duration);
5272        assert_eq!(cc.max_open_duration, default_cc.max_open_duration);
5273        assert_eq!(cc.backoff_multiplier, default_cc.backoff_multiplier);
5274    }
5275}