mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
feat: Add experimental elicitation support (#756)
This is still unstable on ACP, but experimenting with the latest api for testing. Also allows for a representation of the AskUserQuestion tool
This commit is contained in:
121
src/acp-agent.ts
121
src/acp-agent.ts
@@ -48,8 +48,10 @@ import {
|
||||
McpServerConfig,
|
||||
ModelInfo,
|
||||
ModelUsage,
|
||||
OnElicitation,
|
||||
Options,
|
||||
PermissionMode,
|
||||
PermissionResult,
|
||||
PermissionUpdate,
|
||||
Query,
|
||||
query,
|
||||
@@ -70,6 +72,14 @@ import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import packageJson from "../package.json" with { type: "json" };
|
||||
import {
|
||||
applyAskElicitationResponse,
|
||||
askUserQuestionsToCreateRequest,
|
||||
createElicitationResponseToElicitResult,
|
||||
ElicitationSupport,
|
||||
extractAskUserQuestions,
|
||||
mcpElicitationToCreateRequest,
|
||||
} from "./elicitation.js";
|
||||
import { SettingsManager } from "./settings.js";
|
||||
import {
|
||||
applyTaskCreate,
|
||||
@@ -1123,7 +1133,22 @@ export class ClaudeAcpAgent implements Agent {
|
||||
case "task_notification":
|
||||
case "task_progress":
|
||||
case "task_updated":
|
||||
case "elicitation_complete":
|
||||
break;
|
||||
case "elicitation_complete": {
|
||||
// A url-mode MCP elicitation finished server-side. Let the client
|
||||
// dismiss any UI it opened for it. Only meaningful when the
|
||||
// client supports url elicitation; ignore failures otherwise.
|
||||
if (this.clientCapabilities?.elicitation?.url) {
|
||||
try {
|
||||
await this.client.unstable_completeElicitation({
|
||||
elicitationId: message.elicitation_id,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to complete elicitation: ${error}`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "plugin_install":
|
||||
case "notification":
|
||||
case "api_retry":
|
||||
@@ -1897,6 +1922,14 @@ export class ClaudeAcpAgent implements Agent {
|
||||
};
|
||||
}
|
||||
|
||||
// AskUserQuestion is surfaced to us as a normal permission check (the SDK
|
||||
// routes it through canUseTool whenever a callback is registered, rather
|
||||
// than the interactive dialog). Present it as an ACP form elicitation and
|
||||
// feed the answers back as updatedInput for the tool's own call() to read.
|
||||
if (toolName === "AskUserQuestion" && this.clientCapabilities?.elicitation?.form) {
|
||||
return this.handleAskUserQuestion(sessionId, toolInput, toolUseID, signal);
|
||||
}
|
||||
|
||||
if (toolName === "ExitPlanMode") {
|
||||
const optionsAll: PermissionOption[] = [
|
||||
{ kind: "allow_always", name: 'Yes, and use "auto" mode', optionId: "auto" },
|
||||
@@ -2041,6 +2074,71 @@ export class ClaudeAcpAgent implements Agent {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle elicitation requests that originate from MCP servers by forwarding
|
||||
* them to the client over ACP. Modes the client did not advertise (or
|
||||
* requests we can't represent) are declined.
|
||||
*/
|
||||
private handleMcpElicitation(sessionId: string, support: ElicitationSupport): OnElicitation {
|
||||
return async (request, { signal }) => {
|
||||
const isUrl = request.mode === "url";
|
||||
if ((isUrl && !support.url) || (!isUrl && !support.form)) {
|
||||
return { action: "decline" };
|
||||
}
|
||||
|
||||
const createRequest = mcpElicitationToCreateRequest(request, sessionId);
|
||||
if (!createRequest) {
|
||||
return { action: "decline" };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.client.unstable_createElicitation(createRequest);
|
||||
if (signal.aborted) {
|
||||
return { action: "cancel" };
|
||||
}
|
||||
return createElicitationResponseToElicitResult(response);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to forward MCP elicitation: ${error}`);
|
||||
return { action: "decline" };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
private async handleAskUserQuestion(
|
||||
sessionId: string,
|
||||
toolInput: Record<string, unknown>,
|
||||
toolUseID: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<PermissionResult> {
|
||||
const questions = extractAskUserQuestions(toolInput);
|
||||
if (!questions) {
|
||||
return { behavior: "deny", message: "AskUserQuestion called with no valid questions." };
|
||||
}
|
||||
|
||||
const createRequest = askUserQuestionsToCreateRequest(questions, sessionId, toolUseID);
|
||||
let response;
|
||||
try {
|
||||
response = await this.client.unstable_createElicitation(createRequest);
|
||||
} catch (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);
|
||||
if (outcome.action === "cancel") {
|
||||
throw new Error("Tool use aborted");
|
||||
}
|
||||
return { behavior: "allow", updatedInput: outcome.updatedInput };
|
||||
}
|
||||
|
||||
private async sendAvailableCommandsUpdate(sessionId: string): Promise<void> {
|
||||
const session = this.sessions[sessionId];
|
||||
if (!session) return;
|
||||
@@ -2335,8 +2433,18 @@ export class ClaudeAcpAgent implements Agent {
|
||||
// Parse model configuration from environment (e.g. Bedrock model overrides)
|
||||
const modelConfig = parseModelConfig(process.env.CLAUDE_MODEL_CONFIG);
|
||||
|
||||
// Disable this for now, not a great way to expose this over ACP at the moment (in progress work so we can revisit)
|
||||
const disallowedTools = ["AskUserQuestion"];
|
||||
// Elicitation modes the connected client advertised. We only forward
|
||||
// elicitations (and only re-enable AskUserQuestion) for modes the client
|
||||
// can actually render.
|
||||
const elicitationSupport: ElicitationSupport = {
|
||||
form: !!this.clientCapabilities?.elicitation?.form,
|
||||
url: !!this.clientCapabilities?.elicitation?.url,
|
||||
};
|
||||
|
||||
// AskUserQuestion surfaces as a `permission_ask_user_question` dialog that
|
||||
// we render as a form elicitation. Without form-elicitation support there
|
||||
// is no way to present it over ACP, so keep it disabled in that case.
|
||||
const disallowedTools = elicitationSupport.form ? [] : ["AskUserQuestion"];
|
||||
|
||||
// Resolve which built-in tools to expose.
|
||||
// Explicit tools array from _meta.claudeCode.options takes precedence.
|
||||
@@ -2385,6 +2493,13 @@ export class ClaudeAcpAgent implements Agent {
|
||||
allowDangerouslySkipPermissions: ALLOW_BYPASS,
|
||||
permissionMode,
|
||||
canUseTool: this.canUseTool(sessionId),
|
||||
// Forward MCP elicitation requests onto ACP elicitation. Only attached
|
||||
// when the client advertised support, so non-supporting clients keep the
|
||||
// SDK's default (auto-decline) behavior. (AskUserQuestion is handled in
|
||||
// canUseTool, not here.)
|
||||
...(elicitationSupport.form || elicitationSupport.url
|
||||
? { onElicitation: this.handleMcpElicitation(sessionId, elicitationSupport) }
|
||||
: {}),
|
||||
pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE ?? (await claudeCliPath()),
|
||||
extraArgs: {
|
||||
...userProvidedOptions?.extraArgs,
|
||||
|
||||
249
src/elicitation.ts
Normal file
249
src/elicitation.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type {
|
||||
CreateElicitationRequest,
|
||||
CreateElicitationResponse,
|
||||
ElicitationPropertySchema,
|
||||
ElicitationSchema,
|
||||
EnumOption,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import type { ElicitationRequest, ElicitationResult } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type {
|
||||
AskUserQuestionInput,
|
||||
AskUserQuestionOutput,
|
||||
} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js";
|
||||
|
||||
/**
|
||||
* Bridges between the Claude Agent SDK's elicitation/dialog callbacks and ACP's
|
||||
* (unstable) elicitation protocol.
|
||||
*
|
||||
* Two distinct SDK surfaces flow through here:
|
||||
*
|
||||
* 1. `onElicitation` — fired when an MCP server requests user input. These map
|
||||
* directly onto ACP `session/create_elicitation` (form or url mode).
|
||||
* 2. The built-in AskUserQuestion tool — when a `canUseTool` callback is
|
||||
* registered the SDK routes its permission check through `canUseTool`
|
||||
* (not the interactive `permission_ask_user_question` dialog). We render
|
||||
* its questions as an ACP form elicitation and feed the user's selections
|
||||
* back as the tool's `updatedInput`, which the tool's own `call()` reads.
|
||||
*/
|
||||
|
||||
/** Modes the connected client advertised support for. */
|
||||
export type ElicitationSupport = {
|
||||
form: boolean;
|
||||
url: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert an MCP elicitation request (from the SDK's `onElicitation` callback)
|
||||
* into an ACP `CreateElicitationRequest`. Returns `null` when the request can't
|
||||
* be represented (e.g. a url-mode request with no url).
|
||||
*/
|
||||
export function mcpElicitationToCreateRequest(
|
||||
request: ElicitationRequest,
|
||||
sessionId: string,
|
||||
): CreateElicitationRequest | null {
|
||||
if (request.mode === "url") {
|
||||
if (!request.url) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
mode: "url",
|
||||
sessionId,
|
||||
message: request.message,
|
||||
url: request.url,
|
||||
// URL elicitations need a stable id so the client can correlate the
|
||||
// later `session/complete_elicitation` notification. MCP servers usually
|
||||
// provide one; fall back to a generated id if not.
|
||||
elicitationId: request.elicitationId ?? randomUUID(),
|
||||
};
|
||||
}
|
||||
|
||||
// Form mode (the default). The MCP `requestedSchema` is already a JSON Schema
|
||||
// with primitive-typed properties, which is structurally what ACP expects.
|
||||
return {
|
||||
mode: "form",
|
||||
sessionId,
|
||||
message: request.message,
|
||||
requestedSchema: normalizeElicitationSchema(request.requestedSchema),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an ACP elicitation response back to the MCP `ElicitResult` the SDK expects
|
||||
* to hand back to the requesting server.
|
||||
*/
|
||||
export function createElicitationResponseToElicitResult(
|
||||
response: CreateElicitationResponse,
|
||||
): ElicitationResult {
|
||||
switch (response.action) {
|
||||
case "accept":
|
||||
return { action: "accept", content: response.content ?? {} };
|
||||
case "decline":
|
||||
return { action: "decline" };
|
||||
case "cancel":
|
||||
default:
|
||||
return { action: "cancel" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A single question as supplied by the AskUserQuestion tool. Derived from the
|
||||
* SDK's input type so the shape stays in sync; the SDK validates the model's
|
||||
* tool call against this schema before it reaches us.
|
||||
*/
|
||||
export type AskUserQuestion = AskUserQuestionInput["questions"][number];
|
||||
|
||||
/**
|
||||
* Pull the well-formed questions out of an AskUserQuestion tool input. Returns
|
||||
* `null` when there are no usable questions — including the case where every
|
||||
* entry is malformed and filtering leaves an empty list — so callers can treat
|
||||
* "nothing to ask" uniformly.
|
||||
*/
|
||||
export function extractAskUserQuestions(input: Record<string, unknown>): AskUserQuestion[] | null {
|
||||
const questions = (input as { questions?: unknown }).questions;
|
||||
if (!Array.isArray(questions)) {
|
||||
return null;
|
||||
}
|
||||
const valid = questions.filter(
|
||||
(q): q is AskUserQuestion =>
|
||||
!!q && typeof q.question === "string" && Array.isArray(q.options) && q.options.length > 0,
|
||||
);
|
||||
return valid.length > 0 ? valid : null;
|
||||
}
|
||||
|
||||
/** Stable form-field key for the question at the given index. */
|
||||
function questionFieldKey(index: number): string {
|
||||
return `question_${index}`;
|
||||
}
|
||||
|
||||
/** Form-field key for the optional free-text "custom answer" field. */
|
||||
const CUSTOM_ANSWER_FIELD = "customAnswer";
|
||||
|
||||
/**
|
||||
* Render the AskUserQuestion tool's questions as an ACP form elicitation.
|
||||
*
|
||||
* Fields are keyed by a short stable id (`question_<n>`) rather than the full
|
||||
* question text, so the question text appears in exactly one place per field.
|
||||
* Single-select questions use a titled `oneOf` enum; multi-select questions use
|
||||
* an array with a titled `anyOf` item enum. The enum `const` is always the
|
||||
* option label, since that is what the tool records as the answer.
|
||||
*
|
||||
* A trailing optional free-text field mirrors the CLI's custom-answer box: the
|
||||
* user can type their own answer instead of (or as well as) picking an option.
|
||||
* Nothing is marked required, so the user can also just skip — matching the
|
||||
* built-in tool, which always offers Skip + a free-text box.
|
||||
*/
|
||||
export function askUserQuestionsToCreateRequest(
|
||||
questions: AskUserQuestion[],
|
||||
sessionId: string,
|
||||
toolCallId: string | undefined,
|
||||
): CreateElicitationRequest {
|
||||
const single = questions.length === 1;
|
||||
const properties: Record<string, ElicitationPropertySchema> = {};
|
||||
|
||||
questions.forEach((question, index) => {
|
||||
const options: EnumOption[] = question.options.map((option) => ({
|
||||
const: option.label,
|
||||
title: option.description ? `${option.label} — ${option.description}` : option.label,
|
||||
}));
|
||||
|
||||
// For a single question the prompt is carried by `message`, so we don't
|
||||
// repeat it in the field description. With multiple questions each field
|
||||
// needs its own question text.
|
||||
const description = single ? undefined : question.question;
|
||||
const title = question.header || undefined;
|
||||
|
||||
properties[questionFieldKey(index)] = question.multiSelect
|
||||
? { type: "array", title, description, items: { anyOf: options } }
|
||||
: { type: "string", title, description, oneOf: options };
|
||||
});
|
||||
|
||||
properties[CUSTOM_ANSWER_FIELD] = {
|
||||
type: "string",
|
||||
title: "Other",
|
||||
description: "Type your own answer instead of choosing an option above (optional).",
|
||||
};
|
||||
|
||||
const requestedSchema: ElicitationSchema = {
|
||||
type: "object",
|
||||
properties,
|
||||
};
|
||||
|
||||
const message = single ? questions[0].question : "Please answer the following questions.";
|
||||
|
||||
return {
|
||||
mode: "form",
|
||||
sessionId,
|
||||
...(toolCallId ? { toolCallId } : {}),
|
||||
message,
|
||||
requestedSchema,
|
||||
};
|
||||
}
|
||||
|
||||
/** Outcome of an AskUserQuestion elicitation, decoupled from any transport. */
|
||||
export type AskUserQuestionOutcome =
|
||||
| { action: "answered"; updatedInput: Record<string, unknown> }
|
||||
| { action: "cancel" };
|
||||
|
||||
/**
|
||||
* Fold an ACP elicitation response into the AskUserQuestion tool's input.
|
||||
*
|
||||
* Selected labels are read back from the indexed form fields and written into
|
||||
* `answers` as a `{ [questionText]: label }` map (comma-joining multi-selects)
|
||||
* — the key shape the tool's own `call()` reads. Free text from the custom-
|
||||
* answer field becomes the tool's top-level `response`. Decline yields empty
|
||||
* answers (the model is told the user skipped rather than the turn aborting);
|
||||
* cancel aborts the tool call.
|
||||
*/
|
||||
export function applyAskElicitationResponse(
|
||||
response: CreateElicitationResponse,
|
||||
toolInput: Record<string, unknown>,
|
||||
questions: AskUserQuestion[],
|
||||
): AskUserQuestionOutcome {
|
||||
if (response.action === "cancel") {
|
||||
return { action: "cancel" };
|
||||
}
|
||||
|
||||
if (response.action === "decline") {
|
||||
return { action: "answered", updatedInput: { ...toolInput, answers: {} } };
|
||||
}
|
||||
|
||||
const content = response.content ?? {};
|
||||
// Typed against the tool's own output schema so the answer/response shapes
|
||||
// stay in sync with what the built-in tool's call() expects to read back.
|
||||
const answers: AskUserQuestionOutput["answers"] = {};
|
||||
questions.forEach((question, index) => {
|
||||
const value = content[questionFieldKey(index)];
|
||||
if (value === undefined || value === null) {
|
||||
return;
|
||||
}
|
||||
const text = Array.isArray(value) ? value.join(", ") : String(value);
|
||||
if (text === "") {
|
||||
return;
|
||||
}
|
||||
answers[question.question] = text;
|
||||
});
|
||||
|
||||
const updatedInput: Record<string, unknown> = { ...toolInput, answers };
|
||||
const custom = content[CUSTOM_ANSWER_FIELD];
|
||||
if (typeof custom === "string" && custom.trim() !== "") {
|
||||
const response: AskUserQuestionOutput["response"] = custom;
|
||||
updatedInput.response = response;
|
||||
}
|
||||
|
||||
return { action: "answered", updatedInput };
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce an arbitrary MCP `requestedSchema` into an ACP `ElicitationSchema`.
|
||||
* The two are structurally compatible JSON Schemas; we just guarantee the
|
||||
* `type: "object"` discriminator is present.
|
||||
*/
|
||||
function normalizeElicitationSchema(
|
||||
schema: Record<string, unknown> | undefined,
|
||||
): ElicitationSchema {
|
||||
if (!schema || typeof schema !== "object") {
|
||||
return { type: "object", properties: {} };
|
||||
}
|
||||
return { ...(schema as ElicitationSchema), type: "object" };
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
AvailableCommand,
|
||||
Client,
|
||||
ClientSideConnection,
|
||||
CreateElicitationRequest,
|
||||
CreateElicitationResponse,
|
||||
ndJsonStream,
|
||||
NewSessionResponse,
|
||||
ReadTextFileRequest,
|
||||
@@ -89,6 +91,10 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
|
||||
agent: Agent;
|
||||
files: Map<string, string> = new Map();
|
||||
receivedText: string = "";
|
||||
// Records for the AskUserQuestion elicitation test.
|
||||
elicitations: CreateElicitationRequest[] = [];
|
||||
permissionToolInputs: unknown[] = [];
|
||||
chosenAnswers: Record<string, string | string[]> = {};
|
||||
resolveAvailableCommands: (commands: AvailableCommand[]) => void;
|
||||
availableCommandsPromise: Promise<AvailableCommand[]>;
|
||||
|
||||
@@ -107,11 +113,39 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
|
||||
}
|
||||
|
||||
async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
|
||||
// Record what asked for permission so a test can assert that
|
||||
// AskUserQuestion did NOT fall back to a generic permission prompt.
|
||||
this.permissionToolInputs.push(params.toolCall?.rawInput);
|
||||
const optionId = params.options.find((p) => p.kind === "allow_once")!.optionId;
|
||||
|
||||
return { outcome: { outcome: "selected", optionId } };
|
||||
}
|
||||
|
||||
async unstable_createElicitation(
|
||||
params: CreateElicitationRequest,
|
||||
): Promise<CreateElicitationResponse> {
|
||||
this.elicitations.push(params);
|
||||
if (params.mode !== "form") {
|
||||
return { action: "decline" };
|
||||
}
|
||||
// Accept the first option of every choice field (skip the free-text one).
|
||||
const content: Record<string, string | string[]> = {};
|
||||
for (const [key, prop] of Object.entries(params.requestedSchema.properties ?? {})) {
|
||||
if (key === "customAnswer") continue;
|
||||
const p = prop as {
|
||||
oneOf?: Array<{ const: string }>;
|
||||
items?: { anyOf?: Array<{ const: string }> };
|
||||
};
|
||||
if (p.oneOf?.length) {
|
||||
content[key] = p.oneOf[0].const;
|
||||
} else if (p.items?.anyOf?.length) {
|
||||
content[key] = [p.items.anyOf[0].const];
|
||||
}
|
||||
}
|
||||
this.chosenAnswers = content;
|
||||
return { action: "accept", content };
|
||||
}
|
||||
|
||||
async sessionUpdate(params: SessionNotification): Promise<void> {
|
||||
console.error("RECEIVED", JSON.stringify(params, null, 4));
|
||||
|
||||
@@ -164,6 +198,9 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
|
||||
readTextFile: true,
|
||||
writeTextFile: true,
|
||||
},
|
||||
elicitation: {
|
||||
form: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -267,6 +304,60 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
|
||||
|
||||
expect(client.takeReceivedText()).toContain("Compacting...\n\nCompacting completed.");
|
||||
}, 60000);
|
||||
|
||||
// Regression guard for the SDK's AskUserQuestion routing. The built-in
|
||||
// AskUserQuestion tool is delivered to us through `canUseTool` (not the
|
||||
// interactive `onUserDialog` path), where we intercept it and render an ACP
|
||||
// form elicitation, returning the answer via `updatedInput`. If a future SDK
|
||||
// changes that routing — e.g. stops calling `canUseTool` for it, or no longer
|
||||
// reads answers back from `updatedInput` — this test fails: either no
|
||||
// elicitation arrives, the tool falls back to a permission prompt, or the
|
||||
// answer never reaches the model's reply.
|
||||
it("routes AskUserQuestion through ACP form elicitation and round-trips the answer", async () => {
|
||||
const { client, connection, newSessionResponse } = await setupTestSession(process.cwd());
|
||||
|
||||
await connection.prompt({
|
||||
prompt: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
"Use the AskUserQuestion tool right now to ask me to choose a favorite color. " +
|
||||
"Offer exactly two options: 'Red' and 'Blue'. Do not use any other tool and do " +
|
||||
"not ask in plain text. After I answer, reply with one short sentence naming the " +
|
||||
"color I picked.",
|
||||
},
|
||||
],
|
||||
sessionId: newSessionResponse.sessionId,
|
||||
});
|
||||
|
||||
// The tool surfaced as an ACP form elicitation...
|
||||
expect(client.elicitations.length).toBeGreaterThan(0);
|
||||
const elicitation = client.elicitations[0];
|
||||
expect(elicitation.mode).toBe("form");
|
||||
|
||||
// ...built by our converter (indexed field key + free-text "Other" field),
|
||||
// which confirms our interception path produced it rather than some other
|
||||
// mechanism.
|
||||
const properties =
|
||||
elicitation.mode === "form" ? Object.keys(elicitation.requestedSchema.properties ?? {}) : [];
|
||||
expect(properties).toContain("question_0");
|
||||
expect(properties).toContain("customAnswer");
|
||||
|
||||
// AskUserQuestion must NOT fall back to a generic permission prompt: no
|
||||
// permission request should have carried AskUserQuestion's `questions`.
|
||||
const fellBackToPermission = client.permissionToolInputs.some(
|
||||
(input) =>
|
||||
!!input &&
|
||||
typeof input === "object" &&
|
||||
Array.isArray((input as { questions?: unknown }).questions),
|
||||
);
|
||||
expect(fellBackToPermission).toBe(false);
|
||||
|
||||
// The chosen answer round-trips: the model's reply names the picked option.
|
||||
const picked = String(Object.values(client.chosenAnswers)[0] ?? "");
|
||||
expect(picked).not.toEqual("");
|
||||
expect(client.takeReceivedText().toLowerCase()).toContain(picked.toLowerCase());
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
describe("tool conversions", () => {
|
||||
|
||||
@@ -565,4 +565,51 @@ describe("createSession options merging", () => {
|
||||
await expect(agent.newSession({ cwd: process.cwd(), mcpServers: [] })).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("elicitation", () => {
|
||||
it("keeps AskUserQuestion disabled and omits callbacks without elicitation capability", async () => {
|
||||
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(capturedOptions!.disallowedTools).toContain("AskUserQuestion");
|
||||
expect(capturedOptions!.onElicitation).toBeUndefined();
|
||||
});
|
||||
|
||||
it("enables AskUserQuestion and wires the elicitation callback when form is supported", async () => {
|
||||
await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { elicitation: { form: {} } },
|
||||
});
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(capturedOptions!.disallowedTools).not.toContain("AskUserQuestion");
|
||||
expect(typeof capturedOptions!.onElicitation).toBe("function");
|
||||
});
|
||||
|
||||
it("wires callbacks for url-only elicitation but keeps AskUserQuestion disabled", async () => {
|
||||
await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { elicitation: { url: {} } },
|
||||
});
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(capturedOptions!.disallowedTools).toContain("AskUserQuestion");
|
||||
expect(typeof capturedOptions!.onElicitation).toBe("function");
|
||||
});
|
||||
|
||||
it("still merges user-provided disallowedTools when AskUserQuestion is enabled", async () => {
|
||||
await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { elicitation: { form: {} } },
|
||||
});
|
||||
await agent.newSession({
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: { claudeCode: { options: { disallowedTools: ["WebSearch"] } } },
|
||||
});
|
||||
|
||||
expect(capturedOptions!.disallowedTools).toContain("WebSearch");
|
||||
expect(capturedOptions!.disallowedTools).not.toContain("AskUserQuestion");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
309
src/tests/elicitation.test.ts
Normal file
309
src/tests/elicitation.test.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { CreateElicitationRequest, CreateElicitationResponse } from "@agentclientprotocol/sdk";
|
||||
import type { ElicitationRequest } from "@anthropic-ai/claude-agent-sdk";
|
||||
import {
|
||||
applyAskElicitationResponse,
|
||||
askUserQuestionsToCreateRequest,
|
||||
AskUserQuestion,
|
||||
createElicitationResponseToElicitResult,
|
||||
extractAskUserQuestions,
|
||||
mcpElicitationToCreateRequest,
|
||||
} from "../elicitation.js";
|
||||
|
||||
const SESSION_ID = "session-1";
|
||||
|
||||
/**
|
||||
* Build a question matching the SDK's (strict) AskUserQuestion schema while
|
||||
* keeping test sites terse. Option descriptions default to "" (the tool always
|
||||
* provides one); pass a description to exercise the "label — description" title.
|
||||
*/
|
||||
function mkQuestion(
|
||||
question: string,
|
||||
options: Array<{ label: string; description?: string }>,
|
||||
opts: { header?: string; multiSelect?: boolean } = {},
|
||||
): AskUserQuestion {
|
||||
return {
|
||||
question,
|
||||
header: opts.header ?? "",
|
||||
multiSelect: opts.multiSelect ?? false,
|
||||
options: options.map((o) => ({ label: o.label, description: o.description ?? "" })),
|
||||
} as AskUserQuestion;
|
||||
}
|
||||
|
||||
describe("mcpElicitationToCreateRequest", () => {
|
||||
it("maps a form-mode request, defaulting the schema type to object", () => {
|
||||
const request: ElicitationRequest = {
|
||||
serverName: "server",
|
||||
message: "Need your name",
|
||||
mode: "form",
|
||||
requestedSchema: { properties: { name: { type: "string" } } },
|
||||
};
|
||||
|
||||
const result = mcpElicitationToCreateRequest(request, SESSION_ID);
|
||||
|
||||
expect(result).toEqual({
|
||||
mode: "form",
|
||||
sessionId: SESSION_ID,
|
||||
message: "Need your name",
|
||||
requestedSchema: { type: "object", properties: { name: { type: "string" } } },
|
||||
});
|
||||
});
|
||||
|
||||
it("maps a url-mode request and preserves the elicitation id", () => {
|
||||
const request: ElicitationRequest = {
|
||||
serverName: "server",
|
||||
message: "Authorize",
|
||||
mode: "url",
|
||||
url: "https://example.com/auth",
|
||||
elicitationId: "el-123",
|
||||
};
|
||||
|
||||
expect(mcpElicitationToCreateRequest(request, SESSION_ID)).toEqual({
|
||||
mode: "url",
|
||||
sessionId: SESSION_ID,
|
||||
message: "Authorize",
|
||||
url: "https://example.com/auth",
|
||||
elicitationId: "el-123",
|
||||
});
|
||||
});
|
||||
|
||||
it("generates an elicitation id when a url request omits one", () => {
|
||||
const request: ElicitationRequest = {
|
||||
serverName: "server",
|
||||
message: "Authorize",
|
||||
mode: "url",
|
||||
url: "https://example.com/auth",
|
||||
};
|
||||
|
||||
const result = mcpElicitationToCreateRequest(request, SESSION_ID);
|
||||
expect(result?.mode).toBe("url");
|
||||
expect((result as { elicitationId: string }).elicitationId).toMatch(/.+/);
|
||||
});
|
||||
|
||||
it("returns null for a url request with no url", () => {
|
||||
const request: ElicitationRequest = {
|
||||
serverName: "server",
|
||||
message: "Authorize",
|
||||
mode: "url",
|
||||
};
|
||||
|
||||
expect(mcpElicitationToCreateRequest(request, SESSION_ID)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createElicitationResponseToElicitResult", () => {
|
||||
it("maps accept with content", () => {
|
||||
const response = { action: "accept", content: { name: "Ada" } } as CreateElicitationResponse;
|
||||
expect(createElicitationResponseToElicitResult(response)).toEqual({
|
||||
action: "accept",
|
||||
content: { name: "Ada" },
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults accept content to an empty object", () => {
|
||||
const response = { action: "accept" } as CreateElicitationResponse;
|
||||
expect(createElicitationResponseToElicitResult(response)).toEqual({
|
||||
action: "accept",
|
||||
content: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("maps decline and cancel", () => {
|
||||
expect(
|
||||
createElicitationResponseToElicitResult({ action: "decline" } as CreateElicitationResponse),
|
||||
).toEqual({ action: "decline" });
|
||||
expect(
|
||||
createElicitationResponseToElicitResult({ action: "cancel" } as CreateElicitationResponse),
|
||||
).toEqual({ action: "cancel" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractAskUserQuestions", () => {
|
||||
const question = mkQuestion("Which?", [{ label: "A" }, { label: "B" }]);
|
||||
|
||||
it("reads questions off the tool input", () => {
|
||||
expect(extractAskUserQuestions({ questions: [question] })).toEqual([question]);
|
||||
});
|
||||
|
||||
it("returns null when there are no questions", () => {
|
||||
expect(extractAskUserQuestions({})).toBeNull();
|
||||
expect(extractAskUserQuestions({ questions: [] })).toBeNull();
|
||||
});
|
||||
|
||||
it("filters out malformed questions (missing options, empty options, bad question)", () => {
|
||||
const input = {
|
||||
questions: [
|
||||
question,
|
||||
{ question: "missing options" },
|
||||
{ options: [] },
|
||||
{ options: [], question: "empty" },
|
||||
],
|
||||
} as unknown as Record<string, unknown>;
|
||||
expect(extractAskUserQuestions(input)).toEqual([question]);
|
||||
});
|
||||
|
||||
it("returns null when every question is filtered out", () => {
|
||||
const input = {
|
||||
questions: [{ question: "no options" }, { options: [{ label: "A" }] }],
|
||||
} as unknown as Record<string, unknown>;
|
||||
expect(extractAskUserQuestions(input)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("askUserQuestionsToCreateRequest", () => {
|
||||
it("keys a single-select question by a stable id and carries the prompt in message only", () => {
|
||||
const questions = [
|
||||
mkQuestion(
|
||||
"Which library?",
|
||||
[{ label: "date-fns", description: "Lightweight" }, { label: "luxon" }],
|
||||
{
|
||||
header: "Library",
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
const result = askUserQuestionsToCreateRequest(questions, SESSION_ID, "tool-1");
|
||||
|
||||
expect(result).toMatchObject({
|
||||
mode: "form",
|
||||
sessionId: SESSION_ID,
|
||||
toolCallId: "tool-1",
|
||||
message: "Which library?",
|
||||
});
|
||||
const schema = (result as Extract<CreateElicitationRequest, { mode: "form" }>).requestedSchema;
|
||||
// Nothing is required; the user can pick, type a custom answer, or skip.
|
||||
expect(schema.required).toBeUndefined();
|
||||
// Single question → no duplicated description (prompt is in `message`).
|
||||
expect(schema.properties?.["question_0"]).toEqual({
|
||||
type: "string",
|
||||
title: "Library",
|
||||
description: undefined,
|
||||
oneOf: [
|
||||
{ const: "date-fns", title: "date-fns — Lightweight" },
|
||||
{ const: "luxon", title: "luxon" },
|
||||
],
|
||||
});
|
||||
// The full question text is not used as a property key.
|
||||
expect(schema.properties?.["Which library?"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes an optional free-text custom-answer field", () => {
|
||||
const questions = [mkQuestion("Which?", [{ label: "A" }, { label: "B" }])];
|
||||
const schema = (
|
||||
askUserQuestionsToCreateRequest(questions, SESSION_ID, undefined) as Extract<
|
||||
CreateElicitationRequest,
|
||||
{ mode: "form" }
|
||||
>
|
||||
).requestedSchema;
|
||||
|
||||
expect(schema.properties?.["customAnswer"]).toMatchObject({ type: "string", title: "Other" });
|
||||
});
|
||||
|
||||
it("builds an array property for multi-select questions and includes per-field question text", () => {
|
||||
const questions = [
|
||||
mkQuestion("Pick one?", [{ label: "A" }, { label: "B" }]),
|
||||
mkQuestion("Which features?", [{ label: "auth" }, { label: "logging" }], {
|
||||
multiSelect: true,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = askUserQuestionsToCreateRequest(questions, SESSION_ID, undefined);
|
||||
const schema = (result as Extract<CreateElicitationRequest, { mode: "form" }>).requestedSchema;
|
||||
|
||||
expect(result).not.toHaveProperty("toolCallId");
|
||||
// Multiple questions → generic message, each field carries its own question.
|
||||
expect(result.message).toBe("Please answer the following questions.");
|
||||
expect(schema.properties?.["question_1"]).toEqual({
|
||||
type: "array",
|
||||
title: undefined,
|
||||
description: "Which features?",
|
||||
items: {
|
||||
anyOf: [
|
||||
{ const: "auth", title: "auth" },
|
||||
{ const: "logging", title: "logging" },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyAskElicitationResponse", () => {
|
||||
const questions = [
|
||||
mkQuestion("Single?", [{ label: "A" }, { label: "B" }]),
|
||||
mkQuestion("Multi?", [{ label: "X" }, { label: "Y" }], { multiSelect: true }),
|
||||
];
|
||||
const toolInput = { questions, metadata: { source: "test" } };
|
||||
|
||||
it("writes selected labels (keyed by indexed fields) into updatedInput on accept", () => {
|
||||
const response = {
|
||||
action: "accept",
|
||||
content: { question_0: "A", question_1: ["X", "Y"] },
|
||||
} as CreateElicitationResponse;
|
||||
|
||||
expect(applyAskElicitationResponse(response, toolInput, questions)).toEqual({
|
||||
action: "answered",
|
||||
updatedInput: {
|
||||
questions,
|
||||
metadata: { source: "test" },
|
||||
answers: { "Single?": "A", "Multi?": "X, Y" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("maps the free-text custom-answer field to the tool's response", () => {
|
||||
const response = {
|
||||
action: "accept",
|
||||
content: { question_0: "A", customAnswer: "something else entirely" },
|
||||
} as CreateElicitationResponse;
|
||||
|
||||
expect(applyAskElicitationResponse(response, toolInput, questions)).toEqual({
|
||||
action: "answered",
|
||||
updatedInput: {
|
||||
questions,
|
||||
metadata: { source: "test" },
|
||||
answers: { "Single?": "A" },
|
||||
response: "something else entirely",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores empty selections and blank custom answers", () => {
|
||||
const response = {
|
||||
action: "accept",
|
||||
content: { question_1: [], customAnswer: " " },
|
||||
} as CreateElicitationResponse;
|
||||
|
||||
expect(applyAskElicitationResponse(response, toolInput, questions)).toEqual({
|
||||
action: "answered",
|
||||
updatedInput: { questions, metadata: { source: "test" }, answers: {} },
|
||||
});
|
||||
});
|
||||
|
||||
it("treats decline as answered with no answers", () => {
|
||||
const response = { action: "decline" } as CreateElicitationResponse;
|
||||
expect(applyAskElicitationResponse(response, toolInput, questions)).toEqual({
|
||||
action: "answered",
|
||||
updatedInput: { questions, metadata: { source: "test" }, answers: {} },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns cancel on cancel", () => {
|
||||
const response = { action: "cancel" } as CreateElicitationResponse;
|
||||
expect(applyAskElicitationResponse(response, toolInput, questions)).toEqual({
|
||||
action: "cancel",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits answers for questions the user left unanswered", () => {
|
||||
const response = {
|
||||
action: "accept",
|
||||
content: { question_0: "B" },
|
||||
} as CreateElicitationResponse;
|
||||
|
||||
const result = applyAskElicitationResponse(response, toolInput, questions);
|
||||
expect(result.action).toBe("answered");
|
||||
expect((result as Extract<typeof result, { action: "answered" }>).updatedInput.answers).toEqual(
|
||||
{ "Single?": "B" },
|
||||
);
|
||||
});
|
||||
});
|
||||
19
src/tools.ts
19
src/tools.ts
@@ -8,6 +8,7 @@ import {
|
||||
import { HookCallback } from "@anthropic-ai/claude-agent-sdk";
|
||||
import {
|
||||
AgentInput,
|
||||
AskUserQuestionInput,
|
||||
BashInput,
|
||||
FileEditInput,
|
||||
FileReadInput,
|
||||
@@ -415,6 +416,24 @@ export function toolInfoFromToolUse(
|
||||
};
|
||||
}
|
||||
|
||||
case "AskUserQuestion": {
|
||||
const input = toolUse.input as Partial<AskUserQuestionInput> | undefined;
|
||||
const questions = Array.isArray(input?.questions) ? input.questions : [];
|
||||
return {
|
||||
title:
|
||||
questions.length === 1 && questions[0]?.question
|
||||
? questions[0].question
|
||||
: "Asking for your input",
|
||||
kind: "other",
|
||||
content: questions
|
||||
.filter((q) => typeof q?.question === "string")
|
||||
.map((q) => ({
|
||||
type: "content" as const,
|
||||
content: { type: "text" as const, text: q.question },
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
case "Other": {
|
||||
const input = toolUse.input;
|
||||
let output;
|
||||
|
||||
Reference in New Issue
Block a user