authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-18 10:33:00-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-18 21:59:17-08:00
logc90e245038970029f777200201048513da8f84e8
treeedfcdabcdd353f50e7946456467a37d8bd04859f
parent200e43e44c52e0f819f7162a6a3fb7551a9ee013

Address PR review feedback for checkpoint and diff flows

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 });
264264
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 }
798798
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 @@
1import { mkdtemp, rm, writeFile } from "node:fs/promises";1import { mkdtemp, rm } from "node:fs/promises";
2import os from "node:os";2import os from "node:os";
3import path from "node:path";3import path from "node:path";
4import { randomUUID } from "node:crypto";4import { 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);
4848
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 };
5950
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 };
6261
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 {
133132
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 }
139138
...@@ -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 });
230229
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 }
237245
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 }
290298
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", () => {
151151
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 };
190188
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");
219213
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 });
231240
232 manager.dispose();241 manager.dispose();
233 });242 });
...@@ -471,6 +480,101 @@ describe("ProviderManager", () => {...@@ -471,6 +480,101 @@ describe("ProviderManager", () => {
471 }480 }
472 });481 });
473482
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 };
510614
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"]);
552667
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 }
309309
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 }
332335
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 > 0349 requestedRollbackTurns > 0
336 ? await this.codex.rollbackThread(input.sessionId, requestedRollbackTurns)350 ? await this.codex.rollbackThread(input.sessionId, requestedRollbackTurns)
337 : beforeSnapshot;351 : beforeSnapshot;
338352
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 }
356358
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 }
421425
...@@ -428,9 +432,7 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {...@@ -428,9 +432,7 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {
428 const session = this.codex432 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 }
465469
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";
73import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons";71import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons";
74import { useTheme } from "../hooks/useTheme";72import { useTheme } from "../hooks/useTheme";
73import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
75import BranchToolbar from "./BranchToolbar";74import BranchToolbar from "./BranchToolbar";
76import GitActionsControl from "./GitActionsControl";75import GitActionsControl from "./GitActionsControl";
77import {76import {
...@@ -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";
8import { isElectron } from "../env";8import { isElectron } from "../env";
9import { useNativeApi } from "../hooks/useNativeApi";9import { useNativeApi } from "../hooks/useNativeApi";
10import { useTheme } from "../hooks/useTheme";10import { useTheme } from "../hooks/useTheme";
11import {11import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
12 deriveTurnDiffSummaries,12import { formatTimestamp } from "../session-logic";
13 formatTimestamp,
14 inferCheckpointTurnCountByTurnId,
15} from "../session-logic";
16import { useStore } from "../store";13import { useStore } from "../store";
17import { ToggleGroup, Toggle } from "./ui/toggle-group";14import { ToggleGroup, Toggle } from "./ui/toggle-group";
1815
...@@ -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 );
9280
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);
6060
61 const resolvedVariant = context.variant || variant;61 const resolvedVariant = variant ?? context.variant;
62 const resolvedSize = context.size || size;62 const resolvedSize = size ?? context.size;
6363
64 return (64 return (
65 <ToggleComponent65 <ToggleComponent
apps/web/src/hooks/useTurnDiffSummaries.ts created+35
...@@ -0,0 +1,35 @@
1import type { ProviderEvent } from "@t3tools/contracts";
2import { useMemo } from "react";
3import { deriveTurnDiffSummaries, inferCheckpointTurnCountByTurnId } from "../session-logic";
4import type { Thread, TurnDiffSummary } from "../types";
5
6const derivedTurnDiffSummaryCache = new WeakMap<readonly ProviderEvent[], TurnDiffSummary[]>();
7
8function 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
18export 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;
3238
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});
329369
330describe("toPersistedState", () => {370describe("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});
8989
90const persistedStateBodySchema = z.object({90const 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 });
498502
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});
526579
527describe("deriveTurnDiffFilesFromUnifiedDiff", () => {580describe("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});
549618
550describe("derivePendingApprovals", () => {619describe("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}
3535
36function asArray(value: unknown): unknown[] | undefined {
37 return Array.isArray(value) ? value : undefined;
38}
39
36export function asString(value: unknown): string | undefined {40export function asString(value: unknown): string | undefined {
37 return typeof value === "string" ? value : undefined;41 return typeof value === "string" ? value : undefined;
38}42}
3943
44function asNumber(value: unknown): number | undefined {
45 return typeof value === "number" && Number.isFinite(value) ? value : undefined;
46}
47
40export function formatTimestamp(isoDate: string): string {48export 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
460function 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
497function 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}
449520
450function parsePathFromDiff(diff: string): string | undefined {521function 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 {
463export function splitUnifiedDiffByFile(diff: string): Map<string, string> {534export 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)];
467538
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}
556627
557export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummary[] {628export 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>();
560639
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);
578659
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 }
585669
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 }
596694
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 });
571571
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;
207206
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";