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.
Rizzen pushed a commit that referenced this pull request Oct 26, 2026
🤖 I have created a release *beep* *boop*
---


##
[2.3.0](v2.2.2...v2.3.0)
(2026-10-26)


### Features

* add ACP v2 support again
([#620](#620))
([9e1690f](9e1690f))
* mark session list rows of worktrees Codex made (agentWorktree)
([#631](#631))
([aa02d43](aa02d43))
* narrow models to what the providers/set gateway lists
([#627](#627))
([3f555f9](3f555f9))
* report the model of each native subagent session
([#678](#678))
([51005a6](51005a6))
* return initial ACP v2 session commands
([#652](#652))
([5e9b7c5](5e9b7c5))
* support AIR sessionIndex extension
([#590](#590)) v2
([#622](#622))
([2fd758c](2fd758c))
* support append-mode ACP system prompts
([#653](#653))
([5993a12](5993a12))


### Bug Fixes

* disable Codex's Visualize skill, whose output ACP clients show as raw
text
([#667](#667))
([0ba94f5](0ba94f5))
* display the selected wire provider as JetBrains Air Gateway
([#589](#589))
([cc54abf](cc54abf))
* explain Codex config load errors caused by a deleted working directory
([#626](#626))
([a636804](a636804))
* explain invalid_encrypted_content errors after a provider change
([#668](#668))
([a77829f](a77829f))
* hold a fork back from the session list until its session/fork response
is out
([#642](#642))
([59974c7](59974c7))
* include subagent edits in the agent file-change report
([#632](#632))
([7d07123](7d07123))
* leave codex exec runs out of the session list without sessionIndex
([#639](#639))
([df75d2b](df75d2b))
* migrate to ACP SDK 2.0
([#649](#649))
([c8fd166](c8fd166))
* publish the commands when the skills change
([#643](#643))
([a62b568](a62b568))
* report structured ACP v2 failures
([#651](#651))
([5377bfe](5377bfe))
* show a Codex subagent's follow-up turn in a new subagent session
([#634](#634))
([7c761c7](7c761c7))
* synchronize ACP v2 foreground state
([#650](#650))
([986955a](986955a))
* update codex to 0.164.0
([#666](#666))
([e227c0e](e227c0e))
* wait for the compact turn to finish
([#654](#654))
([ced4c34](ced4c34))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: acp-release-bot[bot] <246668977+acp-release-bot[bot]@users.noreply.github.com>
mrmans0n added a commit to mrmans0n/codex-acp that referenced this pull request Oct 26, 2026
Upstream v2.3.0 (agentclientprotocol#620) installs a baseline session subscription (startCodexTurnTracker) when a
session is opened, resumed or moved by a provider restart, so a Codex-started turn before the
first prompt is tracked and rendered. It denies every approval and elicitation, and the lazy
resume after a crash restart does not install it again. Upstream also dropped the adapter-started
goal continuation, so startGoalContinuationIfCurrent is not restored.

Keep every original +/- line: goal control still subscribes a never-prompted session on the
installed client, with forwarded, loss-aware approvals and elicitations. Because that subscription
replaces the baseline tracker, its handler now also runs the tracker's duties (foreground work,
steer landing, skills change, turn completion). A new test fails without them.
Adapted from a52405d (original 9d2c300).
mrmans0n added a commit to mrmans0n/codex-acp that referenced this pull request Oct 26, 2026
Upstream v2.3.0 moved the ACP request routing from index.ts into AcpAgentRouter.ts (agentclientprotocol#620), wraps
app-server request errors with explainCodexError (agentclientprotocol#626), adds insertion-aware prompts for steering (agentclientprotocol#620),
a provider request ordering for providers/set (agentclientprotocol#627), and registers a baseline turn tracker before a
provider restart resumes a session. None of this absorbs the downstream hook review (no upstream PR).

Keep the downstream behaviour: the hook execution gate still fences thread/start, thread/resume,
thread/fork, turn/start and review/start inside the new error wrapper; session/new, load, resume,
fork and prompt still run under the review (prompt now forwards upstream's insertion); providers/set
still invalidates the review after the routing changes; a provider restart still leaves a reviewed
session for its reviewed lazy resume, before any baseline tracker is registered for it. The
codexHooks initialize capability moves into initializeExtensionsMeta and stays ACP v1 only (the
review is configured only by the v1 initialize).

_codex/hooks/trust must keep accepting reviewToken, and its route now lives in AcpAgentRouter.ts,
which is not a ledger file of this patch: this commit binds only after that path is added to the
patch files (level 3). A new router test fails without it.
Adapted from 4d43405.
mrmans0n added a commit to mrmans0n/codex-acp that referenced this pull request Oct 26, 2026
Upstream v2.3.0 (agentclientprotocol#620) removed adapter-started goal continuation turns (Codex continues an active
goal itself) and gave external prompts a turn-start reservation: startNewTurnFromExternalPrompt now
hands its reservation to prompt() together with the insertion. No upstream PR absorbs this patch.

Keep upstream's reservation call and move the downstream continueOperation into
promptAfterReservation, so a turn started inside a reviewed operation still continues that review
and any other prompt starts one as before. Goal set/resume control, the active thread/goal/set gate,
provider inputs and the authentication invalidation apply unchanged. The fallback goal tests now
expect no adapter turn/start, and the dispatch-history test moves from the removed goal fallback
turn to a prompt after a crash, whose lazy thread/resume and turn/start still dispatch twice.
Mutating the dispatch mark, the goal gate or the goal control review fails the tests.
Adapted from 960f779.
SeanLF added a commit to SeanLF/claude-code-subagent-harnesses that referenced this pull request Oct 28, 2026
…e closest spelling first

codex-acp 2.3.0 adds ACP v2 beside v1 ("v1 stays fully supported; this is not
a cutover", agentclientprotocol/codex-acp#620); the relay still asks for v1.
It carries codex 0.164.0 and fixes cancel/close racing a turn's start. Checked
live: a read-only run answers and signs, a refused model gets suggestions, and
three parallel runs finish. The fixtures stay the 2.2.2 capture: v1's frames
change only for the bug fixes #620 lists.

gpt-6-lunar shares gpt and 6 with every gpt-6 model, so the suggestions came
in list order (astra, sol, luna); ties now go to the longest common start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0131zPk82o4yhGkjvhSWGU4o
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