Remove ghost snapshots (#19481)

## Summary
- Remove `ghost_snapshot` / `GhostCommit` from the Responses API surface
and generated SDK/schema artifacts.
- Keep legacy config loading compatible, but make undo a no-op that
reports the feature is unavailable.
- Clean up core history, compaction, telemetry, rollout, and tests to
stop carrying ghost snapshot items.

## Testing
- Unit tests passed for `codex-protocol`, `codex-core` targeted undo and
compaction flows, `codex-rollout`, and `codex-app-server-protocol`.
- Regenerated config and app-server schemas plus Python SDK artifacts
and verified they match the checked-in outputs.
This commit is contained in:
pakrym-oai
2026-04-27 18:48:57 -07:00
committed by GitHub
Unverified
parent 7e8594fc19
commit 4e05f3053c
43 changed files with 305 additions and 3254 deletions
+4 -4
View File
@@ -766,16 +766,16 @@
"additionalProperties": false,
"properties": {
"disable_warnings": {
"description": "Disable all ghost snapshot warning events.",
"description": "Legacy no-op setting retained for compatibility.",
"type": "boolean"
},
"ignore_large_untracked_dirs": {
"description": "Ignore untracked directories that contain this many files or more. (Still emits a warning unless warnings are disabled.)",
"description": "Legacy no-op setting retained for compatibility.",
"format": "int64",
"type": "integer"
},
"ignore_large_untracked_files": {
"description": "Exclude untracked files larger than this many bytes from ghost snapshots.",
"description": "Legacy no-op setting retained for compatibility.",
"format": "int64",
"type": "integer"
}
@@ -2824,7 +2824,7 @@
}
],
"default": null,
"description": "Settings for ghost snapshots (used for undo)."
"description": "Compatibility-only settings retained so legacy `ghost_snapshot` config still loads."
},
"hide_agent_reasoning": {
"description": "When set to `true`, `AgentReasoning` events will be hidden from the UI/output. Defaults to `false`.",
-1
View File
@@ -114,7 +114,6 @@ fn keep_forked_rollout_item(item: &RolloutItem) -> bool {
| ResponseItem::ToolSearchOutput { .. }
| ResponseItem::WebSearchCall { .. }
| ResponseItem::ImageGenerationCall { .. }
| ResponseItem::GhostSnapshot { .. }
| ResponseItem::Compaction { .. }
| ResponseItem::Other,
) => false,
-1
View File
@@ -383,7 +383,6 @@ fn build_arc_monitor_message_item(
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::ToolSearchOutput { .. }
| ResponseItem::ImageGenerationCall { .. }
| ResponseItem::GhostSnapshot { .. }
| ResponseItem::Compaction { .. }
| ResponseItem::Other => None,
}
-6
View File
@@ -265,12 +265,6 @@ async fn run_compact_task_inner_impl(
new_history =
insert_initial_context_before_last_real_user_or_summary(new_history, initial_context);
}
let ghost_snapshots: Vec<ResponseItem> = history_items
.iter()
.filter(|item| matches!(item, ResponseItem::GhostSnapshot { .. }))
.cloned()
.collect();
new_history.extend(ghost_snapshots);
let reference_context_item = match initial_context_injection {
InitialContextInjection::DoNotInject => None,
InitialContextInjection::BeforeLastUserMessage => Some(turn_context.to_turn_context_item()),
-12
View File
@@ -145,14 +145,6 @@ async fn run_remote_compact_task_inner_impl(
// compact endpoint. The checkpoint below records it separately from the next sampling request,
// whose prompt will repeat current developer/context prefix items.
let trace_input_history = history.raw_items().to_vec();
// Required to keep `/undo` available after compaction
let ghost_snapshots: Vec<ResponseItem> = history
.raw_items()
.iter()
.filter(|item| matches!(item, ResponseItem::GhostSnapshot { .. }))
.cloned()
.collect();
let prompt_input = history.for_prompt(&turn_context.model_info.input_modalities);
let tool_router = built_tools(
sess.as_ref(),
@@ -204,9 +196,6 @@ async fn run_remote_compact_task_inner_impl(
)
.await;
if !ghost_snapshots.is_empty() {
new_history.extend(ghost_snapshots);
}
let reference_context_item = match initial_context_injection {
InitialContextInjection::DoNotInject => None,
InitialContextInjection::BeforeLastUserMessage => Some(turn_context.to_turn_context_item()),
@@ -290,7 +279,6 @@ fn should_keep_compacted_history_item(item: &ResponseItem) -> bool {
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::WebSearchCall { .. }
| ResponseItem::ImageGenerationCall { .. }
| ResponseItem::GhostSnapshot { .. }
| ResponseItem::Other => false,
}
}
+23 -2
View File
@@ -124,7 +124,27 @@ pub use network_proxy_spec::NetworkProxySpec;
pub use network_proxy_spec::StartedNetworkProxy;
pub(crate) use permissions::resolve_permission_profile;
pub use codex_git_utils::GhostSnapshotConfig;
const DEFAULT_IGNORE_LARGE_UNTRACKED_DIRS: i64 = 200;
const DEFAULT_IGNORE_LARGE_UNTRACKED_FILES: i64 = 10 * 1024 * 1024;
/// Compatibility-only config retained so legacy `ghost_snapshot` settings
/// continue to load even though snapshots are no longer produced.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GhostSnapshotConfig {
pub ignore_large_untracked_files: Option<i64>,
pub ignore_large_untracked_dirs: Option<i64>,
pub disable_warnings: bool,
}
impl Default for GhostSnapshotConfig {
fn default() -> Self {
Self {
ignore_large_untracked_files: Some(DEFAULT_IGNORE_LARGE_UNTRACKED_FILES),
ignore_large_untracked_dirs: Some(DEFAULT_IGNORE_LARGE_UNTRACKED_DIRS),
disable_warnings: false,
}
}
}
/// Maximum number of bytes of the documentation that will be embedded. Larger
/// files are *silently truncated* to this size so we do not take up too much of
@@ -655,7 +675,8 @@ pub struct Config {
/// Default: `300000` (5 minutes).
pub background_terminal_max_timeout: u64,
/// Settings for ghost snapshots (used for undo).
/// Compatibility-only settings retained for legacy `ghost_snapshot`
/// config loading.
pub ghost_snapshot: GhostSnapshotConfig,
/// Settings specific to the task-path-based multi-agent tool surface.
+1 -8
View File
@@ -103,8 +103,7 @@ impl ContextManager {
{
for item in items {
let item_ref = item.deref();
let is_ghost_snapshot = matches!(item_ref, ResponseItem::GhostSnapshot { .. });
if !is_api_message(item_ref) && !is_ghost_snapshot {
if !is_api_message(item_ref) {
continue;
}
@@ -120,8 +119,6 @@ impl ContextManager {
pub(crate) fn for_prompt(mut self, input_modalities: &[InputModality]) -> Vec<ResponseItem> {
self.normalize_history(input_modalities);
self.items
.retain(|item| !matches!(item, ResponseItem::GhostSnapshot { .. }));
self.items
}
/// Returns raw items in the history.
@@ -403,7 +400,6 @@ impl ContextManager {
| ResponseItem::ImageGenerationCall { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::Compaction { .. }
| ResponseItem::GhostSnapshot { .. }
| ResponseItem::Other => item.clone(),
}
}
@@ -492,7 +488,6 @@ fn is_api_message(message: &ResponseItem) -> bool {
| ResponseItem::WebSearchCall { .. }
| ResponseItem::ImageGenerationCall { .. }
| ResponseItem::Compaction { .. } => true,
ResponseItem::GhostSnapshot { .. } => false,
ResponseItem::Other => false,
}
}
@@ -534,7 +529,6 @@ static ORIGINAL_IMAGE_ESTIMATE_CACHE: LazyLock<BlockingLruCache<[u8; 20], Option
pub(crate) fn estimate_response_item_model_visible_bytes(item: &ResponseItem) -> i64 {
match item {
ResponseItem::GhostSnapshot { .. } => 0,
ResponseItem::Reasoning {
encrypted_content: Some(content),
..
@@ -691,7 +685,6 @@ fn is_model_generated_item(item: &ResponseItem) -> bool {
ResponseItem::FunctionCallOutput { .. }
| ResponseItem::ToolSearchOutput { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::GhostSnapshot { .. }
| ResponseItem::Other => false,
}
}
@@ -1,7 +1,6 @@
use super::*;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use codex_git_utils::GhostCommit;
use codex_protocol::AgentPath;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::models::BaseInstructions;
@@ -594,22 +593,6 @@ fn for_prompt_clears_image_generation_result_when_images_are_unsupported() {
);
}
#[test]
fn get_history_for_prompt_drops_ghost_commits() {
let items = vec![ResponseItem::GhostSnapshot {
ghost_commit: GhostCommit::new(
"ghost-1".to_string(),
/*parent*/ None,
Vec::new(),
Vec::new(),
),
}];
let history = create_history_with_items(items);
let modalities = default_input_modalities();
let filtered = history.for_prompt(&modalities);
assert_eq!(filtered, vec![]);
}
#[test]
fn estimate_token_count_with_base_instructions_uses_provided_text() {
let history = create_history_with_items(vec![assistant_msg("hello from history")]);
-33
View File
@@ -168,7 +168,6 @@ use crate::compact::collect_user_messages;
use crate::config::Config;
use crate::config::Constrained;
use crate::config::ConstraintResult;
use crate::config::GhostSnapshotConfig;
use crate::config::StartedNetworkProxy;
use crate::config::resolve_web_search_mode_for_turn;
use crate::context_manager::ContextManager;
@@ -289,10 +288,7 @@ use crate::state::SessionState;
use crate::stream_events_utils::HandleOutputCtx;
#[cfg(test)]
use crate::stream_events_utils::handle_output_item_done;
use crate::tasks::GhostSnapshotTask;
use crate::tasks::ReviewTask;
use crate::tasks::SessionTask;
use crate::tasks::SessionTaskContext;
use crate::tools::network_approval::NetworkApprovalService;
use crate::tools::network_approval::build_blocked_request_observer;
use crate::tools::network_approval::build_network_policy_decider;
@@ -358,7 +354,6 @@ use codex_protocol::user_input::UserInput;
use codex_tools::ToolsConfig;
use codex_tools::ToolsConfigParams;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_readiness::Readiness;
use codex_utils_readiness::ReadinessFlag;
#[cfg(test)]
use codex_utils_stream_parser::ProposedPlanSegment;
@@ -2912,34 +2907,6 @@ impl Session {
self.send_event(turn_context, event).await;
}
async fn maybe_start_ghost_snapshot(
self: &Arc<Self>,
turn_context: Arc<TurnContext>,
cancellation_token: CancellationToken,
) {
if !self.enabled(Feature::GhostCommit) {
return;
}
let token = match turn_context.tool_call_gate.subscribe().await {
Ok(token) => token,
Err(err) => {
warn!("failed to subscribe to ghost snapshot readiness: {err}");
return;
}
};
info!("spawning ghost snapshot task");
let task = GhostSnapshotTask::new(token);
Arc::new(task)
.run(
Arc::new(SessionTaskContext::new(self.clone())),
turn_context.clone(),
Vec::new(),
cancellation_token,
)
.await;
}
/// Inject additional user input into the currently active turn.
///
/// Returns the active turn id when accepted.
-3
View File
@@ -356,8 +356,6 @@ pub(crate) async fn run_turn(
track_turn_resolved_config_analytics(&sess, &turn_context, &input).await;
let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref());
sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token())
.await;
let mut last_agent_message: Option<String> = None;
let mut stop_hook_active = false;
// Although from the perspective of codex.rs, TurnDiffTracker has the lifecycle of a Task which contains
@@ -1951,7 +1949,6 @@ async fn try_run_sampling_request(
| ResponseItem::ToolSearchOutput { .. }
| ResponseItem::WebSearchCall { .. }
| ResponseItem::ImageGenerationCall { .. }
| ResponseItem::GhostSnapshot { .. }
| ResponseItem::Compaction { .. }
| ResponseItem::Other => false,
};
@@ -1,4 +1,5 @@
use super::*;
use crate::config::GhostSnapshotConfig;
use codex_model_provider::SharedModelProvider;
use codex_model_provider::create_model_provider;
use codex_protocol::models::AdditionalPermissionProfile;
-252
View File
@@ -1,252 +0,0 @@
use crate::session::turn_context::TurnContext;
use crate::state::TaskKind;
use crate::tasks::SessionTask;
use crate::tasks::SessionTaskContext;
use codex_git_utils::CreateGhostCommitOptions;
use codex_git_utils::GhostSnapshotReport;
use codex_git_utils::GitToolingError;
use codex_git_utils::create_ghost_commit_with_report;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::WarningEvent;
use codex_protocol::user_input::UserInput;
use codex_utils_readiness::Readiness;
use codex_utils_readiness::Token;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::oneshot;
use tokio_util::sync::CancellationToken;
use tracing::info;
use tracing::warn;
pub(crate) struct GhostSnapshotTask {
token: Token,
}
const SNAPSHOT_WARNING_THRESHOLD: Duration = Duration::from_secs(240);
impl SessionTask for GhostSnapshotTask {
fn kind(&self) -> TaskKind {
TaskKind::Regular
}
fn span_name(&self) -> &'static str {
"session_task.ghost_snapshot"
}
async fn run(
self: Arc<Self>,
session: Arc<SessionTaskContext>,
ctx: Arc<TurnContext>,
_input: Vec<UserInput>,
cancellation_token: CancellationToken,
) -> Option<String> {
tokio::task::spawn(async move {
let token = self.token;
let warnings_enabled = !ctx.ghost_snapshot.disable_warnings;
// Channel used to signal when the snapshot work has finished so the
// timeout warning task can exit early without sending a warning.
let (snapshot_done_tx, snapshot_done_rx) = oneshot::channel::<()>();
if warnings_enabled {
let ctx_for_warning = ctx.clone();
let cancellation_token_for_warning = cancellation_token.clone();
let session_for_warning = session.clone();
// Fire a generic warning if the snapshot is still running after
// three minutes; this helps users discover large untracked files
// that might need to be added to .gitignore.
tokio::task::spawn(async move {
tokio::select! {
_ = tokio::time::sleep(SNAPSHOT_WARNING_THRESHOLD) => {
session_for_warning.session
.send_event(
&ctx_for_warning,
EventMsg::Warning(WarningEvent {
message: "Repository snapshot is taking longer than expected. Large untracked or ignored files can slow snapshots; consider adding large files or directories to .gitignore or disabling `undo` in your config.".to_string()
}),
)
.await;
}
_ = snapshot_done_rx => {}
_ = cancellation_token_for_warning.cancelled() => {}
}
});
} else {
drop(snapshot_done_rx);
}
let ctx_for_task = ctx.clone();
let cancelled = tokio::select! {
_ = cancellation_token.cancelled() => true,
_ = async {
let repo_path = ctx_for_task.cwd.clone();
let ghost_snapshot = ctx_for_task.ghost_snapshot.clone();
let ghost_snapshot_for_commit = ghost_snapshot.clone();
// Required to run in a dedicated blocking pool.
match tokio::task::spawn_blocking(move || {
let options =
CreateGhostCommitOptions::new(&repo_path).ghost_snapshot(ghost_snapshot_for_commit);
create_ghost_commit_with_report(&options)
})
.await
{
Ok(Ok((ghost_commit, report))) => {
info!("ghost snapshot blocking task finished");
if warnings_enabled {
for message in format_snapshot_warnings(
ghost_snapshot.ignore_large_untracked_files,
ghost_snapshot.ignore_large_untracked_dirs,
&report,
) {
session
.session
.send_event(
&ctx_for_task,
EventMsg::Warning(WarningEvent { message }),
)
.await;
}
}
session
.session
.record_conversation_items(&ctx, &[ResponseItem::GhostSnapshot {
ghost_commit: ghost_commit.clone(),
}])
.await;
info!("ghost commit captured: {}", ghost_commit.id());
}
Ok(Err(err)) => match err {
GitToolingError::NotAGitRepository { .. } => info!(
sub_id = ctx_for_task.sub_id.as_str(),
"skipping ghost snapshot because current directory is not a Git repository"
),
_ => {
warn!(
sub_id = ctx_for_task.sub_id.as_str(),
"failed to capture ghost snapshot: {err}"
);
}
},
Err(err) => {
warn!(
sub_id = ctx_for_task.sub_id.as_str(),
"ghost snapshot task panicked: {err}"
);
let message =
format!("Snapshots disabled after ghost snapshot panic: {err}.");
session
.session
.notify_background_event(&ctx_for_task, message)
.await;
}
}
} => false,
};
let _ = snapshot_done_tx.send(());
if cancelled {
info!("ghost snapshot task cancelled");
}
match ctx.tool_call_gate.mark_ready(token).await {
Ok(true) => info!("ghost snapshot gate marked ready"),
Ok(false) => warn!("ghost snapshot gate already ready"),
Err(err) => warn!("failed to mark ghost snapshot ready: {err}"),
}
});
None
}
}
impl GhostSnapshotTask {
pub(crate) fn new(token: Token) -> Self {
Self { token }
}
}
fn format_snapshot_warnings(
ignore_large_untracked_files: Option<i64>,
ignore_large_untracked_dirs: Option<i64>,
report: &GhostSnapshotReport,
) -> Vec<String> {
let mut warnings = Vec::new();
if let Some(message) = format_large_untracked_warning(ignore_large_untracked_dirs, report) {
warnings.push(message);
}
if let Some(message) =
format_ignored_untracked_files_warning(ignore_large_untracked_files, report)
{
warnings.push(message);
}
warnings
}
fn format_large_untracked_warning(
ignore_large_untracked_dirs: Option<i64>,
report: &GhostSnapshotReport,
) -> Option<String> {
if report.large_untracked_dirs.is_empty() {
return None;
}
let threshold = ignore_large_untracked_dirs?;
const MAX_DIRS: usize = 3;
let mut parts: Vec<String> = Vec::new();
for dir in report.large_untracked_dirs.iter().take(MAX_DIRS) {
parts.push(format!("{} ({} files)", dir.path.display(), dir.file_count));
}
if report.large_untracked_dirs.len() > MAX_DIRS {
let remaining = report.large_untracked_dirs.len() - MAX_DIRS;
parts.push(format!("{remaining} more"));
}
Some(format!(
"Repository snapshot ignored large untracked directories (>= {threshold} files): {}. These directories are excluded from snapshots and undo cleanup. Adjust `ghost_snapshot.ignore_large_untracked_dirs` to change this behavior.",
parts.join(", ")
))
}
fn format_ignored_untracked_files_warning(
ignore_large_untracked_files: Option<i64>,
report: &GhostSnapshotReport,
) -> Option<String> {
let threshold = ignore_large_untracked_files?;
if report.ignored_untracked_files.is_empty() {
return None;
}
const MAX_FILES: usize = 3;
let mut parts: Vec<String> = Vec::new();
for file in report.ignored_untracked_files.iter().take(MAX_FILES) {
parts.push(format!(
"{} ({})",
file.path.display(),
format_bytes(file.byte_size)
));
}
if report.ignored_untracked_files.len() > MAX_FILES {
let remaining = report.ignored_untracked_files.len() - MAX_FILES;
parts.push(format!("{remaining} more"));
}
Some(format!(
"Repository snapshot ignored untracked files larger than {}: {}. These files are preserved during undo cleanup, but their contents are not captured in the snapshot. Adjust `ghost_snapshot.ignore_large_untracked_files` to change this behavior. To avoid this message in the future, update your `.gitignore`.",
format_bytes(threshold),
parts.join(", ")
))
}
fn format_bytes(bytes: i64) -> String {
const KIB: i64 = 1024;
const MIB: i64 = 1024 * 1024;
if bytes >= MIB {
return format!("{} MiB", bytes / MIB);
}
if bytes >= KIB {
return format!("{} KiB", bytes / KIB);
}
format!("{bytes} B")
}
#[cfg(test)]
#[path = "ghost_snapshot_tests.rs"]
mod tests;
@@ -1,34 +0,0 @@
use super::*;
use codex_git_utils::LargeUntrackedDir;
use pretty_assertions::assert_eq;
use std::path::PathBuf;
#[test]
fn large_untracked_warning_includes_threshold() {
let report = GhostSnapshotReport {
large_untracked_dirs: vec![LargeUntrackedDir {
path: PathBuf::from("models"),
file_count: 250,
}],
ignored_untracked_files: Vec::new(),
};
let message = format_large_untracked_warning(Some(200), &report).unwrap();
assert!(message.contains(">= 200 files"));
}
#[test]
fn large_untracked_warning_disabled_when_threshold_disabled() {
let report = GhostSnapshotReport {
large_untracked_dirs: vec![LargeUntrackedDir {
path: PathBuf::from("models"),
file_count: 250,
}],
ignored_untracked_files: Vec::new(),
};
assert_eq!(
format_large_untracked_warning(/*ignore_large_untracked_dirs*/ None, &report),
None
);
}
-2
View File
@@ -1,5 +1,4 @@
mod compact;
mod ghost_snapshot;
mod regular;
mod review;
mod undo;
@@ -54,7 +53,6 @@ use codex_protocol::user_input::UserInput;
use codex_features::Feature;
use codex_protocol::models::ContentItem;
pub(crate) use compact::CompactTask;
pub(crate) use ghost_snapshot::GhostSnapshotTask;
pub(crate) use regular::RegularTask;
pub(crate) use review::ReviewTask;
pub(crate) use undo::UndoTask;
+2 -59
View File
@@ -4,17 +4,11 @@ use crate::session::turn_context::TurnContext;
use crate::state::TaskKind;
use crate::tasks::SessionTask;
use crate::tasks::SessionTaskContext;
use codex_git_utils::RestoreGhostCommitOptions;
use codex_git_utils::restore_ghost_commit_with_options;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::UndoCompletedEvent;
use codex_protocol::protocol::UndoStartedEvent;
use codex_protocol::user_input::UserInput;
use tokio_util::sync::CancellationToken;
use tracing::error;
use tracing::info;
use tracing::warn;
pub(crate) struct UndoTask;
@@ -66,62 +60,11 @@ impl SessionTask for UndoTask {
return None;
}
let history = sess.clone_history().await;
let mut items = history.raw_items().to_vec();
let mut completed = UndoCompletedEvent {
let completed = UndoCompletedEvent {
success: false,
message: None,
message: Some("Undo is no longer available.".to_string()),
};
let Some((idx, ghost_commit)) =
items
.iter()
.enumerate()
.rev()
.find_map(|(idx, item)| match item {
ResponseItem::GhostSnapshot { ghost_commit } => {
Some((idx, ghost_commit.clone()))
}
_ => None,
})
else {
completed.message = Some("No ghost snapshot available to undo.".to_string());
sess.send_event(ctx.as_ref(), EventMsg::UndoCompleted(completed))
.await;
return None;
};
let commit_id = ghost_commit.id().to_string();
let repo_path = ctx.cwd.clone();
let ghost_snapshot = ctx.ghost_snapshot.clone();
let restore_result = tokio::task::spawn_blocking(move || {
let options = RestoreGhostCommitOptions::new(&repo_path).ghost_snapshot(ghost_snapshot);
restore_ghost_commit_with_options(&options, &ghost_commit)
})
.await;
match restore_result {
Ok(Ok(())) => {
items.remove(idx);
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;
completed.message = Some(format!("Undo restored snapshot {short_id}."));
}
Ok(Err(err)) => {
let message = format!("Failed to restore snapshot {commit_id}: {err}");
warn!("{message}");
completed.message = Some(message);
}
Err(err) => {
let message = format!("Failed to restore snapshot {commit_id}: {err}");
error!("{message}");
completed.message = Some(message);
}
}
sess.send_event(ctx.as_ref(), EventMsg::UndoCompleted(completed))
.await;
None
-1
View File
@@ -180,7 +180,6 @@ fn response_item_records_turn_ttft(item: &ResponseItem) -> bool {
| ResponseItem::ToolSearchCall { .. }
| ResponseItem::WebSearchCall { .. }
| ResponseItem::ImageGenerationCall { .. }
| ResponseItem::GhostSnapshot { .. }
| ResponseItem::Compaction { .. } => true,
ResponseItem::FunctionCallOutput { .. }
| ResponseItem::CustomToolCallOutput { .. }
@@ -60,29 +60,6 @@ fn json_fragment(text: &str) -> String {
.to_string()
}
fn filter_out_ghost_snapshot_entries(items: &[Value]) -> Vec<Value> {
items
.iter()
.filter(|item| !is_ghost_snapshot_message(item))
.cloned()
.collect()
}
fn is_ghost_snapshot_message(item: &Value) -> bool {
if item.get("type").and_then(Value::as_str) != Some("message") {
return false;
}
if item.get("role").and_then(Value::as_str) != Some("user") {
return false;
}
item.get("content")
.and_then(Value::as_array)
.and_then(|content| content.first())
.and_then(|entry| entry.get("text"))
.and_then(Value::as_str)
.is_some_and(|text| text.trim_start().starts_with("<ghost_snapshot>"))
}
fn normalize_line_endings_str(text: &str) -> String {
if text.contains('\r') {
text.replace("\r\n", "\n").replace('\r', "\n")
@@ -366,15 +343,13 @@ async fn compact_resume_after_second_compaction_preserves_history() -> Result<()
let resume_input_array = input_after_resume
.as_array()
.expect("input after resume should be an array");
let compact_filtered = filter_out_ghost_snapshot_entries(compact_input_array);
let resume_filtered = filter_out_ghost_snapshot_entries(resume_input_array);
assert!(
compact_filtered.len() <= resume_filtered.len(),
compact_input_array.len() <= resume_input_array.len(),
"after-resume input should have at least as many items as after-compact"
);
assert_eq!(
compact_filtered.as_slice(),
&resume_filtered[..compact_filtered.len()]
compact_input_array.as_slice(),
&resume_input_array[..compact_input_array.len()]
);
let first_request_user_texts = json_message_input_texts(&requests[0], "user");
let first_turn_user_index = first_request_user_texts
+9 -519
View File
@@ -1,114 +1,19 @@
#![cfg(not(target_os = "windows"))]
use std::fs;
use std::path::Path;
use std::process::Command;
use std::sync::Arc;
use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use codex_core::CodexThread;
use codex_features::Feature;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::UndoCompletedEvent;
use core_test_support::responses::ev_apply_patch_function_call;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::sse;
use core_test_support::skip_if_no_network;
use core_test_support::test_codex::TestCodexHarness;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event_match;
use pretty_assertions::assert_eq;
#[allow(clippy::expect_used)]
async fn undo_harness() -> Result<TestCodexHarness> {
let builder = test_codex().with_model("gpt-5.4").with_config(|config| {
config.include_apply_patch_tool = true;
config
.features
.enable(Feature::GhostCommit)
.expect("test config should allow feature update");
});
TestCodexHarness::with_builder(builder).await
}
fn git(path: &Path, args: &[&str]) -> Result<()> {
let status = Command::new("git")
.args(args)
.current_dir(path)
.status()
.with_context(|| format!("failed to run git {args:?}"))?;
if status.success() {
return Ok(());
}
let exit_status = status;
bail!("git {args:?} exited with {exit_status}");
}
fn git_output(path: &Path, args: &[&str]) -> Result<String> {
let output = Command::new("git")
.args(args)
.current_dir(path)
.output()
.with_context(|| format!("failed to run git {args:?}"))?;
if !output.status.success() {
let exit_status = output.status;
bail!("git {args:?} exited with {exit_status}");
}
String::from_utf8(output.stdout).context("stdout was not valid utf8")
}
fn init_git_repo(path: &Path) -> Result<()> {
// Use a consistent initial branch and config across environments to avoid
// CI variance (default-branch hints, line ending differences, etc.).
git(path, &["init", "--initial-branch=main"])?;
git(path, &["config", "core.autocrlf", "false"])?;
git(path, &["config", "user.name", "Codex Tests"])?;
git(path, &["config", "user.email", "codex-tests@example.com"])?;
// Create README.txt
let readme_path = path.join("README.txt");
fs::write(&readme_path, "Test repository initialized by Codex.\n")?;
// Stage and commit
git(path, &["add", "README.txt"])?;
git(path, &["commit", "-m", "Add README.txt"])?;
Ok(())
}
fn apply_patch_responses(call_id: &str, patch: &str, assistant_msg: &str) -> Vec<String> {
vec![
sse(vec![
ev_response_created("resp-1"),
ev_apply_patch_function_call(call_id, patch),
ev_completed("resp-1"),
]),
sse(vec![
ev_assistant_message("msg-1", assistant_msg),
ev_completed("resp-2"),
]),
]
}
async fn run_apply_patch_turn(
harness: &TestCodexHarness,
prompt: &str,
call_id: &str,
patch: &str,
assistant_msg: &str,
) -> Result<()> {
mount_sse_sequence(
harness.server(),
apply_patch_responses(call_id, patch, assistant_msg),
)
.await;
harness.submit(prompt).await
TestCodexHarness::with_builder(test_codex().with_model("gpt-5.4")).await
}
async fn invoke_undo(codex: &Arc<CodexThread>) -> Result<UndoCompletedEvent> {
@@ -121,432 +26,17 @@ async fn invoke_undo(codex: &Arc<CodexThread>) -> Result<UndoCompletedEvent> {
Ok(event)
}
async fn expect_successful_undo(codex: &Arc<CodexThread>) -> Result<UndoCompletedEvent> {
let event = invoke_undo(codex).await?;
assert!(
event.success,
"expected undo to succeed but failed with message {:?}",
event.message
);
Ok(event)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn undo_reports_feature_removal() -> Result<()> {
let harness = undo_harness().await?;
let codex = Arc::clone(&harness.test().codex);
async fn expect_failed_undo(codex: &Arc<CodexThread>) -> Result<UndoCompletedEvent> {
let event = invoke_undo(codex).await?;
assert!(
!event.success,
"expected undo to fail but succeeded with message {:?}",
event.message
);
let event = invoke_undo(&codex).await?;
assert!(!event.success, "expected undo to fail");
assert_eq!(
event.message.as_deref(),
Some("No ghost snapshot available to undo.")
);
Ok(event)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn undo_removes_new_file_created_during_turn() -> Result<()> {
skip_if_no_network!(Ok(()));
let harness = undo_harness().await?;
init_git_repo(harness.cwd())?;
let call_id = "undo-create-file";
let patch = "*** Begin Patch\n*** Add File: new_file.txt\n+from turn\n*** End Patch";
run_apply_patch_turn(&harness, "create file", call_id, patch, "ok").await?;
let new_path = harness.path("new_file.txt");
assert_eq!(fs::read_to_string(&new_path)?, "from turn\n");
let codex = Arc::clone(&harness.test().codex);
let completed = expect_successful_undo(&codex).await?;
assert!(completed.success, "undo failed: {:?}", completed.message);
assert!(!new_path.exists());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn undo_restores_tracked_file_edit() -> Result<()> {
skip_if_no_network!(Ok(()));
let harness = undo_harness().await?;
init_git_repo(harness.cwd())?;
let tracked = harness.path("tracked.txt");
fs::write(&tracked, "before\n")?;
git(harness.cwd(), &["add", "tracked.txt"])?;
git(harness.cwd(), &["commit", "-m", "track file"])?;
let patch = "*** Begin Patch\n*** Update File: tracked.txt\n@@\n-before\n+after\n*** End Patch";
run_apply_patch_turn(
&harness,
"update tracked file",
"undo-tracked-edit",
patch,
"done",
)
.await?;
println!(
"apply_patch output: {}",
harness.function_call_stdout("undo-tracked-edit").await
);
assert_eq!(fs::read_to_string(&tracked)?, "after\n");
let codex = Arc::clone(&harness.test().codex);
let completed = expect_successful_undo(&codex).await?;
assert!(completed.success, "undo failed: {:?}", completed.message);
assert_eq!(fs::read_to_string(&tracked)?, "before\n");
let status = git_output(harness.cwd(), &["status", "--short"])?;
assert_eq!(status, "");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn undo_restores_untracked_file_edit() -> Result<()> {
skip_if_no_network!(Ok(()));
let harness = undo_harness().await?;
init_git_repo(harness.cwd())?;
git(harness.cwd(), &["commit", "--allow-empty", "-m", "init"])?;
let notes = harness.path("notes.txt");
fs::write(&notes, "original\n")?;
let status_before = git_output(harness.cwd(), &["status", "--short", "--ignored"])?;
assert!(status_before.contains("?? notes.txt"));
let patch =
"*** Begin Patch\n*** Update File: notes.txt\n@@\n-original\n+modified\n*** End Patch";
run_apply_patch_turn(
&harness,
"edit untracked",
"undo-untracked-edit",
patch,
"done",
)
.await?;
assert_eq!(fs::read_to_string(&notes)?, "modified\n");
let codex = Arc::clone(&harness.test().codex);
let completed = expect_successful_undo(&codex).await?;
assert!(completed.success, "undo failed: {:?}", completed.message);
assert_eq!(fs::read_to_string(&notes)?, "original\n");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn undo_reverts_only_latest_turn() -> Result<()> {
skip_if_no_network!(Ok(()));
let harness = undo_harness().await?;
init_git_repo(harness.cwd())?;
let call_id_one = "undo-turn-one";
let add_patch = "*** Begin Patch\n*** Add File: story.txt\n+first version\n*** End Patch";
run_apply_patch_turn(&harness, "create story", call_id_one, add_patch, "done").await?;
let story = harness.path("story.txt");
assert_eq!(fs::read_to_string(&story)?, "first version\n");
let call_id_two = "undo-turn-two";
let update_patch = "*** Begin Patch\n*** Update File: story.txt\n@@\n-first version\n+second version\n*** End Patch";
run_apply_patch_turn(&harness, "revise story", call_id_two, update_patch, "done").await?;
assert_eq!(fs::read_to_string(&story)?, "second version\n");
let codex = Arc::clone(&harness.test().codex);
let completed = expect_successful_undo(&codex).await?;
assert!(completed.success, "undo failed: {:?}", completed.message);
assert_eq!(fs::read_to_string(&story)?, "first version\n");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn undo_does_not_touch_unrelated_files() -> Result<()> {
skip_if_no_network!(Ok(()));
let harness = undo_harness().await?;
init_git_repo(harness.cwd())?;
let tracked_constant = harness.path("stable.txt");
fs::write(&tracked_constant, "stable\n")?;
let target = harness.path("target.txt");
fs::write(&target, "start\n")?;
let gitignore = harness.path(".gitignore");
fs::write(&gitignore, "ignored-stable.log\n")?;
git(
harness.cwd(),
&["add", "stable.txt", "target.txt", ".gitignore"],
)?;
git(harness.cwd(), &["commit", "-m", "seed tracked"])?;
let preexisting_untracked = harness.path("scratch.txt");
fs::write(&preexisting_untracked, "scratch before\n")?;
let ignored = harness.path("ignored-stable.log");
fs::write(&ignored, "ignored before\n")?;
let full_patch = "*** Begin Patch\n*** Update File: target.txt\n@@\n-start\n+edited\n*** Add File: temp.txt\n+ephemeral\n*** End Patch";
run_apply_patch_turn(
&harness,
"modify target",
"undo-unrelated",
full_patch,
"done",
)
.await?;
let temp = harness.path("temp.txt");
assert_eq!(fs::read_to_string(&target)?, "edited\n");
assert_eq!(fs::read_to_string(&temp)?, "ephemeral\n");
let codex = Arc::clone(&harness.test().codex);
let completed = expect_successful_undo(&codex).await?;
assert!(completed.success, "undo failed: {:?}", completed.message);
assert_eq!(fs::read_to_string(&tracked_constant)?, "stable\n");
assert_eq!(fs::read_to_string(&target)?, "start\n");
assert_eq!(
fs::read_to_string(&preexisting_untracked)?,
"scratch before\n"
);
assert_eq!(fs::read_to_string(&ignored)?, "ignored before\n");
assert!(!temp.exists());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn undo_sequential_turns_consumes_snapshots() -> Result<()> {
skip_if_no_network!(Ok(()));
let harness = undo_harness().await?;
init_git_repo(harness.cwd())?;
let story = harness.path("story.txt");
fs::write(&story, "initial\n")?;
git(harness.cwd(), &["add", "story.txt"])?;
git(harness.cwd(), &["commit", "-m", "seed story"])?;
run_apply_patch_turn(
&harness,
"first change",
"seq-turn-1",
"*** Begin Patch\n*** Update File: story.txt\n@@\n-initial\n+turn one\n*** End Patch",
"ok",
)
.await?;
assert_eq!(fs::read_to_string(&story)?, "turn one\n");
run_apply_patch_turn(
&harness,
"second change",
"seq-turn-2",
"*** Begin Patch\n*** Update File: story.txt\n@@\n-turn one\n+turn two\n*** End Patch",
"ok",
)
.await?;
assert_eq!(fs::read_to_string(&story)?, "turn two\n");
run_apply_patch_turn(
&harness,
"third change",
"seq-turn-3",
"*** Begin Patch\n*** Update File: story.txt\n@@\n-turn two\n+turn three\n*** End Patch",
"ok",
)
.await?;
assert_eq!(fs::read_to_string(&story)?, "turn three\n");
let codex = Arc::clone(&harness.test().codex);
expect_successful_undo(&codex).await?;
assert_eq!(fs::read_to_string(&story)?, "turn two\n");
expect_successful_undo(&codex).await?;
assert_eq!(fs::read_to_string(&story)?, "turn one\n");
expect_successful_undo(&codex).await?;
assert_eq!(fs::read_to_string(&story)?, "initial\n");
expect_failed_undo(&codex).await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn undo_without_snapshot_reports_failure() -> Result<()> {
skip_if_no_network!(Ok(()));
let harness = undo_harness().await?;
let codex = Arc::clone(&harness.test().codex);
expect_failed_undo(&codex).await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn undo_restores_moves_and_renames() -> Result<()> {
skip_if_no_network!(Ok(()));
let harness = undo_harness().await?;
init_git_repo(harness.cwd())?;
let source = harness.path("rename_me.txt");
fs::write(&source, "original\n")?;
git(harness.cwd(), &["add", "rename_me.txt"])?;
git(harness.cwd(), &["commit", "-m", "add rename target"])?;
let patch = "*** Begin Patch\n*** Update File: rename_me.txt\n*** Move to: relocated/renamed.txt\n@@\n-original\n+renamed content\n*** End Patch";
run_apply_patch_turn(&harness, "rename file", "undo-rename", patch, "done").await?;
let destination = harness.path("relocated/renamed.txt");
assert!(!source.exists());
assert_eq!(fs::read_to_string(&destination)?, "renamed content\n");
let codex = Arc::clone(&harness.test().codex);
expect_successful_undo(&codex).await?;
assert_eq!(fs::read_to_string(&source)?, "original\n");
assert!(!destination.exists());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn undo_does_not_touch_ignored_directory_contents() -> Result<()> {
skip_if_no_network!(Ok(()));
let harness = undo_harness().await?;
init_git_repo(harness.cwd())?;
let gitignore = harness.path(".gitignore");
fs::write(&gitignore, "logs/\n")?;
git(harness.cwd(), &["add", ".gitignore"])?;
git(harness.cwd(), &["commit", "-m", "ignore logs directory"])?;
let logs_dir = harness.path("logs");
fs::create_dir_all(&logs_dir)?;
let preserved = logs_dir.join("persistent.log");
fs::write(&preserved, "keep me\n")?;
run_apply_patch_turn(
&harness,
"write log",
"undo-log",
"*** Begin Patch\n*** Add File: logs/session.log\n+ephemeral log\n*** End Patch",
"ok",
)
.await?;
let new_log = logs_dir.join("session.log");
assert_eq!(fs::read_to_string(&new_log)?, "ephemeral log\n");
let codex = Arc::clone(&harness.test().codex);
expect_successful_undo(&codex).await?;
assert!(new_log.exists());
assert_eq!(fs::read_to_string(&preserved)?, "keep me\n");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn undo_overwrites_manual_edits_after_turn() -> Result<()> {
skip_if_no_network!(Ok(()));
let harness = undo_harness().await?;
init_git_repo(harness.cwd())?;
let tracked = harness.path("tracked.txt");
fs::write(&tracked, "baseline\n")?;
git(harness.cwd(), &["add", "tracked.txt"])?;
git(harness.cwd(), &["commit", "-m", "baseline tracked"])?;
run_apply_patch_turn(
&harness,
"modify tracked",
"undo-manual-overwrite",
"*** Begin Patch\n*** Update File: tracked.txt\n@@\n-baseline\n+turn change\n*** End Patch",
"ok",
)
.await?;
assert_eq!(fs::read_to_string(&tracked)?, "turn change\n");
fs::write(&tracked, "manual edit\n")?;
assert_eq!(fs::read_to_string(&tracked)?, "manual edit\n");
let codex = Arc::clone(&harness.test().codex);
expect_successful_undo(&codex).await?;
assert_eq!(fs::read_to_string(&tracked)?, "baseline\n");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn undo_preserves_unrelated_staged_changes() -> Result<()> {
skip_if_no_network!(Ok(()));
let harness = undo_harness().await?;
init_git_repo(harness.cwd())?;
// create a file for user to mess with
let user_file = harness.path("user_file.txt");
fs::write(&user_file, "user content v1\n")?;
git(harness.cwd(), &["add", "user_file.txt"])?;
git(harness.cwd(), &["commit", "-m", "add user file"])?;
// AI turn: modifies a DIFFERENT file (creating ghost commit of baseline)
let ai_file = harness.path("ai_file.txt");
fs::write(&ai_file, "ai content v1\n")?;
git(harness.cwd(), &["add", "ai_file.txt"])?;
git(harness.cwd(), &["commit", "-m", "add ai file"])?; // baseline
let patch = "*** Begin Patch\n*** Update File: ai_file.txt\n@@\n-ai content v1\n+ai content v2\n*** End Patch";
run_apply_patch_turn(&harness, "modify ai file", "undo-staging-test", patch, "ok").await?;
assert_eq!(fs::read_to_string(&ai_file)?, "ai content v2\n");
// NOW: User modifies user_file AND stages it
fs::write(&user_file, "user content v2 (staged)\n")?;
git(harness.cwd(), &["add", "user_file.txt"])?;
// Verify status before undo
let status_before = git_output(harness.cwd(), &["status", "--porcelain"])?;
assert!(status_before.contains("M user_file.txt")); // M in index
// UNDO
let codex = Arc::clone(&harness.test().codex);
// checks that undo succeeded
expect_successful_undo(&codex).await?;
// AI file should be reverted
assert_eq!(fs::read_to_string(&ai_file)?, "ai content v1\n");
// User file should STILL be staged with v2
let status_after = git_output(harness.cwd(), &["status", "--porcelain"])?;
// We expect 'M' in the first column (index modified).
// The second column will likely be 'M' because the worktree was reverted to v1 while index has v2.
// So "MM user_file.txt" is expected.
if !status_after.contains("MM user_file.txt") && !status_after.contains("M user_file.txt") {
bail!("Status should contain staged change (M in first col), but was: '{status_after}'");
}
// Disk content is reverted to v1 (snapshot state)
assert_eq!(fs::read_to_string(&user_file)?, "user content v1\n");
// But we can get v2 back from index
git(harness.cwd(), &["checkout", "user_file.txt"])?;
assert_eq!(
fs::read_to_string(&user_file)?,
"user content v2 (staged)\n"
Some("Undo is no longer available.")
);
Ok(())