Skip to content

Commit 4cd445e

Browse files
gaoyu06claude
andcommitted
feat(codex): conversation rewind via thread/rollback
Wires codex's native thread/rollback RPC to the rewind affordance: the pencil rewind on a user turn confirms directly (no checkpoint_view round-trip), sends /rewind <numTurns> which the adapter maps to thread/rollback, and mirrors the truncation in the projected transcript. Flips the codex checkpoints cap on. Conversation-only for now — codex's rollback deliberately doesn't touch files (the app-server schema says the client must); git-checkpoint file restore is a follow-up phase. Also guards the claude adapter so desktop-only commands (/rewind, /tree, …) are never forwarded to the CLI as text. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a204764 commit 4cd445e

8 files changed

Lines changed: 90 additions & 5 deletions

File tree

src/lib/backends/caps.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ describe('caps() gating helper', () => {
5555
expect(cx.goals).toBe(true);
5656
expect(cx.transcriptReplay).toBe(true);
5757
expect(cx.slashCommands).toBe(false);
58+
expect(cx.checkpoints).toBe(true); // conversation rewind via thread/rollback
5859
});
5960

6061
it('every backend declares the full flag set', () => {

src/lib/backends/claude.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1033,8 +1033,10 @@ export function createClaudeAdapter(): EngineAdapter {
10331033
];
10341034
default:
10351035
// /resume is page-driven for claude (session listing needs fs
1036-
// access via the claude_sessions command).
1037-
if (cmd === '/resume') return null;
1036+
// access via the claude_sessions command); desktop/jucode-only
1037+
// commands aren't claude CLI commands, so never forward them.
1038+
if (['/resume', '/rewind', '/tree', '/checkout', '/fork', '/undo'].includes(cmd))
1039+
return null;
10381040
// Any other slash command comes from the CLI's own slash_commands
10391041
// list (built-ins like /context, /doctor or a user's custom
10401042
// command) — forward it verbatim as stream-json user text for the

src/lib/backends/codex-types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,14 @@ export interface ThreadResumeParams {
101101
sandbox?: SandboxMode | null;
102102
}
103103

104+
/** thread/rollback: drop `numTurns` turns from the end of the thread's history
105+
* (must be >= 1). Per the app-server schema this only rewinds the *conversation*
106+
* — local file changes are the client's responsibility (a git checkpoint). */
107+
export interface ThreadRollbackParams {
108+
threadId: string;
109+
numTurns: number;
110+
}
111+
104112
export type UserInput =
105113
| { type: 'text'; text: string; text_elements: unknown[] }
106114
| { type: 'localImage'; path: string };

src/lib/backends/codex.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,22 @@ describe('codex adapter: caps', () => {
5151
});
5252
});
5353

