Files
fusion/packages/dashboard/app/utils/__tests__/parseQuestionToolCall.test.ts
gsxdsm 01b80db603 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
2026-06-17 16:53:29 -07:00

102 lines
4.7 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { ToolCallInfo } from "../../hooks/chatTypes";
import { formatQuestionAnswer, isQuestionToolName, parseQuestionToolCall } from "../parseQuestionToolCall";
function toolCall(toolName: string, args?: Record<string, unknown>): ToolCallInfo {
return { toolName, args, isError: false, status: "completed" };
}
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);
});
it("normalizes Claude AskUserQuestion multi-question args", () => {
const parsed = parseQuestionToolCall(toolCall("AskUserQuestion", {
questions: [
{ question: "Pick one", header: "Decision", options: [{ label: "A" }, { label: "B", description: "Bee" }] },
{ id: "features", question: "Pick many", options: [{ id: "x", label: "X" }, { label: "Y" }], multiSelect: true },
],
}));
expect(parsed).toEqual({
questions: [
{
id: "q-0",
type: "single_select",
question: "Pick one",
header: "Decision",
description: undefined,
options: [{ id: "opt-0", label: "A", description: undefined }, { id: "opt-1", label: "B", description: "Bee" }],
multiSelect: undefined,
},
{
id: "features",
type: "multi_select",
question: "Pick many",
header: undefined,
description: undefined,
options: [{ id: "x", label: "X", description: undefined }, { id: "opt-1", label: "Y", description: undefined }],
multiSelect: true,
},
],
});
});
it.each([
["ask_user", { question: "Continue?", options: ["Yes", "No"] }, "confirm"],
["request_user_input", { prompt: "Name?" }, "text"],
["elicit", { message: "Choose", choices: [{ value: "a", label: "Alpha" }] }, "single_select"],
["ask_followup_question", { question: "Boolean?", type: "boolean" }, "confirm"],
] as const)("normalizes %s common schema", (name, args, expectedType) => {
const parsed = parseQuestionToolCall(toolCall(name, args));
expect(parsed?.questions).toHaveLength(1);
expect(parsed?.questions[0]?.type).toBe(expectedType);
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();
expect(parseQuestionToolCall(toolCall("ask_user", { question: "Pick", type: "single_select", options: [] }))).toBeNull();
expect(parseQuestionToolCall(toolCall("read", { question: "No" }))).toBeNull();
});
it("formats selected labels, text, and confirm answers", () => {
const parsed = parseQuestionToolCall(toolCall("AskUserQuestion", {
questions: [
{ id: "one", question: "Pick one", options: [{ id: "a", label: "Alpha" }] },
{ id: "many", question: "Pick many", options: [{ id: "x", label: "X" }, { id: "y", label: "Y" }], multiSelect: true },
{ id: "text", question: "Explain" },
{ id: "ok", question: "Proceed?", type: "confirm" },
],
}));
expect(parsed).not.toBeNull();
expect(formatQuestionAnswer(parsed!.questions, { one: "a", many: ["x", "y"], text: "Because", ok: false })).toBe(
"> Q: Pick one\nAlpha\n\n> Q: Pick many\nX, Y\n\n> Q: Explain\nBecause\n\n> Q: Proceed?\nNo",
);
});
});