1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3
4pub const DEFAULT_LOCAL_LLM_MODEL: &str = "qwen3.5:4b";
5
6pub const DEFAULT_BUNDLED_MODEL_ID: &str = "Qwen3.5-2B-MLX-4bit";
11
12pub const DEFAULT_MAX_RETRIES: u32 = 1;
13pub const DEFAULT_BACKOFF_BASE_MS: u64 = 500;
14pub const DEFAULT_COOLDOWN_SECS: u64 = 60;
15pub const DEFAULT_ROUTING_THRESHOLD: u32 = 2000;
16pub const DEFAULT_SMART_MAX_ESCALATIONS: u32 = 1;
17
18pub const DEFAULT_OLLAMA_ENDPOINT: &str = "http://localhost:11434";
23
24fn default_max_retries() -> u32 {
25 DEFAULT_MAX_RETRIES
26}
27fn default_backoff_base_ms() -> u64 {
28 DEFAULT_BACKOFF_BASE_MS
29}
30fn default_cooldown_secs() -> u64 {
31 DEFAULT_COOLDOWN_SECS
32}
33fn default_smart_max_escalations() -> u32 {
34 DEFAULT_SMART_MAX_ESCALATIONS
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
42pub struct SmartConfig {
43 #[serde(default = "default_true")]
44 pub enabled: bool,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub cheap: Option<String>,
47 #[serde(default = "default_smart_max_escalations")]
48 pub max_escalations: u32,
49}
50
51impl Default for SmartConfig {
52 fn default() -> Self {
53 Self {
54 enabled: true,
55 cheap: None,
56 max_escalations: DEFAULT_SMART_MAX_ESCALATIONS,
57 }
58 }
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize, Default)]
64pub struct ModelSwitchConfig {
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub default: Option<String>,
68 #[serde(default, skip_serializing_if = "Vec::is_empty")]
70 pub fallback_chain: Vec<String>,
71 #[serde(default)]
72 pub retry: RetryConfig,
73 #[serde(default)]
74 pub routing: RoutingConfig,
75 #[serde(default)]
76 pub smart: SmartConfig,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct RetryConfig {
81 #[serde(default = "default_max_retries")]
82 pub max_retries: u32,
83 #[serde(default = "default_backoff_base_ms")]
84 pub backoff_base_ms: u64,
85 #[serde(default = "default_cooldown_secs")]
86 pub cooldown_secs: u64,
87}
88
89impl Default for RetryConfig {
90 fn default() -> Self {
91 Self {
92 max_retries: DEFAULT_MAX_RETRIES,
93 backoff_base_ms: DEFAULT_BACKOFF_BASE_MS,
94 cooldown_secs: DEFAULT_COOLDOWN_SECS,
95 }
96 }
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
100pub struct RoutingConfig {
101 #[serde(default)]
102 pub enabled: bool,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub cheap: Option<String>,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub frontier: Option<String>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub threshold_input_tokens: Option<u32>,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub smart: Option<SmartConfig>,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize, Default)]
115pub struct Config {
116 #[serde(default)]
117 pub embedding: EmbeddingConfig,
118
119 #[serde(default)]
120 pub llm: LlmConfig,
121
122 #[serde(default)]
123 pub models: ModelSwitchConfig,
124
125 #[serde(default)]
126 pub retrieval: RetrievalConfig,
127
128 #[serde(default)]
129 pub paths: PathConfig,
130
131 #[serde(default)]
132 pub server: ServerConfig,
133
134 #[serde(default)]
135 pub community: CommunityConfig,
136
137 #[serde(default)]
138 pub conversations: ConversationsConfig,
139
140 #[serde(default)]
141 pub sync: SyncConfig,
142
143 #[serde(default)]
145 pub storage: StorageConfig,
146
147 #[serde(default)]
148 pub sources_global: SourcesGlobalConfig,
149
150 #[serde(default)]
152 pub sleep_cycle: SleepCycleConfig,
153
154 #[serde(default)]
156 pub skills: SkillsConfig,
157
158 #[serde(default)]
160 pub skill_llm: SkillLlmConfig,
161
162 #[serde(default)]
164 pub cross_agent: CrossAgentConfig,
165
166 #[serde(default)]
168 pub nudge: NudgeConfig,
169
170 #[serde(default)]
172 pub mobile_relay: MobileRelayConfig,
173
174 #[serde(default)]
176 pub session: SessionCfg,
177
178 #[serde(default)]
179 pub harvest: HarvestCfg,
180
181 #[serde(default)]
183 pub cc_proxy: CcProxyConfig,
184
185 #[serde(default)]
187 pub cli: CliConfig,
188
189 #[serde(default)]
191 pub parallel_jobs: ParallelJobsConfig,
192
193 #[serde(default)]
195 pub federation_snapshot: SnapshotConfig,
196
197 #[serde(default)]
199 pub memory: MemoryConfig,
200
201 #[serde(default)]
203 pub fleet_run: FleetRunConfig,
204
205 #[serde(default)]
207 pub open_items: OpenItemsConfig,
208
209 #[serde(default)]
211 pub fleet: FleetConfig,
212}
213
214#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
220pub struct ParallelJobsConfig {
221 #[serde(default)]
224 pub targets: Vec<String>,
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
233#[serde(default)]
234pub struct SnapshotConfig {
235 pub poll_secs: u64,
237 pub request_max_age_secs: u64,
239 pub min_lifecycle: crate::skill::stats::LifecycleState,
241}
242
243impl Default for SnapshotConfig {
244 fn default() -> Self {
245 Self {
246 poll_secs: 30,
247 request_max_age_secs: 600,
248 min_lifecycle: crate::skill::stats::LifecycleState::Stable,
249 }
250 }
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
257#[serde(default)]
258pub struct MemoryConfig {
259 pub capture: CaptureMode,
260}
261
262impl Default for MemoryConfig {
263 fn default() -> Self {
264 Self {
265 capture: CaptureMode::AutoAnnounce,
266 }
267 }
268}
269
270#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
272#[serde(rename_all = "snake_case")]
273pub enum CaptureMode {
274 Ask,
276 AutoAnnounce,
278 Off,
280}
281
282#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
290pub struct FleetRunConfig {
291 #[serde(default)]
293 pub agents: Vec<String>,
294 #[serde(default)]
296 pub fleets: Vec<String>,
297}
298
299#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
306pub struct OpenItemsConfig {
307 #[serde(default)]
310 pub muted: Vec<String>,
311}
312
313#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
320pub struct FleetConfig {
321 #[serde(default)]
323 pub autorun: bool,
324}
325
326#[cfg(test)]
327mod fleet_config_tests {
328 use super::*;
329
330 #[test]
331 fn fleet_config_defaults_off_and_roundtrips() {
332 assert!(!FleetConfig::default().autorun);
333
334 let cfg: Config = serde_yaml_ng::from_str("fleet:\n autorun: true\n").unwrap();
335 assert!(cfg.fleet.autorun);
336
337 let cfg2: Config = serde_yaml_ng::from_str("{}").unwrap();
339 assert!(!cfg2.fleet.autorun);
340 }
341
342 #[test]
343 fn fleet_run_config_defaults_deny_all_and_roundtrips() {
344 let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
346 assert!(cfg.fleet_run.agents.is_empty());
347 assert!(cfg.fleet_run.fleets.is_empty());
348
349 let cfg2: Config =
350 serde_yaml_ng::from_str("fleet_run:\n agents: [mur]\n fleets: [deep-research]\n")
351 .unwrap();
352 assert_eq!(cfg2.fleet_run.agents, vec!["mur"]);
353 assert_eq!(cfg2.fleet_run.fleets, vec!["deep-research"]);
354 }
355}
356
357#[derive(Debug, Clone, Serialize, Deserialize)]
368pub struct CcProxyConfig {
369 #[serde(default = "default_cc_proxy_url")]
371 pub url: String,
372
373 #[serde(default = "default_true")]
376 pub enabled: bool,
377}
378
379fn default_cc_proxy_url() -> String {
380 "http://127.0.0.1:8088".to_string()
381}
382
383fn default_true() -> bool {
384 true
385}
386
387impl Default for CcProxyConfig {
388 fn default() -> Self {
389 Self {
390 url: default_cc_proxy_url(),
391 enabled: true,
392 }
393 }
394}
395
396#[derive(Debug, Clone, Serialize, Deserialize, Default)]
399pub struct CliConfig {
400 pub skin: Option<String>,
403}
404
405#[derive(Debug, Clone, Serialize, Deserialize, Default)]
408pub struct MobileRelayConfig {
409 #[serde(default, skip_serializing_if = "Option::is_none")]
412 pub relay_url: Option<String>,
413
414 #[serde(default, skip_serializing_if = "Option::is_none")]
417 pub api_key: Option<String>,
418}
419
420impl Config {
421 pub fn load_or_default(path: &std::path::Path) -> Self {
426 let Ok(text) = std::fs::read_to_string(path) else {
427 return Self::default();
428 };
429 let text = crate::config_migrate::migrate_conversations_yaml(&text).unwrap_or(text);
430 serde_yaml_ng::from_str(&text).unwrap_or_default()
431 }
432}
433
434#[derive(Debug, Clone, Serialize, Deserialize, Default)]
435pub struct SyncConfig {
436 #[serde(default = "default_sync_method")]
438 pub method: String,
439
440 #[serde(default, skip_serializing_if = "Option::is_none")]
442 pub git_remote: Option<String>,
443
444 #[serde(default)]
446 pub auto: bool,
447
448 #[serde(default, skip_serializing_if = "Option::is_none")]
450 pub team_id: Option<String>,
451}
452
453fn default_sync_method() -> String {
454 "local".to_string()
455}
456
457#[derive(Debug, Clone, Serialize, Deserialize)]
458pub struct ServerConfig {
459 #[serde(default = "default_server_url")]
461 pub url: String,
462}
463
464impl Default for ServerConfig {
465 fn default() -> Self {
466 Self {
467 url: default_server_url(),
468 }
469 }
470}
471
472#[derive(Debug, Clone, Serialize, Deserialize, Default)]
473pub struct CommunityConfig {
474 #[serde(default)]
476 pub enabled: bool,
477}
478
479#[derive(Debug, Clone, Serialize, Deserialize)]
480pub struct EmbeddingConfig {
481 #[serde(default = "default_embedding_provider")]
483 pub provider: String,
484
485 #[serde(default = "default_embedding_model")]
487 pub model: String,
488
489 #[serde(default = "default_dimensions")]
491 pub dimensions: usize,
492
493 #[serde(default, skip_serializing_if = "Option::is_none")]
497 pub ollama_endpoint: Option<String>,
498
499 #[serde(default, skip_serializing_if = "Option::is_none")]
501 pub api_key_env: Option<String>,
502
503 #[serde(default, skip_serializing_if = "Option::is_none")]
506 pub api_key_ref: Option<String>,
507
508 #[serde(default, skip_serializing_if = "Option::is_none")]
510 pub openai_url: Option<String>,
511}
512
513impl Default for EmbeddingConfig {
514 fn default() -> Self {
515 Self {
516 provider: default_embedding_provider(),
517 model: default_embedding_model(),
518 dimensions: default_dimensions(),
519 ollama_endpoint: Some(default_ollama_endpoint()),
520 api_key_env: None,
521 api_key_ref: None,
522 openai_url: None,
523 }
524 }
525}
526
527#[derive(Debug, Clone, Serialize, Deserialize)]
528pub struct LlmConfig {
529 #[serde(default = "default_llm_provider")]
531 pub provider: String,
532
533 #[serde(default = "default_llm_model")]
534 pub model: String,
535
536 #[serde(default, skip_serializing_if = "Option::is_none")]
538 pub api_key_env: Option<String>,
539
540 #[serde(default, skip_serializing_if = "Option::is_none")]
543 pub api_key_ref: Option<String>,
544
545 #[serde(default, skip_serializing_if = "Option::is_none")]
547 pub openai_url: Option<String>,
548}
549
550impl Default for LlmConfig {
551 fn default() -> Self {
552 Self {
553 provider: default_llm_provider(),
554 model: default_llm_model(),
555 api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
556 api_key_ref: None,
557 openai_url: None,
558 }
559 }
560}
561
562impl LlmConfig {
563 pub fn to_backend_config(&self) -> BackendConfig {
576 let provider = match self.provider.as_str() {
577 "anthropic" | "openai" | "openrouter" | "gemini" | "ollama" => self.provider.clone(),
578 _ if self.openai_url.is_some() => "openai".into(),
579 other => other.into(), };
581 BackendConfig {
582 provider,
583 model: self.model.clone(),
584 endpoint: self.openai_url.clone(),
585 api_key_env: self.api_key_env.clone(),
586 api_key_ref: self.api_key_ref.clone(),
587 timeout_secs: None,
588 }
589 }
590}
591
592#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
603#[serde(default)]
604pub struct BackendConfig {
605 pub provider: String,
607 pub model: String,
609 pub endpoint: Option<String>,
612 pub api_key_env: Option<String>,
614 pub api_key_ref: Option<String>,
616 pub timeout_secs: Option<u64>,
618}
619
620impl Default for BackendConfig {
621 fn default() -> Self {
622 Self {
623 provider: "ollama".into(),
624 model: DEFAULT_LOCAL_LLM_MODEL.into(),
625 endpoint: None,
626 api_key_env: None,
627 api_key_ref: None,
628 timeout_secs: None,
629 }
630 }
631}
632
633#[derive(Debug, Clone, Serialize, Deserialize)]
634pub struct RetrievalConfig {
635 #[serde(default = "default_max_patterns")]
637 pub max_patterns: usize,
638
639 #[serde(default = "default_max_tokens")]
641 pub max_tokens: usize,
642
643 #[serde(default = "default_min_score")]
645 pub min_score: f64,
646
647 #[serde(default = "default_mmr_threshold")]
649 pub mmr_threshold: f64,
650
651 #[serde(default = "default_reserved_note_slots")]
654 pub reserved_note_slots: usize,
655}
656
657impl Default for RetrievalConfig {
658 fn default() -> Self {
659 Self {
660 max_patterns: default_max_patterns(),
661 max_tokens: default_max_tokens(),
662 min_score: default_min_score(),
663 mmr_threshold: default_mmr_threshold(),
664 reserved_note_slots: default_reserved_note_slots(),
665 }
666 }
667}
668
669fn default_reserved_note_slots() -> usize {
670 1
671}
672
673#[derive(Debug, Clone, Serialize, Deserialize)]
674pub struct PathConfig {
675 #[serde(default = "default_mur_dir")]
677 pub mur_dir: PathBuf,
678}
679
680impl Default for PathConfig {
681 fn default() -> Self {
682 Self {
683 mur_dir: default_mur_dir(),
684 }
685 }
686}
687
688#[derive(Debug, Clone, Serialize, Deserialize)]
689pub struct StorageConfig {
690 #[serde(default = "default_vector_backend")]
692 pub vector_backend: String,
693
694 #[serde(default, skip_serializing_if = "Option::is_none")]
696 pub qdrant_url: Option<String>,
697
698 #[serde(default, skip_serializing_if = "Option::is_none")]
700 pub qdrant_api_key_ref: Option<String>,
701}
702
703impl Default for StorageConfig {
704 fn default() -> Self {
705 Self {
706 vector_backend: default_vector_backend(),
707 qdrant_url: None,
708 qdrant_api_key_ref: None,
709 }
710 }
711}
712
713fn default_vector_backend() -> String {
714 "lancedb".to_string()
715}
716
717#[derive(Debug, Clone, Serialize, Deserialize)]
718pub struct SourcesGlobalConfig {
719 #[serde(default = "default_poll_interval_secs")]
721 pub poll_interval_secs: u64,
722
723 #[serde(default = "default_max_chunks_per_sync")]
725 pub max_chunks_per_sync: usize,
726
727 #[serde(default = "default_max_parallel_sources")]
729 pub max_parallel_sources: usize,
730
731 #[serde(default = "default_source_weight")]
733 pub default_weight: f32,
734
735 #[serde(default = "default_embedding_batch_size")]
737 pub embedding_batch_size: usize,
738}
739
740impl Default for SourcesGlobalConfig {
741 fn default() -> Self {
742 Self {
743 poll_interval_secs: default_poll_interval_secs(),
744 max_chunks_per_sync: default_max_chunks_per_sync(),
745 max_parallel_sources: default_max_parallel_sources(),
746 default_weight: default_source_weight(),
747 embedding_batch_size: default_embedding_batch_size(),
748 }
749 }
750}
751
752fn default_poll_interval_secs() -> u64 {
753 600
754}
755fn default_max_chunks_per_sync() -> usize {
756 10_000
757}
758fn default_max_parallel_sources() -> usize {
759 3
760}
761fn default_source_weight() -> f32 {
762 1.0
763}
764fn default_embedding_batch_size() -> usize {
765 32
766}
767
768fn default_embedding_provider() -> String {
769 "ollama".to_string()
770}
771fn default_embedding_model() -> String {
772 "qwen3-embedding:0.6b".to_string()
773}
774fn default_dimensions() -> usize {
775 1024
776}
777fn default_ollama_endpoint() -> String {
778 DEFAULT_OLLAMA_ENDPOINT.to_string()
779}
780fn default_llm_provider() -> String {
781 "anthropic".to_string()
782}
783fn default_llm_model() -> String {
784 "claude-opus-5".to_string()
785}
786fn default_max_patterns() -> usize {
787 5
788}
789fn default_max_tokens() -> usize {
790 2000
791}
792fn default_min_score() -> f64 {
793 0.35
794}
795fn default_mmr_threshold() -> f64 {
796 0.85
797}
798fn default_mur_dir() -> PathBuf {
799 let home = std::env::var("HOME")
802 .map(PathBuf::from)
803 .unwrap_or_else(|_| PathBuf::from("/tmp"));
804 home.join(".mur")
805}
806fn default_server_url() -> String {
807 "https://mur-server.fly.dev".to_string()
808}
809
810#[derive(Debug, Clone, Serialize, Deserialize)]
813pub struct AskConfig {
814 #[serde(default = "ask_default_k_summary")]
815 pub k_summary: u32,
816 #[serde(default = "ask_default_k_raw")]
817 pub k_raw: u32,
818 #[serde(default = "ask_default_esc")]
819 pub escalation_threshold: f64,
820 #[serde(default = "ask_default_mmr")]
821 pub mmr_threshold: f64,
822 #[serde(default = "ask_default_max_ctx")]
823 pub max_context_tokens: u32,
824 #[serde(default = "ask_default_resp_tok")]
825 pub response_tokens: u32,
826 #[serde(default = "ask_default_timeout")]
827 pub timeout_secs: u32,
828 #[serde(default = "ask_default_min_score")]
829 pub min_score: f64,
830 #[serde(default = "ask_default_continue_history_turns")]
831 pub continue_history_turns: u32,
832 #[serde(default = "ask_default_rewriter_timeout")]
838 pub rewriter_timeout_secs: u32,
839 #[serde(default = "ask_default_compress_hits_enabled")]
840 pub compress_hits_enabled: bool,
841 #[serde(default = "ask_default_summarize_hits_enabled")]
842 pub summarize_hits_enabled: bool,
843 #[serde(default)]
844 pub summarize_model: Option<String>,
845 #[serde(default)]
848 pub backend: Option<BackendConfig>,
849 #[serde(default)]
853 pub rewriter_backend: Option<BackendConfig>,
854}
855
856impl AskConfig {
857 pub fn effective_backend(&self, llm: &LlmConfig) -> BackendConfig {
862 self.backend.clone().unwrap_or_else(|| BackendConfig {
863 timeout_secs: Some(self.timeout_secs as u64),
864 ..llm.to_backend_config()
865 })
866 }
867
868 pub fn effective_rewriter_backend(&self, llm: &LlmConfig) -> BackendConfig {
878 self.rewriter_backend
879 .clone()
880 .unwrap_or_else(|| BackendConfig {
881 timeout_secs: Some(self.rewriter_timeout_secs as u64),
882 ..self
883 .backend
884 .clone()
885 .unwrap_or_else(|| llm.to_backend_config())
886 })
887 }
888}
889
890impl Default for AskConfig {
891 fn default() -> Self {
892 Self {
893 k_summary: ask_default_k_summary(),
894 k_raw: ask_default_k_raw(),
895 escalation_threshold: ask_default_esc(),
896 mmr_threshold: ask_default_mmr(),
897 max_context_tokens: ask_default_max_ctx(),
898 response_tokens: ask_default_resp_tok(),
899 timeout_secs: ask_default_timeout(),
900 min_score: ask_default_min_score(),
901 continue_history_turns: ask_default_continue_history_turns(),
902 rewriter_timeout_secs: ask_default_rewriter_timeout(),
903 compress_hits_enabled: ask_default_compress_hits_enabled(),
904 summarize_hits_enabled: ask_default_summarize_hits_enabled(),
905 summarize_model: None,
906 backend: None,
907 rewriter_backend: None,
908 }
909 }
910}
911
912fn ask_default_k_summary() -> u32 {
913 5
914}
915fn ask_default_k_raw() -> u32 {
916 10
917}
918fn ask_default_esc() -> f64 {
919 0.5
920}
921fn ask_default_mmr() -> f64 {
922 0.88
923}
924fn ask_default_max_ctx() -> u32 {
925 6000
926}
927fn ask_default_resp_tok() -> u32 {
928 1024
929}
930fn ask_default_timeout() -> u32 {
931 120
932}
933fn ask_default_min_score() -> f64 {
934 0.35
935}
936fn ask_default_rewriter_timeout() -> u32 {
937 8
938}
939fn ask_default_continue_history_turns() -> u32 {
940 3
941}
942fn ask_default_compress_hits_enabled() -> bool {
943 true
944}
945fn ask_default_summarize_hits_enabled() -> bool {
946 true
947}
948
949#[derive(Debug, Clone, Serialize, Deserialize)]
958pub struct ConversationsConfig {
959 #[serde(default)]
960 pub enabled: bool,
961 #[serde(default = "conv_default_retention_days")]
962 pub retention_days: u32,
963 #[serde(default = "conv_default_poll_interval")]
964 pub poll_interval_secs: u64,
965 #[serde(default)]
966 pub sources: ConversationsSources,
967 #[serde(default)]
968 pub filter: ConversationsFilter,
969 #[serde(default)]
970 pub compact: CompactConfig,
971 #[serde(default)]
972 pub ask: AskConfig,
973 #[serde(default)]
974 pub rollup: RollupConfig,
975}
976
977impl Default for ConversationsConfig {
978 fn default() -> Self {
979 Self {
980 enabled: false,
981 retention_days: conv_default_retention_days(),
982 poll_interval_secs: conv_default_poll_interval(),
983 sources: ConversationsSources::default(),
984 filter: ConversationsFilter::default(),
985 compact: CompactConfig::default(),
986 ask: AskConfig::default(),
987 rollup: RollupConfig::default(),
988 }
989 }
990}
991
992fn conv_default_retention_days() -> u32 {
993 30
994}
995fn conv_default_poll_interval() -> u64 {
996 300
997}
998fn conv_truthy() -> bool {
999 true
1000}
1001fn conv_default_dedup() -> f64 {
1002 0.85
1003}
1004
1005#[derive(Debug, Clone, Serialize, Deserialize)]
1006pub struct CompactConfig {
1007 #[serde(default = "conv_truthy")]
1008 pub enabled_in_daemon: bool,
1009 #[serde(default = "compact_default_max_days")]
1010 pub max_days_per_run: u32,
1011 #[serde(default = "compact_default_max_spans")]
1012 pub max_extractive_spans: u32,
1013 #[serde(default = "compact_default_max_words")]
1014 pub max_abstractive_words: u32,
1015 #[serde(default = "compact_default_chunk_tokens")]
1016 pub chunk_tokens: u32,
1017 #[serde(default = "compact_default_history_retain")]
1018 pub history_retain: u32,
1019 #[serde(default = "compact_default_cron")]
1020 pub daemon_cron: String,
1021 #[serde(default)]
1024 pub extractive_backend: Option<BackendConfig>,
1025 #[serde(default)]
1028 pub abstractive_backend: Option<BackendConfig>,
1029}
1030
1031impl CompactConfig {
1032 pub fn effective_extractive_backend(&self, llm: &LlmConfig) -> BackendConfig {
1037 self.extractive_backend
1038 .clone()
1039 .unwrap_or_else(|| BackendConfig {
1040 timeout_secs: Some(120),
1041 ..llm.to_backend_config()
1042 })
1043 }
1044
1045 pub fn effective_abstractive_backend(&self, llm: &LlmConfig) -> BackendConfig {
1048 self.abstractive_backend
1049 .clone()
1050 .unwrap_or_else(|| BackendConfig {
1051 timeout_secs: Some(120),
1052 ..llm.to_backend_config()
1053 })
1054 }
1055}
1056
1057impl Default for CompactConfig {
1058 fn default() -> Self {
1059 Self {
1060 enabled_in_daemon: true,
1061 max_days_per_run: compact_default_max_days(),
1062 max_extractive_spans: compact_default_max_spans(),
1063 max_abstractive_words: compact_default_max_words(),
1064 chunk_tokens: compact_default_chunk_tokens(),
1065 history_retain: compact_default_history_retain(),
1066 daemon_cron: compact_default_cron(),
1067 extractive_backend: None,
1068 abstractive_backend: None,
1069 }
1070 }
1071}
1072
1073fn compact_default_max_days() -> u32 {
1074 7
1075}
1076fn compact_default_max_spans() -> u32 {
1077 20
1078}
1079fn compact_default_max_words() -> u32 {
1080 400
1081}
1082fn compact_default_chunk_tokens() -> u32 {
1083 6000
1084}
1085fn compact_default_history_retain() -> u32 {
1086 5
1087}
1088fn compact_default_cron() -> String {
1089 "0 0 3 * * * *".into()
1090}
1091
1092#[derive(Debug, Clone, Serialize, Deserialize)]
1095pub struct RollupConfig {
1096 #[serde(default = "rollup_default_enabled")]
1097 pub enabled: bool,
1098 #[serde(default = "rollup_default_max_weeks")]
1099 pub max_weeks_per_run: u32,
1100 #[serde(default = "rollup_default_max_months")]
1101 pub max_months_per_run: u32,
1102 #[serde(default = "rollup_default_max_spans_week")]
1103 pub max_extractive_spans_per_week: u32,
1104 #[serde(default = "rollup_default_max_words_week")]
1105 pub max_abstractive_words_per_week: u32,
1106 #[serde(default = "rollup_default_max_spans_month")]
1107 pub max_extractive_spans_per_month: u32,
1108 #[serde(default = "rollup_default_max_words_month")]
1109 pub max_abstractive_words_per_month: u32,
1110 #[serde(default = "rollup_default_week_mmr")]
1111 pub week_mmr_threshold: f64,
1112 #[serde(default = "rollup_default_month_mmr")]
1113 pub month_mmr_threshold: f64,
1114 #[serde(default)]
1117 pub extractive_backend: Option<BackendConfig>,
1118 #[serde(default)]
1121 pub abstractive_backend: Option<BackendConfig>,
1122}
1123
1124impl Default for RollupConfig {
1125 fn default() -> Self {
1126 Self {
1127 enabled: rollup_default_enabled(),
1128 max_weeks_per_run: rollup_default_max_weeks(),
1129 max_months_per_run: rollup_default_max_months(),
1130 max_extractive_spans_per_week: rollup_default_max_spans_week(),
1131 max_abstractive_words_per_week: rollup_default_max_words_week(),
1132 max_extractive_spans_per_month: rollup_default_max_spans_month(),
1133 max_abstractive_words_per_month: rollup_default_max_words_month(),
1134 week_mmr_threshold: rollup_default_week_mmr(),
1135 month_mmr_threshold: rollup_default_month_mmr(),
1136 extractive_backend: None,
1137 abstractive_backend: None,
1138 }
1139 }
1140}
1141
1142impl RollupConfig {
1143 pub fn effective_extractive_backend(&self, llm: &LlmConfig) -> BackendConfig {
1147 self.extractive_backend
1148 .clone()
1149 .unwrap_or_else(|| BackendConfig {
1150 timeout_secs: Some(120),
1151 ..llm.to_backend_config()
1152 })
1153 }
1154
1155 pub fn effective_abstractive_backend(&self, llm: &LlmConfig) -> BackendConfig {
1157 self.abstractive_backend
1158 .clone()
1159 .unwrap_or_else(|| BackendConfig {
1160 timeout_secs: Some(120),
1161 ..llm.to_backend_config()
1162 })
1163 }
1164}
1165
1166fn rollup_default_enabled() -> bool {
1167 true
1168}
1169fn rollup_default_max_weeks() -> u32 {
1170 4
1171}
1172fn rollup_default_max_months() -> u32 {
1173 2
1174}
1175fn rollup_default_max_spans_week() -> u32 {
1176 20
1177}
1178fn rollup_default_max_words_week() -> u32 {
1179 500
1180}
1181fn rollup_default_max_spans_month() -> u32 {
1182 20
1183}
1184fn rollup_default_max_words_month() -> u32 {
1185 700
1186}
1187fn rollup_default_week_mmr() -> f64 {
1188 0.85
1189}
1190fn rollup_default_month_mmr() -> f64 {
1191 0.82
1192}
1193
1194#[derive(Debug, Clone, Serialize, Deserialize)]
1195pub struct ConversationsSources {
1196 #[serde(default = "conv_truthy")]
1197 pub claude_code: bool,
1198 #[serde(default = "conv_truthy")]
1199 pub cursor: bool,
1200 #[serde(default = "conv_truthy")]
1201 pub gemini: bool,
1202 #[serde(default)]
1203 pub aider: AiderSourceConfig,
1204}
1205
1206impl Default for ConversationsSources {
1207 fn default() -> Self {
1208 Self {
1209 claude_code: true,
1210 cursor: true,
1211 gemini: true,
1212 aider: AiderSourceConfig::default(),
1213 }
1214 }
1215}
1216
1217#[derive(Debug, Clone, Serialize, Deserialize)]
1218pub struct AiderSourceConfig {
1219 #[serde(default = "conv_truthy")]
1220 pub enabled: bool,
1221 #[serde(default)]
1222 pub watched_dirs: Vec<String>,
1223}
1224
1225impl Default for AiderSourceConfig {
1226 fn default() -> Self {
1227 Self {
1228 enabled: true,
1229 watched_dirs: Vec::new(),
1230 }
1231 }
1232}
1233
1234#[derive(Debug, Clone, Serialize, Deserialize)]
1235pub struct ConversationsFilter {
1236 #[serde(default = "conv_default_dedup")]
1237 pub dedup_threshold: f64,
1238 #[serde(default = "conv_truthy")]
1239 pub reject_heartbeat: bool,
1240 #[serde(default = "conv_truthy")]
1241 pub reject_system_restatement: bool,
1242}
1243
1244impl Default for ConversationsFilter {
1245 fn default() -> Self {
1246 Self {
1247 dedup_threshold: conv_default_dedup(),
1248 reject_heartbeat: true,
1249 reject_system_restatement: true,
1250 }
1251 }
1252}
1253
1254#[cfg(test)]
1255mod conversations_tests {
1256 use super::*;
1257
1258 #[test]
1259 fn conversations_section_defaults() {
1260 let c = ConversationsConfig::default();
1261 assert!(!c.enabled);
1262 assert_eq!(c.retention_days, 30);
1263 assert_eq!(c.poll_interval_secs, 300);
1264 assert!(c.sources.claude_code);
1265 assert!(c.sources.cursor);
1266 assert!(c.sources.gemini);
1267 assert!(c.sources.aider.enabled);
1268 assert!(c.sources.aider.watched_dirs.is_empty());
1269 assert_eq!(c.filter.dedup_threshold, 0.85);
1270 assert!(c.filter.reject_heartbeat);
1271 assert!(c.filter.reject_system_restatement);
1272 }
1273
1274 #[test]
1275 fn parse_from_yaml_with_overrides() {
1276 let y = r#"
1277conversations:
1278 enabled: true
1279 retention_days: 45
1280 poll_interval_secs: 120
1281 sources:
1282 cursor: false
1283 aider:
1284 watched_dirs: ["~/Projects/a", "~/Projects/b"]
1285 filter:
1286 dedup_threshold: 0.9
1287"#;
1288 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1289 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1290 assert!(conv.enabled);
1291 assert_eq!(conv.retention_days, 45);
1292 assert_eq!(conv.poll_interval_secs, 120);
1293 assert!(conv.sources.claude_code); assert!(!conv.sources.cursor); assert!(conv.sources.gemini); assert_eq!(conv.sources.aider.watched_dirs.len(), 2);
1297 assert_eq!(conv.filter.dedup_threshold, 0.9);
1298 assert!(conv.filter.reject_heartbeat); }
1300
1301 #[test]
1302 fn missing_conversations_section_is_fine() {
1303 let y = r#"
1304# No conversations section at all
1305foo: bar
1306"#;
1307 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1308 let conv: ConversationsConfig = v
1310 .get("conversations")
1311 .cloned()
1312 .map(|x| serde_yaml::from_value(x).unwrap_or_default())
1313 .unwrap_or_default();
1314 assert_eq!(conv.retention_days, 30);
1315 }
1316
1317 #[test]
1318 fn compact_config_defaults() {
1319 let c = CompactConfig::default();
1320 assert!(c.enabled_in_daemon);
1321 assert_eq!(c.max_days_per_run, 7);
1322 assert_eq!(c.max_extractive_spans, 20);
1323 assert_eq!(c.chunk_tokens, 6000);
1324 assert_eq!(c.history_retain, 5);
1325 assert_eq!(c.daemon_cron, "0 0 3 * * * *");
1326 }
1327
1328 #[test]
1329 fn compact_parses_partial_overrides() {
1330 let y = r#"
1331conversations:
1332 compact:
1333 max_days_per_run: 3
1334 extractive_backend:
1335 provider: anthropic
1336 model: claude-haiku-4-5
1337"#;
1338 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1339 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1340 assert_eq!(conv.compact.max_days_per_run, 3);
1341 assert_eq!(
1342 conv.compact.extractive_backend.as_ref().unwrap().model,
1343 "claude-haiku-4-5"
1344 );
1345 assert!(conv.compact.enabled_in_daemon); assert!(conv.compact.abstractive_backend.is_none()); }
1348
1349 #[test]
1350 fn ask_config_defaults() {
1351 let c = AskConfig::default();
1352 assert_eq!(c.k_raw, 10);
1353 assert_eq!(c.escalation_threshold, 0.5);
1354 assert_eq!(c.mmr_threshold, 0.88);
1355 assert_eq!(c.max_context_tokens, 6000);
1356 assert_eq!(c.response_tokens, 1024);
1357 assert_eq!(c.timeout_secs, 120);
1358 assert_eq!(c.min_score, 0.35);
1359 }
1360
1361 #[test]
1362 fn ask_config_mmr_threshold_default_is_cosine_scaled() {
1363 let c = AskConfig::default();
1365 assert!(
1366 (c.mmr_threshold - 0.88).abs() < 1e-9,
1367 "expected 0.88, got {}",
1368 c.mmr_threshold
1369 );
1370 }
1371
1372 #[test]
1373 fn rollup_config_defaults() {
1374 let c = RollupConfig::default();
1375 assert!(c.enabled);
1376 assert_eq!(c.max_weeks_per_run, 4);
1377 assert_eq!(c.max_months_per_run, 2);
1378 assert_eq!(c.max_extractive_spans_per_week, 20);
1379 assert_eq!(c.max_abstractive_words_per_week, 500);
1380 assert_eq!(c.max_extractive_spans_per_month, 20);
1381 assert_eq!(c.max_abstractive_words_per_month, 700);
1382 assert!((c.week_mmr_threshold - 0.85).abs() < 1e-9);
1383 assert!((c.month_mmr_threshold - 0.82).abs() < 1e-9);
1384 }
1385
1386 #[test]
1387 fn rollup_config_plumbed_into_conversations_config() {
1388 let c = ConversationsConfig::default();
1389 assert!(c.rollup.enabled);
1390 }
1391
1392 #[test]
1393 fn ask_config_default_continue_history_turns_is_3() {
1394 let c = AskConfig::default();
1395 assert_eq!(c.continue_history_turns, 3);
1396 }
1397
1398 #[test]
1399 fn ask_config_default_compress_hits_enabled_is_true() {
1400 let c = AskConfig::default();
1401 assert!(c.compress_hits_enabled);
1402 }
1403
1404 #[test]
1405 fn ask_config_default_summarize_hits_enabled_is_true() {
1406 let c = AskConfig::default();
1407 assert!(c.summarize_hits_enabled);
1408 }
1409
1410 #[test]
1411 fn ask_config_default_summarize_model_is_none() {
1412 let c = AskConfig::default();
1413 assert!(c.summarize_model.is_none());
1414 }
1415
1416 #[test]
1417 fn ask_config_yaml_roundtrip_preserves_summarize_fields() {
1418 let y = r#"
1419conversations:
1420 ask:
1421 summarize_hits_enabled: false
1422 summarize_model: qwen3:4b
1423"#;
1424 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1425 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1426 assert!(!conv.ask.summarize_hits_enabled);
1427 assert_eq!(conv.ask.summarize_model.as_deref(), Some("qwen3:4b"));
1428 }
1429
1430 #[test]
1431 fn ask_config_yaml_without_summarize_fields_uses_defaults() {
1432 let y = r#"
1436conversations:
1437 ask:
1438 min_score: 0.4
1439"#;
1440 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1441 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1442 assert!(conv.ask.summarize_hits_enabled);
1443 assert!(conv.ask.summarize_model.is_none());
1444 }
1445}
1446
1447#[cfg(test)]
1448mod tests {
1449 #[test]
1450 fn federation_snapshot_defaults_apply_when_block_absent() {
1451 let c: super::Config = serde_yaml_ng::from_str("{}").unwrap();
1452 assert_eq!(c.federation_snapshot.poll_secs, 30);
1453 assert_eq!(c.federation_snapshot.request_max_age_secs, 600);
1454 assert_eq!(
1455 c.federation_snapshot.min_lifecycle,
1456 crate::skill::stats::LifecycleState::Stable
1457 );
1458 assert_eq!(c.memory.capture, super::CaptureMode::AutoAnnounce);
1460 let c: super::Config = serde_yaml_ng::from_str("memory:\n capture: off\n").unwrap();
1461 assert_eq!(c.memory.capture, super::CaptureMode::Off);
1462 }
1463
1464 use super::*;
1465
1466 #[test]
1467 fn default_bundled_model_id_is_qwen35_2b() {
1468 assert_eq!(
1469 crate::config::DEFAULT_BUNDLED_MODEL_ID,
1470 "Qwen3.5-2B-MLX-4bit"
1471 );
1472 }
1473
1474 #[test]
1475 fn nudge_config_defaults() {
1476 let c = NudgeConfig::default();
1477 assert!(c.enabled);
1478 assert_eq!(c.daily_cap, 3);
1479 assert_eq!(c.snooze_days, 7);
1480 assert_eq!(c.threshold, 3);
1481 }
1482
1483 #[test]
1484 fn config_has_nudge_section_with_defaults() {
1485 let c: Config = serde_yaml_ng::from_str("{}").unwrap();
1486 assert_eq!(c.nudge.daily_cap, 3);
1487 }
1488
1489 #[test]
1490 fn storage_config_default_is_lancedb() {
1491 let c = StorageConfig::default();
1492 assert_eq!(c.vector_backend, "lancedb");
1493 assert_eq!(c.qdrant_url, None);
1494 assert_eq!(c.qdrant_api_key_ref, None);
1495 }
1496
1497 #[test]
1498 fn sources_global_config_has_sensible_defaults() {
1499 let c = SourcesGlobalConfig::default();
1500 assert_eq!(c.poll_interval_secs, 600);
1501 assert_eq!(c.max_chunks_per_sync, 10_000);
1502 assert_eq!(c.max_parallel_sources, 3);
1503 assert_eq!(c.default_weight, 1.0);
1504 assert_eq!(c.embedding_batch_size, 32);
1505 }
1506
1507 #[test]
1508 fn config_default_has_storage_and_sources_global() {
1509 let c = Config::default();
1510 assert_eq!(c.storage.vector_backend, "lancedb");
1511 assert_eq!(c.sources_global.default_weight, 1.0);
1512 }
1513
1514 #[test]
1515 fn config_loads_yaml_without_new_fields() {
1516 let yaml = r#"
1519embedding:
1520 provider: ollama
1521 model: test-model
1522 dimensions: 512
1523 ollama_endpoint: http://localhost:11434
1524"#;
1525 let c: Config = serde_yaml::from_str(yaml).expect("parses");
1526 assert_eq!(c.storage.vector_backend, "lancedb");
1527 assert_eq!(c.sources_global.max_parallel_sources, 3);
1528 }
1529
1530 #[test]
1531 fn llm_config_to_backend_config_anthropic_passthrough() {
1532 let cfg = LlmConfig {
1533 provider: "anthropic".into(),
1534 model: "claude-haiku-4-5".into(),
1535 api_key_env: Some("ANTHROPIC_API_KEY".into()),
1536 api_key_ref: None,
1537 openai_url: None,
1538 };
1539 let b = cfg.to_backend_config();
1540 assert_eq!(b.provider, "anthropic");
1541 assert_eq!(b.model, "claude-haiku-4-5");
1542 assert_eq!(b.api_key_env.as_deref(), Some("ANTHROPIC_API_KEY"));
1543 assert_eq!(b.endpoint, None);
1544 assert_eq!(b.timeout_secs, None);
1545 }
1546
1547 #[test]
1548 fn llm_config_to_backend_config_openai_url_maps_to_endpoint() {
1549 let cfg = LlmConfig {
1550 provider: "openai".into(),
1551 model: "gpt-4o-mini".into(),
1552 api_key_env: None,
1553 api_key_ref: None,
1554 openai_url: Some("https://api.together.xyz/v1".into()),
1555 };
1556 let b = cfg.to_backend_config();
1557 assert_eq!(b.provider, "openai");
1558 assert_eq!(b.endpoint.as_deref(), Some("https://api.together.xyz/v1"));
1559 assert_eq!(b.api_key_env, None); }
1561
1562 #[test]
1563 fn llm_config_to_backend_config_ollama_openai_url_maps_to_endpoint() {
1564 let cfg = LlmConfig {
1565 provider: "ollama".into(),
1566 model: "qwen3:14b".into(),
1567 api_key_env: None,
1568 api_key_ref: None,
1569 openai_url: Some("http://192.168.1.10:11434".into()),
1570 };
1571 let b = cfg.to_backend_config();
1572 assert_eq!(b.provider, "ollama");
1573 assert_eq!(b.endpoint.as_deref(), Some("http://192.168.1.10:11434"));
1574 }
1575
1576 #[test]
1577 fn llm_config_to_backend_config_unknown_with_openai_url_aliases_to_openai() {
1578 let cfg = LlmConfig {
1582 provider: "custom-name".into(),
1583 model: "some-model".into(),
1584 api_key_env: Some("CUSTOM_KEY".into()),
1585 api_key_ref: None,
1586 openai_url: Some("https://my-proxy.local/v1".into()),
1587 };
1588 let b = cfg.to_backend_config();
1589 assert_eq!(
1590 b.provider, "openai",
1591 "unknown provider + openai_url should alias to openai"
1592 );
1593 assert_eq!(b.endpoint.as_deref(), Some("https://my-proxy.local/v1"));
1594 }
1595
1596 #[test]
1597 fn api_key_ref_roundtrips_and_defaults_none() {
1598 let b: BackendConfig = serde_yaml_ng::from_str("provider: anthropic\nmodel: m\n").unwrap();
1600 assert_eq!(b.api_key_ref, None);
1601 let l: LlmConfig = serde_yaml_ng::from_str("provider: anthropic\nmodel: m\n").unwrap();
1602 assert_eq!(l.api_key_ref, None);
1603 let e: EmbeddingConfig = serde_yaml_ng::from_str("provider: ollama\nmodel: m\n").unwrap();
1604 assert_eq!(e.api_key_ref, None);
1605
1606 let mut l2 = LlmConfig::default();
1608 l2.api_key_ref = Some("keychain:mur/anthropic".into());
1609 let y = serde_yaml_ng::to_string(&l2).unwrap();
1610 let l3: LlmConfig = serde_yaml_ng::from_str(&y).unwrap();
1611 assert_eq!(l3.api_key_ref.as_deref(), Some("keychain:mur/anthropic"));
1612 assert_eq!(
1613 l3.to_backend_config().api_key_ref.as_deref(),
1614 Some("keychain:mur/anthropic")
1615 );
1616 }
1617
1618 #[test]
1619 fn open_items_muted_parses_and_defaults_empty() {
1620 let c: Config = serde_yaml::from_str("open_items:\n muted:\n - inbox\n").unwrap();
1621 assert_eq!(c.open_items.muted, vec!["inbox".to_string()]);
1622
1623 let d: Config = serde_yaml::from_str("llm:\n model: x\n").unwrap();
1624 assert!(d.open_items.muted.is_empty(), "must default to no mutes");
1625 }
1626
1627 #[test]
1630 fn unreadable_config_yields_no_mutes() {
1631 let tmp = tempfile::tempdir().unwrap();
1632 let path = tmp.path().join("config.yaml");
1633 std::fs::write(&path, "this: is: not: valid: yaml: [[[\n").unwrap();
1634 let cfg = Config::load_or_default(&path);
1635 assert!(
1636 cfg.open_items.muted.is_empty(),
1637 "a broken config must hide nothing"
1638 );
1639
1640 let missing = Config::load_or_default(&tmp.path().join("nope.yaml"));
1642 assert!(missing.open_items.muted.is_empty());
1643 }
1644
1645 #[test]
1646 fn rollup_config_accepts_backend_overrides() {
1647 let yaml = r#"
1648enabled: true
1649extractive_backend:
1650 provider: openai
1651 model: Qwen3.5-4B-MLX-4bit
1652 endpoint: http://127.0.0.1:8000/v1
1653"#;
1654 let c: RollupConfig = serde_yaml_ng::from_str(yaml).expect("parses");
1655 let b = c.extractive_backend.expect("override present");
1656 assert_eq!(b.provider, "openai");
1657 assert_eq!(b.model, "Qwen3.5-4B-MLX-4bit");
1658 assert_eq!(b.endpoint.as_deref(), Some("http://127.0.0.1:8000/v1"));
1659 assert!(c.abstractive_backend.is_none());
1660 }
1661
1662 #[test]
1663 fn legacy_conversation_fields_are_gone_from_serialized_output() {
1664 let cfg = Config::default();
1665 let yaml = serde_yaml_ng::to_string(&cfg.conversations).expect("serializes");
1671 for key in ["extractive_model", "abstractive_model", "ollama_endpoint"] {
1672 assert!(
1673 !yaml.contains(key),
1674 "legacy key {key} still serialized:\n{yaml}"
1675 );
1676 }
1677 }
1678
1679 #[test]
1680 fn embedding_ollama_endpoint_is_omitted_when_unset() {
1681 let mut cfg = Config::default();
1682 cfg.embedding.provider = "omlx".into();
1683 cfg.embedding.openai_url = Some("http://127.0.0.1:8000/v1".into());
1684 cfg.embedding.ollama_endpoint = None;
1685 let yaml = serde_yaml_ng::to_string(&cfg).expect("serializes");
1686 assert!(
1687 !yaml.contains("ollama_endpoint"),
1688 "dead field re-emitted:\n{yaml}"
1689 );
1690 }
1691
1692 #[test]
1693 fn embedding_ollama_endpoint_still_round_trips_when_set() {
1694 let yaml =
1695 "provider: ollama\nmodel: nomic-embed-text\nollama_endpoint: http://box.local:11434\n";
1696 let e: EmbeddingConfig = serde_yaml_ng::from_str(yaml).expect("parses");
1697 assert_eq!(e.ollama_endpoint.as_deref(), Some("http://box.local:11434"));
1698 }
1699}
1700
1701#[cfg(test)]
1702mod backend_config_tests {
1703 use super::*;
1704
1705 #[test]
1706 fn default_is_ollama_qwen3() {
1707 let cfg = BackendConfig::default();
1708 assert_eq!(cfg.provider, "ollama");
1709 assert_eq!(cfg.model, "qwen3.5:4b");
1710 assert_eq!(cfg.endpoint, None);
1711 assert_eq!(cfg.api_key_env, None);
1712 assert_eq!(cfg.timeout_secs, None);
1713 }
1714
1715 #[test]
1716 fn deserializes_anthropic_full() {
1717 let yaml = "\
1718provider: anthropic
1719model: claude-haiku-4-5
1720api_key_env: ANTHROPIC_API_KEY
1721timeout_secs: 60
1722";
1723 let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
1724 assert_eq!(cfg.provider, "anthropic");
1725 assert_eq!(cfg.model, "claude-haiku-4-5");
1726 assert_eq!(cfg.api_key_env, Some("ANTHROPIC_API_KEY".into()));
1727 assert_eq!(cfg.timeout_secs, Some(60));
1728 assert_eq!(cfg.endpoint, None);
1729 }
1730
1731 #[test]
1732 fn deserializes_partial_fills_defaults() {
1733 let yaml = "provider: anthropic\nmodel: claude-sonnet-5\n";
1734 let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
1735 assert_eq!(cfg.provider, "anthropic");
1736 assert_eq!(cfg.model, "claude-sonnet-5");
1737 assert_eq!(cfg.api_key_env, None);
1738 assert_eq!(cfg.timeout_secs, None);
1739 }
1740
1741 #[test]
1742 fn round_trips_through_yaml() {
1743 let original = BackendConfig {
1744 provider: "anthropic".into(),
1745 model: "claude-haiku-4-5".into(),
1746 endpoint: Some("https://api.anthropic.com".into()),
1747 api_key_env: Some("ANTHROPIC_API_KEY".into()),
1748 api_key_ref: None,
1749 timeout_secs: Some(60),
1750 };
1751 let yaml = serde_yaml::to_string(&original).unwrap();
1752 let parsed: BackendConfig = serde_yaml::from_str(&yaml).unwrap();
1753 assert_eq!(parsed, original);
1754 }
1755
1756 #[test]
1757 fn skills_config_curation_gate_defaults_on() {
1758 let c = SkillsConfig::default();
1759 assert!(c.require_human_curation_before_stable);
1760 }
1761}
1762
1763#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1771#[serde(rename_all = "lowercase")]
1772pub enum DevDisciplineIndex {
1773 #[default]
1775 Auto,
1776 Always,
1778 Never,
1780}
1781
1782#[derive(Debug, Clone, Serialize, Deserialize)]
1783#[serde(default)]
1784pub struct SkillsConfig {
1785 pub max_skills_in_prompt: usize,
1786 pub max_total_tokens: usize,
1787 pub priority_order: Vec<String>,
1788 pub adaptive: Option<AdaptiveSkillsConfig>,
1789
1790 #[serde(default = "default_require_human_curation")]
1794 pub require_human_curation_before_stable: bool,
1795
1796 #[serde(default)]
1800 pub lifecycle: SkillLifecycleConfig,
1801
1802 #[serde(default = "default_auto_upgrade")]
1807 pub auto_upgrade: bool,
1808
1809 #[serde(default)]
1811 pub dev_discipline_index: DevDisciplineIndex,
1812}
1813
1814fn default_require_human_curation() -> bool {
1815 true
1816}
1817
1818fn default_auto_upgrade() -> bool {
1819 true
1820}
1821
1822impl Default for SkillsConfig {
1823 fn default() -> Self {
1824 Self {
1825 max_skills_in_prompt: 5,
1826 max_total_tokens: 2000,
1827 priority_order: vec!["agent".into(), "global".into()],
1828 adaptive: Some(AdaptiveSkillsConfig::default()),
1829 require_human_curation_before_stable: default_require_human_curation(),
1830 lifecycle: SkillLifecycleConfig::default(),
1831 auto_upgrade: default_auto_upgrade(),
1832 dev_discipline_index: DevDisciplineIndex::default(),
1833 }
1834 }
1835}
1836
1837#[derive(Debug, Clone, Serialize, Deserialize)]
1843#[serde(default)]
1844pub struct SkillLifecycleConfig {
1845 pub note_rule_half_life_factor: f64,
1849 pub note_fact_half_life_factor: f64,
1852 pub promote_draft_uses: u64,
1854 pub promote_emerging_uses: u64,
1855 pub promote_emerging_success_rate: f64,
1856 pub promote_emerging_age_days: i64,
1857 pub promote_stable_uses: u64,
1858 pub promote_stable_success_rate: f64,
1859 pub promote_stable_age_days: i64,
1860
1861 pub demote_emerging_uses: u64,
1863 pub demote_emerging_success_rate: f64,
1864 pub demote_stable_uses: u64,
1865 pub demote_stable_success_rate: f64,
1866 pub deprecated_success_rate: f64,
1867 pub deprecated_no_success_days: i64,
1868
1869 pub auto_archive_confidence: f64,
1871 pub auto_archive_age_days: i64,
1872
1873 pub broken_workflow_streak: u32,
1878
1879 pub archive_destroy_grace_days: i64,
1884}
1885
1886impl Default for SkillLifecycleConfig {
1887 fn default() -> Self {
1888 Self {
1889 promote_draft_uses: 3,
1890 promote_emerging_uses: 10,
1891 promote_emerging_success_rate: 0.6,
1892 promote_emerging_age_days: 7,
1893 promote_stable_uses: 30,
1894 promote_stable_success_rate: 0.8,
1895 promote_stable_age_days: 30,
1896 demote_emerging_uses: 8,
1897 demote_emerging_success_rate: 0.55,
1898 demote_stable_uses: 25,
1899 demote_stable_success_rate: 0.75,
1900 deprecated_success_rate: 0.3,
1901 deprecated_no_success_days: 90,
1902 auto_archive_confidence: 0.10,
1903 auto_archive_age_days: 180,
1904 broken_workflow_streak: 3,
1905 archive_destroy_grace_days: 30,
1906 note_rule_half_life_factor: crate::skill::lifecycle::NOTE_RULE_HALF_LIFE_FACTOR,
1907 note_fact_half_life_factor: crate::skill::lifecycle::NOTE_FACT_HALF_LIFE_FACTOR,
1908 }
1909 }
1910}
1911
1912#[derive(Debug, Clone, Serialize, Deserialize)]
1913#[serde(default)]
1914pub struct AdaptiveSkillsConfig {
1915 pub context_fill_decay: f64,
1916 pub min_remaining_context_ratio: f64,
1917 pub recent_fire_boost_turns: usize,
1918 pub model_max_context_tokens: u64,
1922}
1923
1924impl Default for AdaptiveSkillsConfig {
1925 fn default() -> Self {
1926 Self {
1927 context_fill_decay: 1.5,
1928 min_remaining_context_ratio: 0.20,
1929 recent_fire_boost_turns: 5,
1930 model_max_context_tokens: 200_000,
1931 }
1932 }
1933}
1934
1935#[derive(Debug, Clone, Serialize, Deserialize)]
1938pub struct SleepCycleConfig {
1939 #[serde(default)]
1941 pub enabled: bool,
1942
1943 #[serde(default = "default_idle_threshold_minutes")]
1945 pub idle_threshold_minutes: u64,
1946
1947 #[serde(default = "default_agent_idle_minutes")]
1949 pub agent_idle_minutes: u64,
1950}
1951
1952fn default_idle_threshold_minutes() -> u64 {
1953 15
1954}
1955
1956fn default_agent_idle_minutes() -> u64 {
1957 5
1958}
1959
1960impl Default for SleepCycleConfig {
1961 fn default() -> Self {
1962 Self {
1963 enabled: false,
1964 idle_threshold_minutes: default_idle_threshold_minutes(),
1965 agent_idle_minutes: default_agent_idle_minutes(),
1966 }
1967 }
1968}
1969
1970#[derive(Debug, Clone, Serialize, Deserialize)]
1973pub struct NudgeConfig {
1974 #[serde(default = "default_nudge_enabled")]
1976 pub enabled: bool,
1977 #[serde(default = "default_nudge_daily_cap")]
1978 pub daily_cap: u32,
1979 #[serde(default = "default_nudge_snooze_days")]
1980 pub snooze_days: u32,
1981 #[serde(default = "default_nudge_threshold")]
1982 pub threshold: usize,
1983}
1984
1985fn default_nudge_enabled() -> bool {
1986 true
1987}
1988fn default_nudge_daily_cap() -> u32 {
1989 3
1990}
1991fn default_nudge_snooze_days() -> u32 {
1992 7
1993}
1994fn default_nudge_threshold() -> usize {
1995 3
1996}
1997
1998impl Default for NudgeConfig {
1999 fn default() -> Self {
2000 Self {
2001 enabled: true,
2002 daily_cap: default_nudge_daily_cap(),
2003 snooze_days: default_nudge_snooze_days(),
2004 threshold: default_nudge_threshold(),
2005 }
2006 }
2007}
2008
2009#[derive(Debug, Clone, Serialize, Deserialize)]
2013pub struct SessionCfg {
2014 #[serde(default = "default_capture_mode")]
2016 pub capture: String,
2017 #[serde(default = "default_retention_days")]
2019 pub retention_days: u32,
2020}
2021
2022impl Default for SessionCfg {
2023 fn default() -> Self {
2024 Self {
2025 capture: default_capture_mode(),
2026 retention_days: default_retention_days(),
2027 }
2028 }
2029}
2030
2031fn default_capture_mode() -> String {
2032 "ambient".to_string()
2033}
2034fn default_retention_days() -> u32 {
2035 14
2036}
2037
2038#[derive(Debug, Clone, Serialize, Deserialize)]
2040pub struct HarvestCfg {
2041 #[serde(default = "default_harvest_enabled")]
2043 pub auto_gate: bool,
2044 #[serde(default = "default_harvest_llm")]
2046 pub llm: String,
2047 #[serde(default = "default_min_events")]
2049 pub min_events: usize,
2050 #[serde(default = "default_min_user_turns")]
2051 pub min_user_turns: usize,
2052 #[serde(default = "default_min_duration_secs")]
2053 pub min_duration_secs: i64,
2054 #[serde(default = "default_idle_minutes")]
2056 pub idle_minutes: i64,
2057 #[serde(default = "default_max_steps")]
2060 pub max_steps: usize,
2061 #[serde(default = "default_max_duration_secs")]
2062 pub max_duration_secs: i64,
2063 #[serde(default = "default_max_llm_calls_per_day")]
2065 pub max_llm_calls_per_day: u32,
2066 #[serde(default = "default_max_extract_input_tokens")]
2067 pub max_extract_input_tokens: usize,
2068 #[serde(default = "default_harvest_enabled")]
2070 pub session_start_hint: bool,
2071 #[serde(default = "default_similarity_merge_threshold")]
2074 pub similarity_merge_threshold: f32,
2075 #[serde(default = "default_min_occurrences")]
2079 pub min_occurrences: usize,
2080}
2081
2082impl Default for HarvestCfg {
2083 fn default() -> Self {
2084 serde_yaml::from_str("{}").expect("HarvestCfg defaults")
2085 }
2086}
2087
2088fn default_harvest_enabled() -> bool {
2089 true
2090}
2091fn default_harvest_llm() -> String {
2092 "local-first".to_string()
2093}
2094fn default_min_events() -> usize {
2095 5
2096}
2097fn default_min_user_turns() -> usize {
2098 2
2099}
2100fn default_min_duration_secs() -> i64 {
2101 120
2102}
2103fn default_idle_minutes() -> i64 {
2104 30
2105}
2106fn default_max_steps() -> usize {
2110 20
2111}
2112fn default_max_duration_secs() -> i64 {
2115 1800
2116}
2117fn default_max_llm_calls_per_day() -> u32 {
2118 10
2119}
2120fn default_max_extract_input_tokens() -> usize {
2121 12000
2122}
2123fn default_similarity_merge_threshold() -> f32 {
2124 0.6
2125}
2126fn default_min_occurrences() -> usize {
2129 2
2130}
2131
2132#[derive(Debug, Clone, Serialize, Deserialize)]
2135#[serde(default)]
2136pub struct CrossAgentConfig {
2137 #[serde(default = "default_half_life_days")]
2138 pub fitness_half_life_days: u32,
2139 #[serde(default = "default_fitness_floor")]
2140 pub fitness_floor: f64,
2141}
2142
2143fn default_half_life_days() -> u32 {
2144 7
2145}
2146fn default_fitness_floor() -> f64 {
2147 0.1
2148}
2149
2150impl Default for CrossAgentConfig {
2151 fn default() -> Self {
2152 Self {
2153 fitness_half_life_days: default_half_life_days(),
2154 fitness_floor: default_fitness_floor(),
2155 }
2156 }
2157}
2158
2159#[derive(Debug, Clone, Serialize, Deserialize)]
2162#[serde(default)]
2163pub struct SkillLlmConfig {
2164 #[serde(default = "default_per_call_token_cap")]
2166 pub per_call_token_cap: u32,
2167
2168 #[serde(default = "default_per_day_usd_cap")]
2170 pub per_day_usd_cap: f64,
2171
2172 #[serde(default = "default_cache_ttl_days")]
2174 pub cache_ttl_days: u32,
2175
2176 #[serde(default, skip_serializing_if = "Option::is_none")]
2178 pub model_ref: Option<String>,
2179}
2180
2181fn default_per_call_token_cap() -> u32 {
2182 1500
2183}
2184fn default_per_day_usd_cap() -> f64 {
2185 0.50
2186}
2187fn default_cache_ttl_days() -> u32 {
2188 30
2189}
2190
2191impl Default for SkillLlmConfig {
2192 fn default() -> Self {
2193 Self {
2194 per_call_token_cap: default_per_call_token_cap(),
2195 per_day_usd_cap: default_per_day_usd_cap(),
2196 cache_ttl_days: default_cache_ttl_days(),
2197 model_ref: None,
2198 }
2199 }
2200}
2201#[cfg(test)]
2202mod per_stage_backend_tests {
2203 use super::*;
2204
2205 #[test]
2206 fn compact_extractive_backend_override_parses() {
2207 let yaml = "\
2208extractive_backend:
2209 provider: anthropic
2210 model: claude-haiku-4-5
2211 api_key_env: ANTHROPIC_API_KEY
2212";
2213 let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
2214 let extractive = cfg
2215 .extractive_backend
2216 .as_ref()
2217 .expect("override should parse");
2218 assert_eq!(extractive.provider, "anthropic");
2219 assert_eq!(extractive.model, "claude-haiku-4-5");
2220 assert!(cfg.abstractive_backend.is_none());
2221 }
2222
2223 #[test]
2224 fn ask_rewriter_backend_can_override_to_local_while_answer_is_cloud() {
2225 let yaml = "\
2226backend:
2227 provider: anthropic
2228 model: claude-sonnet-5
2229 api_key_env: ANTHROPIC_API_KEY
2230rewriter_backend:
2231 provider: ollama
2232 model: llama3.2:3b
2233";
2234 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
2235 assert_eq!(cfg.backend.as_ref().unwrap().provider, "anthropic");
2236 assert_eq!(cfg.rewriter_backend.as_ref().unwrap().provider, "ollama");
2237 }
2238
2239 #[test]
2240 fn rewriter_falls_through_to_answer_stage_backend_before_the_smart_slot() {
2241 let cfg = AskConfig {
2252 backend: Some(BackendConfig {
2253 provider: "anthropic".into(),
2254 model: "claude-sonnet-5".into(),
2255 endpoint: None,
2256 api_key_env: Some("ANTHROPIC_API_KEY".into()),
2257 api_key_ref: None,
2258 timeout_secs: None,
2259 }),
2260 ..Default::default()
2261 };
2262 let rewriter = cfg.effective_rewriter_backend(&omlx_llm());
2263 assert_eq!(rewriter.provider, "anthropic");
2264 assert_eq!(rewriter.model, "claude-sonnet-5");
2265 assert_eq!(
2266 rewriter.timeout_secs,
2267 Some(cfg.rewriter_timeout_secs as u64),
2268 "rewriter must keep its own tighter timeout even while following the answer stage's backend"
2269 );
2270 }
2271
2272 #[test]
2273 fn rewriter_explicit_override_timeout_wins_over_rewriter_timeout_secs() {
2274 let mut cfg = AskConfig {
2275 rewriter_timeout_secs: 8,
2276 ..AskConfig::default()
2277 };
2278 cfg.rewriter_backend = Some(BackendConfig {
2279 provider: "anthropic".into(),
2280 model: "claude-haiku-4-5".into(),
2281 endpoint: None,
2282 api_key_env: Some("ANTHROPIC_API_KEY".into()),
2283 api_key_ref: None,
2284 timeout_secs: Some(30),
2285 });
2286 let b = cfg.effective_rewriter_backend(&omlx_llm());
2287 assert_eq!(
2288 b.timeout_secs,
2289 Some(30),
2290 "explicit per-stage rewriter_backend override must NOT be overridden by ask.rewriter_timeout_secs"
2291 );
2292 }
2293
2294 fn omlx_llm() -> LlmConfig {
2295 LlmConfig {
2296 provider: "omlx".into(),
2297 model: "Qwen3.5-4B-MLX-4bit".into(),
2298 api_key_env: None,
2299 api_key_ref: Some("env:OMLX_API_KEY".into()),
2300 openai_url: Some("http://127.0.0.1:8000/v1".into()),
2301 }
2302 }
2303
2304 #[test]
2305 fn ask_without_override_inherits_smart_slot_and_maps_omlx_to_openai() {
2306 let ask = AskConfig::default();
2307 let b = ask.effective_backend(&omlx_llm());
2308 assert_eq!(b.provider, "openai");
2309 assert_eq!(b.model, "Qwen3.5-4B-MLX-4bit");
2310 assert_eq!(b.endpoint.as_deref(), Some("http://127.0.0.1:8000/v1"));
2311 assert_eq!(b.api_key_ref.as_deref(), Some("env:OMLX_API_KEY"));
2312 assert_eq!(b.timeout_secs, Some(ask.timeout_secs as u64));
2314 }
2315
2316 #[test]
2317 fn ask_rewriter_inherits_its_own_shorter_timeout_not_the_answer_one() {
2318 let ask = AskConfig::default();
2319 let b = ask.effective_rewriter_backend(&omlx_llm());
2320 assert_eq!(b.timeout_secs, Some(ask.rewriter_timeout_secs as u64));
2321 assert_ne!(b.timeout_secs, Some(ask.timeout_secs as u64));
2322 }
2323
2324 #[test]
2325 fn explicit_override_wins_over_the_smart_slot() {
2326 let ask = AskConfig {
2327 backend: Some(BackendConfig {
2328 provider: "anthropic".into(),
2329 model: "claude-haiku-4-5".into(),
2330 endpoint: None,
2331 api_key_env: None,
2332 api_key_ref: None,
2333 timeout_secs: Some(42),
2334 }),
2335 ..Default::default()
2336 };
2337 let b = ask.effective_backend(&omlx_llm());
2338 assert_eq!(b.provider, "anthropic");
2339 assert_eq!(b.timeout_secs, Some(42));
2340 }
2341
2342 #[test]
2343 fn compact_and_rollup_inherit_smart_slot_with_the_120s_budget() {
2344 let llm = omlx_llm();
2345 for b in [
2346 CompactConfig::default().effective_extractive_backend(&llm),
2347 CompactConfig::default().effective_abstractive_backend(&llm),
2348 RollupConfig::default().effective_extractive_backend(&llm),
2349 RollupConfig::default().effective_abstractive_backend(&llm),
2350 ] {
2351 assert_eq!(b.provider, "openai");
2352 assert_eq!(b.endpoint.as_deref(), Some("http://127.0.0.1:8000/v1"));
2353 assert_eq!(b.timeout_secs, Some(120));
2354 }
2355 }
2356
2357 #[test]
2358 fn rollup_override_is_honored() {
2359 let r = RollupConfig {
2360 abstractive_backend: Some(BackendConfig {
2361 provider: "ollama".into(),
2362 model: "qwen3:4b".into(),
2363 endpoint: Some("http://box.local:11434".into()),
2364 api_key_env: None,
2365 api_key_ref: None,
2366 timeout_secs: None,
2367 }),
2368 ..Default::default()
2369 };
2370 let b = r.effective_abstractive_backend(&omlx_llm());
2371 assert_eq!(b.provider, "ollama");
2372 assert_eq!(b.endpoint.as_deref(), Some("http://box.local:11434"));
2373 }
2374}
2375
2376#[cfg(test)]
2377mod skills_config_tests {
2378 use super::*;
2379
2380 #[test]
2381 fn empty_yaml_hydrates_defaults() {
2382 let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
2383 assert_eq!(cfg.skills.max_skills_in_prompt, 5);
2384 assert_eq!(cfg.skills.max_total_tokens, 2000);
2385 assert!(cfg.skills.adaptive.is_some());
2386 }
2387
2388 #[test]
2389 fn load_or_default_missing_file_returns_default() {
2390 let cfg = Config::load_or_default(std::path::Path::new("/nonexistent/config.yaml"));
2391 assert_eq!(cfg.skills.max_skills_in_prompt, 5);
2392 }
2393
2394 #[test]
2395 fn dev_discipline_index_defaults_auto_and_parses() {
2396 use crate::config::DevDisciplineIndex;
2397 let cfg: Config = serde_yaml_ng::from_str("").unwrap_or_default();
2398 assert_eq!(cfg.skills.dev_discipline_index, DevDisciplineIndex::Auto);
2399 let cfg: Config =
2400 serde_yaml_ng::from_str("skills:\n dev_discipline_index: never\n").unwrap();
2401 assert_eq!(cfg.skills.dev_discipline_index, DevDisciplineIndex::Never);
2402 let cfg: Config =
2403 serde_yaml_ng::from_str("skills:\n dev_discipline_index: always\n").unwrap();
2404 assert_eq!(cfg.skills.dev_discipline_index, DevDisciplineIndex::Always);
2405 }
2406}
2407
2408#[cfg(test)]
2409mod ambient_capture_cfg_tests {
2410 use super::*;
2411
2412 #[test]
2413 fn session_and_harvest_defaults() {
2414 let cfg: Config = serde_yaml::from_str("{}").unwrap();
2415 assert_eq!(cfg.session.capture, "ambient");
2416 assert_eq!(cfg.session.retention_days, 14);
2417 assert!(cfg.harvest.auto_gate);
2418 assert_eq!(cfg.harvest.llm, "local-first");
2419 assert_eq!(cfg.harvest.min_events, 5);
2420 assert_eq!(cfg.harvest.min_user_turns, 2);
2421 assert_eq!(cfg.harvest.min_duration_secs, 120);
2422 assert_eq!(cfg.harvest.idle_minutes, 30);
2423 assert_eq!(cfg.harvest.max_llm_calls_per_day, 10);
2424 assert_eq!(cfg.harvest.max_extract_input_tokens, 12000);
2425 assert!(cfg.harvest.session_start_hint);
2426 assert!((cfg.harvest.similarity_merge_threshold - 0.6).abs() < f32::EPSILON);
2427 }
2428
2429 #[test]
2430 fn session_capture_override_parses() {
2431 let cfg: Config =
2432 serde_yaml::from_str("session:\n capture: off\n retention_days: 3\n").unwrap();
2433 assert_eq!(cfg.session.capture, "off");
2434 assert_eq!(cfg.session.retention_days, 3);
2435 }
2436}
2437
2438#[cfg(test)]
2439mod cc_proxy_cfg_tests {
2440 use super::*;
2441
2442 #[test]
2443 fn defaults_to_local_cc_proxy_enabled() {
2444 let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
2445 assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:8088");
2446 assert!(cfg.cc_proxy.enabled);
2447 }
2448
2449 #[test]
2450 fn url_and_enabled_override_parse() {
2451 let cfg: Config =
2452 serde_yaml_ng::from_str("cc_proxy:\n url: http://127.0.0.1:9999\n enabled: false\n")
2453 .unwrap();
2454 assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:9999");
2455 assert!(!cfg.cc_proxy.enabled);
2456 }
2457
2458 #[test]
2459 fn partial_section_keeps_other_default() {
2460 let cfg: Config = serde_yaml_ng::from_str("cc_proxy:\n enabled: false\n").unwrap();
2462 assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:8088");
2463 assert!(!cfg.cc_proxy.enabled);
2464 }
2465}
2466
2467#[cfg(test)]
2468mod model_switch_config_tests {
2469 use super::*;
2470
2471 #[test]
2472 fn model_switch_config_defaults_and_omitted_block() {
2473 let cfg: Config = serde_yaml::from_str("{}").unwrap();
2475 assert_eq!(cfg.models.default, None);
2476 assert!(cfg.models.fallback_chain.is_empty());
2477 assert_eq!(cfg.models.retry.max_retries, DEFAULT_MAX_RETRIES);
2478 assert_eq!(cfg.models.retry.backoff_base_ms, DEFAULT_BACKOFF_BASE_MS);
2479 assert_eq!(cfg.models.retry.cooldown_secs, DEFAULT_COOLDOWN_SECS);
2480 assert!(!cfg.models.routing.enabled);
2481
2482 let yaml = "models:\n default: claude_sonnet\n fallback_chain: [claude_sonnet, deepseek_v4_pro]\n routing:\n enabled: true\n cheap: deepseek_v4_flash\n frontier: claude_opus\n threshold_input_tokens: 1500\n";
2484 let cfg: Config = serde_yaml::from_str(yaml).unwrap();
2485 assert_eq!(cfg.models.default.as_deref(), Some("claude_sonnet"));
2486 assert_eq!(
2487 cfg.models.fallback_chain,
2488 vec!["claude_sonnet", "deepseek_v4_pro"]
2489 );
2490 assert!(cfg.models.routing.enabled);
2491 assert_eq!(cfg.models.routing.threshold_input_tokens, Some(1500));
2492 }
2493
2494 #[test]
2495 fn smart_config_defaults_on_with_autopick() {
2496 let cfg: Config = serde_yaml::from_str("{}").unwrap();
2497 assert!(cfg.models.smart.enabled); assert_eq!(cfg.models.smart.cheap, None); assert_eq!(
2500 cfg.models.smart.max_escalations,
2501 DEFAULT_SMART_MAX_ESCALATIONS
2502 );
2503 }
2504}