diff --git a/apps/server/src/providerManager.test.ts b/apps/server/src/providerManager.test.ts index e21b079e531b81d8a09b8f12cd63ee3adb724571..c26f34cb6ae55ba5c536dba0a86bd866971b57da 100644 --- a/apps/server/src/providerManager.test.ts +++ b/apps/server/src/providerManager.test.ts @@ -296,6 +296,82 @@ describe("ProviderManager", () => { manager.dispose(); }); + it("emits checkpoint/captured after turn completion checkpoint capture", async () => { + const manager = new ProviderManager(); + const events: Array<{ method: string; payload?: unknown; threadId?: string }> = []; + manager.on("event", (event) => { + events.push({ + method: event.method, + ...(event.payload !== undefined ? { payload: event.payload } : {}), + ...(event.threadId ? { threadId: event.threadId } : {}), + }); + }); + + const internals = manager as unknown as { + onCodexEvent: (event: { + id: string; + kind: "notification"; + provider: "codex"; + sessionId: string; + createdAt: string; + method: string; + threadId: string; + }) => void; + codex: { + hasSession: (sessionId: string) => boolean; + readThread: (sessionId: string) => Promise<{ + threadId: string; + turns: Array<{ id: string; items: unknown[] }>; + }>; + }; + filesystemCheckpointStore: { + captureCheckpoint: (input: { + cwd: string; + threadId: string; + turnCount: number; + }) => Promise; + }; + sessionCheckpointCwds: Map; + }; + + internals.codex.hasSession = () => true; + internals.codex.readThread = async () => ({ + threadId: "thr_1", + turns: [{ id: "turn_1", items: [] }], + }); + const captureCheckpoint = vi.fn(async () => undefined); + internals.filesystemCheckpointStore.captureCheckpoint = captureCheckpoint; + internals.sessionCheckpointCwds.set("sess_1", "/repo"); + + internals.onCodexEvent({ + id: "evt-turn-complete", + kind: "notification", + provider: "codex", + sessionId: "sess_1", + createdAt: "2026-02-19T00:00:00.000Z", + method: "turn/completed", + threadId: "thr_1", + }); + + await vi.waitFor(() => { + expect(captureCheckpoint).toHaveBeenCalledWith({ + cwd: "/repo", + threadId: "thr_1", + turnCount: 1, + }); + expect(events.some((event) => event.method === "checkpoint/captured")).toBe(true); + }); + + const checkpointCapturedEvent = events.find((event) => event.method === "checkpoint/captured"); + expect(checkpointCapturedEvent?.threadId).toBe("thr_1"); + expect(checkpointCapturedEvent?.payload).toEqual({ + threadId: "thr_1", + turnCount: 1, + }); + + manager.dispose(); + }); + it("lazily initializes filesystem checkpoints before diffing when cache is missing", async () => { const manager = new ProviderManager(); const internals = manager as unknown as { diff --git a/apps/server/src/providerManager.ts b/apps/server/src/providerManager.ts index 532f32f7fa9db1e9d9bd5676da10a83014ad5dc2..d8442e6c7dee4aaf52d907eec6f69e730677fec5 100644 --- a/apps/server/src/providerManager.ts +++ b/apps/server/src/providerManager.ts @@ -477,11 +477,19 @@ export class ProviderManager extends EventEmitter { const checkpointCwd = this.sessionCheckpointCwds.get(event.sessionId); if (!checkpointCwd) { - void this.getOrInitializeFilesystemCheckpointCwd(event.sessionId).catch((error) => { - const message = - error instanceof Error ? error.message : "Failed to initialize filesystem checkpoints."; - this.emitFilesystemCheckpointError(event.sessionId, message, event.threadId); - }); + void this.getOrInitializeFilesystemCheckpointCwd(event.sessionId) + .then(async (initializedCwd) => { + if (!initializedCwd || !this.codex.hasSession(event.sessionId)) { + return; + } + const snapshot = await this.codex.readThread(event.sessionId); + this.emitCheckpointCaptured(event.sessionId, snapshot.threadId, snapshot.turns.length); + }) + .catch((error) => { + const message = + error instanceof Error ? error.message : "Failed to initialize filesystem checkpoints."; + this.emitFilesystemCheckpointError(event.sessionId, message, event.threadId); + }); return; } @@ -496,12 +504,29 @@ export class ProviderManager extends EventEmitter { threadId: snapshot.threadId, turnCount: snapshot.turns.length, }); + this.emitCheckpointCaptured(event.sessionId, snapshot.threadId, snapshot.turns.length); }).catch((error) => { const message = error instanceof Error ? error.message : "Failed to capture checkpoint."; this.emitFilesystemCheckpointError(event.sessionId, message, event.threadId); }); } + private emitCheckpointCaptured(sessionId: string, threadId: string, turnCount: number): void { + this.emit("event", { + id: randomUUID(), + kind: "notification", + provider: "codex", + sessionId, + createdAt: new Date().toISOString(), + method: "checkpoint/captured", + threadId, + payload: { + threadId, + turnCount, + }, + }); + } + private emitFilesystemCheckpointError( sessionId: string, message: string, diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 57c6085349c3baaee549952af44f85a70ff233c6..866da47ebff5a5b5b8adcbf39826af0e8d41d425 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -6,6 +6,7 @@ import { AnchoredToastProvider, ToastProvider } from "../components/ui/toast"; import { isElectron } from "../env"; import { useNativeApi } from "../hooks/useNativeApi"; import { invalidateGitQueries } from "../lib/gitReactQuery"; +import { providerQueryKeys } from "../lib/providerReactQuery"; import { DEFAULT_MODEL } from "../model-logic"; import { useStore } from "../store"; import { onServerWelcome } from "../wsNativeApi"; @@ -57,6 +58,9 @@ function EventRouter() { if (event.method === "turn/completed") { void invalidateGitQueries(queryClient); } + if (event.method === "checkpoint/captured") { + void queryClient.invalidateQueries({ queryKey: providerQueryKeys.all }); + } if (!activeThreadId) return; dispatch({ type: "APPLY_EVENT", diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index d07219639ae3f2a3007d94f2a9ebb7bc195ce12c..24fe2b73d831f244552974d616f2a9281b700a16 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -891,13 +891,7 @@ export function reducer(state: AppState, action: Action): AppState { ...state, threads: updateThread(state.threads, target.id, (t) => { const nextEvents = [event, ...t.events]; - 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 shouldRederiveDiffs = event.method === "turn/completed"; const turnDiffSummaries = shouldRederiveDiffs ? mergeTurnDiffSummaries(t.turnDiffSummaries, deriveTurnDiffSummaries(nextEvents)) : t.turnDiffSummaries;