FN-7982: clear stale planner chat streaming on fresh reply
Fix task chat showing a previous agent message while a new reply is generating by clearing streaming carriers on fresh generations. - Clear streamingThinking and the streaming-assistant row when starting a generation without an in-flight snapshot - Preserve text/thinking/tool restore only when attaching to a live in-flight generation - Add regression coverage for consecutive replies that must not reuse prior stream content - Add patch changeset for the planner chat stale-message fix Files changed: .changeset/fn-7982-planner-chat-stale-message.md | 7 ++++ .../app/components/TaskPlannerChatTab.tsx | 19 ++++++++-- .../__tests__/TaskPlannerChatTab.test.tsx | 44 ++++++++++++++++++++++ 3 files changed, 67 insertions(+), 3 deletions(-) Fusion-Task-Id: FN-7982 Fusion-Task-Lineage: 71daaa80-6d66-4940-a656-a2c42a6b03bb Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7982-planner-chat-stale-message.md
Normal file
7
.changeset/fn-7982-planner-chat-stale-message.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Fix task chat showing a stale agent message while generating a new reply.
|
||||
category: fix
|
||||
dev: TaskPlannerChatTab now clears streamingThinking and the streaming-assistant row on fresh generations while preserving attach-to-in-flight snapshots.
|
||||
@@ -398,9 +398,22 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
|
||||
}) => {
|
||||
const { resolvedSessionId, content = "", inFlightGeneration, requestId, attach } = options;
|
||||
const isCurrentStreamRequest = () => streamRequestRef.current === requestId;
|
||||
let accumulated = inFlightGeneration?.streamingText ?? "";
|
||||
let accumulatedThinking = inFlightGeneration?.streamingThinking ?? "";
|
||||
const streamingToolCalls = cloneToolCalls(inFlightGeneration?.toolCalls);
|
||||
const inFlightSnapshot = attach ? inFlightGeneration : null;
|
||||
let accumulated = inFlightSnapshot?.streamingText ?? "";
|
||||
let accumulatedThinking = inFlightSnapshot?.streamingThinking ?? "";
|
||||
const streamingToolCalls = cloneToolCalls(inFlightSnapshot?.toolCalls);
|
||||
|
||||
/*
|
||||
* FNXC:TaskDetailPlannerChat 2026-07-15-00:00:
|
||||
* A fresh reply, and an attach without a persisted in-flight snapshot, must start with empty
|
||||
* streaming carriers so no previous turn appears before its first event. Only an attach with a
|
||||
* valid snapshot restores text, thinking, and tools because that data belongs to the still-live
|
||||
* generation the user is returning to.
|
||||
*/
|
||||
if (!inFlightSnapshot) {
|
||||
setStreamingThinking("");
|
||||
setMessages((current) => current.filter((message) => message.id !== "streaming-assistant"));
|
||||
}
|
||||
|
||||
streamRef.current?.close();
|
||||
if (!isCurrentStreamRequest()) return;
|
||||
|
||||
@@ -630,6 +630,50 @@ describe("TaskPlannerChatTab", () => {
|
||||
await waitFor(() => expect(screen.getByText("Hello")).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("clears a completed turn's streaming carriers before a delayed consecutive reply", async () => {
|
||||
const user = userEvent.setup();
|
||||
const streamHandlers: any[] = [];
|
||||
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
|
||||
streamHandlers.push(handlers);
|
||||
return { close: vi.fn(), isConnected: () => true };
|
||||
});
|
||||
renderPlannerChat();
|
||||
await screen.findByTestId("task-planner-chat-empty");
|
||||
|
||||
const input = screen.getByLabelText("Message planner chat");
|
||||
await user.type(input, "First reply");
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
act(() => {
|
||||
streamHandlers[0].onText("Previous transient reply");
|
||||
streamHandlers[0].onThinking("Previous transient thinking");
|
||||
streamHandlers[0].onDone({
|
||||
messageId: "assistant-first",
|
||||
message: {
|
||||
id: "assistant-first",
|
||||
sessionId: "chat-planner",
|
||||
role: "assistant",
|
||||
content: "Previous completed reply",
|
||||
thinkingOutput: "Previous completed thinking",
|
||||
metadata: null,
|
||||
createdAt: "2026-06-30T00:03:00.000Z",
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(await screen.findByText("Previous completed reply")).toBeInTheDocument();
|
||||
|
||||
await user.type(input, "Second delayed reply");
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
|
||||
// The prior persisted turn remains in the transcript, but the new live bubble must be empty
|
||||
// until this generation supplies its own first event.
|
||||
const streamingBubble = screen.getByTestId("chat-message-__streaming__");
|
||||
expect(streamingBubble).toHaveTextContent("Working…");
|
||||
expect(streamingBubble).not.toHaveTextContent("Previous transient reply");
|
||||
expect(streamingBubble).not.toHaveTextContent("Previous transient thinking");
|
||||
expect(streamingBubble).not.toHaveTextContent("Previous completed reply");
|
||||
expect(streamingBubble).not.toHaveTextContent("Previous completed thinking");
|
||||
});
|
||||
|
||||
it("sends planner Chat exactly once on the first mobile tap while the textarea is focused", async () => {
|
||||
mockFetchTaskPlannerChatSession.mockResolvedValueOnce({ session: null });
|
||||
renderPlannerChat({ projectId: "project-1" });
|
||||
|
||||
Reference in New Issue
Block a user