import { describe, it, expect, vi, beforeAll, beforeEach } from "vitest"; import { renderHook, act, waitFor } from "@testing-library/react"; import { AssistantProvider, useAssistantContext } from "../AssistantContext"; import { AssistantService } from "../../data/AssistantService"; import { AssistantMessageService } from "../../../assistant-message/data/AssistantMessageService"; import { useSocketContext } from "../../../../contexts/SocketContext"; import type { JsonApiHydratedDataInterface } from "../../../../core"; import { ModuleRegistry } from "../../../../core/registry/ModuleRegistry"; import { DataClassRegistry } from "../../../../core/registry/DataClassRegistry"; import { AssistantMessage } from "../../../assistant-message/data/AssistantMessage"; import { Assistant } from "../../data/Assistant"; vi.mock("../../../../contexts/SocketContext", () => ({ useSocketContext: vi.fn(() => ({ socket: null, isConnected: false })), })); function wrapper({ children }: { children: React.ReactNode }) { return {children}; } function buildAssistantStub({ id, title = "Stub", engine }: { id: string; title?: string; engine?: string }) { return { id, title, engine, messageCount: 0, type: "assistants", createdAt: new Date(), updatedAt: new Date(), } as any; } function buildAssistantDehydrated({ id, title = "Stub", engine, }: { id: string; title?: string; engine?: string; }): JsonApiHydratedDataInterface { return { jsonApi: { type: "assistants", id, attributes: { title, messageCount: 0, ...(engine !== undefined ? { engine } : {}) }, }, included: [], }; } function buildMessageStub({ role, content = "hi" }: { role: "user" | "assistant"; content?: string }) { return { role, content, position: 0, type: "assistant-messages", id: Math.random().toString(36).slice(2) } as any; } describe("AssistantContext", () => { beforeAll(() => { const assistantMessageModule = { name: "assistant-messages", model: AssistantMessage } as any; const assistantModule = { name: "assistants", model: Assistant } as any; ModuleRegistry.register("AssistantMessage", assistantMessageModule); ModuleRegistry.register("Assistant", assistantModule); DataClassRegistry.registerObjectClass(assistantMessageModule, AssistantMessage); DataClassRegistry.registerObjectClass(assistantModule, Assistant); }); beforeEach(() => { vi.mocked(useSocketContext).mockReturnValue({ socket: null, isConnected: false } as any); AssistantService.findMany = vi.fn().mockResolvedValue([]); }); it("initial state: no assistant, empty messages, not sending", () => { const { result } = renderHook(() => useAssistantContext(), { wrapper }); expect(result.current.assistant).toBeUndefined(); expect(result.current.messages).toEqual([]); expect(result.current.sending).toBe(false); }); it("sendMessage with no assistant: creates one and swaps URL via replaceState", async () => { const replaceState = vi.spyOn(window.history, "replaceState").mockImplementation(() => {}); const created = buildAssistantStub({ id: "a-1", title: "Test" }); const userMsg = buildMessageStub({ role: "user" }); const assistantMsg = buildMessageStub({ role: "assistant" }); AssistantService.create = vi.fn().mockResolvedValue(created); AssistantMessageService.findByAssistant = vi.fn().mockResolvedValue([userMsg, assistantMsg]); const { result } = renderHook(() => useAssistantContext(), { wrapper: ({ children }) => {children}, }); await act(async () => { await result.current.sendMessage("first question"); }); expect(AssistantService.create).toHaveBeenCalledWith({ firstMessage: "first question" }); expect(AssistantMessageService.findByAssistant).toHaveBeenCalledWith({ assistantId: "a-1" }); expect(result.current.assistant?.id).toBe("a-1"); expect(result.current.messages).toHaveLength(2); expect(replaceState).toHaveBeenCalledWith(null, "", "/assistants/a-1"); }); it("sendMessage with existing assistant: appends [user, assistant]", async () => { const existing = buildAssistantDehydrated({ id: "a-2", title: "Existing" }); AssistantService.appendMessage = vi .fn() .mockResolvedValue([ buildMessageStub({ role: "user", content: "follow-up" }), buildMessageStub({ role: "assistant", content: "reply" }), ]); const { result } = renderHook(() => useAssistantContext(), { wrapper: ({ children }) => {children}, }); await act(async () => { await result.current.sendMessage("follow-up"); }); expect(AssistantService.appendMessage).toHaveBeenCalledWith({ assistantId: "a-2", content: "follow-up" }); expect(result.current.messages.map((m) => m.content)).toEqual(["follow-up", "reply"]); }); it("selectThread loads messages and replaces URL", async () => { const target = buildAssistantStub({ id: "a-3", title: "Target" }); AssistantService.findOne = vi.fn().mockResolvedValue(target); AssistantMessageService.findByAssistant = vi.fn().mockResolvedValue([buildMessageStub({ role: "user" })]); const replaceState = vi.spyOn(window.history, "replaceState").mockImplementation(() => {}); const { result } = renderHook(() => useAssistantContext(), { wrapper: ({ children }) => {children}, }); await act(async () => { await result.current.selectThread("a-3"); }); expect(AssistantService.findOne).toHaveBeenCalledWith({ id: "a-3" }); expect(AssistantMessageService.findByAssistant).toHaveBeenCalledWith({ assistantId: "a-3" }); expect(result.current.assistant?.id).toBe("a-3"); expect(result.current.messages).toHaveLength(1); expect(replaceState).toHaveBeenCalledWith(null, "", "/assistants/a-3"); }); it("selectThread on an operator thread enables operatorMode and routes follow-ups to the operator endpoint", async () => { const target = buildAssistantStub({ id: "a-op", title: "Operator", engine: "operator" }); AssistantService.findOne = vi.fn().mockResolvedValue(target); AssistantMessageService.findByAssistant = vi.fn().mockResolvedValue([]); AssistantService.appendMessage = vi.fn().mockResolvedValue([]); AssistantService.appendMessageOperator = vi.fn().mockResolvedValue([]); vi.spyOn(window.history, "replaceState").mockImplementation(() => {}); const { result } = renderHook(() => useAssistantContext(), { wrapper: ({ children }) => {children}, }); await act(async () => { await result.current.selectThread("a-op"); }); expect(result.current.operatorMode).toBe(true); await act(async () => { await result.current.sendMessage("continue"); }); expect(AssistantService.appendMessageOperator).toHaveBeenCalledWith({ assistantId: "a-op", content: "continue" }); expect(AssistantService.appendMessage).not.toHaveBeenCalled(); }); it("selectThread on a responder thread (engine absent) resets operatorMode and keeps the responder endpoint", async () => { const operatorThread = buildAssistantStub({ id: "a-op", engine: "operator" }); const responderThread = buildAssistantStub({ id: "a-resp" }); AssistantService.findOne = vi.fn().mockResolvedValueOnce(operatorThread).mockResolvedValueOnce(responderThread); AssistantMessageService.findByAssistant = vi.fn().mockResolvedValue([]); AssistantService.appendMessage = vi.fn().mockResolvedValue([]); AssistantService.appendMessageOperator = vi.fn().mockResolvedValue([]); vi.spyOn(window.history, "replaceState").mockImplementation(() => {}); const { result } = renderHook(() => useAssistantContext(), { wrapper: ({ children }) => {children}, }); await act(async () => { await result.current.selectThread("a-op"); }); expect(result.current.operatorMode).toBe(true); await act(async () => { await result.current.selectThread("a-resp"); }); expect(result.current.operatorMode).toBe(false); await act(async () => { await result.current.sendMessage("hello"); }); expect(AssistantService.appendMessage).toHaveBeenCalledWith( expect.objectContaining({ assistantId: "a-resp", content: "hello" }), ); expect(AssistantService.appendMessageOperator).not.toHaveBeenCalled(); }); it("hydrating an operator thread (reload) initialises operatorMode and routes sends to the operator endpoint", async () => { const existing = buildAssistantDehydrated({ id: "a-op", title: "Operator", engine: "operator" }); AssistantService.appendMessage = vi.fn().mockResolvedValue([]); AssistantService.appendMessageOperator = vi.fn().mockResolvedValue([]); const { result } = renderHook(() => useAssistantContext(), { wrapper: ({ children }) => {children}, }); expect(result.current.operatorMode).toBe(true); await act(async () => { await result.current.sendMessage("after reload"); }); expect(AssistantService.appendMessageOperator).toHaveBeenCalledWith({ assistantId: "a-op", content: "after reload", }); expect(AssistantService.appendMessage).not.toHaveBeenCalled(); }); it("hydrating a responder thread (engine absent) keeps operatorMode off", () => { const existing = buildAssistantDehydrated({ id: "a-resp", title: "Responder" }); const { result } = renderHook(() => useAssistantContext(), { wrapper: ({ children }) => {children}, }); expect(result.current.operatorMode).toBe(false); }); it("renameThread calls the service + updates active assistant title", async () => { AssistantService.rename = vi.fn().mockResolvedValue(undefined); const active = buildAssistantDehydrated({ id: "a-1", title: "Old" }); const { result } = renderHook(() => useAssistantContext(), { wrapper: ({ children }) => {children}, }); await act(async () => { await result.current.renameThread("a-1", "New"); }); expect(AssistantService.rename).toHaveBeenCalledWith({ id: "a-1", title: "New" }); expect(result.current.assistant?.title).toBe("New"); }); it("deleteThread calls the service + clears active if deleted was active", async () => { AssistantService.delete = vi.fn().mockResolvedValue(undefined); const active = buildAssistantDehydrated({ id: "a-1", title: "A" }); const { result } = renderHook(() => useAssistantContext(), { wrapper: ({ children }) => {children}, }); await act(async () => { await result.current.deleteThread("a-1"); }); expect(AssistantService.delete).toHaveBeenCalledWith({ id: "a-1" }); expect(result.current.assistant).toBeUndefined(); expect(result.current.messages).toEqual([]); }); it("startNew clears the active assistant, messages, failed ids, and resets URL to /assistants", async () => { const replaceState = vi.spyOn(window.history, "replaceState").mockImplementation(() => {}); const existing = buildAssistantDehydrated({ id: "a-9", title: "Hydrated" }); const msg = buildMessageStub({ role: "user", content: "hi" }); const { result } = renderHook(() => useAssistantContext(), { wrapper: ({ children }) => ( {children} ), }); // Seed a failed message id by forcing an append failure. AssistantService.appendMessage = vi.fn().mockRejectedValue(new Error("boom")); await act(async () => { await result.current.sendMessage("will fail").catch(() => {}); }); expect(result.current.failedMessageIds.size).toBeGreaterThan(0); expect(result.current.assistant?.id).toBe("a-9"); expect(result.current.messages.length).toBeGreaterThan(0); act(() => { result.current.startNew(); }); expect(result.current.assistant).toBeUndefined(); expect(result.current.messages).toEqual([]); expect(result.current.failedMessageIds.size).toBe(0); expect(replaceState).toHaveBeenCalledWith(null, "", "/assistants"); }); it("loads threads on mount", async () => { const t1 = buildAssistantStub({ id: "t1", title: "T1" }); AssistantService.findMany = vi.fn().mockResolvedValue([t1]); const { result } = renderHook(() => useAssistantContext(), { wrapper: ({ children }) => {children}, }); await waitFor(() => expect(result.current.threads).toEqual([t1])); }); it("subscribes to assistant:status while sending and unsubscribes after", async () => { const handlers: Record void> = {}; const socket = { on: vi.fn((evt: string, h: any) => { handlers[evt] = h; }), off: vi.fn((evt: string) => { delete handlers[evt]; }), }; vi.mocked(useSocketContext).mockReturnValue({ socket, isConnected: true } as any); const created = buildAssistantStub({ id: "a-4", title: "Stub" }); AssistantService.create = vi .fn() .mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve(created), 20))); AssistantMessageService.findByAssistant = vi.fn().mockResolvedValue([]); const { result } = renderHook(() => useAssistantContext(), { wrapper: ({ children }) => {children}, }); let sendPromise: Promise; await act(async () => { sendPromise = result.current.sendMessage("go"); // allow subscription to register before the create resolves await Promise.resolve(); }); await waitFor(() => expect(socket.on).toHaveBeenCalledWith("assistant:status", expect.any(Function))); handlers["assistant:status"]?.({ assistantId: "a-4", status: "Searching accounts", at: new Date().toISOString() }); await act(async () => { await sendPromise!; }); expect(socket.off).toHaveBeenCalledWith("assistant:status", expect.any(Function)); }); it("exposes an empty failedMessageIds set and a retrySend callback", () => { const { result } = renderHook(() => useAssistantContext(), { wrapper }); expect(result.current.failedMessageIds).toBeInstanceOf(Set); expect(result.current.failedMessageIds.size).toBe(0); expect(typeof result.current.retrySend).toBe("function"); }); it("sendMessage (existing assistant): shows the user bubble synchronously before the server responds", async () => { const existing = buildAssistantDehydrated({ id: "a-2", title: "Existing" }); let resolveAppend!: (value: any) => void; AssistantService.appendMessage = vi.fn().mockImplementation( () => new Promise((resolve) => { resolveAppend = resolve; }), ); const { result } = renderHook(() => useAssistantContext(), { wrapper: ({ children }) => {children}, }); let sendPromise: Promise; act(() => { sendPromise = result.current.sendMessage("follow-up"); }); // Before the server responds, the optimistic user bubble must be visible. expect(result.current.messages.map((m) => m.content)).toContain("follow-up"); expect(result.current.messages.some((m) => m.isOptimistic && m.role === "user")).toBe(true); expect(result.current.sending).toBe(true); await act(async () => { resolveAppend([ buildMessageStub({ role: "user", content: "follow-up" }), buildMessageStub({ role: "assistant", content: "reply" }), ]); await sendPromise!; }); // After reconciliation, no tmp-* remains, and server messages are appended. expect(result.current.messages.some((m) => m.isOptimistic)).toBe(false); expect(result.current.messages.map((m) => m.content)).toEqual(["follow-up", "reply"]); expect(result.current.sending).toBe(false); }); it("sendMessage (no assistant): shows the user bubble synchronously before create resolves", async () => { const replaceState = vi.spyOn(window.history, "replaceState").mockImplementation(() => {}); const created = buildAssistantStub({ id: "a-1", title: "Test" }); const userMsg = buildMessageStub({ role: "user", content: "first question" }); const assistantMsg = buildMessageStub({ role: "assistant", content: "answer" }); let resolveCreate!: (value: any) => void; AssistantService.create = vi.fn().mockImplementation( () => new Promise((resolve) => { resolveCreate = resolve; }), ); AssistantMessageService.findByAssistant = vi.fn().mockResolvedValue([userMsg, assistantMsg]); const { result } = renderHook(() => useAssistantContext(), { wrapper: ({ children }) => {children}, }); let sendPromise: Promise; act(() => { sendPromise = result.current.sendMessage("first question"); }); // Before the server responds: thread has exactly the optimistic user bubble. expect(result.current.messages).toHaveLength(1); expect(result.current.messages[0].isOptimistic).toBe(true); expect(result.current.messages[0].content).toBe("first question"); expect(result.current.assistant).toBeUndefined(); expect(result.current.sending).toBe(true); await act(async () => { resolveCreate(created); await sendPromise!; }); // After reconciliation: assistant set, URL replaced, server messages only. expect(result.current.assistant?.id).toBe("a-1"); expect(result.current.messages.some((m) => m.isOptimistic)).toBe(false); expect(result.current.messages).toHaveLength(2); expect(replaceState).toHaveBeenCalledWith(null, "", "/assistants/a-1"); }); it("forwards howToMode to AssistantService.create on first send", async () => { const replaceState = vi.spyOn(window.history, "replaceState").mockImplementation(() => {}); const created = buildAssistantStub({ id: "a-1" }); AssistantService.create = vi.fn().mockResolvedValue(created); AssistantMessageService.findByAssistant = vi.fn().mockResolvedValue([]); const { result } = renderHook(() => useAssistantContext(), { wrapper: ({ children }) => {children}, }); await act(async () => { await result.current.sendMessage("hi", { howToMode: true }); }); expect(AssistantService.create).toHaveBeenCalledWith( expect.objectContaining({ firstMessage: "hi", howToMode: true }), ); replaceState.mockRestore(); }); it("forwards howToMode to AssistantService.appendMessage on follow-up send", async () => { const existing = buildAssistantDehydrated({ id: "a-2", title: "Existing" }); AssistantService.appendMessage = vi.fn().mockResolvedValue([]); const { result } = renderHook(() => useAssistantContext(), { wrapper: ({ children }) => {children}, }); await act(async () => { await result.current.sendMessage("follow up", { howToMode: true, limitToHowToId: "ht-1" }); }); expect(AssistantService.appendMessage).toHaveBeenCalledWith( expect.objectContaining({ assistantId: "a-2", content: "follow up", howToMode: true, limitToHowToId: "ht-1", }), ); }); it("calls service without opts when called with content only (regression)", async () => { const replaceState = vi.spyOn(window.history, "replaceState").mockImplementation(() => {}); const created = buildAssistantStub({ id: "a-1" }); AssistantService.create = vi.fn().mockResolvedValue(created); AssistantMessageService.findByAssistant = vi.fn().mockResolvedValue([]); const { result } = renderHook(() => useAssistantContext(), { wrapper: ({ children }) => {children}, }); await act(async () => { await result.current.sendMessage("hi"); }); expect(AssistantService.create).toHaveBeenCalledWith(expect.objectContaining({ firstMessage: "hi" })); const call = (AssistantService.create as ReturnType).mock.calls[0][0]; expect(call.howToMode).toBeUndefined(); expect(call.limitToHowToId).toBeUndefined(); replaceState.mockRestore(); }); it("sendMessage failure: optimistic message stays and its id lands in failedMessageIds", async () => { const existing = buildAssistantDehydrated({ id: "a-x", title: "Ex" }); AssistantService.appendMessage = vi.fn().mockRejectedValue(new Error("boom")); const { result } = renderHook(() => useAssistantContext(), { wrapper: ({ children }) => {children}, }); await act(async () => { await result.current.sendMessage("oops").catch(() => {}); }); const optimistic = result.current.messages.find((m) => m.isOptimistic); expect(optimistic).toBeDefined(); expect(optimistic!.content).toBe("oops"); expect(result.current.failedMessageIds.has(optimistic!.id)).toBe(true); expect(result.current.sending).toBe(false); }); it("retrySend: clears the failed id, removes the old tmp message, and resends the content", async () => { const existing = buildAssistantDehydrated({ id: "a-y", title: "Ey" }); const appendMock = vi .fn() .mockRejectedValueOnce(new Error("fail-1")) .mockResolvedValueOnce([ buildMessageStub({ role: "user", content: "retry-me" }), buildMessageStub({ role: "assistant", content: "ok" }), ]); AssistantService.appendMessage = appendMock; const { result } = renderHook(() => useAssistantContext(), { wrapper: ({ children }) => {children}, }); await act(async () => { await result.current.sendMessage("retry-me").catch(() => {}); }); const failedId = [...result.current.failedMessageIds][0]; expect(failedId).toBeDefined(); await act(async () => { await result.current.retrySend(failedId!); }); expect(result.current.failedMessageIds.has(failedId!)).toBe(false); expect(result.current.messages.some((m) => m.isOptimistic)).toBe(false); expect(result.current.messages.map((m) => m.content)).toEqual(["retry-me", "ok"]); expect(appendMock).toHaveBeenCalledTimes(2); }); it("loads only threads bound to the scope", async () => { const findMany = vi.fn().mockResolvedValue([]); AssistantService.findMany = findMany; renderHook(() => useAssistantContext(), { wrapper: ({ children }) => ( {children} ), }); await waitFor(() => expect(findMany).toHaveBeenCalledWith({ boundType: "campaigns", boundId: "camp-1" })); }); it("creates the first thread bound to the scope", async () => { const replaceState = vi.spyOn(window.history, "replaceState").mockImplementation(() => {}); const create = vi.fn().mockResolvedValue(buildAssistantStub({ id: "a1" })); AssistantService.create = create; AssistantMessageService.findByAssistant = vi.fn().mockResolvedValue([]); const { result } = renderHook(() => useAssistantContext(), { wrapper: ({ children }) => ( {children} ), }); await act(async () => { await result.current.sendMessage("hello"); }); expect(create).toHaveBeenCalledWith(expect.objectContaining({ boundContent: { type: "campaigns", id: "camp-1" } })); replaceState.mockRestore(); }); it("uses threadUrl for history replacement", () => { const replaceState = vi.spyOn(window.history, "replaceState").mockImplementation(() => {}); const { result } = renderHook(() => useAssistantContext(), { wrapper: ({ children }) => ( (id ? `/campaigns/camp-1/assistant/${id}` : "/campaigns/camp-1/assistant")} > {children} ), }); act(() => { result.current.startNew(); }); expect(replaceState).toHaveBeenCalledWith(null, "", "/campaigns/camp-1/assistant"); replaceState.mockRestore(); }); it("forwards contentBlocks from the composer to create and appendMessage", async () => { const replaceState = vi.spyOn(window.history, "replaceState").mockImplementation(() => {}); const blocks = [{ type: "paragraph", content: [] }]; AssistantService.create = vi.fn().mockResolvedValue(buildAssistantStub({ id: "a-blocks" })); AssistantMessageService.findByAssistant = vi.fn().mockResolvedValue([]); const { result } = renderHook(() => useAssistantContext(), { wrapper: ({ children }) => {children}, }); await act(async () => { await result.current.sendMessage("hello", { contentBlocks: blocks }); }); expect(AssistantService.create).toHaveBeenCalledWith(expect.objectContaining({ contentBlocks: blocks })); const existing = buildAssistantDehydrated({ id: "a-blocks-2" }); AssistantService.appendMessage = vi.fn().mockResolvedValue([]); const { result: followUp } = renderHook(() => useAssistantContext(), { wrapper: ({ children }) => {children}, }); await act(async () => { await followUp.current.sendMessage("more", { contentBlocks: blocks }); }); expect(AssistantService.appendMessage).toHaveBeenCalledWith(expect.objectContaining({ contentBlocks: blocks })); replaceState.mockRestore(); }); });