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:
@@ -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] });
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user