mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
fix: report no running for a subagent's request that a cancel withdraws, for subagents RFD clients (#1337)
## Problem For a subagents RFD client, a subagent's child session reports `requires_action` while one of its permission or question requests is open, and `running` again once its last open request ends (#1322, `NativeSubagentRuntime.awaitingUser`). A request that ends because the work was **stopped** is not an answer, but it still reported `running`. On `main`, with Claude Code 2.1.296, a foreground child whose Bash permission request is open, and a cancel on the parent: | What stops the work | States the child reported | | --- | --- | | `session/cancel` (v1, and v2 on the child's own stream) | `running`, `requires_action`, **`running`**, `idle cancelled` | | `session/close` | `running`, `requires_action`, **`running`**, and then nothing: a closed session reports no `idle` | Claude Code's interrupt withdraws the request, so the SDK aborts the signal of the `canUseTool` call, the adapter sends `$/cancel_request`, and the request's `finally` reports `running`. The SDK's own report of the child's end (`task_updated killed`) comes a few ms later (3 to 4 ms in these runs), so the client shows a child that is being stopped as working. After a `session/close` nothing corrects it. ## Cause `awaitingUser` reported `running` whenever the last open request ended, whatever ended it. A request ends in two ways, the user answers it or Claude Code withdraws it, and a withdrawal alone does not say whether the work was stopped. Claude Code also withdraws a request when something else answers it, and then the child runs on. I ran one such case, a `PermissionRequest` hook that allows the command after 3 s: the child worked for 9 more seconds after the withdrawal, and `running` was right. (The CLI also re-checks a pending ask when its permission context changes, and withdraws it if that now allows the call. I did not manage to trigger that: `session/set_mode` to `bypassPermissions` left the request open in my run.) So "a withdrawn request reports no `running`" is wrong: that child would sit on `requires_action` for the rest of its run. Waiting for the child's next update instead does not help either: in that run the child's first update after the withdrawal came 8 s later, with the tool's result. ## Change A request reports no `running` when it was **withdrawn after a cancel that came while it was open**. Then Claude Code's interrupt withdrew it as it stopped the child, and the SDK's events report the child `idle`. - `NativeSubagentRuntime.parentCancelled` (called for `session/cancel`, and by the teardown of `session/close`) counts the cancels in the RFD form. It still reports nothing, so #2308 is unchanged: a child that keeps running keeps its requests and its state. - `awaitingUser` takes the request's `signal`, which the agent passes for permission requests and AskUserQuestion (`withChildAwaitingUser`). A request records the count when it opens. If its signal aborted and the count changed, its end reports no `running`. - Everything else is as before: a request that the user answers, also after the cancel, reports `running`; a withdrawal without a cancel reports `running`; two overlapping requests report once, when the last one ends, by how that one ended. - README: one clause in the work-state bullet of "Subagent sessions". AIR is unchanged: `awaitingUser` only runs in the RFD form, and the AIR golden files do not change. ## Evidence Live runs against Claude Code 2.1.296 (Sonnet), driving `dist/index.js` over stdio with a client that holds the child's permission request, validating every update and request against the SDK's JSON schema (0 violations in all runs): | Run | `main` | This PR | | --- | --- | --- | | v1, `session/cancel` | `running`, `requires_action`, `running`, `idle cancelled` | `running`, `requires_action`, `idle cancelled` | | v2, `session/cancel` (child's stream) | `running`, `requires_action`, `running`, `idle cancelled` | `running`, `requires_action`, `idle cancelled` | | v1, `session/close` | `running`, `requires_action`, `running` | `running`, `requires_action` | | v1, hook allows after 3 s, no cancel | `running`, `requires_action`, `running`, `idle end_turn` | the same | Tests (all fail without the rule, as checked by removing it): - `native-subagents.test.ts`: the cancel withdrawal (and the child's `idle cancelled` after it); a withdrawal without a cancel (the hook case, which a simpler "abort means stopped" rule fails); a request that outlives a cancel and is answered later; a cancel that came before the request opened; two overlapping requests, in each order. - `acp-agent.test.ts`: through `canUseTool`, with `agent.cancel` and `agent.closeSession` aborting the request as Claude Code does, and the hook case. - `acp-v2.test.ts`: the child's own `state_update` stream and the parent's copy. Removal checks, each run against the three test files: no rule (6 tests fail), the cancel not counted (6), the agent not passing the signal (3: agent cancel and close, v2), and the simple "any withdrawal" rule (3: the hook cases). `npm run check`, `npm run build` and `npm run test:run` pass (2935 tests; the scenario golden files do not change). ## Not covered - **A stream end without a cancel.** If Claude Code exits by itself, the SDK's cleanup aborts every request before the consumer reads the end of the stream, and nothing at that moment tells it from a hook's withdrawal. Live (`kill -9` of the CLI): `running`, then `idle`, 1 ms apart. The session is dead right after. A rule for it would be a timing one: report `running` for a withdrawal with no cancel one event-loop turn later, so that `finishAll` goes first. I tried it (`await new Promise((resolve) => setImmediate(resolve))` before the check in `awaitingUser`'s `finally`): the `kill -9` run then reports `running`, `requires_action`, `idle`, and the hook run still reports `running` 1 ms after the withdrawal. I did not include it. It only works when the end of the stream reaches `finishAll` in the same turn (a worker shutdown publishes its failure notice first, for one), and it hides an ordering assumption about the consumer. Your call. - **Claude Code stopping a child on its own** (the model's TaskStop) while its request is open: the same shape, no cancel. - **The root session on v2** has the same flicker for its own request: `requires_action`, `running`, `idle cancelled` (live, v2). It comes from `syncAwaitingUser`, not from this runtime, and is not changed here. - A background child that outlives a cancel and whose still-open request a hook then withdraws would report no `running`. Claude Code stops background children at an interrupt today, so it does not arise. Related, and independent: a child's AskUserQuestion is not abandoned on an abort when its client ignores `$/cancel_request` (#1338). The two PRs touch the same call site and merge cleanly in either order (checked with `git merge-tree`).
This commit is contained in:
@@ -42,7 +42,8 @@ Task subagent as its own child session, following the [subagents RFD](https://gi
|
||||
|
||||
- `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
|
||||
`requires_action` while one of its requests is open (and `running` again when the last one ends,
|
||||
unless a cancel withdrew it: the child is stopping then), 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.
|
||||
|
||||
Reference in New Issue
Block a user