Skip to content

v2.2.3-preview.2

@EugeneTheDev EugeneTheDev tagged this 13 Oct 12:09
## Summary

- Adds ACP v2 as a second agent chain in the same process. v1 stays
fully supported; this is not a cutover.
- Each connection picks its protocol version on its first `initialize`
and keeps it:

`acpV2.agentProtocolRouter().withV1(v1Agent).withV2(v2Agent).connect(stream)`
(`src/index.ts`).
- Both chains share the same business logic (sessions, turn tracking,
tool calls, permissions, elicitation). Outbound
session updates fork by version in one place: `toV2SessionUpdate`
(`src/AcpV2SessionUpdate.ts`), called from
  `ACPSessionConnection`.
- Rule for v1: client-visible behavior changes only to fix a real bug.
Every such change is listed under "Fixes".

## Implemented (v2)

The v2 method set is implemented as specified: `initialize`,
`session/new|list|close|delete|resume|fork`,
`session/prompt`, `session/cancel`, `$/cancel_request`,
`session/set_config_option`, `auth/login|logout`,
`session/request_permission`, `elicitation/create|complete`,
`state_update`, plus the extensions below. Only the parts
that are project-specific or not obvious from the spec are described
here.

### Prompts and turns

- `session/prompt` is answered with `{messageId}` once the user message
is actually inserted into a Codex turn, not
  when the request arrives.
- Overlapping prompts on one session are queued (FIFO, no limit) and
each runs as its own turn after the current one
ends. They are not steered into the running turn; steering stays a
separate `_session/steering` request.
- `session/cancel` interrupts the running turn and rejects every queued,
not-yet-inserted prompt with -32800. Those
  prompts get no `state_update`.
- Codex starts some turns on its own (e.g. goal auto-continuations after
`session/new` or `session/resume`). These get
`running`/`idle` like any other turn, with no matching `session/prompt`.
Clients should not assume every
  `running`/`idle` pair belongs to one of their prompts.
- `requires_action`/`running` bracket a permission or elicitation
request only while a turn is running. Requests sent
while the session is idle (e.g. MCP OAuth re-auth when a session opens)
go out without the bracket.
- The live user message is sent as one `user_message_chunk` (with
`messageId`) per prompt block, the same shape resume
  replay produces. An empty prompt sends no user message update.
- `/compact` and `/goal` prompts render live only; Codex persists no
user message for them, so replay doesn't show
  them.

### Tool calls and terminals

- Command output streams as `terminal_output_chunk`s without Codex's
shell-wrapper framing. Completion doesn't resend
  the full output: the streamed chunks are the only copy.
- File changes render as `changes[]`; adds and deletes get a synthesized
git patch.

### Resume replay

- `session/resume` with `replayFrom: {type: "start"}` replays the full
history (messages, tool calls, plans, subagent
and async-task updates) with stable ids. Each replayed message starts
with a `content: []` primer before its first
  chunk.
- For old rollouts that need the response-item history fallback, v2 only
receives the tool calls the fallback
recovers. Its message chunks are used to position those tool calls and
are never sent.

### Extensions

- `providers/list|set|disable` reuse the v1 logic.
`providers/set|disable` restarts the Codex app-server and resumes
every session the client has loaded; a turn that was running at restart
ends with `idle`/`cancelled`.
- `_session/goal` (set/pause/resume/clear) and
`_session/async_task/stop`.
- Subagent and async-task session updates use v2 extension names:
`subagent_spawned`/`subagent_state_update` become one
`_subagent_update` (sent when AIR declares `nativeSubagentSessions`);
`async_task_spawned`/`async_task_state_update`
become `_async_task_spawned`/`_async_task_state_update` (sent when AIR
declares `asyncTasks`). Payloads are
  unchanged.

### Compatibility details

- MCP server configs are accepted in both the v1 (untagged stdio) and v2
(tagged) shapes. Unsupported transports are
  rejected.
- `session/list` and `auth/logout` accept a request with `params`
omitted. The ACP SDK (1.5.0) rejects that even
though every field is optional, so a stream transform fills in `params:
{}` for these two methods only.

