diff --git a/codex-rs/app-server/src/config_manager_service.rs b/codex-rs/app-server/src/config_manager_service.rs index 6d3651ebcc..0b4d87a06f 100644 --- a/codex-rs/app-server/src/config_manager_service.rs +++ b/codex-rs/app-server/src/config_manager_service.rs @@ -583,10 +583,33 @@ fn apply_merge( )); }; - if matches!(strategy, MergeStrategy::Upsert) - && (shell_environment_policy_representation_switch(root, segments, value) - || (matches!(value_at_path(root, segments), Some(TomlValue::Table(_))) - && matches!(value, TomlValue::Table(_)))) + let multi_agent_v2_feature_depth = match segments { + [features, feature, ..] if features == "features" && feature == "multi_agent_v2" => Some(2), + [profiles, _, features, feature, ..] + if profiles == "profiles" && features == "features" && feature == "multi_agent_v2" => + { + Some(4) + } + _ => None, + }; + let preserves_multi_agent_v2_feature_config = + multi_agent_v2_feature_depth.is_some_and(|feature_depth| { + match value_at_path(root, &segments[..feature_depth]) { + Some(TomlValue::Boolean(_)) => { + segments.len() > feature_depth || matches!(value, TomlValue::Table(_)) + } + Some(TomlValue::Table(_)) => { + segments.len() == feature_depth && matches!(value, TomlValue::Boolean(_)) + } + _ => false, + } + }); + + if preserves_multi_agent_v2_feature_config + || matches!(strategy, MergeStrategy::Upsert) + && (shell_environment_policy_representation_switch(root, segments, value) + || (matches!(value_at_path(root, segments), Some(TomlValue::Table(_))) + && matches!(value, TomlValue::Table(_)))) { let overlay = sparse_overlay(segments, value); merge_toml_values(root, &overlay); @@ -741,6 +764,24 @@ fn value_at_semantic_path<'a>(root: &'a TomlValue, segments: &[String]) -> Optio shell_environment_filter_entry(root, segments) .map(|(_, value)| value) .or_else(|| value_at_path(root, segments)) + .or_else(|| { + let (field, parents) = segments.split_last()?; + if field != "enabled" { + return None; + } + let is_multi_agent_v2_feature = match parents { + [features, feature] => features == "features" && feature == "multi_agent_v2", + [profiles, _, features, feature] => { + profiles == "profiles" && features == "features" && feature == "multi_agent_v2" + } + _ => false, + }; + if !is_multi_agent_v2_feature { + return None; + } + let feature = value_at_path(root, parents)?; + matches!(feature, TomlValue::Boolean(_)).then_some(feature) + }) } fn override_message(layer: &ConfigLayerSource) -> String { diff --git a/codex-rs/app-server/src/config_manager_service_tests.rs b/codex-rs/app-server/src/config_manager_service_tests.rs index c88e1b1ece..cfe6116e36 100644 --- a/codex-rs/app-server/src/config_manager_service_tests.rs +++ b/codex-rs/app-server/src/config_manager_service_tests.rs @@ -1037,6 +1037,63 @@ async fn write_value_reports_managed_override() { assert_eq!(overridden.effective_value, serde_json::json!("never")); } +/// Legacy managed feature toggles own their normalized enabled origin and override metadata. +#[tokio::test] +async fn multi_agent_v2_boolean_layer_owns_enabled_origin_and_overrides() { + let tmp = tempdir().expect("tempdir"); + let user_path = tmp.path().join(CONFIG_TOML_FILE); + std::fs::write( + &user_path, + "[features.multi_agent_v2]\nenabled = true\nsubagent_usage_hint_text = \"keep\"\n", + ) + .expect("user config"); + + let managed_path = tmp.path().join("managed_config.toml"); + std::fs::write(&managed_path, "[features]\nmulti_agent_v2 = false\n").expect("managed config"); + let managed_file = AbsolutePathBuf::try_from(managed_path.clone()).expect("managed file"); + let service = ConfigManager::new_for_tests( + tmp.path().to_path_buf(), + vec![], + LoaderOverrides::with_managed_config_path_for_tests(managed_path), + CloudConfigBundleLoader::default(), + ); + + let read = service + .read(ConfigReadParams { + include_layers: false, + cwd: None, + }) + .await + .expect("read config"); + assert_eq!( + read.origins + .get("features.multi_agent_v2.enabled") + .expect("enabled origin") + .name, + ApiConfigLayerSource::LegacyManagedConfigTomlFromFile { + file: managed_file.clone(), + }, + ); + + let result = service + .write_value(ConfigValueWriteParams { + file_path: Some(user_path.display().to_string()), + key_path: "features.multi_agent_v2.enabled".to_string(), + value: serde_json::json!(true), + merge_strategy: MergeStrategy::Upsert, + expected_version: None, + }) + .await + .expect("write config"); + assert_eq!(result.status, WriteStatus::OkOverridden); + let overridden = result.overridden_metadata.expect("overridden metadata"); + assert_eq!( + overridden.overriding_layer.name, + ApiConfigLayerSource::LegacyManagedConfigTomlFromFile { file: managed_file } + ); + assert_eq!(overridden.effective_value, serde_json::json!(false)); +} + #[tokio::test] async fn upsert_merges_tables_replace_overwrites() -> Result<()> { let tmp = tempdir().expect("tempdir"); @@ -1229,6 +1286,69 @@ no_memories_if_mcp_or_web_search = false serde_json::json!({"disable_on_external_context": true}), r#"[memories] disable_on_external_context = true +"#, + ), + ( + r#"[features] +multi_agent_v2 = true +"#, + "features.multi_agent_v2.subagent_usage_hint_text", + serde_json::json!("Delegate carefully."), + r#"[features.multi_agent_v2] +enabled = true +subagent_usage_hint_text = "Delegate carefully." +"#, + ), + ( + r#"[features] +multi_agent_v2 = true +"#, + "features.multi_agent_v2", + serde_json::json!({"subagent_usage_hint_text": "Delegate carefully."}), + r#"[features.multi_agent_v2] +enabled = true +subagent_usage_hint_text = "Delegate carefully." +"#, + ), + ( + r#"[features.multi_agent_v2] +enabled = true +subagent_usage_hint_text = "Delegate carefully." +"#, + "features.multi_agent_v2", + serde_json::json!(false), + r#"[features.multi_agent_v2] +enabled = false +subagent_usage_hint_text = "Delegate carefully." +"#, + ), + ( + r#"[features.multi_agent_v2] +enabled = true +subagent_usage_hint_text = "Delegate carefully." +"#, + "features.multi_agent_v2", + serde_json::Value::Null, + "", + ), + ( + r#"[desktop.features.multi_agent_v2] +custom = true +"#, + "desktop.features.multi_agent_v2", + serde_json::json!(false), + r#"[desktop.features] +multi_agent_v2 = false +"#, + ), + ( + r#"[desktop.features] +multi_agent_v2 = true +"#, + "desktop.features.multi_agent_v2", + serde_json::json!({"custom": true}), + r#"[desktop.features.multi_agent_v2] +custom = true "#, ), ]; diff --git a/codex-rs/config/src/fingerprint.rs b/codex-rs/config/src/fingerprint.rs index 7a48b6d416..001f2b1bef 100644 --- a/codex-rs/config/src/fingerprint.rs +++ b/codex-rs/config/src/fingerprint.rs @@ -1,4 +1,5 @@ use crate::ConfigLayerMetadata; +use crate::merge::is_multi_agent_v2_feature_path; use serde_json::Value as JsonValue; use sha2::Digest; use sha2::Sha256; @@ -28,6 +29,12 @@ pub(super) fn record_origins( } _ => { if !path.is_empty() { + if matches!(value, TomlValue::Boolean(_)) && is_multi_agent_v2_feature_path(path) { + path.push("enabled".to_string()); + origins.insert(path.join("."), meta.clone()); + path.pop(); + return; + } origins.insert(path.join("."), meta.clone()); } } diff --git a/codex-rs/config/src/merge.rs b/codex-rs/config/src/merge.rs index b46871c111..be853b9932 100644 --- a/codex-rs/config/src/merge.rs +++ b/codex-rs/config/src/merge.rs @@ -58,9 +58,39 @@ pub fn merge_toml_values(base: &mut TomlValue, overlay: &TomlValue) { merge_toml_values_at_path(base, overlay, &mut Vec::new()); } +pub(crate) fn is_multi_agent_v2_feature_path>(path: &[S]) -> bool { + match path { + [features, feature] => { + features.as_ref() == "features" && feature.as_ref() == "multi_agent_v2" + } + [profiles, _, features, feature] => { + profiles.as_ref() == "profiles" + && features.as_ref() == "features" + && feature.as_ref() == "multi_agent_v2" + } + _ => false, + } +} + fn merge_toml_values_at_path(base: &mut TomlValue, overlay: &TomlValue, path: &mut Vec) { replace_shell_environment_policy_filter_representation(base, overlay, path); + if is_multi_agent_v2_feature_path(path) { + if let TomlValue::Boolean(enabled) = base + && overlay.is_table() + { + *base = TomlValue::Table(toml::map::Map::from_iter([( + "enabled".to_string(), + TomlValue::Boolean(*enabled), + )])); + } else if let TomlValue::Table(table) = base + && let TomlValue::Boolean(enabled) = overlay + { + table.insert("enabled".to_string(), TomlValue::Boolean(*enabled)); + return; + } + } + if let TomlValue::Table(overlay_table) = overlay && let TomlValue::Table(base_table) = base { diff --git a/codex-rs/config/src/merge_tests.rs b/codex-rs/config/src/merge_tests.rs index 9a62a72b22..be8c8fd047 100644 --- a/codex-rs/config/src/merge_tests.rs +++ b/codex-rs/config/src/merge_tests.rs @@ -161,6 +161,117 @@ max_concurrent_threads_per_session = 7 assert_eq!(base, expected); } +/// Feature tables added above legacy toggles retain the lower layer's enabled state. +#[test] +fn merge_multi_agent_v2_table_preserves_legacy_boolean_toggle() { + for feature_path in ["features", "profiles.work.features"] { + let mut base = parse_toml(&format!("[{feature_path}]\nmulti_agent_v2 = true\n")); + let overlay = parse_toml(&format!( + "[{feature_path}.multi_agent_v2]\nsubagent_usage_hint_text = \"Delegate carefully.\"\n", + )); + + merge_toml_values(&mut base, &overlay); + + assert_eq!( + base, + parse_toml(&format!( + "[{feature_path}.multi_agent_v2]\nenabled = true\nsubagent_usage_hint_text = \"Delegate carefully.\"\n", + )) + ); + } +} + +/// Legacy feature toggles update enabled state without discarding nested configuration. +#[test] +fn merge_multi_agent_v2_boolean_preserves_existing_feature_table() { + for feature_path in ["features", "profiles.work.features"] { + let mut base = parse_toml(&format!( + "[{feature_path}.multi_agent_v2]\nenabled = true\nsubagent_usage_hint_text = \"Delegate carefully.\"\n", + )); + let overlay = parse_toml(&format!("[{feature_path}]\nmulti_agent_v2 = false\n")); + + merge_toml_values(&mut base, &overlay); + + assert_eq!( + base, + parse_toml(&format!( + "[{feature_path}.multi_agent_v2]\nenabled = false\nsubagent_usage_hint_text = \"Delegate carefully.\"\n", + )) + ); + } +} + +/// Opaque desktop settings retain ordinary scalar/table replacement semantics. +#[test] +fn merge_multi_agent_v2_compatibility_excludes_opaque_desktop_paths() { + let cases = [ + ( + "[desktop.features.multi_agent_v2]\nenabled = true\n", + "[desktop.features]\nmulti_agent_v2 = false\n", + "[desktop.features]\nmulti_agent_v2 = false\n", + ), + ( + "[desktop.features]\nmulti_agent_v2 = true\n", + "[desktop.features.multi_agent_v2]\ncustom = true\n", + "[desktop.features.multi_agent_v2]\ncustom = true\n", + ), + ]; + + for (base, overlay, expected) in cases { + let mut base = parse_toml(base); + merge_toml_values(&mut base, &parse_toml(overlay)); + assert_eq!(base, parse_toml(expected)); + } +} + +/// CLI overrides preserve the multi-agent toggle and nested options in either ordering. +#[test] +fn multi_agent_v2_cli_overrides_preserve_boolean_and_nested_configuration() { + for feature_path in ["features", "profiles.work.features"] { + let instructions = ( + format!("{feature_path}.multi_agent_v2.subagent_usage_hint_text"), + TomlValue::String("Delegate carefully.".to_string()), + ); + let enabled = ( + format!("{feature_path}.multi_agent_v2"), + TomlValue::Boolean(true), + ); + let feature_table = ( + format!("{feature_path}.multi_agent_v2"), + parse_toml("subagent_usage_hint_text = \"Delegate carefully.\"\n"), + ); + let expected = parse_toml(&format!( + "[{feature_path}.multi_agent_v2]\nenabled = true\nsubagent_usage_hint_text = \"Delegate carefully.\"\n", + )); + + for overrides in [ + vec![enabled.clone(), instructions.clone()], + vec![instructions, enabled.clone()], + vec![enabled.clone(), feature_table.clone()], + vec![feature_table, enabled], + ] { + assert_eq!(crate::build_cli_overrides_layer(&overrides), expected); + } + } +} + +/// Repeated opaque desktop overrides continue to replace their previous value. +#[test] +fn multi_agent_v2_cli_compatibility_excludes_opaque_desktop_paths() { + let path = "desktop.features.multi_agent_v2".to_string(); + let enabled = (path.clone(), TomlValue::Boolean(true)); + let feature_table = (path, parse_toml("custom = true\n")); + + assert_eq!( + crate::build_cli_overrides_layer(&[enabled.clone(), feature_table.clone()]), + parse_toml("[desktop.features.multi_agent_v2]\ncustom = true\n") + ); + assert_eq!( + crate::build_cli_overrides_layer(&[feature_table, enabled]), + parse_toml("[desktop.features]\nmulti_agent_v2 = true\n") + ); +} + #[test] fn merge_toml_values_normalizes_permission_network_domains_before_overlaying() { let mut base = parse_toml( diff --git a/codex-rs/config/src/overrides.rs b/codex-rs/config/src/overrides.rs index a27caedb9a..5edc3b6810 100644 --- a/codex-rs/config/src/overrides.rs +++ b/codex-rs/config/src/overrides.rs @@ -1,3 +1,5 @@ +use crate::merge::is_multi_agent_v2_feature_path; +use crate::merge::merge_toml_values; use toml::Value as TomlValue; pub(crate) fn default_empty_table() -> TomlValue { @@ -18,13 +20,47 @@ fn apply_toml_override(root: &mut TomlValue, path: &str, value: TomlValue) { let mut current = root; let mut segments_iter = path.split('.').peekable(); + let mut traversed_segments = Vec::new(); while let Some(segment) = segments_iter.next() { + traversed_segments.push(segment); let is_last = segments_iter.peek().is_none(); if is_last { match current { TomlValue::Table(table) => { + if is_multi_agent_v2_feature_path(&traversed_segments) + && let Some(existing) = table.get_mut(segment) + { + match (&mut *existing, &value) { + (TomlValue::Table(feature), TomlValue::Boolean(enabled)) => { + feature.insert("enabled".to_string(), TomlValue::Boolean(*enabled)); + return; + } + (TomlValue::Boolean(enabled), TomlValue::Table(_)) => { + *existing = TomlValue::Table(Table::from_iter([( + "enabled".to_string(), + TomlValue::Boolean(*enabled), + )])); + merge_toml_values(existing, &value); + return; + } + (TomlValue::Table(_), TomlValue::Table(_)) => { + merge_toml_values(existing, &value); + return; + } + ( + TomlValue::String(_) + | TomlValue::Integer(_) + | TomlValue::Float(_) + | TomlValue::Boolean(_) + | TomlValue::Datetime(_) + | TomlValue::Array(_) + | TomlValue::Table(_), + _, + ) => {} + } + } table.insert(segment.to_string(), value); } _ => { @@ -41,6 +77,14 @@ fn apply_toml_override(root: &mut TomlValue, path: &str, value: TomlValue) { current = table .entry(segment.to_string()) .or_insert_with(|| TomlValue::Table(Table::new())); + if is_multi_agent_v2_feature_path(&traversed_segments) + && let TomlValue::Boolean(enabled) = current + { + *current = TomlValue::Table(Table::from_iter([( + "enabled".to_string(), + TomlValue::Boolean(*enabled), + )])); + } } _ => { *current = TomlValue::Table(Table::new()); diff --git a/codex-rs/config/src/state_tests.rs b/codex-rs/config/src/state_tests.rs index 2664506440..71871f121a 100644 --- a/codex-rs/config/src/state_tests.rs +++ b/codex-rs/config/src/state_tests.rs @@ -39,6 +39,45 @@ no_memories_if_mcp_or_web_search = true ); } +/// Legacy feature toggles own the semantic enabled leaf after layered merging. +#[test] +fn origins_attribute_multi_agent_v2_enabled_to_overriding_boolean_layer() { + let temp_dir = TempDir::new().expect("tempdir"); + let user_layer = ConfigLayerEntry::new( + ConfigLayerSource::User { + file: test_user_config_path(&temp_dir, "config.toml"), + profile: None, + }, + toml::from_str( + "[features.multi_agent_v2]\nenabled = true\nsubagent_usage_hint_text = \"keep\"\n", + ) + .expect("user config"), + ); + let user_metadata = user_layer.metadata(); + let session_layer = ConfigLayerEntry::new( + ConfigLayerSource::SessionFlags, + toml::from_str("[features]\nmulti_agent_v2 = false\n").expect("session config"), + ); + let session_metadata = session_layer.metadata(); + let stack = ConfigLayerStack::new( + vec![user_layer, session_layer], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("layer stack should be valid"); + + let origins = stack.origins(); + + assert_eq!( + origins.get("features.multi_agent_v2.enabled"), + Some(&session_metadata) + ); + assert_eq!( + origins.get("features.multi_agent_v2.subagent_usage_hint_text"), + Some(&user_metadata) + ); +} + #[test] fn enabled_layers_validate_shell_environment_policy() { let layer = ConfigLayerEntry::new( diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 419a96d48f..35de852cf1 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -322,7 +322,20 @@ impl ConfigDocument { ConfigEdit::SetSkillConfigByName { name, enabled } => { Ok(self.set_skill_config(SkillConfigSelector::Name(name.clone()), *enabled)) } - ConfigEdit::SetPath { segments, value } => Ok(self.insert(segments, value.clone())), + ConfigEdit::SetPath { segments, value } => { + if is_multi_agent_v2_feature_path(segments) && value.as_bool().is_some() { + let mut existing = Some(self.doc.as_item()); + for segment in segments { + existing = existing.and_then(|item| item.as_table_like()?.get(segment)); + } + if existing.and_then(TomlItem::as_table_like).is_some() { + let mut enabled_segments = segments.clone(); + enabled_segments.push("enabled".to_string()); + return Ok(self.insert(&enabled_segments, value.clone())); + } + } + Ok(self.insert(segments, value.clone())) + } ConfigEdit::ClearPath { segments } => Ok(self.clear_owned(segments)), ConfigEdit::SetProjectTrustLevel { path, level } => { // Delegate to the existing, tested logic in config.rs to @@ -594,7 +607,7 @@ impl ConfigDocument { fn descend(&mut self, segments: &[String], mode: TraversalMode) -> Option<&mut TomlTable> { let mut current = self.doc.as_table_mut(); - for segment in segments { + for (index, segment) in segments.iter().enumerate() { match mode { TraversalMode::Create => { if !current.contains_key(segment.as_str()) { @@ -605,6 +618,13 @@ impl ConfigDocument { } let item = current.get_mut(segment.as_str())?; + if is_multi_agent_v2_feature_path(&segments[..=index]) + && let Some(enabled) = item.as_bool() + { + let mut feature = document_helpers::new_implicit_table(); + feature.insert("enabled", value(enabled)); + *item = TomlItem::Table(feature); + } current = document_helpers::ensure_table_for_write(item)?; } TraversalMode::Existing => { @@ -649,6 +669,16 @@ impl ConfigDocument { } } +fn is_multi_agent_v2_feature_path(segments: &[String]) -> bool { + match segments { + [features, feature] => features == "features" && feature == "multi_agent_v2", + [profiles, _, features, feature] => { + profiles == "profiles" && features == "features" && feature == "multi_agent_v2" + } + _ => false, + } +} + fn normalize_skill_config_path(path: &Path) -> String { dunce::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -834,9 +864,18 @@ impl ConfigEditsBuilder { /// /// Disabling a default-false feature clears the key instead of /// persisting `false`, so the config does not pin the feature once it - /// graduates to globally enabled. + /// graduates to globally enabled. Structured multi-agent v2 settings are + /// an exception: its explicit `enabled = false` preserves nested options. pub fn set_feature_enabled(mut self, key: &str, enabled: bool) -> Self { - let segments = vec!["features".to_string(), key.to_string()]; + let mut segments = vec!["features".to_string(), key.to_string()]; + if key == "multi_agent_v2" && !enabled { + segments.push("enabled".to_string()); + self.edits.push(ConfigEdit::SetPath { + segments, + value: value(false), + }); + return self; + } let is_default_false_feature = FEATURES .iter() .find(|spec| spec.key == key) diff --git a/codex-rs/core/src/config/edit_tests.rs b/codex-rs/core/src/config/edit_tests.rs index d47f128f94..84989596b3 100644 --- a/codex-rs/core/src/config/edit_tests.rs +++ b/codex-rs/core/src/config/edit_tests.rs @@ -93,6 +93,127 @@ fn builder_with_edits_applies_custom_paths() { assert_eq!(contents, "enabled = true\n"); } +/// Toggling multi-agent v2 must preserve settings stored in its feature table. +#[test] +fn multi_agent_v2_feature_toggle_preserves_nested_configuration() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + let config_path = codex_home.join(CONFIG_TOML_FILE); + std::fs::write( + &config_path, + "[features.multi_agent_v2]\nenabled = true\nsubagent_usage_hint_text = \"Delegate carefully.\"\n", + ) + .expect("write config"); + + ConfigEditsBuilder::new(codex_home) + .set_feature_enabled("multi_agent_v2", /*enabled*/ false) + .apply_blocking() + .expect("disable feature"); + let disabled: TomlValue = + toml::from_str(&std::fs::read_to_string(&config_path).expect("read disabled config")) + .expect("parse disabled config"); + assert_eq!( + disabled, + toml::from_str::( + "[features.multi_agent_v2]\nenabled = false\nsubagent_usage_hint_text = \"Delegate carefully.\"\n", + ) + .expect("parse expected config") + ); + + ConfigEditsBuilder::new(codex_home) + .set_feature_enabled("multi_agent_v2", /*enabled*/ true) + .apply_blocking() + .expect("enable feature"); + let enabled: TomlValue = + toml::from_str(&std::fs::read_to_string(&config_path).expect("read enabled config")) + .expect("parse enabled config"); + assert_eq!( + enabled, + toml::from_str::( + "[features.multi_agent_v2]\nenabled = true\nsubagent_usage_hint_text = \"Delegate carefully.\"\n", + ) + .expect("parse expected config") + ); +} + +/// Adding nested multi-agent settings must retain an existing legacy boolean toggle. +#[test] +fn multi_agent_v2_nested_edit_preserves_legacy_boolean_toggle() { + for feature_path in ["features", "profiles.work.features"] { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + let config_path = codex_home.join(CONFIG_TOML_FILE); + std::fs::write( + &config_path, + format!("[{feature_path}]\nmulti_agent_v2 = true\n"), + ) + .expect("write config"); + let mut feature_segments = feature_path + .split('.') + .map(str::to_string) + .collect::>(); + feature_segments.push("multi_agent_v2".to_string()); + let mut instruction_segments = feature_segments.clone(); + instruction_segments.push("subagent_usage_hint_text".to_string()); + + ConfigEditsBuilder::new(codex_home) + .with_edits([ConfigEdit::SetPath { + segments: instruction_segments, + value: value("Delegate carefully."), + }]) + .apply_blocking() + .expect("persist nested config"); + + let updated: TomlValue = + toml::from_str(&std::fs::read_to_string(&config_path).expect("read config")) + .expect("parse config"); + assert_eq!( + updated, + toml::from_str::(&format!( + "[{feature_path}.multi_agent_v2]\nenabled = true\nsubagent_usage_hint_text = \"Delegate carefully.\"\n", + )) + .expect("parse expected config") + ); + + ConfigEditsBuilder::new(codex_home) + .with_edits([ConfigEdit::SetPath { + segments: feature_segments.clone(), + value: value(false), + }]) + .apply_blocking() + .expect("disable feature"); + + let disabled: TomlValue = + toml::from_str(&std::fs::read_to_string(&config_path).expect("read config")) + .expect("parse config"); + assert_eq!( + disabled, + toml::from_str::(&format!( + "[{feature_path}.multi_agent_v2]\nenabled = false\nsubagent_usage_hint_text = \"Delegate carefully.\"\n", + )) + .expect("parse expected config") + ); + + ConfigEditsBuilder::new(codex_home) + .with_edits([ConfigEdit::ClearPath { + segments: feature_segments, + }]) + .apply_blocking() + .expect("clear feature toggle"); + + let cleared: TomlValue = + toml::from_str(&std::fs::read_to_string(&config_path).expect("read config")) + .expect("parse config"); + assert_eq!( + feature_path + .split('.') + .try_fold(&cleared, |config, segment| config.get(segment)) + .and_then(|features| features.get("multi_agent_v2")), + None + ); + } +} + #[test] fn session_picker_view_edit_writes_root_tui_setting() { let tmp = tempdir().expect("tmpdir");