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:
gsxdsm
2026-07-15 12:22:33 -07:00
parent ec7898163c
commit b2977d1a7a
3 changed files with 67 additions and 3 deletions

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

View File

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

View File

@@ -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" });