nexus/server/src/__tests__/33-assistant-handoff.test.ts
Nexus Dev 222d00c57f feat(33-03): real AI streaming with memory injection + assistant handoff
Replace streamEcho with Puter proxy AI call, inject memory facts as
system message, append memory after each turn. Assistant-to-PM handoff
creates new conversation with context summary.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 03:55:49 +00:00

193 lines
7.2 KiB
TypeScript

// [nexus] Tests for assistant handoff route (Plan 33-03)
import { describe, it, expect, vi, beforeEach } from "vitest";
// ---------------------------------------------------------------------------
// Mock chatService so we can track calls without a real DB
// ---------------------------------------------------------------------------
const mockGetConversation = vi.fn();
const mockListMessages = vi.fn();
const mockCreateConversation = vi.fn();
const mockAddSystemMessage = vi.fn();
vi.mock("../services/chat.js", () => ({
chatService: () => ({
getConversation: mockGetConversation,
listMessages: mockListMessages,
createConversation: mockCreateConversation,
addSystemMessage: mockAddSystemMessage,
}),
}));
// Mock authz — assertBoard is a no-op in tests
vi.mock("../routes/authz.js", () => ({
assertBoard: vi.fn(),
}));
// ---------------------------------------------------------------------------
// Import the route factory under test
// ---------------------------------------------------------------------------
const { buildHandoffSummary, assistantHandoffRoutes } = await import("../routes/assistant-handoff.js");
// ---------------------------------------------------------------------------
// Unit tests for buildHandoffSummary (pure function)
// ---------------------------------------------------------------------------
describe("buildHandoffSummary", () => {
it("concatenates user messages into a summary", () => {
const messages = [
{ role: "user", content: "Hello world" },
{ role: "assistant", content: "Hi there" },
{ role: "user", content: "Can you build a login page?" },
];
const summary = buildHandoffSummary(messages as Array<{ role: string; content: string }>);
expect(summary).toContain("Hello world");
expect(summary).toContain("Can you build a login page?");
expect(summary).not.toContain("Hi there"); // assistant message excluded
});
it("excludes non-user messages (assistant, system)", () => {
const messages = [
{ role: "system", content: "You are a helpful assistant" },
{ role: "user", content: "Build me a dashboard" },
{ role: "assistant", content: "Sure!" },
];
const summary = buildHandoffSummary(messages as Array<{ role: string; content: string }>);
expect(summary).toContain("Build me a dashboard");
expect(summary).not.toContain("You are a helpful assistant");
expect(summary).not.toContain("Sure!");
});
it("caps summary at 1500 chars", () => {
const longContent = "A".repeat(600);
const messages = [
{ role: "user", content: longContent },
{ role: "user", content: longContent },
{ role: "user", content: longContent },
];
const summary = buildHandoffSummary(messages as Array<{ role: string; content: string }>);
expect(summary.length).toBeLessThanOrEqual(1500);
});
it("returns empty string if no user messages", () => {
const messages = [
{ role: "assistant", content: "Hello!" },
{ role: "system", content: "Context here" },
];
const summary = buildHandoffSummary(messages as Array<{ role: string; content: string }>);
expect(summary).toBe("");
});
});
// ---------------------------------------------------------------------------
// Integration-style tests for the route handler
// ---------------------------------------------------------------------------
describe("POST /conversations/:id/assistant-handoff", () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetConversation.mockResolvedValue({
id: "conv-123",
companyId: "company-abc",
agentId: null,
title: "Test conversation",
});
mockListMessages.mockResolvedValue({
items: [
{ role: "user", content: "I want to build a todo app" },
{ role: "assistant", content: "Great idea! Let me help." },
{ role: "user", content: "It should have drag-and-drop" },
],
hasMore: false,
nextCursor: null,
});
mockCreateConversation.mockResolvedValue({
id: "new-conv-999",
companyId: "company-abc",
title: "Project from assistant chat",
agentId: null,
});
mockAddSystemMessage.mockResolvedValue({
id: "msg-handoff-1",
role: "system",
content: "summary",
messageType: "handoff_context",
});
});
it("creates a new conversation with handoff_context system message", async () => {
const router = assistantHandoffRoutes({} as any);
// Find the POST handler for /conversations/:id/assistant-handoff
const layer = (router as any).stack.find(
(l: any) => l.route?.path === "/conversations/:id/assistant-handoff" && l.route?.methods?.post,
);
expect(layer).toBeDefined();
const handler = layer.route.stack[0].handle;
const req = { params: { id: "conv-123" }, actor: { type: "board" } } as any;
const res = { json: vi.fn(), status: vi.fn().mockReturnThis() } as any;
await handler(req, res);
expect(mockGetConversation).toHaveBeenCalledWith("conv-123");
expect(mockListMessages).toHaveBeenCalledWith("conv-123", { limit: 20 });
expect(mockCreateConversation).toHaveBeenCalledWith(
"company-abc",
expect.objectContaining({ title: "Project from assistant chat" }),
);
expect(mockAddSystemMessage).toHaveBeenCalledWith(
"new-conv-999",
expect.objectContaining({ messageType: "handoff_context" }),
);
expect(res.json).toHaveBeenCalledWith({ targetConversationId: "new-conv-999" });
});
it("builds summary from user messages only", async () => {
const router = assistantHandoffRoutes({} as any);
const layer = (router as any).stack.find(
(l: any) => l.route?.path === "/conversations/:id/assistant-handoff" && l.route?.methods?.post,
);
const handler = layer.route.stack[0].handle;
const req = { params: { id: "conv-123" }, actor: { type: "board" } } as any;
const res = { json: vi.fn(), status: vi.fn().mockReturnThis() } as any;
await handler(req, res);
const callArgs = mockAddSystemMessage.mock.calls[0];
const systemMessageContent = callArgs[1].content;
expect(systemMessageContent).toContain("I want to build a todo app");
expect(systemMessageContent).toContain("It should have drag-and-drop");
expect(systemMessageContent).not.toContain("Great idea! Let me help.");
});
it("caps summary content at 1500 chars in the system message", async () => {
const longContent = "X".repeat(600);
mockListMessages.mockResolvedValue({
items: [
{ role: "user", content: longContent },
{ role: "user", content: longContent },
{ role: "user", content: longContent },
],
hasMore: false,
nextCursor: null,
});
const router = assistantHandoffRoutes({} as any);
const layer = (router as any).stack.find(
(l: any) => l.route?.path === "/conversations/:id/assistant-handoff" && l.route?.methods?.post,
);
const handler = layer.route.stack[0].handle;
const req = { params: { id: "conv-123" }, actor: { type: "board" } } as any;
const res = { json: vi.fn(), status: vi.fn().mockReturnThis() } as any;
await handler(req, res);
const callArgs = mockAddSystemMessage.mock.calls[0];
expect(callArgs[1].content.length).toBeLessThanOrEqual(1500);
});
});