feat: present Ask User as a native builtin in the tools dialog

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
This commit is contained in:
Danny Avila 2026-07-07 09:08:04 -04:00
parent fc08aeac93
commit a8a26a106f
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
11 changed files with 119 additions and 9 deletions

View file

@ -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": []
},

View file

@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<rect width="24" height="24" rx="6" fill="#7C3AED"/>
<path d="M12 4.75c-3.31 0-6 2.32-6 5.18 0 1.63.87 3.08 2.24 4.03-.08.65-.35 1.5-1.04 2.29a.4.4 0 0 0 .32.66c1.5-.06 2.63-.63 3.36-1.19.36.06.74.09 1.12.09 3.31 0 6-2.32 6-5.18s-2.69-5.18-6-5.18Z" fill="#fff"/>
<path d="M11.98 12.05c-.4 0-.68-.28-.68-.67v-.09c0-.72.4-1.12.93-1.48.51-.35.73-.55.73-.94 0-.44-.35-.74-.89-.74-.42 0-.73.19-.94.55-.16.24-.36.35-.63.35-.4 0-.63-.26-.63-.58 0-.13.03-.25.08-.37.3-.71 1.13-1.19 2.19-1.19 1.32 0 2.26.73 2.26 1.86 0 .74-.37 1.16-.98 1.56-.52.34-.72.56-.74.99-.03.44-.28.75-.7.75Zm-.01 2.1a.83.83 0 0 1-.84-.83c0-.46.37-.83.84-.83.46 0 .83.37.83.83a.83.83 0 0 1-.83.83Z" fill="#7C3AED"/>
</svg>

Before

Width:  |  Height:  |  Size: 768 B

View file

@ -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: [] });

View file

@ -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',
});
});
});

View file

@ -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');
});
});

View file

@ -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,

View file

@ -1,5 +1,6 @@
import {
Code,
MessageCircleQuestion,
Globe,
Brain,
Sparkles,
@ -45,6 +46,10 @@ const BUILTIN_ICONS: Record<string, ItemIcon> = {
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<AgentItem['kind'], ItemIcon> = {

View file

@ -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 };

View file

@ -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;
}

View file

@ -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';

View file

@ -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",