From 8571b9eaa46e42c8f75e0842ac6f2b02f550c9f7 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Mon, 5 Oct 2026 16:00:17 +0000 Subject: [PATCH] Install full context in compaction replacement history (#51117) ## Why Manual, pre-turn, and post-turn compaction previously deferred rebuilding initial context until the next turn. Their saved replacement history could omit refreshed instructions and skill catalogs. ## What changed - Install initial context during local and remote compaction, before the last real user message or summary, keeping the compaction summary last. - Persist matching full world-state and turn-context baselines with every replacement instead of retaining only partial extension metadata. - Build replacement context and recompute token usage for the current model even when the previous model performs compaction. Reuse that captured context for eligible current-model retries. - Capture manual compaction context centrally and propagate preparation errors to the task runner. ## Testing Update assertions and snapshots for context ordering, refreshed instructions, resume and fork history, current-model checkpoint baselines, and cloud skill catalog retention across compaction and resume. GitOrigin-RevId: 8572a76c31c6884a3c580b3bbb350d49af876bd1 --- codex-rs/core/src/compact.rs | 119 +++++------ codex-rs/core/src/compact_model_fallback.rs | 22 +- codex-rs/core/src/compact_remote_v2.rs | 61 +++--- codex-rs/core/src/compact_tests.rs | 7 +- codex-rs/core/src/compact_token_budget.rs | 21 +- .../core/src/guardian/review_session_tests.rs | 4 +- codex-rs/core/src/guardian/tests.rs | 4 +- codex-rs/core/src/session/mod.rs | 51 ++--- codex-rs/core/src/session/tests.rs | 122 +++++------ .../core/src/session/tests/guardian_tests.rs | 8 +- codex-rs/core/src/session/turn.rs | 189 +++++++----------- codex-rs/core/src/tasks/compact.rs | 26 ++- codex-rs/core/tests/suite/compact.rs | 25 +-- .../core/tests/suite/compact_program_tests.rs | 60 +++--- .../core/tests/suite/compact_resume_fork.rs | 33 +-- codex-rs/core/tests/suite/skills_extension.rs | 17 +- ...t__manual_compact_with_history_shapes.snap | 40 ++-- ...nual_compact_without_prev_user_shapes.snap | 10 +- ...mpling_model_switch_compaction_shapes.snap | 14 +- ..._compaction_including_incoming_shapes.snap | 12 +- ...n_strips_incoming_model_switch_shapes.snap | 14 +- ...tra_kickoff_remote_compaction_windows.snap | 14 +- ...ios__compaction__remote_slash_compact.snap | 67 ++++++- ...narios__guardian_checkpoint_migration.snap | 42 ++-- ...loud_skills_across_executor_readiness.snap | 31 +-- ...lls_across_executor_readiness_windows.snap | 52 +++-- .../ext/extension-api/src/contributors.rs | 12 -- codex-rs/ext/skills/src/extension.rs | 20 -- 28 files changed, 513 insertions(+), 584 deletions(-) diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 39919cfef6..83f9774074 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -58,23 +58,6 @@ pub use codex_prompts::SUMMARIZATION_PROMPT; pub use codex_prompts::SUMMARY_PREFIX; const COMPACT_USER_MESSAGE_MAX_TOKENS: usize = 20_000; -/// Controls whether compaction replacement history must include initial context. -/// -/// Pre-turn/manual compaction variants use `DoNotInject`: they replace history with a summary and -/// clear `reference_context_item`, so the next regular turn will fully reinject initial context -/// after compaction. -/// -/// Mid-turn compaction must use `BeforeLastUserMessage` because the model is trained to see the -/// compaction summary as the last item in history after mid-turn compaction; we therefore inject -/// initial context into the replacement history just above the last real user message. -pub(crate) enum InitialContextInjection { - BeforeLastUserMessage { - world_state: Arc, - step_context: Arc, - }, - DoNotInject, -} - /// Metadata for a new compaction checkpoint, kept separate from its replacement history. /// /// `Session::replace_compacted_history` assigns missing item IDs before constructing the persisted @@ -89,32 +72,28 @@ pub(crate) struct CompactedHistoryMetadata { pub(crate) reviewer_compaction_hash: Option, } +/// Render replacement context and its comparison baseline from the same captured step. +/// Initial context is installed only after compaction returns, above the last real user message +/// (or the summary when no user message remains), keeping the compaction summary last. pub(crate) async fn build_compaction_initial_context( sess: &Session, - initial_context_injection: &InitialContextInjection, -) -> (Vec, Option) { - // Return the rendered state with its items so history and its baseline stay identical. - match initial_context_injection { - InitialContextInjection::BeforeLastUserMessage { - world_state, - step_context, - } => { - let (items, snapshot) = sess - .build_initial_context_with_world_state(step_context, world_state.as_ref()) - .await; - ( - items.into_iter().map(ResponseItemEnvelope::new).collect(), - Some(snapshot), - ) - } - InitialContextInjection::DoNotInject => (Vec::new(), None), - } + step_context: &StepContext, + world_state: &WorldState, +) -> (Vec, WorldStateSnapshot) { + let (items, snapshot) = sess + .build_initial_context_with_world_state(step_context, world_state) + .await; + ( + items.into_iter().map(ResponseItemEnvelope::new).collect(), + snapshot, + ) } pub(crate) async fn run_inline_auto_compact_task( sess: Arc, turn_context: Arc, - initial_context_injection: InitialContextInjection, + replacement_step_context: Arc, + world_state: Arc, reason: CompactionReason, phase: CompactionPhase, ) -> CodexResult<()> { @@ -133,11 +112,15 @@ pub(crate) async fn run_inline_auto_compact_task( run_compact_task_inner( sess, turn_context, + replacement_step_context, input, - initial_context_injection, - CompactionTrigger::Auto, - reason, - phase, + world_state, + CompactionTurnMetadata::new( + CompactionTrigger::Auto, + reason, + CompactionImplementation::Responses, + phase, + ), ) .await?; Ok(()) @@ -145,18 +128,22 @@ pub(crate) async fn run_inline_auto_compact_task( pub(crate) async fn run_compact_task( sess: Arc, - turn_context: Arc, + step_context: Arc, + world_state: Arc, input: Vec, ) -> CodexResult<()> { - sess.emit_turn_started(&turn_context).await; run_compact_task_inner( sess.clone(), - turn_context, + Arc::clone(&step_context.turn), + step_context, input, - InitialContextInjection::DoNotInject, - CompactionTrigger::Manual, - CompactionReason::UserRequested, - CompactionPhase::StandaloneTurn, + world_state, + CompactionTurnMetadata::new( + CompactionTrigger::Manual, + CompactionReason::UserRequested, + CompactionImplementation::Responses, + CompactionPhase::StandaloneTurn, + ), ) .await?; Ok(()) @@ -165,14 +152,14 @@ pub(crate) async fn run_compact_task( async fn run_compact_task_inner( sess: Arc, turn_context: Arc, + replacement_step_context: Arc, input: Vec, - initial_context_injection: InitialContextInjection, - trigger: CompactionTrigger, - reason: CompactionReason, - phase: CompactionPhase, + world_state: Arc, + compaction_metadata: CompactionTurnMetadata, ) -> CodexResult<()> { - let compaction_metadata = - CompactionTurnMetadata::new(trigger, reason, CompactionImplementation::Responses, phase); + let trigger = compaction_metadata.trigger(); + let reason = compaction_metadata.reason(); + let phase = compaction_metadata.phase(); let attempt = CompactionAnalyticsAttempt::begin( sess.as_ref(), turn_context.as_ref(), @@ -201,8 +188,9 @@ async fn run_compact_task_inner( let result = run_compact_task_inner_impl( Arc::clone(&sess), Arc::clone(&turn_context), + replacement_step_context, input, - initial_context_injection, + world_state, compaction_metadata, ) .await; @@ -250,8 +238,9 @@ async fn run_compact_task_inner( async fn run_compact_task_inner_impl( sess: Arc, turn_context: Arc, + replacement_step_context: Arc, input: Vec, - initial_context_injection: InitialContextInjection, + world_state: Arc, compaction_metadata: CompactionTurnMetadata, ) -> CodexResult { let compaction_item = TurnItem::ContextCompaction(ContextCompactionItem::new()); @@ -371,20 +360,13 @@ async fn run_compact_task_inner_impl( let (window_number, window_ids) = sess.advance_auto_compact_window().await; let (initial_context, world_state_baseline) = - build_compaction_initial_context(sess.as_ref(), &initial_context_injection).await; - if !initial_context.is_empty() { - new_history = - insert_initial_context_before_last_real_user_or_summary(new_history, initial_context); - } - let reference_context_item = match initial_context_injection { - InitialContextInjection::DoNotInject => None, - InitialContextInjection::BeforeLastUserMessage { step_context, .. } => { - Some(step_context.to_turn_context_item()) - } - }; + build_compaction_initial_context(sess.as_ref(), &replacement_step_context, &world_state) + .await; + new_history = + insert_initial_context_before_last_real_user_or_summary(new_history, initial_context); sess.replace_compacted_history( new_history, - reference_context_item, + replacement_step_context.to_turn_context_item(), world_state_baseline, CompactedHistoryMetadata { input_goal_ids, @@ -397,7 +379,8 @@ async fn run_compact_task_inner_impl( }, ) .await; - sess.recompute_token_usage(&turn_context).await; + sess.recompute_token_usage(&replacement_step_context.turn) + .await; sess.emit_turn_item_completed(&turn_context, compaction_item) .await; diff --git a/codex-rs/core/src/compact_model_fallback.rs b/codex-rs/core/src/compact_model_fallback.rs index 139c88879b..9e9a6213d5 100644 --- a/codex-rs/core/src/compact_model_fallback.rs +++ b/codex-rs/core/src/compact_model_fallback.rs @@ -1,3 +1,4 @@ +use crate::session::turn_context::TurnContext; use codex_analytics::CompactionImplementation; use codex_analytics::CompactionReason; use codex_otel::SessionTelemetry; @@ -6,13 +7,28 @@ use codex_protocol::error::CodexErrorDetails; use tracing::warn; /// Returns whether a failed compaction attempt should use the current model. -pub(crate) fn should_retry_with_current_model(error: &CodexErr) -> bool { - !matches!( +pub(crate) fn should_retry_with_current_model( + error: &CodexErr, + previous: &TurnContext, + current: &TurnContext, +) -> bool { + if matches!( error.details(), CodexErrorDetails::TurnAborted | CodexErrorDetails::Interrupted | CodexErrorDetails::SessionBudgetExceeded - ) + ) || (previous.model_info().slug == current.model_info().slug + && previous.cyber_access_program == current.cyber_access_program) + { + return false; + } + + current.provider.info().is_openai() + && current + .auth_manager + .as_deref() + .and_then(codex_login::AuthManager::auth_cached) + .is_some_and(|auth| auth.uses_codex_backend() || auth.is_api_key_auth()) } pub(crate) fn record_model_fallback( diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 56e4aace1b..e20b0a7f5c 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -7,7 +7,6 @@ use crate::client_common::ResponseEvent; use crate::compact::CompactedHistoryMetadata; use crate::compact::CompactionAnalyticsAttempt; use crate::compact::CompactionAnalyticsDetails; -use crate::compact::InitialContextInjection; use crate::compact::build_compaction_initial_context; use crate::compact::compaction_status_from_result; use crate::compact::insert_initial_context_before_last_real_user_or_summary; @@ -15,6 +14,7 @@ use crate::compact_model_fallback::record_model_fallback; use crate::compact_model_fallback::should_retry_with_current_model; use crate::compact_remote_history::HistoryItemGroup; use crate::compact_remote_history::history_item_groups; +use crate::context::world_state::WorldState; use crate::context_manager::estimate_item_token_count; use crate::hook_runtime::PostCompactHookOutcome; use crate::hook_runtime::PreCompactHookOutcome; @@ -56,7 +56,6 @@ use codex_rollout_trace::InferenceTraceContext; use codex_utils_output_truncation::approx_token_count; use codex_utils_output_truncation::truncate_text; use futures::StreamExt; -use tokio_util::sync::CancellationToken; #[path = "compact_remote_v2_attempt.rs"] mod attempt; @@ -81,9 +80,9 @@ const MAX_REMOTE_COMPACTION_V2_STREAM_RETRIES: u64 = 2; pub(crate) async fn run_inline_remote_auto_compact_task( sess: Arc, step_context: Arc, - fallback_step_context: Option>, + replacement_step_context: Arc, client_session: &mut ModelClientSession, - initial_context_injection: InitialContextInjection, + world_state: Arc, reason: CompactionReason, phase: CompactionPhase, ) -> CodexResult<()> { @@ -96,9 +95,9 @@ pub(crate) async fn run_inline_remote_auto_compact_task( run_remote_compact_task_inner( &sess, &step_context, - fallback_step_context.as_ref(), + &replacement_step_context, Some(client_session), - initial_context_injection, + world_state, compaction_metadata, ) .await @@ -106,14 +105,9 @@ pub(crate) async fn run_inline_remote_auto_compact_task( pub(crate) async fn run_remote_compact_task( sess: Arc, - turn_context: Arc, + step_context: Arc, + world_state: Arc, ) -> CodexResult<()> { - // Standalone compaction is its own request boundary, so it captures a fresh step. - let step_context = sess - .capture_step_context(Arc::clone(&turn_context), &CancellationToken::new()) - .await?; - sess.emit_turn_started(&turn_context).await; - let compaction_metadata = CompactionTurnMetadata::new( CompactionTrigger::Manual, CompactionReason::UserRequested, @@ -123,9 +117,9 @@ pub(crate) async fn run_remote_compact_task( run_remote_compact_task_inner( &sess, &step_context, - /*fallback_step_context*/ None, + &step_context, /*client_session*/ None, - InitialContextInjection::DoNotInject, + world_state, compaction_metadata, ) .await @@ -134,9 +128,9 @@ pub(crate) async fn run_remote_compact_task( async fn run_remote_compact_task_inner( sess: &Arc, step_context: &Arc, - fallback_step_context: Option<&Arc>, + replacement_step_context: &Arc, client_session: Option<&mut ModelClientSession>, - initial_context_injection: InitialContextInjection, + world_state: Arc, compaction_metadata: CompactionTurnMetadata, ) -> CodexResult<()> { let turn_context = &step_context.turn; @@ -176,9 +170,9 @@ async fn run_remote_compact_task_inner( let result = run_remote_compact_task_inner_impl( sess, step_context, - fallback_step_context, + replacement_step_context, client_session, - initial_context_injection, + world_state, compaction_metadata, &mut analytics_details, ) @@ -222,9 +216,9 @@ async fn run_remote_compact_task_inner( async fn run_remote_compact_task_inner_impl( sess: &Arc, step_context: &Arc, - fallback_step_context: Option<&Arc>, + replacement_step_context: &Arc, mut client_session: Option<&mut ModelClientSession>, - initial_context_injection: InitialContextInjection, + world_state: Arc, compaction_metadata: CompactionTurnMetadata, analytics_details: &mut CompactionAnalyticsDetails, ) -> CodexResult<()> { @@ -253,15 +247,12 @@ async fn run_remote_compact_task_inner_impl( let (attempt, compaction_turn_context) = match attempt { Ok(attempt) => (attempt, turn_context), Err(error) => { - let Some(fallback_step_context) = fallback_step_context else { - return Err(error); - }; - if !should_retry_with_current_model(&error) { + let fallback_turn_context = &replacement_step_context.turn; + if !should_retry_with_current_model(&error, turn_context, fallback_turn_context) { return Err(error); } - sess.set_last_known_step_context(fallback_step_context) + sess.set_last_known_step_context(replacement_step_context) .await; - let fallback_turn_context = &fallback_step_context.turn; let fallback_compaction_trace = sess.services.rollout_thread_trace.compaction_trace_context( fallback_turn_context.sub_id.as_str(), @@ -271,7 +262,7 @@ async fn run_remote_compact_task_inner_impl( ); let fallback_result = run_remote_compact_v2_attempt( sess, - fallback_step_context, + replacement_step_context, client_session, &fallback_compaction_trace, compaction_metadata, @@ -323,16 +314,11 @@ async fn run_remote_compact_task_inner_impl( analytics_details.retained_image_count = Some(retained_images); let (new_window_number, new_window_ids) = sess.advance_auto_compact_window().await; let (initial_context, world_state_baseline) = - build_compaction_initial_context(sess.as_ref(), &initial_context_injection).await; + build_compaction_initial_context(sess.as_ref(), replacement_step_context, &world_state) + .await; let new_history = insert_initial_context_before_last_real_user_or_summary(compacted_history, initial_context); - let reference_context_item = match initial_context_injection { - InitialContextInjection::DoNotInject => None, - InitialContextInjection::BeforeLastUserMessage { step_context, .. } => { - Some(step_context.to_turn_context_item()) - } - }; if let Some(trace_input_history) = trace_input_history.as_deref() { let replacement_history = new_history .iter() @@ -359,7 +345,7 @@ async fn run_remote_compact_task_inner_impl( }; sess.replace_compacted_history( new_history, - reference_context_item, + replacement_step_context.to_turn_context_item(), world_state_baseline, CompactedHistoryMetadata { input_goal_ids, @@ -372,7 +358,8 @@ async fn run_remote_compact_task_inner_impl( }, ) .await; - sess.recompute_token_usage(compaction_turn_context).await; + sess.recompute_token_usage(&replacement_step_context.turn) + .await; sess.emit_turn_item_completed(compaction_turn_context, compaction_item) .await; diff --git a/codex-rs/core/src/compact_tests.rs b/codex-rs/core/src/compact_tests.rs index 19d6ed2e5d..a407ffcb33 100644 --- a/codex-rs/core/src/compact_tests.rs +++ b/codex-rs/core/src/compact_tests.rs @@ -157,10 +157,15 @@ async fn local_compaction_respects_tool_metadata_state( ]), ) .await; + let step = session + .capture_step_context(turn, &tokio_util::sync::CancellationToken::new()) + .await?; + let world_state = Arc::new(session.build_world_state_for_step(&step).await?); // OpenAI identity keeps the client from removing passthrough for compatibility. run_compact_task( Arc::clone(&session), - turn, + step, + world_state, vec![UserInput::Text { text: "Summarize the conversation.".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/src/compact_token_budget.rs b/codex-rs/core/src/compact_token_budget.rs index 3e6ce1beaa..3d97b6864a 100644 --- a/codex-rs/core/src/compact_token_budget.rs +++ b/codex-rs/core/src/compact_token_budget.rs @@ -1,6 +1,5 @@ use std::sync::Arc; -use crate::compact::InitialContextInjection; use crate::context::world_state::WorldState; use crate::hook_runtime::PostCompactHookOutcome; use crate::hook_runtime::PreCompactHookOutcome; @@ -8,13 +7,11 @@ use crate::hook_runtime::run_post_compact_hooks; use crate::hook_runtime::run_pre_compact_hooks; use crate::session::session::Session; use crate::session::step_context::StepContext; -use crate::session::turn_context::TurnContext; use codex_analytics::CompactionTrigger; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; use codex_protocol::items::ContextCompactionItem; use codex_protocol::items::TurnItem; -use tokio_util::sync::CancellationToken; /// Runs token-budget manual compaction as a normal compaction lifecycle. /// @@ -23,15 +20,9 @@ use tokio_util::sync::CancellationToken; /// observe the same lifecycle as local or remote compaction. pub(crate) async fn run_manual_compact_task( sess: Arc, - turn_context: Arc, + step_context: Arc, + world_state: Arc, ) -> CodexResult<()> { - sess.emit_turn_started(&turn_context).await; - - // Manual compaction runs outside run_turn, so it captures its own current step. - let step_context = sess - .capture_step_context(Arc::clone(&turn_context), &CancellationToken::new()) - .await?; - let world_state = Arc::new(sess.build_world_state_for_step(&step_context).await?); run_compact_task_inner(&sess, &step_context, world_state, CompactionTrigger::Manual).await } @@ -43,14 +34,8 @@ pub(crate) async fn run_manual_compact_task( pub(crate) async fn run_inline_auto_compact_task( sess: Arc, step_context: Arc, - initial_context_injection: InitialContextInjection, + world_state: Arc, ) -> CodexResult<()> { - let world_state = match initial_context_injection { - InitialContextInjection::BeforeLastUserMessage { world_state, .. } => world_state, - InitialContextInjection::DoNotInject => { - Arc::new(sess.build_world_state_for_step(&step_context).await?) - } - }; run_compact_task_inner(&sess, &step_context, world_state, CompactionTrigger::Auto).await } diff --git a/codex-rs/core/src/guardian/review_session_tests.rs b/codex-rs/core/src/guardian/review_session_tests.rs index 7414760c66..b1fd75b719 100644 --- a/codex-rs/core/src/guardian/review_session_tests.rs +++ b/codex-rs/core/src/guardian/review_session_tests.rs @@ -82,8 +82,8 @@ async fn run_review_preserves_evidence_during_parent_compaction() { parent .replace_compacted_history( vec![checkpoint.into()], - /*reference_context_item*/ None, - /*world_state_baseline*/ None, + turn.to_turn_context_item(), + crate::context::world_state::WorldStateSnapshot::default(), crate::compact::CompactedHistoryMetadata { input_goal_ids: Default::default(), message: String::new(), diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 88792b6dda..bd4de5a580 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -2654,8 +2654,8 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow: .into_iter() .map(codex_history::ResponseItemEnvelope::new) .collect(), - /*reference_context_item*/ None, - /*world_state_baseline*/ None, + turn.to_turn_context_item(), + crate::context::world_state::WorldStateSnapshot::default(), crate::compact::CompactedHistoryMetadata { input_goal_ids: Default::default(), message: String::new(), diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index dea3a06f0d..7b14f934c9 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -3993,8 +3993,8 @@ impl Session { pub(crate) async fn replace_compacted_history( &self, mut items: Vec, - reference_context_item: Option, - world_state_baseline: Option, + reference_context_item: TurnContextItem, + world_state_baseline: WorldStateSnapshot, metadata: CompactedHistoryMetadata, ) { for envelope in &mut items { @@ -4024,20 +4024,9 @@ impl Session { // Wait for accepted updates to finish persisting, then keep later updates from // overtaking the current settings snapshot while its checkpoint is written. let _settings_guard = thread_settings::acquire_persistence_lock(self).await; - // A new history window needs a full checkpoint, even when it contains only - // extension metadata and model-visible context will be rebuilt on the next turn. - let mut world_state_item = None; + let world_state_item = WorldStateItem::full(world_state_baseline.clone().into_object()); let compacted_item = { let mut state = self.state.lock().await; - let snapshot = world_state_baseline.or_else(|| { - let previous = state.history.world_state_checkpoint()?; - let mut retained = serde_json::Map::new(); - for contributor in self.services.extensions.context_contributors() { - retained - .extend(contributor.retain_world_state_after_compaction(&previous.state)); - } - (!retained.is_empty()).then(|| WorldStateSnapshot::from(&retained)) - }); // Goal edits are published outside the running task. Keep edits accepted after // the compaction input snapshot, in their original order, after its replacement. let replacement_goal_ids = crate::context::UserGoalUpdate::message_ids( @@ -4060,16 +4049,13 @@ impl Session { let replacement_history = items.clone(); state.replace_annotated_history( items, - reference_context_item.clone(), + Some(reference_context_item.clone()), HistoryReplacement::Compaction { reviewer_compaction_hash: metadata.reviewer_compaction_hash, }, ); state.reasoning_effort_pin = ReasoningEffortPin::Compacted; - if let Some(snapshot) = snapshot { - world_state_item = Some(WorldStateItem::full(snapshot.clone().into_object())); - state.history.set_world_state_baseline(snapshot); - } + state.history.set_world_state_baseline(world_state_baseline); CompactedItem { message: metadata.message, replacement_history: Some(replacement_history), @@ -4093,18 +4079,13 @@ impl Session { } }; - let mut rollout_items = vec![RolloutItem::Compacted(compacted_item)]; - // Persist the baseline after the replacement history that established it. - if let Some(world_state_item) = world_state_item { - rollout_items.push(RolloutItem::WorldState(world_state_item)); - } - if let Some(turn_context_item) = reference_context_item { - rollout_items.push(RolloutItem::TurnContext(turn_context_item)); - } - // The frozen turn context must not override current settings in persisted metadata. - rollout_items.push(RolloutItem::EventMsg( - thread_settings::applied_event(self).await, - )); + let rollout_items = [ + RolloutItem::Compacted(compacted_item), + RolloutItem::WorldState(world_state_item), + RolloutItem::TurnContext(reference_context_item), + // The frozen turn context must not override current settings in persisted metadata. + RolloutItem::EventMsg(thread_settings::applied_event(self).await), + ]; if self.persist_rollout_items(&rollout_items).await && let Some(revision) = mcp_revision { @@ -4573,8 +4554,8 @@ impl Session { let turn_context_item = step_context.to_turn_context_item(); self.replace_compacted_history( context_items, - Some(turn_context_item), - Some(world_state_snapshot), + turn_context_item, + world_state_snapshot, CompactedHistoryMetadata { input_goal_ids, message: String::new(), @@ -4605,8 +4586,8 @@ impl Session { /// `` developer message so model-specific instructions are not lost. /// /// This is the normal runtime path that establishes a new `reference_context_item`. - /// Mid-turn compaction is the other path that can re-establish that reference when it - /// reinjects full initial context into replacement history. Live world-state changes may + /// Compaction also establishes that reference when it installs full initial context + /// into replacement history. Live world-state changes may /// independently advance their in-memory baseline within a turn. #[instrument(level = "trace", skip_all)] pub(crate) async fn record_context_updates_and_set_reference_context_item( diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 1c6bfab190..8e13dbf102 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -15,7 +15,6 @@ use super::*; mod config_refresh_tests; use crate::agents_md_manager::AgentsMdManager; use crate::agents_md_manager::SessionInstructions; -use crate::compact::InitialContextInjection; use crate::config::ConfigBuilder; use crate::config::ConfigOverrides; use crate::config::RuntimeConfigRefresh; @@ -5854,7 +5853,7 @@ async fn session_configuration_apply_preserves_absolute_cwd_write_root_on_cwd_up #[tokio::test] async fn settings_checkpoint_waits_for_accepted_settings_persistence() { - let (mut session, _turn_context, rx) = make_session_and_context_with_auth_and_config_and_rx( + let (mut session, turn_context, rx) = make_session_and_context_with_auth_and_config_and_rx( CodexAuth::from_api_key("Test API Key"), Vec::new(), |config| { @@ -5892,8 +5891,8 @@ async fn settings_checkpoint_waits_for_accepted_settings_persistence() { let mut checkpoint = Box::pin(tokio::task::unconstrained( session.replace_compacted_history( vec![ResponseItemEnvelope::new(user_message("compacted history"))], - /*reference_context_item*/ None, - /*world_state_baseline*/ None, + turn_context.to_turn_context_item(), + WorldStateSnapshot::default(), CompactedHistoryMetadata { input_goal_ids: Default::default(), message: "summary".to_string(), @@ -6026,8 +6025,8 @@ async fn mcp_attribution_checkpoints_cover_batch_prefixes_compaction_and_restore session .replace_compacted_history( vec![ResponseItemEnvelope::new(user_message("compacted history"))], - /*reference_context_item*/ None, - /*world_state_baseline*/ None, + turn_context.to_turn_context_item(), + WorldStateSnapshot::default(), CompactedHistoryMetadata { input_goal_ids: Default::default(), message: "summary".to_string(), @@ -6154,48 +6153,39 @@ async fn compaction_persists_resume_metadata_and_companion_records() { thread_settings: session.thread_settings_snapshot().await, }; - let mut first_live_history = None; - for with_baselines in [true, false] { - let input_goal_ids = - crate::context::UserGoalUpdate::message_ids(session.clone_history().await.raw_items()); - // The goal edit is accepted after the compaction input was captured. - let accepted_goal = if with_baselines { - session - .record_user_goal_update(crate::context::UserGoalUpdate::Clear) - .await - .expect("record concurrent clear"); - session - .clone_history() - .await - .annotated_items() - .last() - .cloned() - } else { - None - }; - let (window_number, window_ids) = session.advance_auto_compact_window().await; - session - .replace_compacted_history( - vec![ResponseItemEnvelope::new(user_message("compacted context"))], - with_baselines.then_some(turn_context_baseline.clone()), - with_baselines.then(|| world_state.render_full().0), - CompactedHistoryMetadata { - input_goal_ids, - message: String::new(), - window_number, - window_ids, - compaction_response_id: None, - compaction_model_hash: None, - reviewer_compaction_hash: None, - }, - ) - .await; - if let Some(accepted_goal) = accepted_goal { - let live_history = session.clone_history().await.annotated_items().to_vec(); - assert_eq!(&live_history[1..], &[accepted_goal]); - first_live_history = Some(live_history); - } - } + let input_goal_ids = + crate::context::UserGoalUpdate::message_ids(session.clone_history().await.raw_items()); + // The goal edit is accepted after the compaction input was captured. + session + .record_user_goal_update(crate::context::UserGoalUpdate::Clear) + .await + .expect("record concurrent clear"); + let accepted_goal = session + .clone_history() + .await + .annotated_items() + .last() + .cloned() + .expect("accepted goal"); + let (window_number, window_ids) = session.advance_auto_compact_window().await; + session + .replace_compacted_history( + vec![ResponseItemEnvelope::new(user_message("compacted context"))], + turn_context_baseline.clone(), + world_state.render_full().0, + CompactedHistoryMetadata { + input_goal_ids, + message: String::new(), + window_number, + window_ids, + compaction_response_id: None, + compaction_model_hash: None, + reviewer_compaction_hash: None, + }, + ) + .await; + let live_history = session.clone_history().await.annotated_items().to_vec(); + assert_eq!(&live_history[1..], &[accepted_goal]); session.flush_rollout().await.expect("flush checkpoints"); let (items, _, _) = RolloutRecorder::load_rollout_items(&rollout_path) @@ -6206,26 +6196,22 @@ async fn compaction_persists_resume_metadata_and_companion_records() { .skip_while(|item| !matches!(item, RolloutItem::Compacted(_))) .collect::>(); let [ - RolloutItem::Compacted(first), - RolloutItem::WorldState(first_world_state), - RolloutItem::TurnContext(first_turn_context), - RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(first_settings)), - RolloutItem::Compacted(second), - RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(second_settings)), + RolloutItem::Compacted(compacted), + RolloutItem::WorldState(saved_world_state), + RolloutItem::TurnContext(saved_turn_context), + RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(saved_settings)), ] = compaction_items.as_slice() else { panic!("unexpected compaction records: {compaction_items:#?}"); }; - assert_eq!(first.replacement_history, first_live_history); - assert_eq!(first.resume_metadata.as_ref(), Some(&expected)); - assert_eq!(second.resume_metadata.as_ref(), Some(&expected)); + assert_eq!(compacted.replacement_history, Some(live_history)); + assert_eq!(compacted.resume_metadata.as_ref(), Some(&expected)); assert_eq!( - first_world_state, + saved_world_state, &WorldStateItem::full(world_state.render_full().0.into_object()) ); - assert_eq!(first_turn_context, &turn_context_baseline); - assert_eq!(first_settings, &expected_settings); - assert_eq!(second_settings, &expected_settings); + assert_eq!(saved_turn_context, &turn_context_baseline); + assert_eq!(saved_settings, &expected_settings); } #[tokio::test] @@ -11312,12 +11298,8 @@ async fn build_initial_context_uses_retained_step_after_model_change() { .await .unwrap(); let world_a = Arc::new(session.build_world_state_for_step(&step_a).await.unwrap()); - let retained = crate::compact::InitialContextInjection::BeforeLastUserMessage { - world_state: Arc::clone(&world_a), - step_context: Arc::clone(&step_a), - }; let (initial_a, _) = - crate::compact::build_compaction_initial_context(&session, &retained).await; + crate::compact::build_compaction_initial_context(&session, &step_a, &world_a).await; let mut selected_b = step_a.settings.selected().clone(); selected_b.collaboration_mode.settings.model = "model-b".to_string(); @@ -11348,10 +11330,10 @@ async fn build_initial_context_uses_retained_step_after_model_change() { .0; let turn_contributions_b = session.build_turn_context_contribution_items(&step_b).await; let (restored_a, restored_snapshot) = - crate::compact::build_compaction_initial_context(&session, &retained).await; + crate::compact::build_compaction_initial_context(&session, &step_a, &world_a).await; assert_eq!(restored_a, initial_a); - assert_eq!(restored_snapshot, Some(world_a.render_full().0)); + assert_eq!(restored_snapshot, world_a.render_full().0); let initial_a = initial_a .into_iter() .map(ResponseItemEnvelope::into_item) @@ -11943,9 +11925,9 @@ async fn remote_compaction_v2_retains_only_the_selected_step(first_attempt: Firs crate::compact_remote_v2::run_inline_remote_auto_compact_task( Arc::clone(&session), Arc::clone(&primary), - Some(Arc::clone(&fallback)), + Arc::clone(&fallback), &mut client_session, - InitialContextInjection::DoNotInject, + Arc::new(session.build_world_state_for_step(&fallback).await.unwrap()), CompactionReason::ModelDownshift, CompactionPhase::PreTurn, ) diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index 59a65e10a6..6eee4d3939 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -1,5 +1,4 @@ use super::*; -use crate::compact::InitialContextInjection; use crate::config::Constrained; use crate::exec_policy::ExecPolicyManager; use crate::guardian::GUARDIAN_REVIEWER_NAME; @@ -1118,13 +1117,8 @@ async fn compaction_initial_context_preserves_separate_guardian_developer_messag .await .expect("world state should build"), ); - let initial_context_injection = InitialContextInjection::BeforeLastUserMessage { - world_state, - step_context, - }; - let (refreshed, _) = - crate::compact::build_compaction_initial_context(&session, &initial_context_injection) + crate::compact::build_compaction_initial_context(&session, &step_context, &world_state) .await; let developer_messages = refreshed diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index b5147caa73..413b3103e3 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -6,12 +6,12 @@ use std::sync::atomic::Ordering; use crate::client::ModelClientSession; use crate::client_common::Prompt; use crate::client_common::ResponseEvent; -use crate::compact::InitialContextInjection; use crate::compact::run_inline_auto_compact_task; use crate::compact_remote_v2::run_inline_remote_auto_compact_task as run_inline_remote_auto_compact_task_v2; use crate::connectors; use crate::context::ContextualUserFragment; use crate::context::UserVerificationNotice; +use crate::context::world_state::WorldState; use crate::cyber_access_program; use crate::environment_selection::TurnEnvironmentSnapshot; use crate::feedback_tags; @@ -346,9 +346,9 @@ pub(crate) async fn run_turn( run_auto_compact( &sess, Arc::clone(&first_step_context), - /*fallback_step_context*/ None, + Arc::clone(&first_step_context), &mut client_session, - InitialContextInjection::DoNotInject, + Arc::clone(&world_state), CompactionReason::ContextLimit, CompactionPhase::PreTurn, ) @@ -614,12 +614,9 @@ pub(crate) async fn run_turn( if let Err(err) = run_auto_compact( &sess, Arc::clone(&step_context), - /*fallback_step_context*/ None, + Arc::clone(&step_context), &mut client_session, - InitialContextInjection::BeforeLastUserMessage { - world_state: Arc::clone(&world_state), - step_context: Arc::clone(&step_context), - }, + Arc::clone(&world_state), CompactionReason::ContextLimit, CompactionPhase::MidTurn, ) @@ -726,9 +723,9 @@ pub(crate) async fn run_turn( && let Err(err) = run_auto_compact( &sess, Arc::clone(&step_context), - /*fallback_step_context*/ None, + Arc::clone(&step_context), &mut client_session, - InitialContextInjection::DoNotInject, + Arc::clone(&world_state), CompactionReason::ContextLimit, CompactionPhase::PostTurn, ) @@ -777,12 +774,9 @@ pub(crate) async fn run_turn( run_auto_compact( &sess, Arc::clone(&step_context), - /*fallback_step_context*/ None, + Arc::clone(&step_context), &mut client_session, - InitialContextInjection::BeforeLastUserMessage { - world_state: Arc::clone(&world_state), - step_context: Arc::clone(&step_context), - }, + Arc::clone(&world_state), CompactionReason::ContextLimit, CompactionPhase::MidTurn, ) @@ -1318,12 +1312,13 @@ async fn run_pre_sampling_compact( let step_context = sess .capture_step_context(Arc::clone(turn_context), cancellation_token) .await?; + let world_state = Arc::new(sess.build_world_state_for_step(&step_context).await?); run_auto_compact( sess, + Arc::clone(&step_context), step_context, - /*fallback_step_context*/ None, client_session, - InitialContextInjection::DoNotInject, + world_state, CompactionReason::ContextLimit, CompactionPhase::PreTurn, ) @@ -1340,33 +1335,6 @@ fn comp_hash_changed(previous: Option<&str>, current: Option<&str>) -> bool { .is_some_and(|(previous, current)| previous != current) } -/// Captures the current model's request-scoped state for retrying previous-model compaction. -/// -/// Returns `None` when auth uses neither the Codex backend nor an API key, the provider is -/// not OpenAI, or the previous and current model/program pairs are the same. -async fn capture_current_model_fallback_step_context( - sess: &Arc, - turn_context: &Arc, - previous_turn_context: &TurnContext, - cancellation_token: &CancellationToken, -) -> CodexResult>> { - let supports_fallback = turn_context - .auth_manager - .as_deref() - .and_then(codex_login::AuthManager::auth_cached) - .is_some_and(|auth| auth.uses_codex_backend() || auth.is_api_key_auth()); - if !supports_fallback - || !turn_context.provider.info().is_openai() - || (previous_turn_context.model_info().slug == turn_context.model_info().slug - && previous_turn_context.cyber_access_program == turn_context.cyber_access_program) - { - return Ok(None); - } - sess.capture_speculative_step_context(Arc::clone(turn_context), cancellation_token) - .await - .map(Some) -} - /// Runs pre-sampling compaction against the previous model when its compaction compatibility /// hash changed or when switching to a smaller context-window model. /// @@ -1401,79 +1369,65 @@ async fn maybe_run_previous_model_inline_compact( ); let previous_model_turn_context = Arc::new(previous_model_turn_context); - if should_compact_for_comp_hash_change { - let step_context = sess - .capture_step_context(Arc::clone(&previous_model_turn_context), cancellation_token) - .await?; - let fallback_step_context = capture_current_model_fallback_step_context( - sess, - turn_context, - &previous_model_turn_context, - cancellation_token, - ) - .await?; - run_auto_compact( - sess, - step_context, - fallback_step_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(()); - }; - let Some(new_context_window) = turn_context.model_context_window() else { - return Ok(()); - }; - let active_context_tokens = sess.get_total_token_usage().await; - let previous_model_limit_reached = match turn_context - .config - .model_auto_compact_token_limit_scope - { - AutoCompactTokenLimitScope::Total => { - let new_auto_compact_limit = turn_context - .model_info() - .auto_compact_token_limit() - .unwrap_or(i64::MAX); - active_context_tokens > new_auto_compact_limit - || active_context_tokens >= new_context_window + let reason = if should_compact_for_comp_hash_change { + CompactionReason::CompHashChanged + } else { + let Some(old_context_window) = previous_model_turn_context.model_context_window() else { + return Ok(()); + }; + let Some(new_context_window) = turn_context.model_context_window() else { + return Ok(()); + }; + let active_context_tokens = sess.get_total_token_usage().await; + let previous_model_limit_reached = + match turn_context.config.model_auto_compact_token_limit_scope { + AutoCompactTokenLimitScope::Total => { + let new_auto_compact_limit = turn_context + .model_info() + .auto_compact_token_limit() + .unwrap_or(i64::MAX); + active_context_tokens > new_auto_compact_limit + || active_context_tokens >= new_context_window + } + AutoCompactTokenLimitScope::BodyAfterPrefix => { + active_context_tokens >= new_context_window + } + }; + if !previous_model_limit_reached + || previous_model_turn_context.model_info().slug == turn_context.model_info().slug + || old_context_window <= new_context_window + { + return Ok(()); } - AutoCompactTokenLimitScope::BodyAfterPrefix => active_context_tokens >= new_context_window, + CompactionReason::ModelDownshift }; - let should_run = previous_model_limit_reached - && previous_model_turn_context.model_info().slug != turn_context.model_info().slug - && old_context_window > new_context_window; - if should_run { - let step_context = sess - .capture_step_context(Arc::clone(&previous_model_turn_context), cancellation_token) - .await?; - let fallback_step_context = capture_current_model_fallback_step_context( - sess, - turn_context, - &previous_model_turn_context, - cancellation_token, - ) + + let step_context = sess + .capture_step_context(previous_model_turn_context, cancellation_token) .await?; - run_auto_compact( - sess, - step_context, - fallback_step_context, - client_session, - InitialContextInjection::DoNotInject, - CompactionReason::ModelDownshift, - CompactionPhase::PreTurn, - ) + // The previous model summarizes the old window; the current model owns its replacement. + // Reuse the replacement step if the compaction request needs to fall back to that model. + let replacement_step_context = sess + .capture_speculative_step_context(Arc::clone(turn_context), cancellation_token) .await?; - } + let world_state = Arc::new( + sess.build_world_state_for_step(&replacement_step_context) + .await?, + ); + run_auto_compact( + sess, + step_context, + replacement_step_context, + client_session, + world_state, + reason, + CompactionPhase::PreTurn, + ) + .await?; Ok(()) } +/// `world_state` belongs to `replacement_step_context`, which may differ from the compactor. #[instrument( level = "trace", skip_all, @@ -1482,9 +1436,9 @@ async fn maybe_run_previous_model_inline_compact( async fn run_auto_compact( sess: &Arc, step_context: Arc, - fallback_step_context: Option>, + replacement_step_context: Arc, client_session: &mut ModelClientSession, - initial_context_injection: InitialContextInjection, + world_state: Arc, reason: CompactionReason, phase: CompactionPhase, ) -> CodexResult<()> { @@ -1501,8 +1455,8 @@ async fn run_auto_compact( // instead of consuming a pending `new_context` tool request. crate::compact_token_budget::run_inline_auto_compact_task( Arc::clone(sess), - step_context, - initial_context_injection, + replacement_step_context, + world_state, ) .await?; return Ok(()); @@ -1518,9 +1472,9 @@ async fn run_auto_compact( run_inline_remote_auto_compact_task_v2( Arc::clone(sess), step_context, - fallback_step_context, + replacement_step_context, client_session, - initial_context_injection, + world_state, reason, phase, ) @@ -1535,7 +1489,8 @@ async fn run_auto_compact( run_inline_auto_compact_task( Arc::clone(sess), Arc::clone(turn_context), - initial_context_injection, + replacement_step_context, + world_state, reason, phase, ) diff --git a/codex-rs/core/src/tasks/compact.rs b/codex-rs/core/src/tasks/compact.rs index baa8a639e1..62808c9e60 100644 --- a/codex-rs/core/src/tasks/compact.rs +++ b/codex-rs/core/src/tasks/compact.rs @@ -31,7 +31,7 @@ impl SessionTask for CompactTask { session: Arc, ctx: Arc, _input: Vec, - _cancellation_token: CancellationToken, + cancellation_token: CancellationToken, ) -> SessionTaskResult { let _profile_guard = ctx.turn_timing_state.begin_compaction(); let _compaction_span = tracing::trace_span!( @@ -40,8 +40,19 @@ impl SessionTask for CompactTask { conversation.id = %session.thread_id, turn.id = %ctx.sub_id, ); + // Preparation errors must reach the task runner, which reports them to the client. + session.emit_turn_started(&ctx).await; + let step_context = session + .capture_step_context(Arc::clone(&ctx), &cancellation_token) + .await?; + let world_state = Arc::new(session.build_world_state_for_step(&step_context).await?); if ctx.config.features.enabled(Feature::TokenBudget) { - crate::compact_token_budget::run_manual_compact_task(session, ctx).await?; + crate::compact_token_budget::run_manual_compact_task( + session, + step_context, + world_state, + ) + .await?; return Ok(None); } @@ -52,8 +63,12 @@ impl SessionTask for CompactTask { "remote_v2", /*manual*/ true, ); - crate::compact_remote_v2::run_remote_compact_task(session.clone(), Arc::clone(&ctx)) - .await + crate::compact_remote_v2::run_remote_compact_task( + session.clone(), + step_context, + world_state, + ) + .await } RemoteCompactionSupport::Unsupported => { emit_compact_metric( @@ -71,7 +86,8 @@ impl SessionTask for CompactTask { // Compaction prompt is synthesized; no UI element ranges to preserve. text_elements: Vec::new(), }]; - crate::compact::run_compact_task(session.clone(), Arc::clone(&ctx), input).await + crate::compact::run_compact_task(session.clone(), step_context, world_state, input) + .await } }; if let Err(err) = result { diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 08a9df5d9b..080a161dad 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -735,7 +735,7 @@ async fn summarize_context_three_requests_and_instructions( // Verify rollout contains user-turn TurnContext entries and a Compacted entry. println!("rollout path: {}", rollout_path.display()); let text = std::fs::read_to_string(&rollout_path).expect("failed to read rollout file"); - let mut regular_turn_context_count = 0usize; + let mut turn_context_count = 0usize; let mut saw_compacted_summary = false; for line in text.lines() { let trimmed = line.trim(); @@ -747,7 +747,7 @@ async fn summarize_context_three_requests_and_instructions( }; match entry.item { RolloutItem::TurnContext(_) => { - regular_turn_context_count += 1; + turn_context_count += 1; } RolloutItem::Compacted(ci) if ci.message == expected_summary_message => { let summary_item = ci @@ -780,8 +780,8 @@ async fn summarize_context_three_requests_and_instructions( } assert_eq!( - regular_turn_context_count, 2, - "rollout should contain one TurnContext entry per real user turn" + turn_context_count, 3, + "rollout should contain two user-turn contexts and the compaction baseline" ); assert!( saw_compacted_summary, @@ -4463,15 +4463,10 @@ async fn manual_compact_twice_preserves_latest_user_messages() { final_request_last_user_text, final_user_message, "final turn request should end with the submitted user message" ); - let history_before_seeded_prefix = final_request_before_last_user - .strip_suffix(initial_seeded_user_prefix) - .expect("final request should end with the seeded user prefix from the first request"); - let expected_history = vec![ - first_user_message.to_string(), - second_user_message.to_string(), - expected_second_summary, - ]; - assert_eq!(history_before_seeded_prefix, expected_history.as_slice()); + let mut expected_history = vec![first_user_message.to_string()]; + expected_history.extend_from_slice(initial_seeded_user_prefix); + expected_history.extend([second_user_message.to_string(), expected_second_summary]); + assert_eq!(final_request_before_last_user, expected_history.as_slice()); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -5825,8 +5820,8 @@ async fn remote_v2_compaction_refreshes_instructions_and_preserves_them_on_cold_ let replacement_history = replacement_history_from_rollout(&rollout_path)?; assert_eq!( instruction_fragments_in_items(&replacement_history), - Vec::::new(), - "remote-v2 replacement history currently omits the global-instruction fragment" + vec![new_fragment.clone()], + "remote-v2 replacement history includes refreshed global instructions" ); assert_eq!( test.codex.instruction_sources().await, diff --git a/codex-rs/core/tests/suite/compact_program_tests.rs b/codex-rs/core/tests/suite/compact_program_tests.rs index 808fffe145..67d42b69e8 100644 --- a/codex-rs/core/tests/suite/compact_program_tests.rs +++ b/codex-rs/core/tests/suite/compact_program_tests.rs @@ -17,7 +17,6 @@ enum History { Fork, Rollback, IncompleteRollback, - Checkpoint, ApiKeyResume, ApiKeyEnabledResume, ApiKeyCustomProviderResume, @@ -71,7 +70,6 @@ async fn submit_pair( #[test_case(Compaction::RemoteHash, History::Fork, Some(CyberAccessProgram::DaybreakBlue); "fork")] #[test_case(Compaction::RemoteHash, History::Rollback, Some(CyberAccessProgram::DaybreakBlue); "rollback")] #[test_case(Compaction::RemoteHash, History::IncompleteRollback, Some(CyberAccessProgram::DaybreakBlue); "incomplete rollback")] -#[test_case(Compaction::RemoteHash, History::Checkpoint, Some(CyberAccessProgram::DaybreakBlue); "checkpoint")] #[test_case(Compaction::RemoteHash, History::Resume, None; "missing program")] #[test_case(Compaction::RemoteHash, History::Resume, Some(CyberAccessProgram::Standard); "standard")] #[test_case(Compaction::LocalHash, History::Live, None; "local missing program")] @@ -165,30 +163,6 @@ async fn model_switch_program_pair( discarded_model ); } - if matches!(history, History::Checkpoint) { - let compact = mount_sse_once( - &server, - sse(vec![ - json!({"type": "response.output_item.done", "item": { - "type": "compaction", "encrypted_content": "CHECKPOINT" - }}), - ev_completed("checkpoint"), - ]), - ) - .await; - initial.codex.submit(Op::Compact).await?; - wait_for_event(&initial.codex, |event| { - matches!(event, EventMsg::TurnComplete(_)) - }) - .await; - assert_eq!( - compact - .single_request() - .inputs_of_type("compaction_trigger") - .len(), - 1 - ); - } let resumed; let thread = match history { @@ -210,7 +184,6 @@ async fn model_switch_program_pair( History::Resume | History::Rollback | History::IncompleteRollback - | History::Checkpoint | History::ApiKeyResume | History::ApiKeyEnabledResume | History::ApiKeyCustomProviderResume => { @@ -368,6 +341,39 @@ async fn model_switch_program_pair( if !local { assert_eq!(requests[0].inputs_of_type("compaction_trigger").len(), 1); } + + // Even when the previous model summarizes, the replacement belongs to the current model. + let rollout = fs::read_to_string(thread.rollout_path().expect("rollout"))? + .lines() + .map(codex_rollout::parse_rollout_line) + .collect::, _>>()?; + let checkpoint = rollout + .iter() + .rposition(|line| matches!(&line.item, RolloutItem::Compacted(_))) + .expect("model-switch compaction"); + let replacement = &rollout[checkpoint + 1..]; + let world_state = replacement + .iter() + .find_map(|line| match &line.item { + RolloutItem::WorldState(state) => Some(state), + _ => None, + }) + .expect("replacement world state"); + let turn_context = replacement + .iter() + .find_map(|line| match &line.item { + RolloutItem::TurnContext(context) => Some(context), + _ => None, + }) + .expect("replacement turn context"); + assert_eq!( + ( + world_state.full, + world_state.state.get("model"), + turn_context.model.as_str(), + ), + (true, Some(&json!(next_model)), next_model), + ); Ok(()) } diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index 12ae3d701d..c20769beb6 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -277,18 +277,16 @@ async fn compact_resume_and_fork_preserve_model_history_view() { let summary_after_compact = extract_summary_user_text(&requests[2], SUMMARY_TEXT); let summary_after_resume = extract_summary_user_text(&requests[3], SUMMARY_TEXT); let summary_after_fork = extract_summary_user_text(&requests[4], SUMMARY_TEXT); - let mut expected_after_compact_user_texts = - vec!["hello world".to_string(), summary_after_compact]; - expected_after_compact_user_texts.extend_from_slice(seeded_user_prefix); + let mut expected_after_compact_user_texts = seeded_user_prefix.to_vec(); + expected_after_compact_user_texts.extend(["hello world".to_string(), summary_after_compact]); expected_after_compact_user_texts.push("AFTER_COMPACT".to_string()); assert_eq!( json_message_input_texts(&requests[2], "user"), expected_after_compact_user_texts ); - let mut expected_after_resume_user_texts = - vec!["hello world".to_string(), summary_after_resume]; - expected_after_resume_user_texts.extend_from_slice(seeded_user_prefix); + let mut expected_after_resume_user_texts = seeded_user_prefix.to_vec(); + expected_after_resume_user_texts.extend(["hello world".to_string(), summary_after_resume]); expected_after_resume_user_texts.push("AFTER_COMPACT".to_string()); let after_resume_user_texts = json_message_input_texts(&requests[3], "user"); let (after_resume_last, after_resume_prefix) = after_resume_user_texts @@ -317,9 +315,8 @@ async fn compact_resume_and_fork_preserve_model_history_view() { } let after_fork_user_texts = json_message_input_texts(&requests[4], "user"); - let mut expected_after_fork_history_prefix = - vec!["hello world".to_string(), summary_after_fork]; - expected_after_fork_history_prefix.extend_from_slice(seeded_user_prefix); + let mut expected_after_fork_history_prefix = seeded_user_prefix.to_vec(); + expected_after_fork_history_prefix.extend(["hello world".to_string(), summary_after_fork]); expected_after_fork_history_prefix.push("AFTER_COMPACT".to_string()); let (after_fork_last, after_fork_prefix) = after_fork_user_texts .split_last() @@ -450,15 +447,19 @@ async fn compact_resume_after_second_compaction_preserves_history() -> Result<() "hello world".to_string(), "AFTER_COMPACT".to_string(), "AFTER_RESUME".to_string(), - "AFTER_FORK".to_string(), - summary_after_second_compact.clone(), ]; expected_after_second_compact_user_texts.extend_from_slice(seeded_user_prefix); - expected_after_second_compact_user_texts.push("AFTER_COMPACT_2".to_string()); - let mut expected_fork_local_user_texts = - vec!["AFTER_FORK".to_string(), summary_after_second_compact]; - expected_fork_local_user_texts.extend_from_slice(seeded_user_prefix); - expected_fork_local_user_texts.push("AFTER_COMPACT_2".to_string()); + expected_after_second_compact_user_texts.extend([ + "AFTER_FORK".to_string(), + summary_after_second_compact.clone(), + "AFTER_COMPACT_2".to_string(), + ]); + let mut expected_fork_local_user_texts = seeded_user_prefix.to_vec(); + expected_fork_local_user_texts.extend([ + "AFTER_FORK".to_string(), + summary_after_second_compact, + "AFTER_COMPACT_2".to_string(), + ]); let final_user_texts = json_message_input_texts(&requests[requests.len() - 1], "user"); let (final_last, final_prefix) = final_user_texts .split_last() diff --git a/codex-rs/core/tests/suite/skills_extension.rs b/codex-rs/core/tests/suite/skills_extension.rs index be32ec9232..5b3bea2236 100644 --- a/codex-rs/core/tests/suite/skills_extension.rs +++ b/codex-rs/core/tests/suite/skills_extension.rs @@ -3236,17 +3236,8 @@ async fn production_turn_keeps_rebalanced_catalogs_stable_after_compaction_and_r _ => None, }) .expect("compaction retains an allocation checkpoint"); - let allocation = checkpoint.state["cloud_skills"]["allocation"] - .as_object() - .expect("retained allocation"); - assert_eq!( - serde_json::to_value(checkpoint)?, - json!({ - "full": true, - "state": { "cloud_skills": { "allocation": allocation } }, - }), - "compaction retains only allocation metadata, not rendered catalogs", - ); + assert!(checkpoint.full); + assert!(checkpoint.state["cloud_skills"]["allocation"].is_object()); let resumed = test .thread_manager .start_thread(StartThreadOptions { @@ -3337,7 +3328,7 @@ async fn production_turn_keeps_rebalanced_catalogs_stable_after_compaction_and_r .flat_map(|text| text.lines()) .filter(|line| line.starts_with("- exec-")) .collect::>(), - if matches!(index, 0 | 4) { + if index == 0 { Vec::new() } else { ready_executor_lines.clone() @@ -3358,7 +3349,7 @@ async fn production_turn_keeps_rebalanced_catalogs_stable_after_compaction_and_r "cloud_skills_across_executor_readiness" }, context_snapshot::format_request_history_snapshot( - "Cloud skills rebalance once to retain every executor skill. Post-turn compaction and resume restore the same cloud allocation before the executor reconnects; both catalogs are fully reinjected once into the new history.", + "Cloud skills rebalance once to retain every executor skill. Post-turn compaction installs both catalogs and their allocation before resume; the saved catalogs remain visible while the executor reconnects.", &requests, &ContextSnapshotOptions::default() .rewrite_known_segments() diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_with_history_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_with_history_shapes.snap index 82dbcd68b9..a62c82bf67 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_with_history_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_with_history_shapes.snap @@ -26,31 +26,31 @@ Scenario: Manual /compact with prior user history compacts existing history and 04:message/user: -## Window 3 (after request 2: input diverged at item 00, settings changed (parallel_tool_calls, tools)) +## Window 3 (after request 2: input diverged at item 03, settings changed (parallel_tool_calls, tools)) -- request 3 (turn) -- -00:message/user: - first manual turn +00:message/developer: + 01:message/user: + +02:message/user: + first manual turn +03:message/user: FIRST_MANUAL_SUMMARY -02:message/developer: - -03:message/user: - 04:message/user: second manual turn ## Window 4 (after request 3: settings changed (parallel_tool_calls, tools)) -- request 4 (compaction) -- -00:message/user: - first manual turn +00:message/developer: + 01:message/user: + +02:message/user: + first manual turn +03:message/user: FIRST_MANUAL_SUMMARY -02:message/developer: - -03:message/user: - 04:message/user: second manual turn 05:message/assistant: @@ -58,18 +58,18 @@ Scenario: Manual /compact with prior user history compacts existing history and 06:message/user: -## Window 5 (after request 4: input diverged at item 01, settings changed (parallel_tool_calls, tools)) +## Window 5 (after request 4: input diverged at item 00, settings changed (parallel_tool_calls, tools)) -- request 5 (turn) -- 00:message/user: first manual turn -01:message/user: - second manual turn +01:message/developer: + 02:message/user: + +03:message/user: + second manual turn +04:message/user: SECOND_MANUAL_SUMMARY -03:message/developer: - -04:message/user: - 05:message/user: post compact follow-up diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_without_prev_user_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_without_prev_user_shapes.snap index 173094fbad..805b72dae8 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_without_prev_user_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_without_prev_user_shapes.snap @@ -11,12 +11,12 @@ Scenario: Manual /compact with no prior user turn currently still issues a compa ## Window 2 (after request 1: input diverged at item 00, settings changed (parallel_tool_calls, tools)) -- request 2 (turn; Local Post-Compaction History Layout) -- -00:message/user: +00:message/developer: + +01:message/user: + +02:message/user: MANUAL_EMPTY_SUMMARY -01:message/developer: - -02:message/user: - 03:message/user: AFTER_MANUAL_EMPTY_COMPACT diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_sampling_model_switch_compaction_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_sampling_model_switch_compaction_shapes.snap index 41ce4d86b1..d20f5f5087 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_sampling_model_switch_compaction_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_sampling_model_switch_compaction_shapes.snap @@ -28,15 +28,15 @@ Scenario: Pre-sampling compaction on model switch to a smaller context window: c ## Window 3 (after request 2: input diverged at item 00, settings changed (model, parallel_tool_calls, tools)) -- request 3 (turn; Post-Compaction Follow-up Request (Next Model)) -- -00:message/user: - before switch -01:message/user: - - PRE_SAMPLING_SUMMARY -02:message/developer[2]: +00:message/developer[2]: [01] [02] -03:message/user: +01:message/user: +02:message/user: + before switch +03:message/user: + + PRE_SAMPLING_SUMMARY 04:message/user: after switch diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap index b02d359089..1d92f720c1 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap @@ -39,15 +39,15 @@ Scenario: Pre-turn auto-compaction uses the prior context without the incoming u -- request 4 (turn) -- 00:message/user: USER_ONE -01:message/user: - USER_TWO +01:message/developer: + 02:message/user: + +03:message/user: + USER_TWO +04:message/user: PRE_TURN_SUMMARY -03:message/developer: - -04:message/user: - 05:message/user[2]: [01] [02] USER_THREE diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_strips_incoming_model_switch_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_strips_incoming_model_switch_shapes.snap index b8c8987bac..2aed44c7f1 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_strips_incoming_model_switch_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_strips_incoming_model_switch_shapes.snap @@ -28,15 +28,15 @@ Scenario: Pre-turn compaction during model switch (without pre-sampling model-sw ## Window 3 (after request 2: input diverged at item 00, settings changed (parallel_tool_calls, tools)) -- request 3 (turn; Local Post-Compaction History Layout) -- -00:message/user: - BEFORE_SWITCH_USER -01:message/user: - - PRETURN_SWITCH_SUMMARY -02:message/developer[2]: +00:message/developer[2]: [01] [02] -03:message/user: +01:message/user: +02:message/user: + BEFORE_SWITCH_USER +03:message/user: + + PRETURN_SWITCH_SUMMARY 04:message/user: AFTER_SWITCH_USER diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__astra_kickoff_remote_compaction_windows.snap b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__astra_kickoff_remote_compaction_windows.snap index 4509958edc..877c62c5bf 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__astra_kickoff_remote_compaction_windows.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__astra_kickoff_remote_compaction_windows.snap @@ -222,10 +222,7 @@ Settings: same as window 1 02:message/user: Plan a team kickoff for Friday using $outline and $calendar:agenda. -03:message/user: - Summarize the kickoff notes with $notes:summarize. -04:compaction:encrypted=true; chars=26; hash=ec5e2724e6f5f641 -05:message/developer[3]: +03:message/developer[3]: [01] ## Skills A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list ...ed into an absolute path using the skill roots table. [hash=ca27c3957f42502b] @@ -271,7 +268,7 @@ Settings: same as window 1 Never use the `request_user_input` tool for permission requests or permission-related escalations. -06:message/developer: +04:message/developer: You are `/root`, the primary agent in a team of agents collaborating to fulfill the user's goals. At the start of your turn, you are the active agent. @@ -289,9 +286,9 @@ Settings: same as window 1 There are 4 available concurrency slots, meaning that up to 4 agents can be active at once, including you. Full-history forks (`fork_turns` omitted or `"all"`) inherit the parent model and reasoning effort and d...ne"` or a positive integer string. [hash=c7f52e90688299ce] -07:message/developer: +05:message/developer: Any earlier instruction enabling proactive multi-agent delegation no longer applies. D...elegation, or parallel agent work. [hash=2b6e037067b4390c] -08:message/user[2]: +06:message/user[2]: [01] # AGENTS.md instructions for @@ -304,6 +301,9 @@ Settings: same as window 1 [hash=911722cc43b4c3b8] +07:message/user: + Summarize the kickoff notes with $notes:summarize. +08:compaction:encrypted=true; chars=26; hash=ec5e2724e6f5f641 09:message/user[2]: [01] Check the final kickoff brief and attached sketch with $final-check and $calendar:agenda. [02] diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__compaction__remote_slash_compact.snap b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__compaction__remote_slash_compact.snap index ab2bf0b330..00d8b90f33 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__compaction__remote_slash_compact.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__compaction__remote_slash_compact.snap @@ -108,9 +108,58 @@ Settings: ## Window 2 (after request 2: input diverged at item 00) Settings: unavailable (items only) -- request 3 (items; Stored replacement before next turn) -- -00:message/user: +00:message/developer[2]: + [01] + Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `danger-full-access`...ll commands are permitted. Network access is enabled. [hash=c134061cebb94a1b] + Approval policy is currently never. Do not provide the `sandbox_permissions` for any reason, commands will be rejected. + + [02] # Collaboration Mode: Default + + You are now in Default mode. Any previous instructions for other modes (e.g. Plan mode) are no longer active. + + Your active mode changes only when new developer instructions with a different `...<...by themselves. Known mode names are Default and Plan. [hash=e75f6b12fc52d812] + + ## request_user_input availability + + Use the `request_user_input` tool only when it is listed in the available tools for this turn. + + Use the `request_user_input` tool only for optional questions where the answer would materially improve the quality of the work. + + If `request_user_input` returns no answers, continue with best judgment instead of asking again or treating the turn as blocked. + + Never use the `request_user_input` tool for permission requests or permission-related escalations. + +01:message/developer: + You are `/root`, the primary agent in a team of agents collaborating to fulfill the user's goals. + + At the start of your turn, you are the active agent. + You can spawn sub-agents to handle subtasks, and those sub-agents can spawn their own sub-agents. + All agents in the team, including the agents that you can assign tasks to, are equally intelligent and capable, and have access to the same set of tools. + + You can use `spawn_agent` to create a new agent, `followup_task` to give an existing agent a new task an...message to a running agent without triggering a turn. [hash=7d40ff8b58977202] + `send_message` calls may be read by a human, so ensure they are legible. Always put proper spaces between words and/or numbers. + + - All agents use the same current working directory. + - As a result, edits made by one agent are immediately visible to all other agents. + + When calling `wait_agent`, prefer longer waits (minutes) to avoid busy polling. + + There are 4 available concurrency slots, meaning that up to 4 agents can be active at once, including you. + + Full-history forks (`fork_turns` omitted or `"all"`) inherit the parent model and reasoning effort and d...ne"` or a positive integer string. [hash=c7f52e90688299ce] +02:message/developer: + Any earlier instruction enabling proactive multi-agent delegation no longer applies. D...elegation, or parallel agent work. [hash=2b6e037067b4390c] +03:message/user: + + + + + + <...e="unrestricted" /> [hash=d40b2f45cb028888] + +04:message/user: Plan the release. -01:compaction:encrypted=true; chars=31; hash=8255caf138edede8 +05:compaction:encrypted=true; chars=31; hash=8255caf138edede8 ## Window 3 (after request 3: input diverged at item 00) Settings: same as window 1 @@ -148,10 +197,7 @@ Settings: same as window 1 - Relevance: Determine what a plugin can help with from explicit user mention or from the plugin-associa..., MCP tools, and apps exposed elsewhere in this turn. [hash=620459a4e7b590f0] - Missing/blocked: If the user requests a plugin that does not have relevant callable capabilities for t..., say so briefly and continue with the best fallback. [hash=746b0469a8f6cb3e] -02:message/user: - Plan the release. -03:compaction:encrypted=true; chars=31; hash=8255caf138edede8 -04:message/developer[2]: +02:message/developer[2]: [01] Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `danger-full-access`...ll commands are permitted. Network access is enabled. [hash=c134061cebb94a1b] Approval policy is currently never. Do not provide the `sandbox_permissions` for any reason, commands will be rejected. @@ -172,7 +218,7 @@ Settings: same as window 1 Never use the `request_user_input` tool for permission requests or permission-related escalations. -05:message/developer: +03:message/developer: You are `/root`, the primary agent in a team of agents collaborating to fulfill the user's goals. At the start of your turn, you are the active agent. @@ -190,9 +236,9 @@ Settings: same as window 1 There are 4 available concurrency slots, meaning that up to 4 agents can be active at once, including you. Full-history forks (`fork_turns` omitted or `"all"`) inherit the parent model and reasoning effort and d...ne"` or a positive integer string. [hash=c7f52e90688299ce] -06:message/developer: +04:message/developer: Any earlier instruction enabling proactive multi-agent delegation no longer applies. D...elegation, or parallel agent work. [hash=2b6e037067b4390c] -07:message/user: +05:message/user: @@ -200,5 +246,8 @@ Settings: same as window 1 <...e="unrestricted" /> [hash=d40b2f45cb028888] +06:message/user: + Plan the release. +07:compaction:encrypted=true; chars=31; hash=8255caf138edede8 08:message/user: Continue with the first step. diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__guardian_checkpoint_migration.snap b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__guardian_checkpoint_migration.snap index fcf9b869e4..4cbd316078 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__guardian_checkpoint_migration.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__guardian_checkpoint_migration.snap @@ -59,14 +59,14 @@ Settings: relative to window 1 02:message/user: Keep the working tree unchanged. -03:message/user: - Confirm where publishing is allowed. -04:compaction:encrypted=true; chars=39; hash=4010be12538dbedf -05:message/developer[2]: +03:message/developer[2]: [01] [02] -06:message/user: +04:message/user: +05:message/user: + Confirm where publishing is allowed. +06:compaction:encrypted=true; chars=39; hash=4010be12538dbedf 07:message/user: Continue with the new model. -- request 5 (turn) -- @@ -196,14 +196,14 @@ Settings: same as window 2 02:message/user: Keep the working tree unchanged. -03:message/user: - Confirm where publishing is allowed. -04:compaction:encrypted=true; chars=39; hash=4010be12538dbedf -05:message/developer[2]: +03:message/developer[2]: [01] [02] -06:message/user: +04:message/user: +05:message/user: + Confirm where publishing is allowed. +06:compaction:encrypted=true; chars=39; hash=4010be12538dbedf 07:message/user: Continue with the new model. 08:message/user: @@ -213,7 +213,7 @@ Settings: same as window 2 -- request 8 (compaction) -- 11:compaction_trigger -## Window 5 (after request 8: input diverged at item 04) +## Window 5 (after request 8: input diverged at item 03) Settings: same as window 2 -- request 9 (turn) -- 00:additional_tools/developer (1; hash=1ea3c73f7e3c8b33): @@ -229,13 +229,13 @@ Settings: same as window 2 Confirm where publishing is allowed. 04:message/user: Continue with the new model. -05:message/user: - Run another check after compaction. -06:compaction:encrypted=true; chars=33; hash=5c07b4383c427607 -07:message/developer: +05:message/developer: -08:message/user: +06:message/user: +07:message/user: + Run another check after compaction. +08:compaction:encrypted=true; chars=33; hash=5c07b4383c427607 09:message/user: Run another check after compaction. @@ -353,13 +353,13 @@ Settings: same as window 2 Confirm where publishing is allowed. 04:message/user: Continue with the new model. -05:message/user: - Run another check after compaction. -06:compaction:encrypted=true; chars=33; hash=5c07b4383c427607 -07:message/developer: +05:message/developer: -08:message/user: +06:message/user: +07:message/user: + Run another check after compaction. +08:compaction:encrypted=true; chars=33; hash=5c07b4383c427607 09:message/user: Run another check after compaction. 10:function_call/exec_command:{"cmd":"exit 0","sandbox_permissions":"require_escalated"} diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__skills_extension__cloud_skills_across_executor_readiness.snap b/codex-rs/core/tests/suite/snapshots/all__suite__skills_extension__cloud_skills_across_executor_readiness.snap index 40d24ce51c..5dc7e7d935 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__skills_extension__cloud_skills_across_executor_readiness.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__skills_extension__cloud_skills_across_executor_readiness.snap @@ -1,8 +1,8 @@ --- source: core/tests/suite/skills_extension.rs -expression: "context_snapshot::format_request_history_snapshot(\"Cloud skills rebalance once to retain every executor skill. Post-turn compaction and resume restore the same cloud allocation before the executor reconnects; both catalogs are fully reinjected once into the new history.\",\n&requests,\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)" +expression: "context_snapshot::format_request_history_snapshot(\"Cloud skills rebalance once to retain every executor skill. Post-turn compaction installs both catalogs and their allocation before resume; the saved catalogs remain visible while the executor reconnects.\",\n&requests,\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)" --- -Scenario: Cloud skills rebalance once to retain every executor skill. Post-turn compaction and resume restore the same cloud allocation before the executor reconnects; both catalogs are fully reinjected once into the new history. +Scenario: Cloud skills rebalance once to retain every executor skill. Post-turn compaction installs both catalogs and their allocation before resume; the saved catalogs remain visible while the executor reconnects. ## Window 1 Settings: @@ -102,12 +102,7 @@ Settings: same as window 1 -- request 5 (turn) -- 00:message/user: Inspect the available skills. -01:message/user: - Inspect the available skills again. -02:message/user: - - The available skills were inspected. -03:message/developer[3]: +01:message/developer[4]: [01] [02] ## Execution environments @@ -118,14 +113,24 @@ Settings: same as window 1 Wait only when the current task needs that environment. Continue using tools that are already available for unrelated work. [03] -04:message/user: + [04] +02:message/user: +03:message/user: + Inspect the available skills again. +04:message/user: + + The available skills were inspected. 05:message/user: + +06:message/developer: + +07:message/user: Inspect the skills after resuming. -- request 6 (turn) -- -06:function_call/wait_for_environment:{"environment_id":"skills-executor"} -07:function_call_output:{"environment_id":"skills-executor","status":"ready"} -08:message/user: +08:function_call/wait_for_environment:{"environment_id":"skills-executor"} +09:function_call_output:{"environment_id":"skills-executor","status":"ready"} +10:message/user: -09:message/developer: +11:message/developer: diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__skills_extension__cloud_skills_across_executor_readiness_windows.snap b/codex-rs/core/tests/suite/snapshots/all__suite__skills_extension__cloud_skills_across_executor_readiness_windows.snap index 980a174b01..6a0f17c4e2 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__skills_extension__cloud_skills_across_executor_readiness_windows.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__skills_extension__cloud_skills_across_executor_readiness_windows.snap @@ -1,8 +1,8 @@ --- source: core/tests/suite/skills_extension.rs -expression: "context_snapshot::format_request_history_snapshot(\"Cloud skills rebalance once to retain every executor skill. Post-turn compaction and resume restore the same cloud allocation before the executor reconnects; both catalogs are fully reinjected once into the new history.\",\n&requests,\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)" +expression: "context_snapshot::format_request_history_snapshot(\"Cloud skills rebalance once to retain every executor skill. Post-turn compaction installs both catalogs and their allocation before resume; the saved catalogs remain visible while the executor reconnects.\",\n&requests,\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)" --- -Scenario: Cloud skills rebalance once to retain every executor skill. Post-turn compaction and resume restore the same cloud allocation before the executor reconnects; both catalogs are fully reinjected once into the new history. +Scenario: Cloud skills rebalance once to retain every executor skill. Post-turn compaction installs both catalogs and their allocation before resume; the saved catalogs remain visible while the executor reconnects. ## Window 1 Settings: @@ -122,12 +122,7 @@ Settings: same as window 1 -- request 5 (turn) -- 00:message/user: Inspect the available skills. -01:message/user: - Inspect the available skills again. -02:message/user: - - The available skills were inspected. -03:message/developer[3]: +01:message/developer[4]: [01] [02] ## Execution environments @@ -138,9 +133,19 @@ Settings: same as window 1 Wait only when the current task needs that environment. Continue using tools that are already available for unrelated work. [03] -04:message/user: + [04] +02:message/user: +03:message/user: + Inspect the available skills again. +04:message/user: + + The available skills were inspected. 05:message/user: + +06:message/developer: + +07:message/user: Inspect the skills after resuming. ## Window 5 (after request 5) @@ -148,12 +153,7 @@ Settings: same as window 2 -- request 6 (turn) -- 00:message/user: Inspect the available skills. -01:message/user: - Inspect the available skills again. -02:message/user: - - The available skills were inspected. -03:message/developer[3]: +01:message/developer[4]: [01] [02] ## Execution environments @@ -164,13 +164,23 @@ Settings: same as window 2 Wait only when the current task needs that environment. Continue using tools that are already available for unrelated work. [03] + [04] +02:message/user: + +03:message/user: + Inspect the available skills again. 04:message/user: - + + The available skills were inspected. 05:message/user: - Inspect the skills after resuming. -06:function_call/wait_for_environment:{"environment_id":"skills-executor"} -07:function_call_output:{"environment_id":"skills-executor","status":"ready"} -08:message/user: -09:message/developer: +06:message/developer: + +07:message/user: + Inspect the skills after resuming. +08:function_call/wait_for_environment:{"environment_id":"skills-executor"} +09:function_call_output:{"environment_id":"skills-executor","status":"ready"} +10:message/user: + +11:message/developer: diff --git a/codex-rs/ext/extension-api/src/contributors.rs b/codex-rs/ext/extension-api/src/contributors.rs index 09892600d7..61cdbe4da7 100644 --- a/codex-rs/ext/extension-api/src/contributors.rs +++ b/codex-rs/ext/extension-api/src/contributors.rs @@ -142,18 +142,6 @@ pub trait ContextContributor: Send + Sync { Vec::new() }) } - - /// Retains bounded extension metadata when compaction discards rendered context. - /// - /// Return only this contributor's section IDs, without rendered text or availability - /// state. Core persists these partial sections so the next step can rebuild full - /// context without losing decisions that are independent of model-visible history. - fn retain_world_state_after_compaction( - &self, - _previous_world_state: &serde_json::Map, - ) -> serde_json::Map { - serde_json::Map::new() - } } /// Contributor for host-owned thread lifecycle gates. diff --git a/codex-rs/ext/skills/src/extension.rs b/codex-rs/ext/skills/src/extension.rs index 4b6568045d..9a1c1cc19c 100644 --- a/codex-rs/ext/skills/src/extension.rs +++ b/codex-rs/ext/skills/src/extension.rs @@ -71,7 +71,6 @@ use crate::tools::SkillAnalytics; use crate::tools::SkillToolAuthority; use crate::tools::skill_tools; use crate::warnings::bounded_warnings; -use crate::world_state::CLOUD_SKILLS_WORLD_STATE_ID; use crate::world_state_catalogs::CatalogContext; use crate::world_state_catalogs::CatalogStatus; @@ -265,25 +264,6 @@ where .collect() }) } - - fn retain_world_state_after_compaction( - &self, - previous_world_state: &serde_json::Map, - ) -> serde_json::Map { - let mut retained = serde_json::Map::new(); - if let Some(allocation) = previous_world_state - .get(CLOUD_SKILLS_WORLD_STATE_ID) - .and_then(|section| section.get("allocation")) - { - // Allocation survives lost history; the catalog fingerprint and budget still - // determine whether it can be reused after fresh discovery and policy resolution. - retained.insert( - CLOUD_SKILLS_WORLD_STATE_ID.to_string(), - serde_json::json!({ "allocation": allocation }), - ); - } - retained - } } impl ToolContributor for SkillsExtension