diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index a19fb05d6..560c86b2d 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -364,6 +364,7 @@ pub enum CompactionReason { UserRequested, ContextLimit, ModelDownshift, + CompHashChanged, } #[derive(Clone, Copy, Debug, Serialize)] diff --git a/codex-rs/core/src/compact_tests.rs b/codex-rs/core/src/compact_tests.rs index 69bd95f2d..550a7ec84 100644 --- a/codex-rs/core/src/compact_tests.rs +++ b/codex-rs/core/src/compact_tests.rs @@ -483,6 +483,7 @@ async fn process_compacted_history_reinjects_model_switch_message() { }]; let previous_turn_settings = PreviousTurnSettings { model: "previous-regular-model".to_string(), + comp_hash: None, realtime_active: None, }; diff --git a/codex-rs/core/src/context/environment_context_tests.rs b/codex-rs/core/src/context/environment_context_tests.rs index 49c3fc0c5..c8e491b68 100644 --- a/codex-rs/core/src/context/environment_context_tests.rs +++ b/codex-rs/core/src/context/environment_context_tests.rs @@ -178,6 +178,7 @@ fn turn_context_item_filesystem_uses_workspace_roots_instead_of_cwd() { network: None, file_system_sandbox_policy: None, model: "gpt-5".to_string(), + comp_hash: None, personality: None, collaboration_mode: None, multi_agent_version: None, diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index bb22749cb..f4bc87f5b 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -130,6 +130,7 @@ fn reference_context_item() -> TurnContextItem { network: None, file_system_sandbox_policy: None, model: "gpt-test".to_string(), + comp_hash: None, personality: None, collaboration_mode: None, multi_agent_version: None, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 9f83ff5d0..c97053480 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -276,10 +276,12 @@ impl SteerInputError { /// Conceptually this is the same role that `previous_model` used to fill, but /// it can carry other prior-turn settings that matter when constructing /// sensible state-change diffs or full-context reinjection, such as model -/// switches or detecting a prior `realtime_active -> false` transition. +/// switches, compaction compatibility, or detecting a prior +/// `realtime_active -> false` transition. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct PreviousTurnSettings { pub(crate) model: String, + pub(crate) comp_hash: Option, pub(crate) realtime_active: Option, } diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index 00a89b2b5..60c028036 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -114,6 +114,7 @@ pub(super) async fn spawn_review_thread( config: per_turn_config, auth_manager: auth_manager_for_context, model_info: model_info.clone(), + comp_hash: model_info.comp_hash.clone(), tool_mode, session_telemetry: session_telemetry_for_context, provider: provider_for_context, diff --git a/codex-rs/core/src/session/rollout_reconstruction.rs b/codex-rs/core/src/session/rollout_reconstruction.rs index 2d0327bf4..e88664851 100644 --- a/codex-rs/core/src/session/rollout_reconstruction.rs +++ b/codex-rs/core/src/session/rollout_reconstruction.rs @@ -184,6 +184,7 @@ impl Session { ) { active_segment.previous_turn_settings = Some(PreviousTurnSettings { model: ctx.model.clone(), + comp_hash: ctx.comp_hash.clone(), realtime_active: ctx.realtime_active, }); if matches!( diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index c84ee7f5f..ca8494168 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -70,6 +70,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ network: None, file_system_sandbox_policy: None, model: previous_model.to_string(), + comp_hash: None, personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, @@ -109,6 +110,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif network: None, file_system_sandbox_policy: None, model: previous_model.to_string(), + comp_hash: Some("comp-hash-a".to_string()), personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, @@ -166,6 +168,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif session.previous_turn_settings().await, Some(PreviousTurnSettings { model: previous_model.to_string(), + comp_hash: Some("comp-hash-a".to_string()), realtime_active: Some(turn_context.realtime_active), }) ); @@ -271,6 +274,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com reconstructed.previous_turn_settings, Some(PreviousTurnSettings { model: turn_context.model_info.slug.clone(), + comp_hash: None, realtime_active: Some(turn_context.realtime_active), }) ); @@ -364,6 +368,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc reconstructed.previous_turn_settings, Some(PreviousTurnSettings { model: turn_context.model_info.slug.clone(), + comp_hash: None, realtime_active: Some(turn_context.realtime_active), }) ); @@ -489,6 +494,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad reconstructed.previous_turn_settings, Some(PreviousTurnSettings { model: turn_context.model_info.slug.clone(), + comp_hash: None, realtime_active: Some(turn_context.realtime_active), }) ); @@ -589,6 +595,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { reconstructed.previous_turn_settings, Some(PreviousTurnSettings { model: turn_context.model_info.slug.clone(), + comp_hash: None, realtime_active: Some(turn_context.realtime_active), }) ); @@ -810,6 +817,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp session.previous_turn_settings().await, Some(PreviousTurnSettings { model: turn_context.model_info.slug.clone(), + comp_hash: None, realtime_active: Some(turn_context.realtime_active), }) ); @@ -963,6 +971,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis network: None, file_system_sandbox_policy: None, model: previous_model.to_string(), + comp_hash: None, personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, @@ -1024,6 +1033,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis session.previous_turn_settings().await, Some(PreviousTurnSettings { model: previous_model.to_string(), + comp_hash: None, realtime_active: Some(turn_context.realtime_active), }) ); @@ -1043,6 +1053,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis network: None, file_system_sandbox_policy: None, model: previous_model.to_string(), + comp_hash: None, personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, @@ -1072,6 +1083,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu network: None, file_system_sandbox_policy: None, model: previous_model.to_string(), + comp_hash: None, personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, @@ -1161,6 +1173,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu session.previous_turn_settings().await, Some(PreviousTurnSettings { model: previous_model.to_string(), + comp_hash: None, realtime_active: Some(turn_context.realtime_active), }) ); @@ -1192,6 +1205,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo network: None, file_system_sandbox_policy: None, model: current_model.to_string(), + comp_hash: None, personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, @@ -1281,6 +1295,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo session.previous_turn_settings().await, Some(PreviousTurnSettings { model: current_model.to_string(), + comp_hash: None, realtime_active: Some(turn_context.realtime_active), }) ); @@ -1310,6 +1325,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea network: None, file_system_sandbox_policy: None, model: previous_model.to_string(), + comp_hash: None, personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, @@ -1391,6 +1407,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea session.previous_turn_settings().await, Some(PreviousTurnSettings { model: previous_model.to_string(), + comp_hash: None, realtime_active: Some(turn_context.realtime_active), }) ); @@ -1441,6 +1458,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_preserves_turn_ session.previous_turn_settings().await, Some(PreviousTurnSettings { model: turn_context.model_info.slug.clone(), + comp_hash: None, realtime_active: Some(turn_context.realtime_active), }) ); @@ -1470,6 +1488,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear network: None, file_system_sandbox_policy: None, model: previous_model.to_string(), + comp_hash: None, personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, @@ -1563,6 +1582,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear session.previous_turn_settings().await, Some(PreviousTurnSettings { model: previous_model.to_string(), + comp_hash: None, realtime_active: Some(turn_context.realtime_active), }) ); diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index af2bfaa4c..843f8db88 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -2669,6 +2669,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { network: None, file_system_sandbox_policy: None, model: previous_model.to_string(), + comp_hash: None, personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, @@ -2721,6 +2722,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { session.previous_turn_settings().await, Some(PreviousTurnSettings { model: previous_model.to_string(), + comp_hash: None, realtime_active: Some(turn_context.realtime_active), }) ); @@ -2763,6 +2765,7 @@ async fn thread_rollback_drops_last_turn_from_history() { sess.persist_rollout_items(&rollout_items).await; sess.set_previous_turn_settings(Some(PreviousTurnSettings { model: "stale-model".to_string(), + comp_hash: None, realtime_active: Some(tc.realtime_active), })) .await; @@ -2945,6 +2948,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context .await; sess.set_previous_turn_settings(Some(PreviousTurnSettings { model: "stale-model".to_string(), + comp_hash: None, realtime_active: None, })) .await; @@ -2961,6 +2965,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context sess.previous_turn_settings().await, Some(PreviousTurnSettings { model: tc.model_info.slug.clone(), + comp_hash: None, realtime_active: Some(tc.realtime_active), }) ); @@ -3061,6 +3066,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::TurnContext(TurnContextItem { turn_id: Some(rolled_back_turn_id.clone()), model: "rolled-back-model".to_string(), + comp_hash: None, ..first_context_item.clone() }), RolloutItem::ResponseItem(user_message("turn 2 user")), @@ -7457,6 +7463,7 @@ async fn build_settings_update_items_uses_previous_turn_settings_for_realtime_en previous_context_item.realtime_active = None; let previous_turn_settings = PreviousTurnSettings { model: previous_context.model_info.slug.clone(), + comp_hash: None, realtime_active: Some(true), }; let mut current_context = previous_context @@ -8050,6 +8057,7 @@ async fn build_initial_context_uses_previous_turn_settings_for_realtime_end() { let (session, turn_context) = make_session_and_context().await; let previous_turn_settings = PreviousTurnSettings { model: turn_context.model_info.slug.clone(), + comp_hash: None, realtime_active: Some(true), }; @@ -8072,6 +8080,7 @@ async fn build_initial_context_restates_realtime_start_when_reference_context_is turn_context.realtime_active = true; let previous_turn_settings = PreviousTurnSettings { model: turn_context.model_info.slug.clone(), + comp_hash: None, realtime_active: Some(true), }; @@ -8105,6 +8114,18 @@ fn file_system_policy_with_unreadable_glob(turn_context: &TurnContext) -> FileSy policy } +#[tokio::test] +async fn turn_context_item_uses_turn_context_comp_hash_snapshot() { + let (_session, mut turn_context) = make_session_and_context().await; + turn_context.comp_hash = Some("turn-context-hash".to_string()); + turn_context.model_info.comp_hash = Some("model-info-hash".to_string()); + + assert_eq!( + turn_context.to_turn_context_item().comp_hash.as_deref(), + Some("turn-context-hash") + ); +} + #[tokio::test] async fn turn_context_item_omits_legacy_equivalent_file_system_sandbox_policy() { let (_session, turn_context) = make_session_and_context().await; @@ -8296,6 +8317,7 @@ async fn build_initial_context_prepends_model_switch_message() { let (session, turn_context) = make_session_and_context().await; let previous_turn_settings = PreviousTurnSettings { model: "previous-regular-model".to_string(), + comp_hash: None, realtime_active: None, }; @@ -8348,6 +8370,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(), + comp_hash: None, realtime_active: Some(previous_context.realtime_active), })) .await; diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index a694ed480..7e429dcac 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -172,6 +172,7 @@ pub(crate) async fn run_turn( .await; sess.set_previous_turn_settings(Some(PreviousTurnSettings { model: turn_context.model_info.slug.clone(), + comp_hash: turn_context.comp_hash.clone(), realtime_active: Some(turn_context.realtime_active), })) .await; @@ -798,8 +799,16 @@ async fn run_pre_sampling_compact( Ok(()) } -/// Runs pre-sampling compaction against the previous model when switching to a smaller -/// context-window model. +/// Returns true only when both turns declare compaction compatibility hashes and they differ. +/// A missing hash does not provide enough information to trigger compaction. +fn comp_hash_changed(previous: Option<&str>, current: Option<&str>) -> bool { + previous + .zip(current) + .is_some_and(|(previous, current)| previous != current) +} + +/// Runs pre-sampling compaction against the previous model when its compaction compatibility +/// hash changed or when switching to a smaller context-window model. /// /// Returns `Err(_)` only when compaction was attempted and failed. async fn maybe_run_previous_model_inline_compact( @@ -810,12 +819,29 @@ async fn maybe_run_previous_model_inline_compact( let Some(previous_turn_settings) = sess.previous_turn_settings().await else { return Ok(()); }; + let should_compact_for_comp_hash_change = comp_hash_changed( + previous_turn_settings.comp_hash.as_deref(), + turn_context.comp_hash.as_deref(), + ); let previous_model_turn_context = Arc::new( turn_context .with_model(previous_turn_settings.model, &sess.services.models_manager) .await, ); + if should_compact_for_comp_hash_change { + run_auto_compact( + sess, + &previous_model_turn_context, + client_session, + InitialContextInjection::DoNotInject, + CompactionReason::CompHashChanged, + CompactionPhase::PreTurn, + ) + .await?; + return Ok(()); + } + let Some(old_context_window) = previous_model_turn_context.model_context_window() else { return Ok(()); }; diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 10eff3f3b..32c209c88 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -61,6 +61,7 @@ pub struct TurnContext { pub config: Arc, pub(crate) auth_manager: Option>, pub(crate) model_info: ModelInfo, + pub(crate) comp_hash: Option, pub(crate) tool_mode: ToolMode, pub(crate) session_telemetry: SessionTelemetry, pub(crate) provider: SharedModelProvider, @@ -229,6 +230,7 @@ impl TurnContext { config: Arc::new(config), auth_manager: self.auth_manager.clone(), model_info: model_info.clone(), + comp_hash: model_info.comp_hash.clone(), tool_mode, session_telemetry: self .session_telemetry @@ -355,6 +357,7 @@ 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.comp_hash.clone(), personality: self.personality, collaboration_mode: Some(self.collaboration_mode.clone()), multi_agent_version: Some(self.multi_agent_version), @@ -531,6 +534,7 @@ impl Session { config: per_turn_config.clone(), auth_manager: auth_manager_for_context, model_info: model_info.clone(), + comp_hash: model_info.comp_hash.clone(), tool_mode, session_telemetry: session_telemetry_for_context, provider: provider_for_context, diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 339cadda4..c80d7241a 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -376,6 +376,12 @@ fn model_info_with_context_window(slug: &str, context_window: i64) -> ModelInfo model_info } +fn model_info_with_optional_comp_hash(slug: &str, comp_hash: Option<&str>) -> ModelInfo { + let mut model_info = model_info_with_context_window(slug, /*context_window*/ 273_000); + model_info.comp_hash = comp_hash.map(str::to_string); + model_info +} + fn assert_pre_sampling_switch_compaction_requests( first: &serde_json::Value, compact: &serde_json::Value, @@ -2189,6 +2195,203 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pre_sampling_compact_runs_when_comp_hash_changes() { + skip_if_no_network!(); + + let server = MockServer::start().await; + let previous_model = "gpt-5.3-codex"; + let next_model = "gpt-5.2"; + + let models_mock = mount_models_once( + &server, + ModelsResponse { + models: vec![ + model_info_with_optional_comp_hash(previous_model, Some("hash-a")), + model_info_with_optional_comp_hash(next_model, Some("hash-b")), + ], + }, + ) + .await; + + let request_log = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_assistant_message("m1", "before switch"), + ev_completed_with_tokens("r1", /*total_tokens*/ 100), + ]), + sse(vec![ + ev_assistant_message("m2", "COMP_HASH_SUMMARY"), + ev_completed_with_tokens("r2", /*total_tokens*/ 10), + ]), + sse(vec![ + ev_assistant_message("m3", "after switch"), + ev_completed_with_tokens("r3", /*total_tokens*/ 100), + ]), + ], + ) + .await; + + let model_provider = non_openai_model_provider(&server); + let mut builder = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_model(previous_model) + .with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + }); + let test = builder.build(&server).await.expect("build test codex"); + + test.codex + .submit(disabled_permission_user_turn( + "before switch", + test.cwd.path().to_path_buf(), + previous_model.to_string(), + )) + .await + .expect("submit first user turn"); + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + test.codex + .submit(disabled_permission_user_turn( + "after switch", + test.cwd.path().to_path_buf(), + next_model.to_string(), + )) + .await + .expect("submit second user turn"); + assert_compaction_uses_turn_lifecycle_id(&test.codex).await; + + let requests = request_log.requests(); + assert_eq!(models_mock.requests().len(), 1); + assert_eq!( + requests.len(), + 3, + "a comp-hash change should compact before sampling the next turn" + ); + assert_pre_sampling_switch_compaction_requests( + &requests[0].body_json(), + &requests[1].body_json(), + &requests[2].body_json(), + previous_model, + next_model, + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pre_sampling_compact_skips_when_either_comp_hash_is_missing() { + skip_if_no_network!(); + + let server = MockServer::start().await; + let model_without_hash = "gpt-5.4"; + let model_with_hash = "gpt-5.3-codex"; + let next_model_without_hash = "gpt-5.2"; + + let models_mock = mount_models_once( + &server, + ModelsResponse { + models: vec![ + model_info_with_optional_comp_hash(model_without_hash, /*comp_hash*/ None), + model_info_with_optional_comp_hash(model_with_hash, Some("hash-a")), + model_info_with_optional_comp_hash( + next_model_without_hash, + /*comp_hash*/ None, + ), + ], + }, + ) + .await; + + let request_log = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_assistant_message("m1", "before hash"), + ev_completed_with_tokens("r1", /*total_tokens*/ 100), + ]), + sse(vec![ + ev_assistant_message("m2", "hash introduced"), + ev_completed_with_tokens("r2", /*total_tokens*/ 100), + ]), + sse(vec![ + ev_assistant_message("m3", "hash removed"), + ev_completed_with_tokens("r3", /*total_tokens*/ 100), + ]), + ], + ) + .await; + + let model_provider = non_openai_model_provider(&server); + let mut builder = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_model(model_without_hash) + .with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + }); + let test = builder.build(&server).await.expect("build test codex"); + + test.codex + .submit(disabled_permission_user_turn( + "before hash", + test.cwd.path().to_path_buf(), + model_without_hash.to_string(), + )) + .await + .expect("submit first user turn"); + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + test.codex + .submit(disabled_permission_user_turn( + "hash introduced", + test.cwd.path().to_path_buf(), + model_with_hash.to_string(), + )) + .await + .expect("submit second user turn"); + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + test.codex + .submit(disabled_permission_user_turn( + "hash removed", + test.cwd.path().to_path_buf(), + next_model_without_hash.to_string(), + )) + .await + .expect("submit third user turn"); + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let requests = request_log.requests(); + assert_eq!(models_mock.requests().len(), 1); + assert_eq!( + requests + .iter() + .map(|request| request.body_json()["model"].as_str().map(str::to_string)) + .collect::>(), + vec![ + Some(model_without_hash.to_string()), + Some(model_with_hash.to_string()), + Some(next_model_without_hash.to_string()), + ] + ); + assert!(requests.iter().all(|request| { + !body_contains_text(&request.body_json().to_string(), SUMMARIZATION_PROMPT) + })); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn body_after_prefix_model_switch_budget_compacts_with_next_model() { skip_if_no_network!(); @@ -2404,6 +2607,266 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pre_sampling_compact_recovers_comp_hash_after_resume() { + skip_if_no_network!(); + + let server = MockServer::start().await; + let previous_model = "gpt-5.3-codex"; + let next_model = "gpt-5.2"; + + let models_mock = mount_models_once( + &server, + ModelsResponse { + models: vec![ + model_info_with_optional_comp_hash(previous_model, Some("hash-a")), + model_info_with_optional_comp_hash(next_model, Some("hash-b")), + ], + }, + ) + .await; + + let request_log = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_assistant_message("m1", "before resume"), + ev_completed_with_tokens("r1", /*total_tokens*/ 100), + ]), + sse(vec![ + ev_assistant_message("m2", "RESUMED_COMP_HASH_SUMMARY"), + ev_completed_with_tokens("r2", /*total_tokens*/ 10), + ]), + sse(vec![ + ev_assistant_message("m3", "after resume"), + ev_completed_with_tokens("r3", /*total_tokens*/ 100), + ]), + ], + ) + .await; + + let model_provider = non_openai_model_provider(&server); + let mut initial_builder = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_model(previous_model) + .with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + }); + let initial = initial_builder + .build(&server) + .await + .expect("build initial test codex"); + let home = initial.home.clone(); + let rollout_path = initial + .session_configured + .rollout_path + .clone() + .expect("rollout path"); + + initial + .codex + .submit(disabled_permission_user_turn( + "before resume", + initial.cwd.path().to_path_buf(), + previous_model.to_string(), + )) + .await + .expect("submit pre-resume turn"); + wait_for_event(&initial.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + initial + .codex + .submit(Op::Shutdown) + .await + .expect("shutdown initial session"); + wait_for_event(&initial.codex, |event| { + matches!(event, EventMsg::ShutdownComplete) + }) + .await; + + let rollout = fs::read_to_string(&rollout_path).expect("read rollout"); + let persisted_comp_hash = rollout + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .find_map(|line| match line.item { + RolloutItem::TurnContext(context) => context.comp_hash, + _ => None, + }); + assert_eq!(persisted_comp_hash.as_deref(), Some("hash-a")); + + let model_provider = non_openai_model_provider(&server); + let mut resumed_builder = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_model(previous_model) + .with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + }); + let resumed = resumed_builder + .resume(&server, home, rollout_path) + .await + .expect("resume codex"); + + resumed + .codex + .submit(disabled_permission_user_turn( + "after resume", + resumed.cwd.path().to_path_buf(), + next_model.to_string(), + )) + .await + .expect("submit resumed user turn"); + assert_compaction_uses_turn_lifecycle_id(&resumed.codex).await; + + let requests = request_log.requests(); + assert_eq!(models_mock.requests().len(), 1); + assert_eq!( + requests.len(), + 3, + "the resumed turn should compact using the comp hash recovered from rollout" + ); + assert_pre_sampling_switch_compaction_requests( + &requests[0].body_json(), + &requests[1].body_json(), + &requests[2].body_json(), + previous_model, + next_model, + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pre_sampling_compact_skips_missing_comp_hash_after_resume() { + skip_if_no_network!(); + + let server = MockServer::start().await; + let previous_model = "gpt-5.3-codex"; + let next_model = "gpt-5.2"; + + let models_mock = mount_models_once( + &server, + ModelsResponse { + models: vec![ + model_info_with_optional_comp_hash(previous_model, /*comp_hash*/ None), + model_info_with_optional_comp_hash(next_model, Some("hash-b")), + ], + }, + ) + .await; + + let request_log = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_assistant_message("m1", "before resume"), + ev_completed_with_tokens("r1", /*total_tokens*/ 100), + ]), + sse(vec![ + ev_assistant_message("m2", "after resume"), + ev_completed_with_tokens("r2", /*total_tokens*/ 100), + ]), + ], + ) + .await; + + let model_provider = non_openai_model_provider(&server); + let mut initial_builder = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_model(previous_model) + .with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + }); + let initial = initial_builder + .build(&server) + .await + .expect("build initial test codex"); + let home = initial.home.clone(); + let rollout_path = initial + .session_configured + .rollout_path + .clone() + .expect("rollout path"); + + initial + .codex + .submit(disabled_permission_user_turn( + "before resume", + initial.cwd.path().to_path_buf(), + previous_model.to_string(), + )) + .await + .expect("submit pre-resume turn"); + wait_for_event(&initial.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + initial + .codex + .submit(Op::Shutdown) + .await + .expect("shutdown initial session"); + wait_for_event(&initial.codex, |event| { + matches!(event, EventMsg::ShutdownComplete) + }) + .await; + + let rollout = fs::read_to_string(&rollout_path).expect("read rollout"); + let persisted_turn_context = rollout + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .find(|line| line["type"] == "turn_context") + .expect("persisted turn context"); + assert!(persisted_turn_context["payload"].get("comp_hash").is_none()); + + let model_provider = non_openai_model_provider(&server); + let mut resumed_builder = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_model(previous_model) + .with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + }); + let resumed = resumed_builder + .resume(&server, home, rollout_path) + .await + .expect("resume codex"); + + resumed + .codex + .submit(disabled_permission_user_turn( + "after resume", + resumed.cwd.path().to_path_buf(), + next_model.to_string(), + )) + .await + .expect("submit resumed user turn"); + wait_for_event(&resumed.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let requests = request_log.requests(); + assert_eq!(models_mock.requests().len(), 1); + assert_eq!( + requests + .iter() + .map(|request| request.body_json()["model"].as_str().map(str::to_string)) + .collect::>(), + vec![ + Some(previous_model.to_string()), + Some(next_model.to_string()), + ] + ); + assert!(requests.iter().all(|request| { + !body_contains_text(&request.body_json().to_string(), SUMMARIZATION_PROMPT) + })); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn auto_compact_persists_rollout_entries() { skip_if_no_network!(); diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 422489a86..212055975 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -37,6 +37,7 @@ fn resume_history( network: None, file_system_sandbox_policy: None, model: previous_model.to_string(), + comp_hash: None, personality: None, collaboration_mode: None, multi_agent_version: None, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 01e664c73..059c5692c 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2941,6 +2941,8 @@ pub struct TurnContextItem { #[serde(default, skip_serializing_if = "Option::is_none")] pub file_system_sandbox_policy: Option, pub model: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub comp_hash: Option, #[serde(skip_serializing_if = "Option::is_none")] pub personality: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -5162,6 +5164,7 @@ mod tests { assert_eq!(item.network, None); assert_eq!(item.file_system_sandbox_policy, None); + assert_eq!(item.comp_hash, None); Ok(()) } @@ -5222,6 +5225,7 @@ mod tests { }, ])), model: "gpt-5".to_string(), + comp_hash: None, personality: None, collaboration_mode: None, multi_agent_version: None, diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index 07029abe3..68896ad20 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -1144,6 +1144,7 @@ async fn resume_candidate_matches_cwd_reads_latest_turn_context() -> std::io::Re network: None, file_system_sandbox_policy: None, model: "test-model".to_string(), + comp_hash: None, personality: None, collaboration_mode: None, multi_agent_version: None, diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index 47285c5cf..c1c2e56c9 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -356,6 +356,7 @@ mod tests { network: None, file_system_sandbox_policy: None, model: "gpt-5".to_string(), + comp_hash: None, personality: None, collaboration_mode: None, multi_agent_version: None, @@ -394,6 +395,7 @@ mod tests { network: None, file_system_sandbox_policy: None, model: "gpt-5".to_string(), + comp_hash: None, personality: None, collaboration_mode: None, multi_agent_version: None, @@ -429,6 +431,7 @@ mod tests { network: None, file_system_sandbox_policy: None, model: "gpt-5".to_string(), + comp_hash: None, personality: None, collaboration_mode: None, multi_agent_version: None, @@ -460,6 +463,7 @@ mod tests { network: None, file_system_sandbox_policy: None, model: "gpt-5".to_string(), + comp_hash: None, personality: None, collaboration_mode: None, multi_agent_version: None,