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