From d78f68f8d1016b2ffba14a91ccd33ef7fa527971 Mon Sep 17 00:00:00 2001 From: Henry Levy Date: Wed, 4 Nov 2026 19:36:51 +0000 Subject: [PATCH] Allow per-turn overrides for direct-only tool namespaces (#59481) ## What changed - Add `direct_only_tool_namespaces` to `TurnStartOptions`. Omission inherits the thread configuration; an empty list clears direct-only routing for that turn. Steering ignores the option. - Persist explicit overrides in turn context and restore them when recovering an interrupted turn, including turns started by standalone tool output. - Keep subsequent turns and spawned or resumed child agents on the thread's routing defaults. ## Testing Add coverage for routing changes across turns in Responses and Responses Lite, recovery without carrying overrides into subsequent turns, and child-agent inheritance. GitOrigin-RevId: cea1a997424572d4cd372cd15b098626904c6796 --- .../app-server-exports-stable.json.zst | Bin 167079 -> 167123 bytes .../request_processors/feedback_processor.rs | 1 + .../persisted_resume_settings_tests.rs | 1 + .../src/request_processors/turn_processor.rs | 2 +- codex-rs/core/src/agent/child_config.rs | 6 + .../core/src/context_manager/history_tests.rs | 1 + codex-rs/core/src/session/mod.rs | 18 +++ .../session/rollout_reconstruction_tests.rs | 8 ++ codex-rs/core/src/session/tests.rs | 1 + codex-rs/core/src/session/turn_context.rs | 23 +++- codex-rs/core/src/session/turn_input.rs | 2 + codex-rs/core/src/state/session.rs | 29 +++-- codex-rs/core/src/tasks/mod.rs | 1 + .../src/tools/handlers/multi_agents_tests.rs | 35 ++++++ .../core/tests/suite/cyber_access_program.rs | 115 +++++++++++++----- .../core/tests/suite/mcp_tool_exposure.rs | 101 +++++++++++++++ codex-rs/core/tests/suite/resume_warning.rs | 1 + codex-rs/protocol/src/protocol.rs | 5 + codex-rs/protocol/src/turn_input.rs | 3 + codex-rs/rollout/src/recorder_tests.rs | 1 + codex-rs/state/src/extract.rs | 4 + codex-rs/thread-store/src/local/mod.rs | 1 + .../src/local/model_context_tests.rs | 1 + .../src/local/rollout_migration_tests.rs | 1 + 24 files changed, 322 insertions(+), 39 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 0b19013af9dd2444078cf6999a927f2c12c88e02..cee815df5a7dc6e435527199b12d06beb9182499 100644 GIT binary patch delta 7337 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 P*b_gLFII;5o9@v*)QbIM delta 7292 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%0AZ?-_+d#3UeKAEix$2zU(5)MN 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 WMNe_c6XXc<-tsRWQ$E{d;j{#$IO@#+ 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 bf8ac0b152..67cdd4da76 100644 --- a/codex-rs/app-server/src/request_processors/feedback_processor.rs +++ b/codex-rs/app-server/src/request_processors/feedback_processor.rs @@ -850,6 +850,7 @@ mod tests { item: RolloutItem::TurnContext(TurnContextItem { turn_id: Some((*turn_id).to_string()), root_turn_id: None, + direct_only_tool_namespaces: None, disabled_plugin_ids: None, cwd: AbsolutePathBuf::from_absolute_path(tempdir.path()) .expect("absolute feedback rollout directory"), 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 135e671414..42caf2b8d0 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 @@ -68,6 +68,7 @@ fn turn_context_item( RolloutItem::TurnContext(TurnContextItem { turn_id: Some(turn_id.to_string()), root_turn_id: None, + direct_only_tool_namespaces: None, disabled_plugin_ids: None, cwd: cwd(), approval_policy, 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 faa5b420fb..2667f98dd6 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -694,12 +694,12 @@ impl TurnRequestProcessor { .on_start(TurnStartOptions { turn_trigger: params.turn_trigger, parent_turn_id: params.parent_turn_id, - initiating_agent_path: None, root_turn_id: params.root_turn_id, final_output_json_schema: params.output_schema, service_tier: params.service_tier_for_turn, prompt_cache_ttl: None, cyber_access_program: params.cyber_access_program.map(Into::into), + ..Default::default() }) .with_additional_context(additional_context) .with_responses_metadata(params.responsesapi_client_metadata) diff --git a/codex-rs/core/src/agent/child_config.rs b/codex-rs/core/src/agent/child_config.rs index 96d9675f63..4aa8a725e8 100644 --- a/codex-rs/core/src/agent/child_config.rs +++ b/codex-rs/core/src/agent/child_config.rs @@ -143,6 +143,12 @@ fn build_agent_shared_config(turn: &TurnContext) -> Result { // Preserve activation for history forks without freezing the parent's model-owned prompts. // Fresh child startup restores configured preferences from the retained snapshot. config.token_budget = turn.configured_token_budget.clone(); + if let Some(thread_namespaces) = &turn.overridden_direct_only_tool_namespaces { + config + .code_mode + .direct_only_tool_namespaces + .clone_from(thread_namespaces); + } config.model = Some(turn.model_info().slug.clone()); config.model_provider = turn.provider.info().clone(); config.model_reasoning_effort = turn diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 0597417151..5a314620a4 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -845,6 +845,7 @@ fn reference_context_item() -> TurnContextItem { TurnContextItem { turn_id: Some("reference-turn".to_string()), root_turn_id: None, + direct_only_tool_namespaces: None, disabled_plugin_ids: None, cwd: AbsolutePathBuf::try_from( std::env::current_dir() diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 133360cd7d..40439db6bc 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -1950,6 +1950,23 @@ impl Session { } = self .reconstruct_history_from_rollout(turn_context, rollout_items) .await; + // An automatic turn can have a persisted routing override without establishing the + // user-turn context baseline. Recover its own latest context by surviving turn ID. + let restored_turn_routing = turn_attribution.as_ref().and_then(|turn| { + rollout_items + .iter() + .rev() + .find_map(|item| match item { + RolloutItem::TurnContext(context) + if context.turn_id.as_deref() == Some(turn.turn_id.as_str()) => + { + Some(context) + } + _ => None, + }) + .and_then(|context| context.direct_only_tool_namespaces.clone()) + .map(|namespaces| (turn.turn_id.clone(), namespaces)) + }); // Keep the recorded rollout unchanged. Prepare its reconstructed history before // installing it, so legacy media is processed once for this resume or fork and // will be processed again if the rollout is reconstructed in a future session. @@ -2015,6 +2032,7 @@ impl Session { ); state.last_started_turn_id = last_started_turn_id; state.turn_attribution = turn_attribution; + state.restored_turn_routing = restored_turn_routing; if let Some(world_state) = world_state_baseline { state.history.set_world_state_baseline(world_state); } diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index eeb239ba4d..66e07fdaf2 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -466,6 +466,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, + direct_only_tool_namespaces: None, disabled_plugin_ids: None, #[allow(deprecated)] cwd: turn_context.cwd.clone(), @@ -512,6 +513,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, + direct_only_tool_namespaces: None, disabled_plugin_ids: None, #[allow(deprecated)] cwd: turn_context.cwd.clone(), @@ -2167,6 +2169,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()), + direct_only_tool_namespaces: None, disabled_plugin_ids: None, #[allow(deprecated)] cwd: turn_context.cwd.clone(), @@ -2265,6 +2268,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()), + direct_only_tool_namespaces: None, disabled_plugin_ids: None, #[allow(deprecated)] cwd: turn_context.cwd.clone(), @@ -2295,6 +2299,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, + direct_only_tool_namespaces: None, disabled_plugin_ids: None, #[allow(deprecated)] cwd: turn_context.cwd.clone(), @@ -2438,6 +2443,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, + direct_only_tool_namespaces: None, disabled_plugin_ids: None, #[allow(deprecated)] cwd: turn_context.cwd.clone(), @@ -2573,6 +2579,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, + direct_only_tool_namespaces: None, disabled_plugin_ids: None, #[allow(deprecated)] cwd: turn_context.cwd.clone(), @@ -2758,6 +2765,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, + direct_only_tool_namespaces: None, disabled_plugin_ids: None, #[allow(deprecated)] cwd: turn_context.cwd.clone(), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index dd5aae05ad..9d9135feac 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -4439,6 +4439,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, + direct_only_tool_namespaces: None, disabled_plugin_ids: None, #[allow(deprecated)] cwd: turn_context.cwd.clone(), diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 065e90b220..0cb7fe3d69 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -335,6 +335,7 @@ impl std::fmt::Debug for TurnEnvironment { /// Request options for one turn, separate from persistent thread settings. #[derive(Default)] pub(crate) struct NewTurnContextOptions { + pub(crate) direct_only_tool_namespaces: Option>, pub(crate) final_output_json_schema: Option, pub(crate) prompt_cache_ttl: Option, pub(crate) cyber_access_program: Option, @@ -351,6 +352,8 @@ pub struct TurnContext { /// Turn-scoped configuration. Read step-specific settings such as service tier and /// approvals reviewer from the corresponding `StepContext` instead. pub config: Arc, + /// Thread defaults displaced by an explicit routing override for this turn. + pub(crate) overridden_direct_only_tool_namespaces: Option>, /// Preferences captured before token-budget defaults from the turn's initial model. pub(crate) configured_token_budget: Option, /// Captured once so later steps do not re-read config layers to detect user preferences. @@ -451,6 +454,7 @@ impl TurnContext { trace_id: current_span_trace_id(), realtime_active: self.realtime_active, code_mode_available: self.code_mode_available, + overridden_direct_only_tool_namespaces: None, configured_token_budget: config.token_budget.clone(), use_model_token_budget_defaults: config.features.enabled(Feature::TokenBudget) && !has_explicit_settings(&config), @@ -894,6 +898,9 @@ impl TurnContext { realtime_active: self.realtime_active, code_mode_available: self.code_mode_available, config: Arc::new(config), + overridden_direct_only_tool_namespaces: self + .overridden_direct_only_tool_namespaces + .clone(), configured_token_budget: self.configured_token_budget.clone(), use_model_token_budget_defaults: self.use_model_token_budget_defaults, auth_manager: self.auth_manager.clone(), @@ -962,6 +969,10 @@ impl TurnContext { TurnContextItem { turn_id: Some(self.sub_id.clone()), root_turn_id: self.turn_metadata_state.root_turn_id(), + direct_only_tool_namespaces: self + .overridden_direct_only_tool_namespaces + .as_ref() + .map(|_| self.config.code_mode.direct_only_tool_namespaces.clone()), disabled_plugin_ids: Some(self.disabled_plugin_ids.clone()), cwd, approval_policy: self.approval_policy(), @@ -1180,6 +1191,7 @@ impl Session { realtime_active: false, code_mode_available: true, config: per_turn_config, + overridden_direct_only_tool_namespaces: None, configured_token_budget, use_model_token_budget_defaults, auth_manager, @@ -1345,8 +1357,15 @@ impl Session { git_enrichment_policy: GitEnrichmentPolicy, ) -> Arc { let primary_turn_environment = turn_environments.primary(); - let per_turn_config = + let mut per_turn_config = self.build_per_turn_config(&session_configuration, turn_environments.local_selection()); + let overridden_direct_only_tool_namespaces = + options.direct_only_tool_namespaces.map(|namespaces| { + std::mem::replace( + &mut per_turn_config.code_mode.direct_only_tool_namespaces, + namespaces, + ) + }); // Legacy turn consumers still refer to the primary executor's repository. let cwd = primary_turn_environment .and_then(|environment| environment.cwd().to_abs_path().ok()) @@ -1475,6 +1494,8 @@ impl Session { if let Some(discovery) = skill_discovery { turn_context.extension_data.insert(discovery); } + turn_context.overridden_direct_only_tool_namespaces = + overridden_direct_only_tool_namespaces; turn_context.code_mode_available = self.services.code_mode_service.is_available(); turn_context.extension_data.insert(trusted_plugin_roots); turn_context.active_host_plugin_identities = Some( diff --git a/codex-rs/core/src/session/turn_input.rs b/codex-rs/core/src/session/turn_input.rs index eb6f48fc02..0229b165dc 100644 --- a/codex-rs/core/src/session/turn_input.rs +++ b/codex-rs/core/src/session/turn_input.rs @@ -151,6 +151,7 @@ impl PreparedTurnInputSettings { let TurnStartOptions { turn_trigger, final_output_json_schema, + direct_only_tool_namespaces, service_tier, prompt_cache_ttl, parent_turn_id, @@ -168,6 +169,7 @@ impl PreparedTurnInputSettings { updates.service_tier_for_turn = service_tier; let options = NewTurnContextOptions { + direct_only_tool_namespaces, final_output_json_schema, prompt_cache_ttl, cyber_access_program, diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index b7ad2a4d85..acd1077fe4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -96,6 +96,8 @@ pub(crate) struct SessionState { pub(crate) last_started_turn_id: Option, /// Latest regular turn, retained across completion, settings changes, and compaction. pub(crate) turn_attribution: Option, + /// Explicit namespaces from the recovered turn's own rollout record. + pub(crate) restored_turn_routing: Option<(String, Vec)>, /// Runtime accounting state for the active auto-compaction window. auto_compact_window: AutoCompactWindow, context_window_id_generator: ContextWindowIdGenerator, @@ -150,6 +152,7 @@ impl SessionState { previous_turn_settings: None, last_started_turn_id: None, turn_attribution: None, + restored_turn_routing: None, auto_compact_window: AutoCompactWindow::new_with_ids(auto_compact_window_ids), context_window_id_generator, async_compaction: crate::session::async_compaction::AsyncCompactionState::default(), @@ -223,6 +226,7 @@ impl SessionState { .replace_compacted(items, reviewer_compaction_hash.as_deref()), HistoryReplacement::Reset => { self.turn_attribution = None; + self.restored_turn_routing = None; self.history.replace_annotated(items); true } @@ -286,18 +290,25 @@ impl SessionState { } pub(crate) fn recovered_turn_start_options(&self, turn_id: &str) -> TurnStartOptions { - self.turn_attribution + let mut options = self + .turn_attribution .as_ref() .filter(|attribution| attribution.turn_id == turn_id) .map(TurnAttribution::start_options) - .unwrap_or_else(|| TurnStartOptions { - // Older rollouts only persisted the root on the model-context record. - root_turn_id: self - .reference_context_item() - .filter(|context| context.turn_id.as_deref() == Some(turn_id)) - .and_then(|context| context.root_turn_id), - ..Default::default() - }) + .unwrap_or_default(); + if let Some(context) = self + .reference_context_item() + .filter(|context| context.turn_id.as_deref() == Some(turn_id)) + { + // Older rollouts only persisted the root on the model-context record. + options.root_turn_id = options.root_turn_id.or(context.root_turn_id); + options.direct_only_tool_namespaces = context.direct_only_tool_namespaces; + } else if let Some((id, namespaces)) = &self.restored_turn_routing + && id == turn_id + { + options.direct_only_tool_namespaces = Some(namespaces.clone()); + } + options } // Token/rate limit helpers diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 36e35680a5..5337d7f364 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -556,6 +556,7 @@ impl Session { .and_then(|context| context.cyber_access_program); } let options = NewTurnContextOptions { + direct_only_tool_namespaces: start_options.direct_only_tool_namespaces, final_output_json_schema: start_options.final_output_json_schema, prompt_cache_ttl: start_options.prompt_cache_ttl, cyber_access_program: start_options.cyber_access_program, diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 254f603029..36f24c7f37 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -4689,6 +4689,41 @@ async fn build_agent_spawn_config_uses_captured_step_settings_and_turn_context_v assert_eq!(config, expected); } +#[tokio::test] +async fn child_agents_inherit_thread_routing_when_parent_overrides_it_for_one_turn() { + let home = tempfile::tempdir().expect("create test home"); + let (session, _, _events) = make_session_and_context_with_auth_config_home_and_rx( + CodexAuth::from_api_key("test"), + Vec::new(), + home.path(), + |config| config.code_mode.direct_only_tool_namespaces = vec!["thread_tools".into()], + ) + .await; + let turn = session + .new_turn_with_default_settings( + "parent-turn".into(), + crate::session::turn_context::NewTurnContextOptions { + direct_only_tool_namespaces: Some(vec!["user_message".into()]), + ..Default::default() + }, + ) + .await; + assert_eq!( + turn.config.code_mode.direct_only_tool_namespaces, + ["user_message"] + ); + let resumed = build_agent_resume_config(&turn).expect("resume config"); + let step = StepContext::for_test(turn); + let spawned = + build_agent_spawn_config(&BaseInstructions::default(), &step).expect("spawn config"); + for config in [spawned, resumed] { + assert_eq!( + config.code_mode.direct_only_tool_namespaces, + ["thread_tools"] + ); + } +} + #[tokio::test] async fn build_agent_resume_config_clears_base_instructions() { let (_session, mut turn) = make_session_and_context().await; diff --git a/codex-rs/core/tests/suite/cyber_access_program.rs b/codex-rs/core/tests/suite/cyber_access_program.rs index dfe4a24bbc..9a7fbf4645 100644 --- a/codex-rs/core/tests/suite/cyber_access_program.rs +++ b/codex-rs/core/tests/suite/cyber_access_program.rs @@ -1,6 +1,7 @@ use anyhow::Result; use codex_core::RecoverTurnRequest; use codex_core::StartIfIdleSubmission; +use codex_core::TurnInput; use codex_core::TurnInputRequest; use codex_core::TurnInputSubmission; use codex_core::TurnStartOptions; @@ -12,6 +13,10 @@ use codex_protocol::turn_input::CyberAccessProgram; use codex_protocol::user_input::UserInput; use codex_rollout::RolloutItem; use codex_rollout::RolloutRecorder; +use core_test_support::apps_test_server::AppsTestServer; +use core_test_support::apps_test_server::SEARCH_CALENDAR_CREATE_TOOL; +use core_test_support::apps_test_server::SEARCH_CALENDAR_NAMESPACE; +use core_test_support::apps_test_server::search_capable_apps_builder; use core_test_support::responses; use core_test_support::streaming_sse::StreamingSseChunk; use core_test_support::streaming_sse::start_streaming_sse_server; @@ -27,35 +32,64 @@ use wiremock::matchers::header; use wiremock::matchers::method; use wiremock::matchers::path; +#[test_case(false; "user_input")] +#[test_case(true; "standalone_tool_output")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn recover_turn_restores_cyber_access_program_without_making_it_sticky() -> Result<()> { +async fn recover_turn_restores_start_options_without_making_them_sticky( + standalone_tool_output: bool, +) -> Result<()> { // Keep sampling pending until interruption so restart must reload the unfinished turn. let (release_response, response_gate) = oneshot::channel(); - let (initial_server, _completions) = - start_streaming_sse_server(vec![vec![StreamingSseChunk { + let (initial_server, _completions) = start_streaming_sse_server(vec![ + vec![StreamingSseChunk { + gate: None, + body: responses::sse_completed("seed"), + }], + vec![StreamingSseChunk { gate: Some(response_gate), body: responses::sse(vec![responses::ev_completed("resp-initial")]), - }]]) - .await; - let mut builder = test_codex().with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()); - let initial = builder.build_with_streaming_server(&initial_server).await?; + }], + ]) + .await; + let server = responses::start_mock_server().await; + let apps = AppsTestServer::mount_searchable(&server).await?; + let builder = || { + search_capable_apps_builder(apps.chatgpt_base_url.clone()).with_config(|config| { + config + .features + .enable(Feature::CodeModeOnly) + .expect("enable code mode only"); + config.code_mode.direct_only_tool_namespaces = + vec![SEARCH_CALENDAR_NAMESPACE.to_owned()]; + }) + }; + let initial = builder() + .build_with_streaming_server(&initial_server) + .await?; + initial.submit_text_turn("seed user turn").await?; + let request = if standalone_tool_output { + TurnInputRequest::new(TurnInput::ResponseItem(serde_json::from_value(json!({ + "type": "function_call_output", "name": "result", "namespace": "example", + "output": "recover this tool output", + }))?)) + } else { + TurnInputRequest::user_input(vec![UserInput::Text { + text: "recover this turn".to_owned(), + text_elements: Vec::new(), + }]) + }; let TurnInputSubmission::Started { turn_id, .. } = initial .codex - .start_or_steer_turn( - TurnInputRequest::user_input(vec![UserInput::Text { - text: "recover this turn".to_owned(), - text_elements: Vec::new(), - }]) - .on_start(TurnStartOptions { - cyber_access_program: Some(CyberAccessProgram::DaybreakBlue), - ..Default::default() - }), - ) + .start_or_steer_turn(request.on_start(TurnStartOptions { + direct_only_tool_namespaces: Some(Vec::new()), + cyber_access_program: Some(CyberAccessProgram::DaybreakBlue), + ..Default::default() + })) .await? else { panic!("expected a new turn"); }; - initial_server.wait_for_request_count(/*count*/ 1).await; + initial_server.wait_for_request_count(/*count*/ 2).await; initial.codex.submit(Op::Interrupt).await?; wait_for_event(&initial.codex, |event| { matches!(event, EventMsg::TurnAborted(_)) @@ -65,8 +99,7 @@ async fn recover_turn_restores_cyber_access_program_without_making_it_sticky() - initial_server.shutdown().await; initial.codex.flush_rollout().await?; - let server = responses::start_mock_server().await; - let test = builder.restart(&server, &initial).await?; + let test = builder().restart(&server, &initial).await?; let (rollout, _, _) = RolloutRecorder::load_rollout_items( &test .codex @@ -91,6 +124,11 @@ async fn recover_turn_restores_cyber_access_program_without_making_it_sticky() - Some(CyberAccessProgram::DaybreakBlue) ); + assert_eq!( + persisted_context.direct_only_tool_namespaces, + Some(Vec::new()) + ); + let response_mock = responses::mount_sse_once(&server, responses::sse_completed("resp-1")).await; let submission = test @@ -118,15 +156,33 @@ async fn recover_turn_restores_cyber_access_program_without_making_it_sticky() - response_mock.single_request().body_json()["access_programs"], json!({"cyber": "daybreak_blue"}) ); - assert_eq!( - response_mock - .single_request() + let recovered_request = response_mock.single_request(); + let recovered_input_count = if standalone_tool_output { + recovered_request + .inputs_of_type("function_call_output") + .iter() + .filter(|item| item["name"] == "result") + .count() + } else { + recovered_request .message_input_texts("user") - .into_iter() - .filter(|text| text == "recover this turn") - .count(), - 1 - ); + .iter() + .filter(|text| text.as_str() == "recover this turn") + .count() + }; + assert_eq!(recovered_input_count, 1); + + let declares_calendar = |body: &serde_json::Value| { + responses::namespace_child_tool( + body, + SEARCH_CALENDAR_NAMESPACE, + SEARCH_CALENDAR_CREATE_TOOL, + ) + .is_some() + }; + assert!(!declares_calendar( + &response_mock.single_request().body_json() + )); let next_response = responses::mount_sse_once( &server, @@ -158,6 +214,9 @@ async fn recover_turn_restores_cyber_access_program_without_making_it_sticky() - .get("access_programs"), None ); + assert!(declares_calendar( + &next_response.single_request().body_json() + )); test.codex.shutdown_and_wait().await?; Ok(()) } diff --git a/codex-rs/core/tests/suite/mcp_tool_exposure.rs b/codex-rs/core/tests/suite/mcp_tool_exposure.rs index 5e3affcc8c..c5f868974e 100644 --- a/codex-rs/core/tests/suite/mcp_tool_exposure.rs +++ b/codex-rs/core/tests/suite/mcp_tool_exposure.rs @@ -2,6 +2,7 @@ use anyhow::Result; use codex_config::Constrained; use codex_core::EnvironmentConfig; use codex_core::TurnInputRequest; +use codex_core::TurnStartOptions; use codex_core::config::Config; use codex_core::windows_sandbox::WindowsSandboxLevelExt; use codex_extension_api::ExtensionFuture; @@ -1086,6 +1087,106 @@ async fn code_mode_only_exposes_direct_model_only_mcp_namespaces() -> Result<()> Ok(()) } +#[test_case::test_case(false; "responses")] +#[test_case::test_case(true; "responses_lite")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn code_mode_refreshes_direct_tool_routing_between_turns( + use_responses_lite: bool, +) -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let apps = AppsTestServer::mount_searchable(&server).await?; + let calendar = vec![SEARCH_CALENDAR_NAMESPACE.to_owned()]; + let cases = [ + (None, false), + (Some(calendar.clone()), true), + (Some(Vec::new()), false), + (Some(calendar), true), + (None, false), + ]; + let responses = mount_sse_sequence(&server, completed_response_sequence(cases.len())).await; + let test = search_capable_apps_builder(apps.chatgpt_base_url) + .with_model_info_override("gpt-5.5", move |model| { + model.use_responses_lite = use_responses_lite + }) + .with_config(|config| { + config + .features + .enable(Feature::CodeModeOnly) + .expect("enable code mode only"); + config + .features + .enable(Feature::IncrementalTools) + .expect("enable incremental tools"); + }) + .build(&server) + .await?; + + let mut previous_input = Vec::new(); + for (index, (direct_only_tool_namespaces, expect_direct)) in cases.into_iter().enumerate() { + test.codex + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { + text: "inspect calendar tools".into(), + text_elements: Vec::new(), + }]) + .on_start(TurnStartOptions { + direct_only_tool_namespaces, + ..Default::default() + }), + ) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + let requests = responses.requests(); + assert_eq!(requests.len(), index + 1); + let body = requests[index].body_json(); + let declares_calendar = |item: &Value| { + namespace_child_tool(item, SEARCH_CALENDAR_NAMESPACE, SEARCH_CALENDAR_CREATE_TOOL) + .is_some() + }; + if use_responses_lite { + let input = body["input"].as_array().expect("Responses Lite input"); + assert!( + input.starts_with(&previous_input), + "history changed on turn {index}" + ); + let appended = &input[previous_input.len()..]; + assert_eq!( + appended + .iter() + .filter(|item| item["type"] == "additional_tools") + .any(declares_calendar), + expect_direct, + "calendar declaration on turn {index}", + ); + if index > 0 && !expect_direct { + let removed = format!("- {SEARCH_CALENDAR_NAMESPACE}"); + assert!( + appended.iter().any(|item| { + item["role"] == "developer" + && item["content"][0]["text"] + .as_str() + .is_some_and(|text| text.lines().any(|line| line == removed)) + }), + "missing calendar removal on turn {index}" + ); + } + previous_input.clone_from(input); + } else { + assert_eq!( + declares_calendar(&body), + expect_direct, + "calendar exposure on turn {index}" + ); + } + } + Ok(()) +} + // Keep spawned tasks on the thread with the scoped tracing subscriber. #[tokio::test(flavor = "current_thread")] async fn deferred_tool_world_state_tracks_initial_unchanged_and_removed_namespaces() -> Result<()> { diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 0d4866d9b7..a496421e67 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, + direct_only_tool_namespaces: None, disabled_plugin_ids: None, cwd: config.cwd.clone(), approval_policy: config.permissions.approval_policy.value(), diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index e6d7448dfe..89ef2fed24 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -3634,6 +3634,10 @@ pub struct TurnContextItem { /// Root turn that owns this turn's attribution, retained across recovery. #[serde(default, skip_serializing_if = "Option::is_none")] pub root_turn_id: Option, + /// Explicit routing for this turn. An empty list clears the thread's direct namespaces. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub direct_only_tool_namespaces: Option>, /// Plugin selection captured for this turn. Absent in older histories. #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] @@ -6594,6 +6598,7 @@ mod tests { let item = TurnContextItem { turn_id: None, root_turn_id: None, + direct_only_tool_namespaces: None, disabled_plugin_ids: None, cwd: test_path_buf("/tmp").abs(), approval_policy: AskForApproval::Never, diff --git a/codex-rs/protocol/src/turn_input.rs b/codex-rs/protocol/src/turn_input.rs index ec911d50bf..93fa948cdb 100644 --- a/codex-rs/protocol/src/turn_input.rs +++ b/codex-rs/protocol/src/turn_input.rs @@ -241,6 +241,9 @@ pub struct TurnStartOptions { /// Structured-output schema for a new turn. When steering, Core rejects /// the input if the active turn uses a different schema. pub final_output_json_schema: Option, + /// Direct-only tool namespaces for a new turn. Omission inherits the thread's + /// configuration. An empty list clears it for this turn. Ignored when steering. + pub direct_only_tool_namespaces: Option>, /// Service tier for a new turn, without changing the thread's preference. pub service_tier: Option, /// Lifetime requested for new Responses prompt cache entries during this turn. diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index b1d3e7b04f..81d38dea50 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -2013,6 +2013,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, + direct_only_tool_namespaces: None, disabled_plugin_ids: None, cwd: serde_json::from_value(serde_json::json!(&latest_cwd)) .expect("absolute latest cwd"), diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index 35358a62b1..d40af89fc1 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -578,6 +578,7 @@ mod tests { &RolloutItem::TurnContext(TurnContextItem { turn_id: Some("turn-1".to_string()), root_turn_id: None, + direct_only_tool_namespaces: None, disabled_plugin_ids: None, cwd: serde_json::from_value(serde_json::json!( std::env::current_dir() @@ -622,6 +623,7 @@ mod tests { &RolloutItem::TurnContext(TurnContextItem { turn_id: Some("turn-1".to_string()), root_turn_id: None, + direct_only_tool_namespaces: None, disabled_plugin_ids: None, cwd: serde_json::from_value(serde_json::json!( std::env::current_dir() @@ -666,6 +668,7 @@ mod tests { &RolloutItem::TurnContext(TurnContextItem { turn_id: Some("turn-1".to_string()), root_turn_id: None, + direct_only_tool_namespaces: None, disabled_plugin_ids: None, cwd: serde_json::from_value(serde_json::json!(&fallback_cwd)) .expect("absolute fallback cwd"), @@ -699,6 +702,7 @@ mod tests { &RolloutItem::TurnContext(TurnContextItem { turn_id: Some("turn-1".to_string()), root_turn_id: None, + direct_only_tool_namespaces: None, disabled_plugin_ids: None, cwd: serde_json::from_value(serde_json::json!( std::env::current_dir() diff --git a/codex-rs/thread-store/src/local/mod.rs b/codex-rs/thread-store/src/local/mod.rs index f2e7c3ea6e..dab03b904b 100644 --- a/codex-rs/thread-store/src/local/mod.rs +++ b/codex-rs/thread-store/src/local/mod.rs @@ -1141,6 +1141,7 @@ mod tests { RolloutItem::TurnContext(TurnContextItem { turn_id: Some("turn-1".to_string()), root_turn_id: None, + direct_only_tool_namespaces: None, disabled_plugin_ids: None, cwd: serde_json::from_value(serde_json::json!(cwd)).expect("absolute cwd"), approval_policy, 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 d60b119df6..5eb5dd3f65 100644 --- a/codex-rs/thread-store/src/local/model_context_tests.rs +++ b/codex-rs/thread-store/src/local/model_context_tests.rs @@ -742,6 +742,7 @@ fn turn_context(root: &Path, turn_id: &str) -> RolloutItem { RolloutItem::TurnContext(TurnContextItem { turn_id: Some(turn_id.to_string()), root_turn_id: None, + direct_only_tool_namespaces: None, disabled_plugin_ids: None, cwd: serde_json::from_value(serde_json::json!(root)).expect("absolute cwd"), approval_policy: AskForApproval::Never, 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 f5a99ed3a5..6bc91d55a4 100644 --- a/codex-rs/thread-store/src/local/rollout_migration_tests.rs +++ b/codex-rs/thread-store/src/local/rollout_migration_tests.rs @@ -2217,6 +2217,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, + direct_only_tool_namespaces: None, disabled_plugin_ids: None, cwd: serde_json::from_value(json!(home.path())).expect("absolute cwd"), approval_policy: AskForApproval::Never,