From d3dc1b73bdc23b11779742587f6eb6c9173af71b Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Fri, 3 Apr 2026 22:15:39 +0000 Subject: [PATCH] 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) --- .../__tests__/33-assistant-handoff.test.ts | 193 ++++++++++++++++++ server/src/app.ts | 2 + server/src/routes/assistant-handoff.ts | 57 ++++++ server/src/routes/chat.ts | 67 +++++- ui/src/api/chat.ts | 7 + ui/src/pages/PersonalAssistant.tsx | 48 +++-- 6 files changed, 356 insertions(+), 18 deletions(-) create mode 100644 server/src/__tests__/33-assistant-handoff.test.ts create mode 100644 server/src/routes/assistant-handoff.ts diff --git a/server/src/__tests__/33-assistant-handoff.test.ts b/server/src/__tests__/33-assistant-handoff.test.ts new file mode 100644 index 00000000..938bfb47 --- /dev/null +++ b/server/src/__tests__/33-assistant-handoff.test.ts @@ -0,0 +1,193 @@ +// [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); + }); +}); diff --git a/server/src/app.ts b/server/src/app.ts index c9c0a43c..a0b9d188 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -29,6 +29,7 @@ import { llmRoutes } from "./routes/llms.js"; import { assetRoutes } from "./routes/assets.js"; import { accessRoutes } from "./routes/access.js"; import { assistantMemoryRoutes } from "./routes/assistant-memory.js"; +import { assistantHandoffRoutes } from "./routes/assistant-handoff.js"; import { pluginRoutes } from "./routes/plugins.js"; import { pluginUiStaticRoutes } from "./routes/plugin-ui-static.js"; import { applyUiBranding } from "./ui-branding.js"; @@ -157,6 +158,7 @@ export async function createApp( api.use(sidebarBadgeRoutes(db)); api.use(instanceSettingsRoutes(db)); api.use(assistantMemoryRoutes()); + api.use(assistantHandoffRoutes(db)); const hostServicesDisposers = new Map void>(); const workerManager = createPluginWorkerManager(); const pluginRegistry = pluginRegistryService(db); diff --git a/server/src/routes/assistant-handoff.ts b/server/src/routes/assistant-handoff.ts new file mode 100644 index 00000000..98b130d1 --- /dev/null +++ b/server/src/routes/assistant-handoff.ts @@ -0,0 +1,57 @@ +// [nexus] Assistant-to-PM handoff route (Plan 33-03) +import { Router } from "express"; +import type { Db } from "@paperclipai/db"; +import { assertBoard } from "./authz.js"; +import { chatService } from "../services/chat.js"; + +/** + * Build a summary string from user messages only, capped at 1500 chars. + * Exported for unit testing. + */ +export function buildHandoffSummary( + messages: Array<{ role: string; content: string }>, +): string { + const userMessages = messages + .filter((m) => m.role === "user") + .map((m) => m.content) + .join("\n"); + + return userMessages.slice(0, 1500); +} + +export function assistantHandoffRoutes(db: Db): Router { + const router = Router(); + const svc = chatService(db); + + // POST /api/conversations/:id/assistant-handoff + // Creates a new PM conversation with a handoff_context system message + // containing a summary of user messages from the source conversation. + router.post("/conversations/:id/assistant-handoff", async (req, res) => { + assertBoard(req); + + // 1. Get source conversation + const conversation = await svc.getConversation(req.params.id!); + + // 2. Fetch last 20 messages + const msgs = await svc.listMessages(req.params.id!, { limit: 20 }); + + // 3. Build summary from user messages only, capped at 1500 chars + const summary = buildHandoffSummary(msgs.items); + + // 4. Create new PM conversation (generic — PM agent wiring is out of scope here) + const newConv = await svc.createConversation(conversation.companyId, { + title: "Project from assistant chat", + }); + + // 5. Insert handoff_context system message + await svc.addSystemMessage(newConv.id, { + content: summary, + messageType: "handoff_context", + }); + + // 6. Return target conversation ID + res.json({ targetConversationId: newConv.id }); + }); + + return router; +} diff --git a/server/src/routes/chat.ts b/server/src/routes/chat.ts index 97748a94..73c7ca28 100644 --- a/server/src/routes/chat.ts +++ b/server/src/routes/chat.ts @@ -4,6 +4,9 @@ import { assertBoard, assertCompanyAccess } from "./authz.js"; import { chatService } from "../services/chat.js"; import { sendPushToAll } from "../services/pushService.js"; import { issueService } from "../services/issues.js"; +import { assistantMemoryService } from "../services/assistant-memory.js"; +import { nexusSettingsService } from "../services/nexus-settings.js"; +import { puterProxyService } from "../services/puter-proxy.js"; import { z } from "zod"; import { createConversationSchema, @@ -93,6 +96,49 @@ export function chatRoutes(db: Db): Router { return; } + // Resolve conversation and settings BEFORE flushing headers (Pitfall 3) + const conversation = await svc.getConversation(req.params.id!); + const settings = await nexusSettingsService().get(); + const isAssistant = settings.mode !== "project_builder"; + + // Load memory facts if in assistant mode + const memory = isAssistant + ? await assistantMemoryService().get(conversation.companyId) + : { facts: [] as string[], updatedAt: null }; + + // Try resolving puter token — fall back to echo if not configured + let puterTokenAvailable = false; + try { + await puterProxyService(db).resolveToken(conversation.companyId); + puterTokenAvailable = true; + } catch { + // No puter token — will fall back to echo stub + } + + // Build messages array for AI call + const recentMsgs = await svc.listMessages(req.params.id!, { limit: 20 }); + // listMessages returns newest-first; reverse for chronological order + const chronological = [...recentMsgs.items].reverse(); + + const messagesWithMemory: Array<{ role: string; content: string }> = []; + + // Inject memory as system message if applicable + if (isAssistant && memory.facts.length > 0) { + const memoryText = `[Memory from previous sessions]\n${memory.facts.map((f) => "- " + f).join("\n")}\n\nUse these facts to personalize your responses. Do not mention that you have a memory system unless asked.`; + const capped = memoryText.slice(0, 2000); + messagesWithMemory.push({ role: "system", content: capped }); + } + + // Add recent conversation history + for (const msg of chronological) { + if (msg.role === "user" || msg.role === "assistant") { + messagesWithMemory.push({ role: msg.role, content: msg.content }); + } + } + + // Add the new user message + messagesWithMemory.push({ role: "user", content }); + // Set SSE headers and flush BEFORE any generation (PERF-02) res.setHeader("Content-Type", "text/event-stream"); res.setHeader("Cache-Control", "no-cache"); @@ -106,10 +152,16 @@ export function chatRoutes(db: Db): Router { try { let fullContent = ""; - for await (const token of svc.streamEcho(content, abort.signal)) { + + // Choose stream source: real AI or echo fallback + const tokenStream = puterTokenAvailable + ? puterProxyService(db).chatStream(conversation.companyId, agentId || undefined, messagesWithMemory, undefined, abort.signal) + : svc.streamEcho(content, abort.signal); + + for await (const token of tokenStream) { if (!res.writable) break; fullContent += token; - res.write(`data: ${JSON.stringify({ token })}\n\n`); + res.write(`data: ${JSON.stringify({ type: "token", token })}\n\n`); } if (res.writable && !abort.signal.aborted) { const message = await svc.addMessage(req.params.id!, { @@ -117,19 +169,24 @@ export function chatRoutes(db: Db): Router { content: fullContent.trim(), agentId: agentId || undefined, }); - res.write(`data: ${JSON.stringify({ done: true, messageId: message.id, content: fullContent.trim() })}\n\n`); + res.write(`data: ${JSON.stringify({ type: "done", messageId: message.id, content: fullContent.trim() })}\n\n`); // Fire push notification for offline subscribers (PWA-06) - const conversation = await svc.getConversation(req.params.id!); sendPushToAll(db, conversation.companyId, { title: "New agent response", body: fullContent.trim().slice(0, 100), data: { url: `/chat/${conversation.id}` }, }).catch(() => {}); // non-blocking + + // Append a brief memory fact after each assistant turn (non-blocking) + if (isAssistant) { + const fact = `User asked about: ${content.slice(0, 100)}. Assistant topic: ${fullContent.slice(0, 100)}`; + assistantMemoryService().append(conversation.companyId, fact).catch(() => {}); + } } } catch (err) { if (res.writable && !abort.signal.aborted) { - res.write(`data: ${JSON.stringify({ error: "Stream error" })}\n\n`); + res.write(`data: ${JSON.stringify({ type: "error", error: "Stream error" })}\n\n`); } } finally { res.end(); diff --git a/ui/src/api/chat.ts b/ui/src/api/chat.ts index fa2cdd95..233742ac 100644 --- a/ui/src/api/chat.ts +++ b/ui/src/api/chat.ts @@ -209,6 +209,13 @@ export const chatApi = { ); }, + assistantHandoff(conversationId: string): Promise<{ targetConversationId: string }> { + return api.post<{ targetConversationId: string }>( + `/conversations/${conversationId}/assistant-handoff`, + {}, + ); + }, + exportConversation(conversationId: string, format: "markdown" | "json") { // Returns a download URL — use window.location.href to trigger return `/api/conversations/${conversationId}/export?format=${format}`; diff --git a/ui/src/pages/PersonalAssistant.tsx b/ui/src/pages/PersonalAssistant.tsx index f7603fa5..84018ebb 100644 --- a/ui/src/pages/PersonalAssistant.tsx +++ b/ui/src/pages/PersonalAssistant.tsx @@ -1,15 +1,16 @@ // [nexus] Personal Assistant page — full-page chat for Personal AI mode import { useState, useEffect, useRef, useCallback } from "react"; -import { Navigate, useParams } from "@/lib/router"; +import { Navigate, useParams, useNavigate } from "@/lib/router"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Bot, Send, Loader2, Plus, ArrowRight } from "lucide-react"; import { useNexusMode } from "../hooks/useNexusMode"; import { useCompany } from "../context/CompanyContext"; +import { useToast } from "../context/ToastContext"; import { chatApi } from "../api/chat"; import { Button } from "@/components/ui/button"; -import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import type { ChatConversationListItem, ChatMessage } from "@paperclipai/shared"; + // ─── Conversation list panel ───────────────────────────────────────────────── interface ConversationListProps { @@ -97,12 +98,15 @@ export function PersonalAssistant() { const { selectedCompany } = useCompany(); const { conversationId: routeConvId } = useParams<{ conversationId?: string }>(); const queryClient = useQueryClient(); + const navigate = useNavigate(); + const { pushToast } = useToast(); const [selectedConvId, setSelectedConvId] = useState(routeConvId ?? null); const [isCreating, setIsCreating] = useState(false); const [inputValue, setInputValue] = useState(""); const [streamingContent, setStreamingContent] = useState(null); const [isSending, setIsSending] = useState(false); + const [isHandingOff, setIsHandingOff] = useState(false); const messagesEndRef = useRef(null); const inputRef = useRef(null); const abortRef = useRef(null); @@ -215,6 +219,20 @@ export function PersonalAssistant() { } }, [inputValue, selectedConvId, isSending, queryClient, companyId]); + const handleHandoff = useCallback(async () => { + if (!selectedConvId || isHandingOff) return; + setIsHandingOff(true); + try { + await chatApi.assistantHandoff(selectedConvId); + pushToast({ title: "Conversation handed off to PM", tone: "positive" }); + navigate("/dashboard"); + } catch { + pushToast({ title: "Handoff failed", body: "Could not create project conversation.", tone: "critical" }); + } finally { + setIsHandingOff(false); + } + }, [selectedConvId, isHandingOff, navigate, pushToast]); + const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { @@ -257,17 +275,21 @@ export function PersonalAssistant() {

Personal Assistant

- - - - - - - Coming soon — will create a project from this conversation - + {/* Messages */}