feat(FN-3759): fix stale refs in useChat and useQuickChat queue flush handl

Merged branch adds FN-3759 queue flush ref fixes for `useChat` and `useQuickChat` hooks (with expanded regression test coverage), extracts the roadmap UI into a standalone plugin with schema hook and store bootstrap, ships FN-3281 review revisions with dashboard/plugin integration updates, and resol

Fusion-Task-Id: FN-3759
This commit is contained in:
Fusion
2026-05-08 17:47:39 -07:00
committed by gsxdsm
parent 08d2d130b5
commit 1011dc5eab
4 changed files with 214 additions and 45 deletions

View File

@@ -743,55 +743,162 @@ describe("useChat", () => {
expect(mockStreamChatResponse).toHaveBeenCalledTimes(1);
});
it("queued message auto-sends after onDone", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
describe("queued message closure behavior", () => {
it("queued message auto-sends after onDone with the active session and completes second stream", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
const handlers: Array<Parameters<typeof mockStreamChatResponse>[2]> = [];
mockStreamChatResponse.mockImplementation((_sessionId, _content, nextHandlers) => {
handlers.push(nextHandlers);
return { close: vi.fn(), isConnected: () => true };
const handlers: Array<Parameters<typeof mockStreamChatResponse>[2]> = [];
mockStreamChatResponse.mockImplementation((_sessionId, _content, nextHandlers) => {
handlers.push(nextHandlers);
return { close: vi.fn(), isConnected: () => true };
});
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.activeSession?.id).toBe("session-001");
});
act(() => {
result.current.sendMessage("First");
});
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(() => {
handlers[0]?.onDone?.({ messageId: "msg-001" });
});
await waitFor(() => {
expect(mockStreamChatResponse).toHaveBeenCalledTimes(2);
expect(mockStreamChatResponse.mock.calls[1]?.[0]).toBe("session-001");
expect(mockStreamChatResponse.mock.calls[1]?.[1]).toBe("Queued follow-up");
expect(result.current.pendingMessage).toBe("");
expect(result.current.isStreaming).toBe(true);
});
act(() => {
handlers[1]?.onDone?.({ messageId: "msg-002" });
});
await waitFor(() => {
expect(result.current.isStreaming).toBe(false);
expect(result.current.streamingText).toBe("");
});
});
const { result } = renderHook(() => useChat("proj-123"));
it("keeps only the latest queued message while streaming", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
await waitFor(() => {
expect(result.current.sessions).toHaveLength(1);
const handlers: Array<Parameters<typeof mockStreamChatResponse>[2]> = [];
mockStreamChatResponse.mockImplementation((_sessionId, _content, nextHandlers) => {
handlers.push(nextHandlers);
return { close: vi.fn(), isConnected: () => true };
});
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.activeSession?.id).toBe("session-001");
});
act(() => {
result.current.sendMessage("First");
});
await waitFor(() => {
expect(result.current.isStreaming).toBe(true);
});
act(() => {
result.current.sendMessage("Queued B");
result.current.sendMessage("Queued C");
});
expect(result.current.pendingMessage).toBe("Queued C");
act(() => {
handlers[0]?.onDone?.({ messageId: "msg-001" });
});
await waitFor(() => {
expect(mockStreamChatResponse).toHaveBeenCalledTimes(2);
expect(mockStreamChatResponse.mock.calls[1]?.[1]).toBe("Queued C");
});
});
act(() => {
result.current.selectSession("session-001");
});
it("flushes queued message after stream error when not cancelled", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
await waitFor(() => {
expect(result.current.activeSession?.id).toBe("session-001");
});
const handlers: Array<Parameters<typeof mockStreamChatResponse>[2]> = [];
mockStreamChatResponse.mockImplementation((_sessionId, _content, nextHandlers) => {
handlers.push(nextHandlers);
return { close: vi.fn(), isConnected: () => true };
});
act(() => {
result.current.sendMessage("First");
});
const { result } = renderHook(() => useChat("proj-123"));
await waitFor(() => {
expect(result.current.isStreaming).toBe(true);
});
await waitFor(() => {
expect(result.current.sessions).toHaveLength(1);
});
act(() => {
result.current.sendMessage("Queued follow-up");
});
act(() => {
result.current.selectSession("session-001");
});
await waitFor(() => {
expect(result.current.pendingMessage).toBe("Queued follow-up");
});
await waitFor(() => {
expect(result.current.activeSession?.id).toBe("session-001");
});
act(() => {
handlers[0]?.onDone?.({ messageId: "msg-001" });
});
act(() => {
result.current.sendMessage("First");
});
await waitFor(() => {
expect(mockStreamChatResponse).toHaveBeenCalledTimes(2);
expect(mockStreamChatResponse.mock.calls[1]?.[1]).toBe("Queued follow-up");
expect(result.current.pendingMessage).toBe("");
await waitFor(() => {
expect(result.current.isStreaming).toBe(true);
});
act(() => {
result.current.sendMessage("Queued follow-up");
handlers[0]?.onError?.("network");
});
await waitFor(() => {
expect(mockStreamChatResponse).toHaveBeenCalledTimes(2);
expect(mockStreamChatResponse.mock.calls[1]?.[1]).toBe("Queued follow-up");
});
});
});

View File

@@ -602,7 +602,8 @@ describe("useQuickChat", () => {
});
});
it("queued message is auto-sent after streaming onDone", async () => {
describe("message queue behavior", () => {
it("queued message is auto-sent after streaming onDone", async () => {
const existingSession = makeSession({ id: "session-existing", agentId: "agent-001" });
const handlers: Array<Parameters<typeof mockStreamChatResponse>[2]> = [];
@@ -642,6 +643,53 @@ describe("useQuickChat", () => {
});
});
it("resolves current send promise and creates a new completion for queued send", async () => {
const existingSession = makeSession({ id: "session-existing", agentId: "agent-001" });
const handlers: Array<Parameters<typeof mockStreamChatResponse>[2]> = [];
mockFetchResumeChatSession.mockResolvedValueOnce({ session: existingSession });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
mockStreamChatResponse.mockImplementation((_sessionId, _content, nextHandlers) => {
handlers.push(nextHandlers);
return { close: vi.fn(), isConnected: () => true };
});
const { result } = renderHook(() => useQuickChat("proj-123"));
await act(async () => {
await result.current.switchSession("agent-001");
});
let firstSend: Promise<void>;
await act(async () => {
firstSend = result.current.sendMessage("First");
});
act(() => {
void result.current.sendMessage("Queued follow-up");
});
await act(async () => {
handlers[0]?.onDone?.({ messageId: "msg-001" });
});
await expect(firstSend!).resolves.toBeUndefined();
await waitFor(() => {
expect(mockStreamChatResponse).toHaveBeenCalledTimes(2);
expect(result.current.isStreaming).toBe(true);
});
await act(async () => {
handlers[1]?.onDone?.({ messageId: "msg-002" });
});
await waitFor(() => {
expect(result.current.isStreaming).toBe(false);
});
});
});
it("onError does not remove user message from local state", async () => {
const existingSession = makeSession({ id: "session-existing", agentId: "agent-001" });
let onErrorHandler: ((data: string) => void) | undefined;