diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 7b08921ba8..5220f51221 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -3482,12 +3482,34 @@ mod handlers { if let Some(previous_model) = sess.previous_model().await && let Some(previous_context_item) = previous_context_item.as_ref() && previous_model != previous_context_item.model - && !update_items - .iter() - .any(Session::is_model_switch_developer_message) { - // Apply resume/fork model hydration only to model-switch diffing so it does not - // suppress other updates (for example personality changes). + // Rebase model-switch diffing on resume/fork model hydration so model-switch + // updates reflect rollout history while other diffs (for example personality) still + // use the real previous turn context. + let model_switch_insert_index = update_items + .iter() + .position(Session::is_model_switch_developer_message) + .or_else(|| { + update_items.iter().position(|item| { + let codex_protocol::models::ResponseItem::Message { + role, + content, + .. + } = item + else { + return false; + }; + role == "developer" + && content.iter().any(|content_item| { + matches!( + content_item, + codex_protocol::models::ContentItem::InputText { text } if text.starts_with("") + ) + }) + }) + }); + update_items.retain(|item| !Session::is_model_switch_developer_message(item)); + let mut previous_context_item_for_model_switch = previous_context_item.clone(); previous_context_item_for_model_switch.model = previous_model; if let Some(model_switch_item) = @@ -3496,7 +3518,11 @@ mod handlers { ¤t_context_item, ) { - update_items.push(model_switch_item); + if let Some(index) = model_switch_insert_index { + update_items.insert(index, model_switch_item); + } else { + update_items.push(model_switch_item); + } } } if !update_items.is_empty() { diff --git a/codex-rs/core/tests/suite/resume.rs b/codex-rs/core/tests/suite/resume.rs index 33844284b3..26c30b4554 100644 --- a/codex-rs/core/tests/suite/resume.rs +++ b/codex-rs/core/tests/suite/resume.rs @@ -472,3 +472,98 @@ async fn resume_model_hydration_does_not_suppress_personality_update() -> Result Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn resume_override_matching_rollout_model_skips_model_switch_update() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let mut builder = test_codex().with_config(|config| { + config.model = Some("gpt-5.2".to_string()); + }); + let initial = builder.build(&server).await?; + let codex = Arc::clone(&initial.codex); + let home = initial.home.clone(); + let rollout_path = initial + .session_configured + .rollout_path + .clone() + .expect("rollout path"); + + let initial_mock = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-initial"), + ev_assistant_message("msg-1", "Completed first turn"), + ev_completed("resp-initial"), + ]), + ) + .await; + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "record initial rollout model".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + let _ = initial_mock.single_request(); + + let resumed_mock = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-resume"), + ev_assistant_message("msg-2", "Resumed turn"), + ev_completed("resp-resume"), + ]), + ) + .await; + + let mut resume_builder = test_codex().with_config(|config| { + config.model = Some("gpt-5.2-codex".to_string()); + }); + let resumed = resume_builder.resume(&server, home, rollout_path).await?; + resumed + .codex + .submit(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + windows_sandbox_level: None, + model: Some("gpt-5.2".to_string()), + effort: None, + summary: None, + collaboration_mode: None, + personality: None, + }) + .await?; + resumed + .codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "first turn after override to rollout model".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&resumed.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let request = resumed_mock.single_request(); + let developer_texts = request.message_input_texts("developer"); + let model_switch_count = developer_texts + .iter() + .filter(|text| text.contains("")) + .count(); + assert_eq!( + model_switch_count, 0, + "did not expect model switch update when override matches rollout model" + ); + + Ok(()) +}