FN-6589: add structured ask-question chat tool

Add a native chat-agent tool for presenting structured questions to dashboard users.

- Register `fn_ask_question` for dashboard chat sessions with prompt guidance to wait for user replies.
- Reuse the existing structured question card parser by recognizing the new tool name.
- Validate ask-question parameters in engine tooling and cover chat/parser behavior with tests.
- Document the dashboard chat question flow and add a patch changeset.

Files changed:
 .changeset/fn-6589-chat-ask-question.md            |  5 ++
 docs/dashboard-guide.md                            |  3 +-
 .../utils/__tests__/parseQuestionToolCall.test.ts  | 19 +++++
 .../dashboard/app/utils/parseQuestionToolCall.ts   |  3 +-
 .../dashboard/src/__tests__/chat-manager.test.ts   | 10 ++-
 packages/dashboard/src/chat.ts                     | 10 ++-
 .../src/__tests__/agent-tools-ask-question.test.ts | 51 ++++++++++++
 packages/engine/src/agent-tools.ts                 | 94 ++++++++++++++++++++++
 packages/engine/src/index.ts                       |  2 +
 9 files changed, 190 insertions(+), 7 deletions(-)

Fusion-Task-Id: FN-6589
Fusion-Task-Lineage: 3f416010-ed2f-40b3-a899-7a9f4a6ed265
This commit is contained in:
gsxdsm
2026-06-17 16:53:29 -07:00
parent c261217ac1
commit 01b80db603
9 changed files with 190 additions and 7 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Add a Fusion-native `fn_ask_question` tool for dashboard chat agents so structured questions render in the existing chat response card and answers return through the next chat message.

View File

@@ -247,7 +247,8 @@ Chat view provides project-scoped conversations with agents.
- Direct chat sessions can be renamed from the desktop conversation context menu and from the mobile session switcher; blank rename submissions clear the custom title so the default session label is shown again. - Direct chat sessions can be renamed from the desktop conversation context menu and from the mobile session switcher; blank rename submissions clear the custom title so the default session label is shown again.
- On mobile (`max-width: 768px`), chat bubbles are slightly wider in full Chat for improved readability while preserving header/composer gutters. - On mobile (`max-width: 768px`), chat bubbles are slightly wider in full Chat for improved readability while preserving header/composer gutters.
- Full Chat tool-call summaries now use a denser mobile layout: grouped and single-call collapsed rows keep icon + label + status on one line (Quick Chat-style scanability) while expanded details remain unchanged. - Full Chat tool-call summaries now use a denser mobile layout: grouped and single-call collapsed rows keep icon + label + status on one line (Quick Chat-style scanability) while expanded details remain unchanged.
- Assistant question tool calls now render as a shared in-chat response card instead of a generic tool-call disclosure. The card supports select, multi-select, text, and yes/no prompts, sends the formatted answer back into the same direct or room thread, and renders historical answered questions read-only. <!-- FNXC:ChatAskQuestion 2026-06-17-16:35: Dashboard chat agents have a Fusion-native `fn_ask_question` tool, so the documented question-card behavior must cover both provider-native question tools and Fusion's first-party tool. -->
- Assistant question tool calls now render as a shared in-chat response card instead of a generic tool-call disclosure. The card recognizes provider-native question tools and Fusion's `fn_ask_question`, supports select, multi-select, text, and yes/no prompts, sends the formatted answer back into the same direct or room thread, and renders historical answered questions read-only.
- The desktop Chat view toggle and mobile Chat tab now show an unread-response indicator when a live assistant reply arrives for your active chat thread after you leave Chat; opening Chat clears it immediately. - The desktop Chat view toggle and mobile Chat tab now show an unread-response indicator when a live assistant reply arrives for your active chat thread after you leave Chat; opening Chat clears it immediately.
- Agent-backed chat sessions now expose the same mailbox messaging tools (`fn_send_message`, `fn_read_messages`) used by runtime execution/heartbeat flows whenever the engine `MessageStore` is available; model-only chats continue to run without mailbox tools. - Agent-backed chat sessions now expose the same mailbox messaging tools (`fn_send_message`, `fn_read_messages`) used by runtime execution/heartbeat flows whenever the engine `MessageStore` is available; model-only chats continue to run without mailbox tools.
- Chat attachments are included in agent-visible prompts for both direct sessions and rooms: supported text attachments are appended under an `Attachments` prompt section, and supported images (`png`, `jpeg`, `gif`, `webp`) are passed as image inputs to the model. - Chat attachments are included in agent-visible prompts for both direct sessions and rooms: supported text attachments are appended under an `Attachments` prompt section, and supported images (`png`, `jpeg`, `gif`, `webp`) are passed as image inputs to the model.

