Skip to main content

mur_common/config/
mod.rs

1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3
4mod attribution;
5mod backend;
6mod conversations;
7mod ops;
8mod scratch;
9mod search;
10mod skills;
11mod turn_track;
12
13pub use attribution::*;
14pub use backend::*;
15pub use conversations::*;
16pub use ops::*;
17pub use scratch::*;
18pub use search::*;
19pub use skills::*;
20pub use turn_track::*;
21
22use ops::{
23    default_heartbeat_interval_secs, default_heartbeat_stale_after_intervals, default_rotate_at_mb,
24};
25
26pub const DEFAULT_LOCAL_LLM_MODEL: &str = "qwen3.5:4b";
27
28/// Default model id seeded for the built-in "Mur" agent and used to name the
29/// bundled MLX weights. This is the DEFAULT VALUE only — it is written into the
30/// seed agent's profile and can be changed by the user afterwards; it is not a
31/// behavioural constant baked into logic.
32pub const DEFAULT_BUNDLED_MODEL_ID: &str = "Qwen3.5-2B-MLX-4bit";
33
34pub const DEFAULT_MAX_RETRIES: u32 = 1;
35pub const DEFAULT_BACKOFF_BASE_MS: u64 = 500;
36pub const DEFAULT_COOLDOWN_SECS: u64 = 60;
37pub const DEFAULT_ROUTING_THRESHOLD: u32 = 2000;
38pub const DEFAULT_SMART_MAX_ESCALATIONS: u32 = 1;
39
40/// Smart background routing is opt-in. Its failure mode is silent and
41/// irreversible for the turn it degrades, which is the kind of automation that
42/// has to be asked for. See the capability-gate spec §2.
43pub const DEFAULT_SMART_ENABLED: bool = false;
44
45/// The Ollama provider default endpoint. Single definition — `BackendConfig`
46/// resolution (`default_ollama_endpoint`), `config_migrate`, and the
47/// conversations `doctor`/`preflight` probes all read this constant instead of
48/// repeating the literal.
49pub const DEFAULT_OLLAMA_ENDPOINT: &str = "http://localhost:11434";
50
51fn default_max_retries() -> u32 {
52    DEFAULT_MAX_RETRIES
53}
54fn default_backoff_base_ms() -> u64 {
55    DEFAULT_BACKOFF_BASE_MS
56}
57fn default_cooldown_secs() -> u64 {
58    DEFAULT_COOLDOWN_SECS
59}
60fn default_smart_max_escalations() -> u32 {
61    DEFAULT_SMART_MAX_ESCALATIONS
62}
63fn default_smart_enabled() -> bool {
64    DEFAULT_SMART_ENABLED
65}
66
67/// Smart background routing: auto-pick a cheap model for low-stakes/background
68/// requests instead of always dialing the agent's primary model_ref. Defaults
69/// OFF — enable it globally (`mur model smart on`) or per agent. `cheap: None`
70/// auto-picks the cheapest registry entry that can serve the request
71/// (`mur_common::model::pick_cheap_model`).
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
73pub struct SmartConfig {
74    #[serde(default = "default_smart_enabled")]
75    pub enabled: bool,
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub cheap: Option<String>,
78    #[serde(default = "default_smart_max_escalations")]
79    pub max_escalations: u32,
80}
81
82impl Default for SmartConfig {
83    fn default() -> Self {
84        Self {
85            enabled: DEFAULT_SMART_ENABLED,
86            cheap: None,
87            max_escalations: DEFAULT_SMART_MAX_ESCALATIONS,
88        }
89    }
90}
91
92/// Config-layered model selection + failure fallback. See
93/// docs/superpowers/specs/2026-07-12-intelligent-model-switch-design.md.
94#[derive(Debug, Clone, Serialize, Deserialize, Default)]
95pub struct ModelSwitchConfig {
96    /// Global default model_ref when an agent has no `model_ref`.
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub default: Option<String>,
99    /// Global fallback chain (ordered model_refs).
100    #[serde(default, skip_serializing_if = "Vec::is_empty")]
101    pub fallback_chain: Vec<String>,
102    #[serde(default)]
103    pub retry: RetryConfig,
104    #[serde(default)]
105    pub routing: RoutingConfig,
106    #[serde(default)]
107    pub smart: SmartConfig,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct RetryConfig {
112    #[serde(default = "default_max_retries")]
113    pub max_retries: u32,
114    #[serde(default = "default_backoff_base_ms")]
115    pub backoff_base_ms: u64,
116    #[serde(default = "default_cooldown_secs")]
117    pub cooldown_secs: u64,
118}
119
120impl Default for RetryConfig {
121    fn default() -> Self {
122        Self {
123            max_retries: DEFAULT_MAX_RETRIES,
124            backoff_base_ms: DEFAULT_BACKOFF_BASE_MS,
125            cooldown_secs: DEFAULT_COOLDOWN_SECS,
126        }
127    }
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
131pub struct RoutingConfig {
132    #[serde(default)]
133    pub enabled: bool,
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub cheap: Option<String>,
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub frontier: Option<String>,
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub threshold_input_tokens: Option<u32>,
140}
141
142/// Partial view of [`SmartConfig`] for per-agent overrides: `None` on a field
143/// means "inherit the global value". A distinct type rather than reusing
144/// `SmartConfig`, because a full config standing in for a partial is exactly
145/// what made an omitted field silently mean `false` instead of "unset".
146#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
147pub struct SmartOverride {
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub enabled: Option<bool>,
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub cheap: Option<String>,
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub max_escalations: Option<u32>,
154}
155
156/// Partial view of [`RoutingConfig`]. Same inheritance rule as
157/// [`SmartOverride`].
158#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
159pub struct RoutingOverride {
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub enabled: Option<bool>,
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub cheap: Option<String>,
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub frontier: Option<String>,
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub threshold_input_tokens: Option<u32>,
168    /// Legacy nesting. Smart used to be overridden at `routing.smart`;
169    /// profiles written before the promotion to `AgentProfile.smart` — and
170    /// every exported `.muragent` bundle — still carry it, so it stays
171    /// readable forever. MUR never writes it.
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub smart: Option<SmartOverride>,
174}
175
176impl SmartConfig {
177    /// Global values with an agent's override layered on, field by field.
178    pub fn merged(&self, ov: Option<&SmartOverride>) -> SmartConfig {
179        let Some(o) = ov else { return self.clone() };
180        SmartConfig {
181            enabled: o.enabled.unwrap_or(self.enabled),
182            cheap: o.cheap.clone().or_else(|| self.cheap.clone()),
183            max_escalations: o.max_escalations.unwrap_or(self.max_escalations),
184        }
185    }
186}
187
188impl RoutingConfig {
189    /// Global values with an agent's override layered on, field by field.
190    pub fn merged(&self, ov: Option<&RoutingOverride>) -> RoutingConfig {
191        let Some(o) = ov else { return self.clone() };
192        RoutingConfig {
193            enabled: o.enabled.unwrap_or(self.enabled),
194            cheap: o.cheap.clone().or_else(|| self.cheap.clone()),
195            frontier: o.frontier.clone().or_else(|| self.frontier.clone()),
196            threshold_input_tokens: o.threshold_input_tokens.or(self.threshold_input_tokens),
197        }
198    }
199}
200
201/// Global MUR configuration (~/.mur/config.yaml)
202#[derive(Debug, Clone, Serialize, Deserialize, Default)]
203pub struct Config {
204    #[serde(default)]
205    pub embedding: EmbeddingConfig,
206
207    #[serde(default)]
208    pub llm: LlmConfig,
209
210    #[serde(default)]
211    pub models: ModelSwitchConfig,
212
213    #[serde(default)]
214    pub retrieval: RetrievalConfig,
215
216    #[serde(default)]
217    pub paths: PathConfig,
218
219    #[serde(default)]
220    pub server: ServerConfig,
221
222    #[serde(default)]
223    pub community: CommunityConfig,
224
225    #[serde(default)]
226    pub conversations: ConversationsConfig,
227
228    #[serde(default)]
229    pub sync: SyncConfig,
230
231    // --- P1.1 additions ---
232    #[serde(default)]
233    pub storage: StorageConfig,
234
235    #[serde(default)]
236    pub sources_global: SourcesGlobalConfig,
237
238    // --- E3 additions ---
239    #[serde(default)]
240    pub sleep_cycle: SleepCycleConfig,
241
242    // --- M2 additions ---
243    #[serde(default)]
244    pub skills: SkillsConfig,
245
246    // --- M6c additions ---
247    #[serde(default)]
248    pub skill_llm: SkillLlmConfig,
249
250    // --- M7a additions ---
251    #[serde(default)]
252    pub cross_agent: CrossAgentConfig,
253
254    // --- nudge additions ---
255    #[serde(default)]
256    pub nudge: NudgeConfig,
257
258    // --- mobile P4 additions ---
259    #[serde(default)]
260    pub mobile_relay: MobileRelayConfig,
261
262    // --- Ambient capture & harvest (2026-06-11 spec) ---
263    #[serde(default)]
264    pub session: SessionCfg,
265
266    #[serde(default)]
267    pub harvest: HarvestCfg,
268
269    // --- OAuth bridge (cc-proxy) routing for subscription tokens ---
270    #[serde(default)]
271    pub cc_proxy: CcProxyConfig,
272
273    // --- Agent CLI TUI ---
274    #[serde(default)]
275    pub cli: CliConfig,
276
277    // --- parallel_jobs MCP tool ---
278    #[serde(default)]
279    pub parallel_jobs: ParallelJobsConfig,
280
281    /// Memory-federation snapshot settings (`federation_snapshot:`).
282    #[serde(default)]
283    pub federation_snapshot: SnapshotConfig,
284
285    /// Proactive memory capture (`memory:`, federation P2).
286    #[serde(default)]
287    pub memory: MemoryConfig,
288
289    // --- fleet_run runtime built-in tool ---
290    #[serde(default)]
291    pub fleet_run: FleetRunConfig,
292
293    // --- git push broker (agent-side tools + daemon sweeper) ---
294    #[serde(default)]
295    pub git_push: GitPushConfig,
296
297    // --- `mur open` display policy ---
298    #[serde(default)]
299    pub open_items: OpenItemsConfig,
300
301    // --- Hub Fleet Manager redesign ---
302    #[serde(default)]
303    pub fleet: FleetConfig,
304
305    /// `mur update` post-upgrade behavior (`update:`, issue #866).
306    #[serde(default)]
307    pub update: UpdateConfig,
308
309    /// Job/fleet/workflow run-status heartbeat tuning (`runs:`).
310    #[serde(default)]
311    pub runs: RunsConfig,
312
313    /// Capture-queue rotation (`capture:`).
314    #[serde(default)]
315    pub capture: CaptureConfig,
316
317    /// Global execution limits (`limits:`), the outermost scope of spec
318    /// 2026-09-12 §3.1. Written textually by `mur limits --global`, never by
319    /// load-modify-save (that drops blocks other binaries own).
320    #[serde(default)]
321    pub limits: crate::limits::Limits,
322
323    /// Durable-monitor notification channels (`notifications:`). Absent
324    /// means log-only: `desktop` is opt-in.
325    #[serde(default)]
326    pub notifications: NotificationsConfig,
327
328    /// AgentResolver (`monitor_resolver:`), spec §混合處置策略 step 3.
329    /// Absent means off — see [`MonitorResolverConfig`].
330    #[serde(default)]
331    pub monitor_resolver: MonitorResolverConfig,
332
333    /// Pre-dispatch triage (`triage:`). Absent means shadow mode: triage
334    /// runs and records, and never stops a dispatch.
335    #[serde(default)]
336    pub triage: TriageConfig,
337
338    /// MUR's credit line on work an agent publishes (`attribution:`).
339    #[serde(default)]
340    pub attribution: AttributionConfig,
341
342    /// Per-agent scratch dir retention and doctor threshold (`scratch:`).
343    #[serde(default)]
344    pub scratch: ScratchConfig,
345
346    /// Per-turn copy-on-write track tuning (`turn_track:`).
347    #[serde(default)]
348    pub turn_track: TurnTrackConfig,
349
350    /// Code-search tool bounds (`search:`), e.g. `search.ast_grep`.
351    #[serde(default)]
352    pub search: SearchConfig,
353}
354
355impl Config {
356    /// Read from disk, falling back to defaults. Legacy conversation model
357    /// fields are migrated **in memory only** — this is called from agent
358    /// runtime processes (`mur-agent-runtime`), which must never write the
359    /// user's config file.
360    pub fn load_or_default(path: &std::path::Path) -> Self {
361        let Ok(text) = std::fs::read_to_string(path) else {
362            return Self::default();
363        };
364        let text = crate::config_migrate::migrate_conversations_yaml(&text).unwrap_or(text);
365        let mut cfg: Self = serde_yaml_ng::from_str(&text).unwrap_or_default();
366        cfg.sanitize();
367        cfg
368    }
369
370    /// Like [`Config::load_or_default`], but a file that is present and does
371    /// not parse is an error rather than silently the defaults. For a reader
372    /// that already holds a good copy and must not trade it for defaults
373    /// (the agent runtime's hot reload). An absent file is still the defaults.
374    pub fn try_load(path: &std::path::Path) -> Result<Self, String> {
375        let text = match std::fs::read_to_string(path) {
376            Ok(t) => t,
377            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Self::default()),
378            Err(e) => return Err(format!("{}: {e}", path.display())),
379        };
380        let text = crate::config_migrate::migrate_conversations_yaml(&text).unwrap_or(text);
381        let mut cfg: Self =
382            serde_yaml_ng::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))?;
383        cfg.sanitize();
384        Ok(cfg)
385    }
386
387    /// Clamp values that are legal YAML but illegal at runtime. Called once
388    /// from [`Config::load_or_default`] — never from call sites, so every
389    /// reader (CLI, executor heartbeat ticker, `status_of`'s stale
390    /// threshold) sees the sanitized value.
391    fn sanitize(&mut self) {
392        // A zero interval would make the stale threshold zero (a healthy
393        // run instantly reports STALLED) and `tokio::time::interval` panics
394        // on a zero period — both from one user-edited line.
395        if self.runs.heartbeat_interval_secs == 0 {
396            self.runs.heartbeat_interval_secs = default_heartbeat_interval_secs();
397        }
398        // A zero rotate size would rotate on every single append.
399        if self.capture.rotate_at_mb == 0 {
400            self.capture.rotate_at_mb = default_rotate_at_mb();
401        }
402        if self.runs.heartbeat_stale_after_intervals == 0 {
403            self.runs.heartbeat_stale_after_intervals = default_heartbeat_stale_after_intervals();
404        }
405    }
406}
407
408#[cfg(test)]
409mod tests {
410    #[test]
411    fn update_defaults_apply_when_block_absent_and_fields_parse() {
412        let c: super::Config = serde_yaml_ng::from_str("{}").unwrap();
413        assert_eq!(c.update, super::UpdateConfig::default());
414        assert!(c.update.codesign_identity.is_none());
415        assert!(c.update.restart_exclude.is_empty());
416
417        let c: super::Config = serde_yaml_ng::from_str(
418            "update:\n  codesign_identity: \"Developer ID Application: X (TEAM)\"\n  restart_exclude: [dr_worker_1]\n",
419        )
420        .unwrap();
421        assert_eq!(
422            c.update.codesign_identity.as_deref(),
423            Some("Developer ID Application: X (TEAM)")
424        );
425        assert_eq!(c.update.restart_exclude, vec!["dr_worker_1".to_string()]);
426    }
427
428    #[test]
429    fn federation_snapshot_defaults_apply_when_block_absent() {
430        let c: super::Config = serde_yaml_ng::from_str("{}").unwrap();
431        assert_eq!(c.federation_snapshot.poll_secs, 30);
432        assert_eq!(c.federation_snapshot.request_max_age_secs, 600);
433        assert_eq!(
434            c.federation_snapshot.min_lifecycle,
435            crate::skill::stats::LifecycleState::Stable
436        );
437        // Memory capture (P2a): defaults to auto_announce; `off` parses.
438        assert_eq!(c.memory.capture, super::CaptureMode::AutoAnnounce);
439        let c: super::Config = serde_yaml_ng::from_str("memory:\n  capture: off\n").unwrap();
440        assert_eq!(c.memory.capture, super::CaptureMode::Off);
441    }
442
443    use super::*;
444
445    #[test]
446    fn default_bundled_model_id_is_qwen35_2b() {
447        assert_eq!(
448            crate::config::DEFAULT_BUNDLED_MODEL_ID,
449            "Qwen3.5-2B-MLX-4bit"
450        );
451    }
452
453    #[test]
454    fn nudge_config_defaults() {
455        let c = NudgeConfig::default();
456        assert!(c.enabled);
457        assert_eq!(c.daily_cap, 3);
458        assert_eq!(c.snooze_days, 7);
459        assert_eq!(c.threshold, 3);
460    }
461
462    #[test]
463    fn config_has_nudge_section_with_defaults() {
464        let c: Config = serde_yaml_ng::from_str("{}").unwrap();
465        assert_eq!(c.nudge.daily_cap, 3);
466    }
467
468    #[test]
469    fn storage_config_default_is_lancedb() {
470        let c = StorageConfig::default();
471        assert_eq!(c.vector_backend, "lancedb");
472        assert_eq!(c.qdrant_url, None);
473        assert_eq!(c.qdrant_api_key_ref, None);
474    }
475
476    #[test]
477    fn sources_global_config_has_sensible_defaults() {
478        let c = SourcesGlobalConfig::default();
479        assert_eq!(c.poll_interval_secs, 600);
480        assert_eq!(c.max_chunks_per_sync, 10_000);
481        assert_eq!(c.max_parallel_sources, 3);
482        assert_eq!(c.default_weight, 1.0);
483        assert_eq!(c.embedding_batch_size, 32);
484    }
485
486    #[test]
487    fn config_default_has_storage_and_sources_global() {
488        let c = Config::default();
489        assert_eq!(c.storage.vector_backend, "lancedb");
490        assert_eq!(c.sources_global.default_weight, 1.0);
491    }
492
493    #[test]
494    fn config_loads_yaml_without_new_fields() {
495        // Existing users' config.yaml won't mention storage or sources_global.
496        // It must still parse.
497        let yaml = r#"
498embedding:
499  provider: ollama
500  model: test-model
501  dimensions: 512
502  ollama_endpoint: http://localhost:11434
503"#;
504        let c: Config = serde_yaml::from_str(yaml).expect("parses");
505        assert_eq!(c.storage.vector_backend, "lancedb");
506        assert_eq!(c.sources_global.max_parallel_sources, 3);
507    }
508
509    #[test]
510    fn llm_config_to_backend_config_anthropic_passthrough() {
511        let cfg = LlmConfig {
512            provider: "anthropic".into(),
513            model: "claude-haiku-4-5".into(),
514            api_key_env: Some("ANTHROPIC_API_KEY".into()),
515            api_key_ref: None,
516            openai_url: None,
517        };
518        let b = cfg.to_backend_config();
519        assert_eq!(b.provider, "anthropic");
520        assert_eq!(b.model, "claude-haiku-4-5");
521        assert_eq!(b.api_key_env.as_deref(), Some("ANTHROPIC_API_KEY"));
522        assert_eq!(b.endpoint, None);
523        assert_eq!(b.timeout_secs, None);
524    }
525
526    #[test]
527    fn llm_config_to_backend_config_openai_url_maps_to_endpoint() {
528        let cfg = LlmConfig {
529            provider: "openai".into(),
530            model: "gpt-4o-mini".into(),
531            api_key_env: None,
532            api_key_ref: None,
533            openai_url: Some("https://api.together.xyz/v1".into()),
534        };
535        let b = cfg.to_backend_config();
536        assert_eq!(b.provider, "openai");
537        assert_eq!(b.endpoint.as_deref(), Some("https://api.together.xyz/v1"));
538        assert_eq!(b.api_key_env, None); // factory will fall back to OPENAI_API_KEY
539    }
540
541    #[test]
542    fn llm_config_to_backend_config_ollama_openai_url_maps_to_endpoint() {
543        let cfg = LlmConfig {
544            provider: "ollama".into(),
545            model: "qwen3:14b".into(),
546            api_key_env: None,
547            api_key_ref: None,
548            openai_url: Some("http://192.168.1.10:11434".into()),
549        };
550        let b = cfg.to_backend_config();
551        assert_eq!(b.provider, "ollama");
552        assert_eq!(b.endpoint.as_deref(), Some("http://192.168.1.10:11434"));
553    }
554
555    #[test]
556    fn llm_config_to_backend_config_unknown_with_openai_url_aliases_to_openai() {
557        // Historical LlmConfig allowed provider="custom" + openai_url to act as
558        // an OpenAI-compatible passthrough. Preserve that by re-tagging as
559        // "openai" so factory dispatches to OpenAIBackend.
560        let cfg = LlmConfig {
561            provider: "custom-name".into(),
562            model: "some-model".into(),
563            api_key_env: Some("CUSTOM_KEY".into()),
564            api_key_ref: None,
565            openai_url: Some("https://my-proxy.local/v1".into()),
566        };
567        let b = cfg.to_backend_config();
568        assert_eq!(
569            b.provider, "openai",
570            "unknown provider + openai_url should alias to openai"
571        );
572        assert_eq!(b.endpoint.as_deref(), Some("https://my-proxy.local/v1"));
573    }
574
575    #[test]
576    fn api_key_ref_roundtrips_and_defaults_none() {
577        // Old YAML without the field still parses, field defaults to None.
578        let b: BackendConfig = serde_yaml_ng::from_str("provider: anthropic\nmodel: m\n").unwrap();
579        assert_eq!(b.api_key_ref, None);
580        let l: LlmConfig = serde_yaml_ng::from_str("provider: anthropic\nmodel: m\n").unwrap();
581        assert_eq!(l.api_key_ref, None);
582        let e: EmbeddingConfig = serde_yaml_ng::from_str("provider: ollama\nmodel: m\n").unwrap();
583        assert_eq!(e.api_key_ref, None);
584
585        // Set → survives YAML round-trip and to_backend_config.
586        let l2 = LlmConfig {
587            api_key_ref: Some("keychain:mur/anthropic".into()),
588            ..Default::default()
589        };
590        let y = serde_yaml_ng::to_string(&l2).unwrap();
591        let l3: LlmConfig = serde_yaml_ng::from_str(&y).unwrap();
592        assert_eq!(l3.api_key_ref.as_deref(), Some("keychain:mur/anthropic"));
593        assert_eq!(
594            l3.to_backend_config().api_key_ref.as_deref(),
595            Some("keychain:mur/anthropic")
596        );
597    }
598
599    #[test]
600    fn open_items_muted_parses_and_defaults_empty() {
601        let c: Config = serde_yaml::from_str("open_items:\n  muted:\n    - inbox\n").unwrap();
602        assert_eq!(c.open_items.muted, vec!["inbox".to_string()]);
603
604        let d: Config = serde_yaml::from_str("llm:\n  model: x\n").unwrap();
605        assert!(d.open_items.muted.is_empty(), "must default to no mutes");
606    }
607
608    /// Fail toward showing. A config that will not parse must yield an empty
609    /// mute set, never a quiet, confident, incomplete list.
610    #[test]
611    fn unreadable_config_yields_no_mutes() {
612        let tmp = tempfile::tempdir().unwrap();
613        let path = tmp.path().join("config.yaml");
614        std::fs::write(&path, "this: is: not: valid: yaml: [[[\n").unwrap();
615        let cfg = Config::load_or_default(&path);
616        assert!(
617            cfg.open_items.muted.is_empty(),
618            "a broken config must hide nothing"
619        );
620
621        // Same for a config that is simply absent.
622        let missing = Config::load_or_default(&tmp.path().join("nope.yaml"));
623        assert!(missing.open_items.muted.is_empty());
624    }
625
626    #[test]
627    fn rollup_config_accepts_backend_overrides() {
628        let yaml = r#"
629enabled: true
630extractive_backend:
631  provider: openai
632  model: Qwen3.5-4B-MLX-4bit
633  endpoint: http://127.0.0.1:8000/v1
634"#;
635        let c: RollupConfig = serde_yaml_ng::from_str(yaml).expect("parses");
636        let b = c.extractive_backend.expect("override present");
637        assert_eq!(b.provider, "openai");
638        assert_eq!(b.model, "Qwen3.5-4B-MLX-4bit");
639        assert_eq!(b.endpoint.as_deref(), Some("http://127.0.0.1:8000/v1"));
640        assert!(c.abstractive_backend.is_none());
641    }
642
643    #[test]
644    fn legacy_conversation_fields_are_gone_from_serialized_output() {
645        let cfg = Config::default();
646        // Scoped to the conversations block intentionally: `embedding` still
647        // carries its own `ollama_endpoint`, and asserting over the whole
648        // document here would require that field to be omitted, which is not
649        // the case. The Config type will never serialize without it, so this
650        // scoping to conversations is permanent.
651        let yaml = serde_yaml_ng::to_string(&cfg.conversations).expect("serializes");
652        for key in ["extractive_model", "abstractive_model", "ollama_endpoint"] {
653            assert!(
654                !yaml.contains(key),
655                "legacy key {key} still serialized:\n{yaml}"
656            );
657        }
658    }
659
660    #[test]
661    fn embedding_ollama_endpoint_is_omitted_when_unset() {
662        let mut cfg = Config::default();
663        cfg.embedding.provider = "omlx".into();
664        cfg.embedding.openai_url = Some("http://127.0.0.1:8000/v1".into());
665        cfg.embedding.ollama_endpoint = None;
666        let yaml = serde_yaml_ng::to_string(&cfg).expect("serializes");
667        assert!(
668            !yaml.contains("ollama_endpoint"),
669            "dead field re-emitted:\n{yaml}"
670        );
671    }
672
673    #[test]
674    fn embedding_ollama_endpoint_still_round_trips_when_set() {
675        let yaml =
676            "provider: ollama\nmodel: nomic-embed-text\nollama_endpoint: http://box.local:11434\n";
677        let e: EmbeddingConfig = serde_yaml_ng::from_str(yaml).expect("parses");
678        assert_eq!(e.ollama_endpoint.as_deref(), Some("http://box.local:11434"));
679    }
680}
681
682#[cfg(test)]
683mod model_switch_config_tests {
684    use super::*;
685
686    #[test]
687    fn model_switch_config_defaults_and_omitted_block() {
688        // Omitted `models:` block deserializes to defaults.
689        let cfg: Config = serde_yaml::from_str("{}").unwrap();
690        assert_eq!(cfg.models.default, None);
691        assert!(cfg.models.fallback_chain.is_empty());
692        assert_eq!(cfg.models.retry.max_retries, DEFAULT_MAX_RETRIES);
693        assert_eq!(cfg.models.retry.backoff_base_ms, DEFAULT_BACKOFF_BASE_MS);
694        assert_eq!(cfg.models.retry.cooldown_secs, DEFAULT_COOLDOWN_SECS);
695        assert!(!cfg.models.routing.enabled);
696
697        // A populated block round-trips.
698        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";
699        let cfg: Config = serde_yaml::from_str(yaml).unwrap();
700        assert_eq!(cfg.models.default.as_deref(), Some("claude_sonnet"));
701        assert_eq!(
702            cfg.models.fallback_chain,
703            vec!["claude_sonnet", "deepseek_v4_pro"]
704        );
705        assert!(cfg.models.routing.enabled);
706        assert_eq!(cfg.models.routing.threshold_input_tokens, Some(1500));
707    }
708
709    #[test]
710    fn smart_config_defaults_off_with_autopick() {
711        let cfg: Config = serde_yaml::from_str("{}").unwrap();
712        assert!(
713            !cfg.models.smart.enabled,
714            "Smart background routing is opt-in (capability-gate spec §2)"
715        );
716        assert_eq!(cfg.models.smart.cheap, None); // auto-pick
717        assert_eq!(
718            cfg.models.smart.max_escalations,
719            DEFAULT_SMART_MAX_ESCALATIONS
720        );
721    }
722
723    #[test]
724    fn smart_override_inherits_field_by_field() {
725        let global = SmartConfig {
726            enabled: true,
727            cheap: Some("g".into()),
728            max_escalations: 3,
729        };
730        assert_eq!(global.merged(None), global);
731        assert_eq!(global.merged(Some(&SmartOverride::default())), global);
732        // Overriding one field must not reset the others — the whole point.
733        let only_cheap = SmartOverride {
734            cheap: Some("a".into()),
735            ..Default::default()
736        };
737        let m = global.merged(Some(&only_cheap));
738        assert!(m.enabled, "overriding cheap must not disable Smart");
739        assert_eq!(m.cheap.as_deref(), Some("a"));
740        assert_eq!(m.max_escalations, 3);
741        // An explicit false still beats a global true.
742        let off = SmartOverride {
743            enabled: Some(false),
744            ..Default::default()
745        };
746        assert!(!global.merged(Some(&off)).enabled);
747    }
748
749    #[test]
750    fn routing_override_inherits_field_by_field() {
751        let global = RoutingConfig {
752            enabled: true,
753            cheap: Some("c".into()),
754            frontier: Some("f".into()),
755            threshold_input_tokens: Some(9),
756        };
757        let only_cheap = RoutingOverride {
758            cheap: Some("a".into()),
759            ..Default::default()
760        };
761        let m = global.merged(Some(&only_cheap));
762        assert!(m.enabled, "overriding cheap must not disable routing");
763        assert_eq!(m.cheap.as_deref(), Some("a"));
764        assert_eq!(m.frontier.as_deref(), Some("f"));
765        assert_eq!(m.threshold_input_tokens, Some(9));
766    }
767}