The full client contract, including what AIR relies on, is in
`readme-dev.md` ("ACP v2 support", "AIR v2 client
contract").

## v1 compatibility

v1 wire behavior is unchanged apart from the fixes below. Verified by:

- The existing v1 unit tests passing throughout; assertions only changed
where a fix intentionally changed pinned
  behavior.
- A full v1 TCK run: `CONFORMANT`, 50 pass / 1 fail / 5 skip, identical
to the baseline before this work. The single
  failure is the pre-existing advisory `ACP-SCHEMA-002`.
- Isolation at the router: the v1 handler chain (`v1Agent` in
`src/index.ts`) is built exactly as before, and the SDK's
  protocol router binds each connection to one chain for its lifetime.

### Fixes

Pre-existing bugs, fixed on both v1 and v2 unless stated otherwise.

#### A goal turn that Codex starts right after a session is loaded loses
its opening content

Codex auto-continues an active goal a few milliseconds after
`thread/resume`. The turn tracker was registered only
after `model/list` and `account/read`, and the app-server client drops
notifications that have no handler yet. The
tracker is now registered before `thread/resume`/`thread/load` and held
until the session is ready. Paths that didn't
hit the race send exactly the same frames as before.

#### Turns Codex starts on its own are invisible

A turn no `session/prompt` owns (e.g. a goal auto-continuation) was
tracked but never rendered. It now renders through
the same session-scoped path a prompt uses. A prompt-owned turn can't be
rendered twice: a session has only one current
dispatcher.

#### Closing or cancelling a session can leave Codex running a turn

When `turn/interrupt` raced the turn's registration, the retry only
covered cancel, not close, and it computed the turn
id once instead of on each attempt. Close now gets the same retry, and
the id is recomputed on every attempt.

#### `/review` completion goes to the wrong turn, and a review that
fails to start hangs the prompt

`review/start` responds with the parent turn id, while `turn/started`
carries the reviewer's child turn id, and
`turn/interrupt` needs the child id. The two ids are now tracked
separately, so completion and errors are attributed to
the review's own turn, and a review that fails before starting ends the
prompt. A successful review reports the same as
before.

#### Cancel leaves a pending permission or elicitation request open, and
a cancelled review reports `end_turn`

`session/cancel` only aborted work that hadn't started a turn yet. Each
prompt now has an interaction signal that cancel
and close abort. That withdraws any permission or elicitation request
still waiting on the client (`$/cancel_request`),
so a late answer can't be applied. A cancelled plan-implementation
review now reports `stopReason: cancelled`.

#### Replay resurrects tool calls from a rolled-back history

The response-item history fallback for old rollouts ignored
`thread_rolled_back`. It now bails out when the rollout
shows a rollback. Only that legacy replay path is affected.

#### codex-acp starts its own goal continuations on top of Codex's

codex-acp had a fallback that continued an active goal after a turn
ended. Codex 0.156.1 does this itself, so the
fallback could start an unchecked turn (even for a goal whose budget was
exhausted) or steer into a turn Codex had just
started. The fallback is removed; goal continuation is left to Codex.

#### An unknown permission outcome is treated as a selection

Permission responses were read as "selected" whenever the outcome wasn't
`cancelled`, but the outcome is an open union
(`ACP-ENUM-203`). The readers now check `outcome === "selected"`. Every
valid outcome behaves as before.

#### Provider restart re-resumes forks the client hasn't loaded yet

After a fork, the session waits for the client's `session/load`, but a
provider restart resumed it anyway. Restart now
skips such sessions; their `session/load` resumes them as usual.

#### Provider restart leaves the cut-off turn open forever

`providers/set|disable` kills the old app-server while a turn may still
be running, so that turn's `turn/completed` and
`item/completed` never arrive. Before, the resumed session also lost its
turn tracker, so turns after the restart
weren't rendered. Now, per session, the restart:

1. drains the old client's notifications;
2. ends every tool call the cut-off turn left in progress as `failed`
and closes its terminal;
3. on v2, reports the cut-off turn as `idle`/`cancelled`;
4. registers the turn tracker, then resumes the session.

The order matters. Codex can start a goal turn about 3 ms after
`thread/resume`, and closing out after the resume would
cancel that new turn instead of the old one. The list of open tool calls
is cleared when a turn completes, so a later
restart can't fail a tool call from an earlier turn.

On v1 the only new frames are the `failed` tool call updates and the
terminal exit.

#### `session/new` has no fallback for a missing `mcpServers`

The other session-creation paths default it to `[]`; `session/new` now
does too.

### Known issues (not fixed)

#### A session that was never prompted is lost on provider restart

Codex writes a thread's rollout only after its first turn. If
`providers/set|disable` runs before a session's first
prompt, `thread/resume` fails with "no rollout found", and so does the
`thread/read` fallback. The `providers/*` request
fails with -32603 "Failed to resume 1 session(s)", and the next
`session/prompt` fails with "thread not found". This
already happens on `main`. Deferred: it needs a separate design for
restarting threads that were never persisted. A
`FIXME` marks the resume loop, and `readme-dev.md` "Known gaps"
describes it.

#### A disabled `openai` provider is still reported as current

After `providers/disable("openai")`, `providers/list` still reports
native OpenAI routing as `current`; the providers
RFD says it should be `null`. Left as-is deliberately and documented in
`readme-dev.md` "Known gaps".

#### Fork points can't target a user message sent with a client id

AIR's fork points match Codex item ids, but on v2 a user message's id is
the client's `clientId` when the client
supplied one. A fork at such a message wouldn't resolve. This is an open
question for AIR, documented in
`readme-dev.md` "Known gaps".

#### `stopReason` is only ever `end_turn` or `cancelled`

codex-acp never reports `refusal`, `max_tokens` or `max_turn_requests`.
This predates the PR; v2 only needed the
mapping moved, not made more precise.

#### The title generator keeps using the old client after a provider
restart

Title generation after a restart goes to the app-server that was killed.
Out of scope for this PR.

#### Races with a pending provider update

A session opened while `providers/set` is in flight may run on the
client that is about to be killed.
`_session/goal` and `_session/steering` likely don't wait for a pending
provider update either (not verified). Out of
scope.

#### Idle-time permission and elicitation requests can't be cancelled

`session/cancel` withdraws requests through the current prompt's
interaction signal. A request sent while no prompt is
running reuses the finished prompt's signal, so cancel doesn't reach it.
Low impact: these are rare (e.g. MCP OAuth
re-auth).

#### Extra `running` after a late permission answer on v2

If a v2 client cancels during `requires_action` and then answers the
permission request anyway, it gets a redundant
`running` before `idle`. The final state is correct.

#### Closing a session during a turn Codex started gets no interrupt
retry

The registration-race retry covers prompt-owned turns only. Closing
during a self-started turn that hasn't registered
yet can leave it running.

## Testing

- New test areas:
`initialize-v2`, `session-lifecycle-v2` (including fork and
resume/replay), `session-update-v2`, `cancel-v2`,
`elicitation-v2`, `providers-v2`, `session-goal-v2`,
`async-task-stop-v2`, `steering-v2`, `prompt-v2`,
`agent-initiated-turns-v1/v2`, `resume-goal-turn-timing`,
`provider-restart-turn-tracking`, `v2-resume-replay`
(including subagents), `subagent-and-async-task-updates-v2`,
`session-config-options-v2`.
- TCK, full run for both protocol versions against the same build:

| Protocol | Verdict | Pass | Fail | Skip |

|----------|------------|------|---------------------------------------------|------|
| v1 | CONFORMANT | 50 | 1 (`ACP-SCHEMA-002`, pre-existing advisory) | 5
|
| v2 | CONFORMANT | 84 | 0 | 19 |

- Live checks against real Codex 0.156.1 (isolated `CODEX_HOME`, raw
JSON-RPC driver): a goal turn right after
`session/new`, cancel during a real command approval, resume with an
active goal in a fresh process, and a provider
restart with an active goal. The restart check found the last
provider-restart fix above and the unfixed
  never-prompted-session issue.

## Reviewer notes

Suggested reading order:

- `src/index.ts`: where the v1/v2 protocol router is wired up.
- `src/AcpAgentRouter.ts`: the v2 agent chain (method registrations).
- `src/ACPSessionConnection.ts` + `src/AcpV2SessionUpdate.ts`: the
`toV2SessionUpdate` fork point. This pair explains
  how the rest of the code avoids version-specific logic.
- `src/CodexAcpServer.ts`: shared business logic (turn tracking, session
lifecycle, provider restart). The largest and
  most-changed file.
- `src/CodexSessionToolCalls.ts`: small new tracker of open tool calls,
used by provider restart.

Worth extra attention:

- The provider restart sequence (`enqueueProviderUpdate` in
`CodexAcpServer.ts`). The per-session order (drain, close
out, then register the tracker and resume) is load-bearing and easy to
break.
- Prompt queueing and turn-start reservation
(`acquireTurnStartReservation`, `promptAfterReservation`, `promptV2`).
This is the one new concurrency mechanism; the rest mostly reuses
existing v1 logic.
Assets 2
Loading