mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-12 15:10:59 -07:00
516 lines
16 KiB
TypeScript
516 lines
16 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2026 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { AskUserTool, isCompletedAskUserTool } from './ask-user.js';
|
|
import { QuestionType, type Question } from '../confirmation-bus/types.js';
|
|
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
|
import { ToolConfirmationOutcome } from './tools.js';
|
|
import { ToolErrorType } from './tool-error.js';
|
|
import { ASK_USER_DISPLAY_NAME } from './tool-names.js';
|
|
|
|
describe('AskUserTool Helpers', () => {
|
|
describe('isCompletedAskUserTool', () => {
|
|
it('returns false for non-AskUser tools', () => {
|
|
expect(isCompletedAskUserTool('other-tool', 'Success')).toBe(false);
|
|
});
|
|
|
|
it('returns true for Success status', () => {
|
|
expect(isCompletedAskUserTool(ASK_USER_DISPLAY_NAME, 'Success')).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it('returns true for Error status', () => {
|
|
expect(isCompletedAskUserTool(ASK_USER_DISPLAY_NAME, 'Error')).toBe(true);
|
|
});
|
|
|
|
it('returns true for Canceled status', () => {
|
|
expect(isCompletedAskUserTool(ASK_USER_DISPLAY_NAME, 'Canceled')).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it('returns false for in-progress statuses', () => {
|
|
expect(isCompletedAskUserTool(ASK_USER_DISPLAY_NAME, 'Executing')).toBe(
|
|
false,
|
|
);
|
|
expect(isCompletedAskUserTool(ASK_USER_DISPLAY_NAME, 'Pending')).toBe(
|
|
false,
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('AskUserTool', () => {
|
|
let mockMessageBus: MessageBus;
|
|
let tool: AskUserTool;
|
|
|
|
beforeEach(() => {
|
|
mockMessageBus = {
|
|
publish: vi.fn().mockResolvedValue(undefined),
|
|
subscribe: vi.fn(),
|
|
unsubscribe: vi.fn(),
|
|
} as unknown as MessageBus;
|
|
tool = new AskUserTool(mockMessageBus);
|
|
});
|
|
|
|
it('should have correct metadata', () => {
|
|
expect(tool.name).toBe('ask_user');
|
|
expect(tool.displayName).toBe('Ask User');
|
|
});
|
|
|
|
describe('validateToolParams', () => {
|
|
it('should return error if questions is missing', () => {
|
|
// @ts-expect-error - Intentionally invalid params
|
|
const result = tool.validateToolParams({});
|
|
expect(result).toContain("must have required property 'questions'");
|
|
});
|
|
|
|
it('should return error if questions array is empty', () => {
|
|
const result = tool.validateToolParams({ questions: [] });
|
|
expect(result).toContain('must NOT have fewer than 1 items');
|
|
});
|
|
|
|
it('should return error if questions array exceeds max', () => {
|
|
const questions = Array(5).fill({
|
|
question: 'Test?',
|
|
header: 'Test',
|
|
type: QuestionType.CHOICE,
|
|
options: [
|
|
{ label: 'A', description: 'A' },
|
|
{ label: 'B', description: 'B' },
|
|
],
|
|
});
|
|
const result = tool.validateToolParams({ questions });
|
|
expect(result).toContain('must NOT have more than 4 items');
|
|
});
|
|
|
|
it('should return error if question field is missing', () => {
|
|
const result = tool.validateToolParams({
|
|
questions: [{ header: 'Test' } as unknown as Question],
|
|
});
|
|
expect(result).toContain("must have required property 'question'");
|
|
});
|
|
|
|
it('should return error if header field is missing', () => {
|
|
const result = tool.validateToolParams({
|
|
questions: [{ question: 'Test?' } as unknown as Question],
|
|
});
|
|
expect(result).toContain("must have required property 'header'");
|
|
});
|
|
|
|
it('should return error if options has fewer than 2 items', () => {
|
|
const result = tool.validateToolParams({
|
|
questions: [
|
|
{
|
|
question: 'Test?',
|
|
header: 'Test',
|
|
type: QuestionType.CHOICE,
|
|
options: [{ label: 'A', description: 'A' }],
|
|
},
|
|
],
|
|
});
|
|
expect(result).toContain(
|
|
"type='choice' requires 'options' array with 2-4 items",
|
|
);
|
|
});
|
|
|
|
it('should return error if options has more than 4 items', () => {
|
|
const result = tool.validateToolParams({
|
|
questions: [
|
|
{
|
|
question: 'Test?',
|
|
header: 'Test',
|
|
type: QuestionType.CHOICE,
|
|
options: [
|
|
{ label: 'A', description: 'A' },
|
|
{ label: 'B', description: 'B' },
|
|
{ label: 'C', description: 'C' },
|
|
{ label: 'D', description: 'D' },
|
|
{ label: 'E', description: 'E' },
|
|
],
|
|
},
|
|
],
|
|
});
|
|
expect(result).toContain("'options' array must have at most 4 items");
|
|
});
|
|
|
|
it('should return null for valid params', () => {
|
|
const result = tool.validateToolParams({
|
|
questions: [
|
|
{
|
|
question: 'Which approach?',
|
|
header: 'Approach',
|
|
type: QuestionType.CHOICE,
|
|
options: [
|
|
{ label: 'A', description: 'Option A' },
|
|
{ label: 'B', description: 'Option B' },
|
|
],
|
|
},
|
|
],
|
|
});
|
|
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 missing', () => {
|
|
const result = tool.validateToolParams({
|
|
questions: [
|
|
{
|
|
question: 'Pick one?',
|
|
header: 'Choice',
|
|
} as unknown as Question,
|
|
],
|
|
});
|
|
expect(result).toContain("must have required property 'type'");
|
|
});
|
|
|
|
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 accept placeholder for choice type', () => {
|
|
const result = tool.validateToolParams({
|
|
questions: [
|
|
{
|
|
question: 'Which language?',
|
|
header: 'Language',
|
|
type: QuestionType.CHOICE,
|
|
options: [
|
|
{ label: 'TypeScript', description: 'Typed JavaScript' },
|
|
{ label: 'JavaScript', description: 'Dynamic language' },
|
|
],
|
|
placeholder: 'Type another language...',
|
|
},
|
|
],
|
|
});
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should return error if option has empty label', () => {
|
|
const result = tool.validateToolParams({
|
|
questions: [
|
|
{
|
|
question: 'Pick one?',
|
|
header: 'Choice',
|
|
type: QuestionType.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',
|
|
type: QuestionType.CHOICE,
|
|
options: [
|
|
{ label: 'A' } as { label: string; description: string },
|
|
{ label: 'B', description: 'Option B' },
|
|
],
|
|
},
|
|
],
|
|
});
|
|
expect(result).toContain("must have required property 'description'");
|
|
});
|
|
});
|
|
|
|
describe('validateBuildAndExecute', () => {
|
|
it('should hide validation errors from returnDisplay', async () => {
|
|
const params = {
|
|
questions: [],
|
|
};
|
|
|
|
const result = await tool.validateBuildAndExecute(
|
|
params,
|
|
new AbortController().signal,
|
|
);
|
|
|
|
expect(result.error).toBeDefined();
|
|
expect(result.error?.type).toBe(ToolErrorType.INVALID_TOOL_PARAMS);
|
|
expect(result.returnDisplay).toBe('');
|
|
});
|
|
|
|
it('should NOT hide non-validation errors (if any were to occur)', async () => {
|
|
const validateParamsSpy = vi
|
|
.spyOn(tool, 'validateToolParams')
|
|
.mockReturnValue(null);
|
|
|
|
const params = {
|
|
questions: [
|
|
{ question: 'Valid?', header: 'Valid', type: QuestionType.TEXT },
|
|
],
|
|
};
|
|
|
|
const mockInvocation = {
|
|
execute: vi.fn().mockRejectedValue(new Error('Some execution error')),
|
|
params,
|
|
getDescription: vi.fn().mockReturnValue(''),
|
|
toolLocations: vi.fn().mockReturnValue([]),
|
|
shouldConfirmExecute: vi.fn().mockResolvedValue(false),
|
|
};
|
|
|
|
const buildSpy = vi.spyOn(tool, 'build').mockReturnValue(mockInvocation);
|
|
|
|
const result = await tool.validateBuildAndExecute(
|
|
params,
|
|
new AbortController().signal,
|
|
);
|
|
|
|
expect(result.error).toBeDefined();
|
|
expect(result.error?.type).toBe(ToolErrorType.EXECUTION_FAILED);
|
|
expect(result.returnDisplay).toBe('Some execution error');
|
|
|
|
buildSpy.mockRestore();
|
|
validateParamsSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('shouldConfirmExecute', () => {
|
|
it('should return confirmation details with normalized questions', async () => {
|
|
const questions: Question[] = [
|
|
{
|
|
question: 'How should we proceed with this task?',
|
|
header: 'Approach',
|
|
type: QuestionType.CHOICE,
|
|
options: [
|
|
{
|
|
label: 'Quick fix (Recommended)',
|
|
description:
|
|
'Apply the most direct solution to resolve the immediate issue.',
|
|
},
|
|
{
|
|
label: 'Comprehensive refactor',
|
|
description:
|
|
'Restructure the affected code for better long-term maintainability.',
|
|
},
|
|
],
|
|
multiSelect: false,
|
|
},
|
|
];
|
|
|
|
const invocation = tool.build({ questions });
|
|
const details = await invocation.shouldConfirmExecute(
|
|
new AbortController().signal,
|
|
);
|
|
|
|
expect(details).not.toBe(false);
|
|
if (details && details.type === 'ask_user') {
|
|
expect(details.title).toBe('Ask User');
|
|
expect(details.questions).toEqual(questions);
|
|
expect(typeof details.onConfirm).toBe('function');
|
|
} else {
|
|
// Type guard for TypeScript
|
|
expect(details).toBeTruthy();
|
|
}
|
|
});
|
|
|
|
it('should use provided question type', async () => {
|
|
const questions: Question[] = [
|
|
{
|
|
question: 'Which approach?',
|
|
header: 'Approach',
|
|
type: QuestionType.CHOICE,
|
|
options: [
|
|
{ label: 'Option A', description: 'First option' },
|
|
{ label: 'Option B', description: 'Second option' },
|
|
],
|
|
},
|
|
];
|
|
|
|
const invocation = tool.build({ questions });
|
|
const details = await invocation.shouldConfirmExecute(
|
|
new AbortController().signal,
|
|
);
|
|
|
|
if (details && details.type === 'ask_user') {
|
|
expect(details.questions[0].type).toBe(QuestionType.CHOICE);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('execute', () => {
|
|
it('should return user answers after confirmation', async () => {
|
|
const questions: Question[] = [
|
|
{
|
|
question: 'How should we proceed with this task?',
|
|
header: 'Approach',
|
|
type: QuestionType.CHOICE,
|
|
options: [
|
|
{
|
|
label: 'Quick fix (Recommended)',
|
|
description:
|
|
'Apply the most direct solution to resolve the immediate issue.',
|
|
},
|
|
{
|
|
label: 'Comprehensive refactor',
|
|
description:
|
|
'Restructure the affected code for better long-term maintainability.',
|
|
},
|
|
],
|
|
multiSelect: false,
|
|
},
|
|
];
|
|
|
|
const invocation = tool.build({ questions });
|
|
const details = await invocation.shouldConfirmExecute(
|
|
new AbortController().signal,
|
|
);
|
|
|
|
// Simulate confirmation with answers
|
|
if (details && 'onConfirm' in details) {
|
|
const answers = { '0': 'Quick fix (Recommended)' };
|
|
await details.onConfirm(ToolConfirmationOutcome.ProceedOnce, {
|
|
answers,
|
|
});
|
|
}
|
|
|
|
const result = await invocation.execute(new AbortController().signal);
|
|
expect(result.returnDisplay).toContain('User answered:');
|
|
expect(result.returnDisplay).toContain(
|
|
' Approach → Quick fix (Recommended)',
|
|
);
|
|
expect(JSON.parse(result.llmContent as string)).toEqual({
|
|
answers: { '0': 'Quick fix (Recommended)' },
|
|
});
|
|
expect(result.data).toEqual({
|
|
ask_user: {
|
|
question_types: [QuestionType.CHOICE],
|
|
dismissed: false,
|
|
empty_submission: false,
|
|
answer_count: 1,
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should display message when user submits without answering', async () => {
|
|
const questions: Question[] = [
|
|
{
|
|
question: 'Which approach?',
|
|
header: 'Approach',
|
|
type: QuestionType.CHOICE,
|
|
options: [
|
|
{ label: 'Option A', description: 'First option' },
|
|
{ label: 'Option B', description: 'Second option' },
|
|
],
|
|
},
|
|
];
|
|
|
|
const invocation = tool.build({ questions });
|
|
const details = await invocation.shouldConfirmExecute(
|
|
new AbortController().signal,
|
|
);
|
|
|
|
// Simulate confirmation with empty answers
|
|
if (details && 'onConfirm' in details) {
|
|
await details.onConfirm(ToolConfirmationOutcome.ProceedOnce, {
|
|
answers: {},
|
|
});
|
|
}
|
|
|
|
const result = await invocation.execute(new AbortController().signal);
|
|
expect(result.returnDisplay).toBe(
|
|
'User submitted without answering questions.',
|
|
);
|
|
expect(JSON.parse(result.llmContent as string)).toEqual({ answers: {} });
|
|
expect(result.data).toEqual({
|
|
ask_user: {
|
|
question_types: [QuestionType.CHOICE],
|
|
dismissed: false,
|
|
empty_submission: true,
|
|
answer_count: 0,
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should handle cancellation', async () => {
|
|
const invocation = tool.build({
|
|
questions: [
|
|
{
|
|
question: 'Which sections of the documentation should be updated?',
|
|
header: 'Docs',
|
|
type: QuestionType.CHOICE,
|
|
options: [
|
|
{
|
|
label: 'User Guide',
|
|
description: 'Update the main user-facing documentation.',
|
|
},
|
|
{
|
|
label: 'API Reference',
|
|
description: 'Update the detailed API documentation.',
|
|
},
|
|
],
|
|
multiSelect: true,
|
|
},
|
|
],
|
|
});
|
|
|
|
const details = await invocation.shouldConfirmExecute(
|
|
new AbortController().signal,
|
|
);
|
|
|
|
// Simulate cancellation
|
|
if (details && 'onConfirm' in details) {
|
|
await details.onConfirm(ToolConfirmationOutcome.Cancel);
|
|
}
|
|
|
|
const result = await invocation.execute(new AbortController().signal);
|
|
expect(result.returnDisplay).toBe('User dismissed dialog');
|
|
expect(result.llmContent).toBe(
|
|
'User dismissed ask_user dialog without answering.',
|
|
);
|
|
expect(result.data).toEqual({
|
|
ask_user: {
|
|
question_types: [QuestionType.CHOICE],
|
|
dismissed: true,
|
|
},
|
|
});
|
|
});
|
|
});
|
|
});
|