feat(chat): add ask_user_question tool for structured disambiguation

A global-mode tool that pauses execution and lets the AI ask the user
a multiple-choice question (2–4 mutually-exclusive options). Pure
frontend, mirrors the existing tool-confirmation flow:

- shared.ts: ToolPendingQuestion type; pendingQuestion + answeredValue
  fields on ToolDisplayMessage; requestAnswer callback on ToolCallbacks.
- AIChatManager: answerCallback + requestAnswer / handleToolAnswer pair.
  Resolves with the picked option's value, or '' on cancel. Cancel sweep
  clears the callback alongside the existing confirmation cleanup.
- ToolExecutionDisplay: when pendingQuestion is set, the card auto-
  expands and replaces its body with the question text + one button per
  option. Clicking an option calls aiChatManager.handleToolAnswer.
- global/core.ts: askUserQuestionSchema and the tool entry in
  globalTools. Returns the picked option's value verbatim to the model,
  or '(user cancelled the question)' if the user cancelled.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-05-14 16:16:23 +02:00
co-authored by Claude Opus 4.7
parent 33bf01b627
commit 85218dae5b
4 changed files with 229 additions and 101 deletions
@@ -228,6 +228,24 @@ class AIChatManager {
}
}
// Pending resolver for `ask_user_question`. Same shape as
// confirmationCallback but resolves with the chosen option value
// (a string) instead of a boolean.
private answerCallback = $state<((value: string) => void) | undefined>(undefined)
requestAnswer = (_toolId: string): Promise<string> => {
return new Promise((resolve) => {
this.answerCallback = resolve
})
}
handleToolAnswer = (_toolId: string, value: string) => {
if (this.answerCallback) {
this.answerCallback(value)
this.answerCallback = undefined
}
}
setAiChatInput(aiChatInput: AIChatInput | null) {
this.aiChatInput = aiChatInput
}
@@ -838,7 +856,8 @@ class AIChatManager {
this.displayMessages = [...this.displayMessages]
}
},
requestConfirmation: this.requestConfirmation
requestConfirmation: this.requestConfirmation,
requestAnswer: this.requestAnswer
}
}
@@ -869,6 +888,13 @@ class AIChatManager {
this.confirmationCallback(false)
this.confirmationCallback = undefined
}
if (this.answerCallback) {
// Resolve with empty string so the awaiting tool fn returns
// gracefully; the cancelLoadingTools sweep below will then
// flag the message as cancelled.
this.answerCallback('')
this.answerCallback = undefined
}
const cancelReason = reason ?? 'user_cancelled'
console.log('cancelling request:', {
reason: cancelReason,
@@ -17,10 +17,13 @@
message.parameters !== undefined && Object.keys(message.parameters).length > 0
)
const hasPendingQuestion = $derived(!!message.pendingQuestion)
let isExpanded = $derived(
message.showDetails ||
(message.isStreamingArguments && hasParameters) ||
(message.isLoading && message.needsConfirmation)
(message.isLoading && message.needsConfirmation) ||
hasPendingQuestion
)
const visibleActions = $derived(
@@ -67,70 +70,96 @@
<!-- 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}
/>
<!-- User question prompt: takes over the card while the
ask_user_question tool is paused waiting for an answer. -->
{#if message.pendingQuestion}
<div class="space-y-2 font-sans">
<div class="text-primary text-sm">{message.pendingQuestion.question}</div>
<div class="flex flex-col gap-1.5">
{#each message.pendingQuestion.options as opt}
<button
type="button"
class="text-left px-3 py-2 rounded-md border border-gray-200 dark:border-gray-700 hover:bg-surface-hover hover:border-blue-400 transition-colors"
onclick={() => {
if (message.tool_call_id) {
aiChatManager.handleToolAnswer(message.tool_call_id, opt.value)
}
}}
>
<div class="text-primary text-xs font-medium">{opt.label}</div>
{#if opt.description}
<div class="text-tertiary text-2xs mt-0.5">{opt.description}</div>
{/if}
</button>
{/each}
</div>
</div>
{/if}
{:else}
<!-- 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>
@@ -41,12 +41,7 @@ import {
validateEditableFlowJson
} from '../flow/editableFlowJson'
import { createInlineScriptSession } from '../flow/inlineScriptsUtils'
import {
getFlowPrompt,
getRawAppPrompt,
getResourcePrompt,
getScriptPrompt
} from '$system_prompts'
import { getFlowPrompt, getRawAppPrompt, getResourcePrompt, getScriptPrompt } from '$system_prompts'
import type {
ChatCompletionSystemMessageParam,
ChatCompletionUserMessageParam
@@ -111,6 +106,34 @@ const getInstructionsSchema = z.object({
)
})
const askUserQuestionSchema = z.object({
question: z
.string()
.describe('The question to put to the user, phrased clearly and ending with a question mark.'),
options: z
.array(
z.object({
label: z.string().describe('Short text shown on the option button (16 words).'),
value: z
.string()
.describe(
'Machine-readable value returned to the tool when this option is picked. Keep it concise — you will receive this exact string back as the tool result.'
),
description: z
.string()
.optional()
.describe(
'Optional one-line explanation rendered below the label to clarify what picking this option means.'
)
})
)
.min(2)
.max(4)
.describe(
'24 mutually exclusive options for the user to choose from. Order most-recommended first.'
)
})
const listWorkspaceItemsSchema = z.object({
types: z
.array(itemTypeSchema)
@@ -141,9 +164,7 @@ const readWorkspaceItemSchema = z.object({
})
const writeScriptSchema = z.object({
path: z
.string()
.describe('Workspace path of the script, e.g. f/folder/name or u/user/name.'),
path: z.string().describe('Workspace path of the script, e.g. f/folder/name or u/user/name.'),
summary: z.string().optional().describe('Short human-readable summary.'),
language: scriptLangSchema.describe('Script language.'),
content: z.string().describe('Full script source code.')
@@ -165,7 +186,7 @@ const setFlowModuleCodeSchema = z.object({
.describe(
'Module id whose inline rawscript content to overwrite. Must reference a module whose value.type is "rawscript". Use patch_flow_json for structural changes.'
),
code: z.string().describe('New script source. Replaces the module\'s value.content entirely.')
code: z.string().describe("New script source. Replaces the module's value.content entirely.")
})
// Flow structure fields are taken as JSON strings rather than typed objects
@@ -174,9 +195,7 @@ const setFlowModuleCodeSchema = z.object({
// rejects those keywords ("Unknown name $ref/$defs"). Same trick as
// set_flow_json in chat/flow/core.ts.
const writeFlowSchema = z.object({
path: z
.string()
.describe('Workspace path of the flow, e.g. f/folder/name or u/user/name.'),
path: z.string().describe('Workspace path of the flow, e.g. f/folder/name or u/user/name.'),
summary: z.string().optional().describe('Short human-readable summary.'),
modules: z.string().describe('JSON string containing the complete flow modules array.'),
schema: z
@@ -423,7 +442,12 @@ const deleteAppRunnableSchema = z.object({
key: z.string().describe('Key of the backend runnable to remove.')
})
const FRAMEWORK_KEYS = ['react19', 'react18', 'svelte5', 'vue'] as const satisfies readonly FrameworkKey[]
const FRAMEWORK_KEYS = [
'react19',
'react18',
'svelte5',
'vue'
] as const satisfies readonly FrameworkKey[]
const initAppSchema = z.object({
path: z
@@ -644,7 +668,7 @@ function buildPersistedRunnable(
{ type: 'static', value: v, fieldType: 'object' }
])
)
: existing?.fields ?? {}
: (existing?.fields ?? {})
if (input.type === 'inline') {
if (!input.inlineScript) {
@@ -698,10 +722,12 @@ type AppMetadata = {
}
function summarizeAppValue(value: AppDraftValue): AppMetadata {
const frontend: AppFrontendFileMetadata[] = Object.entries(value.files).map(([path, content]) => ({
path,
size: typeof content === 'string' ? content.length : 0
}))
const frontend: AppFrontendFileMetadata[] = Object.entries(value.files).map(
([path, content]) => ({
path,
size: typeof content === 'string' ? content.length : 0
})
)
const backend: AppBackendRunnableMetadata[] = Object.entries(value.runnables).map(
([key, runnable]) => {
const converted = convertPersistedToBackendRunnable(runnable as PersistedRunnable, key)
@@ -849,11 +875,7 @@ function triggerToItem(
type TriggerService = {
exists(args: { workspace: string; path: string }): Promise<boolean>
get(args: { workspace: string; path: string }): Promise<TriggerLike>
list(args: {
workspace: string
pathStart?: string
perPage?: number
}): Promise<TriggerLike[]>
list(args: { workspace: string; pathStart?: string; perPage?: number }): Promise<TriggerLike[]>
create(args: { workspace: string; requestBody: any }): Promise<string>
update(args: { workspace: string; path: string; requestBody: any }): Promise<string>
delete(args: { workspace: string; path: string }): Promise<string>
@@ -983,7 +1005,7 @@ async function readWorkspaceItem(
)
case 'resource':
return resourceToItem(
await ResourceService.getResource({ workspace, path }) as ListableResource,
(await ResourceService.getResource({ workspace, path })) as ListableResource,
true
)
case 'variable':
@@ -1205,6 +1227,39 @@ export const globalTools: Tool<{}>[] = [
return getInstructions(parsed.subject, parsed.language)
}
},
{
def: createToolDef(
askUserQuestionSchema,
'ask_user_question',
"Ask the user a multiple-choice question and wait for their reply. Use this when you need to disambiguate before continuing — for example to pick a target folder/path, choose between framework options, or confirm a non-obvious tradeoff. Provide 24 mutually exclusive options; do NOT use this for free-form questions or yes/no confirmations (regular text or the tool-confirmation flow is better for those). The tool result is the picked option's value verbatim, or empty string if the user cancelled."
),
fn: async ({ args, toolId, toolCallbacks }) => {
const parsed = askUserQuestionSchema.parse(args)
if (!toolCallbacks.requestAnswer) {
throw new Error('ask_user_question is not supported in this chat mode')
}
toolCallbacks.setToolStatus(toolId, {
content: parsed.question,
pendingQuestion: { question: parsed.question, options: parsed.options }
})
const answer = await toolCallbacks.requestAnswer(toolId, {
question: parsed.question,
options: parsed.options
})
const picked = parsed.options.find((o) => o.value === answer)
toolCallbacks.setToolStatus(toolId, {
content: picked
? `User picked: ${picked.label}`
: answer
? `User answer: ${answer}`
: 'User cancelled',
pendingQuestion: undefined,
answeredValue: answer,
result: answer
})
return answer || '(user cancelled the question)'
}
},
{
def: createToolDef(
listWorkspaceItemsSchema,
@@ -1275,12 +1330,7 @@ export const globalTools: Tool<{}>[] = [
toolCallbacks.setToolStatus(toolId, {
content: `Reading ${parsed.type} "${parsed.path}"...`
})
const item = await readWorkspaceItem(
parsed.type,
parsed.path,
workspace,
parsed.trigger_kind
)
const item = await readWorkspaceItem(parsed.type, parsed.path, workspace, parsed.trigger_kind)
toolCallbacks.setToolStatus(toolId, { content: `Read ${parsed.type} "${parsed.path}"` })
return JSON.stringify(serializeWorkspaceItemForRead(item), null, 2)
}
@@ -1980,13 +2030,21 @@ async function patchAppFile(
ctx: WriteDraftCtx
): Promise<string> {
const { workspace, toolId, toolCallbacks } = ctx
const { path, file_path: filePath, old_string: oldString, new_string: newString, replace_all: replaceAll } = args
const {
path,
file_path: filePath,
old_string: oldString,
new_string: newString,
replace_all: replaceAll
} = args
const target = resolveAppFileTarget(filePath)
if (target.kind === 'frontend') {
assertNotGeneratedAppFile(target.filePath)
}
toolCallbacks.setToolStatus(toolId, { content: `Patching ${target.filePath} in app "${path}"...` })
toolCallbacks.setToolStatus(toolId, {
content: `Patching ${target.filePath} in app "${path}"...`
})
const value = await loadAppDraftValue(path, workspace)
let currentContent: string
@@ -2020,7 +2078,8 @@ async function patchAppFile(
[target.key]: {
...runnable!,
inlineScript: {
language: runnable!.inlineScript?.language ?? (target.extension === 'py' ? 'python3' : 'bun'),
language:
runnable!.inlineScript?.language ?? (target.extension === 'py' ? 'python3' : 'bun'),
content: updated
}
}
@@ -2044,10 +2103,7 @@ async function patchAppFile(
}
async function recomputeAppPolicy(value: AppDraftValue): Promise<void> {
value.policy = (await updateRawAppPolicy(
value.runnables as any,
value.policy as any
)) as any
value.policy = (await updateRawAppPolicy(value.runnables as any, value.policy as any)) as any
}
async function writeAppRunnable(
@@ -2128,10 +2184,7 @@ const triggerLabels: Record<TriggerKind, string> = {
azure: 'Azure Event Grid trigger'
}
function createOpenScheduleAction(
path: string,
targetKind: 'script' | 'flow'
): ToolDisplayAction {
function createOpenScheduleAction(path: string, targetKind: 'script' | 'flow'): ToolDisplayAction {
return {
id: `open-deployed-schedule:${path}`,
type: 'open_created_resource',
@@ -485,6 +485,20 @@ export type CreatedResourceAction = {
export type ToolDisplayAction = CreatedResourceAction
// A multiple-choice question the assistant put to the user via the
// `ask_user_question` tool. While `pendingQuestion` is set the tool
// call is paused — the UI renders the options and clicking one resolves
// the tool's awaiting promise with the selected value.
export type ToolQuestionOption = {
label: string
value: string
description?: string
}
export type ToolPendingQuestion = {
question: string
options: ToolQuestionOption[]
}
export type ToolDisplayMessage = {
role: 'tool'
tool_call_id: string
@@ -495,6 +509,8 @@ export type ToolDisplayMessage = {
isLoading?: boolean
error?: string
needsConfirmation?: boolean
pendingQuestion?: ToolPendingQuestion
answeredValue?: string
showDetails?: boolean
isStreamingArguments?: boolean
toolName?: string
@@ -684,6 +700,10 @@ export interface ToolCallbacks {
setToolStatus: (id: string, metadata?: Partial<ToolDisplayMessage>) => void
removeToolStatus: (id: string) => void
requestConfirmation?: (toolId: string) => Promise<boolean>
// Used by the `ask_user_question` tool. Suspends the tool until the
// user picks one of the supplied options. Resolves with the chosen
// value string, or empty string when the user cancels.
requestAnswer?: (toolId: string, question: ToolPendingQuestion) => Promise<string>
}
export function createToolDef(