mirror of
https://github.com/openai/codex.git
synced 2026-02-01 22:47:52 +00:00
Compare commits
12 Commits
capture-sh
...
jif/async-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de430ae728 | ||
|
|
597ac3b875 | ||
|
|
de63774a9e | ||
|
|
25defbf253 | ||
|
|
7761cbcec7 | ||
|
|
637713178d | ||
|
|
c43b2f5538 | ||
|
|
8c51d50dbb | ||
|
|
1eb368a095 | ||
|
|
5095aca10b | ||
|
|
01f74b5ee6 | ||
|
|
e3f10031b7 |
791
codex-rs/Cargo.lock
generated
791
codex-rs/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,8 @@ codex-app-server-protocol = { workspace = true }
|
||||
codex-apply-patch = { workspace = true }
|
||||
codex-file-search = { workspace = true }
|
||||
codex-mcp-client = { workspace = true }
|
||||
codex-git-tooling = { workspace = true }
|
||||
codex-utils-readiness = { workspace = true }
|
||||
codex-otel = { workspace = true, features = ["otel"] }
|
||||
codex-protocol = { workspace = true }
|
||||
codex-rmcp-client = { workspace = true }
|
||||
|
||||
@@ -113,6 +113,7 @@ use crate::unified_exec::UnifiedExecSessionManager;
|
||||
use crate::user_instructions::UserInstructions;
|
||||
use crate::user_notification::UserNotification;
|
||||
use crate::util::backoff;
|
||||
// Ghost snapshot/undo logic resides in `undo.rs` via inherent methods on `Session`.
|
||||
use codex_otel::otel_event_manager::OtelEventManager;
|
||||
use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig;
|
||||
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
|
||||
@@ -122,6 +123,7 @@ use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::InitialHistory;
|
||||
// Readiness types used only inside `undo.rs`.
|
||||
|
||||
pub mod compact;
|
||||
use self::compact::build_compacted_history;
|
||||
@@ -243,7 +245,7 @@ use crate::state::SessionState;
|
||||
pub(crate) struct Session {
|
||||
conversation_id: ConversationId,
|
||||
tx_event: Sender<Event>,
|
||||
state: Mutex<SessionState>,
|
||||
pub(crate) state: Mutex<SessionState>,
|
||||
pub(crate) active_turn: Mutex<Option<ActiveTurn>>,
|
||||
pub(crate) services: SessionServices,
|
||||
next_internal_sub_id: AtomicU64,
|
||||
@@ -857,6 +859,8 @@ impl Session {
|
||||
turn_diff_tracker: SharedTurnDiffTracker,
|
||||
exec_command_context: ExecCommandContext,
|
||||
) {
|
||||
// Ensure core pre-tool readiness (e.g., ghost snapshot) before the first tool call.
|
||||
self.await_pretool_ready().await;
|
||||
let ExecCommandContext {
|
||||
sub_id,
|
||||
call_id,
|
||||
@@ -1164,6 +1168,14 @@ async fn submission_loop(
|
||||
Op::Interrupt => {
|
||||
sess.interrupt_task().await;
|
||||
}
|
||||
Op::UndoLastSnapshot => {
|
||||
let cwd = turn_context.cwd.clone();
|
||||
let sub_id = sub.id.clone();
|
||||
let sess2 = Arc::clone(&sess);
|
||||
tokio::spawn(async move {
|
||||
sess2.undo_last_snapshot(&cwd, &sub_id).await;
|
||||
});
|
||||
}
|
||||
Op::OverrideTurnContext {
|
||||
cwd,
|
||||
approval_policy,
|
||||
@@ -1663,6 +1675,9 @@ pub(crate) async fn run_task(
|
||||
};
|
||||
sess.send_event(event).await;
|
||||
|
||||
// Initialize pre-tool readiness and kick off the ghost snapshot worker in the background.
|
||||
sess.init_pretool_from_turn(turn_context.as_ref()).await;
|
||||
|
||||
let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input);
|
||||
// For review threads, keep an isolated in-memory history so the
|
||||
// model sees a fresh conversation without the parent session's history.
|
||||
|
||||
@@ -81,6 +81,7 @@ pub use rollout::list::Cursor;
|
||||
mod function_tool;
|
||||
mod state;
|
||||
mod tasks;
|
||||
mod undo;
|
||||
mod user_notification;
|
||||
pub mod util;
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ pub(crate) async fn handle_mcp_tool_call(
|
||||
tool_name: String,
|
||||
arguments: String,
|
||||
) -> ResponseInputItem {
|
||||
// Ensure core pre-tool readiness (e.g., ghost snapshot) before the first tool call.
|
||||
sess.await_pretool_ready().await;
|
||||
// Parse the `arguments` as JSON. An empty string is OK, but invalid JSON
|
||||
// is not.
|
||||
let arguments_value = if arguments.trim().is_empty() {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Session-wide mutable state.
|
||||
|
||||
use codex_git_tooling::GhostCommit;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
|
||||
use crate::conversation_history::ConversationHistory;
|
||||
@@ -13,6 +14,9 @@ pub(crate) struct SessionState {
|
||||
pub(crate) history: ConversationHistory,
|
||||
pub(crate) token_info: Option<TokenUsageInfo>,
|
||||
pub(crate) latest_rate_limits: Option<RateLimitSnapshot>,
|
||||
/// Core-managed undo snapshots for `/undo` (ring buffer; bounded for memory control).
|
||||
pub(crate) undo_snapshots: Vec<GhostCommit>,
|
||||
pub(crate) undo_snapshots_disabled: bool,
|
||||
}
|
||||
|
||||
impl SessionState {
|
||||
@@ -20,6 +24,8 @@ impl SessionState {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
history: ConversationHistory::new(),
|
||||
undo_snapshots: Vec::new(),
|
||||
undo_snapshots_disabled: false,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -64,6 +70,23 @@ impl SessionState {
|
||||
(self.token_info.clone(), self.latest_rate_limits.clone())
|
||||
}
|
||||
|
||||
// Undo snapshot ring helpers
|
||||
pub(crate) fn push_undo_snapshot(&mut self, gc: GhostCommit) {
|
||||
const MAX_TRACKED_GHOST_COMMITS: usize = 20;
|
||||
self.undo_snapshots.push(gc);
|
||||
if self.undo_snapshots.len() > MAX_TRACKED_GHOST_COMMITS {
|
||||
self.undo_snapshots.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn pop_undo_snapshot(&mut self) -> Option<GhostCommit> {
|
||||
self.undo_snapshots.pop()
|
||||
}
|
||||
|
||||
pub(crate) fn push_back_undo_snapshot(&mut self, gc: GhostCommit) {
|
||||
self.undo_snapshots.push(gc);
|
||||
}
|
||||
|
||||
pub(crate) fn set_token_usage_full(&mut self, context_window: u64) {
|
||||
match &mut self.token_info {
|
||||
Some(info) => info.fill_to_context_window(context_window),
|
||||
|
||||
@@ -7,6 +7,8 @@ use tokio::sync::Mutex;
|
||||
use tokio::task::AbortHandle;
|
||||
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_utils_readiness::ReadinessFlag;
|
||||
use codex_utils_readiness::Token;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use crate::protocol::ReviewDecision;
|
||||
@@ -71,6 +73,9 @@ impl ActiveTurn {
|
||||
pub(crate) struct TurnState {
|
||||
pending_approvals: HashMap<String, oneshot::Sender<ReviewDecision>>,
|
||||
pending_input: Vec<ResponseInputItem>,
|
||||
// Pre-tool readiness gating (ghost snapshot, etc.)
|
||||
pub(crate) pretool_flag: Option<Arc<ReadinessFlag>>,
|
||||
pub(crate) pretool_sub_token: Option<Token>,
|
||||
}
|
||||
|
||||
impl TurnState {
|
||||
|
||||
147
codex-rs/core/src/undo.rs
Normal file
147
codex-rs/core/src/undo.rs
Normal file
@@ -0,0 +1,147 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_git_tooling::CreateGhostCommitOptions;
|
||||
use codex_git_tooling::create_ghost_commit;
|
||||
use codex_git_tooling::restore_ghost_commit;
|
||||
use codex_utils_readiness::Readiness;
|
||||
use codex_utils_readiness::ReadinessFlag;
|
||||
use tokio::task;
|
||||
use tracing::info;
|
||||
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
|
||||
impl Session {
|
||||
/// Initialize pre-tool readiness and kick off the ghost snapshot worker for this turn.
|
||||
/// No-op for review mode or when snapshots are disabled for this session.
|
||||
pub async fn init_pretool_from_turn(self: &Arc<Self>, turn_context: &TurnContext) {
|
||||
if turn_context.is_review_mode {
|
||||
return;
|
||||
}
|
||||
{
|
||||
let state = self.state.lock().await;
|
||||
if state.undo_snapshots_disabled {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let flag = Arc::new(ReadinessFlag::new());
|
||||
let token = match flag.subscribe().await {
|
||||
Ok(tok) => tok,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
{
|
||||
if let Some(active) = self.active_turn.lock().await.as_mut() {
|
||||
let mut ts = active.turn_state.lock().await;
|
||||
ts.pretool_flag = Some(Arc::clone(&flag));
|
||||
ts.pretool_sub_token = Some(token);
|
||||
}
|
||||
}
|
||||
|
||||
let cwd = turn_context.cwd.clone();
|
||||
let sess = Arc::clone(self);
|
||||
// Capture the readiness flag and token so we can always mark readiness,
|
||||
// avoiding races with locks held by await_pretool_ready().
|
||||
let ready_flag = Arc::clone(&flag);
|
||||
let ready_token = token;
|
||||
tokio::spawn(async move {
|
||||
// Perform git operations on a blocking thread.
|
||||
let res = task::spawn_blocking(move || {
|
||||
let options = CreateGhostCommitOptions::new(&cwd);
|
||||
create_ghost_commit(&options)
|
||||
})
|
||||
.await;
|
||||
|
||||
// Mark flag as ready in all cases, unconditionally using the captured token.
|
||||
let _ = ready_flag.mark_ready(ready_token).await;
|
||||
|
||||
match res {
|
||||
Ok(Ok(commit)) => {
|
||||
let short_id: String = commit.id().chars().take(8).collect();
|
||||
info!("created ghost snapshot {short_id}");
|
||||
let mut state = sess.state.lock().await;
|
||||
state.push_undo_snapshot(commit);
|
||||
state.undo_snapshots_disabled = false;
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
let mut state = sess.state.lock().await;
|
||||
state.undo_snapshots_disabled = true;
|
||||
let msg = match &err {
|
||||
codex_git_tooling::GitToolingError::NotAGitRepository { .. } => {
|
||||
"Snapshots disabled: current directory is not a Git repository."
|
||||
.to_string()
|
||||
}
|
||||
_ => format!("Snapshots disabled after error: {err}"),
|
||||
};
|
||||
let _ = sess.notify_background_event("", msg).await;
|
||||
}
|
||||
Err(join_err) => {
|
||||
let mut state = sess.state.lock().await;
|
||||
state.undo_snapshots_disabled = true;
|
||||
let msg = format!("Snapshot worker failed to run: {join_err}");
|
||||
let _ = sess.notify_background_event("", msg).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Await pre-tool readiness (ghost snapshot) once. Subsequent calls resolve immediately.
|
||||
pub async fn await_pretool_ready(&self) {
|
||||
let flag_opt = {
|
||||
let active = self.active_turn.lock().await;
|
||||
match active.as_ref() {
|
||||
Some(at) => {
|
||||
let ts = at.turn_state.lock().await;
|
||||
ts.pretool_flag.clone()
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(flag) = flag_opt {
|
||||
flag.wait_ready().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore the workspace to the last ghost snapshot, if any.
|
||||
pub async fn undo_last_snapshot(&self, cwd: &std::path::Path, sub_id: &str) {
|
||||
let maybe_commit = {
|
||||
let mut state = self.state.lock().await;
|
||||
state.pop_undo_snapshot()
|
||||
};
|
||||
|
||||
let Some(commit) = maybe_commit else {
|
||||
self.notify_background_event(sub_id, "No snapshot available to undo.")
|
||||
.await;
|
||||
return;
|
||||
};
|
||||
|
||||
let commit_id = commit.id().to_string();
|
||||
match task::spawn_blocking({
|
||||
let cwd = cwd.to_path_buf();
|
||||
let commit = commit.clone();
|
||||
move || restore_ghost_commit(&cwd, &commit)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok(())) => {
|
||||
let short_id: String = commit_id.chars().take(8).collect();
|
||||
let msg = format!("Restored workspace to snapshot {short_id}");
|
||||
self.notify_background_event(sub_id, msg).await;
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
let mut state = self.state.lock().await;
|
||||
state.push_back_undo_snapshot(commit);
|
||||
let msg = format!("Failed to restore snapshot: {err}");
|
||||
self.notify_background_event(sub_id, msg).await;
|
||||
}
|
||||
Err(join_err) => {
|
||||
let mut state = self.state.lock().await;
|
||||
state.push_back_undo_snapshot(commit);
|
||||
let msg = format!("Failed to restore snapshot: {join_err}");
|
||||
self.notify_background_event(sub_id, msg).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ mod stream_no_completed;
|
||||
mod tool_harness;
|
||||
mod tool_parallelism;
|
||||
mod tools;
|
||||
mod undo;
|
||||
mod unified_exec;
|
||||
mod user_notification;
|
||||
mod view_image;
|
||||
|
||||
246
codex-rs/core/tests/suite/undo.rs
Normal file
246
codex-rs/core/tests/suite/undo.rs
Normal file
@@ -0,0 +1,246 @@
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::InputItem;
|
||||
use codex_core::protocol::Op;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[allow(clippy::expect_used)]
|
||||
fn run_git_in(repo_path: &Path, args: &[&str]) {
|
||||
let status = Command::new("git")
|
||||
.current_dir(repo_path)
|
||||
.args(args)
|
||||
.status()
|
||||
.expect("git command");
|
||||
assert!(status.success(), "git command failed: {args:?}");
|
||||
}
|
||||
|
||||
fn init_test_repo(repo: &Path) {
|
||||
run_git_in(repo, &["init", "--initial-branch=main"]);
|
||||
run_git_in(repo, &["config", "core.autocrlf", "false"]);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn undo_no_snapshot_emits_background_message() {
|
||||
// No snapshot has been created in this session. Undo should report that clearly.
|
||||
let server = responses::start_mock_server().await;
|
||||
|
||||
let mut builder = test_codex();
|
||||
let codex = builder
|
||||
.build(&server)
|
||||
.await
|
||||
.expect("start codex conversation");
|
||||
|
||||
let id = codex
|
||||
.codex
|
||||
.submit(Op::UndoLastSnapshot)
|
||||
.await
|
||||
.expect("submit undo");
|
||||
|
||||
// Expect a background event saying there is no snapshot to undo.
|
||||
let ev = wait_for_event(&codex.codex, |msg| match msg {
|
||||
EventMsg::BackgroundEvent(ev) => ev.message.contains("No snapshot available to undo."),
|
||||
_ => false,
|
||||
})
|
||||
.await;
|
||||
match ev {
|
||||
EventMsg::BackgroundEvent(ev) => {
|
||||
assert!(ev.message.contains("No snapshot available to undo."));
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
|
||||
// Avoid unused id warnings in case diagnostics change.
|
||||
assert!(!id.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn undo_restores_workspace_root() {
|
||||
skip_if_no_network!();
|
||||
|
||||
// Create a git repository with an initial commit.
|
||||
let repo = TempDir::new().expect("tempdir");
|
||||
let repo_path = repo.path();
|
||||
init_test_repo(repo_path);
|
||||
std::fs::write(repo_path.join("tracked.txt"), "v1\n").unwrap();
|
||||
run_git_in(repo_path, &["add", "."]);
|
||||
run_git_in(
|
||||
repo_path,
|
||||
&[
|
||||
"-c",
|
||||
"user.name=Tester",
|
||||
"-c",
|
||||
"user.email=test@example.com",
|
||||
"commit",
|
||||
"-m",
|
||||
"initial",
|
||||
],
|
||||
);
|
||||
|
||||
// Start a Codex session rooted at the repo and run a minimal turn to trigger snapshot.
|
||||
let server = responses::start_mock_server().await;
|
||||
let sse = responses::sse(vec![responses::ev_completed("r1")]);
|
||||
responses::mount_sse_once(&server, sse).await;
|
||||
|
||||
let repo_root = repo_path.to_path_buf();
|
||||
let mut builder = test_codex().with_config(move |c| {
|
||||
c.cwd = repo_root;
|
||||
});
|
||||
let codex = builder
|
||||
.build(&server)
|
||||
.await
|
||||
.expect("start codex conversation");
|
||||
|
||||
codex
|
||||
.codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![InputItem::Text {
|
||||
text: "hello".to_string(),
|
||||
}],
|
||||
})
|
||||
.await
|
||||
.expect("submit input");
|
||||
|
||||
// Wait for the request to reach the mock server to rule out network issues.
|
||||
{
|
||||
use tokio::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
let mut tries = 0u32;
|
||||
loop {
|
||||
let reqs = server.received_requests().await.unwrap();
|
||||
if !reqs.is_empty() || tries > 50 {
|
||||
break;
|
||||
}
|
||||
tries += 1;
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
let reqs = server.received_requests().await.unwrap();
|
||||
assert!(
|
||||
!reqs.is_empty(),
|
||||
"model request was not observed by mock server"
|
||||
);
|
||||
}
|
||||
|
||||
// Wait until the turn completes.
|
||||
let _ = wait_for_event(&codex.codex, |msg| matches!(msg, EventMsg::TaskComplete(_))).await;
|
||||
|
||||
// Change tracked file after the snapshot.
|
||||
std::fs::write(repo_path.join("tracked.txt"), "v2\n").unwrap();
|
||||
|
||||
// Request undo and await confirmation.
|
||||
let _undo_id = codex
|
||||
.codex
|
||||
.submit(Op::UndoLastSnapshot)
|
||||
.await
|
||||
.expect("submit undo");
|
||||
let _ = wait_for_event(&codex.codex, |msg| match msg {
|
||||
EventMsg::BackgroundEvent(ev) => ev.message.starts_with("Restored workspace to snapshot"),
|
||||
_ => false,
|
||||
})
|
||||
.await;
|
||||
|
||||
// File content should be restored to v1.
|
||||
let after = std::fs::read_to_string(repo_path.join("tracked.txt")).unwrap();
|
||||
assert_eq!(after, "v1\n");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn undo_restores_only_within_subdirectory() {
|
||||
skip_if_no_network!();
|
||||
|
||||
// Create a git repository with a tracked file in a subdirectory.
|
||||
let repo = TempDir::new().expect("tempdir");
|
||||
let repo_path = repo.path();
|
||||
init_test_repo(repo_path);
|
||||
let workspace = repo_path.join("workspace");
|
||||
std::fs::create_dir_all(&workspace).unwrap();
|
||||
std::fs::write(repo_path.join("root.txt"), "root v1\n").unwrap();
|
||||
std::fs::write(workspace.join("nested.txt"), "nested v1\n").unwrap();
|
||||
run_git_in(repo_path, &["add", "."]);
|
||||
run_git_in(
|
||||
repo_path,
|
||||
&[
|
||||
"-c",
|
||||
"user.name=Tester",
|
||||
"-c",
|
||||
"user.email=test@example.com",
|
||||
"commit",
|
||||
"-m",
|
||||
"initial",
|
||||
],
|
||||
);
|
||||
|
||||
// Start a Codex session rooted at the subdirectory and run a turn to trigger a subdir snapshot.
|
||||
let server = responses::start_mock_server().await;
|
||||
let sse = responses::sse(vec![responses::ev_completed("r1")]);
|
||||
responses::mount_sse_once(&server, sse).await;
|
||||
|
||||
let workspace_dir = workspace.clone();
|
||||
let mut builder = test_codex().with_config(move |c| {
|
||||
c.cwd = workspace_dir;
|
||||
});
|
||||
let codex = builder
|
||||
.build(&server)
|
||||
.await
|
||||
.expect("start codex conversation");
|
||||
|
||||
codex
|
||||
.codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![InputItem::Text {
|
||||
text: "hello".to_string(),
|
||||
}],
|
||||
})
|
||||
.await
|
||||
.expect("submit input");
|
||||
|
||||
// Wait for the request to reach the mock server to rule out network issues.
|
||||
{
|
||||
use tokio::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
let mut tries = 0u32;
|
||||
loop {
|
||||
let reqs = server.received_requests().await.unwrap();
|
||||
if !reqs.is_empty() || tries > 50 {
|
||||
break;
|
||||
}
|
||||
tries += 1;
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
let reqs = server.received_requests().await.unwrap();
|
||||
assert!(
|
||||
!reqs.is_empty(),
|
||||
"model request was not observed by mock server"
|
||||
);
|
||||
}
|
||||
|
||||
let _ = wait_for_event(&codex.codex, |msg| matches!(msg, EventMsg::TaskComplete(_))).await;
|
||||
|
||||
// Modify files both inside and outside the subdirectory.
|
||||
std::fs::write(repo_path.join("root.txt"), "root v2\n").unwrap();
|
||||
std::fs::write(workspace.join("nested.txt"), "nested v2\n").unwrap();
|
||||
|
||||
// Undo should restore the snapshot only within the subdirectory (workspace).
|
||||
let _ = codex
|
||||
.codex
|
||||
.submit(Op::UndoLastSnapshot)
|
||||
.await
|
||||
.expect("submit undo");
|
||||
let _ = wait_for_event(&codex.codex, |msg| match msg {
|
||||
EventMsg::BackgroundEvent(ev) => ev.message.starts_with("Restored workspace to snapshot"),
|
||||
_ => false,
|
||||
})
|
||||
.await;
|
||||
|
||||
// Verify: nested.txt restored to v1; root.txt remains at v2.
|
||||
let nested_after = std::fs::read_to_string(workspace.join("nested.txt")).unwrap();
|
||||
assert_eq!(nested_after, "nested v1\n");
|
||||
let root_after = std::fs::read_to_string(repo_path.join("root.txt")).unwrap();
|
||||
assert_eq!(root_after, "root v2\n");
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
use std::collections::HashSet;
|
||||
use std::ffi::OsString;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -14,6 +17,7 @@ use crate::operations::resolve_head;
|
||||
use crate::operations::resolve_repository_root;
|
||||
use crate::operations::run_git_for_status;
|
||||
use crate::operations::run_git_for_stdout;
|
||||
use crate::operations::run_git_for_stdout_all;
|
||||
|
||||
/// Default commit message used for ghost commits when none is provided.
|
||||
const DEFAULT_COMMIT_MESSAGE: &str = "codex snapshot";
|
||||
@@ -69,6 +73,8 @@ pub fn create_ghost_commit(
|
||||
let repo_root = resolve_repository_root(options.repo_path)?;
|
||||
let repo_prefix = repo_subdir(repo_root.as_path(), options.repo_path);
|
||||
let parent = resolve_head(repo_root.as_path())?;
|
||||
let existing_untracked =
|
||||
capture_existing_untracked(repo_root.as_path(), repo_prefix.as_deref())?;
|
||||
|
||||
let normalized_force = options
|
||||
.force_include
|
||||
@@ -84,6 +90,16 @@ pub fn create_ghost_commit(
|
||||
OsString::from(index_path.as_os_str()),
|
||||
)];
|
||||
|
||||
// Pre-populate the temporary index with HEAD so unchanged tracked files
|
||||
// are included in the snapshot tree.
|
||||
if let Some(parent_sha) = parent.as_deref() {
|
||||
run_git_for_status(
|
||||
repo_root.as_path(),
|
||||
vec![OsString::from("read-tree"), OsString::from(parent_sha)],
|
||||
Some(base_env.as_slice()),
|
||||
)?;
|
||||
}
|
||||
|
||||
let mut add_args = vec![OsString::from("add"), OsString::from("--all")];
|
||||
if let Some(prefix) = repo_prefix.as_deref() {
|
||||
add_args.extend([OsString::from("--"), prefix.as_os_str().to_os_string()]);
|
||||
@@ -127,12 +143,29 @@ pub fn create_ghost_commit(
|
||||
Some(commit_env.as_slice()),
|
||||
)?;
|
||||
|
||||
Ok(GhostCommit::new(commit_id, parent))
|
||||
Ok(GhostCommit::new(
|
||||
commit_id,
|
||||
parent,
|
||||
existing_untracked.files,
|
||||
existing_untracked.dirs,
|
||||
))
|
||||
}
|
||||
|
||||
/// Restore the working tree to match the provided ghost commit.
|
||||
pub fn restore_ghost_commit(repo_path: &Path, commit: &GhostCommit) -> Result<(), GitToolingError> {
|
||||
restore_to_commit(repo_path, commit.id())
|
||||
ensure_git_repository(repo_path)?;
|
||||
|
||||
let repo_root = resolve_repository_root(repo_path)?;
|
||||
let repo_prefix = repo_subdir(repo_root.as_path(), repo_path);
|
||||
let current_untracked =
|
||||
capture_existing_untracked(repo_root.as_path(), repo_prefix.as_deref())?;
|
||||
remove_new_untracked(
|
||||
repo_root.as_path(),
|
||||
commit.preexisting_untracked_files(),
|
||||
commit.preexisting_untracked_dirs(),
|
||||
current_untracked,
|
||||
)?;
|
||||
restore_to_commit_inner(repo_root.as_path(), repo_prefix.as_deref(), commit.id())
|
||||
}
|
||||
|
||||
/// Restore the working tree to match the given commit ID.
|
||||
@@ -141,7 +174,16 @@ pub fn restore_to_commit(repo_path: &Path, commit_id: &str) -> Result<(), GitToo
|
||||
|
||||
let repo_root = resolve_repository_root(repo_path)?;
|
||||
let repo_prefix = repo_subdir(repo_root.as_path(), repo_path);
|
||||
restore_to_commit_inner(repo_root.as_path(), repo_prefix.as_deref(), commit_id)
|
||||
}
|
||||
|
||||
/// Restores the working tree and index to the given commit using `git restore`.
|
||||
/// The repository root and optional repository-relative prefix limit the restore scope.
|
||||
fn restore_to_commit_inner(
|
||||
repo_root: &Path,
|
||||
repo_prefix: Option<&Path>,
|
||||
commit_id: &str,
|
||||
) -> Result<(), GitToolingError> {
|
||||
let mut restore_args = vec![
|
||||
OsString::from("restore"),
|
||||
OsString::from("--source"),
|
||||
@@ -150,13 +192,138 @@ pub fn restore_to_commit(repo_path: &Path, commit_id: &str) -> Result<(), GitToo
|
||||
OsString::from("--staged"),
|
||||
OsString::from("--"),
|
||||
];
|
||||
if let Some(prefix) = repo_prefix.as_deref() {
|
||||
if let Some(prefix) = repo_prefix {
|
||||
restore_args.push(prefix.as_os_str().to_os_string());
|
||||
} else {
|
||||
restore_args.push(OsString::from("."));
|
||||
}
|
||||
|
||||
run_git_for_status(repo_root.as_path(), restore_args, None)?;
|
||||
run_git_for_status(repo_root, restore_args, None)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct UntrackedSnapshot {
|
||||
files: Vec<PathBuf>,
|
||||
dirs: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
/// Captures the repository's untracked files and directories scoped to an optional subdirectory.
|
||||
fn capture_existing_untracked(
|
||||
repo_root: &Path,
|
||||
repo_prefix: Option<&Path>,
|
||||
) -> Result<UntrackedSnapshot, GitToolingError> {
|
||||
let mut args = vec![
|
||||
OsString::from("status"),
|
||||
OsString::from("--porcelain=2"),
|
||||
OsString::from("-z"),
|
||||
OsString::from("--ignored=matching"),
|
||||
OsString::from("--untracked-files=all"),
|
||||
];
|
||||
if let Some(prefix) = repo_prefix {
|
||||
args.push(OsString::from("--"));
|
||||
args.push(prefix.as_os_str().to_os_string());
|
||||
}
|
||||
|
||||
let output = run_git_for_stdout_all(repo_root, args, None)?;
|
||||
if output.is_empty() {
|
||||
return Ok(UntrackedSnapshot::default());
|
||||
}
|
||||
|
||||
let mut snapshot = UntrackedSnapshot::default();
|
||||
for entry in output.split('\0') {
|
||||
if entry.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut parts = entry.splitn(2, ' ');
|
||||
let code = parts.next();
|
||||
let path_part = parts.next();
|
||||
let (Some(code), Some(path_part)) = (code, path_part) else {
|
||||
continue;
|
||||
};
|
||||
if code != "?" && code != "!" {
|
||||
continue;
|
||||
}
|
||||
if path_part.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let normalized = normalize_relative_path(Path::new(path_part))?;
|
||||
let absolute = repo_root.join(&normalized);
|
||||
let is_dir = absolute.is_dir();
|
||||
if is_dir {
|
||||
snapshot.dirs.push(normalized);
|
||||
} else {
|
||||
snapshot.files.push(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
/// Removes untracked files and directories that were not present when the snapshot was captured.
|
||||
fn remove_new_untracked(
|
||||
repo_root: &Path,
|
||||
preserved_files: &[PathBuf],
|
||||
preserved_dirs: &[PathBuf],
|
||||
current: UntrackedSnapshot,
|
||||
) -> Result<(), GitToolingError> {
|
||||
if current.files.is_empty() && current.dirs.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let preserved_file_set: HashSet<PathBuf> = preserved_files.iter().cloned().collect();
|
||||
let preserved_dirs_vec: Vec<PathBuf> = preserved_dirs.to_vec();
|
||||
|
||||
for path in current.files {
|
||||
if should_preserve(&path, &preserved_file_set, &preserved_dirs_vec) {
|
||||
continue;
|
||||
}
|
||||
remove_path(&repo_root.join(&path))?;
|
||||
}
|
||||
|
||||
for dir in current.dirs {
|
||||
if should_preserve(&dir, &preserved_file_set, &preserved_dirs_vec) {
|
||||
continue;
|
||||
}
|
||||
remove_path(&repo_root.join(&dir))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Determines whether an untracked path should be kept because it existed in the snapshot.
|
||||
fn should_preserve(
|
||||
path: &Path,
|
||||
preserved_files: &HashSet<PathBuf>,
|
||||
preserved_dirs: &[PathBuf],
|
||||
) -> bool {
|
||||
if preserved_files.contains(path) {
|
||||
return true;
|
||||
}
|
||||
|
||||
preserved_dirs
|
||||
.iter()
|
||||
.any(|dir| path.starts_with(dir.as_path()))
|
||||
}
|
||||
|
||||
/// Deletes the file or directory at the provided path, ignoring if it is already absent.
|
||||
fn remove_path(path: &Path) -> Result<(), GitToolingError> {
|
||||
match fs::symlink_metadata(path) {
|
||||
Ok(metadata) => {
|
||||
if metadata.is_dir() {
|
||||
fs::remove_dir_all(path)?;
|
||||
} else {
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
if err.kind() == io::ErrorKind::NotFound {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(err.into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -239,6 +406,9 @@ mod tests {
|
||||
],
|
||||
);
|
||||
|
||||
let preexisting_untracked = repo.join("notes.txt");
|
||||
std::fs::write(&preexisting_untracked, "notes before\n")?;
|
||||
|
||||
let tracked_contents = "modified contents\n";
|
||||
std::fs::write(repo.join("tracked.txt"), tracked_contents)?;
|
||||
std::fs::remove_file(repo.join("delete-me.txt"))?;
|
||||
@@ -267,6 +437,7 @@ mod tests {
|
||||
std::fs::write(repo.join("ignored.txt"), "changed\n")?;
|
||||
std::fs::remove_file(repo.join("new-file.txt"))?;
|
||||
std::fs::write(repo.join("ephemeral.txt"), "temp data\n")?;
|
||||
std::fs::write(&preexisting_untracked, "notes after\n")?;
|
||||
|
||||
restore_ghost_commit(repo, &ghost)?;
|
||||
|
||||
@@ -277,7 +448,9 @@ mod tests {
|
||||
let new_file_after = std::fs::read_to_string(repo.join("new-file.txt"))?;
|
||||
assert_eq!(new_file_after, new_file_contents);
|
||||
assert_eq!(repo.join("delete-me.txt").exists(), false);
|
||||
assert!(repo.join("ephemeral.txt").exists());
|
||||
assert!(!repo.join("ephemeral.txt").exists());
|
||||
let notes_after = std::fs::read_to_string(&preexisting_untracked)?;
|
||||
assert_eq!(notes_after, "notes before\n");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -488,7 +661,43 @@ mod tests {
|
||||
assert!(vscode.join("settings.json").exists());
|
||||
let settings_after = std::fs::read_to_string(vscode.join("settings.json"))?;
|
||||
assert_eq!(settings_after, "{\n \"after\": true\n}\n");
|
||||
assert!(repo.join("temp.txt").exists());
|
||||
assert!(!repo.join("temp.txt").exists());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Restoring removes ignored directories created after the snapshot.
|
||||
fn restore_removes_new_ignored_directory() -> Result<(), GitToolingError> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let repo = temp.path();
|
||||
init_test_repo(repo);
|
||||
|
||||
std::fs::write(repo.join(".gitignore"), ".vscode/\n")?;
|
||||
std::fs::write(repo.join("tracked.txt"), "snapshot version\n")?;
|
||||
run_git_in(repo, &["add", ".gitignore", "tracked.txt"]);
|
||||
run_git_in(
|
||||
repo,
|
||||
&[
|
||||
"-c",
|
||||
"user.name=Tester",
|
||||
"-c",
|
||||
"user.email=test@example.com",
|
||||
"commit",
|
||||
"-m",
|
||||
"initial",
|
||||
],
|
||||
);
|
||||
|
||||
let ghost = create_ghost_commit(&CreateGhostCommitOptions::new(repo))?;
|
||||
|
||||
let vscode = repo.join(".vscode");
|
||||
std::fs::create_dir_all(&vscode)?;
|
||||
std::fs::write(vscode.join("settings.json"), "{\n \"after\": true\n}\n")?;
|
||||
|
||||
restore_ghost_commit(repo, &ghost)?;
|
||||
|
||||
assert!(!vscode.exists());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::fmt;
|
||||
use std::path::PathBuf;
|
||||
|
||||
mod errors;
|
||||
mod ghost_commits;
|
||||
@@ -12,17 +13,31 @@ pub use ghost_commits::restore_ghost_commit;
|
||||
pub use ghost_commits::restore_to_commit;
|
||||
pub use platform::create_symlink;
|
||||
|
||||
type CommitID = String;
|
||||
|
||||
/// Details of a ghost commit created from a repository state.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GhostCommit {
|
||||
id: String,
|
||||
parent: Option<String>,
|
||||
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: String, parent: Option<String>) -> Self {
|
||||
Self { id, 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.
|
||||
@@ -34,6 +49,16 @@ impl GhostCommit {
|
||||
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 {
|
||||
|
||||
@@ -161,6 +161,22 @@ where
|
||||
})
|
||||
}
|
||||
|
||||
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>,
|
||||
{
|
||||
let run = run_git(dir, args, env)?;
|
||||
String::from_utf8(run.output.stdout).map_err(|source| GitToolingError::GitOutputUtf8 {
|
||||
command: run.command,
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
fn run_git<I, S>(
|
||||
dir: &Path,
|
||||
args: I,
|
||||
|
||||
@@ -174,6 +174,10 @@ pub enum Op {
|
||||
/// Request a code review from the agent.
|
||||
Review { review_request: ReviewRequest },
|
||||
|
||||
/// Restore the workspace to the most recent core-managed ghost snapshot, if any.
|
||||
/// A concise status update is emitted via `EventMsg::BackgroundEvent`.
|
||||
UndoLastSnapshot,
|
||||
|
||||
/// Request to shut down codex instance.
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
@@ -35,7 +35,6 @@ codex-common = { workspace = true, features = [
|
||||
] }
|
||||
codex-core = { workspace = true }
|
||||
codex-file-search = { workspace = true }
|
||||
codex-git-tooling = { workspace = true }
|
||||
codex-login = { workspace = true }
|
||||
codex-ollama = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
|
||||
@@ -110,16 +110,9 @@ use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig;
|
||||
use codex_file_search::FileMatch;
|
||||
use codex_git_tooling::CreateGhostCommitOptions;
|
||||
use codex_git_tooling::GhostCommit;
|
||||
use codex_git_tooling::GitToolingError;
|
||||
use codex_git_tooling::create_ghost_commit;
|
||||
use codex_git_tooling::restore_ghost_commit;
|
||||
use codex_protocol::plan_tool::UpdatePlanArgs;
|
||||
use strum::IntoEnumIterator;
|
||||
|
||||
const MAX_TRACKED_GHOST_COMMITS: usize = 20;
|
||||
|
||||
// Track information about an in-flight exec command.
|
||||
struct RunningCommand {
|
||||
command: Vec<String>,
|
||||
@@ -261,9 +254,6 @@ pub(crate) struct ChatWidget {
|
||||
pending_notification: Option<Notification>,
|
||||
// Simple review mode flag; used to adjust layout and banners.
|
||||
is_review_mode: bool,
|
||||
// List of ghost commits corresponding to each turn.
|
||||
ghost_snapshots: Vec<GhostCommit>,
|
||||
ghost_snapshots_disabled: bool,
|
||||
// Whether to add a final message separator after the last message
|
||||
needs_final_message_separator: bool,
|
||||
|
||||
@@ -636,6 +626,8 @@ impl ChatWidget {
|
||||
|
||||
fn on_background_event(&mut self, message: String) {
|
||||
debug!("BackgroundEvent: {message}");
|
||||
self.add_to_history(history_cell::new_background_event(message));
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
fn on_stream_error(&mut self, message: String) {
|
||||
@@ -955,8 +947,6 @@ impl ChatWidget {
|
||||
suppress_session_configured_redraw: false,
|
||||
pending_notification: None,
|
||||
is_review_mode: false,
|
||||
ghost_snapshots: Vec::new(),
|
||||
ghost_snapshots_disabled: true,
|
||||
needs_final_message_separator: false,
|
||||
last_rendered_width: std::cell::Cell::new(None),
|
||||
}
|
||||
@@ -1020,8 +1010,6 @@ impl ChatWidget {
|
||||
suppress_session_configured_redraw: true,
|
||||
pending_notification: None,
|
||||
is_review_mode: false,
|
||||
ghost_snapshots: Vec::new(),
|
||||
ghost_snapshots_disabled: true,
|
||||
needs_final_message_separator: false,
|
||||
last_rendered_width: std::cell::Cell::new(None),
|
||||
}
|
||||
@@ -1165,7 +1153,12 @@ impl ChatWidget {
|
||||
self.app_event_tx.send(AppEvent::ExitRequest);
|
||||
}
|
||||
SlashCommand::Undo => {
|
||||
self.undo_last_snapshot();
|
||||
self.add_info_message(
|
||||
"Restoring workspace to the last Codex snapshot...".to_string(),
|
||||
Some("This may take a few seconds.".to_string()),
|
||||
);
|
||||
// Delegate undo to core which manages the snapshot ring.
|
||||
self.submit_op(Op::UndoLastSnapshot);
|
||||
}
|
||||
SlashCommand::Diff => {
|
||||
self.add_diff_in_progress();
|
||||
@@ -1282,8 +1275,6 @@ impl ChatWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
self.capture_ghost_snapshot();
|
||||
|
||||
let mut items: Vec<InputItem> = Vec::new();
|
||||
|
||||
if !text.is_empty() {
|
||||
@@ -1316,57 +1307,6 @@ impl ChatWidget {
|
||||
self.needs_final_message_separator = false;
|
||||
}
|
||||
|
||||
fn capture_ghost_snapshot(&mut self) {
|
||||
if self.ghost_snapshots_disabled {
|
||||
return;
|
||||
}
|
||||
|
||||
let options = CreateGhostCommitOptions::new(&self.config.cwd);
|
||||
match create_ghost_commit(&options) {
|
||||
Ok(commit) => {
|
||||
self.ghost_snapshots.push(commit);
|
||||
if self.ghost_snapshots.len() > MAX_TRACKED_GHOST_COMMITS {
|
||||
self.ghost_snapshots.remove(0);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
self.ghost_snapshots_disabled = true;
|
||||
let (message, hint) = match &err {
|
||||
GitToolingError::NotAGitRepository { .. } => (
|
||||
"Snapshots disabled: current directory is not a Git repository."
|
||||
.to_string(),
|
||||
None,
|
||||
),
|
||||
_ => (
|
||||
format!("Snapshots disabled after error: {err}"),
|
||||
Some(
|
||||
"Restart Codex after resolving the issue to re-enable snapshots."
|
||||
.to_string(),
|
||||
),
|
||||
),
|
||||
};
|
||||
self.add_info_message(message, hint);
|
||||
tracing::warn!("failed to create ghost snapshot: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn undo_last_snapshot(&mut self) {
|
||||
let Some(commit) = self.ghost_snapshots.pop() else {
|
||||
self.add_info_message("No snapshot available to undo.".to_string(), None);
|
||||
return;
|
||||
};
|
||||
|
||||
if let Err(err) = restore_ghost_commit(&self.config.cwd, &commit) {
|
||||
self.add_error_message(format!("Failed to restore snapshot: {err}"));
|
||||
self.ghost_snapshots.push(commit);
|
||||
return;
|
||||
}
|
||||
|
||||
let short_id: String = commit.id().chars().take(8).collect();
|
||||
self.add_info_message(format!("Restored workspace to snapshot {short_id}"), None);
|
||||
}
|
||||
|
||||
/// Replay a subset of initial events into the UI to seed the transcript when
|
||||
/// resuming an existing session. This approximates the live event flow and
|
||||
/// is intentionally conservative: only safe-to-replay items are rendered to
|
||||
|
||||
@@ -15,6 +15,7 @@ use codex_core::protocol::AgentMessageEvent;
|
||||
use codex_core::protocol::AgentReasoningDeltaEvent;
|
||||
use codex_core::protocol::AgentReasoningEvent;
|
||||
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
|
||||
use codex_core::protocol::BackgroundEventEvent;
|
||||
use codex_core::protocol::Event;
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::ExecApprovalRequestEvent;
|
||||
@@ -178,6 +179,31 @@ fn entered_review_mode_defaults_to_current_changes_banner() {
|
||||
assert!(chat.is_review_mode);
|
||||
}
|
||||
|
||||
/// Background events produce a visible info cell in the history.
|
||||
#[test]
|
||||
fn background_event_renders_in_history() {
|
||||
let (mut chat, mut rx, _ops) = make_chatwidget_manual();
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "bg".to_string(),
|
||||
msg: EventMsg::BackgroundEvent(BackgroundEventEvent {
|
||||
message: "Restored workspace to snapshot deadbeef".to_string(),
|
||||
}),
|
||||
});
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert_eq!(cells.len(), 1, "expected a single background event cell");
|
||||
let rendered = lines_to_single_string(&cells[0]);
|
||||
assert!(
|
||||
rendered.contains("Restored workspace to snapshot deadbeef"),
|
||||
"background event text should be present"
|
||||
);
|
||||
assert!(
|
||||
rendered.starts_with("• "),
|
||||
"background events should use a bullet prefix"
|
||||
);
|
||||
}
|
||||
|
||||
/// Completing review with findings shows the selection popup and finishes with
|
||||
/// the closing banner while clearing review mode state.
|
||||
#[test]
|
||||
@@ -286,8 +312,6 @@ fn make_chatwidget_manual() -> (
|
||||
suppress_session_configured_redraw: false,
|
||||
pending_notification: None,
|
||||
is_review_mode: false,
|
||||
ghost_snapshots: Vec::new(),
|
||||
ghost_snapshots_disabled: false,
|
||||
needs_final_message_separator: false,
|
||||
last_rendered_width: std::cell::Cell::new(None),
|
||||
};
|
||||
|
||||
@@ -270,6 +270,30 @@ impl HistoryCell for PlainHistoryCell {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct BackgroundEventCell {
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl BackgroundEventCell {
|
||||
pub(crate) fn new(message: String) -> Self {
|
||||
Self { message }
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryCell for BackgroundEventCell {
|
||||
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
let wrap_width = width.max(2) as usize;
|
||||
let message_line = Line::from(self.message.as_str()).style(Style::default().dim());
|
||||
word_wrap_lines(
|
||||
&[message_line],
|
||||
RtOptions::new(wrap_width)
|
||||
.initial_indent(Line::from("• ".dim()))
|
||||
.subsequent_indent(Line::from(" ")),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct PrefixedWrappedHistoryCell {
|
||||
text: Text<'static>,
|
||||
@@ -1067,6 +1091,10 @@ pub(crate) fn new_error_event(message: String) -> PlainHistoryCell {
|
||||
PlainHistoryCell { lines }
|
||||
}
|
||||
|
||||
pub(crate) fn new_background_event(message: String) -> BackgroundEventCell {
|
||||
BackgroundEventCell::new(message)
|
||||
}
|
||||
|
||||
/// Render a user‑friendly plan update styled like a checkbox todo list.
|
||||
pub(crate) fn new_plan_update(update: UpdatePlanArgs) -> PlanUpdateCell {
|
||||
let UpdatePlanArgs { explanation, plan } = update;
|
||||
|
||||
@@ -85,13 +85,7 @@ pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> {
|
||||
let show_beta_features = beta_features_enabled();
|
||||
|
||||
SlashCommand::iter()
|
||||
.filter(|cmd| {
|
||||
if *cmd == SlashCommand::Undo {
|
||||
show_beta_features
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.filter(|cmd| show_beta_features || *cmd != SlashCommand::Undo)
|
||||
.map(|c| (c.command(), c))
|
||||
.collect()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user