mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-12 20:31:15 +00:00
Merge branch 'native-v2/elicitation' into v2-core-exploration
# Conflicts: # docs/v2-native-core.md # src/v2-native/agent.ts # src/v2-native/session.ts
This commit is contained in:
@@ -31,11 +31,13 @@ both, so a v2 core can follow Claude Code's own events instead of rebuilding a t
|
||||
| `session/prompt` fails | `session/cancel` or `cancelled` (-32800), `discarded` or `refused` (-32603) |
|
||||
| `state_update` `running` | a turn starts: a prompt's `started` or echo, or the main conversation's output |
|
||||
| `state_update` `idle` | the turn's `result`, with its stop reason, usage, `_meta.quota` and error |
|
||||
| `state_update` `requires_action` | a turn is open and a permission request of the session is open |
|
||||
| `state_update` `requires_action` | a turn is open and a permission request or elicitation of the session is open |
|
||||
| `session/cancel` | `interrupt({ cancelQueued: true })`, also while idle, as on the bridge |
|
||||
| `session/resume` with `replayFrom` | `getSessionMessages`, mapped like live output |
|
||||
| `session/fork` | `forkSession` (`fork-session.ts`), then a resume of the copy |
|
||||
| `availableCommands` | `supportedCommands`, filtered by `getAvailableSlashCommands`, and the adapter's `/mcp` |
|
||||
| `elicitation/create` | AskUserQuestion, `onElicitation`, `onUserDialog`, and MCP OAuth (see below) |
|
||||
| `elicitation/complete` | `system/elicitation_complete`, and an MCP OAuth sign-in that finished |
|
||||
| output updates | the shared SDK mapping (`toAcpNotifications`), translated by `V2UpdateTranslator` |
|
||||
|
||||
The reports that are not Claude's output come from `src/v2-native/reports.ts`, built as on v1:
|
||||
@@ -55,7 +57,7 @@ The reports that are not Claude's output come from `src/v2-native/reports.ts`, b
|
||||
| `running`, then `idle`, no user_message | the adapter's idle `/compact` (`IdleCompaction`) |
|
||||
|
||||
The session keeps little of its own: a map of prompts that are not inserted yet, whether a turn
|
||||
is open, and its count of open permission requests (`src/v2-native/session.ts`).
|
||||
is open, and its count of open permission requests and elicitations (`src/v2-native/session.ts`).
|
||||
|
||||
Claude Code's `session_state_changed` is not the foreground state. It stays `running` while
|
||||
background subagents work: across the turn's `result`, the next prompts' turns, and the turn
|
||||
@@ -103,7 +105,7 @@ Code's task events (with `forwardSubagentText` and `perTaskStopAffordance`):
|
||||
| incoming `session_message` in the child | the first user text of each run: the delegation |
|
||||
| `cwd` of the child | the `SubagentStart` hook's `cwd`, when it is not the parent's (such as a worktree) |
|
||||
| child `running` / `idle`, mirrored on the parent | `task_started` (or a `running` patch) to `task_notification` |
|
||||
| child `requires_action` | a permission request whose `agentID` is the child |
|
||||
| child `requires_action` | a permission request or question whose `agentID` is the child |
|
||||
| `session/cancel` of a child | `stopTask(agentId)` |
|
||||
| `background` of a session | its background subagents, commands (terminals), task tool calls and other tasks |
|
||||
| `terminal/cancel`, `tool_call/cancel` | `stopTask` of the listed item's task |
|
||||
@@ -124,6 +126,14 @@ close, resume, delete and config changes on it fail with -32602.
|
||||
`background` list of each session (see below).
|
||||
- `src/v2-native/permissions.ts`: `canUseTool` as v2 permission requests, on the session of the
|
||||
agent that asks.
|
||||
- `src/v2-native/elicitation.ts`: elicitations, for the modes the client advertised. A form
|
||||
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.
|
||||
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`.
|
||||
- `src/v2-native/config.ts`: the mode and model config options.
|
||||
- `src/v2-native/setup.ts`: what session setup adds on the bridge's rules: the client's
|
||||
`_meta.claudeCode.options`, `CLAUDE_MODEL_CONFIG`, the models (the `availableModels`
|
||||
@@ -164,6 +174,10 @@ These pieces were extracted so that both cores use the same code:
|
||||
- `getAvailableSlashCommands`, exported from `acp-agent.ts`
|
||||
- `AuthStatusReporter` and `cliLogout` (`auth-status-reporter.ts`): the `authStatus` state, the
|
||||
`claude auth status` probe and the `_auth/status_update` pushes, taken from `ClaudeAcpAgent`
|
||||
- the elicitation answers (`elicitation.ts`): `answerAskUserQuestion`, `answerMcpElicitation`,
|
||||
`answerUserDialog` and `elicitUnlessWithdrawn`, taken from `ClaudeAcpAgent`'s handlers
|
||||
- MCP OAuth (`mcp-oauth.ts`): `startMcpServerAuthentication` and its status polling, taken from
|
||||
`acp-agent.ts`
|
||||
- the provider routing (`providers.ts`): `ProviderConfig`, the gateway login methods and their
|
||||
validation, the `providers/list` response, `providers/set` validation and model listing, and
|
||||
`createEnvForProvider`, taken from `ClaudeAcpAgent`
|
||||
@@ -180,10 +194,9 @@ These pieces were extracted so that both cores use the same code:
|
||||
insertion, folding, lifecycle failures, cancel, permission states, config, local commands,
|
||||
a crashed query, and replay.
|
||||
- `src/tests/acp-scenarios-v2-native.test.ts` runs every scenario of the shared suite on the
|
||||
native core. All 40 send only schema-valid v2. 39 pass. `ask-user-question` stops, because
|
||||
the native core sends no form elicitations. `SAME_AS_BRIDGE` lists the scenarios whose session
|
||||
updates equal the bridge's golden file: 25 of them. The others differ in what is listed under
|
||||
"Not served yet" (an Edit's patch, subagent details, the resume snapshot).
|
||||
native core. All 40 send only schema-valid v2, and all pass. `SAME_AS_BRIDGE` lists the
|
||||
scenarios whose session updates equal the bridge's golden file. The others differ in what is
|
||||
listed under "Not served yet" (subagent details, the resume snapshot).
|
||||
- The scenario double now sends `session_state_changed` when the query asks for it, as the real
|
||||
CLI does. The bridge and v1 scenario golden files did not change.
|
||||
- A manual run against the bundled Claude Code CLI checked the following:
|
||||
@@ -211,11 +224,6 @@ These pieces were extracted so that both cores use the same code:
|
||||
- A gateway login, `providers/set`, `providers/disable` or `auth/logout` routes only the sessions
|
||||
started after it. The bridge recreates the queries of live sessions between turns; on the
|
||||
native core they keep their route until they are closed and resumed.
|
||||
- Elicitation (and so `requires_action` for a question):
|
||||
- AskUserQuestion (the tool is disallowed)
|
||||
- MCP elicitation (the SDK declines)
|
||||
- the refusal-fallback dialog
|
||||
- MCP OAuth
|
||||
- Subagents:
|
||||
- the child's configuration (`config_option_update` from the `SubagentStart` hook)
|
||||
- the incoming view of a SendMessage, which the child's transcript does not show
|
||||
@@ -279,4 +287,5 @@ Beyond the scenarios, `v2-native.test.ts` covers these requirements of the draft
|
||||
logins are advertised only with `auth.terminal`.
|
||||
|
||||
`requires_action` comes from the session's own open requests, not Claude Code's: it covers
|
||||
exactly this session's permission requests, and will cover its elicitations.
|
||||
exactly this session's permission requests and elicitations. MCP OAuth is not one: it does not
|
||||
block a turn.
|
||||
|
||||
367
src/acp-agent.ts
367
src/acp-agent.ts
@@ -63,7 +63,6 @@ import {
|
||||
getSessionMessages,
|
||||
HookCallback,
|
||||
listSessions,
|
||||
McpServerStatus,
|
||||
ModelInfo,
|
||||
OnElicitation,
|
||||
OnUserDialog,
|
||||
@@ -184,17 +183,17 @@ import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import packageJson from "../package.json" with { type: "json" };
|
||||
import {
|
||||
applyAskElicitationResponse,
|
||||
askUserQuestionsToCreateRequest,
|
||||
createElicitationResponseToElicitResult,
|
||||
answerAskUserQuestion,
|
||||
answerMcpElicitation,
|
||||
answerUserDialog,
|
||||
ElicitationSupport,
|
||||
extractAskUserQuestions,
|
||||
extractRefusalFallbackPrompt,
|
||||
mcpElicitationToCreateRequest,
|
||||
elicitUnlessWithdrawn,
|
||||
REFUSAL_FALLBACK_DIALOG_KIND,
|
||||
refusalFallbackResultFromResponse,
|
||||
refusalFallbackToCreateRequest,
|
||||
} from "./elicitation.js";
|
||||
import {
|
||||
startMcpServerAuthentication as startSharedMcpServerAuthentication,
|
||||
supportsMcpOAuth,
|
||||
} from "./mcp-oauth.js";
|
||||
import { forkSession } from "./fork-session.js";
|
||||
import {
|
||||
clientSystemPrompt,
|
||||
@@ -564,34 +563,6 @@ async function structuredUsageMarkdown(
|
||||
}
|
||||
}
|
||||
|
||||
/** Claude Code keeps the OAuth callback listener open in the background after
|
||||
* `mcpAuthenticate` returns the authorization URL. The SDK does not expose
|
||||
* that listener's completion promise, so watch the server status while the
|
||||
* ACP client has the URL elicitation open. */
|
||||
const MCP_OAUTH_STATUS_POLL_MS = 1_000;
|
||||
const MCP_OAUTH_TIMEOUT_MS = 10 * 60_000;
|
||||
|
||||
/** Runtime MCP OAuth control exposed by the pinned Agent SDK. It is not yet in
|
||||
* the public `Query` declaration, even though the method is present on the
|
||||
* SDK query object and backed by Claude Code's `mcp_authenticate` control. */
|
||||
type McpOAuthQuery = Query & {
|
||||
mcpAuthenticate(
|
||||
serverName: string,
|
||||
redirectUri?: string,
|
||||
): Promise<{
|
||||
authUrl?: string;
|
||||
requiresUserAction: boolean;
|
||||
callbackExpected: boolean;
|
||||
redirectScheme?: "localhost" | "custom";
|
||||
callbackPort?: number;
|
||||
state?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
function supportsMcpOAuth(query: Query): query is McpOAuthQuery {
|
||||
return typeof (query as Partial<McpOAuthQuery>).mcpAuthenticate === "function";
|
||||
}
|
||||
|
||||
/** Runtime Remote Control control exposed by the pinned Agent SDK. Like
|
||||
* `mcpAuthenticate`, it is not in the public `Query` declaration yet. The CLI's
|
||||
* own `/remote-control` is terminal-bound and not offered to SDK hosts, so the
|
||||
@@ -652,22 +623,6 @@ function claudeCodeOffersCommand(
|
||||
);
|
||||
}
|
||||
|
||||
/** Wait for a polling interval, resolving false when the session is aborted. */
|
||||
function waitUnlessAborted(milliseconds: number, signal: AbortSignal): Promise<boolean> {
|
||||
if (signal.aborted) return Promise.resolve(false);
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve(true);
|
||||
}, milliseconds);
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
resolve(false);
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
/** Error surfaced when the SDK declares a turn over (`session_state_changed:
|
||||
* idle`, its authoritative turn-over signal) without ever emitting the turn's
|
||||
* `result` — a model stream that dropped mid-turn, or an async agent that
|
||||
@@ -2483,10 +2438,6 @@ type McpAuthenticationHost = {
|
||||
logger: Logger;
|
||||
};
|
||||
|
||||
/** Background OAuth work is implementation state, not part of the public
|
||||
* `Session` shape exposed through `ClaudeAcpAgent.sessions`. */
|
||||
const mcpAuthentications = new WeakMap<Session, Promise<void>>();
|
||||
|
||||
/** Start OAuth for ACP-provided MCP servers that Claude reported as needing
|
||||
* authentication. This runs after session creation has returned so an
|
||||
* interactive browser flow never delays `session/new`. */
|
||||
@@ -2500,147 +2451,30 @@ function startMcpAuthentication(
|
||||
}
|
||||
|
||||
/** Start OAuth in the background for the servers in `requestedServers` that
|
||||
* Claude reports as needing authentication. Startup and `/mcp` use it. It
|
||||
* does nothing while another OAuth run of the session is in progress. */
|
||||
* Claude reports as needing authentication (see `mcp-oauth.ts`). Startup and
|
||||
* `/mcp` use it. */
|
||||
function startMcpServerAuthentication(
|
||||
host: McpAuthenticationHost,
|
||||
sessionId: string,
|
||||
requestedServers: Set<string>,
|
||||
): void {
|
||||
if (!host.clientCapabilities?.elicitation?.url) return;
|
||||
|
||||
const session = host.sessions[sessionId];
|
||||
if (!session || mcpAuthentications.has(session)) return;
|
||||
|
||||
const authentication = authenticateMcpServers(host, sessionId, session.query, requestedServers)
|
||||
.catch((error) => {
|
||||
if (!session.abortController.signal.aborted) {
|
||||
host.logger.error(`Failed to inspect MCP servers for OAuth: ${error}`);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (mcpAuthentications.get(session) === authentication) {
|
||||
mcpAuthentications.delete(session);
|
||||
}
|
||||
});
|
||||
mcpAuthentications.set(session, authentication);
|
||||
}
|
||||
|
||||
async function authenticateMcpServers(
|
||||
host: McpAuthenticationHost,
|
||||
sessionId: string,
|
||||
query: Query,
|
||||
requestedServers: Set<string>,
|
||||
): Promise<void> {
|
||||
if (!supportsMcpOAuth(query)) {
|
||||
host.logger.error("The Claude Agent SDK does not expose MCP OAuth authentication.");
|
||||
return;
|
||||
}
|
||||
|
||||
const statuses = await query.mcpServerStatus();
|
||||
for (const status of statuses) {
|
||||
if (status.status !== "needs-auth" || !requestedServers.has(status.name)) continue;
|
||||
try {
|
||||
await authenticateMcpServer(host, sessionId, query, status.name);
|
||||
} catch (error) {
|
||||
const session = host.sessions[sessionId];
|
||||
if (session && !session.abortController.signal.aborted) {
|
||||
host.logger.error(`Failed to authenticate MCP server ${status.name}: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Bridge Claude Code's startup MCP OAuth control to ACP URL elicitation.
|
||||
* Claude opens and owns the localhost callback listener; the ACP client only
|
||||
* needs to present the returned authorization URL. */
|
||||
async function authenticateMcpServer(
|
||||
host: McpAuthenticationHost,
|
||||
sessionId: string,
|
||||
query: McpOAuthQuery,
|
||||
serverName: string,
|
||||
): Promise<void> {
|
||||
const session = host.sessions[sessionId];
|
||||
if (!session) return;
|
||||
|
||||
const login = await query.mcpAuthenticate(serverName);
|
||||
if (!login.requiresUserAction) return;
|
||||
if (!login.authUrl) {
|
||||
throw new Error("Claude Code requested user action without returning an authorization URL");
|
||||
}
|
||||
|
||||
const elicitationId = `mcp-oauth-${randomUUID()}`;
|
||||
const flowAbort = new AbortController();
|
||||
const abortFlow = () => flowAbort.abort(session.abortController.signal.reason);
|
||||
session.abortController.signal.addEventListener("abort", abortFlow, { once: true });
|
||||
if (session.abortController.signal.aborted) abortFlow();
|
||||
|
||||
try {
|
||||
const completed = waitForMcpAuthentication(
|
||||
host.sessions,
|
||||
startSharedMcpServerAuthentication(
|
||||
{
|
||||
urlElicitation: !!host.clientCapabilities?.elicitation?.url,
|
||||
createElicitation: (params, signal) => host.client.createElicitation(params, signal),
|
||||
completeElicitation: (params) => host.client.completeElicitation(params),
|
||||
logger: host.logger,
|
||||
},
|
||||
{
|
||||
key: session,
|
||||
sessionId,
|
||||
query,
|
||||
serverName,
|
||||
flowAbort.signal,
|
||||
);
|
||||
const elicitation = host.client.createElicitation(
|
||||
{
|
||||
mode: "url",
|
||||
sessionId,
|
||||
message: `Authenticate with MCP server ${serverName}`,
|
||||
url: login.authUrl,
|
||||
elicitationId,
|
||||
},
|
||||
flowAbort.signal,
|
||||
);
|
||||
const first = await Promise.race([
|
||||
completed.then((authenticated) => ({ type: "completed" as const, authenticated })),
|
||||
elicitation.then((response) => ({ type: "elicitation" as const, response })),
|
||||
]);
|
||||
|
||||
// A url-mode accept carries no content to check. The guard
|
||||
// `CreateElicitationResponse.isAccept` would also reject an accept whose
|
||||
// `content` holds a v2-only value, such as an object.
|
||||
if (first.type === "elicitation" && first.response.action !== "accept") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (first.type === "elicitation") {
|
||||
await completed;
|
||||
}
|
||||
try {
|
||||
await host.client.completeElicitation({ elicitationId });
|
||||
} catch (error) {
|
||||
if (!flowAbort.signal.aborted) {
|
||||
host.logger.error(`Failed to complete MCP OAuth elicitation: ${error}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
flowAbort.abort();
|
||||
session.abortController.signal.removeEventListener("abort", abortFlow);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForMcpAuthentication(
|
||||
sessions: Record<string, Session>,
|
||||
sessionId: string,
|
||||
query: Query,
|
||||
serverName: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<boolean> {
|
||||
const deadline = Date.now() + MCP_OAUTH_TIMEOUT_MS;
|
||||
while (!signal.aborted && Date.now() < deadline) {
|
||||
const session = sessions[sessionId];
|
||||
if (!session || session.query !== query) return false;
|
||||
|
||||
const status: McpServerStatus | undefined = (await query.mcpServerStatus()).find(
|
||||
(server) => server.name === serverName,
|
||||
);
|
||||
if (!status || status.status === "failed" || status.status === "disabled") return false;
|
||||
if (status.status === "connected") return true;
|
||||
if (!(await waitUnlessAborted(MCP_OAUTH_STATUS_POLL_MS, signal))) return false;
|
||||
}
|
||||
return false;
|
||||
currentQuery: () => host.sessions[sessionId]?.query,
|
||||
closed: session.abortController.signal,
|
||||
},
|
||||
requestedServers,
|
||||
);
|
||||
}
|
||||
|
||||
export class ClaudeAcpAgent {
|
||||
@@ -10562,53 +10396,32 @@ export class ClaudeAcpAgent {
|
||||
|
||||
/**
|
||||
* Handle elicitation requests that originate from MCP servers by forwarding
|
||||
* them to the client over ACP. Only the user's own decline reaches the MCP
|
||||
* server as `decline`. A request the user never saw, because the client did
|
||||
* not advertise its mode, we can't represent it, or the client answered with
|
||||
* an error, is a `cancel`.
|
||||
* them to the client over ACP (see {@link answerMcpElicitation}).
|
||||
*/
|
||||
private handleMcpElicitation(sessionId: string, support: ElicitationSupport): OnElicitation {
|
||||
return async (request, { signal: callbackSignal }) => {
|
||||
const signal = this.untilSessionClosed(sessionId, callbackSignal);
|
||||
const isUrl = request.mode === "url";
|
||||
if ((isUrl && !support.url) || (!isUrl && !support.form)) {
|
||||
return { action: "cancel" };
|
||||
}
|
||||
|
||||
const createRequest = mcpElicitationToCreateRequest(request, sessionId);
|
||||
if (!createRequest) {
|
||||
return { action: "cancel" };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.withPendingUserInput(sessionId, signal, () =>
|
||||
this.elicitFromClient(createRequest, signal),
|
||||
);
|
||||
if (signal.aborted) {
|
||||
return { action: "cancel" };
|
||||
}
|
||||
return createElicitationResponseToElicitResult(response);
|
||||
} catch (error) {
|
||||
// A cancellation we requested (signal aborted) settles as a cancel, not
|
||||
// a hard decline — the elicitation was abandoned, not refused.
|
||||
if (signal.aborted) {
|
||||
return { action: "cancel" };
|
||||
}
|
||||
// The client answered with an error, e.g. -32602 for a form it cannot
|
||||
// render, so the user never saw the request. MCP's `decline` means the
|
||||
// user refused; `cancel` means they made no explicit choice.
|
||||
this.logger.error(`Failed to forward MCP elicitation: ${error}`);
|
||||
return { action: "cancel" };
|
||||
}
|
||||
return answerMcpElicitation(request, {
|
||||
sessionId,
|
||||
support,
|
||||
logger: this.logger,
|
||||
elicit: (createRequest) =>
|
||||
elicitUnlessWithdrawn(signal, (signal) =>
|
||||
this.withPendingUserInput(sessionId, signal, () =>
|
||||
this.elicitFromClient(createRequest, signal),
|
||||
),
|
||||
),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Present the built-in AskUserQuestion tool's questions as an ACP form
|
||||
* elicitation and return the answers as the tool's `updatedInput`. Called from
|
||||
* `canUseTool` since that is where the SDK routes the tool's permission check.
|
||||
* The elicitation goes to `requestSessionId`, a subagent's child session for
|
||||
* a question it asks, and blocks the turn of `sessionId`.
|
||||
* elicitation and return the answers as the tool's `updatedInput` (see
|
||||
* {@link answerAskUserQuestion}). Called from `canUseTool` since that is
|
||||
* where the SDK routes the tool's permission check. The elicitation goes to
|
||||
* `requestSessionId`, a subagent's child session for a question it asks,
|
||||
* and blocks the turn of `sessionId`.
|
||||
*/
|
||||
private async handleAskUserQuestion(
|
||||
sessionId: string,
|
||||
@@ -10619,89 +10432,43 @@ export class ClaudeAcpAgent {
|
||||
agentID: string | undefined,
|
||||
): Promise<PermissionResult> {
|
||||
const signal = this.untilSessionClosed(sessionId, toolSignal);
|
||||
const questions = extractAskUserQuestions(toolInput);
|
||||
if (!questions) {
|
||||
return { behavior: "deny", message: "AskUserQuestion called with no valid questions." };
|
||||
}
|
||||
|
||||
const createRequest = askUserQuestionsToCreateRequest(
|
||||
questions,
|
||||
requestSessionId,
|
||||
return answerAskUserQuestion(toolInput, {
|
||||
sessionId: requestSessionId,
|
||||
toolUseID,
|
||||
this.toolCallCapabilities.air.client,
|
||||
);
|
||||
let response;
|
||||
try {
|
||||
response = await this.withChildAwaitingUser(sessionId, requestSessionId, signal, () =>
|
||||
this.withPendingUserInput(
|
||||
sessionId,
|
||||
signal,
|
||||
() => this.elicitFromClient(createRequest, signal),
|
||||
agentID,
|
||||
airClient: this.toolCallCapabilities.air.client,
|
||||
reportUnanswered: () => this.sessions[sessionId]?.askUserQuestionUnansweredV1 === true,
|
||||
logger: this.logger,
|
||||
elicit: (createRequest) =>
|
||||
elicitUnlessWithdrawn(signal, (signal) =>
|
||||
this.withChildAwaitingUser(sessionId, requestSessionId, signal, () =>
|
||||
this.withPendingUserInput(
|
||||
sessionId,
|
||||
signal,
|
||||
() => this.elicitFromClient(createRequest, signal),
|
||||
agentID,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
// A cancellation we requested (signal aborted) settles as an aborted tool
|
||||
// use, matching the post-response check below.
|
||||
if (signal.aborted) {
|
||||
throw new Error("Tool use aborted", { cause: error });
|
||||
}
|
||||
this.logger.error(`Failed to present AskUserQuestion elicitation: ${error}`);
|
||||
return { behavior: "deny", message: "Could not present the question to the user." };
|
||||
}
|
||||
if (signal.aborted) {
|
||||
throw new Error("Tool use aborted");
|
||||
}
|
||||
|
||||
const outcome = applyAskElicitationResponse(response, toolInput, questions, {
|
||||
reportUnanswered: this.sessions[sessionId]?.askUserQuestionUnansweredV1 === true,
|
||||
});
|
||||
if (outcome.action === "cancel") {
|
||||
throw new Error("Tool use aborted");
|
||||
}
|
||||
return { behavior: "allow", updatedInput: outcome.updatedInput };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle `request_user_dialog` control requests — blocking dialogs the CLI
|
||||
* asks the host to render. Only kinds declared in `supportedDialogKinds`
|
||||
* are ever emitted; everything unexpected is answered `cancelled` (the
|
||||
* required answer for unrecognized kinds), which applies the dialog's
|
||||
* default behavior CLI-side. Today the only declared kind is the
|
||||
* refusal-fallback consent prompt, rendered as an ACP form elicitation.
|
||||
* asks the host to render (see {@link answerUserDialog}).
|
||||
*/
|
||||
private handleUserDialog(sessionId: string): OnUserDialog {
|
||||
return async (request, { signal: callbackSignal }) => {
|
||||
const signal = this.untilSessionClosed(sessionId, callbackSignal);
|
||||
if (request.dialogKind !== REFUSAL_FALLBACK_DIALOG_KIND) {
|
||||
return { behavior: "cancelled" };
|
||||
}
|
||||
const prompt = extractRefusalFallbackPrompt(request.payload);
|
||||
if (!prompt) {
|
||||
this.logger.error(
|
||||
`refusal_fallback_prompt payload had an unexpected shape; cancelling the dialog: ${JSON.stringify(request.payload)}`,
|
||||
);
|
||||
return { behavior: "cancelled" };
|
||||
}
|
||||
let response: CreateElicitationResponse;
|
||||
try {
|
||||
response = await this.withPendingUserInput(sessionId, signal, () =>
|
||||
this.elicitFromClient(refusalFallbackToCreateRequest(prompt, sessionId), signal),
|
||||
);
|
||||
} catch (error) {
|
||||
// A cancellation we requested (signal aborted) is expected teardown;
|
||||
// anything else is a client failure. Either way the safe answer is
|
||||
// `cancelled` — the CLI applies the dialog's default (keep the
|
||||
// refusal) rather than switching models without consent.
|
||||
if (!signal.aborted) {
|
||||
this.logger.error(`Failed to present refusal fallback elicitation: ${error}`);
|
||||
}
|
||||
return { behavior: "cancelled" };
|
||||
}
|
||||
if (signal.aborted) {
|
||||
return { behavior: "cancelled" };
|
||||
}
|
||||
return { behavior: "completed", result: refusalFallbackResultFromResponse(response) };
|
||||
return answerUserDialog(request, {
|
||||
sessionId,
|
||||
logger: this.logger,
|
||||
elicit: (createRequest) =>
|
||||
elicitUnlessWithdrawn(signal, (signal) =>
|
||||
this.withPendingUserInput(sessionId, signal, () =>
|
||||
this.elicitFromClient(createRequest, signal),
|
||||
),
|
||||
),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,13 @@ import type {
|
||||
ElicitationSchema,
|
||||
EnumOption,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import type { ElicitationRequest, ElicitationResult } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type {
|
||||
ElicitationRequest,
|
||||
ElicitationResult,
|
||||
PermissionResult,
|
||||
UserDialogRequest,
|
||||
UserDialogResult,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import type {
|
||||
AskUserQuestionInput,
|
||||
AskUserQuestionOutput,
|
||||
@@ -512,3 +518,156 @@ export function refusalFallbackResultFromResponse(response: CreateElicitationRes
|
||||
const choice = acceptedElicitationContent(response)[REFUSAL_FALLBACK_CHOICE_KEY];
|
||||
return choice === RETRY_FALLBACK_RESULT ? RETRY_FALLBACK_RESULT : KEEP_REFUSAL_RESULT;
|
||||
}
|
||||
|
||||
/**
|
||||
* What became of an elicitation: the client's response, or `withdrawn` when
|
||||
* its signal aborted first (Claude Code withdrew the request, or a cancel or
|
||||
* close of the session did). A response that comes after the abort counts as
|
||||
* withdrawn too.
|
||||
*/
|
||||
export type ElicitationOutcome = { response: CreateElicitationResponse } | { withdrawn: true };
|
||||
|
||||
/**
|
||||
* Sends an elicitation with `send` and reads what became of it. A rejection
|
||||
* while `signal` is not aborted, such as the client's -32602 for a form it
|
||||
* cannot render, is rethrown.
|
||||
*/
|
||||
export async function elicitUnlessWithdrawn(
|
||||
signal: AbortSignal,
|
||||
send: (signal: AbortSignal) => Promise<CreateElicitationResponse>,
|
||||
): Promise<ElicitationOutcome> {
|
||||
try {
|
||||
const response = await send(signal);
|
||||
return signal.aborted ? { withdrawn: true } : { response };
|
||||
} catch (error) {
|
||||
if (signal.aborted) return { withdrawn: true };
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Sends a built elicitation to the client (see {@link elicitUnlessWithdrawn}). */
|
||||
export type Elicit = (request: CreateElicitationRequest) => Promise<ElicitationOutcome>;
|
||||
|
||||
type ElicitationLogger = { error: (...args: unknown[]) => void };
|
||||
|
||||
/**
|
||||
* Answers an MCP server's elicitation (the SDK's `onElicitation`) through
|
||||
* `elicit`. Only the user's own decline reaches the MCP server as `decline`.
|
||||
* A request the user never saw, because the client did not advertise its
|
||||
* mode, it cannot be represented, the client answered with an error, or it
|
||||
* was withdrawn, is a `cancel`.
|
||||
*/
|
||||
export async function answerMcpElicitation(
|
||||
request: ElicitationRequest,
|
||||
{
|
||||
sessionId,
|
||||
support,
|
||||
elicit,
|
||||
logger,
|
||||
}: { sessionId: string; support: ElicitationSupport; elicit: Elicit; logger: ElicitationLogger },
|
||||
): Promise<ElicitationResult> {
|
||||
const isUrl = request.mode === "url";
|
||||
if ((isUrl && !support.url) || (!isUrl && !support.form)) {
|
||||
return { action: "cancel" };
|
||||
}
|
||||
const createRequest = mcpElicitationToCreateRequest(request, sessionId);
|
||||
if (!createRequest) {
|
||||
return { action: "cancel" };
|
||||
}
|
||||
try {
|
||||
const outcome = await elicit(createRequest);
|
||||
if ("withdrawn" in outcome) return { action: "cancel" };
|
||||
return createElicitationResponseToElicitResult(outcome.response);
|
||||
} catch (error) {
|
||||
// The client answered with an error, e.g. -32602 for a form it cannot
|
||||
// render, so the user never saw the request. MCP's `decline` means the
|
||||
// user refused; `cancel` means they made no explicit choice.
|
||||
logger.error(`Failed to forward MCP elicitation: ${error}`);
|
||||
return { action: "cancel" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Presents the AskUserQuestion tool's questions as a form elicitation of
|
||||
* `sessionId` through `elicit`, and returns the answers as the tool's
|
||||
* `updatedInput`. A withdrawn question, or one the user cancelled, aborts the
|
||||
* tool use. `reportUnanswered` is read once the answer is in (see
|
||||
* {@link applyAskElicitationResponse}).
|
||||
*/
|
||||
export async function answerAskUserQuestion(
|
||||
toolInput: Record<string, unknown>,
|
||||
{
|
||||
sessionId,
|
||||
toolUseID,
|
||||
airClient = false,
|
||||
reportUnanswered,
|
||||
elicit,
|
||||
logger,
|
||||
}: {
|
||||
sessionId: string;
|
||||
toolUseID: string;
|
||||
airClient?: boolean;
|
||||
reportUnanswered: () => boolean;
|
||||
elicit: Elicit;
|
||||
logger: ElicitationLogger;
|
||||
},
|
||||
): Promise<PermissionResult> {
|
||||
const questions = extractAskUserQuestions(toolInput);
|
||||
if (!questions) {
|
||||
return { behavior: "deny", message: "AskUserQuestion called with no valid questions." };
|
||||
}
|
||||
const createRequest = askUserQuestionsToCreateRequest(questions, sessionId, toolUseID, airClient);
|
||||
let outcome: ElicitationOutcome;
|
||||
try {
|
||||
outcome = await elicit(createRequest);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to present AskUserQuestion elicitation: ${error}`);
|
||||
return { behavior: "deny", message: "Could not present the question to the user." };
|
||||
}
|
||||
if ("withdrawn" in outcome) {
|
||||
throw new Error("Tool use aborted");
|
||||
}
|
||||
const answer = applyAskElicitationResponse(outcome.response, toolInput, questions, {
|
||||
reportUnanswered: reportUnanswered(),
|
||||
});
|
||||
if (answer.action === "cancel") {
|
||||
throw new Error("Tool use aborted");
|
||||
}
|
||||
return { behavior: "allow", updatedInput: answer.updatedInput };
|
||||
}
|
||||
|
||||
/**
|
||||
* Answers a `request_user_dialog` of Claude Code (the SDK's `onUserDialog`).
|
||||
* Only kinds declared in `supportedDialogKinds` are ever emitted; everything
|
||||
* unexpected is answered `cancelled` (the required answer for unrecognized
|
||||
* kinds), which applies the dialog's default behavior CLI-side. The only
|
||||
* declared kind is the refusal-fallback consent prompt, rendered as a form
|
||||
* elicitation. A failed or withdrawn elicitation is `cancelled` too: the CLI
|
||||
* then keeps the refusal rather than switching models without consent.
|
||||
*/
|
||||
export async function answerUserDialog(
|
||||
request: UserDialogRequest,
|
||||
{ sessionId, elicit, logger }: { sessionId: string; elicit: Elicit; logger: ElicitationLogger },
|
||||
): Promise<UserDialogResult> {
|
||||
if (request.dialogKind !== REFUSAL_FALLBACK_DIALOG_KIND) {
|
||||
return { behavior: "cancelled" };
|
||||
}
|
||||
const prompt = extractRefusalFallbackPrompt(request.payload);
|
||||
if (!prompt) {
|
||||
logger.error(
|
||||
`refusal_fallback_prompt payload had an unexpected shape; cancelling the dialog: ${JSON.stringify(request.payload)}`,
|
||||
);
|
||||
return { behavior: "cancelled" };
|
||||
}
|
||||
let outcome: ElicitationOutcome;
|
||||
try {
|
||||
outcome = await elicit(refusalFallbackToCreateRequest(prompt, sessionId));
|
||||
} catch (error) {
|
||||
logger.error(`Failed to present refusal fallback elicitation: ${error}`);
|
||||
return { behavior: "cancelled" };
|
||||
}
|
||||
if ("withdrawn" in outcome) {
|
||||
return { behavior: "cancelled" };
|
||||
}
|
||||
return { behavior: "completed", result: refusalFallbackResultFromResponse(outcome.response) };
|
||||
}
|
||||
|
||||
214
src/mcp-oauth.ts
Normal file
214
src/mcp-oauth.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* OAuth for the MCP servers that Claude Code reports as `needs-auth`, as an
|
||||
* ACP URL elicitation. Protocol-neutral: the v1 core and the native v2 core
|
||||
* both start it once a session's setup response is sent.
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type {
|
||||
CompleteElicitationNotification,
|
||||
CreateElicitationRequest,
|
||||
CreateElicitationResponse,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import type { McpServerStatus, Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
/** Claude Code keeps the OAuth callback listener open in the background after
|
||||
* `mcpAuthenticate` returns the authorization URL. The SDK does not expose
|
||||
* that listener's completion promise, so watch the server status while the
|
||||
* ACP client has the URL elicitation open. */
|
||||
const MCP_OAUTH_STATUS_POLL_MS = 1_000;
|
||||
const MCP_OAUTH_TIMEOUT_MS = 10 * 60_000;
|
||||
|
||||
/** Runtime MCP OAuth control exposed by the pinned Agent SDK. It is not yet in
|
||||
* the public `Query` declaration, even though the method is present on the
|
||||
* SDK query object and backed by Claude Code's `mcp_authenticate` control. */
|
||||
type McpOAuthQuery = Query & {
|
||||
mcpAuthenticate(
|
||||
serverName: string,
|
||||
redirectUri?: string,
|
||||
): Promise<{
|
||||
authUrl?: string;
|
||||
requiresUserAction: boolean;
|
||||
callbackExpected: boolean;
|
||||
redirectScheme?: "localhost" | "custom";
|
||||
callbackPort?: number;
|
||||
state?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export function supportsMcpOAuth(query: Query): query is McpOAuthQuery {
|
||||
return typeof (query as Partial<McpOAuthQuery>).mcpAuthenticate === "function";
|
||||
}
|
||||
|
||||
/** What an OAuth run needs of the client connection. */
|
||||
export type McpOAuthClient = {
|
||||
/** Whether the client advertised URL elicitation. Without it, OAuth does not start. */
|
||||
readonly urlElicitation: boolean;
|
||||
createElicitation(
|
||||
params: CreateElicitationRequest,
|
||||
signal: AbortSignal,
|
||||
): Promise<CreateElicitationResponse>;
|
||||
completeElicitation(params: CompleteElicitationNotification): Promise<void>;
|
||||
readonly logger: { error: (...args: unknown[]) => void };
|
||||
};
|
||||
|
||||
/** What an OAuth run needs of the session. */
|
||||
export type McpOAuthSession = {
|
||||
/** One OAuth run at a time per key: the session object. */
|
||||
readonly key: object;
|
||||
readonly sessionId: string;
|
||||
/** The session's query now, or `undefined` once the session is gone. */
|
||||
currentQuery(): Query | undefined;
|
||||
/** Aborts when the session closes. */
|
||||
readonly closed: AbortSignal;
|
||||
};
|
||||
|
||||
const mcpAuthentications = new WeakMap<object, Promise<void>>();
|
||||
|
||||
/** Start OAuth in the background for the servers in `requestedServers` that
|
||||
* Claude reports as needing authentication. It does nothing while another
|
||||
* OAuth run of the session is in progress. */
|
||||
export function startMcpServerAuthentication(
|
||||
client: McpOAuthClient,
|
||||
session: McpOAuthSession,
|
||||
requestedServers: Set<string>,
|
||||
): void {
|
||||
if (!client.urlElicitation || requestedServers.size === 0) return;
|
||||
const query = session.currentQuery();
|
||||
if (!query || mcpAuthentications.has(session.key)) return;
|
||||
|
||||
const authentication = authenticateMcpServers(client, session, query, requestedServers)
|
||||
.catch((error) => {
|
||||
if (!session.closed.aborted) {
|
||||
client.logger.error(`Failed to inspect MCP servers for OAuth: ${error}`);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (mcpAuthentications.get(session.key) === authentication) {
|
||||
mcpAuthentications.delete(session.key);
|
||||
}
|
||||
});
|
||||
mcpAuthentications.set(session.key, authentication);
|
||||
}
|
||||
|
||||
async function authenticateMcpServers(
|
||||
client: McpOAuthClient,
|
||||
session: McpOAuthSession,
|
||||
query: Query,
|
||||
requestedServers: Set<string>,
|
||||
): Promise<void> {
|
||||
if (!supportsMcpOAuth(query)) {
|
||||
client.logger.error("The Claude Agent SDK does not expose MCP OAuth authentication.");
|
||||
return;
|
||||
}
|
||||
|
||||
const statuses = await query.mcpServerStatus();
|
||||
for (const status of statuses) {
|
||||
if (status.status !== "needs-auth" || !requestedServers.has(status.name)) continue;
|
||||
try {
|
||||
await authenticateMcpServer(client, session, query, status.name);
|
||||
} catch (error) {
|
||||
if (session.currentQuery() && !session.closed.aborted) {
|
||||
client.logger.error(`Failed to authenticate MCP server ${status.name}: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Bridge Claude Code's startup MCP OAuth control to ACP URL elicitation.
|
||||
* Claude opens and owns the localhost callback listener; the ACP client only
|
||||
* needs to present the returned authorization URL. */
|
||||
async function authenticateMcpServer(
|
||||
client: McpOAuthClient,
|
||||
session: McpOAuthSession,
|
||||
query: McpOAuthQuery,
|
||||
serverName: string,
|
||||
): Promise<void> {
|
||||
if (!session.currentQuery()) return;
|
||||
|
||||
const login = await query.mcpAuthenticate(serverName);
|
||||
if (!login.requiresUserAction) return;
|
||||
if (!login.authUrl) {
|
||||
throw new Error("Claude Code requested user action without returning an authorization URL");
|
||||
}
|
||||
|
||||
const elicitationId = `mcp-oauth-${randomUUID()}`;
|
||||
const flowAbort = new AbortController();
|
||||
const abortFlow = () => flowAbort.abort(session.closed.reason);
|
||||
session.closed.addEventListener("abort", abortFlow, { once: true });
|
||||
if (session.closed.aborted) abortFlow();
|
||||
|
||||
try {
|
||||
const completed = waitForMcpAuthentication(session, query, serverName, flowAbort.signal);
|
||||
const elicitation = client.createElicitation(
|
||||
{
|
||||
mode: "url",
|
||||
sessionId: session.sessionId,
|
||||
message: `Authenticate with MCP server ${serverName}`,
|
||||
url: login.authUrl,
|
||||
elicitationId,
|
||||
},
|
||||
flowAbort.signal,
|
||||
);
|
||||
const first = await Promise.race([
|
||||
completed.then((authenticated) => ({ type: "completed" as const, authenticated })),
|
||||
elicitation.then((response) => ({ type: "elicitation" as const, response })),
|
||||
]);
|
||||
|
||||
// A url-mode accept carries no content to check. The guard
|
||||
// `CreateElicitationResponse.isAccept` would also reject an accept whose
|
||||
// `content` holds a v2-only value, such as an object.
|
||||
if (first.type === "elicitation" && first.response.action !== "accept") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (first.type === "elicitation") {
|
||||
await completed;
|
||||
}
|
||||
try {
|
||||
await client.completeElicitation({ elicitationId });
|
||||
} catch (error) {
|
||||
if (!flowAbort.signal.aborted) {
|
||||
client.logger.error(`Failed to complete MCP OAuth elicitation: ${error}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
flowAbort.abort();
|
||||
session.closed.removeEventListener("abort", abortFlow);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForMcpAuthentication(
|
||||
session: McpOAuthSession,
|
||||
query: Query,
|
||||
serverName: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<boolean> {
|
||||
const deadline = Date.now() + MCP_OAUTH_TIMEOUT_MS;
|
||||
while (!signal.aborted && Date.now() < deadline) {
|
||||
if (session.currentQuery() !== query) return false;
|
||||
|
||||
const status: McpServerStatus | undefined = (await query.mcpServerStatus()).find(
|
||||
(server) => server.name === serverName,
|
||||
);
|
||||
if (!status || status.status === "failed" || status.status === "disabled") return false;
|
||||
if (status.status === "connected") return true;
|
||||
if (!(await waitUnlessAborted(MCP_OAUTH_STATUS_POLL_MS, signal))) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Wait for a polling interval, resolving false when the session is aborted. */
|
||||
export function waitUnlessAborted(milliseconds: number, signal: AbortSignal): Promise<boolean> {
|
||||
if (signal.aborted) return Promise.resolve(false);
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve(true);
|
||||
}, milliseconds);
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
resolve(false);
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
@@ -59,12 +59,7 @@ const PASSES = "passes";
|
||||
* The scenarios that stop on the native core, each with the reason, as the
|
||||
* native core reports it.
|
||||
*/
|
||||
const NATIVE_STOPS: Record<string, string> = {
|
||||
// AskUserQuestion needs a form elicitation, which the native core does not
|
||||
// send: it disallows the tool, and the scenario's script, which calls it
|
||||
// anyway, then ends the stream.
|
||||
"ask-user-question": "Internal error: Claude Code stopped. Resume the session to continue.",
|
||||
};
|
||||
const NATIVE_STOPS: Record<string, string> = {};
|
||||
|
||||
/**
|
||||
* The scenarios whose session updates equal the bridge's v2 golden file.
|
||||
@@ -93,6 +88,7 @@ const SAME_AS_BRIDGE: string[] = [
|
||||
"goal",
|
||||
"exit-plan-approve",
|
||||
"exit-plan-reject",
|
||||
"ask-user-question",
|
||||
"skill",
|
||||
"mcp-tool",
|
||||
"task-output-and-stop",
|
||||
|
||||
@@ -47,6 +47,8 @@ const sdk = vi.hoisted(() => ({
|
||||
{ value: "default", displayName: "Default", description: "Recommended" },
|
||||
{ value: "opus", displayName: "Opus", description: "Most capable" },
|
||||
],
|
||||
/** What `mcpServerStatus` reports. */
|
||||
mcpServers: [] as Array<{ name: string; status: string }>,
|
||||
}));
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
|
||||
@@ -111,12 +113,18 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
|
||||
supportedCommands: async () => [
|
||||
{ name: "review", description: "Review a change", argumentHint: "<pull request>" },
|
||||
],
|
||||
mcpServerStatus: async () => [{ name: "linear", status: "connected" }],
|
||||
interrupt: fake.interrupt,
|
||||
setPermissionMode: fake.setPermissionMode,
|
||||
setModel: fake.setModel,
|
||||
stopTask: fake.stopTask,
|
||||
close: fake.close,
|
||||
mcpServerStatus: async () => sdk.mcpServers,
|
||||
// A server that needs OAuth: Claude Code returns its authorization URL.
|
||||
mcpAuthenticate: async () => ({
|
||||
authUrl: "https://mcp.example.com/authorize",
|
||||
requiresUserAction: true,
|
||||
callbackExpected: true,
|
||||
}),
|
||||
});
|
||||
},
|
||||
getSessionMessages: vi.fn(async () => []),
|
||||
@@ -147,6 +155,7 @@ beforeEach(async () => {
|
||||
cwd = await mkdtemp(path.join(tempDir, "project-"));
|
||||
vi.stubEnv("CLAUDE_CONFIG_DIR", path.join(tempDir, "claude"));
|
||||
sdk.processes = [];
|
||||
sdk.mcpServers = [];
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -158,14 +167,23 @@ afterEach(async () => {
|
||||
/** A v2 client of the native core that records the session updates it receives. */
|
||||
function nativeClient({
|
||||
answerPermission = () => ({ outcome: { outcome: "cancelled" } }),
|
||||
answerElicitation = () => ({ action: "cancel" }),
|
||||
}: {
|
||||
answerPermission?: (
|
||||
request: v2.RequestPermissionRequest,
|
||||
) => v2.RequestPermissionResponse | Promise<v2.RequestPermissionResponse>;
|
||||
/** `signal` aborts when the agent cancels the request. */
|
||||
answerElicitation?: (
|
||||
request: v2.CreateElicitationRequest,
|
||||
signal: AbortSignal,
|
||||
) => v2.CreateElicitationResponse | Promise<v2.CreateElicitationResponse>;
|
||||
} = {}) {
|
||||
const sessionUpdates: v2.SessionUpdate[] = [];
|
||||
const notifications: v2.UpdateSessionNotification[] = [];
|
||||
const permissionRequests: v2.RequestPermissionRequest[] = [];
|
||||
const elicitations: v2.CreateElicitationRequest[] = [];
|
||||
/** The ids of the `elicitation/complete` notifications. */
|
||||
const completedElicitations: string[] = [];
|
||||
const app = v2
|
||||
.client({ name: "native-test-client" })
|
||||
.onNotification(v2.methods.client.session.update, ({ params }) => {
|
||||
@@ -175,6 +193,13 @@ function nativeClient({
|
||||
.onRequest(v2.methods.client.session.requestPermission, ({ params }) => {
|
||||
permissionRequests.push(params);
|
||||
return answerPermission(params);
|
||||
})
|
||||
.onRequest(v2.methods.client.elicitation.create, ({ params, signal }) => {
|
||||
elicitations.push(params);
|
||||
return answerElicitation(params, signal);
|
||||
})
|
||||
.onNotification(v2.methods.client.elicitation.complete, ({ params }) => {
|
||||
completedElicitations.push(params.elicitationId);
|
||||
});
|
||||
const toAgent = new TransformStream<v2.AnyMessage, v2.AnyMessage>();
|
||||
const toClient = new TransformStream<v2.AnyMessage, v2.AnyMessage>();
|
||||
@@ -197,6 +222,8 @@ function nativeClient({
|
||||
sessionUpdates,
|
||||
notifications,
|
||||
permissionRequests,
|
||||
elicitations,
|
||||
completedElicitations,
|
||||
states,
|
||||
connect: <T>(run: (agent: v2.ClientContext) => Promise<T>) =>
|
||||
app.connectWith({ readable: toClient.readable, writable: toAgent.writable }, run),
|
||||
@@ -700,6 +727,7 @@ describe("native ACP v2 core", () => {
|
||||
|
||||
it("replaces Claude Code's text of /mcp with the server list, before the turn's idle", async () => {
|
||||
const client = nativeClient();
|
||||
sdk.mcpServers = [{ name: "linear", status: "connected" }];
|
||||
await client.connect(async (agent) => {
|
||||
const { response, claude } = await openSession(agent);
|
||||
const answer = agent.request(v2.methods.agent.session.prompt, {
|
||||
@@ -1411,6 +1439,126 @@ describe("native ACP v2 core", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("elicitations", () => {
|
||||
it.each([
|
||||
["no elicitation", {}, { ask: false, mcp: false, dialog: false }],
|
||||
["forms", { form: {} }, { ask: true, mcp: true, dialog: true }],
|
||||
["URLs only", { url: {} }, { ask: false, mcp: true, dialog: false }],
|
||||
])(
|
||||
"asks Claude Code for the requests that a client with %s renders",
|
||||
async (_, elicitation, expected) => {
|
||||
const client = nativeClient();
|
||||
const { claude } = await client.connect((agent) => openSession(agent, { elicitation }));
|
||||
|
||||
const { options } = claude;
|
||||
expect(!(options.disallowedTools ?? []).includes("AskUserQuestion")).toBe(expected.ask);
|
||||
expect(options.onElicitation !== undefined).toBe(expected.mcp);
|
||||
expect(options.onUserDialog !== undefined).toBe(expected.dialog);
|
||||
expect(options.supportedDialogKinds).toEqual(
|
||||
expected.dialog ? ["refusal_fallback_prompt"] : undefined,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[
|
||||
"lists",
|
||||
["ask_user_question_unanswered_v1"],
|
||||
{ "Which package?": { unanswered: "skipped" } },
|
||||
],
|
||||
["does not list", [], undefined],
|
||||
])(
|
||||
"marks a skipped question as unanswered only when the turn's system/init %s the capability",
|
||||
async (_, capabilities, annotations) => {
|
||||
const client = nativeClient({
|
||||
answerElicitation: () => ({ action: "accept", content: {} }),
|
||||
});
|
||||
const decision = await client.connect(async (agent) => {
|
||||
const { claude } = await openSession(agent, { elicitation: { form: {} } });
|
||||
claude.send({ type: "system", subtype: "init", uuid: randomUUID(), capabilities });
|
||||
claude.send(cycleStart());
|
||||
await vi.waitFor(() => expect(client.states()).toEqual(["running"]));
|
||||
return claude.options.canUseTool!(
|
||||
"AskUserQuestion",
|
||||
{
|
||||
questions: [
|
||||
{
|
||||
question: "Which package?",
|
||||
header: "Package",
|
||||
options: [{ label: "core" }, { label: "cli" }],
|
||||
multiSelect: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
signal: new AbortController().signal,
|
||||
toolUseID: "toolu_ask",
|
||||
requestId: "r1",
|
||||
suggestions: [],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
expect(client.elicitations[0]).toMatchObject({ mode: "form", toolCallId: "toolu_ask" });
|
||||
expect(decision).toMatchObject({ behavior: "allow", updatedInput: { answers: {} } });
|
||||
expect(
|
||||
(decision as { updatedInput?: { annotations?: unknown } }).updatedInput?.annotations,
|
||||
).toEqual(annotations);
|
||||
},
|
||||
);
|
||||
|
||||
it("completes an MCP server's URL elicitation when Claude Code reports it complete", async () => {
|
||||
const client = nativeClient();
|
||||
await client.connect(async (agent) => {
|
||||
const { claude } = await openSession(agent, { elicitation: { url: {} } });
|
||||
claude.send({
|
||||
type: "system",
|
||||
subtype: "elicitation_complete",
|
||||
mcp_server_name: "linear",
|
||||
elicitation_id: "e1",
|
||||
uuid: randomUUID(),
|
||||
session_id: "session",
|
||||
});
|
||||
await vi.waitFor(() => expect(client.completedElicitations).toEqual(["e1"]));
|
||||
});
|
||||
});
|
||||
|
||||
it("cancels an open MCP OAuth elicitation before it answers session/close", async () => {
|
||||
// The server stays `needs-auth`: the user has not signed in yet.
|
||||
sdk.mcpServers = [{ name: "linear", status: "needs-auth" }];
|
||||
let cancelled = false;
|
||||
const client = nativeClient({
|
||||
answerElicitation: (_, signal) =>
|
||||
new Promise((resolve) =>
|
||||
signal.addEventListener("abort", () => {
|
||||
cancelled = true;
|
||||
resolve({ action: "cancel" });
|
||||
}),
|
||||
),
|
||||
});
|
||||
const cancelledAtClose = await client.connect(async (agent) => {
|
||||
await agent.request(v2.methods.agent.initialize, {
|
||||
protocolVersion: v2.PROTOCOL_VERSION,
|
||||
info: { name: "native-test-client", version: "1.0.0" },
|
||||
capabilities: { elicitation: { url: {} } },
|
||||
});
|
||||
const { sessionId } = await agent.request(v2.methods.agent.session.new, {
|
||||
cwd,
|
||||
mcpServers: [{ type: "http", name: "linear", url: "https://mcp.example.com/mcp" }],
|
||||
});
|
||||
await vi.waitFor(() => expect(client.elicitations).toHaveLength(1));
|
||||
await agent.request(v2.methods.agent.session.close, { sessionId });
|
||||
return cancelled;
|
||||
});
|
||||
|
||||
expect(client.elicitations[0]).toMatchObject({
|
||||
mode: "url",
|
||||
url: "https://mcp.example.com/authorize",
|
||||
});
|
||||
expect(cancelledAtClose).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("reads which v2 core to serve from CLAUDE_AGENT_ACP_V2_CORE", () => {
|
||||
expect(v2CoreFromEnv(undefined)).toBe("bridge");
|
||||
expect(v2CoreFromEnv("")).toBe("bridge");
|
||||
|
||||
@@ -15,7 +15,12 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { RequestError } from "@agentclientprotocol/sdk/unstable";
|
||||
import {
|
||||
RequestError,
|
||||
type CompleteElicitationNotification,
|
||||
type CreateElicitationRequest,
|
||||
type CreateElicitationResponse,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
|
||||
import {
|
||||
deleteSession,
|
||||
@@ -30,6 +35,7 @@ import packageJson from "../../package.json" with { type: "json" };
|
||||
import { messageIdForGrouping, type Logger } from "../acp-agent.js";
|
||||
import { claudeCliPath } from "../claude-cli.js";
|
||||
import { clientSystemPrompt, sessionSystemPrompt } from "../client-system-prompt.js";
|
||||
import type { ElicitationSupport } from "../elicitation.js";
|
||||
import { forkSession } from "../fork-session.js";
|
||||
import { sdkMcpServers } from "../mcp-servers.js";
|
||||
import { ALLOW_BYPASS, autoModeDisabled } from "../permissions/modes.js";
|
||||
@@ -118,6 +124,8 @@ class NativeV2Agent implements AgentHandle, SessionConnection {
|
||||
readonly auth: NativeAuth;
|
||||
/** What the client declared in `initialize`. */
|
||||
private clientCapabilities: v2.ClientCapabilities | null | undefined;
|
||||
/** The elicitation modes that the client advertised in `initialize`. */
|
||||
elicitation: ElicitationSupport = { form: false, url: false };
|
||||
|
||||
constructor(
|
||||
private readonly ctx: v2.AgentContext,
|
||||
@@ -145,6 +153,24 @@ class NativeV2Agent implements AgentHandle, SessionConnection {
|
||||
});
|
||||
}
|
||||
|
||||
createElicitation(
|
||||
request: CreateElicitationRequest,
|
||||
signal: AbortSignal,
|
||||
): Promise<CreateElicitationResponse> {
|
||||
// The v1 types accept a property schema with any tag; the v2 types accept
|
||||
// one only in a received value. An MCP server's schema is relayed
|
||||
// unchanged, as on the bridge, so this cast does not check it.
|
||||
return this.ctx.request(
|
||||
v2.methods.client.elicitation.create,
|
||||
request as v2.CreateElicitationRequest,
|
||||
{ cancellationSignal: signal, stopWaitingOnCancel: true },
|
||||
);
|
||||
}
|
||||
|
||||
completeElicitation(params: CompleteElicitationNotification): Promise<void> {
|
||||
return this.ctx.notify(v2.methods.client.elicitation.complete, params);
|
||||
}
|
||||
|
||||
ended(sessionId: string): void {
|
||||
// A later `session/resume` starts the session again from its transcript.
|
||||
this.sessions.delete(sessionId);
|
||||
@@ -160,6 +186,10 @@ class NativeV2Agent implements AgentHandle, SessionConnection {
|
||||
*/
|
||||
initialize(request: v2.InitializeRequest): v2.InitializeResponse {
|
||||
this.clientCapabilities = request.capabilities;
|
||||
this.elicitation = {
|
||||
form: request.capabilities?.elicitation?.form != null,
|
||||
url: request.capabilities?.elicitation?.url != null,
|
||||
};
|
||||
const { authMethods, _meta } = this.auth.initialize(request.capabilities);
|
||||
return {
|
||||
protocolVersion: v2.PROTOCOL_VERSION,
|
||||
@@ -185,7 +215,9 @@ class NativeV2Agent implements AgentHandle, SessionConnection {
|
||||
async newSession(params: v2.NewSessionRequest): Promise<v2.NewSessionResponse> {
|
||||
const sessionId = randomUUID();
|
||||
const session = await this.startSession(sessionId, params, undefined);
|
||||
return { sessionId, ...(await this.setupFields(session)) };
|
||||
const response = { sessionId, ...(await this.setupFields(session)) };
|
||||
afterSetupResponse(session, params);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -256,7 +288,9 @@ class NativeV2Agent implements AgentHandle, SessionConnection {
|
||||
// The current title, after the replay and before the state, as on the bridge.
|
||||
await session.reports.publishTitle();
|
||||
for (const notification of session.resumeSnapshot()) this.send(notification);
|
||||
return this.setupFields(session);
|
||||
const response = await this.setupFields(session);
|
||||
afterSetupResponse(session, params);
|
||||
return response;
|
||||
}
|
||||
|
||||
async listSessions(params: v2.ListSessionsRequest): Promise<v2.ListSessionsResponse> {
|
||||
@@ -465,6 +499,7 @@ class NativeV2Agent implements AgentHandle, SessionConnection {
|
||||
this,
|
||||
input,
|
||||
);
|
||||
const elicitationOptions = session.elicitations.queryOptions();
|
||||
const options: Options = {
|
||||
systemPrompt: sessionSystemPrompt(clientPrompt, params._meta?.systemPrompt),
|
||||
settingSources: ["user", "project", "local"],
|
||||
@@ -496,12 +531,17 @@ class NativeV2Agent implements AgentHandle, SessionConnection {
|
||||
mcpServers,
|
||||
permissionMode: mode,
|
||||
allowDangerouslySkipPermissions: allowBypass,
|
||||
canUseTool: canUseTool(session),
|
||||
canUseTool: session.elicitations.canUseTool(canUseTool(session)),
|
||||
// MCP elicitations and the refusal fallback, for the modes the client
|
||||
// renders. Without forms, AskUserQuestion is disallowed (below).
|
||||
...elicitationOptions,
|
||||
pathToClaudeCodeExecutable: await claudeCliPath(),
|
||||
// Echoes each prompt that Claude Code takes in (see `NativeSession`).
|
||||
extraArgs: { ...client?.extraArgs, "replay-user-messages": "" },
|
||||
// AskUserQuestion needs a form elicitation, which this core does not send.
|
||||
disallowedTools: [...(client?.disallowedTools ?? []), "AskUserQuestion"],
|
||||
disallowedTools: [
|
||||
...(client?.disallowedTools ?? []),
|
||||
...(elicitationOptions.disallowedTools ?? []),
|
||||
],
|
||||
tools:
|
||||
client?.tools ??
|
||||
(params._meta?.disableBuiltInTools === true
|
||||
@@ -628,6 +668,24 @@ 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: NativeSession,
|
||||
params: v2.NewSessionRequest | v2.ResumeSessionRequest,
|
||||
): 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");
|
||||
|
||||
166
src/v2-native/elicitation.ts
Normal file
166
src/v2-native/elicitation.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Claude Code's requests for user input that are not permission checks, as
|
||||
* ACP v2 elicitations of one session:
|
||||
*
|
||||
* - AskUserQuestion: a form, on the session of the agent that asks (the root
|
||||
* or a subagent's child session), with the question's `toolCallId`.
|
||||
* - An MCP server's elicitation (`onElicitation`): a form or a URL. A mode
|
||||
* that the client did not advertise is answered `cancel`.
|
||||
* - The refusal fallback's consent prompt (`onUserDialog`): a form.
|
||||
* - MCP OAuth: a URL, sent after the setup response (see `mcp-oauth.ts`).
|
||||
*
|
||||
* The first three are open requests of their session while they wait
|
||||
* (`requires_action`), and a cancel or close withdraws them with
|
||||
* `$/cancel_request`, as it does a permission request. Building the
|
||||
* elicitations and reading the answers is shared with v1 (`elicitation.ts`).
|
||||
*/
|
||||
import type {
|
||||
CompleteElicitationNotification,
|
||||
CreateElicitationRequest,
|
||||
CreateElicitationResponse,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import type { CanUseTool, Options, Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { Logger } from "../acp-agent.js";
|
||||
import {
|
||||
answerAskUserQuestion,
|
||||
answerMcpElicitation,
|
||||
answerUserDialog,
|
||||
elicitUnlessWithdrawn,
|
||||
REFUSAL_FALLBACK_DIALOG_KIND,
|
||||
type Elicit,
|
||||
type ElicitationSupport,
|
||||
} from "../elicitation.js";
|
||||
import { startMcpServerAuthentication } from "../mcp-oauth.js";
|
||||
import type { RequestTarget } from "./tasks.js";
|
||||
|
||||
/** What the elicitations need of the connection and the session. */
|
||||
export interface ElicitationHost {
|
||||
readonly sessionId: string;
|
||||
readonly logger: Logger;
|
||||
/** The modes that the client advertised in `initialize`. */
|
||||
readonly support: ElicitationSupport;
|
||||
createElicitation(
|
||||
request: CreateElicitationRequest,
|
||||
signal: AbortSignal,
|
||||
): Promise<CreateElicitationResponse>;
|
||||
completeElicitation(params: CompleteElicitationNotification): Promise<void>;
|
||||
/** The session of the agent `agentId` (see `NativeSession.target`). */
|
||||
target(agentId: string | undefined): Promise<RequestTarget>;
|
||||
/** The session's query, once it started. */
|
||||
query(): Query | undefined;
|
||||
}
|
||||
|
||||
export class SessionElicitations {
|
||||
/** Whether Claude Code takes `annotations[question].unanswered` (`ask_user_question_unanswered_v1`). */
|
||||
private reportUnanswered = false;
|
||||
/**
|
||||
* Aborted by `session/close`: stops the MCP OAuth runs. A query that ended
|
||||
* on its own fails their next `mcpServerStatus`, which ends them too.
|
||||
*/
|
||||
private readonly closed = new AbortController();
|
||||
|
||||
constructor(private readonly host: ElicitationHost) {}
|
||||
|
||||
/**
|
||||
* The query options: the elicitation callbacks for the modes the client
|
||||
* renders. Without form elicitation AskUserQuestion is disallowed, and the
|
||||
* refusal fallback is not declared, so Claude Code ends the turn with the
|
||||
* refusal instead.
|
||||
*/
|
||||
queryOptions(): Pick<
|
||||
Options,
|
||||
"onElicitation" | "onUserDialog" | "supportedDialogKinds" | "disallowedTools"
|
||||
> {
|
||||
const { support, sessionId, logger } = this.host;
|
||||
return {
|
||||
...(support.form || support.url
|
||||
? {
|
||||
onElicitation: async (request, { signal }) =>
|
||||
answerMcpElicitation(request, {
|
||||
sessionId,
|
||||
support,
|
||||
logger,
|
||||
elicit: this.elicit(await this.host.target(undefined), signal),
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
...(support.form
|
||||
? {
|
||||
onUserDialog: async (request, { signal }) =>
|
||||
answerUserDialog(request, {
|
||||
sessionId,
|
||||
logger,
|
||||
elicit: this.elicit(await this.host.target(undefined), signal),
|
||||
}),
|
||||
supportedDialogKinds: [REFUSAL_FALLBACK_DIALOG_KIND],
|
||||
}
|
||||
: { disallowedTools: ["AskUserQuestion"] }),
|
||||
};
|
||||
}
|
||||
|
||||
/** `canUseTool` that asks AskUserQuestion's questions, and leaves the other tools to `permissions`. */
|
||||
canUseTool(permissions: CanUseTool): CanUseTool {
|
||||
return async (toolName, toolInput, options) => {
|
||||
if (toolName !== "AskUserQuestion" || !this.host.support.form) {
|
||||
return permissions(toolName, toolInput, options);
|
||||
}
|
||||
const target = await this.host.target(options.agentID);
|
||||
return answerAskUserQuestion(toolInput, {
|
||||
sessionId: target.sessionId,
|
||||
toolUseID: options.toolUseID,
|
||||
reportUnanswered: () => this.reportUnanswered,
|
||||
logger: this.host.logger,
|
||||
elicit: this.elicit(target, options.signal),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/** A `system/init` of the session: it lists Claude Code's capabilities. */
|
||||
init(capabilities: readonly string[] | undefined): void {
|
||||
if (capabilities?.includes("ask_user_question_unanswered_v1")) this.reportUnanswered = true;
|
||||
}
|
||||
|
||||
/** Claude Code's `elicitation_complete`: a URL elicitation of an MCP server finished. */
|
||||
completed(elicitationId: string): void {
|
||||
if (!this.host.support.url || this.closed.signal.aborted) return;
|
||||
this.host.completeElicitation({ elicitationId }).catch((error) => {
|
||||
this.host.logger.error(`Failed to complete elicitation: ${error}`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts OAuth in the background for the servers of `serverNames` that
|
||||
* Claude Code reports as `needs-auth`. Call it only once the setup
|
||||
* response is sent: v2 asks for no elicitation before it.
|
||||
*/
|
||||
authenticateMcpServers(serverNames: readonly string[]): void {
|
||||
startMcpServerAuthentication(
|
||||
{
|
||||
urlElicitation: this.host.support.url,
|
||||
createElicitation: (request, signal) => this.host.createElicitation(request, signal),
|
||||
completeElicitation: (params) => this.host.completeElicitation(params),
|
||||
logger: this.host.logger,
|
||||
},
|
||||
{
|
||||
key: this,
|
||||
sessionId: this.host.sessionId,
|
||||
currentQuery: () => (this.closed.signal.aborted ? undefined : this.host.query()),
|
||||
closed: this.closed.signal,
|
||||
},
|
||||
new Set(serverNames),
|
||||
);
|
||||
}
|
||||
|
||||
/** The session closes: the OAuth runs stop, and withdraw their elicitations. */
|
||||
close(): void {
|
||||
this.closed.abort();
|
||||
}
|
||||
|
||||
/** Sends an elicitation as an open request of `target`. */
|
||||
private elicit(target: RequestTarget, signal: AbortSignal): Elicit {
|
||||
return (request) =>
|
||||
target.openRequest(signal, (signal) =>
|
||||
elicitUnlessWithdrawn(signal, (signal) => this.host.createElicitation(request, signal)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,8 @@
|
||||
* which v2 lists as background work instead (`background`), so its `idle`
|
||||
* only ends a turn that ended without a result.
|
||||
* - `requires_action` is this session's own: a turn is open, and a
|
||||
* permission request of this session is open, or of a subagent that the
|
||||
* turn waits on.
|
||||
* permission request or elicitation of this session is open, or of a
|
||||
* subagent that the turn waits on.
|
||||
* - Subagents are child sessions, and background work is listed on every
|
||||
* state (see {@link SessionTasks}).
|
||||
* - `session/cancel` answers the prompts that are not inserted yet with
|
||||
@@ -32,7 +32,13 @@
|
||||
* {@link SessionOutput}).
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { RequestError, type PromptRequest } from "@agentclientprotocol/sdk/unstable";
|
||||
import {
|
||||
RequestError,
|
||||
type CompleteElicitationNotification,
|
||||
type CreateElicitationRequest,
|
||||
type CreateElicitationResponse,
|
||||
type PromptRequest,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import type * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
|
||||
import type {
|
||||
PermissionMode,
|
||||
@@ -43,6 +49,7 @@ import type {
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { promptToClaude, type Logger } from "../acp-agent.js";
|
||||
import type { McpCommand } from "../mcp-command.js";
|
||||
import type { ElicitationSupport } from "../elicitation.js";
|
||||
import { Pushable } from "../utils.js";
|
||||
import { v1PromptRequest } from "../v2/prompt.js";
|
||||
import { V2Background } from "../v2/background.js";
|
||||
@@ -53,6 +60,7 @@ import type { SessionConfig } from "./config.js";
|
||||
import { idleOutcome, isPlaceholderResult, ResultUsage, type TurnEnd } from "./outcome.js";
|
||||
import { StoppedWork } from "./stopped-work.js";
|
||||
import { SessionReports } from "./reports.js";
|
||||
import { SessionElicitations } from "./elicitation.js";
|
||||
import { SessionOutput } from "./stream.js";
|
||||
import { SessionTasks, type RequestTarget } from "./tasks.js";
|
||||
|
||||
@@ -66,6 +74,13 @@ export interface SessionConnection {
|
||||
request: v2.RequestPermissionRequest,
|
||||
signal: AbortSignal,
|
||||
): Promise<v2.RequestPermissionResponse>;
|
||||
/** The elicitation modes that the client advertised. */
|
||||
readonly elicitation: ElicitationSupport;
|
||||
createElicitation(
|
||||
request: CreateElicitationRequest,
|
||||
signal: AbortSignal,
|
||||
): Promise<CreateElicitationResponse>;
|
||||
completeElicitation(params: CompleteElicitationNotification): Promise<void>;
|
||||
/** The session's query ended on its own: the session is no longer active. */
|
||||
ended(sessionId: string): void;
|
||||
}
|
||||
@@ -102,7 +117,7 @@ export class NativeSession implements RequestTarget {
|
||||
private turnCommand: string | undefined;
|
||||
/** The state the client was told last. */
|
||||
private reportedState: ForegroundState = "idle";
|
||||
/** The open permission requests of the session. */
|
||||
/** The open permission requests and elicitations of the session. */
|
||||
private readonly requests = new Set<OpenRequest>();
|
||||
/** Whether `session/cancel` stops the open turn: its idle reports `cancelled`. */
|
||||
private cancelledWork = false;
|
||||
@@ -136,6 +151,7 @@ export class NativeSession implements RequestTarget {
|
||||
/** The `background` lists of the session and its children, stamped on each state. */
|
||||
private readonly background: V2Background;
|
||||
readonly tasks: SessionTasks;
|
||||
readonly elicitations: SessionElicitations;
|
||||
config!: SessionConfig;
|
||||
commands!: SessionCommands;
|
||||
/** The `/mcp` prompts not inserted yet, by uuid, and the open turn's (see `SessionCommands`). */
|
||||
@@ -201,6 +217,15 @@ export class NativeSession implements RequestTarget {
|
||||
this.output,
|
||||
);
|
||||
this.tasks.hostRequest = (request, signal) => connection.requestPermission(request, signal);
|
||||
this.elicitations = new SessionElicitations({
|
||||
sessionId,
|
||||
logger: connection.logger,
|
||||
support: connection.elicitation,
|
||||
createElicitation: (request, signal) => connection.createElicitation(request, signal),
|
||||
completeElicitation: (params) => connection.completeElicitation(params),
|
||||
target: (agentId) => this.target(agentId),
|
||||
query: () => this.query,
|
||||
});
|
||||
// The session reports its background work: every state carries a list.
|
||||
this.background.changed({ sessionId, items: [], state: undefined });
|
||||
}
|
||||
@@ -398,8 +423,9 @@ export class NativeSession implements RequestTarget {
|
||||
this.closing ??= (async () => {
|
||||
if (!this.closed) this.stopForeground();
|
||||
// v2 asks to withdraw every request of the session before the close
|
||||
// response, also its background subagents'.
|
||||
// response, also its background subagents' and the MCP OAuth runs'.
|
||||
this.tasks.withdrawAll();
|
||||
this.elicitations.close();
|
||||
for (const request of this.requests) {
|
||||
request.coveredByCancel = true;
|
||||
request.abort.abort();
|
||||
@@ -437,21 +463,33 @@ export class NativeSession implements RequestTarget {
|
||||
return this.tasks.target(agentId, this);
|
||||
}
|
||||
|
||||
async requestPermission(
|
||||
requestPermission(
|
||||
request: v2.RequestPermissionRequest,
|
||||
toolSignal: AbortSignal,
|
||||
): Promise<v2.RequestPermissionResponse> {
|
||||
return this.openRequest(toolSignal, async (signal) => {
|
||||
try {
|
||||
// The subject is an upsert of the tool call: the client may see the
|
||||
// request before the tool use streams.
|
||||
return await this.connection.requestPermission(request, signal);
|
||||
} catch (error) {
|
||||
if (signal.aborted) return { outcome: { outcome: "cancelled" } };
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Runs `send` as an open request of this session (see {@link RequestTarget.openRequest}). */
|
||||
async openRequest<T>(
|
||||
toolSignal: AbortSignal,
|
||||
send: (signal: AbortSignal) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const open: OpenRequest = { abort: new AbortController(), coveredByCancel: false };
|
||||
const signal = AbortSignal.any([toolSignal, open.abort.signal]);
|
||||
this.requests.add(open);
|
||||
this.reportState();
|
||||
try {
|
||||
// The subject is an upsert of the tool call: the client may see the
|
||||
// request before the tool use streams.
|
||||
return await this.connection.requestPermission(request, signal);
|
||||
} catch (error) {
|
||||
if (signal.aborted) return { outcome: { outcome: "cancelled" } };
|
||||
throw error;
|
||||
return await send(signal);
|
||||
} finally {
|
||||
this.requests.delete(open);
|
||||
// A request withdrawn after a cancel (Claude Code's interrupt aborts
|
||||
@@ -539,6 +577,7 @@ export class NativeSession implements RequestTarget {
|
||||
switch (message.subtype) {
|
||||
case "init":
|
||||
if (message.capabilities?.includes("msg_lifecycle_v1")) this.lifecycleFrames = true;
|
||||
this.elicitations.init(message.capabilities);
|
||||
this.reports.handle(message);
|
||||
// Its terminal-bound commands leave the list.
|
||||
if (this.commands.init(message)) this.resendCommands();
|
||||
@@ -559,6 +598,9 @@ export class NativeSession implements RequestTarget {
|
||||
case "commands_changed":
|
||||
this.send(this.commands.changed(message.commands));
|
||||
return;
|
||||
case "elicitation_complete":
|
||||
this.elicitations.completed(message.elicitation_id);
|
||||
return;
|
||||
case "session_state_changed":
|
||||
if (message.state === "idle") {
|
||||
this.claudeCodeIdle();
|
||||
|
||||
@@ -63,7 +63,7 @@ export interface TaskHost {
|
||||
stopTask(taskId: string): Promise<void>;
|
||||
}
|
||||
|
||||
/** Where a permission request goes: the root session or a child. */
|
||||
/** Where a permission request or elicitation goes: the root session or a child. */
|
||||
export interface RequestTarget {
|
||||
readonly sessionId: string;
|
||||
readonly cwd: string;
|
||||
@@ -75,6 +75,12 @@ export interface RequestTarget {
|
||||
request: v2.RequestPermissionRequest,
|
||||
signal: AbortSignal,
|
||||
): Promise<v2.RequestPermissionResponse>;
|
||||
/**
|
||||
* Runs `send` as an open request of the target (`requires_action`) until it
|
||||
* settles. Its signal aborts with `signal`, or when a cancel or close
|
||||
* withdraws the target's requests.
|
||||
*/
|
||||
openRequest<T>(signal: AbortSignal, send: (signal: AbortSignal) => Promise<T>): Promise<T>;
|
||||
}
|
||||
|
||||
type ChildState = "idle" | "running" | "requires_action";
|
||||
@@ -307,7 +313,16 @@ export class SessionTasks {
|
||||
cwd: child.cwd ?? this.host.cwd,
|
||||
output: child.output,
|
||||
cancelling: () => child.cancelled,
|
||||
requestPermission: (request, signal) => this.childRequest(child, request, signal),
|
||||
requestPermission: (request, toolSignal) =>
|
||||
this.childRequest(child, toolSignal, async (signal) => {
|
||||
try {
|
||||
return await this.hostRequest(request, signal);
|
||||
} catch (error) {
|
||||
if (signal.aborted) return { outcome: { outcome: "cancelled" } };
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
openRequest: (signal, send) => this.childRequest(child, signal, send),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -709,11 +724,12 @@ export class SessionTasks {
|
||||
});
|
||||
}
|
||||
|
||||
private async childRequest(
|
||||
/** Runs `send` as an open request of `child` (see {@link RequestTarget.openRequest}). */
|
||||
private async childRequest<T>(
|
||||
child: Child,
|
||||
request: v2.RequestPermissionRequest,
|
||||
toolSignal: AbortSignal,
|
||||
): Promise<v2.RequestPermissionResponse> {
|
||||
send: (signal: AbortSignal) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const abort = new AbortController();
|
||||
const signal = AbortSignal.any([toolSignal, abort.signal]);
|
||||
child.requestAborts.add(abort);
|
||||
@@ -721,10 +737,7 @@ export class SessionTasks {
|
||||
this.reportChild(child);
|
||||
this.host.foregroundRequestsChanged();
|
||||
try {
|
||||
return await this.hostRequest(request, signal);
|
||||
} catch (error) {
|
||||
if (signal.aborted) return { outcome: { outcome: "cancelled" } };
|
||||
throw error;
|
||||
return await send(signal);
|
||||
} finally {
|
||||
child.requestAborts.delete(abort);
|
||||
child.openRequests--;
|
||||
|
||||
Reference in New Issue
Block a user