View File

@@ -10,6 +10,7 @@ describe("parseQuestionToolCall", () => {
it("recognizes question tool names case-insensitively", () => { it("recognizes question tool names case-insensitively", () => {
expect(isQuestionToolName("AskUserQuestion")).toBe(true); expect(isQuestionToolName("AskUserQuestion")).toBe(true);
expect(isQuestionToolName("ASK_USER")).toBe(true); expect(isQuestionToolName("ASK_USER")).toBe(true);
expect(isQuestionToolName("fn_ask_question")).toBe(true);
expect(isQuestionToolName("grep")).toBe(false); expect(isQuestionToolName("grep")).toBe(false);
}); });
@@ -57,6 +58,24 @@ describe("parseQuestionToolCall", () => {
expect(parsed?.questions[0]?.id).toBe("q-0"); expect(parsed?.questions[0]?.id).toBe("q-0");
}); });
it("normalizes fn_ask_question across all supported question types", () => {
const parsed = parseQuestionToolCall(toolCall("fn_ask_question", {
questions: [
{ question: "Pick one", type: "single_select", options: [{ label: "Alpha" }] },
{ question: "Pick many", type: "multi_select", options: [{ label: "Beta", description: "Second" }] },
{ question: "Explain", type: "text", description: "Short answer is fine." },
{ question: "Proceed?", type: "confirm" },
],
}));
expect(parsed?.questions).toEqual([
expect.objectContaining({ id: "q-0", type: "single_select", question: "Pick one", options: [{ id: "opt-0", label: "Alpha", description: undefined }] }),
expect.objectContaining({ id: "q-1", type: "multi_select", question: "Pick many", multiSelect: true, options: [{ id: "opt-0", label: "Beta", description: "Second" }] }),
expect.objectContaining({ id: "q-2", type: "text", question: "Explain", description: "Short answer is fine." }),
expect.objectContaining({ id: "q-3", type: "confirm", question: "Proceed?" }),
]);
});
it("falls back for malformed, empty option select, and non-question tools", () => { it("falls back for malformed, empty option select, and non-question tools", () => {
expect(parseQuestionToolCall(toolCall("ask_user"))).toBeNull(); expect(parseQuestionToolCall(toolCall("ask_user"))).toBeNull();
expect(parseQuestionToolCall(toolCall("ask_user", { question: "" }))).toBeNull(); expect(parseQuestionToolCall(toolCall("ask_user", { question: "" }))).toBeNull();

View File

@@ -8,6 +8,7 @@ export const QUESTION_TOOL_NAMES = [
"request_user_input", "request_user_input",
"elicit", "elicit",
"ask_question", "ask_question",
"fn_ask_question",
] as const; ] as const;
const QUESTION_TOOL_NAME_SET = new Set(QUESTION_TOOL_NAMES.map((name) => name.toLowerCase())); const QUESTION_TOOL_NAME_SET = new Set(QUESTION_TOOL_NAMES.map((name) => name.toLowerCase()));
@@ -37,7 +38,7 @@ export type ChatQuestionAnswers = Record<string, ChatQuestionAnswerValue>;
/** /**
* FNXC:ChatQuestionResponse 2026-06-16-19:18: * FNXC:ChatQuestionResponse 2026-06-16-19:18:
* Chat question tools from multiple agent CLIs must render as structured response controls in both ChatView and QuickChatFAB instead of exposing raw JSON in generic tool-call details. * Chat question tools from multiple agent CLIs and Fusion's native `fn_ask_question` tool must render as structured response controls in both ChatView and QuickChatFAB instead of exposing raw JSON in generic tool-call details.
* Keep schema normalization centralized so both chat surfaces recognize the same question tools, synthesize stable ids, and fall back safely when args are malformed. * Keep schema normalization centralized so both chat surfaces recognize the same question tools, synthesize stable ids, and fall back safely when args are malformed.
*/ */
export function isQuestionToolName(name: string): boolean { export function isQuestionToolName(name: string): boolean {

View File

@@ -920,6 +920,7 @@ describe("ChatManager.sendMessage", () => {
})); }));
expect(createResolvedSession).toHaveBeenCalledWith(expect.objectContaining({ expect(createResolvedSession).toHaveBeenCalledWith(expect.objectContaining({
customTools: expect.arrayContaining([ customTools: expect.arrayContaining([
expect.objectContaining({ name: "fn_ask_question" }),
expect.objectContaining({ name: "fn_send_message" }), expect.objectContaining({ name: "fn_send_message" }),
expect.objectContaining({ name: "fn_read_messages" }), expect.objectContaining({ name: "fn_read_messages" }),
]), ]),
@@ -980,7 +981,7 @@ describe("ChatManager.sendMessage", () => {
})); }));
}); });
it("does not inject mailbox tools for non-agent chat sessions", async () => { it("injects ask-question but not mailbox tools for non-agent chat sessions", async () => {
mockChatStore.getSession.mockReturnValue({ mockChatStore.getSession.mockReturnValue({
id: "chat-001", id: "chat-001",
agentId: null, agentId: null,
@@ -1008,9 +1009,10 @@ describe("ChatManager.sendMessage", () => {
await chatManager.sendMessage("chat-001", "Hello"); await chatManager.sendMessage("chat-001", "Hello");
expect(createResolvedSession).toHaveBeenCalledWith(expect.not.objectContaining({ const customTools = createResolvedSession.mock.calls[0]?.[0]?.customTools ?? [];
customTools: expect.anything(), expect(customTools.map((tool: { name: string }) => tool.name)).toContain("fn_ask_question");
})); expect(customTools.map((tool: { name: string }) => tool.name)).not.toContain("fn_send_message");
expect(customTools.map((tool: { name: string }) => tool.name)).not.toContain("fn_read_messages");
}); });
it("uses the assigned built-in pi agent model when the chat session has no explicit model override", async () => { it("uses the assigned built-in pi agent model when the chat session has no explicit model override", async () => {

View File

@@ -43,6 +43,7 @@ import {
buildSessionSkillContextSync, buildSessionSkillContextSync,
createSendMessageTool, createSendMessageTool,
createReadMessagesTool, createReadMessagesTool,
createAskQuestionTool,
createWorkflowAuthoringTools, createWorkflowAuthoringTools,
} from "@fusion/engine"; } from "@fusion/engine";
import * as engineModule from "@fusion/engine"; import * as engineModule from "@fusion/engine";
@@ -133,6 +134,12 @@ export const CHAT_SYSTEM_PROMPT = `You are a helpful AI assistant integrated int
export const CHAT_AGENT_MESSAGE_ROUTING_GUIDANCE = `## Messaging Semantics\n\nYour chat reply is the primary response to the user. Do not also call \`fn_send_message\` with the same content just to mirror your chat response into mailbox.\n\nUse \`fn_send_message\` only when either (a) the user explicitly asks for mailbox/inbox/notification delivery (for example: "send me this in mail", "ntfy me when…", or "leave me a note in my inbox"), or (b) you are sending a genuinely longer follow-up that did not fit in a short chat reply. In either case, send with \`type: "agent-to-user"\` and target the dashboard user alias (\`to_id: "dashboard"\` is preferred), and ensure the mailbox message is additive rather than a duplicate of the chat reply. Never route that as a user/CLI → agent message.`; export const CHAT_AGENT_MESSAGE_ROUTING_GUIDANCE = `## Messaging Semantics\n\nYour chat reply is the primary response to the user. Do not also call \`fn_send_message\` with the same content just to mirror your chat response into mailbox.\n\nUse \`fn_send_message\` only when either (a) the user explicitly asks for mailbox/inbox/notification delivery (for example: "send me this in mail", "ntfy me when…", or "leave me a note in my inbox"), or (b) you are sending a genuinely longer follow-up that did not fit in a short chat reply. In either case, send with \`type: "agent-to-user"\` and target the dashboard user alias (\`to_id: "dashboard"\` is preferred), and ensure the mailbox message is additive rather than a duplicate of the chat reply. Never route that as a user/CLI → agent message.`;
/**
* FNXC:ChatAskQuestion 2026-06-17-13:17:
* Only the dashboard chat lane registers `fn_ask_question`, so append this guidance during sendMessage prompt assembly instead of baking it into room-responder prompts that do not receive the tool.
*/
export const CHAT_ASK_QUESTION_GUIDANCE = `## Asking the User\n\nWhen you need structured input, call \`fn_ask_question\` with one or more questions, then stop and wait for the user's next chat message.`;
/** Rate limiting window in milliseconds (1 minute) */ /** Rate limiting window in milliseconds (1 minute) */
const RATE_LIMIT_WINDOW_MS = 60 * 1000; const RATE_LIMIT_WINDOW_MS = 60 * 1000;
@@ -1618,6 +1625,7 @@ export class ChatManager {
diagnostics.warn(`Failed to build enriched system prompt for ${agent.id}: ${message}`); diagnostics.warn(`Failed to build enriched system prompt for ${agent.id}: ${message}`);
} }
} }
systemPrompt = `${systemPrompt}\n\n${CHAT_ASK_QUESTION_GUIDANCE}`;
if (agent) { if (agent) {
const runtimeModel = extractRuntimeModel(agent.runtimeConfig); const runtimeModel = extractRuntimeModel(agent.runtimeConfig);
@@ -1715,7 +1723,7 @@ export class ChatManager {
? createWorkflowAuthoringTools(this.taskStore, "", { stripApprovalFlags: true }) ? createWorkflowAuthoringTools(this.taskStore, "", { stripApprovalFlags: true })
: []; : [];
const customTools = [...messagingTools, ...workflowTools]; const customTools = [createAskQuestionTool(), ...messagingTools, ...workflowTools];
const sessionOptions = { const sessionOptions = {
cwd: this.rootDir, cwd: this.rootDir,

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { createAskQuestionTool } from "../agent-tools.js";
describe("createAskQuestionTool", () => {
async function execute(params: Parameters<ReturnType<typeof createAskQuestionTool>["execute"]>[1]) {
const tool = createAskQuestionTool();
return tool.execute("call-1", params, undefined as never, undefined as never, undefined as never);
}
it("creates the fn_ask_question tool", () => {
const tool = createAskQuestionTool();
expect(tool.name).toBe("fn_ask_question");
expect(tool.label).toBe("Ask User Question");
});
it("accepts valid single-question and multi-question payloads and tells the agent to wait", async () => {
const single = await execute({
questions: [{ question: "Which path should I take?", type: "single_select", options: [{ label: "A" }] }],
});
expect(single.isError).not.toBe(true);
expect(single.details).toEqual({ questionCount: 1 });
expect(single.content[0]?.type === "text" ? single.content[0].text : "").toContain("Stop and wait");
const multi = await execute({
questions: [
{ question: "Explain the goal", type: "text" },
{ question: "Proceed?", type: "confirm" },
],
});
expect(multi.isError).not.toBe(true);
expect(multi.details).toEqual({ questionCount: 2 });
expect(multi.content[0]?.type === "text" ? multi.content[0].text : "").toContain("next turn");
});
it("accepts text and confirm questions without options", async () => {
const text = await execute({ questions: [{ question: "What should I call it?", type: "text" }] });
const confirm = await execute({ questions: [{ question: "Should I continue?", type: "confirm" }] });
expect(text.isError).not.toBe(true);
expect(confirm.isError).not.toBe(true);
});
it("rejects empty question lists, blank question text, and optionless select questions", async () => {
await expect(execute({ questions: [] })).resolves.toMatchObject({ isError: true });
await expect(execute({ questions: [{ question: " ", type: "text" }] })).resolves.toMatchObject({ isError: true });
await expect(execute({ questions: [{ question: "Pick one", type: "single_select" }] })).resolves.toMatchObject({ isError: true });
await expect(execute({ questions: [{ question: "Pick many", type: "multi_select", options: [] }] })).resolves.toMatchObject({ isError: true });
await expect(execute({ questions: [{ question: "Pick many", multiSelect: true }] })).resolves.toMatchObject({ isError: true });
});
});

View File

@@ -301,6 +301,40 @@ export const postRoomMessageParams = Type.Object({
mentions: Type.Optional(Type.Array(Type.String(), { description: "Optional agent IDs to mention in the room message" })), mentions: Type.Optional(Type.Array(Type.String(), { description: "Optional agent IDs to mention in the room message" })),
}); });
export const askQuestionParams = Type.Object({
questions: Type.Array(
Type.Object({
question: Type.String({
description: "The question text shown to the user. Required and must be specific enough to answer.",
}),
header: Type.Optional(Type.String({
description: "Optional short heading for the question card, such as 'Decision needed'.",
})),
description: Type.Optional(Type.String({
description: "Optional helper text explaining why the answer is needed or how it will be used.",
})),
options: Type.Optional(Type.Array(Type.Object({
label: Type.String({ description: "Visible option label the user can choose." }),
description: Type.Optional(Type.String({ description: "Optional explanatory text for this option." })),
}), {
description: "Options for single_select, multi_select, or confirm questions. Select questions require at least one option.",
})),
multiSelect: Type.Optional(Type.Boolean({
description: "Set true when the user may choose multiple options. Prefer type='multi_select' for clarity.",
})),
type: Type.Optional(Type.Union([
Type.Literal("text"),
Type.Literal("single_select"),
Type.Literal("multi_select"),
Type.Literal("confirm"),
], {
description: "Question input type: free text, single option, multiple options, or yes/no confirmation.",
})),
}),
{ description: "One or more structured questions to present to the user." },
),
});
export const memorySearchParams = Type.Object({ export const memorySearchParams = Type.Object({
query: Type.String({ description: "Search terms for durable project memory. Use focused keywords, not a full prompt." }), query: Type.String({ description: "Search terms for durable project memory. Use focused keywords, not a full prompt." }),
limit: Type.Optional(Type.Number({ description: "Maximum snippets to return (default: 5, max: 20)" })), limit: Type.Optional(Type.Number({ description: "Maximum snippets to return (default: 5, max: 20)" })),
@@ -2871,6 +2905,66 @@ export function createDelegateTaskTool(
}; };
} }
type AskQuestionInput = Static<typeof askQuestionParams>;
function askQuestionError(message: string) {
return {
content: [{ type: "text" as const, text: `ERROR: ${message}` }],
details: {},
isError: true,
};
}
/**
* FNXC:ChatAskQuestion 2026-06-17-13:08:
* Dashboard chat agents need a provider-agnostic `fn_ask_question` tool that emits the FN-6501 structured question payload, renders through the existing chat question UI, and receives the answer through the normal next user message instead of a blocking tool response.
*
* Create a `fn_ask_question` tool that asks the dashboard user structured questions.
*
* @returns ToolDefinition for the `fn_ask_question` tool
*/
export function createAskQuestionTool(): ToolDefinition {
return {
name: "fn_ask_question",
label: "Ask User Question",
description:
"Ask the user a structured question (single-select, multi-select, free-text, or yes/no confirm). " +
"The question renders as an interactive card in chat. After calling this tool, end the turn and wait; " +
"the user's answer arrives as the next message.",
parameters: askQuestionParams,
execute: async (_id: string, params: AskQuestionInput) => {
if (!Array.isArray(params.questions) || params.questions.length === 0) {
return askQuestionError("questions must contain at least one question");
}
for (const [index, question] of params.questions.entries()) {
const questionText = typeof question.question === "string" ? question.question.trim() : "";
if (!questionText) {
return askQuestionError(`questions[${index}].question must be a non-empty string`);
}
const optionCount = Array.isArray(question.options)
? question.options.filter((option) => typeof option.label === "string" && option.label.trim().length > 0).length
: 0;
const requiresOptions = question.type === "single_select"
|| question.type === "multi_select"
|| question.multiSelect === true;
if (requiresOptions && optionCount === 0) {
return askQuestionError(`questions[${index}] select questions must include at least one option`);
}
}
return {
content: [{
type: "text" as const,
text: "Question presented to the user. Stop and wait for their reply on the next turn.",
}],
details: { questionCount: params.questions.length },
};
},
};
}
/** /**
* Create a `fn_send_message` tool that sends a message to another agent or user. * Create a `fn_send_message` tool that sends a message to another agent or user.
* *

View File

@@ -8,6 +8,7 @@ export {
createTaskLogTool, createTaskLogTool,
createSendMessageTool, createSendMessageTool,
createReadMessagesTool, createReadMessagesTool,
createAskQuestionTool,
createWorkflowListTool, createWorkflowListTool,
createWorkflowGetTool, createWorkflowGetTool,
createWorkflowSelectTool, createWorkflowSelectTool,
@@ -20,6 +21,7 @@ export {
taskDocumentReadParams, taskDocumentReadParams,
taskDocumentWriteParams, taskDocumentWriteParams,
taskLogParams, taskLogParams,
askQuestionParams,
workflowListParams, workflowListParams,
workflowSelectParams, workflowSelectParams,
executeApprovedAgentProvisioning, executeApprovedAgentProvisioning,