diff --git a/.changeset/fn-7379-planner-question-cards.md b/.changeset/fn-7379-planner-question-cards.md new file mode 100644 index 0000000000..56c97da663 --- /dev/null +++ b/.changeset/fn-7379-planner-question-cards.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Make Planner Chat clarification questions answerable in task details. +category: fix +dev: Extracts fn_ask_question cards from grouped tool-call details in shared chat rendering. diff --git a/packages/dashboard/app/components/StandardChatSurface.tsx b/packages/dashboard/app/components/StandardChatSurface.tsx index f443a7bf16..7d3a6faddc 100644 --- a/packages/dashboard/app/components/StandardChatSurface.tsx +++ b/packages/dashboard/app/components/StandardChatSurface.tsx @@ -183,6 +183,10 @@ export function renderStandardToolCalls( }, ): ReactNode { if (!toolCalls || toolCalls.length === 0) return null; + /* + FNXC:StandardChatSurface 2026-07-01-09:20: + Planner Chat and regular Chat must surface `fn_ask_question` as an actionable ChatQuestionResponse even when the model also calls tools such as `bash`. Question cards render outside the collapsed grouped tool-call details so users are not stranded on summary text, while non-question tools keep their generic visibility. + */ const renderToolCallItem = (toolCall: ToolCallInfo, index: number) => { const custom = options?.toolCallRenderer?.(toolCall, index); if (custom !== undefined) return custom; @@ -221,29 +225,54 @@ export function renderStandardToolCalls( ); }; - if (toolCalls.length === 1) { - return
{t("chat.toolCallsHeader", "Tool calls")}
{renderToolCallItem(toolCalls[0], 0)}
; - } - const runningCount = toolCalls.filter((toolCall) => toolCall.status === "running").length; - const errorCount = toolCalls.filter((toolCall) => toolCall.status === "completed" && toolCall.isError).length; - const hasRunning = runningCount > 0; - const uniqueNames = Array.from(new Set(toolCalls.map((toolCall) => toolCall.toolName))); - const visibleNames = uniqueNames.slice(0, 5); - const overflowCount = Math.max(0, uniqueNames.length - visibleNames.length); - const namesSummary = overflowCount > 0 ? `${visibleNames.join(", ")}, +${overflowCount} more` : visibleNames.join(", "); - const statusSummary = hasRunning ? `(${runningCount} ${t("chat.toolCallStatusRunning", "running")})` : errorCount > 0 ? `(${errorCount} ${errorCount === 1 ? t("chat.toolCallStatusError", "error") : t("chat.toolCallStatusErrors", "errors")})` : null; + const questionEntries: ReactNode[] = []; + const nonQuestionEntries: Array<{ toolCall: ToolCallInfo; index: number }> = []; + toolCalls.forEach((toolCall, index) => { + if (parseQuestionToolCall(toolCall)) { + const renderedQuestion = renderToolCallItem(toolCall, index); + if (renderedQuestion !== null && renderedQuestion !== undefined && renderedQuestion !== false) { + questionEntries.push(renderedQuestion); + } + return; + } + nonQuestionEntries.push({ toolCall, index }); + }); + const renderNonQuestionToolCalls = (): ReactNode => { + if (nonQuestionEntries.length === 0) return null; + if (nonQuestionEntries.length === 1) { + const entry = nonQuestionEntries[0]!; + return
{t("chat.toolCallsHeader", "Tool calls")}
{renderToolCallItem(entry.toolCall, entry.index)}
; + } + const nonQuestionToolCalls = nonQuestionEntries.map((entry) => entry.toolCall); + const runningCount = nonQuestionToolCalls.filter((toolCall) => toolCall.status === "running").length; + const errorCount = nonQuestionToolCalls.filter((toolCall) => toolCall.status === "completed" && toolCall.isError).length; + const hasRunning = runningCount > 0; + const uniqueNames = Array.from(new Set(nonQuestionToolCalls.map((toolCall) => toolCall.toolName))); + const visibleNames = uniqueNames.slice(0, 5); + const overflowCount = Math.max(0, uniqueNames.length - visibleNames.length); + const namesSummary = overflowCount > 0 ? `${visibleNames.join(", ")}, +${overflowCount} more` : visibleNames.join(", "); + const statusSummary = hasRunning ? `(${runningCount} ${t("chat.toolCallStatusRunning", "running")})` : errorCount > 0 ? `(${errorCount} ${errorCount === 1 ? t("chat.toolCallStatusError", "error") : t("chat.toolCallStatusErrors", "errors")})` : null; + return ( +
+
+ + + {t("chat.toolCallsCount", "{{count}} tool calls", { count: nonQuestionToolCalls.length })} + {namesSummary} + {statusSummary && {statusSummary}} + + {nonQuestionEntries.map(({ toolCall, index }) => renderToolCallItem(toolCall, index))} +
+
+ ); + }; + const renderedNonQuestionToolCalls = renderNonQuestionToolCalls(); + if (questionEntries.length === 0) return renderedNonQuestionToolCalls; return ( -
-
- - - {t("chat.toolCallsCount", "{{count}} tool calls", { count: toolCalls.length })} - {namesSummary} - {statusSummary && {statusSummary}} - - {toolCalls.map((toolCall, index) => renderToolCallItem(toolCall, index))} -
-
+ <> + {questionEntries} + {renderedNonQuestionToolCalls} + ); } diff --git a/packages/dashboard/app/components/TaskPlannerChatTab.tsx b/packages/dashboard/app/components/TaskPlannerChatTab.tsx index 319eb03764..b71a0f66f8 100644 --- a/packages/dashboard/app/components/TaskPlannerChatTab.tsx +++ b/packages/dashboard/app/components/TaskPlannerChatTab.tsx @@ -691,6 +691,9 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false, FNXC:TaskDetailPlannerChat 2026-06-30-23:59: Planner-generated clarification questions in the task-detail Chat transcript must reuse ChatQuestionResponse instead of bespoke chat text. Submitted answers stay in the planner-chat lane as ordinary follow-up user messages, render the prior question read-only, and duplicate refetched pending tool calls hide older live forms so users never see competing submit affordances. + FNXC:TaskDetailPlannerChat 2026-07-01-09:20: + Task-detail Planner Chat must keep `fn_ask_question` actionable when streamed or persisted alongside other tools such as `bash`. The planner renderer owns task-scoped answer submission and dedupe while StandardChatSurface extracts the question card outside grouped tool-call details. + FNXC:TaskDetailPlannerChat 2026-07-01-09:34: Planner Chat delegates transcript bubbles, thinking details, tool-call framing, and mobile send/stop gestures to StandardChatSurface. TaskPlannerChatTab keeps lookup-only session loading, task-context sends, starter prompts, and steering confirmations local so reuse does not collapse the lazy ChatView chunk or merge planner chat with Activity. diff --git a/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx index 1d5bf3af97..ff1866359b 100644 --- a/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx @@ -847,17 +847,32 @@ describe("TaskPlannerChatTab", () => { expect(screen.queryByTestId("task-planner-chat-empty")).not.toBeInTheDocument(); }); - it("renders planner question tool calls with the shared answer UI", async () => { + it("renders mixed persisted planner question tool calls with the shared answer UI outside collapsed details", async () => { const user = userEvent.setup(); mockFetchChatMessages.mockResolvedValue({ - messages: [plannerQuestionMessage("assistant-question", { question: "Pick a path", options: ["Conservative", "Aggressive"] })], + messages: [{ + ...plannerQuestionMessage("assistant-question", { question: "Pick a path", options: ["Conservative", "Aggressive"] }), + metadata: { + toolCalls: [ + { toolName: "bash", args: { command: "echo hi" }, isError: false, result: "hi", status: "completed" }, + { toolName: "fn_ask_question", args: { question: "Pick a path", options: ["Conservative", "Aggressive"] }, isError: false, status: "completed" }, + ], + }, + }], }); renderPlannerChat(); - expect(await screen.findByTestId("chat-question-response")).toBeInTheDocument(); + const transcript = await screen.findByTestId("task-planner-chat-transcript"); + const question = await screen.findByTestId("chat-question-response"); + expect(transcript).toContainElement(question); + expect(question).toHaveTextContent("Pick a path"); + expect(question.closest("details.chat-tool-calls-group")).toBeNull(); + expect(screen.getByTestId("chat-tool-calls")).toHaveTextContent("bash"); + expect(screen.getByTestId("chat-tool-calls")).not.toHaveTextContent("fn_ask_question"); await user.click(screen.getByTestId("chat-question-response-option-q-0-opt-0")); await user.click(screen.getByTestId("chat-question-response-submit")); + expect(mockStreamChatResponse).toHaveBeenCalledTimes(1); expect(mockStreamChatResponse).toHaveBeenCalledWith( "chat-planner", "> Q: Pick a path\nConservative", @@ -962,11 +977,13 @@ describe("TaskPlannerChatTab", () => { expect(mockFetchTaskDetail).not.toHaveBeenCalled(); }); - it("streams risky change requests as clarification questions without steering mutation", async () => { + it("streams mixed planner question tool calls as an actionable card outside collapsed details", async () => { const user = userEvent.setup(); const onTaskUpdated = vi.fn(); mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => { setTimeout(() => { + handlers.onToolStart({ toolName: "bash", args: { command: "echo preparing" } }); + handlers.onToolEnd({ toolName: "bash", isError: false, result: "preparing" }); handlers.onToolStart({ toolName: "fn_ask_question", args: { question: "Which files and safety constraints should this destructive change use?", options: ["Clarify scope", "Cancel"] } }); handlers.onDone({ messageId: "assistant-risky-question", @@ -976,7 +993,12 @@ describe("TaskPlannerChatTab", () => { role: "assistant", content: "I need clarification before adding steering.", thinkingOutput: null, - metadata: { toolCalls: [{ toolName: "fn_ask_question", args: { question: "Which files and safety constraints should this destructive change use?", options: ["Clarify scope", "Cancel"] }, isError: false }] }, + metadata: { + toolCalls: [ + { toolName: "bash", args: { command: "echo preparing" }, isError: false, result: "preparing", status: "completed" }, + { toolName: "fn_ask_question", args: { question: "Which files and safety constraints should this destructive change use?", options: ["Clarify scope", "Cancel"] }, isError: false, status: "completed" }, + ], + }, createdAt: "2026-06-30T00:03:00.000Z", }, }); @@ -989,9 +1011,23 @@ describe("TaskPlannerChatTab", () => { await user.type(screen.getByLabelText("Message planner chat"), "Delete the risky parts and rewrite the security flow broadly"); await user.click(screen.getByRole("button", { name: "Send" })); - expect(await screen.findByTestId("chat-question-response")).toHaveTextContent("Which files and safety constraints should this destructive change use?"); + const question = await screen.findByTestId("chat-question-response"); + expect(question).toHaveTextContent("Which files and safety constraints should this destructive change use?"); + expect(question.closest("details.chat-tool-calls-group")).toBeNull(); expect(screen.getAllByTestId("chat-question-response")).toHaveLength(1); expect(screen.getAllByTestId("chat-question-response-submit")).toHaveLength(1); + expect(screen.getByTestId("chat-tool-calls")).toHaveTextContent("bash"); + expect(screen.getByTestId("chat-tool-calls")).not.toHaveTextContent("fn_ask_question"); + await user.click(screen.getByTestId("chat-question-response-option-q-0-opt-0")); + await user.click(screen.getByTestId("chat-question-response-submit")); + expect(mockStreamChatResponse).toHaveBeenLastCalledWith( + "chat-planner", + "> Q: Which files and safety constraints should this destructive change use?\nClarify scope", + expect.any(Object), + undefined, + "project-1", + { taskId: "FN-7310" }, + ); expect(screen.queryByTestId("task-planner-chat-steering-confirmation")).not.toBeInTheDocument(); expect(mockFetchTaskDetail).not.toHaveBeenCalled(); expect(onTaskUpdated).not.toHaveBeenCalled();