Merge branch 'v2/review2-b' into v2-core-exploration

# Conflicts:
#	src/tests/v2.test.ts
This commit is contained in:
Ben Brandt
2026-11-09 23:23:02 +01:00
11 changed files with 403 additions and 54 deletions

View File

@@ -216,7 +216,9 @@ lists: `_meta.jetbrains.air.capabilities` is `["agentFileChangeReport", "recomme
`_meta.jetbrains.air.agentFileChangeReportRequest` is valid gets one report. v1 sends it before
the prompt's response, at the turn's end. A v2 prompt is answered at its insertion, so the
report goes out at the end of the turn that serves the prompt, before that turn's idle: the
session reads the prompt's checkpoint (up to 2 seconds), and the idle waits for it. A turn that
session reads the prompt's checkpoint (up to 2 seconds), and the idle waits for it. A turn whose
plan runs in a fresh context (ExitPlanMode's "Yes, clear context") reads the checkpoint of the
plan's prompt instead: `/clear` reset Claude Code's file history. A turn that
ends `cancelled` reports `unavailable` with `cancelled`. Any other turn that ends with an error
or without a result reports `unavailable` with `providerError`. A prompt that a cancel answers with -32800 before its
insertion reports `cancelled`, and one that fails otherwise `providerError`.
@@ -256,7 +258,9 @@ exit codes), which the shared renderer gives only with `air.client`, and the `ki
for AskUserQuestion (on the session of the agent that asks), for an MCP server's form, and for
the refusal fallback's consent prompt. A URL for an MCP server's URL request and for MCP OAuth.
All but MCP OAuth are open requests of their session, like permission requests: a cancel or a
close withdraws them. MCP OAuth starts after the setup response, and a close withdraws it.
close withdraws them. MCP OAuth starts after the setup response, and a close withdraws it. A
restart of the query (`restarts.ts`) withdraws it on the old query and starts it again on the
new one.
Without form elicitation, AskUserQuestion is disallowed and the refusal fallback is not
declared. An MCP server's request in a mode the client did not advertise, or that the client
answers with an error, is answered `cancel`.
@@ -341,8 +345,7 @@ exit codes), which the shared renderer gives only with `air.client`, and the `ki
that a result of any session on the same backend reported, else a guess from the model's names
refined by a background `getContextUsage`, until the session's own result reports it.
About 5,500 lines in total, plus the v2 wire conversions (see "History"). The v2 agent shares
these with the v1 core:
The v2 agent shares these with the v1 core:
- the SDK message mapping and tool call rendering (`toAcpNotifications`,
`streamEventToAcpNotifications`, `tool-calls/*`)

View File

@@ -1649,7 +1649,8 @@ The hold protects a client that stops listening at the prompt response.
### Behavior with the capability
AIR declares `backgroundSubagents` in `initialize.clientCapabilities._meta.jetbrains.air.capabilities`.
The capability applies to ACP v1 only. ACP v2 keeps the hold, and the agent does not advertise the capability to ACP v2.
The capability applies to ACP v1 only, and the agent does not advertise it to ACP v2.
ACP v2 has no hold: the v2 agent answers `session/prompt` when Claude Code takes the prompt in, lists a background subagent in its parent session's `background` while it works, and reports each subagent's work in the state of its child session (see [ACP v2](#acp-v2)).
- The turn ends at its result with the stop reason of the result, usually `end_turn`.
- The subagent updates, permission requests and elicitations go out after the prompt response.

View File

@@ -145,6 +145,7 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
rewindFiles: fake.rewindFiles,
mcpServerStatus: async () => sdk.mcpServers,
...(sdk.enableRemoteControl ? { enableRemoteControl: sdk.enableRemoteControl } : {}),
setMcpServers: async () => ({ added: [], removed: [], errors: {} }),
// A server that needs OAuth: Claude Code returns its authorization URL.
mcpAuthenticate: async () => ({
authUrl: "https://mcp.example.com/authorize",
@@ -674,6 +675,66 @@ describe("ACP v2 core", () => {
});
});
describe("MCP OAuth", () => {
const linear = { type: "http" as const, name: "linear", url: "https://mcp.example.com/mcp" };
/** A client that leaves each OAuth elicitation open until the agent withdraws it. */
const oauthClient = () => {
const withdrawn: number[] = [];
const client = v2Client({
answerElicitation: (_, signal) =>
new Promise((resolve) =>
signal.addEventListener("abort", () => {
withdrawn.push(client.elicitations.length);
resolve({ action: "cancel" });
}),
),
});
return { client, withdrawn };
};
const initialize = (agent: v2.ClientContext) =>
agent.request(v2.methods.agent.initialize, {
protocolVersion: v2.PROTOCOL_VERSION,
info: { name: "v2-test-client", version: "1.0.0" },
capabilities: { elicitation: { url: {} } },
});
it("starts OAuth again on the new query of a restart that withdrew it", async () => {
// The server stays `needs-auth`: the user has not signed in yet.
sdk.mcpServers = [{ name: "linear", status: "needs-auth" }];
const { client, withdrawn } = oauthClient();
await client.connect(async (agent) => {
await initialize(agent);
await agent.request(v2.methods.agent.session.new, { cwd, mcpServers: [linear] });
await vi.waitFor(() => expect(client.elicitations).toHaveLength(1));
await setProvider(agent);
await vi.waitFor(() => expect(client.elicitations).toHaveLength(2));
// The old query's elicitation was withdrawn before the new one went out.
expect(withdrawn).toEqual([1]);
});
});
it("starts OAuth only on the new query of a resume with other additional directories", async () => {
sdk.mcpServers = [{ name: "linear", status: "needs-auth" }];
const extra = await mkdtemp(path.join(tempDir, "extra-"));
const { client, withdrawn } = oauthClient();
await client.connect(async (agent) => {
await initialize(agent);
const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd });
await agent.request(v2.methods.agent.session.resume, {
sessionId,
cwd,
mcpServers: [linear],
additionalDirectories: [extra],
});
await vi.waitFor(() => expect(sdk.processes[0].close).toHaveBeenCalled());
await vi.waitFor(() => expect(client.elicitations).toHaveLength(1));
await new Promise((resolve) => setTimeout(resolve, 20));
expect(client.elicitations).toHaveLength(1);
expect(withdrawn).toEqual([]);
});
});
});
it("stops a session whose query does not start again under the new route", async () => {
const client = v2Client();
await client.connect(async (agent) => {
@@ -1858,6 +1919,54 @@ describe("ACP v2 core", () => {
expect(client.sessionUpdates.at(-1)).toMatchObject({ state: "idle", stopReason: "end_turn" });
});
it.each([
["a cycle of Claude Code's own ended", "cycle"],
["a prompt started", "prompt"],
])(
"does not read a later aborted result as cancelled by a cancel of an idle session once %s",
async (_, after) => {
const client = v2Client();
await client.connect(async (agent) => {
const { response, claude } = await openSession(agent);
const sessionId = response.sessionId;
// Claude Code runs nothing: the interrupt stops nothing and sends nothing.
await agent.notify(v2.methods.agent.session.cancel, { sessionId });
await vi.waitFor(() => expect(claude.interrupt).toHaveBeenCalled());
if (after === "cycle") {
// Say, a background task ended: Claude Code starts a cycle after the interrupt.
claude.send(cycleStart());
claude.send(result());
claude.send(sessionState("idle"));
await vi.waitFor(() => expect(client.states()).toEqual(["running", "idle"]));
claude.send(cycleStart());
} else {
const answer = agent.request(v2.methods.agent.session.prompt, {
sessionId,
prompt: PROMPT,
});
claude.send(lifecycle(await claude.prompt(0), "started"));
await answer;
}
// Then an interrupt of Claude Code's remote control stops the work.
claude.send(
result({
subtype: "error_during_execution",
is_error: true,
terminal_reason: "aborted_streaming",
}),
);
claude.send(sessionState("idle"));
await vi.waitFor(() =>
expect(client.states()).toEqual(
after === "cycle" ? ["running", "idle", "running", "idle"] : ["running", "idle"],
),
);
});
expect(client.sessionUpdates.at(-1)).toMatchObject({ state: "idle", stopReason: "error" });
},
);
it("sends no permission request of a session after session/close answered", async () => {
const client = v2Client();
const decision = await client.connect(async (agent) => {
@@ -2502,6 +2611,35 @@ describe("ACP v2 core", () => {
});
});
it("lets the subagent that a child waits on ask again when Claude Code refuses to stop the child", async () => {
const client = v2Client({ answerPermission: () => new Promise(() => {}) });
await client.connect(async (agent) => {
const { claude } = await openSession(agent);
const child = subagent("child", "toolu_launch", { is_backgrounded: true });
const inner = subagent("inner", "toolu_inner", { parent_task_id: "child" });
claude.send(cycleStart());
claude.send(child.launch());
claude.send(child.started());
claude.send(inner.launch("toolu_launch"));
claude.send(inner.started());
await vi.waitFor(() =>
expect(updatesOf(client.notifications, "inner")).toContainEqual(
expect.objectContaining({ state: "running" }),
),
);
claude.stopTask.mockRejectedValueOnce(new Error("refused"));
await agent.notify(v2.methods.agent.session.cancel, { sessionId: "child" });
await vi.waitFor(() => expect(claude.stopTask).toHaveBeenCalledWith("child"));
await new Promise((resolve) => setTimeout(resolve, 20));
// The inner subagent's run goes on: its next request is its own.
void askPermission(claude, "Bash", { command: "make" }, { agentID: "inner" }).catch(
() => {},
);
await vi.waitFor(() => expect(client.permissionRequests).toHaveLength(1));
expect(client.permissionRequests[0].sessionId).toBe("inner");
});
});
it("reports the root running again when the interrupt fails, and lets its subagent ask again", async () => {
let answer = (_: v2.RequestPermissionResponse) => {};
const client = v2Client({
@@ -3498,27 +3636,171 @@ describe("ACP v2 core", () => {
expect(reportAt).toBeLessThan(idleAt);
});
it("reports a prompt that a cancel ended unavailable, without reading a checkpoint", async () => {
const client = v2Client();
const { claude, sessionId } = await client.connect(async (agent) => {
it("reads the report of a turn whose plan ran in a fresh context from the plan's prompt", async () => {
const client = v2Client({
answerPermission: () => ({
outcome: { outcome: "selected", optionId: "exit-plan-clear-auto" },
}),
});
const changed = path.join(await realpath(cwd), "src", "App.ts");
const { claude, sessionId, plan } = await client.connect(async (agent) => {
const { response, claude } = await openSession(agent, air("agentFileChangeReport"));
claude.rewindFiles.mockResolvedValue({ canRewind: true, filesChanged: [changed] });
const answer = agent.request(v2.methods.agent.session.prompt, {
sessionId: response.sessionId,
prompt: PROMPT,
_meta: requestMeta("r-1"),
});
await claude.prompt(0);
await agent.notify(v2.methods.agent.session.cancel, { sessionId: response.sessionId });
await expect(answer).rejects.toMatchObject({ code: -32800 });
return { claude, sessionId: response.sessionId };
claude.send(echo(await claude.prompt(0)));
await answer;
claude.send(
assistant("msg_plan", [
{
type: "tool_use",
id: "toolu_plan",
name: "ExitPlanMode",
input: { plan: "Do it" },
},
]),
);
await askPermission(
claude,
"ExitPlanMode",
{ plan: "Do it" },
{ toolUseID: "toolu_plan" },
);
claude.send({
type: "user",
message: {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_plan",
content: "User accepted the plan and requested a fresh context",
is_error: true,
},
],
},
tool_result_meta: [{ id: "toolu_plan", non_execution_kind: "user-rejected" }],
parent_tool_use_id: null,
uuid: randomUUID(),
});
claude.send(
result({
subtype: "error_during_execution",
is_error: true,
stop_reason: null,
errors: [
"[ede_diagnostic] result_type=user last_content_type=n/a stop_reason=tool_use",
],
}),
);
claude.send(sessionState("idle"));
const clear = await claude.prompt(1);
claude.send(echo(clear));
claude.send({
type: "conversation_reset",
new_conversation_id: randomUUID(),
uuid: randomUUID(),
trigger: "clear",
timestamp: new Date().toISOString(),
session_id: "session",
});
claude.send(result({ num_turns: 0, stop_reason: null }));
// `/clear` reset Claude Code's file history: only the plan's prompt has a checkpoint.
const plan = await claude.prompt(2);
claude.send(echo(plan));
claude.send(result());
claude.send(sessionState("idle"));
await vi.waitFor(() => expect(client.states().at(-1)).toBe("idle"));
return { claude, sessionId: response.sessionId, plan };
});
expect(claude.rewindFiles).not.toHaveBeenCalled();
expect(claude.rewindFiles).toHaveBeenCalledExactlyOnceWith(plan.uuid, { dryRun: true });
expect(reports(updatesOf(client.notifications, sessionId))).toEqual([
{ version: 1, requestId: "r-1", status: "unavailable", reason: "cancelled" },
{
version: 1,
requestId: "r-1",
status: "reported",
paths: [changed],
declaredComplete: false,
truncated: false,
},
]);
});
it.each([
["a cancel answered with -32800 before its insertion", false, "cancel", "cancelled"],
["a cancel stopped after its insertion", true, "cancel", "cancelled"],
["ended with an error result", true, "error", "providerError"],
["ended without a result, as the query ended", true, "end", "providerError"],
[
"failed otherwise before its insertion, as the query ended",
false,
"end",
"providerError",
],
] as const)(
"reports a prompt that %s unavailable, without reading a checkpoint",
async (_, insert, end, reason) => {
const client = v2Client();
const { claude, sessionId } = await client.connect(async (agent) => {
const { response, claude } = await openSession(agent, air("agentFileChangeReport"));
const sessionId = response.sessionId;
const answer = agent
.request(v2.methods.agent.session.prompt, {
sessionId,
prompt: PROMPT,
_meta: requestMeta("r-1"),
})
.catch((error: unknown) => error);
const prompt = await claude.prompt(0);
if (insert) {
claude.send(echo(prompt));
expect(await answer).toEqual({ messageId: prompt.uuid });
}
if (end === "cancel") {
await agent.notify(v2.methods.agent.session.cancel, { sessionId });
if (insert) {
await vi.waitFor(() => expect(claude.interrupt).toHaveBeenCalled());
claude.send(
result({
subtype: "error_during_execution",
is_error: true,
terminal_reason: "aborted_streaming",
}),
);
claude.send(sessionState("idle"));
}
} else if (end === "error") {
claude.send(
result({
subtype: "error_during_execution",
is_error: true,
errors: ["Overloaded"],
}),
);
claude.send(sessionState("idle"));
} else {
claude.end(new Error("Claude Code exited"));
}
if (!insert) {
expect(await answer).toMatchObject({ code: end === "cancel" ? -32800 : -32603 });
}
await vi.waitFor(() =>
expect(reports(updatesOf(client.notifications, sessionId))).toHaveLength(1),
);
return { claude, sessionId };
});
expect(claude.rewindFiles).not.toHaveBeenCalled();
expect(reports(updatesOf(client.notifications, sessionId))).toEqual([
{ version: 1, requestId: "r-1", status: "unavailable", reason },
]);
},
);
it.each([
["an AIR client without the capability", air()],
["any other client", {}],

View File

@@ -60,7 +60,13 @@ import { canUseTool, denyingStoppedWork } from "./permissions.js";
import { reportHooks } from "./reports.js";
import { SessionRestarts, type SetupParams } from "./restarts.js";
import { V2Session, type SessionConnection } from "./session.js";
import { clientOptions, replayEndUpdate, sessionModels, withClientHooks } from "./setup.js";
import {
afterSetupResponse,
clientOptions,
replayEndUpdate,
sessionModels,
withClientHooks,
} from "./setup.js";
import type { SubagentReplay } from "./subagent-replay.js";
import { SubscriptionGuard } from "./subscription-guard.js";
import { restrictedChild } from "./tasks.js";
@@ -384,7 +390,8 @@ class V2Agent implements AgentHandle, SessionConnection {
await session.reports.publishTitle();
for (const notification of session.resumeSnapshot()) this.send(notification);
const response = await this.setupFields(session);
afterSetupResponse(session, params);
// A restart for other additional directories retires `session`.
if (!this.restarts.restarting(sessionId)) afterSetupResponse(session, params);
// The response ends the replay, and the replayed children's states before
// it are history: their current states follow it. Nothing enforces that
// the timer runs after the response goes out; it relies on the response
@@ -887,24 +894,6 @@ async function validatedSetup(
return request;
}
/**
* Starts OAuth for the setup request's MCP servers that need it, once the
* setup response is sent: v2 asks for no elicitation before it. The handler
* returns the response right after this call, and the SDK writes it before a
* timer runs. Nothing else orders the two.
*/
function afterSetupResponse(
session: V2Session,
params: v2.NewSessionRequest | v2.ResumeSessionRequest | v2.ForkSessionRequest,
): void {
// The setup already rejected servers of an unknown transport.
const names = (params.mcpServers ?? []).flatMap((server) =>
typeof server.name === "string" ? [server.name] : [],
);
if (names.length === 0) return;
setTimeout(() => session.elicitations.authenticateMcpServers(names), 0);
}
function sameDirectories(left: readonly string[], right: readonly string[]): boolean {
const normalize = (directories: readonly string[]) =>
[...new Set(directories.map((directory) => path.resolve(directory)))].sort().join("\n");

View File

@@ -149,9 +149,11 @@ export class ClearContext {
return this.continuation !== undefined && !this.continuation.planStarted;
}
/** Claude Code took in the prompt `uuid`. */
promptStarted(uuid: string): void {
if (this.continuation?.planUuid === uuid) this.continuation.planStarted = true;
/** Claude Code took in the prompt `uuid`. Returns whether it is the plan's prompt. */
promptStarted(uuid: string): boolean {
if (this.continuation?.planUuid !== uuid) return false;
this.continuation.planStarted = true;
return true;
}
/** Whether `uuid` is one of the adapter's prompts for the fresh context: the client gets no `user_message`. */

View File

@@ -68,6 +68,17 @@ export class TurnFileChanges {
this.turn.push(report);
}
/**
* The open turn goes on in a fresh context (`clear-context.ts`), with the
* plan's prompt `planUuid`. `/clear` reset Claude Code's file history, so
* the checkpoints of the turn's own prompts are gone: its reports read the
* plan prompt's checkpoint. v1 sends the plan with the turn's prompt uuid
* instead (`clear-context-coordinator.ts`).
*/
contextCleared(planUuid: string): void {
for (const report of this.turn) report.promptUuid = planUuid;
}
/** A message of the query: a subagent's file-tool edits go into the open turn's reports. */
message(message: SDKMessage): void {
if (this.turn.length === 0) return;

View File

@@ -37,7 +37,9 @@
* (their terminals exit), as when a query ends. The new query starts without
* the effort of the setup's `_meta`, and the client's choices are set again
* on it (`SessionConfig.restoreFrom`), which then reports its options in a
* `config_option_update`. One restart of a session runs at
* `config_option_update`. Ending the old query withdraws its MCP OAuth
* runs, and the new one starts OAuth for the setup's MCP servers that still
* need it, as a setup does. One restart of a session runs at
* a time: a route restart waits for an account restart that runs, as for a
* busy session.
*
@@ -51,6 +53,7 @@ import type * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
import type { Logger } from "../acp-agent.js";
import { withoutEffort } from "../session-effort.js";
import type { V2Session } from "./session.js";
import { afterSetupResponse } from "./setup.js";
/** The request that set the session up, whose options a restart starts with again. */
export type SetupParams = v2.NewSessionRequest | v2.ResumeSessionRequest | v2.ForkSessionRequest;
@@ -150,6 +153,14 @@ export class SessionRestarts {
return undefined;
}
/**
* Whether a restart runs: its new query starts MCP OAuth once it took
* over, so the setup response of the session it replaces need not.
*/
restarting(sessionId: string): boolean {
return this.swaps.has(sessionId);
}
/** What a request other than a prompt waits for: a restart that runs. */
settled(sessionId: string): Promise<void> | undefined {
return this.swaps.get(sessionId)?.catch(() => {});
@@ -293,6 +304,9 @@ export class SessionRestarts {
sessionId,
update: { sessionUpdate: "config_option_update", configOptions: session.config.options() },
});
// Closing the old query withdrew its MCP OAuth runs. The servers of a
// resume that came during the restart are tracked by now.
afterSetupResponse(session, this.setups.get(sessionId) ?? params);
})().finally(() => {
if (this.swaps.get(sessionId) === run) this.swaps.delete(sessionId);
if (this.retiring.get(sessionId) === previous) this.retiring.delete(sessionId);

View File

@@ -1066,7 +1066,7 @@ export class V2Session implements RequestTarget {
// Claude Code takes its prompts in order: the work that a cancel stopped
// has ended, also when the cancel came while it ran nothing.
this.tasks.foregroundCancelEnded();
this.clearContext.promptStarted(uuid);
if (this.clearContext.promptStarted(uuid)) this.fileChanges?.contextCleared(uuid);
this.inserted(uuid);
this.openTurn(uuid);
}

View File

@@ -27,6 +27,8 @@ import {
withCurrentModelInfo,
} from "../session-model.js";
import type { SettingsManager } from "../settings.js";
import type { SetupParams } from "./restarts.js";
import type { V2Session } from "./session.js";
/** The SDK options a client passes in `_meta.claudeCode.options`. */
export function clientOptions(meta: unknown): Options | undefined {
@@ -110,3 +112,19 @@ export function replayEndUpdate(
_meta: withAirMeta(undefined, AIR_LOAD_REPLAY_END_KEY, true),
};
}
/**
* Starts OAuth for the setup request's MCP servers that need it, once the
* setup response is sent: v2 asks for no elicitation before it. The handler
* returns the response right after this call, and the SDK writes it before a
* timer runs. Nothing else orders the two. A restart calls it on the new
* query too: closing the old one withdrew the old one's OAuth runs.
*/
export function afterSetupResponse(session: V2Session, params: SetupParams): void {
// The setup already rejected servers of an unknown transport.
const names = (params.mcpServers ?? []).flatMap((server) =>
typeof server.name === "string" ? [server.name] : [],
);
if (names.length === 0) return;
setTimeout(() => session.elicitations.authenticateMcpServers(names), 0);
}

View File

@@ -13,7 +13,13 @@
*
* The interrupt stops whatever Claude Code runs when it arrives, which can be
* a cycle that the session reads only after the cancel. That cycle ends with
* an aborted result, which reports `cancelled`.
* an aborted result, which reports `cancelled`. Only the next result can be
* that cycle's after a cancel of an idle session, and the next two after a
* cancel of open work (the open cycle can end before the interrupt arrives).
* A prompt that a cancel did not abandon starting, or an idle that follows no
* result, ends that reach sooner: a later aborted result, such as of an
* interrupt from Claude Code's remote control, reports as it would without
* the cancel.
*/
import type {
SDKAssistantMessage,
@@ -50,8 +56,14 @@ export class StoppedWork {
private readonly toolUses = new Map<string, boolean>();
/** The `canUseTool` checks that wait for the session to read their tool use. */
private readonly toolUseWaiters = new Map<string, Set<() => void>>();
/** Whether a cancel's interrupt went out and Claude Code has not shown what it stopped. */
private interruptPending = false;
/**
* How many of the next results a cancel's interrupt can end, until Claude
* Code shows what it stopped: 2 after a cancel of open work, whose result
* can come before the interrupt arrives and stops the next cycle, and 1
* after a cancel of an idle session (the cycle that Claude Code runs when
* it arrives).
*/
private interruptReach = 0;
/**
* Whether a model request started since the last result: a `status:
* requesting` frame, which starts each one.
@@ -66,12 +78,12 @@ export class StoppedWork {
cancelled(pushed: readonly string[], foreground: boolean): void {
for (const uuid of pushed) this.abandoned.add(uuid);
if (!foreground && this.modelRequestSinceResult) this.dropping = true;
this.interruptPending = true;
this.interruptReach = foreground ? 2 : 1;
}
/** The interrupt failed: Claude Code stops nothing. */
interruptFailed(): void {
this.interruptPending = false;
this.interruptReach = 0;
}
/** A `status: requesting` frame, with the prompts that the request serves. */
@@ -93,9 +105,13 @@ export class StoppedWork {
if (!this.abandoned.has(uuid)) {
// Claude Code takes its prompts in order: the earlier ones ran or were
// dropped, and a dead turn's late result came before this prompt started.
// A prompt that a cancel did not abandon went to Claude Code after the
// cancel's interrupt, on the same stdin: the interrupt reached no
// further either.
this.abandoned.clear();
this.endDropping();
this.deadTurnResult = false;
this.interruptReach = 0;
return false;
}
if (!foreground) this.drop(uuid);
@@ -177,8 +193,10 @@ export class StoppedWork {
this.startedSinceResult.clear();
// Claude Code asks for a tool use before the result that ends its turn.
this.toolUses.clear();
const aborted = this.interruptPending && isAbortedResult(result);
if (aborted) this.interruptPending = false;
// An aborted result shows what the interrupt stopped. Another one uses up
// one result of its reach (see `interruptReach`).
const aborted = this.interruptReach > 0 && isAbortedResult(result);
this.interruptReach = aborted ? 0 : Math.max(0, this.interruptReach - 1);
const dropped = this.dropping || this.deadTurnResult;
this.deadTurnResult = false;
this.endDropping();
@@ -190,7 +208,7 @@ export class StoppedWork {
* ended, and so has the interrupt's reach.
*/
idle(): void {
this.interruptPending = false;
this.interruptReach = 0;
this.modelRequestSinceResult = false;
// A dead turn's result comes before Claude Code goes idle, or never.
this.deadTurnResult = false;

View File

@@ -609,13 +609,22 @@ export class SessionTasks {
*/
foregroundCancelFailed(): void {
this.rootCancelling = false;
for (const agentId of this.foregroundCancelled) {
this.cancelFailed(this.foregroundCancelled);
this.foregroundCancelled.clear();
}
/**
* A cancel failed to stop the agents `agentIds` that it withdrew: their
* runs go on, and their later requests are theirs again. Each reports
* `running` again, which the withdrawn requests did not.
*/
private cancelFailed(agentIds: Iterable<string>): void {
for (const agentId of agentIds) {
this.cancelledAgents.delete(agentId);
const child = this.children.get(agentId);
if (child?.output) child.output.cancelled = false;
if (child) this.reportChild(child);
}
this.foregroundCancelled.clear();
}
/**
@@ -635,17 +644,19 @@ export class SessionTasks {
async cancelChild(sessionId: string): Promise<void> {
const child = this.children.get(sessionId);
if (!child?.announced || !child.working) return;
// The agents that this cancel stops, and not an earlier one still.
const withdrawn: string[] = [];
for (const agentId of this.agents.keys()) {
if (this.waitsOn(agentId, sessionId)) this.withdraw(agentId);
if (!this.waitsOn(agentId, sessionId)) continue;
if (!this.cancelledAgents.has(agentId)) withdrawn.push(agentId);
this.withdraw(agentId);
}
try {
await this.host.stopTask(child.sessionId);
} catch (error) {
// The run ended meanwhile, or Claude Code refused: it reports how it
// ends. A run that goes on reports `running` again, which the
// withdrawn requests did not.
this.cancelledAgents.delete(sessionId);
this.reportChild(child);
// ends, and so do the subagents that it waits on.
this.cancelFailed(withdrawn);
this.host.logger.error(`Subagent ${sessionId}: stopping it failed`, error);
}
}