Refs #1061. > Reworked on top of #1327, which merged in the meantime and already closes a cancelled turn's open tool calls in the consumer. This PR now covers the one path #1327 does not reach. The original description is kept at the end. ## What it fixes When a cancel lands on a turn that already has its result, `cancelTurns` settles the turn itself instead of leaving it to the consumer: - a turn held open for its background subagents, while the session is idle; - a turn waiting on its checkpoint preview. That path skipped #1327's `closeCancelledToolCalls`, so a tool call still open there stayed `pending` after the cancelled response. `cancelTurns` now runs the same closing before it responds. ## Changes - **One rule for both paths.** The consumer exposes its closing as `Session.closeCancelledToolCalls`, the way it already exposes `nativeSubagentDeliver`. Every open call ends with `AcpToolCallRenderer.unfinished`, except a background subagent's calls, which the cancel did not stop. - **The closing runs once per turn** (`Turn.closingCancelledToolCalls`). Example: a first cancel lands during a held turn's follow-up cycle, so the consumer settles the turn at the idle and starts closing its calls. A second cancel then settles the turn inline. It waits for the consumer's closing instead of starting its own, so each call gets one report and the response comes after all of them. - **`cancelTurns`** marks the turn settled before it waits for the reports, so the consumer cannot settle it again. If a report fails, it logs the error and still responds and interrupts. - **Removed:** this PR's original consumer-side branch and its `failed` report, which #1327's closing replaces. ## Tests - **Kept from this PR:** inline settles of a deferred turn and of a checkpoint turn, overlapping and simultaneous cancels, a failed report, a checkpoint that finishes during the closing. - **Changed:** the deferred fixture's background shell had no tool result, though Claude Code answers a `run_in_background` call when it starts. It is now a background subagent whose own command stays open. - **Removed:** `terminates a tool whose start streams in after the cancel`, which #1327 covers. - **New:** a second cancel that settles a held turn while the consumer is closing its calls. - **Removal checks:** removing the inline closing fails 6 tests, the once-per-turn closing 1, settling before the wait 2, and the error handling 1. `npm run check`, build and the full suite (2909 tests) pass, with no golden changes. ## Not covered A tool start that streams in after the turn has settled (the incident in #1061) still opens a tool call that nothing closes. Neither this PR nor #1327 changes that. <details> <summary>Original description</summary> Refs #1061. This closes part of the issue, not all of it; the part it leaves open is described below. ## What it fixes `settleActive` already fails every unfinished foreground tool when a turn ends as `end_turn` ("Claude ended the turn without returning a result for this tool"). It did nothing when the turn settled as `cancelled`, so a tool start that never got a `tool_result` stayed in `emittedToolCalls` and the client was left with an open `tool_call` forever. How a start can end up with no terminal: the `stream_event` case has no `session.cancelled` gate, so `content_block_start` for a `tool_use` keeps becoming a `tool_call` after `cancel()`. The consolidated `user`/`assistant` path, which is where `tool_result`-derived terminals come from, drops everything at its `session.cancelled` guard. This adds a `cancelled` branch next to the `end_turn` one. It reuses the same selection (this turn's `foregroundToolCallIds` still in `emittedToolCalls`, minus confirmed background tasks) and sends one `tool_call_update` with `status: "failed"` per tool ("The turn was cancelled before this tool call finished."). ACP's `ToolCallStatus` has no cancelled value, so `failed` is the available terminal. Unlike `end_turn`, cancellation is expected, so there is no session failure and no early return. ## What it does not fix A tool start that arrives **after** the turn has already settled (the reporter's incident: starts streaming in minutes after the cancel). There is no unsettled turn to attach it to, and I could not find an exact gate: stream events carry no `origin` or turn attribution (the existing comment on `emittedAssistantText` says as much), so such a start cannot be told apart from an autonomous cycle or from the next turn's pre-echo events. Gating on `session.cancelled` has the inverse problem the reporter described. If maintainers see an attribution signal I missed, a lifecycle-scoped gate would be the complement to this change. ## Test changes - `preserves cancellation and does not attribute its unfinished tool to the next turn` asserted that the cancelled turn's tool gets **no** `failed` update. That encoded the old behaviour, so it now asserts exactly one cancellation terminal that the next turn neither repeats nor reports as `incomplete_tool_call`. The cancel-then-next-prompt assertions are unchanged. - New: `terminates a tool whose start streams in after the cancel` (start arrives between `cancel()` and the trailing idle). Both fail without the change and pass with it. Full suite (2127 tests), `npm run check` and `build` pass. One unrelated timeout in `acp-scenarios` (`schema check`) showed up under load in one full run and passes when run on its own. ## Live check Against the built adapter (real credentials), I asked for `sleep 45`, cancelled once the `tool_call` appeared, and listed every tool call and its statuses at the end. Before and after the change the result is the same: one tool call, `pending > ... > failed`, nothing open. So in the plain cancel-mid-tool case the SDK already delivers an interrupted result and there is no ghost to remove; I could not reproduce the reporter's leak live, only in the unit fixture above. What the live run does show is that the new branch does not send a duplicate terminal for a tool that was already terminated. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_019vH6BXrsYQN3MqQ93JgStf </details> --------- Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com> Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
ACP adapter for the Claude Agent SDK
Use Claude Agent SDK from ACP-compatible clients!
This tool implements an ACP agent by using the official Claude Agent SDK, supporting:
- Context @-mentions
- Images
- Tool calls (with permission requests)
- Compact file changes through the negotiated AIR diff patch extension
- Following
- Edit review
- TODO lists
- Nested subagent transcripts
- Interactive (and background) terminals
- Custom Slash commands
- Client MCP servers
/mcpin the chat: the MCP server status as a list. The adapter runs/mcp reconnect,/mcp enable, and/mcp disablethrough the SDK control API, because Claude Code refuses them in SDK mode. A reconnect of an ACP server that needs authentication starts MCP OAuth through URL elicitation- Session-scoped long-running goals for AIR through the goal extension under
_meta.jetbrains.air.goal - Structured errors, recovery, and warnings through the opt-in session failure extension
- Concrete model and effort defaults through the opt-in recommended config value extension
- Tool permission presentation, editable choices, and durable effects through the permission extension
- One fact per field in every tool call report, as the ACP tool call contract defines
- All AIR extensions, capabilities, and
_metakeys: AIR extensions
Learn more about the Agent Client Protocol.
To try changes that have landed on main but are not released yet, install from the
preview channel — every push to main publishes one. See
docs/RELEASES.md.
npm install @agentclientprotocol/claude-agent-acp@preview
Subagent sessions
A client that declares clientCapabilities.subagents, other than JetBrains AIR, gets each Agent or
Task subagent as its own child session, following the subagents RFD:
subagent_updateon the parent announces the child before any of its traffic, with the parent'stitleanddescriptionfor it, and reports its work state:running,requires_actionwhile one of its requests is open, andidlewhen its work ends, withend_turnorcancelledwhen the SDK gives the reason. A failure isidlewith theerrorstop reason and the SDK's error text as itserror. The child keeps its session for every delegation to it.- The prompt of the Agent or Task call that launches the child, and the message of each
SendMessage call that resumes it, is a
session_messagein the child's transcript. The tool call stays a tool call of the session that made it. session/cancelon the parent interrupts Claude Code, which stops the foreground children that the turn waits on and, currently, background children too. Each stopped child reportsidlewithcancelledfrom Claude Code's own events, and the open requests of the foreground ones are withdrawn. The adapter ends no child at a cancel itself, so a child that keeps running keeps its open requests and its open tool calls. A tool call of a stopped child that Claude Code reports no result for, such as the command it was running, ends in the child's session before the cancelled prompt resolves. A child that still runs when Claude Code exits isidlewith no stop reason.- A child whose parent is not yet known when it ends is not exposed, since the RFD forbids guessing its parent.
- A replay restores each child under its live session id, from its own transcript: its prompt and
each SendMessage message are
session_messages, and its end is anidlebefore the response. A launch without Claude Code's metadata file for the child stays an ordinary tool call of the parent, and a notice, for a client that takes notices, says that subagent history is missing. - A client cannot prompt, cancel, or otherwise change a child yet.
An ACP v2 client gets the same without declaring anything, since subagents
are part of v2's baseline. Each child also reports its work state as its own state_update, right
after the parent's copy.
JetBrains AIR keeps the earlier draft of the RFD (subagent_spawned, subagent_state_update),
which it enables with nativeSubagentSessions in _meta.jetbrains.air.capabilities. See
AIR extensions.
Without either signal, Agent and Task stay ordinary tool calls and child interactions stay on the
root session. Clients that use the historical _meta["subagent-transcript"] capability or the
forwardSubagentText session option retain the flattened child transcript.
Experimental ACP v2
Started with CLAUDE_AGENT_ACP_EXPERIMENTAL_V2=1, the adapter also serves the
draft ACP v2: a client that asks for
protocol version 2 or later in initialize gets v2, and every other client gets v1, as before.
The draft can still change, and this surface changes with it.
On v2 the adapter serves the session baseline (session/new, session/list, session/resume
with replay, session/close, session/prompt, session/cancel), session/delete,
session/fork, additional directories, config options (the mode is one), plans, notices,
compaction, display terminals for commands, permission requests, elicitation, providers, auth, and
subagents. Steering and the AIR extensions are v1 only for now.
Contribution Policy
This project does not require a Contributor License Agreement (CLA). Instead, contributions are accepted under the following terms:
By contributing to this project, you agree that your contributions will be licensed under the Apache License, Version 2.0. You affirm that you have the legal right to submit your work, that you are not including code you do not have rights to, and that you understand contributions are made without requiring a Contributor License Agreement (CLA).