From 699e99b59a5620168f9b89544cad27b4422c2a77 Mon Sep 17 00:00:00 2001 From: terry heath Date: Mon, 12 Oct 2026 11:09:39 -0600 Subject: [PATCH] fix: forward native asynchronous user questions --- readme-dev.md | 11 ++++++ src/AsyncUserInput.ts | 18 ++++++++++ src/CodexAcpServer.ts | 6 +++- src/CodexEventHandler.ts | 3 +- .../agent-message-events.test.ts | 19 +++++++++++ .../data/async-user-input-history.json | 28 +++++++++++++++ .../CodexACPAgent/data/async-user-input.json | 30 ++++++++++++++++ .../CodexACPAgent/load-session.test.ts | 34 +++++++++++++++++++ 8 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 src/AsyncUserInput.ts create mode 100644 src/__tests__/CodexACPAgent/data/async-user-input-history.json create mode 100644 src/__tests__/CodexACPAgent/data/async-user-input.json diff --git a/readme-dev.md b/readme-dev.md index 458c263fc..0e8bcd1d7 100644 --- a/readme-dev.md +++ b/readme-dev.md @@ -125,3 +125,14 @@ The adapter restarts the Codex app-server when it dies (for example, killed for with the stderr of the last crash. A session that the app-server died opening twice in 30 minutes is not opened again for the rest of that time, so one session that is too large for the memory cannot take the other sessions down with it. + +### Asynchronous user questions + +Codex `agentMessage.questions` items are emitted as `agent_message_chunk` updates, +including when their text is empty and during history replay. The text content +contains the question titles and choices for clients without form support. +`_meta["codex/asyncUserInput"]` carries `{id, questions}`, where `id` is the native +item ID and each question has a `title` and nullable `options` array of strings. +Clients can render a persistent form and send the user's answers as a subsequent +user prompt. These questions do not hold an ACP request open and must not be +withdrawn merely because the current prompt completes. diff --git a/src/AsyncUserInput.ts b/src/AsyncUserInput.ts new file mode 100644 index 000000000..38e810495 --- /dev/null +++ b/src/AsyncUserInput.ts @@ -0,0 +1,18 @@ +import type {UpdateSessionEvent} from "./ACPSessionConnection"; +import type {ThreadItem} from "./app-server/v2"; +import {createAgentTextMessageChunk} from "./ContentChunks"; + +export function createAsyncUserInputUpdate(item: Extract): UpdateSessionEvent | null { + if (!item.questions?.length) return null; + const questions = item.questions.map(question => ({ + title: question.title, + options: question.options, + })); + const text = questions.map(question => { + const options = (question.options ?? []).map(option => `- ${option}`).join("\n"); + return options ? `${question.title}\n${options}` : question.title; + }).join("\n\n"); + return createAgentTextMessageChunk(text, item.id, { + "codex/asyncUserInput": {id: item.id, questions}, + }); +} diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index d3f90a8bb..9df16a91f 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -1,3 +1,4 @@ +import {createAsyncUserInputUpdate} from "./AsyncUserInput"; import * as acp from "@agentclientprotocol/sdk"; import {RequestError, type SessionId, type SessionModeState} from "@agentclientprotocol/sdk"; import {CodexEventHandler, type CompletedPlan} from "./CodexEventHandler"; @@ -2825,12 +2826,15 @@ export class CodexAcpServer { return [renderer.render(SubagentActivityReporter.activity(item, "completed", "start"))]; case "agentMessage": { const meta = createMessagePhaseMeta(item.phase, sessionState.clientCapabilities.airClient); - return [{ + const updates: UpdateSessionEvent[] = [{ sessionUpdate: "agent_message_chunk", messageId: item.id, content: { type: "text", text: item.text }, ...(meta ? { _meta: meta } : {}), }]; + const questions = createAsyncUserInputUpdate(item); + if (questions !== null) updates.push(questions); + return updates; } case "reasoning": return this.createReasoningUpdates(item); diff --git a/src/CodexEventHandler.ts b/src/CodexEventHandler.ts index 5c1e6645e..8f459eaf8 100644 --- a/src/CodexEventHandler.ts +++ b/src/CodexEventHandler.ts @@ -1,3 +1,4 @@ +import {createAsyncUserInputUpdate} from "./AsyncUserInput"; import type { ServerNotification } from "./app-server"; @@ -854,7 +855,7 @@ export class CodexEventHandler { return this.renderer.render(this.subagents.legacyCollaborationCompleted(event.item)); case "agentMessage": this.rememberAgentMessagePhase(event.item); - return null; + return createAsyncUserInputUpdate(event.item); case "plan": return await this.createCompletedPlanEvent(event.item); case "exitedReviewMode": diff --git a/src/__tests__/CodexACPAgent/agent-message-events.test.ts b/src/__tests__/CodexACPAgent/agent-message-events.test.ts index 732126cd9..dc63393da 100644 --- a/src/__tests__/CodexACPAgent/agent-message-events.test.ts +++ b/src/__tests__/CodexACPAgent/agent-message-events.test.ts @@ -86,4 +86,23 @@ describe("CodexEventHandler - agent message events", () => { "data/agent-message-phases.json" ); }); + it("delivers textless async questions with a visible fallback and structured metadata", async () => { + const notifications: ServerNotification[] = [{ + method: "item/completed", + params: { + threadId: sessionId, turnId: "turn-1", completedAtMs: 1, + item: { + type: "agentMessage", id: "async-question", text: "", phase: "commentary", + memoryCitation: null, delivery: null, + questions: [{ title: "Proceed with the bounded test?", options: ["Approve", "Keep paused"] }], + }, + }, + }]; + await setupPromptAndSendNotifications(mockFixture, sessionId, sessionState, notifications); + const events = mockFixture.getAcpConnectionEvents([]); + expect(JSON.stringify(events)).toContain("Proceed with the bounded test?"); + expect(JSON.stringify(events)).toContain("codex/asyncUserInput"); + await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot("data/async-user-input.json"); + }); + }); diff --git a/src/__tests__/CodexACPAgent/data/async-user-input-history.json b/src/__tests__/CodexACPAgent/data/async-user-input-history.json new file mode 100644 index 000000000..96c27c2f4 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/async-user-input-history.json @@ -0,0 +1,28 @@ +[ + { + "sessionUpdate": "agent_message_chunk", + "messageId": "question-1", + "content": { + "type": "text", + "text": "Which color?\n- Blue\n- Green\n\nWhy?" + }, + "_meta": { + "codex/asyncUserInput": { + "id": "question-1", + "questions": [ + { + "title": "Which color?", + "options": [ + "Blue", + "Green" + ] + }, + { + "title": "Why?", + "options": null + } + ] + } + } + } +] \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/async-user-input.json b/src/__tests__/CodexACPAgent/data/async-user-input.json new file mode 100644 index 000000000..012c04c1c --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/async-user-input.json @@ -0,0 +1,30 @@ +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "agent_message_chunk", + "messageId": "async-question", + "content": { + "type": "text", + "text": "Proceed with the bounded test?\n- Approve\n- Keep paused" + }, + "_meta": { + "codex/asyncUserInput": { + "id": "async-question", + "questions": [ + { + "title": "Proceed with the bounded test?", + "options": [ + "Approve", + "Keep paused" + ] + } + ] + } + } + } + } + ] +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/load-session.test.ts b/src/__tests__/CodexACPAgent/load-session.test.ts index eefdb3529..9fa4e766e 100644 --- a/src/__tests__/CodexACPAgent/load-session.test.ts +++ b/src/__tests__/CodexACPAgent/load-session.test.ts @@ -4,6 +4,40 @@ import { createCodexMockTestFixture, createTestModel } from "../acp-test-utils"; import type { Model, Thread, ThreadGoal, UserInput } from "../../app-server/v2"; describe("CodexACPAgent - loadSession", () => { + it("replays structured async questions from a textless history item", async () => { + const fixture = createCodexMockTestFixture(); + const agent = fixture.getCodexAcpAgent(); + const client = fixture.getCodexAcpClient(); + const appServer = fixture.getCodexAppServerClient(); + client.readAuthRequirement = vi.fn().mockResolvedValue({required: false, account: null}); + client.getAccount = vi.fn().mockResolvedValue({account: null, requiresOpenaiAuth: false}); + client.listSkills = vi.fn().mockResolvedValue({data: []}); + const model = createTestModel(); + appServer.listModels = vi.fn().mockResolvedValue({data: [model], nextCursor: null}); + const thread = { + id: "async-history", historyMode: "legacy", turns: [{ + id: "turn-1", itemsView: "full", status: "completed", items: [{ + type: "agentMessage", id: "question-1", text: "", phase: "commentary", + memoryCitation: null, delivery: null, + questions: [{title: "Which color?", options: ["Blue", "Green"]}, {title: "Why?", options: null}], + }], + }], + } as unknown as Thread; + appServer.threadResume = vi.fn().mockResolvedValue({ + thread, model: model.id, modelProvider: "openai", cwd: "/workspace", + approvalPolicy: "never", sandbox: {type: "dangerFullAccess"}, reasoningEffort: model.defaultReasoningEffort, + }); + appServer.threadReadWithHistory = vi.fn().mockResolvedValue({thread}); + await agent.initialize({protocolVersion: 1, clientCapabilities: {}}); + await agent.loadSession({sessionId: thread.id, cwd: "/workspace", mcpServers: []}); + const updates = fixture.getAcpConnectionEvents([]) + .filter(event => event.method === "sessionUpdate") + .map(event => event.args[0].update) + .filter(update => update._meta?.["codex/asyncUserInput"]); + expect(updates).toHaveLength(1); + await expect(JSON.stringify(updates, null, 2)).toMatchFileSnapshot("data/async-user-input-history.json"); + }); + it("preserves every native user input kind during history replay", async () => { const cases: Array<{input: UserInput, content: acp.ContentBlock}> = [ {input: {type: "text", text: "Request", text_elements: []}, content: {type: "text", text: "Request"}},