From 01b80db6034b1b24e2b35e9193a31e3054500fb2 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 17 Jun 2026 16:53:29 -0700 Subject: [PATCH] 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 --- .changeset/fn-6589-chat-ask-question.md | 5 + docs/dashboard-guide.md | 3 +- .../__tests__/parseQuestionToolCall.test.ts | 19 ++++ .../app/utils/parseQuestionToolCall.ts | 3 +- .../src/__tests__/chat-manager.test.ts | 10 +- packages/dashboard/src/chat.ts | 10 +- .../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(-) create mode 100644 .changeset/fn-6589-chat-ask-question.md create mode 100644 packages/engine/src/__tests__/agent-tools-ask-question.test.ts diff --git a/.changeset/fn-6589-chat-ask-question.md b/.changeset/fn-6589-chat-ask-question.md new file mode 100644 index 0000000000..86897197f5 --- /dev/null +++ b/.changeset/fn-6589-chat-ask-question.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index a433c1a45d..50baa441ce 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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. - 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. -- 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. + +- 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. - 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. diff --git a/packages/dashboard/app/utils/__tests__/parseQuestionToolCall.test.ts b/packages/dashboard/app/utils/__tests__/parseQuestionToolCall.test.ts index 8aeb9d67a6..a53fb37e2c 100644 --- a/packages/dashboard/app/utils/__tests__/parseQuestionToolCall.test.ts +++ b/packages/dashboard/app/utils/__tests__/parseQuestionToolCall.test.ts @@ -10,6 +10,7 @@ describe("parseQuestionToolCall", () => { it("recognizes question tool names case-insensitively", () => { expect(isQuestionToolName("AskUserQuestion")).toBe(true); expect(isQuestionToolName("ASK_USER")).toBe(true); + expect(isQuestionToolName("fn_ask_question")).toBe(true); expect(isQuestionToolName("grep")).toBe(false); }); @@ -57,6 +58,24 @@ describe("parseQuestionToolCall", () => { 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", () => { expect(parseQuestionToolCall(toolCall("ask_user"))).toBeNull(); expect(parseQuestionToolCall(toolCall("ask_user", { question: "" }))).toBeNull(); diff --git a/packages/dashboard/app/utils/parseQuestionToolCall.ts b/packages/dashboard/app/utils/parseQuestionToolCall.ts index beb529df08..c5885d757b 100644 --- a/packages/dashboard/app/utils/parseQuestionToolCall.ts +++ b/packages/dashboard/app/utils/parseQuestionToolCall.ts @@ -8,6 +8,7 @@ export const QUESTION_TOOL_NAMES = [ "request_user_input", "elicit", "ask_question", + "fn_ask_question", ] as const; const QUESTION_TOOL_NAME_SET = new Set(QUESTION_TOOL_NAMES.map((name) => name.toLowerCase())); @@ -37,7 +38,7 @@ export type ChatQuestionAnswers = Record; /** * 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. */ export function isQuestionToolName(name: string): boolean { diff --git a/packages/dashboard/src/__tests__/chat-manager.test.ts b/packages/dashboard/src/__tests__/chat-manager.test.ts index 2240e9e4fb..4bdc369f79 100644 --- a/packages/dashboard/src/__tests__/chat-manager.test.ts +++ b/packages/dashboard/src/__tests__/chat-manager.test.ts @@ -920,6 +920,7 @@ describe("ChatManager.sendMessage", () => { })); expect(createResolvedSession).toHaveBeenCalledWith(expect.objectContaining({ customTools: expect.arrayContaining([ + expect.objectContaining({ name: "fn_ask_question" }), expect.objectContaining({ name: "fn_send_message" }), 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({ id: "chat-001", agentId: null, @@ -1008,9 +1009,10 @@ describe("ChatManager.sendMessage", () => { await chatManager.sendMessage("chat-001", "Hello"); - expect(createResolvedSession).toHaveBeenCalledWith(expect.not.objectContaining({ - customTools: expect.anything(), - })); + const customTools = createResolvedSession.mock.calls[0]?.[0]?.customTools ?? []; + 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 () => { diff --git a/packages/dashboard/src/chat.ts b/packages/dashboard/src/chat.ts index 3dc2bc215b..08b8cf27f9 100644 --- a/packages/dashboard/src/chat.ts +++ b/packages/dashboard/src/chat.ts @@ -43,6 +43,7 @@ import { buildSessionSkillContextSync, createSendMessageTool, createReadMessagesTool, + createAskQuestionTool, createWorkflowAuthoringTools, } 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.`; +/** + * 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) */ 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}`); } } + systemPrompt = `${systemPrompt}\n\n${CHAT_ASK_QUESTION_GUIDANCE}`; if (agent) { const runtimeModel = extractRuntimeModel(agent.runtimeConfig); @@ -1715,7 +1723,7 @@ export class ChatManager { ? createWorkflowAuthoringTools(this.taskStore, "", { stripApprovalFlags: true }) : []; - const customTools = [...messagingTools, ...workflowTools]; + const customTools = [createAskQuestionTool(), ...messagingTools, ...workflowTools]; const sessionOptions = { cwd: this.rootDir, diff --git a/packages/engine/src/__tests__/agent-tools-ask-question.test.ts b/packages/engine/src/__tests__/agent-tools-ask-question.test.ts new file mode 100644 index 0000000000..0b2dbe3a99 --- /dev/null +++ b/packages/engine/src/__tests__/agent-tools-ask-question.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { createAskQuestionTool } from "../agent-tools.js"; + +describe("createAskQuestionTool", () => { + async function execute(params: Parameters["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 }); + }); +}); diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index fe564debbf..6a6b2cfcb3 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -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" })), }); +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({ 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)" })), @@ -2871,6 +2905,66 @@ export function createDelegateTaskTool( }; } +type AskQuestionInput = Static; + +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. * diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 6765f8b3b2..9593491ebc 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -8,6 +8,7 @@ export { createTaskLogTool, createSendMessageTool, createReadMessagesTool, + createAskQuestionTool, createWorkflowListTool, createWorkflowGetTool, createWorkflowSelectTool, @@ -20,6 +21,7 @@ export { taskDocumentReadParams, taskDocumentWriteParams, taskLogParams, + askQuestionParams, workflowListParams, workflowSelectParams, executeApprovedAgentProvisioning,