Skip to content
Open
2 changes: 2 additions & 0 deletions docs/air-extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -784,6 +784,7 @@ Without the capability, the config options keep their old shape and carry no rec
Codex app-server owns shell commands that keep running after their tool call.
The adapter exposes them as async tasks when the client declares `asyncTasks`.
Without the capability, the adapter sends no async task update.
ACP v1 and v2 use the same `async_task_spawned`, `async_task_progress`, and `async_task_state_update` names and payloads.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line and line 1004 ("AIR receives subagent_spawned and subagent_state_update with the same names ... on ACP v1 and v2") are no longer true after 447c97d. On v2, toV2SessionUpdate sends _async_task_* and _subagent_update, and readme-dev.md already says so. Please say that v2 uses the _-prefixed tags with the same payloads, or remove both lines.


### Lifecycle

Expand Down Expand Up @@ -1000,6 +1001,7 @@ Recovery never removes the transcript record.
The adapter implements the draft [ACP subagent RFD](https://github.com/agentclientprotocol/agent-client-protocol/pull/1992).
[Subagent sessions](subagent-sessions.md) describes the lifecycle.
This section covers only the AIR bridge.
AIR receives `subagent_spawned` and `subagent_state_update` with the same names and payloads on ACP v1 and v2.

- The canonical client field is `clientCapabilities.subagents: {}`.
- Released ACP SDKs can strip that draft field.
Expand Down
13 changes: 6 additions & 7 deletions docs/async-questions.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,13 @@ the questions were accepted, and the turn goes on.

## 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.
- An accepted answer joins the current turn through `turn/steer`. If the session is idle,
the adapter starts one new turn with the reply. The form summary shows the answer, without a second 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.
- The form stays open after its turn ends. Closing the session or losing its app-server cancels it.
- A rejected answer produces a warning notice where the client supports notices.
- On ACP v2, request metadata lists Air's companion field IDs under
`_meta.jetbrains.air.customAnswerFields`. The note stays part of its choice.

## Reply format and replay

Expand Down
13 changes: 13 additions & 0 deletions docs/steering.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ mid-task instead of queueing until the turn ends or cancelling it. The method is
standardization proposal is
[agent-client-protocol PR #1261](https://github.com/agentclientprotocol/agent-client-protocol/pull/1261).

## Native v2 prompts

An ordinary v2 `session/prompt` steers a running turn. An idle session starts a new turn.
Slash commands wait for their own execution slot. The adapter returns `messageId` only after
Codex records the message. It emits the message once with that ID and keeps the foreground
state unchanged during steering.

If the turn ends before it can take the input, the adapter starts a new turn once. If Codex
accepts the steer but never records its input, the prompt fails without a retry.
A session cancellation or close interrupts the work. An individual request cancellation can
stop an input before dispatch. After dispatch, insertion decides the response; cancelling
that request does not interrupt the existing turn.

## Capability

The adapter advertises steering in the `initialize` response as
Expand Down
2 changes: 1 addition & 1 deletion readme-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ schema alone:
- Custom session updates are renamed on v2: `subagent_spawned`/`subagent_state_update` become a
single `_subagent_update` (gated on the AIR `nativeSubagentSessions` capability; this contract is
retained independently of standard subagent sessions), and
`async_task_spawned`/`async_task_state_update` become `_async_task_spawned`/`_async_task_state_update`
`async_task_spawned`/`async_task_progress`/`async_task_state_update` gain the `_` prefix
(gated on the AIR `asyncTasks` capability). Payload shapes are unchanged.
- Foreground state is independent of prompt acceptance. `running` can precede the user-message
echo, including while Codex runs hooks. A successful prompt response still waits for insertion.
Expand Down
16 changes: 10 additions & 6 deletions src/AcpV2Elicitation.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import * as acp from "@agentclientprotocol/sdk/unstable";
import type * as acpV2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
import {AIR_CUSTOM_ANSWER_FIELDS_KEY, isAirCustomAnswer, withAirMeta} from "./AirExtension";

/**
* `elicitation/create`'s request/response, and `elicitation/complete`'s notification, are
* share field and method names between v1 and v2 -- unlike
* `session/request_permission`, there is no shape to render, only the type import differs per
* version. v2 responses allow arbitrary JSON content values. Keep them intact through the
* v1-typed view and validate with the v2 response guards in shared consumers.
* Keeps the shared elicitation wire shape and copies Air's companion IDs to v2 request metadata.
* V2 responses retain arbitrary JSON values for validation by their shared consumers.
*/
export function toV2CreateElicitationRequest(request: acp.CreateElicitationRequest): acpV2.CreateElicitationRequest {
return request as unknown as acpV2.CreateElicitationRequest;
if (!acp.CreateElicitationRequest.isForm(request)) return request as unknown as acpV2.CreateElicitationRequest;
const fields = Object.entries(request.requestedSchema.properties ?? {})
.filter(([, property]) => isAirCustomAnswer(property._meta))
.map(([id]) => id);
if (fields.length === 0) return request as unknown as acpV2.CreateElicitationRequest;
// AIR's Kotlin v2 property models omit metadata. Keep the companion IDs on the request.
return {...request, _meta: withAirMeta(request._meta, AIR_CUSTOM_ANSWER_FIELDS_KEY, fields)} as unknown as acpV2.CreateElicitationRequest;
}

export function toV1CreateElicitationResponse(response: acpV2.CreateElicitationResponse): acp.CreateElicitationResponse {
Expand Down
13 changes: 6 additions & 7 deletions src/AcpV2Prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,6 @@ export type UserMessageInsertion = {
/** Sent as `clientUserMessageId` on `turn/start`; Codex echoes it as `userMessage.clientId`. */
clientUserMessageId: string;
onInserted: () => Promise<void>;
/**
* Shows a synthetic prompt codex-acp starts itself (the plan-implementation follow-up turn,
* a `/goal` continuation) as a live `user_message`, once its own minted `clientUserMessageId`
* is echoed back. These turns run inside the original prompt's running…idle pair, so this
* only emits the chunks: no response is resolved and no `state_update` is sent.
*/
onSyntheticInserted: (clientUserMessageId: string, prompt: acp.ContentBlock[]) => Promise<void>;
/**
* Fired synchronously once, from the turn-start response, when Codex steers this prompt into
* a turn that was already running and unowned (its `running` went out before this prompt
Expand Down Expand Up @@ -53,6 +46,12 @@ export function isInsertedUserMessage(
&& event.params.item.clientId === clientUserMessageId;
}

/** The identity and cancellation of a native v2 prompt offered to a running turn. */
export interface V2SteeringPrompt {
messageId: string;
signal: AbortSignal;
}

const SUPPORTED_PROMPT_CONTENT_TYPES = new Set(["text", "image", "resource_link", "resource"]);

export function assertPromptContentSupported(params: {prompt: Array<{type: string}>}): void {
Expand Down
36 changes: 5 additions & 31 deletions src/AcpV2SessionUpdate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,39 +122,13 @@ export function toV2SessionUpdate(update: AcpSessionUpdate): acpV2.SessionUpdate
}
case "subagent_update":
return update;
// AIR's private contract is selected independently upstream.
case "subagent_spawned": {
const {subagentSessionId, name, task, _meta} = update;
return {
sessionUpdate: "_subagent_update",
subagentSessionId,
name,
task,
capabilities: {},
...(_meta != null ? {_meta} : {}),
};
}
case "subagent_state_update": {
const {subagentSessionId, state, _meta} = update;
return {
sessionUpdate: "_subagent_update",
subagentSessionId,
state,
...(_meta != null ? {_meta} : {}),
};
}
// No upstream v2 design exists for async tasks (AIR-private extension); same payload as v1
// under a `_`-prefixed tag. Gated on AIR `asyncTasks` upstream, same as above.
case "subagent_spawned":
case "subagent_state_update":
return {...update, sessionUpdate: "_subagent_update"};
case "async_task_spawned":
return {...update, sessionUpdate: "_async_task_spawned"};
case "async_task_state_update":
return {...update, sessionUpdate: "_async_task_state_update"};
// Declared as a type but never emitted; no v2 rendering to give it yet.
case "async_task_progress":
throw acp.RequestError.internalError(
undefined,
`'${update.sessionUpdate}' session update is not supported on an ACP v2 connection yet`,
);
case "async_task_state_update":
return {...update, sessionUpdate: `_${update.sessionUpdate}` as const};
}
}

Expand Down
10 changes: 10 additions & 0 deletions src/AirExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export const AIR_CODEX_HOOKS_KEY = "codexHooks";
export const AIR_CONTENT_DELTA_KEY = "contentDelta";
export const AIR_SUBAGENT_KEY = "subagent";
export const AIR_CUSTOM_ANSWER_KEY = "customAnswer";
export const AIR_CUSTOM_ANSWER_FIELDS_KEY = "customAnswerFields";
/**
* The root `_meta` spelling of {@link AIR_CUSTOM_ANSWER_KEY} that released AIR versions read.
* Remove it when the ACP registry no longer serves AIR versions that read only this spelling.
Expand Down Expand Up @@ -131,3 +132,12 @@ function asRecord(value: unknown): Record<string, unknown> {
? value as Record<string, unknown>
: {};
}

/** Whether property metadata marks a text companion to a choice. */
export function isAirCustomAnswer(meta: unknown): boolean {
const root = asRecord(meta);
const air = asRecord(asRecord(root[JETBRAINS_META_KEY])[AIR_META_KEY]);
const marker = air[AIR_CUSTOM_ANSWER_KEY] === undefined
? root[LEGACY_AIR_CUSTOM_ANSWER_KEY] : air[AIR_CUSTOM_ANSWER_KEY];
return marker === true || Object.keys(asRecord(marker)).length > 0;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The adapter only ever writes this marker as true (CodexElicitationHandler.ts, both the jetbrains.air and the legacy root spelling), so the object case is never produced. return marker === true; is enough.

}
Loading
Loading