Skip to content

fix: Closing the gaps for the ACP v2 support - #744

Open
KirillAlekhnovich wants to merge 8 commits into
mainfrom
alekhnovich/IJAI-862
Open

KirillAlekhnovich wants to merge 8 commits into
mainfrom
alekhnovich/IJAI-862

Conversation

@KirillAlekhnovich

@KirillAlekhnovich KirillAlekhnovich commented Nov 8, 2026 •

Copy link
Copy Markdown
Collaborator

Why

Close ACP v2 gaps in Air input handling and agent lifecycle updates.

Related: IJAI-6021, IJAI-6023.

What

  • Preserve prompt receipts, queued input, cancellation, and asynchronous question answers across turn changes.
  • Keep fallback steering in the same order as native prompts to prevent a queue deadlock after cancellation.
  • Handle failed fallback prompts without an unhandled rejection. Release the queue so the next prompt can start.
  • Publish subagent and task updates with private v2 tags during live output and replay. Keep the v1 tags unchanged.
  • Retain both form metadata locations for Air clients whose Kotlin property models omit metadata.

Validation

  • Unit and wire suites: 2511 passed, 39 skipped, without retries or unhandled errors.
  • Integration suite: 19 passed.
  • Type checking, the production build, and all six binary bundles passed. Bundles used Bun 1.3.11, as in CI.
  • A real Codex prompt completed successfully through the adapter.

Notes

The full IDE manual checklist was not repeated for this revision.

@jetbrains-air jetbrains-air Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thanks for this — there's one blocking issue in the inline comment to sort out before this can merge.

