Skip to content
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Use [OpenAI Codex](https://github.com/openai/codex) from [Agent Client Protocol]
- For AIR, one tool call shape with each fact in one field, as the [tool call contract](docs/air-extensions.md#tool-call-contract) defines. Other clients keep the earlier fields.
- [Native ACP subagent sessions](docs/subagent-sessions.md) (after capability negotiation) with separate child histories and root-routed permissions; a legacy tool-call fallback otherwise.
- Mid-turn input through the [_session/steering extension](docs/steering.md), with an opt-in host-owned fallback when no turn can accept it.
- [Async Codex questions](docs/async-questions.md) as non-blocking form elicitations, with answers steered into the turn that asked them.
- [Background terminal tasks](docs/air-extensions.md#async-tasks) in AIR, with task status and targeted stop support after capability negotiation.
- Session-scoped long-running goals in AIR through the [goal extension](docs/air-extensions.md#goal).
- Typed warnings and errors through the opt-in [AIR session failure extension](docs/air-extensions.md#session-failure).
Expand Down
50 changes: 50 additions & 0 deletions docs/async-questions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Async questions

Codex's `request_user_input_async` tool lets the agent ask the user questions without waiting for
an answer. Unlike the blocking `request_user_input` of Plan mode, it is not an app-server request:
Codex emits an `agentMessage` item with `delivery: "async"` and `questions`, tells the model that
the questions were accepted, and the turn goes on.

## What the client sees

- The message text (the questions and their options) is sent as an ordinary agent message.
- If the client supports form elicitation, the adapter also sends a session-scoped
`elicitation/create` with one field per question. A question with options offers them, plus an
own answer the way Plan mode questions do. Fields are optional, since each question can be
answered on its own. The request carries `_meta.codex.delivery: "async"`.
- The adapter does not wait for the elicitation. On ACP v2 it does not count as a user action, so
the session state stays `running` while the form is open.

## Answers

- An accepted answer is steered into the turn that asked the question (`turn/steer` with that
turn as `expectedTurnId`). It is not echoed as a live user message.
- Declining or cancelling sends nothing; the agent keeps its own default.
- When the turn ends, its open elicitations are cancelled with `$/cancel_request`. The same happens
when the session closes or its app-server is lost.
- An answer that arrives after its turn ended is dropped, as in the Codex TUI. This includes an
answer to a cancelled elicitation, and one that Codex rejects because a new turn has started. The
adapter then sends a `warning` notice, where the client supports notices.

## Reply format and replay

Answers use the envelope that the Codex TUI and desktop app send, so Codex reads them the same way:

```
<send_user_message_question_reply>
[{"answer":"Staging","question":"Which environment?","questionItemId":"[\"request_user_input_async\",\"call_1\",0]"}]
</send_user_message_question_reply>
```

Codex stores this text as an ordinary user message. On `session/load` and `session/resume`, a user
message that is exactly one such envelope is replayed as each question in a quote followed by its
answer:

```
> Which environment?

Staging
```

The format follows Codex's `codex-rs/context-fragments/src/answered_question.rs`, and its parsing
follows `codex-rs/tui/src/async_question_reply.rs`.
16 changes: 14 additions & 2 deletions src/ACPSessionConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,11 +138,23 @@ export class AcpV2Connection {
}, options?.cancellationSignal);
}

/** Request-scoped login elicitations and idle background elicitations never change state. */
/**
* Sends `elicitation/create`. Request-scoped login elicitations and idle background elicitations never change
* the session state.
*
* @param request The elicitation to show.
* @param options Request options, such as the signal that cancels the request.
* @param interaction.userAction Whether the agent waits for the answer, `true` by default. While such an
* elicitation is open in a running turn, the session reports `requires_action` whenever none of its items
* is in progress. Pass `false` for a question the agent keeps working through, so the session state is left
* as it is.
*/
async createElicitation(
request: acp.CreateElicitationRequest,
options?: acp.SendRequestOptions,
interaction: {userAction?: boolean} = {},
): Promise<acp.CreateElicitationResponse> {
const userAction = interaction.userAction ?? true;
const sendRequest = async () => {
const response = await this.client.request(
acpV2.methods.client.elicitation.create,
Expand All @@ -152,7 +164,7 @@ export class AcpV2Connection {
return toV1CreateElicitationResponse(response);
};
const sessionId = elicitationSessionId(request);
if (sessionId === undefined) {
if (sessionId === undefined || !userAction) {
return sendRequest();
}
const toolCallId = "toolCallId" in request && typeof request.toolCallId === "string" ? request.toolCallId : undefined;
Expand Down
27 changes: 26 additions & 1 deletion src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ import {
import packageJson from "../package.json";
import {isJetBrains2026_1Client} from "./JBUtils";
import {clientSupportsNotices} from "./SessionNotice";
import {AsyncQuestionClient, AsyncQuestionElicitations, renderAsyncQuestionReply} from "./CodexAsyncQuestions";
import {
createAgentTextMessageChunk,
createAgentTextThoughtChunk,
Expand Down Expand Up @@ -520,6 +521,8 @@ export class CodexAcpServer {
/** Tail of the per-session turn-start FIFO; see `acquireTurnStartReservation`. */
private readonly turnStartQueueTail: Map<string, {promise: Promise<void>; settled: boolean}>;
private readonly steeringQueues: Map<string, SteeringQueue>;
/** The async questions of each session, shown while the turn that asked them runs. */
private readonly asyncQuestions = new Map<string, AsyncQuestionElicitations>();
/**
* Steers awaiting their `userMessage` landing, keyed by the minted `clientUserMessageId`
* passed to `turn/steer`. Used only to show a v2 live `user_message` when a steer injected
Expand Down Expand Up @@ -2016,6 +2019,8 @@ export class CodexAcpServer {
} finally {
if (this.getSessionGeneration(params.sessionId) === closeGeneration) {
this.sessions.delete(params.sessionId);
this.asyncQuestions.get(params.sessionId)?.dispose();
this.asyncQuestions.delete(params.sessionId);
if (!this.v2PromptsInFlight.has(params.sessionId)) {
AcpV2Connection.of(this.connection)?.forgetSession(params.sessionId);
}
Expand Down Expand Up @@ -4040,9 +4045,12 @@ export class CodexAcpServer {

private userInputToContentBlocks(input: UserInput): acp.ContentBlock[] {
switch (input.type) {
case "text":
case "text": {
const asyncQuestionReply = renderAsyncQuestionReply(input.text);
if (asyncQuestionReply !== null) return [{type: "text", text: asyncQuestionReply}];
return desktopAttachmentHistory(input.text)
?? (input.text.length > 0 ? [{ type: "text", text: input.text }] : []);
}
case "image":
return [{
type: "text",
Expand Down Expand Up @@ -4867,13 +4875,16 @@ export class CodexAcpServer {
await observeInteraction(event);
if (!promptNotificationsActive) {
await promptEventHandler.handleSessionScopedNotification(event);
this.asyncQuestionsFor(sessionState).handleNotification(event);
await this.trackCodexTurnCompletion(sessionState, event, promptEventHandler);
return;
}
const completesActiveTurn = event.method === "turn/completed"
&& event.params.threadId === sessionState.sessionId
&& event.params.turn.id === sessionState.currentTurnId;
await promptEventHandler.handleNotification(event);
// After the question text is shown.
this.asyncQuestionsFor(sessionState).handleNotification(event);
if (completesActiveTurn) {
// The prompt may remain open for plan approval after its turn has ended. Switch at
// the causal boundary so a queued late error cannot enter the completed turn's buffer.
Expand Down Expand Up @@ -5388,6 +5399,19 @@ export class CodexAcpServer {
throw this.recovery.errorForGeneration(generation);
}

/** The async questions of a session, created on first use. */
private asyncQuestionsFor(sessionState: SessionState): AsyncQuestionElicitations {
const sessionId = sessionState.sessionId;
let questions = this.asyncQuestions.get(sessionId);
if (questions === undefined) {
questions = new AsyncQuestionElicitations(sessionId, this.clientCapabilities, new AsyncQuestionClient(
sessionId, this.connection, this.clientCapabilities, () => this.codexAcpClient,
));
this.asyncQuestions.set(sessionId, questions);
}
return questions;
}

/**
* Ends what a prompt showed as running when its app-server died: open tool calls of the session and of its native
* subagent sessions, the subagent sessions themselves and compactions. Nothing will ever complete them.
Expand All @@ -5400,6 +5424,7 @@ export class CodexAcpServer {
state: "failed" | "cancelled",
): Promise<void> {
try {
this.asyncQuestions.get(sessionState.sessionId)?.dispose();
await elicitations?.completeAllUrlElicitations();
const sessionIds = new Set([sessionState.sessionId, ...sessionState.subagents.childSessionIds()]);
await this.failUnfinishedToolCalls(sessionIds);
Expand Down
Loading
Loading