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:
7
.changeset/fn-7379-planner-question-cards.md
Normal file
7
.changeset/fn-7379-planner-question-cards.md
Normal 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.
|
||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user