Comment thread src/CodexAcpServer.ts Outdated
const queue = this.getSteeringQueue(params.sessionId);
try {
return await queue.enqueue(params);
return await queue.enqueue(params, insertion);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Blocking: A v2 follow-up now waits in the session's SteeringQueue while it holds its turn-start slot. A steer that falls back to starting a turn takes its slot only inside the queue handler, in startNewTurnFromExternalPrompt. That covers _session/steering without promptRequired, and async-question answers, which make the same call through AsyncQuestionClient.sendReply. If such a steer is queued ahead of a v2 follow-up, it gets a slot behind that follow-up and waits for it. The follow-up is waiting for the steer to leave the queue, so the session deadlocks.

I reproduced this in a v2 harness test:

  1. A turn is running. Follow-up B arrives by session/prompt, gets steered, and waits in the queue for insertion.
  2. A _session/steering request S (an async-question answer works the same way) queues behind B.
  3. Follow-up C arrives. promptV2 reserves C's slot, then C queues behind S.
  4. The user sends session/cancel. The turn ends interrupted and B gets -32800. Because cancelRequested is set, S finds no steerable turn, reserves a slot behind C, and waits for C, which is still behind S in the queue.

What the client sees: S and C never get a response and no new turn starts. A later session/prompt D queues behind them and also hangs, so the session is stuck until it is closed. This PR introduced it: on main, promptV2 never enters the steering queue. The trigger needs a fallback steer queued behind a pending follow-up. That is more likely now, because async-question forms stay open after their turn ends.

Fix: make the steering queue and the turn-start FIFO put entries in the same order.

  • In executeOrQueueSteeringRequest, acquire the turn-start reservation when the steer is enqueued, for steers with no insertion and no promptRequired.
  • Pass it through performSteeringRequest → startNewTurnFromSteering → startNewTurnFromExternalPrompt instead of acquiring a new one there.
  • When the steer injects or fails, release it in FIFO order with void reservation.wait.then(() => reservation.release()), as promptV2 already does.

I applied this temporarily and checked:

  • The reproduction settles: B and C get -32800, S gets startedNewTurn.
  • steer-events.test.ts › "serializes concurrent late steering requests" still passes.
  • The full npm test passes: 2509 passed, 39 skipped.

Produced by Air Automations. Name: Codex ACP Code Review / Run: https://air.jetbrains.cloud/org/05cf1a7f-6ab5-713b-abd3-29d0c8a05e2d/automations/deba358f-772c-4cff-bf03-c95d1670abe9?run=a2f48558-b6ac-4772-bdc5-c24f9612c3f2

Use native steering and track insertion separately from the active turn response.
Keep internal plan approval prompts out of user message updates and restored history.
Keep asynchronous questions alive until an answer, rejection, or cancellation.
Preserve companion metadata so custom text remains beside its question.

Type checking passed. The full suite passed 2468 tests and skipped 39 tests.
Four failures also occur on the unchanged HEAD and are outside this change.
The current main sends the subagent capabilities before its state.
It also combines terminal output with the completion update.
Refresh the three snapshots without changing their expected content.
Use unlink for a directory symlink so cleanup works on this Node version.

Validation: 2502 tests pass. Type checking and the build pass.
Keep the AIR subagent and async task extension names on ACP v2. Air uses
these events to show child sessions and background work. The previous
conversion changed the names and hid those events from the client.

Cover live updates, history replay, task stops, and the extension guard.
The capability gates stay the same on both protocol versions.

Tests:
- npm run typecheck
- npm test: 2507 passed, 39 skipped
- npm run build
The subagent tests emitted live events before replay state publication finished.
The scheduler then changed the update order or delayed terminal output until completion.

Wait for the restored child directory before emitting more work.
Drain the output delta before the completion event.
Refresh the v1 and v2 snapshots for these explicit boundaries.

Validation:
- Unit tests: 2507 passed, 39 skipped, with retries disabled.
- The live subagent suite passed ten iterations per test without retries.
- Integration tests: 19 passed.
- Type checking, the JavaScript build, and all six binary builds passed.
- The checks used Node 24.22.2 and Bun 1.3.11.
Reserve fallback turn slots before steering requests enter the queue. Release unused slots in order. Cover cancellation with a queued steer and with an asynchronous question answer.
Prefix the Air subagent and task update tags on v2. Keep the v1 tags unchanged. Reject unprefixed private updates in tests. Retain both form metadata locations for Air clients.
Extract the repeated user message insertion step. Keep the queue recovery scenario within the function length guideline. The affected steering and question suites pass.
…ejection

Keep reservation cleanup in the handled prompt promise chain. A separate
finally promise could reject after the caller had handled the original failure.

Cover a rejected asynchronous answer in ACP v1 and v2. Check that the warning
reaches the client and the next prompt can start.
Comment thread src/CodexAcpServer.ts
if (turnStillActive && !this.isNoActiveTurnToSteerError(err)) {
throw err;
}
if (turnStillActive && !this.isNoActiveTurnToSteerError(error)) throw error;

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.

Problem: a v2 prompt sent while /review runs now fails instead of waiting for the review to finish.

How it happens:

  1. The user runs /review (or /review-branch <x>, /review-commit <x>). The review turn is running and holds the turn-start slot.
  2. The user sends an ordinary prompt. promptV2 sees needsWait and calls steerOrWaitForV2Prompt with canSteer = true.
  3. tryInjectSteeringRequest waits only for compact (line 3122), so it sends turn/steer into the review turn.
  4. Codex rejects it with cannot steer a review turn (activeTurnNotSteerable; NonSteerableTurnKind is "review" | "compact").
  5. The turn is still active and this is not "no active turn to steer", so this line rethrows. executeOrQueueSteeringRequest turns it into a v2 error.

Impact: the client gets -32600 for the follow-up, and it never runs. Any prompt during a review hits this. The PR introduced it: on the base commit the same prompt waits in the turn-start queue and starts a new turn once the review ends.

Fix: treat Codex's not-steerable error like "no active turn to steer" here, so the caller waits for its turn-start slot:

if (turnStillActive && !this.isNoActiveTurnToSteerError(error) && !this.isActiveTurnNotSteerableError(error)) throw error;

This also covers review turns the adapter did not start itself. Async answers and _session/steering without promptRequired would then start their turn after the review, instead of failing. I checked it with a temporary v2 harness test: /review runs, turn/steer throws cannot steer a review turn, and a follow-up prompt arrives. Without the fix the prompt gets -32600. With the fix it starts a turn after the review and resolves with its messageId, and the full suite still passes. Please add that case next to compact-command-wire.test.ts › "holds the next prompt until the compact turn ends".

Comment thread docs/air-extensions.md
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.

});

if (protocolVersion === 2) {
it('delivers an async answer queued between cancelled follow-ups', async () => {

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 repeats prompt-v2-steering.test.ts › "keeps fallback steering ahead of later prompts after cancellation" in the same harness. sendReply makes the same executeOrQueueSteeringRequest call (no insertion, no promptRequired) as _session/steering, and the steps are the same: a pending follow-up, a queued fallback, a later prompt, cancel, then the fallback starts turn 2. The idle-answer test above already covers sendReply starting a turn. Please drop this test, or make the steering test an it.each over the two sources of the fallback. That saves about 28 lines.

Comment thread src/AirExtension.ts
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.

threadId: this.sessionId,
turnId,
/** Sends the reply through the session's shared steering and turn-start queue. */
async sendReply(text: string): Promise<void> {

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.

On ACP v1, an answer that arrives when the session is idle now starts a full turn with no session/prompt in flight. The v1 client then gets streamed output with no prompt it can show as busy. Turns that Codex starts by itself already stream like this on v1, so this may be fine. Can you confirm this is the intended v1 behavior for Air, and say so in docs/async-questions.md? It currently does not say how the two versions differ.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants