mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
## 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.