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();
});
});