feat: wire up AskUserTool with dialog (#17411)

This commit is contained in:
Jack Wotherspoon
2026-01-27 13:30:44 -05:00
committed by GitHub
parent 246a6d10c3
commit 36d618f72a
11 changed files with 441 additions and 44 deletions

View File

@@ -32,6 +32,7 @@ import { WriteFileTool } from '../tools/write-file.js';
import { WebFetchTool } from '../tools/web-fetch.js';
import { MemoryTool, setGeminiMdFilename } from '../tools/memoryTool.js';
import { WebSearchTool } from '../tools/web-search.js';
import { AskUserTool } from '../tools/ask-user.js';
import { GeminiClient } from '../core/client.js';
import { BaseLlmClient } from '../core/baseLlmClient.js';
import type { HookDefinition, HookEventName } from '../hooks/types.js';
@@ -2005,6 +2006,7 @@ export class Config {
registerCoreTool(ShellTool, this);
registerCoreTool(MemoryTool);
registerCoreTool(WebSearchTool, this);
registerCoreTool(AskUserTool);
if (this.getUseWriteTodos()) {
registerCoreTool(WriteTodosTool);
}

View File

@@ -134,11 +134,11 @@ export interface Question {
header: string;
/** Question type: 'choice' renders selectable options, 'text' renders free-form input, 'yesno' renders a binary Yes/No choice. Defaults to 'choice'. */
type?: QuestionType;
/** Available choices. Required when type is 'choice' (or omitted), ignored for 'text'. */
/** Selectable choices. REQUIRED when type='choice' or omitted. IGNORED for 'text' and 'yesno'. */
options?: QuestionOption[];
/** Allow multiple selections. Only applies to 'choice' type. */
/** Allow multiple selections. Only applies when type='choice'. */
multiSelect?: boolean;
/** Placeholder hint text for 'text' type input field. */
/** Placeholder hint text. Only applies when type='text'. */
placeholder?: string;
}
@@ -152,6 +152,8 @@ export interface AskUserResponse {
type: MessageBusType.ASK_USER_RESPONSE;
correlationId: string;
answers: { [questionIndex: string]: string };
/** When true, indicates the user cancelled the dialog without submitting answers */
cancelled?: boolean;
}
export type Message =

View File

@@ -87,7 +87,9 @@ describe('AskUserTool', () => {
},
],
});
expect(result).toContain('must NOT have fewer than 2 items');
expect(result).toContain(
"type='choice' requires 'options' array with 2-4 items",
);
});
it('should return error if options has more than 4 items', () => {
@@ -106,7 +108,7 @@ describe('AskUserTool', () => {
},
],
});
expect(result).toContain('must NOT have more than 4 items');
expect(result).toContain("'options' array must have at most 4 items");
});
it('should return null for valid params', () => {
@@ -124,6 +126,91 @@ describe('AskUserTool', () => {
});
expect(result).toBeNull();
});
it('should return error if choice type has no options', () => {
const result = tool.validateToolParams({
questions: [
{
question: 'Pick one?',
header: 'Choice',
type: QuestionType.CHOICE,
},
],
});
expect(result).toContain("type='choice' requires 'options'");
});
it('should return error if type is omitted and options missing (defaults to choice)', () => {
const result = tool.validateToolParams({
questions: [
{
question: 'Pick one?',
header: 'Choice',
// type omitted, defaults to 'choice'
// options missing
},
],
});
expect(result).toContain("type='choice' requires 'options'");
});
it('should accept text type without options', () => {
const result = tool.validateToolParams({
questions: [
{
question: 'Enter your name?',
header: 'Name',
type: QuestionType.TEXT,
},
],
});
expect(result).toBeNull();
});
it('should accept yesno type without options', () => {
const result = tool.validateToolParams({
questions: [
{
question: 'Do you want to proceed?',
header: 'Confirm',
type: QuestionType.YESNO,
},
],
});
expect(result).toBeNull();
});
it('should return error if option has empty label', () => {
const result = tool.validateToolParams({
questions: [
{
question: 'Pick one?',
header: 'Choice',
options: [
{ label: '', description: 'Empty label' },
{ label: 'B', description: 'Option B' },
],
},
],
});
expect(result).toContain("'label' is required");
});
it('should return error if option is missing description', () => {
const result = tool.validateToolParams({
questions: [
{
question: 'Pick one?',
header: 'Choice',
options: [
{ label: 'A' } as { label: string; description: string },
{ label: 'B', description: 'Option B' },
],
},
],
});
expect(result).toContain("must have required property 'description'");
});
});
it('should publish ASK_USER_REQUEST and wait for response', async () => {
@@ -195,6 +282,46 @@ describe('AskUserTool', () => {
expect(JSON.parse(result.llmContent as string)).toEqual({ answers });
});
it('should display message when user submits without answering', async () => {
const questions = [
{
question: 'Which approach?',
header: 'Approach',
options: [
{ label: 'Option A', description: 'First option' },
{ label: 'Option B', description: 'Second option' },
],
},
];
const invocation = tool.build({ questions });
const executePromise = invocation.execute(new AbortController().signal);
// Get the correlation ID from the published message
const publishCall = vi.mocked(mockMessageBus.publish).mock.calls[0][0] as {
correlationId: string;
};
const correlationId = publishCall.correlationId;
// Simulate response with empty answers
const subscribeCall = vi
.mocked(mockMessageBus.subscribe)
.mock.calls.find((call) => call[0] === MessageBusType.ASK_USER_RESPONSE);
const handler = subscribeCall![1];
handler({
type: MessageBusType.ASK_USER_RESPONSE,
correlationId,
answers: {},
});
const result = await executePromise;
expect(result.returnDisplay).toBe(
'User submitted without answering questions.',
);
expect(JSON.parse(result.llmContent as string)).toEqual({ answers: {} });
});
it('should handle cancellation', async () => {
const invocation = tool.build({
questions: [

View File

@@ -20,7 +20,7 @@ import {
type AskUserResponse,
} from '../confirmation-bus/types.js';
import { randomUUID } from 'node:crypto';
import { ASK_USER_TOOL_NAME } from './tool-names.js';
import { ASK_USER_TOOL_NAME, ASK_USER_DISPLAY_NAME } from './tool-names.js';
export interface AskUserParams {
questions: Question[];
@@ -33,7 +33,7 @@ export class AskUserTool extends BaseDeclarativeTool<
constructor(messageBus: MessageBus) {
super(
ASK_USER_TOOL_NAME,
'Ask User',
ASK_USER_DISPLAY_NAME,
'Ask the user one or more questions to gather preferences, clarify requirements, or make decisions.',
Kind.Communicate,
{
@@ -62,15 +62,14 @@ export class AskUserTool extends BaseDeclarativeTool<
type: {
type: 'string',
enum: ['choice', 'text', 'yesno'],
default: 'choice',
description:
"Question type. 'choice' (default) shows selectable options, 'text' shows a free-form text input, 'yesno' shows a binary Yes/No choice.",
"Question type: 'choice' (default) for multiple-choice with options, 'text' for free-form input, 'yesno' for Yes/No confirmation.",
},
options: {
type: 'array',
description:
"Required for 'choice' type, ignored for 'text' and 'yesno'. The available choices (2-4 options). Do NOT include an 'Other' option - one is automatically added for 'choice' type.",
minItems: 2,
maxItems: 4,
"The selectable choices for 'choice' type questions. Provide 2-4 options. An 'Other' option is automatically added. Not needed for 'text' or 'yesno' types.",
items: {
type: 'object',
required: ['label', 'description'],
@@ -78,12 +77,12 @@ export class AskUserTool extends BaseDeclarativeTool<
label: {
type: 'string',
description:
'The display text for this option that the user will see and select. Should be concise (1-5 words) and clearly describe the choice.',
'The display text for this option (1-5 words). Example: "OAuth 2.0"',
},
description: {
type: 'string',
description:
'Explanation of what this option means or what will happen if chosen. Useful for providing context about trade-offs or implications.',
'Brief explanation of this option. Example: "Industry standard, supports SSO"',
},
},
},
@@ -91,12 +90,12 @@ export class AskUserTool extends BaseDeclarativeTool<
multiSelect: {
type: 'boolean',
description:
"Only applies to 'choice' type. Set to true to allow multiple selections.",
"Only applies when type='choice'. Set to true to allow selecting multiple options.",
},
placeholder: {
type: 'string',
description:
"Optional hint text for 'text' type input field.",
"Only applies when type='text'. Hint text shown in the input field.",
},
},
},
@@ -107,6 +106,51 @@ export class AskUserTool extends BaseDeclarativeTool<
);
}
protected override validateToolParamValues(
params: AskUserParams,
): string | null {
if (!params.questions || params.questions.length === 0) {
return 'At least one question is required.';
}
for (let i = 0; i < params.questions.length; i++) {
const q = params.questions[i];
const questionType = q.type ?? QuestionType.CHOICE;
// Validate that 'choice' type has options
if (questionType === QuestionType.CHOICE) {
if (!q.options || q.options.length < 2) {
return `Question ${i + 1}: type='choice' requires 'options' array with 2-4 items.`;
}
if (q.options.length > 4) {
return `Question ${i + 1}: 'options' array must have at most 4 items.`;
}
}
// Validate option structure if provided
if (q.options) {
for (let j = 0; j < q.options.length; j++) {
const opt = q.options[j];
if (
!opt.label ||
typeof opt.label !== 'string' ||
!opt.label.trim()
) {
return `Question ${i + 1}, option ${j + 1}: 'label' is required and must be a non-empty string.`;
}
if (
opt.description === undefined ||
typeof opt.description !== 'string'
) {
return `Question ${i + 1}, option ${j + 1}: 'description' is required and must be a string.`;
}
}
}
}
return null;
}
protected createInvocation(
params: AskUserParams,
messageBus: MessageBus,
@@ -148,16 +192,28 @@ export class AskUserInvocation extends BaseToolInvocation<
if (response.correlationId === correlationId) {
cleanup();
// Build formatted key-value display
const formattedAnswers = Object.entries(response.answers)
.map(([index, answer]) => {
const question = this.params.questions[parseInt(index, 10)];
const category = question?.header ?? `Q${index}`;
return ` ${category}${answer}`;
})
.join('\n');
// Handle user cancellation
if (response.cancelled) {
resolve({
llmContent: 'User dismissed ask user dialog without answering.',
returnDisplay: 'User dismissed dialog',
});
return;
}
const returnDisplay = `User answered:\n${formattedAnswers}`;
// Build formatted key-value display
const answerEntries = Object.entries(response.answers);
const hasAnswers = answerEntries.length > 0;
const returnDisplay = hasAnswers
? `**User answered:**\n${answerEntries
.map(([index, answer]) => {
const question = this.params.questions[parseInt(index, 10)];
const category = question?.header ?? `Q${index}`;
return ` ${category}${answer}`;
})
.join('\n')}`
: 'User submitted without answering questions.';
resolve({
llmContent: JSON.stringify({ answers: response.answers }),

View File

@@ -24,6 +24,7 @@ export const GET_INTERNAL_DOCS_TOOL_NAME = 'get_internal_docs';
export const ACTIVATE_SKILL_TOOL_NAME = 'activate_skill';
export const EDIT_TOOL_NAMES = new Set([EDIT_TOOL_NAME, WRITE_FILE_TOOL_NAME]);
export const ASK_USER_TOOL_NAME = 'ask_user';
export const ASK_USER_DISPLAY_NAME = 'Ask User';
/** Prefix used for tools discovered via the toolDiscoveryCommand. */
export const DISCOVERED_TOOL_PREFIX = 'discovered_tool_';