[codex] Compact when comp_hash changes (#27520)

## Summary
- snapshot `comp_hash` into `TurnContext` when the turn is created and
use that snapshot as the downstream source of truth
- persist the turn hash in rollout context and recover it into
previous-turn settings during resume and fork replay
- compact existing history with the previous model only when both
adjacent turns provide hashes and the values differ
- record `comp_hash_changed` as the compaction reason
- cover ordinary transitions, resume, and missing-hash compatibility
with end-to-end tests

## Why
History produced under one compaction-compatible model configuration may
not be safe to carry directly into another. Compacting at the turn
boundary converts that history before context updates and the new user
message are added. Persisting the turn snapshot in `TurnContextItem`
makes the same protection work after resuming a rollout.

A missing hash is not treated as evidence of incompatibility. `None →
Some`, `Some → None`, and `None → None` do not trigger compaction; only
`Some(previous) → Some(current)` with unequal values does.

## Stack
- depends on #27532
- #27532 is based directly on `main`

## Testing
- `just test -p codex-core pre_sampling_compact_` — 6 passed
- `just test -p codex-core
turn_context_item_uses_turn_context_comp_hash_snapshot` — passed
- `just fix -p codex-core -p codex-protocol -p codex-analytics -p
codex-models-manager`
This commit is contained in:
Ahmed Ibrahim
2026-06-10 21:11:26 -07:00
committed by GitHub
Unverified
parent 856855914f
commit ba4925b3c2
16 changed files with 557 additions and 3 deletions
+1
View File
@@ -364,6 +364,7 @@ pub enum CompactionReason {
UserRequested,
ContextLimit,
ModelDownshift,
CompHashChanged,
}
#[derive(Clone, Copy, Debug, Serialize)]
+1
View File
@@ -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,
};
@@ -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,
@@ -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,
+3 -1
View File
@@ -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<String>,
pub(crate) realtime_active: Option<bool>,
}
+1
View File
@@ -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,
@@ -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!(
@@ -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),
})
);
+23
View File
@@ -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;
+28 -2
View File
@@ -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(());
};
@@ -61,6 +61,7 @@ pub struct TurnContext {
pub config: Arc<Config>,
pub(crate) auth_manager: Option<Arc<AuthManager>>,
pub(crate) model_info: ModelInfo,
pub(crate) comp_hash: Option<String>,
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,
+463
View File
@@ -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<_>>(),
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::<RolloutLine>(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::<Value>(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<_>>(),
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!();
@@ -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,
+4
View File
@@ -2941,6 +2941,8 @@ pub struct TurnContextItem {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file_system_sandbox_policy: Option<FileSystemSandboxPolicy>,
pub model: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub comp_hash: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub personality: Option<Personality>,
#[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,
+1
View File
@@ -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,
+4
View File
@@ -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,