FN-7497: keep accepted chat streams waiting

Keep accepted-but-silent chat streams waiting so late responses can reconcile without false timeout failures.

- Stop aborting accepted chat streams when the first SSE event timer fires without content.
- Cover desktop, mobile, planner chat, reattach, hook, and SSE parser paths for late accepted responses.
- Add a patch changeset for the chat first-event timeout fix.

Files changed:
 .changeset/fn-7497-chat-first-event-timeout.md     |  7 +++
 .../app/api/__tests__/legacy-chat-stream.test.ts   | 27 ++++++--
 packages/dashboard/app/api/legacy.ts               |  8 ++-
 .../__tests__/ChatView.core-interactions.test.tsx  | 41 +++++++++++++
 .../__tests__/TaskPlannerChatTab.test.tsx          | 71 ++++++++++++++++++++++
 .../dashboard/app/hooks/__tests__/useChat.test.ts  | 41 +++++++++++++
 6 files changed, 188 insertions(+), 7 deletions(-)

Fusion-Task-Id: FN-7497

Fusion-Task-Lineage: bb53793d-dc78-4a25-af22-ed1c62b73094

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-04 09:56:02 -07:00
parent efa8105036
commit 61c8bdc117
6 changed files with 188 additions and 7 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Keep accepted chat requests waiting instead of showing false first-event timeout failures.
category: fix
dev: Dashboard chat POST streams no longer abort accepted-but-silent responses on the client first-event timer.

View File

@@ -121,22 +121,41 @@ describe("streamChatResponse SSE parser", () => {
});
});
it("fires onError when no stream events arrive before timeout", async () => {
it("keeps accepted streams open when no real stream events arrive before timeout", async () => {
vi.useFakeTimers();
const encoder = new TextEncoder();
let streamController: ReadableStreamDefaultController<Uint8Array> | null = null;
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(": connected\n\n"));
streamController = controller;
controller.enqueue(encoder.encode(": connected\n\n"));
},
}), { status: 200 }));
const onError = vi.fn();
streamChatResponse("s-1", "hi", { onError }, undefined, undefined, { firstEventTimeoutMs: 1_000 });
const textChunks: string[] = [];
const donePayloads: Array<{ messageId: string }> = [];
streamChatResponse("s-1", "hi", {
onText: (data) => textChunks.push(data),
onDone: (data) => donePayloads.push(data),
onError,
}, undefined, undefined, { firstEventTimeoutMs: 1_000 });
await Promise.resolve();
await Promise.resolve();
await vi.advanceTimersByTimeAsync(1_100);
expect(onError).toHaveBeenCalledWith("Timed out waiting for first response event");
expect(onError).not.toHaveBeenCalled();
streamController?.enqueue(encoder.encode("event: text\ndata: \"Late reply\"\n\n"));
streamController?.enqueue(encoder.encode("event: done\ndata: {\"messageId\":\"msg-late\"}\n\n"));
streamController?.close();
await vi.waitFor(() => {
expect(textChunks).toEqual(["Late reply"]);
expect(donePayloads).toEqual([{ messageId: "msg-late" }]);
});
expect(onError).not.toHaveBeenCalled();
vi.useRealTimers();
});

View File

@@ -10476,9 +10476,11 @@ export function streamChatResponse(
if (terminated || closedByUser || receivedStreamEvent) {
return;
}
terminated = true;
handlers.onError?.("Timed out waiting for first response event", { requestAccepted: true, receivedStreamEvent: false });
abortController.abort();
/*
FNXC:ChatReliability 2026-07-04-00:00:
Accepted chat requests can keep generating after the dashboard has not yet seen the first SSE event. Treat this timer as a non-terminal wait marker so the UI stays in-progress and can reconcile late persisted output instead of showing a false Response failed bubble.
*/
firstEventTimer = null;
}, firstEventTimeoutMs);
const reader = res.body.getReader();

View File

@@ -903,6 +903,47 @@ describe("ChatView core interactions", () => {
expect(screen.queryByText("Loading messages...")).not.toBeInTheDocument();
});
it("keeps desktop accepted silent requests as waiting instead of failure", async () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [
{ id: "msg-001", sessionId: "session-001", role: "user", content: "Slow prompt", createdAt: "2026-04-08T00:00:00.000Z" },
],
isStreaming: true,
streamingText: "",
streamingThinking: "",
});
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(screen.queryByText("Response failed")).not.toBeInTheDocument();
expect(screen.queryByText("Timed out waiting for first response event")).not.toBeInTheDocument();
expect(document.querySelector(".chat-message-content--failure")).not.toBeInTheDocument();
expect(document.querySelector(".chat-message--streaming")?.textContent).toContain("Working");
});
it("keeps mobile accepted silent requests in the visible thread", async () => {
const mediaQuerySpy = mockViewportMode("mobile");
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [
{ id: "msg-001", sessionId: "session-001", role: "user", content: "Slow mobile prompt", createdAt: "2026-04-08T00:00:00.000Z" },
],
isStreaming: true,
streamingText: "",
streamingThinking: "",
});
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(screen.queryByText("Response failed")).not.toBeInTheDocument();
expect(screen.queryByText("Timed out waiting for first response event")).not.toBeInTheDocument();
expect(document.querySelector(".chat-message--streaming")?.textContent).toContain("Working");
expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument();
void mediaQuerySpy;
});
it("shows waiting indicator when streaming starts before text arrives", async () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },

View File

