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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions readme-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
18 changes: 18 additions & 0 deletions src/AsyncUserInput.ts
Original file line number Diff line number Diff line change
@@ -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<ThreadItem, {type: "agentMessage"}>): 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},
});
}
6 changes: 5 additions & 1 deletion src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion src/CodexEventHandler.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {createAsyncUserInputUpdate} from "./AsyncUserInput";
import type {
ServerNotification
} from "./app-server";
Expand Down Expand Up @@ -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":
Expand Down
19 changes: 19 additions & 0 deletions src/__tests__/CodexACPAgent/agent-message-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});

});
28 changes: 28 additions & 0 deletions src/__tests__/CodexACPAgent/data/async-user-input-history.json
Original file line number Diff line number Diff line change
@@ -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
}
]
}
}
}
]
30 changes: 30 additions & 0 deletions src/__tests__/CodexACPAgent/data/async-user-input.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
]
}
}
}
}
]
}
34 changes: 34 additions & 0 deletions src/__tests__/CodexACPAgent/load-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"}},
Expand Down