From a80ffd043168fa2e5c20323f59ffffc80d94f4ee Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 18 Aug 2026 02:09:11 -0700 Subject: [PATCH] fix(sessions): propagate compaction transcript read errors (#125741) --- .../sessions/session-accessor.transcript.ts | 2 +- .../server-methods/sessions-compact.ts | 4 +- ...er.sessions.compaction-read-errors.test.ts | 179 ++++++++++++++++++ 3 files changed, 182 insertions(+), 3 deletions(-) create mode 100644 src/gateway/server.sessions.compaction-read-errors.test.ts diff --git a/src/config/sessions/session-accessor.transcript.ts b/src/config/sessions/session-accessor.transcript.ts index 12ba01c7c748..a431592431ff 100644 --- a/src/config/sessions/session-accessor.transcript.ts +++ b/src/config/sessions/session-accessor.transcript.ts @@ -74,7 +74,7 @@ export async function preflightSessionTranscriptForManualCompact( scope: SessionTranscriptRuntimeScope, params: { maxLines: number; sessionFile?: string }, ): Promise { - const events = await loadTranscriptEvents(scope).catch(() => []); + const events = await loadTranscriptEvents(scope); if (events.length === 0) { return { compacted: false, reason: "no transcript" }; } diff --git a/src/gateway/server-methods/sessions-compact.ts b/src/gateway/server-methods/sessions-compact.ts index f38709dd7dc9..e4f0547082cc 100644 --- a/src/gateway/server-methods/sessions-compact.ts +++ b/src/gateway/server-methods/sessions-compact.ts @@ -154,7 +154,7 @@ export const sessionCompactHandlers: GatewayRequestHandlers = { sessionId, sessionKey: compactTarget.primaryKey, storePath, - }).catch(() => []); + }); if (transcriptEvents.length === 0) { respond( true, @@ -355,7 +355,7 @@ export const sessionCompactHandlers: GatewayRequestHandlers = { sessionId, sessionKey: compactTarget.primaryKey, storePath, - }).catch(() => []); + }); if (transcriptEvents.length === 0) { respond( true, diff --git a/src/gateway/server.sessions.compaction-read-errors.test.ts b/src/gateway/server.sessions.compaction-read-errors.test.ts new file mode 100644 index 000000000000..4521dcb35bff --- /dev/null +++ b/src/gateway/server.sessions.compaction-read-errors.test.ts @@ -0,0 +1,179 @@ +import { beforeEach, expect, test, vi } from "vitest"; +import { + appendTranscriptEvent, + appendTranscriptMessage, + upsertSessionEntryCore, +} from "../config/sessions/session-accessor.js"; +import { rpcReq } from "./test-helpers.js"; +import { + sessionStoreEntry, + setupGatewaySessionsTestHarness, +} from "./test/server-sessions.test-helpers.js"; + +type LoadTranscriptEvents = + (typeof import("../config/sessions/session-accessor.sqlite-read.js"))["loadTranscriptEvents"]; + +const transcriptReads = vi.hoisted(() => ({ + actual: undefined as LoadTranscriptEvents | undefined, + load: vi.fn(), +})); + +vi.mock("../config/sessions/session-accessor.sqlite-read.js", async (importOriginal) => { + const actual = + await importOriginal(); + transcriptReads.actual = actual.loadTranscriptEvents; + transcriptReads.load.mockImplementation(actual.loadTranscriptEvents); + return { ...actual, loadTranscriptEvents: transcriptReads.load }; +}); + +const { createSessionStoreDir, openClient } = setupGatewaySessionsTestHarness(); + +function requireTranscriptReader(): LoadTranscriptEvents { + if (!transcriptReads.actual) { + throw new Error("transcript reader mock was not initialized"); + } + return transcriptReads.actual; +} + +beforeEach(() => { + transcriptReads.load.mockReset(); + transcriptReads.load.mockImplementation(requireTranscriptReader()); +}); + +async function seedCompactionSession(params: { + sessionId: string; + storePath: string; + nativeHarness?: boolean; + withTranscript?: boolean; +}) { + const scope = { + agentId: "main", + sessionId: params.sessionId, + sessionKey: "agent:main:main", + storePath: params.storePath, + }; + await upsertSessionEntryCore( + scope, + sessionStoreEntry( + params.sessionId, + params.nativeHarness + ? { + agentHarnessId: "codex", + cliSessionBindings: { "codex-cli": { sessionId: "thread-1" } }, + cliSessionIds: { "codex-cli": "thread-1" }, + modelSelectionLocked: true, + } + : {}, + ), + ); + if (params.withTranscript === false) { + return scope; + } + await appendTranscriptEvent(scope, { + type: "session", + version: 3, + id: params.sessionId, + timestamp: "2026-08-18T12:00:00.000Z", + cwd: "/tmp", + }); + await appendTranscriptMessage(scope, { + message: { role: "user", content: "compact me", timestamp: 1 }, + now: Date.parse("2026-08-18T12:00:01.000Z"), + }); + return scope; +} + +const transcriptReadError = () => + new Error("SQLITE_IOERR: failed to read session transcript storage"); + +test("sessions.compact reports initial transcript read failures as unavailable", async () => { + const { storePath } = await createSessionStoreDir(); + await seedCompactionSession({ sessionId: "sess-read-failure", storePath }); + transcriptReads.load.mockRejectedValueOnce(transcriptReadError()); + + const { ws } = await openClient(); + try { + const response = await rpcReq(ws, "sessions.compact", { key: "main" }); + + expect(response.ok).toBe(false); + expect(response.error).toMatchObject({ + code: "UNAVAILABLE", + message: expect.stringContaining("failed to read session transcript storage"), + }); + } finally { + ws.close(); + } +}); + +test("sessions.compact reports model compaction transcript re-read failures as unavailable", async () => { + const { storePath } = await createSessionStoreDir(); + const scope = await seedCompactionSession({ + sessionId: "sess-model-read-failure", + storePath, + nativeHarness: true, + }); + const events = await requireTranscriptReader()(scope); + transcriptReads.load.mockResolvedValueOnce(events).mockRejectedValueOnce(transcriptReadError()); + + const { ws } = await openClient(); + try { + const response = await rpcReq(ws, "sessions.compact", { key: "main" }); + + expect(response.ok).toBe(false); + expect(response.error).toMatchObject({ + code: "UNAVAILABLE", + message: expect.stringContaining("failed to read session transcript storage"), + }); + } finally { + ws.close(); + } +}); + +test("sessions.compact maxLines reports transcript preflight read failures as unavailable", async () => { + const { storePath } = await createSessionStoreDir(); + await seedCompactionSession({ sessionId: "sess-max-lines-read-failure", storePath }); + transcriptReads.load.mockRejectedValueOnce(transcriptReadError()); + + const { ws } = await openClient(); + try { + const response = await rpcReq(ws, "sessions.compact", { key: "main", maxLines: 50 }); + + expect(response.ok).toBe(false); + expect(response.error).toMatchObject({ + code: "UNAVAILABLE", + message: expect.stringContaining("failed to read session transcript storage"), + }); + } finally { + ws.close(); + } +}); + +test.each([{ maxLines: undefined }, { maxLines: 50 }])( + "sessions.compact keeps an empty transcript as a successful no-op (maxLines=$maxLines)", + async ({ maxLines }) => { + const { storePath } = await createSessionStoreDir(); + await seedCompactionSession({ + sessionId: `sess-empty-${maxLines ?? "model"}`, + storePath, + withTranscript: false, + }); + + const { ws } = await openClient(); + try { + const response = await rpcReq<{ compacted: boolean; ok: true; reason: string }>( + ws, + "sessions.compact", + { key: "main", ...(maxLines === undefined ? {} : { maxLines }) }, + ); + + expect(response.ok).toBe(true); + expect(response.payload).toMatchObject({ + ok: true, + compacted: false, + reason: "no transcript", + }); + } finally { + ws.close(); + } + }, +);