diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index be891a4d35..5fa3ff1f67 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -5990,6 +5990,20 @@ struct PreTurnInlineCompactionState { turn_context_item: TurnContextItem, } +fn collect_new_ghost_snapshots_since( + history_before_turn: &[ResponseItem], + current_history: &[ResponseItem], +) -> Vec { + current_history + .iter() + .filter(|item| { + matches!(item, ResponseItem::GhostSnapshot { .. }) + && !history_before_turn.contains(item) + }) + .cloned() + .collect() +} + fn record_compaction_metric( sess: &Session, mode: &'static str, @@ -6127,8 +6141,17 @@ async fn downgrade_known_inline_compaction_error( let Some(preturn_state) = preturn_state else { return Ok(false); }; + // Preserve same-turn ghost snapshots that may have completed after + // the pre-turn baseline was captured so `/undo` still works after + // we downgrade to the legacy compaction path. + let current_history = sess.clone_history().await; + let mut restored_history = preturn_state.history_before_turn.clone(); + restored_history.extend(collect_new_ghost_snapshots_since( + &preturn_state.history_before_turn, + current_history.raw_items(), + )); sess.replace_history( - preturn_state.history_before_turn.clone(), + restored_history, preturn_state.reference_context_before_turn.clone(), ) .await; diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 2d627671d7..c6be5cbf46 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -55,6 +55,7 @@ use crate::tools::registry::ToolHandler; use crate::tools::router::ToolCallSource; use crate::turn_diff_tracker::TurnDiffTracker; use codex_app_server_protocol::AppInfo; +use codex_git::GhostCommit; use codex_otel::TelemetryAuthMode; use codex_protocol::models::BaseInstructions; use codex_protocol::models::ContentItem; @@ -136,6 +137,12 @@ fn skill_message(text: &str) -> ResponseItem { } } +fn ghost_snapshot(id: &str) -> ResponseItem { + ResponseItem::GhostSnapshot { + ghost_commit: GhostCommit::new(id.to_string(), None, Vec::new(), Vec::new()), + } +} + fn developer_input_texts(items: &[ResponseItem]) -> Vec<&str> { items .iter() @@ -253,6 +260,24 @@ fn assistant_message_stream_parsers_seed_plan_parser_across_added_and_delta_boun assert!(tail.plan_segments.is_empty()); } +#[test] +fn collect_new_ghost_snapshots_since_returns_only_snapshots_added_after_turn_start() { + let prior_snapshot = ghost_snapshot("ghost-before"); + let same_turn_snapshot = ghost_snapshot("ghost-during"); + let history_before_turn = vec![user_message("earlier"), prior_snapshot.clone()]; + let current_history = vec![ + user_message("earlier"), + prior_snapshot, + user_message("current turn"), + assistant_message("in progress"), + same_turn_snapshot.clone(), + ]; + + let new_snapshots = collect_new_ghost_snapshots_since(&history_before_turn, ¤t_history); + + assert_eq!(new_snapshots, vec![same_turn_snapshot]); +} + fn make_mcp_tool( server_name: &str, tool_name: &str,