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 {
|
||||
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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user