diff --git a/src/__tests__/unit/services/canvas-turn-enrichments.test.ts b/src/__tests__/unit/services/canvas-turn-enrichments.test.ts new file mode 100644 index 0000000000..17218ad7e2 --- /dev/null +++ b/src/__tests__/unit/services/canvas-turn-enrichments.test.ts @@ -0,0 +1,231 @@ +/** + * Unit tests for `emitConversationTitle` + * (`src/services/canvas-turn-enrichments.ts`). + * + * The seeded title (`generateTitle`) is a raw slice of the first user + * message. This enrichment replaces it once, after the first turn, on the + * cheap model. Coverage: + * - fires on a brand-new conversation and writes the title + * - no-op on a continuing conversation (never re-titles) + * - a model failure leaves the seeded title untouched + * - routes through Bifrost on the caller's `agentName` + */ + +import { describe, test, expect, vi, beforeEach } from "vitest"; + +// ─── Mocks ────────────────────────────────────────────────────────── + +vi.mock("@/lib/db", () => ({ + db: { sharedConversation: { update: vi.fn() } }, +})); + +vi.mock("ai", async (importOriginal) => ({ + ...(await importOriginal()), + generateObject: vi.fn(), +})); + +vi.mock("@/lib/ai/provider", () => ({ + getModel: vi.fn(() => "mock-model"), + getApiKeyForProvider: vi.fn(() => "mock-key"), +})); + +vi.mock("@/services/bifrost/orchestrator", () => ({ + getBifrostForLLM: vi.fn(async () => undefined), +})); + +vi.mock("@/lib/pusher", () => ({ + getWorkspaceChannelName: vi.fn(() => "chan"), + pusherServer: { trigger: vi.fn() }, + PUSHER_EVENTS: { FOLLOW_UP_QUESTIONS: "follow-up", PROVENANCE_DATA: "provenance" }, +})); + +vi.mock("@/lib/ai/concepts", () => ({ + swarmFetch: vi.fn(async () => ({ ok: true, json: async () => ({ concepts: [] }) })), +})); + +import type { ModelMessage } from "ai"; +import { generateObject } from "ai"; +import { db } from "@/lib/db"; +import { getModel } from "@/lib/ai/provider"; +import { getBifrostForLLM } from "@/services/bifrost/orchestrator"; +import { swarmFetch } from "@/lib/ai/concepts"; +import { pusherServer } from "@/lib/pusher"; +import { + emitConversationTitle, + runTurnEnrichments, +} from "@/services/canvas-turn-enrichments"; + +const genObject = generateObject as unknown as ReturnType; +const update = db.sharedConversation.update as ReturnType; +const model = getModel as ReturnType; +const bifrost = getBifrostForLLM as ReturnType; + +const MESSAGES: ModelMessage[] = [ + { + role: "user", + content: + "TypeError: Cannot read properties of undefined (reading 'slug')\n at getWorkspace (workspace.ts:41:12)\n at handler (route.ts:88:5)", + }, + { role: "assistant", content: "That's a missing workspace guard in the route." }, +]; + +function args(overrides: Record = {}) { + return { + conversationId: "conv-1", + isNewConversation: true, + messages: MESSAGES, + primarySlug: "acme", + primaryWorkspaceId: "ws-1", + primaryUserId: "user-1", + agentName: "canvas-agent" as const, + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + genObject.mockResolvedValue({ object: { title: "Missing workspace guard in route" } }); + bifrost.mockResolvedValue(undefined); +}); + +describe("emitConversationTitle", () => { + test("writes a model-written title on a brand-new conversation", async () => { + await emitConversationTitle(args()); + + expect(genObject).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledWith({ + where: { id: "conv-1" }, + data: { title: "Missing workspace guard in route" }, + }); + }); + + test("uses the cheap model, not the turn's default", async () => { + await emitConversationTitle(args()); + + // 4th positional arg of `getModel` is the model type. + expect(model.mock.calls[0][3]).toBe("haiku"); + }); + + test("routes through Bifrost under the caller's agentName", async () => { + await emitConversationTitle(args({ agentName: "chat-agent" })); + + expect(bifrost).toHaveBeenCalledWith( + { workspaceId: "ws-1", workspaceSlug: "acme", userId: "user-1" }, + { agentName: "chat-agent" }, + ); + }); + + test("no-op on a continuing conversation — never re-titles", async () => { + await emitConversationTitle(args({ isNewConversation: false })); + + expect(genObject).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + }); + + test("no-op when there is no conversation row", async () => { + await emitConversationTitle(args({ conversationId: null })); + + expect(genObject).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + }); + + test("a model failure leaves the seeded title untouched", async () => { + genObject.mockRejectedValue(new Error("overloaded")); + + await expect(emitConversationTitle(args())).resolves.toBeUndefined(); + expect(update).not.toHaveBeenCalled(); + }); + + test("an empty title is not written", async () => { + genObject.mockResolvedValue({ object: { title: " " } }); + + await emitConversationTitle(args()); + + expect(update).not.toHaveBeenCalled(); + }); + + test("a DB failure is swallowed", async () => { + update.mockRejectedValue(new Error("db down")); + + await expect(emitConversationTitle(args())).resolves.toBeUndefined(); + }); +}); + +/** + * The route runs these inside `after()`, and the route tests mock + * `next/server`'s `after` to drop the callback — so this suite is the only + * coverage the gating has. Asserted through observable side effects rather + * than by spying on same-module functions: + * title → `db.sharedConversation.update` + * follow-ups → `pusherServer.trigger` + * provenance → `swarmFetch` + */ +describe("runTurnEnrichments", () => { + const trigger = pusherServer.trigger as ReturnType; + const swarm = swarmFetch as ReturnType; + + function turnArgs(overrides: Record = {}) { + return { + skipEnrichments: false, + conversationId: "conv-1", + isNewConversation: true, + messages: MESSAGES, + conceptIds: ["concept-1"], + primarySlug: "acme", + primaryWorkspaceId: "ws-1", + primaryUserId: "user-1", + primarySwarmUrl: "https://swarm.test", + primarySwarmApiKey: "swarm-key", + agentName: "canvas-agent" as const, + ...overrides, + }; + } + + beforeEach(() => { + genObject.mockResolvedValue({ + object: { title: "Missing workspace guard in route", questions: ["a", "b", "c"] }, + }); + }); + + test("runs all three enrichments when nothing is skipped", async () => { + await runTurnEnrichments(turnArgs()); + + expect(update).toHaveBeenCalledTimes(1); + expect(trigger).toHaveBeenCalledWith("chan", "follow-up", expect.anything()); + expect(swarm).toHaveBeenCalledTimes(1); + }); + + /** + * Regression: the canvas sidebar — the surface titles matter most on — + * sends `skipEnrichments: true`. Gating the title on that flag made the + * feature dead on arrival while every direct unit test still passed. + */ + test("still titles when skipEnrichments is set", async () => { + await runTurnEnrichments(turnArgs({ skipEnrichments: true })); + + expect(update).toHaveBeenCalledTimes(1); + }); + + test("skips follow-ups and provenance when skipEnrichments is set", async () => { + await runTurnEnrichments(turnArgs({ skipEnrichments: true })); + + expect(trigger).not.toHaveBeenCalled(); + expect(swarm).not.toHaveBeenCalled(); + }); + + test("a title failure does not block the other enrichments", async () => { + update.mockRejectedValue(new Error("db down")); + + await runTurnEnrichments(turnArgs()); + + expect(trigger).toHaveBeenCalledWith("chan", "follow-up", expect.anything()); + expect(swarm).toHaveBeenCalledTimes(1); + }); + + test("no concepts learned means no provenance fetch", async () => { + await runTurnEnrichments(turnArgs({ conceptIds: [] })); + + expect(swarm).not.toHaveBeenCalled(); + expect(update).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/api/ask/quick/route.ts b/src/app/api/ask/quick/route.ts index 5093aab910..22b341ac68 100644 --- a/src/app/api/ask/quick/route.ts +++ b/src/app/api/ask/quick/route.ts @@ -40,10 +40,7 @@ import { fetchOrgCanvasConversationMessages, persistOrgCanvasPromptResolutions, } from "@/services/org-canvas-conversation"; -import { - emitFollowUpQuestions, - emitProvenance, -} from "@/services/canvas-turn-enrichments"; +import { runTurnEnrichments } from "@/services/canvas-turn-enrichments"; // Tier-1 backend-driven canvas turns (docs/plans/backend-driven-canvas-turns.md): // the org-canvas turn is persisted server-side in `after()` so it survives the @@ -822,23 +819,21 @@ export async function POST(request: NextRequest) { } after(async () => { - // Surfaces that don't render follow-ups or provenance opt out - // of computing them. Saves a `generateObject` round-trip and - // a `${swarmUrl}/gitree/provenance` POST per turn. - if (skipEnrichments) return; - await emitFollowUpQuestions({ + await runTurnEnrichments({ + skipEnrichments, + conversationId: canvasConversationRowId, + // Null `promptCache.rowId` at load means `persistCanvasUserMessage` + // created the row in this request — i.e. this is the first turn. + isNewConversation: !promptCache?.rowId, messages, + conceptIds: Array.from(learnedConceptIds), primarySlug, primaryWorkspaceId, primaryUserId, - agentName: - orgId && isMultiWorkspace ? "canvas-agent" : "chat-agent", - }); - await emitProvenance({ - conceptIds: Array.from(learnedConceptIds), - primarySlug, primarySwarmUrl, primarySwarmApiKey, + agentName: + orgId && isMultiWorkspace ? "canvas-agent" : "chat-agent", }); }); diff --git a/src/services/canvas-turn-enrichments.ts b/src/services/canvas-turn-enrichments.ts index 41c4f1be94..231d18e0e9 100644 --- a/src/services/canvas-turn-enrichments.ts +++ b/src/services/canvas-turn-enrichments.ts @@ -13,6 +13,8 @@ * the user's next 3 questions → `FOLLOW_UP_QUESTIONS`. * - `emitProvenance` — fetch stakgraph provenance for the concepts * learned this turn → `PROVENANCE_DATA`. + * - `emitConversationTitle` — a `generateObject` round-trip replacing + * the seeded title on a brand-new conversation → DB write. */ import { ModelMessage, generateObject } from "ai"; @@ -21,6 +23,60 @@ import { getModel, getApiKeyForProvider } from "@/lib/ai/provider"; import { getBifrostForLLM } from "@/services/bifrost/orchestrator"; import { getWorkspaceChannelName, PUSHER_EVENTS, pusherServer } from "@/lib/pusher"; import { swarmFetch } from "@/lib/ai/concepts"; +import { db } from "@/lib/db"; + +/** Flatten a turn's messages into `User: … / Assistant: …` prompt text. */ +function flattenConversation(messages: ModelMessage[]): string { + return messages + .filter((m) => m.role === "user" || m.role === "assistant") + .map((m) => { + const role = m.role === "user" ? "User" : "Assistant"; + let text = ""; + if (typeof m.content === "string") { + text = m.content; + } else if (Array.isArray(m.content)) { + text = m.content + .filter((part: any) => part.type === "text") + .map((part: any) => part.text) + .join("\n"); + } + return text ? `${role}: ${text}` : null; + }) + .filter(Boolean) + .join("\n\n"); +} + +/** + * Resolve a model for an enrichment call, routed through Bifrost under the + * SAME `agentName` as the main stream so per-surface rollups don't fragment. + * Falls back to the default key when `getBifrostForLLM` returns undefined. + */ +async function enrichmentModel(args: { + primarySlug: string; + primaryWorkspaceId: string; + primaryUserId: string; + agentName: "canvas-agent" | "chat-agent"; + modelType?: string; +}) { + const { primarySlug, primaryWorkspaceId, primaryUserId, agentName, modelType } = + args; + const apiKey = getApiKeyForProvider("anthropic"); + const bifrost = await getBifrostForLLM( + { + workspaceId: primaryWorkspaceId, + workspaceSlug: primarySlug, + userId: primaryUserId, + }, + { agentName }, + ); + return getModel( + "anthropic", + bifrost?.apiKey ?? apiKey, + primarySlug, + modelType, + bifrost ? { baseUrl: bifrost.baseUrl, headers: bifrost.headers } : undefined, + ); +} /** * Provenance data shape returned by `${swarmUrl}/gitree/provenance`. @@ -94,51 +150,14 @@ export async function emitFollowUpQuestions(args: { .describe("Exactly 3 short, specific follow-up questions (max 10 words each)"), }); - const conversationSummary = messages - .filter((m) => m.role === "user" || m.role === "assistant") - .map((m) => { - const role = m.role === "user" ? "User" : "Assistant"; - let text = ""; - if (typeof m.content === "string") { - text = m.content; - } else if (Array.isArray(m.content)) { - text = m.content - .filter((part: any) => part.type === "text") - .map((part: any) => part.text) - .join("\n"); - } - return text ? `${role}: ${text}` : null; - }) - .filter(Boolean) - .join("\n\n"); - - const followUpApiKey = getApiKeyForProvider("anthropic"); - // Route the follow-up `generateObject` through Bifrost under the - // SAME `agentName` as the main stream. Follow-ups are part of the - // same user-facing turn — splitting them into a separate dim would - // fragment the per-surface rollups operators actually want. Returns - // `undefined` and falls back to the default key when BIFROST_ENABLED - // doesn't cover the primary slug, or for public-viewer requests. - const followUpBifrost = await getBifrostForLLM( - { - workspaceId: primaryWorkspaceId, - workspaceSlug: primarySlug, - userId: primaryUserId, - }, - { agentName }, - ); - const followUpModel = getModel( - "anthropic", - followUpBifrost?.apiKey ?? followUpApiKey, + const conversationSummary = flattenConversation(messages); + + const followUpModel = await enrichmentModel({ primarySlug, - undefined, - followUpBifrost - ? { - baseUrl: followUpBifrost.baseUrl, - headers: followUpBifrost.headers, - } - : undefined, - ); + primaryWorkspaceId, + primaryUserId, + agentName, + }); const followUpResult = await generateObject({ model: followUpModel, @@ -190,3 +209,130 @@ export async function emitProvenance(args: { console.error("❌ Error generating provenance:", error); } } + +const titleSchema = z.object({ + title: z + .string() + .describe("Clear, concise conversation title (3-8 words) describing the topic"), +}); + +/** + * Replace a new conversation's seeded title with a model-written one. + * + * `generateTitle` seeds the row at create time from a raw slice of the first + * user message, so a pasted stack trace becomes the title. Gated on + * `isNewConversation` so it can't race a manual rename. Best-effort. + */ +export async function emitConversationTitle(args: { + conversationId: string | null; + isNewConversation: boolean; + messages: ModelMessage[]; + primarySlug: string; + primaryWorkspaceId: string; + primaryUserId: string; + agentName: "canvas-agent" | "chat-agent"; +}): Promise { + const { + conversationId, + isNewConversation, + messages, + primarySlug, + primaryWorkspaceId, + primaryUserId, + agentName, + } = args; + try { + if (!conversationId || !isNewConversation) return; + + const model = await enrichmentModel({ + primarySlug, + primaryWorkspaceId, + primaryUserId, + agentName, + modelType: "haiku", + }); + + const result = await generateObject({ + model, + schema: titleSchema, + prompt: `Title this conversation:\n\n${flattenConversation(messages)}`, + system: + "Write a short title for this conversation, from the perspective of someone scanning a chat history list. Capture what the conversation is actually about — not the literal opening words. 3-8 words, no trailing punctuation, no quotes, no 'Conversation about' preamble. If the conversation is only a greeting or has no substantive topic yet, return an empty string rather than describing the greeting.", + temperature: 0.3, + }); + + const title = result.object.title.trim(); + if (!title) return; + + await db.sharedConversation.update({ + where: { id: conversationId }, + data: { title }, + }); + + console.log("✅ Conversation title set:", title); + } catch (error) { + console.error("❌ Error generating conversation title:", error); + } +} + +/** + * Run every post-turn enrichment, in one place, with the gating rules. + * + * `skipEnrichments` means "this surface renders neither follow-ups nor + * provenance"; the title is a DB write, not a rendered enrichment, so it + * runs regardless. + */ +export async function runTurnEnrichments(args: { + skipEnrichments: boolean; + conversationId: string | null; + isNewConversation: boolean; + messages: ModelMessage[]; + conceptIds: string[]; + primarySlug: string; + primaryWorkspaceId: string; + primaryUserId: string; + primarySwarmUrl: string; + primarySwarmApiKey: string; + agentName: "canvas-agent" | "chat-agent"; +}): Promise { + const { + skipEnrichments, + conversationId, + isNewConversation, + messages, + conceptIds, + primarySlug, + primaryWorkspaceId, + primaryUserId, + primarySwarmUrl, + primarySwarmApiKey, + agentName, + } = args; + + await emitConversationTitle({ + conversationId, + isNewConversation, + messages, + primarySlug, + primaryWorkspaceId, + primaryUserId, + agentName, + }); + + // Saves a `generateObject` round-trip and a provenance POST per turn. + if (skipEnrichments) return; + + await emitFollowUpQuestions({ + messages, + primarySlug, + primaryWorkspaceId, + primaryUserId, + agentName, + }); + await emitProvenance({ + conceptIds, + primarySlug, + primarySwarmUrl, + primarySwarmApiKey, + }); +}