From a8a26a106fbc334390eff167b8e1990d60b59301 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 7 Jul 2026 09:08:04 -0400 Subject: [PATCH] feat: present Ask User as a native builtin in the tools dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It ships with the app and pauses the run like a first-class feature, so it belongs with the builtins (Run Code, Web Search, Memory, ...) rather than in the third-party plugin list — while its mechanics stay exactly a plugin's: - BuiltinId += 'ask_user_question' (documented exception: a native TOOL, not a capability; selection reads agent.tools, the toggle emits tool-add/remove patches instead of a capability field) - buildCatalog surfaces it as a builtin gated on the same signals as before (tools capability on + the server lists the plugin, i.e. not admin-filtered) and skips it in the plugin loop so it never double-lists - On-theme icon: lucide MessageCircleQuestion in a teal chip via the builtin icon map, matching the other native entries; the bespoke purple SVG and the manifest icon field are gone - i18n'd name/description keys like the other builtins --- api/app/clients/tools/manifest.json | 1 - client/public/assets/ask-user-question.svg | 5 ---- .../Tools/items/__tests__/catalog.spec.ts | 24 ++++++++++++++++++ .../Tools/items/__tests__/mutations.spec.ts | 21 ++++++++++++++++ .../Tools/items/__tests__/selectors.spec.ts | 25 +++++++++++++++++++ .../SidePanel/Agents/Tools/items/catalog.ts | 24 ++++++++++++++++++ .../SidePanel/Agents/Tools/items/icons.ts | 5 ++++ .../SidePanel/Agents/Tools/items/mutations.ts | 9 +++++-- .../SidePanel/Agents/Tools/items/selectors.ts | 3 +++ .../SidePanel/Agents/Tools/items/types.ts | 9 ++++++- client/src/locales/en/translation.json | 2 ++ 11 files changed, 119 insertions(+), 9 deletions(-) delete mode 100644 client/public/assets/ask-user-question.svg diff --git a/api/app/clients/tools/manifest.json b/api/app/clients/tools/manifest.json index df01b6bbb2..a45fca7b43 100644 --- a/api/app/clients/tools/manifest.json +++ b/api/app/clients/tools/manifest.json @@ -94,7 +94,6 @@ "name": "Ask User", "pluginKey": "ask_user_question", "description": "Let the agent pause mid-run to ask you a clarifying question and wait for your answer.", - "icon": "assets/ask-user-question.svg", "agentsOnly": true, "authConfig": [] }, diff --git a/client/public/assets/ask-user-question.svg b/client/public/assets/ask-user-question.svg deleted file mode 100644 index 3ccf453f93..0000000000 --- a/client/public/assets/ask-user-question.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/client/src/components/SidePanel/Agents/Tools/items/__tests__/catalog.spec.ts b/client/src/components/SidePanel/Agents/Tools/items/__tests__/catalog.spec.ts index 91db59db56..ae70ccdec2 100644 --- a/client/src/components/SidePanel/Agents/Tools/items/__tests__/catalog.spec.ts +++ b/client/src/components/SidePanel/Agents/Tools/items/__tests__/catalog.spec.ts @@ -57,6 +57,30 @@ describe('buildCatalog', () => { expect(systemDefined?.kind === 'builtin' && systemDefined.userProvidedAuth).toBe(false); }); + test('surfaces ask_user_question as a BUILTIN (not a plugin) when the server lists it', () => { + const askPlugin = makePlugin({ pluginKey: 'ask_user_question', name: 'Ask User' }); + const items = buildCatalog({ ...toolInputs, regularTools: [askPlugin] }); + const builtin = items.find((i) => i.kind === 'builtin' && i.id === 'ask_user_question'); + expect(builtin).toBeDefined(); + expect(builtin?.iconKey).toBe('ask_user_question'); + // never double-listed in the plugin section + expect(items.find((i) => i.kind === 'tool' && i.id === 'ask_user_question')).toBeUndefined(); + }); + + test('omits the ask_user_question builtin when the server filtered it or tools are off', () => { + const askPlugin = makePlugin({ pluginKey: 'ask_user_question', name: 'Ask User' }); + // admin filtered (not in regularTools) + expect( + buildCatalog(toolInputs).find((i) => i.kind === 'builtin' && i.id === 'ask_user_question'), + ).toBeUndefined(); + // tools capability off + expect( + buildCatalog({ ...emptyInputs, regularTools: [askPlugin] }).find( + (i) => i.kind === 'builtin' && i.id === 'ask_user_question', + ), + ).toBeUndefined(); + }); + test('hides MCP items when the user lacks MCP permission', () => { const map = new Map(); map.set('srv', { serverName: 'srv', isConfigured: true, tools: [] }); diff --git a/client/src/components/SidePanel/Agents/Tools/items/__tests__/mutations.spec.ts b/client/src/components/SidePanel/Agents/Tools/items/__tests__/mutations.spec.ts index e485de9d16..b264a4ccb6 100644 --- a/client/src/components/SidePanel/Agents/Tools/items/__tests__/mutations.spec.ts +++ b/client/src/components/SidePanel/Agents/Tools/items/__tests__/mutations.spec.ts @@ -152,3 +152,24 @@ describe('skillsEnabledTransition', () => { expect(skillsEnabledTransition([], undefined)).toBeUndefined(); }); }); + +describe('computeToggleAction — ask_user_question builtin', () => { + const item = { + kind: 'builtin', + id: 'ask_user_question', + iconKey: 'ask_user_question', + name: 'com_ui_ask_user', + description: 'com_agents_ask_user_info', + } as const; + + test('toggles agent.tools, never a capability field', () => { + expect(computeToggleAction(item as never, { selected: false })).toEqual({ + type: 'tool-add', + id: 'ask_user_question', + }); + expect(computeToggleAction(item as never, { selected: true })).toEqual({ + type: 'tool-remove', + id: 'ask_user_question', + }); + }); +}); diff --git a/client/src/components/SidePanel/Agents/Tools/items/__tests__/selectors.spec.ts b/client/src/components/SidePanel/Agents/Tools/items/__tests__/selectors.spec.ts index e315507eef..12b904e9af 100644 --- a/client/src/components/SidePanel/Agents/Tools/items/__tests__/selectors.spec.ts +++ b/client/src/components/SidePanel/Agents/Tools/items/__tests__/selectors.spec.ts @@ -224,3 +224,28 @@ describe('matchesMcpServer', () => { expect(matchesMcpServer('search_mcp_github_extra', 'github_extra')).toBe(true); }); }); + +describe('selection — ask_user_question builtin rides agent.tools', () => { + const catalogWithAsk: AgentItem[] = [ + ...sampleCatalog, + { + kind: 'builtin', + id: 'ask_user_question', + name: 'com_ui_ask_user', + description: 'com_agents_ask_user_info', + iconKey: 'ask_user_question', + }, + ]; + + test('selected iff agent.tools contains the tool (no capability field involved)', () => { + const selected = deriveSelectedItems( + { ...emptyFormState, tools: ['ask_user_question'] }, + catalogWithAsk, + [], + ); + expect(selected.map((i) => i.id)).toContain('ask_user_question'); + + const none = deriveSelectedItems(emptyFormState, catalogWithAsk, []); + expect(none.map((i) => i.id)).not.toContain('ask_user_question'); + }); +}); diff --git a/client/src/components/SidePanel/Agents/Tools/items/catalog.ts b/client/src/components/SidePanel/Agents/Tools/items/catalog.ts index e471828146..1a3f874b31 100644 --- a/client/src/components/SidePanel/Agents/Tools/items/catalog.ts +++ b/client/src/components/SidePanel/Agents/Tools/items/catalog.ts @@ -118,6 +118,27 @@ export function buildCatalog(inputs: BuildCatalogInputs): AgentItem[] { }); } + /** + * Native tool presented with the builtins (it ships with the app and pauses + * the run like a first-class feature), while remaining an `agent.tools` + * entry mechanically. Availability = the plugin listing itself: it appears + * only when the `tools` capability is on AND the server lists the plugin + * (i.e. the admin didn't filter it) — the same gates as the plugin section + * it graduated from. + */ + if ( + enabled.has(AgentCapabilities.tools) && + inputs.regularTools.some((plugin) => plugin.pluginKey === 'ask_user_question') + ) { + items.push({ + kind: 'builtin', + id: 'ask_user_question', + iconKey: 'ask_user_question', + name: 'com_ui_ask_user', + description: 'com_agents_ask_user_info', + }); + } + if (inputs.showMemory) { items.push({ kind: 'builtin', @@ -150,6 +171,9 @@ export function buildCatalog(inputs: BuildCatalogInputs): AgentItem[] { if (enabled.has(AgentCapabilities.tools)) { for (const plugin of inputs.regularTools) { + if (plugin.pluginKey === 'ask_user_question') { + continue; // surfaced as a builtin above — don't double-list as a plugin + } items.push({ kind: 'tool', id: plugin.pluginKey, diff --git a/client/src/components/SidePanel/Agents/Tools/items/icons.ts b/client/src/components/SidePanel/Agents/Tools/items/icons.ts index 632346700e..0937552f30 100644 --- a/client/src/components/SidePanel/Agents/Tools/items/icons.ts +++ b/client/src/components/SidePanel/Agents/Tools/items/icons.ts @@ -1,5 +1,6 @@ import { Code, + MessageCircleQuestion, Globe, Brain, Sparkles, @@ -45,6 +46,10 @@ const BUILTIN_ICONS: Record = { Icon: Brain, colorClass: 'bg-indigo-500/15 text-indigo-600 dark:text-indigo-300', }, + ask_user_question: { + Icon: MessageCircleQuestion, + colorClass: 'bg-teal-500/15 text-teal-600 dark:text-teal-300', + }, }; const KIND_FALLBACK_ICONS: Record = { diff --git a/client/src/components/SidePanel/Agents/Tools/items/mutations.ts b/client/src/components/SidePanel/Agents/Tools/items/mutations.ts index d4fed0a0c1..93272c9a45 100644 --- a/client/src/components/SidePanel/Agents/Tools/items/mutations.ts +++ b/client/src/components/SidePanel/Agents/Tools/items/mutations.ts @@ -13,8 +13,13 @@ export type TogglePatch = | { type: 'action-remove'; actionId: string }; function builtinTogglePatch(id: string, selected: boolean): TogglePatch { - // Every BuiltinId string equals its AgentCapabilities enum value, so the id - // is already the form field name. + if (id === 'ask_user_question') { + // Native tool presented as a builtin — it has no capability field; the + // toggle edits agent.tools exactly like a plugin. + return selected ? { type: 'tool-remove', id } : { type: 'tool-add', id }; + } + // Every other BuiltinId string equals its AgentCapabilities enum value, so + // the id is already the form field name. const field = id as AgentCapabilities; if (id === 'artifacts') { return { type: 'builtin', field, value: selected ? '' : ArtifactModes.DEFAULT }; diff --git a/client/src/components/SidePanel/Agents/Tools/items/selectors.ts b/client/src/components/SidePanel/Agents/Tools/items/selectors.ts index 93b9670242..b626f4d7e1 100644 --- a/client/src/components/SidePanel/Agents/Tools/items/selectors.ts +++ b/client/src/components/SidePanel/Agents/Tools/items/selectors.ts @@ -42,6 +42,9 @@ function isBuiltinSelected(item: AgentItem, form: FormSelection): boolean { return Boolean(form.artifacts); case 'context': return form.context_files.length > 0; + case 'ask_user_question': + // Native tool presented as a builtin — selection lives in agent.tools. + return form.tools.includes('ask_user_question'); default: return false; } diff --git a/client/src/components/SidePanel/Agents/Tools/items/types.ts b/client/src/components/SidePanel/Agents/Tools/items/types.ts index 26ac623226..d3c563bb0d 100644 --- a/client/src/components/SidePanel/Agents/Tools/items/types.ts +++ b/client/src/components/SidePanel/Agents/Tools/items/types.ts @@ -14,7 +14,14 @@ export type BuiltinId = | `${AgentCapabilities.file_search}` | `${AgentCapabilities.artifacts}` | `${AgentCapabilities.memory}` - | `${AgentCapabilities.context}`; + | `${AgentCapabilities.context}` + /** + * Native tool, not a capability: PRESENTED with the builtins (it ships with + * the app and pauses the run, like a first-class feature), but selection and + * toggling ride `agent.tools` exactly like a plugin — see the special cases + * in `selectors.ts` / `mutations.ts`. + */ + | 'ask_user_question'; export type AgentItemStatus = 'needs_setup'; diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index facc3a492c..386e52c128 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -9,6 +9,7 @@ "com_a11y_summarize_completed": "Context summarized.", "com_a11y_summarize_failed": "Summarization failed, continuing with available context.", "com_a11y_summarize_started": "Summarizing context.", + "com_agents_ask_user_info": "Lets the agent pause mid-run to ask you a clarifying question and wait for your answer.", "com_agents_agent_card_label": "{{name}} agent. {{description}}", "com_agents_all": "All Agents", "com_agents_all_category": "All", @@ -856,6 +857,7 @@ "com_ui_artifacts_options": "Artifacts Options", "com_ui_artifacts_subtext": "Lets the agent render React, HTML, SVG, Markdown, and Mermaid as interactive artifacts in a side panel instead of plain code blocks.", "com_ui_ascending": "Asc", + "com_ui_ask_user": "Ask User", "com_ui_asked_a_question": "Asked a question", "com_ui_assistant": "Assistant", "com_ui_assistant_delete_error": "There was an error deleting the assistant",