feat(FN-4350): complete Step 2 — auto-reconnect suspension errors in useChat

Fusion-Task-Id: FN-4350
Fusion-Task-Lineage: 11fff2c2-1cd1-4562-9b01-032a15217479
This commit is contained in:
Fusion
2026-05-13 12:09:12 -07:00
committed by gsxdsm
parent 138038971f
commit 456c880b99
2 changed files with 201 additions and 14 deletions

View File

@@ -12,6 +12,7 @@ import type { ChatSession, ChatMessage } from "@fusion/core";
// Mock the API module
vi.mock("../../api", () => ({
fetchChatSessions: vi.fn(),
fetchChatSession: vi.fn(),
createChatSession: vi.fn(),
fetchChatMessages: vi.fn(),
updateChatSession: vi.fn(),
@@ -46,6 +47,7 @@ const mockRemoveScopedItem = vi.mocked(projectStorageModule.removeScopedItem);
const mockSubscribeSse = vi.mocked(sseBusModule.subscribeSse);
const mockFetchChatSessions = vi.mocked(apiModule.fetchChatSessions);
const mockFetchChatSession = vi.mocked(apiModule.fetchChatSession);
const mockCreateChatSession = vi.mocked(apiModule.createChatSession);
const mockFetchChatMessages = vi.mocked(apiModule.fetchChatMessages);
const mockUpdateChatSession = vi.mocked(apiModule.updateChatSession);
@@ -93,6 +95,9 @@ describe("useChat", () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetchChatSessions.mockResolvedValue({ sessions: [] });
mockFetchChatSession.mockResolvedValue({
session: makeSession({ id: "session-001", agentId: "agent-001" }),
});
mockCreateChatSession.mockResolvedValue({
session: makeSession({ id: "session-001", agentId: "agent-001", title: "New Chat" }),
});
@@ -752,6 +757,7 @@ describe("useChat", () => {
},
};
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatSession.mockResolvedValueOnce({ session });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
const addToast = vi.fn();
@@ -795,10 +801,11 @@ describe("useChat", () => {
});
});
it("shows Load failed toast when tab stays visible", async () => {
it("suppresses Load failed when tab stays visible and does not add failure bubble", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
mockFetchChatSession.mockResolvedValueOnce({ session });
const addToast = vi.fn();
let errorHandler: ((data: string | apiModule.ChatFailureInfo) => void) | undefined;
@@ -828,7 +835,9 @@ describe("useChat", () => {
});
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Load failed", "error");
expect(addToast).not.toHaveBeenCalledWith("Load failed", "error");
expect(result.current.messages.find((m) => m.failureInfo?.summary === "Load failed")).toBeUndefined();
expect(mockFetchChatSession).toHaveBeenCalledWith("session-001", undefined);
});
});
@@ -870,6 +879,99 @@ describe("useChat", () => {
});
});
it("re-attaches from fetchChatSession replay id when tab becomes visible", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
const generatingSession = {
...session,
isGenerating: true,
inFlightGeneration: {
status: "generating" as const,
streamingText: "partial",
streamingThinking: "thinking",
toolCalls: [],
replayFromEventId: 77,
updatedAt: "2026-04-08T00:00:00.000Z",
},
};
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatSession.mockResolvedValueOnce({ session: generatingSession });
const addToast = vi.fn();
const { result } = renderHook(() => useChat(undefined, 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(() => {
setDocumentVisibilityState("hidden");
setDocumentVisibilityState("visible");
});
await waitFor(() => {
expect(mockFetchChatSession).toHaveBeenCalledWith("session-001", undefined);
expect(mockAttachChatStream).toHaveBeenCalledWith(
"session-001",
expect.any(Object),
undefined,
{ lastEventId: 77 },
);
expect(addToast).not.toHaveBeenCalled();
});
});
it("fetches session on visible return only when no live stream and swallows reconnect failures", async () => {
const session = {
...makeSession({ id: "session-001", agentId: "agent-001" }),
isGenerating: false,
};
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatSession.mockRejectedValueOnce(new Error("network"));
mockFetchChatMessages.mockResolvedValue({ messages: [] });
const addToast = vi.fn();
const { result } = renderHook(() => useChat(undefined, 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(() => {
setDocumentVisibilityState("hidden");
setDocumentVisibilityState("visible");
});
await waitFor(() => {
expect(mockFetchChatSession).toHaveBeenCalledWith("session-001", undefined);
expect(addToast).not.toHaveBeenCalled();
});
mockFetchChatSession.mockClear();
act(() => {
result.current.sendMessage("Hello");
setDocumentVisibilityState("hidden");
setDocumentVisibilityState("visible");
});
expect(mockFetchChatSession).not.toHaveBeenCalled();
});
it("still shows toast for non-suspension errors regardless of visibility", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback, useRef } from "react";
import {
fetchChatSessions,
fetchChatSession,
createChatSession as apiCreateChatSession,
fetchChatMessages,
updateChatSession,
@@ -405,7 +406,11 @@ export function useChat(
setIsStreaming(false);
}, []);
const attachIfGenerating = useCallback((sessionId: string, inFlightGeneration?: ChatInFlightGenerationState | null) => {
const attachIfGenerating = useCallback((
sessionId: string,
inFlightGeneration?: ChatInFlightGenerationState | null,
options?: { silent?: boolean },
) => {
if (streamRef.current || !sessionId) {
return true;
}
@@ -425,7 +430,7 @@ export function useChat(
setStreamingThinking,
setStreamingToolCalls,
cancelStreamingFlushesRef,
addToast,
addToast: options?.silent ? undefined : addToast,
onFallbackSession: (data, fallbackSessionId) => {
const nextModel = parseModelDescriptor(data.fallbackModel);
setSessions((prev) => prev.map((session) =>
@@ -450,7 +455,9 @@ export function useChat(
isStreamingRef.current = false;
streamRef.current = null;
const failureInfo = normalizeFailureInfo(data);
addToast?.(failureInfo.summary, "error");
if (!options?.silent) {
addToast?.(failureInfo.summary, "error");
}
void loadMessages(sessionId);
},
});
@@ -625,6 +632,43 @@ export function useChat(
});
const visibilitySuspension = useTabVisibilitySuspension();
const reconnectSessionSilently = useCallback(async (sessionId: string) => {
try {
await refreshSessions();
const refreshedSession = await fetchChatSession(sessionId, projectId);
if (activeSessionRef.current?.id === sessionId) {
setActiveSession((prev) => {
if (!prev || prev.id !== sessionId) {
return prev;
}
return {
...prev,
...refreshedSession.session,
};
});
}
if (refreshedSession.session.isGenerating) {
setStreamingText("");
setStreamingThinking("");
setStreamingToolCalls([]);
setIsStreaming(true);
isStreamingRef.current = true;
attachIfGenerating(sessionId, refreshedSession.session.inFlightGeneration, { silent: true });
} else {
setStreamingText("");
setStreamingThinking("");
setStreamingToolCalls([]);
setIsStreaming(false);
isStreamingRef.current = false;
await loadMessages(sessionId);
}
} catch {
// Intentionally swallow reconnect failures for suspension-style recovery.
}
}, [attachIfGenerating, loadMessages, projectId, refreshSessions]);
const sendMessage = useCallback(
(content: string, attachments?: File[]) => {
if (!activeSession) return;
@@ -719,9 +763,8 @@ export function useChat(
},
onError: (data, tempUserMessageId) => {
const failureInfo = normalizeFailureInfo(data);
const shouldSuppressSuspensionError = typeof data === "string"
&& isLikelyTabSuspensionError(data)
&& (visibilitySuspension.isHiddenNow() || visibilitySuspension.wasRecentlyHidden(5000));
const suspensionMessage = typeof data === "string" ? data : failureInfo.summary;
const shouldSuppressSuspensionError = isLikelyTabSuspensionError(suspensionMessage);
setMessages((prev) => {
const nextMessages = prev.filter((message) => message.id !== tempUserMessageId);
@@ -751,11 +794,12 @@ export function useChat(
if (shouldSuppressSuspensionError) {
console.info("[useChat] Suppressed tab-suspension stream error:", data);
if (activeSession?.id) {
if (activeSession.isGenerating) {
attachIfGenerating(activeSession.id, activeSession.inFlightGeneration);
} else {
void loadMessages(activeSession.id);
}
setStreamingText("");
setStreamingThinking("");
setStreamingToolCalls([]);
setIsStreaming(true);
isStreamingRef.current = true;
void reconnectSessionSilently(activeSession.id);
}
} else {
addToast?.(failureInfo.summary, "error");
@@ -775,7 +819,7 @@ export function useChat(
streamRef.current = streamChatResponse(activeSession.id, content, handlers, attachments, projectId);
},
[activeSession, projectId, refreshSessions, addToast, loadMessages, attachIfGenerating, visibilitySuspension],
[activeSession, projectId, refreshSessions, addToast, attachIfGenerating, reconnectSessionSilently],
);
sendMessageRef.current = sendMessage;
@@ -825,6 +869,47 @@ export function useChat(
return () => clearInterval(interval);
}, [attachIfGenerating, loadMessages, projectId, activeSession]);
useEffect(() => {
const unsubscribe = visibilitySuspension.onBecameVisible(() => {
const currentSession = activeSessionRef.current;
if (!currentSession || streamRef.current) {
return;
}
const contextVersionAtStart = projectContextVersionRef.current;
void fetchChatSession(currentSession.id, projectId)
.then((data) => {
if (projectContextVersionRef.current !== contextVersionAtStart || streamRef.current) {
return;
}
if (data.session.isGenerating) {
setStreamingText("");
setStreamingThinking("");
setStreamingToolCalls([]);
setIsStreaming(true);
isStreamingRef.current = true;
attachIfGenerating(currentSession.id, data.session.inFlightGeneration, { silent: true });
return;
}
if (isStreamingRef.current) {
setStreamingText("");
setStreamingThinking("");
setStreamingToolCalls([]);
setIsStreaming(false);
isStreamingRef.current = false;
void loadMessages(currentSession.id);
}
})
.catch(() => {
// Intentionally silent for visibility reconnect path.
});
});
return unsubscribe;
}, [attachIfGenerating, loadMessages, projectId, visibilitySuspension]);
// SSE real-time updates
useEffect(() => {
const contextVersionAtStart = projectContextVersionRef.current;