feat: add global ask user question tool (#9217)

* feat: add global ask user question tool

* feat: add keyboard navigation to user questions

* feat: simplify ask user question answers

* fix: disable strict mode for optional tool schemas

* fix: scope ask question keyboard events

* fix: clean up ask question display state
This commit is contained in:
centdix
2026-05-18 23:36:52 +02:00
committed by GitHub
parent 2e05bdd73a
commit f965512c7a
8 changed files with 444 additions and 101 deletions
@@ -142,6 +142,7 @@ class AIChatManager {
cachedDatatables = $state<AppDatatableElement[]>([])
private confirmationCallback = $state<((value: boolean) => void) | undefined>(undefined)
private userQuestionCallbacks = new Map<string, (choice: string | undefined) => void>()
private appDatatablesRefreshTimeout: ReturnType<typeof setTimeout> | undefined = undefined
allowedModes: Record<AIMode, boolean> = $derived({
@@ -228,6 +229,40 @@ class AIChatManager {
}
}
requestUserQuestion = (
toolId: string,
_question: { question: string; choices: string[] }
): Promise<string | undefined> => {
return new Promise((resolve) => {
this.userQuestionCallbacks.set(toolId, resolve)
})
}
handleUserQuestionAnswer = (toolId: string, choice: string) => {
const callback = this.userQuestionCallbacks.get(toolId)
if (!callback) {
return
}
this.displayMessages = this.displayMessages.map((message) => {
if (message.role === 'tool' && message.tool_call_id === toolId && message.userQuestion) {
return {
...message,
content: `User answered question: ${choice}`,
isLoading: false,
userQuestion: {
...message.userQuestion,
selectedChoice: choice
}
}
}
return message
})
callback(choice)
this.userQuestionCallbacks.delete(toolId)
}
setAiChatInput(aiChatInput: AIChatInput | null) {
this.aiChatInput = aiChatInput
}
@@ -838,7 +873,8 @@ class AIChatManager {
this.displayMessages = [...this.displayMessages]
}
},
requestConfirmation: this.requestConfirmation
requestConfirmation: this.requestConfirmation,
requestUserQuestion: this.requestUserQuestion
}
}
@@ -869,6 +905,10 @@ class AIChatManager {
this.confirmationCallback(false)
this.confirmationCallback = undefined
}
for (const resolveQuestion of this.userQuestionCallbacks.values()) {
resolveQuestion(undefined)
}
this.userQuestionCallbacks.clear()
const cancelReason = reason ?? 'user_cancelled'
console.log('cancelling request:', {
reason: cancelReason,
@@ -1207,7 +1247,10 @@ class AIChatManager {
...message,
isLoading: false,
content: messageText,
error: messageText
error: messageText,
userQuestion: message.userQuestion
? { ...message.userQuestion, canceled: true }
: undefined
}
}
return message
@@ -0,0 +1,85 @@
<script lang="ts">
import { onMount, tick } from 'svelte'
import { CircleHelp } from 'lucide-svelte'
import Button from '$lib/components/common/button/Button.svelte'
import { aiChatManager } from './AIChatManager.svelte'
import type { UserQuestionDisplay } from './shared'
interface Props {
toolCallId: string
userQuestion: UserQuestionDisplay
}
let { toolCallId, userQuestion }: Props = $props()
let choiceButtons = $state<(HTMLButtonElement | undefined)[]>([])
onMount(() => {
if (userQuestion.choices.length === 0) {
return
}
void tick().then(() => {
focusChoice(0)
})
})
function focusChoice(index: number) {
choiceButtons[index]?.focus()
}
function selectChoice(choice: string) {
aiChatManager.handleUserQuestionAnswer(toolCallId, choice)
}
function handleChoiceKeydown(event: KeyboardEvent, choice: string, index: number) {
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
event.preventDefault()
event.stopPropagation()
focusChoice((index + 1) % userQuestion.choices.length)
return
}
if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
event.preventDefault()
event.stopPropagation()
focusChoice((index - 1 + userQuestion.choices.length) % userQuestion.choices.length)
return
}
if (event.key === 'Enter') {
event.preventDefault()
event.stopPropagation()
selectChoice(choice)
}
}
</script>
<div
class="rounded-md border border-gray-200 bg-surface p-3 text-sm dark:border-gray-700"
data-chat-keyboard-scope="ask-user-question"
>
<div class="flex items-start gap-2">
<CircleHelp class="mt-0.5 h-4 w-4 shrink-0 text-blue-500" />
<p class="min-w-0 flex-1 whitespace-pre-wrap text-xs font-medium text-primary"
>{userQuestion.question}</p
>
</div>
<div class="mt-3 flex flex-col gap-2">
{#each userQuestion.choices as choice, index (index)}
<Button
variant="default"
unifiedSize="sm"
bind:element={choiceButtons[index]}
onClick={() => selectChoice(choice)}
onkeydown={(event) => handleChoiceKeydown(event, choice, index)}
btnClasses="!h-auto min-h-[40px] !items-start !justify-start !px-3 !py-2 !text-left !whitespace-normal"
>
<span class="flex min-w-0 flex-col items-start gap-0.5">
<span class="max-w-full break-words text-2xs font-medium">{choice}</span>
</span>
</Button>
{/each}
</div>
</div>
@@ -6,6 +6,7 @@
import { twMerge } from 'tailwind-merge'
import ToolContentDisplay from './ToolContentDisplay.svelte'
import ToolMessageActions from './ToolMessageActions.svelte'
import AskUserQuestionDisplay from './AskUserQuestionDisplay.svelte'
interface Props {
message: ToolDisplayMessage
@@ -28,111 +29,125 @@
? message.actions
: []
)
const activeUserQuestion = $derived(
message.userQuestion &&
message.isLoading &&
!message.error &&
!message.userQuestion.selectedChoice &&
!message.userQuestion.canceled
? message.userQuestion
: undefined
)
</script>
<div
class="bg-surface border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden font-mono text-xs"
>
<!-- Collapsible Header -->
<button
class={twMerge(
'w-full p-2 bg-surface-secondary hover:bg-surface-hover transition-colors flex items-center justify-between text-left border-b border-gray-200 dark:border-gray-700',
message.needsConfirmation ? 'opacity-80' : ''
)}
onclick={() => (isExpanded = !isExpanded)}
disabled={!message.showDetails && !message.isStreamingArguments}
{#if activeUserQuestion}
<AskUserQuestionDisplay toolCallId={message.tool_call_id} userQuestion={activeUserQuestion} />
{:else}
<div
class="bg-surface border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden font-mono text-xs"
>
<div class="flex items-center gap-2 flex-1">
{#if message.showDetails || message.isStreamingArguments}
{#if isExpanded}
<ChevronDown class="w-3 h-3 text-secondary" />
{:else}
<ChevronRight class="w-3 h-3 text-secondary" />
<!-- Collapsible Header -->
<button
class={twMerge(
'w-full p-2 bg-surface-secondary hover:bg-surface-hover transition-colors flex items-center justify-between text-left border-b border-gray-200 dark:border-gray-700',
message.needsConfirmation ? 'opacity-80' : ''
)}
onclick={() => (isExpanded = !isExpanded)}
disabled={!message.showDetails && !message.isStreamingArguments}
>
<div class="flex items-center gap-2 flex-1">
{#if message.showDetails || message.isStreamingArguments}
{#if isExpanded}
<ChevronDown class="w-3 h-3 text-secondary" />
{:else}
<ChevronRight class="w-3 h-3 text-secondary" />
{/if}
{/if}
{/if}
{#if message.isLoading && !message.needsConfirmation}
<Loader2 class="w-3.5 h-3.5 animate-spin text-blue-500" />
{:else if message.error}
<span class="text-red-500"></span>
{:else if !message.isLoading && !message.error}
<span class="text-green-500"></span>
{/if}
<span class="text-primary font-medium text-2xs">
{message.content}
</span>
</div>
</button>
{#if message.isLoading && !message.needsConfirmation}
<Loader2 class="w-3.5 h-3.5 animate-spin text-blue-500" />
{:else if message.error}
<span class="text-red-500"></span>
{:else if !message.isLoading && !message.error}
<span class="text-green-500"></span>
{/if}
<span class="text-primary font-medium text-2xs">
{message.content}
</span>
</div>
</button>
<!-- Expanded Content -->
{#if isExpanded}
<div class="p-2 bg-surface space-y-3">
<!-- Parameters Section - show if we have parameters, or if confirmation is needed (even with empty params) -->
{#if hasParameters || message.needsConfirmation}
<div class={message.needsConfirmation ? 'opacity-80' : ''}>
<ToolContentDisplay
title="Parameters"
content={message.parameters}
streaming={message.isStreamingArguments}
toolName={message.toolName}
showFade={message.showFade}
/>
</div>
{/if}
<!-- Expanded Content -->
{#if isExpanded}
<div class="p-2 bg-surface space-y-3">
<!-- Parameters Section - show if we have parameters, or if confirmation is needed (even with empty params) -->
{#if hasParameters || message.needsConfirmation}
<div class={message.needsConfirmation ? 'opacity-80' : ''}>
<ToolContentDisplay
title="Parameters"
content={message.parameters}
streaming={message.isStreamingArguments}
toolName={message.toolName}
showFade={message.showFade}
/>
</div>
{/if}
<!-- Confirmation Footer -->
{#if message.needsConfirmation}
<div
class={twMerge(
'mt-3 pt-3 flex flex-row items-center justify-end gap-2',
hasParameters ? 'border-t border-gray-200 dark:border-gray-700' : ''
)}
>
<Button
variant="default"
size="xs"
on:click={() => {
if (message.tool_call_id) {
aiChatManager.handleToolConfirmation(message.tool_call_id, false)
}
}}
startIcon={{ icon: XCircle }}
destructive
></Button>
<Button
variant="accent"
size="xs"
on:click={() => {
if (message.tool_call_id) {
aiChatManager.handleToolConfirmation(message.tool_call_id, true)
}
}}
startIcon={{ icon: Play }}
<!-- Confirmation Footer -->
{#if message.needsConfirmation}
<div
class={twMerge(
'mt-3 pt-3 flex flex-row items-center justify-end gap-2',
hasParameters ? 'border-t border-gray-200 dark:border-gray-700' : ''
)}
>
Run
</Button>
</div>
<Button
variant="default"
size="xs"
on:click={() => {
if (message.tool_call_id) {
aiChatManager.handleToolConfirmation(message.tool_call_id, false)
}
}}
startIcon={{ icon: XCircle }}
destructive
></Button>
<Button
variant="accent"
size="xs"
on:click={() => {
if (message.tool_call_id) {
aiChatManager.handleToolConfirmation(message.tool_call_id, true)
}
}}
startIcon={{ icon: Play }}
>
Run
</Button>
</div>
<!-- Logs and Result - hide while streaming -->
{:else if !message.isStreamingArguments}
<ToolContentDisplay
title="Logs"
content={message.logs}
loading={message.isLoading}
showWhileLoading={false}
/>
{#if visibleActions.length > 0}
<ToolMessageActions actions={visibleActions} />
{:else}
<!-- Logs and Result - hide while streaming -->
{:else if !message.isStreamingArguments}
<ToolContentDisplay
title="Result"
content={message.result}
error={message.error}
title="Logs"
content={message.logs}
loading={message.isLoading}
showWhileLoading={false}
/>
{#if visibleActions.length > 0}
<ToolMessageActions actions={visibleActions} />
{:else}
<ToolContentDisplay
title="Result"
content={message.result}
error={message.error}
loading={message.isLoading}
/>
{/if}
{/if}
{/if}
</div>
{/if}
</div>
</div>
{/if}
</div>
{/if}
@@ -70,12 +70,16 @@ function getGlobalTool(name: string): Tool<{}> {
return tool
}
async function callGlobalTool(name: string, args: Record<string, unknown>): Promise<string> {
async function callGlobalTool(
name: string,
args: Record<string, unknown>,
callbacks: ToolCallbacks = toolCallbacks
): Promise<string> {
return getGlobalTool(name).fn({
args,
workspace: WORKSPACE,
helpers: {},
toolCallbacks,
toolCallbacks: callbacks,
toolId: `test-${name}`
})
}
@@ -198,4 +202,39 @@ describe('global AI tools', () => {
})
expect(item.value.value).toBeUndefined()
})
it('asks the user a multiple-choice question and returns the selected answer', async () => {
const callbacks: ToolCallbacks = {
setToolStatus: vi.fn(),
removeToolStatus: vi.fn(),
requestUserQuestion: vi.fn(async (_toolId, question) => question.choices[1])
}
const raw = await callGlobalTool(
'askUserQuestion',
{
question: 'Which script language should be used?',
choices: ['bun', 'python3']
},
callbacks
)
expect(raw).toBe('python3')
expect(callbacks.requestUserQuestion).toHaveBeenCalledWith(
'test-askUserQuestion',
expect.objectContaining({
question: 'Which script language should be used?',
choices: ['bun', 'python3']
})
)
expect(callbacks.setToolStatus).toHaveBeenLastCalledWith(
'test-askUserQuestion',
expect.objectContaining({
content: 'User answered question: python3',
isLoading: false,
result: 'python3',
userQuestion: expect.objectContaining({ selectedChoice: 'python3' })
})
)
})
})
@@ -111,6 +111,18 @@ const getInstructionsSchema = z.object({
)
})
const askUserQuestionSchema = z.object({
question: z
.string()
.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.'))
.min(2)
.max(6)
.describe('Two to six mutually exclusive answer strings.')
})
const listWorkspaceItemsSchema = z.object({
types: z
.array(itemTypeSchema)
@@ -469,6 +481,7 @@ Important rules:
- Use search_resource_types before write_resource to discover the resource_type name and the JSON Schema its value must match.
- Use get_instructions before writing a script, flow, resource, or app. For scripts, pass the target language; when modifying, use the language from the item you read.
- Schedules, triggers, and variables do not need get_instructions — their tool schemas describe every field.
- When a required decision is ambiguous, use askUserQuestion with two to six clear answer strings instead of guessing.
- A workspace item is { type, path, summary?, language?, triggerKind?, value, isDraft }. For scripts, value is the source code string. For flows, read_workspace_item returns value as the compact flow object { modules, schema, preprocessor_module, failure_module, groups }; write_flow takes the same flow fields as top-level tool arguments plus path/summary. For schedules/triggers/resources/variables, value is the full request body for that type. For apps, value is { files, runnables, data?, policy?, custom_path? } with frontend file contents and backend runnable definitions.
- Apps (raw apps): use list_workspace_items with types: ['app'] to find them, read_workspace_item with type 'app' for a metadata summary (file paths + runnable list, no contents), then read_app_file to read individual files. Edit with write_app_file / patch_app_file / delete_app_file for frontend files and write_app_runnable / delete_app_runnable for backend runnables. Frontend file paths start with "/" (e.g. /index.tsx). Backend inline runnables are addressed as "backend/<key>/main.{ts|py}". /wmill.d.ts is generated and cannot be written.
- To create a new raw app, use init_app. Before calling it, confirm framework (react19 / react18 / svelte5 / vue), path, and summary with the user — do not silently default to react19, even though it is the recommended choice.
@@ -1205,6 +1218,60 @@ export const globalTools: Tool<{}>[] = [
return getInstructions(parsed.subject, parsed.language)
}
},
{
def: createToolDef(
askUserQuestionSchema,
'askUserQuestion',
'Ask the user a multiple-choice question and wait for their selection before continuing.'
),
fn: async ({ args, toolId, toolCallbacks }) => {
const parsed = askUserQuestionSchema.parse(args)
const userQuestion = {
question: parsed.question,
choices: parsed.choices
}
toolCallbacks.setToolStatus(toolId, {
content: parsed.question,
userQuestion,
isLoading: true
})
if (!toolCallbacks.requestUserQuestion) {
const message = 'This chat context cannot ask interactive questions.'
toolCallbacks.setToolStatus(toolId, {
content: message,
userQuestion: { ...userQuestion, canceled: true },
isLoading: false,
error: message
})
return JSON.stringify({ success: false, error: message })
}
const selectedChoice = await toolCallbacks.requestUserQuestion(toolId, userQuestion)
if (!selectedChoice) {
const message = 'Question cancelled by user'
toolCallbacks.setToolStatus(toolId, {
content: message,
userQuestion: { ...userQuestion, canceled: true },
isLoading: false,
error: message
})
return JSON.stringify({ success: false, error: message })
}
toolCallbacks.setToolStatus(toolId, {
content: `User answered question: ${selectedChoice}`,
userQuestion: {
...userQuestion,
selectedChoice
},
result: selectedChoice,
isLoading: false
})
return selectedChoice
}
},
{
def: createToolDef(
listWorkspaceItemsSchema,
@@ -68,6 +68,39 @@ describe('createToolDef', () => {
expect(parameters?.properties?.config?.anyOf?.length).toBeGreaterThan(1)
})
it('disables strict mode for schemas with optional properties', async () => {
const { createToolDef } = await import('./shared')
const toolDef = createToolDef(
z.object({
subject: z.string(),
language: z.string().optional()
}),
'get_instructions',
'Get instructions'
)
const parameters = toolDef.function.parameters as any
expect(toolDef.function.strict).toBe(false)
expect(parameters.required).toEqual(['subject'])
expect(parameters.properties.language.type).toBe('string')
})
it('keeps strict mode for schemas without optional properties', async () => {
const { createToolDef } = await import('./shared')
const toolDef = createToolDef(
z.object({
question: z.string(),
choices: z.array(z.string())
}),
'askUserQuestion',
'Ask a question'
)
const parameters = toolDef.function.parameters as any
expect(toolDef.function.strict).toBe(true)
expect(parameters.required).toEqual(['question', 'choices'])
})
it('does not expose runnable target fields on workspace mutation tools', async () => {
const { createWorkspaceMutationTools } = await import('./workspaceTools')
const [scheduleTool, triggerTool] = createWorkspaceMutationTools()
@@ -485,6 +485,13 @@ export type CreatedResourceAction = {
export type ToolDisplayAction = CreatedResourceAction
export type UserQuestionDisplay = {
question: string
choices: string[]
selectedChoice?: string
canceled?: boolean
}
export type ToolDisplayMessage = {
role: 'tool'
tool_call_id: string
@@ -500,6 +507,7 @@ export type ToolDisplayMessage = {
toolName?: string
showFade?: boolean
actions?: ToolDisplayAction[]
userQuestion?: UserQuestionDisplay
}
export type AssistantDisplayMessage = BaseDisplayMessage & {
@@ -684,6 +692,10 @@ export interface ToolCallbacks {
setToolStatus: (id: string, metadata?: Partial<ToolDisplayMessage>) => void
removeToolStatus: (id: string) => void
requestConfirmation?: (toolId: string) => Promise<boolean>
requestUserQuestion?: (
toolId: string,
question: UserQuestionDisplay
) => Promise<string | undefined>
}
export function createToolDef(
@@ -697,11 +709,12 @@ export function createToolDef(
delete parameters.$schema
if (!parameters.required) parameters.required = []
normalizeToolParameterSchema(parameters)
const effectiveStrict = strict && !hasOptionalProperties(parameters)
return {
type: 'function',
function: {
strict,
strict: effectiveStrict,
name,
description,
parameters
@@ -709,6 +722,54 @@ export function createToolDef(
}
}
function hasOptionalProperties(schema: Record<string, any> | undefined): boolean {
if (!schema || typeof schema !== 'object') {
return false
}
if (schema.properties && typeof schema.properties === 'object') {
const required = new Set(Array.isArray(schema.required) ? schema.required : [])
const propertyKeys = Object.keys(schema.properties)
if (propertyKeys.some((key) => !required.has(key))) {
return true
}
for (const key of propertyKeys) {
if (hasOptionalProperties(schema.properties[key])) {
return true
}
}
}
if (schema.items) {
if (Array.isArray(schema.items)) {
if (schema.items.some((item) => hasOptionalProperties(item))) {
return true
}
} else if (hasOptionalProperties(schema.items)) {
return true
}
}
if (
schema.additionalProperties &&
typeof schema.additionalProperties === 'object' &&
hasOptionalProperties(schema.additionalProperties)
) {
return true
}
for (const key of ['allOf', 'anyOf', 'oneOf']) {
if (
Array.isArray(schema[key]) &&
schema[key].some((subSchema: Record<string, any>) => hasOptionalProperties(subSchema))
) {
return true
}
}
return false
}
const searchHubScriptsSchema = z.object({
query: z
.string()
@@ -492,7 +492,7 @@
}
const skipSelector =
'[role="menu"], [role="menuitem"], [role="dialog"], [role="listbox"], [role="combobox"], [aria-expanded="true"], [data-menu]'
'[role="menu"], [role="menuitem"], [role="dialog"], [role="listbox"], [role="combobox"], [aria-expanded="true"], [data-menu], [data-chat-keyboard-scope]'
if (target) {
const tag = target.tagName
const isEditable =