Files
claude-agent-acp/README.md
Ben Brandt 9a1b90fb9d fix: close the tool calls of a background subagent that the SDK reports stopped after a cancel (#1336)
## Problem

After `session/cancel`, the tool call of a **background** subagent's
command stays `pending` for good when no turn is left to settle.

Example, live (Claude Code 2.1.296, Sonnet): a background subagent runs
`python3 -c "import time; time.sleep(60)"`, and the client cancels while
it runs. Either the session is idle, or v1 holds the prompt open for the
subagent and the cancel settles it at once. Claude Code's interrupt
stops the subagent: the SDK reports `task_updated` `killed` and
`task_notification` `stopped`, and the RFD form reports the child `idle`
`cancelled`. The command's call is never closed, in the root session or
in the child's.

## Cause

#1327 closes a cancelled turn's open tool calls when the turn settles
(`closeCancelledToolCalls`), and leaves a background subagent's calls
open, because the interrupt may not stop it. Here the turn is already
settled (the cancel settled the held turn before the interrupt) or there
is none, and the stop comes after that. Nothing closes the calls then.

Claude Code does send the command's interrupted result. It comes after
the stop reports, so it is output after the work's end, and the
`session.cancelled` check of the user/assistant case drops it, as it
drops everything after that. The raw stream, in the order Claude Code
sends it:

```
task_updated      agent patch killed
task_notification agent stopped
task_notification the command's task stopped
user (ptu=agent)  tool_result for the command, is_error     <- dropped
```

## Change

- **A subagent's stop closes its open tool calls**
(`closeStoppedSubagentToolCalls`), at `task_notification` `stopped` and
at `task_updated` `killed`, after `finishTask` has reported the child
idle. It closes the calls whose recorded parent is that subagent's Agent
call (`Session.toolCallParents`) with the cancelled report that #1327
sends, naming the subagent so that every client form routes it like the
call's other updates.
- **Only where nothing else will end the calls**: a cancel is in effect
(`session.cancelled`), and the client was told that the work ended
(`workEndReported`: the prompt response on v1, `idle` on v2). While the
work runs, a stopped subagent's calls get Claude Code's own result or
`closeCancelledToolCalls` at the work's end, as before.
- The loop of `closeCancelledToolCalls` is now `closeOpenToolCalls`,
which takes the filter for the calls: the turn's closing, named
`closedAtCancel`, skips a running background subagent's calls, and this
closing takes one subagent's. These hunks are the same as in #1339,
which closes the calls of a cycle that Claude Code started, so that the
PRs merge in any order.
- README: one sentence on when such a call ends.

Nothing is closed twice: a closing forgets the call before it reports
it, so a later closing, and the result that Claude Code sends after the
stop, find nothing. The cancel settles a held turn and closes its calls
before it interrupts Claude Code, so the stop reports always come after
that closing.

## Per client form

- **v1 without subagents:** the call goes to the root session, after the
response when the cancel settled a held turn.
- **RFD:** the call ends in the child's session right after the child's
`idle` `cancelled`, as #1327 does at a turn's settle.
- **AIR:** the cancel already ended every child, so the runtime drops
the report (`ignoring late update for terminal subagent`). The call's
state is released, which it was not before.
- **v2:** as RFD. The root reports nothing, since it had no work.

## Evidence

**Tests** (10 new):

- `incomplete-tools.test.ts`: for a held turn and for no turn, the
command ends once with the cancelled report, after the cancel's
response, and the other subagent's command stays open; each of the two
reports ends it alone; a subagent that completed after the cancel is
left alone; one that stopped without a cancel, and one that stopped
while the work still runs, get Claude Code's result, not a made-up
report.
- `acp-agent.test.ts`: the RFD form (the child's session, after its
idle, after the response) and AIR (nothing reaches the ended child).
- `acp-v2.test.ts`: the child's command ends `cancelled` in its session
after its `idle cancelled`, and the root reports nothing more.

**Removal checks.** Each of these fails at least one test: no closing at
the notification (2), none at the patch (1), closing without a cancel
(1), before the work's end is reported (2), at any status of the
notification (1), of the patch (1), every subagent's calls (4), calls of
another parent (7), the turn's closing no longer skipping a running
background subagent (7), the report without the subagent's name (7), and
the closing before the child reports idle (2).

**Live**, 60 s command in a background subagent, cancel 4 s after its
permission request was answered, every update validated against the
SDK's JSON schema (0 violations):

| client | cancel while | `main` | this PR |
| --- | --- | --- | --- |
| v1, no capabilities | the held turn waits for the subagent |
`pending`, response `cancelled` | `failed` 4 ms after the response |
| v1, no capabilities | no turn runs | `pending` | `failed` after the
SDK's first stop report |
| v1, RFD | no turn runs | `pending` in the child's session | `failed`
in the child's session, after its `idle` `cancelled` |
| v1, RFD | the held turn | not run on `main` | `failed` in the child's
session, after the response |
| v1, AIR | the held turn | not closed | report dropped by the runtime,
state released |
| v2 | root idle | `pending` in the child's session | `cancelled` in the
child's session, after its `idle cancelled` |

`npm run check`, `npm run build` and `npm run test:run` (2934 tests)
pass. No golden file changes. With #1335 and #1339 merged into it, in
any of the six orders, the branch merges without conflicts and all 2948
tests pass.

## For the maintainer

- **Closing, not delivering.** Claude Code 2.1.296 sends the result, so
delivering it after the work's end would also end the call, with Claude
Code's text. I closed instead: 2.1.293 sent none for a subagent's call
(#1327), the result comes after the response in the window #1313 closes,
and closing works for both. The cost is the made-up text ("The turn was
cancelled before this tool call returned a result.") in place of Claude
Code's.

## Not covered

- A background subagent that the interrupt does not stop keeps running,
and its calls stay open. Its output after a cancel is dropped until the
next prompt, as before.
- A subagent that completes after a cancel: Claude Code's results for
its calls were dropped, and the calls stay open. Nothing says they were
cancelled, so none is reported. Delivering those results would end them.
- A subagent that a SendMessage resumed: its calls carry the id of the
call that launched it, while the registry holds the SendMessage id.
`runsInBackground` has the same limit.
- The calls of a cycle that Claude Code started on its own are #1339.
2026-10-14 11:45:36 +02:00

104 lines
7.1 KiB
Markdown

# ACP adapter for the Claude Agent SDK
[![npm](https://img.shields.io/npm/v/%40agentclientprotocol%2Fclaude-agent-acp)](https://www.npmjs.com/package/@agentclientprotocol/claude-agent-acp)
Use [Claude Agent SDK](https://platform.claude.com/docs/en/agent-sdk/overview#branding-guidelines) from [ACP-compatible](https://agentclientprotocol.com) clients!
This tool implements an ACP agent by using the official [Claude Agent SDK](https://platform.claude.com/docs/en/agent-sdk/overview), supporting:
- Context @-mentions
- Images
- Tool calls (with permission requests)
- Compact file changes through the negotiated [AIR diff patch extension](docs/air-extensions.md#diff-patch)
- Following
- Edit review
- TODO lists
- Nested subagent transcripts
- Interactive (and background) terminals
- Custom [Slash commands](https://docs.anthropic.com/en/docs/claude-code/slash-commands)
- Client MCP servers
- `/mcp` in the chat: the MCP server status as a list. The adapter runs `/mcp reconnect`, `/mcp enable`, and `/mcp disable` through 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](docs/air-extensions.md#goal) under `_meta.jetbrains.air.goal`
- Structured errors, recovery, and warnings through the opt-in [session failure extension](docs/air-extensions.md#session-failure)
- Concrete model and effort defaults through the opt-in [recommended config value extension](docs/air-extensions.md#recommended-config-values)
- Tool permission presentation, editable choices, and durable effects through the [permission extension](docs/air-extensions.md#permission-presentation)
- One fact per field in every tool call report, as the [ACP tool call contract](docs/air-extensions.md#tool-call-contract) defines
- All AIR extensions, capabilities, and `_meta` keys: [AIR extensions](docs/air-extensions.md)
Learn more about the [Agent Client Protocol](https://agentclientprotocol.com/).
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`](docs/RELEASES.md#preview-releases).
```sh
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](https://github.com/agentclientprotocol/agent-client-protocol/blob/main/docs/rfds/subagents.mdx):
- `subagent_update` on the parent announces the child before any of its traffic, with the
parent's `title` and `description` for it, and reports its work state: `running`,
`requires_action` while one of its requests is open, and `idle` when its work ends, with
`end_turn` or `cancelled` when the SDK gives the reason. A failure is `idle` with the `error`
stop reason and the SDK's error text as its `error`. 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_message` in the child's transcript. The tool
call stays a tool call of the session that made it.
- `session/cancel` on the parent interrupts Claude Code, which stops the foreground children that
the turn waits on and, currently, background children too. Each stopped child reports `idle`
with `cancelled` from 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. When Claude Code stops a background child after that, or while no prompt runs,
the tool call ends right after the child reports `idle`. A child that still runs when Claude Code
exits is `idle` with 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_message`s, and its end is an `idle` before 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.
- After a load or resume, with or without replay, a SendMessage that resumes an agent from before
it runs that agent in its session from before. Before the agent's traffic, each of its ancestors
that the client was not told about in this connection is announced, parent before child and
with no work state, as the RFD requires. An agent whose launch the session's history does not
record stays unexposed.
- A client cannot prompt, cancel, or otherwise change a child yet.
An [ACP v2](#experimental-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](docs/air-extensions.md#native-subagent-sessions).
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](https://agentclientprotocol.com/protocol/v2/overview): 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](#subagent-sessions). 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](https://www.apache.org/licenses/LICENSE-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).