mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 21:03:05 -07:00
feat: wire up AskUserTool with dialog (#17411)
This commit is contained in:
@@ -26,6 +26,7 @@ import {
|
||||
} from '../ui/contexts/UIActionsContext.js';
|
||||
import { type HistoryItemToolGroup, StreamingState } from '../ui/types.js';
|
||||
import { ToolActionsProvider } from '../ui/contexts/ToolActionsContext.js';
|
||||
import { AskUserActionsProvider } from '../ui/contexts/AskUserActionsContext.js';
|
||||
|
||||
import { makeFakeConfig, type Config } from '@google/gemini-cli-core';
|
||||
import { FakePersistentState } from './persistentStateFake.js';
|
||||
@@ -300,20 +301,28 @@ export const renderWithProviders = (
|
||||
config={config}
|
||||
toolCalls={allToolCalls}
|
||||
>
|
||||
<KeypressProvider>
|
||||
<MouseProvider mouseEventsEnabled={mouseEventsEnabled}>
|
||||
<ScrollProvider>
|
||||
<Box
|
||||
width={terminalWidth}
|
||||
flexShrink={0}
|
||||
flexGrow={0}
|
||||
flexDirection="column"
|
||||
>
|
||||
{component}
|
||||
</Box>
|
||||
</ScrollProvider>
|
||||
</MouseProvider>
|
||||
</KeypressProvider>
|
||||
<AskUserActionsProvider
|
||||
request={null}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
>
|
||||
<KeypressProvider>
|
||||
<MouseProvider
|
||||
mouseEventsEnabled={mouseEventsEnabled}
|
||||
>
|
||||
<ScrollProvider>
|
||||
<Box
|
||||
width={terminalWidth}
|
||||
flexShrink={0}
|
||||
flexGrow={0}
|
||||
flexDirection="column"
|
||||
>
|
||||
{component}
|
||||
</Box>
|
||||
</ScrollProvider>
|
||||
</MouseProvider>
|
||||
</KeypressProvider>
|
||||
</AskUserActionsProvider>
|
||||
</ToolActionsProvider>
|
||||
</UIActionsContext.Provider>
|
||||
</StreamingContext.Provider>
|
||||
|
||||
@@ -30,6 +30,10 @@ import {
|
||||
} from './types.js';
|
||||
import { MessageType, StreamingState } from './types.js';
|
||||
import { ToolActionsProvider } from './contexts/ToolActionsContext.js';
|
||||
import {
|
||||
AskUserActionsProvider,
|
||||
type AskUserState,
|
||||
} from './contexts/AskUserActionsContext.js';
|
||||
import {
|
||||
type EditorType,
|
||||
type Config,
|
||||
@@ -63,6 +67,8 @@ import {
|
||||
SessionStartSource,
|
||||
SessionEndReason,
|
||||
generateSummary,
|
||||
MessageBusType,
|
||||
type AskUserRequest,
|
||||
type AgentsDiscoveredPayload,
|
||||
ChangeAuthRequestedError,
|
||||
} from '@google/gemini-cli-core';
|
||||
@@ -282,6 +288,11 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
AgentDefinition | undefined
|
||||
>();
|
||||
|
||||
// AskUser dialog state
|
||||
const [askUserRequest, setAskUserRequest] = useState<AskUserState | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const openAgentConfigDialog = useCallback(
|
||||
(name: string, displayName: string, definition: AgentDefinition) => {
|
||||
setSelectedAgentName(name);
|
||||
@@ -299,6 +310,56 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
setSelectedAgentDefinition(undefined);
|
||||
}, []);
|
||||
|
||||
// Subscribe to ASK_USER_REQUEST messages from the message bus
|
||||
useEffect(() => {
|
||||
const messageBus = config.getMessageBus();
|
||||
|
||||
const handler = (msg: AskUserRequest) => {
|
||||
setAskUserRequest({
|
||||
questions: msg.questions,
|
||||
correlationId: msg.correlationId,
|
||||
});
|
||||
};
|
||||
|
||||
messageBus.subscribe(MessageBusType.ASK_USER_REQUEST, handler);
|
||||
|
||||
return () => {
|
||||
messageBus.unsubscribe(MessageBusType.ASK_USER_REQUEST, handler);
|
||||
};
|
||||
}, [config]);
|
||||
|
||||
// Handler to submit ask_user answers
|
||||
const handleAskUserSubmit = useCallback(
|
||||
async (answers: { [questionIndex: string]: string }) => {
|
||||
if (!askUserRequest) return;
|
||||
|
||||
const messageBus = config.getMessageBus();
|
||||
await messageBus.publish({
|
||||
type: MessageBusType.ASK_USER_RESPONSE,
|
||||
correlationId: askUserRequest.correlationId,
|
||||
answers,
|
||||
});
|
||||
|
||||
setAskUserRequest(null);
|
||||
},
|
||||
[config, askUserRequest],
|
||||
);
|
||||
|
||||
// Handler to cancel ask_user dialog
|
||||
const handleAskUserCancel = useCallback(async () => {
|
||||
if (!askUserRequest) return;
|
||||
|
||||
const messageBus = config.getMessageBus();
|
||||
await messageBus.publish({
|
||||
type: MessageBusType.ASK_USER_RESPONSE,
|
||||
correlationId: askUserRequest.correlationId,
|
||||
answers: {},
|
||||
cancelled: true,
|
||||
});
|
||||
|
||||
setAskUserRequest(null);
|
||||
}, [config, askUserRequest]);
|
||||
|
||||
const toggleDebugProfiler = useCallback(
|
||||
() => setShowDebugProfiler((prev) => !prev),
|
||||
[],
|
||||
@@ -1355,6 +1416,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.QUIT](key)) {
|
||||
// Skip when ask_user dialog is open (use Esc to cancel instead)
|
||||
if (askUserRequest) {
|
||||
return;
|
||||
}
|
||||
// If the user presses Ctrl+C, we want to cancel any ongoing requests.
|
||||
// This should happen regardless of the count.
|
||||
cancelOngoingRequest?.();
|
||||
@@ -1442,6 +1507,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
setCtrlDPressCount,
|
||||
handleSlashCommand,
|
||||
cancelOngoingRequest,
|
||||
askUserRequest,
|
||||
activePtyId,
|
||||
embeddedShellFocused,
|
||||
settings.merged.general.debugKeystrokeLogging,
|
||||
@@ -1554,6 +1620,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
const nightly = props.version.includes('nightly');
|
||||
|
||||
const dialogsVisible =
|
||||
!!askUserRequest ||
|
||||
shouldShowIdePrompt ||
|
||||
isFolderTrustDialogOpen ||
|
||||
adminSettingsChanged ||
|
||||
@@ -1988,9 +2055,15 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}}
|
||||
>
|
||||
<ToolActionsProvider config={config} toolCalls={allToolCalls}>
|
||||
<ShellFocusContext.Provider value={isFocused}>
|
||||
<App />
|
||||
</ShellFocusContext.Provider>
|
||||
<AskUserActionsProvider
|
||||
request={askUserRequest}
|
||||
onSubmit={handleAskUserSubmit}
|
||||
onCancel={handleAskUserCancel}
|
||||
>
|
||||
<ShellFocusContext.Provider value={isFocused}>
|
||||
<App />
|
||||
</ShellFocusContext.Provider>
|
||||
</AskUserActionsProvider>
|
||||
</ToolActionsProvider>
|
||||
</AppContext.Provider>
|
||||
</ConfigContext.Provider>
|
||||
|
||||
@@ -32,6 +32,8 @@ import process from 'node:process';
|
||||
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
|
||||
import { AdminSettingsChangedDialog } from './AdminSettingsChangedDialog.js';
|
||||
import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js';
|
||||
import { AskUserDialog } from './AskUserDialog.js';
|
||||
import { useAskUserActions } from '../contexts/AskUserActionsContext.js';
|
||||
import { NewAgentsNotification } from './NewAgentsNotification.js';
|
||||
import { AgentConfigDialog } from './AgentConfigDialog.js';
|
||||
|
||||
@@ -57,6 +59,22 @@ export const DialogManager = ({
|
||||
terminalWidth: uiTerminalWidth,
|
||||
} = uiState;
|
||||
|
||||
const {
|
||||
request: askUserRequest,
|
||||
submit: askUserSubmit,
|
||||
cancel: askUserCancel,
|
||||
} = useAskUserActions();
|
||||
|
||||
if (askUserRequest) {
|
||||
return (
|
||||
<AskUserDialog
|
||||
questions={askUserRequest.questions}
|
||||
onSubmit={askUserSubmit}
|
||||
onCancel={askUserCancel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (uiState.adminSettingsChanged) {
|
||||
return <AdminSettingsChangedDialog />;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useConfig } from '../../contexts/ConfigContext.js';
|
||||
import { isShellTool, isThisShellFocused } from './ToolShared.js';
|
||||
import { ASK_USER_DISPLAY_NAME } from '@google/gemini-cli-core';
|
||||
|
||||
interface ToolGroupMessageProps {
|
||||
groupId: number;
|
||||
@@ -27,15 +28,30 @@ interface ToolGroupMessageProps {
|
||||
onShellInputSubmit?: (input: string) => void;
|
||||
}
|
||||
|
||||
// Helper to identify Ask User tools that are in progress (have their own dialog UI)
|
||||
const isAskUserInProgress = (t: IndividualToolCallDisplay): boolean =>
|
||||
t.name === ASK_USER_DISPLAY_NAME &&
|
||||
[
|
||||
ToolCallStatus.Pending,
|
||||
ToolCallStatus.Executing,
|
||||
ToolCallStatus.Confirming,
|
||||
].includes(t.status);
|
||||
|
||||
// Main component renders the border and maps the tools using ToolMessage
|
||||
export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
toolCalls,
|
||||
toolCalls: allToolCalls,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
isFocused = true,
|
||||
activeShellPtyId,
|
||||
embeddedShellFocused,
|
||||
}) => {
|
||||
// Filter out in-progress Ask User tools (they have their own AskUserDialog UI)
|
||||
const toolCalls = useMemo(
|
||||
() => allToolCalls.filter((t) => !isAskUserInProgress(t)),
|
||||
[allToolCalls],
|
||||
);
|
||||
|
||||
const config = useConfig();
|
||||
|
||||
const isEventDriven = config.isEventDrivenSchedulerEnabled();
|
||||
|
||||
@@ -18,6 +18,7 @@ import { theme } from '../../semantic-colors.js';
|
||||
import {
|
||||
type Config,
|
||||
SHELL_TOOL_NAME,
|
||||
ASK_USER_DISPLAY_NAME,
|
||||
type ToolResultDisplay,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useInactivityTimer } from '../../hooks/useInactivityTimer.js';
|
||||
@@ -198,13 +199,28 @@ export const ToolInfo: React.FC<ToolInfoProps> = ({
|
||||
}
|
||||
}
|
||||
}, [emphasis]);
|
||||
|
||||
// Hide description for completed Ask User tools (the result display speaks for itself)
|
||||
const isCompletedAskUser =
|
||||
name === ASK_USER_DISPLAY_NAME &&
|
||||
[
|
||||
ToolCallStatus.Success,
|
||||
ToolCallStatus.Error,
|
||||
ToolCallStatus.Canceled,
|
||||
].includes(status);
|
||||
|
||||
return (
|
||||
<Box overflow="hidden" height={1} flexGrow={1} flexShrink={1}>
|
||||
<Text strikethrough={status === ToolCallStatus.Canceled} wrap="truncate">
|
||||
<Text color={nameColor} bold>
|
||||
{name}
|
||||
</Text>{' '}
|
||||
<Text color={theme.text.secondary}>{description}</Text>
|
||||
</Text>
|
||||
{!isCompletedAskUser && (
|
||||
<>
|
||||
{' '}
|
||||
<Text color={theme.text.secondary}>{description}</Text>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { createContext, useContext, useMemo } from 'react';
|
||||
import type { Question } from '@google/gemini-cli-core';
|
||||
|
||||
export interface AskUserState {
|
||||
questions: Question[];
|
||||
correlationId: string;
|
||||
}
|
||||
|
||||
interface AskUserActionsContextValue {
|
||||
/** Current ask_user request, or null if no dialog should be shown */
|
||||
request: AskUserState | null;
|
||||
|
||||
/** Submit answers - publishes ASK_USER_RESPONSE to message bus */
|
||||
submit: (answers: { [questionIndex: string]: string }) => Promise<void>;
|
||||
|
||||
/** Cancel the dialog - clears request state */
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
const AskUserActionsContext = createContext<AskUserActionsContextValue | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
export const useAskUserActions = () => {
|
||||
const context = useContext(AskUserActionsContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useAskUserActions must be used within an AskUserActionsProvider',
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface AskUserActionsProviderProps {
|
||||
children: React.ReactNode;
|
||||
/** Current ask_user request state (managed by AppContainer) */
|
||||
request: AskUserState | null;
|
||||
/** Handler to submit answers */
|
||||
onSubmit: (answers: { [questionIndex: string]: string }) => Promise<void>;
|
||||
/** Handler to cancel the dialog */
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides ask_user dialog state and actions to child components.
|
||||
*
|
||||
* State is managed by AppContainer (which subscribes to the message bus)
|
||||
* and passed here as props. This follows the same pattern as ToolActionsProvider.
|
||||
*/
|
||||
export const AskUserActionsProvider: React.FC<AskUserActionsProviderProps> = ({
|
||||
children,
|
||||
request,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}) => {
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
request,
|
||||
submit: onSubmit,
|
||||
cancel: onCancel,
|
||||
}),
|
||||
[request, onSubmit, onCancel],
|
||||
);
|
||||
|
||||
return (
|
||||
<AskUserActionsContext.Provider value={value}>
|
||||
{children}
|
||||
</AskUserActionsContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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_';
|
||||
|
||||
Reference in New Issue
Block a user