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 {
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(
</details>
);
};
if (toolCalls.length === 1) {
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 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 <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 (
<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: 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>
<>
{questionEntries}
{renderedNonQuestionToolCalls}
</>
);
}

View File

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

View File

@@ -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();