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();
|
vi.useFakeTimers();
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
let streamController: ReadableStreamDefaultController<Uint8Array> | null = null;
|
||||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(new ReadableStream<Uint8Array>({
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(new ReadableStream<Uint8Array>({
|
||||||
start(controller) {
|
start(controller) {
|
||||||
controller.enqueue(new TextEncoder().encode(": connected\n\n"));
|
streamController = controller;
|
||||||
|
controller.enqueue(encoder.encode(": connected\n\n"));
|
||||||
},
|
},
|
||||||
}), { status: 200 }));
|
}), { status: 200 }));
|
||||||
|
|
||||||
const onError = vi.fn();
|
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 Promise.resolve();
|
await Promise.resolve();
|
||||||
await vi.advanceTimersByTimeAsync(1_100);
|
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();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10476,9 +10476,11 @@ export function streamChatResponse(
|
|||||||
if (terminated || closedByUser || receivedStreamEvent) {
|
if (terminated || closedByUser || receivedStreamEvent) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
terminated = true;
|
/*
|
||||||
handlers.onError?.("Timed out waiting for first response event", { requestAccepted: true, receivedStreamEvent: false });
|
FNXC:ChatReliability 2026-07-04-00:00:
|
||||||
abortController.abort();
|
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);
|
}, firstEventTimeoutMs);
|
||||||
|
|
||||||
const reader = res.body.getReader();
|
const reader = res.body.getReader();
|
||||||
|
|||||||
@@ -903,6 +903,47 @@ describe("ChatView core interactions", () => {
|
|||||||
expect(screen.queryByText("Loading messages...")).not.toBeInTheDocument();
|
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 () => {
|
it("shows waiting indicator when streaming starts before text arrives", async () => {
|
||||||
setupMockChat({
|
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" },
|
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);
|
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 () => {
|
it("keeps first planner message visible after accepted provider error and reconciles persisted history", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
mockFetchTaskPlannerChatSession.mockResolvedValueOnce({ session: null });
|
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);
|
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 () => {
|
it("does not keep optimistic sent message for pre-acceptance HTTP failures", async () => {
|
||||||
mockFetchChatSessions.mockResolvedValueOnce({
|
mockFetchChatSessions.mockResolvedValueOnce({
|
||||||
sessions: [makeSession({ id: "session-001", agentId: "agent-001" })],
|
sessions: [makeSession({ id: "session-001", agentId: "agent-001" })],
|
||||||
|
|||||||
Reference in New Issue
Block a user