diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index e1ce2fcd1244c97a607e7c4873d1bee54bf3fc64..59077ce7870f8c308c8773947cf4b1594b5842f1 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -262,6 +262,35 @@ describe("thread checkpoint control", () => { }); }); + it("reads thread turns from flat thread/read responses", async () => { + const { manager, context, sendRequest } = createThreadControlHarness(); + sendRequest.mockResolvedValue({ + threadId: "thread_1", + turns: [ + { + id: "turn_1", + items: [{ type: "userMessage", content: [{ type: "text", text: "hello" }] }], + }, + ], + }); + + const result = await manager.readThread("sess_1"); + + expect(sendRequest).toHaveBeenCalledWith(context, "thread/read", { + threadId: "thread_1", + includeTurns: true, + }); + expect(result).toEqual({ + threadId: "thread_1", + turns: [ + { + id: "turn_1", + items: [{ type: "userMessage", content: [{ type: "text", text: "hello" }] }], + }, + ], + }); + }); + it("rolls back turns via thread/rollback and resets session running state", async () => { const { manager, context, sendRequest, updateSession } = createThreadControlHarness(); sendRequest.mockResolvedValue({ diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index 73af81bbe8e441b649c60eba5e7c19496269eee6..c3352ccb249c6c54c52389bcb0bf3b0b47c0bb1c 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -796,7 +796,7 @@ export class CodexAppServerManager extends EventEmitter { const turn = this.readObject(turnValue); const turnId = this.readString(turn, "id") ?? `${threadId}:turn:${index + 1}`; diff --git a/apps/server/src/filesystemCheckpointStore.ts b/apps/server/src/filesystemCheckpointStore.ts index 41c58ed06a11c2d4e8d990205a80311d554c4967..2de2a65b27e39d02f7ea99ceeaa097c8459c2c34 100644 --- a/apps/server/src/filesystemCheckpointStore.ts +++ b/apps/server/src/filesystemCheckpointStore.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, rm } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { randomUUID } from "node:crypto"; @@ -47,18 +47,17 @@ export class FilesystemCheckpointStore { const ref = checkpointRefForThreadTurn(threadId, turnCount); const tempDir = await mkdtemp(path.join(os.tmpdir(), "t3-fs-checkpoint-")); - const tempIndexPath = path.join(tempDir, `index-${randomUUID()}`); - const commitEnv: NodeJS.ProcessEnv = { - ...process.env, - GIT_INDEX_FILE: tempIndexPath, - GIT_AUTHOR_NAME: "T3 Code", - GIT_AUTHOR_EMAIL: "codex@users.noreply.github.com", - GIT_COMMITTER_NAME: "T3 Code", - GIT_COMMITTER_EMAIL: "codex@users.noreply.github.com", - }; try { - await writeFile(tempIndexPath, ""); + const tempIndexPath = path.join(tempDir, `index-${randomUUID()}`); + const commitEnv: NodeJS.ProcessEnv = { + ...process.env, + GIT_INDEX_FILE: tempIndexPath, + GIT_AUTHOR_NAME: "T3 Code", + GIT_AUTHOR_EMAIL: "codex@users.noreply.github.com", + GIT_COMMITTER_NAME: "T3 Code", + GIT_COMMITTER_EMAIL: "codex@users.noreply.github.com", + }; const hasHead = await this.hasHeadCommit(cwd); if (hasHead) { @@ -133,7 +132,7 @@ export class FilesystemCheckpointStore { await this.runGit(cwd, ["restore", "--source", commitOid, "--worktree", "--staged", "--", "."]); await this.runGit(cwd, ["clean", "-fd", "--", "."]); - await this.runGit(cwd, ["reset", "--quiet", "--", "."], { allowNonZeroExit: true }); + await this.runGit(cwd, ["reset", "--quiet", "--", "."]); return true; } @@ -228,11 +227,20 @@ export class FilesystemCheckpointStore { return turn !== null && turn > maxTurnCount; }); - await Promise.all( - refsToDelete.map((refName) => - this.runGit(cwd, ["update-ref", "-d", refName], { allowNonZeroExit: true }), - ), + const batchSize = 32; + const batches = Array.from( + { length: Math.ceil(refsToDelete.length / batchSize) }, + (_, index) => refsToDelete.slice(index * batchSize, (index + 1) * batchSize), ); + await batches.reduce>((pending, batch) => { + return pending.then(async () => { + await Promise.all( + batch.map((refName) => + this.runGit(cwd, ["update-ref", "-d", refName], { allowNonZeroExit: true }), + ), + ); + }); + }, Promise.resolve()); } private async hasHeadCommit(cwd: string): Promise { @@ -288,6 +296,12 @@ export class FilesystemCheckpointStore { throw new Error(`${quoteGitCommand(args)} timed out.`); } + if (result.stdoutTruncated || result.stderrTruncated) { + throw new Error( + `${quoteGitCommand(args)} output exceeded ${DEFAULT_MAX_OUTPUT_BYTES} bytes and was truncated.`, + ); + } + if (!options.allowNonZeroExit && result.code !== 0) { const stderr = result.stderr.trim(); throw new Error( diff --git a/apps/server/src/providerManager.test.ts b/apps/server/src/providerManager.test.ts index 5cac86588f3af8faa0bc1f786b565e0d1d2ae248..e21b079e531b81d8a09b8f12cd63ee3adb724571 100644 --- a/apps/server/src/providerManager.test.ts +++ b/apps/server/src/providerManager.test.ts @@ -151,55 +151,44 @@ describe("ProviderManager", () => { it("rolls back from a selected checkpoint turn count", async () => { const manager = new ProviderManager(); - const codex = ( - manager as unknown as { - codex: { - hasSession: (sessionId: string) => boolean; - readThread: (sessionId: string) => Promise<{ - threadId: string; - turns: Array<{ id: string; items: unknown[] }>; - }>; - rollbackThread: ( - sessionId: string, - numTurns: number, - ) => Promise<{ - threadId: string; - turns: Array<{ id: string; items: unknown[] }>; - }>; - listSessions: () => Array<{ - sessionId: string; - provider: "codex"; - status: "ready"; - createdAt: string; - updatedAt: string; - cwd?: string; - }>; - }; - filesystemCheckpointStore: { - isGitRepository: (cwd: string) => Promise; - ensureRootCheckpoint: (input: { cwd: string; threadId: string }) => Promise; - }; - } - ).codex; const internals = manager as unknown as { + codex: { + hasSession: (sessionId: string) => boolean; + readThread: (sessionId: string) => Promise<{ + threadId: string; + turns: Array<{ id: string; items: unknown[] }>; + }>; + rollbackThread: ( + sessionId: string, + numTurns: number, + ) => Promise<{ + threadId: string; + turns: Array<{ id: string; items: unknown[] }>; + }>; + }; filesystemCheckpointStore: { - isGitRepository: (cwd: string) => Promise; - ensureRootCheckpoint: (input: { cwd: string; threadId: string }) => Promise; + hasCheckpoint: (input: { + cwd: string; + threadId: string; + turnCount: number; + }) => Promise; + restoreCheckpoint: (input: { + cwd: string; + threadId: string; + turnCount: number; + }) => Promise; + pruneAfterTurn: (input: { + cwd: string; + threadId: string; + maxTurnCount: number; + }) => Promise; }; + sessionCheckpointCwds: Map; }; const rollbackCalls: Array<{ sessionId: string; numTurns: number }> = []; - codex.hasSession = () => true; - codex.listSessions = () => [ - { - sessionId: "sess_1", - provider: "codex", - status: "ready", - createdAt: "2026-02-18T00:00:00.000Z", - updatedAt: "2026-02-18T00:00:00.000Z", - }, - ]; - codex.readThread = async () => ({ + internals.codex.hasSession = () => true; + internals.codex.readThread = async () => ({ threadId: "thr_1", turns: [ { id: "turn_1", items: [] }, @@ -207,15 +196,20 @@ describe("ProviderManager", () => { { id: "turn_3", items: [] }, ], }); - codex.rollbackThread = async (sessionId, numTurns) => { + internals.codex.rollbackThread = async (sessionId, numTurns) => { rollbackCalls.push({ sessionId, numTurns }); return { threadId: "thr_1", turns: [{ id: "turn_1", items: [] }], }; }; - internals.filesystemCheckpointStore.isGitRepository = async () => false; - internals.filesystemCheckpointStore.ensureRootCheckpoint = async () => true; + const hasCheckpoint = vi.fn(async () => true); + const restoreCheckpoint = vi.fn(async () => true); + const pruneAfterTurn = vi.fn(async () => undefined); + internals.filesystemCheckpointStore.hasCheckpoint = hasCheckpoint; + internals.filesystemCheckpointStore.restoreCheckpoint = restoreCheckpoint; + internals.filesystemCheckpointStore.pruneAfterTurn = pruneAfterTurn; + internals.sessionCheckpointCwds.set("sess_1", "/repo"); const result = await manager.revertToCheckpoint({ sessionId: "sess_1", @@ -228,6 +222,21 @@ describe("ProviderManager", () => { expect(result.messageCount).toBe(0); expect(result.rolledBackTurns).toBe(2); expect(result.checkpoints).toHaveLength(2); + expect(hasCheckpoint).toHaveBeenCalledWith({ + cwd: "/repo", + threadId: "thr_1", + turnCount: 1, + }); + expect(restoreCheckpoint).toHaveBeenCalledWith({ + cwd: "/repo", + threadId: "thr_1", + turnCount: 1, + }); + expect(pruneAfterTurn).toHaveBeenCalledWith({ + cwd: "/repo", + threadId: "thr_1", + maxTurnCount: 1, + }); manager.dispose(); }); @@ -471,6 +480,101 @@ describe("ProviderManager", () => { } }); + it("does not fall back to process cwd when a session cwd exists but is not a git repo", async () => { + const manager = new ProviderManager(); + const internals = manager as unknown as { + codex: { + hasSession: (sessionId: string) => boolean; + listSessions: () => Array<{ + sessionId: string; + provider: "codex"; + status: "ready"; + createdAt: string; + updatedAt: string; + cwd?: string; + }>; + }; + filesystemCheckpointStore: { + isGitRepository: (cwd: string) => Promise; + }; + sessionCheckpointCwds: Map; + }; + + const processCwd = vi.spyOn(process, "cwd").mockReturnValue("/fallback-repo"); + try { + internals.codex.hasSession = () => true; + internals.codex.listSessions = () => [ + { + sessionId: "sess_1", + provider: "codex", + status: "ready", + createdAt: "2026-02-18T00:00:00.000Z", + updatedAt: "2026-02-18T00:00:00.000Z", + cwd: "/session-cwd", + }, + ]; + const isGitRepository = vi.fn(async (cwd: string) => cwd === "/fallback-repo"); + internals.filesystemCheckpointStore.isGitRepository = isGitRepository; + internals.sessionCheckpointCwds.delete("sess_1"); + + await expect( + manager.getCheckpointDiff({ + sessionId: "sess_1", + fromTurnCount: 0, + toTurnCount: 1, + }), + ).rejects.toThrow("Filesystem checkpoints are unavailable for this session."); + expect(isGitRepository).toHaveBeenCalledWith("/session-cwd"); + expect(isGitRepository).not.toHaveBeenCalledWith("/fallback-repo"); + } finally { + processCwd.mockRestore(); + manager.dispose(); + } + }); + + it("fails revert when filesystem checkpoints are unavailable for the session", async () => { + const manager = new ProviderManager(); + const internals = manager as unknown as { + codex: { + hasSession: (sessionId: string) => boolean; + listSessions: () => Array<{ + sessionId: string; + provider: "codex"; + status: "ready"; + createdAt: string; + updatedAt: string; + cwd?: string; + }>; + }; + filesystemCheckpointStore: { + isGitRepository: (cwd: string) => Promise; + }; + sessionCheckpointCwds: Map; + }; + + internals.codex.hasSession = () => true; + internals.codex.listSessions = () => [ + { + sessionId: "sess_1", + provider: "codex", + status: "ready", + createdAt: "2026-02-18T00:00:00.000Z", + updatedAt: "2026-02-18T00:00:00.000Z", + }, + ]; + internals.filesystemCheckpointStore.isGitRepository = async () => false; + internals.sessionCheckpointCwds.delete("sess_1"); + + await expect( + manager.revertToCheckpoint({ + sessionId: "sess_1", + turnCount: 1, + }), + ).rejects.toThrow("Filesystem checkpoints are unavailable for this session."); + + manager.dispose(); + }); + it("restores filesystem checkpoint when reverting and checkpointing is enabled", async () => { const manager = new ProviderManager(); const internals = manager as unknown as { @@ -509,6 +613,7 @@ describe("ProviderManager", () => { }; internals.codex.hasSession = () => true; + const callOrder: string[] = []; internals.codex.readThread = async () => ({ threadId: "thr_1", turns: [ @@ -516,13 +621,22 @@ describe("ProviderManager", () => { { id: "turn_2", items: [] }, ], }); - internals.codex.rollbackThread = async () => ({ - threadId: "thr_1", - turns: [{ id: "turn_1", items: [] }], - }); + internals.codex.rollbackThread = async () => { + callOrder.push("rollback"); + return { + threadId: "thr_1", + turns: [{ id: "turn_1", items: [] }], + }; + }; const hasCheckpoint = vi.fn(async () => true); - const restoreCheckpoint = vi.fn(async () => true); - const pruneAfterTurn = vi.fn(async () => undefined); + const restoreCheckpoint = vi.fn(async () => { + callOrder.push("restore"); + return true; + }); + const pruneAfterTurn = vi.fn(async () => { + callOrder.push("prune"); + return undefined; + }); internals.filesystemCheckpointStore.hasCheckpoint = hasCheckpoint; internals.filesystemCheckpointStore.restoreCheckpoint = restoreCheckpoint; internals.filesystemCheckpointStore.pruneAfterTurn = pruneAfterTurn; @@ -549,6 +663,7 @@ describe("ProviderManager", () => { threadId: "thr_1", maxTurnCount: 1, }); + expect(callOrder).toEqual(["restore", "rollback", "prune"]); manager.dispose(); }); diff --git a/apps/server/src/providerManager.ts b/apps/server/src/providerManager.ts index dd20804227d28a6b82d128674c4bdf179101b7fa..0b2d11cc0e550a1b99f5432252424a8c0baf11d3 100644 --- a/apps/server/src/providerManager.ts +++ b/apps/server/src/providerManager.ts @@ -308,6 +308,9 @@ export class ProviderManager extends EventEmitter { } const checkpointCwd = await this.getOrInitializeFilesystemCheckpointCwd(input.sessionId); + if (!checkpointCwd) { + throw new Error("Filesystem checkpoints are unavailable for this session."); + } return this.withFilesystemLock(input.sessionId, async () => { const beforeSnapshot = await this.codex.readThread(input.sessionId); const currentTurnCount = beforeSnapshot.turns.length; @@ -330,29 +333,28 @@ export class ProviderManager extends EventEmitter { } } + const restored = await this.filesystemCheckpointStore.restoreCheckpoint({ + cwd: checkpointCwd, + threadId: beforeSnapshot.threadId, + turnCount: input.turnCount, + }); + if (!restored) { + throw new Error( + `Filesystem checkpoint is unavailable for turn ${input.turnCount} in thread ${beforeSnapshot.threadId}.`, + ); + } + const requestedRollbackTurns = currentTurnCount - input.turnCount; const afterSnapshot = requestedRollbackTurns > 0 ? await this.codex.rollbackThread(input.sessionId, requestedRollbackTurns) : beforeSnapshot; - if (checkpointCwd) { - const restored = await this.filesystemCheckpointStore.restoreCheckpoint({ - cwd: checkpointCwd, - threadId: beforeSnapshot.threadId, - turnCount: input.turnCount, - }); - if (!restored) { - throw new Error( - `Filesystem checkpoint is unavailable for turn ${input.turnCount} in thread ${beforeSnapshot.threadId}.`, - ); - } - await this.filesystemCheckpointStore.pruneAfterTurn({ - cwd: checkpointCwd, - threadId: afterSnapshot.threadId, - maxTurnCount: afterSnapshot.turns.length, - }); - } + await this.filesystemCheckpointStore.pruneAfterTurn({ + cwd: checkpointCwd, + threadId: afterSnapshot.threadId, + maxTurnCount: afterSnapshot.turns.length, + }); const checkpoints = buildCheckpoints(afterSnapshot.turns); const currentCheckpoint = @@ -415,7 +417,9 @@ export class ProviderManager extends EventEmitter { threadId: snapshot.threadId, turnCount: snapshot.turns.length, }); - this.sessionCheckpointCwds.set(session.sessionId, cwd); + if (this.codex.hasSession(session.sessionId)) { + this.sessionCheckpointCwds.set(session.sessionId, cwd); + } }); } @@ -428,9 +432,7 @@ export class ProviderManager extends EventEmitter { const session = this.codex .listSessions() .find((candidate) => candidate.sessionId === sessionId); - const candidateCwds = Array.from( - new Set([session?.cwd, process.cwd()].filter((value): value is string => Boolean(value))), - ); + const candidateCwds = session?.cwd ? [session.cwd] : [process.cwd()]; if (candidateCwds.length === 0) { return null; } @@ -459,7 +461,9 @@ export class ProviderManager extends EventEmitter { threadId: snapshot.threadId, turnCount: snapshot.turns.length, }); - this.sessionCheckpointCwds.set(sessionId, supportedCwd); + if (this.codex.hasSession(sessionId)) { + this.sessionCheckpointCwds.set(sessionId, supportedCwd); + } return; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f01f226cc28629ec9bb18c893df7ba644e3c58b3..232c07d97658a0dd6b8188d0564ba2de0c8a4275 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -53,9 +53,7 @@ import { countDiffStat, derivePendingApprovals, derivePhase, - deriveTurnDiffSummaries, deriveTimelineEntries, - inferCheckpointTurnCountByTurnId, type TurnDiffSummary, type PendingApproval, deriveWorkLogEntries, @@ -72,6 +70,7 @@ import { } from "../types"; import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons"; import { useTheme } from "../hooks/useTheme"; +import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import BranchToolbar from "./BranchToolbar"; import GitActionsControl from "./GitActionsControl"; import { @@ -414,13 +413,8 @@ export default function ChatView() { () => deriveTimelineEntries(activeThread?.messages ?? [], workLogEntries), [activeThread?.messages, workLogEntries], ); - const turnDiffSummaries = useMemo( - () => - activeThread?.turnDiffSummaries.length - ? activeThread.turnDiffSummaries - : deriveTurnDiffSummaries(activeThread?.events ?? []), - [activeThread?.events, activeThread?.turnDiffSummaries], - ); + const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = + useTurnDiffSummaries(activeThread); const turnDiffSummaryByAssistantMessageId = useMemo(() => { const byMessageId = new Map(); for (const summary of turnDiffSummaries) { @@ -429,11 +423,6 @@ export default function ChatView() { } return byMessageId; }, [turnDiffSummaries]); - const inferredCheckpointTurnCountByTurnId = useMemo( - () => inferCheckpointTurnCountByTurnId(turnDiffSummaries), - [turnDiffSummaries], - ); - const revertTurnCountByUserMessageId = useMemo(() => { const byUserMessageId = new Map(); for (let index = 0; index < timelineEntries.length; index += 1) { @@ -1487,8 +1476,10 @@ export default function ChatView() { const selectedDiffTurnId = state.diffOpen && state.diffThreadId === activeThreadId ? state.diffTurnId : null; const selectedDiffTurn = - turnDiffSummaries.find((summary) => summary.turnId === selectedDiffTurnId) ?? - turnDiffSummaries[0]; + selectedDiffTurnId === null + ? undefined + : (turnDiffSummaries.find((summary) => summary.turnId === selectedDiffTurnId) ?? + turnDiffSummaries[0]); const selectedTurnMissingPatchBody = Boolean( selectedDiffTurn && !selectedDiffTurn.unifiedDiff && @@ -1505,9 +1496,14 @@ export default function ChatView() { } checkpointHydrationSessionRequestRef.current.add(requestKey); setThreadError(activeThreadId, null); - void ensureSession().finally(() => { - checkpointHydrationSessionRequestRef.current.delete(requestKey); - }); + void ensureSession().then( + () => { + checkpointHydrationSessionRequestRef.current.delete(requestKey); + }, + () => { + // Keep the key on failure so this effect does not re-enter in a tight loop. + }, + ); }, [ activeThreadId, activeThreadRuntimeId, diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index c6ad5eabc4cab01e611ff54dc9887bd2e9171e0f..2d030d21fc093af2ff5ddc0d2ffbd0f48a4db921 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -8,11 +8,8 @@ import { cn } from "~/lib/utils"; import { isElectron } from "../env"; import { useNativeApi } from "../hooks/useNativeApi"; import { useTheme } from "../hooks/useTheme"; -import { - deriveTurnDiffSummaries, - formatTimestamp, - inferCheckpointTurnCountByTurnId, -} from "../session-logic"; +import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; +import { formatTimestamp } from "../session-logic"; import { useStore } from "../store"; import { ToggleGroup, Toggle } from "./ui/toggle-group"; @@ -78,17 +75,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const activeThreadRuntimeId = activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null; const activeSessionId = activeThread?.session?.sessionId ?? null; - const turnDiffSummaries = useMemo( - () => - activeThread?.turnDiffSummaries.length - ? activeThread.turnDiffSummaries - : deriveTurnDiffSummaries(activeThread?.events ?? []), - [activeThread?.events, activeThread?.turnDiffSummaries], - ); - const inferredCheckpointTurnCountByTurnId = useMemo( - () => inferCheckpointTurnCountByTurnId(turnDiffSummaries), - [turnDiffSummaries], - ); + const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = + useTurnDiffSummaries(activeThread); const canApplyStoredTarget = Boolean(activeThread && state.diffThreadId === activeThread.id); const selectedTurnId = canApplyStoredTarget ? state.diffTurnId : null; @@ -358,7 +346,12 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { variant="outline" size="xs" value={[diffRenderMode]} - onValueChange={(value) => setDiffRenderMode(value[0] as DiffRenderMode)} + onValueChange={(value) => { + const next = value[0]; + if (next === "stacked" || next === "split") { + setDiffRenderMode(next); + } + }} > diff --git a/apps/web/src/components/ui/toggle-group.tsx b/apps/web/src/components/ui/toggle-group.tsx index d633c0010fd6e48ad6ffeb3274bb017fab3d7323..4124dc6ed95ec8a7ada5cdd27fb639981d127d04 100644 --- a/apps/web/src/components/ui/toggle-group.tsx +++ b/apps/web/src/components/ui/toggle-group.tsx @@ -58,8 +58,8 @@ function Toggle({ }: TogglePrimitive.Props & VariantProps) { const context = React.useContext(ToggleGroupContext); - const resolvedVariant = context.variant || variant; - const resolvedSize = context.size || size; + const resolvedVariant = variant ?? context.variant; + const resolvedSize = size ?? context.size; return ( (); + +function deriveTurnDiffSummariesCached(events: ProviderEvent[]): TurnDiffSummary[] { + const cached = derivedTurnDiffSummaryCache.get(events); + if (cached) { + return cached; + } + const derived = deriveTurnDiffSummaries(events); + derivedTurnDiffSummaryCache.set(events, derived); + return derived; +} + +export function useTurnDiffSummaries(activeThread: Thread | undefined) { + const turnDiffSummaries = useMemo(() => { + if (!activeThread) { + return []; + } + if (activeThread.turnDiffSummaries.length > 0) { + return activeThread.turnDiffSummaries; + } + return deriveTurnDiffSummariesCached(activeThread.events); + }, [activeThread]); + + const inferredCheckpointTurnCountByTurnId = useMemo( + () => inferCheckpointTurnCountByTurnId(turnDiffSummaries), + [turnDiffSummaries], + ); + + return { turnDiffSummaries, inferredCheckpointTurnCountByTurnId }; +} diff --git a/apps/web/src/lib/providerReactQuery.ts b/apps/web/src/lib/providerReactQuery.ts index 1dc837b7bcf4d34a66ad8704df9cf1b1d627d72b..0ccf7ee6030754076c4fb46a1e02ac846eff9221 100644 --- a/apps/web/src/lib/providerReactQuery.ts +++ b/apps/web/src/lib/providerReactQuery.ts @@ -28,7 +28,13 @@ export function checkpointDiffQueryOptions( input: CheckpointDiffQueryInput, ) { const hasValidRange = - typeof input.fromTurnCount === "number" && typeof input.toTurnCount === "number"; + typeof input.fromTurnCount === "number" && + typeof input.toTurnCount === "number" && + Number.isInteger(input.fromTurnCount) && + Number.isInteger(input.toTurnCount) && + input.fromTurnCount >= 0 && + input.toTurnCount >= 0 && + input.fromTurnCount <= input.toTurnCount; return queryOptions({ queryKey: providerQueryKeys.checkpointDiff(input), diff --git a/apps/web/src/persistenceSchema.test.ts b/apps/web/src/persistenceSchema.test.ts index 6d98047185e8820ca3e36cf9855e7deb56508f16..385f0e700a8c532beb0c28c7ecb657a9b8fbd59c 100644 --- a/apps/web/src/persistenceSchema.test.ts +++ b/apps/web/src/persistenceSchema.test.ts @@ -325,6 +325,46 @@ describe("hydratePersistedState", () => { }, ]); }); + + it("drops malformed persisted turn diff summaries instead of rejecting the whole snapshot", () => { + const payload = JSON.stringify({ + version: 7, + runtimeMode: "full-access", + projects: [ + { + id: "p-1", + name: "Project", + cwd: "/tmp/project", + model: "gpt-5.3-codex", + expanded: true, + }, + ], + threads: [ + { + id: "t-1", + codexThreadId: "thr_1", + projectId: "p-1", + title: "Thread", + model: "gpt-5.3-codex", + messages: [], + createdAt: "2026-02-08T10:00:00.000Z", + turnDiffSummaries: [ + { + turnId: "turn-1", + completedAt: "2026-02-08T10:05:00.000Z", + files: [{ path: 123 }], + }, + ], + }, + ], + activeThreadId: "t-1", + }); + + const hydrated = hydratePersistedState(payload, false); + expect(hydrated).not.toBeNull(); + expect(hydrated?.threads[0]?.id).toBe("t-1"); + expect(hydrated?.threads[0]?.turnDiffSummaries).toEqual([]); + }); }); describe("toPersistedState", () => { diff --git a/apps/web/src/persistenceSchema.ts b/apps/web/src/persistenceSchema.ts index 1daedcb6b6e6b4cebf772cdd0ef9f6607e563274..afc083839bf9ecc61722f363ca75092fdfa58b85 100644 --- a/apps/web/src/persistenceSchema.ts +++ b/apps/web/src/persistenceSchema.ts @@ -84,7 +84,7 @@ const persistedThreadSchema = z.object({ lastVisitedAt: z.string().min(1).optional(), branch: z.string().min(1).nullable().optional(), worktreePath: z.string().min(1).nullable().optional(), - turnDiffSummaries: z.array(persistedTurnDiffSummarySchema).default([]), + turnDiffSummaries: z.array(persistedTurnDiffSummarySchema).catch([]).default([]), }); const persistedStateBodySchema = z.object({ diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index eec1b38087a5f8afa124b139b1b34465bca188ed..28768cc79a5173f9d354573ebd2b1aa75a4b9e3c 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -493,7 +493,11 @@ describe("deriveTurnDiffSummaries", () => { expect(summaries).toHaveLength(1); expect(summaries[0]?.turnId).toBe("turn-1"); expect(summaries[0]?.assistantMessageId).toBe("msg-1"); - expect(summaries[0]?.files).toEqual([]); + expect(summaries[0]?.unifiedDiff).toContain("diff --git a/src/a.ts b/src/a.ts"); + expect(summaries[0]?.files.map((file) => file.path)).toEqual(["src/a.ts", "src/b.ts"]); + expect(summaries[0]?.files[0]?.kind).toBe("modified"); + expect(summaries[0]?.files[0]?.additions).toBe(1); + expect(summaries[0]?.files[0]?.deletions).toBe(1); }); it("includes completed turns even when no fileChange tool events were emitted", () => { @@ -522,6 +526,55 @@ describe("deriveTurnDiffSummaries", () => { expect(summaries[0]?.turnId).toBe("turn-1"); expect(summaries[0]?.files).toEqual([]); }); + + it("keeps newest diff data when older events provide stale values", () => { + const summaries = deriveTurnDiffSummaries([ + makeEvent({ + id: "evt-turn-diff-old", + method: "turn/diff/updated", + turnId: "turn-1", + createdAt: "2026-02-08T10:00:01.000Z", + payload: { + diff: [ + "diff --git a/src/example.ts b/src/example.ts", + "@@ -1 +1 @@", + "-old", + "+older", + ].join("\n"), + }, + }), + makeEvent({ + id: "evt-turn-diff-new", + method: "turn/diff/updated", + turnId: "turn-1", + createdAt: "2026-02-08T10:00:02.000Z", + payload: { + diff: [ + "diff --git a/src/example.ts b/src/example.ts", + "@@ -1 +1 @@", + "-old", + "+newest", + ].join("\n"), + }, + }), + makeEvent({ + id: "evt-turn-completed", + method: "turn/completed", + turnId: "turn-1", + createdAt: "2026-02-08T10:00:03.000Z", + payload: { + turn: { + id: "turn-1", + status: "completed", + }, + }, + }), + ]); + + expect(summaries).toHaveLength(1); + expect(summaries[0]?.unifiedDiff).toContain("+newest"); + expect(summaries[0]?.unifiedDiff).not.toContain("+older"); + }); }); describe("deriveTurnDiffFilesFromUnifiedDiff", () => { @@ -545,6 +598,22 @@ describe("deriveTurnDiffFilesFromUnifiedDiff", () => { expect(files[0]?.additions).toBe(1); expect(files[0]?.deletions).toBe(1); }); + + it("parses git headers when file paths include ` b/`", () => { + const files = deriveTurnDiffFilesFromUnifiedDiff( + [ + "diff --git a/subdir b/example.ts b/subdir b/example.ts", + "--- a/subdir b/example.ts", + "+++ b/subdir b/example.ts", + "@@ -1 +1 @@", + "-old", + "+new", + ].join("\n"), + ); + + expect(files).toHaveLength(1); + expect(files[0]?.path).toBe("subdir b/example.ts"); + }); }); describe("derivePendingApprovals", () => { diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index f9f3013ca85fc36eff4fdf3d649f3573ae167b0f..224d5f12919f7669f98d9ae75aa7bc846c0d71de 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -33,10 +33,18 @@ export function asObject(value: unknown): Record | undefined { return value as Record; } +function asArray(value: unknown): unknown[] | undefined { + return Array.isArray(value) ? value : undefined; +} + export function asString(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } +function asNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + export function formatTimestamp(isoDate: string): string { return new Intl.DateTimeFormat(undefined, { hour: "numeric", @@ -445,14 +453,77 @@ interface MutableTurnDiffSummary { completedAt: string | undefined; status: string | undefined; assistantMessageId: string | undefined; + unifiedDiff: string | undefined; + filesByPath: Map; +} + +function parseFileChangeEntriesFromEvent(event: ProviderEvent): TurnDiffFileChange[] { + if (event.method !== "item/completed") { + return []; + } + + const item = asObject(asObject(event.payload)?.item); + const itemType = normalizeItemType(asString(item?.type)); + if (itemType !== "file change") { + return []; + } + + const changes = asArray(item?.changes) ?? []; + const parsed: TurnDiffFileChange[] = []; + for (const rawChange of changes) { + const change = asObject(rawChange); + if (!change) continue; + + const diff = normalizeDetail(asString(change.diff)); + const path = normalizeDetail(asString(change.path)) ?? (diff ? parsePathFromDiff(diff) : undefined); + if (!path) continue; + + const additions = asNumber(change.additions); + const deletions = asNumber(change.deletions); + const stat = additions !== undefined && deletions !== undefined ? null : diff ? countDiffStat(diff) : null; + const kind = normalizeDetail(asString(change.kind)); + parsed.push({ + path, + ...(kind ? { kind } : {}), + ...(diff ? { diff } : {}), + ...(additions !== undefined ? { additions } : stat ? { additions: stat.additions } : {}), + ...(deletions !== undefined ? { deletions } : stat ? { deletions: stat.deletions } : {}), + }); + } + + return parsed; +} + +function mergeTurnDiffFileChange( + byPath: Map, + incoming: TurnDiffFileChange, +): void { + const existing = byPath.get(incoming.path); + if (!existing) { + byPath.set(incoming.path, incoming); + return; + } + + if (existing.kind === undefined && incoming.kind !== undefined) { + existing.kind = incoming.kind; + } + if (existing.diff === undefined && incoming.diff !== undefined) { + existing.diff = incoming.diff; + } + if (existing.additions === undefined && incoming.additions !== undefined) { + existing.additions = incoming.additions; + } + if (existing.deletions === undefined && incoming.deletions !== undefined) { + existing.deletions = incoming.deletions; + } } function parsePathFromDiff(diff: string): string | undefined { const normalized = diff.replace(/\r\n/g, "\n"); - const gitHeader = normalized.match(/^diff --git a\/(.+) b\/(.+)$/m); - if (gitHeader && gitHeader[2]) return gitHeader[2]; const bPath = normalized.match(/^\+\+\+ b\/(.+)$/m); if (bPath && bPath[1]) return bPath[1]; + const gitHeader = normalized.match(/^diff --git a\/(.+) b\/(.+)$/m); + if (gitHeader && gitHeader[2]) return gitHeader[2]; const direct = normalized.match(/^\+\+\+ (.+)$/m); if (!direct || !direct[1] || direct[1] === "/dev/null") { return undefined; @@ -463,7 +534,7 @@ function parsePathFromDiff(diff: string): string | undefined { export function splitUnifiedDiffByFile(diff: string): Map { const normalized = diff.replace(/\r\n/g, "\n"); const byPath = new Map(); - const headerMatches = [...normalized.matchAll(/^diff --git a\/(.+) b\/(.+)$/gm)]; + const headerMatches = [...normalized.matchAll(/^diff --git .+$/gm)]; if (headerMatches.length === 0) { const path = parsePathFromDiff(normalized); @@ -479,7 +550,7 @@ export function splitUnifiedDiffByFile(diff: string): Map { const start = match.index ?? 0; const nextStart = headerMatches[index + 1]?.index ?? normalized.length; const segment = normalized.slice(start, nextStart).trim(); - const path = match[2] ?? match[1] ?? parsePathFromDiff(segment); + const path = parsePathFromDiff(segment); if (!path || segment.length === 0) continue; byPath.set(path, segment); } @@ -555,7 +626,15 @@ export function countDiffStat(patch: string | undefined): { additions: number; d } export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummary[] { - const ordered = [...events].toReversed(); + const firstEventAt = Date.parse(events[0]?.createdAt ?? ""); + const lastEventAt = Date.parse(events.at(-1)?.createdAt ?? ""); + const ordered = + events.length < 2 || + Number.isNaN(firstEventAt) || + Number.isNaN(lastEventAt) || + firstEventAt >= lastEventAt + ? events + : [...events].toReversed(); const byTurnId = new Map(); const ensureSummary = (turnId: string): MutableTurnDiffSummary => { @@ -566,6 +645,8 @@ export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummar completedAt: undefined, status: undefined, assistantMessageId: undefined, + unifiedDiff: undefined, + filesByPath: new Map(), }; byTurnId.set(turnId, next); return next; @@ -577,21 +658,38 @@ export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummar const summary = ensureSummary(turnId); if (event.method === "turn/completed") { - summary.completedAt = event.createdAt; + if (summary.completedAt === undefined) { + summary.completedAt = event.createdAt; + } const turn = asObject(asObject(event.payload)?.turn); - summary.status = normalizeDetail(asString(turn?.status)); - continue; + if (summary.status === undefined) { + summary.status = normalizeDetail(asString(turn?.status)); + } } if (event.method === "item/completed") { const item = asObject(asObject(event.payload)?.item); if (asString(item?.type) === "agentMessage") { const itemId = normalizeDetail(asString(item?.id)); - if (itemId) { + if (itemId && summary.assistantMessageId === undefined) { summary.assistantMessageId = itemId; } } } + + if (event.method === "turn/diff/updated" && summary.unifiedDiff === undefined) { + const diff = normalizeDetail(asString(asObject(event.payload)?.diff)); + if (diff) { + summary.unifiedDiff = diff; + for (const file of deriveTurnDiffFilesFromUnifiedDiff(diff)) { + mergeTurnDiffFileChange(summary.filesByPath, file); + } + } + } + + for (const file of parseFileChangeEntriesFromEvent(event)) { + mergeTurnDiffFileChange(summary.filesByPath, file); + } } const summaries: TurnDiffSummary[] = []; @@ -602,7 +700,10 @@ export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummar turnId: summary.turnId, completedAt: summary.completedAt, ...(summary.status ? { status: summary.status } : {}), - files: [], + files: Array.from(summary.filesByPath.values()).toSorted((a, b) => + a.path.localeCompare(b.path), + ), + ...(summary.unifiedDiff ? { unifiedDiff: summary.unifiedDiff } : {}), ...(summary.assistantMessageId ? { assistantMessageId: summary.assistantMessageId } : {}), }); } diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 30a03daf307b710599943a6367a5a8ba23f59444..625934f04e2fce8d9e9897f60f86ed27364a3ca7 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -569,6 +569,23 @@ describe("store reducer thread continuity", () => { ]); }); + it("infers checkpoint turn counts when deriving turn summaries from an empty baseline", () => { + const state = makeState(makeThread({ turnDiffSummaries: [] })); + const next = reducer(state, { + type: "APPLY_EVENT", + event: makeEvent({ + method: "turn/completed", + turnId: "turn-1", + createdAt: "2026-02-09T00:00:03.000Z", + payload: { turn: { id: "turn-1", status: "completed" } }, + }), + activeAssistantItemRef: { current: null }, + }); + + expect(next.threads[0]?.turnDiffSummaries[0]?.turnId).toBe("turn-1"); + expect(next.threads[0]?.turnDiffSummaries[0]?.checkpointTurnCount).toBe(1); + }); + it("reconciles project ids by cwd when syncing backend projects", () => { const state: AppState = { projects: [ diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index f1b0fadb7ce9092d12f0bf5d492fdbfb555efa36..06f3e10523a2621d082701d6dcc7fcacd2f5b99e 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -202,7 +202,6 @@ function mergeTurnDiffSummaries( existing: Thread["turnDiffSummaries"], next: Thread["turnDiffSummaries"], ): Thread["turnDiffSummaries"] { - if (existing.length === 0) return next; if (next.length === 0) return existing; const existingByTurnId = new Map(existing.map((summary) => [summary.turnId, summary] as const)); @@ -893,11 +892,16 @@ export function reducer(state: AppState, action: Action): AppState { ...state, threads: updateThread(state.threads, target.id, (t) => { const nextEvents = [event, ...t.events]; - const derivedTurnDiffSummaries = deriveTurnDiffSummaries(nextEvents); - const turnDiffSummaries = mergeTurnDiffSummaries( - t.turnDiffSummaries, - derivedTurnDiffSummaries, - ); + const itemType = asString(asObject(asObject(event.payload)?.item)?.type); + const normalizedItemType = itemType?.replace(/[_-]/g, "").toLowerCase(); + const shouldRederiveDiffs = + event.method === "turn/completed" || + event.method === "turn/diff/updated" || + (event.method === "item/completed" && + (normalizedItemType === "agentmessage" || normalizedItemType === "filechange")); + const turnDiffSummaries = shouldRederiveDiffs + ? mergeTurnDiffSummaries(t.turnDiffSummaries, deriveTurnDiffSummaries(nextEvents)) + : t.turnDiffSummaries; const eventThreadId = getEventThreadId(event); const shouldRebindIdentity = event.method === "thread/started" && t.session?.status === "connecting";