Skip to content

Commit 8b7ec31

Browse files
authored
feat(app-server): thread/rollback API (#8454)
Add `thread/rollback` to app-server to support IDEs undo-ing the last N turns of a thread. For context, an IDE partner will be supporting an "undo" capability where the IDE (the app-server client) will be responsible for reverting the local changes made during the last turn. To support this well, we also need a way to drop the last turn (or more generally, the last N turns) from the agent's context. This is what `thread/rollback` does. **Core idea**: A Thread rollback is represented as a persisted event message (EventMsg::ThreadRollback) in the rollout JSONL file, not by rewriting history. On resume, both the model's context (core replay) and the UI turn list (app-server v2's thread history builder) apply these markers so the pruned history is consistent across live conversations and `thread/resume`. Implementation notes: - Rollback only affects agent context and appends to the rollout file; clients are responsible for reverting files on disk. - If a thread rollback is currently in progress, subsequent `thread/rollback` calls are rejected. - Because we use `CodexConversation::submit` and codex core tracks active turns, returning an error on concurrent rollbacks is communicated via an `EventMsg::Error` with a new variant `CodexErrorInfo::ThreadRollbackFailed`. app-server watches for that and sends the BAD_REQUEST RPC response. Tests cover thread rollbacks in both core and app-server, including when `num_turns` > existing turns (which clears all turns). **Note**: this explicitly does **not** behave like `/undo` which we just removed from the CLI, which does the opposite of what `thread/rollback` does. `/undo` reverts local changes via ghost commits/snapshots and does not modify the agent's context / conversation history.
1 parent 188f79a commit 8b7ec31

File tree

21 files changed

+1187
-30
lines changed

21 files changed

+1187
-30
lines changed

codex-rs/app-server-protocol/src/protocol/common.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,10 @@ client_request_definitions! {
113113
params: v2::ThreadArchiveParams,
114114
response: v2::ThreadArchiveResponse,
115115
},
116+
ThreadRollback => "thread/rollback" {
117+
params: v2::ThreadRollbackParams,
118+
response: v2::ThreadRollbackResponse,
119+
},
116120
ThreadList => "thread/list" {
117121
params: v2::ThreadListParams,
118122
response: v2::ThreadListResponse,

codex-rs/app-server-protocol/src/protocol/thread_history.rs

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use crate::protocol::v2::UserInput;
66
use codex_protocol::protocol::AgentReasoningEvent;
77
use codex_protocol::protocol::AgentReasoningRawContentEvent;
88
use codex_protocol::protocol::EventMsg;
9+
use codex_protocol::protocol::ThreadRolledBackEvent;
910
use codex_protocol::protocol::TurnAbortedEvent;
1011
use codex_protocol::protocol::UserMessageEvent;
1112

@@ -57,6 +58,7 @@ impl ThreadHistoryBuilder {
5758
EventMsg::TokenCount(_) => {}
5859
EventMsg::EnteredReviewMode(_) => {}
5960
EventMsg::ExitedReviewMode(_) => {}
61+
EventMsg::ThreadRolledBack(payload) => self.handle_thread_rollback(payload),
6062
EventMsg::UndoCompleted(_) => {}
6163
EventMsg::TurnAborted(payload) => self.handle_turn_aborted(payload),
6264
_ => {}
@@ -130,6 +132,23 @@ impl ThreadHistoryBuilder {
130132
turn.status = TurnStatus::Interrupted;
131133
}
132134

135+
fn handle_thread_rollback(&mut self, payload: &ThreadRolledBackEvent) {
136+
self.finish_current_turn();
137+
138+
let n = usize::try_from(payload.num_turns).unwrap_or(usize::MAX);
139+
if n >= self.turns.len() {
140+
self.turns.clear();
141+
} else {
142+
self.turns.truncate(self.turns.len().saturating_sub(n));
143+
}
144+
145+
// Re-number subsequent synthetic ids so the pruned history is consistent.
146+
self.next_turn_index =
147+
i64::try_from(self.turns.len().saturating_add(1)).unwrap_or(i64::MAX);
148+
let item_count: usize = self.turns.iter().map(|t| t.items.len()).sum();
149+
self.next_item_index = i64::try_from(item_count.saturating_add(1)).unwrap_or(i64::MAX);
150+
}
151+
133152
fn finish_current_turn(&mut self) {
134153
if let Some(turn) = self.current_turn.take() {
135154
if turn.items.is_empty() {
@@ -213,6 +232,7 @@ mod tests {
213232
use codex_protocol::protocol::AgentMessageEvent;
214233
use codex_protocol::protocol::AgentReasoningEvent;
215234
use codex_protocol::protocol::AgentReasoningRawContentEvent;
235+
use codex_protocol::protocol::ThreadRolledBackEvent;
216236
use codex_protocol::protocol::TurnAbortReason;
217237
use codex_protocol::protocol::TurnAbortedEvent;
218238
use codex_protocol::protocol::UserMessageEvent;
@@ -410,4 +430,95 @@ mod tests {
410430
}
411431
);
412432
}
433+
434+
#[test]
435+
fn drops_last_turns_on_thread_rollback() {
436+
let events = vec![
437+
EventMsg::UserMessage(UserMessageEvent {
438+
message: "First".into(),
439+
images: None,
440+
}),
441+
EventMsg::AgentMessage(AgentMessageEvent {
442+
message: "A1".into(),
443+
}),
444+
EventMsg::UserMessage(UserMessageEvent {
445+
message: "Second".into(),
446+
images: None,
447+
}),
448+
EventMsg::AgentMessage(AgentMessageEvent {
449+
message: "A2".into(),
450+
}),
451+
EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 1 }),
452+
EventMsg::UserMessage(UserMessageEvent {
453+
message: "Third".into(),
454+
images: None,
455+
}),
456+
EventMsg::AgentMessage(AgentMessageEvent {
457+
message: "A3".into(),
458+
}),
459+
];
460+
461+
let turns = build_turns_from_event_msgs(&events);
462+
let expected = vec![
463+
Turn {
464+
id: "turn-1".into(),
465+
status: TurnStatus::Completed,
466+
error: None,
467+
items: vec![
468+
ThreadItem::UserMessage {
469+
id: "item-1".into(),
470+
content: vec![UserInput::Text {
471+
text: "First".into(),
472+
}],
473+
},
474+
ThreadItem::AgentMessage {
475+
id: "item-2".into(),
476+
text: "A1".into(),
477+
},
478+
],
479+
},
480+
Turn {
481+
id: "turn-2".into(),
482+
status: TurnStatus::Completed,
483+
error: None,
484+
items: vec![
485+
ThreadItem::UserMessage {
486+
id: "item-3".into(),
487+
content: vec![UserInput::Text {
488+
text: "Third".into(),
489+
}],
490+
},
491+
ThreadItem::AgentMessage {
492+
id: "item-4".into(),
493+
text: "A3".into(),
494+
},
495+
],
496+
},
497+
];
498+
assert_eq!(turns, expected);
499+
}
500+
501+
#[test]
502+
fn thread_rollback_clears_all_turns_when_num_turns_exceeds_history() {
503+
let events = vec![
504+
EventMsg::UserMessage(UserMessageEvent {
505+
message: "One".into(),
506+
images: None,
507+
}),
508+
EventMsg::AgentMessage(AgentMessageEvent {
509+
message: "A1".into(),
510+
}),
511+
EventMsg::UserMessage(UserMessageEvent {
512+
message: "Two".into(),
513+
images: None,
514+
}),
515+
EventMsg::AgentMessage(AgentMessageEvent {
516+
message: "A2".into(),
517+
}),
518+
EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 99 }),
519+
];
520+
521+
let turns = build_turns_from_event_msgs(&events);
522+
assert_eq!(turns, Vec::<Turn>::new());
523+
}
413524
}

codex-rs/app-server-protocol/src/protocol/v2.rs

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ pub enum CodexErrorInfo {
8989
InternalServerError,
9090
Unauthorized,
9191
BadRequest,
92+
ThreadRollbackFailed,
9293
SandboxError,
9394
/// The response SSE stream disconnected in the middle of a turn before completion.
9495
ResponseStreamDisconnected {
@@ -119,6 +120,7 @@ impl From<CoreCodexErrorInfo> for CodexErrorInfo {
119120
CoreCodexErrorInfo::InternalServerError => CodexErrorInfo::InternalServerError,
120121
CoreCodexErrorInfo::Unauthorized => CodexErrorInfo::Unauthorized,
121122
CoreCodexErrorInfo::BadRequest => CodexErrorInfo::BadRequest,
123+
CoreCodexErrorInfo::ThreadRollbackFailed => CodexErrorInfo::ThreadRollbackFailed,
122124
CoreCodexErrorInfo::SandboxError => CodexErrorInfo::SandboxError,
123125
CoreCodexErrorInfo::ResponseStreamDisconnected { http_status_code } => {
124126
CodexErrorInfo::ResponseStreamDisconnected { http_status_code }
@@ -1055,6 +1057,30 @@ pub struct ThreadArchiveParams {
10551057
#[ts(export_to = "v2/")]
10561058
pub struct ThreadArchiveResponse {}
10571059

1060+
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
1061+
#[serde(rename_all = "camelCase")]
1062+
#[ts(export_to = "v2/")]
1063+
pub struct ThreadRollbackParams {
1064+
pub thread_id: String,
1065+
/// The number of turns to drop from the end of the thread. Must be >= 1.
1066+
///
1067+
/// This only modifies the thread's history and does not revert local file changes
1068+
/// that have been made by the agent. Clients are responsible for reverting these changes.
1069+
pub num_turns: u32,
1070+
}
1071+
1072+
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
1073+
#[serde(rename_all = "camelCase")]
1074+
#[ts(export_to = "v2/")]
1075+
pub struct ThreadRollbackResponse {
1076+
/// The updated thread after applying the rollback, with `turns` populated.
1077+
///
1078+
/// The ThreadItems stored in each Turn are lossy since we explicitly do not
1079+
/// persist all agent interactions, such as command executions. This is the same
1080+
/// behavior as `thread/resume`.
1081+
pub thread: Thread,
1082+
}
1083+
10581084
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
10591085
#[serde(rename_all = "camelCase")]
10601086
#[ts(export_to = "v2/")]
@@ -1193,7 +1219,7 @@ pub struct Thread {
11931219
pub source: SessionSource,
11941220
/// Optional Git metadata captured when the thread was created.
11951221
pub git_info: Option<GitInfo>,
1196-
/// Only populated on a `thread/resume` response.
1222+
/// Only populated on `thread/resume` and `thread/rollback` responses.
11971223
/// For all other responses and notifications returning a Thread,
11981224
/// the turns field will be an empty list.
11991225
pub turns: Vec<Turn>,

codex-rs/app-server/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ Example (from OpenAI's official VSCode extension):
7272
- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it.
7373
- `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders` filtering.
7474
- `thread/archive` — move a thread’s rollout file into the archived directory; returns `{}` on success.
75+
- `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success.
7576
- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications.
7677
- `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`.
7778
- `review/start` — kick off Codex’s automated reviewer for a thread; responds like `turn/start` and emits `item/started`/`item/completed` notifications with `enteredReviewMode` and `exitedReviewMode` items, plus a final assistant `agentMessage` containing the review.

0 commit comments

Comments
 (0)