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