diff --git a/src/crates/core/src/service/config/mode_config_canonicalizer.rs b/src/crates/core/src/service/config/mode_config_canonicalizer.rs index 8bdd2f9b8..0b0155ba3 100644 --- a/src/crates/core/src/service/config/mode_config_canonicalizer.rs +++ b/src/crates/core/src/service/config/mode_config_canonicalizer.rs @@ -220,6 +220,9 @@ fn canonicalize_mode_config( let Some(raw_mode) = raw_mode else { return Ok(None); }; + if raw_mode.is_null() { + return Ok(None); + } let mut stored: ModeConfig = serde_json::from_value(raw_mode.clone()).map_err(|error| { BitFunError::config(format!( @@ -454,7 +457,10 @@ pub async fn canonicalize_mode_configs() -> BitFunResult(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let raw = Option::>>::deserialize(deserializer)?; + Ok(raw + .unwrap_or_default() + .into_iter() + .filter_map(|(mode_id, config)| config.map(|config| (mode_id, config))) + .collect()) +} + /// Web UI font preferences (settings → basics). Keys match `FontPreference` in the frontend (camelCase). #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -516,7 +528,7 @@ pub struct AIConfig { /// Mode configuration. /// mode_id -> ModeConfig - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_mode_configs")] pub mode_configs: HashMap, /// SubAgent configuration (enable/disable state). @@ -2008,6 +2020,39 @@ mod tests { assert_eq!(config.subagent_max_concurrency, 9); } + #[test] + fn deserializes_mode_configs_with_null_entries() { + let config: AIConfig = serde_json::from_value(serde_json::json!({ + "models": [], + "agent_models": {}, + "func_agent_models": {}, + "default_models": {}, + "mode_configs": { + "Claw": null, + "Cowork": { + "mode_id": "Cowork", + "removed_tools": ["shell"] + } + }, + "subagent_configs": {}, + "proxy": { + "enabled": false, + "url": "" + } + })) + .expect("config with null mode config entries should deserialize"); + + assert!(!config.mode_configs.contains_key("Claw")); + assert_eq!( + config + .mode_configs + .get("Cowork") + .expect("non-null mode config should be retained") + .removed_tools, + vec!["shell".to_string()] + ); + } + #[test] fn deserializes_explicit_default_review_team_config() { let config: AIConfig = serde_json::from_value(serde_json::json!({