| 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,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 | it("rolls back turns via thread/rollback and resets session running state", async () => { | 294 | it("rolls back turns via thread/rollback and resets session running state", async () => { |
| 266 | const { manager, context, sendRequest, updateSession } = createThreadControlHarness(); | 295 | const { manager, context, sendRequest, updateSession } = createThreadControlHarness(); |
| 267 | sendRequest.mockResolvedValue({ | 296 | sendRequest.mockResolvedValue({ |
apps/server/src/codexAppServerManager.ts+1-1| ... | @@ -796,7 +796,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | ... | @@ -796,7 +796,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 796 | throw new Error(`${method} response did not include a thread id.`); | 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 | const turns = turnsRaw.map((turnValue, index) => { | 800 | const turns = turnsRaw.map((turnValue, index) => { |
| 801 | const turn = this.readObject(turnValue); | 801 | const turn = this.readObject(turnValue); |
| 802 | const turnId = this.readString(turn, "id") ?? `${threadId}:turn:${index + 1}`; | 802 | const turnId = this.readString(turn, "id") ?? `${threadId}:turn:${index + 1}`; |
apps/server/src/filesystemCheckpointStore.ts+30-16| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { mkdtemp, rm, writeFile } from "node:fs/promises"; | 1 | import { mkdtemp, rm } from "node:fs/promises"; |
| 2 | import os from "node:os"; | 2 | import os from "node:os"; |
| 3 | import path from "node:path"; | 3 | import path from "node:path"; |
| 4 | import { randomUUID } from "node:crypto"; | 4 | import { randomUUID } from "node:crypto"; |
| ... | @@ -47,18 +47,17 @@ export class FilesystemCheckpointStore { | ... | @@ -47,18 +47,17 @@ export class FilesystemCheckpointStore { |
| 47 | const ref = checkpointRefForThreadTurn(threadId, turnCount); | 47 | const ref = checkpointRefForThreadTurn(threadId, turnCount); |
| 48 | 48 | ||
| 49 | const tempDir = await mkdtemp(path.join(os.tmpdir(), "t3-fs-checkpoint-")); | 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 | try { | 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 | const hasHead = await this.hasHeadCommit(cwd); | 62 | const hasHead = await this.hasHeadCommit(cwd); |
| 64 | if (hasHead) { | 63 | if (hasHead) { |
| ... | @@ -133,7 +132,7 @@ export class FilesystemCheckpointStore { | ... | @@ -133,7 +132,7 @@ export class FilesystemCheckpointStore { |
| 133 | 132 | ||
| 134 | await this.runGit(cwd, ["restore", "--source", commitOid, "--worktree", "--staged", "--", "."]); | 133 | await this.runGit(cwd, ["restore", "--source", commitOid, "--worktree", "--staged", "--", "."]); |
| 135 | await this.runGit(cwd, ["clean", "-fd", "--", "."]); | 134 | await this.runGit(cwd, ["clean", "-fd", "--", "."]); |
| 136 | await this.runGit(cwd, ["reset", "--quiet", "--", "."], { allowNonZeroExit: true }); | 135 | await this.runGit(cwd, ["reset", "--quiet", "--", "."]); |
| 137 | return true; | 136 | return true; |
| 138 | } | 137 | } |
| 139 | 138 | ||
| ... | @@ -228,11 +227,20 @@ export class FilesystemCheckpointStore { | ... | @@ -228,11 +227,20 @@ export class FilesystemCheckpointStore { |
| 228 | return turn !== null && turn > maxTurnCount; | 227 | return turn !== null && turn > maxTurnCount; |
| 229 | }); | 228 | }); |
| 230 | 229 | ||
| 231 | await Promise.all( | 230 | const batchSize = 32; |
| 232 | refsToDelete.map((refName) => | 231 | const batches = Array.from( |
| 233 | this.runGit(cwd, ["update-ref", "-d", refName], { allowNonZeroExit: true }), | 232 | { length: Math.ceil(refsToDelete.length / batchSize) }, |
| 234 | ), | 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 | private async hasHeadCommit(cwd: string): Promise<boolean> { | 246 | private async hasHeadCommit(cwd: string): Promise<boolean> { |
| ... | @@ -288,6 +296,12 @@ export class FilesystemCheckpointStore { | ... | @@ -288,6 +296,12 @@ export class FilesystemCheckpointStore { |
| 288 | throw new Error(`${quoteGitCommand(args)} timed out.`); | 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 | if (!options.allowNonZeroExit && result.code !== 0) { | 305 | if (!options.allowNonZeroExit && result.code !== 0) { |
| 292 | const stderr = result.stderr.trim(); | 306 | const stderr = result.stderr.trim(); |
| 293 | throw new Error( | 307 | throw new Error( |
apps/server/src/providerManager.test.ts+167-52| ... | @@ -151,55 +151,44 @@ describe("ProviderManager", () => { | ... | @@ -151,55 +151,44 @@ describe("ProviderManager", () => { |
| 151 | 151 | ||
| 152 | it("rolls back from a selected checkpoint turn count", async () => { | 152 | it("rolls back from a selected checkpoint turn count", async () => { |
| 153 | const manager = new ProviderManager(); | 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 | const internals = manager as unknown as { | 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 | filesystemCheckpointStore: { | 169 | filesystemCheckpointStore: { |
| 186 | isGitRepository: (cwd: string) => Promise<boolean>; | 170 | hasCheckpoint: (input: { |
| 187 | ensureRootCheckpoint: (input: { cwd: string; threadId: string }) => Promise<boolean>; | 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 | const rollbackCalls: Array<{ sessionId: string; numTurns: number }> = []; | 189 | const rollbackCalls: Array<{ sessionId: string; numTurns: number }> = []; |
| 192 | codex.hasSession = () => true; | 190 | internals.codex.hasSession = () => true; |
| 193 | codex.listSessions = () => [ | 191 | internals.codex.readThread = async () => ({ |
| 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 () => ({ | ||
| 203 | threadId: "thr_1", | 192 | threadId: "thr_1", |
| 204 | turns: [ | 193 | turns: [ |
| 205 | { id: "turn_1", items: [] }, | 194 | { id: "turn_1", items: [] }, |
| ... | @@ -207,15 +196,20 @@ describe("ProviderManager", () => { | ... | @@ -207,15 +196,20 @@ describe("ProviderManager", () => { |
| 207 | { id: "turn_3", items: [] }, | 196 | { id: "turn_3", items: [] }, |
| 208 | ], | 197 | ], |
| 209 | }); | 198 | }); |
| 210 | codex.rollbackThread = async (sessionId, numTurns) => { | 199 | internals.codex.rollbackThread = async (sessionId, numTurns) => { |
| 211 | rollbackCalls.push({ sessionId, numTurns }); | 200 | rollbackCalls.push({ sessionId, numTurns }); |
| 212 | return { | 201 | return { |
| 213 | threadId: "thr_1", | 202 | threadId: "thr_1", |
| 214 | turns: [{ id: "turn_1", items: [] }], | 203 | turns: [{ id: "turn_1", items: [] }], |
| 215 | }; | 204 | }; |
| 216 | }; | 205 | }; |
| 217 | internals.filesystemCheckpointStore.isGitRepository = async () => false; | 206 | const hasCheckpoint = vi.fn(async () => true); |
| 218 | internals.filesystemCheckpointStore.ensureRootCheckpoint = 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 | const result = await manager.revertToCheckpoint({ | 214 | const result = await manager.revertToCheckpoint({ |
| 221 | sessionId: "sess_1", | 215 | sessionId: "sess_1", |
| ... | @@ -228,6 +222,21 @@ describe("ProviderManager", () => { | ... | @@ -228,6 +222,21 @@ describe("ProviderManager", () => { |
| 228 | expect(result.messageCount).toBe(0); | 222 | expect(result.messageCount).toBe(0); |
| 229 | expect(result.rolledBackTurns).toBe(2); | 223 | expect(result.rolledBackTurns).toBe(2); |
| 230 | expect(result.checkpoints).toHaveLength(2); | 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 | manager.dispose(); | 241 | manager.dispose(); |
| 233 | }); | 242 | }); |
| ... | @@ -471,6 +480,101 @@ describe("ProviderManager", () => { | ... | @@ -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 | it("restores filesystem checkpoint when reverting and checkpointing is enabled", async () => { | 578 | it("restores filesystem checkpoint when reverting and checkpointing is enabled", async () => { |
| 475 | const manager = new ProviderManager(); | 579 | const manager = new ProviderManager(); |
| 476 | const internals = manager as unknown as { | 580 | const internals = manager as unknown as { |
| ... | @@ -509,6 +613,7 @@ describe("ProviderManager", () => { | ... | @@ -509,6 +613,7 @@ describe("ProviderManager", () => { |
| 509 | }; | 613 | }; |
| 510 | 614 | ||
| 511 | internals.codex.hasSession = () => true; | 615 | internals.codex.hasSession = () => true; |
| 616 | const callOrder: string[] = []; | ||
| 512 | internals.codex.readThread = async () => ({ | 617 | internals.codex.readThread = async () => ({ |
| 513 | threadId: "thr_1", | 618 | threadId: "thr_1", |
| 514 | turns: [ | 619 | turns: [ |
| ... | @@ -516,13 +621,22 @@ describe("ProviderManager", () => { | ... | @@ -516,13 +621,22 @@ describe("ProviderManager", () => { |
| 516 | { id: "turn_2", items: [] }, | 621 | { id: "turn_2", items: [] }, |
| 517 | ], | 622 | ], |
| 518 | }); | 623 | }); |
| 519 | internals.codex.rollbackThread = async () => ({ | 624 | internals.codex.rollbackThread = async () => { |
| 520 | threadId: "thr_1", | 625 | callOrder.push("rollback"); |
| 521 | turns: [{ id: "turn_1", items: [] }], | 626 | return { |
| 522 | }); | 627 | threadId: "thr_1", |
| 628 | turns: [{ id: "turn_1", items: [] }], | ||
| 629 | }; | ||
| 630 | }; | ||
| 523 | const hasCheckpoint = vi.fn(async () => true); | 631 | const hasCheckpoint = vi.fn(async () => true); |
| 524 | const restoreCheckpoint = vi.fn(async () => true); | 632 | const restoreCheckpoint = vi.fn(async () => { |
| 525 | const pruneAfterTurn = vi.fn(async () => undefined); | 633 | callOrder.push("restore"); |
| 634 | return true; | ||
| 635 | }); | ||
| 636 | const pruneAfterTurn = vi.fn(async () => { | ||
| 637 | callOrder.push("prune"); | ||
| 638 | return undefined; | ||
| 639 | }); | ||
| 526 | internals.filesystemCheckpointStore.hasCheckpoint = hasCheckpoint; | 640 | internals.filesystemCheckpointStore.hasCheckpoint = hasCheckpoint; |
| 527 | internals.filesystemCheckpointStore.restoreCheckpoint = restoreCheckpoint; | 641 | internals.filesystemCheckpointStore.restoreCheckpoint = restoreCheckpoint; |
| 528 | internals.filesystemCheckpointStore.pruneAfterTurn = pruneAfterTurn; | 642 | internals.filesystemCheckpointStore.pruneAfterTurn = pruneAfterTurn; |
| ... | @@ -549,6 +663,7 @@ describe("ProviderManager", () => { | ... | @@ -549,6 +663,7 @@ describe("ProviderManager", () => { |
| 549 | threadId: "thr_1", | 663 | threadId: "thr_1", |
| 550 | maxTurnCount: 1, | 664 | maxTurnCount: 1, |
| 551 | }); | 665 | }); |
| 666 | expect(callOrder).toEqual(["restore", "rollback", "prune"]); | ||
| 552 | 667 | ||
| 553 | manager.dispose(); | 668 | manager.dispose(); |
| 554 | }); | 669 | }); |
apps/server/src/providerManager.ts+26-22| ... | @@ -308,6 +308,9 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { | ... | @@ -308,6 +308,9 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { |
| 308 | } | 308 | } |
| 309 | 309 | ||
| 310 | const checkpointCwd = await this.getOrInitializeFilesystemCheckpointCwd(input.sessionId); | 310 | const checkpointCwd = await this.getOrInitializeFilesystemCheckpointCwd(input.sessionId); |
| 311 | if (!checkpointCwd) { | ||
| 312 | throw new Error("Filesystem checkpoints are unavailable for this session."); | ||
| 313 | } | ||
| 311 | return this.withFilesystemLock(input.sessionId, async () => { | 314 | return this.withFilesystemLock(input.sessionId, async () => { |
| 312 | const beforeSnapshot = await this.codex.readThread(input.sessionId); | 315 | const beforeSnapshot = await this.codex.readThread(input.sessionId); |
| 313 | const currentTurnCount = beforeSnapshot.turns.length; | 316 | const currentTurnCount = beforeSnapshot.turns.length; |
| ... | @@ -330,29 +333,28 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { | ... | @@ -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 | const requestedRollbackTurns = currentTurnCount - input.turnCount; | 347 | const requestedRollbackTurns = currentTurnCount - input.turnCount; |
| 334 | const afterSnapshot = | 348 | const afterSnapshot = |
| 335 | requestedRollbackTurns > 0 | 349 | requestedRollbackTurns > 0 |
| 336 | ? await this.codex.rollbackThread(input.sessionId, requestedRollbackTurns) | 350 | ? await this.codex.rollbackThread(input.sessionId, requestedRollbackTurns) |
| 337 | : beforeSnapshot; | 351 | : beforeSnapshot; |
| 338 | 352 | ||
| 339 | if (checkpointCwd) { | 353 | await this.filesystemCheckpointStore.pruneAfterTurn({ |
| 340 | const restored = await this.filesystemCheckpointStore.restoreCheckpoint({ | 354 | cwd: checkpointCwd, |
| 341 | cwd: checkpointCwd, | 355 | threadId: afterSnapshot.threadId, |
| 342 | threadId: beforeSnapshot.threadId, | 356 | maxTurnCount: afterSnapshot.turns.length, |
| 343 | turnCount: input.turnCount, | 357 | }); |
| 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 | } | ||
| 356 | 358 | ||
| 357 | const checkpoints = buildCheckpoints(afterSnapshot.turns); | 359 | const checkpoints = buildCheckpoints(afterSnapshot.turns); |
| 358 | const currentCheckpoint = | 360 | const currentCheckpoint = |
| ... | @@ -415,7 +417,9 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { | ... | @@ -415,7 +417,9 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { |
| 415 | threadId: snapshot.threadId, | 417 | threadId: snapshot.threadId, |
| 416 | turnCount: snapshot.turns.length, | 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,9 +432,7 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { |
| 428 | const session = this.codex | 432 | const session = this.codex |
| 429 | .listSessions() | 433 | .listSessions() |
| 430 | .find((candidate) => candidate.sessionId === sessionId); | 434 | .find((candidate) => candidate.sessionId === sessionId); |
| 431 | const candidateCwds = Array.from( | 435 | const candidateCwds = session?.cwd ? [session.cwd] : [process.cwd()]; |
| 432 | new Set([session?.cwd, process.cwd()].filter((value): value is string => Boolean(value))), | ||
| 433 | ); | ||
| 434 | if (candidateCwds.length === 0) { | 436 | if (candidateCwds.length === 0) { |
| 435 | return null; | 437 | return null; |
| 436 | } | 438 | } |
| ... | @@ -459,7 +461,9 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { | ... | @@ -459,7 +461,9 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { |
| 459 | threadId: snapshot.threadId, | 461 | threadId: snapshot.threadId, |
| 460 | turnCount: snapshot.turns.length, | 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 | return; | 467 | return; |
| 464 | } | 468 | } |
| 465 | 469 |
apps/web/src/components/ChatView.tsx+15-19| ... | @@ -53,9 +53,7 @@ import { | ... | @@ -53,9 +53,7 @@ import { |
| 53 | countDiffStat, | 53 | countDiffStat, |
| 54 | derivePendingApprovals, | 54 | derivePendingApprovals, |
| 55 | derivePhase, | 55 | derivePhase, |
| 56 | deriveTurnDiffSummaries, | ||
| 57 | deriveTimelineEntries, | 56 | deriveTimelineEntries, |
| 58 | inferCheckpointTurnCountByTurnId, | ||
| 59 | type TurnDiffSummary, | 57 | type TurnDiffSummary, |
| 60 | type PendingApproval, | 58 | type PendingApproval, |
| 61 | deriveWorkLogEntries, | 59 | deriveWorkLogEntries, |
| ... | @@ -72,6 +70,7 @@ import { | ... | @@ -72,6 +70,7 @@ import { |
| 72 | } from "../types"; | 70 | } from "../types"; |
| 73 | import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons"; | 71 | import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons"; |
| 74 | import { useTheme } from "../hooks/useTheme"; | 72 | import { useTheme } from "../hooks/useTheme"; |
| 73 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; | ||
| 75 | import BranchToolbar from "./BranchToolbar"; | 74 | import BranchToolbar from "./BranchToolbar"; |
| 76 | import GitActionsControl from "./GitActionsControl"; | 75 | import GitActionsControl from "./GitActionsControl"; |
| 77 | import { | 76 | import { |
| ... | @@ -414,13 +413,8 @@ export default function ChatView() { | ... | @@ -414,13 +413,8 @@ export default function ChatView() { |
| 414 | () => deriveTimelineEntries(activeThread?.messages ?? [], workLogEntries), | 413 | () => deriveTimelineEntries(activeThread?.messages ?? [], workLogEntries), |
| 415 | [activeThread?.messages, workLogEntries], | 414 | [activeThread?.messages, workLogEntries], |
| 416 | ); | 415 | ); |
| 417 | const turnDiffSummaries = useMemo( | 416 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = |
| 418 | () => | 417 | useTurnDiffSummaries(activeThread); |
| 419 | activeThread?.turnDiffSummaries.length | ||
| 420 | ? activeThread.turnDiffSummaries | ||
| 421 | : deriveTurnDiffSummaries(activeThread?.events ?? []), | ||
| 422 | [activeThread?.events, activeThread?.turnDiffSummaries], | ||
| 423 | ); | ||
| 424 | const turnDiffSummaryByAssistantMessageId = useMemo(() => { | 418 | const turnDiffSummaryByAssistantMessageId = useMemo(() => { |
| 425 | const byMessageId = new Map<string, TurnDiffSummary>(); | 419 | const byMessageId = new Map<string, TurnDiffSummary>(); |
| 426 | for (const summary of turnDiffSummaries) { | 420 | for (const summary of turnDiffSummaries) { |
| ... | @@ -429,11 +423,6 @@ export default function ChatView() { | ... | @@ -429,11 +423,6 @@ export default function ChatView() { |
| 429 | } | 423 | } |
| 430 | return byMessageId; | 424 | return byMessageId; |
| 431 | }, [turnDiffSummaries]); | 425 | }, [turnDiffSummaries]); |
| 432 | const inferredCheckpointTurnCountByTurnId = useMemo( | ||
| 433 | () => inferCheckpointTurnCountByTurnId(turnDiffSummaries), | ||
| 434 | [turnDiffSummaries], | ||
| 435 | ); | ||
| 436 | |||
| 437 | const revertTurnCountByUserMessageId = useMemo(() => { | 426 | const revertTurnCountByUserMessageId = useMemo(() => { |
| 438 | const byUserMessageId = new Map<string, number>(); | 427 | const byUserMessageId = new Map<string, number>(); |
| 439 | for (let index = 0; index < timelineEntries.length; index += 1) { | 428 | for (let index = 0; index < timelineEntries.length; index += 1) { |
| ... | @@ -1487,8 +1476,10 @@ export default function ChatView() { | ... | @@ -1487,8 +1476,10 @@ export default function ChatView() { |
| 1487 | const selectedDiffTurnId = | 1476 | const selectedDiffTurnId = |
| 1488 | state.diffOpen && state.diffThreadId === activeThreadId ? state.diffTurnId : null; | 1477 | state.diffOpen && state.diffThreadId === activeThreadId ? state.diffTurnId : null; |
| 1489 | const selectedDiffTurn = | 1478 | const selectedDiffTurn = |
| 1490 | turnDiffSummaries.find((summary) => summary.turnId === selectedDiffTurnId) ?? | 1479 | selectedDiffTurnId === null |
| 1491 | turnDiffSummaries[0]; | 1480 | ? undefined |
| 1481 | : (turnDiffSummaries.find((summary) => summary.turnId === selectedDiffTurnId) ?? | ||
| 1482 | turnDiffSummaries[0]); | ||
| 1492 | const selectedTurnMissingPatchBody = Boolean( | 1483 | const selectedTurnMissingPatchBody = Boolean( |
| 1493 | selectedDiffTurn && | 1484 | selectedDiffTurn && |
| 1494 | !selectedDiffTurn.unifiedDiff && | 1485 | !selectedDiffTurn.unifiedDiff && |
| ... | @@ -1505,9 +1496,14 @@ export default function ChatView() { | ... | @@ -1505,9 +1496,14 @@ export default function ChatView() { |
| 1505 | } | 1496 | } |
| 1506 | checkpointHydrationSessionRequestRef.current.add(requestKey); | 1497 | checkpointHydrationSessionRequestRef.current.add(requestKey); |
| 1507 | setThreadError(activeThreadId, null); | 1498 | setThreadError(activeThreadId, null); |
| 1508 | void ensureSession().finally(() => { | 1499 | void ensureSession().then( |
| 1509 | checkpointHydrationSessionRequestRef.current.delete(requestKey); | 1500 | () => { |
| 1510 | }); | 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 | activeThreadId, | 1508 | activeThreadId, |
| 1513 | activeThreadRuntimeId, | 1509 | activeThreadRuntimeId, |
apps/web/src/components/DiffPanel.tsx+10-17| ... | @@ -8,11 +8,8 @@ import { cn } from "~/lib/utils"; | ... | @@ -8,11 +8,8 @@ import { cn } from "~/lib/utils"; |
| 8 | import { isElectron } from "../env"; | 8 | import { isElectron } from "../env"; |
| 9 | import { useNativeApi } from "../hooks/useNativeApi"; | 9 | import { useNativeApi } from "../hooks/useNativeApi"; |
| 10 | import { useTheme } from "../hooks/useTheme"; | 10 | import { useTheme } from "../hooks/useTheme"; |
| 11 | import { | 11 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; |
| 12 | deriveTurnDiffSummaries, | 12 | import { formatTimestamp } from "../session-logic"; |
| 13 | formatTimestamp, | ||
| 14 | inferCheckpointTurnCountByTurnId, | ||
| 15 | } from "../session-logic"; | ||
| 16 | import { useStore } from "../store"; | 13 | import { useStore } from "../store"; |
| 17 | import { ToggleGroup, Toggle } from "./ui/toggle-group"; | 14 | import { ToggleGroup, Toggle } from "./ui/toggle-group"; |
| 18 | 15 | ||
| ... | @@ -78,17 +75,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { | ... | @@ -78,17 +75,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 78 | const activeThreadRuntimeId = | 75 | const activeThreadRuntimeId = |
| 79 | activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null; | 76 | activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null; |
| 80 | const activeSessionId = activeThread?.session?.sessionId ?? null; | 77 | const activeSessionId = activeThread?.session?.sessionId ?? null; |
| 81 | const turnDiffSummaries = useMemo( | 78 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = |
| 82 | () => | 79 | useTurnDiffSummaries(activeThread); |
| 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 | ); | ||
| 92 | 80 | ||
| 93 | const canApplyStoredTarget = Boolean(activeThread && state.diffThreadId === activeThread.id); | 81 | const canApplyStoredTarget = Boolean(activeThread && state.diffThreadId === activeThread.id); |
| 94 | const selectedTurnId = canApplyStoredTarget ? state.diffTurnId : null; | 82 | const selectedTurnId = canApplyStoredTarget ? state.diffTurnId : null; |
| ... | @@ -358,7 +346,12 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { | ... | @@ -358,7 +346,12 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 358 | variant="outline" | 346 | variant="outline" |
| 359 | size="xs" | 347 | size="xs" |
| 360 | value={[diffRenderMode]} | 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 | <Toggle aria-label="Stacked diff view" value="stacked"> | 356 | <Toggle aria-label="Stacked diff view" value="stacked"> |
| 364 | <Rows3Icon className="size-3" /> | 357 | <Rows3Icon className="size-3" /> |
apps/web/src/components/ui/toggle-group.tsx+2-2| ... | @@ -58,8 +58,8 @@ function Toggle({ | ... | @@ -58,8 +58,8 @@ function Toggle({ |
| 58 | }: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) { | 58 | }: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) { |
| 59 | const context = React.useContext(ToggleGroupContext); | 59 | const context = React.useContext(ToggleGroupContext); |
| 60 | 60 | ||
| 61 | const resolvedVariant = context.variant || variant; | 61 | const resolvedVariant = variant ?? context.variant; |
| 62 | const resolvedSize = context.size || size; | 62 | const resolvedSize = size ?? context.size; |
| 63 | 63 | ||
| 64 | return ( | 64 | return ( |
| 65 | <ToggleComponent | 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,7 +28,13 @@ export function checkpointDiffQueryOptions( |
| 28 | input: CheckpointDiffQueryInput, | 28 | input: CheckpointDiffQueryInput, |
| 29 | ) { | 29 | ) { |
| 30 | const hasValidRange = | 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 | return queryOptions({ | 39 | return queryOptions({ |
| 34 | queryKey: providerQueryKeys.checkpointDiff(input), | 40 | queryKey: providerQueryKeys.checkpointDiff(input), |
apps/web/src/persistenceSchema.test.ts+40| ... | @@ -325,6 +325,46 @@ describe("hydratePersistedState", () => { | ... | @@ -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 | describe("toPersistedState", () => { | 370 | describe("toPersistedState", () => { |
apps/web/src/persistenceSchema.ts+1-1| ... | @@ -84,7 +84,7 @@ const persistedThreadSchema = z.object({ | ... | @@ -84,7 +84,7 @@ const persistedThreadSchema = z.object({ |
| 84 | lastVisitedAt: z.string().min(1).optional(), | 84 | lastVisitedAt: z.string().min(1).optional(), |
| 85 | branch: z.string().min(1).nullable().optional(), | 85 | branch: z.string().min(1).nullable().optional(), |
| 86 | worktreePath: z.string().min(1).nullable().optional(), | 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 | const persistedStateBodySchema = z.object({ | 90 | const persistedStateBodySchema = z.object({ |
apps/web/src/session-logic.test.ts+70-1| ... | @@ -493,7 +493,11 @@ describe("deriveTurnDiffSummaries", () => { | ... | @@ -493,7 +493,11 @@ describe("deriveTurnDiffSummaries", () => { |
| 493 | expect(summaries).toHaveLength(1); | 493 | expect(summaries).toHaveLength(1); |
| 494 | expect(summaries[0]?.turnId).toBe("turn-1"); | 494 | expect(summaries[0]?.turnId).toBe("turn-1"); |
| 495 | expect(summaries[0]?.assistantMessageId).toBe("msg-1"); | 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 | it("includes completed turns even when no fileChange tool events were emitted", () => { | 503 | it("includes completed turns even when no fileChange tool events were emitted", () => { |
| ... | @@ -522,6 +526,55 @@ describe("deriveTurnDiffSummaries", () => { | ... | @@ -522,6 +526,55 @@ describe("deriveTurnDiffSummaries", () => { |
| 522 | expect(summaries[0]?.turnId).toBe("turn-1"); | 526 | expect(summaries[0]?.turnId).toBe("turn-1"); |
| 523 | expect(summaries[0]?.files).toEqual([]); | 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 | describe("deriveTurnDiffFilesFromUnifiedDiff", () => { | 580 | describe("deriveTurnDiffFilesFromUnifiedDiff", () => { |
| ... | @@ -545,6 +598,22 @@ describe("deriveTurnDiffFilesFromUnifiedDiff", () => { | ... | @@ -545,6 +598,22 @@ describe("deriveTurnDiffFilesFromUnifiedDiff", () => { |
| 545 | expect(files[0]?.additions).toBe(1); | 598 | expect(files[0]?.additions).toBe(1); |
| 546 | expect(files[0]?.deletions).toBe(1); | 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 | describe("derivePendingApprovals", () => { | 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,10 +33,18 @@ export function asObject(value: unknown): Record<string, unknown> | undefined { |
| 33 | return value as Record<string, unknown>; | 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 | export function asString(value: unknown): string | undefined { | 40 | export function asString(value: unknown): string | undefined { |
| 37 | return typeof value === "string" ? value : undefined; | 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 | export function formatTimestamp(isoDate: string): string { | 48 | export function formatTimestamp(isoDate: string): string { |
| 41 | return new Intl.DateTimeFormat(undefined, { | 49 | return new Intl.DateTimeFormat(undefined, { |
| 42 | hour: "numeric", | 50 | hour: "numeric", |
| ... | @@ -445,14 +453,77 @@ interface MutableTurnDiffSummary { | ... | @@ -445,14 +453,77 @@ interface MutableTurnDiffSummary { |
| 445 | completedAt: string | undefined; | 453 | completedAt: string | undefined; |
| 446 | status: string | undefined; | 454 | status: string | undefined; |
| 447 | assistantMessageId: string | undefined; | 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 | function parsePathFromDiff(diff: string): string | undefined { | 521 | function parsePathFromDiff(diff: string): string | undefined { |
| 451 | const normalized = diff.replace(/\r\n/g, "\n"); | 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 | const bPath = normalized.match(/^\+\+\+ b\/(.+)$/m); | 523 | const bPath = normalized.match(/^\+\+\+ b\/(.+)$/m); |
| 455 | if (bPath && bPath[1]) return bPath[1]; | 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 | const direct = normalized.match(/^\+\+\+ (.+)$/m); | 527 | const direct = normalized.match(/^\+\+\+ (.+)$/m); |
| 457 | if (!direct || !direct[1] || direct[1] === "/dev/null") { | 528 | if (!direct || !direct[1] || direct[1] === "/dev/null") { |
| 458 | return undefined; | 529 | return undefined; |
| ... | @@ -463,7 +534,7 @@ function parsePathFromDiff(diff: string): string | undefined { | ... | @@ -463,7 +534,7 @@ function parsePathFromDiff(diff: string): string | undefined { |
| 463 | export function splitUnifiedDiffByFile(diff: string): Map<string, string> { | 534 | export function splitUnifiedDiffByFile(diff: string): Map<string, string> { |
| 464 | const normalized = diff.replace(/\r\n/g, "\n"); | 535 | const normalized = diff.replace(/\r\n/g, "\n"); |
| 465 | const byPath = new Map<string, string>(); | 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 | if (headerMatches.length === 0) { | 539 | if (headerMatches.length === 0) { |
| 469 | const path = parsePathFromDiff(normalized); | 540 | const path = parsePathFromDiff(normalized); |
| ... | @@ -479,7 +550,7 @@ export function splitUnifiedDiffByFile(diff: string): Map<string, string> { | ... | @@ -479,7 +550,7 @@ export function splitUnifiedDiffByFile(diff: string): Map<string, string> { |
| 479 | const start = match.index ?? 0; | 550 | const start = match.index ?? 0; |
| 480 | const nextStart = headerMatches[index + 1]?.index ?? normalized.length; | 551 | const nextStart = headerMatches[index + 1]?.index ?? normalized.length; |
| 481 | const segment = normalized.slice(start, nextStart).trim(); | 552 | const segment = normalized.slice(start, nextStart).trim(); |
| 482 | const path = match[2] ?? match[1] ?? parsePathFromDiff(segment); | 553 | const path = parsePathFromDiff(segment); |
| 483 | if (!path || segment.length === 0) continue; | 554 | if (!path || segment.length === 0) continue; |
| 484 | byPath.set(path, segment); | 555 | byPath.set(path, segment); |
| 485 | } | 556 | } |
| ... | @@ -555,7 +626,15 @@ export function countDiffStat(patch: string | undefined): { additions: number; d | ... | @@ -555,7 +626,15 @@ export function countDiffStat(patch: string | undefined): { additions: number; d |
| 555 | } | 626 | } |
| 556 | 627 | ||
| 557 | export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummary[] { | 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 | const byTurnId = new Map<string, MutableTurnDiffSummary>(); | 638 | const byTurnId = new Map<string, MutableTurnDiffSummary>(); |
| 560 | 639 | ||
| 561 | const ensureSummary = (turnId: string): MutableTurnDiffSummary => { | 640 | const ensureSummary = (turnId: string): MutableTurnDiffSummary => { |
| ... | @@ -566,6 +645,8 @@ export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummar | ... | @@ -566,6 +645,8 @@ export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummar |
| 566 | completedAt: undefined, | 645 | completedAt: undefined, |
| 567 | status: undefined, | 646 | status: undefined, |
| 568 | assistantMessageId: undefined, | 647 | assistantMessageId: undefined, |
| 648 | unifiedDiff: undefined, | ||
| 649 | filesByPath: new Map<string, TurnDiffFileChange>(), | ||
| 569 | }; | 650 | }; |
| 570 | byTurnId.set(turnId, next); | 651 | byTurnId.set(turnId, next); |
| 571 | return next; | 652 | return next; |
| ... | @@ -577,21 +658,38 @@ export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummar | ... | @@ -577,21 +658,38 @@ export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummar |
| 577 | const summary = ensureSummary(turnId); | 658 | const summary = ensureSummary(turnId); |
| 578 | 659 | ||
| 579 | if (event.method === "turn/completed") { | 660 | if (event.method === "turn/completed") { |
| 580 | summary.completedAt = event.createdAt; | 661 | if (summary.completedAt === undefined) { |
| 662 | summary.completedAt = event.createdAt; | ||
| 663 | } | ||
| 581 | const turn = asObject(asObject(event.payload)?.turn); | 664 | const turn = asObject(asObject(event.payload)?.turn); |
| 582 | summary.status = normalizeDetail(asString(turn?.status)); | 665 | if (summary.status === undefined) { |
| 583 | continue; | 666 | summary.status = normalizeDetail(asString(turn?.status)); |
| 667 | } | ||
| 584 | } | 668 | } |
| 585 | 669 | ||
| 586 | if (event.method === "item/completed") { | 670 | if (event.method === "item/completed") { |
| 587 | const item = asObject(asObject(event.payload)?.item); | 671 | const item = asObject(asObject(event.payload)?.item); |
| 588 | if (asString(item?.type) === "agentMessage") { | 672 | if (asString(item?.type) === "agentMessage") { |
| 589 | const itemId = normalizeDetail(asString(item?.id)); | 673 | const itemId = normalizeDetail(asString(item?.id)); |
| 590 | if (itemId) { | 674 | if (itemId && summary.assistantMessageId === undefined) { |
| 591 | summary.assistantMessageId = itemId; | 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 | const summaries: TurnDiffSummary[] = []; | 695 | const summaries: TurnDiffSummary[] = []; |
| ... | @@ -602,7 +700,10 @@ export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummar | ... | @@ -602,7 +700,10 @@ export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummar |
| 602 | turnId: summary.turnId, | 700 | turnId: summary.turnId, |
| 603 | completedAt: summary.completedAt, | 701 | completedAt: summary.completedAt, |
| 604 | ...(summary.status ? { status: summary.status } : {}), | 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 | ...(summary.assistantMessageId ? { assistantMessageId: summary.assistantMessageId } : {}), | 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,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 | it("reconciles project ids by cwd when syncing backend projects", () => { | 589 | it("reconciles project ids by cwd when syncing backend projects", () => { |
| 573 | const state: AppState = { | 590 | const state: AppState = { |
| 574 | projects: [ | 591 | projects: [ |
apps/web/src/store.ts+10-6| ... | @@ -202,7 +202,6 @@ function mergeTurnDiffSummaries( | ... | @@ -202,7 +202,6 @@ function mergeTurnDiffSummaries( |
| 202 | existing: Thread["turnDiffSummaries"], | 202 | existing: Thread["turnDiffSummaries"], |
| 203 | next: Thread["turnDiffSummaries"], | 203 | next: Thread["turnDiffSummaries"], |
| 204 | ): Thread["turnDiffSummaries"] { | 204 | ): Thread["turnDiffSummaries"] { |
| 205 | if (existing.length === 0) return next; | ||
| 206 | if (next.length === 0) return existing; | 205 | if (next.length === 0) return existing; |
| 207 | 206 | ||
| 208 | const existingByTurnId = new Map(existing.map((summary) => [summary.turnId, summary] as const)); | 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,11 +892,16 @@ export function reducer(state: AppState, action: Action): AppState { |
| 893 | ...state, | 892 | ...state, |
| 894 | threads: updateThread(state.threads, target.id, (t) => { | 893 | threads: updateThread(state.threads, target.id, (t) => { |
| 895 | const nextEvents = [event, ...t.events]; | 894 | const nextEvents = [event, ...t.events]; |
| 896 | const derivedTurnDiffSummaries = deriveTurnDiffSummaries(nextEvents); | 895 | const itemType = asString(asObject(asObject(event.payload)?.item)?.type); |
| 897 | const turnDiffSummaries = mergeTurnDiffSummaries( | 896 | const normalizedItemType = itemType?.replace(/[_-]/g, "").toLowerCase(); |
| 898 | t.turnDiffSummaries, | 897 | const shouldRederiveDiffs = |
| 899 | derivedTurnDiffSummaries, | 898 | event.method === "turn/completed" || |
| 900 | ); | 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 | const eventThreadId = getEventThreadId(event); | 905 | const eventThreadId = getEventThreadId(event); |
| 902 | const shouldRebindIdentity = | 906 | const shouldRebindIdentity = |
| 903 | event.method === "thread/started" && t.session?.status === "connecting"; | 907 | event.method === "thread/started" && t.session?.status === "connecting"; |