From 3f219aed98d93158aefce01bb51ed12dcb4711a1 Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Mon, 25 May 2026 16:52:02 +0200 Subject: [PATCH] feat(ai-chat): expand chat question answers (#9310) --- .../chat/AskUserQuestionDisplay.svelte | 44 +++++++++++ .../copilot/chat/global/core.test.ts | 75 ++++++++++++++++++- .../components/copilot/chat/global/core.ts | 10 +-- 3 files changed, 123 insertions(+), 6 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/AskUserQuestionDisplay.svelte b/frontend/src/lib/components/copilot/chat/AskUserQuestionDisplay.svelte index 29481a04f2..e59ed4b513 100644 --- a/frontend/src/lib/components/copilot/chat/AskUserQuestionDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AskUserQuestionDisplay.svelte @@ -2,6 +2,7 @@ import { onMount, tick } from 'svelte' import { CircleHelp } from 'lucide-svelte' import Button from '$lib/components/common/button/Button.svelte' + import TextInput from '$lib/components/text_input/TextInput.svelte' import { aiChatManager } from './AIChatManager.svelte' import type { UserQuestionDisplay } from './shared' @@ -13,6 +14,8 @@ let { toolCallId, userQuestion }: Props = $props() let choiceButtons = $state<(HTMLButtonElement | undefined)[]>([]) + let customAnswer = $state('') + let canSubmitCustomAnswer = $derived(customAnswer.trim().length > 0) onMount(() => { if (userQuestion.choices.length === 0) { @@ -32,6 +35,15 @@ aiChatManager.handleUserQuestionAnswer(toolCallId, choice) } + function submitCustomAnswer() { + const answer = customAnswer.trim() + if (!answer) { + return + } + + aiChatManager.handleUserQuestionAnswer(toolCallId, answer) + } + function handleChoiceKeydown(event: KeyboardEvent, choice: string, index: number) { if (event.key === 'ArrowDown' || event.key === 'ArrowRight') { event.preventDefault() @@ -53,6 +65,16 @@ selectChoice(choice) } } + + function handleCustomAnswerKeydown(event: KeyboardEvent) { + if (event.key !== 'Enter') { + return + } + + event.preventDefault() + event.stopPropagation() + submitCustomAnswer() + }
{/each} + +
+ + +
diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index 7dce5eedad..6a7a9c8a9c 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -1104,7 +1104,7 @@ describe('global AI tools', () => { expect(item.value.value).toBeUndefined() }) - it('asks the user a multiple-choice question and returns the selected answer', async () => { + it('asks the user a question and returns the selected answer', async () => { const callbacks: ToolCallbacks = { setToolStatus: vi.fn(), removeToolStatus: vi.fn(), @@ -1138,6 +1138,79 @@ describe('global AI tools', () => { }) ) }) + + it('allows up to ten proposed answers', async () => { + const choices = Array.from({ length: 10 }, (_, index) => `choice-${index + 1}`) + const callbacks: ToolCallbacks = { + setToolStatus: vi.fn(), + removeToolStatus: vi.fn(), + requestUserQuestion: vi.fn(async (_toolId, question) => question.choices[9]) + } + + const raw = await callGlobalTool( + 'askUserQuestion', + { + question: 'Which option should be used?', + choices + }, + callbacks + ) + + expect(raw).toBe('choice-10') + expect(callbacks.requestUserQuestion).toHaveBeenCalledWith( + 'test-askUserQuestion', + expect.objectContaining({ + choices + }) + ) + }) + + it('rejects more than ten proposed answers', async () => { + const callbacks: ToolCallbacks = { + setToolStatus: vi.fn(), + removeToolStatus: vi.fn(), + requestUserQuestion: vi.fn() + } + + await expect( + callGlobalTool( + 'askUserQuestion', + { + question: 'Which option should be used?', + choices: Array.from({ length: 11 }, (_, index) => `choice-${index + 1}`) + }, + callbacks + ) + ).rejects.toThrow() + expect(callbacks.requestUserQuestion).not.toHaveBeenCalled() + }) + + it('returns a custom answer that is not one of the proposed answers', async () => { + const callbacks: ToolCallbacks = { + setToolStatus: vi.fn(), + removeToolStatus: vi.fn(), + requestUserQuestion: vi.fn(async () => 'use deno instead') + } + + const raw = await callGlobalTool( + 'askUserQuestion', + { + question: 'Which script language should be used?', + choices: ['bun', 'python3'] + }, + callbacks + ) + + expect(raw).toBe('use deno instead') + expect(callbacks.setToolStatus).toHaveBeenLastCalledWith( + 'test-askUserQuestion', + expect.objectContaining({ + content: 'User answered question: use deno instead', + result: 'use deno instead', + userQuestion: expect.objectContaining({ selectedChoice: 'use deno instead' }) + }) + ) + }) }) describe('prepareGlobalSystemMessage', () => { diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 11c94e677b..10d055938c 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -134,10 +134,10 @@ const askUserQuestionSchema = z.object({ .min(1) .describe('The concise question to show to the user before continuing.'), choices: z - .array(z.string().min(1).describe('Short answer text shown to the user and returned as-is.')) + .array(z.string().min(1).describe('Proposed answer text shown to the user and returned as-is.')) .min(2) - .max(6) - .describe('Two to six mutually exclusive answer strings.') + .max(10) + .describe('Two to ten mutually exclusive proposed answer strings.') }) const listWorkspaceItemsSchema = z.object({ @@ -503,7 +503,7 @@ Rules: - Variable values are never readable. For secrets, create a secret variable and reference it from resources as "$var:path/to/variable". - Use search_resource_types before write_resource. - Use get_instructions before writing scripts, flows, resources, or apps. For scripts, pass the target language. -- Ask the user when a required decision is ambiguous. +- When a required decision is ambiguous, use askUserQuestion with two to ten clear proposed answer strings instead of guessing. The user can also type a custom answer when none of the proposed answers fit. - Keep context targeted. Flows: @@ -1275,7 +1275,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( askUserQuestionSchema, 'askUserQuestion', - 'Ask the user a multiple-choice question.' + 'Ask the user a question with proposed answers and wait for their selected or custom answer before continuing.' ), fn: async ({ args, toolId, toolCallbacks }) => { const parsed = askUserQuestionSchema.parse(args)