From 45b25c1f43e50cbd85247be25d1d61a468e01c6b Mon Sep 17 00:00:00 2001 From: Jeff Strunk Date: Thu, 13 Aug 2026 17:15:31 +0000 Subject: [PATCH] feat: add _session/stop_tasks to stop background sub-agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #994 session/cancel aborts the turn, but the SDK's background tasks (sub-agents) survive it by design — and in the settled-dispatch shape there is no turn to cancel at all, so a client's stop button is otherwise a no-op while the sub-agents run on invisibly. There was no ACP surface to stop them. Adds a request extension method mirroring _session/steering: _session/stop_tasks { sessionId, toolUseIds?: string[] } -> { stopped: string[], notFound: string[] } (parent tool_use ids) With toolUseIds, stop the live tasks whose spawning Agent/Task call id matches; without, stop every live isSubagent task (background Bash spared, matching the discriminator used across the liveBackgroundTasks registry). Each stop is session.query.stopTask(taskId); a per-task failure degrades to notFound rather than failing the batch. The SDK emits a task_notification with status 'stopped', which the existing terminal path turns into a card close. Capability advertised at initialize via _meta.stopTasks.supported. Adds unit tests covering: stop by explicit toolUseIds, stop-all sub-agents with Bash spared, per-task stopTask failure degrading to notFound without failing the batch, and the capability advertisement at initialize. --- src/acp-agent.ts | 109 +++++++++++++++++++++++++++++ src/tests/acp-agent.test.ts | 133 ++++++++++++++++++++++++++++++++++++ 2 files changed, 242 insertions(+) diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 3b70d658..26bdb0f5 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -282,6 +282,54 @@ function parseSteerRequest(params: unknown): SteerRequest { }; } +/** Custom (extension) request method a client uses to stop the background + * tasks (sub-agents) a session has dispatched. `session/cancel` aborts the + * turn, but the SDK's background tasks survive it by design — and in the + * settled-dispatch shape there is no turn to cancel at all, so a client's stop + * button is otherwise a no-op while the sub-agents run on invisibly. Same + * ext-method conventions as {@link STEER_METHOD} above; advertised to clients + * via `InitializeResponse._meta.stopTasks.supported`. */ +const STOP_TASKS_METHOD = "_session/stop_tasks"; + +/** Params of a {@link STOP_TASKS_METHOD} request. With `toolUseIds`, stop the + * live tasks whose spawning Agent/Task tool_use id matches (the card ids a + * client already renders); without it, stop every live `isSubagent` task. + * Background Bash jobs are deliberately spared in the latter case, matching + * the sub-agent discriminator the rest of the registry uses. */ +export type StopTasksRequest = { + sessionId: string; + toolUseIds?: string[]; +}; + +/** Result of a {@link STOP_TASKS_METHOD} request, in parent tool_use ids: the + * calls whose task the SDK accepted a stop for (`stopped`) and, when explicit + * `toolUseIds` were requested, those that matched no live task (`notFound`). + * A per-task `stopTask()` failure degrades to `notFound` rather than failing + * the batch. */ +export type StopTasksResponse = { + stopped: string[]; + notFound: string[]; +}; + +/** Validate raw JSON-RPC params into a {@link StopTasksRequest}. `sessionId` is + * required; `toolUseIds`, if present, must be an array of non-empty strings. */ +function parseStopTasksRequest(params: unknown): StopTasksRequest { + if (!params || typeof params !== "object") { + throw RequestError.invalidParams(undefined, "stop_tasks params must be an object"); + } + const { sessionId, toolUseIds } = params as Record; + if (typeof sessionId !== "string" || sessionId.length === 0) { + throw RequestError.invalidParams(undefined, "stop_tasks params require a non-empty sessionId"); + } + if ( + toolUseIds !== undefined && + (!Array.isArray(toolUseIds) || toolUseIds.some((t) => typeof t !== "string" || t.length === 0)) + ) { + throw RequestError.invalidParams(undefined, "toolUseIds must be an array of non-empty strings"); + } + return { sessionId, toolUseIds: toolUseIds as string[] | undefined }; +} + /** Internal model-selection state. Mirrors the shape the ACP SDK exposed as * `SessionModelState` before model selection moved entirely into * `SessionConfigOption` (category "model"). Retained internally to track the @@ -1777,6 +1825,12 @@ export class ClaudeAcpAgent { steering: { supported: true, }, + // Advertises the `_session/stop_tasks` request so clients know they may + // stop the background sub-agents a session dispatched (see + // STOP_TASKS_METHOD). + stopTasks: { + supported: true, + }, goal: { version: GOAL_EXTENSION_VERSION, controlMethod: GOAL_CONTROL_METHOD, @@ -2247,6 +2301,56 @@ export class ClaudeAcpAgent { return { outcome: "injected" }; } + /** Stop the background tasks (sub-agents) a session dispatched. See + * {@link parseStopTasksRequest} for the request shape. + * + * Reading `liveBackgroundTasks` is safe here: a stoppable task has not + * settled, so its entry is live by definition (the settle paths that would + * delete an entry all fire at settle time). A task that finishes in the + * window between snapshot and stop simply lands in `notFound` — the honest + * answer. The `stopped` notification the SDK emits flows through the normal + * `task_notification` path, so the existing terminal handling closes the + * card. */ + async stopBackgroundTasks(params: StopTasksRequest): Promise { + const session = this.sessions[params.sessionId]; + if (!session) { + throw new Error("Session not found"); + } + if (session.queryClosed) { + throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE); + } + // With explicit ids, target the live tasks whose spawning call matches; + // without, target every live sub-agent (background Bash is spared — it is + // not `isSubagent`, matching the discriminator used across the registry). + const only = + params.toolUseIds && params.toolUseIds.length > 0 ? new Set(params.toolUseIds) : null; + const targets: Array<[string, string]> = []; + for (const [taskId, record] of session.liveBackgroundTasks) { + const parentId = typeof record.parentToolUseId === "string" ? record.parentToolUseId : ""; + if (only ? parentId.length > 0 && only.has(parentId) : record.isSubagent) { + targets.push([taskId, parentId]); + } + } + const stopped: string[] = []; + const matched = new Set(); + for (const [taskId, parentId] of targets) { + try { + await session.query.stopTask(taskId); + stopped.push(parentId || taskId); + if (parentId) { + matched.add(parentId); + } + } catch (error) { + // Most likely the task settled between snapshot and stop; the SDK also + // rejects unknown ids. Either way it is not stopped BY US — report + // rather than fail the batch. + this.logger.error(`Session ${params.sessionId}: stopTask(${taskId}) failed: ${error}`); + } + } + const notFound = only ? [...only].filter((id) => !matched.has(id)) : []; + return { stopped, notFound }; + } + /** Lazily start the per-session consumer that drains the SDK query stream for * the session's whole life. Idempotent: only the first `prompt()` starts it. */ private ensureConsumer(session: Session, sessionId: string): void { @@ -8537,6 +8641,11 @@ export function runAcp() { .onRequest(STEER_METHOD, { parse: parseSteerRequest }, (ctx) => agent.steer(ctx.params), ) + .onRequest( + STOP_TASKS_METHOD, + { parse: parseStopTasksRequest }, + (ctx) => agent.stopBackgroundTasks(ctx.params), + ) .onRequest( GOAL_CONTROL_METHOD, { parse: parseGoalRequest }, diff --git a/src/tests/acp-agent.test.ts b/src/tests/acp-agent.test.ts index 49b59944..9b4b2c5c 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -12119,6 +12119,139 @@ describe("turn steering (_session/steering)", () => { }); }); +describe("stop background tasks (_session/stop_tasks)", () => { + function createMockAgent() { + const mockClient = { + sessionUpdate: async () => {}, + } as unknown as AcpClient; + return new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} }); + } + + /** Install a session whose `query.stopTask` is a spy, seeded with the given + * live background task records (keyed by task id). Returns the spy so tests + * can assert which task ids the SDK was asked to stop. */ + function seedSession( + agent: ClaudeAcpAgent, + records: Record, + stopTask: (taskId: string) => Promise = async () => {}, + ) { + const spy = vi.fn(stopTask); + const liveBackgroundTasks = new Map(Object.entries(records)); + agent.sessions["test-session"] = mockSessionState({ + liveBackgroundTasks, + query: { stopTask: spy }, + }); + return spy; + } + + it("rejects when the session is unknown", async () => { + const agent = createMockAgent(); + await expect(agent.stopBackgroundTasks({ sessionId: "missing" })).rejects.toThrow( + "Session not found", + ); + }); + + it("rejects when the query stream has already closed", async () => { + const agent = createMockAgent(); + seedSession(agent, {}); + agent.sessions["test-session"]!.queryClosed = true; + await expect(agent.stopBackgroundTasks({ sessionId: "test-session" })).rejects.toThrow(); + }); + + it("stops the tasks named by explicit toolUseIds and returns them in `stopped`", async () => { + const agent = createMockAgent(); + const spy = seedSession(agent, { + "agent-1": { parentToolUseId: "toolu_1", isSubagent: true }, + "agent-2": { parentToolUseId: "toolu_2", isSubagent: true }, + }); + + const res = await agent.stopBackgroundTasks({ + sessionId: "test-session", + toolUseIds: ["toolu_1"], + }); + + // Only the requested task is stopped; its parent tool_use id is reported. + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith("agent-1"); + expect(res.stopped).toEqual(["toolu_1"]); + expect(res.notFound).toEqual([]); + }); + + it("reports an unmatched explicit toolUseId in `notFound`", async () => { + const agent = createMockAgent(); + const spy = seedSession(agent, { + "agent-1": { parentToolUseId: "toolu_1", isSubagent: true }, + }); + + const res = await agent.stopBackgroundTasks({ + sessionId: "test-session", + toolUseIds: ["toolu_1", "toolu_absent"], + }); + + expect(spy).toHaveBeenCalledTimes(1); + expect(res.stopped).toEqual(["toolu_1"]); + expect(res.notFound).toEqual(["toolu_absent"]); + }); + + it("without toolUseIds stops every live sub-agent and spares background Bash", async () => { + const agent = createMockAgent(); + const spy = seedSession(agent, { + "agent-1": { parentToolUseId: "toolu_agent", isSubagent: true }, + "bash-1": { parentToolUseId: "toolu_bash", isSubagent: false }, + }); + + const res = await agent.stopBackgroundTasks({ sessionId: "test-session" }); + + // The sub-agent is stopped; the background Bash task is untouched. + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith("agent-1"); + expect(spy).not.toHaveBeenCalledWith("bash-1"); + expect(res.stopped).toEqual(["toolu_agent"]); + // notFound is empty in the "stop all sub-agents" mode (no explicit ids). + expect(res.notFound).toEqual([]); + }); + + it("a stopTask that throws lands the id in `notFound` and does not fail the batch", async () => { + const agent = createMockAgent(); + // agent-1 rejects; agent-2 succeeds. The batch must still stop agent-2. + const spy = seedSession( + agent, + { + "agent-1": { parentToolUseId: "toolu_1", isSubagent: true }, + "agent-2": { parentToolUseId: "toolu_2", isSubagent: true }, + }, + async (taskId: string) => { + if (taskId === "agent-1") { + throw new Error("task already settled"); + } + }, + ); + + const res = await agent.stopBackgroundTasks({ + sessionId: "test-session", + toolUseIds: ["toolu_1", "toolu_2"], + }); + + // Both were attempted; the failure did not abort the batch. + expect(spy).toHaveBeenCalledTimes(2); + expect(res.stopped).toEqual(["toolu_2"]); + expect(res.notFound).toEqual(["toolu_1"]); + }); + + it("advertises the stopTasks capability at initialize", async () => { + const agent = createMockAgent(); + const response = await agent.initialize({ + protocolVersion: 1, + clientCapabilities: {}, + }); + // Top-level _meta (sibling of agentCapabilities), mirroring the steering + // extension contract. + expect((response._meta as any)?.stopTasks).toEqual({ + supported: true, + }); + }); +}); + describe("session/cancel wedge recovery (issue #680)", () => { function createMockAgent() { const mockClient = {