FN-5710: flush queued first quick chat send after session init

Ensure the first Quick Chat message reliably streams even when a stale sendMessage closure sees no active session.

- trigger a microtask flush for queued pre-session sends when a session is already active and no stream is running
- preserve existing streaming guards so flush only runs when safe
- add regression coverage for stale-closure first-send flow to verify stream start and completion behavior

Files changed:
 .../app/hooks/__tests__/useQuickChat.test.ts       | 47 ++++++++++++++++++++++
 packages/dashboard/app/hooks/useQuickChat.ts       | 12 ++++++
 2 files changed, 59 insertions(+)

Fusion-Task-Id: FN-5710

Fusion-Task-Lineage: a4b32867-dedd-40f5-b6a8-3c71b9f7dee1
This commit is contained in:
gsxdsm
2026-05-29 22:36:45 -07:00
parent b78736ce58
commit 1207dac52d
2 changed files with 59 additions and 0 deletions

View File

@@ -102,6 +102,53 @@ describe("useQuickChat", () => {
await expect(firstSend).resolves.toBeUndefined();
});
it("flushes queued first send even when sendMessage closure sees stale null session", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchResumeChatSession.mockResolvedValue({ session });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
const { result } = renderHook(() => useQuickChat("proj-123"));
const staleSendMessage = result.current.sendMessage;
await act(async () => {
await result.current.switchSession("agent-001");
});
await waitFor(() => {
expect(result.current.activeSession?.id).toBe("session-001");
});
let onDone: ((data: { messageId: string }) => void) | undefined;
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
onDone = handlers.onDone as typeof onDone;
return { close: vi.fn(), isConnected: () => true };
});
let firstSend!: Promise<void>;
await act(async () => {
firstSend = staleSendMessage("Hello from stale sender");
});
await waitFor(() => {
expect(mockStreamChatResponse).toHaveBeenCalledTimes(1);
expect(mockStreamChatResponse.mock.calls[0]?.[1]).toBe("Hello from stale sender");
expect(result.current.isStreaming).toBe(true);
});
act(() => {
onDone?.({ messageId: "msg-001" });
});
await expect(firstSend).resolves.toBeUndefined();
await waitFor(() => {
expect(result.current.messages.at(-1)).toEqual(expect.objectContaining({
role: "assistant",
}));
expect(result.current.isStreaming).toBe(false);
});
});
it("sendMessage returns a promise that resolves on stream completion", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchResumeChatSession.mockResolvedValue({ session });

View File

@@ -743,6 +743,18 @@ export function useQuickChat(
queuedPreSessionCompletionRef.current = { resolve, reject };
pendingMessageRef.current = content;
setPendingMessage(content);
// FN-5710: stale sendMessage closures can still observe null
// activeSession immediately after session init completed. In that
// case, queueing alone can strand the first send because the
// activeSession-triggered flush effect has already fired.
if (activeSessionRef.current && !isStreamingRef.current && !streamRef.current) {
queueMicrotask(() => {
if (!isStreamingRef.current && !streamRef.current) {
flushPendingMessage();
}
});
}
});
}