feat: add config-change extension contributor (#22488)

## Why

Extensions can observe thread and turn lifecycle events today, but there
was no single host-owned hook for changes to the effective thread
configuration. That makes features that need to react to model,
permission, or tool-suggest updates either depend on individual mutation
paths or risk going stale after runtime config refreshes.

This adds a typed config-change contributor so extension-owned state can
stay synchronized with the effective thread config while the host
remains responsible for deciding when config changed.

## What Changed

- Added `ConfigContributor<C>` to `codex_extension_api`, with
before/after immutable snapshots of the effective config plus
session/thread extension stores.
- Added registry builder/accessor support through `config_contributor`
and `config_contributors`.
- Emits config-change callbacks after committed updates from session
settings, per-turn setting updates, and `refresh_runtime_config`.
- Builds effective config snapshots only when config contributors are
registered, and suppresses no-op callbacks when the before/after
snapshots are equal.
- Added a core session regression test that verifies contributors
observe both model changes and user-layer runtime config changes,
including access to session and thread extension stores.

## Validation

Added `config_change_contributor_observes_effective_config_changes` in
`codex-rs/core/src/session/tests.rs` to cover the new contributor path.
This commit is contained in:
jif-oai
2026-05-13 17:13:34 +02:00
committed by GitHub
Unverified
parent 87de4e3290
commit 34bb85519f
6 changed files with 235 additions and 3 deletions
+47 -3
View File
@@ -1317,7 +1317,16 @@ impl Session {
&self,
updates: SessionSettingsUpdate,
) -> ConstraintResult<()> {
let (previous_cwd, permission_profile_changed, next_cwd, codex_home, session_source) = {
let notify_config_contributors = !self.services.extensions.config_contributors().is_empty();
let (
previous_config,
new_config,
previous_cwd,
permission_profile_changed,
next_cwd,
codex_home,
session_source,
) = {
let mut state = self.state.lock().await;
let updated = match state.session_configuration.apply(&updates) {
Ok(updated) => updated,
@@ -1327,6 +1336,10 @@ impl Session {
}
};
let previous_config = notify_config_contributors
.then(|| Self::build_effective_session_config(&state.session_configuration));
let new_config =
notify_config_contributors.then(|| Self::build_effective_session_config(&updated));
let previous_cwd = state.session_configuration.cwd.clone();
let previous_permission_profile = state.session_configuration.permission_profile();
let updated_permission_profile = updated.permission_profile();
@@ -1337,6 +1350,8 @@ impl Session {
let session_source = updated.session_source.clone();
state.session_configuration = updated;
(
previous_config,
new_config,
previous_cwd,
permission_profile_changed,
next_cwd,
@@ -1345,6 +1360,7 @@ impl Session {
)
};
self.emit_config_changed_contributors(previous_config.as_ref(), new_config.as_ref());
self.maybe_refresh_shell_snapshot_for_cwd(
&previous_cwd,
&next_cwd,
@@ -1397,8 +1413,11 @@ impl Session {
// Refresh only the user layer from the incoming snapshot. Preserve thread-local
// layers such as request/session overrides that were present when this session
// was created.
let config = {
let notify_config_contributors = !self.services.extensions.config_contributors().is_empty();
let (previous_config, new_config, config) = {
let mut state = self.state.lock().await;
let previous_config = notify_config_contributors
.then(|| Self::build_effective_session_config(&state.session_configuration));
let mut config = (*state.session_configuration.original_config_do_not_use).clone();
config.config_layer_stack = config
.config_layer_stack
@@ -1407,8 +1426,11 @@ impl Session {
resolve_tool_suggest_config_from_layer_stack(&config.config_layer_stack);
let config = Arc::new(config);
state.session_configuration.original_config_do_not_use = Arc::clone(&config);
config
let new_config = notify_config_contributors
.then(|| Self::build_effective_session_config(&state.session_configuration));
(previous_config, new_config, config)
};
self.emit_config_changed_contributors(previous_config.as_ref(), new_config.as_ref());
self.services.skills_manager.clear_cache();
self.services.plugins_manager.clear_cache();
let hooks = build_hooks_for_config(
@@ -1429,6 +1451,28 @@ impl Session {
}
}
fn emit_config_changed_contributors(
&self,
previous_config: Option<&Config>,
new_config: Option<&Config>,
) {
let (Some(previous_config), Some(new_config)) = (previous_config, new_config) else {
return;
};
if previous_config == new_config {
return;
}
for contributor in self.services.extensions.config_contributors() {
contributor.on_config_changed(
&self.services.session_extension_data,
&self.services.thread_extension_data,
self.conversation_id,
previous_config,
new_config,
);
}
}
pub(crate) async fn reload_user_config_layer(&self) {
// Refresh layer-backed runtime state for an existing session, including enabled plugin,
// skill, and hook state. Derived config fields such as feature gates and legacy notify