mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-12 12:22:20 +00:00
Merge branch 'v2/review2-b' into v2-core-exploration
# Conflicts: # src/tests/v2.test.ts
This commit is contained in:
@@ -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/*`)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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", {}],
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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`. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user