From 2feb73a3a9ee4d3fa3f4a35293f526fe02173019 Mon Sep 17 00:00:00 2001 From: Lody Dev Date: Wed, 29 Jul 2026 09:38:22 +0000 Subject: [PATCH 1/3] feat: support active-turn session forks Model: gpt-5.6-sol --- src/AcpExtensions.ts | 10 +++++ src/CodexAcpClient.ts | 22 +++++++++++ src/CodexAcpServer.ts | 20 ++++++++++ .../CodexACPAgent/initialize.test.ts | 5 +++ .../CodexACPAgent/session-fork.test.ts | 39 +++++++++++++++++++ src/index.ts | 12 +++++- 6 files changed, 107 insertions(+), 1 deletion(-) diff --git a/src/AcpExtensions.ts b/src/AcpExtensions.ts index 1f8f15a3..881ac27a 100644 --- a/src/AcpExtensions.ts +++ b/src/AcpExtensions.ts @@ -12,6 +12,16 @@ export const ACP_EXT_SESSION_USAGE_UPDATE_METHOD = "_acp_ext:session_usage_updat export const ACP_EXT_SESSION_RATE_LIMITS_METHOD = "_acp_ext:session_rate_limits"; export const ACP_EXT_CODEX_PROPOSED_PLAN_METHOD = "_acp_ext:codex_proposed_plan"; export const CODEX_STEER_APPLIED_METHOD = "_codex/steerApplied"; +export const LODY_FORK_POINT_BEFORE_ACTIVE_TURN_METHOD = + "_lody/session/fork-point-before-active-turn"; + +export function getLodyForkPoint(meta: unknown): string | null { + if (typeof meta !== "object" || meta === null) return null; + const lody = (meta as Record)["lody"]; + if (typeof lody !== "object" || lody === null) return null; + const forkPoint = (lody as Record)["forkPoint"]; + return typeof forkPoint === "string" && forkPoint.length > 0 ? forkPoint : null; +} export type CodexSteerCapability = { version: 1; diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index a780eecd..ecc24dbf 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -23,6 +23,7 @@ import {AgentMode} from "./AgentMode"; import path from "node:path"; import {logger} from "./Logger"; import {sanitizeMcpServerName} from "./McpServerName"; +import {getLodyForkPoint} from "./AcpExtensions"; import type { AccountLoginCompletedNotification, AccountUpdatedNotification, @@ -355,11 +356,13 @@ export class CodexAcpClient { ): Promise { const additionalDirectories = readAdditionalDirectories(request.cwd, request.additionalDirectories, request._meta); await this.refreshSkills(request.cwd, additionalDirectories); + const forkPoint = getLodyForkPoint(request._meta); const response = await this.codexClient.threadFork({ config: await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers ?? []), cwd: request.cwd, excludeTurns: true, + ...(forkPoint ? {lastTurnId: forkPoint} : {}), modelProvider: await this.getResumeModelProvider(), threadId: request.sessionId, }); @@ -376,6 +379,25 @@ export class CodexAcpClient { }; } + async findForkPointBeforeTurn(threadId: string, activeTurnId: string): Promise { + const response = await this.codexClient.threadRead({ + threadId, + includeTurns: true, + }); + const turns = response.thread.turns; + const activeIndex = turns.findIndex((turn) => turn.id === activeTurnId); + if (activeIndex < 0) { + throw RequestError.invalidRequest("Active turn changed before its fork point was captured"); + } + for (let index = activeIndex - 1; index >= 0; index--) { + const turn = turns[index]; + if (turn && turn.status !== "inProgress") { + return turn.id; + } + } + throw RequestError.invalidRequest("No completed turn exists before the active turn"); + } + async loadSession(request: acp.LoadSessionRequest, onSubscribed?: () => void): Promise { const additionalDirectories = readAdditionalDirectories(request.cwd, request.additionalDirectories, request._meta); await this.refreshSkills(request.cwd, additionalDirectories); diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 7486ff55..830990a5 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -47,6 +47,7 @@ import { getCodexSteerId, isExtMethodRequest, LEGACY_SET_SESSION_MODEL_METHOD, + LODY_FORK_POINT_BEFORE_ACTIVE_TURN_METHOD, } from "./AcpExtensions"; import { createCollabAgentToolCallUpdate, @@ -266,6 +267,9 @@ export class CodexAcpServer { codex: { steer: CODEX_STEER_CAPABILITY, }, + lody: { + forkPointBeforeActiveTurn: {version: 1}, + }, }, }, authMethods: getCodexAuthMethods(_params.clientCapabilities), @@ -665,6 +669,22 @@ export class CodexAcpServer { }; } + async resolveForkPointBeforeActiveTurn(params: {sessionId: string}): Promise<{forkPoint: string}> { + const session = this.sessions.get(params.sessionId); + const activeTurnId = session?.currentTurnId; + if (!activeTurnId) { + throw RequestError.invalidRequest("Session has no active turn"); + } + const forkPoint = await this.runWithProcessCheck(() => + this.codexAcpClient.findForkPointBeforeTurn(params.sessionId, activeTurnId) + ); + logger.log("Resolved fork point before active turn", { + sessionId: params.sessionId, + method: LODY_FORK_POINT_BEFORE_ACTIVE_TURN_METHOD, + }); + return {forkPoint}; + } + async listSessions(params: acp.ListSessionsRequest): Promise { logger.log("Listing sessions...", {cwd: params.cwd, cursor: params.cursor}); await this.checkAuthorization(); diff --git a/src/__tests__/CodexACPAgent/initialize.test.ts b/src/__tests__/CodexACPAgent/initialize.test.ts index 75785673..3fbb1705 100644 --- a/src/__tests__/CodexACPAgent/initialize.test.ts +++ b/src/__tests__/CodexACPAgent/initialize.test.ts @@ -69,6 +69,11 @@ describe('CodexACPAgent - initialize', () => { configPolicy: "active", }, }, + lody: { + forkPointBeforeActiveTurn: { + version: 1, + }, + }, }, }, authMethods: getCodexAuthMethods(), diff --git a/src/__tests__/CodexACPAgent/session-fork.test.ts b/src/__tests__/CodexACPAgent/session-fork.test.ts index 8841bf63..814db3c8 100644 --- a/src/__tests__/CodexACPAgent/session-fork.test.ts +++ b/src/__tests__/CodexACPAgent/session-fork.test.ts @@ -40,6 +40,11 @@ describe("ACP session fork", () => { cwd: "/workspace", additionalDirectories: ["/workspace/extra"], mcpServers: [mcpServer], + _meta: { + lody: { + forkPoint: "completed-turn-id", + }, + }, }, subscribed); expect(result).toEqual({ @@ -53,6 +58,7 @@ describe("ACP session fork", () => { expect(subscribed).toHaveBeenCalledWith("child-session-id"); expect(threadForkSpy).toHaveBeenCalledWith({ threadId: "source-session-id", + lastTurnId: "completed-turn-id", cwd: "/workspace", excludeTurns: true, modelProvider: "openai", @@ -76,6 +82,39 @@ describe("ACP session fork", () => { expect(threadReadSpy).not.toHaveBeenCalled(); }); + it("resolves the last terminal Codex turn before the active turn", async () => { + const fixture = createCodexMockTestFixture(); + const codexAcpClient = fixture.getCodexAcpClient(); + const codexAppServerClient = fixture.getCodexAppServerClient(); + vi.spyOn(codexAppServerClient, "threadRead").mockResolvedValue({ + thread: { + turns: [ + {id: "completed-turn", status: "completed"}, + {id: "active-turn", status: "inProgress"}, + ], + }, + } as never); + + await expect( + codexAcpClient.findForkPointBeforeTurn("source-session-id", "active-turn"), + ).resolves.toBe("completed-turn"); + }); + + it("rejects when the active Codex turn changed during capture", async () => { + const fixture = createCodexMockTestFixture(); + const codexAcpClient = fixture.getCodexAcpClient(); + const codexAppServerClient = fixture.getCodexAppServerClient(); + vi.spyOn(codexAppServerClient, "threadRead").mockResolvedValue({ + thread: { + turns: [{id: "completed-turn", status: "completed"}], + }, + } as never); + + await expect( + codexAcpClient.findForkPointBeforeTurn("source-session-id", "stale-active-turn"), + ).rejects.toThrow("Invalid request"); + }); + it("installs the fork as an independent promptable ACP session", async () => { const fixture = createCodexMockTestFixture(); const codexAcpAgent = fixture.getCodexAcpAgent(); diff --git a/src/index.ts b/src/index.ts index 5d33486b..1efe48a0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,7 +12,10 @@ import packageJson from "../package.json"; import {logger} from "./Logger"; import {runLoginCommand} from "./login"; import {runCodexCli} from "./CodexCli"; -import {LEGACY_SET_SESSION_MODEL_METHOD} from "./AcpExtensions"; +import { + LEGACY_SET_SESSION_MODEL_METHOD, + LODY_FORK_POINT_BEFORE_ACTIVE_TURN_METHOD, +} from "./AcpExtensions"; const emptyExtensionParamsParser = z.preprocess( (params) => params ?? {}, @@ -24,6 +27,10 @@ const legacySetSessionModelParamsParser = z.object({ modelId: z.string(), }).passthrough(); +const activeTurnForkPointParamsParser = z.object({ + sessionId: z.string().min(1), +}); + if (process.argv.includes("--version")) { console.log(`${packageJson.name} ${packageJson.version}`); process.exit(0); @@ -133,5 +140,8 @@ function startAcpServer() { .onRequest("authentication/status", emptyExtensionParamsParser, (ctx) => getAgent().extMethod("authentication/status", ctx.params)) .onRequest("authentication/logout", emptyExtensionParamsParser, (ctx) => getAgent().extMethod("authentication/logout", ctx.params)) .onRequest(LEGACY_SET_SESSION_MODEL_METHOD, legacySetSessionModelParamsParser, (ctx) => getAgent().extMethod(LEGACY_SET_SESSION_MODEL_METHOD, ctx.params)) + .onRequest(LODY_FORK_POINT_BEFORE_ACTIVE_TURN_METHOD, activeTurnForkPointParamsParser, (ctx) => + getAgent().resolveForkPointBeforeActiveTurn(ctx.params) + ) .connect(acpJsonStream); } From 5442659d244e126ed43482b5739b2522a76d532f Mon Sep 17 00:00:00 2001 From: Leeeon233 Date: Wed, 29 Jul 2026 12:29:48 +0000 Subject: [PATCH 2/3] feat: expose completed turn fork points Model: gpt-5.6-sol --- src/CodexAcpServer.ts | 15 +++++++++++---- .../CodexACPAgent/CodexAcpClient.test.ts | 3 +++ .../CodexACPAgent/data/token-usage-end-turn.json | 3 +++ .../data/token-usage-multiple-updates.json | 3 +++ .../CodexACPAgent/data/token-usage-null.json | 3 +++ 5 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 830990a5..e2fd8f7d 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -1879,7 +1879,10 @@ export class CodexAcpServer { return { stopReason: "end_turn", usage: this.buildPromptUsage(sessionState.lastTokenUsage), - _meta: this.buildQuotaMeta(sessionState), + _meta: this.buildQuotaMeta( + sessionState, + commandResult.turnCompleted?.turn.id, + ), }; } @@ -1966,7 +1969,7 @@ export class CodexAcpServer { return { stopReason: "end_turn", usage: this.buildPromptUsage(sessionState.lastTokenUsage), - _meta: this.buildQuotaMeta(sessionState), + _meta: this.buildQuotaMeta(sessionState, turnCompleted.turn.id), }; } catch (err) { logger.error(`Prompt for session ${params.sessionId} failed`, err); @@ -2002,7 +2005,10 @@ export class CodexAcpServer { }); } - private buildQuotaMeta(sessionState: SessionState): { quota: QuotaMeta } { + private buildQuotaMeta( + sessionState: SessionState, + forkPoint?: string, + ): { quota: QuotaMeta, lody?: { forkPoint: string } } { const lastTokenUsage = sessionState.lastTokenUsage; // Remove the "[reasoning-level]" suffix from currentModelId if present @@ -2017,7 +2023,8 @@ export class CodexAcpServer { quota: { token_count: sessionState.lastTokenUsage, model_usage: modelUsage - } + }, + ...(forkPoint ? {lody: {forkPoint}} : {}), }; } diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index 94bd8b14..f206fddf 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -1421,6 +1421,9 @@ describe('ACP server test', { timeout: 40_000 }, () => { completeReview(createReviewCompletedNotification()); await expect(promptPromise).resolves.toEqual(expect.objectContaining({ stopReason: "end_turn", + _meta: expect.objectContaining({ + lody: {forkPoint: "review-turn-id"}, + }), })); expect(promptResolved).toBe(true); }); diff --git a/src/__tests__/CodexACPAgent/data/token-usage-end-turn.json b/src/__tests__/CodexACPAgent/data/token-usage-end-turn.json index 60ba2e2e..381dfb51 100644 --- a/src/__tests__/CodexACPAgent/data/token-usage-end-turn.json +++ b/src/__tests__/CodexACPAgent/data/token-usage-end-turn.json @@ -28,6 +28,9 @@ } } ] + }, + "lody": { + "forkPoint": "turn-id" } } } diff --git a/src/__tests__/CodexACPAgent/data/token-usage-multiple-updates.json b/src/__tests__/CodexACPAgent/data/token-usage-multiple-updates.json index aebc1783..377425ab 100644 --- a/src/__tests__/CodexACPAgent/data/token-usage-multiple-updates.json +++ b/src/__tests__/CodexACPAgent/data/token-usage-multiple-updates.json @@ -28,6 +28,9 @@ } } ] + }, + "lody": { + "forkPoint": "turn-id" } } } diff --git a/src/__tests__/CodexACPAgent/data/token-usage-null.json b/src/__tests__/CodexACPAgent/data/token-usage-null.json index 65196087..c4c205e4 100644 --- a/src/__tests__/CodexACPAgent/data/token-usage-null.json +++ b/src/__tests__/CodexACPAgent/data/token-usage-null.json @@ -5,6 +5,9 @@ "quota": { "token_count": null, "model_usage": [] + }, + "lody": { + "forkPoint": "turn-id" } } } From 98b78363b6940e2e05912919cd628769eb1448b4 Mon Sep 17 00:00:00 2001 From: Leeeon233 Date: Wed, 29 Jul 2026 14:04:31 +0000 Subject: [PATCH 3/3] feat: fork sessions by ACP message ID Model: gpt-5.6-sol --- AGENTS.md | 6 +++- src/AcpExtensions.ts | 15 +++++--- src/CodexAcpClient.ts | 36 +++++++++++++++---- src/CodexAcpServer.ts | 29 ++++++--------- .../CodexACPAgent/CodexAcpClient.test.ts | 3 -- .../data/token-usage-end-turn.json | 3 -- .../data/token-usage-multiple-updates.json | 3 -- .../CodexACPAgent/data/token-usage-null.json | 3 -- .../CodexACPAgent/initialize.test.ts | 3 +- .../CodexACPAgent/session-fork.test.ts | 33 ++++++++++++----- src/index.ts | 8 ++--- 11 files changed, 86 insertions(+), 56 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 56f93557..dc075fdb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,11 @@ - Codex app-server usage: see https://github.com/openai/codex/blob/main/codex-rs/app-server/README.md when touching protocol/transport details, adding or consuming JSON-RPC methods, handling approvals/turn events, or updating generated schema/clients. - App-server events: prefer `thread/*`, `turn/*`, and `item/*` event surfaces; avoid the deprecated `codex/event/*` API (planned removal). Keep implementations aligned with generated types in `src/app-server` (including `v2` exports). - Steer uses app-server `turn/steer` on the tracked active turn. Correlate `clientUserMessageId` and acknowledge only the matching `item/completed(userMessage)`; never emulate steer with a second `turn/start`. -- Session fork uses app-server `thread/fork` and installs the returned child as an independent ACP session. Never emulate fork by replaying source history. +- Session fork uses app-server `thread/fork` and installs the returned child as an independent ACP + session. The temporary `_meta.lody.forkAtMessage` extension carries a standard ACP `messageId`; + resolve the containing Codex turn with `thread/read` inside this adapter before setting + `thread/fork.lastTurnId`. Never expose Codex turn IDs as ACP message IDs or emulate fork by + replaying source history. - Codex reasoning summaries can echo trailing empty HTML comments from model instructions. Keep that provider-specific cleanup in `src/ReasoningText.ts` across live deltas and history replay; do not filter assistant text, raw reasoning, or HTML globally in the client renderer. diff --git a/src/AcpExtensions.ts b/src/AcpExtensions.ts index 881ac27a..5ba7dfd6 100644 --- a/src/AcpExtensions.ts +++ b/src/AcpExtensions.ts @@ -12,15 +12,20 @@ export const ACP_EXT_SESSION_USAGE_UPDATE_METHOD = "_acp_ext:session_usage_updat export const ACP_EXT_SESSION_RATE_LIMITS_METHOD = "_acp_ext:session_rate_limits"; export const ACP_EXT_CODEX_PROPOSED_PLAN_METHOD = "_acp_ext:codex_proposed_plan"; export const CODEX_STEER_APPLIED_METHOD = "_codex/steerApplied"; -export const LODY_FORK_POINT_BEFORE_ACTIVE_TURN_METHOD = - "_lody/session/fork-point-before-active-turn"; +export const LODY_FORK_MESSAGE_BEFORE_ACTIVE_TURN_METHOD = + "_lody/session/fork-message-before-active-turn"; -export function getLodyForkPoint(meta: unknown): string | null { +export function getLodyForkMessageId(meta: unknown): string | null { if (typeof meta !== "object" || meta === null) return null; const lody = (meta as Record)["lody"]; if (typeof lody !== "object" || lody === null) return null; - const forkPoint = (lody as Record)["forkPoint"]; - return typeof forkPoint === "string" && forkPoint.length > 0 ? forkPoint : null; + const forkAtMessage = (lody as Record)["forkAtMessage"]; + if (typeof forkAtMessage !== "object" || forkAtMessage === null) return null; + const version = (forkAtMessage as Record)["version"]; + const messageId = (forkAtMessage as Record)["messageId"]; + return version === 1 && typeof messageId === "string" && messageId.length > 0 + ? messageId + : null; } export type CodexSteerCapability = { diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index ecc24dbf..80df60bd 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -23,7 +23,7 @@ import {AgentMode} from "./AgentMode"; import path from "node:path"; import {logger} from "./Logger"; import {sanitizeMcpServerName} from "./McpServerName"; -import {getLodyForkPoint} from "./AcpExtensions"; +import {getLodyForkMessageId} from "./AcpExtensions"; import type { AccountLoginCompletedNotification, AccountUpdatedNotification, @@ -356,13 +356,16 @@ export class CodexAcpClient { ): Promise { const additionalDirectories = readAdditionalDirectories(request.cwd, request.additionalDirectories, request._meta); await this.refreshSkills(request.cwd, additionalDirectories); - const forkPoint = getLodyForkPoint(request._meta); + const forkMessageId = getLodyForkMessageId(request._meta); + const forkTurnId = forkMessageId + ? await this.resolveForkTurnIdForMessage(request.sessionId, forkMessageId) + : null; const response = await this.codexClient.threadFork({ config: await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers ?? []), cwd: request.cwd, excludeTurns: true, - ...(forkPoint ? {lastTurnId: forkPoint} : {}), + ...(forkTurnId ? {lastTurnId: forkTurnId} : {}), modelProvider: await this.getResumeModelProvider(), threadId: request.sessionId, }); @@ -379,7 +382,21 @@ export class CodexAcpClient { }; } - async findForkPointBeforeTurn(threadId: string, activeTurnId: string): Promise { + async resolveForkTurnIdForMessage(threadId: string, messageId: string): Promise { + const response = await this.codexClient.threadRead({ + threadId, + includeTurns: true, + }); + const turn = response.thread.turns.find((candidate) => + candidate.items.some((item) => item.type === "agentMessage" && item.id === messageId) + ); + if (!turn) { + throw RequestError.invalidRequest("ACP message is not a forkable Codex turn boundary"); + } + return turn.id; + } + + async findMessageBeforeTurn(threadId: string, activeTurnId: string): Promise { const response = await this.codexClient.threadRead({ threadId, includeTurns: true, @@ -387,15 +404,20 @@ export class CodexAcpClient { const turns = response.thread.turns; const activeIndex = turns.findIndex((turn) => turn.id === activeTurnId); if (activeIndex < 0) { - throw RequestError.invalidRequest("Active turn changed before its fork point was captured"); + throw RequestError.invalidRequest("Active turn changed before its preceding message was captured"); } for (let index = activeIndex - 1; index >= 0; index--) { const turn = turns[index]; if (turn && turn.status !== "inProgress") { - return turn.id; + const agentMessage = [...turn.items] + .reverse() + .find((item) => item.type === "agentMessage"); + if (agentMessage) { + return agentMessage.id; + } } } - throw RequestError.invalidRequest("No completed turn exists before the active turn"); + throw RequestError.invalidRequest("No completed assistant message exists before the active turn"); } async loadSession(request: acp.LoadSessionRequest, onSubscribed?: () => void): Promise { diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index e2fd8f7d..fc18f307 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -47,7 +47,7 @@ import { getCodexSteerId, isExtMethodRequest, LEGACY_SET_SESSION_MODEL_METHOD, - LODY_FORK_POINT_BEFORE_ACTIVE_TURN_METHOD, + LODY_FORK_MESSAGE_BEFORE_ACTIVE_TURN_METHOD, } from "./AcpExtensions"; import { createCollabAgentToolCallUpdate, @@ -268,7 +268,7 @@ export class CodexAcpServer { steer: CODEX_STEER_CAPABILITY, }, lody: { - forkPointBeforeActiveTurn: {version: 1}, + forkAtMessage: {version: 1, beforeActiveTurn: true}, }, }, }, @@ -669,20 +669,20 @@ export class CodexAcpServer { }; } - async resolveForkPointBeforeActiveTurn(params: {sessionId: string}): Promise<{forkPoint: string}> { + async resolveMessageBeforeActiveTurn(params: {sessionId: string}): Promise<{messageId: string}> { const session = this.sessions.get(params.sessionId); const activeTurnId = session?.currentTurnId; if (!activeTurnId) { throw RequestError.invalidRequest("Session has no active turn"); } - const forkPoint = await this.runWithProcessCheck(() => - this.codexAcpClient.findForkPointBeforeTurn(params.sessionId, activeTurnId) + const messageId = await this.runWithProcessCheck(() => + this.codexAcpClient.findMessageBeforeTurn(params.sessionId, activeTurnId) ); - logger.log("Resolved fork point before active turn", { + logger.log("Resolved ACP message before active turn", { sessionId: params.sessionId, - method: LODY_FORK_POINT_BEFORE_ACTIVE_TURN_METHOD, + method: LODY_FORK_MESSAGE_BEFORE_ACTIVE_TURN_METHOD, }); - return {forkPoint}; + return {messageId}; } async listSessions(params: acp.ListSessionsRequest): Promise { @@ -1879,10 +1879,7 @@ export class CodexAcpServer { return { stopReason: "end_turn", usage: this.buildPromptUsage(sessionState.lastTokenUsage), - _meta: this.buildQuotaMeta( - sessionState, - commandResult.turnCompleted?.turn.id, - ), + _meta: this.buildQuotaMeta(sessionState), }; } @@ -1969,7 +1966,7 @@ export class CodexAcpServer { return { stopReason: "end_turn", usage: this.buildPromptUsage(sessionState.lastTokenUsage), - _meta: this.buildQuotaMeta(sessionState, turnCompleted.turn.id), + _meta: this.buildQuotaMeta(sessionState), }; } catch (err) { logger.error(`Prompt for session ${params.sessionId} failed`, err); @@ -2005,10 +2002,7 @@ export class CodexAcpServer { }); } - private buildQuotaMeta( - sessionState: SessionState, - forkPoint?: string, - ): { quota: QuotaMeta, lody?: { forkPoint: string } } { + private buildQuotaMeta(sessionState: SessionState): { quota: QuotaMeta } { const lastTokenUsage = sessionState.lastTokenUsage; // Remove the "[reasoning-level]" suffix from currentModelId if present @@ -2024,7 +2018,6 @@ export class CodexAcpServer { token_count: sessionState.lastTokenUsage, model_usage: modelUsage }, - ...(forkPoint ? {lody: {forkPoint}} : {}), }; } diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index f206fddf..94bd8b14 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -1421,9 +1421,6 @@ describe('ACP server test', { timeout: 40_000 }, () => { completeReview(createReviewCompletedNotification()); await expect(promptPromise).resolves.toEqual(expect.objectContaining({ stopReason: "end_turn", - _meta: expect.objectContaining({ - lody: {forkPoint: "review-turn-id"}, - }), })); expect(promptResolved).toBe(true); }); diff --git a/src/__tests__/CodexACPAgent/data/token-usage-end-turn.json b/src/__tests__/CodexACPAgent/data/token-usage-end-turn.json index 381dfb51..60ba2e2e 100644 --- a/src/__tests__/CodexACPAgent/data/token-usage-end-turn.json +++ b/src/__tests__/CodexACPAgent/data/token-usage-end-turn.json @@ -28,9 +28,6 @@ } } ] - }, - "lody": { - "forkPoint": "turn-id" } } } diff --git a/src/__tests__/CodexACPAgent/data/token-usage-multiple-updates.json b/src/__tests__/CodexACPAgent/data/token-usage-multiple-updates.json index 377425ab..aebc1783 100644 --- a/src/__tests__/CodexACPAgent/data/token-usage-multiple-updates.json +++ b/src/__tests__/CodexACPAgent/data/token-usage-multiple-updates.json @@ -28,9 +28,6 @@ } } ] - }, - "lody": { - "forkPoint": "turn-id" } } } diff --git a/src/__tests__/CodexACPAgent/data/token-usage-null.json b/src/__tests__/CodexACPAgent/data/token-usage-null.json index c4c205e4..65196087 100644 --- a/src/__tests__/CodexACPAgent/data/token-usage-null.json +++ b/src/__tests__/CodexACPAgent/data/token-usage-null.json @@ -5,9 +5,6 @@ "quota": { "token_count": null, "model_usage": [] - }, - "lody": { - "forkPoint": "turn-id" } } } diff --git a/src/__tests__/CodexACPAgent/initialize.test.ts b/src/__tests__/CodexACPAgent/initialize.test.ts index 3fbb1705..1c15c11c 100644 --- a/src/__tests__/CodexACPAgent/initialize.test.ts +++ b/src/__tests__/CodexACPAgent/initialize.test.ts @@ -70,8 +70,9 @@ describe('CodexACPAgent - initialize', () => { }, }, lody: { - forkPointBeforeActiveTurn: { + forkAtMessage: { version: 1, + beforeActiveTurn: true, }, }, }, diff --git a/src/__tests__/CodexACPAgent/session-fork.test.ts b/src/__tests__/CodexACPAgent/session-fork.test.ts index 814db3c8..a424fd92 100644 --- a/src/__tests__/CodexACPAgent/session-fork.test.ts +++ b/src/__tests__/CodexACPAgent/session-fork.test.ts @@ -21,7 +21,14 @@ describe("ACP session fork", () => { vi.spyOn(codexAppServerClient, "skillsExtraRootsSet").mockResolvedValue(undefined); vi.spyOn(codexAppServerClient, "listSkills").mockResolvedValue({data: []}); vi.spyOn(codexAppServerClient, "configRead").mockResolvedValue({config: {}} as never); - const threadReadSpy = vi.spyOn(codexAppServerClient, "threadRead"); + const threadReadSpy = vi.spyOn(codexAppServerClient, "threadRead").mockResolvedValue({ + thread: { + turns: [{ + id: "completed-turn-id", + items: [{type: "agentMessage", id: "assistant-message-id"}], + }], + }, + } as never); const threadForkSpy = vi.spyOn(codexAppServerClient, "threadFork").mockResolvedValue({ thread: {id: "child-session-id"}, model: model.id, @@ -42,7 +49,10 @@ describe("ACP session fork", () => { mcpServers: [mcpServer], _meta: { lody: { - forkPoint: "completed-turn-id", + forkAtMessage: { + version: 1, + messageId: "assistant-message-id", + }, }, }, }, subscribed); @@ -79,7 +89,10 @@ describe("ACP session fork", () => { }, }, }); - expect(threadReadSpy).not.toHaveBeenCalled(); + expect(threadReadSpy).toHaveBeenCalledWith({ + threadId: "source-session-id", + includeTurns: true, + }); }); it("resolves the last terminal Codex turn before the active turn", async () => { @@ -89,15 +102,19 @@ describe("ACP session fork", () => { vi.spyOn(codexAppServerClient, "threadRead").mockResolvedValue({ thread: { turns: [ - {id: "completed-turn", status: "completed"}, - {id: "active-turn", status: "inProgress"}, + { + id: "completed-turn", + status: "completed", + items: [{type: "agentMessage", id: "assistant-message-id"}], + }, + {id: "active-turn", status: "inProgress", items: []}, ], }, } as never); await expect( - codexAcpClient.findForkPointBeforeTurn("source-session-id", "active-turn"), - ).resolves.toBe("completed-turn"); + codexAcpClient.findMessageBeforeTurn("source-session-id", "active-turn"), + ).resolves.toBe("assistant-message-id"); }); it("rejects when the active Codex turn changed during capture", async () => { @@ -111,7 +128,7 @@ describe("ACP session fork", () => { } as never); await expect( - codexAcpClient.findForkPointBeforeTurn("source-session-id", "stale-active-turn"), + codexAcpClient.findMessageBeforeTurn("source-session-id", "stale-active-turn"), ).rejects.toThrow("Invalid request"); }); diff --git a/src/index.ts b/src/index.ts index 1efe48a0..cc5bc28c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,7 +14,7 @@ import {runLoginCommand} from "./login"; import {runCodexCli} from "./CodexCli"; import { LEGACY_SET_SESSION_MODEL_METHOD, - LODY_FORK_POINT_BEFORE_ACTIVE_TURN_METHOD, + LODY_FORK_MESSAGE_BEFORE_ACTIVE_TURN_METHOD, } from "./AcpExtensions"; const emptyExtensionParamsParser = z.preprocess( @@ -27,7 +27,7 @@ const legacySetSessionModelParamsParser = z.object({ modelId: z.string(), }).passthrough(); -const activeTurnForkPointParamsParser = z.object({ +const activeTurnForkMessageParamsParser = z.object({ sessionId: z.string().min(1), }); @@ -140,8 +140,8 @@ function startAcpServer() { .onRequest("authentication/status", emptyExtensionParamsParser, (ctx) => getAgent().extMethod("authentication/status", ctx.params)) .onRequest("authentication/logout", emptyExtensionParamsParser, (ctx) => getAgent().extMethod("authentication/logout", ctx.params)) .onRequest(LEGACY_SET_SESSION_MODEL_METHOD, legacySetSessionModelParamsParser, (ctx) => getAgent().extMethod(LEGACY_SET_SESSION_MODEL_METHOD, ctx.params)) - .onRequest(LODY_FORK_POINT_BEFORE_ACTIVE_TURN_METHOD, activeTurnForkPointParamsParser, (ctx) => - getAgent().resolveForkPointBeforeActiveTurn(ctx.params) + .onRequest(LODY_FORK_MESSAGE_BEFORE_ACTIVE_TURN_METHOD, activeTurnForkMessageParamsParser, (ctx) => + getAgent().resolveMessageBeforeActiveTurn(ctx.params) ) .connect(acpJsonStream); }