diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 189dd5df40..7155486919 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -103,10 +103,7 @@ pub(crate) async fn build_compaction_initial_context( step_context, } => { let items = sess - .build_initial_context_with_world_state( - step_context.turn.as_ref(), - world_state.as_ref(), - ) + .build_initial_context_with_world_state(step_context, world_state.as_ref()) .await; ( items.into_iter().map(ResponseItemEnvelope::new).collect(), diff --git a/codex-rs/core/src/compact_tests.rs b/codex-rs/core/src/compact_tests.rs index 72fa09a1e8..0ab5c93e5f 100644 --- a/codex-rs/core/src/compact_tests.rs +++ b/codex-rs/core/src/compact_tests.rs @@ -26,7 +26,7 @@ async fn process_compacted_history_with_test_session( .expect("world state should build"), ); let initial_context = session - .build_initial_context_with_world_state(&turn_context, world_state.as_ref()) + .build_initial_context_with_world_state(&step_context, world_state.as_ref()) .await; let initial_context_injection = InitialContextInjection::BeforeLastUserMessage { world_state, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 0aae16f6a3..ed0b6981a1 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -3977,11 +3977,15 @@ impl Session { .collect() } + /// `step_context` and `world_state` must come from the same captured step. + /// If more callers need this pair, bundle them into a captured-context struct + /// so callers cannot mix settings and WorldState from different steps. pub(crate) async fn build_initial_context_with_world_state( &self, - turn_context: &TurnContext, + step_context: &StepContext, world_state: &WorldState, ) -> Vec { + let turn_context = step_context.turn.as_ref(); let mut developer_sections = Vec::::with_capacity(8); let mut contextual_user_sections = Vec::::with_capacity(2); let mut separate_developer_sections = Vec::::new(); @@ -4071,7 +4075,11 @@ impl Session { } // This is full-context metadata. Steady-state context diffs should not re-emit it. if turn_context.config.features.enabled(Feature::TokenBudget) - && turn_context.model_context_window().is_some() + && step_context + .settings + .model_info + .resolved_context_window() + .is_some() { // Keep the legacy bridge hint when native Notes is disabled. A failed // native request must not fall back to the bridge. @@ -4291,7 +4299,7 @@ impl Session { }; let (window_number, window_ids) = window; let context_items = self - .build_initial_context_with_world_state(turn_context, world_state.as_ref()) + .build_initial_context_with_world_state(step_context, world_state.as_ref()) .await .into_iter() .map(ResponseItemEnvelope::new) @@ -4350,7 +4358,7 @@ impl Session { // Full initial context resets the baseline; later turns persist only its changes. let (mut context_items, world_state_item) = if should_inject_full_context { let context_items = self - .build_initial_context_with_world_state(turn_context, world_state.as_ref()) + .build_initial_context_with_world_state(step_context, world_state.as_ref()) .await; let snapshot = world_state.snapshot(); self.state diff --git a/codex-rs/core/src/session/multi_agents.rs b/codex-rs/core/src/session/multi_agents.rs index 666f32cce2..19d0cb95e5 100644 --- a/codex-rs/core/src/session/multi_agents.rs +++ b/codex-rs/core/src/session/multi_agents.rs @@ -1,6 +1,6 @@ use crate::config::MultiAgentV2Config; use crate::context::MultiAgentRoleInstructions; -use crate::session::turn_context::TurnContext; +use crate::session::step_context::StepContext; use codex_protocol::config_types::MultiAgentMode; use codex_protocol::openai_models::MultiAgentRoleMessages; use codex_protocol::openai_models::ReasoningEffort; @@ -65,15 +65,17 @@ pub(crate) struct ResolvedMultiAgentV2UsageHints { } pub(super) fn usage_hint_text( - turn_context: &TurnContext, + step_context: &StepContext, session_source: &SessionSource, ) -> Option { + let turn_context = step_context.turn.as_ref(); if turn_context.multi_agent_version != MultiAgentVersion::V2 { return None; } - let catalog = turn_context - .model_info() + let catalog = step_context + .settings + .model_info .model_messages .as_ref() .and_then(|messages| messages.multi_agent.as_ref()) @@ -152,13 +154,15 @@ pub(crate) fn resolve_usage_hints( } } -pub(crate) fn effective_multi_agent_mode(turn_context: &TurnContext) -> Option { +pub(crate) fn effective_multi_agent_mode(step_context: &StepContext) -> Option { + let turn_context = step_context.turn.as_ref(); + let settings = &step_context.settings; if turn_context.multi_agent_version != MultiAgentVersion::V2 { return None; } - let catalog_mode = turn_context - .model_info() + let catalog_mode = settings + .model_info .model_messages .as_ref() .and_then(|messages| messages.multi_agent.as_ref()) @@ -174,7 +178,7 @@ pub(crate) fn effective_multi_agent_mode(turn_context: &TurnContext) -> Option MultiAgentMode::Custom(hint_text.to_string()), - None => match turn_context.effective_reasoning_effort() { + None => match settings.effective_reasoning_effort() { Some(ReasoningEffort::Ultra) => catalog_mode .and_then(|messages| messages.proactive.clone()) .map(MultiAgentMode::Custom) diff --git a/codex-rs/core/src/session/step_settings.rs b/codex-rs/core/src/session/step_settings.rs index 49502e8a6e..d9d99b9b14 100644 --- a/codex-rs/core/src/session/step_settings.rs +++ b/codex-rs/core/src/session/step_settings.rs @@ -90,6 +90,12 @@ impl ResolvedStepSettings { .cloned() } + pub(crate) fn effective_collaboration_mode(&self) -> CollaborationMode { + let mut collaboration_mode = self.selected.collaboration_mode.clone(); + collaboration_mode.settings.model = self.model_info.slug.clone(); + collaboration_mode + } + pub(crate) fn approval_policy(&self) -> AskForApproval { self.selected.approval_policy.value() } diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 3b2d0bfb1f..ca6fa99501 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -74,6 +74,7 @@ use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ImageDetail; use codex_protocol::models::PermissionProfile; use codex_protocol::models::SandboxEnforcement; +use codex_protocol::openai_models::ModelInstructionsVariables; use codex_protocol::openai_models::ModelServiceTier; use codex_protocol::openai_models::ToolMode; use codex_protocol::permissions::FileSystemAccessMode; @@ -6416,9 +6417,13 @@ async fn build_initial_context( session: &Session, turn_context: &Arc, ) -> Vec { - let world_state = build_world_state_from_turn_context(session, turn_context).await; + let step_context = StepContext::for_test(Arc::clone(turn_context)); + let world_state = session + .build_world_state_for_step(&step_context) + .await + .expect("world state should build"); session - .build_initial_context_with_world_state(turn_context.as_ref(), &world_state) + .build_initial_context_with_world_state(&step_context, &world_state) .await } @@ -10123,8 +10128,9 @@ async fn build_initial_context_reuses_in_flight_recommendation_prewarm() { // Keep the OnceCell initializer unpolled while first-thread context joins its in-flight fetch. // This does not depend on how quickly the HTTP server returns its response. let world_state = WorldState::default(); + let step_context = StepContext::for_test(Arc::clone(&turn_context)); let initial_context = - session.build_initial_context_with_world_state(&turn_context, &world_state); + session.build_initial_context_with_world_state(&step_context, &world_state); tokio::pin!(initial_context); assert!(futures::poll!(initial_context.as_mut()).is_pending()); @@ -10802,6 +10808,103 @@ async fn record_context_updates_and_set_reference_context_item_persists_split_fi ); } +#[tokio::test] +async fn build_initial_context_uses_retained_step_after_model_change() { + let (session, mut turn_context, _rx_event) = + make_session_and_context_with_auth_and_config_and_rx( + CodexAuth::from_api_key("Test API Key"), + Vec::new(), + |config| { + config.features.enable(Feature::Personality).unwrap(); + config.features.enable(Feature::TokenBudget).unwrap(); + config.personality = Some(Personality::Friendly); + }, + ) + .await; + update_turn_settings_for_test(Arc::get_mut(&mut turn_context).unwrap(), |settings| { + let model_info = Arc::make_mut(&mut settings.model_info); + model_info.slug = "model-a".to_string(); + model_info.context_window = None; + model_info.max_context_window = None; + let messages = model_info.model_messages.as_mut().unwrap(); + messages.instructions_template = Some("A instructions: {{ personality }}".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()), + }); + }); + session + .set_previous_turn_settings(Some(PreviousTurnSettings { + model: "base-model".to_string(), + comp_hash: None, + realtime_active: None, + })) + .await; + let step_a = session + .capture_step_context(Arc::clone(&turn_context), &CancellationToken::new()) + .await + .unwrap(); + let world_a = Arc::new(session.build_world_state_for_step(&step_a).await.unwrap()); + let retained = crate::compact::InitialContextInjection::BeforeLastUserMessage { + world_state: Arc::clone(&world_a), + step_context: Arc::clone(&step_a), + }; + let (initial_a, _) = + crate::compact::build_compaction_initial_context(&session, &retained).await; + + let mut selected_b = step_a.settings.selected().clone(); + selected_b.collaboration_mode.settings.model = "model-b".to_string(); + selected_b.personality = Some(Personality::Pragmatic); + let mut model_b = step_a.settings.model_info.as_ref().clone(); + model_b.slug = "model-b".to_string(); + model_b.context_window = Some(128_000); + model_b + .model_messages + .as_mut() + .unwrap() + .instructions_template = Some("B instructions: {{ personality }}".to_string()); + turn_context + .current_settings + .store(Arc::new(ResolvedStepSettings::new( + Arc::new(selected_b), + Arc::new(model_b), + /*fast_mode_enabled*/ false, + ))); + let step_b = session + .capture_step_context(Arc::clone(&turn_context), &CancellationToken::new()) + .await + .unwrap(); + let world_b = session.build_world_state_for_step(&step_b).await.unwrap(); + let initial_b = session + .build_initial_context_with_world_state(&step_b, &world_b) + .await; + let (restored_a, restored_world) = + crate::compact::build_compaction_initial_context(&session, &retained).await; + + assert_eq!(restored_a, initial_a); + assert!(Arc::ptr_eq(restored_world.as_ref().unwrap(), &world_a)); + let initial_a = initial_a + .into_iter() + .map(ResponseItemEnvelope::into_item) + .collect::>(); + let a_text = developer_input_texts(&initial_a).join("\n"); + let b_text = developer_input_texts(&initial_b).join("\n"); + assert!(a_text.contains("A instructions: friendly")); + assert!(!a_text.contains("")); + assert!(b_text.contains("B instructions: pragmatic")); + assert!(!b_text.contains("A instructions:")); + assert!( + b_text.contains(""), + "full-context metadata must use B's window even though the turn started without one" + ); + assert_eq!( + step_b.environments.to_selections(), + step_a.environments.to_selections() + ); + assert!(Arc::ptr_eq(&step_b.turn.config, &step_a.turn.config)); +} + #[tokio::test] async fn build_initial_context_prepends_model_switch_message() { let (session, turn_context) = make_session_and_context().await; diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index f224c7ed28..ea3bfd157d 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -369,16 +369,6 @@ impl TurnContext { 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"); @@ -389,14 +379,7 @@ impl TurnContext { /// 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(), - settings: Settings { - model: self.model_info().slug.clone(), - reasoning_effort: self.reasoning_effort().cloned(), - developer_instructions: self.collaboration_mode_developer_instructions().clone(), - }, - } + self.initial_settings.effective_collaboration_mode() } pub(crate) fn plugin_attribution_for_command( diff --git a/codex-rs/core/src/session/world_state.rs b/codex-rs/core/src/session/world_state.rs index 1253a9fcfe..3ed069258f 100644 --- a/codex-rs/core/src/session/world_state.rs +++ b/codex-rs/core/src/session/world_state.rs @@ -37,13 +37,14 @@ impl Session { step_context: &StepContext, ) -> CodexResult { let turn_context = step_context.turn.as_ref(); + let settings = &step_context.settings; + let model_info = settings.model_info.as_ref(); + let personality = settings.personality(); tracing::trace!( selected_capability_root_count = step_context.selected_capability_roots.len(), "building step world state" ); - let model_instructions = turn_context - .model_info() - .get_model_instructions(turn_context.personality()); + let model_instructions = model_info.get_model_instructions(personality); let model_instructions = if !turn_context.config.update_plan_enabled && turn_context.config.model_catalog.is_none() && (turn_context.config.base_instructions.is_none() @@ -77,8 +78,8 @@ impl Session { base_instructions, ) }; - let personality_is_baked = turn_context.model_info().supports_personality() - && base_instructions == model_instructions; + let personality_is_baked = + model_info.supports_personality() && base_instructions == model_instructions; let environment_subagents = if turn_context.config.include_environment_context { self.services .agent_control @@ -92,22 +93,21 @@ impl Session { }; let mut world_state = WorldState::default(); world_state.add_section(ModelInstructionsState::new( - &turn_context.model_info().slug, + &model_info.slug, previous_model.as_deref(), model_instructions, )); if self.features.enabled(Feature::Personality) { - let personality_instructions = turn_context.personality().and_then(|personality| { - turn_context - .model_info() + let personality_instructions = personality.and_then(|personality| { + 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(), + &model_info.slug, + personality, previous_context .as_ref() .map(|previous| previous.model.as_str()) @@ -172,12 +172,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 = model_info.model_messages.as_ref(); world_state.add_section(PermissionsState::new( &permission_profile, - step_context.settings.approval_policy(), + settings.approval_policy(), ApprovalPromptContext::new( - step_context.settings.approvals_reviewer(), + settings.approvals_reviewer(), model_messages.and_then(|messages| messages.approvals.as_ref()), model_messages.and_then(|messages| messages.permissions.as_ref()), ), @@ -197,9 +197,8 @@ impl Session { } if turn_context.config.include_collaboration_mode_instructions { world_state.add_section(CollaborationModeState::from_collaboration_mode( - &turn_context.collaboration_mode(), - turn_context - .model_info() + &settings.effective_collaboration_mode(), + model_info .model_messages .as_ref() .and_then(|messages| messages.collaboration_modes.as_ref()), @@ -266,12 +265,12 @@ impl Session { false }; let apps_usage_instructions_available = - apps_available && turn_context.model_info().include_apps_usage_instructions; + apps_available && 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; + let plugins_usage_instructions_available = + step_context.mcp.plugins_available() && model_info.include_plugin_usage_instructions; world_state.add_section(PluginsInstructionsState::new( plugins_usage_instructions_available, )); @@ -314,10 +313,10 @@ impl Session { } } let mut multi_agent_mode = MultiAgentModeState::new( - super::multi_agents::effective_multi_agent_mode(turn_context), + super::multi_agents::effective_multi_agent_mode(step_context), ); if let Some(usage_hint_text) = - super::multi_agents::usage_hint_text(turn_context, &turn_context.session_source) + super::multi_agents::usage_hint_text(step_context, &turn_context.session_source) { let usage_hint = MultiAgentUsageHintState::new(usage_hint_text); multi_agent_mode = multi_agent_mode.with_usage_hint(&usage_hint); diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index a9d7077e98..7103c17145 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -7,6 +7,7 @@ use crate::mcp::McpEnvironmentScope; use crate::mcp::McpThreadIdentity; use crate::rollout::RolloutRecorder; use crate::session::session::SessionSettingsUpdate; +use crate::session::step_context::StepContext; use crate::session::tests::build_world_state_from_turn_context; use crate::session::tests::make_session_and_context; use crate::tasks::InterruptedTurnHistoryMarker; @@ -598,8 +599,9 @@ async fn ignores_session_prefix_messages_when_truncating() { let (session, turn_context) = make_session_and_context().await; let turn_context = Arc::new(turn_context); let world_state = build_world_state_from_turn_context(&session, &turn_context).await; + let step_context = StepContext::for_test(turn_context); let mut items = session - .build_initial_context_with_world_state(&turn_context, &world_state) + .build_initial_context_with_world_state(&step_context, &world_state) .await; items.push(user_msg("feature request")); items.push(assistant_msg("ack")); @@ -1201,10 +1203,11 @@ async fn spawn_internal_session_preserves_parent_lineage_without_forking_history let reviewer_turn = reviewer.thread.session.new_default_turn().await; let reviewer_world_state = build_world_state_from_turn_context(&reviewer.thread.session, &reviewer_turn).await; + let reviewer_step = StepContext::for_test(Arc::clone(&reviewer_turn)); let reviewer_context = reviewer .thread .session - .build_initial_context_with_world_state(&reviewer_turn, &reviewer_world_state) + .build_initial_context_with_world_state(&reviewer_step, &reviewer_world_state) .await; assert!( !serde_json::to_string(&reviewer_context) diff --git a/codex-rs/core/src/thread_rollout_truncation_tests.rs b/codex-rs/core/src/thread_rollout_truncation_tests.rs index 9bdef1a08c..88d496eaa1 100644 --- a/codex-rs/core/src/thread_rollout_truncation_tests.rs +++ b/codex-rs/core/src/thread_rollout_truncation_tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::session::step_context::StepContext; use crate::session::tests::build_world_state_from_turn_context; use crate::session::tests::make_session_and_context; use codex_protocol::AgentPath; @@ -364,8 +365,9 @@ async fn ignores_session_prefix_messages_when_truncating_rollout_from_start() { let (session, turn_context) = make_session_and_context().await; let turn_context = Arc::new(turn_context); let world_state = build_world_state_from_turn_context(&session, &turn_context).await; + let step_context = StepContext::for_test(turn_context); let mut items = session - .build_initial_context_with_world_state(&turn_context, &world_state) + .build_initial_context_with_world_state(&step_context, &world_state) .await; items.push(user_msg("feature request")); items.push(assistant_msg("ack")); diff --git a/codex-rs/core/tests/suite/step_settings.rs b/codex-rs/core/tests/suite/step_settings.rs index b487f88f05..19ad6daed2 100644 --- a/codex-rs/core/tests/suite/step_settings.rs +++ b/codex-rs/core/tests/suite/step_settings.rs @@ -9,12 +9,19 @@ use codex_login::CodexAuth; use codex_models_manager::bundled_models_response; use codex_models_manager::manager::RefreshStrategy; use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ApprovalMessages; +use codex_protocol::openai_models::CollaborationModeMessages; use codex_protocol::openai_models::ConfirmationPolicies; use codex_protocol::openai_models::ModelInfo; +use codex_protocol::openai_models::ModelInstructionsVariables; use codex_protocol::openai_models::ModelTokenBudgetConfig; use codex_protocol::openai_models::ModelsResponse; +use codex_protocol::openai_models::MultiAgentMessages; +use codex_protocol::openai_models::MultiAgentModeMessages; +use codex_protocol::openai_models::MultiAgentRoleMessages; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::openai_models::ToolMessage; @@ -344,6 +351,21 @@ async fn settings_updates_preserve_turn_identity_and_target(target: SettingsTarg Some("original-turn-state".to_string()) ); assert_eq!(requests[2].header(TURN_STATE_HEADER), None); + let expected_switch_counts = match target { + SettingsTarget::Thread => vec![0, 0, 1], + SettingsTarget::Turn => vec![0, 1, 2], + }; + assert_eq!( + requests + .iter() + .map(|request| request + .message_input_texts("developer") + .iter() + .filter(|text| text.contains("")) + .count()) + .collect::>(), + expected_switch_counts + ); Ok(()) } @@ -365,7 +387,7 @@ enum TokenBudgetScenario { #[test_case(TokenBudgetScenario::InitialWindowOnly)] #[test_case(TokenBudgetScenario::DestinationWithoutGuidance)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn active_model_switch_resolves_token_budget_from_original_preferences( +async fn active_model_switch_updates_core_context_from_captured_settings( scenario: TokenBudgetScenario, ) -> Result<()> { skip_if_no_network!(Ok(())); @@ -411,6 +433,15 @@ async fn active_model_switch_resolves_token_budget_from_original_preferences( .features .enable(Feature::TokenBudget) .expect("enable token-budget feature"); + config + .features + .enable(Feature::Personality) + .expect("enable personality"); + config + .features + .enable(Feature::MultiAgentV2) + .expect("enable multi-agent V2"); + config.personality = Some(Personality::Pragmatic); if context_window_model.is_some() { config.model_context_window = None; } @@ -426,11 +457,38 @@ async fn active_model_switch_resolves_token_budget_from_original_preferences( model.context_window = (slug == context_window_model).then_some(128_000); model.max_context_window = None; } - model - .model_messages - .as_mut() - .expect("model messages") - .token_budget = (initial_model || destination_has_guidance).then(|| ModelTokenBudgetConfig { + let messages = model.model_messages.as_mut().expect("model messages"); + messages.instructions_template = Some(format!( + "Instructions for {slug}. {{{{ personality }}}}" + )); + messages.instructions_variables = Some(ModelInstructionsVariables { + personality_default: Some(format!("Default {slug} personality.")), + personality_friendly: Some(format!("Friendly {slug} personality.")), + personality_pragmatic: Some(format!("Pragmatic {slug} personality.")), + }); + messages.collaboration_modes = Some(CollaborationModeMessages { + default: Some(format!("Default collaboration for {slug}.")), + plan: None, + }); + messages.multi_agent = Some(MultiAgentMessages { + role: Some(MultiAgentRoleMessages { + root: Some(format!("Root role for {slug}.")), + subagent: None, + }), + mode: Some(MultiAgentModeMessages { + explicit: Some(format!("Delegation policy for {slug}.")), + proactive: None, + hint_text: None, + }), + }); + messages.approvals = Some(ApprovalMessages { + on_request: Some(format!("Approval instructions for {slug}.")), + on_request_auto_review: None, + never: None, + unless_trusted: None, + }); + messages.token_budget = + (initial_model || destination_has_guidance).then(|| ModelTokenBudgetConfig { enabled: false, use_history_notes_extension: false, reminder_threshold_tokens: if initial_model { 8_000 } else { 2_000 }, @@ -487,6 +545,64 @@ async fn active_model_switch_resolves_token_budget_from_original_preferences( let requests = response_mock.requests(); assert_eq!(requests.len(), 3); + assert_eq!( + requests + .iter() + .map(|request| request.body_json()["model"].clone()) + .collect::>(), + vec![json!(MODEL_A), json!(MODEL_B), json!(MODEL_B)] + ); + let initial_instructions = + format!("Instructions for {MODEL_A}. Pragmatic {MODEL_A} personality."); + assert_eq!(requests[0].instructions_text(), initial_instructions); + assert!(!requests[0].body_contains_text("")); + for text in [ + format!("Root role for {MODEL_A}."), + format!("Delegation policy for {MODEL_A}."), + ] { + assert!(requests[0].body_contains_text(&text)); + } + for pair in requests.windows(/*size*/ 2) { + assert!( + pair[1].input().starts_with(&pair[0].input()), + "context updates must be append-only" + ); + assert_eq!(pair[1].instructions_text(), initial_instructions); + assert_eq!(pair[1].body_json()["tools"], pair[0].body_json()["tools"]); + assert_eq!( + pair[1].message_input_texts("user"), + pair[0].message_input_texts("user") + ); + } + for request in &requests[1..] { + let developer_texts = request.message_input_texts("developer"); + let switches = developer_texts + .iter() + .filter(|text| text.contains("")) + .collect::>(); + assert_eq!(switches.len(), 1); + assert!(switches[0].contains(&format!( + "Instructions for {MODEL_B}. Pragmatic {MODEL_B} personality." + ))); + assert!( + !request.body_contains_text(""), + "personality is included in the model-switch instructions" + ); + for text in [ + format!("Default collaboration for {MODEL_B}."), + format!("Approval instructions for {MODEL_B}."), + format!("Root role for {MODEL_B}."), + format!("Delegation policy for {MODEL_B}."), + ] { + assert_eq!( + developer_texts + .iter() + .filter(|message| message.contains(&text)) + .count(), + 1 + ); + } + } let initial_guidance = format!("Use {MODEL_A} token-budget guidance."); let initial_guidance_expected = !explicit_default_template && context_window_model != Some(MODEL_B); @@ -532,6 +648,89 @@ async fn active_model_switch_resolves_token_budget_from_original_preferences( Ok(()) } +#[test_case(Some(ReasoningEffort::Ultra); "selected effort")] +#[test_case(None; "captured model default effort")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn active_model_switch_updates_multi_agent_policy_from_captured_effort( + effort: Option, +) -> Result<()> { + skip_if_no_network!(Ok(())); + + let use_model_default = effort.is_none(); + let server = start_mock_server().await; + let response_mock = mount_sse_sequence( + &server, + vec![ + paused_response("resp-1", "pause-before-effort-switch"), + sse_completed("resp-2"), + ], + ) + .await; + let test = step_settings_test() + .with_config(move |config| { + config + .features + .enable(Feature::MultiAgentV2) + .expect("enable multi-agent V2"); + for model in &mut config + .model_catalog + .as_mut() + .expect("controlled model catalog") + .models + { + model + .model_messages + .as_mut() + .expect("model messages") + .multi_agent = None; + model + .supported_reasoning_levels + .push(ReasoningEffortPreset { + effort: ReasoningEffort::Ultra, + description: "Ultra".to_string(), + }); + model.default_reasoning_level = + Some(if model.slug == MODEL_B && use_model_default { + ReasoningEffort::Ultra + } else { + ReasoningEffort::Low + }); + } + }) + .build_with_auto_env(&server) + .await?; + let request = start_paused_turn(&test.codex).await?; + assert_eq!( + submit_turn_settings( + &test.codex, + &request.turn_id, + TurnSettingsUpdate { + model: Some(MODEL_B.to_string()), + effort: Some(effort), + ..Default::default() + }, + ) + .await?, + TurnSettingsUpdateOutcome::Applied + ); + answer_paused_turn(&test.codex, &request.turn_id).await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let requests = response_mock.requests(); + assert_eq!(requests.len(), 2); + assert_eq!(requests[0].body_json()["model"], MODEL_A); + assert_eq!(requests[1].body_json()["model"], MODEL_B); + assert_eq!(requests[1].body_json()["reasoning"]["effort"], "xhigh"); + let proactive_text = "Proactive multi-agent delegation is active."; + assert!(!requests[0].body_contains_text(proactive_text)); + assert!(requests[1].body_contains_text(proactive_text)); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn mcp_confirmation_policy_follows_step_model_changes() -> Result<()> { skip_if_no_network!(Ok(()));