mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
7e8594fc19
commit
4e05f3053c
Generated
-1
@@ -2735,7 +2735,6 @@ name = "codex-git-utils"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_matches",
|
||||
"chrono",
|
||||
"codex-file-system",
|
||||
"codex-protocol",
|
||||
|
||||
@@ -1400,38 +1400,6 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"GhostCommit": {
|
||||
"description": "Details of a ghost commit created from a repository state.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"parent": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"preexisting_untracked_dirs": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"preexisting_untracked_files": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"preexisting_untracked_dirs",
|
||||
"preexisting_untracked_files"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ImageDetail": {
|
||||
"enum": [
|
||||
"auto",
|
||||
@@ -2676,26 +2644,6 @@
|
||||
"title": "ImageGenerationCallResponseItem",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"ghost_commit": {
|
||||
"$ref": "#/definitions/GhostCommit"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"ghost_snapshot"
|
||||
],
|
||||
"title": "GhostSnapshotResponseItemType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"ghost_commit",
|
||||
"type"
|
||||
],
|
||||
"title": "GhostSnapshotResponseItem",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"encrypted_content": {
|
||||
|
||||
-52
@@ -9183,38 +9183,6 @@
|
||||
"title": "GetAccountResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"GhostCommit": {
|
||||
"description": "Details of a ghost commit created from a repository state.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"parent": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"preexisting_untracked_dirs": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"preexisting_untracked_files": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"preexisting_untracked_dirs",
|
||||
"preexisting_untracked_files"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"GitInfo": {
|
||||
"properties": {
|
||||
"branch": {
|
||||
@@ -13123,26 +13091,6 @@
|
||||
"title": "ImageGenerationCallResponseItem",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"ghost_commit": {
|
||||
"$ref": "#/definitions/v2/GhostCommit"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"ghost_snapshot"
|
||||
],
|
||||
"title": "GhostSnapshotResponseItemType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"ghost_commit",
|
||||
"type"
|
||||
],
|
||||
"title": "GhostSnapshotResponseItem",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"encrypted_content": {
|
||||
|
||||
-52
@@ -5813,38 +5813,6 @@
|
||||
"title": "GetAccountResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"GhostCommit": {
|
||||
"description": "Details of a ghost commit created from a repository state.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"parent": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"preexisting_untracked_dirs": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"preexisting_untracked_files": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"preexisting_untracked_dirs",
|
||||
"preexisting_untracked_files"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"GitInfo": {
|
||||
"properties": {
|
||||
"branch": {
|
||||
@@ -9797,26 +9765,6 @@
|
||||
"title": "ImageGenerationCallResponseItem",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"ghost_commit": {
|
||||
"$ref": "#/definitions/GhostCommit"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"ghost_snapshot"
|
||||
],
|
||||
"title": "GhostSnapshotResponseItemType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"ghost_commit",
|
||||
"type"
|
||||
],
|
||||
"title": "GhostSnapshotResponseItem",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"encrypted_content": {
|
||||
|
||||
-52
@@ -143,38 +143,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"GhostCommit": {
|
||||
"description": "Details of a ghost commit created from a repository state.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"parent": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"preexisting_untracked_dirs": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"preexisting_untracked_files": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"preexisting_untracked_dirs",
|
||||
"preexisting_untracked_files"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ImageDetail": {
|
||||
"enum": [
|
||||
"auto",
|
||||
@@ -744,26 +712,6 @@
|
||||
"title": "ImageGenerationCallResponseItem",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"ghost_commit": {
|
||||
"$ref": "#/definitions/GhostCommit"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"ghost_snapshot"
|
||||
],
|
||||
"title": "GhostSnapshotResponseItemType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"ghost_commit",
|
||||
"type"
|
||||
],
|
||||
"title": "GhostSnapshotResponseItem",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"encrypted_content": {
|
||||
|
||||
@@ -402,38 +402,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"GhostCommit": {
|
||||
"description": "Details of a ghost commit created from a repository state.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"parent": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"preexisting_untracked_dirs": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"preexisting_untracked_files": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"preexisting_untracked_dirs",
|
||||
"preexisting_untracked_files"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ImageDetail": {
|
||||
"enum": [
|
||||
"auto",
|
||||
@@ -1140,26 +1108,6 @@
|
||||
"title": "ImageGenerationCallResponseItem",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"ghost_commit": {
|
||||
"$ref": "#/definitions/GhostCommit"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"ghost_snapshot"
|
||||
],
|
||||
"title": "GhostSnapshotResponseItemType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"ghost_commit",
|
||||
"type"
|
||||
],
|
||||
"title": "GhostSnapshotResponseItem",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"encrypted_content": {
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Details of a ghost commit created from a repository state.
|
||||
*/
|
||||
export type GhostCommit = { id: string, parent: string | null, preexisting_untracked_files: Array<string>, preexisting_untracked_dirs: Array<string>, };
|
||||
@@ -3,7 +3,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ContentItem } from "./ContentItem";
|
||||
import type { FunctionCallOutputBody } from "./FunctionCallOutputBody";
|
||||
import type { GhostCommit } from "./GhostCommit";
|
||||
import type { LocalShellAction } from "./LocalShellAction";
|
||||
import type { LocalShellStatus } from "./LocalShellStatus";
|
||||
import type { MessagePhase } from "./MessagePhase";
|
||||
@@ -15,4 +14,4 @@ export type ResponseItem = { "type": "message", role: string, content: Array<Con
|
||||
/**
|
||||
* Set when using the Responses API.
|
||||
*/
|
||||
call_id: string | null, status: LocalShellStatus, action: LocalShellAction, } | { "type": "function_call", name: string, namespace?: string, arguments: string, call_id: string, } | { "type": "tool_search_call", call_id: string | null, status?: string, execution: string, arguments: unknown, } | { "type": "function_call_output", call_id: string, output: FunctionCallOutputBody, } | { "type": "custom_tool_call", status?: string, call_id: string, name: string, input: string, } | { "type": "custom_tool_call_output", call_id: string, name?: string, output: FunctionCallOutputBody, } | { "type": "tool_search_output", call_id: string | null, status: string, execution: string, tools: unknown[], } | { "type": "web_search_call", status?: string, action?: WebSearchAction, } | { "type": "image_generation_call", id: string, status: string, revised_prompt?: string, result: string, } | { "type": "ghost_snapshot", ghost_commit: GhostCommit, } | { "type": "compaction", encrypted_content: string, } | { "type": "other" };
|
||||
call_id: string | null, status: LocalShellStatus, action: LocalShellAction, } | { "type": "function_call", name: string, namespace?: string, arguments: string, call_id: string, } | { "type": "tool_search_call", call_id: string | null, status?: string, execution: string, arguments: unknown, } | { "type": "function_call_output", call_id: string, output: FunctionCallOutputBody, } | { "type": "custom_tool_call", status?: string, call_id: string, name: string, input: string, } | { "type": "custom_tool_call_output", call_id: string, name?: string, output: FunctionCallOutputBody, } | { "type": "tool_search_output", call_id: string | null, status: string, execution: string, tools: unknown[], } | { "type": "web_search_call", status?: string, action?: WebSearchAction, } | { "type": "image_generation_call", id: string, status: string, revised_prompt?: string, result: string, } | { "type": "compaction", encrypted_content: string, } | { "type": "other" };
|
||||
|
||||
@@ -29,7 +29,6 @@ export type { GetAuthStatusParams } from "./GetAuthStatusParams";
|
||||
export type { GetAuthStatusResponse } from "./GetAuthStatusResponse";
|
||||
export type { GetConversationSummaryParams } from "./GetConversationSummaryParams";
|
||||
export type { GetConversationSummaryResponse } from "./GetConversationSummaryResponse";
|
||||
export type { GhostCommit } from "./GhostCommit";
|
||||
export type { GitDiffToRemoteParams } from "./GitDiffToRemoteParams";
|
||||
export type { GitDiffToRemoteResponse } from "./GitDiffToRemoteResponse";
|
||||
export type { GitSha } from "./GitSha";
|
||||
|
||||
@@ -362,7 +362,8 @@ pub struct ConfigToml {
|
||||
/// Suppress warnings about unstable (under development) features.
|
||||
pub suppress_unstable_features_warning: Option<bool>,
|
||||
|
||||
/// Settings for ghost snapshots (used for undo).
|
||||
/// Compatibility-only settings retained so legacy `ghost_snapshot`
|
||||
/// config still loads.
|
||||
#[serde(default)]
|
||||
pub ghost_snapshot: Option<GhostSnapshotToml>,
|
||||
|
||||
@@ -629,14 +630,13 @@ impl From<ToolsToml> for Tools {
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct GhostSnapshotToml {
|
||||
/// Exclude untracked files larger than this many bytes from ghost snapshots.
|
||||
/// Legacy no-op setting retained for compatibility.
|
||||
#[serde(alias = "ignore_untracked_files_over_bytes")]
|
||||
pub ignore_large_untracked_files: Option<i64>,
|
||||
/// Ignore untracked directories that contain this many files or more.
|
||||
/// (Still emits a warning unless warnings are disabled.)
|
||||
/// Legacy no-op setting retained for compatibility.
|
||||
#[serde(alias = "large_untracked_dir_warning_threshold")]
|
||||
pub ignore_large_untracked_dirs: Option<i64>,
|
||||
/// Disable all ghost snapshot warning events.
|
||||
/// Legacy no-op setting retained for compatibility.
|
||||
pub disable_warnings: Option<bool>,
|
||||
}
|
||||
|
||||
|
||||
@@ -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`.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -383,7 +383,6 @@ fn build_arc_monitor_message_item(
|
||||
| ResponseItem::CustomToolCallOutput { .. }
|
||||
| ResponseItem::ToolSearchOutput { .. }
|
||||
| ResponseItem::ImageGenerationCall { .. }
|
||||
| ResponseItem::GhostSnapshot { .. }
|
||||
| ResponseItem::Compaction { .. }
|
||||
| ResponseItem::Other => None,
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")]);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(¬es, "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(¬es)?, "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(¬es)?, "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(())
|
||||
|
||||
@@ -71,7 +71,8 @@ impl Stage {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum Feature {
|
||||
// Stable.
|
||||
/// Create a ghost commit at each turn.
|
||||
/// Removed compatibility flag retained as a no-op so old configs can
|
||||
/// still parse `undo`.
|
||||
GhostCommit,
|
||||
/// Enable the default shell tool.
|
||||
ShellTool,
|
||||
@@ -392,6 +393,9 @@ impl Features {
|
||||
"tui_app_server" => {
|
||||
continue;
|
||||
}
|
||||
"undo" => {
|
||||
continue;
|
||||
}
|
||||
"js_repl" => {
|
||||
continue;
|
||||
}
|
||||
@@ -620,7 +624,7 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
FeatureSpec {
|
||||
id: Feature::GhostCommit,
|
||||
key: "undo",
|
||||
stage: Stage::Stable,
|
||||
stage: Stage::Removed,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
|
||||
@@ -54,6 +54,12 @@ fn use_linux_sandbox_bwrap_is_removed_and_disabled_by_default() {
|
||||
assert_eq!(Feature::UseLinuxSandboxBwrap.default_enabled(), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_is_removed_and_disabled_by_default() {
|
||||
assert_eq!(Feature::GhostCommit.stage(), Stage::Removed);
|
||||
assert_eq!(Feature::GhostCommit.default_enabled(), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_detail_original_is_removed_and_disabled_by_default() {
|
||||
assert_eq!(Feature::ImageDetailOriginal.stage(), Stage::Removed);
|
||||
@@ -349,6 +355,22 @@ fn from_sources_ignores_removed_image_detail_original_feature_key() {
|
||||
assert_eq!(features, Features::with_defaults());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_sources_ignores_removed_undo_feature_key() {
|
||||
let features_toml = FeaturesToml::from(BTreeMap::from([("undo".to_string(), true)]));
|
||||
|
||||
let features = Features::from_sources(
|
||||
FeatureConfigSource {
|
||||
features: Some(&features_toml),
|
||||
..Default::default()
|
||||
},
|
||||
FeatureConfigSource::default(),
|
||||
FeatureOverrides::default(),
|
||||
);
|
||||
|
||||
assert_eq!(features, Features::with_defaults());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_sources_ignores_removed_js_repl_feature_keys() {
|
||||
let features_toml = FeaturesToml::from(BTreeMap::from([
|
||||
|
||||
@@ -32,5 +32,4 @@ ts-rs = { workspace = true, features = [
|
||||
walkdir = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
assert_matches = { workspace = true }
|
||||
pretty_assertions = { workspace = true }
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
# codex-git-utils
|
||||
|
||||
Helpers for interacting with git, including patch application and worktree
|
||||
snapshot utilities. The crate also exposes a lightweight baseline API for
|
||||
internal directories that use git only as a resettable diff mechanism:
|
||||
`ensure_git_baseline_repository` preserves a usable `root/.git` baseline or
|
||||
creates one when it is missing or unusable, `reset_git_repository` replaces
|
||||
`root/.git` with a fresh one-commit baseline, and `diff_since_latest_init`
|
||||
returns structured file changes plus a unified diff from that baseline to the
|
||||
current directory contents.
|
||||
Helpers for interacting with git, including patch application. The crate also
|
||||
exposes a lightweight baseline API for internal directories that use git only
|
||||
as a resettable diff mechanism: `ensure_git_baseline_repository` preserves a
|
||||
usable `root/.git` baseline or creates one when it is missing or unusable,
|
||||
`reset_git_repository` replaces `root/.git` with a fresh one-commit baseline,
|
||||
and `diff_since_latest_init` returns structured file changes plus a unified
|
||||
diff from that baseline to the current directory contents.
|
||||
|
||||
```rust,no_run
|
||||
use std::path::Path;
|
||||
|
||||
use codex_git_utils::{
|
||||
apply_git_patch, create_ghost_commit, restore_ghost_commit, ApplyGitRequest,
|
||||
CreateGhostCommitOptions,
|
||||
};
|
||||
use codex_git_utils::{apply_git_patch, ApplyGitRequest};
|
||||
|
||||
let repo = Path::new("/path/to/repo");
|
||||
|
||||
@@ -27,13 +23,4 @@ let request = ApplyGitRequest {
|
||||
preflight: false,
|
||||
};
|
||||
let result = apply_git_patch(&request)?;
|
||||
|
||||
// Capture the current working tree as an unreferenced commit.
|
||||
let ghost = create_ghost_commit(&CreateGhostCommitOptions::new(repo))?;
|
||||
|
||||
// Later, undo back to that state.
|
||||
restore_ghost_commit(repo, &ghost)?;
|
||||
```
|
||||
|
||||
Pass a custom message with `.message("…")` or force-include ignored files with
|
||||
`.force_include(["ignored.log".into()])`.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,6 @@ mod apply;
|
||||
mod baseline;
|
||||
mod branch;
|
||||
mod errors;
|
||||
mod ghost_commits;
|
||||
mod info;
|
||||
mod operations;
|
||||
mod platform;
|
||||
@@ -20,21 +19,8 @@ pub use baseline::diff_since_latest_init;
|
||||
pub use baseline::ensure_git_baseline_repository;
|
||||
pub use baseline::reset_git_repository;
|
||||
pub use branch::merge_base_with_head;
|
||||
pub use codex_protocol::models::GhostCommit;
|
||||
pub use codex_protocol::protocol::GitSha;
|
||||
pub use errors::GitToolingError;
|
||||
pub use ghost_commits::CreateGhostCommitOptions;
|
||||
pub use ghost_commits::GhostSnapshotConfig;
|
||||
pub use ghost_commits::GhostSnapshotReport;
|
||||
pub use ghost_commits::IgnoredUntrackedFile;
|
||||
pub use ghost_commits::LargeUntrackedDir;
|
||||
pub use ghost_commits::RestoreGhostCommitOptions;
|
||||
pub use ghost_commits::capture_ghost_snapshot_report;
|
||||
pub use ghost_commits::create_ghost_commit;
|
||||
pub use ghost_commits::create_ghost_commit_with_report;
|
||||
pub use ghost_commits::restore_ghost_commit;
|
||||
pub use ghost_commits::restore_ghost_commit_with_options;
|
||||
pub use ghost_commits::restore_to_commit;
|
||||
pub use info::CommitLogEntry;
|
||||
pub use info::GitDiffToRemote;
|
||||
pub use info::GitInfo;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use std::ffi::OsStr;
|
||||
use std::ffi::OsString;
|
||||
use std::path::Component;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
@@ -45,38 +44,6 @@ pub(crate) fn resolve_head(path: &Path) -> Result<Option<String>, GitToolingErro
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_relative_path(path: &Path) -> Result<PathBuf, GitToolingError> {
|
||||
let mut result = PathBuf::new();
|
||||
let mut saw_component = false;
|
||||
for component in path.components() {
|
||||
saw_component = true;
|
||||
match component {
|
||||
Component::Normal(part) => result.push(part),
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir => {
|
||||
if !result.pop() {
|
||||
return Err(GitToolingError::PathEscapesRepository {
|
||||
path: path.to_path_buf(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Component::RootDir | Component::Prefix(_) => {
|
||||
return Err(GitToolingError::NonRelativePath {
|
||||
path: path.to_path_buf(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !saw_component {
|
||||
return Err(GitToolingError::NonRelativePath {
|
||||
path: path.to_path_buf(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_repository_root(path: &Path) -> Result<PathBuf, GitToolingError> {
|
||||
let root = run_git_for_stdout(
|
||||
path,
|
||||
@@ -89,47 +56,6 @@ pub(crate) fn resolve_repository_root(path: &Path) -> Result<PathBuf, GitTooling
|
||||
Ok(PathBuf::from(root))
|
||||
}
|
||||
|
||||
pub(crate) fn apply_repo_prefix_to_force_include(
|
||||
prefix: Option<&Path>,
|
||||
paths: &[PathBuf],
|
||||
) -> Vec<PathBuf> {
|
||||
if paths.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
match prefix {
|
||||
Some(prefix) => paths.iter().map(|path| prefix.join(path)).collect(),
|
||||
None => paths.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn repo_subdir(repo_root: &Path, repo_path: &Path) -> Option<PathBuf> {
|
||||
if repo_root == repo_path {
|
||||
return None;
|
||||
}
|
||||
|
||||
repo_path
|
||||
.strip_prefix(repo_root)
|
||||
.ok()
|
||||
.and_then(non_empty_path)
|
||||
.or_else(|| {
|
||||
let repo_root_canon = repo_root.canonicalize().ok()?;
|
||||
let repo_path_canon = repo_path.canonicalize().ok()?;
|
||||
repo_path_canon
|
||||
.strip_prefix(&repo_root_canon)
|
||||
.ok()
|
||||
.and_then(non_empty_path)
|
||||
})
|
||||
}
|
||||
|
||||
fn non_empty_path(path: &Path) -> Option<PathBuf> {
|
||||
if path.as_os_str().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(path.to_path_buf())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn run_git_for_status<I, S>(
|
||||
dir: &Path,
|
||||
args: I,
|
||||
@@ -161,27 +87,6 @@ where
|
||||
})
|
||||
}
|
||||
|
||||
/// Executes `git` and returns the full stdout without trimming so callers
|
||||
/// can parse delimiter-sensitive output, propagating UTF-8 errors with context.
|
||||
pub(crate) fn run_git_for_stdout_all<I, S>(
|
||||
dir: &Path,
|
||||
args: I,
|
||||
env: Option<&[(OsString, OsString)]>,
|
||||
) -> Result<String, GitToolingError>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<OsStr>,
|
||||
{
|
||||
// Keep the raw stdout untouched so callers can parse delimiter-sensitive
|
||||
// output (e.g. NUL-separated paths) without trimming artefacts.
|
||||
let run = run_git(dir, args, env)?;
|
||||
// Propagate UTF-8 conversion failures with the command context for debugging.
|
||||
String::from_utf8(run.output.stdout).map_err(|source| GitToolingError::GitOutputUtf8 {
|
||||
command: run.command,
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
fn run_git<I, S>(
|
||||
dir: &Path,
|
||||
args: I,
|
||||
|
||||
@@ -1078,7 +1078,6 @@ impl SessionTelemetry {
|
||||
ResponseItem::CustomToolCallOutput { .. } => "custom_tool_call_output".into(),
|
||||
ResponseItem::WebSearchCall { .. } => "web_search_call".into(),
|
||||
ResponseItem::ImageGenerationCall { .. } => "image_generation_call".into(),
|
||||
ResponseItem::GhostSnapshot { .. } => "ghost_snapshot".into(),
|
||||
ResponseItem::Compaction { .. } => "compaction".into(),
|
||||
ResponseItem::Other => "other".into(),
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
use std::io;
|
||||
use std::num::NonZeroUsize;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_utils_image::PromptImageMode;
|
||||
use codex_utils_image::load_for_prompt_bytes;
|
||||
@@ -28,60 +26,6 @@ use schemars::JsonSchema;
|
||||
|
||||
use crate::mcp::CallToolResult;
|
||||
|
||||
type CommitID = String;
|
||||
|
||||
/// Details of a ghost commit created from a repository state.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
pub struct GhostCommit {
|
||||
id: CommitID,
|
||||
parent: Option<CommitID>,
|
||||
preexisting_untracked_files: Vec<PathBuf>,
|
||||
preexisting_untracked_dirs: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl GhostCommit {
|
||||
/// Create a new ghost commit wrapper from a raw commit ID and optional parent.
|
||||
pub fn new(
|
||||
id: CommitID,
|
||||
parent: Option<CommitID>,
|
||||
preexisting_untracked_files: Vec<PathBuf>,
|
||||
preexisting_untracked_dirs: Vec<PathBuf>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
parent,
|
||||
preexisting_untracked_files,
|
||||
preexisting_untracked_dirs,
|
||||
}
|
||||
}
|
||||
|
||||
/// Commit ID for the snapshot.
|
||||
pub fn id(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
|
||||
/// Parent commit ID, if the repository had a `HEAD` at creation time.
|
||||
pub fn parent(&self) -> Option<&str> {
|
||||
self.parent.as_deref()
|
||||
}
|
||||
|
||||
/// Untracked or ignored files that already existed when the snapshot was captured.
|
||||
pub fn preexisting_untracked_files(&self) -> &[PathBuf] {
|
||||
&self.preexisting_untracked_files
|
||||
}
|
||||
|
||||
/// Untracked or ignored directories that already existed when the snapshot was captured.
|
||||
pub fn preexisting_untracked_dirs(&self) -> &[PathBuf] {
|
||||
&self.preexisting_untracked_dirs
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for GhostCommit {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Controls the per-command sandbox override requested by a shell-like tool call.
|
||||
#[derive(
|
||||
Debug, Clone, Copy, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS,
|
||||
@@ -879,14 +823,8 @@ pub enum ResponseItem {
|
||||
revised_prompt: Option<String>,
|
||||
result: String,
|
||||
},
|
||||
// Generated by the harness but considered exactly as a model response.
|
||||
GhostSnapshot {
|
||||
ghost_commit: GhostCommit,
|
||||
},
|
||||
#[serde(alias = "compaction_summary")]
|
||||
Compaction {
|
||||
encrypted_content: String,
|
||||
},
|
||||
Compaction { encrypted_content: String },
|
||||
#[serde(other)]
|
||||
Other,
|
||||
}
|
||||
@@ -1640,6 +1578,7 @@ mod tests {
|
||||
use anyhow::Result;
|
||||
use codex_execpolicy::Policy;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
@@ -2380,6 +2319,24 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserializes_legacy_ghost_snapshot_as_other() -> Result<()> {
|
||||
let json = r#"{
|
||||
"type":"ghost_snapshot",
|
||||
"ghost_commit":{
|
||||
"id":"ghost-1",
|
||||
"parent":null,
|
||||
"preexisting_untracked_files":[],
|
||||
"preexisting_untracked_dirs":[]
|
||||
}
|
||||
}"#;
|
||||
|
||||
let item: ResponseItem = serde_json::from_str(json)?;
|
||||
|
||||
assert_eq!(item, ResponseItem::Other);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrips_web_search_call_actions() -> Result<()> {
|
||||
let cases = vec![
|
||||
|
||||
@@ -775,7 +775,10 @@ pub enum Op {
|
||||
/// model.
|
||||
SetThreadMemoryMode { mode: ThreadMemoryMode },
|
||||
|
||||
/// Request Codex to undo a turn (turn are stacked so it is the same effect as CMD + Z).
|
||||
/// Legacy request to undo a turn.
|
||||
///
|
||||
/// The op is still accepted for compatibility, but ghost snapshots are no
|
||||
/// longer produced so the request reports unavailable.
|
||||
Undo,
|
||||
|
||||
/// Request Codex to drop the last N user turns from in-memory context.
|
||||
|
||||
@@ -37,7 +37,6 @@ pub fn should_persist_response_item(item: &ResponseItem) -> bool {
|
||||
| ResponseItem::CustomToolCallOutput { .. }
|
||||
| ResponseItem::WebSearchCall { .. }
|
||||
| ResponseItem::ImageGenerationCall { .. }
|
||||
| ResponseItem::GhostSnapshot { .. }
|
||||
| ResponseItem::Compaction { .. } => true,
|
||||
ResponseItem::Other => false,
|
||||
}
|
||||
@@ -58,7 +57,6 @@ pub fn should_persist_response_item_for_memories(item: &ResponseItem) -> bool {
|
||||
| ResponseItem::WebSearchCall { .. } => true,
|
||||
ResponseItem::Reasoning { .. }
|
||||
| ResponseItem::ImageGenerationCall { .. }
|
||||
| ResponseItem::GhostSnapshot { .. }
|
||||
| ResponseItem::Compaction { .. }
|
||||
| ResponseItem::Other => false,
|
||||
}
|
||||
|
||||
@@ -867,7 +867,7 @@ impl RolloutRecorder {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let v: Value = match serde_json::from_str(line) {
|
||||
let mut v: Value = match serde_json::from_str(line) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
warn!("failed to parse line as JSON: {line:?}, error: {e}");
|
||||
@@ -875,6 +875,10 @@ impl RolloutRecorder {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if strip_legacy_ghost_snapshot_rollout_line(&mut v) {
|
||||
trace!("skipping legacy ghost_snapshot rollout line");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse the rollout line structure
|
||||
match serde_json::from_value::<RolloutLine>(v.clone()) {
|
||||
@@ -961,6 +965,29 @@ impl RolloutRecorder {
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_legacy_ghost_snapshot_rollout_line(value: &mut Value) -> bool {
|
||||
match value.get("type").and_then(Value::as_str) {
|
||||
Some("response_item") => value
|
||||
.get("payload")
|
||||
.is_some_and(is_legacy_ghost_snapshot_response_item),
|
||||
Some("compacted") => {
|
||||
if let Some(replacement_history) = value
|
||||
.get_mut("payload")
|
||||
.and_then(|payload| payload.get_mut("replacement_history"))
|
||||
.and_then(Value::as_array_mut)
|
||||
{
|
||||
replacement_history.retain(|item| !is_legacy_ghost_snapshot_response_item(item));
|
||||
}
|
||||
false
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_legacy_ghost_snapshot_response_item(value: &Value) -> bool {
|
||||
value.get("type").and_then(Value::as_str) == Some("ghost_snapshot")
|
||||
}
|
||||
|
||||
fn truncate_fs_page(
|
||||
mut page: ThreadsPage,
|
||||
page_size: usize,
|
||||
|
||||
@@ -4,9 +4,12 @@ use super::*;
|
||||
use crate::config::RolloutConfig;
|
||||
use chrono::TimeZone;
|
||||
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::AgentMessageEvent;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::RolloutLine;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::TurnContextItem;
|
||||
use codex_protocol::protocol::UserMessageEvent;
|
||||
@@ -62,6 +65,161 @@ fn write_session_file(root: &Path, ts: &str, uuid: Uuid) -> std::io::Result<Path
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_rollout_items_skips_legacy_ghost_snapshot_lines() -> std::io::Result<()> {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let rollout_path = home.path().join("rollout.jsonl");
|
||||
let mut file = File::create(&rollout_path)?;
|
||||
let thread_id = ThreadId::new();
|
||||
let ts = "2025-01-03T12:00:00Z";
|
||||
|
||||
writeln!(
|
||||
file,
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"timestamp": ts,
|
||||
"type": "session_meta",
|
||||
"payload": {
|
||||
"id": thread_id,
|
||||
"timestamp": ts,
|
||||
"cwd": ".",
|
||||
"originator": "test_originator",
|
||||
"cli_version": "test_version",
|
||||
"source": "cli",
|
||||
"model_provider": "test-provider",
|
||||
},
|
||||
})
|
||||
)?;
|
||||
writeln!(
|
||||
file,
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"timestamp": ts,
|
||||
"type": "response_item",
|
||||
"payload": {
|
||||
"type": "ghost_snapshot",
|
||||
"ghost_commit": {
|
||||
"id": "deadbeef",
|
||||
"preexisting_untracked_dirs": [],
|
||||
"preexisting_untracked_files": [],
|
||||
},
|
||||
},
|
||||
})
|
||||
)?;
|
||||
writeln!(
|
||||
file,
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"timestamp": ts,
|
||||
"type": "response_item",
|
||||
"payload": {
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": "hello",
|
||||
}
|
||||
],
|
||||
},
|
||||
})
|
||||
)?;
|
||||
|
||||
let (items, loaded_thread_id, parse_errors) =
|
||||
RolloutRecorder::load_rollout_items(&rollout_path).await?;
|
||||
|
||||
assert_eq!(loaded_thread_id, Some(thread_id));
|
||||
assert_eq!(parse_errors, 0);
|
||||
assert_eq!(items.len(), 2);
|
||||
assert!(matches!(items[0], RolloutItem::SessionMeta(_)));
|
||||
assert!(matches!(
|
||||
items[1],
|
||||
RolloutItem::ResponseItem(ResponseItem::Message { .. })
|
||||
));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_rollout_items_filters_legacy_ghost_snapshots_from_compaction_history()
|
||||
-> std::io::Result<()> {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let rollout_path = home.path().join("rollout.jsonl");
|
||||
let mut file = File::create(&rollout_path)?;
|
||||
let thread_id = ThreadId::new();
|
||||
let ts = "2025-01-03T12:00:00Z";
|
||||
|
||||
writeln!(
|
||||
file,
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"timestamp": ts,
|
||||
"type": "session_meta",
|
||||
"payload": {
|
||||
"id": thread_id,
|
||||
"timestamp": ts,
|
||||
"cwd": ".",
|
||||
"originator": "test_originator",
|
||||
"cli_version": "test_version",
|
||||
"source": "cli",
|
||||
"model_provider": "test-provider",
|
||||
},
|
||||
})
|
||||
)?;
|
||||
writeln!(
|
||||
file,
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"timestamp": ts,
|
||||
"type": "compacted",
|
||||
"payload": {
|
||||
"message": "summary",
|
||||
"replacement_history": [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": "kept",
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"type": "ghost_snapshot",
|
||||
"ghost_commit": {
|
||||
"id": "deadbeef",
|
||||
"preexisting_untracked_dirs": [],
|
||||
"preexisting_untracked_files": [],
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
})
|
||||
)?;
|
||||
|
||||
let (items, loaded_thread_id, parse_errors) =
|
||||
RolloutRecorder::load_rollout_items(&rollout_path).await?;
|
||||
|
||||
assert_eq!(loaded_thread_id, Some(thread_id));
|
||||
assert_eq!(parse_errors, 0);
|
||||
assert_eq!(items.len(), 2);
|
||||
let RolloutItem::Compacted(compacted) = &items[1] else {
|
||||
panic!("expected compacted rollout item");
|
||||
};
|
||||
let replacement_history = compacted
|
||||
.replacement_history
|
||||
.as_ref()
|
||||
.expect("replacement history");
|
||||
assert_eq!(replacement_history.len(), 1);
|
||||
assert!(matches!(
|
||||
&replacement_history[0],
|
||||
ResponseItem::Message { .. }
|
||||
));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recorder_materializes_on_flush_with_pending_items() -> std::io::Result<()> {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
|
||||
+4
-2
@@ -1,11 +1,13 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
source: tui/src/chatwidget/tests/popups_and_settings.rs
|
||||
expression: popup
|
||||
---
|
||||
Experimental features
|
||||
Toggle experimental features. Changes are saved to config.toml.
|
||||
|
||||
› [ ] Ghost snapshots Capture undo snapshots each turn.
|
||||
› [ ] JavaScript REPL Enable a persistent Node-backed JavaScript REPL for
|
||||
interactive website debugging and other inline
|
||||
JavaScript execution capabilities.
|
||||
[x] Shell tool Allow the model to run shell commands.
|
||||
|
||||
Press space to select or enter to save for next conversation
|
||||
|
||||
@@ -1692,9 +1692,9 @@ async fn experimental_features_popup_snapshot() {
|
||||
|
||||
let features = vec![
|
||||
ExperimentalFeatureItem {
|
||||
feature: Feature::GhostCommit,
|
||||
name: "Ghost snapshots".to_string(),
|
||||
description: "Capture undo snapshots each turn.".to_string(),
|
||||
feature: Feature::JsRepl,
|
||||
name: "JavaScript REPL".to_string(),
|
||||
description: "Enable a persistent Node-backed JavaScript REPL for interactive website debugging and other inline JavaScript execution capabilities.".to_string(),
|
||||
enabled: false,
|
||||
},
|
||||
ExperimentalFeatureItem {
|
||||
@@ -1715,12 +1715,12 @@ async fn experimental_features_popup_snapshot() {
|
||||
async fn experimental_features_toggle_saves_on_exit() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
let expected_feature = Feature::GhostCommit;
|
||||
let expected_feature = Feature::JsRepl;
|
||||
let view = ExperimentalFeaturesView::new(
|
||||
vec![ExperimentalFeatureItem {
|
||||
feature: expected_feature,
|
||||
name: "Ghost snapshots".to_string(),
|
||||
description: "Capture undo snapshots each turn.".to_string(),
|
||||
name: "JavaScript REPL".to_string(),
|
||||
description: "Enable a persistent Node-backed JavaScript REPL for interactive website debugging and other inline JavaScript execution capabilities.".to_string(),
|
||||
enabled: false,
|
||||
}],
|
||||
chat.app_event_tx.clone(),
|
||||
|
||||
Reference in New Issue
Block a user