From 252126f61654cf7ac2cad74be53b4c6add569e1f Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Tue, 13 Oct 2026 16:11:25 +0200 Subject: [PATCH] feat: report a subagent as requires_action while its request is open for subagents RFD clients (#1322) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The third of three stacked PRs that replace #1257. It's based on #1321, so its diff shows only the `requires_action` part. Merge #1320 and #1321 first; this PR then retargets to `main`. 1. #1320: the RFD form for a v1 client that declares `subagents` and isn't AIR. 2. #1321: a child keeps its session across SendMessage resumes. 3. **This PR:** `requires_action` while a child's request is open. **AIR is unchanged.** ## What changes The [subagents RFD](https://github.com/agentclientprotocol/agent-client-protocol/blob/main/docs/rfds/subagents.mdx) reports a child that waits for the user as `requires_action`. - **While a request is open:** a child whose permission or question request is open reports `requires_action` on its parent, then `running` again when its last open request ends (`NativeSubagentRuntime.awaitingUser`). The agent wraps the permission and elicitation requests of a child session in it (`withChildAwaitingUser`). - **Overlapping requests** keep `requires_action` until the last one ends. A child that ended while it waited doesn't report `running` again. - **A resume while a request is open** reports `requires_action`, not `running`. ## Live run Against real Claude Code (Sonnet), a child's Bash permission request showed `running` → `requires_action` while the request was open → `running` once it was answered. No schema violations. ## Follow-ups (low severity, not in this PR) - **Brief `running` after a withdrawal:** after a cancel withdraws a child's request, the child briefly reports `running` before the SDK's stop event. The same happens at a stream end, whose cleanup aborts every pending request. - **Ignored cancel requests:** AskUserQuestion's `createElicitation` is not raced against the abort. If a client ignores `$/cancel_request`, the child's open-request count stays up, and a later resume reports `requires_action`. ## Tests - **End to end:** `requires_action` around a child's permission request. The cancel test now also expects the background child's `requires_action` before the cancel. - **Runtime:** overlapping requests, a child that ended while it waited, and a resume with an open request. - **Checks:** `npm run check`, build and all 2877 tests pass on this branch, whose tree is #1257's with `main` merged in. CI runs only on PRs to `main`, so I ran `ci.yml` on this branch by hand. --- README.md | 9 +++-- src/acp-agent.ts | 35 +++++++++++++---- src/native-subagents.ts | 39 ++++++++++++++++++- src/tests/acp-agent.test.ts | 53 ++++++++++++++++++++++++- src/tests/native-subagents.test.ts | 62 ++++++++++++++++++++++++++++++ 5 files changed, 184 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 9c7e832e..8bef7f96 100644 --- a/README.md +++ b/README.md @@ -41,10 +41,11 @@ A client that declares `clientCapabilities.subagents`, other than JetBrains AIR, 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`, 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. + 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. diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 95c890ad..fc9e8576 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -8759,6 +8759,21 @@ export class ClaudeAcpAgent { return response; } + /** Runs `request`, a request that `requestSessionId` sends for the session + * `ownerSessionId`. When that is a subagent's child session, the child + * reports that it awaits the user while the request is open (the RFD form, + * see `NativeSubagentRuntime.awaitingUser`). */ + private withChildAwaitingUser( + ownerSessionId: string, + requestSessionId: string, + request: () => Promise, + ): Promise { + const runtime = this.sessions[ownerSessionId]?.nativeSubagentRuntime; + return runtime && requestSessionId !== ownerSessionId + ? runtime.awaitingUser(requestSessionId, request) + : request(); + } + /** Mark a client request of the subagent `agentID` (absent for the main * conversation) as awaiting user input for exactly the lifetime of its * promise (see `Session.openUserInputs`). Steering consults the open @@ -8913,10 +8928,12 @@ export class ClaudeAcpAgent { // The request goes to a subagent's child session for a tool call of that // subagent, but it blocks the turn of the owner session. try { - return await this.withPendingUserInput( - ownerSessionId, - () => raceWithAbort(this.client.requestPermission(params, signal), signal), - agentID, + return await this.withChildAwaitingUser(ownerSessionId, params.sessionId, () => + this.withPendingUserInput( + ownerSessionId, + () => raceWithAbort(this.client.requestPermission(params, signal), signal), + agentID, + ), ); } catch (error) { if (signal.aborted) { @@ -9384,10 +9401,12 @@ export class ClaudeAcpAgent { ); let response; try { - response = await this.withPendingUserInput( - sessionId, - () => this.client.createElicitation(createRequest, signal), - agentID, + response = await this.withChildAwaitingUser(sessionId, requestSessionId, () => + this.withPendingUserInput( + sessionId, + () => this.client.createElicitation(createRequest, signal), + agentID, + ), ); } catch (error) { // A cancellation we requested (signal aborted) settles as an aborted tool diff --git a/src/native-subagents.ts b/src/native-subagents.ts index f64c2704..54161422 100644 --- a/src/native-subagents.ts +++ b/src/native-subagents.ts @@ -35,6 +35,8 @@ export type NativeSubagent = { * SDK's error text, which a later event of the same failure may carry. */ missingError?: boolean; + /** The RFD form: the open permission and question requests of the child. */ + openRequests?: number; /** The RFD form: the ids of the prompt messages in the child's transcript. */ promptIds?: Set; /** The RFD form: the calls that delegated to the child, whose runs ended. */ @@ -492,6 +494,32 @@ export class NativeSubagentRuntime { if (this.form === "air") await this.finishAll("cancelled", deliver); } + /** + * Runs `request`, a permission or question request of the session + * `sessionId`. In the RFD form, a child waiting on it is `requires_action` + * until its last open request ends. + */ + async awaitingUser(sessionId: string, request: () => Promise): Promise { + const child = this.form === "rfd" ? this.childOfSession(sessionId) : undefined; + if (!child?.announced) return request(); + child.openRequests = (child.openRequests ?? 0) + 1; + if (child.openRequests === 1 && child.workState === "running") { + await this.reportState(child, { state: "requires_action" }).catch((error) => + this.logger.log(`Failed to report that subagent ${child.sessionId} waits: ${error}`), + ); + } + try { + return await request(); + } finally { + child.openRequests = (child.openRequests ?? 1) - 1; + if (child.openRequests === 0 && child.workState === "requires_action") { + await this.reportState(child, { state: "running" }).catch((error) => + this.logger.log(`Failed to report that subagent ${child.sessionId} runs: ${error}`), + ); + } + } + } + discardPending(parentToolUseId: string): void { this.takePending(parentToolUseId); } @@ -518,7 +546,11 @@ export class NativeSubagentRuntime { this.childByParentToolUse.set(toolUseId, child); child.parentToolUseId = toolUseId; } - if (child.workState !== "running") await this.reportState(child, { state: "running" }); + if (!isWorking(child)) { + await this.reportState(child, { + state: child.openRequests ? "requires_action" : "running", + }); + } if (prompt && toolUseId) { await publishPrompt( child, @@ -901,6 +933,11 @@ async function publishPrompt( }); } +/** The RFD form: whether the child's foreground work is in progress. */ +function isWorking(child: NativeSubagent): boolean { + return child.workState === "running" || child.workState === "requires_action"; +} + /** * The RFD form: the work state at the end of a child's work. * diff --git a/src/tests/acp-agent.test.ts b/src/tests/acp-agent.test.ts index 71195fdc..d996bab7 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -5535,6 +5535,54 @@ describe("subagent permission attribution (issue #851)", () => { expect(requests[0].sessionId).toBe("child-session"); }); + it("reports a child as requires_action while its permission request is open (the subagents RFD)", async () => { + const { agent, updates, requests, session } = setup(); + await initializeClient(agent, { subagents: {} } as ClientCapabilities); + session.liveBackgroundTasks.set("agent-42", { + parentToolUseId: "toolu_parent", + isSubagent: true, + }); + session.nativeSubagentsByTaskId = new Map([ + [ + "agent-42", + { + sessionId: "agent-42", + parentSessionId: "session-1", + parentToolUseId: "toolu_parent", + name: "Explore", + task: "Investigate", + announced: true, + workState: "running", + }, + ], + ]); + session.nativeSubagentRuntime = new NativeSubagentRuntime( + "rfd", + "session-1", + session, + async (notification) => { + updates.push(notification as SessionNotification); + }, + { log: () => {} }, + ); + + await agent.canUseTool("session-1")("Bash", { command: "ls" }, { + signal: new AbortController().signal, + suggestions: [], + toolUseID: "toolu_sub", + agentID: "agent-42", + } as any); + + const states = updates.flatMap(({ sessionId, update }) => + update.sessionUpdate === "subagent_update" ? [[sessionId, update.state]] : [], + ); + expect(states).toEqual([ + ["session-1", { state: "requires_action" }], + ["session-1", { state: "running" }], + ]); + expect(requests[0].sessionId).toBe("agent-42"); + }); + it("keeps a raced permission on the root until the child is announced", async () => { const { agent, updates, requests, session } = setup(); await agent.initialize({ @@ -6688,7 +6736,10 @@ describe("subagent permission attribution (issue #851)", () => { expect(atResponse.at(-1)).toBe("prompt cancelled"); // The background child keeps running, with its request still open. expect(atResponse).not.toContain("withdrawn @agent-bg"); - expect(before("agent-bg ")).toEqual(['agent-bg {"state":"running"}']); + expect(before("agent-bg ")).toEqual([ + 'agent-bg {"state":"running"}', + 'agent-bg {"state":"requires_action"}', + ]); // When the stream ends, its end has no reason the adapter saw. expect(log.filter((line) => line.startsWith("agent-bg ")).at(-1)).toBe( 'agent-bg {"state":"idle"}', diff --git a/src/tests/native-subagents.test.ts b/src/tests/native-subagents.test.ts index 33b86fc9..b1b21e12 100644 --- a/src/tests/native-subagents.test.ts +++ b/src/tests/native-subagents.test.ts @@ -988,6 +988,29 @@ describe("NativeSubagentRuntime in the RFD form", () => { expect(runtime.isStaleEnd("worker-1", "launch-1")).toBe(false); }); + it("resumes a child with an open request as requires_action", async () => { + const { runtime, published } = await runningWorker(); + let answer!: () => void; + const request = runtime.awaitingUser( + "worker-1", + () => new Promise((resolve) => (answer = resolve)), + ); + await Promise.resolve(); + // The work ends while the request is open; then a resume runs it again. + await runtime.finishTask("worker-1", "completed", async () => {}, "launch-1"); + await runtime.taskStarted({ taskId: "worker-1", toolUseId: "send-1" }, async () => {}); + answer(); + await request; + + expect(states(published)).toEqual([ + { state: "running" }, + { state: "requires_action" }, + { state: "idle", stopReason: "end_turn" }, + { state: "requires_action" }, + { state: "running" }, + ]); + }); + it("names the session that sent a resume's prompt, and no sender when it is unknown", async () => { const { runtime, published } = await runningWorker(); // worker-1 launches worker-2, which the root then resumes. @@ -1190,4 +1213,43 @@ describe("NativeSubagentRuntime in the RFD form", () => { await runtime.finishTask("worker-1", "disconnected", async () => {}); expect(states(published).at(-1)).toEqual({ state: "unknown" }); }); + + it("reports a child as requires_action while its requests are open", async () => { + const { runtime, published } = await runningWorker(); + const first = Promise.withResolvers(); + const second = Promise.withResolvers(); + + const answers = Promise.all([ + runtime.awaitingUser("worker-1", () => first.promise), + runtime.awaitingUser("worker-1", () => second.promise), + ]); + await vi.waitFor(() => expect(states(published)).toHaveLength(2)); + first.resolve("allow"); + await first.promise; + expect(states(published).at(-1)).toEqual({ state: "requires_action" }); + second.resolve("deny"); + await expect(answers).resolves.toEqual(["allow", "deny"]); + + expect(states(published)).toEqual([ + { state: "running" }, + { state: "requires_action" }, + { state: "running" }, + ]); + // The root session's own requests change no child. + await runtime.awaitingUser("root", async () => "allow"); + expect(states(published)).toHaveLength(3); + }); + + it("does not report running again for a child that ended while it waited", async () => { + const { runtime, published } = await runningWorker(); + const answer = Promise.withResolvers(); + + const request = runtime.awaitingUser("worker-1", () => answer.promise); + await vi.waitFor(() => expect(states(published)).toHaveLength(2)); + await runtime.finishTask("worker-1", "completed", async () => {}); + answer.resolve("allow"); + await request; + + expect(states(published).at(-1)).toEqual({ state: "idle", stopReason: "end_turn" }); + }); });