54+
describe('codex adapter: rewind', () => {
55+
it('rewinds the conversation with thread/rollback by turn count', () => {
56+
const { lines } = makeIo();
57+
const adapter = createCodexAdapter();
58+
handshake(adapter, lines);
59+
const frames = adapter.encodeOp({ op: 'command', input: '/rewind 2' });
60+
expect(parse(frames![0])).toMatchObject({
61+
method: 'thread/rollback',
62+
params: { threadId: 'thread-1', numTurns: 2 }
63+
});
64+
// A non-positive / missing count is a no-op.
65+
expect(adapter.encodeOp({ op: 'command', input: '/rewind 0' })).toEqual([]);
66+
expect(adapter.encodeOp({ op: 'command', input: '/rewind' })).toEqual([]);
67+
});
68+
});
69+
5470
describe('codex adapter: handshake', () => {
5571
it('onStart sends only the initialize request', () => {
5672
const { lines, io } = makeIo();

src/lib/backends/codex.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ import type {
7474
ThreadGoalUpdatedParams,
7575
ThreadItem,
7676
ThreadListParams,
77+
ThreadRollbackParams,
7778
ThreadListResponse,
7879
ThreadResumeParams,
7980
ThreadStartResponse,
@@ -93,7 +94,7 @@ export const CODEX_CAPS: BackendCaps = {
9394
goals: true, // thread/goal/set|get|clear + thread/goal/updated|cleared
9495
skills: false,
9596
mcpManage: false,
96-
checkpoints: false,
97+
checkpoints: true, // conversation rewind via thread/rollback (files handled desktop-side)
9798
contextUsage: true, // thread/tokenUsage/updated
9899
compact: true, // thread/compact/start + contextCompaction item lifecycle
99100
modelPicker: true, // model/list catalog + per-turn model/effort overrides
@@ -920,8 +921,17 @@ export function createCodexAdapter(): EngineAdapter {
920921
return [request('thread/goal/set', { threadId, status: 'active' })];
921922
return [request('thread/goal/set', { threadId, objective: arg })];
922923
}
924+
case '/rewind': {
925+
// Conversation rewind: drop N turns from the thread history. The
926+
// page computes N from the target turn and truncates its own view;
927+
// files are the client's job (see checkpoints cap note).
928+
const n = parseInt(arg, 10);
929+
return threadId && n > 0
930+
? [request('thread/rollback', { threadId, numTurns: n } satisfies ThreadRollbackParams)]
931+
: [];
932+
}
923933
default:
924-
return null; // /tree, /rewind, … — unsupported, UI notifies
934+
return null; // /tree, … — unsupported, UI notifies
925935
}
926936
}
927937
case 'shutdown':

src/lib/chat.svelte.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,27 @@ export class ChatState {
240240
return out;
241241
}
242242

243+
/** Number of user turns in the transcript (one per sent message). */
244+
get userTurns() {
245+
return this.messages.filter((m) => m.kind === 'user').length;
246+
}
247+
248+
/** Drop the `userIndex`-th user turn and everything after it — used by codex's
249+
* local view truncation on a thread/rollback rewind (the engine rewinds its
250+
* own history; we mirror it in the projected transcript). */
251+
truncateToUserTurn(userIndex: number) {
252+
let count = 0;
253+
for (let i = 0; i < this.messages.length; i++) {
254+
if (this.messages[i].kind === 'user') {
255+
if (count === userIndex) {
256+
this.messages = this.messages.slice(0, i);
257+
return;
258+
}
259+
count++;
260+
}
261+
}
262+
}
263+
243264
/** Whether this session has something the engine actually persisted to resume.
244265
* A fresh session (id assigned at startup but no user turn yet) was never saved,
245266
* so `/resume <id>` would fail with "No such file". Gates restart/switch resume

src/lib/chat.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,19 @@ describe('ChatState.handle', () => {
170170
expect(c.cost).toBe(0.42);
171171
});
172172

173+
it('truncateToUserTurn drops the target turn and everything after (codex rewind)', () => {
174+
const c = new ChatState();
175+
c.handle({ type: 'user_message', content: 'first' });
176+
c.handle({ type: 'assistant_delta', delta: 'a1' });
177+
c.handle({ type: 'user_message', content: 'second' });
178+
c.handle({ type: 'assistant_delta', delta: 'a2' });
179+
c.handle({ type: 'user_message', content: 'third' });
180+
expect(c.userTurns).toBe(3);
181+
c.truncateToUserTurn(1); // rewind to the 2nd user turn
182+
expect(userTexts(c)).toEqual(['first']);
183+
expect(c.messages.map((m) => m.kind)).toEqual(['user', 'assistant']);
184+
});
185+
173186
it('tracks busy state from engine status', () => {
174187
const c = new ChatState();
175188
c.handle({ type: 'model_status', state: 'streaming' });

src/routes/+page.svelte

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -982,13 +982,27 @@
982982
// engine lists user turns in order, so the i-th turn matches the i-th message.
983983
function rewindToMessage(text: string, userIndex: number) {
984984
if (!chat) return;
985+
// codex rewinds its conversation with thread/rollback (by turn count), which
986+
// has no checkpoint_view round-trip — confirm directly from the turn index.
987+
if (chat.backendId === 'codex') {
988+
chat.pendingRewind = { id: `codex:${userIndex}`, text };
989+
return;
990+
}
985991
chat.rewindIntent = { userIndex, text };
986992
send({ op: 'command', input: '/rewind' });
987993
}
988994
function confirmRewind() {
989995
const pr = chat?.pendingRewind;
990996
if (!pr || !chat) return;
991-
send({ op: 'command', input: `/rewind ${pr.id}` });
997+
if (pr.id.startsWith('codex:')) {
998+
const userIndex = Number(pr.id.slice('codex:'.length));
999+
const numTurns = chat.userTurns - userIndex;
1000+
if (numTurns > 0) send({ op: 'command', input: `/rewind ${numTurns}` });
1001+
// codex rolls back its own history; mirror it in our projected transcript.
1002+
chat.truncateToUserTurn(userIndex);
1003+
} else {
1004+
send({ op: 'command', input: `/rewind ${pr.id}` });
1005+
}
9921006
input = pr.text;
9931007
chat.pendingRewind = null;
9941008
composerEl?.focus();

0 commit comments

Comments
 (0)