Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 15 additions & 9 deletions src/CodexAcpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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 {
Expand All @@ -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<Thread> {
return (await this.codexClient.threadRead({
threadId: sessionId,
includeTurns: true,
})).thread;
return (await this.codexClient.threadReadWithHistory(sessionId)).thread;
}

async newSession(request: acp.NewSessionRequest): Promise<SessionMetadata> {
Expand Down Expand Up @@ -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,
Expand Down
43 changes: 43 additions & 0 deletions src/CodexAppServerClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ import type {
ThreadListResponse,
ThreadReadParams,
ThreadReadResponse,
ThreadTurnsListParams,
ThreadTurnsListResponse,
ThreadResumeParams,
ThreadResumeResponse,
ThreadSettings,
Expand Down Expand Up @@ -576,6 +578,47 @@ export class CodexAppServerClient {
return await this.sendRequest({ method: "thread/read", params: params });
}

async threadTurnsList(params: ThreadTurnsListParams): Promise<ThreadTurnsListResponse> {
return await this.sendRequest({method: "thread/turns/list", params});
}

async threadReadWithHistory(threadId: string): Promise<ThreadReadResponse> {
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}};
}
Comment on lines +585 to +594

async threadReadHistory(threadId: string, initialCursor: string | null = null): Promise<ThreadReadResponse["thread"]["turns"]> {
const turns: ThreadReadResponse["thread"]["turns"] = [];
const seenCursors = new Set<string>();
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<ThreadArchiveResponse> {
return await this.sendRequest({ method: "thread/archive", params: params });
}
Expand Down
6 changes: 2 additions & 4 deletions src/SessionFork.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
36 changes: 20 additions & 16 deletions src/__tests__/CodexACPAgent/CodexAcpClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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({
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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({
Expand All @@ -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: [
Expand Down Expand Up @@ -653,6 +652,7 @@ describe('ACP server test', { timeout: 40_000 }, () => {
});

expect(threadForkSpy).toHaveBeenCalledWith(expect.objectContaining({
excludeTurns: true,
threadId: "source-id",
lastTurnId: "turn-2",
}));
Expand All @@ -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",
Expand All @@ -700,6 +703,7 @@ describe('ACP server test', { timeout: 40_000 }, () => {
});

expect(threadForkSpy).toHaveBeenCalledWith(expect.objectContaining({
excludeTurns: true,
threadId: "source-id",
lastTurnId: "turn-2",
}));
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
131 changes: 131 additions & 0 deletions src/__tests__/CodexACPAgent/data/paginated-thread-history.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading