diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 88e6fef89..90d496973 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -18,6 +18,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::InitialContextInjection; 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; @@ -1599,7 +1600,7 @@ impl Session { self.record_conversation_items(&turn_context, &items).await; { let mut state = self.state.lock().await; - state.initial_context_seeded = true; + state.set_reference_context_item(Some(turn_context.to_turn_context_item())); } self.set_previous_model(None).await; // Ensure initial items are visible to immediate readers (e.g., tests, forks). @@ -1609,19 +1610,26 @@ impl Session { let rollout_items = resumed_history.history; let restored_tool_selection = Self::extract_mcp_tool_selection_from_rollout(&rollout_items); - let previous_model = Self::last_rollout_model_name(&rollout_items) - .map(std::string::ToString::to_string); + let (previous_regular_turn_context_item, crossed_compaction_after_turn) = + Self::last_rollout_regular_turn_context_lookup(&rollout_items); + let previous_model = + previous_regular_turn_context_item.map(|ctx| ctx.model.clone()); + let curr = turn_context.model_info.slug.as_str(); + let reference_context_item = if !crossed_compaction_after_turn { + previous_regular_turn_context_item.cloned() + } else { + // Keep the baseline empty when compaction may have stripped the referenced + // context diffs so the first resumed regular turn fully reinjects context. + None + }; { let mut state = self.state.lock().await; - state.initial_context_seeded = false; + state.set_reference_context_item(reference_context_item); } - self.set_previous_model(previous_model).await; + self.set_previous_model(previous_model.clone()).await; // If resuming, warn when the last recorded model differs from the current one. - let curr = turn_context.model_info.slug.as_str(); - if let Some(prev) = - Self::last_rollout_model_name(&rollout_items).filter(|p| *p != curr) - { + if let Some(prev) = previous_model.as_deref().filter(|p| *p != curr) { warn!("resuming session with different model: previous={prev}, current={curr}"); self.send_event( &turn_context, @@ -1661,8 +1669,10 @@ impl Session { InitialHistory::Forked(rollout_items) => { let restored_tool_selection = Self::extract_mcp_tool_selection_from_rollout(&rollout_items); - let previous_model = Self::last_rollout_model_name(&rollout_items) - .map(std::string::ToString::to_string); + let (previous_regular_turn_context_item, _) = + Self::last_rollout_regular_turn_context_lookup(&rollout_items); + let previous_model = + previous_regular_turn_context_item.map(|ctx| ctx.model.clone()); self.set_previous_model(previous_model).await; // Always add response items to conversation history @@ -1695,7 +1705,7 @@ impl Session { .await; { let mut state = self.state.lock().await; - state.initial_context_seeded = true; + state.set_reference_context_item(Some(turn_context.to_turn_context_item())); } // Forked threads should remain file-backed immediately after startup. @@ -1707,14 +1717,148 @@ impl Session { } } - fn last_rollout_model_name(rollout_items: &[RolloutItem]) -> Option<&str> { - rollout_items.iter().rev().find_map(|it| { - if let RolloutItem::TurnContext(ctx) = it { - Some(ctx.model.as_str()) - } else { - None + /// Returns `(last_turn_context_item, crossed_compaction_after_turn)` from the + /// rollback-adjusted rollout view. + /// + /// This relies on the invariant that only regular turns persist `TurnContextItem`. + /// `ThreadRolledBack` markers are applied so resume/fork uses the post-rollback history view. + /// + /// Returns `(None, false)` when no persisted `TurnContextItem` can be found. + /// + /// Older/minimal rollouts may only contain `RolloutItem::TurnContext` entries without turn + /// lifecycle events. In that case we fall back to the last `TurnContextItem` (plus whether a + /// later `Compacted` item appears in rollout order). + // TODO(ccunningham): Simplify this lookup by sharing rollout traversal/rollback application + // with `reconstruct_history_from_rollout` so resume/fork baseline hydration does not need a + // second bespoke rollout scan. + fn last_rollout_regular_turn_context_lookup( + rollout_items: &[RolloutItem], + ) -> (Option<&TurnContextItem>, bool) { + // Reverse scan over rollout items. `ThreadRolledBack(num_turns)` is naturally handled by + // skipping the next `num_turns` completed turn spans we encounter while walking backward. + // + // "Active turn" here means: we have seen `TurnComplete`/`TurnAborted` and are currently + // scanning backward through that completed turn until its matching `TurnStarted`. + let mut turns_to_skip_due_to_rollback = 0usize; + let mut saw_surviving_compaction_after_candidate = false; + let mut saw_turn_lifecycle_event = false; + let mut active_turn_id: Option<&str> = None; + let mut active_turn_saw_user_message = false; + let mut active_turn_context: Option<&TurnContextItem> = None; + let mut active_turn_contains_compaction = false; + + for item in rollout_items.iter().rev() { + match item { + RolloutItem::EventMsg(EventMsg::ThreadRolledBack(rollback)) => { + // Rollbacks count completed turns, not `TurnContextItem`s. We must continue + // ignoring all items inside each skipped turn until we reach its + // corresponding `TurnStarted`. + let num_turns = usize::try_from(rollback.num_turns).unwrap_or(usize::MAX); + turns_to_skip_due_to_rollback = + turns_to_skip_due_to_rollback.saturating_add(num_turns); + } + RolloutItem::EventMsg(EventMsg::TurnComplete(event)) => { + saw_turn_lifecycle_event = true; + // Enter the reverse "turn span" for this completed turn. + active_turn_id = Some(event.turn_id.as_str()); + active_turn_saw_user_message = false; + active_turn_context = None; + active_turn_contains_compaction = false; + } + RolloutItem::EventMsg(EventMsg::TurnAborted(event)) => { + saw_turn_lifecycle_event = true; + // Same reverse-turn handling as `TurnComplete`. Some aborted turns may not + // have a turn id; in that case we cannot match `TurnContextItem`s to them. + active_turn_id = event.turn_id.as_deref(); + active_turn_saw_user_message = false; + active_turn_context = None; + active_turn_contains_compaction = false; + } + RolloutItem::EventMsg(EventMsg::UserMessage(_)) => { + if active_turn_id.is_some() { + active_turn_saw_user_message = true; + } + } + RolloutItem::EventMsg(EventMsg::TurnStarted(event)) => { + saw_turn_lifecycle_event = true; + if active_turn_id == Some(event.turn_id.as_str()) { + let active_turn_is_rolled_back = + active_turn_saw_user_message && turns_to_skip_due_to_rollback > 0; + if active_turn_is_rolled_back { + // `ThreadRolledBack(num_turns)` counts user turns, so only consume a + // skip once we've confirmed this reverse-scanned turn span contains a + // user message. Standalone task turns must not consume rollback skips. + turns_to_skip_due_to_rollback -= 1; + } + if !active_turn_is_rolled_back { + if let Some(context_item) = active_turn_context { + return ( + Some(context_item), + saw_surviving_compaction_after_candidate, + ); + } + // No `TurnContextItem` in this surviving turn; keep scanning older + // turns, but remember if this turn compacted so the eventual + // candidate reports "compaction happened after it". + if active_turn_contains_compaction { + saw_surviving_compaction_after_candidate = true; + } + } + active_turn_id = None; + active_turn_saw_user_message = false; + active_turn_context = None; + active_turn_contains_compaction = false; + } + } + RolloutItem::TurnContext(ctx) => { + // Capture the latest turn context seen in this reverse-scanned turn span. If + // the turn later proves to be rolled back, we discard it when we hit the + // matching `TurnStarted`. Older rollouts may have lifecycle events but omit + // `TurnContextItem.turn_id`; accept those as belonging to the active turn + // span for resume/fork hydration. + if let Some(active_id) = active_turn_id + && ctx + .turn_id + .as_deref() + .is_none_or(|turn_id| turn_id == active_id) + { + // Reverse scan sees the latest `TurnContextItem` for the turn first. + active_turn_context.get_or_insert(ctx); + } + } + RolloutItem::Compacted(_) => { + if active_turn_id.is_some() { + // Compaction inside the currently scanned turn is only "after" the + // eventual candidate if this turn has no `TurnContextItem` and we keep + // scanning into older turns. + active_turn_contains_compaction = true; + } else { + saw_surviving_compaction_after_candidate = true; + } + } + _ => {} } - }) + } + + // Legacy/minimal rollouts may only persist `TurnContextItem`/`Compacted` without turn + // lifecycle events. Fall back to the last `TurnContextItem` in rollout order so + // resume/fork can still hydrate `previous_model` and detect compaction-after-baseline. + if !saw_turn_lifecycle_event { + let mut saw_compaction_after_last_turn_context = false; + for item in rollout_items.iter().rev() { + match item { + RolloutItem::Compacted(_) => { + saw_compaction_after_last_turn_context = true; + } + RolloutItem::TurnContext(ctx) => { + return (Some(ctx), saw_compaction_after_last_turn_context); + } + _ => {} + } + } + } + + (None, false) } fn last_token_info_from_rollout(rollout_items: &[RolloutItem]) -> Option { @@ -2033,33 +2177,21 @@ impl Session { .await } - pub(crate) fn is_model_switch_developer_message(item: &ResponseItem) -> bool { - let ResponseItem::Message { role, content, .. } = item else { - return false; - }; - role == "developer" - && content.iter().any(|content_item| { - matches!( - content_item, - ContentItem::InputText { text } if text.starts_with("") - ) - }) - } fn build_settings_update_items( &self, - previous_context: Option<&TurnContextItem>, - resumed_model: Option<&str>, + reference_context_item: Option<&TurnContextItem>, + previous_user_turn_model: Option<&str>, current_context: &TurnContext, ) -> Vec { // TODO: Make context updates a pure diff of persisted previous/current TurnContextItem // state so replay/backtracking is deterministic. Runtime inputs that affect model-visible - // context (shell, exec policy, feature gates, resumed model bridge) should be persisted + // context (shell, exec policy, feature gates, previous-model bridge) should be persisted // state or explicit non-state replay events. let shell = self.user_shell(); let exec_policy = self.services.exec_policy.current(); crate::context_manager::updates::build_settings_update_items( - previous_context, - resumed_model, + reference_context_item, + previous_user_turn_model, current_context, shell.as_ref(), exec_policy.as_ref(), @@ -2465,15 +2597,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, @@ -2542,24 +2665,13 @@ impl Session { true } - pub(crate) async fn replace_history(&self, items: Vec) { + pub(crate) async fn replace_history( + &self, + items: Vec, + reference_context_item: Option, + ) { let mut state = self.state.lock().await; - state.replace_history(items); - } - - pub(crate) async fn seed_initial_context_if_needed(&self, turn_context: &TurnContext) { - { - let mut state = self.state.lock().await; - if state.initial_context_seeded { - return; - } - state.initial_context_seeded = true; - } - - let initial_context = self.build_initial_context(turn_context).await; - self.record_conversation_items(turn_context, &initial_context) - .await; - self.flush_rollout().await; + state.replace_history(items, reference_context_item); } async fn persist_rollout_response_items(&self, items: &[ResponseItem]) { @@ -2693,14 +2805,61 @@ impl Session { state.clone_history() } - pub(crate) async fn previous_context_item(&self) -> Option { + pub(crate) async fn reference_context_item(&self) -> Option { let state = self.state.lock().await; - state.previous_context_item() + state.reference_context_item() } - pub(crate) async fn set_previous_context_item(&self, item: Option) { + /// Persist the latest turn context snapshot and emit any required model-visible context updates. + /// + /// When the reference snapshot is missing, this injects full initial context. Otherwise, it + /// emits only settings diff items. + /// + /// If full context is injected and a model switch occurred, this prepends the + /// `` developer message so model-specific instructions are not lost. + /// + /// Invariant: this is the only runtime path that writes a non-`None` + /// `reference_context_item`. Non-regular tasks intentionally do not update that + /// baseline; `reference_context_item` tracks the latest regular model turn. + pub(crate) async fn record_context_updates_and_set_reference_context_item( + &self, + turn_context: &TurnContext, + previous_user_turn_model: Option<&str>, + ) { + let reference_context_item = self.reference_context_item().await; + let should_inject_full_context = reference_context_item.is_none(); + let context_items = if should_inject_full_context { + let mut initial_context = self.build_initial_context(turn_context).await; + // Full reinjection bypasses the settings-diff path, so add the model-switch + // instruction explicitly when needed. Keep it before the rest of full context so + // model-specific guidance is read first. + if let Some(model_switch_item) = + crate::context_manager::updates::build_model_instructions_update_item( + previous_user_turn_model, + turn_context, + ) + { + // TODO(ccunningham): When a model switch changes the effective personality + // instructions, inject the updated personality spec alongside + // here so resume/model-switch paths can avoid forcing full reinjection. + initial_context.insert(0, model_switch_item); + } + initial_context + } else { + // Steady-state path: append only context diffs to minimize token overhead. + self.build_settings_update_items( + reference_context_item.as_ref(), + previous_user_turn_model, + turn_context, + ) + }; + if !context_items.is_empty() { + self.record_conversation_items(turn_context, &context_items) + .await; + } + let mut state = self.state.lock().await; - state.set_previous_context_item(item); + state.set_reference_context_item(Some(turn_context.to_turn_context_item())); } pub(crate) async fn update_token_usage_info( @@ -3168,11 +3327,6 @@ impl Session { } async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiver) { - // Seed with context in case there is an OverrideTurnContext first. - let initial_context = sess.new_default_turn().await; - sess.set_previous_context_item(Some(initial_context.to_turn_context_item())) - .await; - // To break out of this loop, send Op::Shutdown. while let Ok(sub) = rx_sub.recv().await { debug!(?sub, "Submission"); @@ -3486,26 +3640,11 @@ mod handlers { // Attempt to inject input into current task. 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 previous_context_item = sess.previous_context_item().await; - let update_items = sess.build_settings_update_items( - previous_context_item.as_ref(), - previous_model.as_deref(), - ¤t_context, - ); - if !update_items.is_empty() { - sess.record_conversation_items(¤t_context, &update_items) - .await; - } - sess.refresh_mcp_servers_if_requested(¤t_context) .await; let regular_task = sess.take_startup_regular_task().await.unwrap_or_default(); sess.spawn_task(Arc::clone(¤t_context), items, regular_task) .await; - sess.set_previous_context_item(Some(current_context.to_turn_context_item())) - .await; } } @@ -3534,8 +3673,6 @@ mod handlers { UserShellCommandTask::new(command), ) .await; - sess.set_previous_context_item(Some(turn_context.to_turn_context_item())) - .await; } pub async fn resolve_elicitation( @@ -3965,15 +4102,20 @@ mod handlers { } let turn_context = sess.new_default_turn_with_sub_id(sub_id).await; - sess.set_previous_model(Some(turn_context.model_info.slug.clone())) - .await; let mut history = sess.clone_history().await; + // TODO(ccunningham): Fix rollback/backtracking baseline handling. + // We clear `reference_context_item` here, but should restore the + // post-rollback baseline from the surviving history/rollout instead. + // Truncating history should also invalidate/recompute `previous_model` + // so the next regular turn replays any dropped model-switch + // instructions. history.drop_last_n_user_turns(num_turns); // Replace with the raw items. We don't want to replace with a normalized // version of the history. - sess.replace_history(history.raw_items().to_vec()).await; + sess.replace_history(history.raw_items().to_vec(), None) + .await; sess.recompute_token_usage(turn_context.as_ref()).await; sess.send_event_raw_flushed(Event { @@ -4247,6 +4389,9 @@ async fn spawn_review_thread( }]; let tc = Arc::new(review_turn_context); tc.turn_metadata_state.spawn_git_enrichment_task(); + // TODO(ccunningham): Review turns currently rely on `spawn_task` for TurnComplete but do not + // emit a parent TurnStarted. Consider giving review a full parent turn lifecycle + // (TurnStarted + TurnComplete) for consistency with other standalone tasks. sess.spawn_task(tc.clone(), input, ReviewTask::new()).await; // Announce entering review mode so UIs can switch modes. @@ -4346,6 +4491,10 @@ pub(crate) async fn run_turn( collaboration_mode_kind: turn_context.collaboration_mode.mode, }); sess.send_event(&turn_context, event).await; + // TODO(ccunningham): Pre-turn compaction runs before context updates and the + // new user message are recorded. Estimate pending incoming items (context + // diffs/full reinjection + user input) and trigger compaction preemptively + // when they would push the thread over the compaction threshold. if run_pre_sampling_compact(&sess, &turn_context) .await .is_err() @@ -4354,6 +4503,13 @@ pub(crate) async fn run_turn( return None; } + let previous_model = sess.previous_model().await; + sess.record_context_updates_and_set_reference_context_item( + turn_context.as_ref(), + previous_model.as_deref(), + ) + .await; + let skills_outcome = Some( sess.services .skills_manager @@ -4465,6 +4621,11 @@ pub(crate) async fn run_turn( 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; + // Track the previous-model baseline from the regular user-turn path only so + // standalone tasks (compact/shell/review/undo) cannot suppress future + // `` injections. + sess.set_previous_model(Some(turn_context.model_info.slug.clone())) + .await; if !skill_items.is_empty() { sess.record_conversation_items(&turn_context, &skill_items) @@ -4568,7 +4729,14 @@ 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 run_auto_compact( + &sess, + &turn_context, + InitialContextInjection::BeforeLastUserMessage, + ) + .await + .is_err() + { return None; } continue; @@ -4688,7 +4856,7 @@ async fn run_pre_sampling_compact( .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?; + run_auto_compact(sess, turn_context, InitialContextInjection::DoNotInject).await?; } Ok(()) } @@ -4696,47 +4864,67 @@ async fn run_pre_sampling_compact( /// Runs pre-sampling compaction against the previous model when switching to a smaller /// context-window model. /// -/// 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. +/// Returns `Ok(true)` when compaction ran successfully, `Ok(false)` when compaction was skipped +/// because the model/context-window preconditions were not met, and `Err(_)` only when compaction +/// was attempted and failed. async fn maybe_run_previous_model_inline_compact( sess: &Arc, turn_context: &Arc, total_usage_tokens: i64, -) -> CodexResult<()> { +) -> CodexResult { let Some(previous_model) = sess.previous_model().await else { - return Ok(()); + return Ok(false); }; - let previous_turn_context = Arc::new( + let previous_model_turn_context = Arc::new( turn_context .with_model(previous_model, &sess.services.models_manager) .await, ); - let Some(old_context_window) = previous_turn_context.model_context_window() else { - return Ok(()); + let Some(old_context_window) = previous_model_turn_context.model_context_window() else { + return Ok(false); }; let Some(new_context_window) = turn_context.model_context_window() else { - return Ok(()); + return Ok(false); }; let new_auto_compact_limit = turn_context .model_info .auto_compact_token_limit() .unwrap_or(i64::MAX); let should_run = total_usage_tokens > new_auto_compact_limit - && previous_turn_context.model_info.slug != turn_context.model_info.slug + && previous_model_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?; + run_auto_compact( + sess, + &previous_model_turn_context, + InitialContextInjection::DoNotInject, + ) + .await?; + return Ok(true); } - Ok(()) + Ok(false) } -async fn run_auto_compact(sess: &Arc, turn_context: &Arc) -> CodexResult<()> { +async fn run_auto_compact( + sess: &Arc, + turn_context: &Arc, + initial_context_injection: InitialContextInjection, +) -> CodexResult<()> { if should_use_remote_compact_task(&turn_context.provider) { - run_inline_remote_auto_compact_task(Arc::clone(sess), Arc::clone(turn_context)).await?; + run_inline_remote_auto_compact_task( + Arc::clone(sess), + Arc::clone(turn_context), + initial_context_injection, + ) + .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), + initial_context_injection, + ) + .await?; } Ok(()) } @@ -5525,6 +5713,9 @@ async fn try_run_sampling_request( prompt: &Prompt, cancellation_token: CancellationToken, ) -> CodexResult { + // Persist one TurnContext marker per sampling request (not just per user turn) so rollout + // analysis can reconstruct API-turn boundaries. `run_turn` persists model-visible context + // diffs/full reinjection earlier in the same regular turn before reaching this path. let rollout_item = RolloutItem::TurnContext(turn_context.to_turn_context_item()); feedback_tags!( @@ -6458,7 +6649,7 @@ mod tests { async fn record_initial_history_resumed_hydrates_previous_model() { let (session, turn_context) = make_session_and_context().await; let previous_model = "previous-rollout-model"; - let rollout_items = vec![RolloutItem::TurnContext(TurnContextItem { + let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), cwd: turn_context.cwd.clone(), approval_policy: turn_context.approval_policy, @@ -6473,7 +6664,8 @@ mod tests { developer_instructions: None, final_output_json_schema: None, truncation_policy: Some(turn_context.truncation_policy.into()), - })]; + }; + let rollout_items = vec![RolloutItem::TurnContext(previous_context_item)]; session .record_initial_history(InitialHistory::Resumed(ResumedHistory { @@ -6490,7 +6682,182 @@ mod tests { } #[tokio::test] - async fn resumed_history_seeds_initial_context_on_first_turn_only() { + async fn record_initial_history_resumed_hydrates_previous_model_from_lifecycle_turn_with_missing_turn_context_id() + { + let (session, turn_context) = make_session_and_context().await; + let previous_model = "previous-rollout-model"; + let mut previous_context_item = TurnContextItem { + turn_id: Some(turn_context.sub_id.clone()), + cwd: turn_context.cwd.clone(), + approval_policy: turn_context.approval_policy, + sandbox_policy: turn_context.sandbox_policy.clone(), + network: None, + model: previous_model.to_string(), + personality: turn_context.personality, + collaboration_mode: Some(turn_context.collaboration_mode.clone()), + effort: turn_context.reasoning_effort, + summary: turn_context.reasoning_summary, + user_instructions: None, + developer_instructions: None, + final_output_json_schema: None, + truncation_policy: Some(turn_context.truncation_policy.into()), + }; + let turn_id = previous_context_item + .turn_id + .clone() + .expect("turn context should have turn_id"); + previous_context_item.turn_id = None; + + let rollout_items = vec![ + RolloutItem::EventMsg(EventMsg::TurnStarted( + codex_protocol::protocol::TurnStartedEvent { + turn_id: turn_id.clone(), + model_context_window: Some(128_000), + collaboration_mode_kind: ModeKind::Default, + }, + )), + RolloutItem::EventMsg(EventMsg::UserMessage( + codex_protocol::protocol::UserMessageEvent { + message: "seed".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + }, + )), + RolloutItem::TurnContext(previous_context_item), + RolloutItem::EventMsg(EventMsg::TurnComplete( + codex_protocol::protocol::TurnCompleteEvent { + turn_id, + last_agent_message: None, + }, + )), + ]; + + session + .record_initial_history(InitialHistory::Resumed(ResumedHistory { + conversation_id: ThreadId::default(), + history: rollout_items, + rollout_path: PathBuf::from("/tmp/resume.jsonl"), + })) + .await; + + assert_eq!( + session.previous_model().await, + Some(previous_model.to_string()) + ); + } + + #[tokio::test] + async fn record_initial_history_resumed_rollback_skips_only_user_turns() { + let (session, turn_context) = make_session_and_context().await; + let previous_context_item = turn_context.to_turn_context_item(); + let user_turn_id = previous_context_item + .turn_id + .clone() + .expect("turn context should have turn_id"); + let standalone_turn_id = "standalone-task-turn".to_string(); + let rollout_items = vec![ + RolloutItem::EventMsg(EventMsg::TurnStarted( + codex_protocol::protocol::TurnStartedEvent { + turn_id: user_turn_id.clone(), + model_context_window: Some(128_000), + collaboration_mode_kind: ModeKind::Default, + }, + )), + RolloutItem::EventMsg(EventMsg::UserMessage( + codex_protocol::protocol::UserMessageEvent { + message: "seed".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + }, + )), + RolloutItem::TurnContext(previous_context_item), + RolloutItem::EventMsg(EventMsg::TurnComplete( + codex_protocol::protocol::TurnCompleteEvent { + turn_id: user_turn_id, + last_agent_message: None, + }, + )), + // Standalone task turn (no UserMessage) should not consume rollback skips. + RolloutItem::EventMsg(EventMsg::TurnStarted( + codex_protocol::protocol::TurnStartedEvent { + turn_id: standalone_turn_id.clone(), + model_context_window: Some(128_000), + collaboration_mode_kind: ModeKind::Default, + }, + )), + RolloutItem::EventMsg(EventMsg::TurnComplete( + codex_protocol::protocol::TurnCompleteEvent { + turn_id: standalone_turn_id, + last_agent_message: None, + }, + )), + RolloutItem::EventMsg(EventMsg::ThreadRolledBack( + codex_protocol::protocol::ThreadRolledBackEvent { num_turns: 1 }, + )), + ]; + + session + .record_initial_history(InitialHistory::Resumed(ResumedHistory { + conversation_id: ThreadId::default(), + history: rollout_items, + rollout_path: PathBuf::from("/tmp/resume.jsonl"), + })) + .await; + + assert_eq!(session.previous_model().await, None); + assert!(session.reference_context_item().await.is_none()); + } + + #[tokio::test] + async fn record_initial_history_resumed_seeds_reference_context_item_without_compaction() { + let (session, turn_context) = make_session_and_context().await; + let previous_context_item = turn_context.to_turn_context_item(); + let rollout_items = vec![RolloutItem::TurnContext(previous_context_item.clone())]; + + session + .record_initial_history(InitialHistory::Resumed(ResumedHistory { + conversation_id: ThreadId::default(), + history: rollout_items, + rollout_path: PathBuf::from("/tmp/resume.jsonl"), + })) + .await; + + assert_eq!( + serde_json::to_value(session.reference_context_item().await) + .expect("serialize seeded reference context item"), + serde_json::to_value(Some(previous_context_item)) + .expect("serialize expected reference context item") + ); + } + + #[tokio::test] + async fn record_initial_history_resumed_does_not_seed_reference_context_item_after_compaction() + { + let (session, turn_context) = make_session_and_context().await; + let previous_context_item = turn_context.to_turn_context_item(); + let rollout_items = vec![ + RolloutItem::TurnContext(previous_context_item), + RolloutItem::Compacted(CompactedItem { + message: String::new(), + replacement_history: Some(Vec::new()), + }), + ]; + + session + .record_initial_history(InitialHistory::Resumed(ResumedHistory { + conversation_id: ThreadId::default(), + history: rollout_items, + rollout_path: PathBuf::from("/tmp/resume.jsonl"), + })) + .await; + + assert!(session.reference_context_item().await.is_none()); + } + + #[tokio::test] + async fn resumed_history_injects_initial_context_on_first_context_update_only() { let (session, turn_context) = make_session_and_context().await; let (rollout_items, mut expected) = sample_rollout(&session, &turn_context).await; @@ -6505,12 +6872,16 @@ mod tests { let history_before_seed = session.state.lock().await.clone_history(); assert_eq!(expected, history_before_seed.raw_items()); - session.seed_initial_context_if_needed(&turn_context).await; + session + .record_context_updates_and_set_reference_context_item(&turn_context, None) + .await; expected.extend(session.build_initial_context(&turn_context).await); let history_after_seed = session.clone_history().await; assert_eq!(expected, history_after_seed.raw_items()); - session.seed_initial_context_if_needed(&turn_context).await; + session + .record_context_updates_and_set_reference_context_item(&turn_context, None) + .await; let history_after_second_seed = session.clone_history().await; assert_eq!(expected, history_after_second_seed.raw_items()); } @@ -6677,7 +7048,7 @@ mod tests { async fn record_initial_history_forked_hydrates_previous_model() { let (session, turn_context) = make_session_and_context().await; let previous_model = "forked-rollout-model"; - let rollout_items = vec![RolloutItem::TurnContext(TurnContextItem { + let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), cwd: turn_context.cwd.clone(), approval_policy: turn_context.approval_policy, @@ -6692,7 +7063,8 @@ mod tests { developer_instructions: None, final_output_json_schema: None, truncation_policy: Some(turn_context.truncation_policy.into()), - })]; + }; + let rollout_items = vec![RolloutItem::TurnContext(previous_context_item)]; session .record_initial_history(InitialHistory::Forked(rollout_items)) @@ -6755,6 +7127,8 @@ mod tests { }, ]; sess.record_into_history(&turn_2, tc.as_ref()).await; + sess.set_previous_model(Some("previous-regular-model".to_string())) + .await; handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await; @@ -6769,7 +7143,7 @@ mod tests { assert_eq!(expected, history.raw_items()); assert_eq!( sess.previous_model().await, - Some(tc.model_info.slug.clone()) + Some("previous-regular-model".to_string()) ); } @@ -7426,8 +7800,7 @@ mod tests { session_configuration.session_source.clone(), ); - let mut state = SessionState::new(session_configuration.clone()); - mark_state_initial_context_seeded(&mut state); + let state = SessionState::new(session_configuration.clone()); let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone())); let network_approval = Arc::new(NetworkApprovalService::default()); @@ -7583,8 +7956,7 @@ mod tests { session_configuration.session_source.clone(), ); - let mut state = SessionState::new(session_configuration.clone()); - mark_state_initial_context_seeded(&mut state); + let state = SessionState::new(session_configuration.clone()); let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone())); let network_approval = Arc::new(NetworkApprovalService::default()); @@ -7671,10 +8043,6 @@ mod tests { (session, turn_context, rx_event) } - fn mark_state_initial_context_seeded(state: &mut SessionState) { - state.initial_context_seeded = true; - } - #[tokio::test] async fn refresh_mcp_servers_is_deferred_until_next_turn() { let (session, turn_context) = make_session_and_context().await; @@ -7746,7 +8114,7 @@ mod tests { } #[tokio::test] - async fn spawn_task_hydrates_previous_model() { + async fn spawn_task_does_not_update_previous_model_for_non_run_turn_tasks() { let (sess, tc, _rx) = make_session_and_context_with_rx().await; sess.set_previous_model(None).await; let input = vec![UserInput::Text { @@ -7765,10 +8133,7 @@ mod tests { .await; sess.abort_all_tasks(TurnAbortReason::Interrupted).await; - assert_eq!( - sess.previous_model().await, - Some(tc.model_info.slug.clone()) - ); + assert_eq!(sess.previous_model().await, None); } #[tokio::test] @@ -7806,9 +8171,9 @@ mod tests { .expect("rebuild config layer stack with network requirements"); current_context.config = Arc::new(config); - let previous_context_item = previous_context.to_turn_context_item(); + let reference_context_item = previous_context.to_turn_context_item(); let update_items = session.build_settings_update_items( - Some(&previous_context_item), + Some(&reference_context_item), None, ¤t_context, ); @@ -7830,6 +8195,92 @@ mod tests { assert!(environment_update.contains("blocked.example.com")); } + #[tokio::test] + async fn record_context_updates_and_set_reference_context_item_injects_full_context_when_baseline_missing() + { + let (session, turn_context) = make_session_and_context().await; + session + .record_context_updates_and_set_reference_context_item(&turn_context, None) + .await; + let history = session.clone_history().await; + let initial_context = session.build_initial_context(&turn_context).await; + assert_eq!(history.raw_items().to_vec(), initial_context); + + let current_context = session.reference_context_item().await; + assert_eq!( + serde_json::to_value(current_context).expect("serialize current context item"), + serde_json::to_value(Some(turn_context.to_turn_context_item())) + .expect("serialize expected context item") + ); + } + + #[tokio::test] + async fn record_context_updates_and_set_reference_context_item_reinjects_full_context_after_clear() + { + let (session, turn_context) = make_session_and_context().await; + let compacted_summary = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: format!("{}\nsummary", crate::compact::SUMMARY_PREFIX), + }], + end_turn: None, + phase: None, + }; + session + .record_into_history(std::slice::from_ref(&compacted_summary), &turn_context) + .await; + session + .record_context_updates_and_set_reference_context_item(&turn_context, None) + .await; + { + let mut state = session.state.lock().await; + state.set_reference_context_item(None); + } + session + .replace_history(vec![compacted_summary.clone()], None) + .await; + + session + .record_context_updates_and_set_reference_context_item(&turn_context, None) + .await; + + let history = session.clone_history().await; + let mut expected_history = vec![compacted_summary]; + expected_history.extend(session.build_initial_context(&turn_context).await); + assert_eq!(history.raw_items().to_vec(), expected_history); + } + + #[tokio::test] + async fn run_user_shell_command_does_not_set_reference_context_item() { + let (session, _turn_context, rx) = make_session_and_context_with_rx().await; + { + let mut state = session.state.lock().await; + state.set_reference_context_item(None); + } + + handlers::run_user_shell_command(&session, "sub-id".to_string(), "echo shell".to_string()) + .await; + + let deadline = StdDuration::from_secs(5); + let start = std::time::Instant::now(); + loop { + let remaining = deadline.saturating_sub(start.elapsed()); + let evt = tokio::time::timeout(remaining, rx.recv()) + .await + .expect("timeout waiting for event") + .expect("event"); + if matches!(evt.msg, EventMsg::TurnComplete(_)) { + break; + } + } + + assert!( + session.reference_context_item().await.is_none(), + "standalone shell tasks should not mutate previous context" + ); + } + #[derive(Clone, Copy)] struct NeverEndingTask { kind: TaskKind, diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 406525142..43ee08fd4 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -32,10 +32,37 @@ 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; +/// Controls whether compaction replacement history must include initial context. +/// +/// Pre-turn/manual compaction variants use `DoNotInject`: they replace history with a summary and +/// clear `reference_context_item`, so the next regular turn will fully reinject initial context +/// after compaction. +/// +/// Mid-turn compaction must use `BeforeLastUserMessage` because the model is trained to see the +/// compaction summary as the last item in history after mid-turn compaction; we therefore inject +/// initial context into the replacement history just above the last real user message. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum InitialContextInjection { + BeforeLastUserMessage, + DoNotInject, +} + pub(crate) fn should_use_remote_compact_task(provider: &ModelProviderInfo) -> bool { provider.is_openai() } +fn is_model_switch_developer_message(item: &ResponseItem) -> bool { + match item { + ResponseItem::Message { role, content, .. } if role == "developer" => { + matches!( + content.as_slice(), + [ContentItem::InputText { text }] if text.starts_with("\n") + ) + } + _ => false, + } +} + pub(crate) fn extract_trailing_model_switch_update_for_compaction_request( history: &mut ContextManager, ) -> Option { @@ -49,7 +76,7 @@ pub(crate) fn extract_trailing_model_switch_update_for_compaction_request( .rev() .find_map(|(i, item)| { let is_trailing = last_user_turn_boundary_index.is_none_or(|boundary| i > boundary); - if is_trailing && Session::is_model_switch_developer_message(item) { + if is_trailing && is_model_switch_developer_message(item) { Some(i) } else { None @@ -64,6 +91,7 @@ pub(crate) fn extract_trailing_model_switch_update_for_compaction_request( pub(crate) async fn run_inline_auto_compact_task( sess: Arc, turn_context: Arc, + initial_context_injection: InitialContextInjection, ) -> CodexResult<()> { let prompt = turn_context.compact_prompt().to_string(); let input = vec![UserInput::Text { @@ -72,7 +100,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, initial_context_injection).await?; Ok(()) } @@ -87,13 +115,20 @@ 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.clone(), + turn_context, + input, + InitialContextInjection::DoNotInject, + ) + .await } async fn run_compact_task_inner( sess: Arc, turn_context: Arc, input: Vec, + initial_context_injection: InitialContextInjection, ) -> CodexResult<()> { let compaction_item = TurnItem::ContextCompaction(ContextCompactionItem::new()); sess.emit_turn_item_started(&turn_context, &compaction_item) @@ -118,9 +153,6 @@ async fn run_compact_task_inner( // Reuse one client session so turn-scoped state (sticky routing, websocket append tracking) // survives retries within this compact turn. - let rollout_item = RolloutItem::TurnContext(turn_context.to_turn_context_item()); - sess.persist_rollout_items(&[rollout_item]).await; - loop { // Clone is required because of the loop let turn_input = history @@ -202,8 +234,16 @@ async fn run_compact_task_inner( 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); + let mut new_history = build_compacted_history(Vec::new(), &user_messages, &summary_text); + + if matches!( + initial_context_injection, + InitialContextInjection::BeforeLastUserMessage + ) { + let initial_context = sess.build_initial_context(turn_context.as_ref()).await; + new_history = + insert_initial_context_before_last_real_user_or_summary(new_history, initial_context); + } // Reattach the stripped model-switch update only after successful compaction so the model // still sees the switch instructions on the next real sampling request. if let Some(model_switch_item) = stripped_model_switch_item { @@ -215,12 +255,17 @@ async fn run_compact_task_inner( .cloned() .collect(); new_history.extend(ghost_snapshots); - sess.replace_history(new_history).await; + let reference_context_item = match initial_context_injection { + InitialContextInjection::DoNotInject => None, + InitialContextInjection::BeforeLastUserMessage => Some(turn_context.to_turn_context_item()), + }; + sess.replace_history(new_history.clone(), reference_context_item) + .await; sess.recompute_token_usage(&turn_context).await; let rollout_item = RolloutItem::Compacted(CompactedItem { message: summary_text.clone(), - replacement_history: None, + replacement_history: Some(new_history), }); sess.persist_rollout_items(&[rollout_item]).await; @@ -272,24 +317,50 @@ pub(crate) fn is_summary_message(message: &str) -> bool { message.starts_with(format!("{SUMMARY_PREFIX}\n").as_str()) } -pub(crate) fn process_compacted_history( +/// Inserts canonical initial context into compacted replacement history at the +/// model-expected boundary. +/// +/// Placement rules: +/// - Prefer immediately before the last real user message. +/// - If no real user messages remain, insert before the compaction summary so +/// the summary stays last. +/// - If there are no user messages, insert before the last compaction item so +/// that item remains last (remote compaction may return only compaction items). +/// - If there are no user messages or compaction items, append the context. +pub(crate) fn insert_initial_context_before_last_real_user_or_summary( mut compacted_history: Vec, - initial_context: &[ResponseItem], + initial_context: Vec, ) -> Vec { - compacted_history.retain(should_keep_compacted_history_item); - - let initial_context = initial_context.to_vec(); + let mut last_user_or_summary_index = None; + let mut last_real_user_index = None; + for (i, item) in compacted_history.iter().enumerate().rev() { + let Some(TurnItem::UserMessage(user)) = crate::event_mapping::parse_turn_item(item) else { + continue; + }; + // Compaction summaries are encoded as user messages, so track both: + // the last real user message (preferred insertion point) and the last + // user-message-like item (fallback summary insertion point). + last_user_or_summary_index.get_or_insert(i); + if !is_summary_message(&user.message()) { + last_real_user_index = Some(i); + break; + } + } + let last_compaction_index = compacted_history + .iter() + .enumerate() + .rev() + .find_map(|(i, item)| matches!(item, ResponseItem::Compaction { .. }).then_some(i)); + let insertion_index = last_real_user_index + .or(last_user_or_summary_index) + .or(last_compaction_index); // 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); + // from the pre-compaction history. Prefer placing it before the last real + // user message; if there is no real user message left, place it before the + // summary or compaction item so the compaction item remains last. + if let Some(insertion_index) = insertion_index { + compacted_history.splice(insertion_index..insertion_index, initial_context); } else { compacted_history.extend(initial_context); } @@ -297,30 +368,6 @@ pub(crate) fn process_compacted_history( compacted_history } -/// Returns whether an item from remote compaction output should be preserved. -/// -/// Called while processing the model-provided compacted transcript, before we -/// append fresh canonical context from the current session. -/// -/// We drop: -/// - `developer` messages because remote output can include stale/duplicated -/// instruction content. -/// - non-user-content `user` messages (session prefix/instruction wrappers), -/// keeping only real user messages as parsed by `parse_turn_item`. -/// -/// 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 { - 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, - } -} - pub(crate) fn build_compacted_history( initial_context: Vec, user_messages: &[String], @@ -442,6 +489,21 @@ mod tests { use super::*; use pretty_assertions::assert_eq; + async fn process_compacted_history_with_test_session( + compacted_history: Vec, + ) -> (Vec, Vec) { + let (session, turn_context) = crate::codex::make_session_and_context().await; + let initial_context = session.build_initial_context(&turn_context).await; + let refreshed = crate::compact_remote::process_compacted_history( + &session, + &turn_context, + compacted_history, + InitialContextInjection::BeforeLastUserMessage, + ) + .await; + (refreshed, initial_context) + } + #[test] fn content_items_to_text_joins_non_empty_segments() { let items = vec![ @@ -514,7 +576,7 @@ mod tests { history .raw_items() .iter() - .all(|item| !Session::is_model_switch_developer_message(item)) + .all(|item| !is_model_switch_developer_message(item)) ); } @@ -569,7 +631,7 @@ mod tests { history .raw_items() .iter() - .any(Session::is_model_switch_developer_message) + .any(is_model_switch_developer_message) ); } @@ -708,8 +770,8 @@ do things assert_eq!(summary, summary_text); } - #[test] - fn process_compacted_history_replaces_developer_messages() { + #[tokio::test] + async fn process_compacted_history_replaces_developer_messages() { let compacted_history = vec![ ResponseItem::Message { id: None, @@ -739,88 +801,22 @@ do things 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, - }, - ResponseItem::Message { - 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(), - }], - 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(), - content: vec![ContentItem::InputText { - text: "fresh permissions".to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - 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(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "summary".to_string(), - }], - end_turn: None, - phase: None, - }, - ]; + let (refreshed, mut expected) = + process_compacted_history_with_test_session(compacted_history).await; + expected.push(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_reinjects_full_initial_context() { + #[tokio::test] + async fn process_compacted_history_reinjects_full_initial_context() { let compacted_history = vec![ResponseItem::Message { id: None, role: "user".to_string(), @@ -830,124 +826,22 @@ 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, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: r#"# AGENTS.md instructions for /repo - - -keep me updated -"# - .to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: r#" - /repo - zsh -"# - .to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: r#" - turn-1 - interrupted -"# - .to_string(), - }], - 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(), - content: vec![ContentItem::InputText { - text: "fresh permissions".to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: r#"# AGENTS.md instructions for /repo - - -keep me updated -"# - .to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: r#" - /repo - zsh -"# - .to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: r#" - turn-1 - interrupted -"# - .to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "summary".to_string(), - }], - end_turn: None, - phase: None, - }, - ]; + let (refreshed, mut expected) = + process_compacted_history_with_test_session(compacted_history).await; + expected.push(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_drops_non_user_content_messages() { + #[tokio::test] + async fn process_compacted_history_drops_non_user_content_messages() { let compacted_history = vec![ ResponseItem::Message { id: None, @@ -1008,42 +902,22 @@ keep me updated phase: None, }, ]; - let initial_context = vec![ResponseItem::Message { + let (refreshed, mut expected) = + process_compacted_history_with_test_session(compacted_history).await; + expected.push(ResponseItem::Message { id: None, - role: "developer".to_string(), + role: "user".to_string(), content: vec![ContentItem::InputText { - text: "fresh developer instructions".to_string(), + text: "summary".to_string(), }], 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(), - content: vec![ContentItem::InputText { - text: "fresh developer instructions".to_string(), - }], - end_turn: None, - phase: None, - }, - 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_inserts_context_before_last_real_user_message_only() { + #[tokio::test] + async fn process_compacted_history_inserts_context_before_last_real_user_message_only() { let compacted_history = vec![ ResponseItem::Message { id: None, @@ -1073,18 +947,10 @@ keep me updated 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, - }]; - let refreshed = process_compacted_history(compacted_history, &initial_context); - let expected = vec![ + let (refreshed, initial_context) = + process_compacted_history_with_test_session(compacted_history).await; + let mut expected = vec![ ResponseItem::Message { id: None, role: "user".to_string(), @@ -1103,6 +969,75 @@ keep me updated end_turn: None, phase: None, }, + ]; + expected.extend(initial_context); + expected.push(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); + } + + #[test] + fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last() { + let compacted_history = 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: "latest 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"), + }], + 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, + }]; + + let refreshed = insert_initial_context_before_last_real_user_or_summary( + 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: "developer".to_string(), @@ -1121,6 +1056,51 @@ keep me updated end_turn: None, phase: None, }, + 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_real_user_or_summary_keeps_compaction_last() { + let compacted_history = vec![ResponseItem::Compaction { + encrypted_content: "encrypted".to_string(), + }]; + 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, + }]; + + let refreshed = insert_initial_context_before_last_real_user_or_summary( + compacted_history, + initial_context, + ); + let expected = vec![ + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "fresh permissions".to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Compaction { + encrypted_content: "encrypted".to_string(), + }, ]; assert_eq!(refreshed, expected); } diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 3d181f885..2a9106c72 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -3,7 +3,9 @@ use std::sync::Arc; use crate::Prompt; use crate::codex::Session; use crate::codex::TurnContext; +use crate::compact::InitialContextInjection; use crate::compact::extract_trailing_model_switch_update_for_compaction_request; +use crate::compact::insert_initial_context_before_last_real_user_or_summary; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; use crate::context_manager::estimate_response_item_model_visible_bytes; @@ -25,8 +27,9 @@ use tracing::info; pub(crate) async fn run_inline_remote_auto_compact_task( sess: Arc, turn_context: Arc, + initial_context_injection: InitialContextInjection, ) -> CodexResult<()> { - run_remote_compact_task_inner(&sess, &turn_context).await?; + run_remote_compact_task_inner(&sess, &turn_context, initial_context_injection).await?; Ok(()) } @@ -41,14 +44,17 @@ 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, InitialContextInjection::DoNotInject).await } async fn run_remote_compact_task_inner( sess: &Arc, turn_context: &Arc, + initial_context_injection: InitialContextInjection, ) -> CodexResult<()> { - if let Err(err) = run_remote_compact_task_inner_impl(sess, turn_context).await { + if let Err(err) = + run_remote_compact_task_inner_impl(sess, turn_context, initial_context_injection).await + { let event = EventMsg::Error( err.to_error_event(Some("Error running remote compact task".to_string())), ); @@ -61,6 +67,7 @@ async fn run_remote_compact_task_inner( async fn run_remote_compact_task_inner_impl( sess: &Arc, turn_context: &Arc, + initial_context_injection: InitialContextInjection, ) -> CodexResult<()> { let compaction_item = TurnItem::ContextCompaction(ContextCompactionItem::new()); sess.emit_turn_item_started(turn_context, &compaction_item) @@ -122,9 +129,13 @@ async fn run_remote_compact_task_inner_impl( Err(err) }) .await?; - new_history = sess - .process_compacted_history(turn_context, new_history) - .await; + new_history = process_compacted_history( + sess.as_ref(), + turn_context.as_ref(), + new_history, + initial_context_injection, + ) + .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. if let Some(model_switch_item) = stripped_model_switch_item { @@ -134,7 +145,12 @@ async fn run_remote_compact_task_inner_impl( if !ghost_snapshots.is_empty() { new_history.extend(ghost_snapshots); } - sess.replace_history(new_history.clone()).await; + let reference_context_item = match initial_context_injection { + InitialContextInjection::DoNotInject => None, + InitialContextInjection::BeforeLastUserMessage => Some(turn_context.to_turn_context_item()), + }; + sess.replace_history(new_history.clone(), reference_context_item) + .await; sess.recompute_token_usage(turn_context).await; let compacted_item = CompactedItem { @@ -149,6 +165,65 @@ async fn run_remote_compact_task_inner_impl( Ok(()) } +pub(crate) async fn process_compacted_history( + sess: &Session, + turn_context: &TurnContext, + mut compacted_history: Vec, + initial_context_injection: InitialContextInjection, +) -> Vec { + // Mid-turn compaction is the only path that must inject initial context above the last user + // message in the replacement history. Pre-turn compaction instead injects context after the + // compaction item, but mid-turn compaction keeps the compaction item last for model training. + let initial_context = if matches!( + initial_context_injection, + InitialContextInjection::BeforeLastUserMessage + ) { + sess.build_initial_context(turn_context).await + } else { + Vec::new() + }; + + compacted_history.retain(should_keep_compacted_history_item); + insert_initial_context_before_last_real_user_or_summary(compacted_history, initial_context) +} + +/// Returns whether an item from remote compaction output should be preserved. +/// +/// Called while processing the model-provided compacted transcript, before we +/// append fresh canonical context from the current session. +/// +/// We drop: +/// - `developer` messages because remote output can include stale/duplicated +/// instruction content. +/// - non-user-content `user` messages (session prefix/instruction wrappers), +/// keeping only real user messages as parsed by `parse_turn_item`. +/// +/// This intentionally keeps: +/// - `assistant` messages (future remote compaction models may emit them) +/// - `user`-role warnings and compaction-generated summary messages because +/// they parse as `TurnItem::UserMessage`. +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(_)) + ), + ResponseItem::Message { role, .. } if role == "assistant" => true, + ResponseItem::Message { .. } => false, + ResponseItem::Compaction { .. } => true, + ResponseItem::Reasoning { .. } + | ResponseItem::LocalShellCall { .. } + | ResponseItem::FunctionCall { .. } + | ResponseItem::FunctionCallOutput { .. } + | ResponseItem::CustomToolCall { .. } + | ResponseItem::CustomToolCallOutput { .. } + | ResponseItem::WebSearchCall { .. } + | ResponseItem::GhostSnapshot { .. } + | ResponseItem::Other => false, + } +} + #[derive(Debug)] struct CompactRequestLogData { failing_compaction_request_model_visible_bytes: i64, diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 0cd935523..52df99fb0 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -27,12 +27,15 @@ pub(crate) struct ContextManager { /// The oldest items are at the beginning of the vector. items: Vec, token_info: Option, - /// Previous turn context snapshot used for diffing context and producing - /// model-visible settings update items. + /// Reference context snapshot used for diffing and producing model-visible + /// settings update items. + /// + /// This is the baseline for the next regular model turn, and may already + /// match the current turn after context updates are persisted. /// /// When this is `None`, settings diffing treats the next turn as having no /// baseline and emits a full reinjection of context state. - previous_context_item: Option, + reference_context_item: Option, } #[derive(Debug, Clone, Copy, Default)] @@ -48,7 +51,7 @@ impl ContextManager { Self { items: Vec::new(), token_info: TokenUsageInfo::new_or_append(&None, &None, None), - previous_context_item: None, + reference_context_item: None, } } @@ -60,12 +63,12 @@ impl ContextManager { self.token_info = info; } - pub(crate) fn set_previous_context_item(&mut self, item: Option) { - self.previous_context_item = item; + pub(crate) fn set_reference_context_item(&mut self, item: Option) { + self.reference_context_item = item; } - pub(crate) fn previous_context_item(&self) -> Option { - self.previous_context_item.clone() + pub(crate) fn reference_context_item(&self) -> Option { + self.reference_context_item.clone() } pub(crate) fn set_token_usage_full(&mut self, context_window: i64) { diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index 217889a72..cdb53ff0c 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -97,11 +97,10 @@ pub(crate) fn personality_message_for( } pub(crate) fn build_model_instructions_update_item( - previous: Option<&TurnContextItem>, - resumed_model: Option<&str>, + previous_user_turn_model: Option<&str>, next: &TurnContext, ) -> Option { - let previous_model = resumed_model.or_else(|| previous.map(|prev| prev.model.as_str()))?; + let previous_model = previous_user_turn_model?; if previous_model == next.model_info.slug { return None; } @@ -116,7 +115,7 @@ pub(crate) fn build_model_instructions_update_item( pub(crate) fn build_settings_update_items( previous: Option<&TurnContextItem>, - resumed_model: Option<&str>, + previous_user_turn_model: Option<&str>, next: &TurnContext, shell: &Shell, exec_policy: &Policy, @@ -124,6 +123,13 @@ pub(crate) fn build_settings_update_items( ) -> Vec { let mut update_items = Vec::new(); + // Keep model-switch instructions first so model-specific guidance is read before + // any other context diffs on this turn. + if let Some(model_instructions_item) = + build_model_instructions_update_item(previous_user_turn_model, next) + { + update_items.push(model_instructions_item); + } if let Some(env_item) = build_environment_update_item(previous, next, shell) { update_items.push(env_item); } @@ -133,11 +139,6 @@ pub(crate) fn build_settings_update_items( if let Some(collaboration_mode_item) = build_collaboration_mode_update_item(previous, next) { update_items.push(collaboration_mode_item); } - if let Some(model_instructions_item) = - build_model_instructions_update_item(previous, resumed_model, next) - { - update_items.push(model_instructions_item); - } if let Some(personality_item) = build_personality_update_item(previous, next, personality_feature_enabled) { diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 76c365ab5..160225174 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -21,12 +21,9 @@ pub(crate) struct SessionState { pub(crate) server_reasoning_included: bool, pub(crate) dependency_env: HashMap, pub(crate) mcp_dependency_prompted: HashSet, - /// Whether the session's initial context has been seeded into history. - /// - /// TODO(owen): This is a temporary solution to avoid updating a thread's updated_at - /// timestamp when resuming a session. Remove this once SQLite is in place. - pub(crate) initial_context_seeded: bool, - /// Previous model seen by the session, used for model-switch handling on task start. + /// Model used by the latest regular user turn, used for model-switch handling + /// on subsequent regular turns (including full-context reinjection after + /// resume or `/compact`). previous_model: Option, /// Startup regular task pre-created during session initialization. pub(crate) startup_regular_task: Option, @@ -45,7 +42,6 @@ impl SessionState { server_reasoning_included: false, dependency_env: HashMap::new(), mcp_dependency_prompted: HashSet::new(), - initial_context_seeded: false, previous_model: None, startup_regular_task: None, active_mcp_tool_selection: None, @@ -73,20 +69,26 @@ impl SessionState { self.history.clone() } - pub(crate) fn replace_history(&mut self, items: Vec) { + pub(crate) fn replace_history( + &mut self, + items: Vec, + reference_context_item: Option, + ) { self.history.replace(items); + self.history + .set_reference_context_item(reference_context_item); } pub(crate) fn set_token_info(&mut self, info: Option) { self.history.set_token_info(info); } - pub(crate) fn set_previous_context_item(&mut self, item: Option) { - self.history.set_previous_context_item(item); + pub(crate) fn set_reference_context_item(&mut self, item: Option) { + self.history.set_reference_context_item(item); } - pub(crate) fn previous_context_item(&self) -> Option { - self.history.previous_context_item() + pub(crate) fn reference_context_item(&self) -> Option { + self.history.reference_context_item() } // Token/rate limit helpers diff --git a/codex-rs/core/src/tasks/compact.rs b/codex-rs/core/src/tasks/compact.rs index b56f7b1df..db2a494c3 100644 --- a/codex-rs/core/src/tasks/compact.rs +++ b/codex-rs/core/src/tasks/compact.rs @@ -25,22 +25,21 @@ impl SessionTask for CompactTask { _cancellation_token: CancellationToken, ) -> Option { let session = session.clone_session(); - if crate::compact::should_use_remote_compact_task(&ctx.provider) { + let _ = 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; + crate::compact_remote::run_remote_compact_task(session.clone(), ctx).await } else { let _ = session.services.otel_manager.counter( "codex.task.compact", 1, &[("type", "local")], ); - let _ = crate::compact::run_compact_task(session, ctx, input).await; - } - + crate::compact::run_compact_task(session.clone(), ctx, input).await + }; None } } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index eb04619ee..861b18a52 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -121,8 +121,6 @@ impl Session { ) { self.abort_all_tasks(TurnAbortReason::Replaced).await; self.clear_connector_selection().await; - self.seed_initial_context_if_needed(turn_context.as_ref()) - .await; let task: Arc = Arc::new(task); let task_kind = task.kind(); @@ -140,7 +138,6 @@ impl Session { tokio::spawn( async move { let ctx_for_finish = Arc::clone(&ctx); - let model_slug = ctx_for_finish.model_info.slug.clone(); let last_agent_message = task_for_run .run( Arc::clone(&session_ctx), @@ -151,9 +148,6 @@ impl Session { .await; let sess = session_ctx.clone_session(); sess.flush_rollout().await; - // Update previous model before TurnComplete is emitted so - // immediately following turns observe the correct switch state. - sess.set_previous_model(Some(model_slug)).await; if !task_cancellation_token.is_cancelled() { // Emit completion uniformly from spawn site so all tasks share the same lifecycle. sess.on_task_finished(Arc::clone(&ctx_for_finish), last_agent_message) @@ -278,10 +272,6 @@ impl Session { task.handle.abort(); - // Set previous model even when interrupted so model-switch handling stays correct. - self.set_previous_model(Some(task.turn_context.model_info.slug.clone())) - .await; - let session_ctx = Arc::new(SessionTaskContext::new(Arc::clone(self))); session_task .abort(session_ctx, Arc::clone(&task.turn_context)) diff --git a/codex-rs/core/src/tasks/undo.rs b/codex-rs/core/src/tasks/undo.rs index bb3b6d315..b2fb4577a 100644 --- a/codex-rs/core/src/tasks/undo.rs +++ b/codex-rs/core/src/tasks/undo.rs @@ -101,7 +101,8 @@ impl SessionTask for UndoTask { match restore_result { Ok(Ok(())) => { items.remove(idx); - sess.replace_history(items).await; + let reference_context_item = sess.reference_context_item().await; + sess.replace_history(items, reference_context_item).await; let short_id: String = commit_id.chars().take(7).collect(); info!(commit_id = commit_id, "Undo restored ghost snapshot"); completed.success = true; diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index d356193f5..3e378fcd7 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -101,6 +101,10 @@ pub(crate) async fn execute_user_shell_command( // Auxiliary mode runs within an existing active turn. That turn already // emitted TurnStarted, so emitting another TurnStarted here would create // duplicate turn lifecycle events and confuse clients. + // TODO(ccunningham): After TurnStarted, emit model-visible turn context diffs for + // standalone lifecycle tasks (for example /shell, and review once it emits TurnStarted). + // `/compact` is an intentional exception because compaction requests should not include + // freshly reinjected context before the summary/replacement history is applied. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), model_context_window: turn_context.model_context_window(), diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index 702e13f81..649d1cf0b 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -867,7 +867,7 @@ pub async fn mount_compact_json_once(server: &MockServer, body: serde_json::Valu /// Mount a `/responses/compact` mock that mirrors the default remote compaction shape: /// keep user+developer messages from the request, drop assistant/tool artifacts, and append one -/// summary user message. +/// compaction item carrying the provided summary text. pub async fn mount_compact_user_history_with_summary_once( server: &MockServer, summary_text: &str, @@ -911,6 +911,9 @@ pub async fn mount_compact_user_history_with_summary_sequence( .cloned() .unwrap_or_default() .into_iter() + // TODO(ccunningham): Update this mock to match future compaction model behavior: + // return user/developer/assistant messages since the last compaction item, then + // append a single newest compaction item. // Match current remote compaction behavior: keep user/developer messages and // omit assistant/tool history entries. .filter(|item| { @@ -921,11 +924,10 @@ pub async fn mount_compact_user_history_with_summary_sequence( ) }) .collect::>(); - // Append the synthetic summary message as the newest user item. + // Append a synthetic compaction item as the newest item. output.push(serde_json::json!({ - "type": "message", - "role": "user", - "content": [{"type": "input_text", "text": summary_text}], + "type": "compaction", + "encrypted_content": summary_text, })); ResponseTemplate::new(200) .insert_header("content-type", "application/json") diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 08850ec38..c4745ab20 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -363,7 +363,7 @@ async fn summarize_context_three_requests_and_instructions() { codex.submit(Op::Shutdown).await.unwrap(); wait_for_event(&codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await; - // Verify rollout contains APITurn entries for each API call and a Compacted entry. + // Verify rollout contains regular sampling TurnContext entries and a Compacted entry. println!("rollout path: {}", rollout_path.display()); let text = std::fs::read_to_string(&rollout_path).unwrap_or_else(|e| { panic!( @@ -371,7 +371,7 @@ async fn summarize_context_three_requests_and_instructions() { rollout_path.display() ) }); - let mut api_turn_count = 0usize; + let mut regular_turn_context_count = 0usize; let mut saw_compacted_summary = false; for line in text.lines() { let trimmed = line.trim(); @@ -383,7 +383,7 @@ async fn summarize_context_three_requests_and_instructions() { }; match entry.item { RolloutItem::TurnContext(_) => { - api_turn_count += 1; + regular_turn_context_count += 1; } RolloutItem::Compacted(ci) => { if ci.message == expected_summary_message { @@ -395,8 +395,8 @@ async fn summarize_context_three_requests_and_instructions() { } assert!( - api_turn_count == 3, - "expected three APITurn entries in rollout" + regular_turn_context_count == 2, + "expected two regular sampling TurnContext entries in rollout" ); assert!( saw_compacted_summary, @@ -2414,26 +2414,36 @@ async fn manual_compact_twice_preserves_latest_user_messages() { 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 initial_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:?}" + !initial_seeded_user_prefix.is_empty(), + "first turn should include seeded user prefix before the submitted user message" ); - let final_output = &final_request_user_texts[seeded_user_prefix.len()..]; - let expected = vec![ + let (final_request_last_user_text, final_request_before_last_user) = final_request_user_texts + .split_last() + .unwrap_or_else(|| panic!("final turn request missing user messages")); + assert_eq!( + final_request_last_user_text, final_user_message, + "final turn request should end with the submitted user message" + ); + let history_before_seeded_prefix = final_request_before_last_user + .strip_suffix(initial_seeded_user_prefix) + .unwrap_or_else(|| { + panic!( + "final request should end with the seeded user prefix from the first request: {initial_seeded_user_prefix:?}" + ) + }); + let expected_history = 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()); + assert_eq!(history_before_seeded_prefix, expected_history.as_slice()); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index c9acde41a..6baccc16f 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -93,12 +93,9 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { ) .await; - let compacted_history = vec![ - responses::user_message_item("REMOTE_COMPACTED_SUMMARY"), - ResponseItem::Compaction { - encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), - }, - ]; + let compacted_history = vec![ResponseItem::Compaction { + encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), + }]; let compact_mock = responses::mount_compact_json_once( harness.server(), serde_json::json!({ "output": compacted_history.clone() }), @@ -159,7 +156,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { let follow_up_request = response_requests.last().expect("follow-up request missing"); let follow_up_body = follow_up_request.body_json().to_string(); assert!( - follow_up_body.contains("REMOTE_COMPACTED_SUMMARY"), + follow_up_body.contains("\"type\":\"compaction\""), "expected follow-up request to use compacted history" ); assert!( @@ -178,7 +175,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { insta::assert_snapshot!( "remote_manual_compact_with_history_shapes", format_labeled_requests_snapshot( - "Remote manual /compact where remote compact output is summary-only: follow-up layout uses returned summary plus new user message.", + "Remote manual /compact where remote compact output is compaction-only: follow-up layout uses the returned compaction item plus new user message.", &[ ("Remote Compaction Request", &compact_request), ("Remote Post-Compaction History Layout", follow_up_request), @@ -958,7 +955,6 @@ async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()> .await; let compacted_history = vec![ - responses::user_message_item("COMPACTED_USER_SUMMARY"), ResponseItem::Compaction { encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), }, @@ -1012,17 +1008,6 @@ async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()> && compacted.message.is_empty() && let Some(replacement_history) = compacted.replacement_history.as_ref() { - let has_compacted_user_summary = replacement_history.iter().any(|item| { - matches!( - item, - ResponseItem::Message { role, content, .. } - if role == "user" - && content.iter().any(|part| matches!( - part, - ContentItem::InputText { text } if text == "COMPACTED_USER_SUMMARY" - )) - ) - }); let has_compaction_item = replacement_history.iter().any(|item| { matches!( item, @@ -1054,7 +1039,7 @@ async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()> ) }); - if has_compacted_user_summary && has_compaction_item && has_compacted_assistant_note { + if has_compaction_item && has_compacted_assistant_note { assert!( !has_permissions_developer_message, "manual remote compact rollout replacement history should not inject permissions context" @@ -1110,7 +1095,6 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res .await; let compacted_history = vec![ - responses::user_message_item("REMOTE_COMPACTED_SUMMARY"), ResponseItem::Message { id: None, role: "developer".to_string(), @@ -1196,8 +1180,8 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res "fresh developer instructions should be present after compaction" ); assert!( - after_compact_body.contains("REMOTE_COMPACTED_SUMMARY"), - "compacted summary should be present after compaction" + after_compact_body.contains("ENCRYPTED_COMPACTION_SUMMARY"), + "compaction item should be present after compaction" ); let after_resume_body = after_resume_request.body_json().to_string(); @@ -1210,8 +1194,8 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res "fresh developer instructions should be present after resume" ); assert!( - after_resume_body.contains("REMOTE_COMPACTED_SUMMARY"), - "compacted summary should persist after resume" + after_resume_body.contains("ENCRYPTED_COMPACTION_SUMMARY"), + "compaction item should persist after resume" ); Ok(()) @@ -1243,7 +1227,6 @@ async fn remote_compact_refreshes_stale_developer_instructions_without_resume() .await; let compacted_history = vec![ - responses::user_message_item("REMOTE_COMPACTED_SUMMARY"), ResponseItem::Message { id: None, role: "developer".to_string(), @@ -1302,8 +1285,8 @@ async fn remote_compact_refreshes_stale_developer_instructions_without_resume() "fresh developer instructions should be present after compaction" ); assert!( - after_compact_body.contains("REMOTE_COMPACTED_SUMMARY"), - "compacted summary should be present after compaction" + after_compact_body.contains("ENCRYPTED_COMPACTION_SUMMARY"), + "compaction item should be present after compaction" ); Ok(()) @@ -1706,7 +1689,7 @@ async fn snapshot_request_shape_remote_mid_turn_continuation_compaction() -> Res insta::assert_snapshot!( "remote_mid_turn_compaction_shapes", format_labeled_requests_snapshot( - "Remote mid-turn continuation compaction after tool output: compact request includes tool artifacts and follow-up request includes the summary.", + "Remote mid-turn continuation compaction after tool output: compact request includes tool artifacts and the follow-up request includes the returned compaction item.", &[ ("Remote Compaction Request", &compact_request), ("Remote Post-Compaction History Layout", &requests[1]), @@ -1749,9 +1732,9 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_summary_only_reinject ) .await; - let compacted_history = vec![responses::user_message_item(&summary_with_prefix( - "REMOTE_SUMMARY_ONLY", - ))]; + let compacted_history = vec![ResponseItem::Compaction { + encrypted_content: summary_with_prefix("REMOTE_SUMMARY_ONLY"), + }]; let compact_mock = responses::mount_compact_json_once( harness.server(), serde_json::json!({ "output": compacted_history }), @@ -1786,7 +1769,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_summary_only_reinject insta::assert_snapshot!( "remote_mid_turn_compaction_summary_only_reinjects_context_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 a compaction item: continuation layout reinjects context before that compaction item.", &[ ("Remote Compaction Request", &compact_request), ( @@ -1893,13 +1876,13 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_reinjec insta::assert_snapshot!( "remote_mid_turn_compaction_multi_summary_reinjects_above_last_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.", + "After a prior manual /compact produced an older remote compaction item, the next turn hits remote auto-compaction before the next sampling request. The compact request carries forward that earlier compaction item, and the next sampling request shows the latest compaction item with context reinjected before USER_TWO.", &[ + ("Remote Compaction Request", &compact_request), ( - "Second Turn Request (Before Mid-Turn Compaction)", + "Second Turn Request (After Compaction)", &second_turn_request ), - ("Remote Compaction Request", &compact_request), ] ) ); diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index fc77a2621..b66edb84a 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -81,25 +81,32 @@ fn normalize_line_endings_str(text: &str) -> String { } } -fn extract_summary_message(request: &Value, summary_text: &str) -> Value { +fn extract_summary_user_text(request: &Value, summary_text: &str) -> String { + json_message_input_texts(request, "user") + .into_iter() + .find(|text| text.contains(summary_text)) + .unwrap_or_else(|| panic!("expected summary message {summary_text}")) +} + +fn json_message_input_texts(request: &Value, role: &str) -> Vec { request .get("input") .and_then(Value::as_array) - .and_then(|items| { - items.iter().find(|item| { - item.get("type").and_then(Value::as_str) == Some("message") - && item.get("role").and_then(Value::as_str) == Some("user") - && item - .get("content") - .and_then(Value::as_array) - .and_then(|arr| arr.first()) - .and_then(|entry| entry.get("text")) - .and_then(Value::as_str) - .is_some_and(|text| text.contains(summary_text)) - }) + .into_iter() + .flatten() + .filter(|item| { + item.get("type").and_then(Value::as_str) == Some("message") + && item.get("role").and_then(Value::as_str) == Some(role) }) - .cloned() - .unwrap_or_else(|| panic!("expected summary message {summary_text}")) + .filter_map(|item| { + item.get("content") + .and_then(Value::as_array) + .and_then(|content| content.first()) + .and_then(|entry| entry.get("text")) + .and_then(Value::as_str) + .map(str::to_string) + }) + .collect() } fn normalize_compact_prompts(requests: &mut [Value]) { @@ -200,459 +207,88 @@ 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()); - let prompt_cache_key = requests[0]["prompt_cache_key"] - .as_str() - .unwrap_or_default() - .to_string(); - let fork_prompt_cache_key = requests[requests.len() - 1]["prompt_cache_key"] - .as_str() - .unwrap_or_default() - .to_string(); - 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" - } - ] + let first_request_user_texts = json_message_input_texts(&requests[0], "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], + "hello world" + ); + let seeded_user_prefix = &first_request_user_texts[..first_turn_user_index]; + let summary_after_compact = extract_summary_user_text(&requests[2], SUMMARY_TEXT); + let summary_after_resume = extract_summary_user_text(&requests[3], SUMMARY_TEXT); + let summary_after_fork = extract_summary_user_text(&requests[4], SUMMARY_TEXT); + let mut expected_after_compact_user_texts = + vec!["hello world".to_string(), summary_after_compact]; + expected_after_compact_user_texts.extend_from_slice(seeded_user_prefix); + expected_after_compact_user_texts.push("AFTER_COMPACT".to_string()); + assert_eq!( + json_message_input_texts(&requests[2], "user"), + expected_after_compact_user_texts + ); + + let mut expected_after_resume_user_texts = + vec!["hello world".to_string(), summary_after_resume]; + expected_after_resume_user_texts.extend_from_slice(seeded_user_prefix); + expected_after_resume_user_texts.push("AFTER_COMPACT".to_string()); + let after_resume_user_texts = json_message_input_texts(&requests[3], "user"); + let (after_resume_last, after_resume_prefix) = after_resume_user_texts + .split_last() + .unwrap_or_else(|| panic!("after-resume request missing user messages")); + assert_eq!(after_resume_last, "AFTER_RESUME"); + assert!( + after_resume_prefix.starts_with(&expected_after_resume_user_texts), + "after-resume user texts should preserve compacted history prefix" + ); + let after_resume_seeded_suffix = &after_resume_prefix[expected_after_resume_user_texts.len()..]; + if seeded_user_prefix.is_empty() { + assert!( + after_resume_seeded_suffix.is_empty(), + "after-resume request should not append unexpected user prefix items" + ); + } else { + let mut chunks = after_resume_seeded_suffix.chunks_exact(seeded_user_prefix.len()); + assert!( + chunks.remainder().is_empty(), + "after-resume suffix should be whole seeded-prefix repeats" + ); + for chunk in &mut chunks { + assert_eq!(chunk, seeded_user_prefix); } - ], - "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 - } - ] + } + + let after_fork_user_texts = json_message_input_texts(&requests[4], "user"); + let mut expected_after_fork_history_prefix = + vec!["hello world".to_string(), summary_after_fork]; + expected_after_fork_history_prefix.extend_from_slice(seeded_user_prefix); + expected_after_fork_history_prefix.push("AFTER_COMPACT".to_string()); + let (after_fork_last, after_fork_prefix) = after_fork_user_texts + .split_last() + .unwrap_or_else(|| panic!("after-fork request missing user messages")); + assert_eq!(after_fork_last, "AFTER_FORK"); + assert!( + after_fork_prefix.starts_with(&expected_after_fork_history_prefix), + "after-fork user texts should preserve compacted user history prefix" + ); + let after_fork_seeded_suffix = &after_fork_prefix[expected_after_fork_history_prefix.len()..]; + if seeded_user_prefix.is_empty() { + assert!( + after_fork_seeded_suffix.is_empty(), + "after-fork request should not append unexpected user prefix items" + ); + } else { + let mut chunks = after_fork_seeded_suffix.chunks_exact(seeded_user_prefix.len()); + assert!( + chunks.remainder().is_empty(), + "after-fork suffix should be whole seeded-prefix repeats" + ); + for chunk in &mut chunks { + assert_eq!(chunk, seeded_user_prefix); } - ], - "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); } assert_eq!(requests.len(), 5); - assert_eq!(json!(requests), expected); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -725,118 +361,47 @@ 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. + let first_request_user_texts = json_message_input_texts(&requests[0], "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], + "hello world" + ); + let seeded_user_prefix = &first_request_user_texts[..first_turn_user_index]; 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); + extract_summary_user_text(&requests[requests.len() - 3], SUMMARY_TEXT); + let mut expected_after_second_compact_user_texts = + vec!["AFTER_FORK".to_string(), summary_after_second_compact]; + expected_after_second_compact_user_texts.extend_from_slice(seeded_user_prefix); + expected_after_second_compact_user_texts.push("AFTER_COMPACT_2".to_string()); + let final_user_texts = json_message_input_texts(&requests[requests.len() - 1], "user"); + let (final_last, final_prefix) = final_user_texts + .split_last() + .unwrap_or_else(|| panic!("after-second-resume request missing user messages")); + assert_eq!(final_last, AFTER_SECOND_RESUME); + assert!( + final_prefix.starts_with(&expected_after_second_compact_user_texts), + "after-second-resume user texts should preserve post-compact user history prefix" + ); + let final_seeded_suffix = &final_prefix[expected_after_second_compact_user_texts.len()..]; + if seeded_user_prefix.is_empty() { + assert!( + final_seeded_suffix.is_empty(), + "after-second-resume request should not append unexpected user prefix items" + ); + } else { + let mut chunks = final_seeded_suffix.chunks_exact(seeded_user_prefix.len()); + assert!( + chunks.remainder().is_empty(), + "after-second-resume suffix should be whole seeded-prefix repeats" + ); + for chunk in &mut chunks { + assert_eq!(chunk, seeded_user_prefix); + } } - if let Some(arr) = last_request_after_2_compacts.as_array_mut() { - normalize_compact_prompts(arr); - } - assert_eq!(expected, last_request_after_2_compacts); } fn normalize_line_endings(value: &mut Value) { diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index eda1b8915..f3daf06c8 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -308,6 +308,8 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul config.personality = Some(Personality::Pragmatic); }); let resumed = resume_builder.resume(&server, home, rollout_path).await?; + let resume_override_cwd = resumed.cwd_path().join(PRETURN_CONTEXT_DIFF_CWD); + fs::create_dir_all(&resume_override_cwd)?; resumed .codex .submit(Op::UserTurn { @@ -316,7 +318,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: resumed.cwd_path().to_path_buf(), + cwd: resume_override_cwd, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: resumed.session_configured.model.clone(), @@ -398,10 +400,12 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() - config.model = Some("gpt-5.2-codex".to_string()); }); let resumed = resume_builder.resume(&server, home, rollout_path).await?; + let resume_override_cwd = resumed.cwd_path().join(PRETURN_CONTEXT_DIFF_CWD); + fs::create_dir_all(&resume_override_cwd)?; resumed .codex .submit(Op::OverrideTurnContext { - cwd: None, + cwd: Some(resume_override_cwd), approval_policy: None, sandbox_policy: None, windows_sandbox_level: None, diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index cd3b4272a..351d1d575 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -672,12 +672,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res }); let expected_permissions_msg = body1["input"][0].clone(); let body1_input = body1["input"].as_array().expect("input array"); - let expected_permissions_msg_2 = body2["input"][body1_input.len() + 1].clone(); - assert_ne!( - expected_permissions_msg_2, expected_permissions_msg, - "expected updated permissions message after per-turn override" - ); - let expected_model_switch_msg = body2["input"][body1_input.len() + 2].clone(); + let expected_model_switch_msg = body2["input"][body1_input.len()].clone(); assert_eq!( expected_model_switch_msg["role"].as_str(), Some("developer") @@ -688,10 +683,15 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res .is_some_and(|text| text.contains("")), "expected model switch message after model override: {expected_model_switch_msg:?}" ); + let expected_permissions_msg_2 = body2["input"][body1_input.len() + 2].clone(); + assert_ne!( + expected_permissions_msg_2, expected_permissions_msg, + "expected updated permissions message after per-turn override" + ); let mut expected_body2 = body1_input.to_vec(); + expected_body2.push(expected_model_switch_msg); expected_body2.push(expected_env_msg_2); expected_body2.push(expected_permissions_msg_2); - expected_body2.push(expected_model_switch_msg); expected_body2.push(expected_user_message_2); assert_eq!(body2["input"], serde_json::Value::Array(expected_body2)); @@ -900,12 +900,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu assert_eq!(body1["input"], expected_input_1); let body1_input = body1["input"].as_array().expect("input array"); - let expected_permissions_msg_2 = body2["input"][body1_input.len()].clone(); - assert_ne!( - expected_permissions_msg_2, expected_permissions_msg, - "expected updated permissions message after policy change" - ); - let expected_model_switch_msg = body2["input"][body1_input.len() + 1].clone(); + let expected_model_switch_msg = body2["input"][body1_input.len()].clone(); assert_eq!( expected_model_switch_msg["role"].as_str(), Some("developer") @@ -916,14 +911,19 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu .is_some_and(|text| text.contains("")), "expected model switch message after model override: {expected_model_switch_msg:?}" ); + let expected_permissions_msg_2 = body2["input"][body1_input.len() + 1].clone(); + assert_ne!( + expected_permissions_msg_2, expected_permissions_msg, + "expected updated permissions message after policy change" + ); let expected_user_message_2 = text_user_input("hello 2".to_string()); let expected_input_2 = serde_json::Value::Array(vec![ expected_permissions_msg, expected_ui_msg, expected_env_msg_1, expected_user_message_1, - expected_permissions_msg_2, expected_model_switch_msg, + expected_permissions_msg_2, expected_user_message_2, ]); assert_eq!(body2["input"], expected_input_2); diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 61726f913..89a08b9e5 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -6,9 +6,13 @@ use codex_core::protocol::EventMsg; use codex_core::protocol::InitialHistory; use codex_core::protocol::ResumedHistory; use codex_core::protocol::RolloutItem; +use codex_core::protocol::TurnCompleteEvent; use codex_core::protocol::TurnContextItem; +use codex_core::protocol::TurnStartedEvent; +use codex_core::protocol::UserMessageEvent; use codex_core::protocol::WarningEvent; use codex_protocol::ThreadId; +use codex_protocol::config_types::ModeKind; use core::time::Duration; use core_test_support::load_default_config_for_test; use core_test_support::wait_for_event; @@ -19,8 +23,9 @@ fn resume_history( previous_model: &str, rollout_path: &std::path::Path, ) -> InitialHistory { + let turn_id = "resume-warning-seed-turn".to_string(); let turn_ctx = TurnContextItem { - turn_id: None, + turn_id: Some(turn_id.clone()), cwd: config.cwd.clone(), approval_policy: config.permissions.approval_policy.value(), sandbox_policy: config.permissions.sandbox_policy.get().clone(), @@ -38,7 +43,24 @@ fn resume_history( InitialHistory::Resumed(ResumedHistory { conversation_id: ThreadId::default(), - history: vec![RolloutItem::TurnContext(turn_ctx)], + history: vec![ + RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { + turn_id: turn_id.clone(), + model_context_window: None, + collaboration_mode_kind: ModeKind::Default, + })), + RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + message: "seed".to_string(), + images: None, + local_images: vec![], + text_elements: vec![], + })), + RolloutItem::TurnContext(turn_ctx), + RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + turn_id, + last_agent_message: None, + })), + ], rollout_path: rollout_path.to_path_buf(), }) } 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 bcca5d357..c88ff3312 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 bdb4fe9ba..0c8f87d04 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_without_prev_user_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_without_prev_user_shapes.snap @@ -11,8 +11,8 @@ Scenario: Manual /compact with no prior user turn currently still issues a compa 03:message/user: ## Local Post-Compaction History Layout -00:message/developer: -01:message/user: -02:message/user:> -03:message/user:\nMANUAL_EMPTY_SUMMARY +00:message/user:\nMANUAL_EMPTY_SUMMARY +01:message/developer: +02:message/user: +03:message/user:> 04:message/user:AFTER_MANUAL_EMPTY_COMPACT diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_sampling_model_switch_compaction_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_sampling_model_switch_compaction_shapes.snap index 8d6c4d9b1..552c117c9 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. @@ -22,10 +21,10 @@ Scenario: Pre-sampling compaction on model switch to a smaller context window: c 06:message/user: ## Post-Compaction Follow-up Request (Next Model) -00:message/developer: -01:message/user: -02:message/user:> -03:message/user:before switch -04:message/user:\nPRE_SAMPLING_SUMMARY -05:message/developer:\nThe user was previously using a different model.... +00:message/user:before switch +01:message/user:\nPRE_SAMPLING_SUMMARY +02:message/developer:\nThe user was previously using a different model.... +03:message/developer: +04:message/user: +05:message/user:> 06:message/user:after switch diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap index e586c8521..ea4fea2f2 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 @@ -12,14 +12,13 @@ Scenario: Pre-turn auto-compaction with a context override emits the context dif 04:message/assistant:FIRST_REPLY 05:message/user:USER_TWO 06:message/assistant:SECOND_REPLY -07:message/user: -08:message/user: +07: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 4d67af911..59d3785a4 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,5 @@ --- source: core/tests/suite/compact.rs -assertion_line: 3152 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. @@ -22,11 +21,11 @@ Scenario: Pre-turn compaction during model switch (without pre-sampling model-sw 06: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 bc795a19f..701e67676 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 @@ -1,9 +1,8 @@ --- source: core/tests/suite/compact_remote.rs -assertion_line: 178 -expression: "format_labeled_requests_snapshot(\"Remote manual /compact where remote compact output is summary-only: follow-up layout uses returned summary plus 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 where remote compact output is compaction-only: follow-up layout uses the returned compaction item plus new user message.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", follow_up_request),])" --- -Scenario: Remote manual /compact where remote compact output is summary-only: follow-up layout uses returned summary plus new user message. +Scenario: Remote manual /compact where remote compact output is compaction-only: follow-up layout uses the returned compaction item plus new user message. ## Remote Compaction Request 00:message/developer: @@ -13,9 +12,8 @@ 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 -05:message/user:after compact +00:compaction:encrypted=true +01:message/developer: +02:message/user: +03:message/user:> +04:message/user:after compact 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_reinjects_above_last_summary_shapes.snap index 1f51f9654..7d5bcde90 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_reinjects_above_last_summary_shapes.snap @@ -1,21 +1,18 @@ --- 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: 1876 +expression: "format_labeled_requests_snapshot(\"After a prior manual /compact produced an older remote compaction item, the next turn hits remote auto-compaction before the next sampling request. The compact request carries forward that earlier compaction item, and the next sampling request shows the latest compaction item with context reinjected before USER_TWO.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Second Turn Request (After Compaction)\", &second_turn_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 -06:message/user:USER_TWO +Scenario: After a prior manual /compact produced an older remote compaction item, the next turn hits remote auto-compaction before the next sampling request. The compact request carries forward that earlier compaction item, and the next sampling request shows the latest compaction item with context reinjected before 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:compaction:encrypted=true + +## Second Turn Request (After Compaction) +00:message/user:USER_ONE +01:compaction:encrypted=true +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 8ef870167..f5533b94a 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,8 +1,8 @@ --- source: core/tests/suite/compact_remote.rs -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]),])" +expression: "format_labeled_requests_snapshot(\"Remote mid-turn continuation compaction after tool output: compact request includes tool artifacts and the follow-up request includes the returned compaction item.\",\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. +Scenario: Remote mid-turn continuation compaction after tool output: compact request includes tool artifacts and the follow-up request includes the returned compaction item. ## Remote Compaction Request 00:message/developer: @@ -13,8 +13,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:> -04:message/user:\nREMOTE_MID_TURN_SUMMARY +00:message/developer: +01:message/user: +02:message/user:> +03:message/user:USER_ONE +04:compaction:encrypted=true 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_reinjects_context_shapes.snap index 4ede5c3ca..3f8c77e97 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_reinjects_context_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 a compaction item: continuation layout reinjects context before that compaction item.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &requests[1]),])" --- -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 a compaction item: continuation layout reinjects context before that compaction item. ## Remote Compaction Request 00:message/developer: @@ -16,4 +16,4 @@ Scenario: Remote mid-turn compaction where compact output has only summary user 00:message/developer: 01:message/user: 02:message/user:> -03:message/user:\nREMOTE_SUMMARY_ONLY +03:compaction:encrypted=true 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 00aaaeaa9..2236984a6 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 @@ -12,13 +12,12 @@ Scenario: Remote pre-turn auto-compaction with a context override emits the cont 04:message/assistant:REMOTE_FIRST_REPLY 05:message/user:USER_TWO 06:message/assistant:REMOTE_SECOND_REPLY -07:message/user: ## 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:compaction:encrypted=true +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 fba17d719..15beec5f2 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,6 +1,6 @@ --- 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]),])" +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)\", &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. @@ -19,10 +19,10 @@ Scenario: Remote pre-turn compaction during model switch currently excludes inco ## 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:compaction:encrypted=true +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__model_visible_layout__model_visible_layout_resume_override_matches_rollout_model.snap b/codex-rs/core/tests/suite/snapshots/all__suite__model_visible_layout__model_visible_layout_resume_override_matches_rollout_model.snap index 850b6be0a..06269eff7 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__model_visible_layout__model_visible_layout_resume_override_matches_rollout_model.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__model_visible_layout__model_visible_layout_resume_override_matches_rollout_model.snap @@ -1,5 +1,6 @@ --- source: core/tests/suite/model_visible_layout.rs +assertion_line: 435 expression: "format_labeled_requests_snapshot(\"First post-resume turn where pre-turn override sets model to rollout model; no model-switch update should appear.\",\n&[(\"Last Request Before Resume\", &initial_request),\n(\"First Request After Resume + Override\", &resumed_request),])" --- Scenario: First post-resume turn where pre-turn override sets model to rollout model; no model-switch update should appear. @@ -16,7 +17,5 @@ Scenario: First post-resume turn where pre-turn override sets model to rollout m 02:message/user:> 03:message/user:seed resume history 04:message/assistant:recorded before resume -05:message/developer: -06:message/user: -07:message/user:> -08:message/user:first resumed turn after model override +05:message/user: +06:message/user:first resumed turn after model override diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__model_visible_layout__model_visible_layout_resume_with_personality_change.snap b/codex-rs/core/tests/suite/snapshots/all__suite__model_visible_layout__model_visible_layout_resume_with_personality_change.snap index a37cd885e..54417c6de 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__model_visible_layout__model_visible_layout_resume_with_personality_change.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__model_visible_layout__model_visible_layout_resume_with_personality_change.snap @@ -1,5 +1,6 @@ --- source: core/tests/suite/model_visible_layout.rs +assertion_line: 337 expression: "format_labeled_requests_snapshot(\"First post-resume turn where resumed config model differs from rollout and personality changes.\",\n&[(\"Last Request Before Resume\", &initial_request),\n(\"First Request After Resume\", &resumed_request),])" --- Scenario: First post-resume turn where resumed config model differs from rollout and personality changes. @@ -16,11 +17,7 @@ Scenario: First post-resume turn where resumed config model differs from rollout 02:message/user:> 03:message/user:seed resume history 04:message/assistant:recorded before resume -05:message/developer: -06:message/developer: The user has requested a new communication style. Future messages should adhe... -07:message/user: -08:message/user:> -09:message/developer: -10:message/developer:\nThe user was previously using a different model. Please continue the conversatio... -11:message/developer: The user has requested a new communication style. Future messages should adhe... -12:message/user:resume and change personality +05:message/developer:\nThe user was previously using a different model. Please continue the conversatio... +06:message/user: +07:message/developer: +08:message/user:resume and change personality diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 86a124865..a8b7ade16 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2088,6 +2088,10 @@ pub struct TurnContextNetworkItem { pub denied_domains: Vec, } +/// Persist only when the same turn also persists the corresponding +/// model-visible context updates (diffs or full reinjection), so +/// resume/fork does not use a `reference_context_item` whose context +/// was never actually visible to the model. #[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, TS)] pub struct TurnContextItem { #[serde(default, skip_serializing_if = "Option::is_none")]