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