diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index f9995a98a..7040057f2 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -532,6 +532,7 @@ export class CodexAcpClient { await this.refreshSkills(request.cwd, additionalDirectories); const response = await this.codexClient.threadResume({ + excludeTurns: true, config: await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers ?? []), cwd: request.cwd, modelProvider: await this.getResumeModelProvider(), @@ -571,16 +572,23 @@ export class CodexAcpClient { await this.refreshSkills(request.cwd, additionalDirectories); const response = await this.codexClient.threadResume({ + excludeTurns: true, config: await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers ?? []), cwd: request.cwd, modelProvider: await this.getResumeModelProvider(), threadId: request.sessionId, }); onSubscribed?.(); - const historyResponse = await this.codexClient.threadRead({ - threadId: response.thread.id, - includeTurns: true, - }); + // Resume cursors bound durable history; later turns arrive through live events. + // A null paginated cursor means there was no durable history at resume time. + const thread = response.thread.historyMode === "paginated" + ? { + ...response.thread, + turns: response.turnsBackwardsCursor === null + ? [] + : await this.codexClient.threadReadHistory(response.thread.id, response.turnsBackwardsCursor), + } + : (await this.codexClient.threadReadWithHistory(response.thread.id)).thread; const codexModels = await this.fetchAvailableModels(); const currentModelId = this.createModelId(codexModels, response.model, response.reasoningEffort).toString(); return { @@ -590,16 +598,13 @@ export class CodexAcpClient { collaborationMode: this.getCollaborationMode(response.thread.id), modelProvider: response.modelProvider, currentServiceTier: response.serviceTier as ServiceTier ?? null, - thread: historyResponse.thread, + thread, additionalDirectories, }; } async readSessionThread(sessionId: string): Promise { - return (await this.codexClient.threadRead({ - threadId: sessionId, - includeTurns: true, - })).thread; + return (await this.codexClient.threadReadWithHistory(sessionId)).thread; } async newSession(request: acp.NewSessionRequest): Promise { @@ -976,6 +981,7 @@ export class CodexAcpClient { let lateStopReason: "cancelled" | "timeout" | null = null; try { const forkPromise = this.codexClient.threadFork({ + excludeTurns: true, threadId: params.sessionId, lastTurnId: params.turnId, cwd: params.workspace.cwd, diff --git a/src/CodexAppServerClient.ts b/src/CodexAppServerClient.ts index 250c76ef4..daa7e8752 100644 --- a/src/CodexAppServerClient.ts +++ b/src/CodexAppServerClient.ts @@ -56,6 +56,8 @@ import type { ThreadListResponse, ThreadReadParams, ThreadReadResponse, + ThreadTurnsListParams, + ThreadTurnsListResponse, ThreadResumeParams, ThreadResumeResponse, ThreadSettings, @@ -576,6 +578,47 @@ export class CodexAppServerClient { return await this.sendRequest({ method: "thread/read", params: params }); } + async threadTurnsList(params: ThreadTurnsListParams): Promise { + return await this.sendRequest({method: "thread/turns/list", params}); + } + + async threadReadWithHistory(threadId: string): Promise { + const response = await this.threadRead({threadId}); + // Legacy stores reconstruct the rollout on each read; paging would repeat + // that work. Full-history reads are only deprecated for paginated threads. + if (response.thread.historyMode === "legacy") { + return await this.threadRead({threadId, includeTurns: true}); + } + const turns = await this.threadReadHistory(threadId); + return {...response, thread: {...response.thread, turns}}; + } + + async threadReadHistory(threadId: string, initialCursor: string | null = null): Promise { + const turns: ThreadReadResponse["thread"]["turns"] = []; + const seenCursors = new Set(); + if (initialCursor !== null) seenCursors.add(initialCursor); + let cursor: string | null = initialCursor; + do { + const page = await this.threadTurnsList({ + threadId, + cursor, + limit: 50, + sortDirection: "desc", + itemsView: "full", + }); + turns.push(...page.data); + cursor = page.nextCursor; + if (cursor !== null) { + if (seenCursors.has(cursor)) { + throw new Error("Codex returned a repeated thread history cursor"); + } + seenCursors.add(cursor); + } + } while (cursor !== null); + // Only reverse turns: items within each full turn are already chronological. + return turns.reverse(); + } + async threadArchive(params: ThreadArchiveParams): Promise { return await this.sendRequest({ method: "thread/archive", params: params }); } diff --git a/src/SessionFork.ts b/src/SessionFork.ts index b52b7caea..1a533df7f 100644 --- a/src/SessionFork.ts +++ b/src/SessionFork.ts @@ -29,6 +29,7 @@ export async function forkSession( await dependencies.refreshSkills(request.cwd, additionalDirectories); const lastTurnId = await resolveForkTurnId(request, dependencies.codexClient); const response = await dependencies.codexClient.threadFork({ + excludeTurns: true, config: await dependencies.createSessionConfig( request.cwd, additionalDirectories, @@ -60,10 +61,7 @@ async function resolveForkTurnId( const forkPoint = readAirForkPoint(request._meta); if (!forkPoint) return undefined; - const history = await codexClient.threadRead({ - threadId: request.sessionId, - includeTurns: true, - }); + const history = await codexClient.threadReadWithHistory(request.sessionId); const candidateIds = airForkMessageIdCandidates(forkPoint.messageId); const itemTurnId = candidateIds .map(candidateId => history.thread.turns.find(turn => turn.items.some(item => item.id === candidateId))?.id) diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index bb5daf6cd..fe51f160c 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -536,7 +536,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { reasoningEffort: "medium", serviceTier: null, } as any); - const threadReadSpy = vi.spyOn(codexAppServerClient, "threadRead").mockResolvedValue({ + const threadReadSpy = vi.spyOn(codexAppServerClient, "threadReadWithHistory").mockResolvedValue({ thread: {id: "thread-id"} as any, }); vi.spyOn(codexAppServerClient, "listModels").mockResolvedValue({ @@ -556,6 +556,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { mcpServers: [], }); + expect(threadResumeSpy.mock.calls.every(([params]) => params.excludeTurns === true)).toBe(true); expect(resumed.additionalDirectories).toEqual(["/workspace/resume-extra"]); expect(loaded.additionalDirectories).toEqual(["/workspace/load-extra"]); expect(threadResumeSpy.mock.calls[0]![0].config?.["projects"]).toEqual({ @@ -566,10 +567,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { "/workspace": {trust_level: "trusted"}, "/workspace/load-extra": {trust_level: "trusted"}, }); - expect(threadReadSpy).toHaveBeenCalledWith({ - threadId: "thread-id", - includeTurns: true, - }); + expect(threadReadSpy).toHaveBeenCalledWith("thread-id"); }); it('forks an ACP session through thread/fork with the requested workspace', async () => { @@ -604,6 +602,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { expect(forked.sessionId).toBe("fork-id"); expect(forked.additionalDirectories).toEqual(["/workspace/extra"]); expect(threadForkSpy).toHaveBeenCalledWith(expect.objectContaining({ + excludeTurns: true, threadId: "source-id", cwd: "/workspace", config: expect.objectContaining({ @@ -623,7 +622,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { vi.spyOn(codexAppServerClient, "skillsExtraRootsSet").mockResolvedValue(undefined); vi.spyOn(codexAppServerClient, "listSkills").mockResolvedValue({data: []}); - vi.spyOn(codexAppServerClient, "threadRead").mockResolvedValue({ + vi.spyOn(codexAppServerClient, "threadReadWithHistory").mockResolvedValue({ thread: { id: "source-id", turns: [ @@ -653,6 +652,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { }); expect(threadForkSpy).toHaveBeenCalledWith(expect.objectContaining({ + excludeTurns: true, threadId: "source-id", lastTurnId: "turn-2", })); @@ -666,14 +666,17 @@ describe('ACP server test', { timeout: 40_000 }, () => { vi.spyOn(codexAppServerClient, "skillsExtraRootsSet").mockResolvedValue(undefined); vi.spyOn(codexAppServerClient, "listSkills").mockResolvedValue({data: []}); vi.spyOn(codexAppServerClient, "threadRead").mockResolvedValue({ - thread: { - id: "source-id", - turns: [ - {id: "turn-1", items: [{type: "agentMessage", id: "new-item-1", text: "Same answer"}]}, - {id: "turn-2", items: [{type: "agentMessage", id: "new-item-2", text: "Same answer"}]}, - ], - }, + thread: {id: "source-id", turns: []}, } as any); + vi.spyOn(codexAppServerClient, "threadTurnsList") + .mockResolvedValueOnce({ + data: [{id: "turn-2", items: [{type: "agentMessage", id: "new-item-2", text: "Same answer"}]}], + nextCursor: "second-page", backwardsCursor: null, + } as any) + .mockResolvedValueOnce({ + data: [{id: "turn-1", items: [{type: "agentMessage", id: "new-item-1", text: "Same answer"}]}], + nextCursor: null, backwardsCursor: null, + } as any); const threadForkSpy = vi.spyOn(codexAppServerClient, "threadFork").mockResolvedValue({ thread: {id: "fork-id"}, model: "gpt-5", @@ -700,6 +703,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { }); expect(threadForkSpy).toHaveBeenCalledWith(expect.objectContaining({ + excludeTurns: true, threadId: "source-id", lastTurnId: "turn-2", })); @@ -736,7 +740,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { serviceTier: null, } as any; }); - vi.spyOn(codexAppServerClient, "threadRead").mockImplementation(async ({threadId}) => ({ + vi.spyOn(codexAppServerClient, "threadReadWithHistory").mockImplementation(async (threadId) => ({ thread: {id: threadId, turns: []}, } as any)); vi.spyOn(codexAppServerClient, "listModels").mockResolvedValue({ @@ -778,7 +782,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { reasoningEffort: "medium", serviceTier: null, } as any); - vi.spyOn(codexAppServerClient, "threadRead").mockResolvedValue({ + vi.spyOn(codexAppServerClient, "threadReadWithHistory").mockResolvedValue({ thread: {id: "thread-id"} as any, }); vi.spyOn(codexAppServerClient, "listModels").mockResolvedValue({ @@ -825,7 +829,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { reasoningEffort: "medium", serviceTier: null, } as any); - vi.spyOn(codexAppServerClient, "threadRead").mockResolvedValue({ + vi.spyOn(codexAppServerClient, "threadReadWithHistory").mockResolvedValue({ thread: {id: "thread-id", turns: []} as any, }); vi.spyOn(codexAppServerClient, "listModels").mockResolvedValue({ diff --git a/src/__tests__/CodexACPAgent/agent-file-change-report.test.ts b/src/__tests__/CodexACPAgent/agent-file-change-report.test.ts index e942155b6..3c0299717 100644 --- a/src/__tests__/CodexACPAgent/agent-file-change-report.test.ts +++ b/src/__tests__/CodexACPAgent/agent-file-change-report.test.ts @@ -178,6 +178,7 @@ describe("agent file-change report lifecycle", () => { })).resolves.toMatchObject({stopReason: "end_turn"}); expect(appServer.threadFork).toHaveBeenCalledWith({ + excludeTurns: true, threadId: sessionState.sessionId, lastTurnId: "main-turn", cwd: "/workspace", diff --git a/src/__tests__/CodexACPAgent/data/paginated-thread-history.json b/src/__tests__/CodexACPAgent/data/paginated-thread-history.json new file mode 100644 index 000000000..8efecb695 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/paginated-thread-history.json @@ -0,0 +1,131 @@ +{ + "reads": [ + [ + { + "threadId": "history" + } + ] + ], + "pages": [ + [ + { + "threadId": "history", + "cursor": null, + "limit": 50, + "sortDirection": "desc", + "itemsView": "full" + } + ], + [ + { + "threadId": "history", + "cursor": "next-page", + "limit": 50, + "sortDirection": "desc", + "itemsView": "full" + } + ] + ], + "thread": { + "id": "history", + "turns": [ + { + "id": "first", + "items": [ + { + "type": "userMessage", + "id": "first-input", + "clientId": null, + "content": [ + { + "type": "text", + "text": "Question first", + "text_elements": [] + } + ] + }, + { + "type": "agentMessage", + "id": "first-message", + "text": "Answer first", + "phase": "final_answer", + "memoryCitation": null, + "delivery": null, + "questions": null + } + ], + "itemsView": "full", + "status": "completed", + "error": null, + "startedAt": null, + "completedAt": null, + "durationMs": null + }, + { + "id": "second", + "items": [ + { + "type": "userMessage", + "id": "second-input", + "clientId": null, + "content": [ + { + "type": "text", + "text": "Question second", + "text_elements": [] + } + ] + }, + { + "type": "agentMessage", + "id": "second-message", + "text": "Answer second", + "phase": "final_answer", + "memoryCitation": null, + "delivery": null, + "questions": null + } + ], + "itemsView": "full", + "status": "completed", + "error": null, + "startedAt": null, + "completedAt": null, + "durationMs": null + }, + { + "id": "third", + "items": [ + { + "type": "userMessage", + "id": "third-input", + "clientId": null, + "content": [ + { + "type": "text", + "text": "Question third", + "text_elements": [] + } + ] + }, + { + "type": "agentMessage", + "id": "third-message", + "text": "Answer third", + "phase": "final_answer", + "memoryCitation": null, + "delivery": null, + "questions": null + } + ], + "itemsView": "full", + "status": "completed", + "error": null, + "startedAt": null, + "completedAt": null, + "durationMs": null + } + ], + "name": "Saved conversation" + } +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/load-session.test.ts b/src/__tests__/CodexACPAgent/load-session.test.ts index ae8ce09a8..05440d659 100644 --- a/src/__tests__/CodexACPAgent/load-session.test.ts +++ b/src/__tests__/CodexACPAgent/load-session.test.ts @@ -146,7 +146,7 @@ describe("CodexACPAgent - loadSession", () => { sandbox: {type: "dangerFullAccess"}, reasoningEffort: model.defaultReasoningEffort, }); - appServer.threadRead = vi.fn().mockImplementation(({threadId}) => { + appServer.threadReadWithHistory = vi.fn().mockImplementation((threadId) => { if (threadId === "orphan-history") return Promise.reject(new Error("missing child history")); return Promise.resolve({thread: threadId === root.id ? root : child}); }); @@ -391,7 +391,7 @@ describe("CodexACPAgent - loadSession", () => { sandbox: { type: "dangerFullAccess" }, reasoningEffort: model.defaultReasoningEffort, }); - codexAppServerClient.threadRead = vi.fn().mockResolvedValue({ + codexAppServerClient.threadReadWithHistory = vi.fn().mockResolvedValue({ thread: thread, }); const goal: ThreadGoal = { @@ -415,10 +415,7 @@ describe("CodexACPAgent - loadSession", () => { }; await codexAcpAgent.loadSession(loadParams); - expect(codexAppServerClient.threadRead).toHaveBeenCalledWith({ - threadId: thread.id, - includeTurns: true, - }); + expect(codexAppServerClient.threadReadWithHistory).toHaveBeenCalledWith(thread.id); expect(codexAppServerClient.threadGoalGet).toHaveBeenCalledWith({ threadId: thread.id }); await expect(fixture.getAcpConnectionDump([])).toMatchFileSnapshot( "data/load-session-history.json" @@ -501,7 +498,7 @@ describe("CodexACPAgent - loadSession", () => { sandbox: { type: "dangerFullAccess" }, reasoningEffort: model.defaultReasoningEffort, }); - codexAppServerClient.threadRead = vi.fn().mockResolvedValue({ + codexAppServerClient.threadReadWithHistory = vi.fn().mockResolvedValue({ thread: thread, }); @@ -757,7 +754,7 @@ describe("CodexACPAgent - loadSession", () => { sandbox: { type: "dangerFullAccess" }, reasoningEffort: model.defaultReasoningEffort, }); - codexAppServerClient.threadRead = vi.fn().mockResolvedValue({ + codexAppServerClient.threadReadWithHistory = vi.fn().mockResolvedValue({ thread, }); @@ -859,7 +856,7 @@ describe("CodexACPAgent - loadSession", () => { sandbox: { type: "dangerFullAccess" }, reasoningEffort: model.defaultReasoningEffort, }); - codexAppServerClient.threadRead = vi.fn().mockResolvedValue({ + codexAppServerClient.threadReadWithHistory = vi.fn().mockResolvedValue({ thread: thread, }); diff --git a/src/__tests__/CodexACPAgent/thread-history.test.ts b/src/__tests__/CodexACPAgent/thread-history.test.ts new file mode 100644 index 000000000..f5058686c --- /dev/null +++ b/src/__tests__/CodexACPAgent/thread-history.test.ts @@ -0,0 +1,166 @@ +import {describe, expect, it, vi} from "vitest"; +import type {Turn} from "../../app-server/v2"; +import {createCodexMockTestFixture, createTestModel} from "../acp-test-utils"; + +function messageTurn(id: string): Turn { + return { + id, + items: [{type: "userMessage", id: `${id}-input`, clientId: null, content: [{type: "text", text: `Question ${id}`, text_elements: []}]}, {type: "agentMessage", id: `${id}-message`, text: `Answer ${id}`, phase: "final_answer", memoryCitation: null, delivery: null, questions: null}], + itemsView: "full", + status: "completed", + error: null, + startedAt: null, + completedAt: null, + durationMs: null, + }; +} + +describe("paginated thread history", () => { + it("loads every page in chronological order with complete messages", async () => { + const fixture = createCodexMockTestFixture(); + const appServer = fixture.getCodexAppServerClient(); + const metadataRead = vi.spyOn(appServer, "threadRead").mockResolvedValue({ + thread: {id: "history", turns: [], name: "Saved conversation"} as any, + }); + const pages = vi.spyOn(appServer, "threadTurnsList") + .mockResolvedValueOnce({data: [messageTurn("third"), messageTurn("second")], nextCursor: "next-page", backwardsCursor: null}) + .mockResolvedValueOnce({data: [messageTurn("first")], nextCursor: null, backwardsCursor: "previous-page"}); + + const thread = await fixture.getCodexAcpClient().readSessionThread("history"); + + await expect(JSON.stringify({ + reads: metadataRead.mock.calls, + pages: pages.mock.calls, + thread, + }, null, 2)).toMatchFileSnapshot("data/paginated-thread-history.json"); + }); + + it.each([ + {mode: "paginated", boundary: "resume-boundary", expectedIds: ["first", "second"]}, + {mode: "paginated", boundary: null, expectedIds: []}, + {mode: "legacy", boundary: null, expectedIds: ["first", "second", "new-after-resume"]}, + ] as const)("loads $mode history with resume boundary $boundary", async ({mode, boundary, expectedIds}) => { + const fixture = createCodexMockTestFixture(); + const appServer = fixture.getCodexAppServerClient(); + const client = fixture.getCodexAcpClient(); + vi.spyOn(appServer, "skillsExtraRootsSet").mockResolvedValue(undefined); + vi.spyOn(appServer, "listSkills").mockResolvedValue({data: []}); + vi.spyOn(appServer, "listModels").mockResolvedValue({data: [createTestModel({id: "gpt-5"})], nextCursor: null}); + vi.spyOn(appServer, "threadResume").mockResolvedValue({ + thread: {id: "history", historyMode: mode, name: "Resume metadata", turns: []}, + turnsBackwardsCursor: boundary, + model: "gpt-5", modelProvider: "openai", reasoningEffort: "medium", serviceTier: null, + } as any); + const read = vi.spyOn(appServer, "threadRead").mockImplementation(async ({includeTurns}) => ({ + thread: { + id: "history", historyMode: mode, name: "Later metadata", + turns: includeTurns ? expectedIds.map(messageTurn) : [], + } as any, + })); + const pages = vi.spyOn(appServer, "threadTurnsList").mockImplementation(async ({cursor, sortDirection, itemsView}) => { + expect(sortDirection).toBe("desc"); + expect(itemsView).toBe("full"); + // Simulate a turn persisted after resume, before history is requested. + if (cursor === null) return {data: [messageTurn("new-after-resume")], nextCursor: "resume-boundary", backwardsCursor: null}; + if (cursor === "resume-boundary") return {data: [messageTurn("second")], nextCursor: "older", backwardsCursor: null}; + if (cursor === "older") return {data: [messageTurn("first")], nextCursor: null, backwardsCursor: null}; + throw new Error("Unexpected cursor"); + }); + + const loaded = await client.loadSession({sessionId: "history", cwd: "/workspace", mcpServers: []}); + + expect(loaded.thread.turns.map(turn => turn.id)).toEqual(expectedIds); + expect(loaded.thread.name).toBe(mode === "paginated" ? "Resume metadata" : "Later metadata"); + expect(read.mock.calls).toEqual(mode === "paginated" ? [] : [ + [{threadId: "history"}], + [{threadId: "history", includeTurns: true}], + ]); + expect(pages).toHaveBeenCalledTimes(mode === "legacy" ? 0 : expectedIds.length); + }); + + it("reads standalone legacy history without requiring a pagination API", async () => { + const fixture = createCodexMockTestFixture(); + const appServer = fixture.getCodexAppServerClient(); + const history = {id: "legacy", historyMode: "legacy", turns: [messageTurn("first"), messageTurn("second")]}; + const read = vi.spyOn(appServer, "threadRead") + .mockResolvedValueOnce({thread: {...history, turns: []} as any}) + .mockResolvedValueOnce({thread: history as any}); + const pages = vi.spyOn(appServer, "threadTurnsList").mockRejectedValue(new Error("Method not found")); + + expect(await fixture.getCodexAcpClient().readSessionThread("legacy")).toEqual(history); + expect(read.mock.calls).toEqual([ + [{threadId: "legacy"}], + [{threadId: "legacy", includeTurns: true}], + ]); + expect(pages).not.toHaveBeenCalled(); + }); + + it("does not include turns appended while standalone history is being paged", async () => { + const fixture = createCodexMockTestFixture(); + const appServer = fixture.getCodexAppServerClient(); + vi.spyOn(appServer, "threadRead").mockResolvedValue({thread: {id: "history", turns: []} as any}); + const stored = [messageTurn("first"), messageTurn("second")]; + vi.spyOn(appServer, "threadTurnsList").mockImplementation(async ({cursor, sortDirection}) => { + expect(sortDirection).toBe("desc"); + const index = cursor === null ? stored.length - 1 : Number(cursor); + const data = [stored[index]!]; + if (cursor === null) stored.push(messageTurn("appended-during-read")); + return {data, nextCursor: index === 0 ? null : String(index - 1), backwardsCursor: null}; + }); + + const thread = await fixture.getCodexAcpClient().readSessionThread("history"); + expect(thread.turns.map(turn => turn.id)).toEqual(["first", "second"]); + expect(stored).toHaveLength(3); + }); + + it("rejects a cursor that returns to the initial resume boundary", async () => { + const fixture = createCodexMockTestFixture(); + const appServer = fixture.getCodexAppServerClient(); + const pages = vi.spyOn(appServer, "threadTurnsList") + .mockResolvedValueOnce({data: [], nextCursor: "resume-boundary", backwardsCursor: null}) + .mockRejectedValue(new Error("Unexpected extra page request")); + + await expect(appServer.threadReadHistory("history", "resume-boundary")) + .rejects.toThrow("Codex returned a repeated thread history cursor"); + expect(pages).toHaveBeenCalledTimes(1); + }); + + it("returns an empty history when the first page is empty", async () => { + const fixture = createCodexMockTestFixture(); + const appServer = fixture.getCodexAppServerClient(); + vi.spyOn(appServer, "threadRead").mockResolvedValue({thread: {id: "empty", turns: []} as any}); + const pages = vi.spyOn(appServer, "threadTurnsList").mockResolvedValue({data: [], nextCursor: null, backwardsCursor: null}); + + expect(await fixture.getCodexAcpClient().readSessionThread("empty")).toEqual({id: "empty", turns: []}); + expect(pages).toHaveBeenCalledTimes(1); + }); + + it.each([ + {name: "repeated cursor", cursors: ["page-a", "page-a"]}, + {name: "cursor cycle", cursors: ["page-a", "page-b", "page-a"]}, + ])("rejects a $name before requesting another page", async ({cursors}) => { + const fixture = createCodexMockTestFixture(); + const appServer = fixture.getCodexAppServerClient(); + vi.spyOn(appServer, "threadRead").mockResolvedValue({thread: {id: "history", turns: []} as any}); + const pages = vi.spyOn(appServer, "threadTurnsList") + .mockRejectedValue(new Error("Unexpected extra page request")); + for (const nextCursor of cursors) { + pages.mockResolvedValueOnce({data: [], nextCursor, backwardsCursor: null}); + } + + await expect(fixture.getCodexAcpClient().readSessionThread("history")) + .rejects.toThrow("Codex returned a repeated thread history cursor"); + expect(pages).toHaveBeenCalledTimes(cursors.length); + }); + + it("rejects an incomplete history if a later page fails", async () => { + const fixture = createCodexMockTestFixture(); + const appServer = fixture.getCodexAppServerClient(); + vi.spyOn(appServer, "threadRead").mockResolvedValue({thread: {id: "history", turns: []} as any}); + vi.spyOn(appServer, "threadTurnsList") + .mockResolvedValueOnce({data: [messageTurn("first")], nextCursor: "next-page", backwardsCursor: null}) + .mockRejectedValueOnce(new Error("History unavailable")); + + await expect(fixture.getCodexAcpClient().readSessionThread("history")).rejects.toThrow("History unavailable"); + }); +});