authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-17 10:39:23-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-18 21:59:17-08:00
log351736baa119e8a9e71f1811edf37ef0615a0541
treea735f1e128d0b08d33a55b46e8f6cd99c7f09aa3
parentb236dccca3048ec6db0fcb53f9de4bcabaafaae2

Add provider checkpoints and thread rollback support

- add provider/WS contracts and server RPC handlers for listing/reverting checkpoints - read and rollback Codex thread turns in the app server manager - add ChatView checkpoint menu + store reducer support to revert local thread state - expand backend, websocket, store, and contract tests for checkpoint flows

14 files changed, 887 insertions(+), 1 deletions(-)

apps/server/src/codexAppServerManager.test.ts+89
......@@ -43,6 +43,36 @@ function createSendTurnHarness() {
4343 return { manager, context, requireSession, sendRequest, updateSession };
4444}
4545
46function createThreadControlHarness() {
47 const manager = new CodexAppServerManager();
48 const context = {
49 session: {
50 sessionId: "sess_1",
51 provider: "codex",
52 status: "ready",
53 threadId: "thread_1",
54 createdAt: "2026-02-10T00:00:00.000Z",
55 updatedAt: "2026-02-10T00:00:00.000Z",
56 },
57 };
58
59 const requireSession = vi
60 .spyOn(
61 manager as unknown as { requireSession: (sessionId: string) => unknown },
62 "requireSession",
63 )
64 .mockReturnValue(context);
65 const sendRequest = vi.spyOn(
66 manager as unknown as { sendRequest: (...args: unknown[]) => Promise<unknown> },
67 "sendRequest",
68 );
69 const updateSession = vi
70 .spyOn(manager as unknown as { updateSession: (...args: unknown[]) => void }, "updateSession")
71 .mockImplementation(() => {});
72
73 return { manager, context, requireSession, sendRequest, updateSession };
74}
75
4676describe("classifyCodexStderrLine", () => {
4777 it("ignores empty lines", () => {
4878 expect(classifyCodexStderrLine(" ")).toBeNull();
......@@ -198,3 +228,62 @@ describe("sendTurn", () => {
198228 ).rejects.toThrow("Turn input must include text or attachments.");
199229 });
200230});
231
232describe("thread checkpoint control", () => {
233 it("reads thread turns from thread/read", async () => {
234 const { manager, context, requireSession, sendRequest } = createThreadControlHarness();
235 sendRequest.mockResolvedValue({
236 thread: {
237 id: "thread_1",
238 turns: [
239 {
240 id: "turn_1",
241 items: [{ type: "userMessage", content: [{ type: "text", text: "hello" }] }],
242 },
243 ],
244 },
245 });
246
247 const result = await manager.readThread("sess_1");
248
249 expect(requireSession).toHaveBeenCalledWith("sess_1");
250 expect(sendRequest).toHaveBeenCalledWith(context, "thread/read", {
251 threadId: "thread_1",
252 includeTurns: true,
253 });
254 expect(result).toEqual({
255 threadId: "thread_1",
256 turns: [
257 {
258 id: "turn_1",
259 items: [{ type: "userMessage", content: [{ type: "text", text: "hello" }] }],
260 },
261 ],
262 });
263 });
264
265 it("rolls back turns via thread/rollback and resets session running state", async () => {
266 const { manager, context, sendRequest, updateSession } = createThreadControlHarness();
267 sendRequest.mockResolvedValue({
268 thread: {
269 id: "thread_1",
270 turns: [],
271 },
272 });
273
274 const result = await manager.rollbackThread("sess_1", 2);
275
276 expect(sendRequest).toHaveBeenCalledWith(context, "thread/rollback", {
277 threadId: "thread_1",
278 numTurns: 2,
279 });
280 expect(updateSession).toHaveBeenCalledWith(context, {
281 status: "ready",
282 activeTurnId: undefined,
283 });
284 expect(result).toEqual({
285 threadId: "thread_1",
286 turns: [],
287 });
288 });
289});
apps/server/src/codexAppServerManager.ts+80
......@@ -65,6 +65,16 @@ interface JsonRpcNotification {
6565 params?: unknown;
6666}
6767
68export interface CodexThreadTurnSnapshot {
69 id: string;
70 items: unknown[];
71}
72
73export interface CodexThreadSnapshot {
74 threadId: string;
75 turns: CodexThreadTurnSnapshot[];
76}
77
6878const ANSI_ESCAPE_CHAR = String.fromCharCode(27);
6979const ANSI_ESCAPE_REGEX = new RegExp(`${ANSI_ESCAPE_CHAR}\\[[0-9;]*m`, "g");
7080const CODEX_STDERR_LOG_REGEX =
......@@ -331,6 +341,41 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
331341 });
332342 }
333343
344 async readThread(sessionId: string): Promise<CodexThreadSnapshot> {
345 const context = this.requireSession(sessionId);
346 const threadId = context.session.threadId;
347 if (!threadId) {
348 throw new Error("Session is missing a thread id.");
349 }
350
351 const response = await this.sendRequest(context, "thread/read", {
352 threadId,
353 includeTurns: true,
354 });
355 return this.parseThreadSnapshot("thread/read", response);
356 }
357
358 async rollbackThread(sessionId: string, numTurns: number): Promise<CodexThreadSnapshot> {
359 const context = this.requireSession(sessionId);
360 const threadId = context.session.threadId;
361 if (!threadId) {
362 throw new Error("Session is missing a thread id.");
363 }
364 if (!Number.isInteger(numTurns) || numTurns < 1) {
365 throw new Error("numTurns must be an integer >= 1.");
366 }
367
368 const response = await this.sendRequest(context, "thread/rollback", {
369 threadId,
370 numTurns,
371 });
372 this.updateSession(context, {
373 status: "ready",
374 activeTurnId: undefined,
375 });
376 return this.parseThreadSnapshot("thread/rollback", response);
377 }
378
334379 async respondToRequest(
335380 sessionId: string,
336381 requestId: string,
......@@ -742,6 +787,31 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
742787 return undefined;
743788 }
744789
790 private parseThreadSnapshot(method: string, response: unknown): CodexThreadSnapshot {
791 const responseRecord = this.readObject(response);
792 const thread = this.readObject(responseRecord, "thread");
793 const threadId = this.readString(thread, "id") ?? this.readString(responseRecord, "threadId");
794 if (!threadId) {
795 throw new Error(`${method} response did not include a thread id.`);
796 }
797
798 const turnsRaw = this.readArray(thread, "turns") ?? [];
799 const turns = turnsRaw.map((turnValue, index) => {
800 const turn = this.readObject(turnValue);
801 const turnId = this.readString(turn, "id") ?? `${threadId}:turn:${index + 1}`;
802 const items = this.readArray(turn, "items") ?? [];
803 return {
804 id: turnId,
805 items,
806 };
807 });
808
809 return {
810 threadId,
811 turns,
812 };
813 }
814
745815 private isServerRequest(value: unknown): value is JsonRpcRequest {
746816 if (!value || typeof value !== "object") {
747817 return false;
......@@ -823,6 +893,16 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
823893 return target as Record<string, unknown>;
824894 }
825895
896 private readArray(value: unknown, key?: string): unknown[] | undefined {
897 const target =
898 key === undefined
899 ? value
900 : value && typeof value === "object"
901 ? (value as Record<string, unknown>)[key]
902 : undefined;
903 return Array.isArray(target) ? target : undefined;
904 }
905
826906 private readString(value: unknown, key: string): string | undefined {
827907 if (!value || typeof value !== "object") {
828908 return undefined;
apps/server/src/providerManager.test.ts+132
......@@ -59,4 +59,136 @@ describe("ProviderManager", () => {
5959
6060 manager.dispose();
6161 });
62
63 it("rejects checkpoint operations for unknown sessions", async () => {
64 const manager = new ProviderManager();
65
66 await expect(
67 manager.listCheckpoints({
68 sessionId: "missing-session",
69 }),
70 ).rejects.toThrow("Unknown provider session: missing-session");
71
72 await expect(
73 manager.revertToCheckpoint({
74 sessionId: "missing-session",
75 turnCount: 0,
76 }),
77 ).rejects.toThrow("Unknown provider session: missing-session");
78
79 manager.dispose();
80 });
81
82 it("derives checkpoints from thread turns", async () => {
83 const manager = new ProviderManager();
84 const codex = (
85 manager as unknown as {
86 codex: {
87 hasSession: (sessionId: string) => boolean;
88 readThread: (sessionId: string) => Promise<{
89 threadId: string;
90 turns: Array<{ id: string; items: unknown[] }>;
91 }>;
92 };
93 }
94 ).codex;
95
96 codex.hasSession = () => true;
97 codex.readThread = async () => ({
98 threadId: "thr_1",
99 turns: [
100 {
101 id: "turn_1",
102 items: [
103 {
104 type: "userMessage",
105 content: [{ type: "text", text: "Refactor the logger" }],
106 },
107 {
108 type: "agentMessage",
109 text: "I refactored it.",
110 },
111 ],
112 },
113 ],
114 });
115
116 const result = await manager.listCheckpoints({
117 sessionId: "sess_1",
118 });
119
120 expect(result).toEqual({
121 threadId: "thr_1",
122 checkpoints: [
123 {
124 id: "root",
125 turnCount: 0,
126 messageCount: 0,
127 label: "Start of conversation",
128 isCurrent: false,
129 },
130 {
131 id: "turn_1",
132 turnCount: 1,
133 messageCount: 2,
134 label: "Turn 1",
135 preview: "Refactor the logger",
136 isCurrent: true,
137 },
138 ],
139 });
140
141 manager.dispose();
142 });
143
144 it("rolls back from a selected checkpoint turn count", async () => {
145 const manager = new ProviderManager();
146 const codex = (
147 manager as unknown as {
148 codex: {
149 hasSession: (sessionId: string) => boolean;
150 readThread: (sessionId: string) => Promise<{
151 threadId: string;
152 turns: Array<{ id: string; items: unknown[] }>;
153 }>;
154 rollbackThread: (sessionId: string, numTurns: number) => Promise<{
155 threadId: string;
156 turns: Array<{ id: string; items: unknown[] }>;
157 }>;
158 };
159 }
160 ).codex;
161
162 const rollbackCalls: Array<{ sessionId: string; numTurns: number }> = [];
163 codex.hasSession = () => true;
164 codex.readThread = async () => ({
165 threadId: "thr_1",
166 turns: [
167 { id: "turn_1", items: [] },
168 { id: "turn_2", items: [] },
169 { id: "turn_3", items: [] },
170 ],
171 });
172 codex.rollbackThread = async (sessionId, numTurns) => {
173 rollbackCalls.push({ sessionId, numTurns });
174 return {
175 threadId: "thr_1",
176 turns: [{ id: "turn_1", items: [] }],
177 };
178 };
179
180 const result = await manager.revertToCheckpoint({
181 sessionId: "sess_1",
182 turnCount: 1,
183 });
184
185 expect(rollbackCalls).toEqual([{ sessionId: "sess_1", numTurns: 2 }]);
186 expect(result.threadId).toBe("thr_1");
187 expect(result.turnCount).toBe(1);
188 expect(result.messageCount).toBe(0);
189 expect(result.rolledBackTurns).toBe(2);
190 expect(result.checkpoints).toHaveLength(2);
191
192 manager.dispose();
193 });
62194});
apps/server/src/providerManager.ts+182
......@@ -3,8 +3,13 @@ import fs from "node:fs";
33import path from "node:path";
44
55import {
6 type ProviderCheckpoint,
67 type ProviderEvent,
78 type ProviderInterruptTurnInput,
9 type ProviderListCheckpointsInput,
10 type ProviderListCheckpointsResult,
11 type ProviderRevertToCheckpointInput,
12 type ProviderRevertToCheckpointResult,
813 type ProviderRespondToRequestInput,
914 type ProviderSendTurnInput,
1015 type ProviderSession,
......@@ -12,17 +17,144 @@ import {
1217 type ProviderStopSessionInput,
1318 type ProviderTurnStartResult,
1419 providerInterruptTurnInputSchema,
20 providerListCheckpointsInputSchema,
21 providerRevertToCheckpointInputSchema,
1522 providerRespondToRequestInputSchema,
1623 providerSendTurnInputSchema,
1724 providerSessionStartInputSchema,
1825 providerStopSessionInputSchema,
1926} from "@t3tools/contracts";
27import type { CodexThreadTurnSnapshot } from "./codexAppServerManager";
2028import { CodexAppServerManager } from "./codexAppServerManager";
2129
2230export interface ProviderManagerEvents {
2331 event: [event: ProviderEvent];
2432}
2533
34function asObject(value: unknown): Record<string, unknown> | undefined {
35 if (!value || typeof value !== "object") {
36 return undefined;
37 }
38 return value as Record<string, unknown>;
39}
40
41function asString(value: unknown): string | undefined {
42 return typeof value === "string" ? value : undefined;
43}
44
45function asArray(value: unknown): unknown[] {
46 return Array.isArray(value) ? value : [];
47}
48
49function trimToPreview(value: string): string {
50 const normalized = value.replace(/\s+/g, " ").trim();
51 if (normalized.length <= 120) {
52 return normalized;
53 }
54 return `${normalized.slice(0, 117)}...`;
55}
56
57function summarizeUserMessageContent(content: unknown[]): string | undefined {
58 const segments: string[] = [];
59
60 for (const part of content) {
61 const record = asObject(part);
62 const type = asString(record?.type);
63 if (!type) continue;
64
65 if (type === "text") {
66 const text = asString(record?.text);
67 if (text && text.trim().length > 0) {
68 segments.push(text.trim());
69 }
70 continue;
71 }
72
73 if (type === "image") {
74 segments.push("[Image attachment]");
75 continue;
76 }
77
78 if (type === "localImage") {
79 segments.push("[Local image attachment]");
80 continue;
81 }
82 }
83
84 if (segments.length === 0) {
85 return undefined;
86 }
87 return trimToPreview(segments.join(" "));
88}
89
90function summarizeTurn(turn: CodexThreadTurnSnapshot): {
91 messageCountDelta: number;
92 preview?: string;
93} {
94 let messageCountDelta = 0;
95 let preview: string | undefined;
96
97 for (const item of turn.items) {
98 const record = asObject(item);
99 const type = asString(record?.type);
100 if (!type) continue;
101
102 if (type === "userMessage") {
103 messageCountDelta += 1;
104 if (!preview) {
105 const content = asArray(record?.content);
106 preview = summarizeUserMessageContent(content);
107 }
108 continue;
109 }
110
111 if (type === "agentMessage") {
112 messageCountDelta += 1;
113 if (!preview) {
114 const text = asString(record?.text);
115 if (text && text.trim().length > 0) {
116 preview = trimToPreview(text);
117 }
118 }
119 }
120 }
121
122 return {
123 messageCountDelta,
124 ...(preview ? { preview } : {}),
125 };
126}
127
128function buildCheckpoints(turns: CodexThreadTurnSnapshot[]): ProviderCheckpoint[] {
129 const checkpoints: ProviderCheckpoint[] = [];
130 let messageCount = 0;
131 const isEmpty = turns.length === 0;
132 checkpoints.push({
133 id: "root",
134 turnCount: 0,
135 messageCount: 0,
136 label: "Start of conversation",
137 isCurrent: isEmpty,
138 });
139
140 for (let index = 0; index < turns.length; index += 1) {
141 const turn = turns[index];
142 if (!turn) continue;
143 const turnSummary = summarizeTurn(turn);
144 messageCount += turnSummary.messageCountDelta;
145 checkpoints.push({
146 id: turn.id,
147 turnCount: index + 1,
148 messageCount,
149 label: `Turn ${index + 1}`,
150 ...(turnSummary.preview ? { preview: turnSummary.preview } : {}),
151 isCurrent: index === turns.length - 1,
152 });
153 }
154
155 return checkpoints;
156}
157
26158export class ProviderManager extends EventEmitter<ProviderManagerEvents> {
27159 private readonly codex = new CodexAppServerManager();
28160 private readonly threadLogsDir: string;
......@@ -101,6 +233,56 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {
101233 return this.codex.listSessions();
102234 }
103235
236 async listCheckpoints(raw: ProviderListCheckpointsInput): Promise<ProviderListCheckpointsResult> {
237 const input = providerListCheckpointsInputSchema.parse(raw);
238 if (!this.codex.hasSession(input.sessionId)) {
239 throw new Error(`Unknown provider session: ${input.sessionId}`);
240 }
241
242 const snapshot = await this.codex.readThread(input.sessionId);
243 return {
244 threadId: snapshot.threadId,
245 checkpoints: buildCheckpoints(snapshot.turns),
246 };
247 }
248
249 async revertToCheckpoint(
250 raw: ProviderRevertToCheckpointInput,
251 ): Promise<ProviderRevertToCheckpointResult> {
252 const input = providerRevertToCheckpointInputSchema.parse(raw);
253 if (!this.codex.hasSession(input.sessionId)) {
254 throw new Error(`Unknown provider session: ${input.sessionId}`);
255 }
256
257 const beforeSnapshot = await this.codex.readThread(input.sessionId);
258 const currentTurnCount = beforeSnapshot.turns.length;
259 if (input.turnCount > currentTurnCount) {
260 throw new Error(
261 `Checkpoint turn count ${input.turnCount} exceeds current turn count ${currentTurnCount}.`,
262 );
263 }
264
265 const requestedRollbackTurns = currentTurnCount - input.turnCount;
266 const afterSnapshot =
267 requestedRollbackTurns > 0
268 ? await this.codex.rollbackThread(input.sessionId, requestedRollbackTurns)
269 : beforeSnapshot;
270 const checkpoints = buildCheckpoints(afterSnapshot.turns);
271 const currentCheckpoint =
272 checkpoints.find((checkpoint) => checkpoint.isCurrent) ??
273 checkpoints[checkpoints.length - 1] ??
274 checkpoints[0];
275 const rolledBackTurns = Math.max(0, currentTurnCount - afterSnapshot.turns.length);
276
277 return {
278 threadId: afterSnapshot.threadId,
279 turnCount: currentCheckpoint?.turnCount ?? 0,
280 messageCount: currentCheckpoint?.messageCount ?? 0,
281 rolledBackTurns,
282 checkpoints,
283 };
284 }
285
104286 stopAll(): void {
105287 this.codex.stopAll();
106288 }
apps/server/src/wsServer.test.ts+24
......@@ -646,6 +646,30 @@ describe("WebSocket Server", () => {
646646 expect(response.result).toEqual([]);
647647 });
648648
649 it("returns unknown-session errors for checkpoint RPCs without an active provider session", async () => {
650 server = createTestServer({ cwd: "/test" });
651 await server.start();
652 const addr = server.httpServer.address();
653 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
654
655 const ws = await connectWs(port);
656 connections.push(ws);
657 await waitForMessage(ws);
658
659 const listResponse = await sendRequest(ws, WS_METHODS.providersListCheckpoints, {
660 sessionId: "missing-session",
661 });
662 expect(listResponse.result).toBeUndefined();
663 expect(listResponse.error?.message).toContain("Unknown provider session");
664
665 const revertResponse = await sendRequest(ws, WS_METHODS.providersRevertToCheckpoint, {
666 sessionId: "missing-session",
667 turnCount: 0,
668 });
669 expect(revertResponse.result).toBeUndefined();
670 expect(revertResponse.error?.message).toContain("Unknown provider session");
671 });
672
649673 it("routes terminal RPC methods and broadcasts terminal events", async () => {
650674 const cwd = makeTempDir("t3code-ws-terminal-cwd-");
651675 const terminalManager = new MockTerminalManager();
apps/server/src/wsServer.ts+6
......@@ -310,6 +310,12 @@ export function createServer(options: ServerOptions) {
310310 case WS_METHODS.providersListSessions:
311311 return providerManager.listSessions();
312312
313 case WS_METHODS.providersListCheckpoints:
314 return providerManager.listCheckpoints(request.params as never);
315
316 case WS_METHODS.providersRevertToCheckpoint:
317 return providerManager.revertToCheckpoint(request.params as never);
318
313319 case WS_METHODS.projectsList:
314320 return projectRegistry.list();
315321
apps/web/src/components/ChatView.tsx+149-1
......@@ -8,6 +8,7 @@ import {
88 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
99 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
1010 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
11 type ProviderCheckpoint,
1112 type ResolvedKeybindingsConfig,
1213 type ProviderApprovalDecision,
1314 type ProviderSendTurnAttachmentInput,
......@@ -345,6 +346,9 @@ export default function ChatView() {
345346 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
346347 const [isSending, setIsSending] = useState(false);
347348 const [isConnecting, setIsConnecting] = useState(false);
349 const [isLoadingCheckpoints, setIsLoadingCheckpoints] = useState(false);
350 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);
351 const [checkpoints, setCheckpoints] = useState<ProviderCheckpoint[]>([]);
348352 const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING);
349353 const [envMode, setEnvMode] = useState<"local" | "worktree">("local");
350354 const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false);
......@@ -374,7 +378,7 @@ export default function ChatView() {
374378 activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,
375379 );
376380 const phase = derivePhase(activeThread?.session ?? null);
377 const isWorking = phase === "running" || isSending || isConnecting;
381 const isWorking = phase === "running" || isSending || isConnecting || isRevertingCheckpoint;
378382 const nowIso = new Date(nowTick).toISOString();
379383 const workLogEntries = useMemo(
380384 () => deriveWorkLogEntries(activeThread?.events ?? [], undefined),
......@@ -575,6 +579,12 @@ export default function ChatView() {
575579 );
576580 const hasReachedTerminalLimit =
577581 (activeThread?.terminalIds.length ?? 0) >= MAX_THREAD_TERMINAL_COUNT;
582 const canCheckpoint =
583 Boolean(api && activeThread && (activeThread.codexThreadId ?? activeThread.session?.threadId)) &&
584 phase !== "running" &&
585 !isSending &&
586 !isConnecting &&
587 !isRevertingCheckpoint;
578588
579589 const revokePreviewUrls = useCallback((images: Array<{ previewUrl?: string }>) => {
580590 for (const image of images) {
......@@ -963,6 +973,12 @@ export default function ChatView() {
963973 );
964974 }, [composerMenuItems, composerMenuOpen]);
965975
976 useEffect(() => {
977 setCheckpoints([]);
978 setIsLoadingCheckpoints(false);
979 setIsRevertingCheckpoint(false);
980 }, [activeThread?.id]);
981
966982 useEffect(() => {
967983 if (!activeThread?.id || activeThread.terminalOpen) return;
968984 const frame = window.requestAnimationFrame(() => {
......@@ -1311,6 +1327,78 @@ export default function ChatView() {
13111327 }
13121328 };
13131329
1330 const loadCheckpoints = async (): Promise<ProviderCheckpoint[] | null> => {
1331 if (!api || !activeThread) return null;
1332 if (phase === "running" || isSending || isConnecting || isRevertingCheckpoint) {
1333 return null;
1334 }
1335 const hasThreadIdentity = Boolean(activeThread.codexThreadId ?? activeThread.session?.threadId);
1336 if (!hasThreadIdentity) {
1337 setCheckpoints([]);
1338 return [];
1339 }
1340
1341 setIsLoadingCheckpoints(true);
1342 setThreadError(activeThread.id, null);
1343 try {
1344 const sessionInfo = await ensureSession();
1345 if (!sessionInfo) {
1346 return null;
1347 }
1348 const result = await api.providers.listCheckpoints({
1349 sessionId: sessionInfo.sessionId,
1350 });
1351 setCheckpoints(result.checkpoints);
1352 return result.checkpoints;
1353 } catch (err) {
1354 setThreadError(
1355 activeThread.id,
1356 err instanceof Error ? err.message : "Failed to load checkpoints.",
1357 );
1358 return null;
1359 } finally {
1360 setIsLoadingCheckpoints(false);
1361 }
1362 };
1363
1364 const onRevertToCheckpoint = async (checkpoint: ProviderCheckpoint) => {
1365 if (!api || !activeThread || checkpoint.isCurrent || isRevertingCheckpoint) {
1366 return;
1367 }
1368 if (phase === "running" || isSending || isConnecting) {
1369 setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints.");
1370 return;
1371 }
1372
1373 setIsRevertingCheckpoint(true);
1374 setThreadError(activeThread.id, null);
1375 try {
1376 const sessionInfo = await ensureSession();
1377 if (!sessionInfo) {
1378 return;
1379 }
1380 const result = await api.providers.revertToCheckpoint({
1381 sessionId: sessionInfo.sessionId,
1382 turnCount: checkpoint.turnCount,
1383 });
1384 dispatch({
1385 type: "REVERT_TO_CHECKPOINT",
1386 threadId: activeThread.id,
1387 sessionId: sessionInfo.sessionId,
1388 threadRuntimeId: result.threadId,
1389 messageCount: result.messageCount,
1390 });
1391 setCheckpoints(result.checkpoints);
1392 } catch (err) {
1393 setThreadError(
1394 activeThread.id,
1395 err instanceof Error ? err.message : "Failed to revert checkpoint.",
1396 );
1397 } finally {
1398 setIsRevertingCheckpoint(false);
1399 }
1400 };
1401
13141402 const onSend = async (e: FormEvent) => {
13151403 e.preventDefault();
13161404 if (!api || !activeThread || isSending || isConnecting) return;
......@@ -1641,6 +1729,14 @@ export default function ChatView() {
16411729 onAddProjectScript={saveProjectScript}
16421730 onUpdateProjectScript={updateProjectScript}
16431731 onToggleDiff={onToggleDiff}
1732 checkpoints={checkpoints}
1733 canCheckpoint={canCheckpoint}
1734 isLoadingCheckpoints={isLoadingCheckpoints}
1735 isRevertingCheckpoint={isRevertingCheckpoint}
1736 onOpenCheckpoints={() => {
1737 void loadCheckpoints();
1738 }}
1739 onRevertToCheckpoint={onRevertToCheckpoint}
16441740 />
16451741 </header>
16461742
......@@ -1974,6 +2070,12 @@ interface ChatHeaderProps {
19742070 onAddProjectScript: (input: NewProjectScriptInput) => Promise<void>;
19752071 onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise<void>;
19762072 onToggleDiff: () => void;
2073 checkpoints: ProviderCheckpoint[];
2074 canCheckpoint: boolean;
2075 isLoadingCheckpoints: boolean;
2076 isRevertingCheckpoint: boolean;
2077 onOpenCheckpoints: () => void;
2078 onRevertToCheckpoint: (checkpoint: ProviderCheckpoint) => void;
19772079}
19782080
19792081const ChatHeader = memo(function ChatHeader({
......@@ -1989,6 +2091,12 @@ const ChatHeader = memo(function ChatHeader({
19892091 onAddProjectScript,
19902092 onUpdateProjectScript,
19912093 onToggleDiff,
2094 checkpoints,
2095 canCheckpoint,
2096 isLoadingCheckpoints,
2097 isRevertingCheckpoint,
2098 onOpenCheckpoints,
2099 onRevertToCheckpoint,
19922100}: ChatHeaderProps) {
19932101 return (
19942102 <>
......@@ -2011,6 +2119,46 @@ const ChatHeader = memo(function ChatHeader({
20112119 )}
20122120 {activeProjectName && <OpenInPicker keybindings={keybindings} />}
20132121 {activeProjectName && <GitActionsControl api={api} gitCwd={gitCwd} />}
2122 <Menu>
2123 <MenuTrigger
2124 render={
2125 <Button
2126 size="xs"
2127 variant="ghost"
2128 className="text-muted-foreground/70 hover:text-foreground/80"
2129 disabled={!canCheckpoint || isRevertingCheckpoint}
2130 onClick={onOpenCheckpoints}
2131 >
2132 {isRevertingCheckpoint
2133 ? "Reverting..."
2134 : isLoadingCheckpoints
2135 ? "Loading..."
2136 : "Checkpoints"}
2137 </Button>
2138 }
2139 />
2140 <MenuPopup align="end" className="w-72">
2141 {!canCheckpoint ? (
2142 <MenuItem disabled>Send at least one message to create checkpoints.</MenuItem>
2143 ) : checkpoints.length === 0 ? (
2144 <MenuItem disabled>
2145 {isLoadingCheckpoints ? "Loading checkpoints..." : "No checkpoints available."}
2146 </MenuItem>
2147 ) : (
2148 checkpoints.map((checkpoint) => (
2149 <MenuItem
2150 key={checkpoint.id}
2151 disabled={checkpoint.isCurrent || isRevertingCheckpoint}
2152 onClick={() => onRevertToCheckpoint(checkpoint)}
2153 >
2154 {checkpoint.label}
2155 {checkpoint.preview ? `: ${checkpoint.preview}` : ""}
2156 {checkpoint.isCurrent ? " (Current)" : ""}
2157 </MenuItem>
2158 ))
2159 )}
2160 </MenuPopup>
2161 </Menu>
20142162 <Button
20152163 size="xs"
20162164 variant="ghost"
apps/web/src/store.test.ts+62
......@@ -718,4 +718,66 @@ describe("store reducer thread continuity", () => {
718718 expect(next.threads[0]?.latestTurnCompletedAt).toBe(completedAt);
719719 expect(next.threads[0]?.lastVisitedAt).toBe(completedAt);
720720 });
721
722 it("reverts thread state to a checkpoint snapshot", () => {
723 const state = makeState(
724 makeThread({
725 codexThreadId: "thr_before",
726 session: makeSession({
727 status: "running",
728 threadId: "thr_before",
729 activeTurnId: "turn-live",
730 }),
731 messages: [
732 {
733 id: "m-1",
734 role: "user",
735 text: "First",
736 createdAt: "2026-02-08T10:00:00.000Z",
737 streaming: false,
738 },
739 {
740 id: "m-2",
741 role: "assistant",
742 text: "First reply",
743 createdAt: "2026-02-08T10:00:01.000Z",
744 streaming: false,
745 },
746 {
747 id: "m-3",
748 role: "user",
749 text: "Second",
750 createdAt: "2026-02-08T10:00:02.000Z",
751 streaming: false,
752 },
753 ],
754 events: [
755 makeEvent({
756 method: "turn/started",
757 turnId: "turn-live",
758 }),
759 ],
760 error: "temporary failure",
761 latestTurnId: "turn-live",
762 latestTurnStartedAt: "2026-02-08T10:00:03.000Z",
763 }),
764 );
765
766 const next = reducer(state, {
767 type: "REVERT_TO_CHECKPOINT",
768 threadId: "thread-local-1",
769 sessionId: "sess-1",
770 threadRuntimeId: "thr_after",
771 messageCount: 2,
772 });
773
774 expect(next.threads[0]?.codexThreadId).toBe("thr_after");
775 expect(next.threads[0]?.messages.map((message) => message.id)).toEqual(["m-1", "m-2"]);
776 expect(next.threads[0]?.events).toEqual([]);
777 expect(next.threads[0]?.error).toBeNull();
778 expect(next.threads[0]?.session?.status).toBe("ready");
779 expect(next.threads[0]?.session?.activeTurnId).toBeUndefined();
780 expect(next.threads[0]?.session?.threadId).toBe("thr_after");
781 expect(next.threads[0]?.latestTurnId).toBeUndefined();
782 });
721783});
apps/web/src/store.ts+39
......@@ -59,6 +59,13 @@ type Action =
5959 | { type: "SET_ERROR"; threadId: string; error: string | null }
6060 | { type: "SET_THREAD_TITLE"; threadId: string; title: string }
6161 | { type: "SET_THREAD_MODEL"; threadId: string; model: string }
62 | {
63 type: "REVERT_TO_CHECKPOINT";
64 threadId: string;
65 sessionId: string;
66 threadRuntimeId: string;
67 messageCount: number;
68 }
6269 | {
6370 type: "SET_THREAD_BRANCH";
6471 threadId: string;
......@@ -834,6 +841,38 @@ export function reducer(state: AppState, action: Action): AppState {
834841 })),
835842 };
836843
844 case "REVERT_TO_CHECKPOINT":
845 return {
846 ...state,
847 threads: updateThread(state.threads, action.threadId, (t) => {
848 const nextMessageCount = Math.max(0, Math.floor(action.messageCount));
849 const now = new Date().toISOString();
850 return {
851 ...t,
852 codexThreadId: action.threadRuntimeId,
853 session:
854 t.session?.sessionId === action.sessionId
855 ? {
856 ...t.session,
857 status: "ready",
858 threadId: action.threadRuntimeId,
859 activeTurnId: undefined,
860 updatedAt: now,
861 lastError: undefined,
862 }
863 : t.session,
864 messages: t.messages.slice(0, nextMessageCount),
865 events: [],
866 error: null,
867 latestTurnId: undefined,
868 latestTurnStartedAt: undefined,
869 latestTurnCompletedAt: undefined,
870 latestTurnDurationMs: undefined,
871 lastVisitedAt: now,
872 };
873 }),
874 };
875
837876 case "SET_THREAD_BRANCH": {
838877 return {
839878 ...state,
apps/web/src/wsNativeApi.ts+3
......@@ -91,6 +91,9 @@ export function createWsNativeApi(): NativeApi {
9191 respondToRequest: (input) => transport.request(WS_METHODS.providersRespondToRequest, input),
9292 stopSession: (input) => transport.request(WS_METHODS.providersStopSession, input),
9393 listSessions: () => transport.request(WS_METHODS.providersListSessions),
94 listCheckpoints: (input) => transport.request(WS_METHODS.providersListCheckpoints, input),
95 revertToCheckpoint: (input) =>
96 transport.request(WS_METHODS.providersRevertToCheckpoint, input),
9497 onEvent: (callback) =>
9598 transport.subscribe(WS_CHANNELS.providerEvent, callback as (data: unknown) => void),
9699 },
packages/contracts/src/ipc.ts+8
......@@ -18,6 +18,10 @@ import type {
1818import type {
1919 ProviderEvent,
2020 ProviderInterruptTurnInput,
21 ProviderListCheckpointsInput,
22 ProviderListCheckpointsResult,
23 ProviderRevertToCheckpointInput,
24 ProviderRevertToCheckpointResult,
2125 ProviderRespondToRequestInput,
2226 ProviderSendTurnInput,
2327 ProviderSession,
......@@ -89,6 +93,10 @@ export interface NativeApi {
8993 respondToRequest: (input: ProviderRespondToRequestInput) => Promise<void>;
9094 stopSession: (input: ProviderStopSessionInput) => Promise<void>;
9195 listSessions: () => Promise<ProviderSession[]>;
96 listCheckpoints: (input: ProviderListCheckpointsInput) => Promise<ProviderListCheckpointsResult>;
97 revertToCheckpoint: (
98 input: ProviderRevertToCheckpointInput,
99 ) => Promise<ProviderRevertToCheckpointResult>;
92100 onEvent: (callback: (event: ProviderEvent) => void) => () => void;
93101 };
94102 projects: {
packages/contracts/src/provider.test.ts+73
......@@ -2,7 +2,12 @@ import { describe, expect, it } from "vitest";
22
33import {
44 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
5 providerCheckpointSchema,
56 providerEventSchema,
7 providerListCheckpointsInputSchema,
8 providerListCheckpointsResultSchema,
9 providerRevertToCheckpointInputSchema,
10 providerRevertToCheckpointResultSchema,
611 providerRespondToRequestInputSchema,
712 providerSendTurnInputSchema,
813 providerSessionStartInputSchema,
......@@ -157,3 +162,71 @@ describe("providerRespondToRequestInputSchema", () => {
157162 ).toThrow();
158163 });
159164});
165
166describe("provider checkpoint schemas", () => {
167 it("accepts list checkpoint inputs", () => {
168 const parsed = providerListCheckpointsInputSchema.parse({
169 sessionId: "sess_1",
170 });
171 expect(parsed.sessionId).toBe("sess_1");
172 });
173
174 it("accepts checkpoint records and list results", () => {
175 const checkpoint = providerCheckpointSchema.parse({
176 id: "turn_1",
177 turnCount: 1,
178 messageCount: 2,
179 label: "Turn 1",
180 preview: "Summarize this file",
181 isCurrent: true,
182 });
183 expect(checkpoint.turnCount).toBe(1);
184
185 const listResult = providerListCheckpointsResultSchema.parse({
186 threadId: "thr_1",
187 checkpoints: [checkpoint],
188 });
189 expect(listResult.checkpoints).toHaveLength(1);
190 });
191
192 it("accepts revert checkpoint input/result", () => {
193 const input = providerRevertToCheckpointInputSchema.parse({
194 sessionId: "sess_1",
195 turnCount: 2,
196 });
197 expect(input.turnCount).toBe(2);
198
199 const result = providerRevertToCheckpointResultSchema.parse({
200 threadId: "thr_1",
201 turnCount: 2,
202 messageCount: 4,
203 rolledBackTurns: 1,
204 checkpoints: [
205 {
206 id: "root",
207 turnCount: 0,
208 messageCount: 0,
209 label: "Start of conversation",
210 isCurrent: false,
211 },
212 {
213 id: "turn_2",
214 turnCount: 2,
215 messageCount: 4,
216 label: "Turn 2",
217 isCurrent: true,
218 },
219 ],
220 });
221 expect(result.rolledBackTurns).toBe(1);
222 });
223
224 it("rejects negative turn counts", () => {
225 expect(() =>
226 providerRevertToCheckpointInputSchema.parse({
227 sessionId: "sess_1",
228 turnCount: -1,
229 }),
230 ).toThrow();
231 });
232});
packages/contracts/src/provider.ts+38
......@@ -111,6 +111,37 @@ export const providerStopSessionInputSchema = z.object({
111111 sessionId: z.string().min(1),
112112});
113113
114export const providerListCheckpointsInputSchema = z.object({
115 sessionId: z.string().min(1),
116});
117
118export const providerCheckpointSchema = z.object({
119 id: z.string().min(1),
120 turnCount: z.number().int().min(0),
121 messageCount: z.number().int().min(0),
122 label: z.string().min(1),
123 preview: z.string().min(1).optional(),
124 isCurrent: z.boolean(),
125});
126
127export const providerListCheckpointsResultSchema = z.object({
128 threadId: z.string().min(1),
129 checkpoints: z.array(providerCheckpointSchema),
130});
131
132export const providerRevertToCheckpointInputSchema = z.object({
133 sessionId: z.string().min(1),
134 turnCount: z.number().int().min(0),
135});
136
137export const providerRevertToCheckpointResultSchema = z.object({
138 threadId: z.string().min(1),
139 turnCount: z.number().int().min(0),
140 messageCount: z.number().int().min(0),
141 rolledBackTurns: z.number().int().min(0),
142 checkpoints: z.array(providerCheckpointSchema),
143});
144
114145export const providerRespondToRequestInputSchema = z.object({
115146 sessionId: z.string().min(1),
116147 requestId: z.string().min(1),
......@@ -151,6 +182,13 @@ export type ProviderSendTurnInput = z.input<typeof providerSendTurnInputSchema>;
151182export type ProviderTurnStartResult = z.infer<typeof providerTurnStartResultSchema>;
152183export type ProviderInterruptTurnInput = z.input<typeof providerInterruptTurnInputSchema>;
153184export type ProviderStopSessionInput = z.input<typeof providerStopSessionInputSchema>;
185export type ProviderListCheckpointsInput = z.input<typeof providerListCheckpointsInputSchema>;
186export type ProviderCheckpoint = z.infer<typeof providerCheckpointSchema>;
187export type ProviderListCheckpointsResult = z.infer<typeof providerListCheckpointsResultSchema>;
188export type ProviderRevertToCheckpointInput = z.input<typeof providerRevertToCheckpointInputSchema>;
189export type ProviderRevertToCheckpointResult = z.infer<
190 typeof providerRevertToCheckpointResultSchema
191>;
154192export type ProviderRespondToRequestInput = z.input<typeof providerRespondToRequestInputSchema>;
155193export type ProviderEventKind = z.infer<typeof providerEventKindSchema>;
156194export type ProviderEvent = z.infer<typeof providerEventSchema>;
packages/contracts/src/ws.ts+2
......@@ -10,6 +10,8 @@ export const WS_METHODS = {
1010 providersRespondToRequest: "providers.respondToRequest",
1111 providersStopSession: "providers.stopSession",
1212 providersListSessions: "providers.listSessions",
13 providersListCheckpoints: "providers.listCheckpoints",
14 providersRevertToCheckpoint: "providers.revertToCheckpoint",
1315
1416 // Project registry methods
1517 projectsList: "projects.list",