Files
fusion/packages/dashboard/app/hooks/__tests__/useChat.test.ts
Fusion 2140148fe2 feat(FN-3609): split TaskDetailModal tests into focused suites
This merge splits the monolithic TaskDetailModal test file into six focused test suites (rendering, attachments/tabs, definition/actions, inline-editing/integrations, models/progress/workflow, responsive/dependencies), adds a changeset for the test isolation baseline, fixes chat SSE optimistic echo

Fusion-Task-Id: FN-3609
2026-05-06 16:05:44 -07:00

1811 lines
58 KiB
TypeScript

/**
* Tests for useChat hook: session management, message loading, SSE streaming,
* search/filter, and pagination.
*/
import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useChat } from "../useChat";
import * as apiModule from "../../api";
import type { ChatSession, ChatMessage } from "@fusion/core";
// Mock the API module
vi.mock("../../api", () => ({
fetchChatSessions: vi.fn(),
createChatSession: vi.fn(),
fetchChatMessages: vi.fn(),
updateChatSession: vi.fn(),
deleteChatSession: vi.fn(),
streamChatResponse: vi.fn(),
cancelChatResponse: vi.fn(),
fetchAgents: vi.fn().mockResolvedValue([
{ id: "agent-001", name: "Alpha", role: "executor", state: "idle", icon: undefined, createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", metadata: {} },
{ id: "agent-002", name: "Beta", role: "reviewer", state: "idle", icon: undefined, createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", metadata: {} },
]),
}));
// Mock the projectStorage module
vi.mock("../../utils/projectStorage", () => ({
getScopedItem: vi.fn(),
setScopedItem: vi.fn(),
removeScopedItem: vi.fn(),
}));
// Mock the SSE bus
vi.mock("../../sse-bus", () => ({
subscribeSse: vi.fn(() => () => {}),
}));
import * as projectStorageModule from "../../utils/projectStorage";
import * as sseBusModule from "../../sse-bus";
const mockGetScopedItem = vi.mocked(projectStorageModule.getScopedItem);
const mockSetScopedItem = vi.mocked(projectStorageModule.setScopedItem);
const mockRemoveScopedItem = vi.mocked(projectStorageModule.removeScopedItem);
const mockSubscribeSse = vi.mocked(sseBusModule.subscribeSse);
const mockFetchChatSessions = vi.mocked(apiModule.fetchChatSessions);
const mockCreateChatSession = vi.mocked(apiModule.createChatSession);
const mockFetchChatMessages = vi.mocked(apiModule.fetchChatMessages);
const mockUpdateChatSession = vi.mocked(apiModule.updateChatSession);
const mockDeleteChatSession = vi.mocked(apiModule.deleteChatSession);
const mockStreamChatResponse = vi.mocked(apiModule.streamChatResponse);
const mockCancelChatResponse = vi.mocked(apiModule.cancelChatResponse);
const mockFetchAgents = vi.mocked(apiModule.fetchAgents);
function makeSession(overrides: Partial<ChatSession> & Pick<ChatSession, "id" | "agentId">): ChatSession {
return {
id: overrides.id,
agentId: overrides.agentId,
status: overrides.status ?? "active",
title: overrides.title ?? null,
projectId: overrides.projectId ?? null,
modelProvider: overrides.modelProvider ?? null,
modelId: overrides.modelId ?? null,
createdAt: overrides.createdAt ?? "2026-04-08T00:00:00.000Z",
updatedAt: overrides.updatedAt ?? "2026-04-08T00:00:00.000Z",
};
}
function makeMessage(overrides: Partial<ChatMessage> & Pick<ChatMessage, "id" | "sessionId" | "role" | "content">): ChatMessage {
return {
id: overrides.id,
sessionId: overrides.sessionId,
role: overrides.role,
content: overrides.content,
thinkingOutput: overrides.thinkingOutput ?? null,
metadata: overrides.metadata ?? null,
createdAt: overrides.createdAt ?? "2026-04-08T00:00:00.000Z",
};
}
describe("useChat", () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetchChatSessions.mockResolvedValue({ sessions: [] });
mockCreateChatSession.mockResolvedValue({
session: makeSession({ id: "session-001", agentId: "agent-001", title: "New Chat" }),
});
mockFetchChatMessages.mockResolvedValue({ messages: [] });
mockUpdateChatSession.mockResolvedValue({
session: makeSession({ id: "session-001", agentId: "agent-001", status: "archived" }),
});
mockDeleteChatSession.mockResolvedValue({ success: true });
mockStreamChatResponse.mockReturnValue({ close: vi.fn(), isConnected: () => true });
mockCancelChatResponse.mockResolvedValue({ success: true });
});
afterEach(() => {
vi.clearAllMocks();
});
it("loads sessions on mount", async () => {
mockFetchChatSessions.mockResolvedValueOnce({
sessions: [
makeSession({ id: "session-001", agentId: "agent-001" }),
makeSession({ id: "session-002", agentId: "agent-002" }),
],
});
const { result } = renderHook(() => useChat("proj-123"));
await waitFor(() => {
expect(mockFetchChatSessions).toHaveBeenCalledWith("proj-123");
});
await waitFor(() => {
expect(result.current.sessions).toHaveLength(2);
});
expect(result.current.sessions[0]?.id).toBe("session-001");
expect(result.current.sessions[1]?.id).toBe("session-002");
});
it("sendMessage is synchronous and returns void", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
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");
});
// sendMessage should return void (undefined), not a Promise
const sendResult = result.current.sendMessage("Hello");
expect(sendResult).toBeUndefined();
});
it("populates agentsMap on mount", async () => {
const { result } = renderHook(() => useChat("proj-123"));
await waitFor(() => {
expect(mockFetchAgents).toHaveBeenCalledWith(undefined, "proj-123");
});
await waitFor(() => {
expect(result.current.agentsMap.size).toBe(2);
});
expect(result.current.agentsMap.get("agent-001")?.name).toBe("Alpha");
expect(result.current.agentsMap.get("agent-002")?.name).toBe("Beta");
});
it("passes projectId to fetchAgents for agentMap hydration", async () => {
renderHook(() => useChat("proj-456"));
await waitFor(() => {
expect(mockFetchAgents).toHaveBeenCalledWith(undefined, "proj-456");
});
});
it("refetches agents when projectId changes", async () => {
const { result, rerender } = renderHook(
({ projectId }: { projectId: string }) => useChat(projectId),
{ initialProps: { projectId: "proj-001" } },
);
await waitFor(() => {
expect(mockFetchAgents).toHaveBeenCalledWith(undefined, "proj-001");
});
// Change project
rerender({ projectId: "proj-002" });
await waitFor(() => {
expect(mockFetchAgents).toHaveBeenCalledWith(undefined, "proj-002");
});
// Should have been called twice (once per project)
expect(mockFetchAgents).toHaveBeenCalledTimes(2);
});
it("does not populate agentsMap from stale response after project switch", async () => {
// Simulate slow agent fetch for project-001 and fast fetch for project-002
mockFetchAgents
.mockResolvedValueOnce([
{ id: "stale-agent", name: "Stale Agent (proj-001)", role: "executor", state: "idle", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", metadata: {} },
])
.mockResolvedValueOnce([
{ id: "fresh-agent", name: "Fresh Agent (proj-002)", role: "executor", state: "idle", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", metadata: {} },
]);
const { rerender } = renderHook(
({ projectId }: { projectId: string }) => useChat(projectId),
{ initialProps: { projectId: "proj-001" } },
);
// Wait for first fetch to start
await waitFor(() => {
expect(mockFetchAgents).toHaveBeenCalledWith(undefined, "proj-001");
});
// Switch to project-002 while first fetch is still in flight
rerender({ projectId: "proj-002" });
await waitFor(() => {
expect(mockFetchAgents).toHaveBeenCalledWith(undefined, "proj-002");
});
// The second renderHook doesn't expose agentsMap directly from a fresh call,
// but we can verify the mock was called correctly by checking call order
const calls = mockFetchAgents.mock.calls;
expect(calls[0][1]).toBe("proj-001");
expect(calls[1][1]).toBe("proj-002");
});
it("selects a session and loads its messages", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValueOnce({
messages: [
makeMessage({ id: "msg-001", sessionId: "session-001", role: "user", content: "Hello" }),
makeMessage({ id: "msg-002", sessionId: "session-001", role: "assistant", content: "Hi there" }),
],
});
const { result } = renderHook(() => useChat());
await waitFor(() => {
expect(result.current.sessions).toHaveLength(1);
});
act(() => {
result.current.selectSession("session-001");
});
await waitFor(() => {
expect(mockFetchChatMessages).toHaveBeenCalledWith("session-001", { limit: 50 }, undefined);
});
await waitFor(() => {
expect(result.current.messages).toHaveLength(2);
expect(result.current.activeSession?.id).toBe("session-001");
});
});
it("loads BOTH user and assistant messages when selecting a session", async () => {
// This test verifies the fix for FN-1857: Chat assistant messages not persisted
// after navigating away. The selectSession should fetch ALL messages from the server,
// including both user and assistant messages.
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
// Simulate a conversation with multiple user and assistant messages
// in backend chronological order (oldest first)
mockFetchChatMessages.mockResolvedValueOnce({
messages: [
makeMessage({ id: "msg-001", sessionId: "session-001", role: "user", content: "First question" }),
makeMessage({ id: "msg-002", sessionId: "session-001", role: "assistant", content: "First answer" }),
makeMessage({ id: "msg-003", sessionId: "session-001", role: "user", content: "Second question" }),
makeMessage({ id: "msg-004", sessionId: "session-001", role: "assistant", content: "Second answer" }),
],
});
const { result } = renderHook(() => useChat());
await waitFor(() => {
expect(result.current.sessions).toHaveLength(1);
});
act(() => {
result.current.selectSession("session-001");
});
await waitFor(() => {
expect(result.current.messages).toHaveLength(4);
});
// Verify all messages are loaded in correct order
expect(result.current.messages[0]).toMatchObject({
id: "msg-001",
role: "user",
content: "First question",
});
expect(result.current.messages[1]).toMatchObject({
id: "msg-002",
role: "assistant",
content: "First answer",
});
expect(result.current.messages[2]).toMatchObject({
id: "msg-003",
role: "user",
content: "Second question",
});
expect(result.current.messages[3]).toMatchObject({
id: "msg-004",
role: "assistant",
content: "Second answer",
});
});
it("creates a new session and selects it", async () => {
const newSession = makeSession({ id: "session-new", agentId: "agent-001", title: "Test Chat" });
mockCreateChatSession.mockResolvedValueOnce({ session: newSession });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [] });
const { result } = renderHook(() => useChat());
await waitFor(() => {
expect(result.current.sessionsLoading).toBe(false);
});
let createdSession: ReturnType<typeof result.current.createSession> extends Promise<infer T> ? T : never;
await act(async () => {
createdSession = await result.current.createSession({
agentId: "agent-001",
title: "Test Chat",
});
});
await waitFor(() => {
expect(mockCreateChatSession).toHaveBeenCalledWith(
{ agentId: "agent-001", title: "Test Chat" },
undefined,
);
});
await waitFor(() => {
expect(result.current.activeSession?.id).toBe("session-new");
expect(result.current.sessions).toHaveLength(1);
});
});
it("archives a session", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
const { result } = renderHook(() => useChat());
await waitFor(() => {
expect(result.current.sessions).toHaveLength(1);
});
await act(async () => {
await result.current.archiveSession("session-001");
});
await waitFor(() => {
expect(mockUpdateChatSession).toHaveBeenCalledWith("session-001", { status: "archived" }, undefined);
});
await waitFor(() => {
expect(result.current.sessions).toHaveLength(0);
});
});
it("deletes a session", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
const { result } = renderHook(() => useChat());
await waitFor(() => {
expect(result.current.sessions).toHaveLength(1);
});
await act(async () => {
await result.current.deleteSession("session-001");
});
await waitFor(() => {
expect(mockDeleteChatSession).toHaveBeenCalledWith("session-001", undefined);
});
await waitFor(() => {
expect(result.current.sessions).toHaveLength(0);
});
});
it("sends a message and receives streaming response", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
// Track stream close call
const closeFn = vi.fn();
let textHandler: ((data: string) => void) | undefined;
let doneHandler: ((data: { messageId: string }) => void) | undefined;
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
textHandler = handlers.onText;
doneHandler = handlers.onDone;
return { close: closeFn, isConnected: () => true };
});
const { result } = renderHook(() => useChat());
await waitFor(() => {
expect(result.current.sessions).toHaveLength(1);
});
act(() => {
result.current.selectSession("session-001");
});
await waitFor(() => {
expect(result.current.messages).toHaveLength(0);
});
// Simulate sending a message
await act(async () => {
await result.current.sendMessage("Hello!");
});
await waitFor(() => {
// Optimistic user message should be added
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0]?.role).toBe("user");
expect(result.current.messages[0]?.content).toBe("Hello!");
expect(result.current.isStreaming).toBe(true);
});
// Simulate streaming text
await act(async () => {
textHandler?.("Hello ");
textHandler?.("there!");
});
await waitFor(() => {
expect(result.current.streamingText).toBe("Hello there!");
});
// Simulate completion
await act(async () => {
doneHandler?.({ messageId: "msg-002" });
});
await waitFor(() => {
expect(result.current.isStreaming).toBe(false);
// User message should be preserved, assistant message added
expect(result.current.messages).toHaveLength(2);
expect(result.current.messages[0]?.role).toBe("user");
expect(result.current.messages[0]?.content).toBe("Hello!");
expect(result.current.messages[1]?.role).toBe("assistant");
expect(result.current.messages[1]?.id).toBe("msg-002");
expect(result.current.streamingText).toBe("");
});
});
it("uses done payload assistant snapshot when no text chunks were streamed", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
let doneHandler: ((data: { messageId: string; message?: ChatMessage }) => void) | undefined;
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
doneHandler = handlers.onDone as typeof doneHandler;
return { close: vi.fn(), isConnected: () => true };
});
const { result } = renderHook(() => useChat());
await waitFor(() => {
expect(result.current.sessions).toHaveLength(1);
});
act(() => {
result.current.selectSession("session-001");
});
await act(async () => {
result.current.sendMessage("Hello!");
});
act(() => {
doneHandler?.({
messageId: "msg-002",
message: {
id: "msg-002",
sessionId: "session-001",
role: "assistant",
content: "Snapshot reply",
thinkingOutput: null,
metadata: null,
createdAt: "2026-01-01T00:00:00.000Z",
} as ChatMessage,
});
});
await waitFor(() => {
expect(result.current.messages.at(-1)).toEqual(expect.objectContaining({
id: "msg-002",
role: "assistant",
content: "Snapshot reply",
}));
expect(result.current.isStreaming).toBe(false);
});
});
it("prefers done payload assistant snapshot over streamed text", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
let textHandler: ((data: string) => void) | undefined;
let doneHandler: ((data: { messageId: string; message?: ChatMessage }) => void) | undefined;
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
textHandler = handlers.onText;
doneHandler = handlers.onDone as typeof doneHandler;
return { close: vi.fn(), isConnected: () => true };
});
const { result } = renderHook(() => useChat());
await waitFor(() => {
expect(result.current.sessions).toHaveLength(1);
});
act(() => {
result.current.selectSession("session-001");
});
act(() => {
result.current.sendMessage("Hello!");
textHandler?.("streamed text");
doneHandler?.({
messageId: "msg-003",
message: {
id: "msg-003",
sessionId: "session-001",
role: "assistant",
content: "snapshot wins",
thinkingOutput: null,
metadata: null,
createdAt: "2026-01-01T00:00:00.000Z",
} as ChatMessage,
});
});
await waitFor(() => {
expect(result.current.messages.at(-1)).toEqual(expect.objectContaining({
id: "msg-003",
content: "snapshot wins",
}));
});
});
it("handles stream errors and surfaces them to the user", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
const addToast = vi.fn();
let errorHandler: ((data: string) => void) | undefined;
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
errorHandler = handlers.onError;
return { close: vi.fn(), isConnected: () => true };
});
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");
});
await act(async () => {
await result.current.sendMessage("Hello!");
});
// Simulate error
await act(async () => {
errorHandler?.("Stream connection failed");
});
await waitFor(() => {
expect(result.current.isStreaming).toBe(false);
expect(result.current.messages).toHaveLength(0);
expect(addToast).toHaveBeenCalledWith("Stream connection failed", "error");
});
});
it("onFallback updates the selected session model, persists fallback metadata, and shows a warning toast", async () => {
const session = makeSession({
id: "session-001",
agentId: "agent-001",
modelProvider: "openai-codex",
modelId: "gpt-5.3-codex",
});
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
const addToast = vi.fn();
let fallbackHandler:
| ((data: { primaryModel: string; fallbackModel: string; triggerPoint: "session-creation" | "prompt-time" }) => void)
| undefined;
let textHandler: ((data: string) => void) | undefined;
let doneHandler: ((data: { messageId: string }) => void) | undefined;
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
fallbackHandler = handlers.onFallback;
textHandler = handlers.onText;
doneHandler = handlers.onDone;
return { close: vi.fn(), isConnected: () => true };
});
const { result } = renderHook(() => useChat(undefined, addToast));
await waitFor(() => {
expect(result.current.sessions).toHaveLength(1);
});
act(() => {
result.current.selectSession("session-001");
});
await act(async () => {
await result.current.sendMessage("Hello!");
});
act(() => {
fallbackHandler?.({
primaryModel: "openai-codex/gpt-5.3-codex",
fallbackModel: "zai/glm-5.1",
triggerPoint: "prompt-time",
});
textHandler?.("Fallback reply");
doneHandler?.({ messageId: "msg-fallback" });
});
await waitFor(() => {
expect(result.current.activeSession?.modelProvider).toBe("zai");
expect(result.current.activeSession?.modelId).toBe("glm-5.1");
expect(addToast).toHaveBeenCalledWith(
"Primary model unavailable. Switched to fallback zai/glm-5.1.",
"warning",
);
expect(result.current.messages.at(-1)).toEqual(expect.objectContaining({
id: "msg-fallback",
role: "assistant",
content: "Fallback reply",
fallbackInfo: {
primaryModel: "openai-codex/gpt-5.3-codex",
fallbackModel: "zai/glm-5.1",
triggerPoint: "prompt-time",
},
}));
});
});
it("stopStreaming aborts stream and resets streaming state", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
const closeFn = vi.fn();
mockStreamChatResponse.mockReturnValue({ close: closeFn, 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("Hello!");
});
await waitFor(() => {
expect(result.current.isStreaming).toBe(true);
});
act(() => {
result.current.stopStreaming();
});
await waitFor(() => {
expect(closeFn).toHaveBeenCalledTimes(1);
expect(mockCancelChatResponse).toHaveBeenCalledWith("session-001", "proj-123");
expect(result.current.isStreaming).toBe(false);
expect(result.current.streamingText).toBe("");
expect(result.current.streamingThinking).toBe("");
});
});
it("sending during streaming queues pendingMessage", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
mockStreamChatResponse.mockReturnValue({ 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 message");
});
expect(result.current.pendingMessage).toBe("Queued message");
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: [] });
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]?.[1]).toBe("Queued follow-up");
expect(result.current.pendingMessage).toBe("");
});
});
it("queued message is not auto-sent after user-initiated stop", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
const handlers: Array<Parameters<typeof mockStreamChatResponse>[2]> = [];
const closeFn = vi.fn();
mockStreamChatResponse.mockImplementation((_sessionId, _content, nextHandlers) => {
handlers.push(nextHandlers);
return { close: closeFn, 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");
result.current.stopStreaming();
});
act(() => {
handlers[0]?.onError?.("Generation cancelled");
});
await waitFor(() => {
expect(closeFn).toHaveBeenCalled();
expect(mockStreamChatResponse).toHaveBeenCalledTimes(1);
expect(result.current.pendingMessage).toBe("Queued follow-up");
});
});
it("selectSession clears pending queued message state", async () => {
const sessionA = makeSession({ id: "session-001", agentId: "agent-001" });
const sessionB = makeSession({ id: "session-002", agentId: "agent-002" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [sessionA, sessionB] });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
mockStreamChatResponse.mockReturnValue({ close: vi.fn(), isConnected: () => true });
const { result } = renderHook(() => useChat("proj-123"));
await waitFor(() => {
expect(result.current.sessions).toHaveLength(2);
});
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(() => {
result.current.selectSession("session-002");
});
await waitFor(() => {
expect(result.current.pendingMessage).toBe("");
expect(result.current.isStreaming).toBe(false);
});
});
it("clearPendingMessage clears pending message", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
mockStreamChatResponse.mockReturnValue({ 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(() => {
result.current.clearPendingMessage();
});
expect(result.current.pendingMessage).toBe("");
});
it("stopStreaming preserves pendingMessage", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
mockStreamChatResponse.mockReturnValue({ 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");
result.current.stopStreaming();
});
await waitFor(() => {
expect(result.current.pendingMessage).toBe("Queued follow-up");
});
});
it("loads more messages with pagination", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
// Return 50 messages for initial load to keep hasMoreMessages=true, then 1 for loadMore
const make50Messages = () =>
Array.from({ length: 50 }, (_, i) => makeMessage({ id: `msg-${i}`, sessionId: "session-001", role: "user", content: `Message ${i}` }));
mockFetchChatMessages
.mockResolvedValueOnce({ messages: make50Messages() })
.mockResolvedValueOnce({ messages: [makeMessage({ id: "msg-old", sessionId: "session-001", role: "user", content: "Old message" })] });
const { result } = renderHook(() => useChat());
await waitFor(() => {
expect(result.current.sessions).toHaveLength(1);
});
act(() => {
result.current.selectSession("session-001");
});
await waitFor(() => {
expect(result.current.messages).toHaveLength(50);
expect(result.current.hasMoreMessages).toBe(true);
});
// Before loadMoreMessages
const callCountBefore = mockFetchChatMessages.mock.calls.length;
await act(async () => {
await result.current.loadMoreMessages();
});
// Verify that loadMoreMessages triggered a new fetch
await waitFor(() => {
expect(mockFetchChatMessages.mock.calls.length).toBeGreaterThan(callCountBefore);
});
// Verify the second call had pagination params
const secondCall = mockFetchChatMessages.mock.calls[1];
expect(secondCall[0]).toBe("session-001");
expect(secondCall[1]).toHaveProperty("limit");
expect(secondCall[1]).toHaveProperty("offset");
await waitFor(() => {
expect(result.current.messages).toHaveLength(51);
});
});
it("sets hasMoreMessages to false when fewer messages returned", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValueOnce({ messages: [makeMessage({ id: "msg-001", sessionId: "session-001", role: "user", content: "Recent" })] });
const { result } = renderHook(() => useChat());
await waitFor(() => {
expect(result.current.sessions).toHaveLength(1);
});
act(() => {
result.current.selectSession("session-001");
});
await waitFor(() => {
expect(result.current.messages).toHaveLength(1);
expect(result.current.hasMoreMessages).toBe(false);
});
});
it("filters sessions by search query", async () => {
mockFetchChatSessions.mockResolvedValueOnce({
sessions: [
makeSession({ id: "session-001", agentId: "agent-001", title: "Frontend work" }),
makeSession({ id: "session-002", agentId: "agent-002", title: "Backend API" }),
makeSession({ id: "session-003", agentId: "agent-003", title: "Frontend design" }),
],
});
const { result } = renderHook(() => useChat());
await waitFor(() => {
expect(result.current.sessions).toHaveLength(3);
});
act(() => {
result.current.setSearchQuery("frontend");
});
await waitFor(() => {
expect(result.current.filteredSessions).toHaveLength(2);
expect(result.current.filteredSessions.map((s) => s.id)).toContain("session-001");
expect(result.current.filteredSessions.map((s) => s.id)).toContain("session-003");
});
act(() => {
result.current.setSearchQuery("");
});
await waitFor(() => {
expect(result.current.filteredSessions).toHaveLength(3);
});
});
it("closes stream when switching sessions", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
const session2 = makeSession({ id: "session-002", agentId: "agent-002" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session, session2] });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
const closeFn = vi.fn();
mockStreamChatResponse.mockReturnValue({ close: closeFn, isConnected: () => true });
const { result } = renderHook(() => useChat());
await waitFor(() => {
expect(result.current.sessions).toHaveLength(2);
});
act(() => {
result.current.selectSession("session-001");
});
await act(async () => {
await result.current.sendMessage("Hello!");
});
await waitFor(() => {
expect(result.current.isStreaming).toBe(true);
});
// Switch sessions
act(() => {
result.current.selectSession("session-002");
});
await waitFor(() => {
expect(closeFn).toHaveBeenCalled();
expect(result.current.activeSession?.id).toBe("session-002");
});
});
it("refreshes sessions", async () => {
mockFetchChatSessions
.mockResolvedValueOnce({ sessions: [makeSession({ id: "session-001", agentId: "agent-001" })] })
.mockResolvedValueOnce({ sessions: [makeSession({ id: "session-001", agentId: "agent-001" }), makeSession({ id: "session-002", agentId: "agent-002" })] });
const { result } = renderHook(() => useChat());
await waitFor(() => {
expect(result.current.sessions).toHaveLength(1);
});
await act(async () => {
await result.current.refreshSessions();
});
await waitFor(() => {
expect(result.current.sessions).toHaveLength(2);
});
});
describe("SSE real-time updates", () => {
let subscribeHandler: Record<string, (event: MessageEvent) => void> = {};
beforeEach(() => {
subscribeHandler = {};
mockSubscribeSse.mockImplementation((_url, options) => {
// Capture the event handlers
if (options?.events) {
subscribeHandler = options.events as typeof subscribeHandler;
}
return () => {};
});
});
afterEach(() => {
subscribeHandler = {};
});
it("subscribes to chat SSE events", async () => {
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [] });
renderHook(() => useChat("proj-123"));
await waitFor(() => {
expect(mockSubscribeSse).toHaveBeenCalledWith(
"/api/events?projectId=proj-123",
expect.objectContaining({
events: expect.objectContaining({
"chat:session:created": expect.any(Function),
"chat:session:updated": expect.any(Function),
"chat:session:deleted": expect.any(Function),
"chat:message:added": expect.any(Function),
"chat:message:deleted": expect.any(Function),
}),
}),
);
});
});
it("adds new session on chat:session:created event", async () => {
mockFetchChatSessions.mockResolvedValueOnce({
sessions: [makeSession({ id: "session-001", agentId: "agent-001" })],
});
const { result } = renderHook(() => useChat("proj-123"));
await waitFor(() => {
expect(result.current.sessions).toHaveLength(1);
});
// Simulate SSE event
const newSession = makeSession({ id: "session-002", agentId: "agent-002", title: "New Chat" });
act(() => {
subscribeHandler["chat:session:created"]?.({
data: JSON.stringify(newSession),
} as MessageEvent);
});
await waitFor(() => {
expect(result.current.sessions).toHaveLength(2);
expect(result.current.sessions[0]?.id).toBe("session-002");
});
});
it("avoids duplicate sessions on chat:session:created", async () => {
mockFetchChatSessions.mockResolvedValueOnce({
sessions: [makeSession({ id: "session-001", agentId: "agent-001" })],
});
const { result } = renderHook(() => useChat("proj-123"));
await waitFor(() => {
expect(result.current.sessions).toHaveLength(1);
});
// Simulate SSE event for the same session
const sameSession = makeSession({ id: "session-001", agentId: "agent-001" });
act(() => {
subscribeHandler["chat:session:created"]?.({
data: JSON.stringify(sameSession),
} as MessageEvent);
});
await waitFor(() => {
expect(result.current.sessions).toHaveLength(1);
});
});
it("updates session on chat:session:updated event", async () => {
mockFetchChatSessions.mockResolvedValueOnce({
sessions: [makeSession({ id: "session-001", agentId: "agent-001", title: "Old Title" })],
});
const { result } = renderHook(() => useChat("proj-123"));
await waitFor(() => {
expect(result.current.sessions).toHaveLength(1);
expect(result.current.sessions[0]?.title).toBe("Old Title");
});
// Simulate SSE event
const updatedSession = makeSession({ id: "session-001", agentId: "agent-001", title: "New Title" });
act(() => {
subscribeHandler["chat:session:updated"]?.({
data: JSON.stringify(updatedSession),
} as MessageEvent);
});
await waitFor(() => {
expect(result.current.sessions[0]?.title).toBe("New Title");
});
});
it("removes session on chat:session:deleted event", async () => {
mockFetchChatSessions.mockResolvedValueOnce({
sessions: [
makeSession({ id: "session-001", agentId: "agent-001" }),
makeSession({ id: "session-002", agentId: "agent-002" }),
],
});
const { result } = renderHook(() => useChat("proj-123"));
await waitFor(() => {
expect(result.current.sessions).toHaveLength(2);
});
// Simulate SSE event
act(() => {
subscribeHandler["chat:session:deleted"]?.({
data: JSON.stringify({ id: "session-001" }),
} as MessageEvent);
});
await waitFor(() => {
expect(result.current.sessions).toHaveLength(1);
expect(result.current.sessions[0]?.id).toBe("session-002");
});
});
it("clears active session when it is deleted", async () => {
mockFetchChatSessions.mockResolvedValueOnce({
sessions: [makeSession({ id: "session-001", agentId: "agent-001" })],
});
mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
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");
});
// Simulate SSE event for the active session
act(() => {
subscribeHandler["chat:session:deleted"]?.({
data: JSON.stringify({ id: "session-001" }),
} as MessageEvent);
});
await waitFor(() => {
expect(result.current.activeSession).toBeNull();
expect(result.current.messages).toHaveLength(0);
});
});
it("adds message on chat:message:added event for active session", async () => {
mockFetchChatSessions.mockResolvedValueOnce({
sessions: [makeSession({ id: "session-001", agentId: "agent-001" })],
});
mockFetchChatMessages.mockResolvedValueOnce({
messages: [makeMessage({ id: "msg-001", sessionId: "session-001", role: "user", content: "Hello" })],
});
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.messages).toHaveLength(1);
});
// Simulate SSE event for a new message in the active session
const newMessage = makeMessage({ id: "msg-002", sessionId: "session-001", role: "assistant", content: "Hi there" });
act(() => {
subscribeHandler["chat:message:added"]?.({
data: JSON.stringify(newMessage),
} as MessageEvent);
});
await waitFor(() => {
expect(result.current.messages).toHaveLength(2);
expect(result.current.messages[1]?.content).toBe("Hi there");
});
});
it("does not add message on chat:message:added when streaming", async () => {
mockFetchChatSessions.mockResolvedValueOnce({
sessions: [makeSession({ id: "session-001", agentId: "agent-001" })],
});
mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
void handlers.onDone;
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.messages).toHaveLength(0);
});
await act(async () => {
await result.current.sendMessage("Hello!");
});
await waitFor(() => {
expect(result.current.isStreaming).toBe(true);
});
const newMessage = makeMessage({ id: "msg-002", sessionId: "session-001", role: "assistant", content: "Hi" });
act(() => {
subscribeHandler["chat:message:added"]?.({
data: JSON.stringify(newMessage),
} as MessageEvent);
});
await waitFor(() => {
expect(result.current.messages).toHaveLength(1);
});
});
it("dedupes optimistic user message when persisted user echo arrives after done", async () => {
mockFetchChatSessions.mockResolvedValueOnce({
sessions: [makeSession({ id: "session-001", agentId: "agent-001" })],
});
mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
let doneHandler: ((data: { messageId: string }) => void) | undefined;
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
doneHandler = handlers.onDone;
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("Hello!");
});
await waitFor(() => {
expect(result.current.messages.filter((message) => message.role === "user")).toHaveLength(1);
});
act(() => {
doneHandler?.({ messageId: "msg-assistant-001" });
});
await waitFor(() => {
expect(result.current.isStreaming).toBe(false);
expect(result.current.messages).toHaveLength(2);
});
const persistedEcho = makeMessage({
id: "msg-user-001",
sessionId: "session-001",
role: "user",
content: "Hello!",
});
act(() => {
subscribeHandler["chat:message:added"]?.({
data: JSON.stringify(persistedEcho),
} as MessageEvent);
});
await waitFor(() => {
const userMessages = result.current.messages.filter((message) => message.role === "user");
expect(userMessages).toHaveLength(1);
});
});
it("removes message on chat:message:deleted event", async () => {
mockFetchChatSessions.mockResolvedValueOnce({
sessions: [makeSession({ id: "session-001", agentId: "agent-001" })],
});
mockFetchChatMessages.mockResolvedValueOnce({
messages: [
makeMessage({ id: "msg-001", sessionId: "session-001", role: "user", content: "Hello" }),
makeMessage({ id: "msg-002", sessionId: "session-001", role: "assistant", content: "Hi there" }),
],
});
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.messages).toHaveLength(2);
});
// Simulate SSE event for deleted message
act(() => {
subscribeHandler["chat:message:deleted"]?.({
data: JSON.stringify({ id: "msg-001" }),
} as MessageEvent);
});
await waitFor(() => {
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0]?.id).toBe("msg-002");
});
});
});
describe("active session persistence", () => {
beforeEach(() => {
// Default: no saved session
mockGetScopedItem.mockReturnValue(null);
});
it("restores active session from localStorage when it matches a loaded session", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
// Simulate a saved session in localStorage
mockGetScopedItem.mockReturnValue("session-001");
const { result } = renderHook(() => useChat());
await waitFor(() => {
expect(result.current.sessions).toHaveLength(1);
});
await waitFor(() => {
expect(result.current.activeSession?.id).toBe("session-001");
});
// Verify messages were loaded
await waitFor(() => {
expect(mockFetchChatMessages).toHaveBeenCalledWith("session-001", { limit: 50 }, undefined);
});
});
it("does not auto-select when saved session does not exist in loaded sessions", async () => {
mockFetchChatSessions.mockResolvedValueOnce({
sessions: [makeSession({ id: "session-001", agentId: "agent-001" })],
});
// Simulate a saved session that no longer exists
mockGetScopedItem.mockReturnValue("non-existent-session");
const { result } = renderHook(() => useChat());
await waitFor(() => {
expect(result.current.sessions).toHaveLength(1);
});
// Should not have an active session since the saved one doesn't exist
await waitFor(() => {
expect(result.current.activeSession).toBeNull();
});
// Messages should not be loaded since no session is selected
expect(mockFetchChatMessages).not.toHaveBeenCalled();
});
it("persists session ID to localStorage when selecting a session", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
const { result } = renderHook(() => useChat("proj-123"));
await waitFor(() => {
expect(result.current.sessions).toHaveLength(1);
});
act(() => {
result.current.selectSession("session-001");
});
await waitFor(() => {
expect(mockSetScopedItem).toHaveBeenCalledWith(
"kb-chat-active-session",
"session-001",
"proj-123",
);
});
});
it("removes session ID from localStorage when deselecting", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
const { result } = renderHook(() => useChat("proj-123"));
await waitFor(() => {
expect(result.current.sessions).toHaveLength(1);
});
// First select a session
act(() => {
result.current.selectSession("session-001");
});
await waitFor(() => {
expect(result.current.activeSession?.id).toBe("session-001");
});
// Reset the mock to track the removal call
mockSetScopedItem.mockClear();
// Now deselect
act(() => {
result.current.selectSession("");
});
await waitFor(() => {
expect(result.current.activeSession).toBeNull();
});
await waitFor(() => {
expect(mockRemoveScopedItem).toHaveBeenCalledWith(
"kb-chat-active-session",
"proj-123",
);
});
});
it("uses undefined projectId when not provided", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
const { result } = renderHook(() => useChat());
await waitFor(() => {
expect(result.current.sessions).toHaveLength(1);
});
act(() => {
result.current.selectSession("session-001");
});
await waitFor(() => {
expect(mockSetScopedItem).toHaveBeenCalledWith(
"kb-chat-active-session",
"session-001",
undefined,
);
});
});
});
describe("FN-3336: streaming state recovery on reload", () => {
it("does not re-select and reset active session on subsequent session refreshes", async () => {
const session = { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: true };
mockGetScopedItem.mockReturnValue("session-001");
mockFetchChatSessions
.mockResolvedValueOnce({ sessions: [session] })
.mockResolvedValueOnce({ sessions: [{ ...session, updatedAt: "2026-04-08T00:05:00.000Z" }] });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
const { result } = renderHook(() => useChat("proj-123"));
await waitFor(() => {
expect(result.current.activeSession?.id).toBe("session-001");
});
expect(mockFetchChatMessages).toHaveBeenCalledTimes(1);
await act(async () => {
await result.current.refreshSessions();
});
await waitFor(() => {
expect(result.current.isStreaming).toBe(true);
});
// A sessions refresh should not auto-reselect/reset the active thread.
expect(mockFetchChatMessages).toHaveBeenCalledTimes(1);
});
it("preserves streaming text/thinking/tool state across sessions refresh", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions
.mockResolvedValueOnce({ sessions: [session] })
.mockResolvedValueOnce({ sessions: [{ ...session, updatedAt: "2026-04-08T00:06:00.000Z" }] });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
let textHandler: ((data: string) => void) | undefined;
let thinkingHandler: ((data: string) => void) | undefined;
let toolStartHandler: ((data: { toolName: string; args?: Record<string, unknown> }) => void) | undefined;
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
textHandler = handlers.onText;
thinkingHandler = handlers.onThinking;
toolStartHandler = handlers.onToolStart;
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 act(async () => {
result.current.sendMessage("Hello");
});
await act(async () => {
textHandler?.("Hi");
thinkingHandler?.("plan");
toolStartHandler?.({ toolName: "read", args: { path: "a.ts" } });
});
await waitFor(() => {
expect(result.current.isStreaming).toBe(true);
expect(result.current.streamingText).toBe("Hi");
expect(result.current.streamingThinking).toBe("plan");
expect(result.current.streamingToolCalls).toHaveLength(1);
});
await act(async () => {
await result.current.refreshSessions();
});
expect(result.current.isStreaming).toBe(true);
expect(result.current.streamingText).toBe("Hi");
expect(result.current.streamingThinking).toBe("plan");
expect(result.current.streamingToolCalls).toHaveLength(1);
});
it("sets isStreaming=true when selecting a session with isGenerating=true", async () => {
const session = { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: true };
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
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);
expect(result.current.streamingText).toBe("");
});
});
it("does not set isStreaming when isGenerating is false", async () => {
const session = { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: false };
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
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(false);
});
});
it("clears recovery streaming state when SSE delivers assistant message", async () => {
let subscribeHandler: Record<string, (event: MessageEvent) => void> = {};
mockSubscribeSse.mockImplementation((_url, options) => {
if (options?.events) {
subscribeHandler = options.events as typeof subscribeHandler;
}
return () => {};
});
const session = { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: true };
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
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);
});
// Simulate SSE delivering the completed assistant message
const assistantMessage = makeMessage({
id: "msg-assistant-001",
sessionId: "session-001",
role: "assistant",
content: "Generated response",
});
act(() => {
subscribeHandler["chat:message:added"](
new MessageEvent("chat:message:added", { data: JSON.stringify(assistantMessage) }),
);
});
await waitFor(() => {
expect(result.current.isStreaming).toBe(false);
expect(result.current.streamingText).toBe("");
expect(result.current.messages.some((m) => m.id === "msg-assistant-001")).toBe(true);
});
});
});
});