Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions src/acp-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<StopTasksResponse> {
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<string>();
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 {
Expand Down Expand Up @@ -8537,6 +8641,11 @@ export function runAcp() {
.onRequest<SteerRequest, SteerResponse>(STEER_METHOD, { parse: parseSteerRequest }, (ctx) =>
agent.steer(ctx.params),
)
.onRequest<StopTasksRequest, StopTasksResponse>(
STOP_TASKS_METHOD,
{ parse: parseStopTasksRequest },
(ctx) => agent.stopBackgroundTasks(ctx.params),
)
.onRequest<GoalRequest, GoalControlResponse>(
GOAL_CONTROL_METHOD,
{ parse: parseGoalRequest },
Expand Down
133 changes: 133 additions & 0 deletions src/tests/acp-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { parentToolUseId?: string; isSubagent: boolean }>,
stopTask: (taskId: string) => Promise<void> = 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 = {
Expand Down