diff --git a/.changeset/fn-7497-chat-first-event-timeout.md b/.changeset/fn-7497-chat-first-event-timeout.md new file mode 100644 index 0000000000..d32b0d45cc --- /dev/null +++ b/.changeset/fn-7497-chat-first-event-timeout.md @@ -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. diff --git a/packages/dashboard/app/api/__tests__/legacy-chat-stream.test.ts b/packages/dashboard/app/api/__tests__/legacy-chat-stream.test.ts index a70fe54f1d..3ab7316e0e 100644 --- a/packages/dashboard/app/api/__tests__/legacy-chat-stream.test.ts +++ b/packages/dashboard/app/api/__tests__/legacy-chat-stream.test.ts @@ -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 | null = null; vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(new ReadableStream({ 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(); }); diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index a23c5a8539..fc8941a5fb 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -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(); diff --git a/packages/dashboard/app/components/__tests__/ChatView.core-interactions.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.core-interactions.test.tsx index b365721e82..74a4e55a22 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.core-interactions.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.core-interactions.test.tsx @@ -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(); + + 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(); + + 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" }, diff --git a/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx index 01bd61de48..fcf8f17f33 100644 --- a/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx @@ -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 }); diff --git a/packages/dashboard/app/hooks/__tests__/useChat.test.ts b/packages/dashboard/app/hooks/__tests__/useChat.test.ts index b46bba0da9..b25de22c69 100644 --- a/packages/dashboard/app/hooks/__tests__/useChat.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useChat.test.ts @@ -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" })],