Reuse the existing question-response modal UI inside task-detail planner Chat clarification flows. - Render planner fn_ask_question tool calls with ChatQuestionResponse, including answered read-only and duplicate-pending states. - Preserve steering-tool confirmations while sending submitted clarification answers back through planner Chat. - Extend parser and modal tests for multi-question payloads, submitted answers, duplicate prompts, and empty dependency labels. - Add a patch changeset for the published Fusion package. Files changed: .changeset/fn-7314-planner-question-ui.md | 7 ++ .../app/components/TaskPlannerChatTab.css | 9 +++ .../app/components/TaskPlannerChatTab.tsx | 76 ++++++++++++++++-- ...etailModal.responsive-and-dependencies.test.tsx | 3 + .../__tests__/TaskPlannerChatTab.test.tsx | 89 ++++++++++++++++++---- .../utils/__tests__/parseQuestionToolCall.test.ts | 10 ++- .../dashboard/app/utils/parseQuestionToolCall.ts | 6 +- 7 files changed, 176 insertions(+), 24 deletions(-) Fusion-Task-Id: FN-7314 Fusion-Task-Lineage: bd5da323-0ba1-4094-a965-cc2801a1c9b7 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
110 lines
5.2 KiB
TypeScript
110 lines
5.2 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("read", { question: "No" }))).toBeNull();
|
|
});
|
|
|
|
it("degrades explicit select questions with missing options to answerable text prompts", () => {
|
|
expect(parseQuestionToolCall(toolCall("fn_ask_question", { question: "Pick", type: "single_select" }))?.questions[0]).toEqual(
|
|
expect.objectContaining({ id: "q-0", type: "text", question: "Pick", options: undefined }),
|
|
);
|
|
expect(parseQuestionToolCall(toolCall("fn_ask_question", { question: "Pick many", type: "multi_select", options: [] }))?.questions[0]).toEqual(
|
|
expect.objectContaining({ id: "q-0", type: "text", question: "Pick many", options: undefined }),
|
|
);
|
|
});
|
|
|
|
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",
|
|
);
|
|
});
|
|
});
|