diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index b83419ad22..b339e9d4ad 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -1121,10 +1121,12 @@ fn guardian_approval_request_to_json_renders_mcp_tool_call_shape() -> serde_json async fn build_guardian_prompt_items_explains_node_repl_review_scope() -> anyhow::Result<()> { let (session, mut turn) = guardian_test_session_and_turn_with_base_url("http://localhost").await; - Arc::get_mut(&mut turn) - .expect("turn should be uniquely owned") - .model_info - .node_repl_auto_review_required = true; + Arc::make_mut( + &mut Arc::get_mut(&mut turn) + .expect("turn should be uniquely owned") + .model_info, + ) + .node_repl_auto_review_required = true; seed_guardian_parent_history(&session, &turn).await; let context = GuardianReviewContext::from(&turn); @@ -1700,7 +1702,7 @@ async fn guardian_request_model_for_auto_review( match catalog { GuardianTestCatalog::Bundled => {} GuardianTestCatalog::ParentOnly => { - let parent_model = turn.model_info.clone(); + let parent_model = turn.model_info.as_ref().clone(); let auth_manager = Arc::clone(&session.services.auth_manager); let models_manager = StaticModelsManager::new( Some(auth_manager), @@ -1714,10 +1716,12 @@ async fn guardian_request_model_for_auto_review( .models_manager = Arc::new(models_manager); } } - Arc::get_mut(&mut turn) - .expect("turn should be unique") - .model_info - .auto_review_model_override = auto_review_model_override; + Arc::make_mut( + &mut Arc::get_mut(&mut turn) + .expect("turn should be unique") + .model_info, + ) + .auto_review_model_override = auto_review_model_override; let parent_model = turn.model_info.slug.clone(); let preferred_model = turn.provider.approval_review_preferred_model().to_string(); let parent_turn_id = turn.sub_id.clone(); @@ -1942,7 +1946,8 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot() session.services.skills_service.clear_cache(); turn.config = Arc::clone(&config); turn.provider = create_model_provider(config.model_provider.clone(), turn.auth_manager.clone()); - turn.model_info.auto_review_model_override = Some("codex-auto-review".to_string()); + Arc::make_mut(&mut turn.model_info).auto_review_model_override = + Some("codex-auto-review".to_string()); let session = Arc::new(session); let turn = Arc::new(turn); seed_guardian_parent_history(&session, &turn).await; @@ -2186,7 +2191,8 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow: .enable(Feature::GuardianReuseParentCompaction) .expect("Guardian parent-compaction reuse should be configurable"); let turn_mut = Arc::get_mut(&mut turn).expect("turn should be unique"); - turn_mut.model_info.auto_review_model_override = Some("codex-auto-review".to_string()); + Arc::make_mut(&mut turn_mut.model_info).auto_review_model_override = + Some("codex-auto-review".to_string()); turn_mut.config = Arc::new(config); seed_guardian_parent_history(&session, &turn).await; @@ -3387,7 +3393,7 @@ async fn guardian_review_session_config_clears_context_overrides_for_distinct_ef async fn guardian_review_session_config_preserves_context_overrides_for_same_effective_model() { let server = start_mock_server().await; let (mut session, mut turn) = guardian_test_session_and_turn(&server).await; - let parent_model = turn.model_info.clone(); + let parent_model = turn.model_info.as_ref().clone(); let auth_manager = Arc::clone(&session.services.auth_manager); Arc::get_mut(&mut session) .expect("session should be unique") diff --git a/codex-rs/core/src/prompt_debug.rs b/codex-rs/core/src/prompt_debug.rs index 7cc2be215c..7e91ee81ad 100644 --- a/codex-rs/core/src/prompt_debug.rs +++ b/codex-rs/core/src/prompt_debug.rs @@ -101,14 +101,9 @@ pub(crate) async fn build_prompt_input_from_session( let prompt_input = sess .clone_history() .await - .for_prompt(&turn_context.model_info.input_modalities); + .for_prompt(&step_context.model_info.input_modalities); let base_instructions = sess.get_base_instructions().await; - let prompt = build_prompt( - prompt_input, - step_context.tool_router.as_ref(), - turn_context.as_ref(), - base_instructions, - ); + let prompt = build_prompt(prompt_input, step_context.as_ref(), base_instructions); Ok(prompt.input) } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 902a3ec359..33b77fadf4 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -3196,6 +3196,13 @@ impl Session { .or_cancel(cancellation_token) .await??; Ok(Arc::new(StepContext { + model_info: Arc::clone(&turn_context.model_info), + reasoning_effort: turn_context.reasoning_effort.clone(), + reasoning_summary: turn_context.reasoning_summary, + service_tier: turn_context.config.service_tier.clone(), + approval_policy: turn_context.approval_policy(), + approvals_reviewer: turn_context.config.approvals_reviewer, + session_telemetry: turn_context.session_telemetry.clone(), turn: turn_context, environments, selected_capability_roots, diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index c115fbd479..d431e602b5 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -138,7 +138,7 @@ pub(super) async fn spawn_review_thread( code_mode_available: parent_turn_context.code_mode_available, config: per_turn_config, auth_manager: auth_manager_for_context, - model_info: model_info.clone(), + model_info: Arc::new(model_info.clone()), session_telemetry: session_telemetry_for_context, provider: provider_for_context, reasoning_effort, diff --git a/codex-rs/core/src/session/step_context.rs b/codex-rs/core/src/session/step_context.rs index 17a436361c..8ab12c1860 100644 --- a/codex-rs/core/src/session/step_context.rs +++ b/codex-rs/core/src/session/step_context.rs @@ -7,10 +7,32 @@ use crate::tools::router::ToolRouter; use codex_exec_server::ExecutorCapabilityDiscoverySnapshot; use codex_exec_server::ResolvedSelectedCapabilityRoot; use codex_mcp::McpBinding; +use codex_otel::SessionTelemetry; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::openai_models::ModelInfo; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; /// Request-scoped state that may change between model sampling requests. pub(crate) struct StepContext { pub(crate) turn: Arc, + /// Concrete model and capabilities used by this sampling request. + pub(crate) model_info: Arc, + /// Reasoning effort selected for this sampling request. + pub(crate) reasoning_effort: Option, + /// Reasoning summary resolved for this sampling request. + pub(crate) reasoning_summary: ReasoningSummary, + /// Effective service tier supported by this sampling request's model. + pub(crate) service_tier: Option, + /// Effective approval policy used by this sampling request's tool actions. + /// Model-specific Guardian requirements can change this during a model switch. + pub(crate) approval_policy: AskForApproval, + /// Effective reviewer selected for this sampling request's approvals. + /// Model-specific Guardian requirements can change this during a model switch. + pub(crate) approvals_reviewer: ApprovalsReviewer, + /// Telemetry context tagged with this sampling request's model. + pub(crate) session_telemetry: SessionTelemetry, pub(crate) environments: TurnEnvironmentSnapshot, /// Capability roots bound to ready environments in this exact step. pub(crate) selected_capability_roots: Vec, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index beb48f4a38..9a90659060 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -210,6 +210,13 @@ impl StepContext { pub(crate) fn for_test(turn: Arc) -> Arc { let environments = turn.environments.clone(); Arc::new(Self { + model_info: Arc::clone(&turn.model_info), + reasoning_effort: turn.reasoning_effort.clone(), + reasoning_summary: turn.reasoning_summary, + service_tier: turn.config.service_tier.clone(), + approval_policy: turn.approval_policy(), + approvals_reviewer: turn.config.approvals_reviewer, + session_telemetry: turn.session_telemetry.clone(), turn: Arc::clone(&turn), environments, selected_capability_roots: Vec::new(), @@ -2746,8 +2753,8 @@ async fn recompute_token_usage_updates_model_context_window() { })); } - turn_context.model_info.context_window = Some(128_000); - turn_context.model_info.effective_context_window_percent = 100; + Arc::make_mut(&mut turn_context.model_info).context_window = Some(128_000); + Arc::make_mut(&mut turn_context.model_info).effective_context_window_percent = 100; session.recompute_token_usage(&turn_context).await; @@ -4435,7 +4442,7 @@ async fn turn_context_with_model_updates_model_fields() { assert_eq!(updated.config.model.as_deref(), Some("gpt-5.4")); assert_eq!(updated.collaboration_mode().model(), "gpt-5.4"); - assert_eq!(updated.model_info, expected_model_info); + assert_eq!(updated.model_info.as_ref(), &expected_model_info); assert_eq!( updated.reasoning_effort, Some(ReasoningEffortConfig::Medium) @@ -9247,8 +9254,8 @@ async fn build_initial_context_includes_turn_context_fragments_from_extensions() let mut builder = codex_extension_api::ExtensionRegistryBuilder::new(); builder.prompt_contributor(Arc::new(TurnContextExtensionTestContributor)); session.services.extensions = Arc::new(builder.build()); - turn_context.model_info.context_window = Some(100); - turn_context.model_info.effective_context_window_percent = 50; + Arc::make_mut(&mut turn_context.model_info).context_window = Some(100); + Arc::make_mut(&mut turn_context.model_info).effective_context_window_percent = 50; turn_context .extension_data .insert(TurnContextExtensionTestState { @@ -9274,8 +9281,8 @@ async fn record_context_updates_includes_turn_context_fragments_on_steady_state_ let mut builder = codex_extension_api::ExtensionRegistryBuilder::new(); builder.prompt_contributor(Arc::new(TurnContextExtensionTestContributor)); session.services.extensions = Arc::new(builder.build()); - turn_context.model_info.context_window = Some(200); - turn_context.model_info.effective_context_window_percent = 25; + Arc::make_mut(&mut turn_context.model_info).context_window = Some(200); + Arc::make_mut(&mut turn_context.model_info).effective_context_window_percent = 25; turn_context .extension_data .insert(TurnContextExtensionTestState { diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index dd0efd1857..89cb35129d 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -96,7 +96,7 @@ async fn request_permissions_routes_to_guardian_when_reviewer_is_enabled() { .await; let (mut session, mut turn_context_raw) = make_session_and_context().await; - turn_context_raw.model_info.node_repl_auto_review_required = true; + Arc::make_mut(&mut turn_context_raw.model_info).node_repl_auto_review_required = true; *session.active_turn.lock().await = Some(ActiveTurn::default()); Arc::make_mut(&mut turn_context_raw.config) .permissions diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 6c2dc1496a..7ff56e4da3 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -350,7 +350,7 @@ pub(crate) async fn run_turn( let sampling_request_input: Vec = async { sess.clone_history() .await - .for_prompt(&turn_context.model_info.input_modalities) + .for_prompt(&step_context.model_info.input_modalities) } .instrument(trace_span!("run_turn.prepare_sampling_request_input")) .await; @@ -1293,13 +1293,13 @@ pub(super) fn collect_explicit_app_ids_from_skill_items( #[instrument(level = "trace", skip_all)] pub(crate) fn build_prompt( input: Vec, - router: &ToolRouter, - turn_context: &TurnContext, + step_context: &StepContext, base_instructions: BaseInstructions, ) -> Prompt { + let turn_context = &step_context.turn; Prompt { input, - tools: router.model_visible_specs(), + tools: step_context.tool_router.model_visible_specs(), parallel_tool_calls: true, base_instructions, output_schema: turn_context.final_output_json_schema.clone(), @@ -1315,7 +1315,7 @@ pub(crate) fn build_prompt( skip_all, fields( turn_id = %step_context.turn.sub_id, - model = %step_context.turn.model_info.slug, + model = %step_context.model_info.slug, cwd = %step_context.turn.cwd.display() ) )] @@ -1330,8 +1330,6 @@ async fn run_sampling_request( cancellation_token: CancellationToken, ) -> CodexResult<(SamplingRequestResult, Vec)> { let turn_context = Arc::clone(&step_context.turn); - let router = Arc::clone(&step_context.tool_router); - let base_instructions = sess.get_base_instructions().await; let tool_runtime = ToolCallRuntime::new( @@ -1355,7 +1353,7 @@ async fn run_sampling_request( } else { sess.clone_history() .await - .for_prompt(&turn_context.model_info.input_modalities) + .for_prompt(&step_context.model_info.input_modalities) }; let mut prompt_input = prompt_input; if let Some(executed_tool_calls) = sess.services.executed_tool_calls.as_ref() @@ -1366,14 +1364,13 @@ async fn run_sampling_request( } let prompt = build_prompt( prompt_input, - router.as_ref(), - turn_context.as_ref(), + step_context.as_ref(), base_instructions.clone(), ); let err = match try_run_sampling_request( tool_runtime.clone(), Arc::clone(&sess), - Arc::clone(&turn_context), + Arc::clone(&step_context), Arc::clone(&turn_store), client_session, responses_metadata, @@ -2144,14 +2141,14 @@ fn assign_missing_streamed_response_item_id( #[instrument(level = "trace", skip_all, fields( - turn_id = %turn_context.sub_id, - model = %turn_context.model_info.slug + turn_id = %step_context.turn.sub_id, + model = %step_context.model_info.slug ) )] async fn try_run_sampling_request( tool_runtime: ToolCallRuntime, sess: Arc, - turn_context: Arc, + step_context: Arc, turn_store: Arc, client_session: &mut ModelClientSession, responses_metadata: &CodexResponsesMetadata, @@ -2159,17 +2156,18 @@ async fn try_run_sampling_request( prompt: &Prompt, cancellation_token: CancellationToken, ) -> CodexResult { + let turn_context = Arc::clone(&step_context.turn); feedback_tags!( - model = turn_context.model_info.slug.clone(), + model = step_context.model_info.slug.clone(), approval_policy = turn_context.approval_policy(), sandbox_policy = &turn_context.sandbox_policy(), - effort = turn_context.reasoning_effort, + effort = step_context.reasoning_effort, auth_mode = sess.services.auth_manager.auth_mode(), features = sess.features.enabled_features(), ); let inference_trace = sess.services.rollout_thread_trace.inference_trace_context( turn_context.sub_id.as_str(), - turn_context.model_info.slug.as_str(), + step_context.model_info.slug.as_str(), turn_context.provider.info().name.as_str(), ); let sampling_timing_guard = turn_context.turn_timing_state.begin_sampling(); @@ -2181,11 +2179,11 @@ async fn try_run_sampling_request( let mut stream = client_session .stream( prompt, - &turn_context.model_info, - &turn_context.session_telemetry, - turn_context.reasoning_effort.clone(), - turn_context.reasoning_summary, - turn_context.config.service_tier.clone(), + &step_context.model_info, + &step_context.session_telemetry, + step_context.reasoning_effort.clone(), + step_context.reasoning_summary, + step_context.service_tier.clone(), responses_metadata, &inference_trace, ) @@ -2205,7 +2203,12 @@ async fn try_run_sampling_request( let mut should_emit_token_count = false; const MAX_ANALYTICS_TOOL_CALL_IDS_PER_RESPONSE: usize = 256; let mut analytics_tool_call_ids = Vec::new(); - let reasoning_effort = turn_context.effective_reasoning_effort_for_tracing(); + let reasoning_effort = step_context + .reasoning_effort + .clone() + .or_else(|| step_context.model_info.default_reasoning_level.clone()) + .map(|effort| effort.to_string()) + .unwrap_or_else(|| "default".to_string()); let plan_mode = turn_context.mode == ModeKind::Plan; let mut assistant_message_stream_parsers = AssistantMessageStreamParsers::new(plan_mode); let mut plan_mode_state = plan_mode.then(|| PlanModeStreamState::new(&turn_context.sub_id)); diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index cfa31f7fff..356cabbc39 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -138,12 +138,18 @@ pub struct TurnContext { pub(crate) trace_id: Option, pub(crate) realtime_active: bool, pub(crate) code_mode_available: bool, + /// Turn-scoped configuration. Read step-specific settings such as service tier and + /// approvals reviewer from the corresponding `StepContext` fields instead. pub config: Arc, pub(crate) auth_manager: Option>, - pub(crate) model_info: ModelInfo, + /// Legacy turn model; step-scoped execution should use `StepContext::model_info`. + pub(crate) model_info: Arc, + /// Turn-wide telemetry; model-attributed step work should use `StepContext::session_telemetry`. pub(crate) session_telemetry: SessionTelemetry, pub(crate) provider: SharedModelProvider, + /// Legacy turn effort; step-scoped execution should use `StepContext::reasoning_effort`. pub(crate) reasoning_effort: Option, + /// Legacy turn summary; step-scoped execution should use `StepContext::reasoning_summary`. pub(crate) reasoning_summary: ReasoningSummaryConfig, pub(crate) session_source: SessionSource, pub(crate) history_mode: ThreadHistoryMode, @@ -223,6 +229,8 @@ impl TurnContext { .await } + /// Approval policy admitted with the turn; step-scoped actions should use + /// `StepContext::approval_policy` instead. pub(crate) fn approval_policy(&self) -> AskForApproval { self.config.permissions.approval_policy.value() } @@ -352,6 +360,7 @@ impl TurnContext { config.http_client_factory(), ) .await; + let model_info = Arc::new(model_info); Self { sub_id: self.sub_id.clone(), @@ -360,7 +369,7 @@ impl TurnContext { code_mode_available: self.code_mode_available, config: Arc::new(config), auth_manager: self.auth_manager.clone(), - model_info: model_info.clone(), + model_info: Arc::clone(&model_info), session_telemetry: self .session_telemetry .clone() @@ -643,7 +652,7 @@ impl Session { code_mode_available: true, config: per_turn_config, auth_manager, - model_info, + model_info: Arc::new(model_info), session_telemetry: session_telemetry_for_context, provider, reasoning_effort, diff --git a/codex-rs/core/src/session/world_state.rs b/codex-rs/core/src/session/world_state.rs index 6e72a69135..532a925fd9 100644 --- a/codex-rs/core/src/session/world_state.rs +++ b/codex-rs/core/src/session/world_state.rs @@ -168,9 +168,9 @@ impl Session { let model_messages = turn_context.model_info.model_messages.as_ref(); world_state.add_section(PermissionsState::new( &permission_profile, - turn_context.approval_policy(), + step_context.approval_policy, ApprovalPromptContext::new( - turn_context.config.approvals_reviewer, + step_context.approvals_reviewer, model_messages.and_then(|messages| messages.approvals.as_ref()), model_messages.and_then(|messages| messages.permissions.as_ref()), ), diff --git a/codex-rs/core/src/session_startup_prewarm.rs b/codex-rs/core/src/session_startup_prewarm.rs index 305c9c51ba..4d63cc8c77 100644 --- a/codex-rs/core/src/session_startup_prewarm.rs +++ b/codex-rs/core/src/session_startup_prewarm.rs @@ -282,7 +282,6 @@ async fn schedule_startup_prewarm_inner( &startup_cancellation_token, ) .await?; - let startup_router = Arc::clone(&step_context.tool_router); startup_turn_context.session_telemetry.record_startup_phase( "startup_prewarm_build_tools", built_tools_started_at.elapsed(), @@ -291,8 +290,7 @@ async fn schedule_startup_prewarm_inner( let build_prompt_started_at = Instant::now(); let startup_prompt = build_prompt( Vec::new(), - startup_router.as_ref(), - startup_turn_context.as_ref(), + step_context.as_ref(), BaseInstructions { text: base_instructions, provenance: None, @@ -316,11 +314,11 @@ async fn schedule_startup_prewarm_inner( client_session .prewarm_websocket( &startup_prompt, - &startup_turn_context.model_info, - &startup_turn_context.session_telemetry, - startup_turn_context.reasoning_effort.clone(), - startup_turn_context.reasoning_summary, - startup_turn_context.config.service_tier.clone(), + &step_context.model_info, + &step_context.session_telemetry, + step_context.reasoning_effort.clone(), + step_context.reasoning_summary, + step_context.service_tier.clone(), &responses_metadata, ) .await?; diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 97b3c9e7d4..87ab8df556 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -664,7 +664,7 @@ async fn shell_family_registers_visible_unified_exec_and_hidden_legacy_shell() { let plan = probe(|turn| { set_features(turn, &[Feature::ShellTool, Feature::UnifiedExec]); set_feature(turn, Feature::ShellZshFork, /*enabled*/ false); - turn.model_info.shell_type = ConfigShellToolType::ShellCommand; + Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::ShellCommand; }) .await; @@ -687,7 +687,7 @@ async fn login_shell_parameter_follows_selected_environment() { set_feature(turn, Feature::ShellTool, /*enabled*/ true); set_feature(turn, Feature::UnifiedExec, tool_name == "exec_command"); set_feature(turn, Feature::ShellZshFork, /*enabled*/ false); - turn.model_info.shell_type = ConfigShellToolType::ShellCommand; + Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::ShellCommand; update_config(turn, |config| { config.permissions.allow_login_shell = !allow_login_shell; }); @@ -744,7 +744,7 @@ async fn shell_command_is_not_registered_without_a_single_local_environment() { let remote_environment = probe(|turn| { set_feature(turn, Feature::ShellTool, /*enabled*/ true); set_feature(turn, Feature::UnifiedExec, /*enabled*/ false); - turn.model_info.shell_type = ConfigShellToolType::ShellCommand; + Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::ShellCommand; let TurnEnvironmentState::Ready(environment) = turn .environments @@ -769,7 +769,7 @@ async fn shell_command_is_not_registered_without_a_single_local_environment() { let multiple_local_environments = probe(|turn| { set_feature(turn, Feature::ShellTool, /*enabled*/ true); set_feature(turn, Feature::UnifiedExec, /*enabled*/ false); - turn.model_info.shell_type = ConfigShellToolType::ShellCommand; + Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::ShellCommand; duplicate_primary_environment(turn); }) .await; @@ -817,7 +817,7 @@ async fn shell_zsh_fork_stays_standalone_until_unified_exec_composition_is_enabl set_features(turn, &[Feature::ShellTool, Feature::UnifiedExec]); set_feature(turn, Feature::ShellZshFork, /*enabled*/ true); set_feature(turn, Feature::UnifiedExecZshFork, /*enabled*/ false); - turn.model_info.shell_type = ConfigShellToolType::ShellCommand; + Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::ShellCommand; }) .await; @@ -836,7 +836,7 @@ async fn shell_zsh_fork_stays_standalone_until_unified_exec_composition_is_enabl Feature::UnifiedExecZshFork, ], ); - turn.model_info.shell_type = ConfigShellToolType::ShellCommand; + Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::ShellCommand; }) .await; @@ -949,7 +949,8 @@ async fn environment_count_controls_environment_backed_tools() { turn.environments.environments.clear(); set_feature(turn, Feature::ShellTool, /*enabled*/ true); set_feature(turn, Feature::RequestPermissionsTool, /*enabled*/ true); - turn.model_info.apply_patch_tool_type = Some(ApplyPatchToolType::Freeform); + Arc::make_mut(&mut turn.model_info).apply_patch_tool_type = + Some(ApplyPatchToolType::Freeform); }) .await; no_environment.assert_visible_lacks(&[ @@ -972,7 +973,8 @@ async fn environment_count_controls_environment_backed_tools() { set_feature(turn, Feature::ShellTool, /*enabled*/ true); set_feature(turn, Feature::UnifiedExec, /*enabled*/ true); set_feature(turn, Feature::RequestPermissionsTool, /*enabled*/ true); - turn.model_info.apply_patch_tool_type = Some(ApplyPatchToolType::Freeform); + Arc::make_mut(&mut turn.model_info).apply_patch_tool_type = + Some(ApplyPatchToolType::Freeform); }) .await; multiple_environments.assert_visible_contains(&[ @@ -1000,7 +1002,7 @@ async fn environment_count_controls_environment_backed_tools() { async fn environment_tools_follow_the_step_context() { let (_session, mut turn) = make_session_and_context().await; set_feature(&mut turn, Feature::UnifiedExec, /*enabled*/ true); - turn.model_info.apply_patch_tool_type = Some(ApplyPatchToolType::Freeform); + Arc::make_mut(&mut turn.model_info).apply_patch_tool_type = Some(ApplyPatchToolType::Freeform); let environments = turn.environments.clone(); turn.environments.environments.clear(); @@ -1130,7 +1132,7 @@ async fn mcp_and_tool_search_follow_direct_and_deferred_tool_exposure() { let missing_model_capability = probe_with( |turn| { - turn.model_info.supports_search_tool = false; + Arc::make_mut(&mut turn.model_info).supports_search_tool = false; }, searchable_mcp(), ) @@ -1139,7 +1141,7 @@ async fn mcp_and_tool_search_follow_direct_and_deferred_tool_exposure() { let missing_deferred_tools = probe(|turn| { set_feature(turn, Feature::Collab, /*enabled*/ false); - turn.model_info.supports_search_tool = true; + Arc::make_mut(&mut turn.model_info).supports_search_tool = true; }) .await; missing_deferred_tools.assert_visible_lacks(&["tool_search"]); @@ -1151,7 +1153,7 @@ async fn mcp_and_tool_search_follow_direct_and_deferred_tool_exposure() { let bedrock_namespace_capability = probe_with( |turn| { - turn.model_info.supports_search_tool = true; + Arc::make_mut(&mut turn.model_info).supports_search_tool = true; use_bedrock_provider(turn); }, searchable_mcp(), @@ -1161,7 +1163,7 @@ async fn mcp_and_tool_search_follow_direct_and_deferred_tool_exposure() { let enabled = probe_with( |turn| { - turn.model_info.supports_search_tool = true; + Arc::make_mut(&mut turn.model_info).supports_search_tool = true; }, searchable_mcp(), ) @@ -1175,7 +1177,7 @@ async fn mcp_and_tool_search_follow_direct_and_deferred_tool_exposure() { let reserved_namespace = probe_with( |turn| { set_feature(turn, Feature::CodeMode, /*enabled*/ true); - turn.model_info.supports_search_tool = true; + Arc::make_mut(&mut turn.model_info).supports_search_tool = true; }, ToolPlanInputs { tool_runtimes: vec![ @@ -1227,8 +1229,8 @@ async fn tool_namespaces_info_is_opt_in_and_tracks_mcp_exposure() { config.tool_registry.turn_metadata_includes_tool_info = enabled; }); set_feature(turn, Feature::CodeMode, /*enabled*/ true); - turn.model_info.supports_search_tool = true; - turn.model_info.use_responses_lite = true; + Arc::make_mut(&mut turn.model_info).supports_search_tool = true; + Arc::make_mut(&mut turn.model_info).use_responses_lite = true; metadata_state = Some(Arc::clone(&turn.turn_metadata_state)); }, ToolPlanInputs { @@ -1302,7 +1304,7 @@ async fn strict_namespace_ownership_requires_tool_namespace_inventory_opt_in() { config.tool_registry.error_on_tool_collisions = true; config.tool_registry.turn_metadata_includes_tool_info = enabled; }); - turn.model_info.use_responses_lite = true; + Arc::make_mut(&mut turn.model_info).use_responses_lite = true; let step_context = StepContext::for_test(Arc::new(turn)); let mut registry = build_core_tool_registry( step_context.turn.as_ref(), @@ -1357,7 +1359,7 @@ async fn unified_tool_runtimes_preserve_source_order_and_collision_priority() { |turn| { set_features(turn, &[Feature::ShellTool, Feature::UnifiedExec]); set_feature(turn, Feature::ShellZshFork, /*enabled*/ false); - turn.model_info.shell_type = ConfigShellToolType::ShellCommand; + Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::ShellCommand; }, ToolPlanInputs { tool_runtimes: vec![mcp_runtime( @@ -1553,7 +1555,7 @@ async fn strict_tool_collisions_reject_external_and_synthetic_duplicates() { if code_mode_enabled { set_feature(&mut turn, Feature::CodeMode, /*enabled*/ true); } - turn.model_info.supports_search_tool = search_enabled; + Arc::make_mut(&mut turn.model_info).supports_search_tool = search_enabled; let turn = Arc::new(turn); let step_context = StepContext::for_test(Arc::clone(&turn)); let mut registry = build_core_tool_registry( @@ -1711,8 +1713,8 @@ async fn code_mode_uses_the_first_normalized_tool_identity() { if code_mode_only { set_feature(turn, Feature::CodeModeOnly, /*enabled*/ true); } - turn.model_info.supports_search_tool = true; - turn.model_info.use_responses_lite = true; + Arc::make_mut(&mut turn.model_info).supports_search_tool = true; + Arc::make_mut(&mut turn.model_info).use_responses_lite = true; metadata_state = Some(Arc::clone(&turn.turn_metadata_state)); }, ToolPlanInputs { @@ -1795,7 +1797,7 @@ async fn code_mode_uses_the_first_normalized_tool_identity() { async fn deferred_extension_tools_are_discoverable_with_tool_search() { let plan = probe_with( |turn| { - turn.model_info.supports_search_tool = true; + Arc::make_mut(&mut turn.model_info).supports_search_tool = true; }, ToolPlanInputs { extension_tool_executors: vec![Arc::new(DeferredExtensionTool)], @@ -1815,7 +1817,7 @@ async fn tool_search_cache_rebuilds_when_deferred_sources_change() { let cache = ToolSearchHandlerCache::default(); let (_session, mut first_turn) = make_session_and_context().await; - first_turn.model_info.supports_search_tool = true; + Arc::make_mut(&mut first_turn.model_info).supports_search_tool = true; let first_turn = Arc::new(first_turn); let first_step_context = StepContext::for_test(Arc::clone(&first_turn)); let mut first_registry = build_core_tool_registry( @@ -1836,7 +1838,7 @@ async fn tool_search_cache_rebuilds_when_deferred_sources_change() { let first_plan = ToolPlanProbe::from_router(first_router); let (_session, mut second_turn) = make_session_and_context().await; - second_turn.model_info.supports_search_tool = true; + Arc::make_mut(&mut second_turn.model_info).supports_search_tool = true; let second_turn = Arc::new(second_turn); let second_step_context = StepContext::for_test(Arc::clone(&second_turn)); let mut second_registry = build_core_tool_registry( @@ -1883,7 +1885,7 @@ async fn tool_search_cache_rebuilds_when_deferred_world_state_changes() { for world_state_enabled in [false, true, false] { let (_session, mut turn) = make_session_and_context().await; - turn.model_info.supports_search_tool = true; + Arc::make_mut(&mut turn.model_info).supports_search_tool = true; set_feature( &mut turn, Feature::DeferredToolWorldState, @@ -1996,7 +1998,7 @@ async fn request_plugin_install_requires_all_discovery_features() { async fn request_plugin_install_stays_visible_without_tool_search() { let plan = probe_with( |turn| { - turn.model_info.supports_search_tool = false; + Arc::make_mut(&mut turn.model_info).supports_search_tool = false; set_features( turn, &[Feature::ToolSuggest, Feature::Apps, Feature::Plugins], @@ -2132,7 +2134,7 @@ async fn code_mode_only_exposes_configured_dynamic_namespace_directly() { let plan = probe_with( |turn| { set_features(turn, &[Feature::CodeMode, Feature::CodeModeOnly]); - turn.model_info.supports_search_tool = true; + Arc::make_mut(&mut turn.model_info).supports_search_tool = true; update_config(turn, |config| { config.code_mode.direct_only_tool_namespaces = vec!["direct_only".to_string()]; }); @@ -2196,7 +2198,7 @@ async fn excluded_deferred_namespaces_do_not_enable_nested_tool_guidance() { |turn| { set_features(turn, &[Feature::CodeMode, Feature::CodeModeOnly]); set_feature(turn, Feature::Collab, /*enabled*/ false); - turn.model_info.supports_search_tool = true; + Arc::make_mut(&mut turn.model_info).supports_search_tool = true; update_config(turn, |config| { config.code_mode.excluded_tool_namespaces = vec!["excluded".to_string()]; }); @@ -2450,7 +2452,7 @@ async fn multi_agent_v2_can_disable_wait_agent() { async fn tool_mode_selector_overrides_feature_flags() { let direct = probe(|turn| { set_features(turn, &[Feature::CodeMode, Feature::CodeModeOnly]); - turn.model_info.tool_mode = Some(ToolMode::Direct); + Arc::make_mut(&mut turn.model_info).tool_mode = Some(ToolMode::Direct); }) .await; direct.assert_visible_lacks(&[ @@ -2462,7 +2464,7 @@ async fn tool_mode_selector_overrides_feature_flags() { #[tokio::test] async fn v1_multi_agent_tools_defer_when_tool_search_available() { let plan = probe(|turn| { - turn.model_info.supports_search_tool = true; + Arc::make_mut(&mut turn.model_info).supports_search_tool = true; set_feature(turn, Feature::Collab, /*enabled*/ true); set_feature(turn, Feature::MultiAgentV2, /*enabled*/ false); }) @@ -2605,8 +2607,8 @@ async fn multi_agent_v2_bedrock_workers_only_delegate_when_model_supports_v2() { config.multi_agent_v2.tool_namespace = Some("agents".to_string()); }); use_bedrock_provider(turn); - turn.model_info.slug = model.to_string(); - turn.model_info.multi_agent_version = model_multi_agent_version; + Arc::make_mut(&mut turn.model_info).slug = model.to_string(); + Arc::make_mut(&mut turn.model_info).multi_agent_version = model_multi_agent_version; turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id: ThreadId::new(), depth: 1, @@ -2721,7 +2723,7 @@ async fn hosted_web_search_and_standalone_image_generation_follow_runtime_gates( let image_generation = probe_with( |turn| { use_chatgpt_auth(turn); - turn.model_info.input_modalities = vec![InputModality::Image]; + Arc::make_mut(&mut turn.model_info).input_modalities = vec![InputModality::Image]; }, ToolPlanInputs { extension_tool_executors: vec![image_generation_tool.clone()], @@ -2735,7 +2737,7 @@ async fn hosted_web_search_and_standalone_image_generation_follow_runtime_gates( |turn| { use_chatgpt_auth(turn); set_feature(turn, Feature::ImageGeneration, /*enabled*/ false); - turn.model_info.input_modalities = vec![InputModality::Image]; + Arc::make_mut(&mut turn.model_info).input_modalities = vec![InputModality::Image]; }, ToolPlanInputs { extension_tool_executors: vec![image_generation_tool.clone()], @@ -2748,7 +2750,7 @@ async fn hosted_web_search_and_standalone_image_generation_follow_runtime_gates( let text_only_model = probe_with( |turn| { use_chatgpt_auth(turn); - turn.model_info.input_modalities = vec![]; + Arc::make_mut(&mut turn.model_info).input_modalities = vec![]; }, ToolPlanInputs { extension_tool_executors: vec![image_generation_tool.clone()], @@ -2761,7 +2763,7 @@ async fn hosted_web_search_and_standalone_image_generation_follow_runtime_gates( let unsupported_provider = probe_with( |turn| { use_bedrock_provider(turn); - turn.model_info.input_modalities = vec![InputModality::Image]; + Arc::make_mut(&mut turn.model_info).input_modalities = vec![InputModality::Image]; }, ToolPlanInputs { extension_tool_executors: vec![image_generation_tool], @@ -2773,7 +2775,7 @@ async fn hosted_web_search_and_standalone_image_generation_follow_runtime_gates( let live_web_search = probe(|turn| { set_web_search_mode(turn, WebSearchMode::Live); - turn.model_info.web_search_tool_type = WebSearchToolType::TextAndImage; + Arc::make_mut(&mut turn.model_info).web_search_tool_type = WebSearchToolType::TextAndImage; }) .await; assert_eq!( @@ -2792,7 +2794,7 @@ async fn hosted_web_search_and_standalone_image_generation_follow_runtime_gates( use_chatgpt_auth(turn); set_features(turn, &[Feature::CodeModeOnly, Feature::MultiAgentV2]); set_web_search_mode(turn, WebSearchMode::Live); - turn.model_info.input_modalities = vec![InputModality::Image]; + Arc::make_mut(&mut turn.model_info).input_modalities = vec![InputModality::Image]; }) .await; assert_eq!( @@ -2869,7 +2871,7 @@ async fn hosted_web_search_and_standalone_image_generation_follow_runtime_gates( let bedrock_cached_web_search = probe(|turn| { use_bedrock_provider(turn); - turn.model_info.web_search_tool_type = WebSearchToolType::Text; + Arc::make_mut(&mut turn.model_info).web_search_tool_type = WebSearchToolType::Text; }) .await; assert_eq!( @@ -2888,7 +2890,7 @@ async fn hosted_web_search_and_standalone_image_generation_follow_runtime_gates( set_feature(turn, Feature::StandaloneWebSearch, /*enabled*/ true); set_web_search_mode(turn, WebSearchMode::Cached); use_bedrock_provider(turn); - turn.model_info.web_search_tool_type = WebSearchToolType::Text; + Arc::make_mut(&mut turn.model_info).web_search_tool_type = WebSearchToolType::Text; }) .await; bedrock_with_standalone_web_search.assert_visible_contains(&["web_search"]); diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index dfbb6d6859..699696289d 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -3,12 +3,14 @@ use codex_config::types::Personality; use codex_core::CodexThread; use codex_core::ForkSnapshot; use codex_core::TurnInputRequest; +use codex_core::config::Constrained; use codex_features::Feature; use codex_history::RolloutItem; use codex_history::RolloutLine; use codex_login::CodexAuth; use codex_models_manager::bundled_models_response; use codex_models_manager::manager::RefreshStrategy; +use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::ReasoningSummary; @@ -31,8 +33,13 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::protocol::ThreadSettingsOverrides; +use codex_protocol::request_user_input::RequestUserInputAnswer; +use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; use core_test_support::responses::ev_completed_with_tokens; +use core_test_support::responses::ev_function_call; use core_test_support::responses::ev_image_generation_call; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_models_once; @@ -47,7 +54,10 @@ use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; +use core_test_support::wait_for_event_match; use pretty_assertions::assert_eq; +use serde_json::json; +use std::collections::HashMap; use test_case::test_case; use wiremock::MockServer; @@ -514,6 +524,150 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn settings_update_during_active_turn_applies_to_next_turn_only() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let response_mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + "pause-turn", + "request_user_input", + &json!({ + "questions": [{ + "id": "continue", + "header": "Continue", + "question": "Continue after settings update?", + "options": [{ + "label": "Yes (Recommended)", + "description": "Continue the current turn." + }, { + "label": "No", + "description": "Stop the current turn." + }] + }] + }) + .to_string(), + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-2", "first turn done"), + ev_completed("resp-2"), + ]), + sse(vec![ + ev_response_created("resp-3"), + ev_assistant_message("msg-3", "second turn done"), + ev_completed("resp-3"), + ]), + ], + ) + .await; + let mut builder = test_codex().with_model("gpt-5.2").with_config(|config| { + config + .features + .enable(Feature::DefaultModeRequestUserInput) + .expect("test config should allow feature update"); + config.model_reasoning_effort = Some(ReasoningEffort::Low); + config.model_reasoning_summary = Some(ReasoningSummary::Concise); + config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); + config.approvals_reviewer = ApprovalsReviewer::User; + }); + let test = builder.build_with_auto_env(&server).await?; + + test.codex + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "pause before continuing".into(), + text_elements: Vec::new(), + }])) + .await?; + let request = wait_for_event_match(&test.codex, |event| match event { + EventMsg::RequestUserInput(request) => Some(request.clone()), + _ => None, + }) + .await; + + core_test_support::submit_thread_settings( + &test.codex, + ThreadSettingsOverrides { + model: Some("gpt-5.4".to_string()), + effort: Some(Some(ReasoningEffort::High)), + summary: Some(ReasoningSummary::Detailed), + service_tier: Some(Some(ServiceTier::Fast.request_value().to_string())), + approval_policy: Some(AskForApproval::Never), + approvals_reviewer: Some(ApprovalsReviewer::AutoReview), + ..Default::default() + }, + ) + .await?; + + test.codex + .submit(Op::UserInputAnswer { + id: request.turn_id, + response: RequestUserInputResponse { + answers: HashMap::from([( + "continue".to_string(), + RequestUserInputAnswer { + answers: vec!["Yes (Recommended)".to_string()], + }, + )]), + }, + }) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + test.submit_text_turn("start the next turn").await?; + + let requests = response_mock.requests(); + let request_settings = requests + .iter() + .map(|request| { + let body = request.body_json(); + json!({ + "model": body["model"], + "reasoning": body["reasoning"], + "service_tier": body.get("service_tier"), + "approval_policy_never": request + .message_input_texts("developer") + .iter() + .any(|text| text.contains("Approval policy is currently never")), + }) + }) + .collect::>(); + assert_eq!( + request_settings, + vec![ + json!({ + "model": "gpt-5.2", + "reasoning": { "effort": "low", "summary": "concise" }, + "service_tier": null, + "approval_policy_never": false, + }), + json!({ + "model": "gpt-5.2", + "reasoning": { "effort": "low", "summary": "concise" }, + "service_tier": null, + "approval_policy_never": false, + }), + json!({ + "model": "gpt-5.4", + "reasoning": { "effort": "high", "summary": "detailed" }, + "service_tier": "priority", + "approval_policy_never": true, + }), + ] + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn service_tier_change_is_applied_on_next_http_turn() -> Result<()> { skip_if_no_network!(Ok(()));