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:
7
.changeset/fn-7497-chat-first-event-timeout.md
Normal file
7
.changeset/fn-7497-chat-first-event-timeout.md
Normal 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.
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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" })],
|
||||
|
||||
Reference in New Issue
Block a user