diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 4dbbd3a424..3ddbea4463 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -155,7 +155,7 @@ pub(crate) async fn run_compact_task( trace_id: turn_context.trace_id.clone(), started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), - collaboration_mode_kind: turn_context.mode, + collaboration_mode_kind: turn_context.mode(), }); sess.send_event(&turn_context, start_event).await; run_compact_task_inner( @@ -257,7 +257,7 @@ async fn run_compact_task_inner_impl( let mut history = sess.clone_history().await; history.record_items( &[initial_input_for_turn.into()], - turn_context.model_info.truncation_policy.into(), + turn_context.model_info().truncation_policy.into(), ); let max_retries = turn_context.provider.info().stream_max_retries(); @@ -277,7 +277,7 @@ async fn run_compact_task_inner_impl( // Clone is required because of the loop let turn_input = history .clone() - .for_prompt(&turn_context.model_info.input_modalities); + .for_prompt(&turn_context.model_info().input_modalities); let turn_input_len = turn_input.len(); let prompt = Prompt { input: turn_input, @@ -742,10 +742,10 @@ async fn drain_to_completed( let mut stream = client_session .stream( prompt, - &turn_context.model_info, + turn_context.model_info(), &turn_context.session_telemetry, - turn_context.reasoning_effort.clone(), - turn_context.reasoning_summary, + turn_context.reasoning_effort().cloned(), + turn_context.reasoning_summary(), turn_context.config.service_tier.clone(), responses_metadata, // Rollout tracing currently models remote compaction only; local compaction streams diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index eb686725c9..932993d14c 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -90,7 +90,7 @@ pub(crate) async fn run_remote_compact_task( trace_id: turn_context.trace_id.clone(), started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), - collaboration_mode_kind: turn_context.mode, + collaboration_mode_kind: turn_context.mode(), }); sess.send_event(&turn_context, start_event).await; @@ -206,7 +206,7 @@ async fn run_remote_compact_task_inner_impl( let compaction_trace = sess.services.rollout_thread_trace.compaction_trace_context( turn_context.sub_id.as_str(), compaction_id.as_str(), - turn_context.model_info.slug.as_str(), + turn_context.model_info().slug.as_str(), turn_context.provider.info().name.as_str(), ); let compaction_item = TurnItem::ContextCompaction(context_compaction_item); @@ -235,7 +235,7 @@ async fn run_remote_compact_task_inner_impl( sess.services.rollout_thread_trace.compaction_trace_context( fallback_turn_context.sub_id.as_str(), compaction_id.as_str(), - fallback_turn_context.model_info.slug.as_str(), + fallback_turn_context.model_info().slug.as_str(), fallback_turn_context.provider.info().name.as_str(), ); let fallback_result = run_remote_compact_attempt( @@ -249,8 +249,8 @@ async fn run_remote_compact_task_inner_impl( .await; record_model_fallback( &sess.services.session_telemetry, - turn_context.model_info.slug.as_str(), - fallback_turn_context.model_info.slug.as_str(), + turn_context.model_info().slug.as_str(), + fallback_turn_context.model_info().slug.as_str(), compaction_metadata.reason(), compaction_metadata.implementation(), fallback_result.as_ref().err(), diff --git a/codex-rs/core/src/compact_remote_request.rs b/codex-rs/core/src/compact_remote_request.rs index 8a46e6ce18..9ca0b88392 100644 --- a/codex-rs/core/src/compact_remote_request.rs +++ b/codex-rs/core/src/compact_remote_request.rs @@ -58,7 +58,7 @@ pub(super) async fn run_remote_compact_attempt( let trace_input_history = compaction_trace .is_enabled() .then(|| history.raw_items().cloned().collect()); - let prompt_input = history.for_prompt(&turn_context.model_info.input_modalities); + let prompt_input = history.for_prompt(&turn_context.model_info().input_modalities); let tool_router = &step_context.tool_router; let prompt = Prompt { input: prompt_input, @@ -79,11 +79,11 @@ pub(super) async fn run_remote_compact_attempt( .model_client .compact_conversation_history( &prompt, - &turn_context.model_info, + turn_context.model_info(), turn_state, CompactConversationRequestSettings { - effort: turn_context.reasoning_effort.clone(), - summary: turn_context.reasoning_summary, + effort: turn_context.reasoning_effort().cloned(), + summary: turn_context.reasoning_summary(), service_tier: if sess.services.auth_manager.auth_mode() == Some(AuthMode::ApiKey) { None } else { diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index b7cebf0b4d..198c469f1c 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -118,7 +118,7 @@ pub(crate) async fn run_remote_compact_task( trace_id: turn_context.trace_id.clone(), started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), - collaboration_mode_kind: turn_context.mode, + collaboration_mode_kind: turn_context.mode(), }); sess.send_event(&turn_context, start_event).await; @@ -234,7 +234,7 @@ async fn run_remote_compact_task_inner_impl( let compaction_trace = sess.services.rollout_thread_trace.compaction_trace_context( turn_context.sub_id.as_str(), compaction_id.as_str(), - turn_context.model_info.slug.as_str(), + turn_context.model_info().slug.as_str(), turn_context.provider.info().name.as_str(), ); let compaction_item = TurnItem::ContextCompaction(context_compaction_item); @@ -264,7 +264,7 @@ async fn run_remote_compact_task_inner_impl( sess.services.rollout_thread_trace.compaction_trace_context( fallback_turn_context.sub_id.as_str(), compaction_id.as_str(), - fallback_turn_context.model_info.slug.as_str(), + fallback_turn_context.model_info().slug.as_str(), fallback_turn_context.provider.info().name.as_str(), ); let fallback_result = run_remote_compact_v2_attempt( @@ -278,8 +278,8 @@ async fn run_remote_compact_task_inner_impl( .await; record_model_fallback( &sess.services.session_telemetry, - turn_context.model_info.slug.as_str(), - fallback_turn_context.model_info.slug.as_str(), + turn_context.model_info().slug.as_str(), + fallback_turn_context.model_info().slug.as_str(), compaction_metadata.reason(), compaction_metadata.implementation(), fallback_result.as_ref().err(), @@ -380,10 +380,10 @@ async fn run_remote_compaction_request_v2( let result = match client_session .stream( prompt, - &turn_context.model_info, + turn_context.model_info(), &turn_context.session_telemetry, - turn_context.reasoning_effort.clone(), - turn_context.reasoning_summary, + turn_context.reasoning_effort().cloned(), + turn_context.reasoning_summary(), turn_context.config.service_tier.clone(), responses_metadata, &InferenceTraceContext::disabled(), diff --git a/codex-rs/core/src/compact_remote_v2_attempt.rs b/codex-rs/core/src/compact_remote_v2_attempt.rs index 6b567801b1..029b821901 100644 --- a/codex-rs/core/src/compact_remote_v2_attempt.rs +++ b/codex-rs/core/src/compact_remote_v2_attempt.rs @@ -69,7 +69,7 @@ pub(super) async fn run_remote_compact_v2_attempt( .is_enabled() .then(|| history.raw_items().cloned().collect()); let (mut input, prompt_input_metadata): (Vec<_>, Vec<_>) = history - .for_prompt_annotated(&turn_context.model_info.input_modalities) + .for_prompt_annotated(&turn_context.model_info().input_modalities) .into_iter() .map(|envelope| (envelope.item, envelope.metadata)) .unzip(); @@ -91,7 +91,7 @@ pub(super) async fn run_remote_compact_v2_attempt( ) .await; let trace_attempt = compaction_trace.start_attempt(&serde_json::json!({ - "model": turn_context.model_info.slug.as_str(), + "model": turn_context.model_info().slug.as_str(), "instructions": prompt.base_instructions.text.as_str(), "input": &prompt.input, "parallel_tool_calls": prompt.parallel_tool_calls, diff --git a/codex-rs/core/src/compact_token_budget.rs b/codex-rs/core/src/compact_token_budget.rs index 2abb6b0b24..60d25c53d8 100644 --- a/codex-rs/core/src/compact_token_budget.rs +++ b/codex-rs/core/src/compact_token_budget.rs @@ -32,7 +32,7 @@ pub(crate) async fn run_manual_compact_task( trace_id: turn_context.trace_id.clone(), started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), - collaboration_mode_kind: turn_context.mode, + collaboration_mode_kind: turn_context.mode(), }); sess.send_event(&turn_context, start_event).await; diff --git a/codex-rs/core/src/context/node_repl_review_evidence.rs b/codex-rs/core/src/context/node_repl_review_evidence.rs index cdd7942350..5d5dbae543 100644 --- a/codex-rs/core/src/context/node_repl_review_evidence.rs +++ b/codex-rs/core/src/context/node_repl_review_evidence.rs @@ -31,7 +31,7 @@ pub(crate) enum NodeReplReviewEvidenceMode { pub(crate) fn node_repl_review_evidence_mode(turn: &TurnContext) -> NodeReplReviewEvidenceMode { let features = &turn.config.features; - if turn.model_info.node_repl_auto_review_required + if turn.model_info().node_repl_auto_review_required || features.enabled(Feature::GuardianEnhancedNodeReplTranscripts) && features.enabled(Feature::GuardianNodeReplTranscriptImages) { diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 94ee21ae96..2d9b647321 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -254,8 +254,10 @@ impl ContextManager { // Estimate token usage using byte-based heuristics from the truncation helpers. // This is a coarse lower bound, not a tokenizer-accurate count. pub(crate) fn estimate_token_count(&self, turn_context: &TurnContext) -> Option { - let model_info = &turn_context.model_info; - let personality = turn_context.personality.or(turn_context.config.personality); + let model_info = &turn_context.model_info(); + let personality = turn_context + .personality() + .or(turn_context.config.personality); let base_instructions = BaseInstructions { text: model_info.get_model_instructions(personality), provenance: None, diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 943bd22509..145fa12dcb 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -249,7 +249,7 @@ async fn record_guardian_non_denial(session: &Arc, turn_id: &str) { } async fn record_guardian_denial(session: &Arc, turn: &Arc, turn_id: &str) { - let policy = if turn.model_info.model_specialty.as_deref() == Some(MODEL_SPECIALTY_CYBER) { + let policy = if turn.model_info().model_specialty.as_deref() == Some(MODEL_SPECIALTY_CYBER) { GuardianRejectionCircuitBreakerPolicy::CyberModel } else { GuardianRejectionCircuitBreakerPolicy::Standard @@ -335,7 +335,7 @@ async fn run_guardian_review( .config .config_layer_stack .requirements() - .auto_review_required_for_model(&turn.model_info.slug) + .auto_review_required_for_model(&turn.model_info().slug) || turn.config.features.enabled(Feature::GuardianV2)) && !requires_synchronous_review && options @@ -722,7 +722,7 @@ async fn run_guardian_review( assessment.rationale.trim() }; let rejection_instructions = turn - .model_info + .model_info() .model_messages .as_ref() .and_then(|messages| messages.auto_review.as_ref()) @@ -847,7 +847,7 @@ pub(super) async fn guardian_review_session_config( fallback } }; - let model_override = turn.model_info.auto_review_model_override.as_deref(); + let model_override = turn.model_info().auto_review_model_override.as_deref(); let review_model_id = model_override.unwrap_or(default_review_model_id); let review_model = available_models .iter() @@ -868,17 +868,17 @@ pub(super) async fn guardian_review_session_config( (review_model_id.to_string(), reasoning_effort) } else { let reasoning_effort = preferred_reasoning_effort( - turn.model_info + turn.model_info() .supported_reasoning_levels .iter() .any(|preset| preset.effort == codex_protocol::openai_models::ReasoningEffort::Low), - turn.reasoning_effort - .clone() - .or_else(|| turn.model_info.default_reasoning_level.clone()), + turn.reasoning_effort() + .or(turn.model_info().default_reasoning_level.as_ref()) + .cloned(), ); ( model_override - .unwrap_or(turn.model_info.slug.as_str()) + .unwrap_or(turn.model_info().slug.as_str()) .to_string(), reasoning_effort, ) @@ -899,7 +899,7 @@ pub(super) async fn guardian_review_session_config( guardian_reasoning_effort.clone(), guardian_model_info.model_messages.as_ref(), )?; - if turn.model_info.node_repl_auto_review_required { + if turn.model_info().node_repl_auto_review_required { spawn_config .features .enable(Feature::RetainClientDeveloperMessages) @@ -909,7 +909,7 @@ pub(super) async fn guardian_review_session_config( ) })?; } - if guardian_model != turn.model_info.slug { + if guardian_model != turn.model_info().slug { spawn_config.model_context_window = None; spawn_config.model_auto_compact_token_limit = None; } @@ -974,8 +974,8 @@ async fn run_guardian_review_session_before_deadline( guardian_catalog_contains_auto_review: session_config.catalog_contains_auto_review, guardian_review_model_overridden: session_config.model_overridden, guardian_review_model_override: session_config.model_override, - reasoning_summary: turn.reasoning_summary, - personality: turn.personality, + reasoning_summary: turn.reasoning_summary(), + personality: turn.personality(), external_cancel, deadline, }), diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 563381973a..3f29a2595e 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -438,7 +438,7 @@ impl GuardianReviewSessionManager { .with_node_repl_policy_eligibility( parent_context .turn() - .model_info + .model_info() .node_repl_auto_review_required, ); let spawn_cancel_token = self.cancellation_token.child_token(); @@ -528,7 +528,7 @@ impl GuardianReviewSessionManager { params .parent_context .turn() - .model_info + .model_info() .node_repl_auto_review_required, ); let mut spawned_trunk = false; @@ -952,7 +952,7 @@ async fn run_review_on_session( .sync_session_approved_hosts_to(&review_session.session.services.network_approval) .await; - if params.parent_context.turn().model_info.node_repl_auto_review_required + if params.parent_context.turn().model_info().node_repl_auto_review_required && matches!( ¶ms.request, GuardianApprovalRequest::McpToolCall { server, tool_name, .. } @@ -1607,10 +1607,10 @@ mod tests { async fn test_review_params() -> GuardianReviewSessionParams { let (session, turn) = crate::session::tests::make_session_and_context().await; - let model = turn.model_info.slug.clone(); - let reasoning_effort = turn.reasoning_effort.clone(); - let reasoning_summary = turn.reasoning_summary; - let personality = turn.personality; + let model = turn.model_info().slug.clone(); + let reasoning_effort = turn.reasoning_effort().cloned(); + let reasoning_summary = turn.reasoning_summary(); + let personality = turn.personality(); #[allow(deprecated)] let cwd = turn.cwd.clone(); let spawn_config = build_guardian_review_session_config( diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index eb9d452398..908ec42807 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -14,6 +14,7 @@ use crate::guardian::prompt::guardian_policy_prompt_with_config_and_template; use crate::guardian::review::guardian_review_session_config; use crate::guardian::review::routes_approval_to_guardian_with_reviewer; use crate::session::session::Session; +use crate::session::tests::update_turn_settings_for_test; use crate::session::turn_context::TurnContext; use crate::test_support; use codex_analytics::GuardianApprovalRequestSource; @@ -1185,12 +1186,10 @@ async fn build_guardian_prompt_items_keeps_required_node_repl_reviews_generic() { let (session, mut turn) = guardian_test_session_and_turn_with_base_url("http://localhost").await; - Arc::make_mut( - &mut Arc::get_mut(&mut turn) - .expect("turn should be uniquely owned") - .model_info, - ) - .node_repl_auto_review_required = true; + update_turn_settings_for_test( + Arc::get_mut(&mut turn).expect("turn should be uniquely owned"), + |settings| Arc::make_mut(&mut settings.model_info).node_repl_auto_review_required = true, + ); seed_guardian_parent_history(&session, &turn).await; let context = GuardianReviewContext::from(&turn); @@ -1810,7 +1809,7 @@ async fn guardian_request_model_for_auto_review( match catalog { GuardianTestCatalog::Bundled => {} GuardianTestCatalog::ParentOnly => { - let parent_model = turn.model_info.as_ref().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), @@ -1824,13 +1823,14 @@ async fn guardian_request_model_for_auto_review( .models_manager = Arc::new(models_manager); } } - 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(); + update_turn_settings_for_test( + Arc::get_mut(&mut turn).expect("turn should be unique"), + |settings| { + Arc::make_mut(&mut settings.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(); seed_guardian_parent_history(&session, &turn).await; @@ -2055,8 +2055,10 @@ 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()); - Arc::make_mut(&mut turn.model_info).auto_review_model_override = - Some("codex-auto-review".to_string()); + update_turn_settings_for_test(&mut turn, |settings| { + Arc::make_mut(&mut settings.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; @@ -2330,8 +2332,10 @@ 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"); - Arc::make_mut(&mut turn_mut.model_info).auto_review_model_override = - Some("codex-auto-review".to_string()); + update_turn_settings_for_test(turn_mut, |settings| { + Arc::make_mut(&mut settings.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; @@ -3542,7 +3546,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.as_ref().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/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index d4a5ee04eb..95803e418f 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -145,7 +145,7 @@ pub(crate) async fn run_pending_session_start_hooks( #[allow(deprecated)] cwd: turn_context.cwd.clone(), transcript_path: sess.hook_transcript_path().await, - model: turn_context.model_info.slug.clone(), + model: turn_context.model_info().slug.clone(), permission_mode: hook_permission_mode(turn_context), target, }; @@ -187,7 +187,7 @@ pub(crate) async fn run_pre_tool_use_hooks( #[allow(deprecated)] cwd: turn_context.cwd.clone(), transcript_path: sess.hook_transcript_path().await, - model: turn_context.model_info.slug.clone(), + model: turn_context.model_info().slug.clone(), permission_mode: hook_permission_mode(turn_context), tool_name: tool_name.name().to_string(), matcher_aliases: tool_name.matcher_aliases().to_vec(), @@ -248,7 +248,7 @@ pub(crate) async fn run_permission_request_hooks( #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), transcript_path: sess.hook_transcript_path().await, - model: turn_context.model_info.slug.clone(), + model: turn_context.model_info().slug.clone(), permission_mode: hook_permission_mode(turn_context), tool_name: payload.tool_name.name().to_string(), matcher_aliases: payload.tool_name.matcher_aliases().to_vec(), @@ -290,7 +290,7 @@ pub(crate) async fn run_post_tool_use_hooks( #[allow(deprecated)] cwd: turn_context.cwd.clone(), transcript_path: sess.hook_transcript_path().await, - model: turn_context.model_info.slug.clone(), + model: turn_context.model_info().slug.clone(), permission_mode: hook_permission_mode(turn_context), tool_name, matcher_aliases, @@ -366,8 +366,8 @@ pub(crate) async fn run_turn_stop_hooks( let request_metadata = turn_context .turn_metadata_state .current_meta_value_for_mcp_request(McpTurnMetadataContext { - model: step_context.model_info.slug.as_str(), - reasoning_effort: step_context.reasoning_effort.clone(), + model: step_context.settings.model_info.slug.as_str(), + reasoning_effort: step_context.settings.reasoning_effort().cloned(), }) .map(|turn_metadata| { serde_json::Map::from_iter([( @@ -381,7 +381,7 @@ pub(crate) async fn run_turn_stop_hooks( #[allow(deprecated)] cwd: turn_context.cwd.clone(), transcript_path, - model: turn_context.model_info.slug.clone(), + model: turn_context.model_info().slug.clone(), permission_mode: hook_permission_mode(turn_context), request_metadata, stop_hook_active, @@ -450,7 +450,7 @@ pub(crate) async fn run_turn_interrupt_hooks(sess: &Arc, turn_context: #[allow(deprecated)] cwd: turn_context.cwd.clone(), transcript_path: sess.hook_transcript_path().await, - model: turn_context.model_info.slug.clone(), + model: turn_context.model_info().slug.clone(), permission_mode: hook_permission_mode(turn_context), }; if let Err(err) = sess.flush_rollout().await { @@ -474,7 +474,7 @@ pub(crate) async fn run_pre_compact_hooks( #[allow(deprecated)] cwd: turn_context.cwd.clone(), transcript_path: sess.hook_transcript_path().await, - model: turn_context.model_info.slug.clone(), + model: turn_context.model_info().slug.clone(), trigger: compaction_trigger_label(trigger).to_string(), }; let preview_runs = sess.hooks().preview_pre_compact(&request); @@ -511,7 +511,7 @@ pub(crate) async fn run_post_compact_hooks( #[allow(deprecated)] cwd: turn_context.cwd.clone(), transcript_path: sess.hook_transcript_path().await, - model: turn_context.model_info.slug.clone(), + model: turn_context.model_info().slug.clone(), trigger: compaction_trigger_label(trigger).to_string(), }; let preview_runs = sess.hooks().preview_post_compact(&request); @@ -608,7 +608,7 @@ pub(crate) async fn inspect_pending_input( #[allow(deprecated)] cwd: turn_context.cwd.clone(), transcript_path: sess.hook_transcript_path().await, - model: turn_context.model_info.slug.clone(), + model: turn_context.model_info().slug.clone(), permission_mode: hook_permission_mode(turn_context), prompt: UserMessageItem::new(content).message(), }; @@ -845,7 +845,7 @@ fn hook_run_analytics_payload( ) -> (codex_analytics::TrackEventsContext, HookRunFact) { ( build_track_events_context( - turn_context.model_info.slug.clone(), + turn_context.model_info().slug.clone(), thread_id, completed .turn_id @@ -1071,7 +1071,7 @@ mod tests { assert_eq!(tracking.thread_id, "thread-123"); assert_eq!(tracking.turn_id, "turn-from-hook"); - assert_eq!(tracking.model_slug, turn_context.model_info.slug); + assert_eq!(tracking.model_slug, turn_context.model_info().slug); assert_eq!(hook.event_name, HookEventName::Stop); assert_eq!(hook.handler_type, HookHandlerType::Command); assert_eq!(hook.execution_mode, HookExecutionMode::Sync); diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 2bbb831ad7..1b9057478c 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -287,7 +287,7 @@ pub(crate) async fn handle_mcp_tool_call( &call_id, invocation, item_metadata.clone(), - crate::guardian::guardian_timeout_message(&turn_context.model_info), + crate::guardian::guardian_timeout_message(turn_context.model_info()), /*already_started*/ true, ) .await @@ -449,7 +449,7 @@ async fn handle_approved_mcp_tool_call( .map(|(connector_id, action_name)| HostedFileUploadContext { connector_id: connector_id.clone(), action_name: action_name.clone(), - model: turn_context.model_info.slug.clone(), + model: turn_context.model_info().slug.clone(), }); let rewritten_arguments = rewrite_mcp_tool_arguments_for_openai_files( sess, @@ -491,7 +491,7 @@ async fn handle_approved_mcp_tool_call( .await .map_err(|error| format!("tool call error: {error:?}"))?; let result = sanitize_mcp_tool_result_for_model( - &turn_context.model_info.input_modalities, + &turn_context.model_info().input_modalities, Ok(result), )?; Ok(maybe_request_codex_apps_auth_elicitation( @@ -1023,7 +1023,7 @@ async fn maybe_track_codex_app_used( }; let tracking = build_track_events_context( - turn_context.model_info.slug.clone(), + turn_context.model_info().slug.clone(), sess.thread_id.to_string(), turn_context.sub_id.clone(), turn_context.originator.clone(), @@ -1193,7 +1193,7 @@ fn build_mcp_tool_call_request_meta( if let Some(turn_metadata) = turn_context .turn_metadata_state .current_meta_value_for_mcp_request(McpTurnMetadataContext { - model: turn_context.model_info.slug.as_str(), + model: turn_context.model_info().slug.as_str(), reasoning_effort: turn_context.effective_reasoning_effort(), }) { @@ -1317,7 +1317,7 @@ async fn maybe_request_mcp_tool_approval( let approvals_reviewer = connectors::mcp_approvals_reviewer_from_layers( &config.config_layer_stack, config.approvals_reviewer, - Some(turn_context.model_info.slug.as_str()), + Some(turn_context.model_info().slug.as_str()), &invocation.server, metadata.connector_id.as_deref(), ); diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 9748432eb1..e8fd9f9f46 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -93,7 +93,7 @@ fn approval_config(turn_context: &TurnContext) -> codex_mcp::McpConfig { fn mcp_turn_metadata_context(turn_context: &TurnContext) -> McpTurnMetadataContext<'_> { McpTurnMetadataContext { - model: turn_context.model_info.slug.as_str(), + model: turn_context.model_info().slug.as_str(), reasoning_effort: turn_context.effective_reasoning_effort(), } } @@ -1038,15 +1038,15 @@ async fn mcp_tool_call_request_meta_includes_turn_metadata_for_custom_server() { turn_metadata .get("model") .and_then(serde_json::Value::as_str), - Some(turn_context.model_info.slug.as_str()) + Some(turn_context.model_info().slug.as_str()) ); assert_eq!( turn_metadata["node_repl_auto_review_required"], - serde_json::Value::Bool(turn_context.model_info.node_repl_auto_review_required), + serde_json::Value::Bool(turn_context.model_info().node_repl_auto_review_required), ); assert_eq!( turn_metadata["node_repl_disabled"], - serde_json::Value::Bool(turn_context.model_info.node_repl_disabled), + serde_json::Value::Bool(turn_context.model_info().node_repl_disabled), ); assert_eq!( turn_metadata @@ -2496,7 +2496,7 @@ async fn permission_request_hook_allows_mcp_tool_call() { "turn_id": "turn_id", "cwd": turn_cwd, "transcript_path": null, - "model": turn_context.model_info.slug, + "model": turn_context.model_info().slug, "permission_mode": "default", "tool_name": "mcp__memory__create_entities", "hook_event_name": "PermissionRequest", @@ -2566,7 +2566,7 @@ async fn permission_request_hook_uses_hook_tool_name_without_metadata() { "turn_id": "turn_id", "cwd": turn_cwd, "transcript_path": null, - "model": turn_context.model_info.slug, + "model": turn_context.model_info().slug, "permission_mode": "default", "tool_name": "mcp__memory__create_entities", "hook_event_name": "PermissionRequest", diff --git a/codex-rs/core/src/prompt_debug.rs b/codex-rs/core/src/prompt_debug.rs index 7e91ee81ad..125315283a 100644 --- a/codex-rs/core/src/prompt_debug.rs +++ b/codex-rs/core/src/prompt_debug.rs @@ -101,7 +101,7 @@ pub(crate) async fn build_prompt_input_from_session( let prompt_input = sess .clone_history() .await - .for_prompt(&step_context.model_info.input_modalities); + .for_prompt(&step_context.settings.model_info.input_modalities); let base_instructions = sess.get_base_instructions().await; let prompt = build_prompt(prompt_input, step_context.as_ref(), base_instructions); diff --git a/codex-rs/core/src/responses_retry.rs b/codex-rs/core/src/responses_retry.rs index 178c13df49..fb0505411e 100644 --- a/codex-rs/core/src/responses_retry.rs +++ b/codex-rs/core/src/responses_retry.rs @@ -85,7 +85,7 @@ pub(crate) async fn handle_retryable_response_stream_error( if retry_state.retries >= max_retries && client_session.try_switch_fallback_transport( &turn_context.session_telemetry, - &turn_context.model_info, + turn_context.model_info(), ) { sess.send_event( diff --git a/codex-rs/core/src/session/context_window.rs b/codex-rs/core/src/session/context_window.rs index 466175c17d..ae9e4676a5 100644 --- a/codex-rs/core/src/session/context_window.rs +++ b/codex-rs/core/src/session/context_window.rs @@ -31,7 +31,7 @@ pub(crate) async fn context_window_token_status( match turn_context.config.model_auto_compact_token_limit_scope { AutoCompactTokenLimitScope::Total => ( active_context_tokens, - turn_context.model_info.auto_compact_token_limit(), + turn_context.model_info().auto_compact_token_limit(), None, ), AutoCompactTokenLimitScope::BodyAfterPrefix => { @@ -41,7 +41,7 @@ pub(crate) async fn context_window_token_status( let scope_limit = turn_context .config .model_auto_compact_token_limit - .or_else(|| turn_context.model_info.auto_compact_token_limit()); + .or_else(|| turn_context.model_info().auto_compact_token_limit()); ( active_context_tokens.saturating_sub(baseline), scope_limit, diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index 28fbc46b3c..de495d99c9 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -119,12 +119,8 @@ pub async fn run_user_shell_command(sess: &Arc, sub_id: String, command } let turn_context = sess.new_default_turn_with_sub_id(sub_id).await; - sess.spawn_task( - Arc::clone(&turn_context), - Vec::new(), - UserShellCommandTask::new(command), - ) - .await; + sess.spawn_task(turn_context, Vec::new(), UserShellCommandTask::new(command)) + .await; } pub async fn resolve_elicitation( @@ -243,8 +239,7 @@ pub async fn reload_user_config(sess: &Arc) { pub async fn compact(sess: &Arc, sub_id: String) { let turn_context = sess.new_default_turn_with_sub_id(sub_id).await; - sess.spawn_task(Arc::clone(&turn_context), Vec::new(), CompactTask) - .await; + sess.spawn_task(turn_context, Vec::new(), CompactTask).await; } pub async fn thread_rollback(sess: &Arc, sub_id: String, num_turns: u32) { diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index dcb5c399ba..847f6e21da 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -766,7 +766,7 @@ async fn review_guardian_mcp_elicitation( || crate::connectors::mcp_approvals_reviewer_from_layers( &mcp_config.config_layer_stack, ApprovalsReviewer::AutoReview, - Some(turn_context.model_info.slug.as_str()), + Some(turn_context.model_info().slug.as_str()), request.server_name.as_str(), connector_id, ) != ApprovalsReviewer::AutoReview @@ -806,7 +806,7 @@ async fn review_guardian_mcp_elicitation( | ReviewDecision::Abort ) .then(|| { - mcp_elicitation_response_from_guardian_decision(decision, &turn_context.model_info) + mcp_elicitation_response_from_guardian_decision(decision, turn_context.model_info()) })); } @@ -844,7 +844,7 @@ async fn review_guardian_mcp_elicitation( let approvals_reviewer = crate::connectors::mcp_approvals_reviewer_from_layers( &mcp_config.config_layer_stack, mcp_config.approvals_reviewer, - Some(turn_context.model_info.slug.as_str()), + Some(turn_context.model_info().slug.as_str()), request.server_name.as_str(), elicitation_connector_id(&request.elicitation), ); @@ -882,7 +882,7 @@ async fn review_guardian_mcp_elicitation( .await; Ok(Some(mcp_elicitation_response_from_guardian_decision( decision, - &turn_context.model_info, + turn_context.model_info(), ))) } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 3d180b03a9..2373476fbc 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -1379,7 +1379,7 @@ impl Session { .await; // If resuming, warn when the last recorded model differs from the current one. - let curr: &str = turn_context.model_info.slug.as_str(); + let curr: &str = turn_context.model_info().slug.as_str(); if let Some(prev) = previous_turn_settings .as_ref() .map(|settings| settings.model.as_str()) @@ -3069,7 +3069,7 @@ impl Session { let mut items = items.to_vec(); let image_preparation_mode = if unified_image_budget_enabled( &turn_context.config.features, - &turn_context.model_info, + turn_context.model_info(), ) { ImagePreparationMode::UnifiedBudget } else { @@ -3202,7 +3202,7 @@ impl Session { .note_recorded_items(&response_items); state .history - .record_annotated_items(&items, turn_context.model_info.truncation_policy.into()); + .record_annotated_items(&items, turn_context.model_info().truncation_policy.into()); } for image in image_preparations { self.services @@ -3286,6 +3286,10 @@ impl Session { cancellation_token: &CancellationToken, required_servers: &[String], ) -> CodexResult> { + // Capture once before asynchronous planning; all request consumers + // retain this immutable settings version even if the turn is updated. + let settings = turn_context.current_settings.load_full(); + let session_telemetry = settings.telemetry(&turn_context.session_telemetry); // Keep selections fixed for the turn while allowing their startup work to finish. let environments = turn_context.environments.refresh_readiness(); self.services @@ -3354,6 +3358,8 @@ impl Session { }); extension_data.insert(selected_plugins.clone()); turn_context.extension_data.insert(selected_plugins); + // Tool planning still uses the admitted turn. Migrating it to the + // captured model is a separate step from diagnostic activation. let tool_router = turn::built_tools( self.as_ref(), turn_context.as_ref(), @@ -3365,13 +3371,8 @@ 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(), + settings, + session_telemetry, turn: turn_context, environments, selected_capability_roots, @@ -3399,7 +3400,7 @@ impl Session { state.current_time_reminder.note_recorded_items(items); state.record_items( items.iter(), - turn_context.model_info.truncation_policy.into(), + turn_context.model_info().truncation_policy.into(), ); } self.persist_rollout_items(&[ @@ -3414,10 +3415,11 @@ impl Session { async fn maybe_warn_on_server_model_mismatch( self: &Arc, - turn_context: &Arc, + step_context: &StepContext, server_model: String, ) -> bool { - let requested_model = turn_context.model_info.slug.clone(); + let turn_context = &step_context.turn; + let requested_model = step_context.settings.model_info.slug.clone(); let server_model_normalized = server_model.to_ascii_lowercase(); let requested_model_normalized = requested_model.to_ascii_lowercase(); if server_model_normalized == requested_model_normalized { diff --git a/codex-rs/core/src/session/multi_agents.rs b/codex-rs/core/src/session/multi_agents.rs index bae1969159..22d56df390 100644 --- a/codex-rs/core/src/session/multi_agents.rs +++ b/codex-rs/core/src/session/multi_agents.rs @@ -73,7 +73,7 @@ pub(super) fn usage_hint_text( } let catalog = turn_context - .model_info + .model_info() .model_messages .as_ref() .and_then(|messages| messages.multi_agent.as_ref()) @@ -148,7 +148,7 @@ pub(crate) fn effective_multi_agent_mode(turn_context: &TurnContext) -> Option { history.record_annotated_items( std::slice::from_ref(response_item), - turn_context.model_info.truncation_policy.into(), + turn_context.model_info().truncation_policy.into(), ); } RolloutItem::InterAgentCommunication(communication) => { let response_item = communication.to_model_input_item(); history.record_items( std::iter::once(&response_item), - turn_context.model_info.truncation_policy.into(), + turn_context.model_info().truncation_policy.into(), ); } RolloutItem::InterAgentCommunicationMetadata { .. } => {} diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index 034c72c3cb..5447999e15 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -241,12 +241,12 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ file_system_sandbox_policy: None, model: previous_model.to_string(), comp_hash: None, - personality: turn_context.personality, + personality: turn_context.personality(), collaboration_mode: Some(turn_context.collaboration_mode()), multi_agent_version: None, multi_agent_mode: None, realtime_active: Some(turn_context.realtime_active), - effort: turn_context.reasoning_effort.clone(), + effort: turn_context.reasoning_effort().cloned(), summary: codex_protocol::config_types::ReasoningSummary::Auto, }; let rollout_items = vec![RolloutItem::TurnContext(previous_context_item)]; @@ -289,12 +289,12 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif file_system_sandbox_policy: None, model: previous_model.to_string(), comp_hash: Some("comp-hash-a".to_string()), - personality: turn_context.personality, + personality: turn_context.personality(), collaboration_mode: Some(turn_context.collaboration_mode()), multi_agent_version: None, multi_agent_mode: None, realtime_active: Some(turn_context.realtime_active), - effort: turn_context.reasoning_effort.clone(), + effort: turn_context.reasoning_effort().cloned(), summary: codex_protocol::config_types::ReasoningSummary::Auto, }; let turn_id = previous_context_item @@ -464,7 +464,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com assert_eq!( reconstructed.previous_turn_settings, Some(PreviousTurnSettings { - model: turn_context.model_info.slug.clone(), + model: turn_context.model_info().slug.clone(), comp_hash: None, realtime_active: Some(turn_context.realtime_active), }) @@ -565,7 +565,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc assert_eq!( reconstructed.previous_turn_settings, Some(PreviousTurnSettings { - model: turn_context.model_info.slug.clone(), + model: turn_context.model_info().slug.clone(), comp_hash: None, realtime_active: Some(turn_context.realtime_active), }) @@ -697,7 +697,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad assert_eq!( reconstructed.previous_turn_settings, Some(PreviousTurnSettings { - model: turn_context.model_info.slug.clone(), + model: turn_context.model_info().slug.clone(), comp_hash: None, realtime_active: Some(turn_context.realtime_active), }) @@ -802,7 +802,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { assert_eq!( reconstructed.previous_turn_settings, Some(PreviousTurnSettings { - model: turn_context.model_info.slug.clone(), + model: turn_context.model_info().slug.clone(), comp_hash: None, realtime_active: Some(turn_context.realtime_active), }) @@ -1036,7 +1036,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp assert_eq!( session.previous_turn_settings().await, Some(PreviousTurnSettings { - model: turn_context.model_info.slug.clone(), + model: turn_context.model_info().slug.clone(), comp_hash: None, realtime_active: Some(turn_context.realtime_active), }) @@ -1360,12 +1360,12 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis file_system_sandbox_policy: None, model: previous_model.to_string(), comp_hash: None, - personality: turn_context.personality, + personality: turn_context.personality(), collaboration_mode: Some(turn_context.collaboration_mode()), multi_agent_version: None, multi_agent_mode: None, realtime_active: Some(turn_context.realtime_active), - effort: turn_context.reasoning_effort.clone(), + effort: turn_context.reasoning_effort().cloned(), summary: codex_protocol::config_types::ReasoningSummary::Auto, }; let previous_turn_id = previous_context_item @@ -1451,12 +1451,12 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis file_system_sandbox_policy: None, model: previous_model.to_string(), comp_hash: None, - personality: turn_context.personality, + personality: turn_context.personality(), collaboration_mode: Some(turn_context.collaboration_mode()), multi_agent_version: None, multi_agent_mode: None, realtime_active: Some(turn_context.realtime_active), - effort: turn_context.reasoning_effort.clone(), + effort: turn_context.reasoning_effort().cloned(), summary: codex_protocol::config_types::ReasoningSummary::Auto, })) .expect("serialize expected reference context item") @@ -1484,12 +1484,12 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu file_system_sandbox_policy: None, model: previous_model.to_string(), comp_hash: None, - personality: turn_context.personality, + personality: turn_context.personality(), collaboration_mode: Some(turn_context.collaboration_mode()), multi_agent_version: None, multi_agent_mode: None, realtime_active: Some(turn_context.realtime_active), - effort: turn_context.reasoning_effort.clone(), + effort: turn_context.reasoning_effort().cloned(), summary: codex_protocol::config_types::ReasoningSummary::Auto, }; let previous_turn_id = previous_context_item @@ -1616,12 +1616,12 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo file_system_sandbox_policy: None, model: current_model.to_string(), comp_hash: None, - personality: turn_context.personality, + personality: turn_context.personality(), collaboration_mode: Some(turn_context.collaboration_mode()), multi_agent_version: None, multi_agent_mode: None, realtime_active: Some(turn_context.realtime_active), - effort: turn_context.reasoning_effort.clone(), + effort: turn_context.reasoning_effort().cloned(), summary: codex_protocol::config_types::ReasoningSummary::Auto, }; @@ -1744,12 +1744,12 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea file_system_sandbox_policy: None, model: previous_model.to_string(), comp_hash: None, - personality: turn_context.personality, + personality: turn_context.personality(), collaboration_mode: Some(turn_context.collaboration_mode()), multi_agent_version: None, multi_agent_mode: None, realtime_active: Some(turn_context.realtime_active), - effort: turn_context.reasoning_effort.clone(), + effort: turn_context.reasoning_effort().cloned(), summary: codex_protocol::config_types::ReasoningSummary::Auto, }; let previous_turn_id = previous_context_item @@ -1882,7 +1882,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_preserves_turn_ assert_eq!( session.previous_turn_settings().await, Some(PreviousTurnSettings { - model: turn_context.model_info.slug.clone(), + model: turn_context.model_info().slug.clone(), comp_hash: None, realtime_active: Some(turn_context.realtime_active), }) @@ -1916,12 +1916,12 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear file_system_sandbox_policy: None, model: previous_model.to_string(), comp_hash: None, - personality: turn_context.personality, + personality: turn_context.personality(), collaboration_mode: Some(turn_context.collaboration_mode()), multi_agent_version: None, multi_agent_mode: None, realtime_active: Some(turn_context.realtime_active), - effort: turn_context.reasoning_effort.clone(), + effort: turn_context.reasoning_effort().cloned(), summary: codex_protocol::config_types::ReasoningSummary::Auto, }; let previous_turn_id = previous_context_item diff --git a/codex-rs/core/src/session/step_context.rs b/codex-rs/core/src/session/step_context.rs index 8ab12c1860..41c65c5c31 100644 --- a/codex-rs/core/src/session/step_context.rs +++ b/codex-rs/core/src/session/step_context.rs @@ -2,35 +2,19 @@ use std::sync::Arc; use crate::agents_md::LoadedAgentsMd; use crate::environment_selection::TurnEnvironmentSnapshot; +use crate::session::step_settings::ResolvedStepSettings; use crate::session::turn_context::TurnContext; 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, + /// One immutable settings version captured before request preparation. + pub(crate) settings: Arc, /// Telemetry context tagged with this sampling request's model. pub(crate) session_telemetry: SessionTelemetry, pub(crate) environments: TurnEnvironmentSnapshot, diff --git a/codex-rs/core/src/session/step_settings.rs b/codex-rs/core/src/session/step_settings.rs index 484ee756bb..7d52a2ad0e 100644 --- a/codex-rs/core/src/session/step_settings.rs +++ b/codex-rs/core/src/session/step_settings.rs @@ -1,32 +1,117 @@ -//! Configured inputs used to derive effective step settings. +//! Configured inputs retained independently of future thread settings. use crate::config::Constrained; use crate::config::ConstraintError; use crate::config::ConstraintResult; use codex_config::ConfigRequirements; use codex_models_manager::ModelsManagerConfig; +use codex_models_manager::manager::ModelsManager; +use codex_otel::SessionTelemetry; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; +use std::sync::Arc; /// Model and execution settings selected for an individual model step within /// a turn. A turn may contain several steps, each using its own captured settings. #[derive(Clone, Debug, PartialEq)] pub(crate) struct StepSettings { pub(crate) collaboration_mode: CollaborationMode, + /// `None` follows the pinned model's default. pub(crate) reasoning_summary: Option, + /// Normalized requested tier. Startup retains initial-model filtering; + /// resolution filters the effective tier against the pinned model. pub(crate) service_tier: Option, pub(crate) personality: Option, pub(crate) approval_policy: Constrained, pub(crate) approvals_reviewer: ApprovalsReviewer, } +/// Immutable configured choices, pinned model metadata, and effective request values. +/// +/// One snapshot may produce several fresh `StepContext`s. Replacing the +/// snapshot does not change steps or actions that have already captured it. +#[derive(Clone, Debug)] +pub(crate) struct ResolvedStepSettings { + /// Inputs for later sparse patches. Unset defaults and unsupported requested + /// tiers must not be reconstructed from the effective values below. + selected: Arc, + pub(crate) model_info: Arc, + /// Effective request summary: the configured value or the pinned model's default. + pub(crate) reasoning_summary: ReasoningSummary, + /// Tier sent on requests, filtered by feature and model support. + pub(crate) service_tier: Option, +} + +impl ResolvedStepSettings { + /// Derives request values from selected settings and pinned metadata. + pub(super) fn new( + selected: Arc, + model_info: Arc, + fast_mode_enabled: bool, + ) -> Self { + let reasoning_summary = selected + .reasoning_summary + .unwrap_or(model_info.default_reasoning_summary); + let service_tier = super::get_service_tier( + selected.service_tier.clone(), + fast_mode_enabled, + &model_info, + ); + Self { + selected, + model_info, + reasoning_summary, + service_tier, + } + } + + pub(crate) fn reasoning_effort(&self) -> Option<&ReasoningEffort> { + self.selected + .collaboration_mode + .settings + .reasoning_effort + .as_ref() + } + + pub(crate) fn approval_policy(&self) -> AskForApproval { + self.selected.approval_policy.value() + } + + pub(crate) fn approvals_reviewer(&self) -> ApprovalsReviewer { + self.selected.approvals_reviewer + } + + /// Retained inputs for constructing a snapshot against different model metadata. + pub(super) fn selected(&self) -> &StepSettings { + &self.selected + } + + pub(super) fn selected_collaboration_mode(&self) -> &CollaborationMode { + &self.selected.collaboration_mode + } + + pub(super) fn personality(&self) -> Option { + self.selected.personality + } + + pub(super) fn telemetry(&self, base: &SessionTelemetry) -> SessionTelemetry { + base.clone().with_model( + self.selected.collaboration_mode.model(), + &self.model_info.slug, + ) + } +} + /// Explicit startup overrides applied to catalog-derived model metadata. +/// Construct from `Config::to_models_manager_config()` so model-derived base +/// instructions are not mistaken for explicit overrides. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub(crate) struct ModelInfoOverrides { pub(crate) context_window: Option, @@ -83,6 +168,8 @@ pub(crate) struct StepSettingsUpdate { } /// Constraints used when applying and validating a candidate settings version. +/// Future settings use the proposed environment; active settings use the +/// environment already admitted for that execution. pub(crate) struct StepSettingsConstraints<'a> { pub(crate) requirements: &'a ConfigRequirements, pub(crate) guardian_approval_enabled: bool, @@ -91,6 +178,19 @@ pub(crate) struct StepSettingsConstraints<'a> { } impl StepSettings { + /// Resolves the selected model using the session's explicit startup overrides. + pub(super) async fn resolve_model_info( + &self, + models_manager: &dyn ModelsManager, + overrides: &ModelInfoOverrides, + personality_enabled: bool, + ) -> ModelInfo { + let config = overrides.models_manager_config(self.personality, personality_enabled); + models_manager + .get_model_info(self.collaboration_mode.model(), &config) + .await + } + /// Applies edits and validates the result against the supplied constraints. /// Callers must supply the constraints of the proposed target environment. pub(crate) fn apply( @@ -182,4 +282,4 @@ impl StepSettings { #[cfg(test)] #[path = "step_settings_tests.rs"] -mod tests; +pub(super) mod tests; diff --git a/codex-rs/core/src/session/step_settings_tests.rs b/codex-rs/core/src/session/step_settings_tests.rs index 778a6edc52..eb75b3c730 100644 --- a/codex-rs/core/src/session/step_settings_tests.rs +++ b/codex-rs/core/src/session/step_settings_tests.rs @@ -1,5 +1,6 @@ use super::*; use crate::config::PermissionProfileState; +use crate::config::test_config; use crate::session::session::SessionConfiguration; use crate::session::session::SessionSettingsUpdate; use crate::session::tests::make_session_configuration_for_tests; @@ -7,9 +8,17 @@ use codex_config::ConfigLayerStack; use codex_config::RequirementSource; use codex_config::Sourced; use codex_features::Feature; +use codex_models_manager::manager::StaticModelsManager; +use codex_models_manager::model_info::model_info_from_slug; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; +use codex_protocol::models::BaseInstructionsProvenance; use codex_protocol::models::PermissionProfile; +use codex_protocol::openai_models::AutoReviewMessages; +use codex_protocol::openai_models::GuardianV2ModelConfig; +use codex_protocol::openai_models::ModelInstructionsVariables; +use codex_protocol::openai_models::ModelsResponse; +use codex_protocol::openai_models::TruncationPolicyConfig; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; @@ -22,6 +31,14 @@ use core_test_support::test_codex::local; use pretty_assertions::assert_eq; use std::collections::BTreeSet; use std::sync::Arc; +use test_case::test_case; + +pub(crate) fn update_selected_settings_for_test( + settings: &mut ResolvedStepSettings, + update: impl FnOnce(&mut StepSettings), +) { + update(Arc::make_mut(&mut settings.selected)); +} fn set_requirements(configuration: &mut SessionConfiguration, requirements: ConfigRequirements) { let config = Arc::make_mut(&mut configuration.original_config_do_not_use); @@ -383,3 +400,109 @@ fn collaboration_replacement_wins_and_effort_clear_remains_sparse() { } ); } + +#[test_case(BaseInstructionsProvenance::Custom; "explicit instructions")] +#[test_case(BaseInstructionsProvenance::Model { model: "model-a".to_string() }; "model-derived instructions")] +#[tokio::test] +async fn model_resolution_preserves_startup_overrides_and_instruction_provenance( + provenance: BaseInstructionsProvenance, +) { + let configured_instructions = "explicit {{ personality }}"; + let auto_review = AutoReviewMessages { + policy: Some("catalog review policy".to_string()), + policy_template: Some("catalog review template".to_string()), + rejection_instructions: None, + timeout_instructions: None, + }; + let guardian_v2 = GuardianV2ModelConfig { + classifier_instructions: Some("catalog classifier".to_string()), + review_threshold_basis_points: Some(7_500), + ..Default::default() + }; + let mut model = model_info_from_slug("model-b"); + model.context_window = Some(90_000); + model.max_context_window = Some(100_000); + model.auto_compact_token_limit = Some(80_000); + model.truncation_policy = TruncationPolicyConfig::tokens(/*limit*/ 1_000); + let messages = model + .model_messages + .as_mut() + .expect("test model should have instruction metadata"); + messages.instructions_template = + Some("Catalog B.\n# Personality\n{{ personality }}\n# Rules\nKeep the rules.".to_string()); + messages.instructions_variables = Some(ModelInstructionsVariables { + personality_default: Some("default".to_string()), + personality_friendly: Some("friendly".to_string()), + personality_pragmatic: Some("pragmatic".to_string()), + }); + messages.auto_review = Some(auto_review); + messages.guardian_v2 = Some(guardian_v2); + let catalog = ModelsResponse { + models: vec![model], + }; + let models_manager = StaticModelsManager::new(/*auth_manager*/ None, catalog.clone()); + + let mut config = test_config().await; + config.model = Some("model-a".to_string()); + config.model_catalog = Some(catalog); + config.model_context_window = Some(160_000); + config.model_auto_compact_token_limit = Some(70_000); + config.tool_output_token_limit = Some(777); + config.base_instructions = Some(configured_instructions.to_string()); + let explicit_instructions = matches!(&provenance, BaseInstructionsProvenance::Custom); + config.base_instructions_provenance = Some(provenance); + + // Capture the same filtered explicit overrides that session startup owns. + let overrides = ModelInfoOverrides::from(config.to_models_manager_config()); + + for (personality, personality_enabled, catalog_instructions) in [ + ( + Personality::Friendly, + true, + "Catalog B.\n# Personality\nfriendly\n# Rules\nKeep the rules.", + ), + ( + Personality::None, + true, + "Catalog B.\n# Rules\nKeep the rules.", + ), + ( + Personality::Friendly, + false, + "Catalog B.\n# Personality\ndefault\n# Rules\nKeep the rules.", + ), + ] { + config.personality = Some(personality); + config + .features + .set_enabled(Feature::Personality, personality_enabled) + .expect("test config should allow personality changes"); + let mut settings = configured_settings(); + settings.collaboration_mode = settings.collaboration_mode.with_updates( + Some("model-b".to_string()), + /*effort*/ None, + /*developer_instructions*/ None, + ); + settings.personality = config.personality; + + let legacy = models_manager + .get_model_info("model-b", &config.to_models_manager_config()) + .await; + let resolved = settings + .resolve_model_info( + &models_manager, + &overrides, + config.features.enabled(Feature::Personality), + ) + .await; + assert_eq!(resolved, legacy); + assert_eq!( + resolved.get_model_instructions(settings.personality), + if explicit_instructions { + configured_instructions + } else { + catalog_instructions + }, + ); + } +} diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 8f29343d14..e9af213209 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1,6 +1,8 @@ use super::mcp_refresh::McpRefresh; +use super::step_settings::ResolvedStepSettings; use super::step_settings::StepSettings; use super::step_settings::StepSettingsUpdate; +pub(crate) use super::step_settings::tests::update_selected_settings_for_test; use super::turn_context::TurnEnvironment; use super::*; use crate::agents_md_manager::AgentsMdManager; @@ -212,16 +214,31 @@ pub(crate) fn mcp_config_for_test(config: &crate::config::Config) -> Arc) -> Arc { let environments = turn.environments.clone(); + // Unit fixtures still customize the legacy Config directly. + // Production capture instead takes the turn's current snapshot. + let mut settings = turn.initial_settings.as_ref().clone(); + update_selected_settings_for_test(&mut settings, |selected| { + selected.approval_policy = turn.config.permissions.approval_policy.clone(); + selected.approvals_reviewer = turn.config.approvals_reviewer; + }); + settings.service_tier = turn.config.service_tier.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, + settings: Arc::new(settings), session_telemetry: turn.session_telemetry.clone(), turn: Arc::clone(&turn), environments, @@ -496,7 +513,7 @@ async fn world_state_extension_metrics_follow_turn_model_switch() { .session_telemetry .clone() .with_metrics(metrics.clone()); - let next_model = if turn_context.model_info.slug == "gpt-5.4" { + let next_model = if turn_context.model_info().slug == "gpt-5.4" { "gpt-5.2" } else { "gpt-5.4" @@ -2890,8 +2907,10 @@ async fn recompute_token_usage_updates_model_context_window() { })); } - 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; + update_turn_settings_for_test(&mut turn_context, |settings| { + Arc::make_mut(&mut settings.model_info).context_window = Some(128_000); + Arc::make_mut(&mut settings.model_info).effective_context_window_percent = 100; + }); session.recompute_token_usage(&turn_context).await; @@ -3655,12 +3674,12 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { file_system_sandbox_policy: None, model: previous_model.to_string(), comp_hash: None, - personality: turn_context.personality, + personality: turn_context.personality(), collaboration_mode: Some(turn_context.collaboration_mode()), multi_agent_version: None, multi_agent_mode: None, realtime_active: Some(turn_context.realtime_active), - effort: turn_context.reasoning_effort.clone(), + effort: turn_context.reasoning_effort().cloned(), summary: codex_protocol::config_types::ReasoningSummary::Auto, }; let turn_id = previous_context_item @@ -3962,7 +3981,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context assert_eq!( sess.previous_turn_settings().await, Some(PreviousTurnSettings { - model: tc.model_info.slug.clone(), + model: tc.model_info().slug.clone(), comp_hash: None, realtime_active: Some(tc.realtime_active), }) @@ -4585,7 +4604,7 @@ async fn includes_timed_out_message() { }; let (_, turn_context) = make_session_and_context().await; - let out = format_exec_output_str(&exec, turn_context.model_info.truncation_policy.into()); + let out = format_exec_output_str(&exec, turn_context.model_info().truncation_policy.into()); assert_eq!( out, @@ -4596,7 +4615,38 @@ async fn includes_timed_out_message() { #[tokio::test] async fn turn_context_with_model_updates_model_fields() { let (session, mut turn_context) = make_session_and_context().await; - turn_context.reasoning_effort = Some(ReasoningEffortConfig::Minimal); + let config = Arc::make_mut(&mut turn_context.config); + config.features.enable(Feature::FastMode).unwrap(); + config.model_reasoning_effort = Some(ReasoningEffortConfig::Minimal); + config.model_reasoning_summary = Some(ReasoningSummaryConfig::Detailed); + update_turn_settings_for_test(&mut turn_context, |settings| { + let mut selected = settings.selected().clone(); + selected.collaboration_mode.settings.reasoning_effort = + Some(ReasoningEffortConfig::Minimal); + selected.reasoning_summary = Some(ReasoningSummaryConfig::Detailed); + selected.service_tier = Some(ServiceTier::Fast.request_value().to_string()); + *settings = ResolvedStepSettings::new( + Arc::new(selected), + Arc::clone(&settings.model_info), + /*fast_mode_enabled*/ true, + ); + }); + Arc::make_mut(&mut turn_context.config).service_tier = + turn_context.initial_settings.service_tier.clone(); + let captured = turn_context.current_settings.load_full(); + let mut current_selection = captured.selected().clone(); + current_selection.reasoning_summary = Some(ReasoningSummaryConfig::None); + current_selection.service_tier = None; + current_selection + .collaboration_mode + .settings + .reasoning_effort = Some(ReasoningEffortConfig::High); + let current = Arc::new(ResolvedStepSettings::new( + Arc::new(current_selection), + Arc::clone(turn_context.model_info()), + /*fast_mode_enabled*/ true, + )); + turn_context.current_settings.store(Arc::clone(¤t)); let updated = turn_context .with_model("gpt-5.4".to_string(), &session.services.models_manager) .await; @@ -4609,12 +4659,48 @@ async fn turn_context_with_model_updates_model_fields() { ) .await; + // Historical model selection starts from the frozen turn settings, not a + // later publication, and retains its selected summary/tier. + assert_eq!( + ( + updated.reasoning_summary(), + updated.initial_settings.service_tier.as_deref() + ), + ( + ReasoningSummaryConfig::Detailed, + Some(ServiceTier::Fast.request_value()) + ), + ); + assert_eq!( + ( + updated.config.model_reasoning_summary, + updated.config.service_tier.as_deref() + ), + ( + Some(ReasoningSummaryConfig::Detailed), + Some(ServiceTier::Fast.request_value()) + ), + ); + assert!(Arc::ptr_eq(&captured, &turn_context.initial_settings)); + assert!(Arc::ptr_eq( + ¤t, + &turn_context.current_settings.load_full() + )); + assert!(!Arc::ptr_eq(&captured, &updated.initial_settings)); + assert!(Arc::ptr_eq( + &updated.initial_settings, + &updated.current_settings.load_full() + )); + assert!(!Arc::ptr_eq( + &updated.current_settings.load_full(), + &turn_context.current_settings.load_full() + )); 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.as_ref(), &expected_model_info); + assert_eq!(updated.model_info().as_ref(), &expected_model_info); assert_eq!( - updated.reasoning_effort, - Some(ReasoningEffortConfig::Medium) + updated.reasoning_effort(), + Some(&ReasoningEffortConfig::Medium) ); assert_eq!( updated.collaboration_mode().reasoning_effort(), @@ -6251,7 +6337,11 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { session.services.shell_zsh_path.as_ref(), session.services.main_execve_wrapper_exe.as_ref(), per_turn_config, - model_info, + Arc::new(super::step_settings::ResolvedStepSettings::new( + Arc::clone(&session_configuration.step_settings), + Arc::new(model_info), + config.features.enabled(Feature::FastMode), + )), &models_manager, /*network*/ None, resolved_turn_environments, @@ -8476,7 +8566,11 @@ where session.services.shell_zsh_path.as_ref(), session.services.main_execve_wrapper_exe.as_ref(), per_turn_config, - model_info, + Arc::new(super::step_settings::ResolvedStepSettings::new( + Arc::clone(&session_configuration.step_settings), + Arc::new(model_info), + config.features.enabled(Feature::FastMode), + )), &models_manager, /*network*/ None, resolved_turn_environments, @@ -9137,7 +9231,7 @@ async fn record_context_updates_emits_environment_item_for_network_changes() { let previous_context = Arc::new(previous_context); let mut current_context = previous_context .with_model( - previous_context.model_info.slug.clone(), + previous_context.model_info().slug.clone(), &session.services.models_manager, ) .await; @@ -9193,7 +9287,7 @@ async fn record_context_updates_emits_environment_item_for_cwd_changes() { let previous_context = Arc::new(previous_context); let mut current_context = previous_context .with_model( - previous_context.model_info.slug.clone(), + previous_context.model_info().slug.clone(), &session.services.models_manager, ) .await; @@ -9250,7 +9344,7 @@ async fn record_context_updates_use_environment_permission_profile_and_workspace let previous_context = Arc::new(previous_context); let mut current_context = previous_context .with_model( - previous_context.model_info.slug.clone(), + previous_context.model_info().slug.clone(), &session.services.models_manager, ) .await; @@ -9304,7 +9398,7 @@ async fn record_context_updates_emits_environment_item_for_time_changes() { let previous_context = Arc::new(previous_context); let mut current_context = previous_context .with_model( - previous_context.model_info.slug.clone(), + previous_context.model_info().slug.clone(), &session.services.models_manager, ) .await; @@ -9328,7 +9422,7 @@ async fn record_context_updates_omits_environment_item_when_disabled() { let previous_context = Arc::new(previous_context); let mut current_context = previous_context .with_model( - previous_context.model_info.slug.clone(), + previous_context.model_info().slug.clone(), &session.services.models_manager, ) .await; @@ -9393,7 +9487,7 @@ async fn record_context_updates_emits_realtime_start_when_session_becomes_live() let previous_context = Arc::new(previous_context); let mut current_context = previous_context .with_model( - previous_context.model_info.slug.clone(), + previous_context.model_info().slug.clone(), &session.services.models_manager, ) .await; @@ -9417,7 +9511,7 @@ async fn record_context_updates_emits_realtime_end_when_session_stops_being_live previous_context.realtime_active = true; let mut current_context = previous_context .with_model( - previous_context.model_info.slug.clone(), + previous_context.model_info().slug.clone(), &session.services.models_manager, ) .await; @@ -9561,8 +9655,10 @@ 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()); - 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; + update_turn_settings_for_test(&mut turn_context, |settings| { + Arc::make_mut(&mut settings.model_info).context_window = Some(100); + Arc::make_mut(&mut settings.model_info).effective_context_window_percent = 50; + }); turn_context .extension_data .insert(TurnContextExtensionTestState { @@ -9588,8 +9684,10 @@ 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()); - 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; + update_turn_settings_for_test(&mut turn_context, |settings| { + Arc::make_mut(&mut settings.model_info).context_window = Some(200); + Arc::make_mut(&mut settings.model_info).effective_context_window_percent = 25; + }); turn_context .extension_data .insert(TurnContextExtensionTestState { @@ -9770,7 +9868,7 @@ async fn build_initial_context_restates_realtime_start_when_reference_context_is let (session, mut turn_context) = make_session_and_context().await; turn_context.realtime_active = true; let previous_turn_settings = PreviousTurnSettings { - model: turn_context.model_info.slug.clone(), + model: turn_context.model_info().slug.clone(), comp_hash: None, realtime_active: Some(true), }; @@ -10121,7 +10219,7 @@ async fn build_initial_context_prepends_model_switch_message() { async fn record_context_updates_and_set_reference_context_item_persists_full_reinjection_to_rollout() { let (mut session, previous_context) = make_session_and_context().await; - let next_model = if previous_context.model_info.slug == "gpt-5.4" { + let next_model = if previous_context.model_info().slug == "gpt-5.4" { "gpt-5.2" } else { "gpt-5.4" @@ -10150,7 +10248,7 @@ async fn record_context_updates_and_set_reference_context_item_persists_full_rei session .set_previous_turn_settings(Some(PreviousTurnSettings { - model: previous_context.model_info.slug.clone(), + model: previous_context.model_info().slug.clone(), comp_hash: None, realtime_active: Some(previous_context.realtime_active), })) @@ -11402,10 +11500,10 @@ async fn sample_rollout( && content.iter().any(|c| { matches!(c, ContentItem::InputText { text } if text.contains("")) })) - }) && let Some(p) = reconstruction_turn.personality + }) && let Some(p) = reconstruction_turn.personality() && session.features.enabled(Feature::Personality) && let Some(personality_message) = reconstruction_turn - .model_info + .model_info() .model_messages .as_ref() .and_then(|m| m.get_personality_message(Some(p)).filter(|s| !s.is_empty())) @@ -11425,20 +11523,20 @@ async fn sample_rollout( } live_history.record_items( initial_context.iter(), - reconstruction_turn.model_info.truncation_policy.into(), + reconstruction_turn.model_info().truncation_policy.into(), ); let user1 = user_message("first user"); live_history.record_items( std::iter::once(&user1), - reconstruction_turn.model_info.truncation_policy.into(), + reconstruction_turn.model_info().truncation_policy.into(), ); rollout_items.push(RolloutItem::ResponseItem(user1.clone().into())); let assistant1 = assistant_message("assistant reply one"); live_history.record_items( std::iter::once(&assistant1), - reconstruction_turn.model_info.truncation_policy.into(), + reconstruction_turn.model_info().truncation_policy.into(), ); rollout_items.push(RolloutItem::ResponseItem(assistant1.clone().into())); @@ -11461,14 +11559,14 @@ async fn sample_rollout( let user2 = user_message("second user"); live_history.record_items( std::iter::once(&user2), - reconstruction_turn.model_info.truncation_policy.into(), + reconstruction_turn.model_info().truncation_policy.into(), ); rollout_items.push(RolloutItem::ResponseItem(user2.clone().into())); let assistant2 = assistant_message("assistant reply two"); live_history.record_items( std::iter::once(&assistant2), - reconstruction_turn.model_info.truncation_policy.into(), + reconstruction_turn.model_info().truncation_policy.into(), ); rollout_items.push(RolloutItem::ResponseItem(assistant2.clone().into())); @@ -11491,14 +11589,14 @@ async fn sample_rollout( let user3 = user_message("third user"); live_history.record_items( std::iter::once(&user3), - reconstruction_turn.model_info.truncation_policy.into(), + reconstruction_turn.model_info().truncation_policy.into(), ); rollout_items.push(RolloutItem::ResponseItem(user3.into())); let assistant3 = assistant_message("assistant reply three"); live_history.record_items( std::iter::once(&assistant3), - reconstruction_turn.model_info.truncation_policy.into(), + reconstruction_turn.model_info().truncation_policy.into(), ); rollout_items.push(RolloutItem::ResponseItem(assistant3.into())); diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index c982a0a128..1371f55915 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -5,6 +5,7 @@ use crate::guardian::GUARDIAN_REVIEWER_NAME; use crate::plugins::plugins_manager_for_config; use crate::sandboxing::SandboxPermissions; use crate::session::step_context::StepContext; +use crate::session::tests::update_turn_settings_for_test; use crate::test_support::models_manager_with_provider; use crate::tools::context::ToolCallSource; use crate::tools::context::ToolOutput; @@ -97,7 +98,9 @@ async fn request_permissions_routes_to_guardian_when_reviewer_is_enabled() { .await; let (mut session, mut turn_context_raw) = make_session_and_context().await; - Arc::make_mut(&mut turn_context_raw.model_info).node_repl_auto_review_required = true; + update_turn_settings_for_test(&mut turn_context_raw, |settings| { + Arc::make_mut(&mut settings.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 850166360d..05b67cbe86 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -272,8 +272,8 @@ pub(crate) async fn run_turn( sess.merge_connector_selection(explicitly_enabled_connectors.clone()) .await; sess.set_previous_turn_settings(Some(PreviousTurnSettings { - model: turn_context.model_info.slug.clone(), - comp_hash: turn_context.model_info.comp_hash.clone(), + model: turn_context.model_info().slug.clone(), + comp_hash: turn_context.model_info().comp_hash.clone(), realtime_active: Some(turn_context.realtime_active), })) .await; @@ -372,7 +372,7 @@ pub(crate) async fn run_turn( let sampling_request_input: Vec = async { sess.clone_history() .await - .for_prompt(&step_context.model_info.input_modalities) + .for_prompt(&step_context.settings.model_info.input_modalities) } .instrument(trace_span!("run_turn.prepare_sampling_request_input")) .await; @@ -782,7 +782,7 @@ async fn build_skills_and_plugins( } let tracking = build_track_events_context( - turn_context.model_info.slug.clone(), + turn_context.model_info().slug.clone(), sess.thread_id.to_string(), turn_context.sub_id.clone(), turn_context.originator.clone(), @@ -999,13 +999,13 @@ async fn track_turn_resolved_config_analytics( submission_type: None, ephemeral: thread_config.ephemeral, session_source: thread_config.session_source, - model: turn_context.model_info.slug.clone(), + model: turn_context.model_info().slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), permission_profile: turn_context.permission_profile(), #[allow(deprecated)] permission_profile_cwd: turn_context.cwd.to_path_buf(), - reasoning_effort: turn_context.reasoning_effort.clone(), - reasoning_summary: Some(turn_context.reasoning_summary), + reasoning_effort: turn_context.reasoning_effort().cloned(), + reasoning_summary: Some(turn_context.reasoning_summary()), service_tier: turn_context .config .service_tier @@ -1014,8 +1014,8 @@ async fn track_turn_resolved_config_analytics( approval_policy: turn_context.approval_policy(), approvals_reviewer: turn_context.config.approvals_reviewer, sandbox_network_access: turn_context.network_sandbox_policy().is_enabled(), - collaboration_mode: turn_context.mode, - personality: turn_context.personality, + collaboration_mode: turn_context.mode(), + personality: turn_context.personality(), workspace_kind: turn_context.turn_metadata_state.workspace_kind(), is_first_turn, }); @@ -1077,7 +1077,7 @@ async fn capture_current_model_fallback_step_context( .is_some_and(codex_login::AuthManager::current_auth_uses_codex_backend); if !uses_codex_backend || !turn_context.provider.info().is_openai() - || previous_model == turn_context.model_info.slug + || previous_model == turn_context.model_info().slug { return Ok(None); } @@ -1101,7 +1101,7 @@ async fn maybe_run_previous_model_inline_compact( }; let should_compact_for_comp_hash_change = comp_hash_changed( previous_turn_settings.comp_hash.as_deref(), - turn_context.model_info.comp_hash.as_deref(), + turn_context.model_info().comp_hash.as_deref(), ); let previous_model = previous_turn_settings.model; let previous_model_turn_context = Arc::new( @@ -1147,7 +1147,7 @@ async fn maybe_run_previous_model_inline_compact( { AutoCompactTokenLimitScope::Total => { let new_auto_compact_limit = turn_context - .model_info + .model_info() .auto_compact_token_limit() .unwrap_or(i64::MAX); active_context_tokens > new_auto_compact_limit @@ -1156,7 +1156,7 @@ async fn maybe_run_previous_model_inline_compact( AutoCompactTokenLimitScope::BodyAfterPrefix => active_context_tokens >= new_context_window, }; let should_run = previous_model_limit_reached - && previous_model_turn_context.model_info.slug != turn_context.model_info.slug + && previous_model_turn_context.model_info().slug != turn_context.model_info().slug && old_context_window > new_context_window; if should_run { let step_context = sess @@ -1346,7 +1346,7 @@ pub(crate) fn build_prompt( skip_all, fields( turn_id = %step_context.turn.sub_id, - model = %step_context.model_info.slug, + model = %step_context.settings.model_info.slug, cwd = %step_context.turn.cwd.display() ) )] @@ -1384,7 +1384,7 @@ async fn run_sampling_request( } else { sess.clone_history() .await - .for_prompt(&step_context.model_info.input_modalities) + .for_prompt(&step_context.settings.model_info.input_modalities) }; let mut prompt_input = prompt_input; if let Some(executed_tool_calls) = sess.services.executed_tool_calls.as_ref() @@ -1500,7 +1500,7 @@ pub(crate) async fn prepare_tool_recommendations( skip_all, fields( turn_id = %turn_context.sub_id, - model = %turn_context.model_info.slug, + model = %turn_context.model_info().slug, apps_enabled = turn_context.apps_enabled() ) )] @@ -2186,7 +2186,7 @@ fn assign_missing_streamed_response_item_id( skip_all, fields( turn_id = %step_context.turn.sub_id, - model = %step_context.model_info.slug + model = %step_context.settings.model_info.slug ) )] async fn try_run_sampling_request( @@ -2202,16 +2202,16 @@ async fn try_run_sampling_request( ) -> CodexResult { let turn_context = Arc::clone(&step_context.turn); feedback_tags!( - model = step_context.model_info.slug.clone(), + model = step_context.settings.model_info.slug.clone(), approval_policy = turn_context.approval_policy(), sandbox_policy = &turn_context.sandbox_policy(), - effort = step_context.reasoning_effort, + effort = step_context.settings.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(), - step_context.model_info.slug.as_str(), + step_context.settings.model_info.slug.as_str(), turn_context.provider.info().name.as_str(), ); let sampling_timing_guard = turn_context.turn_timing_state.begin_sampling(); @@ -2223,11 +2223,11 @@ async fn try_run_sampling_request( let mut stream = client_session .stream( prompt, - &step_context.model_info, + &step_context.settings.model_info, &step_context.session_telemetry, - step_context.reasoning_effort.clone(), - step_context.reasoning_summary, - step_context.service_tier.clone(), + step_context.settings.reasoning_effort().cloned(), + step_context.settings.reasoning_summary, + step_context.settings.service_tier.clone(), responses_metadata, &inference_trace, ) @@ -2248,12 +2248,16 @@ async fn try_run_sampling_request( const MAX_ANALYTICS_TOOL_CALL_IDS_PER_RESPONSE: usize = 256; let mut analytics_tool_call_ids = Vec::new(); let reasoning_effort = step_context - .reasoning_effort - .clone() - .or_else(|| step_context.model_info.default_reasoning_level.clone()) - .map(|effort| effort.to_string()) + .settings + .reasoning_effort() + .or(step_context + .settings + .model_info + .default_reasoning_level + .as_ref()) + .map(std::string::ToString::to_string) .unwrap_or_else(|| "default".to_string()); - let plan_mode = turn_context.mode == ModeKind::Plan; + 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)); let defer_streamed_turn_items_for_contributors = @@ -2499,7 +2503,7 @@ async fn try_run_sampling_request( .server_model_warning_emitted .load(Ordering::Relaxed) && sess - .maybe_warn_on_server_model_mismatch(&turn_context, server_model) + .maybe_warn_on_server_model_mismatch(&step_context, server_model) .await { turn_context @@ -2524,7 +2528,7 @@ async fn try_run_sampling_request( sess.send_event( &turn_context, EventMsg::SafetyBuffering(SafetyBufferingEvent { - model: turn_context.model_info.slug.clone(), + model: step_context.settings.model_info.slug.clone(), use_cases: buffering.use_cases, reasons: buffering.reasons, show_buffering_ui: buffering.show_buffering_ui, diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 5041644280..7782e070d0 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -1,9 +1,11 @@ +use super::step_settings::ResolvedStepSettings; use super::*; use crate::environment_selection::EnvironmentConfigOrigin; use crate::environment_selection::TurnEnvironmentSnapshot; use crate::exec_policy::AllowPrefixRules; use crate::shell_snapshot::ShellSnapshotFile; use crate::tools::sandboxing::executor_windows_sandbox_level; +use arc_swap::ArcSwap; use codex_core_plugins::PluginCommandAttribution; use codex_core_plugins::ResolvedPluginMetricsOperation; use codex_core_plugins::TrustedPluginRoots; @@ -174,18 +176,17 @@ pub struct TurnContext { 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. + /// approvals reviewer from the corresponding `StepContext` instead. pub config: Arc, pub(crate) auth_manager: Option>, - /// Legacy turn model; step-scoped execution should use `StepContext::model_info`. - pub(crate) model_info: Arc, + /// Frozen settings used to construct this context. Legacy turn consumers + /// keep this view even when later steps use different settings. + pub(crate) initial_settings: Arc, + /// Snapshot for the next step; request consumers use their captured StepContext. + pub(super) current_settings: ArcSwap, /// Turn-wide telemetry; model-attributed step work should use `StepContext::session_telemetry`. 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, pub(crate) parent_thread_id: Option, @@ -200,10 +201,7 @@ pub struct TurnContext { pub(crate) timezone: Option, pub(crate) app_server_client_name: Option, pub(crate) developer_instructions: Option, - pub(crate) mode: ModeKind, - pub(crate) collaboration_mode_developer_instructions: Option, pub(crate) multi_agent_version: MultiAgentVersion, - pub(crate) personality: Option, pub(crate) network: Option, // TODO(anp): Reconcile this parallel turn snapshot with TurnEnvironment::sandbox_context // so owner-provided environment settings govern the remaining sandbox decisions. @@ -226,6 +224,46 @@ enum TurnMultiAgentRuntime { } impl TurnContext { + /// Legacy: returns the frozen initial-turn model metadata. + /// Step-scoped consumers should use their captured `StepContext::settings`. + pub(crate) fn model_info(&self) -> &Arc { + &self.initial_settings.model_info + } + + /// Legacy: returns the frozen initial-turn reasoning-effort selection. + /// Step-scoped consumers should use their captured `StepContext::settings`. + pub(crate) fn reasoning_effort(&self) -> Option<&ReasoningEffortConfig> { + self.initial_settings.reasoning_effort() + } + + /// Legacy: returns the frozen initial-turn effective reasoning summary. + /// Step-scoped consumers should use their captured `StepContext::settings`. + pub(crate) fn reasoning_summary(&self) -> ReasoningSummaryConfig { + self.initial_settings.reasoning_summary + } + + /// Legacy: returns the frozen initial-turn collaboration mode kind. + /// Step-scoped consumers should use their captured `StepContext::settings`. + pub(crate) fn mode(&self) -> ModeKind { + self.initial_settings.selected_collaboration_mode().mode + } + + /// Legacy: returns the frozen initial-turn personality. + /// Step-scoped consumers should use their captured `StepContext::settings`. + pub(crate) fn personality(&self) -> Option { + self.initial_settings.personality() + } + + /// Legacy: returns the frozen initial-turn collaboration-mode developer instructions. + /// Step-scoped consumers should use their captured `StepContext::settings`. + pub(crate) fn collaboration_mode_developer_instructions(&self) -> &Option { + &self + .initial_settings + .selected_collaboration_mode() + .settings + .developer_instructions + } + pub(crate) fn skills_snapshot(&self) -> Arc { let Some(snapshot) = self.extension_data.get::() else { unreachable!("every turn has a host skills snapshot"); @@ -233,13 +271,15 @@ impl TurnContext { snapshot } + /// Legacy: returns the frozen initial-turn collaboration mode with the resolved model slug. + /// Step-scoped consumers should use their captured `StepContext::settings`. pub(crate) fn collaboration_mode(&self) -> CollaborationMode { CollaborationMode { - mode: self.mode, + mode: self.mode(), settings: Settings { - model: self.model_info.slug.clone(), - reasoning_effort: self.reasoning_effort.clone(), - developer_instructions: self.collaboration_mode_developer_instructions.clone(), + model: self.model_info().slug.clone(), + reasoning_effort: self.reasoning_effort().cloned(), + developer_instructions: self.collaboration_mode_developer_instructions().clone(), }, } } @@ -266,12 +306,14 @@ impl TurnContext { .await } - /// Approval policy admitted with the turn; step-scoped actions should use - /// `StepContext::approval_policy` instead. + /// Legacy: returns the frozen initial-turn approval policy. + /// Step-scoped consumers should use their captured `StepContext::settings`. pub(crate) fn approval_policy(&self) -> AskForApproval { self.config.permissions.approval_policy.value() } + /// Legacy: returns the frozen initial-turn prefix-rule policy. + /// Step-scoped consumers should use their captured `StepContext::settings`. pub(crate) fn allow_prefix_rules(&self) -> AllowPrefixRules { let ignore_rules = self .config @@ -280,8 +322,9 @@ impl TurnContext { .auto_review .as_ref() .and_then(|auto_review| auto_review.ignore_rules.as_ref()) - .is_some_and(|models| models.contains(&self.model_info.slug)); - if self.model_info.model_specialty.as_deref() == Some(MODEL_SPECIALTY_CYBER) || ignore_rules + .is_some_and(|models| models.contains(&self.model_info().slug)); + if self.model_info().model_specialty.as_deref() == Some(MODEL_SPECIALTY_CYBER) + || ignore_rules { AllowPrefixRules::IgnoreForCyberModel } else { @@ -354,21 +397,27 @@ impl TurnContext { workspace_roots } + /// Legacy: returns the frozen initial-turn reasoning effort, including the initial model default. + /// Step-scoped consumers should use their captured `StepContext::settings`. pub(crate) fn effective_reasoning_effort(&self) -> Option { - self.reasoning_effort - .clone() - .or_else(|| self.model_info.default_reasoning_level.clone()) + self.reasoning_effort() + .or(self.model_info().default_reasoning_level.as_ref()) + .cloned() } + /// Legacy: returns the frozen initial-turn reasoning-effort label for tracing. + /// Step-scoped consumers should use their captured `StepContext::settings`. pub(crate) fn effective_reasoning_effort_for_tracing(&self) -> String { self.effective_reasoning_effort() .map(|effort| effort.to_string()) .unwrap_or_else(|| "default".to_string()) } + /// Legacy: returns the frozen initial-turn model context window. + /// Step-scoped consumers should use their captured `StepContext::settings`. pub(crate) fn model_context_window(&self) -> Option { - let effective_context_window_percent = self.model_info.effective_context_window_percent; - self.model_info + let effective_context_window_percent = self.model_info().effective_context_window_percent; + self.model_info() .resolved_context_window() .map(|context_window| { context_window.saturating_mul(effective_context_window_percent) / 100 @@ -401,10 +450,9 @@ impl TurnContext { .iter() .map(|preset| preset.effort.clone()) .collect::>(); - let reasoning_effort = if let Some(current_reasoning_effort) = self.reasoning_effort.clone() - { - if supported_reasoning_levels.contains(¤t_reasoning_effort) { - Some(current_reasoning_effort) + let reasoning_effort = if let Some(current_reasoning_effort) = self.reasoning_effort() { + if supported_reasoning_levels.contains(current_reasoning_effort) { + Some(current_reasoning_effort.clone()) } else { supported_reasoning_levels .get(supported_reasoning_levels.len().saturating_sub(1) / 2) @@ -426,6 +474,19 @@ impl TurnContext { ) .await; let model_info = Arc::new(model_info); + let mut selected = self.initial_settings.selected().clone(); + selected.collaboration_mode = selected.collaboration_mode.with_updates( + Some(model), + Some(reasoning_effort), + /*developer_instructions*/ None, + ); + let step_settings = Arc::new(ResolvedStepSettings::new( + Arc::new(selected), + model_info, + config.features.enabled(Feature::FastMode), + )); + config.service_tier = step_settings.service_tier.clone(); + let session_telemetry = step_settings.telemetry(&self.session_telemetry); Self { sub_id: self.sub_id.clone(), @@ -434,14 +495,10 @@ impl TurnContext { code_mode_available: self.code_mode_available, config: Arc::new(config), auth_manager: self.auth_manager.clone(), - model_info: Arc::clone(&model_info), - session_telemetry: self - .session_telemetry - .clone() - .with_model(model.as_str(), model_info.slug.as_str()), + initial_settings: Arc::clone(&step_settings), + current_settings: ArcSwap::from(step_settings), + session_telemetry, provider: self.provider.clone(), - reasoning_effort, - reasoning_summary: self.reasoning_summary, session_source: self.session_source.clone(), history_mode: self.history_mode, parent_thread_id: self.parent_thread_id, @@ -453,12 +510,7 @@ impl TurnContext { timezone: self.timezone.clone(), app_server_client_name: self.app_server_client_name.clone(), developer_instructions: self.developer_instructions.clone(), - mode: self.mode, - collaboration_mode_developer_instructions: self - .collaboration_mode_developer_instructions - .clone(), multi_agent_version: self.multi_agent_version, - personality: self.personality, network: self.network.clone(), windows_sandbox_level: self.windows_sandbox_level, available_models, @@ -517,14 +569,14 @@ impl TurnContext { ), network: self.turn_context_network_item(), file_system_sandbox_policy: self.non_legacy_file_system_sandbox_policy(), - model: self.model_info.slug.clone(), - comp_hash: self.model_info.comp_hash.clone(), - personality: self.personality, + model: self.model_info().slug.clone(), + comp_hash: self.model_info().comp_hash.clone(), + personality: self.personality(), collaboration_mode: Some(self.collaboration_mode()), multi_agent_version: Some(self.multi_agent_version), multi_agent_mode: None, realtime_active: Some(self.realtime_active), - effort: self.reasoning_effort.clone(), + effort: self.reasoning_effort().cloned(), summary: ReasoningSummaryConfig::Auto, } } @@ -642,7 +694,7 @@ impl Session { shell_zsh_path: Option<&PathBuf>, main_execve_wrapper_exe: Option<&PathBuf>, per_turn_config: Config, - model_info: ModelInfo, + step_settings: Arc, models_manager: &SharedModelsManager, network: Option, environments: TurnEnvironmentSnapshot, @@ -650,21 +702,9 @@ impl Session { sub_id: String, skills_snapshot: HostSkillsSnapshot, ) -> TurnContext { - let collaboration_mode = &session_configuration.step_settings.collaboration_mode; - let reasoning_effort = collaboration_mode.reasoning_effort(); - let reasoning_summary = session_configuration - .step_settings - .reasoning_summary - .unwrap_or(model_info.default_reasoning_summary); - let session_telemetry = session_telemetry.clone().with_model( - session_configuration - .step_settings - .collaboration_mode - .model(), - model_info.slug.as_str(), - ); + let model_info = &step_settings.model_info; + let session_telemetry_for_context = step_settings.telemetry(session_telemetry); let session_source = session_configuration.session_source.clone(); - let session_telemetry_for_context = session_telemetry; let available_models = models_manager.try_list_models().unwrap_or_default(); let unified_exec_shell_mode = UnifiedExecShellMode::for_session( per_turn_config.features.get(), @@ -674,12 +714,8 @@ impl Session { ); let mut per_turn_config = per_turn_config; - super::token_budget::apply_model_defaults(&mut per_turn_config, &model_info); - per_turn_config.service_tier = get_service_tier( - per_turn_config.service_tier, - per_turn_config.features.enabled(Feature::FastMode), - &model_info, - ); + super::token_budget::apply_model_defaults(&mut per_turn_config, model_info); + per_turn_config.service_tier = step_settings.service_tier.clone(); let permission_profile = environments.permission_profile_or_else(|| { per_turn_config.permissions.effective_permission_profile() }); @@ -701,7 +737,7 @@ impl Session { session_configuration.windows_sandbox_level, network.is_some(), auto_review_enabled, - &model_info, + model_info, )); turn_metadata_state .set_responses_api_metadata(per_turn_config.responses_api_metadata.clone()); @@ -715,11 +751,10 @@ impl Session { code_mode_available: true, config: per_turn_config, auth_manager, - model_info: Arc::new(model_info), + initial_settings: Arc::clone(&step_settings), + current_settings: ArcSwap::from(step_settings), session_telemetry: session_telemetry_for_context, provider, - reasoning_effort, - reasoning_summary, session_source, history_mode: session_configuration.history_mode, parent_thread_id: session_configuration.parent_thread_id, @@ -731,13 +766,7 @@ impl Session { timezone: Some(timezone), app_server_client_name: session_configuration.app_server_client_name.clone(), developer_instructions: session_configuration.developer_instructions.clone(), - mode: collaboration_mode.mode, - collaboration_mode_developer_instructions: collaboration_mode - .settings - .developer_instructions - .clone(), multi_agent_version, - personality: session_configuration.step_settings.personality, network, windows_sandbox_level: session_configuration.windows_sandbox_level, available_models, @@ -758,13 +787,13 @@ impl Session { sub_id: String, updates: SessionSettingsUpdate, ) -> CodexResult<(Arc, ThreadSettingsSnapshot)> { - let Some(turn) = self + let Some((turn_context, snapshot)) = self .new_turn_with_sub_id_if(sub_id, updates, |_, _| true) .await? else { unreachable!("unconditional turn construction must accept valid settings"); }; - Ok(turn) + Ok((turn_context, snapshot)) } /// Commits accepted settings atomically, then constructs the turn without holding @@ -808,7 +837,7 @@ impl Session { Ok(Some((turn_context, commit.snapshot))) } - /// Builds a turn from the exact committed settings without starting a task. + /// Constructs a turn from the exact committed settings without starting a task. async fn new_turn_from_configuration( &self, sub_id: String, @@ -862,20 +891,12 @@ impl Session { .map(TurnEnvironment::permission_profile) .cloned() .unwrap_or_else(|| session_configuration.permission_profile()); - let model_info = self - .services - .models_manager - .get_model_info( - session_configuration - .step_settings - .collaboration_mode - .model(), - &session_configuration - .model_info_overrides - .models_manager_config( - session_configuration.step_settings.personality, - self.features.enabled(Feature::Personality), - ), + let model_info = session_configuration + .step_settings + .resolve_model_info( + self.services.models_manager.as_ref(), + &session_configuration.model_info_overrides, + self.features.enabled(Feature::Personality), ) .await; self.services @@ -924,6 +945,11 @@ impl Session { .snapshot_for_config(&skills_input, fs) .await }; + let step_settings = Arc::new(ResolvedStepSettings::new( + Arc::clone(&session_configuration.step_settings), + Arc::new(model_info), + self.features.enabled(Feature::FastMode), + )); let mut turn_context: TurnContext = Self::make_turn_context( self.thread_id(), self.session_id(), @@ -936,7 +962,7 @@ impl Session { self.services.shell_zsh_path.as_ref(), self.services.main_execve_wrapper_exe.as_ref(), per_turn_config, - model_info, + step_settings, &self.services.models_manager, self.services .network_proxy @@ -973,13 +999,13 @@ impl Session { } pub(crate) async fn maybe_emit_model_warnings_for_turn(&self, tc: &TurnContext) { - if tc.model_info.used_fallback_model_metadata { + if tc.model_info().used_fallback_model_metadata { self.send_event( tc, EventMsg::Warning(WarningEvent { message: format!( "Model metadata for `{}` not found. Defaulting to fallback metadata; this can degrade performance and cause issues.", - tc.model_info.slug + tc.model_info().slug ), }), ) @@ -1002,7 +1028,7 @@ impl Session { } if let Some(message) = - unsupported_code_mode_warning(&tc.model_info, tc.config.features.get()) + unsupported_code_mode_warning(tc.model_info(), tc.config.features.get()) { self.send_event(tc, EventMsg::Warning(WarningEvent { message })) .await; diff --git a/codex-rs/core/src/session/turn_input.rs b/codex-rs/core/src/session/turn_input.rs index 08ca3886bd..f0a34e2a27 100644 --- a/codex-rs/core/src/session/turn_input.rs +++ b/codex-rs/core/src/session/turn_input.rs @@ -130,7 +130,7 @@ impl PreparedTurnInputSettings { updates.final_output_json_schema = Some(final_output_json_schema); updates.service_tier_for_turn = service_tier; - let turn = match kind { + let turn_context = match kind { TurnStartKind::User | TurnStartKind::Recovery => Some( session .new_turn_with_sub_id(submission_id.clone(), updates) @@ -144,7 +144,7 @@ impl PreparedTurnInputSettings { .await? } }; - let Some((turn_context, settings_snapshot)) = turn else { + let Some((turn_context, settings_snapshot)) = turn_context else { return Ok(None); }; if emit_thread_settings_applied { diff --git a/codex-rs/core/src/session/turn_input_tests.rs b/codex-rs/core/src/session/turn_input_tests.rs index 06c1acf343..da374e176d 100644 --- a/codex-rs/core/src/session/turn_input_tests.rs +++ b/codex-rs/core/src/session/turn_input_tests.rs @@ -2,6 +2,7 @@ use super::*; use crate::config::Constrained; use crate::session::step_settings::StepSettingsUpdate; use crate::session::tests::make_session_and_context_with_rx; +use crate::session::turn_context::TurnContext; use crate::state::TaskKind; use crate::tasks::SessionTask; use crate::tasks::SessionTaskResult; @@ -126,7 +127,7 @@ async fn accepted_input_applies_thread_settings() { collaboration_mode: Some(CollaborationMode { mode: ModeKind::Default, settings: Settings { - model: turn_context.model_info.slug.clone(), + model: turn_context.model_info().slug.clone(), reasoning_effort: config.model_reasoning_effort.clone(), developer_instructions: None, }, @@ -382,6 +383,14 @@ async fn prepared_user_updates_merge_with_settings_at_turn_start() { ); assert_eq!(session.collaboration_mode().await, expected); assert_eq!(turn_context.collaboration_mode(), expected); + assert_eq!( + turn_context.initial_settings.selected_collaboration_mode(), + &expected + ); + assert!(Arc::ptr_eq( + &turn_context.initial_settings.model_info, + turn_context.model_info(), + )); } } @@ -432,6 +441,10 @@ async fn automatic_admission_uses_current_candidate_after_plan_preview() { ); assert_eq!(session.collaboration_mode().await, expected); assert_eq!(turn_context.collaboration_mode(), expected); + assert_eq!( + turn_context.initial_settings.selected_collaboration_mode(), + &expected + ); } #[tokio::test] diff --git a/codex-rs/core/src/session/world_state.rs b/codex-rs/core/src/session/world_state.rs index 2baaf8768d..ca21831c1e 100644 --- a/codex-rs/core/src/session/world_state.rs +++ b/codex-rs/core/src/session/world_state.rs @@ -41,8 +41,8 @@ impl Session { "building step world state" ); let model_instructions = turn_context - .model_info - .get_model_instructions(turn_context.personality); + .model_info() + .get_model_instructions(turn_context.personality()); let (previous_model, previous_context, base_instructions) = { let state = self.state.lock().await; let base_instructions = state.session_configuration.base_instructions.clone(); @@ -65,7 +65,7 @@ impl Session { base_instructions, ) }; - let personality_is_baked = turn_context.model_info.supports_personality() + let personality_is_baked = turn_context.model_info().supports_personality() && base_instructions == model_instructions; let environment_subagents = if turn_context.config.include_environment_context { self.services @@ -77,22 +77,22 @@ impl Session { }; let mut world_state = WorldState::default(); world_state.add_section(ModelInstructionsState::new( - &turn_context.model_info.slug, + &turn_context.model_info().slug, previous_model.as_deref(), model_instructions, )); if self.features.enabled(Feature::Personality) { - let personality_instructions = turn_context.personality.and_then(|personality| { + let personality_instructions = turn_context.personality().and_then(|personality| { turn_context - .model_info + .model_info() .model_messages .as_ref() .and_then(|messages| messages.get_personality_message(Some(personality))) .filter(|message| !message.is_empty()) }); world_state.add_section(PersonalityState::new( - &turn_context.model_info.slug, - turn_context.personality, + &turn_context.model_info().slug, + turn_context.personality(), previous_context .as_ref() .map(|previous| previous.model.as_str()) @@ -156,12 +156,12 @@ impl Session { let cwd = environment .and_then(|environment| environment.cwd().to_abs_path().ok()) .unwrap_or_else(|| turn_context.cwd.clone()); - let model_messages = turn_context.model_info.model_messages.as_ref(); + let model_messages = turn_context.model_info().model_messages.as_ref(); world_state.add_section(PermissionsState::new( &permission_profile, - step_context.approval_policy, + step_context.settings.approval_policy(), ApprovalPromptContext::new( - step_context.approvals_reviewer, + step_context.settings.approvals_reviewer(), model_messages.and_then(|messages| messages.approvals.as_ref()), model_messages.and_then(|messages| messages.permissions.as_ref()), ), @@ -183,7 +183,7 @@ impl Session { world_state.add_section(CollaborationModeState::from_collaboration_mode( &turn_context.collaboration_mode(), turn_context - .model_info + .model_info() .model_messages .as_ref() .and_then(|messages| messages.collaboration_modes.as_ref()), @@ -227,12 +227,12 @@ impl Session { false }; let apps_usage_instructions_available = - apps_available && turn_context.model_info.include_apps_usage_instructions; + apps_available && turn_context.model_info().include_apps_usage_instructions; world_state.add_section(AppsInstructionsState::new( apps_usage_instructions_available, )); let plugins_usage_instructions_available = step_context.mcp.plugins_available() - && turn_context.model_info.include_plugin_usage_instructions; + && turn_context.model_info().include_plugin_usage_instructions; world_state.add_section(PluginsInstructionsState::new( plugins_usage_instructions_available, )); diff --git a/codex-rs/core/src/session_startup_prewarm.rs b/codex-rs/core/src/session_startup_prewarm.rs index 4d63cc8c77..4ae1af8b85 100644 --- a/codex-rs/core/src/session_startup_prewarm.rs +++ b/codex-rs/core/src/session_startup_prewarm.rs @@ -314,11 +314,11 @@ async fn schedule_startup_prewarm_inner( client_session .prewarm_websocket( &startup_prompt, - &step_context.model_info, + &step_context.settings.model_info, &step_context.session_telemetry, - step_context.reasoning_effort.clone(), - step_context.reasoning_summary, - step_context.service_tier.clone(), + step_context.settings.reasoning_effort().cloned(), + step_context.settings.reasoning_summary, + step_context.settings.service_tier.clone(), &responses_metadata, ) .await?; diff --git a/codex-rs/core/src/skills.rs b/codex-rs/core/src/skills.rs index abe8f46674..4d35dd403a 100644 --- a/codex-rs/core/src/skills.rs +++ b/codex-rs/core/src/skills.rs @@ -150,7 +150,7 @@ pub(crate) async fn maybe_emit_implicit_skill_invocation( .analytics_events_client .track_skill_invocations( build_track_events_context( - turn_context.model_info.slug.clone(), + turn_context.model_info().slug.clone(), sess.thread_id.to_string(), turn_context.sub_id.clone(), turn_context.originator.clone(), diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs index 53afbf1d7e..5445313656 100644 --- a/codex-rs/core/src/stream_events_utils.rs +++ b/codex-rs/core/src/stream_events_utils.rs @@ -100,7 +100,7 @@ pub(crate) async fn record_completed_response_item_with_finalized_facts( || { completed_item_defers_mailbox_delivery_to_next_turn( item, - turn_context.mode == ModeKind::Plan, + turn_context.mode() == ModeKind::Plan, ) }, |facts| facts.defers_mailbox_delivery_to_next_turn, @@ -292,7 +292,7 @@ pub(crate) async fn handle_output_item_done( previously_active_item: Option, ) -> Result { let mut output = OutputItemResult::default(); - let plan_mode = ctx.turn_context.mode == ModeKind::Plan; + let plan_mode = ctx.turn_context.mode() == ModeKind::Plan; match ToolRouter::build_tool_call(item.clone()) { // The model emitted a tool call; log it, persist the item immediately, and queue the tool execution. diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index f8fae7e986..87ee5a12f1 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -375,7 +375,7 @@ impl Session { otel.name = span_name, thread.id = %self.thread_id, turn.id = %turn_context.sub_id, - model = %turn_context.model_info.slug, + model = %turn_context.model_info().slug, codex.turn.reasoning_effort = %reasoning_effort, codex.turn.token_usage.input_tokens = field::Empty, codex.turn.token_usage.cached_input_tokens = field::Empty, diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index e7b4166149..7b999967c3 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -52,7 +52,7 @@ impl SessionTask for RegularTask { trace_id: ctx.trace_id.clone(), started_at: ctx.turn_timing_state.started_at_unix_secs().await, model_context_window: ctx.model_context_window(), - collaboration_mode_kind: ctx.mode, + collaboration_mode_kind: ctx.mode(), }); sess.send_event(ctx.as_ref(), event).await; sess.set_server_reasoning_included(/*included*/ false).await; diff --git a/codex-rs/core/src/tasks/review.rs b/codex-rs/core/src/tasks/review.rs index 94859375dd..2369495996 100644 --- a/codex-rs/core/src/tasks/review.rs +++ b/codex-rs/core/src/tasks/review.rs @@ -121,7 +121,7 @@ async fn start_review_conversation( let model = config .review_model .clone() - .unwrap_or_else(|| ctx.model_info.slug.clone()); + .unwrap_or_else(|| ctx.model_info().slug.clone()); sub_agent_config.model = Some(model); (run_codex_thread_one_shot( sub_agent_config, diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 7b55a58fc0..1b308b2c16 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -122,7 +122,7 @@ pub(crate) async fn execute_user_shell_command( trace_id: turn_context.trace_id.clone(), started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), - collaboration_mode_kind: turn_context.mode, + collaboration_mode_kind: turn_context.mode(), }); session.send_event(turn_context.as_ref(), event).await; } @@ -312,7 +312,7 @@ pub(crate) async fn execute_user_shell_command( duration: Some(output.duration), formatted_output: Some(format_exec_output_str( &output, - turn_context.model_info.truncation_policy.into(), + turn_context.model_info().truncation_policy.into(), )), }), ) @@ -353,7 +353,7 @@ pub(crate) async fn execute_user_shell_command( duration: Some(exec_output.duration), formatted_output: Some(format_exec_output_str( &exec_output, - turn_context.model_info.truncation_policy.into(), + turn_context.model_info().truncation_policy.into(), )), }), ) diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 41728a6684..87878eab23 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -1561,12 +1561,13 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() { ) .await .expect("resume source thread"); - let (resumed_turn, _) = resumed + let (prepared_turn, _) = resumed .thread .session .new_turn_with_sub_id("resume-turn".to_string(), SessionSettingsUpdate::default()) .await .expect("build resumed turn context"); + let resumed_turn = prepared_turn; assert_eq!(resumed_turn.environments.turn_environments().count(), 1); assert_eq!( resumed_turn @@ -1595,12 +1596,13 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() { ) .await .expect("fork source thread"); - let (forked_turn, _) = forked + let (prepared_turn, _) = forked .thread .session .new_turn_with_sub_id("fork-turn".to_string(), SessionSettingsUpdate::default()) .await .expect("build forked turn context"); + let forked_turn = prepared_turn; assert_eq!(forked_turn.environments.turn_environments().count(), 1); assert_eq!( forked_turn diff --git a/codex-rs/core/src/tools/approvals.rs b/codex-rs/core/src/tools/approvals.rs index ce939f37b1..d78e14f102 100644 --- a/codex-rs/core/src/tools/approvals.rs +++ b/codex-rs/core/src/tools/approvals.rs @@ -497,7 +497,7 @@ impl Session { _ => {} } } - resolution.into_tool_result(&ctx.review_context.turn().model_info) + resolution.into_tool_result(ctx.review_context.turn().model_info()) } async fn request_reviewer_approval( diff --git a/codex-rs/core/src/tools/code_mode/mod.rs b/codex-rs/core/src/tools/code_mode/mod.rs index 0ce1e66b9c..94e9ce7c1b 100644 --- a/codex-rs/core/src/tools/code_mode/mod.rs +++ b/codex-rs/core/src/tools/code_mode/mod.rs @@ -277,7 +277,7 @@ pub(super) async fn handle_runtime_response( } fn sanitize_runtime_image_detail(turn: &TurnContext, items: &mut [FunctionCallOutputContentItem]) { - sanitize_image_detail_items(can_request_original_image_detail(&turn.model_info), items); + sanitize_image_detail_items(can_request_original_image_detail(turn.model_info()), items); } fn format_script_status(response: &RuntimeResponse) -> String { diff --git a/codex-rs/core/src/tools/events.rs b/codex-rs/core/src/tools/events.rs index 5921000fe2..5fa91cc82c 100644 --- a/codex-rs/core/src/tools/events.rs +++ b/codex-rs/core/src/tools/events.rs @@ -132,7 +132,7 @@ async fn emit_exec_command_begin(ctx: ToolEventCtx<'_>, exec_input: &ExecCommand .analytics_events_client .track_artifact_operation( build_track_events_context( - ctx.turn.model_info.slug.clone(), + ctx.turn.model_info().slug.clone(), ctx.session.thread_id.to_string(), ctx.turn.sub_id.clone(), ctx.turn.originator.clone(), @@ -373,7 +373,7 @@ impl ToolEmitter { output: &ExecToolCallOutput, ctx: ToolEventCtx<'_>, ) -> String { - super::format_exec_output_for_model(output, ctx.turn.model_info.truncation_policy.into()) + super::format_exec_output_for_model(output, ctx.turn.model_info().truncation_policy.into()) } pub async fn finish( @@ -521,7 +521,7 @@ async fn emit_exec_stage( duration: output.duration, formatted_output: format_exec_output_str( &output, - ctx.turn.model_info.truncation_policy.into(), + ctx.turn.model_info().truncation_policy.into(), ), status: if output.exit_code == 0 { ExecCommandStatus::Completed diff --git a/codex-rs/core/src/tools/handlers/extension_tools.rs b/codex-rs/core/src/tools/handlers/extension_tools.rs index ec825c774e..cdd068d0ac 100644 --- a/codex-rs/core/src/tools/handlers/extension_tools.rs +++ b/codex-rs/core/src/tools/handlers/extension_tools.rs @@ -162,7 +162,7 @@ async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall { .turn .turn_metadata_state .current_meta_value_for_mcp_request(McpTurnMetadataContext { - model: invocation.turn.model_info.slug.as_str(), + model: invocation.turn.model_info().slug.as_str(), reasoning_effort: invocation.turn.effective_reasoning_effort(), }) .and_then(|metadata| to_ascii_json_string(&metadata).ok()); @@ -194,9 +194,9 @@ async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall { turn_id: invocation.turn.sub_id.clone(), call_id: invocation.call_id.clone(), tool_name: invocation.tool_name.clone(), - model: invocation.turn.model_info.slug.clone(), + model: invocation.turn.model_info().slug.clone(), codex_turn_metadata, - truncation_policy: invocation.turn.model_info.truncation_policy.into(), + truncation_policy: invocation.turn.model_info().truncation_policy.into(), source: extension_tool_call_source(invocation.source.clone()), conversation_history, turn_item_emitter: Arc::new(CoreTurnItemEmitter { @@ -388,8 +388,8 @@ mod tests { let weak_session = Arc::downgrade(&session); let weak_turn = Arc::downgrade(&turn); let turn_id = turn.sub_id.clone(); - let model = turn.model_info.slug.clone(); - let truncation_policy = turn.model_info.truncation_policy.into(); + let model = turn.model_info().slug.clone(); + let truncation_policy = turn.model_info().truncation_policy.into(); let expected_sandbox_cwds = turn .environments .turn_environments() diff --git a/codex-rs/core/src/tools/handlers/mcp.rs b/codex-rs/core/src/tools/handlers/mcp.rs index 5cead01f3d..b62945b1f9 100644 --- a/codex-rs/core/src/tools/handlers/mcp.rs +++ b/codex-rs/core/src/tools/handlers/mcp.rs @@ -208,8 +208,8 @@ impl McpHandler { result: result.result, tool_input: result.tool_input, wall_time: started.elapsed(), - original_image_detail_supported: can_request_original_image_detail(&turn.model_info), - truncation_policy: turn.model_info.truncation_policy.into(), + original_image_detail_supported: can_request_original_image_detail(turn.model_info()), + truncation_policy: turn.model_info().truncation_policy.into(), })) } } diff --git a/codex-rs/core/src/tools/handlers/mcp_resource.rs b/codex-rs/core/src/tools/handlers/mcp_resource.rs index deb7491e75..5e18ee448f 100644 --- a/codex-rs/core/src/tools/handlers/mcp_resource.rs +++ b/codex-rs/core/src/tools/handlers/mcp_resource.rs @@ -290,7 +290,7 @@ where emit_tool_call_begin(session, turn, call_id, invocation.clone()).await; let start = Instant::now(); let result = operation.await.and_then(|payload| { - serialize_function_output(payload, turn.model_info.truncation_policy.into()) + serialize_function_output(payload, turn.model_info().truncation_policy.into()) }); match result { diff --git a/codex-rs/core/src/tools/handlers/multi_agents_common.rs b/codex-rs/core/src/tools/handlers/multi_agents_common.rs index 843030b274..9fdfd48962 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_common.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_common.rs @@ -194,13 +194,13 @@ pub(crate) fn build_agent_resume_config(turn: &TurnContext) -> Result Result { let base_config = turn.config.clone(); let mut config = (*base_config).clone(); - config.model = Some(turn.model_info.slug.clone()); + config.model = Some(turn.model_info().slug.clone()); config.model_provider = turn.provider.info().clone(); config.model_reasoning_effort = turn - .reasoning_effort - .clone() - .or_else(|| turn.model_info.default_reasoning_level.clone()); - config.model_reasoning_summary = Some(turn.reasoning_summary); + .reasoning_effort() + .or(turn.model_info().default_reasoning_level.as_ref()) + .cloned(); + config.model_reasoning_summary = Some(turn.reasoning_summary()); config.developer_instructions = turn.developer_instructions.clone(); if turn.multi_agent_version == MultiAgentVersion::V2 && let Some(developer_instructions) = turn @@ -308,8 +308,8 @@ pub(crate) async fn apply_requested_spawn_agent_model_overrides( if let Some(reasoning_effort) = requested_reasoning_effort { validate_spawn_agent_reasoning_effort( - &turn.model_info.slug, - &turn.model_info.supported_reasoning_levels, + &turn.model_info().slug, + &turn.model_info().supported_reasoning_levels, &reasoning_effort, )?; config.model_reasoning_effort = Some(reasoning_effort); 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 fc76ab4b47..d364236b07 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -386,11 +386,9 @@ async fn multi_agent_v2_spawn_fork_turns_all_applies_agent_type_override() { .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); - let turn = TurnContext { - config: Arc::new(config), - multi_agent_version: codex_protocol::protocol::MultiAgentVersion::V2, - ..turn - }; + let mut turn = turn; + turn.config = Arc::new(config); + turn.multi_agent_version = codex_protocol::protocol::MultiAgentVersion::V2; SpawnAgentHandlerV2::default() .handle(invocation( @@ -915,11 +913,9 @@ async fn multi_agent_v2_spawn_partial_fork_turns_allows_agent_type_override() { .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); - let turn = TurnContext { - config: Arc::new(config), - multi_agent_version: codex_protocol::protocol::MultiAgentVersion::V2, - ..turn - }; + let mut turn = turn; + turn.config = Arc::new(config); + turn.multi_agent_version = codex_protocol::protocol::MultiAgentVersion::V2; let parent_provider_id = turn.config.model_provider_id.clone(); let output = SpawnAgentHandlerV2::default() @@ -4534,7 +4530,7 @@ async fn build_agent_spawn_config_uses_turn_context_values() { let base_instructions = BaseInstructions { text: "base".to_string(), provenance: Some(BaseInstructionsProvenance::Model { - model: turn.model_info.slug.clone(), + model: turn.model_info().slug.clone(), }), }; turn.developer_instructions = Some("dev".to_string()); @@ -4581,10 +4577,10 @@ async fn build_agent_spawn_config_uses_turn_context_values() { let mut expected = (*turn.config).clone(); expected.base_instructions_provenance = base_instructions.provenance.clone(); expected.base_instructions = Some(base_instructions.text); - expected.model = Some(turn.model_info.slug.clone()); + expected.model = Some(turn.model_info().slug.clone()); expected.model_provider = turn.provider.info().clone(); - expected.model_reasoning_effort = turn.reasoning_effort.clone(); - expected.model_reasoning_summary = Some(turn.reasoning_summary); + expected.model_reasoning_effort = turn.reasoning_effort().cloned(); + expected.model_reasoning_summary = Some(turn.reasoning_summary()); expected.developer_instructions = turn.developer_instructions.clone(); #[allow(deprecated)] { @@ -4604,7 +4600,7 @@ async fn build_agent_resume_config_clears_base_instructions() { let mut base_config = (*turn.config).clone(); base_config.base_instructions = Some("caller-base".to_string()); base_config.base_instructions_provenance = Some(BaseInstructionsProvenance::Model { - model: turn.model_info.slug.clone(), + model: turn.model_info().slug.clone(), }); turn.config = Arc::new(base_config); Arc::make_mut(&mut turn.config) @@ -4634,10 +4630,10 @@ async fn build_agent_resume_config_clears_base_instructions() { let mut expected = (*turn.config).clone(); expected.base_instructions = None; expected.base_instructions_provenance = None; - expected.model = Some(turn.model_info.slug.clone()); + expected.model = Some(turn.model_info().slug.clone()); expected.model_provider = turn.provider.info().clone(); - expected.model_reasoning_effort = turn.reasoning_effort.clone(); - expected.model_reasoning_summary = Some(turn.reasoning_summary); + expected.model_reasoning_effort = turn.reasoning_effort().cloned(); + expected.model_reasoning_summary = Some(turn.reasoning_summary()); expected.developer_instructions = turn.developer_instructions.clone(); #[allow(deprecated)] { diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs index 394d0a57e9..f783f23405 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -186,7 +186,7 @@ async fn handle_spawn_agent( let multi_agent_v2_usage_hints = if is_full_history_fork && turn.multi_agent_version == MultiAgentVersion::V2 { let child_model_info = match config.model.as_deref() { - Some(model) if model != turn.model_info.slug => Some( + Some(model) if model != turn.model_info().slug => Some( session .services .models_manager @@ -197,7 +197,7 @@ async fn handle_spawn_agent( }; let child_catalog = child_model_info .as_ref() - .unwrap_or(&turn.model_info) + .unwrap_or(turn.model_info()) .model_messages .as_ref() .and_then(|messages| messages.multi_agent.as_ref()) diff --git a/codex-rs/core/src/tools/handlers/plan.rs b/codex-rs/core/src/tools/handlers/plan.rs index 2c21334a3f..7fdd1fb4a3 100644 --- a/codex-rs/core/src/tools/handlers/plan.rs +++ b/codex-rs/core/src/tools/handlers/plan.rs @@ -81,7 +81,7 @@ impl PlanHandler { } }; - if turn.mode == ModeKind::Plan { + if turn.mode() == ModeKind::Plan { return Err(FunctionCallError::RespondToModel( "update_plan is a TODO/checklist tool and is not allowed in Plan mode".to_string(), )); diff --git a/codex-rs/core/src/tools/handlers/request_plugin_install.rs b/codex-rs/core/src/tools/handlers/request_plugin_install.rs index 02dcebc54d..25348720ac 100644 --- a/codex-rs/core/src/tools/handlers/request_plugin_install.rs +++ b/codex-rs/core/src/tools/handlers/request_plugin_install.rs @@ -217,7 +217,7 @@ impl RequestPluginInstallHandler { .analytics_events_client .track_plugin_install_requested( build_track_events_context( - turn.model_info.slug.clone(), + turn.model_info().slug.clone(), session.thread_id.to_string(), turn.sub_id.clone(), turn.originator.clone(), diff --git a/codex-rs/core/src/tools/handlers/request_user_input_tests.rs b/codex-rs/core/src/tools/handlers/request_user_input_tests.rs index dec9884e51..d6cc5ffc1a 100644 --- a/codex-rs/core/src/tools/handlers/request_user_input_tests.rs +++ b/codex-rs/core/src/tools/handlers/request_user_input_tests.rs @@ -2,6 +2,8 @@ use super::*; use crate::session::step_context::StepContext; use crate::session::tests::make_session_and_context; use crate::session::tests::make_session_and_context_with_rx; +use crate::session::tests::update_selected_settings_for_test; +use crate::session::tests::update_turn_settings_for_test; use crate::state::ActiveTurn; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; @@ -224,9 +226,14 @@ async fn guardian_user_input_evidence_is_bounded() { #[tokio::test] async fn request_user_input_sets_blocking_from_turn_mode() { let (session, mut turn, events) = make_session_and_context_with_rx().await; - Arc::get_mut(&mut turn) - .expect("turn context should be uniquely owned") - .mode = ModeKind::Plan; + update_turn_settings_for_test( + Arc::get_mut(&mut turn).expect("turn context should be uniquely owned"), + |settings| { + update_selected_settings_for_test(settings, |selected| { + selected.collaboration_mode.mode = ModeKind::Plan; + }); + }, + ); *session.active_turn.lock().await = Some(ActiveTurn::default()); let request = tokio::spawn({ diff --git a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs index 9c6c17c43b..f0bf1de790 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs @@ -345,7 +345,7 @@ impl ExecCommandHandler { chunk_id: String::new(), wall_time: std::time::Duration::ZERO, raw_output: output.into_text().into_bytes(), - truncation_policy: turn.model_info.truncation_policy.into(), + truncation_policy: turn.model_info().truncation_policy.into(), max_output_tokens, process_id: None, exit_code: None, @@ -397,7 +397,7 @@ impl ExecCommandHandler { chunk_id: generate_chunk_id(), wall_time: output.duration, raw_output: output_text.into_bytes(), - truncation_policy: turn.model_info.truncation_policy.into(), + truncation_policy: turn.model_info().truncation_policy.into(), max_output_tokens, // Sandbox denial is terminal, so there is no live // process for write_stdin to resume. diff --git a/codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs b/codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs index 7f1709b045..23be88be68 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs @@ -78,7 +78,7 @@ impl WriteStdinHandler { input: &args.chars, yield_time_ms: args.yield_time_ms, max_output_tokens: args.max_output_tokens, - truncation_policy: turn.model_info.truncation_policy.into(), + truncation_policy: turn.model_info().truncation_policy.into(), interaction_event: Some(WriteStdinInteractionEvent { session: &session, turn: &turn, diff --git a/codex-rs/core/src/tools/handlers/view_image.rs b/codex-rs/core/src/tools/handlers/view_image.rs index aa47ddd359..8e7054fb7d 100644 --- a/codex-rs/core/src/tools/handlers/view_image.rs +++ b/codex-rs/core/src/tools/handlers/view_image.rs @@ -93,7 +93,7 @@ impl ViewImageHandler { ) -> Result, FunctionCallError> { if !invocation .turn - .model_info + .model_info() .input_modalities .contains(&InputModality::Image) { @@ -182,7 +182,7 @@ impl ViewImageHandler { FunctionCallError::RespondToModel(VIEW_IMAGE_INVALID_MESSAGE.to_string()) })?; - let can_request_original_detail = can_request_original_image_detail(&turn.model_info); + let can_request_original_detail = can_request_original_image_detail(turn.model_info()); let use_original_detail = self.options.unified_image_budget || can_request_original_detail && matches!(detail, Some(ViewImageDetail::Original)); let image_detail = if use_original_detail { diff --git a/codex-rs/core/src/tools/mod.rs b/codex-rs/core/src/tools/mod.rs index df6b5969a3..e262e00cf0 100644 --- a/codex-rs/core/src/tools/mod.rs +++ b/codex-rs/core/src/tools/mod.rs @@ -65,7 +65,7 @@ pub(crate) fn tool_user_shell_type( } pub(crate) fn requested_tool_mode(turn_context: &TurnContext) -> ToolMode { - turn_context.model_info.tool_mode.unwrap_or_else(|| { + turn_context.model_info().tool_mode.unwrap_or_else(|| { if turn_context.config.features.enabled(Feature::CodeModeOnly) { ToolMode::CodeModeOnly } else if turn_context.config.features.enabled(Feature::CodeMode) { diff --git a/codex-rs/core/src/tools/runtimes/zsh_fork/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/zsh_fork/unix_escalation.rs index 87f8521cfd..7eb07228b7 100644 --- a/codex-rs/core/src/tools/runtimes/zsh_fork/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/zsh_fork/unix_escalation.rs @@ -365,7 +365,7 @@ impl CoreShellActionProvider { } ReviewDecision::TimedOut => EscalationDecision::deny(Some( crate::guardian::guardian_timeout_message( - &self.review_context.turn().model_info, + self.review_context.turn().model_info(), ), )), ReviewDecision::ApprovedMcpPolicyAmendment => { diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index b317ca2c02..e159828c63 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -373,7 +373,7 @@ pub(crate) fn finalize_tool_router( .config .tool_registry .turn_metadata_includes_tool_info - && turn_context.model_info.use_responses_lite; + && turn_context.model_info().use_responses_lite; if turn_context.config.tool_registry.error_on_tool_collisions { if let Some(tool_name) = registry.first_collision() { @@ -547,7 +547,7 @@ fn hosted_model_tool_specs( registered_extension_tool_names: &[ToolName], ) -> Vec { // Responses Lite accepts schemas for client-executed tools, not hosted Responses tools. - if turn_context.model_info.use_responses_lite + if turn_context.model_info().use_responses_lite || crate::guardian::is_basic_session_source(&turn_context.session_source) { return Vec::new(); @@ -567,7 +567,7 @@ fn hosted_model_tool_specs( if let Some(hosted_web_search_tool) = create_web_search_tool(WebSearchToolOptions { web_search_mode, web_search_config, - web_search_tool_type: turn_context.model_info.web_search_tool_type, + web_search_tool_type: turn_context.model_info().web_search_tool_type, }) { specs.push(hosted_web_search_tool); } @@ -575,7 +575,7 @@ fn hosted_model_tool_specs( } pub(crate) fn search_tool_enabled(turn_context: &TurnContext) -> bool { - turn_context.model_info.supports_search_tool && namespace_tools_enabled(turn_context) + turn_context.model_info().supports_search_tool && namespace_tools_enabled(turn_context) } pub(crate) fn tool_suggest_enabled(turn_context: &TurnContext) -> bool { @@ -602,7 +602,7 @@ fn collab_tools_enabled(turn_context: &TurnContext) -> bool { ), MultiAgentVersion::V2 => { turn_context.session_source.get_agent_path().is_none() - || turn_context.model_info.multi_agent_version == Some(MultiAgentVersion::V2) + || turn_context.model_info().multi_agent_version == Some(MultiAgentVersion::V2) } } } @@ -633,7 +633,7 @@ fn image_generation_available(turn_context: &TurnContext) -> bool { } if !turn_context - .model_info + .model_info() .input_modalities .contains(&InputModality::Image) { @@ -794,8 +794,10 @@ fn register_code_mode_executors( &namespace_descriptions, turn_context.config.code_mode.default_exec_yield_time_ms, tool_mode == ToolMode::CodeModeOnly, - if unified_image_budget_enabled(&turn_context.config.features, &turn_context.model_info) - { + if unified_image_budget_enabled( + &turn_context.config.features, + turn_context.model_info(), + ) { codex_code_mode::ImageDetailVisibility::Hidden } else { codex_code_mode::ImageDetailVisibility::Visible @@ -908,7 +910,7 @@ fn add_core_tool_sources(context: &CoreToolPlanContext<'_>, registry: &mut ToolR if turn_context.config.features.enabled(Feature::ShellTool) && turn_context.config.features.enabled(Feature::UnifiedExec) && !matches!( - turn_context.model_info.shell_type, + turn_context.model_info().shell_type, ConfigShellToolType::Disabled ) { @@ -926,11 +928,11 @@ fn add_core_tool_sources(context: &CoreToolPlanContext<'_>, registry: &mut ToolR if turn_context.config.features.enabled(Feature::ViewImage) { registry.add(ViewImageHandler::new(ViewImageToolOptions { can_request_original_image_detail: can_request_original_image_detail( - &turn_context.model_info, + turn_context.model_info(), ), unified_image_budget: unified_image_budget_enabled( &turn_context.config.features, - &turn_context.model_info, + turn_context.model_info(), ), include_environment_id, })); @@ -948,7 +950,7 @@ fn add_core_tool_sources(context: &CoreToolPlanContext<'_>, registry: &mut ToolR fn standalone_web_search_enabled(turn_context: &TurnContext) -> bool { namespace_tools_enabled(turn_context) && turn_context.provider.capabilities().web_search - && (turn_context.model_info.use_responses_lite + && (turn_context.model_info().use_responses_lite || turn_context .config .features @@ -975,7 +977,7 @@ fn add_shell_tools(context: &CoreToolPlanContext<'_>, registry: &mut ToolRegistr || !features.enabled(Feature::ShellTool) || !features.enabled(Feature::UnifiedExec) || matches!( - turn_context.model_info.shell_type, + turn_context.model_info().shell_type, ConfigShellToolType::Disabled ) { @@ -1051,7 +1053,7 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, registry: &mut Tool if !turn_context.session_source.is_non_root_agent() && turn_context - .model_info + .model_info() .experimental_supported_tools .iter() .any(|tool| tool == "send_user_message_async") @@ -1096,14 +1098,15 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, registry: &mut Tool )); } - if environment_mode.has_environment() && turn_context.model_info.apply_patch_tool_type.is_some() + if environment_mode.has_environment() + && turn_context.model_info().apply_patch_tool_type.is_some() { let include_environment_id = matches!(environment_mode, ToolEnvironmentMode::Multiple); registry.add(ApplyPatchHandler::new(include_environment_id)); } if turn_context - .model_info + .model_info() .experimental_supported_tools .iter() .any(|tool| tool == "test_sync_tool") @@ -1115,11 +1118,11 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, registry: &mut Tool let include_environment_id = matches!(environment_mode, ToolEnvironmentMode::Multiple); registry.add(ViewImageHandler::new(ViewImageToolOptions { can_request_original_image_detail: can_request_original_image_detail( - &turn_context.model_info, + turn_context.model_info(), ), unified_image_budget: unified_image_budget_enabled( &turn_context.config.features, - &turn_context.model_info, + turn_context.model_info(), ), include_environment_id, })); diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index b5be3fbf08..b61f7e0f8b 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -1,3 +1,4 @@ +use crate::session::tests::update_turn_settings_for_test; use std::collections::BTreeMap; use std::sync::Arc; @@ -532,7 +533,9 @@ async fn internal_guardian_sessions_respect_managed_shell_restrictions() { ) .expect("managed shell restriction should be valid"); } - Arc::make_mut(&mut turn.model_info).shell_type = shell_type; + update_turn_settings_for_test(&mut turn, |settings| { + Arc::make_mut(&mut settings.model_info).shell_type = shell_type; + }); let turn = Arc::new(turn); let step_context = StepContext::for_test(Arc::clone(&turn)); @@ -850,7 +853,9 @@ async fn shell_family_registers_only_unified_exec_tools() { let plan = probe(|turn| { set_features(turn, &[Feature::ShellTool]); set_feature(turn, Feature::ShellZshFork, /*enabled*/ false); - Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::UnifiedExec; + update_turn_settings_for_test(turn, |settings| { + Arc::make_mut(&mut settings.model_info).shell_type = ConfigShellToolType::UnifiedExec; + }); }) .await; @@ -866,7 +871,10 @@ async fn login_shell_parameter_follows_selected_environment() { let plan = probe(|turn| { set_feature(turn, Feature::ShellTool, /*enabled*/ true); set_feature(turn, Feature::ShellZshFork, /*enabled*/ false); - Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::UnifiedExec; + update_turn_settings_for_test(turn, |settings| { + Arc::make_mut(&mut settings.model_info).shell_type = + ConfigShellToolType::UnifiedExec; + }); update_config(turn, |config| { config.permissions.allow_login_shell = !allow_login_shell; }); @@ -922,7 +930,9 @@ async fn login_shell_parameter_is_available_when_any_environment_allows_it() { async fn disabling_shell_tools_disables_command_tools_for_all_environments() { let remote_environment = probe(|turn| { set_feature(turn, Feature::ShellTool, /*enabled*/ false); - Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::UnifiedExec; + update_turn_settings_for_test(turn, |settings| { + Arc::make_mut(&mut settings.model_info).shell_type = ConfigShellToolType::UnifiedExec; + }); let TurnEnvironmentState::Ready(environment) = turn .environments @@ -946,7 +956,9 @@ async fn disabling_shell_tools_disables_command_tools_for_all_environments() { let multiple_local_environments = probe(|turn| { set_feature(turn, Feature::ShellTool, /*enabled*/ false); - Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::UnifiedExec; + update_turn_settings_for_test(turn, |settings| { + Arc::make_mut(&mut settings.model_info).shell_type = ConfigShellToolType::UnifiedExec; + }); duplicate_primary_environment(turn); }) .await; @@ -988,7 +1000,9 @@ async fn shell_zsh_fork_keeps_unified_exec_available() { set_features(turn, &[Feature::ShellTool]); set_feature(turn, Feature::ShellZshFork, /*enabled*/ true); set_feature(turn, Feature::UnifiedExecZshFork, /*enabled*/ false); - Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::UnifiedExec; + update_turn_settings_for_test(turn, |settings| { + Arc::make_mut(&mut settings.model_info).shell_type = ConfigShellToolType::UnifiedExec; + }); }) .await; @@ -1004,7 +1018,9 @@ async fn shell_zsh_fork_keeps_unified_exec_available() { Feature::UnifiedExecZshFork, ], ); - Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::UnifiedExec; + update_turn_settings_for_test(turn, |settings| { + Arc::make_mut(&mut settings.model_info).shell_type = ConfigShellToolType::UnifiedExec; + }); }) .await; @@ -1116,8 +1132,10 @@ 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); - Arc::make_mut(&mut turn.model_info).apply_patch_tool_type = - Some(ApplyPatchToolType::Freeform); + update_turn_settings_for_test(turn, |settings| { + Arc::make_mut(&mut settings.model_info).apply_patch_tool_type = + Some(ApplyPatchToolType::Freeform); + }); }) .await; no_environment.assert_visible_lacks(&[ @@ -1139,8 +1157,10 @@ async fn environment_count_controls_environment_backed_tools() { duplicate_primary_environment(turn); set_feature(turn, Feature::ShellTool, /*enabled*/ true); set_feature(turn, Feature::RequestPermissionsTool, /*enabled*/ true); - Arc::make_mut(&mut turn.model_info).apply_patch_tool_type = - Some(ApplyPatchToolType::Freeform); + update_turn_settings_for_test(turn, |settings| { + Arc::make_mut(&mut settings.model_info).apply_patch_tool_type = + Some(ApplyPatchToolType::Freeform); + }); }) .await; multiple_environments.assert_visible_contains(&[ @@ -1165,7 +1185,10 @@ async fn environment_count_controls_environment_backed_tools() { #[tokio::test] async fn environment_tools_follow_the_step_context() { let (_session, mut turn) = make_session_and_context().await; - Arc::make_mut(&mut turn.model_info).apply_patch_tool_type = Some(ApplyPatchToolType::Freeform); + update_turn_settings_for_test(&mut turn, |settings| { + Arc::make_mut(&mut settings.model_info).apply_patch_tool_type = + Some(ApplyPatchToolType::Freeform); + }); let environments = turn.environments.clone(); turn.environments.environments.clear(); @@ -1295,7 +1318,9 @@ async fn mcp_and_tool_search_follow_direct_and_deferred_tool_exposure() { let missing_model_capability = probe_with( |turn| { - Arc::make_mut(&mut turn.model_info).supports_search_tool = false; + update_turn_settings_for_test(turn, |settings| { + Arc::make_mut(&mut settings.model_info).supports_search_tool = false; + }); }, searchable_mcp(), ) @@ -1304,7 +1329,9 @@ 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); - Arc::make_mut(&mut turn.model_info).supports_search_tool = true; + update_turn_settings_for_test(turn, |settings| { + Arc::make_mut(&mut settings.model_info).supports_search_tool = true; + }); }) .await; missing_deferred_tools.assert_visible_lacks(&["tool_search"]); @@ -1316,7 +1343,9 @@ async fn mcp_and_tool_search_follow_direct_and_deferred_tool_exposure() { let bedrock_namespace_capability = probe_with( |turn| { - Arc::make_mut(&mut turn.model_info).supports_search_tool = true; + update_turn_settings_for_test(turn, |settings| { + Arc::make_mut(&mut settings.model_info).supports_search_tool = true; + }); use_bedrock_provider(turn); }, searchable_mcp(), @@ -1326,7 +1355,9 @@ async fn mcp_and_tool_search_follow_direct_and_deferred_tool_exposure() { let enabled = probe_with( |turn| { - Arc::make_mut(&mut turn.model_info).supports_search_tool = true; + update_turn_settings_for_test(turn, |settings| { + Arc::make_mut(&mut settings.model_info).supports_search_tool = true; + }); }, searchable_mcp(), ) @@ -1340,7 +1371,9 @@ 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); - Arc::make_mut(&mut turn.model_info).supports_search_tool = true; + update_turn_settings_for_test(turn, |settings| { + Arc::make_mut(&mut settings.model_info).supports_search_tool = true; + }); }, ToolPlanInputs { tool_runtimes: vec![ @@ -1392,8 +1425,10 @@ 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); - Arc::make_mut(&mut turn.model_info).supports_search_tool = true; - Arc::make_mut(&mut turn.model_info).use_responses_lite = true; + update_turn_settings_for_test(turn, |settings| { + Arc::make_mut(&mut settings.model_info).supports_search_tool = true; + Arc::make_mut(&mut settings.model_info).use_responses_lite = true; + }); metadata_state = Some(Arc::clone(&turn.turn_metadata_state)); }, ToolPlanInputs { @@ -1467,7 +1502,9 @@ 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; }); - Arc::make_mut(&mut turn.model_info).use_responses_lite = true; + update_turn_settings_for_test(&mut turn, |settings| { + Arc::make_mut(&mut settings.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(), @@ -1522,7 +1559,10 @@ async fn unified_tool_runtimes_preserve_source_order_and_collision_priority() { |turn| { set_features(turn, &[Feature::ShellTool]); set_feature(turn, Feature::ShellZshFork, /*enabled*/ false); - Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::UnifiedExec; + update_turn_settings_for_test(turn, |settings| { + Arc::make_mut(&mut settings.model_info).shell_type = + ConfigShellToolType::UnifiedExec; + }); }, ToolPlanInputs { tool_runtimes: vec![mcp_runtime( @@ -1718,7 +1758,9 @@ async fn strict_tool_collisions_reject_external_and_synthetic_duplicates() { if code_mode_enabled { set_feature(&mut turn, Feature::CodeMode, /*enabled*/ true); } - Arc::make_mut(&mut turn.model_info).supports_search_tool = search_enabled; + update_turn_settings_for_test(&mut turn, |settings| { + Arc::make_mut(&mut settings.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( @@ -1876,8 +1918,10 @@ async fn code_mode_uses_the_first_normalized_tool_identity() { if code_mode_only { set_feature(turn, Feature::CodeModeOnly, /*enabled*/ true); } - Arc::make_mut(&mut turn.model_info).supports_search_tool = true; - Arc::make_mut(&mut turn.model_info).use_responses_lite = true; + update_turn_settings_for_test(turn, |settings| { + Arc::make_mut(&mut settings.model_info).supports_search_tool = true; + Arc::make_mut(&mut settings.model_info).use_responses_lite = true; + }); metadata_state = Some(Arc::clone(&turn.turn_metadata_state)); }, ToolPlanInputs { @@ -1960,7 +2004,9 @@ 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| { - Arc::make_mut(&mut turn.model_info).supports_search_tool = true; + update_turn_settings_for_test(turn, |settings| { + Arc::make_mut(&mut settings.model_info).supports_search_tool = true; + }); }, ToolPlanInputs { extension_tool_executors: vec![Arc::new(DeferredExtensionTool)], @@ -1980,7 +2026,9 @@ async fn tool_search_cache_rebuilds_when_deferred_sources_change() { let cache = ToolSearchHandlerCache::default(); let (_session, mut first_turn) = make_session_and_context().await; - Arc::make_mut(&mut first_turn.model_info).supports_search_tool = true; + update_turn_settings_for_test(&mut first_turn, |settings| { + Arc::make_mut(&mut settings.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( @@ -2001,7 +2049,9 @@ 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; - Arc::make_mut(&mut second_turn.model_info).supports_search_tool = true; + update_turn_settings_for_test(&mut second_turn, |settings| { + Arc::make_mut(&mut settings.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( @@ -2048,7 +2098,9 @@ 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; - Arc::make_mut(&mut turn.model_info).supports_search_tool = true; + update_turn_settings_for_test(&mut turn, |settings| { + Arc::make_mut(&mut settings.model_info).supports_search_tool = true; + }); set_feature( &mut turn, Feature::DeferredToolWorldState, @@ -2161,7 +2213,9 @@ async fn request_plugin_install_requires_all_discovery_features() { async fn request_plugin_install_stays_visible_without_tool_search() { let plan = probe_with( |turn| { - Arc::make_mut(&mut turn.model_info).supports_search_tool = false; + update_turn_settings_for_test(turn, |settings| { + Arc::make_mut(&mut settings.model_info).supports_search_tool = false; + }); set_features( turn, &[Feature::ToolSuggest, Feature::Apps, Feature::Plugins], @@ -2297,7 +2351,9 @@ async fn code_mode_only_exposes_configured_dynamic_namespace_directly() { let plan = probe_with( |turn| { set_features(turn, &[Feature::CodeMode, Feature::CodeModeOnly]); - Arc::make_mut(&mut turn.model_info).supports_search_tool = true; + update_turn_settings_for_test(turn, |settings| { + Arc::make_mut(&mut settings.model_info).supports_search_tool = true; + }); update_config(turn, |config| { config.code_mode.direct_only_tool_namespaces = vec!["direct_only".to_string()]; }); @@ -2361,7 +2417,9 @@ 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); - Arc::make_mut(&mut turn.model_info).supports_search_tool = true; + update_turn_settings_for_test(turn, |settings| { + Arc::make_mut(&mut settings.model_info).supports_search_tool = true; + }); update_config(turn, |config| { config.code_mode.excluded_tool_namespaces = vec!["excluded".to_string()]; }); @@ -2615,7 +2673,9 @@ 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]); - Arc::make_mut(&mut turn.model_info).tool_mode = Some(ToolMode::Direct); + update_turn_settings_for_test(turn, |settings| { + Arc::make_mut(&mut settings.model_info).tool_mode = Some(ToolMode::Direct); + }); }) .await; direct.assert_visible_lacks(&[ @@ -2627,7 +2687,9 @@ 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| { - Arc::make_mut(&mut turn.model_info).supports_search_tool = true; + update_turn_settings_for_test(turn, |settings| { + Arc::make_mut(&mut settings.model_info).supports_search_tool = true; + }); set_feature(turn, Feature::Collab, /*enabled*/ true); set_feature(turn, Feature::MultiAgentV2, /*enabled*/ false); }) @@ -2770,8 +2832,11 @@ 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); - 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; + update_turn_settings_for_test(turn, |settings| { + Arc::make_mut(&mut settings.model_info).slug = model.to_string(); + Arc::make_mut(&mut settings.model_info).multi_agent_version = + model_multi_agent_version; + }); turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id: ThreadId::new(), depth: 1, @@ -2886,7 +2951,10 @@ async fn hosted_web_search_and_standalone_image_generation_follow_runtime_gates( let image_generation = probe_with( |turn| { use_chatgpt_auth(turn); - Arc::make_mut(&mut turn.model_info).input_modalities = vec![InputModality::Image]; + update_turn_settings_for_test(turn, |settings| { + Arc::make_mut(&mut settings.model_info).input_modalities = + vec![InputModality::Image]; + }); }, ToolPlanInputs { extension_tool_executors: vec![image_generation_tool.clone()], @@ -2900,7 +2968,10 @@ async fn hosted_web_search_and_standalone_image_generation_follow_runtime_gates( |turn| { use_chatgpt_auth(turn); set_feature(turn, Feature::ImageGeneration, /*enabled*/ false); - Arc::make_mut(&mut turn.model_info).input_modalities = vec![InputModality::Image]; + update_turn_settings_for_test(turn, |settings| { + Arc::make_mut(&mut settings.model_info).input_modalities = + vec![InputModality::Image]; + }); }, ToolPlanInputs { extension_tool_executors: vec![image_generation_tool.clone()], @@ -2913,7 +2984,9 @@ async fn hosted_web_search_and_standalone_image_generation_follow_runtime_gates( let text_only_model = probe_with( |turn| { use_chatgpt_auth(turn); - Arc::make_mut(&mut turn.model_info).input_modalities = vec![]; + update_turn_settings_for_test(turn, |settings| { + Arc::make_mut(&mut settings.model_info).input_modalities = vec![]; + }); }, ToolPlanInputs { extension_tool_executors: vec![image_generation_tool.clone()], @@ -2926,7 +2999,10 @@ async fn hosted_web_search_and_standalone_image_generation_follow_runtime_gates( let unsupported_provider = probe_with( |turn| { use_bedrock_provider(turn); - Arc::make_mut(&mut turn.model_info).input_modalities = vec![InputModality::Image]; + update_turn_settings_for_test(turn, |settings| { + Arc::make_mut(&mut settings.model_info).input_modalities = + vec![InputModality::Image]; + }); }, ToolPlanInputs { extension_tool_executors: vec![image_generation_tool], @@ -2938,7 +3014,10 @@ 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); - Arc::make_mut(&mut turn.model_info).web_search_tool_type = WebSearchToolType::TextAndImage; + update_turn_settings_for_test(turn, |settings| { + Arc::make_mut(&mut settings.model_info).web_search_tool_type = + WebSearchToolType::TextAndImage; + }); }) .await; assert_eq!( @@ -2957,7 +3036,9 @@ 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); - Arc::make_mut(&mut turn.model_info).input_modalities = vec![InputModality::Image]; + update_turn_settings_for_test(turn, |settings| { + Arc::make_mut(&mut settings.model_info).input_modalities = vec![InputModality::Image]; + }); }) .await; assert_eq!( @@ -3034,7 +3115,9 @@ async fn hosted_web_search_and_standalone_image_generation_follow_runtime_gates( let bedrock_cached_web_search = probe(|turn| { use_bedrock_provider(turn); - Arc::make_mut(&mut turn.model_info).web_search_tool_type = WebSearchToolType::Text; + update_turn_settings_for_test(turn, |settings| { + Arc::make_mut(&mut settings.model_info).web_search_tool_type = WebSearchToolType::Text; + }); }) .await; assert_eq!( @@ -3053,7 +3136,9 @@ 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); - Arc::make_mut(&mut turn.model_info).web_search_tool_type = WebSearchToolType::Text; + update_turn_settings_for_test(turn, |settings| { + Arc::make_mut(&mut settings.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/src/unified_exec/mod_tests.rs b/codex-rs/core/src/unified_exec/mod_tests.rs index 3ba46c2b03..b4f352d015 100644 --- a/codex-rs/core/src/unified_exec/mod_tests.rs +++ b/codex-rs/core/src/unified_exec/mod_tests.rs @@ -193,7 +193,7 @@ async fn exec_command_with_tty( chunk_id: generate_chunk_id(), wall_time, raw_output: collected, - truncation_policy: turn.model_info.truncation_policy.into(), + truncation_policy: turn.model_info().truncation_policy.into(), max_output_tokens: None, process_id: response_process_id, exit_code, diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 181551409d..ed0da71297 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -741,7 +741,7 @@ impl UnifiedExecProcessManager { truncation_policy: context .step_context .turn - .model_info + .model_info() .truncation_policy .into(), max_output_tokens: request.max_output_tokens, diff --git a/codex-rs/core/src/user_shell_command.rs b/codex-rs/core/src/user_shell_command.rs index e64cf64cdf..02f52e060d 100644 --- a/codex-rs/core/src/user_shell_command.rs +++ b/codex-rs/core/src/user_shell_command.rs @@ -13,7 +13,7 @@ fn user_shell_command_fragment( ) -> UserShellCommand { let output = format_exec_output_str( exec_output, - turn_context.model_info.truncation_policy.into(), + turn_context.model_info().truncation_policy.into(), ); UserShellCommand::new(command, exec_output.exit_code, exec_output.duration, output) } diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index d94239d4ac..40c74d0a04 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -14,10 +14,13 @@ use codex_models_manager::bundled_models_response; use codex_protocol::config_types::AutoCompactTokenLimitScope; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::Settings; use codex_protocol::items::TurnItem; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ModelInfo; +use codex_protocol::openai_models::ModelServiceTier; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; @@ -2280,6 +2283,97 @@ async fn pre_sampling_compact_runs_when_comp_hash_changes() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn previous_model_compaction_resolves_selected_settings() -> Result<()> { + skip_if_no_network!(Ok(())); + let server = start_mock_server().await; + let request_log = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_assistant_message("m1", "before switch"), + ev_completed("r1"), + ]), + sse(vec![ + ev_assistant_message("m2", "COMPACTION_SUMMARY"), + ev_completed("r2"), + ]), + responses::sse_completed("r3"), + ], + ) + .await; + let model_provider = non_openai_model_provider(&server); + let test = test_codex() + .with_model_info_override("gpt-5.4", |model| { + model.comp_hash = Some("hash-a".to_string()); + model.default_reasoning_summary = ReasoningSummary::Detailed; + model.service_tiers = vec![ModelServiceTier { + id: ServiceTier::Fast.request_value().to_string(), + name: "Fast".to_string(), + description: "Priority processing".to_string(), + }]; + }) + .with_model_info_override("gpt-5.2", |model| { + model.comp_hash = Some("hash-b".to_string()); + model.default_reasoning_summary = ReasoningSummary::Auto; + model.service_tiers.clear(); + }) + .with_model("gpt-5.4") + .with_config(move |config| { + config.model_provider = model_provider; + config.model_reasoning_summary = None; + config.service_tier = Some(ServiceTier::Fast.request_value().to_string()); + config + .features + .enable(Feature::FastMode) + .expect("enable FastMode"); + set_test_compact_prompt(config); + }) + .build_with_auto_env(&server) + .await?; + test.submit_text_turn("before switch").await?; + test.codex + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { + text: "after switch".to_string(), + text_elements: Vec::new(), + }]) + .with_thread_settings(ThreadSettingsOverrides { + model: Some("gpt-5.2".to_string()), + ..Default::default() + }), + ) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + // The retained priority selection is filtered out for B, restored for + // compaction on A, and still omitted from the following B request. + let actual = request_log + .requests() + .iter() + .map(|request| { + let body = request.body_json(); + json!([ + body["model"], + body["reasoning"]["summary"], + body["service_tier"] + ]) + }) + .collect::>(); + assert_eq!( + actual, + vec![ + json!(["gpt-5.4", "detailed", "priority"]), + json!(["gpt-5.4", "detailed", "priority"]), + json!(["gpt-5.2", "auto", null]), + ] + ); + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn pre_sampling_compact_falls_back_from_retired_previous_model_after_rename() { skip_if_no_network!(); diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index ba0f21285b..c60d695a96 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -153,6 +153,7 @@ mod skills; mod skills_extension; mod spawn_agent_description; mod sqlite_state; +mod step_settings_snapshots; mod stream_error_allows_next_turn; mod stream_no_completed; mod subagent_notifications; diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index f095d56e55..d926e9dc2f 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -10,7 +10,12 @@ use codex_history::RolloutItem; use codex_history::RolloutLine; use codex_login::CodexAuth; use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::ServiceTier; +use codex_protocol::config_types::Settings; use codex_protocol::items::TurnItem; use codex_protocol::models::ContentItem; use codex_protocol::models::PermissionProfile; @@ -627,6 +632,10 @@ async fn review_uses_updated_turn_permissions_and_approval_policy() { config.review_model = Some("gpt-5.4".to_string()); config.model_context_window = Some(128_000); config.service_tier = Some(ServiceTier::Fast.request_value().to_string()); + config + .features + .enable(Feature::FastMode) + .expect("enable FastMode"); config .features .enable(Feature::TokenBudget) @@ -671,13 +680,22 @@ async fn review_uses_updated_turn_permissions_and_approval_policy() { approval_policy: Some(AskForApproval::Never), approvals_reviewer: Some(ApprovalsReviewer::User), permission_profile: Some(PermissionProfile::Disabled), - effort: Some(Some(ReasoningEffort::XHigh)), + personality: Some(Personality::Friendly), + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Plan, + settings: Settings { + model: "gpt-5.2".to_string(), + reasoning_effort: Some(ReasoningEffort::XHigh), + developer_instructions: Some("Parent planning instructions".to_string()), + }, + }), ..Default::default() }, ) .await .expect("updated thread permissions should be accepted"); + let stored_settings = codex.thread_settings_snapshot().await; codex .submit(Op::Review { review_request: ReviewRequest { @@ -691,6 +709,7 @@ async fn review_uses_updated_turn_permissions_and_approval_policy() { .expect("review should start"); wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + assert_eq!(codex.thread_settings_snapshot().await, stored_settings); let request = request_log.single_request(); assert_eq!(request.body_json()["reasoning"]["effort"], "medium"); assert_eq!( @@ -751,23 +770,166 @@ async fn review_uses_updated_turn_permissions_and_approval_policy() { }) .expect("review rollout should contain session metadata"); assert_eq!(review_session_cwd, updated_cwd.as_path()); - let review_approvals_reviewer = review_rollout + let review_context = review_rollout .lines() .filter_map(|line| { let rollout_line: RolloutLine = serde_json::from_str(line).expect("review rollout line should be valid"); match rollout_line.item { - RolloutItem::TurnContext(turn_context) => turn_context.approvals_reviewer, + RolloutItem::TurnContext(turn_context) => Some(turn_context), _ => None, } }) - .next_back(); - assert_eq!(review_approvals_reviewer, Some(ApprovalsReviewer::User)); + .next_back() + .expect("review rollout should contain turn context"); + assert_eq!( + review_context.approvals_reviewer, + Some(ApprovalsReviewer::User) + ); + assert_eq!(review_context.personality, Some(Personality::Friendly)); + // The review delegate still starts in its own default mode, not the parent's Plan mode. + assert_eq!( + review_context.collaboration_mode, + Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { + model: "gpt-5.4".to_string(), + reasoning_effort: Some(ReasoningEffort::Medium), + developer_instructions: None, + }, + }) + ); let _codex_home_guard = codex_home; server.verify().await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn review_omits_retained_tier_when_fast_mode_disabled() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + let (server, request_log) = + start_responses_server_with_sse(completed_sse(), /*expected_requests*/ 1).await; + let test = test_codex() + .with_model_info_override("gpt-5.4", |model| { + model.service_tiers = vec![ModelServiceTier { + id: ServiceTier::Flex.request_value().to_string(), + name: "Flex".to_string(), + description: "Flexible processing".to_string(), + }]; + }) + .with_config(|config| { + config + .features + .disable(Feature::FastMode) + .expect("disable FastMode"); + }) + .build_with_auto_env(&server) + .await?; + core_test_support::submit_thread_settings( + &test.codex, + ThreadSettingsOverrides { + service_tier: Some(Some(ServiceTier::Flex.request_value().to_string())), + ..Default::default() + }, + ) + .await?; + test.codex + .submit(Op::Review { + review_request: ReviewRequest { + target: ReviewTarget::Custom { + instructions: "review the changes".to_string(), + }, + user_facing_hint: None, + }, + }) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + assert_eq!( + test.codex + .thread_settings_snapshot() + .await + .service_tier + .as_deref(), + Some("flex") + ); + assert_eq!( + request_log.single_request().body_json().get("service_tier"), + None + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn review_resolves_inherited_summary_preferences() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + let (server, request_log) = + start_responses_server_with_sse(completed_sse(), /*expected_requests*/ 2).await; + let test = test_codex() + .with_model_info_override("gpt-5.2", |model| { + model.default_reasoning_summary = ReasoningSummary::Auto; + }) + .with_model_info_override("gpt-5.4", |model| { + model.default_reasoning_summary = ReasoningSummary::Detailed; + }) + .with_model("gpt-5.2") + .with_config(|config| { + config.review_model = Some("gpt-5.4".to_string()); + config.model_reasoning_summary = None; + }) + .build_with_auto_env(&server) + .await?; + + // First follow the review model's default, then use a preference updated on the thread. + for summary in [None, Some(ReasoningSummary::Concise)] { + if let Some(summary) = summary { + core_test_support::submit_thread_settings( + &test.codex, + ThreadSettingsOverrides { + summary: Some(summary), + ..Default::default() + }, + ) + .await?; + } + let stored_settings = test.codex.thread_settings_snapshot().await; + test.codex + .submit(Op::Review { + review_request: ReviewRequest { + target: ReviewTarget::Custom { + instructions: "review the changes".to_string(), + }, + user_facing_hint: None, + }, + }) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + assert_eq!(test.codex.thread_settings_snapshot().await, stored_settings); + } + let actual = request_log + .requests() + .iter() + .map(|request| { + let body = request.body_json(); + serde_json::json!([body["model"], body["reasoning"]["summary"]]) + }) + .collect::>(); + assert_eq!( + actual, + vec![ + serde_json::json!(["gpt-5.4", "detailed"]), + serde_json::json!(["gpt-5.4", "concise"]), + ] + ); + Ok(()) +} + /// Ensure that when a custom `review_model` is set in the config, the review /// request uses that model (and not the main chat model). #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/codex-rs/core/tests/suite/step_settings_snapshots.rs b/codex-rs/core/tests/suite/step_settings_snapshots.rs new file mode 100644 index 0000000000..a31f8dfc3f --- /dev/null +++ b/codex-rs/core/tests/suite/step_settings_snapshots.rs @@ -0,0 +1,171 @@ +use anyhow::Result; +use codex_core::TurnInputRequest; +use codex_features::Feature; +use codex_models_manager::bundled_models_response; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ModelsResponse; +use codex_protocol::openai_models::ReasoningEffort; +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; +use core_test_support::skip_if_no_network; +use core_test_support::submit_thread_settings; +use core_test_support::test_codex::test_codex; +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; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn ordinary_settings_updates_keep_the_running_turn_snapshot() -> Result<()> { + skip_if_no_network!(Ok(())); + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_sequence( + &server, + vec![ + responses::sse(vec![ + responses::ev_response_created("response-1"), + responses::ev_function_call( + "pause", + "request_user_input", + &json!({ "questions": [{ + "id": "continue", + "header": "Continue", + "question": "Continue after the settings update?", + "options": [ + { "label": "Yes", "description": "Continue the turn." }, + { "label": "No", "description": "Stop the turn." } + ] + }] }) + .to_string(), + ), + responses::ev_completed("response-1"), + ]), + responses::sse_completed("response-2"), + responses::sse_completed("response-3"), + ], + ) + .await; + let test = test_codex() + .with_model("snapshot-model-a") + .with_config(|config| { + // This regression exercises ordinary turn construction without + // enabling the diagnostic active-settings path. + config + .features + .enable(Feature::DefaultModeRequestUserInput) + .unwrap(); + config.features.enable(Feature::FastMode).unwrap(); + let template = bundled_models_response() + .unwrap() + .models + .into_iter() + .find(|model| model.slug == "gpt-5.4") + .unwrap(); + config.model_catalog = Some(ModelsResponse { + models: ["snapshot-model-a", "snapshot-model-b"] + .into_iter() + .map(|slug| { + let mut model = template.clone(); + model.slug = slug.to_string(); + model.default_reasoning_summary = ReasoningSummary::Concise; + model + }) + .collect(), + }); + config.model_reasoning_effort = Some(ReasoningEffort::Low); + config.model_reasoning_summary = None; + config.service_tier = None; + }) + .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 paused = wait_for_event_match(&test.codex, |event| match event { + EventMsg::RequestUserInput(request) => Some(request.clone()), + _ => None, + }) + .await; + + let mut expected = test.codex.thread_settings_snapshot().await; + submit_thread_settings( + &test.codex, + ThreadSettingsOverrides { + model: Some("snapshot-model-b".to_string()), + effort: Some(Some(ReasoningEffort::High)), + summary: Some(ReasoningSummary::Detailed), + service_tier: Some(Some(ServiceTier::Fast.request_value().to_string())), + ..Default::default() + }, + ) + .await?; + expected.model = "snapshot-model-b".to_string(); + expected.reasoning_effort = Some(ReasoningEffort::High); + expected.reasoning_summary = Some(ReasoningSummary::Detailed); + expected.service_tier = Some(ServiceTier::Fast.request_value().to_string()); + expected.collaboration_mode = expected.collaboration_mode.with_updates( + Some(expected.model.clone()), + Some(expected.reasoning_effort.clone()), + /*developer_instructions*/ None, + ); + assert_eq!(test.codex.thread_settings_snapshot().await, expected); + test.codex + .submit(Op::UserInputAnswer { + id: paused.turn_id.clone(), + response: RequestUserInputResponse { + answers: HashMap::from([( + "continue".to_string(), + RequestUserInputAnswer { + answers: vec!["Yes".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 actual = requests + .iter() + .map(|request| { + let body = request.body_json(); + json!({ + "model": body["model"], + "reasoning": body["reasoning"], + "service_tier": body.get("service_tier"), + }) + }) + .collect::>(); + let original = json!({ + "model": "snapshot-model-a", + "reasoning": { "effort": "low", "summary": "concise" }, + "service_tier": null, + }); + assert_eq!( + actual, + vec![ + original.clone(), + original, + json!({ + "model": "snapshot-model-b", + "reasoning": { "effort": "high", "summary": "detailed" }, + "service_tier": "priority", + }) + ] + ); + Ok(()) +}