From 9f43ae5740dd7a0864191e7fd9442731955fcd56 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Thu, 11 Jun 2026 02:43:58 +0000 Subject: [PATCH 1/6] add comp hash to model info --- codex-rs/app-server/tests/common/models_cache.rs | 1 + codex-rs/codex-api/tests/models_integration.rs | 1 + codex-rs/core/tests/suite/auto_review.rs | 1 + codex-rs/core/tests/suite/model_switching.rs | 2 ++ codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + codex-rs/core/tests/suite/personality.rs | 2 ++ codex-rs/core/tests/suite/remote_models.rs | 3 +++ codex-rs/core/tests/suite/rmcp_client.rs | 1 + codex-rs/core/tests/suite/spawn_agent_description.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 1 + codex-rs/models-manager/src/model_info.rs | 1 + codex-rs/protocol/src/openai_models.rs | 5 +++++ codex-rs/tools/src/tool_config_tests.rs | 1 + 13 files changed, 21 insertions(+) diff --git a/codex-rs/app-server/tests/common/models_cache.rs b/codex-rs/app-server/tests/common/models_cache.rs index 8233b1b2966e..2a0c10a27c4f 100644 --- a/codex-rs/app-server/tests/common/models_cache.rs +++ b/codex-rs/app-server/tests/common/models_cache.rs @@ -47,6 +47,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo { context_window: Some(272_000), max_context_window: None, auto_compact_token_limit: None, + comp_hash: None, effective_context_window_percent: 95, experimental_supported_tools: Vec::new(), input_modalities: default_input_modalities(), diff --git a/codex-rs/codex-api/tests/models_integration.rs b/codex-rs/codex-api/tests/models_integration.rs index 4ee6069898b7..5f421dc1ad80 100644 --- a/codex-rs/codex-api/tests/models_integration.rs +++ b/codex-rs/codex-api/tests/models_integration.rs @@ -93,6 +93,7 @@ async fn models_client_hits_models_endpoint() { context_window: Some(272_000), max_context_window: None, auto_compact_token_limit: None, + comp_hash: None, effective_context_window_percent: 95, experimental_supported_tools: Vec::new(), input_modalities: default_input_modalities(), diff --git a/codex-rs/core/tests/suite/auto_review.rs b/codex-rs/core/tests/suite/auto_review.rs index 5fe1cbc2f2b3..7dae7c4c6202 100644 --- a/codex-rs/core/tests/suite/auto_review.rs +++ b/codex-rs/core/tests/suite/auto_review.rs @@ -256,6 +256,7 @@ fn remote_model_with_auto_review_override(slug: &str, review_model: &str) -> Mod context_window: Some(272_000), max_context_window: None, auto_compact_token_limit: None, + comp_hash: None, effective_context_window_percent: 95, experimental_supported_tools: Vec::new(), } diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index d176b5bc2495..22363eabea3a 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -136,6 +136,7 @@ fn test_model_info( context_window: Some(272_000), max_context_window: None, auto_compact_token_limit: None, + comp_hash: None, effective_context_window_percent: 95, experimental_supported_tools: Vec::new(), } @@ -957,6 +958,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< context_window: Some(large_context_window), max_context_window: None, auto_compact_token_limit: None, + comp_hash: None, effective_context_window_percent, experimental_supported_tools: Vec::new(), }; diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index b343c4bbaf86..5103f8212384 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -365,6 +365,7 @@ fn test_remote_model(slug: &str, priority: i32) -> ModelInfo { context_window: Some(272_000), max_context_window: None, auto_compact_token_limit: None, + comp_hash: None, effective_context_window_percent: 95, experimental_supported_tools: Vec::new(), input_modalities: default_input_modalities(), diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 06c2fc41f935..a58e5f20fed2 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -587,6 +587,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow context_window: Some(128_000), max_context_window: None, auto_compact_token_limit: None, + comp_hash: None, effective_context_window_percent: 95, experimental_supported_tools: Vec::new(), input_modalities: default_input_modalities(), @@ -701,6 +702,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - context_window: Some(128_000), max_context_window: None, auto_compact_token_limit: None, + comp_hash: None, effective_context_window_percent: 95, experimental_supported_tools: Vec::new(), input_modalities: default_input_modalities(), diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index dc8df93c9b2a..b3fc9b9db7f9 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -499,6 +499,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { context_window: Some(272_000), max_context_window: None, auto_compact_token_limit: None, + comp_hash: None, effective_context_window_percent: 95, experimental_supported_tools: Vec::new(), }; @@ -751,6 +752,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { context_window: Some(272_000), max_context_window: None, auto_compact_token_limit: None, + comp_hash: None, effective_context_window_percent: 95, experimental_supported_tools: Vec::new(), }; @@ -1237,6 +1239,7 @@ fn test_remote_model_with_policy( context_window: Some(272_000), max_context_window: None, auto_compact_token_limit: None, + comp_hash: None, effective_context_window_percent: 95, experimental_supported_tools: Vec::new(), } diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 5cb61fa4a113..f38fea84e039 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -1344,6 +1344,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re context_window: Some(272_000), max_context_window: None, auto_compact_token_limit: None, + comp_hash: None, effective_context_window_percent: 95, experimental_supported_tools: Vec::new(), input_modalities: vec![InputModality::Text], diff --git a/codex-rs/core/tests/suite/spawn_agent_description.rs b/codex-rs/core/tests/suite/spawn_agent_description.rs index ba29b4f00c14..30aab8926e58 100644 --- a/codex-rs/core/tests/suite/spawn_agent_description.rs +++ b/codex-rs/core/tests/suite/spawn_agent_description.rs @@ -84,6 +84,7 @@ fn test_model_info( context_window: Some(272_000), max_context_window: None, auto_compact_token_limit: None, + comp_hash: None, effective_context_window_percent: 95, experimental_supported_tools: Vec::new(), } diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 58207885663b..9f149872f462 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -1374,6 +1374,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an context_window: Some(272_000), max_context_window: None, auto_compact_token_limit: None, + comp_hash: None, effective_context_window_percent: 95, experimental_supported_tools: Vec::new(), }; diff --git a/codex-rs/models-manager/src/model_info.rs b/codex-rs/models-manager/src/model_info.rs index 81a5c6e5edcd..566953dfc2d2 100644 --- a/codex-rs/models-manager/src/model_info.rs +++ b/codex-rs/models-manager/src/model_info.rs @@ -94,6 +94,7 @@ pub fn model_info_from_slug(slug: &str) -> ModelInfo { context_window: Some(272_000), max_context_window: Some(272_000), auto_compact_token_limit: None, + comp_hash: None, effective_context_window_percent: 95, experimental_supported_tools: Vec::new(), input_modalities: default_input_modalities(), diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index 1553ef3435ee..5b95fee29d35 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -389,6 +389,9 @@ pub struct ModelInfo { /// context window when available. #[serde(default, skip_serializing_if = "Option::is_none")] pub auto_compact_token_limit: Option, + /// Opaque identifier for compaction-compatible model configurations. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub comp_hash: Option, /// Percentage of the context window considered usable for inputs, after /// reserving headroom for system prompts, tool overhead, and model output. #[serde(default = "default_effective_context_window_percent")] @@ -671,6 +674,7 @@ mod tests { context_window: None, max_context_window: None, auto_compact_token_limit: None, + comp_hash: None, effective_context_window_percent: 95, experimental_supported_tools: vec![], input_modalities: default_input_modalities(), @@ -940,6 +944,7 @@ mod tests { assert_eq!(model.web_search_tool_type, WebSearchToolType::Text); assert!(!model.supports_search_tool); assert!(!model.use_responses_lite); + assert_eq!(model.comp_hash, None); assert_eq!(model.auto_review_model_override, None); assert_eq!(model.tool_mode, None); } diff --git a/codex-rs/tools/src/tool_config_tests.rs b/codex-rs/tools/src/tool_config_tests.rs index 0e3917d69773..7a4738772ca4 100644 --- a/codex-rs/tools/src/tool_config_tests.rs +++ b/codex-rs/tools/src/tool_config_tests.rs @@ -39,6 +39,7 @@ fn model_with_shell_type(shell_type: ConfigShellToolType) -> ModelInfo { context_window: None, max_context_window: None, auto_compact_token_limit: None, + comp_hash: None, effective_context_window_percent: 95, experimental_supported_tools: Vec::new(), input_modalities: codex_protocol::openai_models::default_input_modalities(), From 87118974041e386856e6308cb5de4a4efdb38825 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Thu, 11 Jun 2026 02:44:12 +0000 Subject: [PATCH 2/6] compact when comp hash changes --- codex-rs/analytics/src/facts.rs | 1 + codex-rs/core/src/compact_tests.rs | 1 + .../src/context/environment_context_tests.rs | 1 + .../core/src/context_manager/history_tests.rs | 1 + codex-rs/core/src/session/mod.rs | 4 +- .../src/session/rollout_reconstruction.rs | 1 + .../session/rollout_reconstruction_tests.rs | 20 ++ codex-rs/core/src/session/tests.rs | 11 + codex-rs/core/src/session/turn.rs | 19 +- codex-rs/core/src/session/turn_context.rs | 1 + codex-rs/core/tests/suite/compact.rs | 224 ++++++++++++++++++ codex-rs/core/tests/suite/resume_warning.rs | 1 + codex-rs/protocol/src/protocol.rs | 4 + codex-rs/rollout/src/recorder_tests.rs | 1 + codex-rs/state/src/extract.rs | 4 + 15 files changed, 291 insertions(+), 3 deletions(-) diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index a035bc98b983..a997c0395f8c 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 69bd95f2df0a..550a7ec8467c 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 49c3fc0c581e..c8e491b68c8f 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 bb22749cb382..f4bc87f5b8e8 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 bb66e8684e60..9e2e3ee7fd14 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -272,10 +272,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/rollout_reconstruction.rs b/codex-rs/core/src/session/rollout_reconstruction.rs index a4c042af0c83..5b149d2dbf9f 100644 --- a/codex-rs/core/src/session/rollout_reconstruction.rs +++ b/codex-rs/core/src/session/rollout_reconstruction.rs @@ -173,6 +173,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 9ee70dada323..0527ab91a3cf 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), }) ); @@ -809,6 +816,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), }) ); @@ -959,6 +967,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, @@ -1019,6 +1028,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), }) ); @@ -1038,6 +1048,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, @@ -1067,6 +1078,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, @@ -1155,6 +1167,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), }) ); @@ -1186,6 +1199,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, @@ -1275,6 +1289,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), }) ); @@ -1304,6 +1319,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, @@ -1384,6 +1400,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), }) ); @@ -1434,6 +1451,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), }) ); @@ -1463,6 +1481,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, @@ -1555,6 +1574,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 ef3f6dbfba71..6c92865ecae9 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -2542,6 +2542,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, @@ -2594,6 +2595,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), }) ); @@ -2636,6 +2638,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; @@ -2818,6 +2821,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; @@ -2834,6 +2838,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), }) ); @@ -2933,6 +2938,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")), @@ -7309,6 +7315,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 @@ -7902,6 +7909,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), }; @@ -7924,6 +7932,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), }; @@ -8148,6 +8157,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, }; @@ -8200,6 +8210,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 9d4e7d1e8288..e9bc3d5ce50b 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.model_info.comp_hash.clone(), realtime_active: Some(turn_context.realtime_active), })) .await; @@ -778,8 +779,8 @@ async fn run_pre_sampling_compact( Ok(()) } -/// Runs pre-sampling compaction against the previous model when switching to a smaller -/// context-window model. +/// 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( @@ -790,12 +791,26 @@ async fn maybe_run_previous_model_inline_compact( let Some(previous_turn_settings) = sess.previous_turn_settings().await else { return Ok(()); }; + let comp_hash_changed = previous_turn_settings.comp_hash != turn_context.model_info.comp_hash; let previous_model_turn_context = Arc::new( turn_context .with_model(previous_turn_settings.model, &sess.services.models_manager) .await, ); + if comp_hash_changed { + 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 be6e316a0072..3c9b09f360f5 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -355,6 +355,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.model_info.comp_hash.clone(), personality: self.personality, collaboration_mode: Some(self.collaboration_mode.clone()), multi_agent_version: Some(self.multi_agent_version), diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index ac497aff3b4c..b9aef36c5308 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -279,6 +279,12 @@ fn model_info_with_context_window(slug: &str, context_window: i64) -> ModelInfo model_info } +fn model_info_with_comp_hash(slug: &str, comp_hash: &str) -> ModelInfo { + let mut model_info = model_info_with_context_window(slug, /*context_window*/ 273_000); + model_info.comp_hash = Some(comp_hash.to_string()); + model_info +} + fn assert_pre_sampling_switch_compaction_requests( first: &serde_json::Value, compact: &serde_json::Value, @@ -2092,6 +2098,93 @@ 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_comp_hash(previous_model, "hash-a"), + model_info_with_comp_hash(next_model, "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 body_after_prefix_model_switch_budget_compacts_with_next_model() { skip_if_no_network!(); @@ -2307,6 +2400,137 @@ 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_comp_hash(previous_model, "hash-a"), + model_info_with_comp_hash(next_model, "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 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 422489a86aa3..212055975197 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 642f2e5dee3c..241dd91b3325 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2909,6 +2909,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")] @@ -5097,6 +5099,7 @@ mod tests { assert_eq!(item.network, None); assert_eq!(item.file_system_sandbox_policy, None); + assert_eq!(item.comp_hash, None); Ok(()) } @@ -5157,6 +5160,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 07029abe37eb..68896ad202ff 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 63cb06fea163..d463924db53c 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, From b5336d7c65a855c2be73a5d969f6261cc0a9016e Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Thu, 11 Jun 2026 03:07:06 +0000 Subject: [PATCH 3/6] use turn context comp hash snapshot --- codex-rs/core/src/session/review.rs | 1 + codex-rs/core/src/session/tests.rs | 12 +++ codex-rs/core/src/session/turn.rs | 8 +- codex-rs/core/src/session/turn_context.rs | 5 +- codex-rs/core/tests/suite/compact.rs | 119 ++++++++++++++++++++-- 5 files changed, 136 insertions(+), 9 deletions(-) diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index a3b23c6a78b5..a3dfb4897aaa 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/tests.rs b/codex-rs/core/src/session/tests.rs index 6c92865ecae9..3a86f1d989a7 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -7966,6 +7966,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; diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index e9bc3d5ce50b..6da515b200d7 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -172,7 +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.model_info.comp_hash.clone(), + comp_hash: turn_context.comp_hash.clone(), realtime_active: Some(turn_context.realtime_active), })) .await; @@ -791,7 +791,11 @@ async fn maybe_run_previous_model_inline_compact( let Some(previous_turn_settings) = sess.previous_turn_settings().await else { return Ok(()); }; - let comp_hash_changed = previous_turn_settings.comp_hash != turn_context.model_info.comp_hash; + let comp_hash_changed = previous_turn_settings + .comp_hash + .as_deref() + .zip(turn_context.comp_hash.as_deref()) + .is_some_and(|(previous, current)| previous != current); let previous_model_turn_context = Arc::new( turn_context .with_model(previous_turn_settings.model, &sess.services.models_manager) diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 3c9b09f360f5..fabd3089f5b9 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,7 +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.model_info.comp_hash.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), @@ -532,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 b9aef36c5308..6cde1f34ca56 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -279,9 +279,9 @@ fn model_info_with_context_window(slug: &str, context_window: i64) -> ModelInfo model_info } -fn model_info_with_comp_hash(slug: &str, comp_hash: &str) -> ModelInfo { +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 = Some(comp_hash.to_string()); + model_info.comp_hash = comp_hash.map(str::to_string); model_info } @@ -2110,8 +2110,8 @@ async fn pre_sampling_compact_runs_when_comp_hash_changes() { &server, ModelsResponse { models: vec![ - model_info_with_comp_hash(previous_model, "hash-a"), - model_info_with_comp_hash(next_model, "hash-b"), + model_info_with_optional_comp_hash(previous_model, Some("hash-a")), + model_info_with_optional_comp_hash(next_model, Some("hash-b")), ], }, ) @@ -2185,6 +2185,113 @@ async fn pre_sampling_compact_runs_when_comp_hash_changes() { ); } +#[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, None), + model_info_with_optional_comp_hash(model_with_hash, Some("hash-a")), + model_info_with_optional_comp_hash(next_model_without_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!(); @@ -2412,8 +2519,8 @@ async fn pre_sampling_compact_recovers_comp_hash_after_resume() { &server, ModelsResponse { models: vec![ - model_info_with_comp_hash(previous_model, "hash-a"), - model_info_with_comp_hash(next_model, "hash-b"), + model_info_with_optional_comp_hash(previous_model, Some("hash-a")), + model_info_with_optional_comp_hash(next_model, Some("hash-b")), ], }, ) From d2faba8e737ea11ebb34e67e1153a927367e40c0 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Thu, 11 Jun 2026 03:12:18 +0000 Subject: [PATCH 4/6] clarify comp hash comparison --- codex-rs/core/src/session/turn.rs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 6da515b200d7..a5fba3d6ff09 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -779,6 +779,14 @@ async fn run_pre_sampling_compact( Ok(()) } +/// 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. /// @@ -791,18 +799,17 @@ async fn maybe_run_previous_model_inline_compact( let Some(previous_turn_settings) = sess.previous_turn_settings().await else { return Ok(()); }; - let comp_hash_changed = previous_turn_settings - .comp_hash - .as_deref() - .zip(turn_context.comp_hash.as_deref()) - .is_some_and(|(previous, current)| previous != current); + 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 comp_hash_changed { + if should_compact_for_comp_hash_change { run_auto_compact( sess, &previous_model_turn_context, From 40b684f149d7b9eb861e7e1bdd07ccbc1ea1803e Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Thu, 11 Jun 2026 03:13:40 +0000 Subject: [PATCH 5/6] cover missing comp hash on resume --- codex-rs/core/tests/suite/compact.rs | 129 +++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 6cde1f34ca56..8474e1ad1fea 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -2638,6 +2638,135 @@ async fn pre_sampling_compact_recovers_comp_hash_after_resume() { ); } +#[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, 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!(); From 3cc928afc04c980727973669c55ef36ac33132a6 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Thu, 11 Jun 2026 03:23:37 +0000 Subject: [PATCH 6/6] fix argument comments in comp hash tests --- codex-rs/core/tests/suite/compact.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 8474e1ad1fea..c91e32b57ac2 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -2198,9 +2198,12 @@ async fn pre_sampling_compact_skips_when_either_comp_hash_is_missing() { &server, ModelsResponse { models: vec![ - model_info_with_optional_comp_hash(model_without_hash, None), + 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, None), + model_info_with_optional_comp_hash( + next_model_without_hash, + /*comp_hash*/ None, + ), ], }, ) @@ -2650,7 +2653,7 @@ async fn pre_sampling_compact_skips_missing_comp_hash_after_resume() { &server, ModelsResponse { models: vec![ - model_info_with_optional_comp_hash(previous_model, None), + model_info_with_optional_comp_hash(previous_model, /*comp_hash*/ None), model_info_with_optional_comp_hash(next_model, Some("hash-b")), ], },