| author | |
| committer | |
| log | c90e245038970029f777200201048513da8f84e8 |
| tree | edfcdabcdd353f50e7946456467a37d8bd04859f |
| parent | 200e43e44c52e0f819f7162a6a3fb7551a9ee013 |
Co-authored-by: codex <codex@users.noreply.github.com>16 files changed, 571 insertions(+), 148 deletions(-)
apps/server/src/codexAppServerManager.test.ts+29| ... | ... | @@ -262,6 +262,35 @@ describe("thread checkpoint control", () => { |
| 262 | 262 | }); |
| 263 | 263 | }); |
| 264 | 264 | |
| 265 | it("reads thread turns from flat thread/read responses", async () => { | |
| 266 | const { manager, context, sendRequest } = createThreadControlHarness(); | |
| 267 | sendRequest.mockResolvedValue({ | |
| 268 | threadId: "thread_1", | |
| 269 | turns: [ | |
| 270 | { | |
| 271 | id: "turn_1", | |
| 272 | items: [{ type: "userMessage", content: [{ type: "text", text: "hello" }] }], | |
| 273 | }, | |
| 274 | ], | |
| 275 | }); | |
| 276 | ||
| 277 | const result = await manager.readThread("sess_1"); | |
| 278 | ||
| 279 | expect(sendRequest).toHaveBeenCalledWith(context, "thread/read", { | |
| 280 | threadId: "thread_1", | |
| 281 | includeTurns: true, | |
| 282 | }); | |
| 283 | expect(result).toEqual({ | |
| 284 | threadId: "thread_1", | |
| 285 | turns: [ | |
| 286 | { | |
| 287 | id: "turn_1", | |
| 288 | items: [{ type: "userMessage", content: [{ type: "text", text: "hello" }] }], | |
| 289 | }, | |
| 290 | ], | |
| 291 | }); | |
| 292 | }); | |
| 293 | ||
| 265 | 294 | it("rolls back turns via thread/rollback and resets session running state", async () => { |
| 266 | 295 | const { manager, context, sendRequest, updateSession } = createThreadControlHarness(); |
| 267 | 296 | sendRequest.mockResolvedValue({ |
apps/server/src/codexAppServerManager.ts+1-1| ... | ... | @@ -796,7 +796,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 796 | 796 | throw new Error(`${method} response did not include a thread id.`); |
| 797 | 797 | } |
| 798 | 798 | |
| 799 | const turnsRaw = this.readArray(thread, "turns") ?? []; | |
| 799 | const turnsRaw = this.readArray(thread, "turns") ?? this.readArray(responseRecord, "turns") ?? []; | |
| 800 | 800 | const turns = turnsRaw.map((turnValue, index) => { |
| 801 | 801 | const turn = this.readObject(turnValue); |
| 802 | 802 | const turnId = this.readString(turn, "id") ?? `${threadId}:turn:${index + 1}`; |
apps/server/src/filesystemCheckpointStore.ts+30-16| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { mkdtemp, rm, writeFile } from "node:fs/promises"; | |
| 1 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 2 | 2 | import os from "node:os"; |
| 3 | 3 | import path from "node:path"; |
| 4 | 4 | import { randomUUID } from "node:crypto"; |
| ... | ... | @@ -47,18 +47,17 @@ export class FilesystemCheckpointStore { |
| 47 | 47 | const ref = checkpointRefForThreadTurn(threadId, turnCount); |
| 48 | 48 | |
| 49 | 49 | const tempDir = await mkdtemp(path.join(os.tmpdir(), "t3-fs-checkpoint-")); |
| 50 | const tempIndexPath = path.join(tempDir, `index-${randomUUID()}`); | |
| 51 | const commitEnv: NodeJS.ProcessEnv = { | |
| 52 | ...process.env, | |
| 53 | GIT_INDEX_FILE: tempIndexPath, | |
| 54 | GIT_AUTHOR_NAME: "T3 Code", | |
| 55 | GIT_AUTHOR_EMAIL: "codex@users.noreply.github.com", | |
| 56 | GIT_COMMITTER_NAME: "T3 Code", | |
| 57 | GIT_COMMITTER_EMAIL: "codex@users.noreply.github.com", | |
| 58 | }; | |
| 59 | 50 | |
| 60 | 51 | try { |
| 61 | await writeFile(tempIndexPath, ""); | |
| 52 | const tempIndexPath = path.join(tempDir, `index-${randomUUID()}`); | |
| 53 | const commitEnv: NodeJS.ProcessEnv = { | |
| 54 | ...process.env, | |
| 55 | GIT_INDEX_FILE: tempIndexPath, | |
| 56 | GIT_AUTHOR_NAME: "T3 Code", | |
| 57 | GIT_AUTHOR_EMAIL: "codex@users.noreply.github.com", | |
| 58 | GIT_COMMITTER_NAME: "T3 Code", | |
| 59 | GIT_COMMITTER_EMAIL: "codex@users.noreply.github.com", | |
| 60 | }; | |
| 62 | 61 | |
| 63 | 62 | const hasHead = await this.hasHeadCommit(cwd); |
| 64 | 63 | if (hasHead) { |
| ... | ... | @@ -133,7 +132,7 @@ export class FilesystemCheckpointStore { |
| 133 | 132 | |
| 134 | 133 | await this.runGit(cwd, ["restore", "--source", commitOid, "--worktree", "--staged", "--", "."]); |
| 135 | 134 | await this.runGit(cwd, ["clean", "-fd", "--", "."]); |
| 136 | await this.runGit(cwd, ["reset", "--quiet", "--", "."], { allowNonZeroExit: true }); | |
| 135 | await this.runGit(cwd, ["reset", "--quiet", "--", "."]); | |
| 137 | 136 | return true; |
| 138 | 137 | } |
| 139 | 138 | |
| ... | ... | @@ -228,11 +227,20 @@ export class FilesystemCheckpointStore { |
| 228 | 227 | return turn !== null && turn > maxTurnCount; |
| 229 | 228 | }); |
| 230 | 229 | |
| 231 | await Promise.all( | |
| 232 | refsToDelete.map((refName) => | |
| 233 | this.runGit(cwd, ["update-ref", "-d", refName], { allowNonZeroExit: true }), | |
| 234 | ), | |
| 230 | const batchSize = 32; | |
| 231 | const batches = Array.from( | |
| 232 | { length: Math.ceil(refsToDelete.length / batchSize) }, | |
| 233 | (_, index) => refsToDelete.slice(index * batchSize, (index + 1) * batchSize), | |
| 235 | 234 | ); |
| 235 | await batches.reduce<Promise<void>>((pending, batch) => { | |
| 236 | return pending.then(async () => { | |
| 237 | await Promise.all( | |
| 238 | batch.map((refName) => | |
| 239 | this.runGit(cwd, ["update-ref", "-d", refName], { allowNonZeroExit: true }), | |
| 240 | ), | |
| 241 | ); | |
| 242 | }); | |
| 243 | }, Promise.resolve()); | |
| 236 | 244 | } |
| 237 | 245 | |
| 238 | 246 | private async hasHeadCommit(cwd: string): Promise<boolean> { |
| ... | ... | @@ -288,6 +296,12 @@ export class FilesystemCheckpointStore { |
| 288 | 296 | throw new Error(`${quoteGitCommand(args)} timed out.`); |
| 289 | 297 | } |
| 290 | 298 | |
| 299 | if (result.stdoutTruncated || result.stderrTruncated) { | |
| 300 | throw new Error( | |
| 301 | `${quoteGitCommand(args)} output exceeded ${DEFAULT_MAX_OUTPUT_BYTES} bytes and was truncated.`, | |
| 302 | ); | |
| 303 | } | |
| 304 | ||
| 291 | 305 | if (!options.allowNonZeroExit && result.code !== 0) { |
| 292 | 306 | const stderr = result.stderr.trim(); |
| 293 | 307 | throw new Error( |
apps/server/src/providerManager.test.ts+167-52| ... | ... | @@ -151,55 +151,44 @@ describe("ProviderManager", () => { |
| 151 | 151 | |
| 152 | 152 | it("rolls back from a selected checkpoint turn count", async () => { |
| 153 | 153 | const manager = new ProviderManager(); |
| 154 | const codex = ( | |
| 155 | manager as unknown as { | |
| 156 | codex: { | |
| 157 | hasSession: (sessionId: string) => boolean; | |
| 158 | readThread: (sessionId: string) => Promise<{ | |
| 159 | threadId: string; | |
| 160 | turns: Array<{ id: string; items: unknown[] }>; | |
| 161 | }>; | |
| 162 | rollbackThread: ( | |
| 163 | sessionId: string, | |
| 164 | numTurns: number, | |
| 165 | ) => Promise<{ | |
| 166 | threadId: string; | |
| 167 | turns: Array<{ id: string; items: unknown[] }>; | |
| 168 | }>; | |
| 169 | listSessions: () => Array<{ | |
| 170 | sessionId: string; | |
| 171 | provider: "codex"; | |
| 172 | status: "ready"; | |
| 173 | createdAt: string; | |
| 174 | updatedAt: string; | |
| 175 | cwd?: string; | |
| 176 | }>; | |
| 177 | }; | |
| 178 | filesystemCheckpointStore: { | |
| 179 | isGitRepository: (cwd: string) => Promise<boolean>; | |
| 180 | ensureRootCheckpoint: (input: { cwd: string; threadId: string }) => Promise<boolean>; | |
| 181 | }; | |
| 182 | } | |
| 183 | ).codex; | |
| 184 | 154 | const internals = manager as unknown as { |
| 155 | codex: { | |
| 156 | hasSession: (sessionId: string) => boolean; | |
| 157 | readThread: (sessionId: string) => Promise<{ | |
| 158 | threadId: string; | |
| 159 | turns: Array<{ id: string; items: unknown[] }>; | |
| 160 | }>; | |
| 161 | rollbackThread: ( | |
| 162 | sessionId: string, | |
| 163 | numTurns: number, | |
| 164 | ) => Promise<{ | |
| 165 | threadId: string; | |
| 166 | turns: Array<{ id: string; items: unknown[] }>; | |
| 167 | }>; | |
| 168 | }; | |
| 185 | 169 | filesystemCheckpointStore: { |
| 186 | isGitRepository: (cwd: string) => Promise<boolean>; | |
| 187 | ensureRootCheckpoint: (input: { cwd: string; threadId: string }) => Promise<boolean>; | |
| 170 | hasCheckpoint: (input: { | |
| 171 | cwd: string; | |
| 172 | threadId: string; | |
| 173 | turnCount: number; | |
| 174 | }) => Promise<boolean>; | |
| 175 | restoreCheckpoint: (input: { | |
| 176 | cwd: string; | |
| 177 | threadId: string; | |
| 178 | turnCount: number; | |
| 179 | }) => Promise<boolean>; | |
| 180 | pruneAfterTurn: (input: { | |
| 181 | cwd: string; | |
| 182 | threadId: string; | |
| 183 | maxTurnCount: number; | |
| 184 | }) => Promise<void>; | |
| 188 | 185 | }; |
| 186 | sessionCheckpointCwds: Map<string, string>; | |
| 189 | 187 | }; |
| 190 | 188 | |
| 191 | 189 | const rollbackCalls: Array<{ sessionId: string; numTurns: number }> = []; |
| 192 | codex.hasSession = () => true; | |
| 193 | codex.listSessions = () => [ | |
| 194 | { | |
| 195 | sessionId: "sess_1", | |
| 196 | provider: "codex", | |
| 197 | status: "ready", | |
| 198 | createdAt: "2026-02-18T00:00:00.000Z", | |
| 199 | updatedAt: "2026-02-18T00:00:00.000Z", | |
| 200 | }, | |
| 201 | ]; | |
| 202 | codex.readThread = async () => ({ | |
| 190 | internals.codex.hasSession = () => true; | |
| 191 | internals.codex.readThread = async () => ({ | |
| 203 | 192 | threadId: "thr_1", |
| 204 | 193 | turns: [ |
| 205 | 194 | { id: "turn_1", items: [] }, |
| ... | ... | @@ -207,15 +196,20 @@ describe("ProviderManager", () => { |
| 207 | 196 | { id: "turn_3", items: [] }, |
| 208 | 197 | ], |
| 209 | 198 | }); |
| 210 | codex.rollbackThread = async (sessionId, numTurns) => { | |
| 199 | internals.codex.rollbackThread = async (sessionId, numTurns) => { | |
| 211 | 200 | rollbackCalls.push({ sessionId, numTurns }); |
| 212 | 201 | return { |
| 213 | 202 | threadId: "thr_1", |
| 214 | 203 | turns: [{ id: "turn_1", items: [] }], |
| 215 | 204 | }; |
| 216 | 205 | }; |
| 217 | internals.filesystemCheckpointStore.isGitRepository = async () => false; | |
| 218 | internals.filesystemCheckpointStore.ensureRootCheckpoint = async () => true; | |
| 206 | const hasCheckpoint = vi.fn(async () => true); | |
| 207 | const restoreCheckpoint = vi.fn(async () => true); | |
| 208 | const pruneAfterTurn = vi.fn(async () => undefined); | |
| 209 | internals.filesystemCheckpointStore.hasCheckpoint = hasCheckpoint; | |
| 210 | internals.filesystemCheckpointStore.restoreCheckpoint = restoreCheckpoint; | |
| 211 | internals.filesystemCheckpointStore.pruneAfterTurn = pruneAfterTurn; | |
| 212 | internals.sessionCheckpointCwds.set("sess_1", "/repo"); | |
| 219 | 213 | |
| 220 | 214 | const result = await manager.revertToCheckpoint({ |
| 221 | 215 | sessionId: "sess_1", |
| ... | ... | @@ -228,6 +222,21 @@ describe("ProviderManager", () => { |
| 228 | 222 | expect(result.messageCount).toBe(0); |
| 229 | 223 | expect(result.rolledBackTurns).toBe(2); |
| 230 | 224 | expect(result.checkpoints).toHaveLength(2); |
| 225 | expect(hasCheckpoint).toHaveBeenCalledWith({ | |
| 226 | cwd: "/repo", | |
| 227 | threadId: "thr_1", | |
| 228 | turnCount: 1, | |
| 229 | }); | |
| 230 | expect(restoreCheckpoint).toHaveBeenCalledWith({ | |
| 231 | cwd: "/repo", | |
| 232 | threadId: "thr_1", | |
| 233 | turnCount: 1, | |
| 234 | }); | |
| 235 | expect(pruneAfterTurn).toHaveBeenCalledWith({ | |
| 236 | cwd: "/repo", | |
| 237 | threadId: "thr_1", | |
| 238 | maxTurnCount: 1, | |
| 239 | }); | |
| 231 | 240 | |
| 232 | 241 | manager.dispose(); |
| 233 | 242 | }); |
| ... | ... | @@ -471,6 +480,101 @@ describe("ProviderManager", () => { |
| 471 | 480 | } |
| 472 | 481 | }); |
| 473 | 482 | |
| 483 | it("does not fall back to process cwd when a session cwd exists but is not a git repo", async () => { | |
| 484 | const manager = new ProviderManager(); | |
| 485 | const internals = manager as unknown as { | |
| 486 | codex: { | |
| 487 | hasSession: (sessionId: string) => boolean; | |
| 488 | listSessions: () => Array<{ | |
| 489 | sessionId: string; | |
| 490 | provider: "codex"; | |
| 491 | status: "ready"; | |
| 492 | createdAt: string; | |
| 493 | updatedAt: string; | |
| 494 | cwd?: string; | |
| 495 | }>; | |
| 496 | }; | |
| 497 | filesystemCheckpointStore: { | |
| 498 | isGitRepository: (cwd: string) => Promise<boolean>; | |
| 499 | }; | |
| 500 | sessionCheckpointCwds: Map<string, string>; | |
| 501 | }; | |
| 502 | ||
| 503 | const processCwd = vi.spyOn(process, "cwd").mockReturnValue("/fallback-repo"); | |
| 504 | try { | |
| 505 | internals.codex.hasSession = () => true; | |
| 506 | internals.codex.listSessions = () => [ | |
| 507 | { | |
| 508 | sessionId: "sess_1", | |
| 509 | provider: "codex", | |
| 510 | status: "ready", | |
| 511 | createdAt: "2026-02-18T00:00:00.000Z", | |
| 512 | updatedAt: "2026-02-18T00:00:00.000Z", | |
| 513 | cwd: "/session-cwd", | |
| 514 | }, | |
| 515 | ]; | |
| 516 | const isGitRepository = vi.fn(async (cwd: string) => cwd === "/fallback-repo"); | |
| 517 | internals.filesystemCheckpointStore.isGitRepository = isGitRepository; | |
| 518 | internals.sessionCheckpointCwds.delete("sess_1"); | |
| 519 | ||
| 520 | await expect( | |
| 521 | manager.getCheckpointDiff({ | |
| 522 | sessionId: "sess_1", | |
| 523 | fromTurnCount: 0, | |
| 524 | toTurnCount: 1, | |
| 525 | }), | |
| 526 | ).rejects.toThrow("Filesystem checkpoints are unavailable for this session."); | |
| 527 | expect(isGitRepository).toHaveBeenCalledWith("/session-cwd"); | |
| 528 | expect(isGitRepository).not.toHaveBeenCalledWith("/fallback-repo"); | |
| 529 | } finally { | |
| 530 | processCwd.mockRestore(); | |
| 531 | manager.dispose(); | |
| 532 | } | |
| 533 | }); | |
| 534 | ||
| 535 | it("fails revert when filesystem checkpoints are unavailable for the session", async () => { | |
| 536 | const manager = new ProviderManager(); | |
| 537 | const internals = manager as unknown as { | |
| 538 | codex: { | |
| 539 | hasSession: (sessionId: string) => boolean; | |
| 540 | listSessions: () => Array<{ | |
| 541 | sessionId: string; | |
| 542 | provider: "codex"; | |
| 543 | status: "ready"; | |
| 544 | createdAt: string; | |
| 545 | updatedAt: string; | |
| 546 | cwd?: string; | |
| 547 | }>; | |
| 548 | }; | |
| 549 | filesystemCheckpointStore: { | |
| 550 | isGitRepository: (cwd: string) => Promise<boolean>; | |
| 551 | }; | |
| 552 | sessionCheckpointCwds: Map<string, string>; | |
| 553 | }; | |
| 554 | ||
| 555 | internals.codex.hasSession = () => true; | |
| 556 | internals.codex.listSessions = () => [ | |
| 557 | { | |
| 558 | sessionId: "sess_1", | |
| 559 | provider: "codex", | |
| 560 | status: "ready", | |
| 561 | createdAt: "2026-02-18T00:00:00.000Z", | |
| 562 | updatedAt: "2026-02-18T00:00:00.000Z", | |
| 563 | }, | |
| 564 | ]; | |
| 565 | internals.filesystemCheckpointStore.isGitRepository = async () => false; | |
| 566 | internals.sessionCheckpointCwds.delete("sess_1"); | |
| 567 | ||
| 568 | await expect( | |
| 569 | manager.revertToCheckpoint({ | |
| 570 | sessionId: "sess_1", | |
| 571 | turnCount: 1, | |
| 572 | }), | |
| 573 | ).rejects.toThrow("Filesystem checkpoints are unavailable for this session."); | |
| 574 | ||
| 575 | manager.dispose(); | |
| 576 | }); | |
| 577 | ||
| 474 | 578 | it("restores filesystem checkpoint when reverting and checkpointing is enabled", async () => { |
| 475 | 579 | const manager = new ProviderManager(); |
| 476 | 580 | const internals = manager as unknown as { |
| ... | ... | @@ -509,6 +613,7 @@ describe("ProviderManager", () => { |
| 509 | 613 | }; |
| 510 | 614 | |
| 511 | 615 | internals.codex.hasSession = () => true; |
| 616 | const callOrder: string[] = []; | |
| 512 | 617 | internals.codex.readThread = async () => ({ |
| 513 | 618 | threadId: "thr_1", |
| 514 | 619 | turns: [ |
| ... | ... | @@ -516,13 +621,22 @@ describe("ProviderManager", () => { |
| 516 | 621 | { id: "turn_2", items: [] }, |
| 517 | 622 | ], |
| 518 | 623 | }); |
| 519 | internals.codex.rollbackThread = async () => ({ | |
| 520 | threadId: "thr_1", | |
| 521 | turns: [{ id: "turn_1", items: [] }], | |
| 522 | }); | |
| 624 | internals.codex.rollbackThread = async () => { | |
| 625 | callOrder.push("rollback"); | |
| 626 | return { | |
| 627 | threadId: "thr_1", | |
| 628 | turns: [{ id: "turn_1", items: [] }], | |
| 629 | }; | |
| 630 | }; | |
| 523 | 631 | const hasCheckpoint = vi.fn(async () => true); |
| 524 | const restoreCheckpoint = vi.fn(async () => true); | |
| 525 | const pruneAfterTurn = vi.fn(async () => undefined); | |
| 632 | const restoreCheckpoint = vi.fn(async () => { | |
| 633 | callOrder.push("restore"); | |
| 634 | return true; | |
| 635 | }); | |
| 636 | const pruneAfterTurn = vi.fn(async () => { | |
| 637 | callOrder.push("prune"); | |
| 638 | return undefined; | |
| 639 | }); | |
| 526 | 640 | internals.filesystemCheckpointStore.hasCheckpoint = hasCheckpoint; |
| 527 | 641 | internals.filesystemCheckpointStore.restoreCheckpoint = restoreCheckpoint; |
| 528 | 642 | internals.filesystemCheckpointStore.pruneAfterTurn = pruneAfterTurn; |
| ... | ... | @@ -549,6 +663,7 @@ describe("ProviderManager", () => { |
| 549 | 663 | threadId: "thr_1", |
| 550 | 664 | maxTurnCount: 1, |
| 551 | 665 | }); |
| 666 | expect(callOrder).toEqual(["restore", "rollback", "prune"]); | |
| 552 | 667 | |
| 553 | 668 | manager.dispose(); |
| 554 | 669 | }); |
apps/server/src/providerManager.ts+26-22| ... | ... | @@ -308,6 +308,9 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { |
| 308 | 308 | } |
| 309 | 309 | |
| 310 | 310 | const checkpointCwd = await this.getOrInitializeFilesystemCheckpointCwd(input.sessionId); |
| 311 | if (!checkpointCwd) { | |
| 312 | throw new Error("Filesystem checkpoints are unavailable for this session."); | |
| 313 | } | |
| 311 | 314 | return this.withFilesystemLock(input.sessionId, async () => { |
| 312 | 315 | const beforeSnapshot = await this.codex.readThread(input.sessionId); |
| 313 | 316 | const currentTurnCount = beforeSnapshot.turns.length; |
| ... | ... | @@ -330,29 +333,28 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { |
| 330 | 333 | } |
| 331 | 334 | } |
| 332 | 335 | |
| 336 | const restored = await this.filesystemCheckpointStore.restoreCheckpoint({ | |
| 337 | cwd: checkpointCwd, | |
| 338 | threadId: beforeSnapshot.threadId, | |
| 339 | turnCount: input.turnCount, | |
| 340 | }); | |
| 341 | if (!restored) { | |
| 342 | throw new Error( | |
| 343 | `Filesystem checkpoint is unavailable for turn ${input.turnCount} in thread ${beforeSnapshot.threadId}.`, | |
| 344 | ); | |
| 345 | } | |
| 346 | ||
| 333 | 347 | const requestedRollbackTurns = currentTurnCount - input.turnCount; |
| 334 | 348 | const afterSnapshot = |
| 335 | 349 | requestedRollbackTurns > 0 |
| 336 | 350 | ? await this.codex.rollbackThread(input.sessionId, requestedRollbackTurns) |
| 337 | 351 | : beforeSnapshot; |
| 338 | 352 | |
| 339 | if (checkpointCwd) { | |
| 340 | const restored = await this.filesystemCheckpointStore.restoreCheckpoint({ | |
| 341 | cwd: checkpointCwd, | |
| 342 | threadId: beforeSnapshot.threadId, | |
| 343 | turnCount: input.turnCount, | |
| 344 | }); | |
| 345 | if (!restored) { | |
| 346 | throw new Error( | |
| 347 | `Filesystem checkpoint is unavailable for turn ${input.turnCount} in thread ${beforeSnapshot.threadId}.`, | |
| 348 | ); | |
| 349 | } | |
| 350 | await this.filesystemCheckpointStore.pruneAfterTurn({ | |
| 351 | cwd: checkpointCwd, | |
| 352 | threadId: afterSnapshot.threadId, | |
| 353 | maxTurnCount: afterSnapshot.turns.length, | |
| 354 | }); | |
| 355 | } | |
| 353 | await this.filesystemCheckpointStore.pruneAfterTurn({ | |
| 354 | cwd: checkpointCwd, | |
| 355 | threadId: afterSnapshot.threadId, | |
| 356 | maxTurnCount: afterSnapshot.turns.length, | |
| 357 | }); | |
| 356 | 358 | |
| 357 | 359 | const checkpoints = buildCheckpoints(afterSnapshot.turns); |
| 358 | 360 | const currentCheckpoint = |
| ... | ... | @@ -415,7 +417,9 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { |
| 415 | 417 | threadId: snapshot.threadId, |
| 416 | 418 | turnCount: snapshot.turns.length, |
| 417 | 419 | }); |
| 418 | this.sessionCheckpointCwds.set(session.sessionId, cwd); | |
| 420 | if (this.codex.hasSession(session.sessionId)) { | |
| 421 | this.sessionCheckpointCwds.set(session.sessionId, cwd); | |
| 422 | } | |
| 419 | 423 | }); |
| 420 | 424 | } |
| 421 | 425 | |
| ... | ... | @@ -428,9 +432,7 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { |
| 428 | 432 | const session = this.codex |
| 429 | 433 | .listSessions() |
| 430 | 434 | .find((candidate) => candidate.sessionId === sessionId); |
| 431 | const candidateCwds = Array.from( | |
| 432 | new Set([session?.cwd, process.cwd()].filter((value): value is string => Boolean(value))), | |
| 433 | ); | |
| 435 | const candidateCwds = session?.cwd ? [session.cwd] : [process.cwd()]; | |
| 434 | 436 | if (candidateCwds.length === 0) { |
| 435 | 437 | return null; |
| 436 | 438 | } |
| ... | ... | @@ -459,7 +461,9 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { |
| 459 | 461 | threadId: snapshot.threadId, |
| 460 | 462 | turnCount: snapshot.turns.length, |
| 461 | 463 | }); |
| 462 | this.sessionCheckpointCwds.set(sessionId, supportedCwd); | |
| 464 | if (this.codex.hasSession(sessionId)) { | |
| 465 | this.sessionCheckpointCwds.set(sessionId, supportedCwd); | |
| 466 | } | |
| 463 | 467 | return; |
| 464 | 468 | } |
| 465 | 469 |
apps/web/src/components/ChatView.tsx+15-19| ... | ... | @@ -53,9 +53,7 @@ import { |
| 53 | 53 | countDiffStat, |
| 54 | 54 | derivePendingApprovals, |
| 55 | 55 | derivePhase, |
| 56 | deriveTurnDiffSummaries, | |
| 57 | 56 | deriveTimelineEntries, |
| 58 | inferCheckpointTurnCountByTurnId, | |
| 59 | 57 | type TurnDiffSummary, |
| 60 | 58 | type PendingApproval, |
| 61 | 59 | deriveWorkLogEntries, |
| ... | ... | @@ -72,6 +70,7 @@ import { |
| 72 | 70 | } from "../types"; |
| 73 | 71 | import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons"; |
| 74 | 72 | import { useTheme } from "../hooks/useTheme"; |
| 73 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; | |
| 75 | 74 | import BranchToolbar from "./BranchToolbar"; |
| 76 | 75 | import GitActionsControl from "./GitActionsControl"; |
| 77 | 76 | import { |
| ... | ... | @@ -414,13 +413,8 @@ export default function ChatView() { |
| 414 | 413 | () => deriveTimelineEntries(activeThread?.messages ?? [], workLogEntries), |
| 415 | 414 | [activeThread?.messages, workLogEntries], |
| 416 | 415 | ); |
| 417 | const turnDiffSummaries = useMemo( | |
| 418 | () => | |
| 419 | activeThread?.turnDiffSummaries.length | |
| 420 | ? activeThread.turnDiffSummaries | |
| 421 | : deriveTurnDiffSummaries(activeThread?.events ?? []), | |
| 422 | [activeThread?.events, activeThread?.turnDiffSummaries], | |
| 423 | ); | |
| 416 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = | |
| 417 | useTurnDiffSummaries(activeThread); | |
| 424 | 418 | const turnDiffSummaryByAssistantMessageId = useMemo(() => { |
| 425 | 419 | const byMessageId = new Map<string, TurnDiffSummary>(); |
| 426 | 420 | for (const summary of turnDiffSummaries) { |
| ... | ... | @@ -429,11 +423,6 @@ export default function ChatView() { |
| 429 | 423 | } |
| 430 | 424 | return byMessageId; |
| 431 | 425 | }, [turnDiffSummaries]); |
| 432 | const inferredCheckpointTurnCountByTurnId = useMemo( | |
| 433 | () => inferCheckpointTurnCountByTurnId(turnDiffSummaries), | |
| 434 | [turnDiffSummaries], | |
| 435 | ); | |
| 436 | ||
| 437 | 426 | const revertTurnCountByUserMessageId = useMemo(() => { |
| 438 | 427 | const byUserMessageId = new Map<string, number>(); |
| 439 | 428 | for (let index = 0; index < timelineEntries.length; index += 1) { |
| ... | ... | @@ -1487,8 +1476,10 @@ export default function ChatView() { |
| 1487 | 1476 | const selectedDiffTurnId = |
| 1488 | 1477 | state.diffOpen && state.diffThreadId === activeThreadId ? state.diffTurnId : null; |
| 1489 | 1478 | const selectedDiffTurn = |
| 1490 | turnDiffSummaries.find((summary) => summary.turnId === selectedDiffTurnId) ?? | |
| 1491 | turnDiffSummaries[0]; | |
| 1479 | selectedDiffTurnId === null | |
| 1480 | ? undefined | |
| 1481 | : (turnDiffSummaries.find((summary) => summary.turnId === selectedDiffTurnId) ?? | |
| 1482 | turnDiffSummaries[0]); | |
| 1492 | 1483 | const selectedTurnMissingPatchBody = Boolean( |
| 1493 | 1484 | selectedDiffTurn && |
| 1494 | 1485 | !selectedDiffTurn.unifiedDiff && |
| ... | ... | @@ -1505,9 +1496,14 @@ export default function ChatView() { |
| 1505 | 1496 | } |
| 1506 | 1497 | checkpointHydrationSessionRequestRef.current.add(requestKey); |
| 1507 | 1498 | setThreadError(activeThreadId, null); |
| 1508 | void ensureSession().finally(() => { | |
| 1509 | checkpointHydrationSessionRequestRef.current.delete(requestKey); | |
| 1510 | }); | |
| 1499 | void ensureSession().then( | |
| 1500 | () => { | |
| 1501 | checkpointHydrationSessionRequestRef.current.delete(requestKey); | |
| 1502 | }, | |
| 1503 | () => { | |
| 1504 | // Keep the key on failure so this effect does not re-enter in a tight loop. | |
| 1505 | }, | |
| 1506 | ); | |
| 1511 | 1507 | }, [ |
| 1512 | 1508 | activeThreadId, |
| 1513 | 1509 | activeThreadRuntimeId, |
apps/web/src/components/DiffPanel.tsx+10-17| ... | ... | @@ -8,11 +8,8 @@ import { cn } from "~/lib/utils"; |
| 8 | 8 | import { isElectron } from "../env"; |
| 9 | 9 | import { useNativeApi } from "../hooks/useNativeApi"; |
| 10 | 10 | import { useTheme } from "../hooks/useTheme"; |
| 11 | import { | |
| 12 | deriveTurnDiffSummaries, | |
| 13 | formatTimestamp, | |
| 14 | inferCheckpointTurnCountByTurnId, | |
| 15 | } from "../session-logic"; | |
| 11 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; | |
| 12 | import { formatTimestamp } from "../session-logic"; | |
| 16 | 13 | import { useStore } from "../store"; |
| 17 | 14 | import { ToggleGroup, Toggle } from "./ui/toggle-group"; |
| 18 | 15 | |
| ... | ... | @@ -78,17 +75,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 78 | 75 | const activeThreadRuntimeId = |
| 79 | 76 | activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null; |
| 80 | 77 | const activeSessionId = activeThread?.session?.sessionId ?? null; |
| 81 | const turnDiffSummaries = useMemo( | |
| 82 | () => | |
| 83 | activeThread?.turnDiffSummaries.length | |
| 84 | ? activeThread.turnDiffSummaries | |
| 85 | : deriveTurnDiffSummaries(activeThread?.events ?? []), | |
| 86 | [activeThread?.events, activeThread?.turnDiffSummaries], | |
| 87 | ); | |
| 88 | const inferredCheckpointTurnCountByTurnId = useMemo( | |
| 89 | () => inferCheckpointTurnCountByTurnId(turnDiffSummaries), | |
| 90 | [turnDiffSummaries], | |
| 91 | ); | |
| 78 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = | |
| 79 | useTurnDiffSummaries(activeThread); | |
| 92 | 80 | |
| 93 | 81 | const canApplyStoredTarget = Boolean(activeThread && state.diffThreadId === activeThread.id); |
| 94 | 82 | const selectedTurnId = canApplyStoredTarget ? state.diffTurnId : null; |
| ... | ... | @@ -358,7 +346,12 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 358 | 346 | variant="outline" |
| 359 | 347 | size="xs" |
| 360 | 348 | value={[diffRenderMode]} |
| 361 | onValueChange={(value) => setDiffRenderMode(value[0] as DiffRenderMode)} | |
| 349 | onValueChange={(value) => { | |
| 350 | const next = value[0]; | |
| 351 | if (next === "stacked" || next === "split") { | |
| 352 | setDiffRenderMode(next); | |
| 353 | } | |
| 354 | }} | |
| 362 | 355 | > |
| 363 | 356 | <Toggle aria-label="Stacked diff view" value="stacked"> |
| 364 | 357 | <Rows3Icon className="size-3" /> |
apps/web/src/components/ui/toggle-group.tsx+2-2| ... | ... | @@ -58,8 +58,8 @@ function Toggle({ |
| 58 | 58 | }: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) { |
| 59 | 59 | const context = React.useContext(ToggleGroupContext); |
| 60 | 60 | |
| 61 | const resolvedVariant = context.variant || variant; | |
| 62 | const resolvedSize = context.size || size; | |
| 61 | const resolvedVariant = variant ?? context.variant; | |
| 62 | const resolvedSize = size ?? context.size; | |
| 63 | 63 | |
| 64 | 64 | return ( |
| 65 | 65 | <ToggleComponent |
apps/web/src/hooks/useTurnDiffSummaries.ts created+35| ... | ... | @@ -0,0 +1,35 @@ |
| 1 | import type { ProviderEvent } from "@t3tools/contracts"; | |
| 2 | import { useMemo } from "react"; | |
| 3 | import { deriveTurnDiffSummaries, inferCheckpointTurnCountByTurnId } from "../session-logic"; | |
| 4 | import type { Thread, TurnDiffSummary } from "../types"; | |
| 5 | ||
| 6 | const derivedTurnDiffSummaryCache = new WeakMap<readonly ProviderEvent[], TurnDiffSummary[]>(); | |
| 7 | ||
| 8 | function deriveTurnDiffSummariesCached(events: ProviderEvent[]): TurnDiffSummary[] { | |
| 9 | const cached = derivedTurnDiffSummaryCache.get(events); | |
| 10 | if (cached) { | |
| 11 | return cached; | |
| 12 | } | |
| 13 | const derived = deriveTurnDiffSummaries(events); | |
| 14 | derivedTurnDiffSummaryCache.set(events, derived); | |
| 15 | return derived; | |
| 16 | } | |
| 17 | ||
| 18 | export function useTurnDiffSummaries(activeThread: Thread | undefined) { | |
| 19 | const turnDiffSummaries = useMemo(() => { | |
| 20 | if (!activeThread) { | |
| 21 | return []; | |
| 22 | } | |
| 23 | if (activeThread.turnDiffSummaries.length > 0) { | |
| 24 | return activeThread.turnDiffSummaries; | |
| 25 | } | |
| 26 | return deriveTurnDiffSummariesCached(activeThread.events); | |
| 27 | }, [activeThread]); | |
| 28 | ||
| 29 | const inferredCheckpointTurnCountByTurnId = useMemo( | |
| 30 | () => inferCheckpointTurnCountByTurnId(turnDiffSummaries), | |
| 31 | [turnDiffSummaries], | |
| 32 | ); | |
| 33 | ||
| 34 | return { turnDiffSummaries, inferredCheckpointTurnCountByTurnId }; | |
| 35 | } |
apps/web/src/lib/providerReactQuery.ts+7-1| ... | ... | @@ -28,7 +28,13 @@ export function checkpointDiffQueryOptions( |
| 28 | 28 | input: CheckpointDiffQueryInput, |
| 29 | 29 | ) { |
| 30 | 30 | const hasValidRange = |
| 31 | typeof input.fromTurnCount === "number" && typeof input.toTurnCount === "number"; | |
| 31 | typeof input.fromTurnCount === "number" && | |
| 32 | typeof input.toTurnCount === "number" && | |
| 33 | Number.isInteger(input.fromTurnCount) && | |
| 34 | Number.isInteger(input.toTurnCount) && | |
| 35 | input.fromTurnCount >= 0 && | |
| 36 | input.toTurnCount >= 0 && | |
| 37 | input.fromTurnCount <= input.toTurnCount; | |
| 32 | 38 | |
| 33 | 39 | return queryOptions({ |
| 34 | 40 | queryKey: providerQueryKeys.checkpointDiff(input), |
apps/web/src/persistenceSchema.test.ts+40| ... | ... | @@ -325,6 +325,46 @@ describe("hydratePersistedState", () => { |
| 325 | 325 | }, |
| 326 | 326 | ]); |
| 327 | 327 | }); |
| 328 | ||
| 329 | it("drops malformed persisted turn diff summaries instead of rejecting the whole snapshot", () => { | |
| 330 | const payload = JSON.stringify({ | |
| 331 | version: 7, | |
| 332 | runtimeMode: "full-access", | |
| 333 | projects: [ | |
| 334 | { | |
| 335 | id: "p-1", | |
| 336 | name: "Project", | |
| 337 | cwd: "/tmp/project", | |
| 338 | model: "gpt-5.3-codex", | |
| 339 | expanded: true, | |
| 340 | }, | |
| 341 | ], | |
| 342 | threads: [ | |
| 343 | { | |
| 344 | id: "t-1", | |
| 345 | codexThreadId: "thr_1", | |
| 346 | projectId: "p-1", | |
| 347 | title: "Thread", | |
| 348 | model: "gpt-5.3-codex", | |
| 349 | messages: [], | |
| 350 | createdAt: "2026-02-08T10:00:00.000Z", | |
| 351 | turnDiffSummaries: [ | |
| 352 | { | |
| 353 | turnId: "turn-1", | |
| 354 | completedAt: "2026-02-08T10:05:00.000Z", | |
| 355 | files: [{ path: 123 }], | |
| 356 | }, | |
| 357 | ], | |
| 358 | }, | |
| 359 | ], | |
| 360 | activeThreadId: "t-1", | |
| 361 | }); | |
| 362 | ||
| 363 | const hydrated = hydratePersistedState(payload, false); | |
| 364 | expect(hydrated).not.toBeNull(); | |
| 365 | expect(hydrated?.threads[0]?.id).toBe("t-1"); | |
| 366 | expect(hydrated?.threads[0]?.turnDiffSummaries).toEqual([]); | |
| 367 | }); | |
| 328 | 368 | }); |
| 329 | 369 | |
| 330 | 370 | describe("toPersistedState", () => { |
apps/web/src/persistenceSchema.ts+1-1| ... | ... | @@ -84,7 +84,7 @@ const persistedThreadSchema = z.object({ |
| 84 | 84 | lastVisitedAt: z.string().min(1).optional(), |
| 85 | 85 | branch: z.string().min(1).nullable().optional(), |
| 86 | 86 | worktreePath: z.string().min(1).nullable().optional(), |
| 87 | turnDiffSummaries: z.array(persistedTurnDiffSummarySchema).default([]), | |
| 87 | turnDiffSummaries: z.array(persistedTurnDiffSummarySchema).catch([]).default([]), | |
| 88 | 88 | }); |
| 89 | 89 | |
| 90 | 90 | const persistedStateBodySchema = z.object({ |
apps/web/src/session-logic.test.ts+70-1| ... | ... | @@ -493,7 +493,11 @@ describe("deriveTurnDiffSummaries", () => { |
| 493 | 493 | expect(summaries).toHaveLength(1); |
| 494 | 494 | expect(summaries[0]?.turnId).toBe("turn-1"); |
| 495 | 495 | expect(summaries[0]?.assistantMessageId).toBe("msg-1"); |
| 496 | expect(summaries[0]?.files).toEqual([]); | |
| 496 | expect(summaries[0]?.unifiedDiff).toContain("diff --git a/src/a.ts b/src/a.ts"); | |
| 497 | expect(summaries[0]?.files.map((file) => file.path)).toEqual(["src/a.ts", "src/b.ts"]); | |
| 498 | expect(summaries[0]?.files[0]?.kind).toBe("modified"); | |
| 499 | expect(summaries[0]?.files[0]?.additions).toBe(1); | |
| 500 | expect(summaries[0]?.files[0]?.deletions).toBe(1); | |
| 497 | 501 | }); |
| 498 | 502 | |
| 499 | 503 | it("includes completed turns even when no fileChange tool events were emitted", () => { |
| ... | ... | @@ -522,6 +526,55 @@ describe("deriveTurnDiffSummaries", () => { |
| 522 | 526 | expect(summaries[0]?.turnId).toBe("turn-1"); |
| 523 | 527 | expect(summaries[0]?.files).toEqual([]); |
| 524 | 528 | }); |
| 529 | ||
| 530 | it("keeps newest diff data when older events provide stale values", () => { | |
| 531 | const summaries = deriveTurnDiffSummaries([ | |
| 532 | makeEvent({ | |
| 533 | id: "evt-turn-diff-old", | |
| 534 | method: "turn/diff/updated", | |
| 535 | turnId: "turn-1", | |
| 536 | createdAt: "2026-02-08T10:00:01.000Z", | |
| 537 | payload: { | |
| 538 | diff: [ | |
| 539 | "diff --git a/src/example.ts b/src/example.ts", | |
| 540 | "@@ -1 +1 @@", | |
| 541 | "-old", | |
| 542 | "+older", | |
| 543 | ].join("\n"), | |
| 544 | }, | |
| 545 | }), | |
| 546 | makeEvent({ | |
| 547 | id: "evt-turn-diff-new", | |
| 548 | method: "turn/diff/updated", | |
| 549 | turnId: "turn-1", | |
| 550 | createdAt: "2026-02-08T10:00:02.000Z", | |
| 551 | payload: { | |
| 552 | diff: [ | |
| 553 | "diff --git a/src/example.ts b/src/example.ts", | |
| 554 | "@@ -1 +1 @@", | |
| 555 | "-old", | |
| 556 | "+newest", | |
| 557 | ].join("\n"), | |
| 558 | }, | |
| 559 | }), | |
| 560 | makeEvent({ | |
| 561 | id: "evt-turn-completed", | |
| 562 | method: "turn/completed", | |
| 563 | turnId: "turn-1", | |
| 564 | createdAt: "2026-02-08T10:00:03.000Z", | |
| 565 | payload: { | |
| 566 | turn: { | |
| 567 | id: "turn-1", | |
| 568 | status: "completed", | |
| 569 | }, | |
| 570 | }, | |
| 571 | }), | |
| 572 | ]); | |
| 573 | ||
| 574 | expect(summaries).toHaveLength(1); | |
| 575 | expect(summaries[0]?.unifiedDiff).toContain("+newest"); | |
| 576 | expect(summaries[0]?.unifiedDiff).not.toContain("+older"); | |
| 577 | }); | |
| 525 | 578 | }); |
| 526 | 579 | |
| 527 | 580 | describe("deriveTurnDiffFilesFromUnifiedDiff", () => { |
| ... | ... | @@ -545,6 +598,22 @@ describe("deriveTurnDiffFilesFromUnifiedDiff", () => { |
| 545 | 598 | expect(files[0]?.additions).toBe(1); |
| 546 | 599 | expect(files[0]?.deletions).toBe(1); |
| 547 | 600 | }); |
| 601 | ||
| 602 | it("parses git headers when file paths include ` b/`", () => { | |
| 603 | const files = deriveTurnDiffFilesFromUnifiedDiff( | |
| 604 | [ | |
| 605 | "diff --git a/subdir b/example.ts b/subdir b/example.ts", | |
| 606 | "--- a/subdir b/example.ts", | |
| 607 | "+++ b/subdir b/example.ts", | |
| 608 | "@@ -1 +1 @@", | |
| 609 | "-old", | |
| 610 | "+new", | |
| 611 | ].join("\n"), | |
| 612 | ); | |
| 613 | ||
| 614 | expect(files).toHaveLength(1); | |
| 615 | expect(files[0]?.path).toBe("subdir b/example.ts"); | |
| 616 | }); | |
| 548 | 617 | }); |
| 549 | 618 | |
| 550 | 619 | describe("derivePendingApprovals", () => { |
apps/web/src/session-logic.ts+111-10| ... | ... | @@ -33,10 +33,18 @@ export function asObject(value: unknown): Record<string, unknown> | undefined { |
| 33 | 33 | return value as Record<string, unknown>; |
| 34 | 34 | } |
| 35 | 35 | |
| 36 | function asArray(value: unknown): unknown[] | undefined { | |
| 37 | return Array.isArray(value) ? value : undefined; | |
| 38 | } | |
| 39 | ||
| 36 | 40 | export function asString(value: unknown): string | undefined { |
| 37 | 41 | return typeof value === "string" ? value : undefined; |
| 38 | 42 | } |
| 39 | 43 | |
| 44 | function asNumber(value: unknown): number | undefined { | |
| 45 | return typeof value === "number" && Number.isFinite(value) ? value : undefined; | |
| 46 | } | |
| 47 | ||
| 40 | 48 | export function formatTimestamp(isoDate: string): string { |
| 41 | 49 | return new Intl.DateTimeFormat(undefined, { |
| 42 | 50 | hour: "numeric", |
| ... | ... | @@ -445,14 +453,77 @@ interface MutableTurnDiffSummary { |
| 445 | 453 | completedAt: string | undefined; |
| 446 | 454 | status: string | undefined; |
| 447 | 455 | assistantMessageId: string | undefined; |
| 456 | unifiedDiff: string | undefined; | |
| 457 | filesByPath: Map<string, TurnDiffFileChange>; | |
| 458 | } | |
| 459 | ||
| 460 | function parseFileChangeEntriesFromEvent(event: ProviderEvent): TurnDiffFileChange[] { | |
| 461 | if (event.method !== "item/completed") { | |
| 462 | return []; | |
| 463 | } | |
| 464 | ||
| 465 | const item = asObject(asObject(event.payload)?.item); | |
| 466 | const itemType = normalizeItemType(asString(item?.type)); | |
| 467 | if (itemType !== "file change") { | |
| 468 | return []; | |
| 469 | } | |
| 470 | ||
| 471 | const changes = asArray(item?.changes) ?? []; | |
| 472 | const parsed: TurnDiffFileChange[] = []; | |
| 473 | for (const rawChange of changes) { | |
| 474 | const change = asObject(rawChange); | |
| 475 | if (!change) continue; | |
| 476 | ||
| 477 | const diff = normalizeDetail(asString(change.diff)); | |
| 478 | const path = normalizeDetail(asString(change.path)) ?? (diff ? parsePathFromDiff(diff) : undefined); | |
| 479 | if (!path) continue; | |
| 480 | ||
| 481 | const additions = asNumber(change.additions); | |
| 482 | const deletions = asNumber(change.deletions); | |
| 483 | const stat = additions !== undefined && deletions !== undefined ? null : diff ? countDiffStat(diff) : null; | |
| 484 | const kind = normalizeDetail(asString(change.kind)); | |
| 485 | parsed.push({ | |
| 486 | path, | |
| 487 | ...(kind ? { kind } : {}), | |
| 488 | ...(diff ? { diff } : {}), | |
| 489 | ...(additions !== undefined ? { additions } : stat ? { additions: stat.additions } : {}), | |
| 490 | ...(deletions !== undefined ? { deletions } : stat ? { deletions: stat.deletions } : {}), | |
| 491 | }); | |
| 492 | } | |
| 493 | ||
| 494 | return parsed; | |
| 495 | } | |
| 496 | ||
| 497 | function mergeTurnDiffFileChange( | |
| 498 | byPath: Map<string, TurnDiffFileChange>, | |
| 499 | incoming: TurnDiffFileChange, | |
| 500 | ): void { | |
| 501 | const existing = byPath.get(incoming.path); | |
| 502 | if (!existing) { | |
| 503 | byPath.set(incoming.path, incoming); | |
| 504 | return; | |
| 505 | } | |
| 506 | ||
| 507 | if (existing.kind === undefined && incoming.kind !== undefined) { | |
| 508 | existing.kind = incoming.kind; | |
| 509 | } | |
| 510 | if (existing.diff === undefined && incoming.diff !== undefined) { | |
| 511 | existing.diff = incoming.diff; | |
| 512 | } | |
| 513 | if (existing.additions === undefined && incoming.additions !== undefined) { | |
| 514 | existing.additions = incoming.additions; | |
| 515 | } | |
| 516 | if (existing.deletions === undefined && incoming.deletions !== undefined) { | |
| 517 | existing.deletions = incoming.deletions; | |
| 518 | } | |
| 448 | 519 | } |
| 449 | 520 | |
| 450 | 521 | function parsePathFromDiff(diff: string): string | undefined { |
| 451 | 522 | const normalized = diff.replace(/\r\n/g, "\n"); |
| 452 | const gitHeader = normalized.match(/^diff --git a\/(.+) b\/(.+)$/m); | |
| 453 | if (gitHeader && gitHeader[2]) return gitHeader[2]; | |
| 454 | 523 | const bPath = normalized.match(/^\+\+\+ b\/(.+)$/m); |
| 455 | 524 | if (bPath && bPath[1]) return bPath[1]; |
| 525 | const gitHeader = normalized.match(/^diff --git a\/(.+) b\/(.+)$/m); | |
| 526 | if (gitHeader && gitHeader[2]) return gitHeader[2]; | |
| 456 | 527 | const direct = normalized.match(/^\+\+\+ (.+)$/m); |
| 457 | 528 | if (!direct || !direct[1] || direct[1] === "/dev/null") { |
| 458 | 529 | return undefined; |
| ... | ... | @@ -463,7 +534,7 @@ function parsePathFromDiff(diff: string): string | undefined { |
| 463 | 534 | export function splitUnifiedDiffByFile(diff: string): Map<string, string> { |
| 464 | 535 | const normalized = diff.replace(/\r\n/g, "\n"); |
| 465 | 536 | const byPath = new Map<string, string>(); |
| 466 | const headerMatches = [...normalized.matchAll(/^diff --git a\/(.+) b\/(.+)$/gm)]; | |
| 537 | const headerMatches = [...normalized.matchAll(/^diff --git .+$/gm)]; | |
| 467 | 538 | |
| 468 | 539 | if (headerMatches.length === 0) { |
| 469 | 540 | const path = parsePathFromDiff(normalized); |
| ... | ... | @@ -479,7 +550,7 @@ export function splitUnifiedDiffByFile(diff: string): Map<string, string> { |
| 479 | 550 | const start = match.index ?? 0; |
| 480 | 551 | const nextStart = headerMatches[index + 1]?.index ?? normalized.length; |
| 481 | 552 | const segment = normalized.slice(start, nextStart).trim(); |
| 482 | const path = match[2] ?? match[1] ?? parsePathFromDiff(segment); | |
| 553 | const path = parsePathFromDiff(segment); | |
| 483 | 554 | if (!path || segment.length === 0) continue; |
| 484 | 555 | byPath.set(path, segment); |
| 485 | 556 | } |
| ... | ... | @@ -555,7 +626,15 @@ export function countDiffStat(patch: string | undefined): { additions: number; d |
| 555 | 626 | } |
| 556 | 627 | |
| 557 | 628 | export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummary[] { |
| 558 | const ordered = [...events].toReversed(); | |
| 629 | const firstEventAt = Date.parse(events[0]?.createdAt ?? ""); | |
| 630 | const lastEventAt = Date.parse(events.at(-1)?.createdAt ?? ""); | |
| 631 | const ordered = | |
| 632 | events.length < 2 || | |
| 633 | Number.isNaN(firstEventAt) || | |
| 634 | Number.isNaN(lastEventAt) || | |
| 635 | firstEventAt >= lastEventAt | |
| 636 | ? events | |
| 637 | : [...events].toReversed(); | |
| 559 | 638 | const byTurnId = new Map<string, MutableTurnDiffSummary>(); |
| 560 | 639 | |
| 561 | 640 | const ensureSummary = (turnId: string): MutableTurnDiffSummary => { |
| ... | ... | @@ -566,6 +645,8 @@ export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummar |
| 566 | 645 | completedAt: undefined, |
| 567 | 646 | status: undefined, |
| 568 | 647 | assistantMessageId: undefined, |
| 648 | unifiedDiff: undefined, | |
| 649 | filesByPath: new Map<string, TurnDiffFileChange>(), | |
| 569 | 650 | }; |
| 570 | 651 | byTurnId.set(turnId, next); |
| 571 | 652 | return next; |
| ... | ... | @@ -577,21 +658,38 @@ export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummar |
| 577 | 658 | const summary = ensureSummary(turnId); |
| 578 | 659 | |
| 579 | 660 | if (event.method === "turn/completed") { |
| 580 | summary.completedAt = event.createdAt; | |
| 661 | if (summary.completedAt === undefined) { | |
| 662 | summary.completedAt = event.createdAt; | |
| 663 | } | |
| 581 | 664 | const turn = asObject(asObject(event.payload)?.turn); |
| 582 | summary.status = normalizeDetail(asString(turn?.status)); | |
| 583 | continue; | |
| 665 | if (summary.status === undefined) { | |
| 666 | summary.status = normalizeDetail(asString(turn?.status)); | |
| 667 | } | |
| 584 | 668 | } |
| 585 | 669 | |
| 586 | 670 | if (event.method === "item/completed") { |
| 587 | 671 | const item = asObject(asObject(event.payload)?.item); |
| 588 | 672 | if (asString(item?.type) === "agentMessage") { |
| 589 | 673 | const itemId = normalizeDetail(asString(item?.id)); |
| 590 | if (itemId) { | |
| 674 | if (itemId && summary.assistantMessageId === undefined) { | |
| 591 | 675 | summary.assistantMessageId = itemId; |
| 592 | 676 | } |
| 593 | 677 | } |
| 594 | 678 | } |
| 679 | ||
| 680 | if (event.method === "turn/diff/updated" && summary.unifiedDiff === undefined) { | |
| 681 | const diff = normalizeDetail(asString(asObject(event.payload)?.diff)); | |
| 682 | if (diff) { | |
| 683 | summary.unifiedDiff = diff; | |
| 684 | for (const file of deriveTurnDiffFilesFromUnifiedDiff(diff)) { | |
| 685 | mergeTurnDiffFileChange(summary.filesByPath, file); | |
| 686 | } | |
| 687 | } | |
| 688 | } | |
| 689 | ||
| 690 | for (const file of parseFileChangeEntriesFromEvent(event)) { | |
| 691 | mergeTurnDiffFileChange(summary.filesByPath, file); | |
| 692 | } | |
| 595 | 693 | } |
| 596 | 694 | |
| 597 | 695 | const summaries: TurnDiffSummary[] = []; |
| ... | ... | @@ -602,7 +700,10 @@ export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummar |
| 602 | 700 | turnId: summary.turnId, |
| 603 | 701 | completedAt: summary.completedAt, |
| 604 | 702 | ...(summary.status ? { status: summary.status } : {}), |
| 605 | files: [], | |
| 703 | files: Array.from(summary.filesByPath.values()).toSorted((a, b) => | |
| 704 | a.path.localeCompare(b.path), | |
| 705 | ), | |
| 706 | ...(summary.unifiedDiff ? { unifiedDiff: summary.unifiedDiff } : {}), | |
| 606 | 707 | ...(summary.assistantMessageId ? { assistantMessageId: summary.assistantMessageId } : {}), |
| 607 | 708 | }); |
| 608 | 709 | } |
apps/web/src/store.test.ts+17| ... | ... | @@ -569,6 +569,23 @@ describe("store reducer thread continuity", () => { |
| 569 | 569 | ]); |
| 570 | 570 | }); |
| 571 | 571 | |
| 572 | it("infers checkpoint turn counts when deriving turn summaries from an empty baseline", () => { | |
| 573 | const state = makeState(makeThread({ turnDiffSummaries: [] })); | |
| 574 | const next = reducer(state, { | |
| 575 | type: "APPLY_EVENT", | |
| 576 | event: makeEvent({ | |
| 577 | method: "turn/completed", | |
| 578 | turnId: "turn-1", | |
| 579 | createdAt: "2026-02-09T00:00:03.000Z", | |
| 580 | payload: { turn: { id: "turn-1", status: "completed" } }, | |
| 581 | }), | |
| 582 | activeAssistantItemRef: { current: null }, | |
| 583 | }); | |
| 584 | ||
| 585 | expect(next.threads[0]?.turnDiffSummaries[0]?.turnId).toBe("turn-1"); | |
| 586 | expect(next.threads[0]?.turnDiffSummaries[0]?.checkpointTurnCount).toBe(1); | |
| 587 | }); | |
| 588 | ||
| 572 | 589 | it("reconciles project ids by cwd when syncing backend projects", () => { |
| 573 | 590 | const state: AppState = { |
| 574 | 591 | projects: [ |
apps/web/src/store.ts+10-6| ... | ... | @@ -202,7 +202,6 @@ function mergeTurnDiffSummaries( |
| 202 | 202 | existing: Thread["turnDiffSummaries"], |
| 203 | 203 | next: Thread["turnDiffSummaries"], |
| 204 | 204 | ): Thread["turnDiffSummaries"] { |
| 205 | if (existing.length === 0) return next; | |
| 206 | 205 | if (next.length === 0) return existing; |
| 207 | 206 | |
| 208 | 207 | const existingByTurnId = new Map(existing.map((summary) => [summary.turnId, summary] as const)); |
| ... | ... | @@ -893,11 +892,16 @@ export function reducer(state: AppState, action: Action): AppState { |
| 893 | 892 | ...state, |
| 894 | 893 | threads: updateThread(state.threads, target.id, (t) => { |
| 895 | 894 | const nextEvents = [event, ...t.events]; |
| 896 | const derivedTurnDiffSummaries = deriveTurnDiffSummaries(nextEvents); | |
| 897 | const turnDiffSummaries = mergeTurnDiffSummaries( | |
| 898 | t.turnDiffSummaries, | |
| 899 | derivedTurnDiffSummaries, | |
| 900 | ); | |
| 895 | const itemType = asString(asObject(asObject(event.payload)?.item)?.type); | |
| 896 | const normalizedItemType = itemType?.replace(/[_-]/g, "").toLowerCase(); | |
| 897 | const shouldRederiveDiffs = | |
| 898 | event.method === "turn/completed" || | |
| 899 | event.method === "turn/diff/updated" || | |
| 900 | (event.method === "item/completed" && | |
| 901 | (normalizedItemType === "agentmessage" || normalizedItemType === "filechange")); | |
| 902 | const turnDiffSummaries = shouldRederiveDiffs | |
| 903 | ? mergeTurnDiffSummaries(t.turnDiffSummaries, deriveTurnDiffSummaries(nextEvents)) | |
| 904 | : t.turnDiffSummaries; | |
| 901 | 905 | const eventThreadId = getEventThreadId(event); |
| 902 | 906 | const shouldRebindIdentity = |
| 903 | 907 | event.method === "thread/started" && t.session?.status === "connecting"; |