From 08bfb1e7f25515599c5578dfa9a681ef5c30003c Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Tue, 17 Feb 2026 13:52:37 -0800 Subject: [PATCH] Preserve personality updates with resumed-model hydration --- codex-rs/core/src/codex.rs | 31 ++++++--- codex-rs/core/tests/suite/resume.rs | 103 ++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 9 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 318a14f425..7b08921ba8 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -3472,20 +3472,33 @@ mod handlers { // Attempt to inject input into current task. if let Err(SteerInputError::NoActiveTurn(items)) = sess.steer_input(items, None).await { sess.seed_initial_context_if_needed(¤t_context).await; - let mut previous_context_item = previous_context + let previous_context_item = previous_context .as_ref() .map(|context| context.to_turn_context_item(context.collaboration_mode.clone())); - if let Some(previous_model) = sess.previous_model().await - && let Some(previous_context_item) = previous_context_item.as_mut() - { - // Preserve resume/fork hydration behavior until previous/current context diffs are - // sourced solely from persisted TurnContextItem history. - previous_context_item.model = previous_model; - } let current_context_item = current_context.to_turn_context_item(current_context.collaboration_mode.clone()); - let update_items = sess + let mut update_items = sess .build_settings_update_items(previous_context_item.as_ref(), ¤t_context_item); + 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). + 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) = + crate::context_manager::updates::build_model_instructions_update_item( + Some(&previous_context_item_for_model_switch), + ¤t_context_item, + ) + { + update_items.push(model_switch_item); + } + } if !update_items.is_empty() { sess.record_conversation_items(¤t_context, &update_items) .await; diff --git a/codex-rs/core/tests/suite/resume.rs b/codex-rs/core/tests/suite/resume.rs index 261ec58089..33844284b3 100644 --- a/codex-rs/core/tests/suite/resume.rs +++ b/codex-rs/core/tests/suite/resume.rs @@ -1,6 +1,11 @@ use anyhow::Result; +use codex_core::config::types::Personality; +use codex_core::features::Feature; +use codex_core::protocol::AskForApproval; use codex_core::protocol::EventMsg; use codex_core::protocol::Op; +use codex_core::protocol::SandboxPolicy; +use codex_protocol::config_types::ReasoningSummary; use codex_protocol::user_input::ByteRange; use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; @@ -369,3 +374,101 @@ async fn resume_model_switch_is_not_duplicated_after_pre_turn_override() -> Resu Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn resume_model_hydration_does_not_suppress_personality_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 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; + initial + .codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "seed resumed history".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&initial.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()); + config.features.enable(Feature::Personality); + config.personality = Some(Personality::Pragmatic); + }); + let resumed = resume_builder.resume(&server, home, rollout_path).await?; + resumed + .codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "first resumed turn with personality change".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: resumed.cwd_path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + model: resumed.session_configured.model.clone(), + effort: resumed.config.model_reasoning_effort, + summary: ReasoningSummary::Auto, + collaboration_mode: None, + personality: Some(Personality::Friendly), + }) + .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"); + assert!( + developer_texts + .iter() + .any(|text| text.contains("")), + "expected model switch message on first post-resume turn" + ); + assert!( + developer_texts + .iter() + .any(|text| text.contains("")), + "expected personality update message when personality changes on first post-resume turn" + ); + + Ok(()) +}