diff --git a/packages/host-daemon-contract/src/protocol.ts b/packages/host-daemon-contract/src/protocol.ts index acc5e5d3a3..c3d0dd1ed1 100644 --- a/packages/host-daemon-contract/src/protocol.ts +++ b/packages/host-daemon-contract/src/protocol.ts @@ -1,3 +1,3 @@ -export const HOST_DAEMON_PROTOCOL_VERSION = 212 as const; +export const HOST_DAEMON_PROTOCOL_VERSION = 213 as const; export const HOST_ARTIFACT_MAX_BYTES = 256 * 1024 * 1024; diff --git a/packages/host-daemon-contract/test/contract.test.ts b/packages/host-daemon-contract/test/contract.test.ts index 971f7f31d5..c393bb9e31 100644 --- a/packages/host-daemon-contract/test/contract.test.ts +++ b/packages/host-daemon-contract/test/contract.test.ts @@ -1066,7 +1066,7 @@ const CONTRIBUTED_ENV = [ describe("host-daemon command schemas", () => { it("uses the current host-daemon protocol version", () => { - expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(212); + expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(213); expect(HOST_ARTIFACT_MAX_BYTES).toBe(256 * 1024 * 1024); }); diff --git a/plugins/provider-codex/src/bridge/app-server-connection.ts b/plugins/provider-codex/src/bridge/app-server-connection.ts index c8272909a5..1b46d1ebed 100644 --- a/plugins/provider-codex/src/bridge/app-server-connection.ts +++ b/plugins/provider-codex/src/bridge/app-server-connection.ts @@ -59,6 +59,16 @@ export class CodexAppServerExitedError extends Error { } } +export class CodexAppServerRpcError extends Error { + constructor( + message: string, + readonly code: number | undefined, + ) { + super(message); + this.name = "CodexAppServerRpcError"; + } +} + interface PendingChildRequest { resolve(value: unknown): void; reject(error: Error): void; @@ -256,9 +266,10 @@ export function createCodexAppServerConnection( } if (message.error) { request.reject( - new Error( + new CodexAppServerRpcError( message.error.message ?? `codex app-server returned error code ${message.error.code ?? "unknown"}`, + message.error.code, ), ); } else { diff --git a/plugins/provider-codex/src/bridge/bridge.ts b/plugins/provider-codex/src/bridge/bridge.ts index 6bf3ef14d2..c9ff4e4ca6 100644 --- a/plugins/provider-codex/src/bridge/bridge.ts +++ b/plugins/provider-codex/src/bridge/bridge.ts @@ -60,6 +60,7 @@ import { } from "../interactive-requests.js"; import { parseModelsResponse } from "../models.js"; import { macOsPermissionPresentation } from "../presentation.js"; +import { codexTurnSchema } from "../schemas.js"; import { resolveCodexInstructionOverrides, toCodexDynamicTools, @@ -79,6 +80,7 @@ import { import { createCodexAppServerConnection, CodexAppServerExitedError, + CodexAppServerRpcError, type CodexAppServerConnection, type CodexAppServerExitInfo, type CodexAppServerRequestResponder, @@ -408,6 +410,39 @@ interface CodexSessionConstruction { dynamicTools: DynamicTool[] | undefined; } +interface ResponseOpenedTurn { + nativeStarted: boolean; + waiters: Array<(started: boolean) => void>; +} + +const codexTurnNotificationPeekSchema = z + .object({ + threadId: z.string(), + turn: z.object({ id: z.string() }).passthrough(), + }) + .passthrough(); + +interface UnopenedDispatch { + clientRequestId: TurnStartParamsShape["clientRequestId"]; + prepared: PreparedProviderCommandDispatch; +} + +interface PendingCompactionDispatch extends UnopenedDispatch { + accepted: boolean; + outcome: UnopenedDispatchOutcome | null; +} + +type UnopenedDispatchOutcome = + | { status: "completed" } + | { status: "failed"; error: { message: string } }; + +const codexThreadStatusChangedParamsSchema = z + .object({ + threadId: z.string(), + status: z.object({ type: z.string() }).passthrough(), + }) + .passthrough(); + interface CodexBridgeSession { bbThreadId: string; codexThreadId: string | null; @@ -417,6 +452,8 @@ interface CodexBridgeSession { construction: CodexSessionConstruction; constructionSignature: string; openCodexTurnIds: Set; + responseOpenedTurns: Map; + unopenedCompactionDispatches: PendingCompactionDispatch[]; turnSettledWaiters: Map void>>; awaitingReplayedUsage: boolean; identityAnnounced: boolean; @@ -541,6 +578,12 @@ function sendThreadDeltas( } const outDeltas: ThreadDelta[] = []; for (const delta of deltas) { + if (delta.kind === "input.accepted") { + session.unopenedCompactionDispatches = + session.unopenedCompactionDispatches.filter( + (dispatch) => dispatch.clientRequestId !== delta.clientRequestId, + ); + } if (delta.kind === "turn.open") { session.awaitingReplayedUsage = false; if (delta.providerTurnId !== undefined) { @@ -549,6 +592,7 @@ function sendThreadDeltas( } if (delta.kind === "turn.boundary" && delta.providerTurnId !== undefined) { session.openCodexTurnIds.delete(delta.providerTurnId); + settleResponseOpenedTurn(session, delta.providerTurnId); const waiters = session.turnSettledWaiters.get(delta.providerTurnId); if (waiters !== undefined) { session.turnSettledWaiters.delete(delta.providerTurnId); @@ -631,6 +675,15 @@ function handleChildNotification( announceSessionIdentity(session, parsed.data.thread.id); } } + if (method === "thread/status/changed") { + settleCompactionDispatchesWhenCodexIsNotRunning(session, params); + } + if (method === "turn/started") { + const parsed = codexTurnNotificationPeekSchema.safeParse(params); + if (parsed.success && parsed.data.threadId === session.codexThreadId) { + markResponseOpenedTurnNativelyStarted(session, parsed.data.turn.id); + } + } const deltas = session.translator.translateEvent( toProviderRuntimeEvent(method, params), ); @@ -811,6 +864,18 @@ function handleChildExit( })), ); session.openCodexTurnIds.clear(); + for (const codexTurnId of [...session.responseOpenedTurns.keys()]) { + settleResponseOpenedTurn(session, codexTurnId); + } + const unopenedCompactions = session.unopenedCompactionDispatches; + session.unopenedCompactionDispatches = []; + for (const dispatch of unopenedCompactions) { + if (!dispatch.accepted) continue; + settleUnopenedDispatch(session, dispatch, { + status: "failed", + error: { message }, + }); + } sendNotification(BRIDGE_NOTIFICATION_METHODS.error, { threadId: session.bbThreadId, ...(session.codexThreadId !== null @@ -952,6 +1017,8 @@ async function constructThreadSession( decoded.sessionOptions, ), openCodexTurnIds: new Set(), + responseOpenedTurns: new Map(), + unopenedCompactionDispatches: [], turnSettledWaiters: new Map(), awaitingReplayedUsage: args.request.kind !== "start", identityAnnounced: false, @@ -1110,6 +1177,8 @@ function registerResumableSession(session: CodexBridgeSession): void { construction: session.construction, constructionSignature: session.constructionSignature, openCodexTurnIds: new Set(), + responseOpenedTurns: new Map(), + unopenedCompactionDispatches: [], turnSettledWaiters: new Map(), awaitingReplayedUsage: true, identityAnnounced: session.identityAnnounced, @@ -1387,10 +1456,153 @@ async function requireLiveSessionForTurn( return { session, connection: session.connection }; } +const codexTurnStartResultSchema = z + .object({ turn: codexTurnSchema }) + .passthrough(); + +function settleAcceptedDispatch(args: { + clientRequestId: TurnStartParamsShape["clientRequestId"]; + compaction: boolean; + prepared: PreparedProviderCommandDispatch | null; + session: CodexBridgeSession; + result: unknown; +}): void { + const { clientRequestId, prepared, session, result } = args; + if (args.compaction) { + const dispatch = session.unopenedCompactionDispatches.find( + (pending) => pending.clientRequestId === clientRequestId, + ); + if (dispatch !== undefined) { + dispatch.accepted = true; + if (dispatch.outcome !== null) { + settleUnopenedDispatch(session, dispatch, dispatch.outcome); + } + } + return; + } + const parsed = codexTurnStartResultSchema.safeParse(result); + if (!parsed.success) { + scheduleZeroWorkTurnSettlement({ clientRequestId, prepared, session }); + return; + } + if (prepared === null) { + return; + } + const live = currentSession(session.bbThreadId, session.serial); + if (!live || live.codexThreadId === null) { + return; + } + const codexTurnId = parsed.data.turn.id; + const turnAlreadyOpen = live.openCodexTurnIds.has(codexTurnId); + sendThreadDeltas( + live, + live.translator.openTurnFromStartResponse({ + providerThreadId: live.codexThreadId, + turn: parsed.data.turn, + clientRequestId, + turnAlreadyOpen, + }), + ); + if ( + !turnAlreadyOpen && + live.openCodexTurnIds.has(codexTurnId) && + !live.responseOpenedTurns.has(codexTurnId) + ) { + live.responseOpenedTurns.set(codexTurnId, { + nativeStarted: false, + waiters: [], + }); + } +} + +function markResponseOpenedTurnNativelyStarted( + session: CodexBridgeSession, + codexTurnId: string, +): void { + const turn = session.responseOpenedTurns.get(codexTurnId); + if (turn === undefined || turn.nativeStarted) { + return; + } + turn.nativeStarted = true; + const waiters = turn.waiters; + turn.waiters = []; + for (const resolve of waiters) { + resolve(true); + } +} + +function settleResponseOpenedTurn( + session: CodexBridgeSession, + codexTurnId: string, +): void { + const turn = session.responseOpenedTurns.get(codexTurnId); + if (turn === undefined) { + return; + } + session.responseOpenedTurns.delete(codexTurnId); + for (const resolve of turn.waiters) { + resolve(false); + } +} + +function waitForNativeTurnStart( + session: CodexBridgeSession, + codexTurnId: string, + timeoutMs: number, +): Promise { + const turn = session.responseOpenedTurns.get(codexTurnId); + if (turn === undefined) { + return Promise.resolve(false); + } + if (turn.nativeStarted) { + return Promise.resolve(true); + } + return new Promise((resolve) => { + const onStart = (started: boolean): void => { + clearTimeout(timer); + resolve(started); + }; + const timer = setTimeout(() => { + turn.waiters = turn.waiters.filter((waiter) => waiter !== onStart); + resolve(false); + }, timeoutMs); + timer.unref?.(); + turn.waiters.push(onStart); + }); +} + const ZERO_WORK_SETTLEMENT_GRACE_MS = 250; let syntheticZeroWorkTurnCounter = 0; +function settleUnopenedDispatch( + session: CodexBridgeSession, + dispatch: UnopenedDispatch, + outcome: UnopenedDispatchOutcome, +): void { + if (!dispatch.prepared.claim()) { + return; + } + syntheticZeroWorkTurnCounter += 1; + const providerTurnId = `zero-work-${syntheticZeroWorkTurnCounter}`; + sendThreadDeltas(session, [ + { kind: "turn.open", providerTurnId }, + { + kind: "input.accepted", + clientRequestId: dispatch.clientRequestId, + providerTurnId, + }, + outcome.status === "completed" + ? { kind: "turn.boundary", providerTurnId, status: "completed" } + : { + kind: "turn.boundary", + providerTurnId, + status: "failed", + error: outcome.error, + }, + ]); +} + function scheduleZeroWorkTurnSettlement(args: { clientRequestId: TurnStartParamsShape["clientRequestId"]; prepared: PreparedProviderCommandDispatch | null; @@ -1406,20 +1618,74 @@ function scheduleZeroWorkTurnSettlement(args: { if (!live || live.openCodexTurnIds.size > 0) { return; } - if (!prepared.claim()) { - return; - } - syntheticZeroWorkTurnCounter += 1; - const providerTurnId = `zero-work-${syntheticZeroWorkTurnCounter}`; - sendThreadDeltas(live, [ - { kind: "turn.open", providerTurnId }, - { kind: "input.accepted", clientRequestId, providerTurnId }, - { kind: "turn.boundary", providerTurnId, status: "completed" }, - ]); + settleUnopenedDispatch( + live, + { clientRequestId, prepared }, + { status: "completed" }, + ); }, ZERO_WORK_SETTLEMENT_GRACE_MS); timer.unref?.(); } +function awaitCompactionTurn(args: { + clientRequestId: TurnStartParamsShape["clientRequestId"]; + prepared: PreparedProviderCommandDispatch | null; + session: CodexBridgeSession; +}): void { + const { clientRequestId, prepared, session } = args; + if (prepared === null) { + return; + } + const live = currentSession(session.bbThreadId, session.serial); + if (!live) { + return; + } + live.unopenedCompactionDispatches.push({ + clientRequestId, + prepared, + accepted: false, + outcome: null, + }); +} + +function settleCompactionDispatchesWhenCodexIsNotRunning( + session: CodexBridgeSession, + params: unknown, +): void { + if (session.unopenedCompactionDispatches.length === 0) { + return; + } + const parsed = codexThreadStatusChangedParamsSchema.safeParse(params); + if ( + !parsed.success || + parsed.data.threadId !== session.codexThreadId || + session.openCodexTurnIds.size > 0 + ) { + return; + } + const status = parsed.data.status.type; + if (status !== "idle" && status !== "systemError") { + return; + } + const outcome: UnopenedDispatchOutcome = + status === "idle" + ? { status: "completed" } + : { + status: "failed", + error: { + message: + "codex reported a system error before the compaction turn started", + }, + }; + for (const dispatch of session.unopenedCompactionDispatches) { + if (dispatch.accepted) { + settleUnopenedDispatch(session, dispatch, outcome); + } else { + dispatch.outcome = outcome; + } + } +} + async function handleTurnStart( id: string | number, params: TurnStartParamsShape, @@ -1450,9 +1716,16 @@ async function handleTurnStart( providerThreadId: codexThreadId, }); + const compaction = isStandaloneBuiltinCompactCommand(input); try { - if (isStandaloneBuiltinCompactCommand(input)) { - await connection.request({ + let result: unknown; + if (compaction) { + awaitCompactionTurn({ + clientRequestId: params.clientRequestId, + prepared, + session, + }); + result = await connection.request({ method: "thread/compact/start", params: { threadId: codexThreadId }, resultSchema: ignoredChildResultSchema, @@ -1466,7 +1739,7 @@ async function handleTurnStart( ), options: decoded.sessionOptions, }); - await connection.request({ + result = await connection.request({ method: "turn/start", params: { threadId: codexThreadId, @@ -1482,12 +1755,18 @@ async function handleTurnStart( }); } sendResult(id, { threadId: params.threadId }); - scheduleZeroWorkTurnSettlement({ + settleAcceptedDispatch({ clientRequestId: params.clientRequestId, + compaction, prepared, session, + result, }); } catch (error) { + session.unopenedCompactionDispatches = + session.unopenedCompactionDispatches.filter( + (dispatch) => dispatch.clientRequestId !== params.clientRequestId, + ); prepared?.rollback(); sendError( id, @@ -1566,21 +1845,16 @@ async function handleThreadStop( return; } - try { - await session.connection.request({ - method: "turn/interrupt", - params: { - threadId: session.codexThreadId, - turnId: params.activeTurnId, - }, - resultSchema: ignoredChildResultSchema, - timeoutMs: CHILD_REQUEST_TIMEOUT_MS, - }); - } catch (error) { + const interruptFailure = await interruptCodexTurn( + session, + session.codexThreadId, + params.activeTurnId, + ); + if (interruptFailure !== null) { sendError( id, BRIDGE_JSON_RPC_ERRORS.BRIDGE_ERROR, - error instanceof Error ? error.message : String(error), + interruptFailure.message, ); return; } @@ -1608,6 +1882,67 @@ async function handleThreadStop( sendResult(id, { ok: true }); } +async function requestCodexTurnInterrupt( + session: CodexBridgeSession, + codexThreadId: string, + codexTurnId: string, +): Promise { + const connection = session.connection; + if (connection === null || connection.exited) { + return null; + } + try { + await connection.request({ + method: "turn/interrupt", + params: { threadId: codexThreadId, turnId: codexTurnId }, + resultSchema: ignoredChildResultSchema, + timeoutMs: CHILD_REQUEST_TIMEOUT_MS, + }); + return null; + } catch (error) { + return error instanceof Error ? error : new Error(String(error)); + } +} + +async function interruptCodexTurn( + session: CodexBridgeSession, + codexThreadId: string, + codexTurnId: string, +): Promise { + const responseOpened = session.responseOpenedTurns.get(codexTurnId); + const awaitingNativeStart = + responseOpened !== undefined && !responseOpened.nativeStarted; + const failure = await requestCodexTurnInterrupt( + session, + codexThreadId, + codexTurnId, + ); + if ( + failure === null || + !awaitingNativeStart || + !(failure instanceof CodexAppServerRpcError) || + failure.code !== -32600 || + failure.message !== "no active turn to interrupt" + ) { + return failure; + } + if (!session.openCodexTurnIds.has(codexTurnId)) { + return null; + } + const started = await waitForNativeTurnStart( + session, + codexTurnId, + INTERRUPT_SETTLEMENT_TIMEOUT_MS, + ); + if (!session.openCodexTurnIds.has(codexTurnId)) { + return null; + } + if (!started) { + return failure; + } + return requestCodexTurnInterrupt(session, codexThreadId, codexTurnId); +} + function waitForCodexTurnSettlement( session: CodexBridgeSession, codexTurnId: string, diff --git a/plugins/provider-codex/src/bridge/bridge.zero-work-turn.test.ts b/plugins/provider-codex/src/bridge/bridge.zero-work-turn.test.ts index 377444f7b8..ce256a5ac6 100644 --- a/plugins/provider-codex/src/bridge/bridge.zero-work-turn.test.ts +++ b/plugins/provider-codex/src/bridge/bridge.zero-work-turn.test.ts @@ -1,8 +1,11 @@ -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; -import type { ThreadEvent } from "@bb/domain"; +import { + createStandaloneBuiltinCompactCommandInput, + type ThreadEvent, +} from "@bb/domain"; import { experimental_assembleCapturedThreadEvents as assembleCapturedThreadEvents, experimental_createBridgeJsonRpcTestHarness as createBridgeJsonRpcTestHarness, @@ -146,7 +149,7 @@ it("preserves the native checkpoint when thread/stop interrupts a turn", async ( ); }, 30_000); -it("lets a turn/started that lands after the turn/start response win the race", async () => { +it("keeps one lifecycle when turn/started lags the turn/start response past the grace window", async () => { const providerThreadId = await startSession(); harness.sendRequest(2, "turn/start", { threadId: THREAD_ID, @@ -163,17 +166,546 @@ it("lets a turn/started that lands after the turn/start response win the race", await new Promise((resolve) => setTimeout(resolve, 500)); const settledEvents = threadEvents(); - expect( - settledEvents.filter((event) => event.type === "turn/started"), - ).toHaveLength(1); - expect( - settledEvents.filter((event) => event.type === "turn/completed"), - ).toHaveLength(1); + const started = settledEvents.filter( + (event) => event.type === "turn/started", + ); + const completed = settledEvents.filter( + (event) => event.type === "turn/completed", + ); + expect(started).toHaveLength(1); + expect(completed).toHaveLength(1); + const turnId = + started[0]?.scope.kind === "turn" ? started[0].scope.turnId : ""; + expect(turnId).not.toBe(""); + expect(completed[0]).toMatchObject({ + status: "completed", + providerCheckpointId: "turn-fx-1", + scope: { kind: "turn", turnId }, + }); expect( settledEvents.some((event) => event.type === "item/agentMessage/delta"), ).toBe(true); expect( settledEvents.filter((event) => event.type === "turn/input/accepted"), - ).toHaveLength(1); + ).toEqual([ + expect.objectContaining({ + clientRequestId: "creq_atestart23", + scope: { kind: "turn", turnId }, + }), + ]); expect(events.length).toBeGreaterThan(0); }, 30_000); + +it("settles a response-proved turn as failed when codex dies before turn/started", async () => { + const providerThreadId = await startSession(); + harness.sendRequest(2, "turn/start", { + threadId: THREAD_ID, + providerThreadId, + input: [{ type: "text", text: "/respond-then-exit", mentions: [] }], + clientRequestId: "creq_dies234567", + options: { ...FULL_ACCESS_SESSION_OPTIONS }, + }); + await harness.waitForResponse(2); + + const events = await waitForEvents((all) => + all.some((event) => event.type === "turn/completed"), + ); + const started = events.filter((event) => event.type === "turn/started"); + const completed = events.filter((event) => event.type === "turn/completed"); + expect(started).toHaveLength(1); + expect(completed).toHaveLength(1); + const turnId = + started[0]?.scope.kind === "turn" ? started[0].scope.turnId : ""; + expect(turnId).not.toBe(""); + expect(completed[0]).toMatchObject({ + status: "failed", + scope: { kind: "turn", turnId }, + }); + expect( + events.filter((event) => event.type === "turn/input/accepted"), + ).toEqual([ + expect.objectContaining({ + clientRequestId: "creq_dies234567", + scope: { kind: "turn", turnId }, + }), + ]); +}, 30_000); + +it("settles a turn the turn/start response reports as already completed", async () => { + const providerThreadId = await startSession(); + harness.sendRequest(2, "turn/start", { + threadId: THREAD_ID, + providerThreadId, + input: [{ type: "text", text: "/respond-completed", mentions: [] }], + clientRequestId: "creq_instdn2345", + options: { ...FULL_ACCESS_SESSION_OPTIONS }, + }); + await harness.waitForResponse(2); + + const events = await waitForEvents((all) => + all.some((event) => event.type === "turn/completed"), + ); + const started = events.filter((event) => event.type === "turn/started"); + const completed = events.filter((event) => event.type === "turn/completed"); + expect(started).toHaveLength(1); + expect(completed).toHaveLength(1); + const turnId = + started[0]?.scope.kind === "turn" ? started[0].scope.turnId : ""; + expect(turnId).not.toBe(""); + expect(completed[0]).toMatchObject({ + status: "completed", + providerCheckpointId: "turn-fx-1", + scope: { kind: "turn", turnId }, + }); + expect( + events.filter((event) => event.type === "turn/input/accepted"), + ).toEqual([ + expect.objectContaining({ + clientRequestId: "creq_instdn2345", + scope: { kind: "turn", turnId }, + }), + ]); +}, 30_000); + +it("acknowledges a dispatch codex steers into the already-running turn", async () => { + const providerThreadId = await startSession(); + harness.sendRequest(2, "turn/start", { + threadId: THREAD_ID, + providerThreadId, + input: [{ type: "text", text: "/wait-for-interrupt", mentions: [] }], + clientRequestId: "creq_first23456", + options: { ...FULL_ACCESS_SESSION_OPTIONS }, + }); + await harness.waitForResponse(2); + await waitForEvents((events) => + events.some((event) => event.type === "turn/started"), + ); + + harness.sendRequest(3, "turn/start", { + threadId: THREAD_ID, + providerThreadId, + input: [{ type: "text", text: "/steer-into-active", mentions: [] }], + clientRequestId: "creq_steer23456", + options: { ...FULL_ACCESS_SESSION_OPTIONS }, + }); + await harness.waitForResponse(3); + const events = await waitForEvents( + (all) => + all.filter((event) => event.type === "turn/input/accepted").length === 2, + ); + + const started = events.filter((event) => event.type === "turn/started"); + expect(started).toHaveLength(1); + const turnId = + started[0]?.scope.kind === "turn" ? started[0].scope.turnId : ""; + expect(turnId).not.toBe(""); + expect( + events.filter((event) => event.type === "turn/input/accepted"), + ).toEqual([ + expect.objectContaining({ + clientRequestId: "creq_first23456", + scope: { kind: "turn", turnId }, + }), + expect.objectContaining({ + clientRequestId: "creq_steer23456", + scope: { kind: "turn", turnId }, + }), + ]); + expect(events.filter((event) => event.type === "turn/completed")).toEqual([]); + + harness.sendRequest(4, "thread/stop", { + threadId: THREAD_ID, + providerThreadId, + intent: "interrupt", + activeTurnId: "turn-fx-1", + }); + await harness.waitForResponse(4); + await waitForEvents((all) => + all.some( + (event) => + event.type === "turn/completed" && event.status === "interrupted", + ), + ); +}, 30_000); + +it("does not resurrect a response-opened turn settled before its turn/started arrives", async () => { + const providerThreadId = await startSession(); + harness.sendRequest(2, "turn/start", { + threadId: THREAD_ID, + providerThreadId, + input: [{ type: "text", text: "/interrupt-before-start", mentions: [] }], + clientRequestId: "creq_prestart23", + options: { ...FULL_ACCESS_SESSION_OPTIONS }, + }); + await harness.waitForResponse(2); + await waitForEvents((events) => + events.some((event) => event.type === "turn/started"), + ); + + harness.sendRequest(3, "thread/stop", { + threadId: THREAD_ID, + providerThreadId, + intent: "interrupt", + activeTurnId: "turn-fx-1", + }); + await harness.waitForResponse(3); + await new Promise((resolve) => setTimeout(resolve, 200)); + + const events = threadEvents(); + const started = events.filter((event) => event.type === "turn/started"); + expect(started).toHaveLength(1); + const turnId = + started[0]?.scope.kind === "turn" ? started[0].scope.turnId : ""; + expect(turnId).not.toBe(""); + const completed = events.filter((event) => event.type === "turn/completed"); + expect(completed).toHaveLength(1); + expect(completed[0]).toMatchObject({ + status: "interrupted", + scope: { kind: "turn", turnId }, + }); + expect( + events.filter((event) => event.type === "turn/input/accepted"), + ).toEqual([ + expect.objectContaining({ + clientRequestId: "creq_prestart23", + scope: { kind: "turn", turnId }, + }), + ]); +}, 30_000); + +it("interrupts a response-opened turn once codex reports it started", async () => { + const providerThreadId = await startSession(); + harness.sendRequest(2, "turn/start", { + threadId: THREAD_ID, + providerThreadId, + input: [{ type: "text", text: "/late-start-interruptible", mentions: [] }], + clientRequestId: "creq_ntrptate23", + options: { ...FULL_ACCESS_SESSION_OPTIONS }, + }); + await harness.waitForResponse(2); + await waitForEvents((events) => + events.some((event) => event.type === "turn/started"), + ); + + harness.sendRequest(3, "thread/stop", { + threadId: THREAD_ID, + providerThreadId, + intent: "interrupt", + activeTurnId: "turn-fx-1", + }); + const stopped = await harness.waitForResponse(3); + expect(stopped.error).toBeUndefined(); + + const events = await waitForEvents((all) => + all.some((event) => event.type === "turn/completed"), + ); + expect(events.filter((event) => event.type === "turn/started")).toHaveLength( + 1, + ); + expect(events.filter((event) => event.type === "turn/completed")).toEqual([ + expect.objectContaining({ status: "interrupted" }), + ]); +}, 30_000); + +async function compactAndWaitForCompletion( + clientRequestId: string, +): Promise { + const providerThreadId = await startSession(); + harness.sendRequest(2, "turn/start", { + threadId: THREAD_ID, + providerThreadId, + input: createStandaloneBuiltinCompactCommandInput(), + clientRequestId, + options: { ...FULL_ACCESS_SESSION_OPTIONS }, + }); + const response = await harness.waitForResponse(2); + expect(response.error).toBeUndefined(); + await waitForEvents((all) => + all.some((event) => event.type === "turn/completed"), + ); + await new Promise((resolve) => setTimeout(resolve, 400)); + return threadEvents(); +} + +it("waits for a compaction turn that starts long after the empty compact response", async () => { + vi.stubEnv("FAKE_CODEX_COMPACTION_TURN_DELAY_MS", "600"); + const events = await compactAndWaitForCompletion("creq_cmpktate23"); + + const started = events.filter((event) => event.type === "turn/started"); + const completed = events.filter((event) => event.type === "turn/completed"); + expect(started).toHaveLength(1); + expect(completed).toHaveLength(1); + const turnId = + started[0]?.scope.kind === "turn" ? started[0].scope.turnId : ""; + expect(turnId).not.toMatch(/zero-work/); + expect(completed[0]).toMatchObject({ + status: "completed", + scope: { kind: "turn", turnId }, + }); + expect( + events.filter((event) => event.type === "turn/input/accepted"), + ).toEqual([ + expect.objectContaining({ + clientRequestId: "creq_cmpktate23", + scope: { kind: "turn", turnId }, + }), + ]); +}, 30_000); + +it("settles a compaction when codex reports the thread idle without starting a turn", async () => { + vi.stubEnv("FAKE_CODEX_COMPACTION_MODE", "idle-without-turn"); + const events = await compactAndWaitForCompletion("creq_cmpktdey23"); + + const completed = events.filter((event) => event.type === "turn/completed"); + expect(events.filter((event) => event.type === "turn/started")).toHaveLength( + 1, + ); + expect(completed).toEqual([expect.objectContaining({ status: "completed" })]); + expect( + events.filter((event) => event.type === "turn/input/accepted"), + ).toEqual([expect.objectContaining({ clientRequestId: "creq_cmpktdey23" })]); +}, 30_000); + +it("fails a compaction when the app-server exits before its turn starts", async () => { + vi.stubEnv("FAKE_CODEX_COMPACTION_MODE", "exit-before-turn"); + const events = await compactAndWaitForCompletion("creq_cmpktext23"); + + expect(events.filter((event) => event.type === "turn/completed")).toEqual([ + expect.objectContaining({ status: "failed" }), + ]); + expect( + events.filter((event) => event.type === "turn/input/accepted"), + ).toEqual([expect.objectContaining({ clientRequestId: "creq_cmpktext23" })]); +}, 30_000); + +it.each([ + { code: -32603, message: "interrupt storage failure" }, + { code: -32603, message: "no active turn to interrupt" }, +])( + "does not retry an unrelated interrupt rejection $code $message", + async (error) => { + const requestLogPath = join(workspaceDir, "requests.jsonl"); + const scriptPath = join(workspaceDir, "script.json"); + writeFileSync( + scriptPath, + JSON.stringify({ requestLogPath, interruptError: error }), + ); + stubFakeCodexAppServer(scriptPath); + const providerThreadId = await startSession(); + harness.sendRequest(2, "turn/start", { + threadId: THREAD_ID, + providerThreadId, + input: [ + { type: "text", text: "/late-start-interruptible", mentions: [] }, + ], + clientRequestId: "creq_nretry2345", + options: { ...FULL_ACCESS_SESSION_OPTIONS }, + }); + expect((await harness.waitForResponse(2)).error).toBeUndefined(); + await waitForEvents((events) => + events.some((event) => event.type === "turn/started"), + ); + harness.sendRequest(3, "thread/stop", { + threadId: THREAD_ID, + providerThreadId, + intent: "interrupt", + activeTurnId: "turn-fx-1", + }); + const stopped = await harness.waitForResponse(3); + expect(stopped.error).toMatchObject({ message: error.message }); + expect( + readFileSync(requestLogPath, "utf8") + .split("\n") + .filter((line) => line.includes('"method":"turn/interrupt"')), + ).toHaveLength(1); + expect( + threadEvents().filter((event) => event.type === "turn/completed"), + ).toHaveLength(0); + }, +); + +it.each([ + "idle-before-response", + "error-before-response", + "error-without-turn", +])( + "settles compaction on %s", + async (mode) => { + vi.stubEnv("FAKE_CODEX_COMPACTION_MODE", mode); + const events = await compactAndWaitForCompletion("creq_cmpkrace23"); + expect( + events.filter((event) => event.type === "turn/started"), + ).toHaveLength(1); + expect(events.filter((event) => event.type === "turn/completed")).toEqual([ + expect.objectContaining({ + status: mode.startsWith("idle") ? "completed" : "failed", + }), + ]); + expect( + events.filter((event) => event.type === "turn/input/accepted"), + ).toEqual([ + expect.objectContaining({ clientRequestId: "creq_cmpkrace23" }), + ]); + }, + 30_000, +); + +it.each([ + { + name: "native activation before stop", + delay: 450, + script: {}, + attempts: 1, + succeeds: false, + }, + { + name: "native activation before rejection", + delay: 0, + script: { startBeforeInterruptError: true }, + attempts: 2, + succeeds: true, + }, + { + name: "second rejection", + delay: 0, + script: { interruptErrorCount: 2 }, + attempts: 2, + succeeds: false, + }, + { + name: "completion before rejection", + delay: 0, + script: { settleBeforeInterruptError: true }, + attempts: 1, + succeeds: true, + }, + { + name: "activation timeout", + delay: 0, + script: { neverStart: true }, + attempts: 1, + succeeds: false, + }, +])( + "bounds interrupt retry for $name", + async ({ delay, script, attempts, succeeds }) => { + const requestLogPath = join(workspaceDir, "requests.jsonl"); + const scriptPath = join(workspaceDir, "script.json"); + writeFileSync( + scriptPath, + JSON.stringify({ + requestLogPath, + interruptError: { + code: -32600, + message: "no active turn to interrupt", + }, + ...script, + }), + ); + stubFakeCodexAppServer(scriptPath); + const providerThreadId = await startSession(); + harness.sendRequest(2, "turn/start", { + threadId: THREAD_ID, + providerThreadId, + input: [ + { type: "text", text: "/late-start-interruptible", mentions: [] }, + ], + clientRequestId: "creq_bnded23456", + options: { ...FULL_ACCESS_SESSION_OPTIONS }, + }); + expect((await harness.waitForResponse(2)).error).toBeUndefined(); + await waitForEvents((events) => + events.some((event) => event.type === "turn/started"), + ); + if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay)); + harness.sendRequest(3, "thread/stop", { + threadId: THREAD_ID, + providerThreadId, + intent: "interrupt", + activeTurnId: "turn-fx-1", + }); + const stopped = await harness.waitForResponse(3); + if (succeeds) { + expect(stopped.error).toBeUndefined(); + } else { + expect(stopped.error).toMatchObject({ + message: "no active turn to interrupt", + }); + expect( + threadEvents().filter((event) => event.type === "turn/completed"), + ).toHaveLength(0); + } + expect( + readFileSync(requestLogPath, "utf8") + .split("\n") + .filter((line) => line.includes('"method":"turn/interrupt"')), + ).toHaveLength(attempts); + }, + 20_000, +); + +it("binds delayed tool work to the accepted request on consecutive turns", async () => { + const scriptPath = join(workspaceDir, "script.json"); + writeFileSync(scriptPath, JSON.stringify({ lateStartTool: true })); + stubFakeCodexAppServer(scriptPath); + const providerThreadId = await startSession(); + const requests = ["creq_first23456", "creq_after23456"]; + for (const [index, clientRequestId] of requests.entries()) { + harness.sendRequest(index + 2, "turn/start", { + threadId: THREAD_ID, + providerThreadId, + clientRequestId, + input: [{ type: "text", text: "/late-start", mentions: [] }], + options: { ...FULL_ACCESS_SESSION_OPTIONS }, + }); + expect((await harness.waitForResponse(index + 2)).error).toBeUndefined(); + await waitForEvents( + (events) => + events.filter((event) => event.type === "turn/completed").length === + index + 1, + ); + } + const events = threadEvents(); + expect(events.filter((event) => event.type === "turn/started")).toHaveLength( + 2, + ); + const work = events.filter( + (event) => + event.type === "item/started" && event.item.type === "commandExecution", + ); + expect(work).toHaveLength(2); + for (const [index, item] of work.entries()) { + const accepted = events.find( + (event) => + event.type === "turn/input/accepted" && + event.clientRequestId === requests[index], + ); + expect(accepted).toBeDefined(); + if (accepted === undefined) throw new Error("Missing accepted input"); + expect(accepted.scope).toEqual(item.scope); + expect(events.indexOf(accepted)).toBeLessThan(events.indexOf(item)); + } +}); + +it("does not acknowledge a rejected compaction after an early idle status", async () => { + vi.stubEnv("FAKE_CODEX_COMPACTION_MODE", "idle-before-rejection"); + const providerThreadId = await startSession(); + harness.sendRequest(2, "turn/start", { + threadId: THREAD_ID, + providerThreadId, + input: createStandaloneBuiltinCompactCommandInput(), + clientRequestId: "creq_reject2345", + options: { ...FULL_ACCESS_SESSION_OPTIONS }, + }); + expect((await harness.waitForResponse(2)).error).toMatchObject({ + message: "compaction rejected", + }); + expect( + threadEvents().filter( + (event) => + event.type === "turn/started" || + event.type === "turn/input/accepted" || + event.type === "turn/completed", + ), + ).toEqual([]); +}); diff --git a/plugins/provider-codex/src/bridge/fake-codex-app-server.mjs b/plugins/provider-codex/src/bridge/fake-codex-app-server.mjs index 8e6c26ec6c..d8e63b7cb6 100644 --- a/plugins/provider-codex/src/bridge/fake-codex-app-server.mjs +++ b/plugins/provider-codex/src/bridge/fake-codex-app-server.mjs @@ -36,6 +36,8 @@ import { createInterface } from "node:readline"; let threadCounter = 0; let turnCounter = 0; const openTurnIdsByThreadId = new Map(); +const pendingStartTurnIdsByThreadId = new Map(); +let interruptAttempts = 0; const processInstanceId = `${process.pid}-${Date.now()}-${Math.random()}`; function send(message) { @@ -57,15 +59,24 @@ function respondError(id, code, message) { /** The prompt the kit's turn/settles-without-activity scenario sends. */ const ZERO_WORK_PROMPT_TEXT = "/clear"; -/** - * A prompt answered BEFORE any turn notification, whose real turn then arrives - * late. Codex normally emits `turn/started` ahead of its `turn/start` - * response; this inverts that order so the bridge's zero-work settlement has - * to lose the race to the real turn (fabricating a turn from a late signal is - * the ACP bug 0c2f4cc9a). - */ +const COMPACTION_TURN_DELAY_MS = Number( + process.env.FAKE_CODEX_COMPACTION_TURN_DELAY_MS ?? "20", +); +const COMPACTION_MODE = process.env.FAKE_CODEX_COMPACTION_MODE ?? "turn"; + const LATE_TURN_START_PROMPT_TEXT = "/late-start"; -const LATE_TURN_START_DELAY_MS = 60; + +const LATE_START_INTERRUPTIBLE_PROMPT_TEXT = "/late-start-interruptible"; +const lateStartTurnIdsByThreadId = new Map(); +const LATE_TURN_START_DELAY_MS = 350; + +const RESPOND_THEN_EXIT_PROMPT_TEXT = "/respond-then-exit"; + +const RESPOND_COMPLETED_PROMPT_TEXT = "/respond-completed"; + +const STEER_INTO_ACTIVE_PROMPT_TEXT = "/steer-into-active"; + +const INTERRUPT_BEFORE_START_PROMPT_TEXT = "/interrupt-before-start"; /** A prompt that stays open until the client sends turn/interrupt. */ const INTERRUPTIBLE_PROMPT_TEXT = "/wait-for-interrupt"; @@ -101,9 +112,57 @@ const FIXED_TOKEN_USAGE = { modelContextWindow: 258400, }; -function runScriptedTurn(threadId) { +function runCompaction(threadId) { + if (COMPACTION_MODE === "exit-before-turn") { + setTimeout(() => process.exit(1), 20); + return; + } + setTimeout(() => { + if ( + COMPACTION_MODE === "idle-without-turn" || + COMPACTION_MODE === "error-without-turn" + ) { + notify("thread/status/changed", { + threadId, + status: { + type: + COMPACTION_MODE === "error-without-turn" ? "systemError" : "idle", + }, + }); + return; + } + turnCounter += 1; + const turnId = `turn-fx-${turnCounter}`; + const itemId = `compaction-fx-${turnCounter}`; + notify("thread/status/changed", { + threadId, + status: { type: "active", activeFlags: [] }, + }); + notify("turn/started", { + threadId, + turn: { id: turnId, status: "inProgress" }, + }); + notify("item/started", { + threadId, + turnId, + item: { type: "contextCompaction", id: itemId }, + }); + notify("item/completed", { + threadId, + turnId, + item: { type: "contextCompaction", id: itemId }, + }); + notify("thread/status/changed", { threadId, status: { type: "idle" } }); + notify("turn/completed", { + threadId, + turn: { id: turnId, status: "completed" }, + }); + }, COMPACTION_TURN_DELAY_MS); +} + +function runScriptedTurn(threadId, presetTurnId) { turnCounter += 1; - const turnId = `turn-fx-${turnCounter}`; + const turnId = presetTurnId ?? `turn-fx-${turnCounter}`; const itemId = `item-fx-${turnCounter}`; const text = `hello from codex turn ${turnCounter}`; openTurnIdsByThreadId.set(threadId, turnId); @@ -112,6 +171,32 @@ function runScriptedTurn(threadId) { threadId, turn: { id: turnId, status: "inProgress" }, }); + if (script?.lateStartTool && presetTurnId !== undefined) { + const item = { + type: "commandExecution", + id: `command-${turnId}`, + command: "echo verified", + cwd: "/tmp", + processId: null, + status: "inProgress", + commandActions: [], + aggregatedOutput: null, + exitCode: null, + durationMs: null, + }; + notify("item/started", { threadId, turnId, item }); + notify("item/completed", { + threadId, + turnId, + item: { + ...item, + status: "completed", + aggregatedOutput: "verified", + exitCode: 0, + durationMs: 1, + }, + }); + } // Delta-first: no item/started for the agent message. The bridge must // synthesize the opening event. notify("item/agentMessage/delta", { threadId, turnId, itemId, delta: text }); @@ -460,13 +545,58 @@ async function handleRequest(message) { return; } if (firstInputText(params.input) === LATE_TURN_START_PROMPT_TEXT) { - respond(id, {}); + turnCounter += 1; + const turnId = `turn-fx-${turnCounter}`; + respond(id, { turn: { id: turnId, status: "inProgress" } }); setTimeout( - () => runScriptedTurn(params.threadId), + () => runScriptedTurn(params.threadId, turnId), LATE_TURN_START_DELAY_MS, ); return; } + if ( + firstInputText(params.input) === LATE_START_INTERRUPTIBLE_PROMPT_TEXT + ) { + turnCounter += 1; + const turnId = `turn-fx-${turnCounter}`; + lateStartTurnIdsByThreadId.set(params.threadId, turnId); + respond(id, { turn: { id: turnId, status: "inProgress" } }); + if (script?.neverStart) return; + setTimeout(() => { + lateStartTurnIdsByThreadId.delete(params.threadId); + openTurnIdsByThreadId.set(params.threadId, turnId); + notify("turn/started", { + threadId: params.threadId, + turn: { id: turnId, status: "inProgress" }, + }); + }, LATE_TURN_START_DELAY_MS); + return; + } + if (firstInputText(params.input) === RESPOND_THEN_EXIT_PROMPT_TEXT) { + turnCounter += 1; + const turnId = `turn-fx-${turnCounter}`; + respond(id, { turn: { id: turnId, status: "inProgress" } }); + setTimeout(() => process.exit(1), 20); + return; + } + if (firstInputText(params.input) === RESPOND_COMPLETED_PROMPT_TEXT) { + turnCounter += 1; + const turnId = `turn-fx-${turnCounter}`; + respond(id, { turn: { id: turnId, status: "completed" } }); + return; + } + if (firstInputText(params.input) === STEER_INTO_ACTIVE_PROMPT_TEXT) { + const activeTurnId = openTurnIdsByThreadId.get(params.threadId); + respond(id, { turn: { id: activeTurnId, status: "inProgress" } }); + return; + } + if (firstInputText(params.input) === INTERRUPT_BEFORE_START_PROMPT_TEXT) { + turnCounter += 1; + const turnId = `turn-fx-${turnCounter}`; + pendingStartTurnIdsByThreadId.set(params.threadId, turnId); + respond(id, { turn: { id: turnId, status: "inProgress" } }); + return; + } if (firstInputText(params.input) === INTERRUPTIBLE_PROMPT_TEXT) { turnCounter += 1; const turnId = `turn-fx-${turnCounter}`; @@ -490,6 +620,51 @@ async function handleRequest(message) { respond(id, {}); return; case "turn/interrupt": { + interruptAttempts += 1; + if ( + script?.interruptError && + interruptAttempts <= (script.interruptErrorCount ?? 1) + ) { + if (script.startBeforeInterruptError) { + const turnId = lateStartTurnIdsByThreadId.get(params.threadId); + lateStartTurnIdsByThreadId.delete(params.threadId); + openTurnIdsByThreadId.set(params.threadId, turnId); + notify("turn/started", { + threadId: params.threadId, + turn: { id: turnId, status: "inProgress" }, + }); + } + if (script.settleBeforeInterruptError) { + notify("turn/completed", { + threadId: params.threadId, + turn: { id: params.turnId, status: "completed" }, + }); + } + respondError( + id, + script.interruptError.code, + script.interruptError.message, + ); + return; + } + if (lateStartTurnIdsByThreadId.has(params.threadId)) { + respondError(id, -32600, "no active turn to interrupt"); + return; + } + const pendingTurnId = pendingStartTurnIdsByThreadId.get(params.threadId); + if (pendingTurnId !== undefined) { + pendingStartTurnIdsByThreadId.delete(params.threadId); + notify("turn/completed", { + threadId: params.threadId, + turn: { id: pendingTurnId, status: "interrupted" }, + }); + notify("turn/started", { + threadId: params.threadId, + turn: { id: pendingTurnId, status: "inProgress" }, + }); + respond(id, {}); + return; + } const openTurnId = openTurnIdsByThreadId.get(params.threadId); if (openTurnId !== undefined) { openTurnIdsByThreadId.delete(params.threadId); @@ -540,6 +715,33 @@ async function handleRequest(message) { respond(id, {}); return; case "thread/compact/start": + if (COMPACTION_MODE === "idle-before-rejection") { + notify("thread/status/changed", { + threadId: params.threadId, + status: { type: "idle" }, + }); + setTimeout(() => respondError(id, -32600, "compaction rejected"), 30); + return; + } + if ( + COMPACTION_MODE === "idle-before-response" || + COMPACTION_MODE === "error-before-response" + ) { + notify("thread/status/changed", { + threadId: params.threadId, + status: { + type: + COMPACTION_MODE === "idle-before-response" + ? "idle" + : "systemError", + }, + }); + setTimeout(() => respond(id, {}), 30); + return; + } + respond(id, {}); + runCompaction(params.threadId); + return; case "thread/goal/clear": respond(id, {}); return; diff --git a/plugins/provider-codex/src/schemas.ts b/plugins/provider-codex/src/schemas.ts index 0b85050cb1..ffcf4fc159 100644 --- a/plugins/provider-codex/src/schemas.ts +++ b/plugins/provider-codex/src/schemas.ts @@ -539,13 +539,14 @@ const codexTurnErrorSchema = z }) .passthrough(); -const codexTurnSchema = z +export const codexTurnSchema = z .object({ id: z.string(), status: codexTurnStatusSchema, error: codexTurnErrorSchema.nullable().optional(), }) .passthrough(); +export type CodexTurn = z.infer; const codexThreadSchema = z .object({ diff --git a/plugins/provider-codex/src/translator.ts b/plugins/provider-codex/src/translator.ts index ac245f01c5..d2040d19ea 100644 --- a/plugins/provider-codex/src/translator.ts +++ b/plugins/provider-codex/src/translator.ts @@ -24,6 +24,7 @@ import { codexSubAgentActivityItemSchema, codexThreadClosedParamsSchema, type CodexSubAgentActivityItem, + type CodexTurn, } from "./schemas.js"; import { buildCodexConfig, @@ -36,6 +37,15 @@ import { import type { JsonValue } from "./generated/codex-app-server/schema/serde_json/JsonValue.js"; import { subAgentPresentation } from "./presentation.js"; +const codexTurnLifecyclePeekSchema = z + .object({ + threadId: z.string(), + turn: z.object({ id: z.string() }).passthrough(), + }) + .passthrough(); + +const MAX_RESPONSE_HANDLED_TURNS_PER_THREAD = 256; + const CODEX_SHELL_TOOL_NAMES = new Set(["exec_command", "Bash", "bash"]); const CODEX_DELEGATION_TOOL_NAMES = new Set(["spawnAgent", "resumeAgent"]); const TOOL_OUTPUT_MARKER_LINE = "Output:"; @@ -377,6 +387,10 @@ export function createCodexEventTranslator( string, ClientTurnRequestId[] >(); + const responseHandledTurnsByThreadId = new Map< + string, + Map + >(); const pendingWorkspaceWriteGitWritableRootsByThreadId = new Map< string, string[] @@ -555,6 +569,7 @@ export function createCodexEventTranslator( providerThreadId: string; }): ThreadDelta[] { rawCommandOutputStateByProviderThreadId.delete(providerThreadId); + responseHandledTurnsByThreadId.delete(providerThreadId); return clearCodexDelegationParentState(providerThreadId); } @@ -663,6 +678,94 @@ export function createCodexEventTranslator( ); } + function openTurnFromStartResponse(args: { + providerThreadId: string; + turn: CodexTurn; + clientRequestId: ClientTurnRequestId; + turnAlreadyOpen: boolean; + }): ThreadDelta[] { + const { providerThreadId, turn, clientRequestId, turnAlreadyOpen } = args; + const queued = + nativeTurnStartClientRequestIdsByProviderThreadId.get(providerThreadId); + if (queued?.[0] !== clientRequestId) { + return []; + } + if (turnAlreadyOpen) { + removeNativeTurnStartClientRequestId({ + clientRequestId, + providerThreadId, + }); + return [ + { kind: "input.accepted", clientRequestId, providerTurnId: turn.id }, + ]; + } + const startedDeltas = translateEvent({ + jsonrpc: "2.0", + method: "turn/started", + params: { threadId: providerThreadId, turn }, + }); + if (turn.status === "inProgress") { + recordResponseHandledTurn(providerThreadId, turn.id, false); + return startedDeltas; + } + const settledDeltas = translateEvent({ + jsonrpc: "2.0", + method: "turn/completed", + params: { threadId: providerThreadId, turn }, + }); + recordResponseHandledTurn(providerThreadId, turn.id, true); + return [...startedDeltas, ...settledDeltas]; + } + + function recordResponseHandledTurn( + providerThreadId: string, + turnId: string, + completed: boolean, + ): void { + const turns = + responseHandledTurnsByThreadId.get(providerThreadId) ?? + new Map(); + turns.set(turnId, { started: true, completed }); + if (turns.size > MAX_RESPONSE_HANDLED_TURNS_PER_THREAD) { + const oldest = turns.keys().next().value; + if (oldest !== undefined) { + turns.delete(oldest); + } + } + responseHandledTurnsByThreadId.set(providerThreadId, turns); + } + + function consumeResponseHandledTurnLifecycle( + event: ProviderRuntimeEvent, + ): boolean { + const boundary = + event.method === "turn/started" + ? "started" + : event.method === "turn/completed" + ? "completed" + : null; + if (boundary === null) { + return false; + } + const parsed = codexTurnLifecyclePeekSchema.safeParse(event.params); + if (!parsed.success) { + return false; + } + const turns = responseHandledTurnsByThreadId.get(parsed.data.threadId); + const turn = turns?.get(parsed.data.turn.id); + if (!turn?.[boundary]) { + return false; + } + turn[boundary] = false; + if (!turn.started && !turn.completed) { + turns?.delete(parsed.data.turn.id); + if (turns?.size === 0) { + responseHandledTurnsByThreadId.delete(parsed.data.threadId); + } + } + return true; + } + function shiftNativeTurnStartClientRequestId( providerThreadId: string, ): ClientTurnRequestId | undefined { @@ -1536,6 +1639,9 @@ export function createCodexEventTranslator( } function translateEvent(event: ProviderRuntimeEvent): ThreadDelta[] { + if (consumeResponseHandledTurnLifecycle(event)) { + return []; + } const closedThreadDeltas = clearClosedThreadState(event); if (closedThreadDeltas.length > 0) { return closedThreadDeltas; @@ -1579,6 +1685,7 @@ export function createCodexEventTranslator( clearExitedChildThreadState, configureInjectedTools, getThreadGitWritableRoots, + openTurnFromStartResponse, prepareTurnStart: queueNativeTurnStartClientRequestId, prepareWorkspaceWriteGitRoots, translateEvent, diff --git a/plugins/provider-codex/src/translator.turn-response.test.ts b/plugins/provider-codex/src/translator.turn-response.test.ts new file mode 100644 index 0000000000..a29d6c86e0 --- /dev/null +++ b/plugins/provider-codex/src/translator.turn-response.test.ts @@ -0,0 +1,102 @@ +import { expect, it } from "vitest"; +import { createCodexEventTranslator } from "./translator.js"; + +function createResponseOpenedTurn(status: "inProgress" | "completed") { + const translator = createCodexEventTranslator({ + additionalWorkspaceWriteRoots: [], + }); + translator.prepareTurnStart({ + providerThreadId: "parent", + clientRequestId: "creq_parent1234", + }); + const deltas = translator.openTurnFromStartResponse({ + providerThreadId: "parent", + clientRequestId: "creq_parent1234", + turn: { id: "turn-1", status }, + turnAlreadyOpen: false, + }); + expect(deltas.filter((delta) => delta.kind === "turn.open")).toHaveLength(1); + expect( + deltas.filter((delta) => delta.kind === "input.accepted"), + ).toHaveLength(1); + return translator; +} + +it("deduplicates response lifecycles only within their provider thread", () => { + const translator = createResponseOpenedTurn("inProgress"); + const child = translator.translateEvent({ + jsonrpc: "2.0", + method: "turn/started", + params: { threadId: "child", turn: { id: "turn-1", status: "inProgress" } }, + }); + expect(child.some((delta) => delta.kind === "turn.open")).toBe(true); + expect( + translator.translateEvent({ + jsonrpc: "2.0", + method: "turn/started", + params: { + threadId: "parent", + turn: { id: "turn-1", status: "inProgress" }, + }, + }), + ).toEqual([]); +}); + +it("clears response lifecycle dedupe when the provider thread exits", () => { + const translator = createResponseOpenedTurn("completed"); + translator.clearExitedChildThreadState({ providerThreadId: "parent" }); + translator.prepareTurnStart({ + providerThreadId: "parent", + clientRequestId: "creq_resumed123", + }); + const deltas = translator.translateEvent({ + jsonrpc: "2.0", + method: "turn/started", + params: { + threadId: "parent", + turn: { id: "turn-1", status: "inProgress" }, + }, + }); + expect(deltas).toContainEqual({ + kind: "input.accepted", + providerTurnId: "turn-1", + clientRequestId: "creq_resumed123", + }); +}); + +it("deduplicates both delayed boundaries of a response-completed turn", () => { + const translator = createResponseOpenedTurn("completed"); + for (const method of ["turn/started", "turn/completed"]) { + expect( + translator.translateEvent({ + jsonrpc: "2.0", + method, + params: { + threadId: "parent", + turn: { + id: "turn-1", + status: method === "turn/started" ? "inProgress" : "completed", + }, + }, + }), + ).toEqual([]); + } + translator.prepareTurnStart({ + providerThreadId: "parent", + clientRequestId: "creq_follow1234", + }); + expect( + translator.translateEvent({ + jsonrpc: "2.0", + method: "turn/started", + params: { + threadId: "parent", + turn: { id: "turn-2", status: "inProgress" }, + }, + }), + ).toContainEqual({ + kind: "input.accepted", + providerTurnId: "turn-2", + clientRequestId: "creq_follow1234", + }); +});