From 4433cdbb7e63675114758566612724161e2f6253 Mon Sep 17 00:00:00 2001 From: djson9 Date: Tue, 25 Aug 2026 14:37:34 +0000 Subject: [PATCH] feat: add prompt-required idle steering fallback --- src/AcpExtensions.ts | 9 +++- src/CodexAcpServer.ts | 22 +++++++- .../CodexACPAgent/steer-events.test.ts | 54 +++++++++++++++++++ 3 files changed, 83 insertions(+), 2 deletions(-) diff --git a/src/AcpExtensions.ts b/src/AcpExtensions.ts index a469d515..db605e06 100644 --- a/src/AcpExtensions.ts +++ b/src/AcpExtensions.ts @@ -99,10 +99,17 @@ export async function legacySetSessionModel( export type SessionSteerRequest = { sessionId: SessionId; prompt: ContentBlock[]; + _meta?: { + [key: string]: unknown; + steering?: { + idleBehavior?: "promptRequired"; + }; + } | null; } export type SessionSteeringResponse = { - outcome: "injected" | "startedNewTurn" | "failed"; + outcome: "injected" | "startedNewTurn" | "failed" | "promptRequired"; + reason?: "noRunningTurn"; } export type SessionSteeringExtRequest = { diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index f3ebb373..b1dae241 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -1254,6 +1254,9 @@ export class CodexAcpServer { return {outcome: "injected"}; } } + if (params._meta?.steering?.idleBehavior === "promptRequired") { + return {outcome: "promptRequired", reason: "noRunningTurn"}; + } return await this.startNewTurnFromSteering(params); } @@ -1431,13 +1434,30 @@ export class CodexAcpServer { private parseSessionSteerParams(params: Record): SessionSteerRequest { const sessionId = params["sessionId"]; const prompt = params["prompt"]; + const meta = params["_meta"]; if (typeof sessionId !== "string" || !Array.isArray(prompt)) { throw RequestError.invalidParams(); } - return { + const steering = meta && typeof meta === "object" + ? (meta as Record)["steering"] + : undefined; + const idleBehavior = steering && typeof steering === "object" + ? (steering as Record)["idleBehavior"] + : undefined; + if (idleBehavior !== undefined && idleBehavior !== "promptRequired") { + throw RequestError.invalidParams(undefined, "unsupported steering idleBehavior"); + } + const request = { sessionId: sessionId, prompt: prompt as acp.ContentBlock[], }; + if (meta === undefined) { + return request; + } + return { + ...request, + _meta: meta as Exclude, + }; } private createSessionConfigOptions(sessionState: SessionState): Array { diff --git a/src/__tests__/CodexACPAgent/steer-events.test.ts b/src/__tests__/CodexACPAgent/steer-events.test.ts index c13a719b..41ac9b45 100644 --- a/src/__tests__/CodexACPAgent/steer-events.test.ts +++ b/src/__tests__/CodexACPAgent/steer-events.test.ts @@ -109,6 +109,50 @@ describe('_session/steering', () => { }); }); + it('returns promptRequired without starting a turn when the host owns the idle fallback', async () => { + const mockFixture = createCodexMockTestFixture(); + const sessionState = createTestSessionState(); + vi.spyOn(mockFixture.getCodexAcpAgent(), "getSessionState").mockReturnValue(sessionState); + const turnStartSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "turnStart"); + + await expect(mockFixture.getCodexAcpAgent().extMethod(SESSION_STEERING_METHOD, { + sessionId: "session-id", + prompt: [{type: "text", text: "too late for the previous turn"}], + _meta: {steering: {idleBehavior: "promptRequired"}}, + })).resolves.toEqual({outcome: "promptRequired", reason: "noRunningTurn"}); + + expect(turnStartSpy).not.toHaveBeenCalled(); + }); + + it('returns promptRequired when the tracked turn ends during injection', async () => { + const {mockFixture, sessionState, turnCompleted} = startActiveTurn(); + vi.spyOn(mockFixture.getCodexAppServerClient(), "turnSteer").mockImplementation(async () => { + turnCompleted.resolve({ + threadId: "session-id", + turn: createTurn("turn-id", "completed"), + }); + throw Object.assign(new Error("Internal error"), { + data: {details: "no active turn to steer"}, + }); + }); + + const promptPromise = mockFixture.getCodexAcpAgent().prompt({ + sessionId: "session-id", + prompt: [{type: "text", text: "long running prompt"}], + }); + await vi.waitFor(() => { + expect(sessionState.currentTurnId).toBe("turn-id"); + }); + + await expect(mockFixture.getCodexAcpAgent().extMethod(SESSION_STEERING_METHOD, { + sessionId: "session-id", + prompt: [{type: "text", text: "racing follow-up"}], + _meta: {steering: {idleBehavior: "promptRequired"}}, + })).resolves.toEqual({outcome: "promptRequired", reason: "noRunningTurn"}); + await expect(promptPromise).resolves.toMatchObject({stopReason: "end_turn"}); + expect(sessionState.currentTurnId).toBeNull(); + }); + it('starts a new turn when Codex reports that the tracked turn is no longer active', async () => { const {mockFixture, sessionState, turnCompleted} = startActiveTurn(); const nextTurnCompleted = deferred(); @@ -212,6 +256,16 @@ describe('_session/steering', () => { })).rejects.toThrow(RequestError); }); + it('rejects unsupported idle steering behavior', async () => { + const mockFixture = createCodexMockTestFixture(); + + await expect(mockFixture.getCodexAcpAgent().extMethod(SESSION_STEERING_METHOD, { + sessionId: "session-id", + prompt: [{type: "text", text: "do not guess how to handle this"}], + _meta: {steering: {idleBehavior: "unsupported"}}, + })).rejects.toThrow("unsupported steering idleBehavior"); + }); + it('rejects image input when the model does not support it', async () => { const {mockFixture} = startActiveTurn({supportedInputModalities: ["text"]}); const turnSteerSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "turnSteer");