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:
Ben Brandt
2026-06-09 09:59:06 +02:00
committed by GitHub
parent 0dbccf588f
commit 12bd2762d9
6 changed files with 833 additions and 3 deletions

View File

@@ -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
View 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" };
}

View File

@@ -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", () => {

View File

@@ -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");
});
});
});

View 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" },
);
});
});

View File

@@ -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;