| author | |
| committer | |
| log | 351736baa119e8a9e71f1811edf37ef0615a0541 |
| tree | a735f1e128d0b08d33a55b46e8f6cd99c7f09aa3 |
| parent | b236dccca3048ec6db0fcb53f9de4bcabaafaae2 |
- 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 flows14 files changed, 887 insertions(+), 1 deletions(-)
apps/server/src/codexAppServerManager.test.ts+89| ... | @@ -43,6 +43,36 @@ function createSendTurnHarness() { | ... | @@ -43,6 +43,36 @@ function createSendTurnHarness() { |
| 43 | return { manager, context, requireSession, sendRequest, updateSession }; | 43 | return { manager, context, requireSession, sendRequest, updateSession }; |
| 44 | } | 44 | } |
| 45 | 45 | ||
| 46 | function 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 | |||
| 46 | describe("classifyCodexStderrLine", () => { | 76 | describe("classifyCodexStderrLine", () => { |
| 47 | it("ignores empty lines", () => { | 77 | it("ignores empty lines", () => { |
| 48 | expect(classifyCodexStderrLine(" ")).toBeNull(); | 78 | expect(classifyCodexStderrLine(" ")).toBeNull(); |
| ... | @@ -198,3 +228,62 @@ describe("sendTurn", () => { | ... | @@ -198,3 +228,62 @@ describe("sendTurn", () => { |
| 198 | ).rejects.toThrow("Turn input must include text or attachments."); | 228 | ).rejects.toThrow("Turn input must include text or attachments."); |
| 199 | }); | 229 | }); |
| 200 | }); | 230 | }); |
| 231 | |||
| 232 | describe("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 { | ... | @@ -65,6 +65,16 @@ interface JsonRpcNotification { |
| 65 | params?: unknown; | 65 | params?: unknown; |
| 66 | } | 66 | } |
| 67 | 67 | ||
| 68 | export interface CodexThreadTurnSnapshot { | ||
| 69 | id: string; | ||
| 70 | items: unknown[]; | ||
| 71 | } | ||
| 72 | |||
| 73 | export interface CodexThreadSnapshot { | ||
| 74 | threadId: string; | ||
| 75 | turns: CodexThreadTurnSnapshot[]; | ||
| 76 | } | ||
| 77 | |||
| 68 | const ANSI_ESCAPE_CHAR = String.fromCharCode(27); | 78 | const ANSI_ESCAPE_CHAR = String.fromCharCode(27); |
| 69 | const ANSI_ESCAPE_REGEX = new RegExp(`${ANSI_ESCAPE_CHAR}\\[[0-9;]*m`, "g"); | 79 | const ANSI_ESCAPE_REGEX = new RegExp(`${ANSI_ESCAPE_CHAR}\\[[0-9;]*m`, "g"); |
| 70 | const CODEX_STDERR_LOG_REGEX = | 80 | const CODEX_STDERR_LOG_REGEX = |
| ... | @@ -331,6 +341,41 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | ... | @@ -331,6 +341,41 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 331 | }); | 341 | }); |
| 332 | } | 342 | } |
| 333 | 343 | ||
| 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 | |||
| 334 | async respondToRequest( | 379 | async respondToRequest( |
| 335 | sessionId: string, | 380 | sessionId: string, |
| 336 | requestId: string, | 381 | requestId: string, |
| ... | @@ -742,6 +787,31 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | ... | @@ -742,6 +787,31 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 742 | return undefined; | 787 | return undefined; |
| 743 | } | 788 | } |
| 744 | 789 | ||
| 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 | |||
| 745 | private isServerRequest(value: unknown): value is JsonRpcRequest { | 815 | private isServerRequest(value: unknown): value is JsonRpcRequest { |
| 746 | if (!value || typeof value !== "object") { | 816 | if (!value || typeof value !== "object") { |
| 747 | return false; | 817 | return false; |
| ... | @@ -823,6 +893,16 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | ... | @@ -823,6 +893,16 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 823 | return target as Record<string, unknown>; | 893 | return target as Record<string, unknown>; |
| 824 | } | 894 | } |
| 825 | 895 | ||
| 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 | |||
| 826 | private readString(value: unknown, key: string): string | undefined { | 906 | private readString(value: unknown, key: string): string | undefined { |
| 827 | if (!value || typeof value !== "object") { | 907 | if (!value || typeof value !== "object") { |
| 828 | return undefined; | 908 | return undefined; |
apps/server/src/providerManager.test.ts+132| ... | @@ -59,4 +59,136 @@ describe("ProviderManager", () => { | ... | @@ -59,4 +59,136 @@ describe("ProviderManager", () => { |
| 59 | 59 | ||
| 60 | manager.dispose(); | 60 | manager.dispose(); |
| 61 | }); | 61 | }); |
| 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 | }); | ||
| 62 | }); | 194 | }); |
apps/server/src/providerManager.ts+182| ... | @@ -3,8 +3,13 @@ import fs from "node:fs"; | ... | @@ -3,8 +3,13 @@ import fs from "node:fs"; |
| 3 | import path from "node:path"; | 3 | import path from "node:path"; |
| 4 | 4 | ||
| 5 | import { | 5 | import { |
| 6 | type ProviderCheckpoint, | ||
| 6 | type ProviderEvent, | 7 | type ProviderEvent, |
| 7 | type ProviderInterruptTurnInput, | 8 | type ProviderInterruptTurnInput, |
| 9 | type ProviderListCheckpointsInput, | ||
| 10 | type ProviderListCheckpointsResult, | ||
| 11 | type ProviderRevertToCheckpointInput, | ||
| 12 | type ProviderRevertToCheckpointResult, | ||
| 8 | type ProviderRespondToRequestInput, | 13 | type ProviderRespondToRequestInput, |
| 9 | type ProviderSendTurnInput, | 14 | type ProviderSendTurnInput, |
| 10 | type ProviderSession, | 15 | type ProviderSession, |
| ... | @@ -12,17 +17,144 @@ import { | ... | @@ -12,17 +17,144 @@ import { |
| 12 | type ProviderStopSessionInput, | 17 | type ProviderStopSessionInput, |
| 13 | type ProviderTurnStartResult, | 18 | type ProviderTurnStartResult, |
| 14 | providerInterruptTurnInputSchema, | 19 | providerInterruptTurnInputSchema, |
| 20 | providerListCheckpointsInputSchema, | ||
| 21 | providerRevertToCheckpointInputSchema, | ||
| 15 | providerRespondToRequestInputSchema, | 22 | providerRespondToRequestInputSchema, |
| 16 | providerSendTurnInputSchema, | 23 | providerSendTurnInputSchema, |
| 17 | providerSessionStartInputSchema, | 24 | providerSessionStartInputSchema, |
| 18 | providerStopSessionInputSchema, | 25 | providerStopSessionInputSchema, |
| 19 | } from "@t3tools/contracts"; | 26 | } from "@t3tools/contracts"; |
| 27 | import type { CodexThreadTurnSnapshot } from "./codexAppServerManager"; | ||
| 20 | import { CodexAppServerManager } from "./codexAppServerManager"; | 28 | import { CodexAppServerManager } from "./codexAppServerManager"; |
| 21 | 29 | ||
| 22 | export interface ProviderManagerEvents { | 30 | export interface ProviderManagerEvents { |
| 23 | event: [event: ProviderEvent]; | 31 | event: [event: ProviderEvent]; |
| 24 | } | 32 | } |
| 25 | 33 | ||
| 34 | function 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 | |||
| 41 | function asString(value: unknown): string | undefined { | ||
| 42 | return typeof value === "string" ? value : undefined; | ||
| 43 | } | ||
| 44 | |||
| 45 | function asArray(value: unknown): unknown[] { | ||
| 46 | return Array.isArray(value) ? value : []; | ||
| 47 | } | ||
| 48 | |||
| 49 | function 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 | |||
| 57 | function 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 | |||
| 90 | function 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 | |||
| 128 | function 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 | |||
| 26 | export class ProviderManager extends EventEmitter<ProviderManagerEvents> { | 158 | export class ProviderManager extends EventEmitter<ProviderManagerEvents> { |
| 27 | private readonly codex = new CodexAppServerManager(); | 159 | private readonly codex = new CodexAppServerManager(); |
| 28 | private readonly threadLogsDir: string; | 160 | private readonly threadLogsDir: string; |
| ... | @@ -101,6 +233,56 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { | ... | @@ -101,6 +233,56 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { |
| 101 | return this.codex.listSessions(); | 233 | return this.codex.listSessions(); |
| 102 | } | 234 | } |
| 103 | 235 | ||
| 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 | |||
| 104 | stopAll(): void { | 286 | stopAll(): void { |
| 105 | this.codex.stopAll(); | 287 | this.codex.stopAll(); |
| 106 | } | 288 | } |
apps/server/src/wsServer.test.ts+24| ... | @@ -646,6 +646,30 @@ describe("WebSocket Server", () => { | ... | @@ -646,6 +646,30 @@ describe("WebSocket Server", () => { |
| 646 | expect(response.result).toEqual([]); | 646 | expect(response.result).toEqual([]); |
| 647 | }); | 647 | }); |
| 648 | 648 | ||
| 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 | |||
| 649 | it("routes terminal RPC methods and broadcasts terminal events", async () => { | 673 | it("routes terminal RPC methods and broadcasts terminal events", async () => { |
| 650 | const cwd = makeTempDir("t3code-ws-terminal-cwd-"); | 674 | const cwd = makeTempDir("t3code-ws-terminal-cwd-"); |
| 651 | const terminalManager = new MockTerminalManager(); | 675 | const terminalManager = new MockTerminalManager(); |
apps/server/src/wsServer.ts+6| ... | @@ -310,6 +310,12 @@ export function createServer(options: ServerOptions) { | ... | @@ -310,6 +310,12 @@ export function createServer(options: ServerOptions) { |
| 310 | case WS_METHODS.providersListSessions: | 310 | case WS_METHODS.providersListSessions: |
| 311 | return providerManager.listSessions(); | 311 | return providerManager.listSessions(); |
| 312 | 312 | ||
| 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 | |||
| 313 | case WS_METHODS.projectsList: | 319 | case WS_METHODS.projectsList: |
| 314 | return projectRegistry.list(); | 320 | return projectRegistry.list(); |
| 315 | 321 |
apps/web/src/components/ChatView.tsx+149-1| ... | @@ -8,6 +8,7 @@ import { | ... | @@ -8,6 +8,7 @@ import { |
| 8 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, | 8 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, |
| 9 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | 9 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 10 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, | 10 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| 11 | type ProviderCheckpoint, | ||
| 11 | type ResolvedKeybindingsConfig, | 12 | type ResolvedKeybindingsConfig, |
| 12 | type ProviderApprovalDecision, | 13 | type ProviderApprovalDecision, |
| 13 | type ProviderSendTurnAttachmentInput, | 14 | type ProviderSendTurnAttachmentInput, |
| ... | @@ -345,6 +346,9 @@ export default function ChatView() { | ... | @@ -345,6 +346,9 @@ export default function ChatView() { |
| 345 | const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null); | 346 | const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null); |
| 346 | const [isSending, setIsSending] = useState(false); | 347 | const [isSending, setIsSending] = useState(false); |
| 347 | const [isConnecting, setIsConnecting] = useState(false); | 348 | const [isConnecting, setIsConnecting] = useState(false); |
| 349 | const [isLoadingCheckpoints, setIsLoadingCheckpoints] = useState(false); | ||
| 350 | const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); | ||
| 351 | const [checkpoints, setCheckpoints] = useState<ProviderCheckpoint[]>([]); | ||
| 348 | const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING); | 352 | const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING); |
| 349 | const [envMode, setEnvMode] = useState<"local" | "worktree">("local"); | 353 | const [envMode, setEnvMode] = useState<"local" | "worktree">("local"); |
| 350 | const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false); | 354 | const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false); |
| ... | @@ -374,7 +378,7 @@ export default function ChatView() { | ... | @@ -374,7 +378,7 @@ export default function ChatView() { |
| 374 | activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL, | 378 | activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL, |
| 375 | ); | 379 | ); |
| 376 | const phase = derivePhase(activeThread?.session ?? null); | 380 | const phase = derivePhase(activeThread?.session ?? null); |
| 377 | const isWorking = phase === "running" || isSending || isConnecting; | 381 | const isWorking = phase === "running" || isSending || isConnecting || isRevertingCheckpoint; |
| 378 | const nowIso = new Date(nowTick).toISOString(); | 382 | const nowIso = new Date(nowTick).toISOString(); |
| 379 | const workLogEntries = useMemo( | 383 | const workLogEntries = useMemo( |
| 380 | () => deriveWorkLogEntries(activeThread?.events ?? [], undefined), | 384 | () => deriveWorkLogEntries(activeThread?.events ?? [], undefined), |
| ... | @@ -575,6 +579,12 @@ export default function ChatView() { | ... | @@ -575,6 +579,12 @@ export default function ChatView() { |
| 575 | ); | 579 | ); |
| 576 | const hasReachedTerminalLimit = | 580 | const hasReachedTerminalLimit = |
| 577 | (activeThread?.terminalIds.length ?? 0) >= MAX_THREAD_TERMINAL_COUNT; | 581 | (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; | ||
| 578 | 588 | ||
| 579 | const revokePreviewUrls = useCallback((images: Array<{ previewUrl?: string }>) => { | 589 | const revokePreviewUrls = useCallback((images: Array<{ previewUrl?: string }>) => { |
| 580 | for (const image of images) { | 590 | for (const image of images) { |
| ... | @@ -963,6 +973,12 @@ export default function ChatView() { | ... | @@ -963,6 +973,12 @@ export default function ChatView() { |
| 963 | ); | 973 | ); |
| 964 | }, [composerMenuItems, composerMenuOpen]); | 974 | }, [composerMenuItems, composerMenuOpen]); |
| 965 | 975 | ||
| 976 | useEffect(() => { | ||
| 977 | setCheckpoints([]); | ||
| 978 | setIsLoadingCheckpoints(false); | ||
| 979 | setIsRevertingCheckpoint(false); | ||
| 980 | }, [activeThread?.id]); | ||
| 981 | |||
| 966 | useEffect(() => { | 982 | useEffect(() => { |
| 967 | if (!activeThread?.id || activeThread.terminalOpen) return; | 983 | if (!activeThread?.id || activeThread.terminalOpen) return; |
| 968 | const frame = window.requestAnimationFrame(() => { | 984 | const frame = window.requestAnimationFrame(() => { |
| ... | @@ -1311,6 +1327,78 @@ export default function ChatView() { | ... | @@ -1311,6 +1327,78 @@ export default function ChatView() { |
| 1311 | } | 1327 | } |
| 1312 | }; | 1328 | }; |
| 1313 | 1329 | ||
| 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 | |||
| 1314 | const onSend = async (e: FormEvent) => { | 1402 | const onSend = async (e: FormEvent) => { |
| 1315 | e.preventDefault(); | 1403 | e.preventDefault(); |
| 1316 | if (!api || !activeThread || isSending || isConnecting) return; | 1404 | if (!api || !activeThread || isSending || isConnecting) return; |
| ... | @@ -1641,6 +1729,14 @@ export default function ChatView() { | ... | @@ -1641,6 +1729,14 @@ export default function ChatView() { |
| 1641 | onAddProjectScript={saveProjectScript} | 1729 | onAddProjectScript={saveProjectScript} |
| 1642 | onUpdateProjectScript={updateProjectScript} | 1730 | onUpdateProjectScript={updateProjectScript} |
| 1643 | onToggleDiff={onToggleDiff} | 1731 | onToggleDiff={onToggleDiff} |
| 1732 | checkpoints={checkpoints} | ||
| 1733 | canCheckpoint={canCheckpoint} | ||
| 1734 | isLoadingCheckpoints={isLoadingCheckpoints} | ||
| 1735 | isRevertingCheckpoint={isRevertingCheckpoint} | ||
| 1736 | onOpenCheckpoints={() => { | ||
| 1737 | void loadCheckpoints(); | ||
| 1738 | }} | ||
| 1739 | onRevertToCheckpoint={onRevertToCheckpoint} | ||
| 1644 | /> | 1740 | /> |
| 1645 | </header> | 1741 | </header> |
| 1646 | 1742 | ||
| ... | @@ -1974,6 +2070,12 @@ interface ChatHeaderProps { | ... | @@ -1974,6 +2070,12 @@ interface ChatHeaderProps { |
| 1974 | onAddProjectScript: (input: NewProjectScriptInput) => Promise<void>; | 2070 | onAddProjectScript: (input: NewProjectScriptInput) => Promise<void>; |
| 1975 | onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise<void>; | 2071 | onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise<void>; |
| 1976 | onToggleDiff: () => void; | 2072 | onToggleDiff: () => void; |
| 2073 | checkpoints: ProviderCheckpoint[]; | ||
| 2074 | canCheckpoint: boolean; | ||
| 2075 | isLoadingCheckpoints: boolean; | ||
| 2076 | isRevertingCheckpoint: boolean; | ||
| 2077 | onOpenCheckpoints: () => void; | ||
| 2078 | onRevertToCheckpoint: (checkpoint: ProviderCheckpoint) => void; | ||
| 1977 | } | 2079 | } |
| 1978 | 2080 | ||
| 1979 | const ChatHeader = memo(function ChatHeader({ | 2081 | const ChatHeader = memo(function ChatHeader({ |
| ... | @@ -1989,6 +2091,12 @@ const ChatHeader = memo(function ChatHeader({ | ... | @@ -1989,6 +2091,12 @@ const ChatHeader = memo(function ChatHeader({ |
| 1989 | onAddProjectScript, | 2091 | onAddProjectScript, |
| 1990 | onUpdateProjectScript, | 2092 | onUpdateProjectScript, |
| 1991 | onToggleDiff, | 2093 | onToggleDiff, |
| 2094 | checkpoints, | ||
| 2095 | canCheckpoint, | ||
| 2096 | isLoadingCheckpoints, | ||
| 2097 | isRevertingCheckpoint, | ||
| 2098 | onOpenCheckpoints, | ||
| 2099 | onRevertToCheckpoint, | ||
| 1992 | }: ChatHeaderProps) { | 2100 | }: ChatHeaderProps) { |
| 1993 | return ( | 2101 | return ( |
| 1994 | <> | 2102 | <> |
| ... | @@ -2011,6 +2119,46 @@ const ChatHeader = memo(function ChatHeader({ | ... | @@ -2011,6 +2119,46 @@ const ChatHeader = memo(function ChatHeader({ |
| 2011 | )} | 2119 | )} |
| 2012 | {activeProjectName && <OpenInPicker keybindings={keybindings} />} | 2120 | {activeProjectName && <OpenInPicker keybindings={keybindings} />} |
| 2013 | {activeProjectName && <GitActionsControl api={api} gitCwd={gitCwd} />} | 2121 | {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> | ||
| 2014 | <Button | 2162 | <Button |
| 2015 | size="xs" | 2163 | size="xs" |
| 2016 | variant="ghost" | 2164 | variant="ghost" |
apps/web/src/store.test.ts+62| ... | @@ -718,4 +718,66 @@ describe("store reducer thread continuity", () => { | ... | @@ -718,4 +718,66 @@ describe("store reducer thread continuity", () => { |
| 718 | expect(next.threads[0]?.latestTurnCompletedAt).toBe(completedAt); | 718 | expect(next.threads[0]?.latestTurnCompletedAt).toBe(completedAt); |
| 719 | expect(next.threads[0]?.lastVisitedAt).toBe(completedAt); | 719 | expect(next.threads[0]?.lastVisitedAt).toBe(completedAt); |
| 720 | }); | 720 | }); |
| 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 | }); | ||
| 721 | }); | 783 | }); |
apps/web/src/store.ts+39| ... | @@ -59,6 +59,13 @@ type Action = | ... | @@ -59,6 +59,13 @@ type Action = |
| 59 | | { type: "SET_ERROR"; threadId: string; error: string | null } | 59 | | { type: "SET_ERROR"; threadId: string; error: string | null } |
| 60 | | { type: "SET_THREAD_TITLE"; threadId: string; title: string } | 60 | | { type: "SET_THREAD_TITLE"; threadId: string; title: string } |
| 61 | | { type: "SET_THREAD_MODEL"; threadId: string; model: string } | 61 | | { 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 | } | ||
| 62 | | { | 69 | | { |
| 63 | type: "SET_THREAD_BRANCH"; | 70 | type: "SET_THREAD_BRANCH"; |
| 64 | threadId: string; | 71 | threadId: string; |
| ... | @@ -834,6 +841,38 @@ export function reducer(state: AppState, action: Action): AppState { | ... | @@ -834,6 +841,38 @@ export function reducer(state: AppState, action: Action): AppState { |
| 834 | })), | 841 | })), |
| 835 | }; | 842 | }; |
| 836 | 843 | ||
| 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 | |||
| 837 | case "SET_THREAD_BRANCH": { | 876 | case "SET_THREAD_BRANCH": { |
| 838 | return { | 877 | return { |
| 839 | ...state, | 878 | ...state, |
apps/web/src/wsNativeApi.ts+3| ... | @@ -91,6 +91,9 @@ export function createWsNativeApi(): NativeApi { | ... | @@ -91,6 +91,9 @@ export function createWsNativeApi(): NativeApi { |
| 91 | respondToRequest: (input) => transport.request(WS_METHODS.providersRespondToRequest, input), | 91 | respondToRequest: (input) => transport.request(WS_METHODS.providersRespondToRequest, input), |
| 92 | stopSession: (input) => transport.request(WS_METHODS.providersStopSession, input), | 92 | stopSession: (input) => transport.request(WS_METHODS.providersStopSession, input), |
| 93 | listSessions: () => transport.request(WS_METHODS.providersListSessions), | 93 | 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), | ||
| 94 | onEvent: (callback) => | 97 | onEvent: (callback) => |
| 95 | transport.subscribe(WS_CHANNELS.providerEvent, callback as (data: unknown) => void), | 98 | transport.subscribe(WS_CHANNELS.providerEvent, callback as (data: unknown) => void), |
| 96 | }, | 99 | }, |
packages/contracts/src/ipc.ts+8| ... | @@ -18,6 +18,10 @@ import type { | ... | @@ -18,6 +18,10 @@ import type { |
| 18 | import type { | 18 | import type { |
| 19 | ProviderEvent, | 19 | ProviderEvent, |
| 20 | ProviderInterruptTurnInput, | 20 | ProviderInterruptTurnInput, |
| 21 | ProviderListCheckpointsInput, | ||
| 22 | ProviderListCheckpointsResult, | ||
| 23 | ProviderRevertToCheckpointInput, | ||
| 24 | ProviderRevertToCheckpointResult, | ||
| 21 | ProviderRespondToRequestInput, | 25 | ProviderRespondToRequestInput, |
| 22 | ProviderSendTurnInput, | 26 | ProviderSendTurnInput, |
| 23 | ProviderSession, | 27 | ProviderSession, |
| ... | @@ -89,6 +93,10 @@ export interface NativeApi { | ... | @@ -89,6 +93,10 @@ export interface NativeApi { |
| 89 | respondToRequest: (input: ProviderRespondToRequestInput) => Promise<void>; | 93 | respondToRequest: (input: ProviderRespondToRequestInput) => Promise<void>; |
| 90 | stopSession: (input: ProviderStopSessionInput) => Promise<void>; | 94 | stopSession: (input: ProviderStopSessionInput) => Promise<void>; |
| 91 | listSessions: () => Promise<ProviderSession[]>; | 95 | listSessions: () => Promise<ProviderSession[]>; |
| 96 | listCheckpoints: (input: ProviderListCheckpointsInput) => Promise<ProviderListCheckpointsResult>; | ||
| 97 | revertToCheckpoint: ( | ||
| 98 | input: ProviderRevertToCheckpointInput, | ||
| 99 | ) => Promise<ProviderRevertToCheckpointResult>; | ||
| 92 | onEvent: (callback: (event: ProviderEvent) => void) => () => void; | 100 | onEvent: (callback: (event: ProviderEvent) => void) => () => void; |
| 93 | }; | 101 | }; |
| 94 | projects: { | 102 | projects: { |
packages/contracts/src/provider.test.ts+73| ... | @@ -2,7 +2,12 @@ import { describe, expect, it } from "vitest"; | ... | @@ -2,7 +2,12 @@ import { describe, expect, it } from "vitest"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 4 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | 4 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 5 | providerCheckpointSchema, | ||
| 5 | providerEventSchema, | 6 | providerEventSchema, |
| 7 | providerListCheckpointsInputSchema, | ||
| 8 | providerListCheckpointsResultSchema, | ||
| 9 | providerRevertToCheckpointInputSchema, | ||
| 10 | providerRevertToCheckpointResultSchema, | ||
| 6 | providerRespondToRequestInputSchema, | 11 | providerRespondToRequestInputSchema, |
| 7 | providerSendTurnInputSchema, | 12 | providerSendTurnInputSchema, |
| 8 | providerSessionStartInputSchema, | 13 | providerSessionStartInputSchema, |
| ... | @@ -157,3 +162,71 @@ describe("providerRespondToRequestInputSchema", () => { | ... | @@ -157,3 +162,71 @@ describe("providerRespondToRequestInputSchema", () => { |
| 157 | ).toThrow(); | 162 | ).toThrow(); |
| 158 | }); | 163 | }); |
| 159 | }); | 164 | }); |
| 165 | |||
| 166 | describe("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({ | ... | @@ -111,6 +111,37 @@ export const providerStopSessionInputSchema = z.object({ |
| 111 | sessionId: z.string().min(1), | 111 | sessionId: z.string().min(1), |
| 112 | }); | 112 | }); |
| 113 | 113 | ||
| 114 | export const providerListCheckpointsInputSchema = z.object({ | ||
| 115 | sessionId: z.string().min(1), | ||
| 116 | }); | ||
| 117 | |||
| 118 | export 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 | |||
| 127 | export const providerListCheckpointsResultSchema = z.object({ | ||
| 128 | threadId: z.string().min(1), | ||
| 129 | checkpoints: z.array(providerCheckpointSchema), | ||
| 130 | }); | ||
| 131 | |||
| 132 | export const providerRevertToCheckpointInputSchema = z.object({ | ||
| 133 | sessionId: z.string().min(1), | ||
| 134 | turnCount: z.number().int().min(0), | ||
| 135 | }); | ||
| 136 | |||
| 137 | export 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 | |||
| 114 | export const providerRespondToRequestInputSchema = z.object({ | 145 | export const providerRespondToRequestInputSchema = z.object({ |
| 115 | sessionId: z.string().min(1), | 146 | sessionId: z.string().min(1), |
| 116 | requestId: z.string().min(1), | 147 | requestId: z.string().min(1), |
| ... | @@ -151,6 +182,13 @@ export type ProviderSendTurnInput = z.input<typeof providerSendTurnInputSchema>; | ... | @@ -151,6 +182,13 @@ export type ProviderSendTurnInput = z.input<typeof providerSendTurnInputSchema>; |
| 151 | export type ProviderTurnStartResult = z.infer<typeof providerTurnStartResultSchema>; | 182 | export type ProviderTurnStartResult = z.infer<typeof providerTurnStartResultSchema>; |
| 152 | export type ProviderInterruptTurnInput = z.input<typeof providerInterruptTurnInputSchema>; | 183 | export type ProviderInterruptTurnInput = z.input<typeof providerInterruptTurnInputSchema>; |
| 153 | export type ProviderStopSessionInput = z.input<typeof providerStopSessionInputSchema>; | 184 | export type ProviderStopSessionInput = z.input<typeof providerStopSessionInputSchema>; |
| 185 | export type ProviderListCheckpointsInput = z.input<typeof providerListCheckpointsInputSchema>; | ||
| 186 | export type ProviderCheckpoint = z.infer<typeof providerCheckpointSchema>; | ||
| 187 | export type ProviderListCheckpointsResult = z.infer<typeof providerListCheckpointsResultSchema>; | ||
| 188 | export type ProviderRevertToCheckpointInput = z.input<typeof providerRevertToCheckpointInputSchema>; | ||
| 189 | export type ProviderRevertToCheckpointResult = z.infer< | ||
| 190 | typeof providerRevertToCheckpointResultSchema | ||
| 191 | >; | ||
| 154 | export type ProviderRespondToRequestInput = z.input<typeof providerRespondToRequestInputSchema>; | 192 | export type ProviderRespondToRequestInput = z.input<typeof providerRespondToRequestInputSchema>; |
| 155 | export type ProviderEventKind = z.infer<typeof providerEventKindSchema>; | 193 | export type ProviderEventKind = z.infer<typeof providerEventKindSchema>; |
| 156 | export type ProviderEvent = z.infer<typeof providerEventSchema>; | 194 | export type ProviderEvent = z.infer<typeof providerEventSchema>; |
packages/contracts/src/ws.ts+2| ... | @@ -10,6 +10,8 @@ export const WS_METHODS = { | ... | @@ -10,6 +10,8 @@ export const WS_METHODS = { |
| 10 | providersRespondToRequest: "providers.respondToRequest", | 10 | providersRespondToRequest: "providers.respondToRequest", |
| 11 | providersStopSession: "providers.stopSession", | 11 | providersStopSession: "providers.stopSession", |
| 12 | providersListSessions: "providers.listSessions", | 12 | providersListSessions: "providers.listSessions", |
| 13 | providersListCheckpoints: "providers.listCheckpoints", | ||
| 14 | providersRevertToCheckpoint: "providers.revertToCheckpoint", | ||
| 13 | 15 | ||
| 14 | // Project registry methods | 16 | // Project registry methods |
| 15 | projectsList: "projects.list", | 17 | projectsList: "projects.list", |