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 5752920d94..15d7e3619b 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst differ diff --git a/codex-rs/app-server/src/request_processors/feedback_processor.rs b/codex-rs/app-server/src/request_processors/feedback_processor.rs index c03fa3b060..fa5fe4ec68 100644 --- a/codex-rs/app-server/src/request_processors/feedback_processor.rs +++ b/codex-rs/app-server/src/request_processors/feedback_processor.rs @@ -727,6 +727,7 @@ mod tests { item: RolloutItem::TurnContext(TurnContextItem { turn_id: Some((*turn_id).to_string()), root_turn_id: None, + disabled_plugin_ids: None, cwd: AbsolutePathBuf::from_absolute_path(tempdir.path()) .expect("absolute feedback rollout directory"), workspace_roots: None, diff --git a/codex-rs/app-server/src/request_processors/persisted_resume_settings_tests.rs b/codex-rs/app-server/src/request_processors/persisted_resume_settings_tests.rs index 9cce9977c3..d6ce293b53 100644 --- a/codex-rs/app-server/src/request_processors/persisted_resume_settings_tests.rs +++ b/codex-rs/app-server/src/request_processors/persisted_resume_settings_tests.rs @@ -30,6 +30,7 @@ fn settings_item( ThreadSettingsAppliedEvent { thread_id: None, thread_settings: ThreadSettingsSnapshot { + disabled_plugin_ids: Vec::new(), model: "gpt-5".to_string(), model_provider_id: "openai".to_string(), service_tier: None, @@ -64,6 +65,7 @@ fn turn_context_item( RolloutItem::TurnContext(TurnContextItem { turn_id: Some(turn_id.to_string()), root_turn_id: None, + disabled_plugin_ids: None, cwd: cwd(), workspace_roots: Some(vec![cwd()]), current_date: None, diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index b939a0f3f4..edceb7b167 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -874,6 +874,7 @@ impl TurnRequestProcessor { if has_any_overrides { thread .preview_thread_settings_overrides(CodexThreadSettingsOverrides { + disabled_plugin_ids: None, environments: environments.clone(), runtime_workspace_roots: runtime_workspace_roots.clone(), approval_policy, @@ -897,6 +898,7 @@ impl TurnRequestProcessor { } Ok(codex_protocol::protocol::ThreadSettingsOverrides { + disabled_plugin_ids: None, environments, runtime_workspace_roots, profile_workspace_roots, diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 01c3737f4a..d2a2010b5f 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -1204,6 +1204,7 @@ async fn spawn_agent_fork_from_paginated_parent_uses_model_context_prefix() { ThreadSettingsAppliedEvent { thread_id: Some(parent_thread_id), thread_settings: ThreadSettingsSnapshot { + disabled_plugin_ids: Vec::new(), model: "parent-only-model".to_string(), model_provider_id: "parent-only-provider".to_string(), service_tier: None, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 3491aa30d2..83908c5173 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -106,6 +106,7 @@ pub(crate) async fn run_codex_thread_interactive( code_mode_session_provider: parent_session.services.code_mode_service.session_provider(), extensions, conversation_history, + disabled_plugin_ids: None, requested_history_mode: None, fork_persistence: ForkPersistence::Copied, session_source, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index a630749a4b..7177740fea 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -152,6 +152,7 @@ pub struct CodexThreadSettingsOverrides { pub service_tier: Option>, 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,