Preserve multi-agent settings across config representations (#35656)

## Why

`features.multi_agent_v2` can be represented as either a legacy boolean toggle
or a table with an `enabled` field and nested settings. Layering or editing
configs that mix these forms could replace one form with the other and discard
the enabled state or nested settings.

## What changed

- Normalize boolean toggles to the table's `enabled` field when merging config
  layers, applying CLI overrides, and editing user or profile config.
- Preserve nested multi-agent settings when toggling the feature, while keeping
  ordinary replacement semantics for unrelated paths.
- Attribute normalized `enabled` values to the layer that supplied the boolean
  toggle so config write results report overrides correctly.

## Testing

Added coverage for layered config, CLI overrides, config edits, app-server
writes, and origin metadata using both root and profile feature paths.

GitOrigin-RevId: 38b248c949b9ea5d6340a73d754f91c1834ac486
This commit is contained in:
Adam Perry @ OpenAI
2026-07-27 18:41:09 +00:00
committed by copyberry
parent 6b23635a7e
commit 2f19a57704
9 changed files with 560 additions and 8 deletions
+43 -4
View File
@@ -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)
+121
View File
@@ -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::<TomlValue>(
"[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::<TomlValue>(
"[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::<Vec<_>>();
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::<TomlValue>(&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::<TomlValue>(&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");