Extend the chat brain-icon popup and its backing session PATCH route so an active Direct chat's model or agent can be switched mid-conversation instead of only being set at creation time. - Add a Model/Agent section to ChatThinkingLevelControl (the brain-icon popup) for picking a model provider/model or retargeting to a real agent without leaving the chat. - Extend PATCH /api/chat/sessions/:id to accept modelProvider/modelId (as a validated pair via the existing validateModelPair helper) and agentId, forwarding only the keys present in the body so omitted fields leave the session's stored target untouched. - Add chat-store updateSession support for the agentId clause alongside the existing model/thinkingLevel fields, and a useChat.setSessionModel hook for the dashboard to call the new PATCH capability. - Update i18n locale strings (en/es/fr/ko/zh-CN/zh-TW) and dashboard-guide.md docs for the new switcher UI. - Add unit/integration test coverage across chat-store, chat-manager, chat-routes, useChat, ChatThinkingLevelControl, and ChatView for the new model/agent switch behavior. - Add changeset fn-7908-chat-model-agent-switcher.md (minor, @runfusion/fusion). Files changed: .changeset/fn-7908-chat-model-agent-switcher.md | 7 + docs/dashboard-guide.md | 3 +- packages/core/src/__tests__/chat-store.test.ts | 21 ++ packages/core/src/chat-store.ts | 8 + packages/core/src/chat-types.ts | 2 + packages/dashboard/app/api/legacy.ts | 11 +- .../app/components/ChatThinkingLevelControl.tsx | 219 ++++++++++++++++++--- packages/dashboard/app/components/ChatView.css | 135 ++++++++++++- packages/dashboard/app/components/ChatView.tsx | 23 ++- .../__tests__/ChatThinkingLevelControl.test.tsx | 109 +++++++++- .../__tests__/ChatView.thinking-level.test.tsx | 67 ++++++- .../dashboard/app/hooks/__tests__/useChat.test.ts | 166 +++++++++++++++- packages/dashboard/app/hooks/useChat.ts | 56 ++++++ .../dashboard/src/__tests__/chat-manager.test.ts | 38 ++++ .../dashboard/src/__tests__/chat-routes.test.ts | 117 ++++++++++- .../dashboard/src/routes/register-chat-routes.ts | 48 ++++- packages/i18n/locales/en/app.json | 8 +- packages/i18n/locales/es/app.json | 8 +- packages/i18n/locales/fr/app.json | 8 +- packages/i18n/locales/ko/app.json | 8 +- packages/i18n/locales/zh-CN/app.json | 8 +- packages/i18n/locales/zh-TW/app.json | 8 +- 22 files changed, 1007 insertions(+), 71 deletions(-) Fusion-Task-Id: FN-7908 Fusion-Task-Lineage: b1104865-9b0c-4d77-973e-89152fe245e0 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
5356 lines
187 KiB
TypeScript
5356 lines
187 KiB
TypeScript
/**
|
|
* Tests for useChat hook: session management, message loading, SSE streaming,
|
|
* search/filter, and pagination.
|
|
*/
|
|
|
|
import { act, fireEvent, renderHook, waitFor } from "@testing-library/react";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { FN_AGENT_ID, useChat } from "../useChat";
|
|
import * as apiModule from "../../api";
|
|
import { getChatPendingMessageKey } from "../chatPendingMessageStorage";
|
|
import * as swrCacheModule from "../../utils/swrCache";
|
|
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(),
|
|
deleteChatSession: vi.fn(),
|
|
editChatMessage: vi.fn(),
|
|
streamChatResponse: vi.fn(),
|
|
attachChatStream: 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 mockFetchChatSession = vi.mocked(apiModule.fetchChatSession);
|
|
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 mockEditChatMessage = vi.mocked(apiModule.editChatMessage);
|
|
const mockStreamChatResponse = vi.mocked(apiModule.streamChatResponse);
|
|
const mockAttachChatStream = vi.mocked(apiModule.attachChatStream);
|
|
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,
|
|
thinkingLevel: overrides.thinkingLevel ?? 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,
|
|
attachments: overrides.attachments,
|
|
createdAt: overrides.createdAt ?? "2026-04-08T00:00:00.000Z",
|
|
};
|
|
}
|
|
|
|
function createDeferredPromise<T>() {
|
|
let resolve!: (value: T | PromiseLike<T>) => void;
|
|
let reject!: (reason?: unknown) => void;
|
|
const promise = new Promise<T>((res, rej) => {
|
|
resolve = res;
|
|
reject = rej;
|
|
});
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
type StreamAppendHandlers = {
|
|
onText: (delta: string) => void;
|
|
onThinking: (delta: string) => void;
|
|
onToolStart: (data: { toolName: string; args?: Record<string, unknown> }) => void;
|
|
onToolEnd: (data: { toolName: string; isError: boolean; result?: unknown }) => void;
|
|
onDone?: (data: { messageId?: string; message?: ChatMessage; accumulated: { text: string; thinking: string; toolCalls: unknown[]; fallbackInfo?: unknown } }) => void;
|
|
};
|
|
|
|
function cacheMessages(projectId: string, sessionId: string, messages: ChatMessage[]) {
|
|
localStorage.setItem(
|
|
`${swrCacheModule.SWR_CACHE_KEYS.CHAT_MESSAGES_PREFIX}${projectId}:${sessionId}`,
|
|
JSON.stringify({ savedAt: Date.now(), data: messages }),
|
|
);
|
|
}
|
|
|
|
const setDocumentVisibilityState = (state: DocumentVisibilityState) => {
|
|
Object.defineProperty(document, "visibilityState", {
|
|
configurable: true,
|
|
get: () => state,
|
|
});
|
|
fireEvent(document, new Event("visibilitychange"));
|
|
};
|
|
|
|
describe("useChat", () => {
|
|
const chatSessionsCacheKey = (projectId: string) => `${swrCacheModule.SWR_CACHE_KEYS.CHAT_SESSIONS_PREFIX}${projectId}`;
|
|
const chatMessagesCacheKey = (projectId: string, sessionId: string) => `${swrCacheModule.SWR_CACHE_KEYS.CHAT_MESSAGES_PREFIX}${projectId}:${sessionId}`;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
localStorage.clear();
|
|
mockGetScopedItem.mockReturnValue(undefined);
|
|
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" }),
|
|
});
|
|
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 });
|
|
mockAttachChatStream.mockReturnValue({ close: vi.fn(), isConnected: () => true });
|
|
mockCancelChatResponse.mockResolvedValue({ success: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
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("hydrates sessions from cache synchronously and skips initial loading state", async () => {
|
|
const projectId = "proj-cache-hit";
|
|
localStorage.setItem(
|
|
chatSessionsCacheKey(projectId),
|
|
JSON.stringify({
|
|
savedAt: Date.now(),
|
|
data: [
|
|
makeSession({ id: "session-001", agentId: "agent-001", updatedAt: "2026-04-08T00:00:00.000Z" }),
|
|
makeSession({ id: "session-002", agentId: "agent-002", updatedAt: "2026-04-07T00:00:00.000Z" }),
|
|
],
|
|
}),
|
|
);
|
|
|
|
let resolveFetch: ((value: { sessions: ChatSession[] }) => void) | undefined;
|
|
mockFetchChatSessions.mockImplementationOnce(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
resolveFetch = resolve;
|
|
}),
|
|
);
|
|
|
|
const { result } = renderHook(() => useChat(projectId));
|
|
|
|
expect(result.current.sessions).toHaveLength(2);
|
|
expect(result.current.sessionsLoading).toBe(false);
|
|
|
|
await act(async () => {
|
|
resolveFetch?.({ sessions: [] });
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchChatSessions).toHaveBeenCalledWith(projectId);
|
|
});
|
|
});
|
|
|
|
it("does not hydrate cached task-planner sessions before server settings filtering returns", async () => {
|
|
const projectId = "proj-cache-task-planner";
|
|
localStorage.setItem(
|
|
chatSessionsCacheKey(projectId),
|
|
JSON.stringify({
|
|
savedAt: Date.now(),
|
|
data: [
|
|
makeSession({ id: "session-direct", agentId: "agent-001", updatedAt: "2026-04-08T00:00:00.000Z" }),
|
|
makeSession({ id: "session-planner", agentId: "task-planner:FN-7364", updatedAt: "2026-04-09T00:00:00.000Z" }),
|
|
],
|
|
}),
|
|
);
|
|
|
|
let resolveFetch: ((value: { sessions: ChatSession[] }) => void) | undefined;
|
|
mockFetchChatSessions.mockImplementationOnce(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
resolveFetch = resolve;
|
|
}),
|
|
);
|
|
|
|
const { result } = renderHook(() => useChat(projectId));
|
|
|
|
expect(result.current.sessions.map((session) => session.id)).toEqual(["session-direct"]);
|
|
|
|
await act(async () => {
|
|
resolveFetch?.({
|
|
sessions: [
|
|
makeSession({ id: "session-planner", agentId: "task-planner:FN-7364", updatedAt: "2026-04-09T00:00:00.000Z" }),
|
|
],
|
|
});
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions.map((session) => session.id)).toEqual(["session-planner"]);
|
|
});
|
|
});
|
|
|
|
it("writes sorted sessions to cache after successful refresh", async () => {
|
|
const projectId = "proj-write-through";
|
|
mockFetchChatSessions.mockResolvedValueOnce({
|
|
sessions: [
|
|
makeSession({ id: "session-001", agentId: "agent-001", updatedAt: "2026-04-08T00:00:00.000Z" }),
|
|
makeSession({ id: "session-003", agentId: "agent-003", updatedAt: "2026-04-10T00:00:00.000Z" }),
|
|
makeSession({ id: "session-002", agentId: "agent-002", updatedAt: "2026-04-09T00:00:00.000Z" }),
|
|
],
|
|
});
|
|
|
|
renderHook(() => useChat(projectId));
|
|
|
|
await waitFor(() => {
|
|
const raw = localStorage.getItem(chatSessionsCacheKey(projectId));
|
|
expect(raw).toBeTruthy();
|
|
const parsed = JSON.parse(raw ?? "null") as { data: ChatSession[] };
|
|
expect(parsed.data.map((session) => session.id)).toEqual(["session-003", "session-002", "session-001"]);
|
|
});
|
|
});
|
|
|
|
it("keeps first-time load semantics when cache is missing", async () => {
|
|
const projectId = "proj-cache-miss";
|
|
mockFetchChatSessions.mockResolvedValueOnce({
|
|
sessions: [
|
|
makeSession({ id: "session-001", agentId: "agent-001", updatedAt: "2026-04-08T00:00:00.000Z" }),
|
|
makeSession({ id: "session-002", agentId: "agent-002", updatedAt: "2026-04-10T00:00:00.000Z" }),
|
|
],
|
|
});
|
|
|
|
const { result } = renderHook(() => useChat(projectId));
|
|
|
|
expect(result.current.sessionsLoading).toBe(true);
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessionsLoading).toBe(false);
|
|
expect(result.current.sessions.map((session) => session.id)).toEqual(["session-002", "session-001"]);
|
|
});
|
|
});
|
|
|
|
it("clears stale cache envelope when refresh fails with empty in-memory sessions", async () => {
|
|
const projectId = "proj-empty-failure";
|
|
localStorage.setItem(
|
|
chatSessionsCacheKey(projectId),
|
|
JSON.stringify({
|
|
savedAt: Date.now() - 120_000,
|
|
data: [makeSession({ id: "session-stale", agentId: "agent-001" })],
|
|
}),
|
|
);
|
|
|
|
const clearCacheSpy = vi.spyOn(swrCacheModule, "clearCache");
|
|
mockFetchChatSessions.mockReset();
|
|
mockFetchChatSessions.mockRejectedValue(new Error("network down"));
|
|
|
|
const { result } = renderHook(() => useChat(projectId));
|
|
|
|
expect(result.current.sessions).toEqual([]);
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessionsLoading).toBe(false);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(clearCacheSpy).toHaveBeenCalledWith(chatSessionsCacheKey(projectId));
|
|
});
|
|
});
|
|
|
|
it("preserves cache envelope when refresh fails after cached sessions hydrate", async () => {
|
|
const projectId = "proj-non-empty-failure";
|
|
localStorage.setItem(
|
|
chatSessionsCacheKey(projectId),
|
|
JSON.stringify({
|
|
savedAt: Date.now(),
|
|
data: [makeSession({ id: "session-cached", agentId: "agent-001" })],
|
|
}),
|
|
);
|
|
|
|
mockFetchChatSessions.mockRejectedValueOnce(new Error("network down"));
|
|
|
|
const { result } = renderHook(() => useChat(projectId));
|
|
|
|
expect(result.current.sessions).toHaveLength(1);
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessionsLoading).toBe(false);
|
|
});
|
|
|
|
expect(localStorage.getItem(chatSessionsCacheKey(projectId))).toBeTruthy();
|
|
});
|
|
|
|
it("rehydrates cached sessions per project on project switch", async () => {
|
|
localStorage.setItem(
|
|
chatSessionsCacheKey("p1"),
|
|
JSON.stringify({ savedAt: Date.now(), data: [makeSession({ id: "session-p1", agentId: "agent-001" })] }),
|
|
);
|
|
localStorage.setItem(
|
|
chatSessionsCacheKey("p2"),
|
|
JSON.stringify({ savedAt: Date.now(), data: [makeSession({ id: "session-p2", agentId: "agent-002" })] }),
|
|
);
|
|
|
|
let deferredResolve: ((value: { sessions: ChatSession[] }) => void) | undefined;
|
|
mockFetchChatSessions.mockImplementation(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
deferredResolve = resolve;
|
|
}),
|
|
);
|
|
|
|
const { result, rerender } = renderHook(({ projectId }: { projectId: string }) => useChat(projectId), {
|
|
initialProps: { projectId: "p1" },
|
|
});
|
|
|
|
expect(result.current.sessions.map((session) => session.id)).toEqual(["session-p1"]);
|
|
expect(result.current.sessionsLoading).toBe(false);
|
|
|
|
rerender({ projectId: "p2" });
|
|
|
|
expect(result.current.sessions.map((session) => session.id)).toEqual(["session-p2"]);
|
|
expect(result.current.sessionsLoading).toBe(false);
|
|
|
|
act(() => {
|
|
deferredResolve?.({ sessions: [] });
|
|
});
|
|
});
|
|
|
|
it("revalidates sessions in the background when projectId changes", async () => {
|
|
mockFetchChatSessions
|
|
.mockResolvedValueOnce({ sessions: [makeSession({ id: "session-p1", agentId: "agent-001" })] })
|
|
.mockResolvedValueOnce({ sessions: [makeSession({ id: "session-p2", agentId: "agent-002" })] });
|
|
|
|
const { rerender } = renderHook(({ projectId }: { projectId: string }) => useChat(projectId), {
|
|
initialProps: { projectId: "p1" },
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchChatSessions).toHaveBeenCalledWith("p1");
|
|
});
|
|
|
|
rerender({ projectId: "p2" });
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchChatSessions).toHaveBeenCalledWith("p2");
|
|
});
|
|
expect(mockFetchChatSessions).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
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();
|
|
});
|
|
|
|
describe("editMessageAndResend", () => {
|
|
const mockAddToast = vi.fn();
|
|
|
|
// fetchChatMessages returns newest-first (order=desc); useChat reverses it to display
|
|
// oldest-first. `messages` here is given in display (oldest-first) order for readability,
|
|
// so we reverse it before handing it to the mock to match the real API contract.
|
|
async function setupWithMessages(messages: ChatMessage[]) {
|
|
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
|
mockFetchChatMessages.mockResolvedValueOnce({ messages: messages.slice().reverse() });
|
|
|
|
const { result } = renderHook(() => useChat("proj-123", mockAddToast));
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions).toHaveLength(1);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.selectSession("session-001");
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.messages).toHaveLength(messages.length);
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
it("optimistically truncates from the edited message and resends via sendMessage", async () => {
|
|
const m1 = makeMessage({ id: "msg-1", sessionId: "session-001", role: "user", content: "one" });
|
|
const m2 = makeMessage({ id: "msg-2", sessionId: "session-001", role: "assistant", content: "two" });
|
|
const m3 = makeMessage({ id: "msg-3", sessionId: "session-001", role: "user", content: "three" });
|
|
const result = await setupWithMessages([m1, m2, m3]);
|
|
|
|
mockEditChatMessage.mockResolvedValueOnce({ retained: [m1, m2] });
|
|
const closeFn = vi.fn();
|
|
mockStreamChatResponse.mockImplementation(() => ({ close: closeFn, isConnected: () => true }));
|
|
|
|
await act(async () => {
|
|
await result.current.editMessageAndResend("msg-3", "three (edited)");
|
|
});
|
|
|
|
expect(mockEditChatMessage).toHaveBeenCalledWith("session-001", "msg-3", "three (edited)", "proj-123");
|
|
await waitFor(() => {
|
|
// Optimistic truncation drops msg-3, then sendMessage appends a fresh optimistic user bubble
|
|
// with the edited content — so retained [m1, m2] plus the new turn is 3 messages.
|
|
expect(result.current.messages).toHaveLength(3);
|
|
expect(result.current.messages[0]?.id).toBe("msg-1");
|
|
expect(result.current.messages[1]?.id).toBe("msg-2");
|
|
expect(result.current.messages[2]?.role).toBe("user");
|
|
expect(result.current.messages[2]?.content).toBe("three (edited)");
|
|
});
|
|
expect(mockStreamChatResponse).toHaveBeenCalledWith("session-001", "three (edited)", expect.anything(), undefined, "proj-123");
|
|
});
|
|
|
|
it("is a no-op while streaming", async () => {
|
|
const m1 = makeMessage({ id: "msg-1", sessionId: "session-001", role: "user", content: "one" });
|
|
const result = await setupWithMessages([m1]);
|
|
|
|
mockStreamChatResponse.mockImplementation(() => ({ close: vi.fn(), isConnected: () => true }));
|
|
act(() => {
|
|
result.current.sendMessage("in flight");
|
|
});
|
|
await waitFor(() => {
|
|
expect(result.current.isStreaming).toBe(true);
|
|
});
|
|
|
|
mockEditChatMessage.mockClear();
|
|
await act(async () => {
|
|
await result.current.editMessageAndResend("msg-1", "edited");
|
|
});
|
|
|
|
expect(mockEditChatMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("reloads messages and does not resend on PATCH failure", async () => {
|
|
const m1 = makeMessage({ id: "msg-1", sessionId: "session-001", role: "user", content: "one" });
|
|
const m2 = makeMessage({ id: "msg-2", sessionId: "session-001", role: "assistant", content: "two" });
|
|
const result = await setupWithMessages([m1, m2]);
|
|
|
|
mockEditChatMessage.mockRejectedValueOnce(new Error("boom"));
|
|
// fetchChatMessages returns newest-first; the reload path reverses it back to [m1, m2].
|
|
mockFetchChatMessages.mockResolvedValueOnce({ messages: [m2, m1] });
|
|
mockStreamChatResponse.mockClear();
|
|
|
|
await act(async () => {
|
|
await result.current.editMessageAndResend("msg-1", "edited");
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.messages.map((m) => m.id)).toEqual(["msg-1", "msg-2"]);
|
|
});
|
|
expect(mockStreamChatResponse).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
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("hydrates restored active-session messages from cache before network resolves", async () => {
|
|
const projectId = "proj-message-cache-hit";
|
|
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
|
localStorage.setItem(
|
|
chatSessionsCacheKey(projectId),
|
|
JSON.stringify({ savedAt: Date.now(), data: [session] }),
|
|
);
|
|
localStorage.setItem(
|
|
chatMessagesCacheKey(projectId, session.id),
|
|
JSON.stringify({
|
|
savedAt: Date.now(),
|
|
data: [makeMessage({ id: "msg-cached", sessionId: session.id, role: "assistant", content: "Cached reply" })],
|
|
}),
|
|
);
|
|
mockGetScopedItem.mockReturnValue(session.id);
|
|
|
|
let resolveFetch: ((value: { messages: ChatMessage[] }) => void) | undefined;
|
|
mockFetchChatMessages.mockImplementationOnce(() => new Promise((resolve) => {
|
|
resolveFetch = resolve;
|
|
}));
|
|
|
|
const { result } = renderHook(() => useChat(projectId));
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.activeSession?.id).toBe(session.id);
|
|
});
|
|
|
|
expect(result.current.messagesLoading).toBe(false);
|
|
expect(result.current.messages).toEqual([
|
|
expect.objectContaining({ id: "msg-cached", content: "Cached reply" }),
|
|
]);
|
|
|
|
await act(async () => {
|
|
resolveFetch?.({ messages: [makeMessage({ id: "msg-fresh", sessionId: session.id, role: "assistant", content: "Fresh reply" })] });
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.messages[0]?.id).toBe("msg-fresh");
|
|
});
|
|
});
|
|
|
|
it("writes loaded messages through to cache", async () => {
|
|
const projectId = "proj-message-write-through";
|
|
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
|
mockFetchChatMessages.mockResolvedValueOnce({
|
|
messages: [
|
|
makeMessage({ id: "msg-001", sessionId: session.id, role: "user", content: "Hello" }),
|
|
makeMessage({ id: "msg-002", sessionId: session.id, role: "assistant", content: "Hi" }),
|
|
],
|
|
});
|
|
|
|
const { result } = renderHook(() => useChat(projectId));
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions).toHaveLength(1);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.selectSession(session.id);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
const raw = localStorage.getItem(chatMessagesCacheKey(projectId, session.id));
|
|
expect(raw).toBeTruthy();
|
|
const parsed = JSON.parse(raw ?? "null") as { data: ChatMessage[] };
|
|
expect(parsed.data).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
it("shows loading on message cache miss until the fetch resolves", async () => {
|
|
const projectId = "proj-message-cache-miss";
|
|
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
|
|
|
let resolveFetch: ((value: { messages: ChatMessage[] }) => void) | undefined;
|
|
mockFetchChatMessages.mockImplementationOnce(() => new Promise((resolve) => {
|
|
resolveFetch = resolve;
|
|
}));
|
|
|
|
const { result } = renderHook(() => useChat(projectId));
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions).toHaveLength(1);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.selectSession(session.id);
|
|
});
|
|
|
|
expect(result.current.messagesLoading).toBe(true);
|
|
|
|
await act(async () => {
|
|
resolveFetch?.({ messages: [makeMessage({ id: "msg-001", sessionId: session.id, role: "assistant", content: "Loaded" })] });
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.messagesLoading).toBe(false);
|
|
expect(result.current.messages[0]).toEqual(expect.objectContaining({ id: "msg-001", content: "Loaded" }));
|
|
});
|
|
});
|
|
|
|
it("does not overwrite the session cache when paginating older messages", async () => {
|
|
const projectId = "proj-pagination-cache";
|
|
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
|
const newestPage = Array.from({ length: 50 }, (_, index) =>
|
|
makeMessage({ id: `msg-${index + 1}`, sessionId: session.id, role: "assistant", content: `Message ${index + 1}` }),
|
|
);
|
|
const olderPage = [makeMessage({ id: "msg-old", sessionId: session.id, role: "assistant", content: "Older message" })];
|
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
|
mockFetchChatMessages
|
|
.mockResolvedValueOnce({ messages: newestPage })
|
|
.mockResolvedValueOnce({ messages: olderPage });
|
|
|
|
const { result } = renderHook(() => useChat(projectId));
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions).toHaveLength(1);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.selectSession(session.id);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.messages).toHaveLength(50);
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.loadMoreMessages();
|
|
});
|
|
|
|
const parsed = JSON.parse(localStorage.getItem(chatMessagesCacheKey(projectId, session.id)) ?? "null") as { data: ChatMessage[] };
|
|
expect(parsed.data).toHaveLength(50);
|
|
expect(parsed.data[0]?.id).toBe("msg-50");
|
|
});
|
|
|
|
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, order: "desc" }, 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
|
|
// API returns messages newest-first (order=desc); simulate that in the mock
|
|
mockFetchChatMessages.mockResolvedValueOnce({
|
|
messages: [
|
|
makeMessage({ id: "msg-004", sessionId: "session-001", role: "assistant", content: "Second answer" }),
|
|
makeMessage({ id: "msg-003", sessionId: "session-001", role: "user", content: "Second question" }),
|
|
makeMessage({ id: "msg-002", sessionId: "session-001", role: "assistant", content: "First answer" }),
|
|
makeMessage({ id: "msg-001", sessionId: "session-001", role: "user", content: "First question" }),
|
|
],
|
|
});
|
|
|
|
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("rehydrates persisted failure metadata when loading message history", async () => {
|
|
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
|
mockFetchChatMessages.mockResolvedValueOnce({
|
|
messages: [
|
|
makeMessage({
|
|
id: "msg-failure",
|
|
sessionId: "session-001",
|
|
role: "assistant",
|
|
content: "Model request failed",
|
|
metadata: {
|
|
failureInfo: {
|
|
summary: "Model request failed",
|
|
errorClass: "ProviderError",
|
|
code: "E_MODEL",
|
|
detail: "ProviderError: Model request failed",
|
|
},
|
|
},
|
|
}),
|
|
],
|
|
});
|
|
|
|
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.messages[0]).toEqual(expect.objectContaining({
|
|
id: "msg-failure",
|
|
failureInfo: {
|
|
summary: "Model request failed",
|
|
errorClass: "ProviderError",
|
|
code: "E_MODEL",
|
|
detail: "ProviderError: Model request failed",
|
|
},
|
|
}));
|
|
});
|
|
|
|
it("creates a new session with thinking level and selects it", async () => {
|
|
const newSession = makeSession({ id: "session-new", agentId: "agent-001", title: "Test Chat", thinkingLevel: "medium" });
|
|
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",
|
|
modelProvider: "anthropic",
|
|
modelId: "claude-sonnet-4-5",
|
|
thinkingLevel: "medium",
|
|
});
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockCreateChatSession).toHaveBeenCalledWith(
|
|
{
|
|
agentId: "agent-001",
|
|
title: "Test Chat",
|
|
modelProvider: "anthropic",
|
|
modelId: "claude-sonnet-4-5",
|
|
thinkingLevel: "medium",
|
|
},
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.activeSession?.id).toBe("session-new");
|
|
expect(result.current.activeSession?.thinkingLevel).toBe("medium");
|
|
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("renames a session optimistically, trims the API title, and updates the active header state", async () => {
|
|
const session = makeSession({ id: "session-001", agentId: "agent-001", title: "Old title" });
|
|
const renamedSession = makeSession({
|
|
id: "session-001",
|
|
agentId: "agent-001",
|
|
title: "New title",
|
|
updatedAt: "2026-04-09T00:00:00.000Z",
|
|
});
|
|
const deferred = createDeferredPromise<{ session: ChatSession }>();
|
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
|
mockUpdateChatSession.mockReturnValueOnce(deferred.promise);
|
|
|
|
const { result } = renderHook(() => useChat("proj-123"));
|
|
|
|
await waitFor(() => expect(result.current.sessions).toHaveLength(1));
|
|
|
|
act(() => {
|
|
result.current.selectSession("session-001", session);
|
|
});
|
|
|
|
await waitFor(() => expect(result.current.activeSession?.id).toBe("session-001"));
|
|
|
|
await act(async () => {
|
|
void result.current.renameSession("session-001", " New title ");
|
|
});
|
|
|
|
expect(mockUpdateChatSession).toHaveBeenCalledWith("session-001", { title: "New title" }, "proj-123");
|
|
expect(result.current.sessions[0]?.title).toBe("New title");
|
|
expect(result.current.activeSession?.title).toBe("New title");
|
|
|
|
await act(async () => {
|
|
deferred.resolve({ session: renamedSession });
|
|
await deferred.promise;
|
|
});
|
|
|
|
expect(result.current.sessions[0]?.updatedAt).toBe("2026-04-09T00:00:00.000Z");
|
|
expect(result.current.activeSession?.updatedAt).toBe("2026-04-09T00:00:00.000Z");
|
|
});
|
|
|
|
it("renames an untitled session to a named title optimistically", async () => {
|
|
const session = makeSession({ id: "session-001", agentId: "agent-001", title: null });
|
|
const deferred = createDeferredPromise<{ session: ChatSession }>();
|
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
|
mockUpdateChatSession.mockReturnValueOnce(deferred.promise);
|
|
|
|
const { result } = renderHook(() => useChat("proj-123"));
|
|
|
|
await waitFor(() => expect(result.current.sessions).toHaveLength(1));
|
|
|
|
act(() => {
|
|
result.current.selectSession("session-001", session);
|
|
});
|
|
|
|
await waitFor(() => expect(result.current.activeSession?.title).toBeNull());
|
|
|
|
await act(async () => {
|
|
void result.current.renameSession("session-001", "Named title");
|
|
});
|
|
|
|
expect(mockUpdateChatSession).toHaveBeenCalledWith("session-001", { title: "Named title" }, "proj-123");
|
|
expect(result.current.sessions[0]?.title).toBe("Named title");
|
|
expect(result.current.activeSession?.title).toBe("Named title");
|
|
|
|
await act(async () => {
|
|
deferred.resolve({ session: makeSession({ ...session, title: "Named title" }) });
|
|
await deferred.promise;
|
|
});
|
|
});
|
|
|
|
it("renames a session to Untitled for whitespace and rolls back with a toast on failure", async () => {
|
|
const addToast = vi.fn();
|
|
const session = makeSession({ id: "session-001", agentId: "agent-001", title: "Keep me" });
|
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
|
mockUpdateChatSession.mockRejectedValueOnce(new Error("rename failed"));
|
|
|
|
const { result } = renderHook(() => useChat("proj-123", addToast));
|
|
|
|
await waitFor(() => expect(result.current.sessions).toHaveLength(1));
|
|
|
|
act(() => {
|
|
result.current.selectSession("session-001", session);
|
|
});
|
|
|
|
await waitFor(() => expect(result.current.activeSession?.title).toBe("Keep me"));
|
|
|
|
await act(async () => {
|
|
await expect(result.current.renameSession("session-001", " ")).rejects.toThrow("rename failed");
|
|
});
|
|
|
|
expect(mockUpdateChatSession).toHaveBeenCalledWith("session-001", { title: null }, "proj-123");
|
|
expect(result.current.sessions[0]?.title).toBe("Keep me");
|
|
expect(result.current.activeSession?.title).toBe("Keep me");
|
|
expect(addToast).toHaveBeenCalledWith("Failed to rename conversation", "error");
|
|
});
|
|
|
|
describe("setSessionModel", () => {
|
|
it("switches an active session to a model optimistically and reconciles with the server", async () => {
|
|
const session = makeSession({ id: "session-001", agentId: "agent-001", modelProvider: null, modelId: null });
|
|
const updatedSession = makeSession({
|
|
id: "session-001",
|
|
agentId: FN_AGENT_ID,
|
|
modelProvider: "openai",
|
|
modelId: "gpt-4o",
|
|
updatedAt: "2026-04-09T00:00:00.000Z",
|
|
});
|
|
const deferred = createDeferredPromise<{ session: ChatSession }>();
|
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
|
mockUpdateChatSession.mockReturnValueOnce(deferred.promise);
|
|
|
|
const { result } = renderHook(() => useChat("proj-123"));
|
|
|
|
await waitFor(() => expect(result.current.sessions).toHaveLength(1));
|
|
|
|
act(() => {
|
|
result.current.selectSession("session-001", session);
|
|
});
|
|
|
|
await waitFor(() => expect(result.current.activeSession?.id).toBe("session-001"));
|
|
|
|
await act(async () => {
|
|
void result.current.setSessionModel("session-001", { modelProvider: "openai", modelId: "gpt-4o" });
|
|
});
|
|
|
|
expect(mockUpdateChatSession).toHaveBeenCalledWith(
|
|
"session-001",
|
|
{ agentId: FN_AGENT_ID, modelProvider: "openai", modelId: "gpt-4o" },
|
|
"proj-123",
|
|
);
|
|
expect(result.current.sessions[0]).toMatchObject({ agentId: FN_AGENT_ID, modelProvider: "openai", modelId: "gpt-4o" });
|
|
expect(result.current.activeSession).toMatchObject({ agentId: FN_AGENT_ID, modelProvider: "openai", modelId: "gpt-4o" });
|
|
|
|
await act(async () => {
|
|
deferred.resolve({ session: updatedSession });
|
|
await deferred.promise;
|
|
});
|
|
|
|
expect(result.current.sessions[0]).toMatchObject({
|
|
agentId: FN_AGENT_ID,
|
|
modelProvider: "openai",
|
|
modelId: "gpt-4o",
|
|
updatedAt: "2026-04-09T00:00:00.000Z",
|
|
});
|
|
expect(result.current.activeSession).toMatchObject({
|
|
agentId: FN_AGENT_ID,
|
|
modelProvider: "openai",
|
|
modelId: "gpt-4o",
|
|
updatedAt: "2026-04-09T00:00:00.000Z",
|
|
});
|
|
});
|
|
|
|
it("switches an active session to an agent optimistically and clears the model pair", async () => {
|
|
const session = makeSession({ id: "session-001", agentId: FN_AGENT_ID, modelProvider: "anthropic", modelId: "claude-sonnet-4-5" });
|
|
const updatedSession = makeSession({ id: "session-001", agentId: "agent-specialist", modelProvider: null, modelId: null });
|
|
const deferred = createDeferredPromise<{ session: ChatSession }>();
|
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
|
mockUpdateChatSession.mockReturnValueOnce(deferred.promise);
|
|
|
|
const { result } = renderHook(() => useChat("proj-123"));
|
|
|
|
await waitFor(() => expect(result.current.sessions).toHaveLength(1));
|
|
|
|
act(() => {
|
|
result.current.selectSession("session-001", session);
|
|
});
|
|
|
|
await waitFor(() => expect(result.current.activeSession?.id).toBe("session-001"));
|
|
|
|
await act(async () => {
|
|
void result.current.setSessionModel("session-001", { agentId: "agent-specialist" });
|
|
});
|
|
|
|
expect(mockUpdateChatSession).toHaveBeenCalledWith(
|
|
"session-001",
|
|
{ agentId: "agent-specialist", modelProvider: null, modelId: null },
|
|
"proj-123",
|
|
);
|
|
expect(result.current.sessions[0]).toMatchObject({ agentId: "agent-specialist", modelProvider: null, modelId: null });
|
|
expect(result.current.activeSession).toMatchObject({ agentId: "agent-specialist", modelProvider: null, modelId: null });
|
|
|
|
await act(async () => {
|
|
deferred.resolve({ session: updatedSession });
|
|
await deferred.promise;
|
|
});
|
|
});
|
|
|
|
it("rolls back sessions/activeSession and surfaces an error toast on failure", async () => {
|
|
const addToast = vi.fn();
|
|
const session = makeSession({ id: "session-001", agentId: FN_AGENT_ID, modelProvider: "anthropic", modelId: "claude-sonnet-4-5" });
|
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
|
mockUpdateChatSession.mockRejectedValueOnce(new Error("model failed"));
|
|
|
|
const { result } = renderHook(() => useChat("proj-123", addToast));
|
|
|
|
await waitFor(() => expect(result.current.sessions).toHaveLength(1));
|
|
|
|
act(() => {
|
|
result.current.selectSession("session-001", session);
|
|
});
|
|
|
|
await waitFor(() => expect(result.current.activeSession?.modelId).toBe("claude-sonnet-4-5"));
|
|
|
|
await act(async () => {
|
|
await expect(result.current.setSessionModel("session-001", { agentId: "agent-specialist" })).rejects.toThrow("model failed");
|
|
});
|
|
|
|
expect(mockUpdateChatSession).toHaveBeenCalledWith(
|
|
"session-001",
|
|
{ agentId: "agent-specialist", modelProvider: null, modelId: null },
|
|
"proj-123",
|
|
);
|
|
expect(result.current.sessions[0]).toMatchObject({ agentId: FN_AGENT_ID, modelProvider: "anthropic", modelId: "claude-sonnet-4-5" });
|
|
expect(result.current.activeSession).toMatchObject({ agentId: FN_AGENT_ID, modelProvider: "anthropic", modelId: "claude-sonnet-4-5" });
|
|
expect(addToast).toHaveBeenCalledWith("Failed to update chat model", "error");
|
|
});
|
|
|
|
it("updates only the matching sessions entry when the session is not active", async () => {
|
|
const activeSessionSeed = makeSession({ id: "session-active", agentId: "agent-001" });
|
|
const otherSession = makeSession({ id: "session-other", agentId: "agent-002", modelProvider: null, modelId: null });
|
|
const deferred = createDeferredPromise<{ session: ChatSession }>();
|
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [activeSessionSeed, otherSession] });
|
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
|
mockUpdateChatSession.mockReturnValueOnce(deferred.promise);
|
|
|
|
const { result } = renderHook(() => useChat("proj-123"));
|
|
|
|
await waitFor(() => expect(result.current.sessions).toHaveLength(2));
|
|
|
|
act(() => {
|
|
result.current.selectSession("session-active", activeSessionSeed);
|
|
});
|
|
|
|
await waitFor(() => expect(result.current.activeSession?.id).toBe("session-active"));
|
|
|
|
await act(async () => {
|
|
void result.current.setSessionModel("session-other", { modelProvider: "openai", modelId: "gpt-4o-mini" });
|
|
});
|
|
|
|
expect(mockUpdateChatSession).toHaveBeenCalledWith(
|
|
"session-other",
|
|
{ agentId: FN_AGENT_ID, modelProvider: "openai", modelId: "gpt-4o-mini" },
|
|
"proj-123",
|
|
);
|
|
expect(result.current.sessions.find((s) => s.id === "session-other")).toMatchObject({
|
|
agentId: FN_AGENT_ID,
|
|
modelProvider: "openai",
|
|
modelId: "gpt-4o-mini",
|
|
});
|
|
expect(result.current.activeSession).toMatchObject({ id: "session-active", agentId: "agent-001" });
|
|
|
|
await act(async () => {
|
|
deferred.resolve({ session: makeSession({ ...otherSession, agentId: FN_AGENT_ID, modelProvider: "openai", modelId: "gpt-4o-mini" }) });
|
|
await deferred.promise;
|
|
});
|
|
});
|
|
});
|
|
|
|
// FN-7898: setSessionThinkingLevel lets an existing session's reasoning-effort level be
|
|
// changed mid-conversation via the in-chat composer control; mirrors renameSession's
|
|
// optimistic-update-with-rollback contract.
|
|
describe("setSessionThinkingLevel", () => {
|
|
it("updates sessions and activeSession optimistically, then reconciles with the server response", async () => {
|
|
const session = makeSession({ id: "session-001", agentId: "agent-001", thinkingLevel: null });
|
|
const updatedSession = makeSession({
|
|
id: "session-001",
|
|
agentId: "agent-001",
|
|
thinkingLevel: "high",
|
|
updatedAt: "2026-04-09T00:00:00.000Z",
|
|
});
|
|
const deferred = createDeferredPromise<{ session: ChatSession }>();
|
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
|
mockUpdateChatSession.mockReturnValueOnce(deferred.promise);
|
|
|
|
const { result } = renderHook(() => useChat("proj-123"));
|
|
|
|
await waitFor(() => expect(result.current.sessions).toHaveLength(1));
|
|
|
|
act(() => {
|
|
result.current.selectSession("session-001", session);
|
|
});
|
|
|
|
await waitFor(() => expect(result.current.activeSession?.id).toBe("session-001"));
|
|
|
|
await act(async () => {
|
|
void result.current.setSessionThinkingLevel("session-001", "high");
|
|
});
|
|
|
|
expect(mockUpdateChatSession).toHaveBeenCalledWith("session-001", { thinkingLevel: "high" }, "proj-123");
|
|
expect(result.current.sessions[0]?.thinkingLevel).toBe("high");
|
|
expect(result.current.activeSession?.thinkingLevel).toBe("high");
|
|
|
|
await act(async () => {
|
|
deferred.resolve({ session: updatedSession });
|
|
await deferred.promise;
|
|
});
|
|
|
|
expect(result.current.sessions[0]?.thinkingLevel).toBe("high");
|
|
expect(result.current.sessions[0]?.updatedAt).toBe("2026-04-09T00:00:00.000Z");
|
|
expect(result.current.activeSession?.thinkingLevel).toBe("high");
|
|
expect(result.current.activeSession?.updatedAt).toBe("2026-04-09T00:00:00.000Z");
|
|
});
|
|
|
|
it("rolls back sessions/activeSession and surfaces an error toast on failure", async () => {
|
|
const addToast = vi.fn();
|
|
const session = makeSession({ id: "session-001", agentId: "agent-001", thinkingLevel: "low" });
|
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
|
mockUpdateChatSession.mockRejectedValueOnce(new Error("update failed"));
|
|
|
|
const { result } = renderHook(() => useChat("proj-123", addToast));
|
|
|
|
await waitFor(() => expect(result.current.sessions).toHaveLength(1));
|
|
|
|
act(() => {
|
|
result.current.selectSession("session-001", session);
|
|
});
|
|
|
|
await waitFor(() => expect(result.current.activeSession?.thinkingLevel).toBe("low"));
|
|
|
|
await act(async () => {
|
|
await expect(result.current.setSessionThinkingLevel("session-001", "xhigh")).rejects.toThrow("update failed");
|
|
});
|
|
|
|
expect(mockUpdateChatSession).toHaveBeenCalledWith("session-001", { thinkingLevel: "xhigh" }, "proj-123");
|
|
expect(result.current.sessions[0]?.thinkingLevel).toBe("low");
|
|
expect(result.current.activeSession?.thinkingLevel).toBe("low");
|
|
expect(addToast).toHaveBeenCalledWith("Failed to update thinking level", "error");
|
|
});
|
|
|
|
it("updates only the matching sessions entry when the session is not active", async () => {
|
|
const activeSessionSeed = makeSession({ id: "session-active", agentId: "agent-001", thinkingLevel: "low" });
|
|
const otherSession = makeSession({ id: "session-other", agentId: "agent-002", thinkingLevel: null });
|
|
const deferred = createDeferredPromise<{ session: ChatSession }>();
|
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [activeSessionSeed, otherSession] });
|
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
|
mockUpdateChatSession.mockReturnValueOnce(deferred.promise);
|
|
|
|
const { result } = renderHook(() => useChat("proj-123"));
|
|
|
|
await waitFor(() => expect(result.current.sessions).toHaveLength(2));
|
|
|
|
act(() => {
|
|
result.current.selectSession("session-active", activeSessionSeed);
|
|
});
|
|
|
|
await waitFor(() => expect(result.current.activeSession?.id).toBe("session-active"));
|
|
|
|
await act(async () => {
|
|
void result.current.setSessionThinkingLevel("session-other", "medium");
|
|
});
|
|
|
|
expect(mockUpdateChatSession).toHaveBeenCalledWith("session-other", { thinkingLevel: "medium" }, "proj-123");
|
|
const otherEntry = result.current.sessions.find((s) => s.id === "session-other");
|
|
expect(otherEntry?.thinkingLevel).toBe("medium");
|
|
expect(result.current.activeSession?.id).toBe("session-active");
|
|
expect(result.current.activeSession?.thinkingLevel).toBe("low");
|
|
|
|
await act(async () => {
|
|
deferred.resolve({ session: makeSession({ ...otherSession, thinkingLevel: "medium" }) });
|
|
await deferred.promise;
|
|
});
|
|
});
|
|
});
|
|
|
|
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("sets isStreaming true during first send and clears on delayed done", async () => {
|
|
const session = makeSession({
|
|
id: "session-001",
|
|
agentId: "agent-001",
|
|
title: "Test Session",
|
|
});
|
|
|
|
mockFetchChatSessions.mockResolvedValue({ sessions: [session] });
|
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
|
|
|
const { result } = renderHook(() => useChat(undefined, "project-123"));
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions).toHaveLength(1);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.selectSession("session-001");
|
|
});
|
|
|
|
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
|
|
setTimeout(() => {
|
|
handlers.onDone?.({ messageId: "msg-001" });
|
|
}, 200);
|
|
return { close: vi.fn(), isConnected: () => true };
|
|
});
|
|
|
|
act(() => {
|
|
void result.current.sendMessage("Hello");
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isStreaming).toBe(true);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isStreaming).toBe(false);
|
|
});
|
|
});
|
|
|
|
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.each([
|
|
{
|
|
name: "single sentence boundary",
|
|
chunks: ["Hello.", " World."],
|
|
expected: "Hello. World.",
|
|
},
|
|
{
|
|
name: "multiple sentence boundaries across three chunks",
|
|
chunks: ["One.", " Two.", " Three.", " Four."],
|
|
expected: "One. Two. Three. Four.",
|
|
},
|
|
{
|
|
name: "trailing whitespace-only delta before done",
|
|
chunks: ["Trailing", " "],
|
|
expected: "Trailing ",
|
|
},
|
|
])("prefers streamed text over done snapshot (%s)", async ({ chunks, expected }) => {
|
|
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");
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.activeSession?.id).toBe("session-001");
|
|
});
|
|
|
|
act(() => {
|
|
result.current.sendMessage("Hello!");
|
|
for (const chunk of chunks) {
|
|
textHandler?.(chunk);
|
|
}
|
|
doneHandler?.({
|
|
messageId: "msg-003",
|
|
message: {
|
|
id: "msg-003",
|
|
sessionId: "session-001",
|
|
role: "assistant",
|
|
content: expected.replace(/\s+/g, ""),
|
|
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: expected,
|
|
}));
|
|
});
|
|
});
|
|
|
|
it("handles stream errors, appends a failure bubble, 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 | apiModule.ChatFailureInfo) => 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!");
|
|
});
|
|
|
|
await act(async () => {
|
|
errorHandler?.("Stream connection failed");
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isStreaming).toBe(false);
|
|
expect(result.current.messages).toHaveLength(1);
|
|
expect(result.current.messages[0]).toEqual(expect.objectContaining({
|
|
role: "assistant",
|
|
content: "Stream connection failed",
|
|
failureInfo: { summary: "Stream connection failed" },
|
|
}));
|
|
expect(addToast).toHaveBeenCalledWith("Stream connection failed", "error");
|
|
});
|
|
});
|
|
|
|
it("suppresses Load failed toast when tab is hidden and reconciles messages", async () => {
|
|
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
|
const addToast = vi.fn();
|
|
|
|
let errorHandler: ((data: string | apiModule.ChatFailureInfo) => void) | undefined;
|
|
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
|
|
errorHandler = handlers.onError;
|
|
return { close: vi.fn(), isConnected: () => true };
|
|
});
|
|
|
|
setDocumentVisibilityState("hidden");
|
|
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(() => {
|
|
result.current.sendMessage("Hello!");
|
|
errorHandler?.("Load failed");
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isStreaming).toBe(false);
|
|
expect(addToast).not.toHaveBeenCalledWith("Load failed", "error");
|
|
expect(mockFetchChatMessages).toHaveBeenCalledWith("session-001", { limit: 50, order: "desc" }, undefined);
|
|
});
|
|
});
|
|
|
|
it("re-attaches suspended stream when session is still generating", async () => {
|
|
const session = {
|
|
...makeSession({ id: "session-001", agentId: "agent-001" }),
|
|
isGenerating: true,
|
|
inFlightGeneration: {
|
|
status: "generating" as const,
|
|
streamingText: "partial",
|
|
streamingThinking: "thinking",
|
|
toolCalls: [],
|
|
replayFromEventId: 42,
|
|
updatedAt: "2026-04-08T00:00:00.000Z",
|
|
},
|
|
};
|
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
|
mockFetchChatSession.mockResolvedValueOnce({ session });
|
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
|
const addToast = vi.fn();
|
|
|
|
let errorHandler: ((data: string | apiModule.ChatFailureInfo) => void) | undefined;
|
|
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
|
|
errorHandler = handlers.onError;
|
|
return { close: vi.fn(), isConnected: () => true };
|
|
});
|
|
|
|
setDocumentVisibilityState("hidden");
|
|
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");
|
|
});
|
|
|
|
const messageLoadCountBeforeError = mockFetchChatMessages.mock.calls.length;
|
|
|
|
act(() => {
|
|
result.current.sendMessage("Hello!");
|
|
errorHandler?.("Load failed");
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(addToast).not.toHaveBeenCalledWith("Load failed", "error");
|
|
expect(mockAttachChatStream).toHaveBeenCalledWith(
|
|
"session-001",
|
|
expect.any(Object),
|
|
undefined,
|
|
{ lastEventId: 42 },
|
|
);
|
|
expect(mockFetchChatMessages.mock.calls.length).toBe(messageLoadCountBeforeError);
|
|
});
|
|
});
|
|
|
|
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;
|
|
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
|
|
errorHandler = handlers.onError;
|
|
return { close: vi.fn(), isConnected: () => true };
|
|
});
|
|
|
|
setDocumentVisibilityState("visible");
|
|
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(() => {
|
|
result.current.sendMessage("Hello!");
|
|
errorHandler?.("Load failed");
|
|
});
|
|
|
|
await waitFor(() => {
|
|
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);
|
|
});
|
|
});
|
|
|
|
it("suppresses Failed to fetch shortly after hidden to visible transition", async () => {
|
|
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
|
const addToast = vi.fn();
|
|
|
|
let errorHandler: ((data: string | apiModule.ChatFailureInfo) => void) | undefined;
|
|
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
|
|
errorHandler = handlers.onError;
|
|
return { close: vi.fn(), isConnected: () => true };
|
|
});
|
|
|
|
setDocumentVisibilityState("hidden");
|
|
const { result } = renderHook(() => useChat(undefined, addToast));
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions).toHaveLength(1);
|
|
});
|
|
|
|
act(() => {
|
|
setDocumentVisibilityState("visible");
|
|
result.current.selectSession("session-001");
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.activeSession?.id).toBe("session-001");
|
|
});
|
|
|
|
act(() => {
|
|
result.current.sendMessage("Hello!");
|
|
errorHandler?.("Failed to fetch");
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(addToast).not.toHaveBeenCalledWith("Failed to fetch", "error");
|
|
});
|
|
});
|
|
|
|
it("FN-6496 loads prior thread when visibility resume reattaches", async () => {
|
|
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
|
const priorThreadNewestFirst = [
|
|
makeMessage({ id: "msg-004", sessionId: session.id, role: "assistant", content: "Second answer" }),
|
|
makeMessage({ id: "msg-003", sessionId: session.id, role: "user", content: "Second question" }),
|
|
makeMessage({ id: "msg-002", sessionId: session.id, role: "assistant", content: "First answer" }),
|
|
makeMessage({ id: "msg-001", sessionId: session.id, role: "user", content: "First question" }),
|
|
];
|
|
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 });
|
|
mockFetchChatMessages
|
|
.mockResolvedValueOnce({ messages: [] })
|
|
.mockResolvedValueOnce({ messages: priorThreadNewestFirst });
|
|
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(result.current.isStreaming).toBe(true);
|
|
expect(result.current.messages.map((message) => message.id)).toEqual([
|
|
"msg-001",
|
|
"msg-002",
|
|
"msg-003",
|
|
"msg-004",
|
|
]);
|
|
expect(addToast).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it("FN-5104 reattaches once when selectSession refresh reveals in-flight generation from stale cache", async () => {
|
|
const staleSession = {
|
|
...makeSession({ id: "session-001", agentId: "agent-001" }),
|
|
isGenerating: false,
|
|
inFlightGeneration: null,
|
|
};
|
|
const generatingSession = {
|
|
...staleSession,
|
|
isGenerating: true,
|
|
inFlightGeneration: {
|
|
status: "generating" as const,
|
|
streamingText: "partial text",
|
|
streamingThinking: "thinking",
|
|
toolCalls: [{ id: "tool-1", type: "function", function: { name: "search", arguments: "{}" } }],
|
|
replayFromEventId: 19,
|
|
updatedAt: "2026-04-08T00:00:00.000Z",
|
|
},
|
|
};
|
|
const priorThreadNewestFirst = [
|
|
makeMessage({ id: "msg-004", sessionId: staleSession.id, role: "assistant", content: "Second answer" }),
|
|
makeMessage({ id: "msg-003", sessionId: staleSession.id, role: "user", content: "Second question" }),
|
|
makeMessage({ id: "msg-002", sessionId: staleSession.id, role: "assistant", content: "First answer" }),
|
|
makeMessage({ id: "msg-001", sessionId: staleSession.id, role: "user", content: "First question" }),
|
|
];
|
|
|
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [staleSession] });
|
|
mockFetchChatSession.mockResolvedValueOnce({ session: generatingSession });
|
|
mockFetchChatMessages
|
|
.mockResolvedValueOnce({ messages: [] })
|
|
.mockResolvedValueOnce({ messages: priorThreadNewestFirst });
|
|
|
|
const { result } = renderHook(() => useChat());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions).toHaveLength(1);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.selectSession("session-001");
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockAttachChatStream).toHaveBeenCalledTimes(1);
|
|
expect(mockAttachChatStream).toHaveBeenCalledWith(
|
|
"session-001",
|
|
expect.any(Object),
|
|
undefined,
|
|
{ lastEventId: 19 },
|
|
);
|
|
expect(result.current.streamingText).toBe("partial text");
|
|
expect(result.current.streamingThinking).toBe("thinking");
|
|
expect(result.current.streamingToolCalls).toHaveLength(1);
|
|
expect(result.current.messages.map((message) => message.id)).toEqual([
|
|
"msg-001",
|
|
"msg-002",
|
|
"msg-003",
|
|
"msg-004",
|
|
]);
|
|
});
|
|
});
|
|
|
|
it("FN-5104 does not reattach after stopStreaming cancels active generation", async () => {
|
|
const session = {
|
|
...makeSession({ id: "session-001", agentId: "agent-001" }),
|
|
isGenerating: true,
|
|
inFlightGeneration: {
|
|
status: "generating" as const,
|
|
streamingText: "partial",
|
|
streamingThinking: "thinking",
|
|
toolCalls: [],
|
|
replayFromEventId: 8,
|
|
updatedAt: "2026-04-08T00:00:00.000Z",
|
|
},
|
|
};
|
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
|
mockFetchChatSession.mockResolvedValue({ session: { ...session, isGenerating: false, inFlightGeneration: null } });
|
|
|
|
const { result } = renderHook(() => useChat());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions).toHaveLength(1);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.selectSession("session-001");
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockAttachChatStream).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.stopStreaming();
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockAttachChatStream).toHaveBeenCalledTimes(1);
|
|
expect(result.current.isStreaming).toBe(false);
|
|
});
|
|
});
|
|
|
|
it("FN-7656 reattaches and shows working state on refresh reporting isGenerating with no inFlightGeneration snapshot yet (pre-first-delta)", async () => {
|
|
// Regression: early in a generation the server reports isGenerating:true
|
|
// with inFlightGeneration still null (no delta emitted yet). The stale
|
|
// local `sessions` cache also reports isGenerating:false. selectSession's
|
|
// authoritative fetchChatSession refresh must reattach on isGenerating
|
|
// alone, without waiting for an inFlightGeneration snapshot.
|
|
const staleSession = {
|
|
...makeSession({ id: "session-001", agentId: "agent-001" }),
|
|
isGenerating: false,
|
|
inFlightGeneration: null,
|
|
};
|
|
const generatingSessionNoSnapshot = {
|
|
...staleSession,
|
|
isGenerating: true,
|
|
inFlightGeneration: null,
|
|
};
|
|
|
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [staleSession] });
|
|
mockFetchChatSession.mockResolvedValueOnce({ session: generatingSessionNoSnapshot });
|
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
|
|
|
const { result } = renderHook(() => useChat());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions).toHaveLength(1);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.selectSession("session-001");
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockAttachChatStream).toHaveBeenCalledTimes(1);
|
|
expect(mockAttachChatStream).toHaveBeenCalledWith("session-001", expect.any(Object), undefined, {});
|
|
expect(result.current.isStreaming).toBe(true);
|
|
});
|
|
});
|
|
|
|
it("FN-7656 does not reattach when the authoritative refresh reports isGenerating:false", async () => {
|
|
const session = {
|
|
...makeSession({ id: "session-001", agentId: "agent-001" }),
|
|
isGenerating: false,
|
|
inFlightGeneration: null,
|
|
};
|
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
|
mockFetchChatSession.mockResolvedValueOnce({ session: { ...session, isGenerating: false, inFlightGeneration: null } });
|
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
|
|
|
const { result } = renderHook(() => useChat());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions).toHaveLength(1);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.selectSession("session-001");
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchChatSession).toHaveBeenCalledWith("session-001", undefined);
|
|
});
|
|
|
|
expect(mockAttachChatStream).not.toHaveBeenCalled();
|
|
expect(result.current.isStreaming).toBe(false);
|
|
});
|
|
|
|
it("FN-7656 does not reattach to a session the user has already navigated away from before the refresh resolves", async () => {
|
|
const sessionA = {
|
|
...makeSession({ id: "session-001", agentId: "agent-001" }),
|
|
isGenerating: false,
|
|
inFlightGeneration: null,
|
|
};
|
|
const sessionB = {
|
|
...makeSession({ id: "session-002", agentId: "agent-001" }),
|
|
isGenerating: false,
|
|
inFlightGeneration: null,
|
|
};
|
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [sessionA, sessionB] });
|
|
const deferredRefresh = createDeferredPromise<{ session: ChatSession }>();
|
|
mockFetchChatSession.mockReturnValueOnce(deferredRefresh.promise);
|
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
|
|
|
const { result } = renderHook(() => useChat());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions).toHaveLength(2);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.selectSession("session-001");
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchChatSession).toHaveBeenCalledWith("session-001", undefined);
|
|
});
|
|
|
|
// User navigates away to session-002 before the session-001 refresh resolves.
|
|
act(() => {
|
|
result.current.selectSession("session-002");
|
|
});
|
|
|
|
await act(async () => {
|
|
deferredRefresh.resolve({
|
|
session: { ...sessionA, isGenerating: true, inFlightGeneration: null },
|
|
});
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(mockAttachChatStream).not.toHaveBeenCalled();
|
|
expect(result.current.isStreaming).toBe(false);
|
|
expect(result.current.activeSession?.id).toBe("session-002");
|
|
});
|
|
|
|
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] });
|
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
|
const addToast = vi.fn();
|
|
|
|
let errorHandler: ((data: string | apiModule.ChatFailureInfo) => void) | undefined;
|
|
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
|
|
errorHandler = handlers.onError;
|
|
return { close: vi.fn(), isConnected: () => true };
|
|
});
|
|
|
|
setDocumentVisibilityState("hidden");
|
|
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(() => {
|
|
result.current.sendMessage("Hello!");
|
|
errorHandler?.("Request failed: 500");
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(addToast).toHaveBeenCalledWith("Request failed: 500", "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("");
|
|
expect(mockStreamChatResponse).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
it("stopStreaming with no pendingMessages cancels stream without sending anything", 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!");
|
|
result.current.stopStreaming();
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(closeFn).toHaveBeenCalledTimes(1);
|
|
expect(result.current.pendingMessages).toEqual([]);
|
|
expect(mockStreamChatResponse).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
it("sending during streaming queues pendingMessages without warning toast", async () => {
|
|
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
|
mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
|
|
const addToast = vi.fn();
|
|
|
|
mockStreamChatResponse.mockReturnValue({ close: vi.fn(), isConnected: () => true });
|
|
|
|
const { result } = renderHook(() => useChat("proj-123", 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(() => {
|
|
result.current.sendMessage("First");
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isStreaming).toBe(true);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.sendMessage("Queued message");
|
|
});
|
|
|
|
expect(result.current.pendingMessages).toEqual(["Queued message"]);
|
|
expect(mockStreamChatResponse).toHaveBeenCalledTimes(1);
|
|
expect(addToast).not.toHaveBeenCalledWith("Still waiting for previous response — message queued", "warning");
|
|
});
|
|
|
|
it("persists queued message text to localStorage while streaming", 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");
|
|
});
|
|
|
|
expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBe(JSON.stringify(["Queued follow-up"]));
|
|
});
|
|
|
|
it("rehydrates queued message from localStorage after remount", async () => {
|
|
const session = {
|
|
...makeSession({ id: "session-001", agentId: "agent-001" }),
|
|
isGenerating: true,
|
|
inFlightGeneration: {
|
|
streamingText: "partial",
|
|
streamingThinking: "",
|
|
toolCalls: [],
|
|
},
|
|
};
|
|
mockFetchChatSessions.mockResolvedValue({ sessions: [session] });
|
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
|
mockAttachChatStream.mockReturnValue({ close: vi.fn(), isConnected: () => true });
|
|
mockStreamChatResponse.mockReturnValue({ close: vi.fn(), isConnected: () => true });
|
|
|
|
const firstHook = renderHook(() => useChat("proj-123"));
|
|
|
|
await waitFor(() => {
|
|
expect(firstHook.result.current.sessions).toHaveLength(1);
|
|
});
|
|
|
|
act(() => {
|
|
firstHook.result.current.selectSession("session-001");
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(firstHook.result.current.isStreaming).toBe(true);
|
|
});
|
|
|
|
act(() => {
|
|
firstHook.result.current.sendMessage("Queued follow-up");
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBe(JSON.stringify(["Queued follow-up"]));
|
|
});
|
|
|
|
firstHook.unmount();
|
|
|
|
const secondHook = renderHook(() => useChat("proj-123"));
|
|
|
|
await waitFor(() => {
|
|
expect(secondHook.result.current.sessions).toHaveLength(1);
|
|
});
|
|
|
|
act(() => {
|
|
secondHook.result.current.selectSession("session-001");
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(secondHook.result.current.pendingMessages).toEqual(["Queued follow-up"]);
|
|
});
|
|
});
|
|
|
|
it("rehydrates legacy single-string queued message from localStorage after remount", async () => {
|
|
const session = {
|
|
...makeSession({ id: "session-001", agentId: "agent-001" }),
|
|
isGenerating: true,
|
|
inFlightGeneration: {
|
|
streamingText: "partial",
|
|
streamingThinking: "",
|
|
toolCalls: [],
|
|
},
|
|
};
|
|
mockFetchChatSessions.mockResolvedValue({ sessions: [session] });
|
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
|
mockAttachChatStream.mockReturnValue({ close: vi.fn(), isConnected: () => true });
|
|
|
|
localStorage.setItem(getChatPendingMessageKey("session-001")!, "Legacy queued follow-up");
|
|
|
|
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.pendingMessages).toEqual(["Legacy queued follow-up"]);
|
|
});
|
|
});
|
|
|
|
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 { 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.pendingMessages).toEqual(["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.pendingMessages).toEqual([]);
|
|
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("");
|
|
});
|
|
});
|
|
|
|
it("stacks queued messages while streaming and flushes them in FIFO order", 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 A");
|
|
result.current.sendMessage("Queued B");
|
|
result.current.sendMessage("Queued C");
|
|
});
|
|
|
|
expect(result.current.pendingMessages).toEqual(["Queued A", "Queued B", "Queued C"]);
|
|
|
|
act(() => {
|
|
handlers[0]?.onDone?.({ messageId: "msg-001" });
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockStreamChatResponse).toHaveBeenCalledTimes(2);
|
|
expect(mockStreamChatResponse.mock.calls[1]?.[1]).toBe("Queued A");
|
|
expect(result.current.pendingMessages).toEqual(["Queued B", "Queued C"]);
|
|
});
|
|
|
|
act(() => {
|
|
handlers[1]?.onDone?.({ messageId: "msg-002" });
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockStreamChatResponse).toHaveBeenCalledTimes(3);
|
|
expect(mockStreamChatResponse.mock.calls[2]?.[1]).toBe("Queued B");
|
|
expect(result.current.pendingMessages).toEqual(["Queued C"]);
|
|
});
|
|
|
|
act(() => {
|
|
handlers[2]?.onDone?.({ messageId: "msg-003" });
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockStreamChatResponse).toHaveBeenCalledTimes(4);
|
|
expect(mockStreamChatResponse.mock.calls[3]?.[1]).toBe("Queued C");
|
|
expect(result.current.pendingMessages).toEqual([]);
|
|
});
|
|
});
|
|
|
|
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: [] });
|
|
|
|
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");
|
|
handlers[0]?.onError?.("network");
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockStreamChatResponse).toHaveBeenCalledTimes(2);
|
|
expect(mockStreamChatResponse.mock.calls[1]?.[1]).toBe("Queued follow-up");
|
|
});
|
|
});
|
|
});
|
|
|
|
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.pendingMessages).toEqual(["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.pendingMessages).toEqual([]);
|
|
});
|
|
});
|
|
|
|
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.pendingMessages).toEqual(["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.pendingMessages).toEqual([]);
|
|
});
|
|
});
|
|
});
|
|
|
|
it("stopStreaming sends queued pendingMessages after cancelling the stream", async () => {
|
|
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
|
mockFetchChatMessages.mockResolvedValue({ 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();
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(closeFn).toHaveBeenCalled();
|
|
expect(mockStreamChatResponse).toHaveBeenCalledTimes(2);
|
|
expect(mockStreamChatResponse.mock.calls[1]?.[1]).toBe("Queued follow-up");
|
|
expect(result.current.pendingMessages).toEqual([]);
|
|
});
|
|
|
|
act(() => {
|
|
handlers[1]?.onDone?.({ messageId: "msg-queued" });
|
|
});
|
|
});
|
|
|
|
it("preserves queued message localStorage entry when navigating away and restores it on return", async () => {
|
|
const sessionA = {
|
|
...makeSession({ id: "session-001", agentId: "agent-001" }),
|
|
isGenerating: true,
|
|
inFlightGeneration: {
|
|
streamingText: "partial",
|
|
streamingThinking: "",
|
|
toolCalls: [],
|
|
},
|
|
};
|
|
|
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [sessionA] });
|
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
|
mockAttachChatStream.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.isStreaming).toBe(true);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.sendMessage("Queued follow-up");
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.pendingMessages).toEqual(["Queued follow-up"]);
|
|
expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBe(JSON.stringify(["Queued follow-up"]));
|
|
});
|
|
|
|
act(() => {
|
|
result.current.selectSession("");
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.activeSession).toBeNull();
|
|
expect(result.current.pendingMessages).toEqual([]);
|
|
expect(result.current.isStreaming).toBe(false);
|
|
});
|
|
|
|
expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBe(JSON.stringify(["Queued follow-up"]));
|
|
|
|
act(() => {
|
|
result.current.selectSession("session-001");
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.activeSession?.id).toBe("session-001");
|
|
expect(result.current.pendingMessages).toEqual(["Queued follow-up"]);
|
|
expect(result.current.isStreaming).toBe(true);
|
|
});
|
|
});
|
|
|
|
it("does not flush a restored queued message while the server still reports an in-flight generation", async () => {
|
|
// Reproduces FN-5852 back-navigation loss: the sessions-list entry has a
|
|
// stale falsy isGenerating (it is a route-level enrichment that the
|
|
// chat:session:updated SSE payload lacks), while the server is actually
|
|
// still generating. The restored queued message must NOT be flushed from
|
|
// local state alone — doing so aborts the live generation server-side.
|
|
const sessionA = makeSession({ id: "session-001", agentId: "agent-001" });
|
|
mockFetchChatSessions.mockResolvedValue({ sessions: [sessionA] });
|
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
|
mockFetchChatSession.mockResolvedValue({
|
|
session: {
|
|
...sessionA,
|
|
isGenerating: true,
|
|
inFlightGeneration: {
|
|
streamingText: "partial",
|
|
streamingThinking: "",
|
|
toolCalls: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
const attachHandlers: Array<Parameters<typeof mockAttachChatStream>[1]> = [];
|
|
mockAttachChatStream.mockImplementation((_sessionId, nextHandlers) => {
|
|
attachHandlers.push(nextHandlers);
|
|
return { close: vi.fn(), isConnected: () => true };
|
|
});
|
|
|
|
localStorage.setItem(getChatPendingMessageKey("session-001")!, JSON.stringify(["Queued follow-up"]));
|
|
|
|
const { result } = renderHook(() => useChat("proj-123"));
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions).toHaveLength(1);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.selectSession("session-001");
|
|
});
|
|
|
|
// The queued message is restored and the authoritative session fetch
|
|
// reveals the in-flight generation, so the hook attaches instead of
|
|
// flushing.
|
|
await waitFor(() => {
|
|
expect(result.current.pendingMessages).toEqual(["Queued follow-up"]);
|
|
expect(result.current.isStreaming).toBe(true);
|
|
});
|
|
|
|
expect(mockStreamChatResponse).not.toHaveBeenCalled();
|
|
expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBe(JSON.stringify(["Queued follow-up"]));
|
|
|
|
// Once the attached generation completes, the queued message flushes.
|
|
act(() => {
|
|
attachHandlers[0]?.onDone?.({ messageId: "msg-001" });
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockStreamChatResponse).toHaveBeenCalledTimes(1);
|
|
expect(mockStreamChatResponse.mock.calls[0]?.[0]).toBe("session-001");
|
|
expect(mockStreamChatResponse.mock.calls[0]?.[1]).toBe("Queued follow-up");
|
|
expect(result.current.pendingMessages).toEqual([]);
|
|
expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBeNull();
|
|
});
|
|
});
|
|
|
|
it("keeps a restored queued message un-flushed when an attached stream errors but the server is still generating", async () => {
|
|
const sessionA = makeSession({ id: "session-001", agentId: "agent-001" });
|
|
mockFetchChatSessions.mockResolvedValue({ sessions: [sessionA] });
|
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
|
mockFetchChatSession.mockResolvedValue({
|
|
session: {
|
|
...sessionA,
|
|
isGenerating: true,
|
|
inFlightGeneration: {
|
|
streamingText: "partial",
|
|
streamingThinking: "",
|
|
toolCalls: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
const attachHandlers: Array<Parameters<typeof mockAttachChatStream>[1]> = [];
|
|
mockAttachChatStream.mockImplementation((_sessionId, nextHandlers) => {
|
|
attachHandlers.push(nextHandlers);
|
|
return { close: vi.fn(), isConnected: () => true };
|
|
});
|
|
|
|
localStorage.setItem(getChatPendingMessageKey("session-001")!, JSON.stringify(["Queued follow-up"]));
|
|
|
|
const { result } = renderHook(() => useChat("proj-123"));
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions).toHaveLength(1);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.selectSession("session-001");
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(attachHandlers).toHaveLength(1);
|
|
expect(result.current.pendingMessages).toEqual(["Queued follow-up"]);
|
|
expect(mockStreamChatResponse).not.toHaveBeenCalled();
|
|
});
|
|
|
|
act(() => {
|
|
attachHandlers[0]?.onError?.("network");
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(attachHandlers).toHaveLength(2);
|
|
expect(result.current.pendingMessages).toEqual(["Queued follow-up"]);
|
|
expect(result.current.isStreaming).toBe(true);
|
|
expect(mockStreamChatResponse).not.toHaveBeenCalled();
|
|
expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBe(JSON.stringify(["Queued follow-up"]));
|
|
});
|
|
});
|
|
|
|
it("keeps a restored queued message un-flushed while the server validation fetch is pending", async () => {
|
|
// Production latency case: the authoritative fetch takes one network
|
|
// RTT. Nothing may flush (or delete) the restored queue in the interim.
|
|
const sessionA = makeSession({ id: "session-001", agentId: "agent-001" });
|
|
mockFetchChatSessions.mockResolvedValue({ sessions: [sessionA] });
|
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
|
// Server check never resolves within the test — simulates in-flight RTT.
|
|
mockFetchChatSession.mockReturnValue(new Promise(() => {}) as never);
|
|
|
|
localStorage.setItem(getChatPendingMessageKey("session-001")!, JSON.stringify(["Queued follow-up"]));
|
|
|
|
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.pendingMessages).toEqual(["Queued follow-up"]);
|
|
expect(mockStreamChatResponse).not.toHaveBeenCalled();
|
|
expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBe(JSON.stringify(["Queued follow-up"]));
|
|
});
|
|
|
|
expect(result.current.pendingMessages).toEqual(["Queued follow-up"]);
|
|
expect(mockStreamChatResponse).not.toHaveBeenCalled();
|
|
expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBe(JSON.stringify(["Queued follow-up"]));
|
|
});
|
|
|
|
it("preserves queued messages across session switches and rehydrates them when returning", async () => {
|
|
const sessionA = {
|
|
...makeSession({ id: "session-001", agentId: "agent-001" }),
|
|
isGenerating: true,
|
|
inFlightGeneration: {
|
|
streamingText: "partial",
|
|
streamingThinking: "",
|
|
toolCalls: [],
|
|
},
|
|
};
|
|
const sessionB = makeSession({ id: "session-002", agentId: "agent-002" });
|
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [sessionA, sessionB] });
|
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
|
mockAttachChatStream.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.isStreaming).toBe(true);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.sendMessage("Queued follow-up");
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.pendingMessages).toEqual(["Queued follow-up"]);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.selectSession("session-002");
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.activeSession?.id).toBe("session-002");
|
|
expect(result.current.pendingMessages).toEqual([]);
|
|
expect(result.current.isStreaming).toBe(false);
|
|
});
|
|
|
|
expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBe(JSON.stringify(["Queued follow-up"]));
|
|
|
|
act(() => {
|
|
result.current.selectSession("session-001");
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.activeSession?.id).toBe("session-001");
|
|
expect(result.current.pendingMessages).toEqual(["Queued follow-up"]);
|
|
expect(result.current.isStreaming).toBe(true);
|
|
});
|
|
});
|
|
|
|
it("clearPendingMessage is safe without an active session and leaves localStorage untouched", async () => {
|
|
const { result } = renderHook(() => useChat("proj-123"));
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchChatSessions).toHaveBeenCalledWith("proj-123");
|
|
});
|
|
|
|
expect(() => {
|
|
act(() => {
|
|
result.current.clearPendingMessage();
|
|
result.current.selectSession("");
|
|
result.current.sendMessage("No session yet");
|
|
});
|
|
}).not.toThrow();
|
|
|
|
expect(localStorage.getItem("fusion:chat-pending:null")).toBeNull();
|
|
expect(localStorage.getItem("fusion:chat-pending:undefined")).toBeNull();
|
|
});
|
|
|
|
it("clearPendingMessage with an index removes only that queued message and persists the tail", 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 A");
|
|
result.current.sendMessage("Queued B");
|
|
result.current.sendMessage("Queued C");
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.pendingMessages).toEqual(["Queued A", "Queued B", "Queued C"]);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.clearPendingMessage(1);
|
|
});
|
|
|
|
expect(result.current.pendingMessages).toEqual(["Queued A", "Queued C"]);
|
|
expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBe(JSON.stringify(["Queued A", "Queued C"]));
|
|
});
|
|
|
|
it("clearPendingMessage clears pending message and removes persisted queue entry", 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.pendingMessages).toEqual(["Queued follow-up"]);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.clearPendingMessage();
|
|
});
|
|
|
|
expect(result.current.pendingMessages).toEqual([]);
|
|
expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBeNull();
|
|
});
|
|
|
|
it("createSession removes the prior session's persisted queued messages", async () => {
|
|
const existingSession = {
|
|
...makeSession({ id: "session-001", agentId: "agent-001" }),
|
|
isGenerating: true,
|
|
inFlightGeneration: {
|
|
streamingText: "partial",
|
|
streamingThinking: "",
|
|
toolCalls: [],
|
|
},
|
|
};
|
|
const newSession = makeSession({ id: "session-002", agentId: "agent-001", title: "Fresh" });
|
|
|
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [existingSession] });
|
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
|
mockAttachChatStream.mockReturnValue({ close: vi.fn(), isConnected: () => true });
|
|
mockCreateChatSession.mockResolvedValueOnce({ session: newSession });
|
|
|
|
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(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBe(JSON.stringify(["Queued follow-up"]));
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.createSession({ agentId: "agent-001", title: "Fresh" });
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.activeSession?.id).toBe("session-002");
|
|
});
|
|
|
|
expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBeNull();
|
|
});
|
|
|
|
it("archiveSession removes the archived session's persisted queued messages", async () => {
|
|
const session = {
|
|
...makeSession({ id: "session-001", agentId: "agent-001" }),
|
|
isGenerating: true,
|
|
inFlightGeneration: {
|
|
streamingText: "partial",
|
|
streamingThinking: "",
|
|
toolCalls: [],
|
|
},
|
|
};
|
|
|
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
|
mockAttachChatStream.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.isStreaming).toBe(true);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.sendMessage("Queued follow-up");
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBe(JSON.stringify(["Queued follow-up"]));
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.archiveSession("session-001");
|
|
});
|
|
|
|
expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBeNull();
|
|
});
|
|
|
|
it("deleteSession removes the deleted session's persisted queued messages", async () => {
|
|
const session = {
|
|
...makeSession({ id: "session-001", agentId: "agent-001" }),
|
|
isGenerating: true,
|
|
inFlightGeneration: {
|
|
streamingText: "partial",
|
|
streamingThinking: "",
|
|
toolCalls: [],
|
|
},
|
|
};
|
|
|
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
|
mockAttachChatStream.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.isStreaming).toBe(true);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.sendMessage("Queued follow-up");
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBe(JSON.stringify(["Queued follow-up"]));
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.deleteSession("session-001");
|
|
});
|
|
|
|
expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBeNull();
|
|
});
|
|
|
|
it("restored queued message auto-sends once after generation already completed", async () => {
|
|
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
|
localStorage.setItem(getChatPendingMessageKey("session-001")!, JSON.stringify(["Queued follow-up"]));
|
|
|
|
const { result } = renderHook(() => useChat("proj-123"));
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions).toHaveLength(1);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.selectSession("session-001");
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockStreamChatResponse).toHaveBeenCalledTimes(1);
|
|
expect(mockStreamChatResponse.mock.calls[0]?.[1]).toBe("Queued follow-up");
|
|
});
|
|
|
|
expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBeNull();
|
|
});
|
|
|
|
it("stopStreaming flushes pendingMessages", async () => {
|
|
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
|
mockFetchChatMessages.mockResolvedValue({ 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(mockStreamChatResponse).toHaveBeenCalledTimes(2);
|
|
expect(mockStreamChatResponse.mock.calls[1]?.[1]).toBe("Queued follow-up");
|
|
expect(result.current.pendingMessages).toEqual([]);
|
|
});
|
|
|
|
expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBeNull();
|
|
});
|
|
|
|
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("before");
|
|
|
|
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("loadMoreMessages callback is stable when messages array changes (no re-create on streaming)", async () => {
|
|
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
|
|
|
// 50 messages so hasMoreMessages=true
|
|
const make50 = () =>
|
|
Array.from({ length: 50 }, (_, i) =>
|
|
makeMessage({ id: `msg-${i}`, sessionId: "session-001", role: "user", content: `m${i}`, createdAt: `2026-04-08T00:00:${String(i).padStart(2, "0")}.000Z` })
|
|
);
|
|
mockFetchChatMessages.mockResolvedValueOnce({ messages: make50() });
|
|
|
|
// Minimal streaming mock — returns immediately so sendMessage won't hang
|
|
mockStreamChatResponse.mockImplementation(() => ({ close: vi.fn(), isConnected: () => false }));
|
|
|
|
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);
|
|
});
|
|
|
|
// Capture callback identity before messages change
|
|
const loadMoreBefore = result.current.loadMoreMessages;
|
|
|
|
// sendMessage adds an optimistic user message → new messages array reference
|
|
act(() => {
|
|
void result.current.sendMessage("hello");
|
|
});
|
|
|
|
await waitFor(() => {
|
|
// Optimistic user message was appended
|
|
expect(result.current.messages.length).toBeGreaterThan(50);
|
|
});
|
|
|
|
// loadMoreMessages must NOT have been recreated despite messages array changing
|
|
expect(result.current.loadMoreMessages).toBe(loadMoreBefore);
|
|
});
|
|
|
|
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("ignores empty task-planner session create events until a message exists", async () => {
|
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [] });
|
|
|
|
const { result } = renderHook(() => useChat("proj-123"));
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions).toHaveLength(0);
|
|
});
|
|
|
|
act(() => {
|
|
subscribeHandler["chat:session:created"]?.({
|
|
data: JSON.stringify(makeSession({ id: "chat-empty-planner", agentId: "task-planner:FN-7337" })),
|
|
} as MessageEvent);
|
|
});
|
|
|
|
expect(result.current.sessions).toHaveLength(0);
|
|
});
|
|
|
|
it("refreshes server-filtered sessions when a message arrives for an unseen planner session", async () => {
|
|
mockFetchChatSessions
|
|
.mockResolvedValueOnce({ sessions: [] })
|
|
.mockResolvedValueOnce({ sessions: [{ ...makeSession({ id: "chat-planner", agentId: "task-planner:FN-7337" }), lastMessagePreview: "Hello", lastMessageAt: "2026-04-08T00:01:00.000Z" } as any] });
|
|
|
|
const { result } = renderHook(() => useChat("proj-123"));
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions).toHaveLength(0);
|
|
});
|
|
|
|
act(() => {
|
|
subscribeHandler["chat:message:added"]?.({
|
|
data: JSON.stringify(makeMessage({ id: "msg-planner", sessionId: "chat-planner", role: "user", content: "Hello" })),
|
|
} as MessageEvent);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions.map((session) => session.id)).toEqual(["chat-planner"]);
|
|
});
|
|
expect(mockFetchChatSessions).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("uses server-filtered refresh instead of directly adding populated task-planner create events", async () => {
|
|
mockFetchChatSessions
|
|
.mockResolvedValueOnce({ sessions: [] })
|
|
.mockResolvedValueOnce({ sessions: [] });
|
|
|
|
const { result } = renderHook(() => useChat("proj-123"));
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions).toHaveLength(0);
|
|
});
|
|
|
|
act(() => {
|
|
subscribeHandler["chat:session:created"]?.({
|
|
data: JSON.stringify({
|
|
...makeSession({ id: "chat-planner", agentId: "task-planner:FN-7337" }),
|
|
lastMessagePreview: "Hello",
|
|
lastMessageAt: "2026-04-08T00:01:00.000Z",
|
|
}),
|
|
} as MessageEvent);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchChatSessions).toHaveBeenCalledTimes(2);
|
|
});
|
|
expect(result.current.sessions).toHaveLength(0);
|
|
});
|
|
|
|
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("FN-6599 keeps restored main-chat prior thread visible during selectSession recovery attach", async () => {
|
|
const generatingSession = {
|
|
...makeSession({
|
|
id: "session-restore-generating",
|
|
agentId: "agent-001",
|
|
title: "Restored generating",
|
|
}),
|
|
isGenerating: true,
|
|
inFlightGeneration: {
|
|
status: "generating" as const,
|
|
streamingText: "restored partial",
|
|
streamingThinking: "thinking",
|
|
toolCalls: [],
|
|
replayFromEventId: 101,
|
|
updatedAt: "2026-04-08T00:00:00.000Z",
|
|
},
|
|
};
|
|
const priorThreadNewestFirst = [
|
|
makeMessage({ id: "msg-004", sessionId: generatingSession.id, role: "assistant", content: "Second answer" }),
|
|
makeMessage({ id: "msg-003", sessionId: generatingSession.id, role: "user", content: "Second question" }),
|
|
makeMessage({ id: "msg-002", sessionId: generatingSession.id, role: "assistant", content: "First answer" }),
|
|
makeMessage({ id: "msg-001", sessionId: generatingSession.id, role: "user", content: "First question" }),
|
|
];
|
|
|
|
mockGetScopedItem.mockImplementation((key) => key === "kb-chat-active-session" ? generatingSession.id : undefined);
|
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [generatingSession] });
|
|
mockFetchChatMessages.mockResolvedValueOnce({ messages: priorThreadNewestFirst });
|
|
|
|
const { result } = renderHook(() => useChat("proj-123"));
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isStreaming).toBe(true);
|
|
expect(result.current.streamingText).toBe("restored partial");
|
|
expect(mockAttachChatStream).toHaveBeenCalledWith(
|
|
generatingSession.id,
|
|
expect.any(Object),
|
|
"proj-123",
|
|
{ lastEventId: 101 },
|
|
);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.messages.map((message) => message.content)).toEqual([
|
|
"First question",
|
|
"First answer",
|
|
"Second question",
|
|
"Second answer",
|
|
]);
|
|
});
|
|
});
|
|
|
|
it("FN-7853 keeps cached prior thread stable when stale mid-turn load resolves empty", async () => {
|
|
const generatingSession = {
|
|
...makeSession({ id: "session-mid-turn-stable", agentId: "agent-001", title: "Mid turn stable" }),
|
|
isGenerating: true,
|
|
inFlightGeneration: {
|
|
status: "generating" as const,
|
|
streamingText: "working",
|
|
streamingThinking: "thinking",
|
|
toolCalls: [],
|
|
replayFromEventId: 201,
|
|
updatedAt: "2026-04-08T00:00:00.000Z",
|
|
},
|
|
};
|
|
const priorThread = [
|
|
makeMessage({ id: "msg-001", sessionId: generatingSession.id, role: "user", content: "First question" }),
|
|
makeMessage({ id: "msg-002", sessionId: generatingSession.id, role: "assistant", content: "First answer" }),
|
|
makeMessage({ id: "msg-003", sessionId: generatingSession.id, role: "user", content: "Second question" }),
|
|
makeMessage({ id: "msg-004", sessionId: generatingSession.id, role: "assistant", content: "Second answer" }),
|
|
];
|
|
const staleFetch = createDeferredPromise<{ messages: ChatMessage[] }>();
|
|
let attachedHandlers: StreamAppendHandlers | undefined;
|
|
|
|
cacheMessages("proj-123", generatingSession.id, priorThread);
|
|
mockGetScopedItem.mockImplementation((key) => key === "kb-chat-active-session" ? generatingSession.id : undefined);
|
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [generatingSession] });
|
|
mockFetchChatMessages.mockReturnValue(staleFetch.promise);
|
|
mockAttachChatStream.mockImplementation((_sessionId, handlers) => {
|
|
attachedHandlers = handlers;
|
|
return { close: vi.fn(), isConnected: () => true };
|
|
});
|
|
|
|
const { result } = renderHook(() => useChat("proj-123"));
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isStreaming).toBe(true);
|
|
expect(result.current.streamingText).toBe("working");
|
|
expect(result.current.messages.map((message) => message.content)).toEqual([
|
|
"First question",
|
|
"First answer",
|
|
"Second question",
|
|
"Second answer",
|
|
]);
|
|
});
|
|
|
|
const expectPriorThreadStable = () => {
|
|
const contents = result.current.messages.map((message) => message.content);
|
|
expect(contents).toEqual(expect.arrayContaining([
|
|
"First question",
|
|
"First answer",
|
|
"Second question",
|
|
"Second answer",
|
|
]));
|
|
};
|
|
|
|
act(() => {
|
|
subscribeHandler["chat:session:updated"]?.({
|
|
data: JSON.stringify({
|
|
...generatingSession,
|
|
inFlightGeneration: { ...generatingSession.inFlightGeneration, streamingText: "working harder", replayFromEventId: 202 },
|
|
}),
|
|
} as MessageEvent);
|
|
});
|
|
expectPriorThreadStable();
|
|
|
|
vi.useFakeTimers();
|
|
act(() => {
|
|
attachedHandlers?.onToolStart({ toolName: "read", args: { path: "README.md" } });
|
|
attachedHandlers?.onText(" now");
|
|
attachedHandlers?.onToolEnd({ toolName: "read", isError: false, result: "ok" });
|
|
});
|
|
act(() => {
|
|
vi.advanceTimersToNextTimer();
|
|
});
|
|
expectPriorThreadStable();
|
|
|
|
act(() => {
|
|
subscribeHandler["chat:message:added"]?.({
|
|
data: JSON.stringify(makeMessage({
|
|
id: "msg-005",
|
|
sessionId: generatingSession.id,
|
|
role: "user",
|
|
content: "Follow-up question",
|
|
})),
|
|
} as MessageEvent);
|
|
});
|
|
expectPriorThreadStable();
|
|
|
|
await act(async () => {
|
|
staleFetch.resolve({ messages: [] });
|
|
await staleFetch.promise;
|
|
});
|
|
|
|
expectPriorThreadStable();
|
|
expect(result.current.isStreaming).toBe(true);
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("FN-7853 keeps prior thread stable during the client's own streaming send turn", async () => {
|
|
const session = makeSession({ id: "session-live-send", agentId: "agent-001", title: "Live send" });
|
|
const priorThreadNewestFirst = [
|
|
makeMessage({ id: "msg-004", sessionId: session.id, role: "assistant", content: "Second answer" }),
|
|
makeMessage({ id: "msg-003", sessionId: session.id, role: "user", content: "Second question" }),
|
|
makeMessage({ id: "msg-002", sessionId: session.id, role: "assistant", content: "First answer" }),
|
|
makeMessage({ id: "msg-001", sessionId: session.id, role: "user", content: "First question" }),
|
|
];
|
|
let streamHandlers: StreamAppendHandlers | undefined;
|
|
|
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
|
mockFetchChatSession.mockResolvedValue({ session });
|
|
mockFetchChatMessages.mockResolvedValue({ messages: priorThreadNewestFirst });
|
|
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
|
|
streamHandlers = handlers;
|
|
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.id);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.messages.map((message) => message.content)).toEqual([
|
|
"First question",
|
|
"First answer",
|
|
"Second question",
|
|
"Second answer",
|
|
]);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.sendMessage("Third question");
|
|
});
|
|
|
|
expect(result.current.messages.map((message) => message.content)).toEqual([
|
|
"First question",
|
|
"First answer",
|
|
"Second question",
|
|
"Second answer",
|
|
"Third question",
|
|
]);
|
|
expect(result.current.isStreaming).toBe(true);
|
|
|
|
vi.useFakeTimers();
|
|
act(() => {
|
|
streamHandlers?.onText("Partial answer");
|
|
subscribeHandler["chat:session:updated"]?.({
|
|
data: JSON.stringify({
|
|
...session,
|
|
isGenerating: true,
|
|
inFlightGeneration: {
|
|
status: "generating" as const,
|
|
streamingText: "Partial answer",
|
|
streamingThinking: "",
|
|
toolCalls: [],
|
|
replayFromEventId: 301,
|
|
updatedAt: "2026-04-08T00:00:00.000Z",
|
|
},
|
|
}),
|
|
} as MessageEvent);
|
|
subscribeHandler["chat:message:added"]?.({
|
|
data: JSON.stringify(makeMessage({ id: "msg-ignored-assistant", sessionId: session.id, role: "assistant", content: "Duplicate assistant" })),
|
|
} as MessageEvent);
|
|
});
|
|
act(() => {
|
|
vi.advanceTimersToNextTimer();
|
|
});
|
|
|
|
expect(result.current.messages.map((message) => message.content)).toEqual([
|
|
"First question",
|
|
"First answer",
|
|
"Second question",
|
|
"Second answer",
|
|
"Third question",
|
|
]);
|
|
expect(result.current.streamingText).toBe("Partial answer");
|
|
|
|
act(() => {
|
|
streamHandlers?.onDone?.({
|
|
messageId: "msg-final-live-send",
|
|
accumulated: { text: "Final live answer", thinking: "", toolCalls: [] },
|
|
});
|
|
});
|
|
|
|
expect(result.current.messages.map((message) => message.content)).toEqual([
|
|
"First question",
|
|
"First answer",
|
|
"Second question",
|
|
"Second answer",
|
|
"Third question",
|
|
"Partial answer",
|
|
]);
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("FN-6496 loads prior thread during chat:session:updated in-flight attach", async () => {
|
|
const session = makeSession({ id: "session-001", agentId: "agent-001", title: "Existing" });
|
|
const priorThreadNewestFirst = [
|
|
makeMessage({ id: "msg-004", sessionId: session.id, role: "assistant", content: "Second answer" }),
|
|
makeMessage({ id: "msg-003", sessionId: session.id, role: "user", content: "Second question" }),
|
|
makeMessage({ id: "msg-002", sessionId: session.id, role: "assistant", content: "First answer" }),
|
|
makeMessage({ id: "msg-001", sessionId: session.id, role: "user", content: "First question" }),
|
|
];
|
|
const generatingSession = {
|
|
...session,
|
|
isGenerating: true,
|
|
inFlightGeneration: {
|
|
status: "generating" as const,
|
|
streamingText: "live partial",
|
|
streamingThinking: "thinking",
|
|
toolCalls: [],
|
|
replayFromEventId: 88,
|
|
updatedAt: "2026-04-08T00:00:00.000Z",
|
|
},
|
|
};
|
|
|
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
|
mockFetchChatMessages
|
|
.mockResolvedValueOnce({ messages: [] })
|
|
.mockResolvedValueOnce({ messages: priorThreadNewestFirst });
|
|
|
|
const { result } = renderHook(() => useChat("proj-123"));
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions).toHaveLength(1);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.selectSession(session.id);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.activeSession?.id).toBe(session.id);
|
|
expect(mockFetchChatMessages).toHaveBeenCalledWith(session.id, { limit: 50, order: "desc" }, "proj-123");
|
|
});
|
|
expect(result.current.messages).toEqual([]);
|
|
|
|
act(() => {
|
|
subscribeHandler["chat:session:updated"]?.({
|
|
data: JSON.stringify(generatingSession),
|
|
} as MessageEvent);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isStreaming).toBe(true);
|
|
expect(result.current.streamingText).toBe("live partial");
|
|
expect(mockAttachChatStream).toHaveBeenCalledWith(
|
|
session.id,
|
|
expect.any(Object),
|
|
"proj-123",
|
|
{ lastEventId: 88 },
|
|
);
|
|
expect(mockFetchChatMessages).toHaveBeenCalledTimes(2);
|
|
expect(result.current.messages.map((message) => message.id)).toEqual([
|
|
"msg-001",
|
|
"msg-002",
|
|
"msg-003",
|
|
"msg-004",
|
|
]);
|
|
});
|
|
});
|
|
|
|
it("FN-6632 preserves prior streamed chunks during chat:session:updated reattach", async () => {
|
|
const session = makeSession({ id: "session-001", agentId: "agent-001", title: "Existing" });
|
|
const generatingSession = {
|
|
...session,
|
|
isGenerating: true,
|
|
inFlightGeneration: {
|
|
status: "generating" as const,
|
|
streamingText: "Hello ",
|
|
streamingThinking: "plan ",
|
|
toolCalls: [{ toolName: "read", status: "running" as const, isError: false }],
|
|
replayFromEventId: 5,
|
|
updatedAt: "2026-04-08T00:00:00.000Z",
|
|
},
|
|
};
|
|
let attachedHandlers: StreamAppendHandlers | undefined;
|
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
|
mockAttachChatStream.mockImplementation((_sessionId, handlers) => {
|
|
attachedHandlers = handlers;
|
|
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.id);
|
|
});
|
|
|
|
act(() => {
|
|
subscribeHandler["chat:session:updated"]?.({
|
|
data: JSON.stringify(generatingSession),
|
|
} as MessageEvent);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isStreaming).toBe(true);
|
|
expect(result.current.streamingText).toBe("Hello ");
|
|
expect(attachedHandlers).toBeDefined();
|
|
});
|
|
|
|
vi.useFakeTimers();
|
|
act(() => {
|
|
attachedHandlers?.onText("world");
|
|
attachedHandlers?.onText("!");
|
|
attachedHandlers?.onThinking("more");
|
|
attachedHandlers?.onToolEnd({ toolName: "read", isError: false, result: "done" });
|
|
});
|
|
act(() => {
|
|
vi.advanceTimersToNextTimer();
|
|
vi.advanceTimersToNextTimer();
|
|
});
|
|
|
|
expect(result.current.isStreaming).toBe(true);
|
|
expect(result.current.streamingText).toBe("Hello world!");
|
|
expect(result.current.streamingThinking).toBe("plan more");
|
|
expect(result.current.streamingToolCalls).toEqual([
|
|
{ toolName: "read", status: "completed", isError: false, result: "done" },
|
|
]);
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("FN-6496 loads prior thread when auto-reattach effect observes refreshed generation", async () => {
|
|
const session = makeSession({ id: "session-001", agentId: "agent-001", title: "Stale" });
|
|
const priorThreadNewestFirst = [
|
|
makeMessage({ id: "msg-004", sessionId: session.id, role: "assistant", content: "Second answer" }),
|
|
makeMessage({ id: "msg-003", sessionId: session.id, role: "user", content: "Second question" }),
|
|
makeMessage({ id: "msg-002", sessionId: session.id, role: "assistant", content: "First answer" }),
|
|
makeMessage({ id: "msg-001", sessionId: session.id, role: "user", content: "First question" }),
|
|
];
|
|
const generatingSession = {
|
|
...session,
|
|
isGenerating: true,
|
|
inFlightGeneration: {
|
|
status: "generating" as const,
|
|
streamingText: "refreshed partial",
|
|
streamingThinking: "thinking",
|
|
toolCalls: [],
|
|
replayFromEventId: 90,
|
|
updatedAt: "2026-04-08T00:00:00.000Z",
|
|
},
|
|
};
|
|
|
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
|
mockFetchChatSession.mockResolvedValueOnce({ session: generatingSession });
|
|
mockFetchChatMessages
|
|
.mockResolvedValueOnce({ messages: [] })
|
|
.mockResolvedValueOnce({ messages: priorThreadNewestFirst });
|
|
let attachedHandlers: StreamAppendHandlers | undefined;
|
|
mockAttachChatStream.mockImplementation((_sessionId, nextHandlers) => {
|
|
attachedHandlers = 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.id);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isStreaming).toBe(true);
|
|
expect(result.current.streamingText).toBe("refreshed partial");
|
|
expect(mockAttachChatStream).toHaveBeenCalledWith(
|
|
session.id,
|
|
expect.any(Object),
|
|
"proj-123",
|
|
{ lastEventId: 90 },
|
|
);
|
|
expect(mockFetchChatMessages).toHaveBeenCalledTimes(2);
|
|
expect(result.current.messages.map((message) => message.id)).toEqual([
|
|
"msg-001",
|
|
"msg-002",
|
|
"msg-003",
|
|
"msg-004",
|
|
]);
|
|
});
|
|
|
|
vi.useFakeTimers();
|
|
act(() => {
|
|
attachedHandlers?.onText(" plus");
|
|
});
|
|
act(() => {
|
|
vi.advanceTimersToNextTimer();
|
|
});
|
|
expect(result.current.streamingText).toBe("refreshed partial plus");
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("FN-6496 loads prior thread when reconnectSessionSilently reattaches after send suspension", async () => {
|
|
const session = makeSession({ id: "session-001", agentId: "agent-001", title: "Reconnect" });
|
|
const priorThreadNewestFirst = [
|
|
makeMessage({ id: "msg-004", sessionId: session.id, role: "assistant", content: "Second answer" }),
|
|
makeMessage({ id: "msg-003", sessionId: session.id, role: "user", content: "Second question" }),
|
|
makeMessage({ id: "msg-002", sessionId: session.id, role: "assistant", content: "First answer" }),
|
|
makeMessage({ id: "msg-001", sessionId: session.id, role: "user", content: "First question" }),
|
|
];
|
|
const generatingSession = {
|
|
...session,
|
|
isGenerating: true,
|
|
inFlightGeneration: {
|
|
status: "generating" as const,
|
|
streamingText: "reconnected partial",
|
|
streamingThinking: "thinking",
|
|
toolCalls: [],
|
|
replayFromEventId: 91,
|
|
updatedAt: "2026-04-08T00:00:00.000Z",
|
|
},
|
|
};
|
|
let onError: ((data: string | apiModule.ChatFailureInfo, tempUserMessageId: string) => void) | undefined;
|
|
|
|
mockFetchChatSessions
|
|
.mockResolvedValueOnce({ sessions: [session] })
|
|
.mockResolvedValueOnce({ sessions: [generatingSession] });
|
|
mockFetchChatSession
|
|
.mockResolvedValueOnce({ session })
|
|
.mockResolvedValueOnce({ session: generatingSession });
|
|
mockFetchChatMessages
|
|
.mockResolvedValueOnce({ messages: [] })
|
|
.mockResolvedValueOnce({ messages: priorThreadNewestFirst });
|
|
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
|
|
onError = handlers.onError;
|
|
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.id);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.activeSession?.id).toBe(session.id);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.sendMessage("Continue");
|
|
onError?.("Failed to fetch", "temp-reconnect");
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isStreaming).toBe(true);
|
|
expect(result.current.streamingText).toBe("reconnected partial");
|
|
expect(mockAttachChatStream).toHaveBeenCalledWith(
|
|
session.id,
|
|
expect.any(Object),
|
|
"proj-123",
|
|
{ lastEventId: 91 },
|
|
);
|
|
expect(result.current.messages.map((message) => message.id)).toEqual([
|
|
"msg-001",
|
|
"msg-002",
|
|
"msg-003",
|
|
"msg-004",
|
|
]);
|
|
});
|
|
});
|
|
|
|
it("FN-6496 does not refetch or duplicate when prior thread is already loaded", async () => {
|
|
const session = makeSession({ id: "session-001", agentId: "agent-001", title: "Loaded" });
|
|
const priorThreadNewestFirst = [
|
|
makeMessage({ id: "msg-002", sessionId: session.id, role: "assistant", content: "First answer" }),
|
|
makeMessage({ id: "msg-001", sessionId: session.id, role: "user", content: "First question" }),
|
|
];
|
|
const generatingSession = {
|
|
...session,
|
|
isGenerating: true,
|
|
inFlightGeneration: {
|
|
status: "generating" as const,
|
|
streamingText: "live partial",
|
|
streamingThinking: "",
|
|
toolCalls: [],
|
|
replayFromEventId: 89,
|
|
updatedAt: "2026-04-08T00:00:00.000Z",
|
|
},
|
|
};
|
|
|
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
|
mockFetchChatMessages.mockResolvedValueOnce({ messages: priorThreadNewestFirst });
|
|
|
|
const { result } = renderHook(() => useChat("proj-123"));
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions).toHaveLength(1);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.selectSession(session.id);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.messages.map((message) => message.id)).toEqual(["msg-001", "msg-002"]);
|
|
});
|
|
mockFetchChatMessages.mockClear();
|
|
|
|
act(() => {
|
|
subscribeHandler["chat:session:updated"]?.({
|
|
data: JSON.stringify(generatingSession),
|
|
} as MessageEvent);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isStreaming).toBe(true);
|
|
expect(mockAttachChatStream).toHaveBeenCalledWith(
|
|
session.id,
|
|
expect.any(Object),
|
|
"proj-123",
|
|
{ lastEventId: 89 },
|
|
);
|
|
});
|
|
expect(mockFetchChatMessages).not.toHaveBeenCalled();
|
|
expect(result.current.messages.map((message) => message.id)).toEqual(["msg-001", "msg-002"]);
|
|
});
|
|
|
|
it("FN-5104 ignores replay checkpoint bumps while attach stream is already active", async () => {
|
|
const generating = {
|
|
...makeSession({ id: "session-001", agentId: "agent-001", title: "Gen" }),
|
|
isGenerating: true,
|
|
inFlightGeneration: {
|
|
status: "generating" as const,
|
|
streamingText: "partial",
|
|
streamingThinking: "",
|
|
toolCalls: [],
|
|
replayFromEventId: 5,
|
|
updatedAt: "2026-04-08T00:00:00.000Z",
|
|
},
|
|
};
|
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [generating] });
|
|
mockFetchChatSession.mockResolvedValue({ session: generating });
|
|
|
|
const { result } = renderHook(() => useChat("proj-123"));
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions).toHaveLength(1);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.selectSession("session-001");
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockAttachChatStream).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
const updatedSession = {
|
|
...generating,
|
|
inFlightGeneration: { ...generating.inFlightGeneration, replayFromEventId: 9 },
|
|
};
|
|
act(() => {
|
|
subscribeHandler["chat:session:updated"]?.({
|
|
data: JSON.stringify(updatedSession),
|
|
} as MessageEvent);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockAttachChatStream).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
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 cache 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");
|
|
});
|
|
|
|
const clearCacheSpy = vi.spyOn(swrCacheModule, "clearCache");
|
|
localStorage.setItem(
|
|
chatMessagesCacheKey("proj-123", "session-001"),
|
|
JSON.stringify({ savedAt: Date.now(), data: [makeMessage({ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Cached" })] }),
|
|
);
|
|
|
|
// 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);
|
|
});
|
|
|
|
expect(clearCacheSpy).toHaveBeenCalledWith(chatMessagesCacheKey("proj-123", "session-001"));
|
|
});
|
|
|
|
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("reconciles persisted user attachment echo during streaming without refetch or duplicate", async () => {
|
|
mockFetchChatSessions.mockResolvedValueOnce({
|
|
sessions: [makeSession({ id: "session-001", agentId: "agent-001" })],
|
|
});
|
|
mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
|
|
|
|
mockStreamChatResponse.mockImplementation((_sessionId, _content, _handlers) => ({ 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"));
|
|
const fetchCountAfterSelect = mockFetchChatMessages.mock.calls.length;
|
|
const uploadedFiles = [
|
|
new File(["image-bytes"], "screenshot.png", { type: "image/png" }),
|
|
new File(["notes"], "notes.txt", { type: "text/plain" }),
|
|
];
|
|
|
|
act(() => {
|
|
result.current.sendMessage("Hi", uploadedFiles);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isStreaming).toBe(true);
|
|
const userMessages = result.current.messages.filter((message) => message.role === "user");
|
|
expect(userMessages).toHaveLength(1);
|
|
expect(userMessages[0]?.id.startsWith("temp-")).toBe(true);
|
|
expect(userMessages[0]?.attachments).toBeUndefined();
|
|
});
|
|
|
|
const persistedAttachments = [
|
|
{
|
|
id: "att-image-001",
|
|
filename: "2026-07-12T00-00-00_screenshot.png",
|
|
originalName: "screenshot.png",
|
|
mimeType: "image/png",
|
|
size: 11,
|
|
createdAt: "2026-07-12T00:00:00.000Z",
|
|
},
|
|
{
|
|
id: "att-file-001",
|
|
filename: "2026-07-12T00-00-00_notes.txt",
|
|
originalName: "notes.txt",
|
|
mimeType: "text/plain",
|
|
size: 5,
|
|
createdAt: "2026-07-12T00:00:00.000Z",
|
|
},
|
|
];
|
|
const persistedEcho = makeMessage({
|
|
id: "msg-user-001",
|
|
sessionId: "session-001",
|
|
role: "user",
|
|
content: "Hi",
|
|
attachments: persistedAttachments,
|
|
});
|
|
|
|
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);
|
|
expect(userMessages[0]?.id).toBe("msg-user-001");
|
|
expect(userMessages[0]?.attachments).toEqual(persistedAttachments);
|
|
});
|
|
expect(mockFetchChatMessages).toHaveBeenCalledTimes(fetchCountAfterSelect);
|
|
});
|
|
|
|
it("reconciles attachment-only persisted user echo during streaming", async () => {
|
|
mockFetchChatSessions.mockResolvedValueOnce({
|
|
sessions: [makeSession({ id: "session-001", agentId: "agent-001" })],
|
|
});
|
|
mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
|
|
mockStreamChatResponse.mockImplementation((_sessionId, _content, _handlers) => ({ 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("", [new File(["image-bytes"], "screenshot.png", { type: "image/png" })]);
|
|
});
|
|
await waitFor(() => expect(result.current.isStreaming).toBe(true));
|
|
|
|
const persistedAttachments = [
|
|
{
|
|
id: "att-image-only-001",
|
|
filename: "2026-07-12T00-00-00_screenshot.png",
|
|
originalName: "screenshot.png",
|
|
mimeType: "image/png",
|
|
size: 11,
|
|
createdAt: "2026-07-12T00:00:00.000Z",
|
|
},
|
|
];
|
|
act(() => {
|
|
subscribeHandler["chat:message:added"]?.({
|
|
data: JSON.stringify(makeMessage({
|
|
id: "msg-user-attachment-only",
|
|
sessionId: "session-001",
|
|
role: "user",
|
|
content: "",
|
|
attachments: persistedAttachments,
|
|
})),
|
|
} as MessageEvent);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
const userMessages = result.current.messages.filter((message) => message.role === "user");
|
|
expect(userMessages).toHaveLength(1);
|
|
expect(userMessages[0]?.id).toBe("msg-user-attachment-only");
|
|
expect(userMessages[0]?.content).toBe("");
|
|
expect(userMessages[0]?.attachments).toEqual(persistedAttachments);
|
|
});
|
|
});
|
|
|
|
it("reconciles text-only persisted user echo during streaming without duplicate", async () => {
|
|
mockFetchChatSessions.mockResolvedValueOnce({
|
|
sessions: [makeSession({ id: "session-001", agentId: "agent-001" })],
|
|
});
|
|
mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
|
|
mockStreamChatResponse.mockImplementation((_sessionId, _content, _handlers) => ({ 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("Plain text only");
|
|
});
|
|
await waitFor(() => expect(result.current.isStreaming).toBe(true));
|
|
|
|
act(() => {
|
|
subscribeHandler["chat:message:added"]?.({
|
|
data: JSON.stringify(makeMessage({
|
|
id: "msg-user-text-only",
|
|
sessionId: "session-001",
|
|
role: "user",
|
|
content: "Plain text only",
|
|
})),
|
|
} as MessageEvent);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
const userMessages = result.current.messages.filter((message) => message.role === "user");
|
|
expect(userMessages).toHaveLength(1);
|
|
expect(userMessages[0]?.id).toBe("msg-user-text-only");
|
|
expect(userMessages[0]?.attachments).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
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("keeps accepted sent message visible after provider error and reconciles persisted echo", async () => {
|
|
mockFetchChatSessions.mockResolvedValueOnce({
|
|
sessions: [makeSession({ id: "session-001", agentId: "agent-001" })],
|
|
});
|
|
mockFetchChatMessages
|
|
.mockResolvedValueOnce({ messages: [] })
|
|
.mockResolvedValueOnce({
|
|
messages: [makeMessage({ id: "msg-user-001", sessionId: "session-001", role: "user", content: "hello after 429" })],
|
|
});
|
|
|
|
let errorHandler: ((data: string | apiModule.ChatFailureInfo, meta?: apiModule.ChatStreamErrorMeta) => void) | undefined;
|
|
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
|
|
errorHandler = handlers.onError;
|
|
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 after 429");
|
|
});
|
|
await waitFor(() => expect(result.current.messages.some((message) => message.content === "hello after 429")).toBe(true));
|
|
|
|
act(() => {
|
|
errorHandler?.({ summary: "Provider rate limit", code: "rate_limit" }, { requestAccepted: true, receivedStreamEvent: true });
|
|
});
|
|
|
|
await waitFor(() => {
|
|
const matching = result.current.messages.filter((message) => message.role === "user" && message.content === "hello after 429");
|
|
expect(matching).toHaveLength(1);
|
|
expect(matching[0]?.id).toBe("msg-user-001");
|
|
});
|
|
expect(result.current.isStreaming).toBe(false);
|
|
expect(result.current.messages.some((message) => message.role === "assistant" && message.failureInfo?.summary === "Provider rate limit")).toBe(true);
|
|
});
|
|
|
|
it("keeps accepted silent streams waiting and reconciles a late assistant message", async () => {
|
|
mockFetchChatSessions.mockResolvedValueOnce({
|
|
sessions: [makeSession({ id: "session-001", agentId: "agent-001" })],
|
|
});
|
|
mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
|
|
|
|
let doneHandler: ((data: { messageId: string; message?: ChatMessage }) => void) | undefined;
|
|
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
|
|
doneHandler = handlers.onDone;
|
|
return { close: vi.fn(), isConnected: () => true };
|
|
});
|
|
|
|
const addToast = vi.fn();
|
|
const { result } = renderHook(() => useChat("proj-123", 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(() => result.current.sendMessage("slow prompt"));
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isStreaming).toBe(true);
|
|
expect(result.current.messages.some((message) => message.role === "user" && message.content === "slow prompt")).toBe(true);
|
|
});
|
|
expect(result.current.messages.some((message) => message.failureInfo?.summary === "Timed out waiting for first response event")).toBe(false);
|
|
expect(addToast).not.toHaveBeenCalledWith("Timed out waiting for first response event", "error");
|
|
|
|
act(() => {
|
|
doneHandler?.({
|
|
messageId: "msg-late-assistant",
|
|
message: makeMessage({ id: "msg-late-assistant", sessionId: "session-001", role: "assistant", content: "late answer" }),
|
|
});
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isStreaming).toBe(false);
|
|
expect(result.current.messages.some((message) => message.role === "assistant" && message.content === "late answer")).toBe(true);
|
|
});
|
|
expect(result.current.messages.some((message) => message.failureInfo?.summary === "Response failed")).toBe(false);
|
|
});
|
|
|
|
it("does not keep optimistic sent message for pre-acceptance HTTP failures", async () => {
|
|
mockFetchChatSessions.mockResolvedValueOnce({
|
|
sessions: [makeSession({ id: "session-001", agentId: "agent-001" })],
|
|
});
|
|
mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
|
|
let errorHandler: ((data: string | apiModule.ChatFailureInfo, meta?: apiModule.ChatStreamErrorMeta) => void) | undefined;
|
|
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
|
|
errorHandler = handlers.onError;
|
|
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("blocked before persist"));
|
|
act(() => errorHandler?.("Request failed: 429", { requestAccepted: false, receivedStreamEvent: false }));
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.messages.some((message) => message.content === "blocked before persist" && message.role === "user")).toBe(false);
|
|
expect(result.current.isStreaming).toBe(false);
|
|
});
|
|
});
|
|
|
|
it("flushes queued direct message after accepted provider error becomes idle", async () => {
|
|
mockFetchChatSessions.mockResolvedValueOnce({
|
|
sessions: [makeSession({ id: "session-001", agentId: "agent-001" })],
|
|
});
|
|
mockFetchChatMessages
|
|
.mockResolvedValueOnce({ messages: [] })
|
|
.mockResolvedValue({ messages: [] });
|
|
let errorHandler: ((data: string | apiModule.ChatFailureInfo, meta?: apiModule.ChatStreamErrorMeta) => void) | undefined;
|
|
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
|
|
errorHandler = handlers.onError;
|
|
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 accepted");
|
|
result.current.sendMessage("second queued");
|
|
});
|
|
expect(result.current.pendingMessages).toEqual(["second queued"]);
|
|
|
|
act(() => errorHandler?.("Provider failed", { requestAccepted: true, receivedStreamEvent: true }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockStreamChatResponse).toHaveBeenCalledTimes(2);
|
|
expect(mockStreamChatResponse).toHaveBeenLastCalledWith("session-001", "second queued", expect.any(Object), undefined, "proj-123");
|
|
});
|
|
});
|
|
|
|
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, order: "desc" }, 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("hydrates durable in-flight snapshot and resumes from replay point", async () => {
|
|
const session = {
|
|
...makeSession({ id: "session-001", agentId: "agent-001" }),
|
|
isGenerating: true,
|
|
inFlightGeneration: {
|
|
status: "generating" as const,
|
|
streamingText: "partial text",
|
|
streamingThinking: "partial thinking",
|
|
toolCalls: [{ toolName: "read", status: "running" as const, isError: false }],
|
|
replayFromEventId: 41,
|
|
updatedAt: "2026-04-08T00:00:00.000Z",
|
|
},
|
|
};
|
|
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("partial text");
|
|
expect(result.current.streamingThinking).toBe("partial thinking");
|
|
expect(result.current.streamingToolCalls).toHaveLength(1);
|
|
});
|
|
|
|
expect(mockAttachChatStream).toHaveBeenCalledWith(
|
|
"session-001",
|
|
expect.any(Object),
|
|
"proj-123",
|
|
{ lastEventId: 41 },
|
|
);
|
|
});
|
|
|
|
it("FN-6632 preserves chunks across selectSession recovery and repeated reattach", async () => {
|
|
const generatingSession = {
|
|
...makeSession({ id: "session-001", agentId: "agent-001", title: "Generating" }),
|
|
isGenerating: true,
|
|
inFlightGeneration: {
|
|
status: "generating" as const,
|
|
streamingText: "Hello ",
|
|
streamingThinking: "plan ",
|
|
toolCalls: [],
|
|
replayFromEventId: 5,
|
|
updatedAt: "2026-04-08T00:00:00.000Z",
|
|
},
|
|
};
|
|
const otherSession = makeSession({ id: "session-002", agentId: "agent-002", title: "Other" });
|
|
const handlers: StreamAppendHandlers[] = [];
|
|
const closeFirstStream = vi.fn();
|
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [generatingSession, otherSession] });
|
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
|
mockAttachChatStream.mockImplementation((_sessionId, nextHandlers) => {
|
|
handlers.push(nextHandlers);
|
|
return {
|
|
close: handlers.length === 1 ? closeFirstStream : 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.streamingText).toBe("Hello ");
|
|
expect(handlers).toHaveLength(1);
|
|
});
|
|
|
|
vi.useFakeTimers();
|
|
act(() => {
|
|
handlers[0]?.onText("world");
|
|
});
|
|
act(() => {
|
|
vi.advanceTimersToNextTimer();
|
|
});
|
|
expect(result.current.streamingText).toBe("Hello world");
|
|
vi.useRealTimers();
|
|
|
|
act(() => {
|
|
result.current.selectSession("session-002");
|
|
});
|
|
expect(closeFirstStream).toHaveBeenCalledTimes(1);
|
|
|
|
act(() => {
|
|
result.current.selectSession("session-001", {
|
|
...generatingSession,
|
|
inFlightGeneration: {
|
|
...generatingSession.inFlightGeneration,
|
|
streamingText: "Hello world",
|
|
streamingThinking: "plan next ",
|
|
replayFromEventId: 6,
|
|
},
|
|
});
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.streamingText).toBe("Hello world");
|
|
expect(handlers).toHaveLength(2);
|
|
});
|
|
|
|
vi.useFakeTimers();
|
|
act(() => {
|
|
handlers[1]?.onText("!");
|
|
handlers[1]?.onThinking("step");
|
|
});
|
|
act(() => {
|
|
vi.advanceTimersToNextTimer();
|
|
vi.advanceTimersToNextTimer();
|
|
});
|
|
|
|
expect(result.current.isStreaming).toBe(true);
|
|
expect(result.current.streamingText).toBe("Hello world!");
|
|
expect(result.current.streamingThinking).toBe("plan next step");
|
|
expect(mockAttachChatStream).toHaveBeenLastCalledWith(
|
|
"session-001",
|
|
expect.any(Object),
|
|
"proj-123",
|
|
{ lastEventId: 6 },
|
|
);
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
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 attach stream completes", async () => {
|
|
const session = { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: true };
|
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
|
mockFetchChatMessages.mockResolvedValue({
|
|
messages: [
|
|
makeMessage({
|
|
id: "msg-assistant-001",
|
|
sessionId: "session-001",
|
|
role: "assistant",
|
|
content: "Generated response",
|
|
}),
|
|
],
|
|
});
|
|
mockAttachChatStream.mockImplementation((_sessionId, handlers) => {
|
|
setTimeout(() => handlers.onDone?.({ messageId: "msg-assistant-001" }), 0);
|
|
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.isStreaming).toBe(false);
|
|
expect(result.current.streamingText).toBe("");
|
|
expect(result.current.messages.some((m) => m.id === "msg-assistant-001")).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
});
|