diff --git a/codex-rs/app-server/tests/suite/v2/compaction.rs b/codex-rs/app-server/tests/suite/v2/compaction.rs index 5b5faa02d6d..d663be130fd 100644 --- a/codex-rs/app-server/tests/suite/v2/compaction.rs +++ b/codex-rs/app-server/tests/suite/v2/compaction.rs @@ -199,15 +199,10 @@ async fn auto_compaction_remote_emits_started_and_completed_items() -> Result<() } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn thread_compact_start_triggers_compaction_and_returns_empty_response() -> Result<()> { +async fn thread_compact_start_without_history_emits_started_and_completed_items() -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; - let sse = responses::sse(vec![ - responses::ev_assistant_message("m1", "MANUAL_COMPACT_SUMMARY"), - responses::ev_completed_with_tokens("r1", 200), - ]); - responses::mount_sse_sequence(&server, vec![sse]).await; let codex_home = TempDir::new()?; write_mock_responses_config_toml( diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 7744480ab41..af1d9206295 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -19,6 +19,7 @@ use crate::analytics_client::build_track_events_context; use crate::apps::render_apps_section; use crate::commit_attribution::commit_message_trailer_instruction; use crate::compact; +use crate::compact::CompactCallsite; use crate::compact::run_inline_auto_compact_task; use crate::compact::should_use_remote_compact_task; use crate::compact_remote::run_inline_remote_auto_compact_task; @@ -126,6 +127,7 @@ use crate::config::types::McpServerConfig; use crate::config::types::ShellEnvironmentPolicy; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; +use crate::context_manager::estimate_item_token_count; use crate::environment_context::EnvironmentContext; use crate::error::CodexErr; use crate::error::Result as CodexResult; @@ -2385,11 +2387,11 @@ impl Session { history.replace(replacement.clone()); } else { let user_messages = collect_user_messages(history.raw_items()); - let rebuilt = compact::build_compacted_history( - self.build_initial_context(turn_context).await, + let mut rebuilt = self.build_initial_context(turn_context).await; + rebuilt.extend(compact::build_compacted_history( &user_messages, &compacted.message, - ); + )); history.replace(rebuilt); } } @@ -2402,15 +2404,6 @@ impl Session { history.raw_items().to_vec() } - pub(crate) async fn process_compacted_history( - &self, - turn_context: &TurnContext, - compacted_history: Vec, - ) -> Vec { - let initial_context = self.build_initial_context(turn_context).await; - compact::process_compacted_history(compacted_history, &initial_context) - } - /// Append ResponseItems to the in-memory conversation history only. pub(crate) async fn record_into_history( &self, @@ -2499,6 +2492,11 @@ impl Session { self.flush_rollout().await; } + pub(crate) async fn mark_initial_context_unseeded_for_next_turn(&self) { + let mut state = self.state.lock().await; + state.initial_context_seeded = false; + } + async fn persist_rollout_response_items(&self, items: &[ResponseItem]) { let rollout_items: Vec = items .iter() @@ -3400,19 +3398,29 @@ mod handlers { if let Err(SteerInputError::NoActiveTurn(items)) = sess.steer_input(items, None).await { sess.seed_initial_context_if_needed(¤t_context).await; let previous_model = sess.previous_model().await; - let update_items = sess.build_settings_update_items( + let pre_turn_context_items = sess.build_settings_update_items( previous_context.as_ref(), previous_model.as_deref(), ¤t_context, ); - if !update_items.is_empty() { - sess.record_conversation_items(¤t_context, &update_items) + let has_user_input = !items.is_empty(); + if !has_user_input && !pre_turn_context_items.is_empty() { + // Empty-input UserTurn still needs these model-visible updates persisted now. + // Otherwise `previous_context` advances and the next non-empty turn computes no diff. + sess.record_conversation_items(¤t_context, &pre_turn_context_items) .await; } sess.refresh_mcp_servers_if_requested(¤t_context) .await; - let regular_task = sess.take_startup_regular_task().await.unwrap_or_default(); + let regular_task = if has_user_input { + sess.take_startup_regular_task() + .await + .unwrap_or_default() + .with_pre_turn_context_items(pre_turn_context_items) + } else { + sess.take_startup_regular_task().await.unwrap_or_default() + }; sess.spawn_task(Arc::clone(¤t_context), items, regular_task) .await; *previous_context = Some(current_context); @@ -4241,6 +4249,7 @@ pub(crate) async fn run_turn( sess: Arc, turn_context: Arc, input: Vec, + pre_turn_context_items: Vec, prewarmed_client_session: Option, cancellation_token: CancellationToken, ) -> Option { @@ -4250,6 +4259,9 @@ pub(crate) async fn run_turn( let model_info = turn_context.model_info.clone(); let auto_compact_limit = model_info.auto_compact_token_limit().unwrap_or(i64::MAX); + let response_item: ResponseItem = ResponseInputItem::from(input.clone()).into(); + let mut incoming_turn_items = pre_turn_context_items.clone(); + incoming_turn_items.push(response_item.clone()); let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), @@ -4257,14 +4269,51 @@ pub(crate) async fn run_turn( collaboration_mode_kind: turn_context.collaboration_mode.mode, }); sess.send_event(&turn_context, event).await; - if run_pre_sampling_compact(&sess, &turn_context) - .await - .is_err() + + let total_usage_tokens_before_compaction = sess.get_total_token_usage().await; + if maybe_run_previous_model_inline_compact( + &sess, + &turn_context, + total_usage_tokens_before_compaction, + &pre_turn_context_items, + ) + .await + .is_err() { - error!("Failed to run pre-sampling compact"); + // Error messaging is emitted inside maybe_run_previous_model_inline_compact. return None; } + let pre_turn_compaction_outcome = match run_pre_turn_auto_compaction_if_needed( + &sess, + &turn_context, + auto_compact_limit, + &incoming_turn_items, + ) + .await + { + Ok(outcome) => outcome, + Err(()) => { + if !pre_turn_context_items.is_empty() { + // Preserve model-visible settings updates even when pre-turn compaction fails + // before turn input persistence can run. + sess.record_conversation_items(&turn_context, &pre_turn_context_items) + .await; + } + // Error messaging is emitted inside run_pre_turn_auto_compaction_if_needed. + return None; + } + }; + persist_pre_turn_items_for_compaction_outcome( + &sess, + &turn_context, + pre_turn_compaction_outcome, + &pre_turn_context_items, + &input, + response_item, + ) + .await; + let skills_outcome = Some( sess.services .skills_manager @@ -4371,12 +4420,6 @@ pub(crate) async fn run_turn( .track_app_mentioned(tracking.clone(), mentioned_app_invocations); sess.merge_connector_selection(explicitly_enabled_connectors.clone()) .await; - - let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input.clone()); - let response_item: ResponseItem = initial_input_for_turn.clone().into(); - sess.record_user_prompt_and_emit_turn_item(turn_context.as_ref(), &input, response_item) - .await; - if !skill_items.is_empty() { sess.record_conversation_items(&turn_context, &skill_items) .await; @@ -4479,7 +4522,21 @@ pub(crate) async fn run_turn( // as long as compaction works well in getting us way below the token limit, we shouldn't worry about being in an infinite loop. if token_limit_reached && needs_follow_up { - if run_auto_compact(&sess, &turn_context).await.is_err() { + if let Err(err) = run_auto_compact( + &sess, + &turn_context, + CompactCallsite::MidTurnContinuation, + None, + ) + .await + { + if matches!(err, CodexErr::Interrupted) { + return None; + } + let event = EventMsg::Error( + err.to_error_event(Some("Error running auto compact task".to_string())), + ); + sess.send_event(&turn_context, event).await; return None; } continue; @@ -4581,40 +4638,16 @@ pub(crate) async fn run_turn( last_agent_message } -async fn run_pre_sampling_compact( - sess: &Arc, - turn_context: &Arc, -) -> CodexResult<()> { - let total_usage_tokens_before_compaction = sess.get_total_token_usage().await; - maybe_run_previous_model_inline_compact( - sess, - turn_context, - total_usage_tokens_before_compaction, - ) - .await?; - let total_usage_tokens = sess.get_total_token_usage().await; - let auto_compact_limit = turn_context - .model_info - .auto_compact_token_limit() - .unwrap_or(i64::MAX); - // Compact if the total usage tokens are greater than the auto compact limit - if total_usage_tokens >= auto_compact_limit { - run_auto_compact(sess, turn_context).await?; - } - Ok(()) -} - -/// Runs pre-sampling compaction against the previous model when switching to a smaller -/// context-window model. +/// Runs the pre-sampling model-switch compaction pass when needed. /// -/// Returns `Ok(())` when compaction either completed successfully or was skipped because the -/// model/context-window preconditions were not met. Returns `Err(_)` only when compaction was -/// attempted and failed. +/// On failure this function emits any user-visible error event itself and returns `Err(())` as a +/// sentinel so callers can stop the turn without duplicating error messaging logic. async fn maybe_run_previous_model_inline_compact( sess: &Arc, turn_context: &Arc, total_usage_tokens: i64, -) -> CodexResult<()> { + pre_turn_context_items: &[ResponseItem], +) -> Result<(), ()> { let Some(previous_model) = sess.previous_model().await else { return Ok(()); }; @@ -4637,19 +4670,229 @@ async fn maybe_run_previous_model_inline_compact( let should_run = total_usage_tokens > new_auto_compact_limit && previous_turn_context.model_info.slug != turn_context.model_info.slug && old_context_window > new_context_window; - if should_run { - run_auto_compact(sess, &previous_turn_context).await?; + if !should_run { + return Ok(()); + } + + match run_auto_compact( + sess, + // We use previous turn context here because we compact with the previous model + &previous_turn_context, + CompactCallsite::PreSamplingModelSwitch, + None, + ) + .await + { + Ok(()) => Ok(()), + Err(err) => { + if !pre_turn_context_items.is_empty() { + // Preserve model-visible settings updates even when pre-turn compaction fails + // before we can persist turn input. + sess.record_conversation_items(turn_context, pre_turn_context_items) + .await; + } + if matches!(err, CodexErr::Interrupted) { + return Err(()); + } + let compact_error_prefix = if should_use_remote_compact_task(&turn_context.provider) { + "Error running remote compact task" + } else { + "Error running local compact task" + }; + let event = EventMsg::Error(err.to_error_event(Some(compact_error_prefix.to_string()))); + sess.send_event(turn_context, event).await; + Err(()) + } } - Ok(()) +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PreTurnCompactionOutcome { + /// Pre-turn input fits without compaction. + NotNeeded, + /// Pre-turn compaction succeeded with incoming turn context + user message included. + CompactedWithIncomingItems, + /// Pre-turn compaction succeeded without incoming turn items + /// (incoming user message should be appended after the compaction summary). + /// This compaction strategy is currently out of distribution for our compaction model, + /// but is planned to be trained on in the future. + #[cfg(test)] + CompactedWithoutIncomingItems, } -async fn run_auto_compact(sess: &Arc, turn_context: &Arc) -> CodexResult<()> { - if should_use_remote_compact_task(&turn_context.provider) { - run_inline_remote_auto_compact_task(Arc::clone(sess), Arc::clone(turn_context)).await?; +async fn persist_pre_turn_items_for_compaction_outcome( + sess: &Arc, + turn_context: &Arc, + outcome: PreTurnCompactionOutcome, + pre_turn_context_items: &[ResponseItem], + input: &[UserInput], + response_item: ResponseItem, +) { + match outcome { + PreTurnCompactionOutcome::NotNeeded => { + if !pre_turn_context_items.is_empty() { + sess.record_conversation_items(turn_context, pre_turn_context_items) + .await; + } + sess.record_user_prompt_and_emit_turn_item(turn_context.as_ref(), input, response_item) + .await; + } + PreTurnCompactionOutcome::CompactedWithIncomingItems => { + // Pre-turn compaction includes incoming items only for the compaction request itself. + // Persist canonical turn context directly above the incoming user item so context + // applies to the latest user message in post-compaction history. + let initial_context = sess.build_initial_context(turn_context.as_ref()).await; + if !initial_context.is_empty() { + sess.record_conversation_items(turn_context, &initial_context) + .await; + } + sess.record_user_prompt_and_emit_turn_item(turn_context.as_ref(), input, response_item) + .await; + } + // TODO(ccunningham): Followup PR will use compacting excluding incoming items as a fallback + // (even though it is out of distribution for current models). + // Also future models may prefer compacting pre-turn history without incoming turn items. + #[cfg(test)] + PreTurnCompactionOutcome::CompactedWithoutIncomingItems => { + // Reseed canonical initial context above the incoming user message. + let initial_context = sess.build_initial_context(turn_context.as_ref()).await; + if !initial_context.is_empty() { + sess.record_conversation_items(turn_context, &initial_context) + .await; + } + sess.record_user_prompt_and_emit_turn_item(turn_context.as_ref(), input, response_item) + .await; + } + } +} + +/// Runs pre-turn auto-compaction with incoming turn context + user message included. +/// +/// On failure this function emits any user-visible error event itself and returns `Err(())` as a +/// sentinel so callers can stop the turn without duplicating error messaging logic. +async fn run_pre_turn_auto_compaction_if_needed( + sess: &Arc, + turn_context: &Arc, + auto_compact_limit: i64, + incoming_turn_items: &[ResponseItem], +) -> Result { + let total_usage_tokens = sess.get_total_token_usage().await; + let incoming_items_tokens_estimate = incoming_turn_items + .iter() + .map(estimate_item_token_count) + .fold(0_i64, i64::saturating_add); + if !is_projected_submission_over_auto_compact_limit( + total_usage_tokens, + incoming_items_tokens_estimate, + auto_compact_limit, + ) { + return Ok(PreTurnCompactionOutcome::NotNeeded); + } + + match run_auto_compact( + sess, + turn_context, + CompactCallsite::PreTurnIncludingIncomingUserMessage, + Some(incoming_turn_items.to_vec()), + ) + .await + { + Ok(()) => { + // If compaction no-oped because there was no user-turn boundary even after including + // incoming items, do not treat this as "compacted with incoming". The caller must + // persist incoming items explicitly in this case. + let has_user_turn_boundary_after_compaction = sess + .clone_history() + .await + .raw_items() + .iter() + .any(crate::context_manager::is_user_turn_boundary); + if !has_user_turn_boundary_after_compaction { + return Ok(PreTurnCompactionOutcome::NotNeeded); + } + + Ok(PreTurnCompactionOutcome::CompactedWithIncomingItems) + } + Err(err) => { + if matches!(err, CodexErr::Interrupted) { + return Err(()); + } + let event = match err { + CodexErr::ContextWindowExceeded => { + error!( + turn_id = %turn_context.sub_id, + compact_callsite = ?CompactCallsite::PreTurnIncludingIncomingUserMessage, + incoming_items_tokens_estimate, + auto_compact_limit, + reason = "pre-turn compaction exceeded context window", + "incoming user/context is too large for pre-turn auto-compaction flow" + ); + let message = format!( + "Incoming user message and/or turn context is too large to fit in context window. Please reduce the size of your message and try again. (incoming_items_tokens_estimate={incoming_items_tokens_estimate})" + ); + EventMsg::Error(CodexErr::ContextWindowExceeded.to_error_event(Some(message))) + } + other => { + let compact_error_prefix = + if should_use_remote_compact_task(&turn_context.provider) { + "Error running remote compact task" + } else { + "Error running local compact task" + }; + EventMsg::Error(other.to_error_event(Some(compact_error_prefix.to_string()))) + } + }; + sess.send_event(turn_context, event).await; + Err(()) + } + } +} + +fn is_projected_submission_over_auto_compact_limit( + total_usage_tokens: i64, + incoming_user_tokens_estimate: i64, + auto_compact_limit: i64, +) -> bool { + if auto_compact_limit == i64::MAX { + return false; + } + + total_usage_tokens.saturating_add(incoming_user_tokens_estimate) >= auto_compact_limit +} + +async fn run_auto_compact( + sess: &Arc, + turn_context: &Arc, + compact_callsite: CompactCallsite, + incoming_items: Option>, +) -> CodexResult<()> { + let result = if should_use_remote_compact_task(&turn_context.provider) { + run_inline_remote_auto_compact_task( + Arc::clone(sess), + Arc::clone(turn_context), + compact_callsite, + incoming_items, + ) + .await } else { - run_inline_auto_compact_task(Arc::clone(sess), Arc::clone(turn_context)).await?; + run_inline_auto_compact_task( + Arc::clone(sess), + Arc::clone(turn_context), + compact_callsite, + incoming_items, + ) + .await + }; + + if let Err(err) = &result { + error!( + turn_id = %turn_context.sub_id, + compact_callsite = ?compact_callsite, + compact_error = %err, + "auto compaction failed" + ); } - Ok(()) + + result } fn collect_explicit_app_ids_from_skill_items( @@ -5596,9 +5839,9 @@ async fn try_run_sampling_request( sess.services.models_manager.refresh_if_new_etag(etag).await; } ResponseEvent::Completed { - response_id: _, + response_id: _response_id, token_usage, - can_append: _, + can_append: _can_append, } => { if let Some(state) = plan_mode_state.as_mut() { flush_proposed_plan_segments_all(&sess, &turn_context, state).await; @@ -5829,6 +6072,239 @@ mod tests { } } + #[test] + fn pre_turn_projection_uses_incoming_user_tokens_for_compaction() { + assert!(is_projected_submission_over_auto_compact_limit(90, 15, 100)); + assert!(!is_projected_submission_over_auto_compact_limit(90, 9, 100)); + } + + #[test] + fn pre_turn_projection_does_not_compact_with_unbounded_limit() { + assert!(!is_projected_submission_over_auto_compact_limit( + i64::MAX - 1, + 100, + i64::MAX, + )); + } + + #[test] + fn post_compaction_projection_triggers_error_when_still_over_limit() { + assert!(is_projected_submission_over_auto_compact_limit(95, 10, 100)); + assert!(is_projected_submission_over_auto_compact_limit( + 100, 10, 100 + )); + assert!(!is_projected_submission_over_auto_compact_limit( + 80, 10, 100 + )); + } + + #[tokio::test] + async fn reserved_compacted_without_incoming_items_records_initial_context_and_prompt() { + let (session, turn_context) = make_session_and_context().await; + let session = Arc::new(session); + let turn_context = Arc::new(turn_context); + let input = vec![UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }]; + let response_item: ResponseItem = ResponseInputItem::from(input.clone()).into(); + let stale_pre_turn_context_items = vec![ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "stale context diff".to_string(), + }], + end_turn: None, + phase: None, + }]; + + persist_pre_turn_items_for_compaction_outcome( + &session, + &turn_context, + PreTurnCompactionOutcome::CompactedWithoutIncomingItems, + &stale_pre_turn_context_items, + &input, + response_item.clone(), + ) + .await; + + let mut expected = session.build_initial_context(turn_context.as_ref()).await; + expected.push(response_item); + let actual = session.clone_history().await.raw_items().to_vec(); + assert_eq!(actual, expected); + } + + #[tokio::test] + async fn compacted_with_incoming_items_persists_context_and_prompt() { + let (session, turn_context) = make_session_and_context().await; + let session = Arc::new(session); + let turn_context = Arc::new(turn_context); + let input = vec![UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }]; + let response_item: ResponseItem = ResponseInputItem::from(input.clone()).into(); + let stale_pre_turn_context_items = vec![ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "stale context diff".to_string(), + }], + end_turn: None, + phase: None, + }]; + + persist_pre_turn_items_for_compaction_outcome( + &session, + &turn_context, + PreTurnCompactionOutcome::CompactedWithIncomingItems, + &stale_pre_turn_context_items, + &input, + response_item.clone(), + ) + .await; + + let actual = session.clone_history().await.raw_items().to_vec(); + let mut expected = session.build_initial_context(turn_context.as_ref()).await; + expected.push(response_item); + assert_eq!(actual, expected); + } + + #[tokio::test] + async fn run_turn_persists_pre_turn_context_before_apps_tool_listing_cancellation() { + let (session, mut turn_context) = make_session_and_context().await; + let session = Arc::new(session); + let mut config = (*turn_context.config).clone(); + config.features.enable(Feature::Apps); + turn_context.features = config.features.clone(); + turn_context.config = Arc::new(config); + let turn_context = Arc::new(turn_context); + let input = vec![UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }]; + let response_item: ResponseItem = ResponseInputItem::from(input.clone()).into(); + let pre_turn_context_items = vec![ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "model-visible setting diff".to_string(), + }], + end_turn: None, + phase: None, + }]; + let cancellation_token = CancellationToken::new(); + cancellation_token.cancel(); + + let result = run_turn( + Arc::clone(&session), + Arc::clone(&turn_context), + input, + pre_turn_context_items.clone(), + None, + cancellation_token, + ) + .await; + assert_eq!(result, None); + + let mut expected_history = pre_turn_context_items; + expected_history.push(response_item); + let actual_history = session.clone_history().await.raw_items().to_vec(); + assert_eq!(actual_history, expected_history); + } + + #[tokio::test] + async fn pre_turn_auto_compaction_noop_without_user_turn_boundary_returns_not_needed() { + let (session, turn_context) = make_session_and_context().await; + let session = Arc::new(session); + let turn_context = Arc::new(turn_context); + + { + let mut state = session.state.lock().await; + state.set_token_info(Some(TokenUsageInfo { + total_token_usage: TokenUsage { + total_tokens: 1_000, + ..TokenUsage::default() + }, + last_token_usage: TokenUsage { + total_tokens: 1_000, + ..TokenUsage::default() + }, + model_context_window: turn_context.model_context_window(), + })); + } + + let incoming_turn_items = vec![ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "\necho hi\n".to_string(), + }], + end_turn: None, + phase: None, + }]; + let outcome = run_pre_turn_auto_compaction_if_needed( + &session, + &turn_context, + 10, + &incoming_turn_items, + ) + .await + .expect("pre-turn compaction no-op should not fail"); + + assert_eq!(outcome, PreTurnCompactionOutcome::NotNeeded); + assert_eq!(session.clone_history().await.raw_items(), &[]); + } + + #[test] + fn estimate_user_input_token_count_is_positive_for_text_input() { + let input = vec![UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }]; + let response_input_item = ResponseInputItem::from(input); + let response_item: ResponseItem = response_input_item.into(); + let estimated_tokens = estimate_item_token_count(&response_item); + assert!(estimated_tokens > 0); + } + + #[test] + fn estimate_user_input_token_count_ignores_skill_and_mention_payload_lengths() { + let short = vec![ + UserInput::Skill { + name: "s".to_string(), + path: PathBuf::from("/s"), + }, + UserInput::Mention { + name: "m".to_string(), + path: "app://m".to_string(), + }, + ]; + let long = vec![ + UserInput::Skill { + name: "very-long-skill-name-that-should-not-affect-prompt-serialization" + .to_string(), + path: PathBuf::from( + "/very/long/skill/path/that/should/not/affect/prompt/serialization/SKILL.md", + ), + }, + UserInput::Mention { + name: "very-long-mention-name-that-should-not-affect-prompt-serialization" + .to_string(), + path: "app://very-long-connector-path-that-should-not-affect-prompt-serialization" + .to_string(), + }, + ]; + + let short_response_input_item = ResponseInputItem::from(short); + let long_response_input_item = ResponseInputItem::from(long); + let short_response_item: ResponseItem = short_response_input_item.into(); + let long_response_item: ResponseItem = long_response_input_item.into(); + let short_tokens = estimate_item_token_count(&short_response_item); + let long_tokens = estimate_item_token_count(&long_response_item); + assert_eq!(short_tokens, long_tokens); + } + fn make_connector(id: &str, name: &str) -> AppInfo { AppInfo { id: id.to_string(), @@ -8083,8 +8559,8 @@ mod tests { .clone() .for_prompt(&reconstruction_turn.model_info.input_modalities); let user_messages1 = collect_user_messages(&snapshot1); - let rebuilt1 = - compact::build_compacted_history(initial_context.clone(), &user_messages1, summary1); + let mut rebuilt1 = initial_context.clone(); + rebuilt1.extend(compact::build_compacted_history(&user_messages1, summary1)); live_history.replace(rebuilt1); rollout_items.push(RolloutItem::Compacted(CompactedItem { message: summary1.to_string(), @@ -8126,8 +8602,8 @@ mod tests { .clone() .for_prompt(&reconstruction_turn.model_info.input_modalities); let user_messages2 = collect_user_messages(&snapshot2); - let rebuilt2 = - compact::build_compacted_history(initial_context.clone(), &user_messages2, summary2); + let mut rebuilt2 = initial_context.clone(); + rebuilt2.extend(compact::build_compacted_history(&user_messages2, summary2)); live_history.replace(rebuilt2); rollout_items.push(RolloutItem::Compacted(CompactedItem { message: summary2.to_string(), diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index a40f1a0b8ef..7eef5b53069 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -8,6 +8,7 @@ use crate::codex::Session; use crate::codex::TurnContext; use crate::codex::get_last_assistant_message_from_turn; use crate::context_manager::ContextManager; +use crate::context_manager::is_user_turn_boundary; use crate::error::CodexErr; use crate::error::Result as CodexResult; use crate::protocol::CompactedItem; @@ -17,6 +18,7 @@ use crate::protocol::WarningEvent; use crate::truncate::TruncationPolicy; use crate::truncate::approx_token_count; use crate::truncate::truncate_text; +use crate::user_shell_command::is_user_shell_command_text; use crate::util::backoff; use codex_protocol::items::ContextCompactionItem; use codex_protocol::items::TurnItem; @@ -32,6 +34,25 @@ pub const SUMMARIZATION_PROMPT: &str = include_str!("../templates/compact/prompt pub const SUMMARY_PREFIX: &str = include_str!("../templates/compact/summary_prefix.md"); const COMPACT_USER_MESSAGE_MAX_TOKENS: usize = 20_000; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CompactCallsite { + /// Manual `/compact` task. + ManualCompact, + /// Pre-turn auto-compaction where the incoming turn context + user message are included in + /// the compaction request. + PreTurnIncludingIncomingUserMessage, + /// Reserved pre-turn auto-compaction strategy that compacts from the end of the previous turn + /// only, excluding incoming turn context + user message. This is currently unused by the + /// default pre-turn flow and retained for future model-specific strategies. + #[allow(dead_code)] + PreTurnExcludingIncomingUserMessage, + /// Pre-sampling compaction triggered by model switch to a smaller context window. + /// This compacts prior-turn history only and should reinsert previous-turn canonical context. + PreSamplingModelSwitch, + /// Mid-turn compaction between assistant responses in a follow-up loop. + MidTurnContinuation, +} + pub(crate) fn should_use_remote_compact_task(provider: &ModelProviderInfo) -> bool { provider.is_openai() } @@ -61,9 +82,22 @@ pub(crate) fn extract_trailing_model_switch_update_for_compaction_request( Some(model_switch_item) } +pub(crate) fn extract_latest_model_switch_update_from_items( + items: &mut Vec, +) -> Option { + let model_switch_index = items + .iter() + .enumerate() + .rev() + .find_map(|(i, item)| Session::is_model_switch_developer_message(item).then_some(i))?; + Some(items.remove(model_switch_index)) +} + pub(crate) async fn run_inline_auto_compact_task( sess: Arc, turn_context: Arc, + compact_callsite: CompactCallsite, + incoming_items: Option>, ) -> CodexResult<()> { let prompt = turn_context.compact_prompt().to_string(); let input = vec![UserInput::Text { @@ -72,7 +106,7 @@ pub(crate) async fn run_inline_auto_compact_task( text_elements: Vec::new(), }]; - run_compact_task_inner(sess, turn_context, input).await?; + run_compact_task_inner(sess, turn_context, input, compact_callsite, incoming_items).await?; Ok(()) } @@ -87,13 +121,22 @@ pub(crate) async fn run_compact_task( collaboration_mode_kind: turn_context.collaboration_mode.mode, }); sess.send_event(&turn_context, start_event).await; - run_compact_task_inner(sess.clone(), turn_context, input).await + run_compact_task_inner( + sess, + turn_context, + input, + CompactCallsite::ManualCompact, + None, + ) + .await } async fn run_compact_task_inner( sess: Arc, turn_context: Arc, input: Vec, + compact_callsite: CompactCallsite, + incoming_items: Option>, ) -> CodexResult<()> { let compaction_item = TurnItem::ContextCompaction(ContextCompactionItem::new()); sess.emit_turn_item_started(&turn_context, &compaction_item) @@ -101,14 +144,33 @@ async fn run_compact_task_inner( let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input); let mut history = sess.clone_history().await; + let mut incoming_items = incoming_items; // Keep compaction prompts in-distribution: if a model-switch update was injected at the - // tail of history (between turns), exclude it from the compaction request payload. - let stripped_model_switch_item = - extract_trailing_model_switch_update_for_compaction_request(&mut history); + // tail of incoming turn items (pre-turn path) or between turns in history, exclude it from + // the compaction request payload. + let stripped_model_switch_item = incoming_items + .as_mut() + .and_then(extract_latest_model_switch_update_from_items) + .or_else(|| extract_trailing_model_switch_update_for_compaction_request(&mut history)); + if let Some(incoming_items) = incoming_items.as_ref() { + history.record_items(incoming_items.iter(), turn_context.truncation_policy); + } + if !history.raw_items().iter().any(is_user_turn_boundary) { + // Nothing to compact: do not rewrite history when there is no user-turn boundary. + sess.emit_turn_item_completed(&turn_context, compaction_item) + .await; + return Ok(()); + } history.record_items( &[initial_input_for_turn.into()], turn_context.truncation_policy, ); + // Keep incoming turn items and the compaction prompt pinned at the tail while trimming. + // Pre-turn compaction should fail with ContextWindowExceeded rather than dropping incoming + // items to force compaction to succeed. + let protected_tail_items = incoming_items + .as_ref() + .map_or(1_usize, |items| items.len().saturating_add(1)); let mut truncated_count = 0usize; @@ -166,9 +228,12 @@ async fn run_compact_task_inner( return Err(CodexErr::Interrupted); } Err(e @ CodexErr::ContextWindowExceeded) => { - if turn_input_len > 1 { - // Trim from the beginning to preserve cache (prefix-based) and keep recent messages intact. + if turn_input_len > 1 && history.raw_items().len() > protected_tail_items { + // Trim from the beginning to preserve cache (prefix-based) and keep recent + // messages intact. error!( + turn_id = %turn_context.sub_id, + compact_callsite = ?compact_callsite, "Context window exceeded while compacting; removing oldest history item. Error: {e}" ); history.remove_first_item(); @@ -177,8 +242,12 @@ async fn run_compact_task_inner( continue; } sess.set_total_tokens_full(turn_context.as_ref()).await; - let event = EventMsg::Error(e.to_error_event(None)); - sess.send_event(&turn_context, event).await; + error!( + turn_id = %turn_context.sub_id, + compact_callsite = ?compact_callsite, + compact_error = %e, + "compaction failed after history truncation could not proceed" + ); return Err(e); } Err(e) => { @@ -193,11 +262,16 @@ async fn run_compact_task_inner( .await; tokio::time::sleep(delay).await; continue; - } else { - let event = EventMsg::Error(e.to_error_event(None)); - sess.send_event(&turn_context, event).await; - return Err(e); } + error!( + turn_id = %turn_context.sub_id, + compact_callsite = ?compact_callsite, + retries, + max_retries, + compact_error = %e, + "compaction failed after retry exhaustion" + ); + return Err(e); } } } @@ -207,11 +281,31 @@ async fn run_compact_task_inner( let summary_suffix = get_last_assistant_message_from_turn(history_items).unwrap_or_default(); let summary_text = format!("{SUMMARY_PREFIX}\n{summary_suffix}"); let user_messages = collect_user_messages(history_items); - - let initial_context = sess.build_initial_context(turn_context.as_ref()).await; - let mut new_history = build_compacted_history(initial_context, &user_messages, &summary_text); - // Reattach the stripped model-switch update only after successful compaction so the model - // still sees the switch instructions on the next real sampling request. + let compacted_history = build_compacted_history_with_limit( + &user_messages, + &summary_text, + COMPACT_USER_MESSAGE_MAX_TOKENS, + ); + let mut new_history = process_compacted_history(compacted_history); + match compact_callsite { + CompactCallsite::MidTurnContinuation | CompactCallsite::PreSamplingModelSwitch => { + // These callsites do not get a later post-compaction canonical-context write in + // `run_turn`, so replacement history must carry canonical context directly. + let initial_context = sess.build_initial_context(turn_context.as_ref()).await; + insert_initial_context_before_last_user_anchor(&mut new_history, initial_context); + } + CompactCallsite::ManualCompact => { + // Manual `/compact` intentionally rewrites transcript history without reseeding turn + // context here; the task marks initial context unseeded for the next user turn. + } + CompactCallsite::PreTurnIncludingIncomingUserMessage + | CompactCallsite::PreTurnExcludingIncomingUserMessage => { + // Pre-turn compaction persists canonical context directly above the incoming user + // message in `run_turn`, not inside compacted replacement history. + } + } + // Reattach stripped model-switch updates into replacement history so post-compaction + // sampling keeps model-switch guidance regardless of compaction callsite. if let Some(model_switch_item) = stripped_model_switch_item { new_history.push(model_switch_item); } @@ -226,7 +320,7 @@ async fn run_compact_task_inner( let rollout_item = RolloutItem::Compacted(CompactedItem { message: summary_text.clone(), - replacement_history: None, + replacement_history: Some(sess.clone_history().await.raw_items().to_vec()), }); sess.persist_rollout_items(&[rollout_item]).await; @@ -261,16 +355,8 @@ pub fn content_items_to_text(content: &[ContentItem]) -> Option { pub(crate) fn collect_user_messages(items: &[ResponseItem]) -> Vec { items .iter() - .filter_map(|item| match crate::event_mapping::parse_turn_item(item) { - Some(TurnItem::UserMessage(user)) => { - if is_summary_message(&user.message()) { - None - } else { - Some(user.message()) - } - } - _ => None, - }) + .filter_map(parsed_user_message_text) + .filter(|message| !is_summary_message(message)) .collect() } @@ -280,27 +366,66 @@ pub(crate) fn is_summary_message(message: &str) -> bool { pub(crate) fn process_compacted_history( mut compacted_history: Vec, - initial_context: &[ResponseItem], ) -> Vec { + // Keep only model-visible transcript items that we allow from remote compaction output. compacted_history.retain(should_keep_compacted_history_item); - let initial_context = initial_context.to_vec(); + compacted_history +} - // Re-inject canonical context from the current session since we stripped it - // from the pre-compaction history. Keep it right before the last user - // message so older user messages remain earlier in the transcript. - if let Some(last_user_index) = compacted_history.iter().rposition(|item| { - matches!( - crate::event_mapping::parse_turn_item(item), - Some(TurnItem::UserMessage(_)) - ) - }) { - compacted_history.splice(last_user_index..last_user_index, initial_context); +/// Inserts canonical initial context immediately before the latest user anchor in compacted +/// replacement history: +/// - prefer the last real (non-summary) user message; +/// - otherwise fall back to the last summary user message. +/// +/// If no user anchor exists, append initial context at the end. +pub(crate) fn insert_initial_context_before_last_user_anchor( + compacted_history: &mut Vec, + initial_context: Vec, +) { + if initial_context.is_empty() { + return; + } + let insertion_index = compacted_history + .iter() + .rposition(is_real_user_message) + .or_else(|| { + compacted_history + .iter() + .rposition(is_summary_user_message_item) + }); + if let Some(index) = insertion_index { + compacted_history.splice(index..index, initial_context); } else { compacted_history.extend(initial_context); } +} - compacted_history +fn is_real_user_message(item: &ResponseItem) -> bool { + parsed_user_message_text(item).is_some_and(|message| !is_summary_message(&message)) +} + +fn is_summary_user_message_item(item: &ResponseItem) -> bool { + parsed_user_message_text(item).is_some_and(|message| is_summary_message(&message)) +} + +fn parsed_user_message_text(item: &ResponseItem) -> Option { + match crate::event_mapping::parse_turn_item(item) { + Some(TurnItem::UserMessage(user_message)) => Some(user_message.message()), + _ => None, + } +} + +fn is_user_shell_command_record(item: &ResponseItem) -> bool { + matches!( + item, + ResponseItem::Message { role, content, .. } + if role == "user" + && matches!( + content.as_slice(), + [ContentItem::InputText { text }] if is_user_shell_command_text(text) + ) + ) } /// Returns whether an item from remote compaction output should be preserved. @@ -313,39 +438,51 @@ pub(crate) fn process_compacted_history( /// instruction content. /// - non-user-content `user` messages (session prefix/instruction wrappers), /// keeping only real user messages as parsed by `parse_turn_item`. +/// - all non-user transcript items except compaction records. /// -/// This intentionally keeps `user`-role warnings and compaction-generated -/// summary messages because they parse as `TurnItem::UserMessage`. -fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { +/// This intentionally keeps compaction-generated summary messages because they +/// parse as `TurnItem::UserMessage`. +pub(crate) fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { match item { - ResponseItem::Message { role, .. } if role == "developer" => false, - ResponseItem::Message { role, .. } if role == "user" => matches!( - crate::event_mapping::parse_turn_item(item), - Some(TurnItem::UserMessage(_)) - ), - _ => true, + ResponseItem::Message { role, .. } => { + if role != "user" { + return false; + } + if is_user_shell_command_record(item) { + // TODO(ccunningham): Truncate preserved user shell-command records so they cannot + // cause repeated context-window overflows across compaction attempts. + return true; + } + + parsed_user_message_text(item).is_some() + } + // Keep compaction records for local/remote history continuity and token accounting. + ResponseItem::Compaction { .. } => true, + ResponseItem::Reasoning { .. } + | ResponseItem::LocalShellCall { .. } + | ResponseItem::FunctionCall { .. } + | ResponseItem::FunctionCallOutput { .. } + | ResponseItem::CustomToolCall { .. } + | ResponseItem::CustomToolCallOutput { .. } + | ResponseItem::WebSearchCall { .. } + | ResponseItem::GhostSnapshot { .. } + | ResponseItem::Other => false, } } pub(crate) fn build_compacted_history( - initial_context: Vec, user_messages: &[String], summary_text: &str, ) -> Vec { - build_compacted_history_with_limit( - initial_context, - user_messages, - summary_text, - COMPACT_USER_MESSAGE_MAX_TOKENS, - ) + build_compacted_history_with_limit(user_messages, summary_text, COMPACT_USER_MESSAGE_MAX_TOKENS) } fn build_compacted_history_with_limit( - mut history: Vec, user_messages: &[String], summary_text: &str, max_tokens: usize, ) -> Vec { + let mut history = Vec::new(); let mut selected_messages: Vec = Vec::new(); if max_tokens > 0 { let mut remaining = max_tokens; @@ -444,7 +581,6 @@ async fn drain_to_completed( #[cfg(test)] mod tests { - use super::*; use pretty_assertions::assert_eq; @@ -579,6 +715,95 @@ mod tests { ); } + #[test] + fn extract_model_switch_update_for_compaction_request_prefers_incoming_items() { + let mut history = ContextManager::new(); + history.replace(vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "USER_MESSAGE".to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: "ASSISTANT_REPLY".to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "\nHISTORY_MODEL_INSTRUCTIONS".to_string(), + }], + end_turn: None, + phase: None, + }, + ]); + let mut incoming_items = vec![ + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "\nINCOMING_MODEL_INSTRUCTIONS".to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "INCOMING_USER_MESSAGE".to_string(), + }], + end_turn: None, + phase: None, + }, + ]; + + let model_switch_item = Some(&mut incoming_items) + .and_then(extract_latest_model_switch_update_from_items) + .or_else(|| extract_trailing_model_switch_update_for_compaction_request(&mut history)); + + assert_eq!( + model_switch_item, + Some(ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "\nINCOMING_MODEL_INSTRUCTIONS".to_string(), + }], + end_turn: None, + phase: None, + }) + ); + assert_eq!( + incoming_items, + vec![ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "INCOMING_USER_MESSAGE".to_string(), + }], + end_turn: None, + phase: None, + }] + ); + assert!( + history + .raw_items() + .iter() + .any(Session::is_model_switch_developer_message) + ); + } + #[test] fn collect_user_messages_extracts_user_text_only() { let items = vec![ @@ -657,7 +882,6 @@ do things let max_tokens = 16; let big = "word ".repeat(200); let history = super::build_compacted_history_with_limit( - Vec::new(), std::slice::from_ref(&big), "SUMMARY", max_tokens, @@ -694,11 +918,10 @@ do things #[test] fn build_token_limited_compacted_history_appends_summary_message() { - let initial_context: Vec = Vec::new(); let user_messages = vec!["first user message".to_string()]; let summary_text = "summary text"; - let history = build_compacted_history(initial_context, &user_messages, summary_text); + let history = build_compacted_history(&user_messages, summary_text); assert!( !history.is_empty(), "expected compacted history to include summary" @@ -715,42 +938,16 @@ do things } #[test] - fn process_compacted_history_replaces_developer_messages() { - let compacted_history = vec![ - ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: "stale permissions".to_string(), - }], - end_turn: None, - phase: None, - }, + fn build_compacted_history_preserves_user_message_structure() { + let history = + super::build_compacted_history_with_limit(&["older user".to_string()], "SUMMARY", 128); + + let expected = vec![ ResponseItem::Message { id: None, role: "user".to_string(), content: vec![ContentItem::InputText { - text: "summary".to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: "stale personality".to_string(), - }], - end_turn: None, - phase: None, - }, - ]; - let initial_context = vec![ - ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: "fresh permissions".to_string(), + text: "older user".to_string(), }], end_turn: None, phase: None, @@ -759,33 +956,91 @@ do things id: None, role: "user".to_string(), content: vec![ContentItem::InputText { - text: r#" - /tmp - zsh -"# - .to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: "fresh personality".to_string(), + text: "SUMMARY".to_string(), }], end_turn: None, phase: None, }, ]; - let refreshed = process_compacted_history(compacted_history, &initial_context); - let expected = vec![ + assert_eq!(history, expected); + } + + #[test] + fn real_user_message_includes_image_only_user_messages() { + let image_only_user = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputImage { + image_url: "data:image/png;base64,AAAA".to_string(), + }], + end_turn: None, + phase: None, + }; + + assert!(super::is_real_user_message(&image_only_user)); + } + + #[test] + fn real_user_message_excludes_user_shell_command_records() { + let shell_command_user = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "\necho hi\n".to_string(), + }], + end_turn: None, + phase: None, + }; + + assert!(!super::is_real_user_message(&shell_command_user)); + } + + #[test] + fn should_keep_compacted_history_item_drops_user_session_prefix_and_keeps_user_shell_command() { + let session_prefix = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "\n /repo\n" + .to_string(), + }], + end_turn: None, + phase: None, + }; + let shell_command_user = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "\necho hi\n".to_string(), + }], + end_turn: None, + phase: None, + }; + + assert!(!super::should_keep_compacted_history_item(&session_prefix)); + assert!(super::should_keep_compacted_history_item( + &shell_command_user + )); + } + + #[test] + fn should_keep_compacted_history_item_keeps_compaction_item() { + let compaction = ResponseItem::Compaction { + encrypted_content: "abc123".to_string(), + }; + + assert!(super::should_keep_compacted_history_item(&compaction)); + } + + #[test] + fn process_compacted_history_drops_developer_messages() { + let compacted_history = vec![ ResponseItem::Message { id: None, role: "developer".to_string(), content: vec![ContentItem::InputText { - text: "fresh permissions".to_string(), + text: "stale permissions".to_string(), }], end_turn: None, phase: None, @@ -794,11 +1049,7 @@ do things id: None, role: "user".to_string(), content: vec![ContentItem::InputText { - text: r#" - /tmp - zsh -"# - .to_string(), + text: "summary".to_string(), }], end_turn: None, phase: None, @@ -807,27 +1058,15 @@ do things id: None, role: "developer".to_string(), content: vec![ContentItem::InputText { - text: "fresh personality".to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "summary".to_string(), + text: "stale personality".to_string(), }], end_turn: None, phase: None, }, ]; - assert_eq!(refreshed, expected); - } - #[test] - fn process_compacted_history_reinjects_full_initial_context() { - let compacted_history = vec![ResponseItem::Message { + let refreshed = process_compacted_history(compacted_history); + let expected = vec![ResponseItem::Message { id: None, role: "user".to_string(), content: vec![ContentItem::InputText { @@ -836,16 +1075,12 @@ do things end_turn: None, phase: None, }]; - let initial_context = vec![ - ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: "fresh permissions".to_string(), - }], - end_turn: None, - phase: None, - }, + assert_eq!(refreshed, expected); + } + + #[test] + fn process_compacted_history_drops_non_user_content_messages() { + let compacted_history = vec![ ResponseItem::Message { id: None, role: "user".to_string(), @@ -886,42 +1121,47 @@ keep me updated end_turn: None, phase: None, }, - ]; - - let refreshed = process_compacted_history(compacted_history, &initial_context); - let expected = vec![ ResponseItem::Message { id: None, - role: "developer".to_string(), + role: "user".to_string(), content: vec![ContentItem::InputText { - text: "fresh permissions".to_string(), + text: "summary".to_string(), }], end_turn: None, phase: None, }, ResponseItem::Message { id: None, - role: "user".to_string(), + role: "developer".to_string(), content: vec![ContentItem::InputText { - text: r#"# AGENTS.md instructions for /repo - - -keep me updated -"# - .to_string(), + text: "stale developer instructions".to_string(), }], end_turn: None, phase: None, }, + ]; + + let refreshed = process_compacted_history(compacted_history); + let expected = vec![ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "summary".to_string(), + }], + end_turn: None, + phase: None, + }]; + assert_eq!(refreshed, expected); + } + + #[test] + fn process_compacted_history_preserves_summary_order() { + let compacted_history = vec![ ResponseItem::Message { id: None, role: "user".to_string(), content: vec![ContentItem::InputText { - text: r#" - /repo - zsh -"# - .to_string(), + text: "older user".to_string(), }], end_turn: None, phase: None, @@ -930,11 +1170,7 @@ keep me updated id: None, role: "user".to_string(), content: vec![ContentItem::InputText { - text: r#" - turn-1 - interrupted -"# - .to_string(), + text: format!("{SUMMARY_PREFIX}\nolder summary"), }], end_turn: None, phase: None, @@ -943,41 +1179,38 @@ keep me updated id: None, role: "user".to_string(), content: vec![ContentItem::InputText { - text: "summary".to_string(), + text: "newer user".to_string(), }], end_turn: None, phase: None, }, - ]; - assert_eq!(refreshed, expected); - } - - #[test] - fn process_compacted_history_drops_non_user_content_messages() { - let compacted_history = vec![ ResponseItem::Message { id: None, role: "user".to_string(), content: vec![ContentItem::InputText { - text: r#"# AGENTS.md instructions for /repo - - -keep me updated -"# - .to_string(), + text: format!("{SUMMARY_PREFIX}\nlatest summary"), }], end_turn: None, phase: None, }, + ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: "assistant after latest summary".to_string(), + }], + end_turn: None, + phase: None, + }, + ]; + + let refreshed = process_compacted_history(compacted_history); + let expected = vec![ ResponseItem::Message { id: None, role: "user".to_string(), content: vec![ContentItem::InputText { - text: r#" - /repo - zsh -"# - .to_string(), + text: "older user".to_string(), }], end_turn: None, phase: None, @@ -986,11 +1219,7 @@ keep me updated id: None, role: "user".to_string(), content: vec![ContentItem::InputText { - text: r#" - turn-1 - interrupted -"# - .to_string(), + text: format!("{SUMMARY_PREFIX}\nolder summary"), }], end_turn: None, phase: None, @@ -999,38 +1228,57 @@ keep me updated id: None, role: "user".to_string(), content: vec![ContentItem::InputText { - text: "summary".to_string(), + text: "newer user".to_string(), }], end_turn: None, phase: None, }, ResponseItem::Message { id: None, - role: "developer".to_string(), + role: "user".to_string(), content: vec![ContentItem::InputText { - text: "stale developer instructions".to_string(), + text: format!("{SUMMARY_PREFIX}\nlatest summary"), }], end_turn: None, phase: None, }, ]; - let initial_context = vec![ResponseItem::Message { + assert_eq!(refreshed, expected); + } + + #[test] + fn process_compacted_history_keeps_summary_only_history() { + let compacted_history = vec![ResponseItem::Message { id: None, - role: "developer".to_string(), + role: "user".to_string(), content: vec![ContentItem::InputText { - text: "fresh developer instructions".to_string(), + text: format!("{SUMMARY_PREFIX}\nsummary text"), }], end_turn: None, phase: None, }]; - let refreshed = process_compacted_history(compacted_history, &initial_context); - let expected = vec![ + let refreshed = process_compacted_history(compacted_history); + let expected = vec![ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: format!("{SUMMARY_PREFIX}\nsummary text"), + }], + end_turn: None, + phase: None, + }]; + assert_eq!(refreshed, expected); + } + + #[test] + fn insert_initial_context_before_last_user_anchor_falls_back_to_last_summary() { + let mut compacted_history = vec![ ResponseItem::Message { id: None, - role: "developer".to_string(), + role: "user".to_string(), content: vec![ContentItem::InputText { - text: "fresh developer instructions".to_string(), + text: format!("{SUMMARY_PREFIX}\nolder summary"), }], end_turn: None, phase: None, @@ -1039,32 +1287,39 @@ keep me updated id: None, role: "user".to_string(), content: vec![ContentItem::InputText { - text: "summary".to_string(), + text: format!("{SUMMARY_PREFIX}\nlatest summary"), }], end_turn: None, phase: None, }, ]; - assert_eq!(refreshed, expected); - } + let initial_context = vec![ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "fresh permissions".to_string(), + }], + end_turn: None, + phase: None, + }]; - #[test] - fn process_compacted_history_inserts_context_before_last_real_user_message_only() { - let compacted_history = vec![ + insert_initial_context_before_last_user_anchor(&mut compacted_history, initial_context); + + let expected = vec![ ResponseItem::Message { id: None, role: "user".to_string(), content: vec![ContentItem::InputText { - text: "older user".to_string(), + text: format!("{SUMMARY_PREFIX}\nolder summary"), }], end_turn: None, phase: None, }, ResponseItem::Message { id: None, - role: "user".to_string(), + role: "developer".to_string(), content: vec![ContentItem::InputText { - text: format!("{SUMMARY_PREFIX}\nsummary text"), + text: "fresh permissions".to_string(), }], end_turn: None, phase: None, @@ -1073,12 +1328,26 @@ keep me updated id: None, role: "user".to_string(), content: vec![ContentItem::InputText { - text: "latest user".to_string(), + text: format!("{SUMMARY_PREFIX}\nlatest summary"), }], end_turn: None, phase: None, }, ]; + assert_eq!(compacted_history, expected); + } + + #[test] + fn insert_initial_context_before_last_user_anchor_appends_when_no_user_anchor_exists() { + let mut compacted_history = vec![ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: "assistant only".to_string(), + }], + end_turn: None, + phase: None, + }]; let initial_context = vec![ResponseItem::Message { id: None, role: "developer".to_string(), @@ -1089,22 +1358,14 @@ keep me updated phase: None, }]; - let refreshed = process_compacted_history(compacted_history, &initial_context); + insert_initial_context_before_last_user_anchor(&mut compacted_history, initial_context); + let expected = vec![ ResponseItem::Message { id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "older user".to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: format!("{SUMMARY_PREFIX}\nsummary text"), + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: "assistant only".to_string(), }], end_turn: None, phase: None, @@ -1118,16 +1379,7 @@ keep me updated end_turn: None, phase: None, }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "latest user".to_string(), - }], - end_turn: None, - phase: None, - }, ]; - assert_eq!(refreshed, expected); + assert_eq!(compacted_history, expected); } } diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 3d181f88552..ac8f6c93005 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -3,11 +3,18 @@ use std::sync::Arc; use crate::Prompt; use crate::codex::Session; use crate::codex::TurnContext; +use crate::compact::CompactCallsite; +use crate::compact::extract_latest_model_switch_update_from_items; use crate::compact::extract_trailing_model_switch_update_for_compaction_request; +use crate::compact::insert_initial_context_before_last_user_anchor; +use crate::compact::process_compacted_history; +use crate::compact::should_keep_compacted_history_item; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; +use crate::context_manager::estimate_item_token_count; use crate::context_manager::estimate_response_item_model_visible_bytes; use crate::context_manager::is_codex_generated_item; +use crate::context_manager::is_user_turn_boundary; use crate::error::CodexErr; use crate::error::Result as CodexResult; use crate::protocol::CompactedItem; @@ -25,8 +32,10 @@ use tracing::info; pub(crate) async fn run_inline_remote_auto_compact_task( sess: Arc, turn_context: Arc, + compact_callsite: CompactCallsite, + incoming_items: Option>, ) -> CodexResult<()> { - run_remote_compact_task_inner(&sess, &turn_context).await?; + run_remote_compact_task_inner(&sess, &turn_context, compact_callsite, incoming_items).await?; Ok(()) } @@ -41,18 +50,25 @@ pub(crate) async fn run_remote_compact_task( }); sess.send_event(&turn_context, start_event).await; - run_remote_compact_task_inner(&sess, &turn_context).await + run_remote_compact_task_inner(&sess, &turn_context, CompactCallsite::ManualCompact, None).await } async fn run_remote_compact_task_inner( sess: &Arc, turn_context: &Arc, + compact_callsite: CompactCallsite, + incoming_items: Option>, ) -> CodexResult<()> { - if let Err(err) = run_remote_compact_task_inner_impl(sess, turn_context).await { - let event = EventMsg::Error( - err.to_error_event(Some("Error running remote compact task".to_string())), + if let Err(err) = + run_remote_compact_task_inner_impl(sess, turn_context, compact_callsite, incoming_items) + .await + { + error!( + turn_id = %turn_context.sub_id, + compact_callsite = ?compact_callsite, + compact_error = %err, + "remote compaction task failed" ); - sess.send_event(turn_context, event).await; return Err(err); } Ok(()) @@ -61,24 +77,42 @@ async fn run_remote_compact_task_inner( async fn run_remote_compact_task_inner_impl( sess: &Arc, turn_context: &Arc, + compact_callsite: CompactCallsite, + incoming_items: Option>, ) -> CodexResult<()> { let compaction_item = TurnItem::ContextCompaction(ContextCompactionItem::new()); sess.emit_turn_item_started(turn_context, &compaction_item) .await; let mut history = sess.clone_history().await; + let mut incoming_items = incoming_items; // Keep compaction prompts in-distribution: if a model-switch update was injected at the - // tail of history (between turns), exclude it from the compaction request payload. - let stripped_model_switch_item = - extract_trailing_model_switch_update_for_compaction_request(&mut history); + // tail of incoming turn items (pre-turn path) or between turns in history, exclude it from + // the compaction request payload. + let stripped_model_switch_item = incoming_items + .as_mut() + .and_then(extract_latest_model_switch_update_from_items) + .or_else(|| extract_trailing_model_switch_update_for_compaction_request(&mut history)); let base_instructions = sess.get_base_instructions().await; let deleted_items = trim_function_call_history_to_fit_context_window( &mut history, turn_context.as_ref(), &base_instructions, + incoming_items.as_deref(), ); + let historical_items_before_incoming = history.raw_items().to_vec(); + if let Some(incoming_items) = incoming_items.as_ref() { + history.record_items(incoming_items.iter(), turn_context.truncation_policy); + } + if !history.raw_items().iter().any(is_user_turn_boundary) { + // Nothing to compact: do not rewrite history when there is no user-turn boundary. + sess.emit_turn_item_completed(turn_context, compaction_item) + .await; + return Ok(()); + } if deleted_items > 0 { info!( turn_id = %turn_context.sub_id, + compact_callsite = ?compact_callsite, deleted_items, "trimmed history items before remote compaction" ); @@ -115,6 +149,7 @@ async fn run_remote_compact_task_inner_impl( build_compact_request_log_data(&prompt.input, &prompt.base_instructions.text); log_remote_compact_failure( turn_context, + compact_callsite, &compact_request_log_data, total_usage_breakdown, &err, @@ -122,11 +157,38 @@ async fn run_remote_compact_task_inner_impl( Err(err) }) .await?; - new_history = sess - .process_compacted_history(turn_context, new_history) - .await; - // Reattach the stripped model-switch update only after successful compaction so the model - // still sees the switch instructions on the next real sampling request. + new_history = process_compacted_history(new_history); + match compact_callsite { + CompactCallsite::MidTurnContinuation | CompactCallsite::PreSamplingModelSwitch => { + // These callsites do not get a later post-compaction canonical-context write in + // `run_turn`, so replacement history must carry canonical context directly. + let initial_context = sess.build_initial_context(turn_context.as_ref()).await; + insert_initial_context_before_last_user_anchor(&mut new_history, initial_context); + } + CompactCallsite::ManualCompact => { + // Manual `/compact` intentionally rewrites transcript history without reseeding turn + // context here; the task marks initial context unseeded for the next user turn. + } + CompactCallsite::PreTurnIncludingIncomingUserMessage + | CompactCallsite::PreTurnExcludingIncomingUserMessage => { + // Pre-turn compaction persists canonical context directly above the incoming user + // message in `run_turn`, not inside compacted replacement history. + } + } + if let Some(incoming_items) = incoming_items.as_ref() { + let incoming_history_items: Vec = incoming_items + .iter() + .filter(|item| should_keep_compacted_history_item(item)) + .cloned() + .collect(); + remove_incoming_echoes_from_compacted_history( + &mut new_history, + &incoming_history_items, + &historical_items_before_incoming, + ); + } + // Reattach stripped model-switch updates into replacement history so post-compaction + // sampling keeps model-switch guidance regardless of compaction callsite. if let Some(model_switch_item) = stripped_model_switch_item { new_history.push(model_switch_item); } @@ -171,14 +233,70 @@ fn build_compact_request_log_data( } } +/// Remote compaction may echo incoming items in `new_history`. Because incoming items are +/// appended after compaction at the caller, remove one semantic duplicate for each incoming item +/// so turn-context/user entries do not appear twice. +fn remove_incoming_echoes_from_compacted_history( + new_history: &mut Vec, + incoming_history_items: &[ResponseItem], + historical_items_before_incoming: &[ResponseItem], +) { + let items_match = + |candidate: &ResponseItem, incoming_item: &ResponseItem| match (candidate, incoming_item) { + ( + ResponseItem::Message { + role: candidate_role, + content: candidate_content, + .. + }, + ResponseItem::Message { + role: incoming_role, + content: incoming_content, + .. + }, + ) => candidate_role == incoming_role && candidate_content == incoming_content, + ( + ResponseItem::Compaction { + encrypted_content: candidate_content, + }, + ResponseItem::Compaction { + encrypted_content: incoming_content, + }, + ) => candidate_content == incoming_content, + _ => candidate == incoming_item, + }; + + for incoming_item in incoming_history_items { + let historical_count = historical_items_before_incoming + .iter() + .filter(|candidate| items_match(candidate, incoming_item)) + .count(); + let compacted_count = new_history + .iter() + .filter(|candidate| items_match(candidate, incoming_item)) + .count(); + if compacted_count <= historical_count { + continue; + } + if let Some(index) = new_history + .iter() + .rposition(|candidate| items_match(candidate, incoming_item)) + { + new_history.remove(index); + } + } +} + fn log_remote_compact_failure( turn_context: &TurnContext, + compact_callsite: CompactCallsite, log_data: &CompactRequestLogData, total_usage_breakdown: TotalTokenUsageBreakdown, err: &CodexErr, ) { error!( turn_id = %turn_context.sub_id, + compact_callsite = ?compact_callsite, last_api_response_total_tokens = total_usage_breakdown.last_api_response_total_tokens, all_history_items_model_visible_bytes = total_usage_breakdown.all_history_items_model_visible_bytes, estimated_tokens_of_items_added_since_last_successful_api_response = total_usage_breakdown.estimated_tokens_of_items_added_since_last_successful_api_response, @@ -194,15 +312,37 @@ fn trim_function_call_history_to_fit_context_window( history: &mut ContextManager, turn_context: &TurnContext, base_instructions: &BaseInstructions, + incoming_items: Option<&[ResponseItem]>, ) -> usize { - let mut deleted_items = 0usize; let Some(context_window) = turn_context.model_context_window() else { - return deleted_items; + return 0; }; + let incoming_items_tokens = incoming_items + .unwrap_or_default() + .iter() + .map(estimate_item_token_count) + .fold(0_i64, i64::saturating_add); + trim_codex_generated_tail_items_to_fit_context_window( + history, + context_window, + base_instructions, + incoming_items_tokens, + ) +} + +fn trim_codex_generated_tail_items_to_fit_context_window( + history: &mut ContextManager, + context_window: i64, + base_instructions: &BaseInstructions, + incoming_items_tokens: i64, +) -> usize { + let mut deleted_items = 0usize; while history .estimate_token_count_with_base_instructions(base_instructions) - .is_some_and(|estimated_tokens| estimated_tokens > context_window) + .is_some_and(|estimated_tokens| { + estimated_tokens.saturating_add(incoming_items_tokens) > context_window + }) { let Some(last_item) = history.raw_items().last() else { break; @@ -218,3 +358,139 @@ fn trim_function_call_history_to_fit_context_window( deleted_items } + +#[cfg(test)] +mod tests { + use super::*; + use crate::truncate::TruncationPolicy; + use codex_protocol::models::ContentItem; + use pretty_assertions::assert_eq; + + fn user_message(text: &str) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: text.to_string(), + }], + end_turn: None, + phase: None, + } + } + + fn developer_message(text: &str) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: text.to_string(), + }], + end_turn: None, + phase: None, + } + } + + fn summary_user_message(text: &str) -> ResponseItem { + user_message(&format!("{}\n{text}", crate::compact::SUMMARY_PREFIX)) + } + + #[test] + fn trim_accounts_for_incoming_items_tokens() { + let base_instructions = BaseInstructions { + text: String::new(), + }; + let incoming_items = [user_message( + "INCOMING_USER_MESSAGE_THAT_TIPS_OVER_THE_WINDOW", + )]; + let incoming_items_tokens = incoming_items + .iter() + .map(estimate_item_token_count) + .fold(0_i64, i64::saturating_add); + assert!( + incoming_items_tokens > 0, + "expected incoming item token estimate to be positive" + ); + + let mut history = ContextManager::new(); + let history_items = [ + user_message("USER_ONE"), + developer_message("TRAILING_CODEX_GENERATED_CONTEXT"), + ]; + history.record_items(history_items.iter(), TruncationPolicy::Tokens(10_000)); + let history_tokens = history + .estimate_token_count_with_base_instructions(&base_instructions) + .unwrap_or_default(); + let context_window = history_tokens + .saturating_add(incoming_items_tokens) + .saturating_sub(1); + let mut without_incoming_projection = history.clone(); + + let deleted_without_incoming = trim_codex_generated_tail_items_to_fit_context_window( + &mut without_incoming_projection, + context_window, + &base_instructions, + 0, + ); + assert_eq!( + deleted_without_incoming, 0, + "history-only projection should not trim when currently under the limit" + ); + + let deleted_with_incoming = trim_codex_generated_tail_items_to_fit_context_window( + &mut history, + context_window, + &base_instructions, + incoming_items_tokens, + ); + assert_eq!( + deleted_with_incoming, 1, + "incoming projection should trim trailing codex-generated history to fit pre-turn request" + ); + assert_eq!(history.raw_items(), vec![user_message("USER_ONE")]); + } + + #[test] + fn remove_incoming_echoes_preserves_historical_duplicates_when_not_echoed() { + let mut compacted_history = vec![ + user_message("REPEAT_MESSAGE"), + summary_user_message("LATEST_SUMMARY"), + ]; + let incoming_items = vec![user_message("REPEAT_MESSAGE")]; + let historical_items_before_incoming = vec![user_message("REPEAT_MESSAGE")]; + + remove_incoming_echoes_from_compacted_history( + &mut compacted_history, + &incoming_items, + &historical_items_before_incoming, + ); + + let expected = vec![ + user_message("REPEAT_MESSAGE"), + summary_user_message("LATEST_SUMMARY"), + ]; + assert_eq!(compacted_history, expected); + } + + #[test] + fn remove_incoming_echoes_removes_net_new_echoes_only() { + let mut compacted_history = vec![ + user_message("REPEAT_MESSAGE"), + user_message("REPEAT_MESSAGE"), + summary_user_message("LATEST_SUMMARY"), + ]; + let incoming_items = vec![user_message("REPEAT_MESSAGE")]; + let historical_items_before_incoming = vec![user_message("REPEAT_MESSAGE")]; + + remove_incoming_echoes_from_compacted_history( + &mut compacted_history, + &incoming_items, + &historical_items_before_incoming, + ); + + let expected = vec![ + user_message("REPEAT_MESSAGE"), + summary_user_message("LATEST_SUMMARY"), + ]; + assert_eq!(compacted_history, expected); + } +} diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index b6b3ceae5af..acd9778d969 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -395,7 +395,7 @@ fn estimate_reasoning_length(encoded_len: usize) -> usize { .saturating_sub(650) } -fn estimate_item_token_count(item: &ResponseItem) -> i64 { +pub(crate) fn estimate_item_token_count(item: &ResponseItem) -> i64 { let model_visible_bytes = estimate_response_item_model_visible_bytes(item); approx_tokens_from_byte_count_i64(model_visible_bytes) } diff --git a/codex-rs/core/src/context_manager/mod.rs b/codex-rs/core/src/context_manager/mod.rs index 853f8af5ac0..2118ebc5754 100644 --- a/codex-rs/core/src/context_manager/mod.rs +++ b/codex-rs/core/src/context_manager/mod.rs @@ -4,6 +4,7 @@ pub(crate) mod updates; pub(crate) use history::ContextManager; pub(crate) use history::TotalTokenUsageBreakdown; +pub(crate) use history::estimate_item_token_count; pub(crate) use history::estimate_response_item_model_visible_bytes; pub(crate) use history::is_codex_generated_item; pub(crate) use history::is_user_turn_boundary; diff --git a/codex-rs/core/src/tasks/compact.rs b/codex-rs/core/src/tasks/compact.rs index b56f7b1df52..c011edccbae 100644 --- a/codex-rs/core/src/tasks/compact.rs +++ b/codex-rs/core/src/tasks/compact.rs @@ -3,6 +3,8 @@ use std::sync::Arc; use super::SessionTask; use super::SessionTaskContext; use crate::codex::TurnContext; +use crate::context_manager::is_user_turn_boundary; +use crate::protocol::EventMsg; use crate::state::TaskKind; use async_trait::async_trait; use codex_protocol::user_input::UserInput; @@ -25,20 +27,50 @@ impl SessionTask for CompactTask { _cancellation_token: CancellationToken, ) -> Option { let session = session.clone_session(); + let has_user_turn_boundary = session + .clone_history() + .await + .raw_items() + .iter() + .any(is_user_turn_boundary); if crate::compact::should_use_remote_compact_task(&ctx.provider) { let _ = session.services.otel_manager.counter( "codex.task.compact", 1, &[("type", "remote")], ); - let _ = crate::compact_remote::run_remote_compact_task(session, ctx).await; + if let Err(err) = + crate::compact_remote::run_remote_compact_task(session.clone(), ctx.clone()).await + { + let event = EventMsg::Error( + err.to_error_event(Some("Error running remote compact task".to_string())), + ); + session.send_event(&ctx, event).await; + } else if has_user_turn_boundary { + // Manual `/compact` rewrites history to compacted transcript items and drops + // per-turn context entries. Force initial-context reseeding on the next user turn. + // TODO(ccunningham): Eliminate this when we have better TurnContextItem diffing (compaction aware) + session.mark_initial_context_unseeded_for_next_turn().await; + } } else { let _ = session.services.otel_manager.counter( "codex.task.compact", 1, &[("type", "local")], ); - let _ = crate::compact::run_compact_task(session, ctx, input).await; + if let Err(err) = + crate::compact::run_compact_task(session.clone(), ctx.clone(), input).await + { + let event = EventMsg::Error( + err.to_error_event(Some("Error running local compact task".to_string())), + ); + session.send_event(&ctx, event).await; + } else if has_user_turn_boundary { + // Manual `/compact` rewrites history to compacted transcript items and drops + // per-turn context entries. Force initial-context reseeding on the next user turn. + // TODO(ccunningham): Eliminate this when we have better TurnContextItem diffing (compaction aware) + session.mark_initial_context_unseeded_for_next_turn().await; + } } None diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index 1428f7775bb..4d44fc9b814 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -8,6 +8,7 @@ use crate::codex::run_turn; use crate::state::TaskKind; use async_trait::async_trait; use codex_otel::OtelManager; +use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelInfo; use codex_protocol::user_input::UserInput; use tokio::task::JoinHandle; @@ -23,12 +24,14 @@ type PrewarmedSessionTask = JoinHandle>; pub(crate) struct RegularTask { prewarmed_session_task: Mutex>, + pre_turn_context_items: Vec, } impl Default for RegularTask { fn default() -> Self { Self { prewarmed_session_task: Mutex::new(None), + pre_turn_context_items: Vec::new(), } } } @@ -55,9 +58,15 @@ impl RegularTask { Self { prewarmed_session_task: Mutex::new(Some(prewarmed_session_task)), + pre_turn_context_items: Vec::new(), } } + pub(crate) fn with_pre_turn_context_items(mut self, items: Vec) -> Self { + self.pre_turn_context_items = items; + self + } + async fn take_prewarmed_session(&self) -> Option { let prewarmed_session_task = self .prewarmed_session_task @@ -101,6 +110,7 @@ impl SessionTask for RegularTask { sess, ctx, input, + self.pre_turn_context_items.clone(), prewarmed_client_session, cancellation_token, ) diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 08850ec38f6..5f5a3b130e8 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -45,7 +45,6 @@ use core_test_support::responses::sse_failed; use core_test_support::responses::sse_response; use core_test_support::responses::start_mock_server; use pretty_assertions::assert_eq; -use serde_json::json; use wiremock::MockServer; // --- Test helpers ----------------------------------------------------------- @@ -132,11 +131,20 @@ fn assert_pre_sampling_switch_compaction_requests( !compact_body.contains(""), "pre-sampling compact request should strip trailing model-switch update item" ); + let first_body = first.to_string(); + assert!( + body_contains_text(&first_body, ""), + "first request should include canonical environment context" + ); let follow_up_body = follow_up.to_string(); assert!( follow_up_body.contains(""), "follow-up request after successful model-switch compaction should include model-switch update item" ); + assert!( + body_contains_text(&follow_up_body, ""), + "follow-up request should include canonical environment context from previous-turn context reinjection" + ); } async fn assert_compaction_uses_turn_lifecycle_id(codex: &std::sync::Arc) { @@ -501,6 +509,10 @@ async fn manual_compact_emits_api_and_local_token_usage_events() { let server = start_mock_server().await; + let sse_turn = sse(vec![ + ev_assistant_message("m0", FIRST_REPLY), + ev_completed_with_tokens("r0", 0), + ]); // Compact run where the API reports zero tokens in usage. Our local // estimator should still compute a non-zero context size for the compacted // history. @@ -508,7 +520,7 @@ async fn manual_compact_emits_api_and_local_token_usage_events() { ev_assistant_message("m1", SUMMARY_TEXT), ev_completed_with_tokens("r1", 0), ]); - mount_sse_once(&server, sse_compact).await; + mount_sse_sequence(&server, vec![sse_turn, sse_compact]).await; let model_provider = non_openai_model_provider(&server); let mut builder = test_codex().with_config(move |config| { @@ -517,39 +529,42 @@ async fn manual_compact_emits_api_and_local_token_usage_events() { }); let codex = builder.build(&server).await.unwrap().codex; + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "seed compact history".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await + .unwrap(); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + // Trigger manual compact and collect TokenCount events for the compact turn. codex.submit(Op::Compact).await.unwrap(); - // First TokenCount: from the compact API call (usage.total_tokens = 0). - let first = wait_for_event_match(&codex, |ev| match ev { - EventMsg::TokenCount(tc) => tc - .info - .as_ref() - .map(|info| info.last_token_usage.total_tokens), - _ => None, - }) - .await; - - // Second TokenCount: from the local post-compaction estimate. - let last = wait_for_event_match(&codex, |ev| match ev { - EventMsg::TokenCount(tc) => tc - .info - .as_ref() - .map(|info| info.last_token_usage.total_tokens), - _ => None, - }) - .await; - - // Ensure the compact task itself completes. - wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + let mut compact_turn_token_totals = Vec::new(); + loop { + let event = wait_for_event(&codex, |_| true).await; + match event { + EventMsg::TokenCount(tc) => { + if let Some(info) = tc.info { + compact_turn_token_totals.push(info.last_token_usage.total_tokens); + } + } + EventMsg::TurnComplete(_) => break, + _ => {} + } + } - assert_eq!( - first, 0, - "expected first TokenCount from compact API usage to be zero" + assert!( + compact_turn_token_totals.contains(&0), + "expected compact turn token events to include API-reported zero usage" ); assert!( - last > 0, - "second TokenCount should reflect a non-zero estimated context size after compaction" + compact_turn_token_totals.iter().any(|total| *total > 0), + "expected compact turn token events to include a non-zero local estimate" ); } @@ -664,10 +679,6 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { // mock responses from the model let reasoning_response_1 = ev_reasoning_item("m1", &["I will create a react app"], &[]); - let encrypted_content_1 = reasoning_response_1["item"]["encrypted_content"] - .as_str() - .unwrap(); - // first chunk of work let model_reasoning_response_1_sse = sse(vec![ reasoning_response_1.clone(), @@ -682,10 +693,6 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { ]); let reasoning_response_2 = ev_reasoning_item("m3", &["I will create a node app"], &[]); - let encrypted_content_2 = reasoning_response_2["item"]["encrypted_content"] - .as_str() - .unwrap(); - // second chunk of work let model_reasoning_response_2_sse = sse(vec![ reasoning_response_2.clone(), @@ -700,13 +707,9 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { ]); let reasoning_response_3 = ev_reasoning_item("m6", &["I will create a python app"], &[]); - let encrypted_content_3 = reasoning_response_3["item"]["encrypted_content"] - .as_str() - .unwrap(); - // third chunk of work let model_reasoning_response_3_sse = sse(vec![ - ev_reasoning_item("m6", &["I will create a python app"], &[]), + reasoning_response_3.clone(), ev_local_shell_call("r6-shell", "completed", vec!["echo", "make-python"]), ev_completed_with_tokens("r6", token_count_used), ]); @@ -790,9 +793,21 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { } let initial_input = normalize_inputs(input); - let environment_message = initial_input[0]["content"][0]["text"].as_str().unwrap(); + assert!( + initial_input.iter().any(|value| { + value + .get("content") + .and_then(|content| content.as_array()) + .and_then(|content| content.first()) + .and_then(|item| item.get("text")) + .and_then(|text| text.as_str()) + .is_some_and(|text| text.contains("")) + }), + "first request should include canonical environment context" + ); - // test 1: after compaction, we should have one environment message, one user message, and one user message with summary prefix + // test 1: after each compaction, the next model request should include + // only the latest user message and the newest summary. let compaction_indices = [2, 4, 6]; let expected_summaries = [ prefixed_first_summary.as_str(), @@ -804,10 +819,13 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { let input = body.get("input").and_then(|v| v.as_array()).unwrap(); let input = normalize_inputs(input); assert_eq!(input.len(), 3); - let environment_message = input[0]["content"][0]["text"].as_str().unwrap(); + let environment_context = input[0]["content"][0]["text"].as_str().unwrap(); let user_message_received = input[1]["content"][0]["text"].as_str().unwrap(); let summary_message = input[2]["content"][0]["text"].as_str().unwrap(); - assert_eq!(environment_message, environment_message); + assert!( + environment_context.contains(""), + "compaction request at index {i} should retain canonical environment context" + ); assert_eq!(user_message_received, user_message); assert_eq!( summary_message, expected_summary, @@ -815,358 +833,22 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { ); } - // test 2: the expected requests inputs should be as follows: - let expected_requests_inputs = json!([ - [ - // 0: first request of the user message. - { - "content": [ - { - "text": environment_message, - "type": "input_text" - } - ], - "role": "user", - "type": "message" - }, - { - "content": [ - { - "text": "create an app", - "type": "input_text" - } - ], - "role": "user", - "type": "message" - } - ] - , - [ - // 1: first automatic compaction request. - { - "content": [ - { - "text": environment_message, - "type": "input_text" - } - ], - "role": "user", - "type": "message" - }, - { - "content": [ - { - "text": "create an app", - "type": "input_text" - } - ], - "role": "user", - "type": "message" - }, - { - "content": null, - "encrypted_content": encrypted_content_1, - "summary": [ - { - "text": "I will create a react app", - "type": "summary_text" - } - ], - "type": "reasoning" - }, - { - "action": { - "command": [ - "echo", - "make-react" - ], - "env": null, - "timeout_ms": null, - "type": "exec", - "user": null, - "working_directory": null - }, - "call_id": "r1-shell", - "status": "completed", - "type": "local_shell_call" - }, - { - "call_id": "r1-shell", - "output": "execution error: Io(Os { code: 2, kind: NotFound, message: \"No such file or directory\" })", - "type": "function_call_output" - }, - { - "content": [ - { - "text": SUMMARIZATION_PROMPT, - "type": "input_text" - } - ], - "role": "user", - "type": "message" - } - ] - , - [ - // 2: request after first automatic compaction. - { - "content": [ - { - "text": environment_message, - "type": "input_text" - } - ], - "role": "user", - "type": "message" - }, - { - "content": [ - { - "text": "create an app", - "type": "input_text" - } - ], - "role": "user", - "type": "message" - }, - { - "content": [ - { - "text": prefixed_first_summary.clone(), - "type": "input_text" - } - ], - "role": "user", - "type": "message" - } - ] - , - [ - // 3: request for second automatic compaction. - { - "content": [ - { - "text": environment_message, - "type": "input_text" - } - ], - "role": "user", - "type": "message" - }, - { - "content": [ - { - "text": "create an app", - "type": "input_text" - } - ], - "role": "user", - "type": "message" - }, - { - "content": [ - { - "text": prefixed_first_summary.clone(), - "type": "input_text" - } - ], - "role": "user", - "type": "message" - }, - { - "content": null, - "encrypted_content": encrypted_content_2, - "summary": [ - { - "text": "I will create a node app", - "type": "summary_text" - } - ], - "type": "reasoning" - }, - { - "action": { - "command": [ - "echo", - "make-node" - ], - "env": null, - "timeout_ms": null, - "type": "exec", - "user": null, - "working_directory": null - }, - "call_id": "r3-shell", - "status": "completed", - "type": "local_shell_call" - }, - { - "call_id": "r3-shell", - "output": "execution error: Io(Os { code: 2, kind: NotFound, message: \"No such file or directory\" })", - "type": "function_call_output" - }, - { - "content": [ - { - "text": SUMMARIZATION_PROMPT, - "type": "input_text" - } - ], - "role": "user", - "type": "message" - } - ] - , - // 4: request after second automatic compaction. - [ - { - "content": [ - { - "text": environment_message, - "type": "input_text" - } - ], - "role": "user", - "type": "message" - }, - { - "content": [ - { - "text": "create an app", - "type": "input_text" - } - ], - "role": "user", - "type": "message" - }, - { - "content": [ - { - "text": prefixed_second_summary.clone(), - "type": "input_text" - } - ], - "role": "user", - "type": "message" - } - ] - , - [ - // 5: request for third automatic compaction. - { - "content": [ - { - "text": environment_message, - "type": "input_text" - } - ], - "role": "user", - "type": "message" - }, - { - "content": [ - { - "text": "create an app", - "type": "input_text" - } - ], - "role": "user", - "type": "message" - }, - { - "content": [ - { - "text": prefixed_second_summary.clone(), - "type": "input_text" - } - ], - "role": "user", - "type": "message" - }, - { - "content": null, - "encrypted_content": encrypted_content_3, - "summary": [ - { - "text": "I will create a python app", - "type": "summary_text" - } - ], - "type": "reasoning" - }, - { - "action": { - "command": [ - "echo", - "make-python" - ], - "env": null, - "timeout_ms": null, - "type": "exec", - "user": null, - "working_directory": null - }, - "call_id": "r6-shell", - "status": "completed", - "type": "local_shell_call" - }, - { - "call_id": "r6-shell", - "output": "execution error: Io(Os { code: 2, kind: NotFound, message: \"No such file or directory\" })", - "type": "function_call_output" - }, - { - "content": [ - { - "text": SUMMARIZATION_PROMPT, - "type": "input_text" - } - ], - "role": "user", - "type": "message" - } - ] - , - [ - { - // 6: request after third automatic compaction. - "content": [ - { - "text": environment_message, - "type": "input_text" - } - ], - "role": "user", - "type": "message" - }, - { - "content": [ - { - "text": "create an app", - "type": "input_text" - } - ], - "role": "user", - "type": "message" - }, - { - "content": [ - { - "text": prefixed_third_summary.clone(), - "type": "input_text" - } - ], - "role": "user", - "type": "message" - } - ] - ]); - - for (i, request) in requests_payloads.iter().enumerate() { - let body = request.body_json(); + // test 2: each auto-compaction request should include the summarization prompt. + for i in [1, 3, 5] { + let body = requests_payloads[i].body_json(); let input = body.get("input").and_then(|v| v.as_array()).unwrap(); - let expected_input = expected_requests_inputs[i].as_array().unwrap(); - assert_eq!(normalize_inputs(input), normalize_inputs(expected_input)); + assert!( + input.iter().any(|value| { + value + .get("content") + .and_then(|content| content.as_array()) + .and_then(|content| content.first()) + .and_then(|item| item.get("text")) + .and_then(|text| text.as_str()) + .is_some_and(|text| text == SUMMARIZATION_PROMPT) + }), + "compaction request {i} should include summarization prompt" + ); } // test 3: the number of requests should be 7 @@ -1784,6 +1466,132 @@ 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_context_window_failure_surfaces_compact_task_error() { + skip_if_no_network!(); + + let server = MockServer::start().await; + let previous_model = "gpt-5.2-codex"; + let next_model = "gpt-5.1-codex-max"; + + let models_mock = mount_models_once( + &server, + ModelsResponse { + models: vec![ + model_info_with_context_window(previous_model, 273_000), + model_info_with_context_window(next_model, 125_000), + ], + }, + ) + .await; + + let request_log = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_assistant_message("m1", "before switch"), + ev_completed_with_tokens("r1", 120_000), + ]), + sse_failed( + "compact-failed", + "context_length_exceeded", + CONTEXT_LIMIT_MESSAGE, + ), + ], + ) + .await; + + let mut model_provider = non_openai_model_provider(&server); + model_provider.stream_max_retries = Some(0); + 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); + config + .features + .enable(codex_core::features::Feature::RemoteModels); + }); + let test = builder.build(&server).await.expect("build test codex"); + + test.codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "before switch".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: test.cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: previous_model.to_string(), + effort: None, + summary: ReasoningSummary::Auto, + collaboration_mode: None, + personality: None, + }) + .await + .expect("submit first user turn"); + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + test.codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "after switch".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: test.cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: next_model.to_string(), + effort: None, + summary: ReasoningSummary::Auto, + collaboration_mode: None, + personality: None, + }) + .await + .expect("submit second user turn"); + + let error_message = wait_for_event_match(&test.codex, |event| match event { + EventMsg::Error(err) => Some(err.message.clone()), + _ => None, + }) + .await; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + assert_eq!(models_mock.requests().len(), 1); + assert!( + error_message.contains("Error running local compact task"), + "expected local compact-task failure prefix, got {error_message}" + ); + assert!( + !error_message.contains( + "Incoming user message and/or turn context is too large to fit in context window" + ), + "model-switch pre-sampling compaction failure should not be misclassified as incoming-input oversize: {error_message}" + ); + + let requests = request_log.requests(); + assert_eq!( + requests.len(), + 2, + "expected first user turn and one pre-sampling compaction request" + ); + let compact_request_body = requests[1].body_json().to_string(); + assert!( + body_contains_text(&compact_request_body, SUMMARIZATION_PROMPT), + "second request should be the pre-sampling compaction request" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { skip_if_no_network!(); @@ -2172,9 +1980,6 @@ async fn manual_compact_retries_after_context_window_error() { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -// TODO(ccunningham): Re-enable after the follow-up compaction behavior PR lands. -// Current main behavior around non-context manual /compact failures is known-incorrect. -#[ignore = "behavior change covered in follow-up compaction PR"] async fn manual_compact_non_context_failure_retries_then_emits_task_error() { skip_if_no_network!(); @@ -2405,35 +2210,33 @@ async fn manual_compact_twice_preserves_latest_user_messages() { "compact requests should consistently include or omit the summarization prompt" ); - let first_request_user_texts = requests[0].message_input_texts("user"); - let first_turn_user_index = first_request_user_texts - .len() - .checked_sub(1) - .unwrap_or_else(|| panic!("first turn request missing user messages")); - assert_eq!( - first_request_user_texts[first_turn_user_index], first_user_message, - "first turn request should end with the submitted user message" - ); - let seeded_user_prefix = &first_request_user_texts[..first_turn_user_index]; - let final_request_user_texts = requests .last() .unwrap_or_else(|| panic!("final turn request missing for {final_user_message}")) .message_input_texts("user"); - assert!( - final_request_user_texts - .as_slice() - .starts_with(seeded_user_prefix), - "final request should start with seeded user prefix from first request: {seeded_user_prefix:?}" - ); - let final_output = &final_request_user_texts[seeded_user_prefix.len()..]; + let Some(first_user_index) = final_request_user_texts + .iter() + .position(|text| text == first_user_message) + else { + panic!("final request missing first user message: {final_request_user_texts:?}"); + }; + let final_output = &final_request_user_texts[first_user_index..]; let expected = vec![ first_user_message.to_string(), second_user_message.to_string(), expected_second_summary, final_user_message.to_string(), ]; - assert_eq!(final_output, expected.as_slice()); + let mut final_output_iter = final_output.iter(); + for expected_text in &expected { + final_output_iter + .position(|text| text == expected_text) + .unwrap_or_else(|| { + panic!( + "final request should preserve expected user-message order; missing `{expected_text}` in {final_output:?}" + ) + }); + } } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -2704,8 +2507,8 @@ async fn auto_compact_clamps_config_limit_to_context_window() { let auto_compact_body = auto_compact_mock.single_request().body_json().to_string(); assert!( - body_contains_text(&auto_compact_body, SUMMARIZATION_PROMPT), - "auto compact should run with the summarization prompt when config limit exceeds context" + body_contains_text(&auto_compact_body, "OVER_LIMIT_TURN"), + "auto compact should run when the configured limit clamps to the model context window" ); } @@ -2824,11 +2627,6 @@ async fn auto_compact_counts_encrypted_reasoning_before_last_user() { "second turn should not include compacted history" ); let third_request_body = requests[2].body_json().to_string(); - assert!( - third_request_body.contains("REMOTE_COMPACT_SUMMARY") - || third_request_body.contains(FINAL_REPLY), - "third turn should include compacted history" - ); assert!( third_request_body.contains("ENCRYPTED_COMPACTION_SUMMARY"), "third turn should include compaction summary item" @@ -2919,7 +2717,6 @@ async fn auto_compact_runs_when_reasoning_header_clears_between_turns() { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -// TODO(ccunningham): Update once pre-turn compaction includes incoming user input. async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_message() { skip_if_no_network!(); @@ -3007,7 +2804,7 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess insta::assert_snapshot!( "pre_turn_compaction_including_incoming_shapes", format_labeled_requests_snapshot( - "Pre-turn auto-compaction with a context override emits the context diff in the compact request while the incoming user message is still excluded.", + "Pre-turn auto-compaction with a context override includes incoming user content in the compact request and preserves it after compaction.", &[ ("Local Compaction Request", &requests[2]), ("Local Post-Compaction History Layout", &requests[3]), @@ -3016,10 +2813,17 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess ); let compact_request_user_texts = requests[2].message_input_texts("user"); assert!( - !compact_request_user_texts + compact_request_user_texts .iter() .any(|text| text == "USER_THREE"), - "current behavior excludes incoming user message from pre-turn compaction input" + "pre-turn compaction request should include the incoming user message" + ); + let compact_request_user_images = requests[2].message_input_image_urls("user"); + assert!( + compact_request_user_images + .iter() + .any(|url| url == image_url.as_str()), + "pre-turn compaction request should include incoming user image content" ); let follow_up_user_texts = requests[3].message_input_texts("user"); assert!( @@ -3036,8 +2840,6 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -// TODO(ccunningham): Update once pre-turn compaction context-overflow handling includes incoming -// user input and emits richer oversized-input messaging. async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch() { skip_if_no_network!(); @@ -3235,9 +3037,9 @@ async fn snapshot_request_shape_pre_turn_compaction_context_window_exceeded() { insta::assert_snapshot!( "pre_turn_compaction_context_window_exceeded_shapes", format_labeled_requests_snapshot( - "Pre-turn auto-compaction context-window failure: compaction request excludes the incoming user message and the turn errors.", + "Pre-turn auto-compaction context-window failure: compaction request includes the incoming user message and the turn errors.", &[( - "Local Compaction Request (Incoming User Excluded)", + "Local Compaction Request (Incoming User Included)", &requests[1] ),] ) @@ -3250,20 +3052,230 @@ async fn snapshot_request_shape_pre_turn_compaction_context_window_exceeded() { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn snapshot_request_shape_manual_compact_without_previous_user_messages() { +async fn pre_turn_local_compaction_trim_retries_keep_incoming_items() { skip_if_no_network!(); let server = start_mock_server().await; + let compact_failed = sse_failed( + "compact-failed", + "context_length_exceeded", + CONTEXT_LIMIT_MESSAGE, + ); + let request_log = mount_sse_sequence( + &server, + vec![ + compact_failed.clone(), + compact_failed.clone(), + compact_failed.clone(), + compact_failed, + ], + ) + .await; - let compact_turn = sse(vec![ - ev_assistant_message("m1", "MANUAL_EMPTY_SUMMARY"), - ev_completed_with_tokens("r1", 90), + let model_provider = non_openai_model_provider(&server); + let codex = test_codex() + .with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + config.model_auto_compact_token_limit = Some(1); + }) + .build(&server) + .await + .expect("build codex") + .codex; + + let incoming_text = "PRETURN_INCOMING_USER"; + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: incoming_text.to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await + .expect("submit user input"); + + let error_message = wait_for_event_match(&codex, |event| match event { + EventMsg::Error(err) => Some(err.message.clone()), + _ => None, + }) + .await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + assert!( + error_message.contains( + "Incoming user message and/or turn context is too large to fit in context window" + ), + "expected oversized incoming-items error, got {error_message}" + ); + + let compact_attempts = request_log.requests(); + assert_eq!( + compact_attempts.len(), + 4, + "expected pre-turn compaction to retry while trimming seeded history only" + ); + for (index, request) in compact_attempts.iter().enumerate() { + let body = request.body_json().to_string(); + assert!( + body_contains_text(&body, SUMMARIZATION_PROMPT), + "request {index} should be a compaction attempt" + ); + assert!( + request + .message_input_texts("user") + .iter() + .any(|text| text == incoming_text), + "request {index} dropped incoming user text during trim retries" + ); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pre_turn_compaction_failure_persists_context_updates_for_next_turn() { + skip_if_no_network!(); + + let server = start_mock_server().await; + let compact_failed = sse_failed( + "compact-failed", + "context_length_exceeded", + CONTEXT_LIMIT_MESSAGE, + ); + let third_turn = sse(vec![ + ev_assistant_message("m3", FINAL_REPLY), + ev_completed_with_tokens("r3", 80), ]); + let request_log = mount_sse_sequence( + &server, + vec![ + compact_failed.clone(), + compact_failed.clone(), + compact_failed.clone(), + compact_failed, + third_turn, + ], + ) + .await; + + let mut model_provider = non_openai_model_provider(&server); + model_provider.stream_max_retries = Some(0); + let codex = test_codex() + .with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + config.model_auto_compact_token_limit = Some(300); + }) + .build(&server) + .await + .expect("build codex") + .codex; + + // Seed `previous_context` without adding a user turn to history. + codex + .submit(Op::UserTurn { + items: Vec::new(), + final_output_json_schema: None, + cwd: PathBuf::from("."), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + model: "gpt-5.2-codex".to_string(), + effort: None, + summary: ReasoningSummary::Auto, + collaboration_mode: None, + personality: None, + }) + .await + .expect("submit empty settings-only turn"); + + let oversized_input = "X".repeat(2_000); + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: oversized_input, + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: PathBuf::from("."), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: "gpt-5.2-codex".to_string(), + effort: None, + summary: ReasoningSummary::Auto, + collaboration_mode: None, + personality: None, + }) + .await + .expect("submit oversized turn that triggers pre-turn compaction failure"); + let error_message = wait_for_event_match(&codex, |event| match event { + EventMsg::Error(err) => Some(err.message.clone()), + _ => None, + }) + .await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + assert!( + error_message.contains( + "Incoming user message and/or turn context is too large to fit in context window" + ), + "expected oversized incoming-items error, got {error_message}" + ); + + let follow_up_text = "after failed pre-turn compact"; + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: follow_up_text.to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: PathBuf::from("."), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: "gpt-5.2-codex".to_string(), + effort: None, + summary: ReasoningSummary::Auto, + collaboration_mode: None, + personality: None, + }) + .await + .expect("submit follow-up turn"); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let requests = request_log.requests(); + assert_eq!( + requests.len(), + 5, + "expected four failed pre-turn compaction attempts and one follow-up model request" + ); + + let follow_up_request = requests.last().expect("missing follow-up request"); + let follow_up_developer_texts = follow_up_request.message_input_texts("developer"); + assert!( + follow_up_developer_texts + .iter() + .any(|text| text.contains("sandbox_mode` is `danger-full-access`")), + "expected danger-full-access permissions update in follow-up turn after failed pre-turn compaction: {follow_up_developer_texts:?}" + ); + assert!( + follow_up_request + .message_input_texts("user") + .iter() + .any(|text| text == follow_up_text), + "expected follow-up request to include follow-up user message" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn snapshot_request_shape_manual_compact_without_previous_user_messages() { + skip_if_no_network!(); + + let server = start_mock_server().await; + let follow_up_turn = sse(vec![ - ev_assistant_message("m2", FINAL_REPLY), - ev_completed_with_tokens("r2", 80), + ev_assistant_message("m1", FINAL_REPLY), + ev_completed_with_tokens("r1", 80), ]); - let request_log = mount_sse_sequence(&server, vec![compact_turn, follow_up_turn]).await; + let request_log = mount_sse_once(&server, follow_up_turn).await; let model_provider = non_openai_model_provider(&server); let codex = test_codex() @@ -3294,18 +3306,15 @@ async fn snapshot_request_shape_manual_compact_without_previous_user_messages() let requests = request_log.requests(); assert_eq!( requests.len(), - 2, - "expected manual /compact request and follow-up turn request" + 1, + "manual /compact with no prior user should be a no-op; only the follow-up turn should hit /responses" ); insta::assert_snapshot!( "manual_compact_without_prev_user_shapes", format_labeled_requests_snapshot( - "Manual /compact with no prior user turn currently still issues a compaction request; follow-up turn carries canonical context and the new user message.", - &[ - ("Local Compaction Request", &requests[0]), - ("Local Post-Compaction History Layout", &requests[1]), - ] + "Manual /compact with no prior user turn is a no-op; follow-up turn carries canonical context and the new user message.", + &[("Local Post-Compaction History Layout", &requests[0]),] ) ); } diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index c9acde41ab0..98d5a05488a 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -579,9 +579,9 @@ async fn auto_remote_compact_failure_stops_agent_loop() -> Result<()> { insta::assert_snapshot!( "remote_pre_turn_compaction_failure_shapes", format_labeled_requests_snapshot( - "Remote pre-turn auto-compaction parse failure: compaction request excludes the incoming user message and the turn stops.", + "Remote pre-turn auto-compaction parse failure: compaction request includes incoming user content and the turn stops.", &[( - "Remote Compaction Request (Incoming User Excluded)", + "Remote Compaction Request (Incoming User Included)", &first_compact_mock.single_request() ),] ) @@ -930,9 +930,6 @@ async fn remote_manual_compact_failure_emits_task_error_event() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -// TODO(ccunningham): Re-enable after the follow-up compaction behavior PR lands. -// Current main behavior for rollout replacement-history persistence is known-incorrect. -#[ignore = "behavior change covered in follow-up compaction PR"] async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()> { skip_if_no_network!(Ok(())); @@ -1054,11 +1051,15 @@ async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()> ) }); - if has_compacted_user_summary && has_compaction_item && has_compacted_assistant_note { + if has_compacted_user_summary && has_compaction_item { assert!( !has_permissions_developer_message, "manual remote compact rollout replacement history should not inject permissions context" ); + assert!( + !has_compacted_assistant_note, + "manual remote compact rollout replacement history should drop assistant notes" + ); saw_compacted_history = true; break; } @@ -1310,7 +1311,6 @@ async fn remote_compact_refreshes_stale_developer_instructions_without_resume() } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -// TODO(ccunningham): Update once remote pre-turn compaction includes incoming user input. async fn snapshot_request_shape_remote_pre_turn_compaction_including_incoming_user_message() -> Result<()> { skip_if_no_network!(Ok(())); @@ -1390,13 +1390,20 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_including_incoming_us insta::assert_snapshot!( "remote_pre_turn_compaction_including_incoming_shapes", format_labeled_requests_snapshot( - "Remote pre-turn auto-compaction with a context override emits the context diff in the compact request while excluding the incoming user message.", + "Remote pre-turn auto-compaction with a context override includes incoming user content in the compact request and preserves it after compaction.", &[ ("Remote Compaction Request", &compact_request), ("Remote Post-Compaction History Layout", &requests[2]), ] ) ); + assert!( + compact_request + .message_input_texts("user") + .iter() + .any(|text| text == "USER_THREE"), + "remote pre-turn compaction request should include incoming user message" + ); assert_eq!( requests[2] .message_input_texts("user") @@ -1506,8 +1513,8 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model let post_compact_turn_request = post_compact_turn_request_mock.single_request(); let compact_body = compact_request.body_json().to_string(); assert!( - !compact_body.contains("AFTER_SWITCH_USER"), - "current behavior excludes incoming user from the pre-turn remote compaction request" + compact_body.contains("AFTER_SWITCH_USER"), + "pre-turn remote compaction request should include incoming user message" ); assert!( !compact_body.contains(""), @@ -1521,7 +1528,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model ); assert!( follow_up_body.contains("AFTER_SWITCH_USER"), - "post-compaction follow-up should preserve incoming user message via runtime append" + "post-compaction follow-up should preserve incoming user message" ); assert!( follow_up_body.contains(""), @@ -1531,7 +1538,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model insta::assert_snapshot!( "remote_pre_turn_compaction_strips_incoming_model_switch_shapes", format_labeled_requests_snapshot( - "Remote pre-turn compaction during model switch currently excludes incoming user input, strips incoming from the compact request payload, and restores it in the post-compaction follow-up request.", + "Remote pre-turn compaction during model switch strips incoming from the compact request and restores it in the post-compaction follow-up request.", &[ ("Initial Request (Previous Model)", &initial_turn_request), ("Remote Compaction Request", &compact_request), @@ -1547,8 +1554,6 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -// TODO(ccunningham): Update once remote pre-turn compaction context-overflow handling includes -// incoming user input and emits richer oversized-input messaging. async fn snapshot_request_shape_remote_pre_turn_compaction_context_window_exceeded() -> Result<()> { skip_if_no_network!(Ok(())); @@ -1718,8 +1723,7 @@ async fn snapshot_request_shape_remote_mid_turn_continuation_compaction() -> Res } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn snapshot_request_shape_remote_mid_turn_compaction_summary_only_reinjects_context() --> Result<()> { +async fn snapshot_request_shape_remote_mid_turn_compaction_summary_only_layout() -> Result<()> { skip_if_no_network!(Ok(())); let harness = TestCodexHarness::with_builder( @@ -1784,9 +1788,9 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_summary_only_reinject let compact_request = compact_mock.single_request(); let post_compact_turn_request = post_compact_turn_request_mock.single_request(); insta::assert_snapshot!( - "remote_mid_turn_compaction_summary_only_reinjects_context_shapes", + "remote_mid_turn_compaction_summary_only_shapes", format_labeled_requests_snapshot( - "Remote mid-turn compaction where compact output has only summary user content: continuation layout reinjects canonical context before that summary.", + "Remote mid-turn compaction where compact output has only summary user content: continuation layout reinjects canonical context before the latest summary.", &[ ("Remote Compaction Request", &compact_request), ( @@ -1801,8 +1805,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_summary_only_reinject } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_reinjects_above_last_summary() --> Result<()> { +async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_layout() -> Result<()> { skip_if_no_network!(Ok(())); let harness = TestCodexHarness::with_builder( @@ -1891,7 +1894,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_reinjec "older summary should round-trip from conversation history into the next compact request" ); insta::assert_snapshot!( - "remote_mid_turn_compaction_multi_summary_reinjects_above_last_summary_shapes", + "remote_mid_turn_compaction_multi_summary_shapes", format_labeled_requests_snapshot( "Remote mid-turn compaction after an earlier summary compaction: the older summary remains in model-visible history and round-trips into the next compact request.", &[ @@ -1908,7 +1911,6 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_reinjec } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -// TODO(ccunningham): Update once manual remote /compact with no prior user turn becomes a no-op. async fn snapshot_request_shape_remote_manual_compact_without_previous_user_messages() -> Result<()> { skip_if_no_network!(Ok(())); @@ -1928,10 +1930,6 @@ async fn snapshot_request_shape_remote_manual_compact_without_previous_user_mess ) .await; - let compact_mock = - responses::mount_compact_json_once(harness.server(), serde_json::json!({ "output": [] })) - .await; - codex.submit(Op::Compact).await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1946,21 +1944,12 @@ async fn snapshot_request_shape_remote_manual_compact_without_previous_user_mess .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - assert_eq!( - compact_mock.requests().len(), - 1, - "current behavior still issues remote compaction for manual /compact without prior user" - ); - let compact_request = compact_mock.single_request(); let follow_up_request = responses_mock.single_request(); insta::assert_snapshot!( "remote_manual_compact_without_prev_user_shapes", format_labeled_requests_snapshot( - "Remote manual /compact with no prior user turn still issues a compact request; follow-up turn carries canonical context and new user message.", - &[ - ("Remote Compaction Request", &compact_request), - ("Remote Post-Compaction History Layout", &follow_up_request), - ] + "Remote manual /compact with no prior user turn is a no-op; follow-up turn carries canonical context and the new user message.", + &[("Remote Post-Compaction History Layout", &follow_up_request),] ) ); diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index fc77a2621fc..9c9618a9783 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -200,24 +200,11 @@ async fn compact_resume_and_fork_preserve_model_history_view() { &fork_arr[..compact_arr.len()] ); - let expected_model = requests[0]["model"] - .as_str() - .unwrap_or_default() - .to_string(); - let prompt = requests[0]["instructions"] - .as_str() - .unwrap_or_default() - .to_string(); - let permissions_message = requests[0]["input"][0].clone(); - let user_instructions = requests[0]["input"][1]["content"][0]["text"] - .as_str() - .unwrap_or_default() - .to_string(); - let environment_context = requests[0]["input"][2]["content"][0]["text"] - .as_str() - .unwrap_or_default() - .to_string(); - let tool_calls = json!(requests[0]["tools"].as_array()); + assert_eq!(requests.len(), 5); + let expected_model = requests[0]["model"].as_str(); + for request in &requests { + assert_eq!(request["model"].as_str(), expected_model); + } let prompt_cache_key = requests[0]["prompt_cache_key"] .as_str() .unwrap_or_default() @@ -226,433 +213,46 @@ async fn compact_resume_and_fork_preserve_model_history_view() { .as_str() .unwrap_or_default() .to_string(); + assert_ne!( + prompt_cache_key, fork_prompt_cache_key, + "forked request should use a new prompt cache key" + ); let summary_after_compact = extract_summary_message(&requests[2], SUMMARY_TEXT); let summary_after_resume = extract_summary_message(&requests[3], SUMMARY_TEXT); let summary_after_fork = extract_summary_message(&requests[4], SUMMARY_TEXT); - let user_turn_1 = json!( - { - "model": expected_model, - "instructions": prompt, - "input": [ - permissions_message, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": user_instructions - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": environment_context - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "hello world" - } - ] - } - ], - "tools": tool_calls, - "tool_choice": "auto", - "parallel_tool_calls": false, - "reasoning": { - "effort": "medium", - "summary": "auto" - }, - "store": false, - "stream": true, - "include": [ - "reasoning.encrypted_content" - ], - "prompt_cache_key": prompt_cache_key - }); - let compact_1 = json!( - { - "model": expected_model, - "instructions": prompt, - "input": [ - permissions_message, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": user_instructions - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": environment_context - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "hello world" - } - ] - }, - { - "type": "message", - "role": "assistant", - "content": [ - { - "type": "output_text", - "text": "FIRST_REPLY" - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": SUMMARIZATION_PROMPT - } - ] - } - ], - "tools": [], - "tool_choice": "auto", - "parallel_tool_calls": false, - "reasoning": { - "effort": "medium", - "summary": "auto" - }, - "store": false, - "stream": true, - "include": [ - "reasoning.encrypted_content" - ], - "prompt_cache_key": prompt_cache_key - }); - let user_turn_2_after_compact = json!( - { - "model": expected_model, - "instructions": prompt, - "input": [ - permissions_message, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": user_instructions - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": environment_context - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "hello world" - } - ] - }, - summary_after_compact, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "AFTER_COMPACT" - } - ] - } - ], - "tools": tool_calls, - "tool_choice": "auto", - "parallel_tool_calls": false, - "reasoning": { - "effort": "medium", - "summary": "auto" - }, - "store": false, - "stream": true, - "include": [ - "reasoning.encrypted_content" - ], - "prompt_cache_key": prompt_cache_key - }); - let usert_turn_3_after_resume = json!( - { - "model": expected_model, - "instructions": prompt, - "input": [ - permissions_message, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": user_instructions - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": environment_context - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "hello world" - } - ] - }, - summary_after_resume, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "AFTER_COMPACT" - } - ] - }, - { - "type": "message", - "role": "assistant", - "content": [ - { - "type": "output_text", - "text": "AFTER_COMPACT_REPLY" - } - ] - }, - permissions_message, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": user_instructions - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": environment_context - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "AFTER_RESUME" - } - ] - } - ], - "tools": tool_calls, - "tool_choice": "auto", - "parallel_tool_calls": false, - "reasoning": { - "effort": "medium", - "summary": "auto" - }, - "store": false, - "stream": true, - "include": [ - "reasoning.encrypted_content" - ], - "prompt_cache_key": prompt_cache_key - }); - let user_turn_3_after_fork = json!( - { - "model": expected_model, - "instructions": prompt, - "input": [ - permissions_message, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": user_instructions - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": environment_context - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "hello world" - } - ] - }, - summary_after_fork, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "AFTER_COMPACT" - } - ] - }, - { - "type": "message", - "role": "assistant", - "content": [ - { - "type": "output_text", - "text": "AFTER_COMPACT_REPLY" - } - ] - }, - permissions_message, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": user_instructions - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": environment_context - } - ] - }, - permissions_message, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": user_instructions - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": environment_context - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "AFTER_FORK" - } - ] - } - ], - "tools": tool_calls, - "tool_choice": "auto", - "parallel_tool_calls": false, - "reasoning": { - "effort": "medium", - "summary": "auto" - }, - "store": false, - "stream": true, - "include": [ - "reasoning.encrypted_content" - ], - "prompt_cache_key": fork_prompt_cache_key - }); - let mut expected = json!([ - user_turn_1, - compact_1, - user_turn_2_after_compact, - usert_turn_3_after_resume, - user_turn_3_after_fork - ]); - normalize_line_endings(&mut expected); - if let Some(arr) = expected.as_array_mut() { - normalize_compact_prompts(arr); + for summary in [ + &summary_after_compact, + &summary_after_resume, + &summary_after_fork, + ] { + assert_eq!(summary["role"].as_str(), Some("user")); + assert!( + summary["content"][0]["text"] + .as_str() + .unwrap_or_default() + .contains(SUMMARY_TEXT), + "summary message should include compacted summary text" + ); } - assert_eq!(requests.len(), 5); - assert_eq!(json!(requests), expected); + let request_2_body = requests[2].to_string(); + assert!( + request_2_body.contains("\"text\":\"AFTER_COMPACT\""), + "post-compact request should include AFTER_COMPACT" + ); + let request_3_body = requests[3].to_string(); + assert!( + request_3_body.contains("\"text\":\"AFTER_RESUME\""), + "post-resume request should include AFTER_RESUME" + ); + let request_4_body = requests[4].to_string(); + assert!( + request_4_body.contains("\"text\":\"AFTER_FORK\""), + "post-fork request should include AFTER_FORK" + ); + assert!( + !request_4_body.contains("\"text\":\"AFTER_RESUME\""), + "forked request should not include resumed-branch user input" + ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -725,118 +325,32 @@ async fn compact_resume_after_second_compaction_preserves_history() { compact_filtered.as_slice(), &resume_filtered[..compact_filtered.len()] ); - // hard coded test - let prompt = requests[0]["instructions"] - .as_str() - .unwrap_or_default() - .to_string(); - let permissions_message = requests[0]["input"][0].clone(); - let user_instructions = requests[0]["input"][1]["content"][0]["text"] - .as_str() - .unwrap_or_default() - .to_string(); - let environment_instructions = requests[0]["input"][2]["content"][0]["text"] - .as_str() - .unwrap_or_default() - .to_string(); - - // Build expected final request input: initial context + forked user message + - // compacted summary + post-compact user message + resumed user message. + // Final resumed request should include the fork branch history, the second compaction + // summary, and the resumed-again user message. let summary_after_second_compact = extract_summary_message(&requests[requests.len() - 3], SUMMARY_TEXT); - - let mut expected = json!([ - { - "instructions": prompt, - "input": [ - permissions_message, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": user_instructions - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": environment_instructions - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "AFTER_FORK" - } - ] - }, - summary_after_second_compact, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "AFTER_COMPACT_2" - } - ] - }, - permissions_message, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": user_instructions - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": environment_instructions - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "AFTER_SECOND_RESUME" - } - ] - } - ], - } - ]); - normalize_line_endings(&mut expected); - let mut last_request_after_2_compacts = json!([{ - "instructions": requests[requests.len() -1]["instructions"], - "input": requests[requests.len() -1]["input"], - }]); - if let Some(arr) = expected.as_array_mut() { - normalize_compact_prompts(arr); - } - if let Some(arr) = last_request_after_2_compacts.as_array_mut() { - normalize_compact_prompts(arr); - } - assert_eq!(expected, last_request_after_2_compacts); + assert_eq!(summary_after_second_compact["role"].as_str(), Some("user")); + assert!( + summary_after_second_compact["content"][0]["text"] + .as_str() + .unwrap_or_default() + .contains(SUMMARY_TEXT), + "second compaction summary should include compacted summary text" + ); + let last_request_after_two_compacts = &requests[requests.len() - 1]; + let last_request_body = last_request_after_two_compacts.to_string(); + assert!( + last_request_body.contains("\"text\":\"AFTER_FORK\""), + "last request should retain fork-branch user message" + ); + assert!( + last_request_body.contains("\"text\":\"AFTER_COMPACT_2\""), + "last request should include post-second-compaction user message" + ); + assert!( + last_request_body.contains(&format!("\"text\":\"{AFTER_SECOND_RESUME}\"")), + "last request should include resumed-again user message" + ); } fn normalize_line_endings(value: &mut Value) { diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 4d4abafeb6e..d02374f54cc 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -115,6 +115,94 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn settings_only_empty_turn_persists_updates_for_next_non_empty_turn() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let resp_mock = mount_sse_sequence( + &server, + vec![sse_completed("resp-1"), sse_completed("resp-2")], + ) + .await; + + let mut builder = test_codex().with_model("gpt-5.2-codex"); + let test = builder.build(&server).await?; + let model = test.session_configured.model.clone(); + + test.codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "first".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: test.cwd_path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + model: model.clone(), + effort: test.config.model_reasoning_effort, + summary: ReasoningSummary::Auto, + collaboration_mode: None, + personality: None, + }) + .await?; + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + // Settings-only turn with no user message. + test.codex + .submit(Op::UserTurn { + items: Vec::new(), + final_output_json_schema: None, + cwd: test.cwd_path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: model.clone(), + effort: test.config.model_reasoning_effort, + summary: ReasoningSummary::Auto, + collaboration_mode: None, + personality: None, + }) + .await?; + + test.codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "after settings-only turn".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: test.cwd_path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model, + effort: test.config.model_reasoning_effort, + summary: ReasoningSummary::Auto, + collaboration_mode: None, + personality: None, + }) + .await?; + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let requests = resp_mock.requests(); + assert_eq!( + requests.len(), + 2, + "expected only first and third turns to hit the model" + ); + + let third_turn_request = requests.last().expect("expected third turn request"); + let developer_texts = third_turn_request.message_input_texts("developer"); + assert!( + developer_texts + .iter() + .any(|text| text.contains("sandbox_mode` is `danger-full-access`")), + "expected danger-full-access permissions update in next non-empty turn: {developer_texts:?}" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn model_and_personality_change_only_appends_model_instructions() -> Result<()> { skip_if_no_network!(Ok(())); 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 bcca5d35755..c88ff33122d 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 @@ -13,9 +13,9 @@ Scenario: Manual /compact with prior user history compacts existing history and 05:message/user: ## Local Post-Compaction History Layout -00:message/developer: -01:message/user: -02:message/user:> -03:message/user:first manual turn -04:message/user:\nFIRST_MANUAL_SUMMARY +00:message/user:first manual turn +01:message/user:\nFIRST_MANUAL_SUMMARY +02:message/developer: +03:message/user: +04:message/user:> 05:message/user:second manual turn 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 bdb4fe9baef..b0898ee0280 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 @@ -1,18 +1,11 @@ --- source: core/tests/suite/compact.rs -expression: "format_labeled_requests_snapshot(\"Manual /compact with no prior user turn currently still issues a compaction request; follow-up turn carries canonical context and the new user message.\",\n&[(\"Local Compaction Request\", &requests[0]),\n(\"Local Post-Compaction History Layout\", &requests[1]),])" +expression: "format_labeled_requests_snapshot(\"Manual /compact with no prior user turn is a no-op; follow-up turn carries canonical context and the new user message.\",\n&[(\"Local Post-Compaction History Layout\", &requests[0]),])" --- -Scenario: Manual /compact with no prior user turn currently still issues a compaction request; follow-up turn carries canonical context and the new user message. - -## Local Compaction Request -00:message/developer: -01:message/user: -02:message/user:> -03:message/user: +Scenario: Manual /compact with no prior user turn is a no-op; follow-up turn carries canonical context and the new user message. ## Local Post-Compaction History Layout 00:message/developer: 01:message/user: 02:message/user:> -03:message/user:\nMANUAL_EMPTY_SUMMARY -04:message/user:AFTER_MANUAL_EMPTY_COMPACT +03:message/user:AFTER_MANUAL_EMPTY_COMPACT diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__mid_turn_compaction_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__mid_turn_compaction_shapes.snap index c13f78aff9c..01ecfe67ca2 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact__mid_turn_compaction_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__mid_turn_compaction_shapes.snap @@ -1,6 +1,6 @@ --- source: core/tests/suite/compact.rs -assertion_line: 2646 +assertion_line: 2434 expression: "format_labeled_requests_snapshot(\"True mid-turn continuation compaction after tool output: compact request includes tool artifacts, and the continuation request includes the summary in the same turn.\",\n&[(\"Local Compaction Request\", &auto_compact_mock.single_request()),\n(\"Local Post-Compaction History Layout\",\n&post_auto_compact_mock.single_request()),])" --- Scenario: True mid-turn continuation compaction after tool output: compact request includes tool artifacts, and the continuation request includes the summary in the same turn. 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 8d6c4d9b1aa..16ca8770755 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 @@ -1,6 +1,5 @@ --- source: core/tests/suite/compact.rs -assertion_line: 1773 expression: "format_labeled_requests_snapshot(\"Pre-sampling compaction on model switch to a smaller context window: current behavior compacts using prior-turn history only (incoming user message excluded), and the follow-up request carries compacted history plus the new user message.\",\n&[(\"Initial Request (Previous Model)\", &requests[0]),\n(\"Pre-sampling Compaction Request\", &requests[1]),\n(\"Post-Compaction Follow-up Request (Next Model)\", &requests[2]),])" --- Scenario: Pre-sampling compaction on model switch to a smaller context window: current behavior compacts using prior-turn history only (incoming user message excluded), and the follow-up request carries compacted history plus the new user message. diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_context_window_exceeded_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_context_window_exceeded_shapes.snap index da30b01bd7c..89066bacaac 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_context_window_exceeded_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_context_window_exceeded_shapes.snap @@ -1,13 +1,14 @@ --- source: core/tests/suite/compact.rs -expression: "format_labeled_requests_snapshot(\"Pre-turn auto-compaction context-window failure: compaction request excludes the incoming user message and the turn errors.\",\n&[(\"Local Compaction Request (Incoming User Excluded)\", &requests[1]),])" +expression: "format_labeled_requests_snapshot(\"Pre-turn auto-compaction context-window failure: compaction request includes the incoming user message and the turn errors.\",\n&[(\"Local Compaction Request (Incoming User Included)\", &requests[1]),])" --- -Scenario: Pre-turn auto-compaction context-window failure: compaction request excludes the incoming user message and the turn errors. +Scenario: Pre-turn auto-compaction context-window failure: compaction request includes the incoming user message and the turn errors. -## Local Compaction Request (Incoming User Excluded) +## Local Compaction Request (Incoming User Included) 00:message/developer: 01:message/user: 02:message/user:> 03:message/user:USER_ONE 04:message/assistant:FIRST_REPLY -05:message/user: +05:message/user:USER_TWO +06:message/user: 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 e586c8521a0..ccafe8237a4 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 @@ -1,8 +1,8 @@ --- source: core/tests/suite/compact.rs -expression: "format_labeled_requests_snapshot(\"Pre-turn auto-compaction with a context override emits the context diff in the compact request while the incoming user message is still excluded.\",\n&[(\"Local Compaction Request\", &requests[2]),\n(\"Local Post-Compaction History Layout\", &requests[3]),])" +expression: "format_labeled_requests_snapshot(\"Pre-turn auto-compaction with a context override includes incoming user content in the compact request and preserves it after compaction.\",\n&[(\"Local Compaction Request\", &requests[2]),\n(\"Local Post-Compaction History Layout\", &requests[3]),])" --- -Scenario: Pre-turn auto-compaction with a context override emits the context diff in the compact request while the incoming user message is still excluded. +Scenario: Pre-turn auto-compaction with a context override includes incoming user content in the compact request and preserves it after compaction. ## Local Compaction Request 00:message/developer: @@ -13,13 +13,14 @@ Scenario: Pre-turn auto-compaction with a context override emits the context dif 05:message/user:USER_TWO 06:message/assistant:SECOND_REPLY 07:message/user: -08:message/user: +08:message/user: | | | USER_THREE +09:message/user: ## Local Post-Compaction History Layout -00:message/developer: -01:message/user: -02:message/user: -03:message/user:USER_ONE -04:message/user:USER_TWO -05:message/user:\nPRE_TURN_SUMMARY +00:message/user:USER_ONE +01:message/user:USER_TWO +02:message/user:\nPRE_TURN_SUMMARY +03:message/developer: +04:message/user: +05:message/user: 06:message/user: | | | 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 4d67af911d8..2f4820b282f 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 @@ -1,6 +1,6 @@ --- source: core/tests/suite/compact.rs -assertion_line: 3152 +assertion_line: 2949 expression: "format_labeled_requests_snapshot(\"Pre-turn compaction during model switch (without pre-sampling model-switch compaction): current behavior strips incoming from the compact request and restores it in the post-compaction follow-up request.\",\n&[(\"Initial Request (Previous Model)\", &requests[0]),\n(\"Local Compaction Request\", &requests[1]),\n(\"Local Post-Compaction History Layout\", &requests[2]),])" --- Scenario: Pre-turn compaction during model switch (without pre-sampling model-switch compaction): current behavior strips incoming from the compact request and restores it in the post-compaction follow-up request. @@ -19,14 +19,15 @@ Scenario: Pre-turn compaction during model switch (without pre-sampling model-sw 03:message/developer: 04:message/user:BEFORE_SWITCH_USER 05:message/assistant:BEFORE_SWITCH_REPLY -06:message/user: +06:message/user:AFTER_SWITCH_USER +07:message/user: ## Local Post-Compaction History Layout -00:message/developer: -01:message/developer: The user has requested a new communication st... -02:message/user: -03:message/user:> -04:message/user:BEFORE_SWITCH_USER -05:message/user:\nPRETURN_SWITCH_SUMMARY -06:message/developer:\nThe user was previously using a different model.... +00:message/user:BEFORE_SWITCH_USER +01:message/user:\nPRETURN_SWITCH_SUMMARY +02:message/developer:\nThe user was previously using a different model.... +03:message/developer: +04:message/developer: The user has requested a new communication st... +05:message/user: +06:message/user:> 07:message/user:AFTER_SWITCH_USER diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_with_history_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_with_history_shapes.snap index bc795a19f44..f332d65d797 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_with_history_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_with_history_shapes.snap @@ -13,9 +13,9 @@ Scenario: Remote manual /compact where remote compact output is summary-only: fo 04:message/assistant:FIRST_REMOTE_REPLY ## Remote Post-Compaction History Layout -00:message/developer: -01:message/user: -02:message/user:> -03:message/user:REMOTE_COMPACTED_SUMMARY -04:compaction:encrypted=true +00:message/user:REMOTE_COMPACTED_SUMMARY +01:compaction:encrypted=true +02:message/developer: +03:message/user: +04:message/user:> 05:message/user:after compact diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_without_prev_user_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_without_prev_user_shapes.snap index ce62806d99c..46eb3b76c5a 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_without_prev_user_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_without_prev_user_shapes.snap @@ -1,13 +1,8 @@ --- source: core/tests/suite/compact_remote.rs -expression: "format_labeled_requests_snapshot(\"Remote manual /compact with no prior user turn still issues a compact request; follow-up turn carries canonical context and new user message.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &follow_up_request),])" +expression: "format_labeled_requests_snapshot(\"Remote manual /compact with no prior user turn is a no-op; follow-up turn carries canonical context and the new user message.\",\n&[(\"Remote Post-Compaction History Layout\", &follow_up_request),])" --- -Scenario: Remote manual /compact with no prior user turn still issues a compact request; follow-up turn carries canonical context and new user message. - -## Remote Compaction Request -00:message/developer: -01:message/user: -02:message/user:> +Scenario: Remote manual /compact with no prior user turn is a no-op; follow-up turn carries canonical context and the new user message. ## Remote Post-Compaction History Layout 00:message/developer: diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_multi_summary_reinjects_above_last_summary_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_multi_summary_shapes.snap similarity index 68% rename from codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_multi_summary_reinjects_above_last_summary_shapes.snap rename to codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_multi_summary_shapes.snap index 1f51f965440..8107eaa37d1 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_multi_summary_reinjects_above_last_summary_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_multi_summary_shapes.snap @@ -1,21 +1,23 @@ --- source: core/tests/suite/compact_remote.rs -expression: "format_labeled_requests_snapshot(\"Remote mid-turn compaction after an earlier summary compaction: the older summary remains in model-visible history and round-trips into the next compact request.\",\n&[(\"Second Turn Request (Before Mid-Turn Compaction)\", &requests[1]),\n(\"Remote Compaction Request\", &compact_request),])" +assertion_line: 1896 +expression: "format_labeled_requests_snapshot(\"Remote mid-turn compaction after an earlier summary compaction: the older summary remains in model-visible history and round-trips into the next compact request.\",\n&[(\"Second Turn Request (Before Mid-Turn Compaction)\", &second_turn_request),\n(\"Remote Compaction Request\", &compact_request),])" --- Scenario: Remote mid-turn compaction after an earlier summary compaction: the older summary remains in model-visible history and round-trips into the next compact request. ## Second Turn Request (Before Mid-Turn Compaction) 00:message/user:USER_ONE 01:message/user:\nREMOTE_OLDER_SUMMARY -02:message/developer: -03:message/user: -04:message/user:> -05:message/user:\nREMOTE_LATEST_SUMMARY +02:message/user:\nREMOTE_LATEST_SUMMARY +03:message/developer: +04:message/user: +05:message/user:> 06:message/user:USER_TWO ## Remote Compaction Request 00:message/user:USER_ONE -01:message/developer: -02:message/user: -03:message/user:> -04:message/user:\nREMOTE_OLDER_SUMMARY +01:message/user:\nREMOTE_OLDER_SUMMARY +02:message/developer: +03:message/user: +04:message/user:> +05:message/user:USER_TWO diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_shapes.snap index 8ef8701673b..5150a9d3b11 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_shapes.snap @@ -1,5 +1,6 @@ --- source: core/tests/suite/compact_remote.rs +assertion_line: 1711 expression: "format_labeled_requests_snapshot(\"Remote mid-turn continuation compaction after tool output: compact request includes tool artifacts and follow-up request includes the summary.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &requests[1]),])" --- Scenario: Remote mid-turn continuation compaction after tool output: compact request includes tool artifacts and follow-up request includes the summary. @@ -13,8 +14,8 @@ Scenario: Remote mid-turn continuation compaction after tool output: compact req 05:function_call_output:unsupported call: test_tool ## Remote Post-Compaction History Layout -00:message/user:USER_ONE -01:message/developer: -02:message/user: -03:message/user:> +00:message/developer: +01:message/user: +02:message/user:> +03:message/user:USER_ONE 04:message/user:\nREMOTE_MID_TURN_SUMMARY diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_summary_only_reinjects_context_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_summary_only_shapes.snap similarity index 76% rename from codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_summary_only_reinjects_context_shapes.snap rename to codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_summary_only_shapes.snap index 4ede5c3cadd..88561b245b9 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_summary_only_reinjects_context_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_summary_only_shapes.snap @@ -1,8 +1,8 @@ --- source: core/tests/suite/compact_remote.rs -expression: "format_labeled_requests_snapshot(\"Remote mid-turn compaction where compact output has only summary user content: continuation layout reinjects canonical context before that summary.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &requests[1]),])" +expression: "format_labeled_requests_snapshot(\"Remote mid-turn compaction where compact output has only summary user content: continuation layout reinjects canonical context before the latest summary.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &post_compact_turn_request),])" --- -Scenario: Remote mid-turn compaction where compact output has only summary user content: continuation layout reinjects canonical context before that summary. +Scenario: Remote mid-turn compaction where compact output has only summary user content: continuation layout reinjects canonical context before the latest summary. ## Remote Compaction Request 00:message/developer: diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_context_window_exceeded_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_context_window_exceeded_shapes.snap index e718bfda886..2bac15dfda7 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_context_window_exceeded_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_context_window_exceeded_shapes.snap @@ -10,3 +10,4 @@ Scenario: Remote pre-turn auto-compaction context-window failure: compaction req 02:message/user:> 03:message/user:USER_ONE 04:message/assistant:REMOTE_FIRST_REPLY +05:message/user:USER_TWO diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_failure_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_failure_shapes.snap index f8aa74e9a1f..864c06b4b8a 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_failure_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_failure_shapes.snap @@ -1,12 +1,13 @@ --- source: core/tests/suite/compact_remote.rs -expression: "format_labeled_requests_snapshot(\"Remote pre-turn auto-compaction parse failure: compaction request excludes the incoming user message and the turn stops.\",\n&[(\"Remote Compaction Request (Incoming User Excluded)\",\n&first_compact_mock.single_request()),])" +expression: "format_labeled_requests_snapshot(\"Remote pre-turn auto-compaction parse failure: compaction request includes incoming user content and the turn stops.\",\n&[(\"Remote Compaction Request (Incoming User Included)\",\n&first_compact_mock.single_request()),])" --- -Scenario: Remote pre-turn auto-compaction parse failure: compaction request excludes the incoming user message and the turn stops. +Scenario: Remote pre-turn auto-compaction parse failure: compaction request includes incoming user content and the turn stops. -## Remote Compaction Request (Incoming User Excluded) +## Remote Compaction Request (Incoming User Included) 00:message/developer: 01:message/user: 02:message/user:> 03:message/user:turn that exceeds token threshold 04:message/assistant:initial turn complete +05:message/user:turn that triggers auto compact diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_including_incoming_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_including_incoming_shapes.snap index 00aaaeaa92c..7476a2db8fd 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_including_incoming_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_including_incoming_shapes.snap @@ -1,8 +1,8 @@ --- source: core/tests/suite/compact_remote.rs -expression: "format_labeled_requests_snapshot(\"Remote pre-turn auto-compaction with a context override emits the context diff in the compact request while excluding the incoming user message.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &requests[2]),])" +expression: "format_labeled_requests_snapshot(\"Remote pre-turn auto-compaction with a context override includes incoming user content in the compact request and preserves it after compaction.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &requests[2]),])" --- -Scenario: Remote pre-turn auto-compaction with a context override emits the context diff in the compact request while excluding the incoming user message. +Scenario: Remote pre-turn auto-compaction with a context override includes incoming user content in the compact request and preserves it after compaction. ## Remote Compaction Request 00:message/developer: @@ -13,12 +13,13 @@ Scenario: Remote pre-turn auto-compaction with a context override emits the cont 05:message/user:USER_TWO 06:message/assistant:REMOTE_SECOND_REPLY 07:message/user: +08:message/user:USER_THREE ## Remote Post-Compaction History Layout 00:message/user:USER_ONE 01:message/user:USER_TWO -02:message/developer: -03:message/user: -04:message/user: -05:message/user:\nREMOTE_PRE_TURN_SUMMARY +02:message/user:\nREMOTE_PRE_TURN_SUMMARY +03:message/developer: +04:message/user: +05:message/user: 06:message/user:USER_THREE diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_strips_incoming_model_switch_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_strips_incoming_model_switch_shapes.snap index fba17d719ed..cfa6125e696 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_strips_incoming_model_switch_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_strips_incoming_model_switch_shapes.snap @@ -1,8 +1,9 @@ --- source: core/tests/suite/compact_remote.rs -expression: "format_labeled_requests_snapshot(\"Remote pre-turn compaction during model switch currently excludes incoming user input, strips incoming from the compact request payload, and restores it in the post-compaction follow-up request.\",\n&[(\"Initial Request (Previous Model)\", &requests[0]),\n(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &requests[1]),])" +assertion_line: 1538 +expression: "format_labeled_requests_snapshot(\"Remote pre-turn compaction during model switch strips incoming from the compact request and restores it in the post-compaction follow-up request.\",\n&[(\"Initial Request (Previous Model)\", &initial_turn_request),\n(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &post_compact_turn_request),])" --- -Scenario: Remote pre-turn compaction during model switch currently excludes incoming user input, strips incoming from the compact request payload, and restores it in the post-compaction follow-up request. +Scenario: Remote pre-turn compaction during model switch strips incoming from the compact request and restores it in the post-compaction follow-up request. ## Initial Request (Previous Model) 00:message/developer: @@ -16,13 +17,14 @@ Scenario: Remote pre-turn compaction during model switch currently excludes inco 02:message/user:> 03:message/user:BEFORE_SWITCH_USER 04:message/assistant:BEFORE_SWITCH_REPLY +05:message/user:AFTER_SWITCH_USER ## Remote Post-Compaction History Layout 00:message/user:BEFORE_SWITCH_USER -01:message/developer: -02:message/developer: The user has requested a new communication st... -03:message/user: -04:message/user:> -05:message/user:\nREMOTE_SWITCH_SUMMARY -06:message/developer:\nThe user was previously using a different model.... +01:message/user:\nREMOTE_SWITCH_SUMMARY +02:message/developer:\nThe user was previously using a different model.... +03:message/developer: +04:message/developer: The user has requested a new communication st... +05:message/user: +06:message/user:> 07:message/user:AFTER_SWITCH_USER