Keep session defaults static during config batch writes (#34940)

## What changed

- Treat batch writes containing only `model`, `model_reasoning_effort`,
  `plan_mode_reasoning_effort`, `service_tier`, or `personality` as defaults for
  future sessions, even when `reloadUserConfig` is requested.
- Keep runtime config reloads and skill/plugin cache invalidation for other
  config changes, and document which settings remain session-static.
- Preserve cached skill listings after session-default writes until a forced
  reload, with test coverage for each supported default.

GitOrigin-RevId: 6d988014cf4758ceb9422a11f7c2bd5fc6b9f90a
This commit is contained in:
jif
2026-07-23 13:11:16 +00:00
committed by copyberry
parent 48ebbf5334
commit 808d3c2702
10 changed files with 83 additions and 18 deletions
+1 -1
View File
@@ -528,7 +528,7 @@
]
},
"reloadUserConfig": {
"description": "When true, hot-reload the updated user config into all loaded threads after writing.",
"description": "When true, hot-reload updated runtime settings into loaded threads after writing. Session-static model, reasoning-effort, Plan-mode reasoning-effort, service-tier, and personality defaults are not reloaded.",
"type": "boolean"
}
},
@@ -8241,7 +8241,7 @@
]
},
"reloadUserConfig": {
"description": "When true, hot-reload the updated user config into all loaded threads after writing.",
"description": "When true, hot-reload updated runtime settings into loaded threads after writing. Session-static model, reasoning-effort, Plan-mode reasoning-effort, service-tier, and personality defaults are not reloaded.",
"type": "boolean"
}
},
@@ -4419,7 +4419,7 @@
]
},
"reloadUserConfig": {
"description": "When true, hot-reload the updated user config into all loaded threads after writing.",
"description": "When true, hot-reload updated runtime settings into loaded threads after writing. Session-static model, reasoning-effort, Plan-mode reasoning-effort, service-tier, and personality defaults are not reloaded.",
"type": "boolean"
}
},
@@ -47,7 +47,7 @@
]
},
"reloadUserConfig": {
"description": "When true, hot-reload the updated user config into all loaded threads after writing.",
"description": "When true, hot-reload updated runtime settings into loaded threads after writing. Session-static model, reasoning-effort, Plan-mode reasoning-effort, service-tier, and personality defaults are not reloaded.",
"type": "boolean"
}
},
@@ -9,6 +9,8 @@ export type ConfigBatchWriteParams = { edits: Array<ConfigEdit>,
*/
filePath?: string | null, expectedVersion?: string | null,
/**
* When true, hot-reload the updated user config into all loaded threads after writing.
* When true, hot-reload updated runtime settings into loaded threads after writing.
* Session-static model, reasoning-effort, Plan-mode reasoning-effort, service-tier, and
* personality defaults are not reloaded.
*/
reloadUserConfig?: boolean, };
@@ -864,7 +864,9 @@ pub struct ConfigBatchWriteParams {
pub file_path: Option<String>,
#[ts(optional = nullable)]
pub expected_version: Option<String>,
/// When true, hot-reload the updated user config into all loaded threads after writing.
/// When true, hot-reload updated runtime settings into loaded threads after writing.
/// Session-static model, reasoning-effort, Plan-mode reasoning-effort, service-tier, and
/// personality defaults are not reloaded.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub reload_user_config: bool,
}
+1 -1
View File
@@ -247,7 +247,7 @@ Example with notification opt-out:
- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any `details` returned by detect. Pass the same optional `migrationSource` used for detection so the server reads from the matching source; omitted, `null`, or unrecognized values retain the default behavior. The optional `source` identifies the product that initiated the import, while the optional opaque `providerId` attributes analytics to the provider selected by that product without affecting migration-source selection. The response acknowledges the synchronous import phase with an `importId`. Expected migration failures are reported as per-item failures rather than JSON-RPC errors, so the server still returns that `importId` and emits `externalAgentConfig/import/completed` with the same ID once all synchronous and background work finishes. The completion notification contains type-level `itemTypeResults` with successes and failures, including raw failure messages for the client to report separately.
- `externalAgentConfig/import/readHistories` — read completed import histories and connector candidates detected from successfully imported session histories. Connector candidates include a normalized display `name`, the number of imported sessions that used the connector, and the source metadata field used for detection.
- `config/value/write` — write a single config key/value to the user's config.toml on disk; dotted paths such as `desktop.someKey` use the same generic write surface. Writes that overlap a managed requirement are rejected with `configRequirementReadonly`.
- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads, including multiple `desktop.*` edits.
- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads, including multiple `desktop.*` edits. Session-static model, reasoning-effort, Plan-mode reasoning-effort, service-tier, and personality defaults do not reload existing threads.
- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including exact managed values (`sqliteHome`, `logDir`, `modelCatalogJson`, `checkForUpdateOnStartup`, `allowLoginShell`, `feedback.enabled`, and `windowsSandboxPrivateDesktop`), allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), the layered permission-profile allow map (`allowedPermissionProfiles`), the managed permission-profile default (`defaultPermissions`), lifecycle hook lockdown (`allowManagedHooksOnly`), remote-control policy (`allowRemoteControl`; `false` force-disables remote control while `true` or `null` preserves existing behavior), computer use policy (`computerUse`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`, including each command handler's optional `additionalContextLimit`), `enforceResidency`, managed new-thread defaults (`models.newThread.model`, `models.newThread.modelReasoningEffort`, and `models.newThread.serviceTier`), and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`.
### Example: Start or resume a thread
@@ -136,9 +136,26 @@ impl ConfigRequestProcessor {
&self,
params: ConfigBatchWriteParams,
) -> Result<ClientResponsePayload, JSONRPCErrorError> {
self.handle_config_mutation_result(self.batch_write_inner(params).await)
.await
.map(ClientResponsePayload::ConfigBatchWrite)
let session_defaults_only = !params.edits.is_empty()
&& params.edits.iter().all(|edit| {
matches!(
edit.key_path.as_str(),
"model"
| "model_reasoning_effort"
| "plan_mode_reasoning_effort"
| "service_tier"
| "personality"
)
});
let reload_user_config = params.reload_user_config;
let response = self.batch_write_inner(params).await?;
if !session_defaults_only {
self.handle_config_mutation().await;
if reload_user_config {
self.reload_user_config().await;
}
}
Ok(ClientResponsePayload::ConfigBatchWrite(response))
}
pub(crate) async fn experimental_feature_enablement_set(
@@ -149,6 +166,9 @@ impl ConfigRequestProcessor {
let response = self
.handle_config_mutation_result(self.set_experimental_feature_enablement(params).await)
.await?;
if !response.enablement.is_empty() {
self.reload_user_config().await;
}
self.outgoing
.send_response_as(
request_id,
@@ -219,7 +239,6 @@ impl ConfigRequestProcessor {
&self,
params: ConfigBatchWriteParams,
) -> Result<ConfigWriteResponse, JSONRPCErrorError> {
let reload_user_config = params.reload_user_config;
let pending_changes = codex_core_plugins::toggles::collect_plugin_enabled_candidates(
params
.edits
@@ -232,9 +251,6 @@ impl ConfigRequestProcessor {
.await
.map_err(map_error)?;
self.emit_plugin_toggle_events(pending_changes).await;
if reload_user_config {
self.reload_user_config().await;
}
Ok(response)
}
@@ -270,7 +286,6 @@ impl ConfigRequestProcessor {
.map_err(|_| internal_error("failed to update feature enablement"))?;
self.load_latest_config(/*fallback_cwd*/ None).await?;
self.reload_user_config().await;
Ok(ExperimentalFeatureEnablementSetResponse { enablement })
}
@@ -7,8 +7,12 @@ use app_test_support::MockResponsesConfig;
use app_test_support::TestAppServer;
use app_test_support::create_mock_responses_server_repeating_assistant;
use app_test_support::write_chatgpt_auth;
use codex_app_server_protocol::ConfigBatchWriteParams;
use codex_app_server_protocol::ConfigEdit;
use codex_app_server_protocol::ConfigWriteResponse;
use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams;
use codex_app_server_protocol::ExperimentalFeatureEnablementSetResponse;
use codex_app_server_protocol::MergeStrategy;
use codex_app_server_protocol::PluginListParams;
use codex_app_server_protocol::PluginListResponse;
use codex_app_server_protocol::SkillsChangedNotification;
@@ -587,7 +591,8 @@ async fn skills_list_preserves_requested_cwd_order() -> Result<()> {
}
#[tokio::test]
async fn skills_list_uses_cached_result_until_force_reload() -> Result<()> {
async fn skills_list_uses_cached_result_after_session_default_writes_until_force_reload()
-> Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
@@ -621,6 +626,47 @@ async fn skills_list_uses_cached_result_until_force_reload() -> Result<()> {
"---\nname: late-extra-skill\ndescription: late skill\n---\n\n# Body\n",
)?;
for edits in [
vec![ConfigEdit {
key_path: "plan_mode_reasoning_effort".to_string(),
value: serde_json::json!("high"),
merge_strategy: MergeStrategy::Replace,
}],
vec![ConfigEdit {
key_path: "service_tier".to_string(),
value: serde_json::json!("fast"),
merge_strategy: MergeStrategy::Replace,
}],
vec![ConfigEdit {
key_path: "personality".to_string(),
value: serde_json::json!("friendly"),
merge_strategy: MergeStrategy::Replace,
}],
vec![
ConfigEdit {
key_path: "model".to_string(),
value: serde_json::json!("gpt-5.4"),
merge_strategy: MergeStrategy::Replace,
},
ConfigEdit {
key_path: "model_reasoning_effort".to_string(),
value: serde_json::json!("high"),
merge_strategy: MergeStrategy::Replace,
},
],
] {
let write_id = mcp
.send_config_batch_write_request(ConfigBatchWriteParams {
edits,
file_path: None,
expected_version: None,
reload_user_config: true,
})
.await?;
let _: ConfigWriteResponse =
timeout(DEFAULT_TIMEOUT, mcp.read_response(write_id)).await??;
}
let second_request_id = mcp
.send_skills_list_request(SkillsListParams {
cwds: vec![cwd.path().to_path_buf()],
+2 -2
View File
@@ -1626,8 +1626,6 @@ impl Session {
(previous_config, new_config, config)
};
self.emit_config_changed_contributors(previous_config.as_ref(), new_config.as_ref());
self.services.skills_service.clear_cache();
self.services.plugins_manager.clear_cache();
let environments = self.services.turn_environments.snapshot().await;
let hooks = build_hooks_for_config(
config.as_ref(),
@@ -1791,6 +1789,8 @@ impl Session {
resolve_tool_suggest_config_from_layer_stack(&config.config_layer_stack);
config
};
self.services.skills_service.clear_cache();
self.services.plugins_manager.clear_cache();
self.refresh_runtime_config(next_config).await;
}