feat(FN-4381): flush queued chat messages on recovery completion

Fixes chat hooks to flush queued messages when recovery completes, preventing stale messages from lingering in useChat and useQuickChat; includes tests for both hooks.

Fusion-Task-Id: FN-4381
This commit is contained in:
Fusion
2026-05-13 13:46:21 -07:00
committed by gsxdsm
parent 039e9a2d02
commit c50e152a87
5 changed files with 211 additions and 44 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix chat queued follow-up delivery so pending messages auto-send when streaming completes through recovery paths (SSE message-added recovery, polling finalization, and visibility-resume), not only fresh-send onDone/onError handlers.

View File

@@ -1317,6 +1317,108 @@ describe("useChat", () => {
}); });
}); });
describe("queued message recovery paths", () => {
it("flushes queued message when recovery completes via chat:message:added SSE", async () => {
const session = {
...makeSession({ id: "session-001", agentId: "agent-001" }),
isGenerating: true,
};
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
mockAttachChatStream.mockReturnValue(null as never);
const { result } = renderHook(() => useChat("proj-123"));
await waitFor(() => {
expect(result.current.sessions).toHaveLength(1);
});
act(() => {
result.current.selectSession("session-001");
});
await waitFor(() => {
expect(result.current.isStreaming).toBe(true);
});
act(() => {
result.current.sendMessage("Queued follow-up");
});
await waitFor(() => {
expect(result.current.pendingMessage).toBe("Queued follow-up");
});
const subscribeOptions = mockSubscribeSse.mock.calls.at(-1)?.[1];
const messageAdded = subscribeOptions?.events?.["chat:message:added"];
expect(messageAdded).toBeTypeOf("function");
act(() => {
messageAdded?.({
data: JSON.stringify(makeMessage({
id: "msg-002",
sessionId: "session-001",
role: "assistant",
content: "Recovered",
})),
} as MessageEvent);
});
await waitFor(() => {
expect(mockStreamChatResponse).toHaveBeenCalledTimes(1);
expect(mockStreamChatResponse.mock.calls[0]?.[1]).toBe("Queued follow-up");
expect(result.current.pendingMessage).toBe("");
});
});
it("flushes queued message when visibility resume sees generation complete", async () => {
const session = {
...makeSession({ id: "session-001", agentId: "agent-001" }),
isGenerating: true,
};
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
mockAttachChatStream.mockReturnValue(null as never);
mockFetchChatSession.mockResolvedValue({
session: { ...session, isGenerating: false },
});
const { result } = renderHook(() => useChat("proj-123"));
await waitFor(() => {
expect(result.current.sessions).toHaveLength(1);
});
act(() => {
result.current.selectSession("session-001");
});
await waitFor(() => {
expect(result.current.isStreaming).toBe(true);
});
act(() => {
result.current.sendMessage("Queued follow-up");
});
await waitFor(() => {
expect(result.current.pendingMessage).toBe("Queued follow-up");
});
act(() => {
setDocumentVisibilityState("hidden");
setDocumentVisibilityState("visible");
});
await waitFor(() => {
expect(mockFetchChatSession).toHaveBeenCalledWith("session-001", "proj-123");
expect(mockStreamChatResponse).toHaveBeenCalledTimes(1);
expect(mockStreamChatResponse.mock.calls[0]?.[1]).toBe("Queued follow-up");
expect(result.current.pendingMessage).toBe("");
});
});
});
it("queued message is not auto-sent after user-initiated stop", async () => { it("queued message is not auto-sent after user-initiated stop", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" }); const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] }); mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });

View File

@@ -739,6 +739,52 @@ describe("useQuickChat", () => {
}); });
}); });
describe("queued message recovery paths", () => {
it("flushes queued message when attached recovery stream completes", async () => {
const existingSession = {
...makeSession({ id: "session-existing", agentId: "agent-001" }),
isGenerating: true,
};
const attachHandlers: Array<Parameters<typeof mockAttachChatStream>[1]> = [];
mockFetchResumeChatSession.mockResolvedValueOnce({ session: existingSession });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
mockAttachChatStream.mockImplementation((_sessionId, handlers) => {
attachHandlers.push(handlers);
return { close: vi.fn(), isConnected: () => true };
});
const { result } = renderHook(() => useQuickChat("proj-123"));
await act(async () => {
await result.current.switchSession("agent-001");
});
await waitFor(() => {
expect(result.current.isStreaming).toBe(true);
});
const queuedSend = result.current.sendMessage("Queued follow-up");
await waitFor(() => {
expect(result.current.pendingMessage).toBe("Queued follow-up");
});
act(() => {
attachHandlers[0]?.onDone?.({ messageId: "msg-recovery" });
});
await waitFor(() => {
expect(mockStreamChatResponse).toHaveBeenCalledTimes(1);
expect(mockStreamChatResponse.mock.calls[0]?.[1]).toBe("Queued follow-up");
expect(result.current.pendingMessage).toBe("");
});
await expect(queuedSend).resolves.toBeUndefined();
});
});
it("onError does not remove user message from local state", async () => { it("onError does not remove user message from local state", async () => {
const existingSession = makeSession({ id: "session-existing", agentId: "agent-001" }); const existingSession = makeSession({ id: "session-existing", agentId: "agent-001" });
let onErrorHandler: ((data: string) => void) | undefined; let onErrorHandler: ((data: string) => void) | undefined;

View File

@@ -406,6 +406,22 @@ export function useChat(
setIsStreaming(false); setIsStreaming(false);
}, []); }, []);
const clearPendingMessage = useCallback(() => {
pendingMessageRef.current = "";
setPendingMessage("");
}, []);
const flushPendingMessage = useCallback(() => {
const queuedMessage = pendingMessageRef.current.trim();
if (!queuedMessage) {
return;
}
pendingMessageRef.current = "";
setPendingMessage("");
sendMessageRef.current(queuedMessage);
}, []);
const attachIfGenerating = useCallback(( const attachIfGenerating = useCallback((
sessionId: string, sessionId: string,
inFlightGeneration?: ChatInFlightGenerationState | null, inFlightGeneration?: ChatInFlightGenerationState | null,
@@ -446,6 +462,7 @@ export function useChat(
isStreamingRef.current = false; isStreamingRef.current = false;
streamRef.current = null; streamRef.current = null;
void loadMessages(sessionId); void loadMessages(sessionId);
flushPendingMessage();
}, },
onError: (data) => { onError: (data) => {
setStreamingText(""); setStreamingText("");
@@ -459,6 +476,7 @@ export function useChat(
addToast?.(failureInfo.summary, "error"); addToast?.(failureInfo.summary, "error");
} }
void loadMessages(sessionId); void loadMessages(sessionId);
flushPendingMessage();
}, },
}); });
@@ -469,7 +487,7 @@ export function useChat(
}); });
streamRef.current = stream; streamRef.current = stream;
return true; return true;
}, [addToast, loadMessages, projectId]); }, [addToast, loadMessages, projectId, flushPendingMessage]);
// Select a session // Select a session
const selectSession = useCallback( const selectSession = useCallback(
@@ -617,11 +635,6 @@ export function useChat(
setStreamingToolCalls([]); setStreamingToolCalls([]);
}, [activeSession, projectId]); }, [activeSession, projectId]);
const clearPendingMessage = useCallback(() => {
pendingMessageRef.current = "";
setPendingMessage("");
}, []);
/** /**
* Send a user message to the active chat session. * Send a user message to the active chat session.
* @param content Message text content to send. * @param content Message text content to send.
@@ -754,12 +767,7 @@ export function useChat(
refreshSessions(); refreshSessions();
const queuedMessage = pendingMessageRef.current.trim(); flushPendingMessage();
if (queuedMessage) {
pendingMessageRef.current = "";
setPendingMessage("");
sendMessageRef.current(queuedMessage);
}
}, },
onError: (data, tempUserMessageId) => { onError: (data, tempUserMessageId) => {
const failureInfo = normalizeFailureInfo(data); const failureInfo = normalizeFailureInfo(data);
@@ -807,19 +815,14 @@ export function useChat(
} }
if (!cancelledByUserRef.current) { if (!cancelledByUserRef.current) {
const queuedMessage = pendingMessageRef.current.trim(); flushPendingMessage();
if (queuedMessage) {
pendingMessageRef.current = "";
setPendingMessage("");
sendMessageRef.current(queuedMessage);
}
} }
}, },
}); });
streamRef.current = streamChatResponse(activeSession.id, content, handlers, attachments, projectId); streamRef.current = streamChatResponse(activeSession.id, content, handlers, attachments, projectId);
}, },
[activeSession, projectId, refreshSessions, addToast, attachIfGenerating, reconnectSessionSilently], [activeSession, projectId, refreshSessions, addToast, attachIfGenerating, reconnectSessionSilently, flushPendingMessage],
); );
sendMessageRef.current = sendMessage; sendMessageRef.current = sendMessage;
@@ -860,6 +863,8 @@ export function useChat(
setStreamingThinking(""); setStreamingThinking("");
setStreamingToolCalls([]); setStreamingToolCalls([]);
setIsStreaming(false); setIsStreaming(false);
isStreamingRef.current = false;
flushPendingMessage();
} }
} catch { } catch {
// Silently fail - will retry next interval // Silently fail - will retry next interval
@@ -867,7 +872,7 @@ export function useChat(
}, 3000); }, 3000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [attachIfGenerating, loadMessages, projectId, activeSession]); }, [attachIfGenerating, loadMessages, projectId, activeSession, flushPendingMessage]);
useEffect(() => { useEffect(() => {
const unsubscribe = visibilitySuspension.onBecameVisible(() => { const unsubscribe = visibilitySuspension.onBecameVisible(() => {
@@ -899,6 +904,7 @@ export function useChat(
setStreamingToolCalls([]); setStreamingToolCalls([]);
setIsStreaming(false); setIsStreaming(false);
isStreamingRef.current = false; isStreamingRef.current = false;
flushPendingMessage();
void loadMessages(currentSession.id); void loadMessages(currentSession.id);
} }
}) })
@@ -908,7 +914,7 @@ export function useChat(
}); });
return unsubscribe; return unsubscribe;
}, [attachIfGenerating, loadMessages, projectId, visibilitySuspension]); }, [attachIfGenerating, loadMessages, projectId, visibilitySuspension, flushPendingMessage]);
// SSE real-time updates // SSE real-time updates
useEffect(() => { useEffect(() => {
@@ -983,6 +989,8 @@ export function useChat(
setStreamingThinking(""); setStreamingThinking("");
setStreamingToolCalls([]); setStreamingToolCalls([]);
setIsStreaming(false); setIsStreaming(false);
isStreamingRef.current = false;
flushPendingMessage();
return; return;
} }
@@ -1031,7 +1039,7 @@ export function useChat(
}); });
return unsubscribe; return unsubscribe;
}, [attachIfGenerating, projectId]); }, [attachIfGenerating, projectId, flushPendingMessage]);
// Cleanup on unmount // Cleanup on unmount
useEffect(() => { useEffect(() => {

View File

@@ -269,6 +269,22 @@ export function useQuickChat(
[projectId], [projectId],
); );
const clearPendingMessage = useCallback(() => {
pendingMessageRef.current = "";
setPendingMessage("");
}, []);
const flushPendingMessage = useCallback(() => {
const queuedMessage = pendingMessageRef.current.trim();
if (!queuedMessage) {
return;
}
pendingMessageRef.current = "";
setPendingMessage("");
void sendMessageRef.current(queuedMessage);
}, []);
const attachIfGenerating = useCallback(( const attachIfGenerating = useCallback((
sessionId: string, sessionId: string,
inFlightGeneration?: ChatInFlightGenerationState | null, inFlightGeneration?: ChatInFlightGenerationState | null,
@@ -311,6 +327,7 @@ export function useQuickChat(
void fetchChatMessages(sessionId, { limit: 50 }, projectId).then((data) => { void fetchChatMessages(sessionId, { limit: 50 }, projectId).then((data) => {
setMessages(data.messages.map(mapChatMessageToInfo)); setMessages(data.messages.map(mapChatMessageToInfo));
}).catch(() => {}); }).catch(() => {});
flushPendingMessage();
}, },
onError: (data) => { onError: (data) => {
setStreamingText(""); setStreamingText("");
@@ -326,6 +343,7 @@ export function useQuickChat(
void fetchChatMessages(sessionId, { limit: 50 }, projectId).then((resp) => { void fetchChatMessages(sessionId, { limit: 50 }, projectId).then((resp) => {
setMessages(resp.messages.map(mapChatMessageToInfo)); setMessages(resp.messages.map(mapChatMessageToInfo));
}).catch(() => {}); }).catch(() => {});
flushPendingMessage();
}, },
}); });
@@ -335,7 +353,7 @@ export function useQuickChat(
: {}), : {}),
}); });
return true; return true;
}, [addToast, projectId]); }, [addToast, projectId, flushPendingMessage]);
// Fetch existing sessions and find/create one for the given target // Fetch existing sessions and find/create one for the given target
const initializeSession = useCallback( const initializeSession = useCallback(
@@ -446,6 +464,8 @@ export function useQuickChat(
setStreamingThinking(""); setStreamingThinking("");
setStreamingToolCalls([]); setStreamingToolCalls([]);
setIsStreaming(false); setIsStreaming(false);
isStreamingRef.current = false;
flushPendingMessage();
} }
} catch { } catch {
// Silently fail - will retry on next interval // Silently fail - will retry on next interval
@@ -453,7 +473,7 @@ export function useQuickChat(
}, 3000); }, 3000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [activeSession, attachIfGenerating, projectId]); }, [activeSession, attachIfGenerating, projectId, flushPendingMessage]);
// Reload messages from server (for same-session revisit) // Reload messages from server (for same-session revisit)
const reloadMessages = useCallback(async () => { const reloadMessages = useCallback(async () => {
@@ -611,11 +631,6 @@ export function useQuickChat(
setStreamingToolCalls([]); setStreamingToolCalls([]);
}, [activeSession, projectId]); }, [activeSession, projectId]);
const clearPendingMessage = useCallback(() => {
pendingMessageRef.current = "";
setPendingMessage("");
}, []);
const sendMessageRef = useRef<(content: string, attachments?: File[]) => Promise<void>>(() => Promise.resolve()); const sendMessageRef = useRef<(content: string, attachments?: File[]) => Promise<void>>(() => Promise.resolve());
const visibilitySuspension = useTabVisibilitySuspension(); const visibilitySuspension = useTabVisibilitySuspension();
@@ -747,12 +762,7 @@ export function useQuickChat(
sendCompletionRef.current?.resolve(); sendCompletionRef.current?.resolve();
sendCompletionRef.current = null; sendCompletionRef.current = null;
const queuedMessage = pendingMessageRef.current.trim(); flushPendingMessage();
if (queuedMessage) {
pendingMessageRef.current = "";
setPendingMessage("");
void sendMessageRef.current(queuedMessage);
}
}, },
onError: (data) => { onError: (data) => {
setStreamingText(""); setStreamingText("");
@@ -784,12 +794,7 @@ export function useQuickChat(
sendCompletionRef.current = null; sendCompletionRef.current = null;
if (!cancelledByUserRef.current) { if (!cancelledByUserRef.current) {
const queuedMessage = pendingMessageRef.current.trim(); flushPendingMessage();
if (queuedMessage) {
pendingMessageRef.current = "";
setPendingMessage("");
void sendMessageRef.current(queuedMessage);
}
} }
if (!shouldSuppressSuspensionError) { if (!shouldSuppressSuspensionError) {
@@ -806,7 +811,7 @@ export function useQuickChat(
void completionPromise.catch(() => {}); void completionPromise.catch(() => {});
return completionPromise; return completionPromise;
}, },
[activeSession, projectId, addToast, reloadMessages, reconnectSessionSilently], [activeSession, projectId, addToast, reloadMessages, reconnectSessionSilently, flushPendingMessage],
); );
sendMessageRef.current = sendMessage; sendMessageRef.current = sendMessage;
@@ -821,7 +826,7 @@ export function useQuickChat(
return; return;
} }
void fetchChatSession(currentSession.id, projectId) void Promise.resolve(fetchChatSession(currentSession.id, projectId))
.then((data) => { .then((data) => {
if (streamRef.current || activeSessionRef.current?.id !== currentSession.id) { if (streamRef.current || activeSessionRef.current?.id !== currentSession.id) {
return; return;
@@ -843,6 +848,7 @@ export function useQuickChat(
setStreamingToolCalls([]); setStreamingToolCalls([]);
setIsStreaming(false); setIsStreaming(false);
isStreamingRef.current = false; isStreamingRef.current = false;
flushPendingMessage();
void reloadMessages(); void reloadMessages();
} }
}) })
@@ -852,7 +858,7 @@ export function useQuickChat(
}); });
return unsubscribe; return unsubscribe;
}, [attachIfGenerating, projectId, reloadMessages, visibilitySuspension]); }, [attachIfGenerating, projectId, reloadMessages, visibilitySuspension, flushPendingMessage]);
// Cleanup on unmount // Cleanup on unmount
useEffect(() => { useEffect(() => {