FN-7379: render planner questions outside tool groups

Make planner clarification prompts stay answerable when assistant messages include other tool calls.

- Extract fn_ask_question cards before grouping generic chat tool calls.
- Keep non-question tools in the existing grouped tool-call details.
- Cover persisted and streamed mixed-tool planner transcripts with actionable question assertions.
- Add a patch changeset for the planner-chat fix.

Files changed:
 .changeset/fn-7379-planner-question-cards.md       |  7 +++
 .../app/components/StandardChatSurface.tsx         | 73 +++++++++++++++-------
 .../app/components/TaskPlannerChatTab.tsx          |  3 +
 .../__tests__/TaskPlannerChatTab.test.tsx          | 48 ++++++++++++--
 4 files changed, 103 insertions(+), 28 deletions(-)

Fusion-Task-Id: FN-7379

Fusion-Task-Lineage: 5e88f991-cbcf-49d8-a815-fc44d49bad10

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-01 10:45:19 -07:00
parent 37027632e0
commit e341c0673f
4 changed files with 103 additions and 28 deletions

View File

@@ -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.

View File

@@ -183,6 +183,10 @@ export function renderStandardToolCalls(
}, },
): ReactNode { ): ReactNode {
if (!toolCalls || toolCalls.length === 0) return null; 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 renderToolCallItem = (toolCall: ToolCallInfo, index: number) => {
const custom = options?.toolCallRenderer?.(toolCall, index); const custom = options?.toolCallRenderer?.(toolCall, index);
if (custom !== undefined) return custom; if (custom !== undefined) return custom;
@@ -221,29 +225,54 @@ export function renderStandardToolCalls(
</details> </details>
); );
}; };
if (toolCalls.length === 1) { const questionEntries: ReactNode[] = [];
return <div className="chat-tool-calls" data-testid="chat-tool-calls"><div className="chat-tool-calls-header"><span className="chat-tool-calls-header-icon" aria-hidden="true">•</span><span>{t("chat.toolCallsHeader", "Tool calls")}</span></div>{renderToolCallItem(toolCalls[0], 0)}</div>; const nonQuestionEntries: Array<{ toolCall: ToolCallInfo; index: number }> = [];
} toolCalls.forEach((toolCall, index) => {
const runningCount = toolCalls.filter((toolCall) => toolCall.status === "running").length; if (parseQuestionToolCall(toolCall)) {
const errorCount = toolCalls.filter((toolCall) => toolCall.status === "completed" && toolCall.isError).length; const renderedQuestion = renderToolCallItem(toolCall, index);
const hasRunning = runningCount > 0; if (renderedQuestion !== null && renderedQuestion !== undefined && renderedQuestion !== false) {
const uniqueNames = Array.from(new Set(toolCalls.map((toolCall) => toolCall.toolName))); questionEntries.push(renderedQuestion);
const visibleNames = uniqueNames.slice(0, 5); }
const overflowCount = Math.max(0, uniqueNames.length - visibleNames.length); return;
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; nonQuestionEntries.push({ toolCall, index });
});
const renderNonQuestionToolCalls = (): ReactNode => {
if (nonQuestionEntries.length === 0) return null;
if (nonQuestionEntries.length === 1) {
const entry = nonQuestionEntries[0]!;
return <div className="chat-tool-calls" data-testid="chat-tool-calls"><div className="chat-tool-calls-header"><span className="chat-tool-calls-header-icon" aria-hidden="true">•</span><span>{t("chat.toolCallsHeader", "Tool calls")}</span></div>{renderToolCallItem(entry.toolCall, entry.index)}</div>;
}
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 (
<div className="chat-tool-calls" data-testid="chat-tool-calls">
<details className="chat-tool-calls-group" data-testid="chat-tool-calls-group" open={hasRunning}>
<summary className="chat-tool-calls-group-summary">
<span className="chat-tool-calls-header-icon" aria-hidden="true">•</span>
<span className="chat-tool-calls-count">{t("chat.toolCallsCount", "{{count}} tool calls", { count: nonQuestionToolCalls.length })}</span>
<span className="chat-tool-calls-names" title={namesSummary}>{namesSummary}</span>
{statusSummary && <span className="chat-tool-calls-group-status">{statusSummary}</span>}
</summary>
{nonQuestionEntries.map(({ toolCall, index }) => renderToolCallItem(toolCall, index))}
</details>
</div>
);
};
const renderedNonQuestionToolCalls = renderNonQuestionToolCalls();
if (questionEntries.length === 0) return renderedNonQuestionToolCalls;
return ( return (
<div className="chat-tool-calls" data-testid="chat-tool-calls"> <>
<details className="chat-tool-calls-group" data-testid="chat-tool-calls-group" open={hasRunning}> {questionEntries}
<summary className="chat-tool-calls-group-summary"> {renderedNonQuestionToolCalls}
<span className="chat-tool-calls-header-icon" aria-hidden="true">•</span> </>
<span className="chat-tool-calls-count">{t("chat.toolCallsCount", "{{count}} tool calls", { count: toolCalls.length })}</span>
<span className="chat-tool-calls-names" title={namesSummary}>{namesSummary}</span>
{statusSummary && <span className="chat-tool-calls-group-status">{statusSummary}</span>}
</summary>
{toolCalls.map((toolCall, index) => renderToolCallItem(toolCall, index))}
</details>
</div>
); );
} }

