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>
193 lines
7.2 KiB
TypeScript
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);
|
|
});
|
|
});
|