mirror of
https://github.com/openai/codex.git
synced 2026-08-23 13:09:46 +00:00
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:
committed by
copyberry
parent
6b23635a7e
commit
2f19a57704
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
"#,
|
||||
),
|
||||
];
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<S: AsRef<str>>(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<String>) {
|
||||
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
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user