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 0b19013af9..cee815df5a 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 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,