@@ -1191,6 +1191,77 @@ describe("TaskPlannerChatTab", () => {
expect(screen.getAllByTestId("chat-question-response-submit")).toHaveLength(1);
});
it("keeps accepted silent planner streams waiting and reconciles late history", async () => {
const user = userEvent.setup();
mockFetchTaskPlannerChatSession.mockResolvedValueOnce({ session: null });
mockFetchChatMessages.mockResolvedValueOnce({
messages: [
{ id: "planner-user-slow", sessionId: "chat-planner", role: "user", content: "slow planner prompt", thinkingOutput: null, metadata: null, createdAt: "2026-07-01T00:00:00.000Z" },
{ id: "planner-assistant-late", sessionId: "chat-planner", role: "assistant", content: "late planner answer", thinkingOutput: null, metadata: null, createdAt: "2026-07-01T00:00:01.000Z" },
],
});
let doneHandler: any;
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
doneHandler = handlers.onDone;
return { close: vi.fn(), isConnected: () => true };
});
renderPlannerChat();
await screen.findByTestId("task-planner-chat-empty");
await user.type(screen.getByLabelText("Message planner chat"), "slow planner prompt");
await user.click(screen.getByRole("button", { name: "Send" }));
expect(await screen.findByText("slow planner prompt")).toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.queryByText("Timed out waiting for first response event")).not.toBeInTheDocument();
expect(screen.queryByText("Planner chat failed to respond")).not.toBeInTheDocument();
expect(document.querySelector(".chat-message--streaming")).toBeInTheDocument();
act(() => doneHandler?.({ messageId: "planner-assistant-late" }));
expect(await screen.findByText("late planner answer")).toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(document.querySelector(".chat-message--streaming")).not.toBeInTheDocument();
});
it("reattaches accepted silent planner streams without showing timeout errors", async () => {
const inFlightSession = makePlannerSession({
isGenerating: true,
inFlightGeneration: {
status: "generating",
streamingText: "",
streamingThinking: "",
toolCalls: [],
replayFromEventId: 9,
updatedAt: "2026-07-01T00:00:00.000Z",
},
});
mockFetchTaskPlannerChatSession.mockResolvedValueOnce({ session: inFlightSession });
mockFetchChatSession.mockResolvedValueOnce({ session: inFlightSession });
mockFetchChatMessages
.mockResolvedValueOnce({ messages: [] })
.mockResolvedValueOnce({
messages: [{ id: "planner-attached-late", sessionId: "chat-planner", role: "assistant", content: "attached late answer", thinkingOutput: null, metadata: null, createdAt: "2026-07-01T00:00:01.000Z" }],
});
let attachedDoneHandler: any;
mockAttachChatStream.mockImplementation((_sessionId, handlers) => {
attachedDoneHandler = handlers.onDone;
return { close: vi.fn(), isConnected: () => true };
});
renderPlannerChat();
await waitFor(() => expect(mockAttachChatStream).toHaveBeenCalledWith("chat-planner", expect.any(Object), undefined, { lastEventId: 9 }));
expect(screen.queryByText("Timed out waiting for first response event")).not.toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(document.querySelector(".chat-message--streaming")).toBeInTheDocument();
act(() => attachedDoneHandler?.({ messageId: "planner-attached-late" }));
expect(await screen.findByText("attached late answer")).toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it("keeps first planner message visible after accepted provider error and reconciles persisted history", async () => {
const user = userEvent.setup();
mockFetchTaskPlannerChatSession.mockResolvedValueOnce({ session: null });

View File

@@ -3916,6 +3916,47 @@ describe("useChat", () => {
expect(result.current.messages.some((message) => message.role === "assistant" && message.failureInfo?.summary === "Provider rate limit")).toBe(true);
});
it("keeps accepted silent streams waiting and reconciles a late assistant message", async () => {
mockFetchChatSessions.mockResolvedValueOnce({
sessions: [makeSession({ id: "session-001", agentId: "agent-001" })],
});
mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
let doneHandler: ((data: { messageId: string; message?: ChatMessage }) => void) | undefined;
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
doneHandler = handlers.onDone;
return { close: vi.fn(), isConnected: () => true };
});
const addToast = vi.fn();
const { result } = renderHook(() => useChat("proj-123", addToast));
await waitFor(() => expect(result.current.sessions).toHaveLength(1));
act(() => result.current.selectSession("session-001"));
await waitFor(() => expect(result.current.activeSession?.id).toBe("session-001"));
act(() => result.current.sendMessage("slow prompt"));
await waitFor(() => {
expect(result.current.isStreaming).toBe(true);
expect(result.current.messages.some((message) => message.role === "user" && message.content === "slow prompt")).toBe(true);
});
expect(result.current.messages.some((message) => message.failureInfo?.summary === "Timed out waiting for first response event")).toBe(false);
expect(addToast).not.toHaveBeenCalledWith("Timed out waiting for first response event", "error");
act(() => {
doneHandler?.({
messageId: "msg-late-assistant",
message: makeMessage({ id: "msg-late-assistant", sessionId: "session-001", role: "assistant", content: "late answer" }),
});
});
await waitFor(() => {
expect(result.current.isStreaming).toBe(false);
expect(result.current.messages.some((message) => message.role === "assistant" && message.content === "late answer")).toBe(true);
});
expect(result.current.messages.some((message) => message.failureInfo?.summary === "Response failed")).toBe(false);
});
it("does not keep optimistic sent message for pre-acceptance HTTP failures", async () => {
mockFetchChatSessions.mockResolvedValueOnce({
sessions: [makeSession({ id: "session-001", agentId: "agent-001" })],