From b5544d5732f40431c57bf6ca3bed5b127cdf27a0 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Wed, 9 Sep 2026 23:11:05 +0000 Subject: [PATCH] Persist disabled plugin IDs in thread settings (#44332) ## What changed - Add `disabled_plugin_ids` to thread startup options, settings overrides, snapshots, and persisted turn contexts. Omitted overrides preserve the current list; an empty list clears it. - Restore the selection on resume and capture it before truncating history for a fork. Prefer the owning thread's latest settings snapshot, falling back only to the latest turn context so stale history cannot resurrect an older selection. - Freeze the selection at turn admission and retain compatibility with histories that lack the new fields. ## Testing Add coverage for owned settings, explicit clears, latest-turn fallback, and legacy snapshots. Extend settings tests to cover plugin selection during runtime restoration and compaction, and verify initial turn persistence without extra settings checkpoints in both history modes. GitOrigin-RevId: 75ffbff5763cd93cc5ac69d73b08b237f59bc009 --- .../app-server-exports-stable.json.zst | Bin 148596 -> 148655 bytes .../request_processors/feedback_processor.rs | 1 + .../persisted_resume_settings_tests.rs | 2 + .../src/request_processors/turn_processor.rs | 2 + codex-rs/core/src/agent/control_tests.rs | 1 + codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_thread.rs | 3 + .../core/src/context_manager/history_tests.rs | 1 + codex-rs/core/src/session/mod.rs | 19 +++ codex-rs/core/src/session/review.rs | 1 + .../session/rollout_reconstruction_tests.rs | 8 ++ codex-rs/core/src/session/session.rs | 8 ++ codex-rs/core/src/session/tests.rs | 9 ++ .../core/src/session/tests/guardian_tests.rs | 1 + codex-rs/core/src/session/thread_settings.rs | 2 + codex-rs/core/src/session/turn_context.rs | 5 + codex-rs/core/src/thread_manager.rs | 17 +++ codex-rs/core/tests/suite/code_mode.rs | 17 +-- codex-rs/core/tests/suite/resume_warning.rs | 1 + codex-rs/core/tests/suite/settings_commits.rs | 112 +++++++++++++++- codex-rs/history/src/lib.rs | 28 ++++ codex-rs/history/src/tests.rs | 125 ++++++++++++++++++ codex-rs/protocol/src/protocol.rs | 12 ++ codex-rs/rollout/src/recorder_tests.rs | 1 + codex-rs/state/src/extract.rs | 5 + codex-rs/thread-store/src/local/mod.rs | 1 + .../src/local/model_context_tests.rs | 1 + .../src/local/rollout_migration_tests.rs | 1 + .../thread-store/src/thread_metadata_sync.rs | 1 + 29 files changed, 374 insertions(+), 12 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index 5752920d9473dfc19d079d73a4438a4ce85a1fef..15d7e3619b83024ed512ed5808fa97c115b53d68 100644 GIT binary patch delta 5549 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, pub collaboration_mode: Option, pub personality: Option, + pub disabled_plugin_ids: Option>, } pub use codex_guardian_context::GuardianRootMessage; @@ -560,6 +561,7 @@ impl CodexThread { service_tier, collaboration_mode, personality, + disabled_plugin_ids, } = overrides; SessionSettingsUpdate { step_settings: StepSettingsUpdate { @@ -579,6 +581,7 @@ impl CodexThread { permission_profile, active_permission_profile, windows_sandbox_level, + disabled_plugin_ids, ..Default::default() } } diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index fb43d1f1b5..e9a2198f48 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -359,6 +359,7 @@ fn reference_context_item() -> TurnContextItem { TurnContextItem { turn_id: Some("reference-turn".to_string()), root_turn_id: None, + disabled_plugin_ids: None, cwd: AbsolutePathBuf::try_from( std::env::current_dir() .expect("current directory") diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 827542947f..d96ad4eca1 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -436,6 +436,7 @@ pub(crate) struct SessionSpawnArgs { pub(crate) code_mode_session_provider: Arc, pub(crate) extensions: Arc>, pub(crate) conversation_history: InitialHistory, + pub(crate) disabled_plugin_ids: Option>, pub(crate) requested_history_mode: Option, pub(crate) fork_persistence: ForkPersistence, pub(crate) session_source: SessionSource, @@ -540,6 +541,7 @@ impl Session { code_mode_session_provider, extensions, conversation_history, + disabled_plugin_ids, requested_history_mode, fork_persistence, session_source, @@ -727,6 +729,22 @@ impl Session { } else { dynamic_tools }; + let disabled_plugin_ids = disabled_plugin_ids.unwrap_or_else(|| { + let settings_owner = match &conversation_history { + InitialHistory::Resumed(resumed) => Some(resumed.conversation_id), + InitialHistory::Forked(_) => forked_from_thread_id, + InitialHistory::New | InitialHistory::Cleared => None, + }; + settings_owner + .and_then(|thread_id| { + codex_history::latest_disabled_plugin_ids( + conversation_history.get_rollout_items(), + thread_id, + ) + }) + .map(<[String]>::to_vec) + .unwrap_or_default() + }); // TODO (aibrahim): Consolidate config.model and config.model_reasoning_effort into config.collaboration_mode // to avoid extracting these fields separately and constructing CollaborationMode here. let collaboration_mode = CollaborationMode { @@ -771,6 +789,7 @@ impl Session { runtime_workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, + disabled_plugin_ids, original_config_do_not_use: Arc::clone(&config), metrics_service_name, app_server_client_name: None, diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index 8f14a5646d..ea078a451e 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -147,6 +147,7 @@ pub(super) async fn spawn_review_thread( config: per_turn_config, auth_manager: auth_manager_for_context, initial_settings: Arc::clone(&step_settings), + disabled_plugin_ids: parent_turn_context.disabled_plugin_ids.clone(), current_settings: ArcSwap::from(step_settings), session_telemetry: session_telemetry_for_context, provider: provider_for_context, diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index c76b9b4fec..cd75eaac8e 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -266,6 +266,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), root_turn_id: None, + disabled_plugin_ids: None, #[allow(deprecated)] cwd: turn_context.cwd.clone(), workspace_roots: None, @@ -316,6 +317,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif let mut previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), root_turn_id: None, + disabled_plugin_ids: None, #[allow(deprecated)] cwd: turn_context.cwd.clone(), workspace_roots: None, @@ -1598,6 +1600,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), root_turn_id: Some("root-turn".to_string()), + disabled_plugin_ids: None, #[allow(deprecated)] cwd: turn_context.cwd.clone(), workspace_roots: None, @@ -1695,6 +1698,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis serde_json::to_value(Some(TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), root_turn_id: Some("root-turn".to_string()), + disabled_plugin_ids: None, #[allow(deprecated)] cwd: turn_context.cwd.clone(), workspace_roots: None, @@ -1730,6 +1734,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), root_turn_id: None, + disabled_plugin_ids: None, #[allow(deprecated)] cwd: turn_context.cwd.clone(), workspace_roots: None, @@ -1868,6 +1873,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo let current_context_item = TurnContextItem { turn_id: Some(current_turn_id.clone()), root_turn_id: None, + disabled_plugin_ids: None, #[allow(deprecated)] cwd: turn_context.cwd.clone(), workspace_roots: None, @@ -1998,6 +2004,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), root_turn_id: None, + disabled_plugin_ids: None, #[allow(deprecated)] cwd: turn_context.cwd.clone(), workspace_roots: None, @@ -2176,6 +2183,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), root_turn_id: None, + disabled_plugin_ids: None, #[allow(deprecated)] cwd: turn_context.cwd.clone(), workspace_roots: None, diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 298ffb8759..3e9c4c607d 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -116,6 +116,8 @@ pub(crate) struct SessionConfiguration { pub(super) codex_home: AbsolutePathBuf, /// Optional user-facing name for the thread, updated during the session. pub(super) thread_name: Option, + /// Thread-owned plugin selection inherited by future turns. + pub(super) disabled_plugin_ids: Vec, // TODO(pakrym): Remove config from here pub(super) original_config_do_not_use: Arc, @@ -302,6 +304,7 @@ impl SessionConfiguration { reasoning_summary: self.step_settings.reasoning_summary, personality: self.step_settings.personality, collaboration_mode: self.step_settings.collaboration_mode.clone(), + disabled_plugin_ids: self.disabled_plugin_ids.clone(), } } @@ -330,6 +333,7 @@ impl SessionConfiguration { service_tier: Some(self.step_settings.service_tier.clone()), collaboration_mode: Some(self.step_settings.collaboration_mode.clone()), personality: self.step_settings.personality, + disabled_plugin_ids: Some(self.disabled_plugin_ids.clone()), ..Default::default() } } @@ -370,6 +374,9 @@ impl SessionConfiguration { current_environments: &[TurnEnvironmentSelection], ) -> ConstraintResult { let mut next_configuration = self.clone(); + if let Some(disabled_plugin_ids) = &updates.disabled_plugin_ids { + next_configuration.disabled_plugin_ids = disabled_plugin_ids.clone(); + } let current_file_system_sandbox_policy = self.file_system_sandbox_policy(current_environments); let file_system_policy_has_rebindable_project_root_write = @@ -573,6 +580,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) service_tier_for_turn: Option, pub(crate) app_server_client_name: Option, pub(crate) app_server_client_version: Option, + pub(crate) disabled_plugin_ids: Option>, } pub(crate) struct AppServerClientMetadata { diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index bfb4bc552d..cfaec60b17 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -3807,6 +3807,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), root_turn_id: None, + disabled_plugin_ids: None, #[allow(deprecated)] cwd: turn_context.cwd.clone(), workspace_roots: None, @@ -4527,6 +4528,7 @@ async fn set_rate_limits_retains_previous_credits() { runtime_workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, + disabled_plugin_ids: Vec::new(), original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, @@ -4647,6 +4649,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { runtime_workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, + disabled_plugin_ids: Vec::new(), original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, @@ -5293,6 +5296,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati runtime_workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, + disabled_plugin_ids: Vec::new(), original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, @@ -6376,6 +6380,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_packaged_zsh() { runtime_workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, + disabled_plugin_ids: Vec::new(), original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, @@ -6535,6 +6540,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { runtime_workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, + disabled_plugin_ids: Vec::new(), original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, @@ -6835,6 +6841,7 @@ async fn make_session_with_config_and_rx( runtime_workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, + disabled_plugin_ids: Vec::new(), original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, @@ -6963,6 +6970,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx( runtime_workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, + disabled_plugin_ids: Vec::new(), original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, @@ -8701,6 +8709,7 @@ where runtime_workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, + disabled_plugin_ids: Vec::new(), original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index 012da43b42..81f836e001 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -1272,6 +1272,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { code_mode_session_provider: Arc::new(codex_code_mode::DisabledCodeModeSessionProvider), extensions: codex_extension_api::empty_extension_registry(), conversation_history: InitialHistory::New, + disabled_plugin_ids: None, requested_history_mode: None, fork_persistence: ForkPersistence::Copied, session_source: SessionSource::SubAgent(SubAgentSource::Other( diff --git a/codex-rs/core/src/session/thread_settings.rs b/codex-rs/core/src/session/thread_settings.rs index 5194ab7c91..2cc34cca8e 100644 --- a/codex-rs/core/src/session/thread_settings.rs +++ b/codex-rs/core/src/session/thread_settings.rs @@ -71,6 +71,7 @@ pub(super) fn prepare_update(overrides: ThreadSettingsOverrides) -> SessionSetti service_tier, collaboration_mode, personality, + disabled_plugin_ids, } = overrides; SessionSettingsUpdate { step_settings: StepSettingsUpdate { @@ -90,6 +91,7 @@ pub(super) fn prepare_update(overrides: ThreadSettingsOverrides) -> SessionSetti permission_profile, active_permission_profile, windows_sandbox_level, + disabled_plugin_ids, ..Default::default() } } diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index ac60990d1e..ae596b734b 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -295,6 +295,8 @@ pub struct TurnContext { /// Frozen settings used to construct this context. Legacy turn consumers /// keep this view even when later steps use different settings. pub(crate) initial_settings: Arc, + /// Thread-owned plugin selection captured when this turn was admitted. + pub(crate) disabled_plugin_ids: Vec, /// Snapshot for the next step; request consumers use their captured StepContext. pub(super) current_settings: ArcSwap, /// Turn-wide telemetry; model-attributed step work should use `StepContext::session_telemetry`. @@ -594,6 +596,7 @@ impl TurnContext { use_model_token_budget_defaults: self.use_model_token_budget_defaults, auth_manager: self.auth_manager.clone(), initial_settings: Arc::clone(&step_settings), + disabled_plugin_ids: self.disabled_plugin_ids.clone(), current_settings: ArcSwap::from(step_settings), session_telemetry, provider: self.provider.clone(), @@ -655,6 +658,7 @@ impl TurnContext { TurnContextItem { turn_id: Some(self.sub_id.clone()), root_turn_id: self.turn_metadata_state.root_turn_id(), + disabled_plugin_ids: Some(self.disabled_plugin_ids.clone()), cwd, workspace_roots: (!workspace_roots.is_empty()).then_some(workspace_roots), current_date: self.current_date.clone(), @@ -866,6 +870,7 @@ impl Session { use_model_token_budget_defaults, auth_manager, initial_settings: Arc::clone(&step_settings), + disabled_plugin_ids: session_configuration.disabled_plugin_ids.clone(), current_settings: ArcSwap::from(step_settings), session_telemetry: session_telemetry_for_context, provider, diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 8025e00b5b..56b4df5f94 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -247,6 +247,8 @@ pub struct StartThreadOptions { pub client_mcp_extensions: ClientMcpExtensions, /// Thread ID reserved before startup so the caller can associate host-owned state with it. pub reserved_thread_id: Option, + /// Initial thread-owned plugin selection; omission restores persisted settings. + pub disabled_plugin_ids: Option>, } impl StartThreadOptions { @@ -267,6 +269,7 @@ impl StartThreadOptions { thread_extension_init: ExtensionDataInit::default(), client_mcp_extensions: ClientMcpExtensions::default(), reserved_thread_id: None, + disabled_plugin_ids: None, } } } @@ -1410,6 +1413,18 @@ impl ThreadManager { InitialHistory::Forked(_) => history.forked_from_id(), InitialHistory::New | InitialHistory::Cleared => None, }; + // Capture the source's settings before truncating its model history. + options.disabled_plugin_ids = Some(options.disabled_plugin_ids.unwrap_or_else(|| { + source_thread_id + .and_then(|thread_id| { + codex_history::latest_disabled_plugin_ids( + history.get_rollout_items(), + thread_id, + ) + }) + .map(<[String]>::to_vec) + .unwrap_or_default() + })); let multi_agent_version = self .state .effective_multi_agent_version_for_spawn( @@ -1914,6 +1929,7 @@ impl ThreadManagerState { thread_extension_init, client_mcp_extensions, reserved_thread_id, + disabled_plugin_ids, } = options; let inherited_environments = captured_environments.or(inherited_environments); let session_source = session_source.unwrap_or_else(|| self.session_source.clone()); @@ -2041,6 +2057,7 @@ impl ThreadManagerState { code_mode_session_provider: Arc::clone(&self.code_mode_session_provider), extensions, conversation_history: initial_history, + disabled_plugin_ids, requested_history_mode: history_mode, fork_persistence, // Keep only the manager registration internal. The session and its saved diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index f14c48e315..387af5e852 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -3768,23 +3768,19 @@ async fn code_mode_wait_uses_its_own_max_tokens_budget() -> Result<()> { let _ = config.features.enable(Feature::CodeMode); }); let test = builder.build(&server).await?; - let completion_gate = test.workspace_path("code-mode-max-tokens.ready"); - let completion_wait = wait_for_file_source(&completion_gate)?; - let code = format!( - r#"// @exec: {{"max_output_tokens": 100}} + // An explicit yield separates the output batches without a nested shell call. + let code = r#"// @exec: {"max_output_tokens": 100} text("phase 1"); yield_control(); -{completion_wait} text("token one token two token three token four token five token six token seven"); -"# - ); +"#; responses::mount_sse_once( &server, sse(vec![ ev_response_created("resp-1"), - ev_custom_tool_call("call-1", "exec", &code), + ev_custom_tool_call("call-1", "exec", code), ev_completed("resp-1"), ]), ) @@ -3802,11 +3798,10 @@ text("token one token two token three token four token five token six token seve let first_request = first_completion.single_request(); let first_items = custom_tool_output_items(&first_request, "call-1"); - assert_eq!(first_items.len(), 2); + assert_eq!(first_items.len(), 2, "exec output: {first_items:?}"); assert_eq!(text_item(&first_items, /*index*/ 1), "phase 1"); let cell_id = extract_running_cell_id(text_item(&first_items, /*index*/ 0)); - fs::write(&completion_gate, "ready")?; responses::mount_sse_once( &server, sse(vec![ @@ -3837,7 +3832,7 @@ text("token one token two token three token four token five token six token seve let second_request = second_completion.single_request(); let second_items = function_tool_output_items(&second_request, "call-2"); - assert_eq!(second_items.len(), 2); + assert_eq!(second_items.len(), 2, "wait output: {second_items:?}"); assert_regex_match( concat!( r"(?s)\A", diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index e5dd6f61bc..df73438792 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -31,6 +31,7 @@ fn resume_history( let turn_ctx = TurnContextItem { turn_id: Some(turn_id.clone()), root_turn_id: None, + disabled_plugin_ids: None, cwd: config.cwd.clone(), workspace_roots: None, current_date: None, diff --git a/codex-rs/core/tests/suite/settings_commits.rs b/codex-rs/core/tests/suite/settings_commits.rs index f1ee6444ec..fd686b7435 100644 --- a/codex-rs/core/tests/suite/settings_commits.rs +++ b/codex-rs/core/tests/suite/settings_commits.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use codex_core::StartIfIdleSubmission; use codex_core::TurnInputRequest; use codex_core::TurnInputSubmission; use codex_core::config::Config; @@ -9,7 +10,9 @@ use codex_history::RolloutItem; use codex_history::RolloutLine; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; +use codex_protocol::protocol::ThreadHistoryMode; use codex_protocol::protocol::ThreadSettingsOverrides; +use codex_protocol::protocol::ThreadSettingsSnapshot; use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; use core_test_support::responses; @@ -17,6 +20,7 @@ use core_test_support::skip_if_no_network; use core_test_support::streaming_sse::StreamingSseChunk; use core_test_support::streaming_sse::start_streaming_sse_server; use core_test_support::submit_thread_settings; +use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; @@ -34,6 +38,106 @@ const INITIAL_MODEL: &str = "gpt-5.4"; const COMMITTED_MODEL: &str = "gpt-5.2"; const TIMEOUT: Duration = Duration::from_secs(10); +fn assert_checkpoints(test: &TestCodex, expected: &[ThreadSettingsSnapshot]) -> Result<()> { + let rollout_path = test.codex.rollout_path().expect("rollout path"); + let rollout: Vec = std::fs::read_to_string(rollout_path)? + .lines() + .map(codex_rollout::parse_rollout_line) + .collect::>()?; + let snapshots: Vec<_> = rollout + .into_iter() + .filter_map(|line| match line.item { + RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(applied)) + if applied.thread_id == Some(test.session_configured.thread_id) => + { + Some(applied.thread_settings) + } + _ => None, + }) + .collect(); + assert_eq!(snapshots, expected); + Ok(()) +} + +#[test_case(ThreadHistoryMode::Legacy; "legacy")] +#[test_case(ThreadHistoryMode::Paginated; "paginated")] +#[tokio::test] +async fn initial_plugin_ids_use_turn_context_without_extra_settings_checkpoints( + history_mode: ThreadHistoryMode, +) -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let test = test_codex() + .with_history_mode(history_mode) + .build_with_auto_env(&server) + .await?; + let selected = vec!["slack@openai".to_string()]; + submit_thread_settings( + &test.codex, + ThreadSettingsOverrides { + disabled_plugin_ids: Some(selected.clone()), + ..Default::default() + }, + ) + .await?; + let response = responses::mount_sse_once(&server, responses::sse_completed("first turn")).await; + let submission = test + .codex + .start_turn_if_idle(TurnInputRequest::user_input(Vec::new())) + .await?; + let StartIfIdleSubmission::Started { turn_id } = submission else { + panic!("expected an accepted first turn, got {submission:?}"); + }; + wait_for_event( + &test.codex, + |event| matches!(event, EventMsg::TurnComplete(completed) if completed.turn_id == turn_id), + ) + .await; + test.codex.flush_rollout().await?; + assert_checkpoints(&test, &[])?; + let rollout_path = test.codex.rollout_path().expect("rollout path"); + let (items, _, parse_errors) = + codex_rollout::RolloutRecorder::load_rollout_items(&rollout_path).await?; + assert_eq!(parse_errors, 0); + let context = items.iter().find_map(|item| match item { + RolloutItem::TurnContext(context) + if context.turn_id.as_deref() == Some(turn_id.as_str()) => + { + Some(context) + } + _ => None, + }); + assert_eq!( + context + .expect("inputless first turn context") + .disabled_plugin_ids, + Some(selected) + ); + assert_eq!(response.requests().len(), 1); + + submit_thread_settings( + &test.codex, + ThreadSettingsOverrides { + disabled_plugin_ids: Some(vec![]), + ..Default::default() + }, + ) + .await?; + let expected = vec![test.codex.thread_settings_snapshot().await]; + assert!(expected[0].disabled_plugin_ids.is_empty()); + test.codex.flush_rollout().await?; + assert_checkpoints(&test, &expected)?; + let response = + responses::mount_sse_once(&server, responses::sse_completed("second turn")).await; + test.submit_text_turn("second turn").await?; + test.codex.flush_rollout().await?; + assert_eq!(response.requests().len(), 1); + assert_checkpoints(&test, &expected)?; + test.codex.shutdown_and_wait().await?; + Ok(()) +} + struct PauseAfterCommit { gate: Mutex, mpsc::Receiver<()>)>>, } @@ -94,9 +198,12 @@ async fn settings_notifications_keep_their_commit_across_postcommit_work( .with_extensions(Arc::new(extensions.build())) .build_with_auto_env(&server) .await?; - let initial = test.codex.restorable_thread_settings().await; + let mut initial = test.codex.restorable_thread_settings().await; + // Restore only runtime model settings, without overwriting the committed plugin selection. + initial.disabled_plugin_ids = None; let thread_settings = ThreadSettingsOverrides { model: Some(COMMITTED_MODEL.to_string()), + disabled_plugin_ids: Some(vec!["slack@openai".to_string()]), ..Default::default() }; let submission = tokio::spawn({ @@ -128,11 +235,13 @@ async fn settings_notifications_keep_their_commit_across_postcommit_work( timeout(TIMEOUT, entered_rx).await??; let expected = test.codex.thread_settings_snapshot().await; assert_eq!(expected.model, COMMITTED_MODEL); + assert_eq!(expected.disabled_plugin_ids, vec!["slack@openai"]); // Submitted operations are serialized. Runtime restoration is an existing // direct writer, so it can overlap the first operation's post-commit work. timeout(TIMEOUT, test.codex.restore_thread_settings(initial)).await??; let restored = test.codex.thread_settings_snapshot().await; assert_eq!(restored.model, INITIAL_MODEL); + assert_eq!(restored.disabled_plugin_ids, vec!["slack@openai"]); release_tx.send(())?; let submission_id = timeout(TIMEOUT, submission).await???; @@ -222,6 +331,7 @@ async fn compaction_checkpoints_settings_changed_during_its_model_request() -> R ThreadSettingsOverrides { environments: Some(local_selections(updated_cwd_path.clone())), model: Some(COMMITTED_MODEL.to_string()), + disabled_plugin_ids: Some(vec!["slack@openai".to_string()]), ..Default::default() }, ) diff --git a/codex-rs/history/src/lib.rs b/codex-rs/history/src/lib.rs index 77e1b66277..2be359013b 100644 --- a/codex-rs/history/src/lib.rs +++ b/codex-rs/history/src/lib.rs @@ -453,6 +453,34 @@ fn session_cwd_from_items(items: &[RolloutItem]) -> Option { }) } +/// Returns a thread's latest plugin selection, with a turn-context fallback. +/// +/// Forked history may contain ancestor snapshots, and compaction may append a +/// frozen turn context after an update. Neither can replace thread-owned settings. +/// Without an owned snapshot, only the latest turn context supplies the initial +/// selection; a missing field must not resurrect a selection from an older turn. +pub fn latest_disabled_plugin_ids(items: &[RolloutItem], thread_id: ThreadId) -> Option<&[String]> { + if let Some(ids) = items.iter().rev().find_map(|item| { + if let RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(event)) = item + && event.thread_id == Some(thread_id) + { + Some(event.thread_settings.disabled_plugin_ids.as_slice()) + } else { + None + } + }) { + return Some(ids); + } + items + .iter() + .rev() + .find_map(|item| match item { + RolloutItem::TurnContext(context) => Some(context), + _ => None, + }) + .and_then(|context| context.disabled_plugin_ids.as_deref()) +} + fn multi_agent_version_from_items( items: &[RolloutItem], thread_id: Option, diff --git a/codex-rs/history/src/tests.rs b/codex-rs/history/src/tests.rs index b4d922907a..b04d2448db 100644 --- a/codex-rs/history/src/tests.rs +++ b/codex-rs/history/src/tests.rs @@ -1,11 +1,136 @@ use anyhow::Result; use codex_protocol::models::ConfigurationReasoning; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::ThreadSettingsSnapshot; use pretty_assertions::assert_eq; use serde_json::json; use super::*; +fn thread_settings_snapshot(disabled_plugin_ids: Vec) -> Result { + Ok(serde_json::from_value(json!({ + "model": "gpt-5", + "model_provider_id": "openai", + "approval_policy": "never", + "approvals_reviewer": "user", + "permission_profile": codex_protocol::models::PermissionProfile::read_only(), + "cwd": std::env::current_dir()?, + "collaboration_mode": { + "mode": "default", + "settings": { + "model": "gpt-5", + "reasoning_effort": null, + "developer_instructions": null + } + }, + "disabled_plugin_ids": disabled_plugin_ids + }))?) +} + +#[test] +fn latest_disabled_plugin_ids_preserves_owned_updates_and_clears() -> Result<()> { + let thread_id = ThreadId::new(); + let ancestor_id = ThreadId::new(); + let selected = thread_settings_snapshot(vec!["example@marketplace".to_string()])?; + let cleared = thread_settings_snapshot(Vec::new())?; + let item = |thread_id, thread_settings| { + RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied( + codex_protocol::protocol::ThreadSettingsAppliedEvent { + thread_id, + thread_settings, + }, + )) + }; + let foreign_items = vec![ + item(Some(ancestor_id), selected.clone()), + item(/*thread_id*/ None, selected.clone()), + ]; + assert_eq!(latest_disabled_plugin_ids(&foreign_items, thread_id), None); + + let mut history = vec![item(Some(thread_id), selected.clone())]; + history.extend(foreign_items.clone()); + assert_eq!( + latest_disabled_plugin_ids(&history, thread_id), + Some(selected.disabled_plugin_ids.as_slice()) + ); + + history.push(item(Some(thread_id), cleared.clone())); + history.extend(foreign_items); + history.push(turn_context_with_disabled_plugins(Some( + selected.disabled_plugin_ids, + ))?); + assert_eq!( + latest_disabled_plugin_ids(&history, thread_id), + Some(cleared.disabled_plugin_ids.as_slice()) + ); + Ok(()) +} + +fn turn_context_with_disabled_plugins( + disabled_plugin_ids: Option>, +) -> Result { + let mut context: TurnContextItem = serde_json::from_value(json!({ + "cwd": std::env::current_dir()?, + "approval_policy": "never", + "sandbox_policy": { "type": "danger-full-access" }, + "model": "gpt-5", + "summary": "auto" + }))?; + context.disabled_plugin_ids = disabled_plugin_ids; + Ok(RolloutItem::TurnContext(context)) +} + +#[test] +fn latest_disabled_plugin_ids_falls_back_only_to_latest_turn_context() -> Result<()> { + let thread_id = ThreadId::new(); + let selected = vec!["example@marketplace".to_string()]; + let mut history = vec![turn_context_with_disabled_plugins(Some(selected.clone()))?]; + assert_eq!( + latest_disabled_plugin_ids(&history, thread_id), + Some(selected.as_slice()) + ); + + history.push(RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied( + codex_protocol::protocol::ThreadSettingsAppliedEvent { + thread_id: Some(ThreadId::new()), + thread_settings: thread_settings_snapshot(Vec::new())?, + }, + ))); + assert_eq!( + latest_disabled_plugin_ids(&history, thread_id), + Some(selected.as_slice()) + ); + + let cleared = Vec::new(); + history.push(turn_context_with_disabled_plugins(Some(cleared.clone()))?); + assert_eq!( + latest_disabled_plugin_ids(&history, thread_id), + Some(cleared.as_slice()) + ); + + history.push(turn_context_with_disabled_plugins( + /*disabled_plugin_ids*/ None, + )?); + assert_eq!(latest_disabled_plugin_ids(&history, thread_id), None); + Ok(()) +} + +#[test] +fn older_thread_settings_snapshot_defaults_disabled_plugins_to_empty() -> Result<()> { + let expected = thread_settings_snapshot(Vec::new())?; + let mut legacy = serde_json::to_value(&expected)?; + legacy + .as_object_mut() + .unwrap() + .remove("disabled_plugin_ids"); + + assert_eq!( + serde_json::from_value::(legacy)?, + expected + ); + Ok(()) +} + #[test] fn response_item_envelope_accessors_preserve_item() { let expected_item = ResponseItem::Message { diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 3dbc19a944..e25cce88a0 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -573,6 +573,10 @@ pub struct ThreadSettingsOverrides { /// Updated personality preference. pub personality: Option, + + /// Replace the thread's disabled plugin IDs. Omission preserves the current + /// selection, and an empty list clears it. + pub disabled_plugin_ids: Option>, } /// Source classification for client-supplied context. @@ -2220,6 +2224,9 @@ pub struct ThreadSettingsSnapshot { #[serde(skip_serializing_if = "Option::is_none")] pub personality: Option, pub collaboration_mode: CollaborationMode, + /// Thread-owned plugin selection, retained even when a plugin is unavailable. + #[serde(default)] + pub disabled_plugin_ids: Vec, } #[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq, JsonSchema, TS)] @@ -3224,6 +3231,10 @@ pub struct TurnContextItem { /// Only set for subagent turns; persisted so resume keeps the scope frozen at turn start. #[serde(default, skip_serializing_if = "Option::is_none")] pub root_turn_id: Option, + /// Plugin selection captured for this turn. Absent in older histories. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub disabled_plugin_ids: Option>, pub cwd: AbsolutePathBuf, /// Effective workspace roots used to materialize symbolic /// `:workspace_roots` filesystem permissions in `permission_profile`. @@ -6061,6 +6072,7 @@ mod tests { let item = TurnContextItem { turn_id: None, root_turn_id: None, + disabled_plugin_ids: None, cwd: test_path_buf("/tmp").abs(), workspace_roots: None, current_date: None, diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index 3db2318926..d8d4c897c5 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -1798,6 +1798,7 @@ async fn resume_candidate_matches_cwd_reads_latest_turn_context() -> std::io::Re item: RolloutItem::TurnContext(TurnContextItem { turn_id: Some("turn-1".to_string()), root_turn_id: None, + disabled_plugin_ids: None, cwd: serde_json::from_value(serde_json::json!(&latest_cwd)) .expect("absolute latest cwd"), workspace_roots: None, diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index 1b04099352..308e892789 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -432,6 +432,7 @@ mod tests { &RolloutItem::TurnContext(TurnContextItem { turn_id: Some("turn-1".to_string()), root_turn_id: None, + disabled_plugin_ids: None, cwd: serde_json::from_value(serde_json::json!( std::env::current_dir() .expect("current directory") @@ -481,6 +482,7 @@ mod tests { &RolloutItem::TurnContext(TurnContextItem { turn_id: Some("turn-1".to_string()), root_turn_id: None, + disabled_plugin_ids: None, cwd: serde_json::from_value(serde_json::json!( std::env::current_dir() .expect("current directory") @@ -530,6 +532,7 @@ mod tests { &RolloutItem::TurnContext(TurnContextItem { turn_id: Some("turn-1".to_string()), root_turn_id: None, + disabled_plugin_ids: None, cwd: serde_json::from_value(serde_json::json!(&fallback_cwd)) .expect("absolute fallback cwd"), workspace_roots: None, @@ -568,6 +571,7 @@ mod tests { &RolloutItem::TurnContext(TurnContextItem { turn_id: Some("turn-1".to_string()), root_turn_id: None, + disabled_plugin_ids: None, cwd: serde_json::from_value(serde_json::json!( std::env::current_dir() .expect("current directory") @@ -613,6 +617,7 @@ mod tests { ThreadSettingsAppliedEvent { thread_id: None, thread_settings: ThreadSettingsSnapshot { + disabled_plugin_ids: Vec::new(), model: "gpt-5.2-codex".to_string(), model_provider_id: "updated-provider".to_string(), service_tier: None, diff --git a/codex-rs/thread-store/src/local/mod.rs b/codex-rs/thread-store/src/local/mod.rs index 18ee7a609c..0c2c36d413 100644 --- a/codex-rs/thread-store/src/local/mod.rs +++ b/codex-rs/thread-store/src/local/mod.rs @@ -957,6 +957,7 @@ mod tests { RolloutItem::TurnContext(TurnContextItem { turn_id: Some("turn-1".to_string()), root_turn_id: None, + disabled_plugin_ids: None, cwd: serde_json::from_value(serde_json::json!(cwd)).expect("absolute cwd"), workspace_roots: None, current_date: None, diff --git a/codex-rs/thread-store/src/local/model_context_tests.rs b/codex-rs/thread-store/src/local/model_context_tests.rs index d86f4ca652..55cae4f6d2 100644 --- a/codex-rs/thread-store/src/local/model_context_tests.rs +++ b/codex-rs/thread-store/src/local/model_context_tests.rs @@ -851,6 +851,7 @@ fn turn_context(root: &Path, turn_id: &str) -> RolloutItem { RolloutItem::TurnContext(TurnContextItem { turn_id: Some(turn_id.to_string()), root_turn_id: None, + disabled_plugin_ids: None, cwd: serde_json::from_value(serde_json::json!(root)).expect("absolute cwd"), workspace_roots: None, current_date: None, diff --git a/codex-rs/thread-store/src/local/rollout_migration_tests.rs b/codex-rs/thread-store/src/local/rollout_migration_tests.rs index 2190bc0f53..473ead2502 100644 --- a/codex-rs/thread-store/src/local/rollout_migration_tests.rs +++ b/codex-rs/thread-store/src/local/rollout_migration_tests.rs @@ -1734,6 +1734,7 @@ async fn migration_compacts_subagent_prefix_and_does_not_project_it() { RolloutItem::TurnContext(TurnContextItem { turn_id: Some("child-turn".to_string()), root_turn_id: None, + disabled_plugin_ids: None, cwd: serde_json::from_value(json!(home.path())).expect("absolute cwd"), workspace_roots: None, current_date: None, diff --git a/codex-rs/thread-store/src/thread_metadata_sync.rs b/codex-rs/thread-store/src/thread_metadata_sync.rs index 22ee5533ff..791c0ff372 100644 --- a/codex-rs/thread-store/src/thread_metadata_sync.rs +++ b/codex-rs/thread-store/src/thread_metadata_sync.rs @@ -656,6 +656,7 @@ mod tests { ThreadSettingsAppliedEvent { thread_id: None, thread_settings: ThreadSettingsSnapshot { + disabled_plugin_ids: Vec::new(), model: "gpt-5.2-codex".to_string(), model_provider_id: "updated-provider".to_string(), service_tier: None,