Skip to content

feat: add ACP v2 support again - #620

Merged
EugeneTheDev merged 1 commit into
mainfrom
eugenethedev/acp-v2
Oct 13, 2026
Merged

EugeneTheDev merged 1 commit into
mainfrom
eugenethedev/acp-v2

Conversation

@EugeneTheDev

Copy link
Copy Markdown
Collaborator

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_chunks 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.

@EugeneTheDev
EugeneTheDev merged commit 9e1690f into main Oct 13, 2026
6 checks passed
@EugeneTheDev
EugeneTheDev deleted the eugenethedev/acp-v2 branch October 13, 2026 12:09
EugeneTheDev added a commit that referenced this pull request Oct 13, 2026
## Summary

The E2E run on #620
([run](https://github.com/agentclientprotocol/codex-acp/actions/runs/38395294465))
failed on two model-dependent tests. #620 didn't cause it: the run
before it shows the same behavior and passed only by luck. This PR
changes test code only.

- **Default e2e model `gpt-5.2` → `gpt-6-luna[low]`.** `gpt-5.2` isn't
in the Codex model list anymore, so Codex used fallback metadata. In
0.160.1 that has `apply_patch_tool_type: None`, so the model ran
`apply_patch` through the shell. A malformed patch came in as an
`execute` approval, not `edit`, and broke `applies an approved workspace
file edit`.
- **Shell approval prompts ask for elevated permissions on the first
try.** In read-only mode the model sometimes ran the command in the
sandbox, hit `Read-only file system`, and ended the turn without asking
for approval (`cancels every command when cancel is selected` got
`end_turn`). This applies only to the tests that expect an approval
prompt.
- **Cancellation test follows how Codex handles interrupts.**
`gpt-6-luna` runs commands through unified exec, and `turn/interrupt`
doesn't kill those processes: they keep running as background terminals.
The test now:
  - waits until the client has seen the command's tool call;
- cancels and asserts the turn is `cancelled` and the process is still
running;
- stops the process with `_session/async_task/stop` and asserts it
exits.

The ACP SDK client rejects the `async_task_spawned` update because its
schema doesn't include it, so the test uses the tool call id as the task
id. For a root command they're the same.
- Fixture helpers: the fixture records tool calls, and
`createAuthenticatedFixture` can declare AIR capabilities.

## Test plan

- [x] `npx tsc --noEmit`
- [x] E2E workflow on this branch: [run
38406199843](https://github.com/agentclientprotocol/codex-acp/actions/runs/38406199843).
33 passed, 2 skipped. One retry, in the exec-policy amendment test: the
model mistyped the long temp path, so the saved approval rule didn't
match.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant