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