View File

@@ -691,6 +691,9 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
FNXC:TaskDetailPlannerChat 2026-06-30-23:59: 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. 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: 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. 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.

View File

@@ -847,17 +847,32 @@ describe("TaskPlannerChatTab", () => {
expect(screen.queryByTestId("task-planner-chat-empty")).not.toBeInTheDocument(); 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(); const user = userEvent.setup();
mockFetchChatMessages.mockResolvedValue({ 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(); 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-option-q-0-opt-0"));
await user.click(screen.getByTestId("chat-question-response-submit")); await user.click(screen.getByTestId("chat-question-response-submit"));
expect(mockStreamChatResponse).toHaveBeenCalledTimes(1);
expect(mockStreamChatResponse).toHaveBeenCalledWith( expect(mockStreamChatResponse).toHaveBeenCalledWith(
"chat-planner", "chat-planner",
"> Q: Pick a path\nConservative", "> Q: Pick a path\nConservative",
@@ -962,11 +977,13 @@ describe("TaskPlannerChatTab", () => {
expect(mockFetchTaskDetail).not.toHaveBeenCalled(); 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 user = userEvent.setup();
const onTaskUpdated = vi.fn(); const onTaskUpdated = vi.fn();
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => { mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
setTimeout(() => { 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.onToolStart({ toolName: "fn_ask_question", args: { question: "Which files and safety constraints should this destructive change use?", options: ["Clarify scope", "Cancel"] } });
handlers.onDone({ handlers.onDone({
messageId: "assistant-risky-question", messageId: "assistant-risky-question",
@@ -976,7 +993,12 @@ describe("TaskPlannerChatTab", () => {
role: "assistant", role: "assistant",
content: "I need clarification before adding steering.", content: "I need clarification before adding steering.",
thinkingOutput: null, 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", 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.type(screen.getByLabelText("Message planner chat"), "Delete the risky parts and rewrite the security flow broadly");
await user.click(screen.getByRole("button", { name: "Send" })); 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")).toHaveLength(1);
expect(screen.getAllByTestId("chat-question-response-submit")).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(screen.queryByTestId("task-planner-chat-steering-confirmation")).not.toBeInTheDocument();
expect(mockFetchTaskDetail).not.toHaveBeenCalled(); expect(mockFetchTaskDetail).not.toHaveBeenCalled();
expect(onTaskUpdated).not.toHaveBeenCalled(); expect(onTaskUpdated).not.toHaveBeenCalled();