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..473fa60b6a --- /dev/null +++ b/src/__tests__/unit/services/canvas-turn-enrichments.test.ts @@ -0,0 +1,315 @@ +/** + * Unit tests for org-canvas conversation title generation. + * + * `generateConversationTitle` is a Bifrost-free generateObject one-shot. + * `maybeGenerateAndPersistTitle` is the persist-side writer: skip on + * error/empty, write once via settings.titleSource === "llm", retry when + * that marker is unset, and never throw (so an LLM failure cannot fall + * into /api/ask/quick's persist catch and append a fake error row). + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("ai", () => ({ + generateObject: vi.fn(), +})); + +vi.mock("@/lib/ai/provider", () => ({ + getApiKeyForProvider: vi.fn(() => "test-api-key"), + getModel: vi.fn(() => "mock-model"), +})); + +vi.mock("@/services/bifrost/orchestrator", () => ({ + getBifrostForLLM: vi.fn(), +})); + +vi.mock("@/lib/db", () => ({ + db: { + sharedConversation: { + findUnique: vi.fn(), + update: vi.fn(), + }, + $executeRaw: vi.fn(), + }, +})); + +vi.mock("@/lib/pusher", () => ({ + notifyCanvasConversationUpdated: vi.fn(), + getWorkspaceChannelName: vi.fn(), + PUSHER_EVENTS: {}, + pusherServer: { trigger: vi.fn() }, +})); + +import { generateObject } from "ai"; +import { getModel, getApiKeyForProvider } from "@/lib/ai/provider"; +import { getBifrostForLLM } from "@/services/bifrost/orchestrator"; +import { db } from "@/lib/db"; +import { notifyCanvasConversationUpdated } from "@/lib/pusher"; +import { TITLE_MAX_LENGTH } from "@/lib/ai/conversationHelpers"; +import { + generateConversationTitle, + maybeGenerateAndPersistTitle, + sanitizeGeneratedTitle, +} from "@/services/canvas-turn-enrichments"; + +const generateObjectMock = generateObject as ReturnType; +const getModelMock = getModel as ReturnType; +const getApiKeyMock = getApiKeyForProvider as ReturnType; +const getBifrostMock = getBifrostForLLM as ReturnType; +const findUnique = db.sharedConversation.findUnique as ReturnType; +const prismaUpdate = db.sharedConversation.update as ReturnType; +const executeRaw = db.$executeRaw as ReturnType; +const notify = notifyCanvasConversationUpdated as ReturnType; + +const USER = "How does the auth middleware work when tokens expire?"; +const ASSISTANT = "It refreshes the access token using the refresh token cookie."; + +beforeEach(() => { + vi.clearAllMocks(); + getApiKeyMock.mockReturnValue("test-api-key"); + getModelMock.mockReturnValue("mock-model"); + generateObjectMock.mockResolvedValue({ + object: { title: "Auth token refresh" }, + }); + findUnique.mockResolvedValue({ + title: USER.slice(0, 200), + settings: { extraWorkspaceSlugs: ["acme"] }, + }); + executeRaw.mockResolvedValue(1); +}); + +describe("sanitizeGeneratedTitle", () => { + it("trims, strips wrapping quotes, and collapses whitespace", () => { + expect(sanitizeGeneratedTitle(' "Auth token refresh" ')).toBe( + "Auth token refresh", + ); + expect(sanitizeGeneratedTitle("'Auth token refresh'")).toBe( + "Auth token refresh", + ); + expect(sanitizeGeneratedTitle("`Auth token\nrefresh`")).toBe( + "Auth token refresh", + ); + }); + + it("keeps only the first 6 words", () => { + expect( + sanitizeGeneratedTitle("one two three four five six seven eight"), + ).toBe("one two three four five six"); + }); + + it("caps at TITLE_MAX_LENGTH", () => { + const longWord = "x".repeat(TITLE_MAX_LENGTH + 40); + const result = sanitizeGeneratedTitle(longWord); + expect(result).toHaveLength(TITLE_MAX_LENGTH); + }); + + it("returns null for empty / whitespace / quotes-only input", () => { + expect(sanitizeGeneratedTitle("")).toBeNull(); + expect(sanitizeGeneratedTitle(" ")).toBeNull(); + expect(sanitizeGeneratedTitle('""')).toBeNull(); + }); +}); + +describe("generateConversationTitle", () => { + it("calls generateObject with the default Anthropic key and no Bifrost", async () => { + const title = await generateConversationTitle(USER, ASSISTANT); + + expect(title).toBe("Auth token refresh"); + expect(getApiKeyMock).toHaveBeenCalledWith("anthropic"); + expect(getModelMock).toHaveBeenCalledWith("anthropic", "test-api-key"); + expect(getBifrostMock).not.toHaveBeenCalled(); + + const call = generateObjectMock.mock.calls[0][0]; + expect(call.prompt).toContain(USER); + expect(call.prompt).toContain(ASSISTANT); + expect(call.system).toMatch(/2-6 words/i); + expect(call.system).toMatch(/not a sentence/i); + expect(call.system).toMatch(/not a truncated copy/i); + expect(call.system).toMatch(/No quotes/i); + }); + + it("post-processes LLM output (quotes, word cap)", async () => { + generateObjectMock.mockResolvedValue({ + object: { + title: '"How the auth middleware works when tokens expire quickly"', + }, + }); + + const title = await generateConversationTitle(USER, ASSISTANT); + expect(title).toBe("How the auth middleware works when"); + }); + + it("returns null on empty sanitized output without throwing", async () => { + generateObjectMock.mockResolvedValue({ object: { title: " " } }); + await expect( + generateConversationTitle(USER, ASSISTANT), + ).resolves.toBeNull(); + }); + + it("swallows LLM failures and returns null", async () => { + generateObjectMock.mockRejectedValue(new Error("model down")); + await expect( + generateConversationTitle(USER, ASSISTANT), + ).resolves.toBeNull(); + expect(getBifrostMock).not.toHaveBeenCalled(); + }); +}); + +describe("maybeGenerateAndPersistTitle", () => { + const rowId = "conv-1"; + + it("skips when assistantIsError is true", async () => { + await maybeGenerateAndPersistTitle({ + rowId, + userText: USER, + assistantText: ASSISTANT, + assistantIsError: true, + }); + expect(findUnique).not.toHaveBeenCalled(); + expect(generateObjectMock).not.toHaveBeenCalled(); + expect(executeRaw).not.toHaveBeenCalled(); + expect(notify).not.toHaveBeenCalled(); + }); + + it("skips when assistant text is empty or whitespace", async () => { + await maybeGenerateAndPersistTitle({ + rowId, + userText: USER, + assistantText: " \n ", + assistantIsError: false, + }); + expect(findUnique).not.toHaveBeenCalled(); + expect(generateObjectMock).not.toHaveBeenCalled(); + expect(executeRaw).not.toHaveBeenCalled(); + }); + + it("writes title + settings.titleSource=llm on a fresh row", async () => { + findUnique.mockResolvedValue({ + title: "Untitled Conversation", + settings: {}, + }); + + await maybeGenerateAndPersistTitle({ + rowId, + userText: USER, + assistantText: ASSISTANT, + assistantIsError: false, + }); + + expect(generateObjectMock).toHaveBeenCalledOnce(); + expect(executeRaw).toHaveBeenCalledOnce(); + const serialized = JSON.stringify(executeRaw.mock.calls[0]); + expect(serialized).toContain("Auth token refresh"); + expect(serialized).toContain("titleSource"); + expect(serialized).toContain(rowId); + expect(prismaUpdate).not.toHaveBeenCalled(); + expect(notify).toHaveBeenCalledWith(rowId, "user-turn"); + }); + + it("no-ops when settings.titleSource is already llm", async () => { + findUnique.mockResolvedValue({ + title: "Auth token refresh", + settings: { titleSource: "llm" }, + }); + + await maybeGenerateAndPersistTitle({ + rowId, + userText: USER, + assistantText: ASSISTANT, + assistantIsError: false, + }); + + expect(generateObjectMock).not.toHaveBeenCalled(); + expect(executeRaw).not.toHaveBeenCalled(); + expect(notify).not.toHaveBeenCalled(); + }); + + it("retries when titleSource is unset even if a placeholder title exists", async () => { + findUnique.mockResolvedValue({ + title: USER.slice(0, 200), + settings: { extraWorkspaceSlugs: ["acme"] }, + }); + + await maybeGenerateAndPersistTitle({ + rowId, + userText: USER, + assistantText: ASSISTANT, + assistantIsError: false, + }); + + expect(generateObjectMock).toHaveBeenCalledOnce(); + expect(executeRaw).toHaveBeenCalledOnce(); + expect(notify).toHaveBeenCalledWith(rowId, "user-turn"); + }); + + it("does not write when the LLM returns an empty title", async () => { + generateObjectMock.mockResolvedValue({ object: { title: " " } }); + + await maybeGenerateAndPersistTitle({ + rowId, + userText: USER, + assistantText: ASSISTANT, + assistantIsError: false, + }); + + expect(executeRaw).not.toHaveBeenCalled(); + expect(notify).not.toHaveBeenCalled(); + }); + + it("does not write and does not throw when the LLM fails", async () => { + generateObjectMock.mockRejectedValue(new Error("model down")); + + await expect( + maybeGenerateAndPersistTitle({ + rowId, + userText: USER, + assistantText: ASSISTANT, + assistantIsError: false, + }), + ).resolves.toBeUndefined(); + + expect(executeRaw).not.toHaveBeenCalled(); + expect(prismaUpdate).not.toHaveBeenCalled(); + expect(notify).not.toHaveBeenCalled(); + }); + + it("never throws into a persist catch (LLM failure cannot append an assistant error row)", async () => { + // Mirrors /api/ask/quick's after(): title generation sits in a nested + // try after a successful appendTurnMessages. Even if that nested try + // were omitted, the helper itself must not reject — a rejection would + // fall into the persist catch and write source.kind === "error". + generateObjectMock.mockRejectedValue(new Error("model down")); + + const persistCatch = vi.fn(); + try { + await maybeGenerateAndPersistTitle({ + rowId, + userText: USER, + assistantText: ASSISTANT, + assistantIsError: false, + }); + } catch { + persistCatch(); + } + + expect(persistCatch).not.toHaveBeenCalled(); + expect(executeRaw).not.toHaveBeenCalled(); + expect(prismaUpdate).not.toHaveBeenCalled(); + }); + + it("swallows DB errors so a title failure cannot append an assistant error row", async () => { + findUnique.mockRejectedValue(new Error("db down")); + + await expect( + maybeGenerateAndPersistTitle({ + rowId, + userText: USER, + assistantText: ASSISTANT, + assistantIsError: false, + }), + ).resolves.toBeUndefined(); + + expect(executeRaw).not.toHaveBeenCalled(); + expect(notify).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/ask/quick/route.ts b/src/app/api/ask/quick/route.ts index 0d5e04c806..91651713cb 100644 --- a/src/app/api/ask/quick/route.ts +++ b/src/app/api/ask/quick/route.ts @@ -43,6 +43,7 @@ import { import { emitFollowUpQuestions, emitProvenance, + maybeGenerateAndPersistTitle, } from "@/services/canvas-turn-enrichments"; // Tier-1 backend-driven canvas turns (docs/plans/backend-driven-canvas-turns.md): @@ -813,6 +814,36 @@ export async function POST(request: NextRequest) { idPrefix: assistantPrefix, reason: "user-turn", }); + // Nested try: an LLM throw must never fall into the persist + // catch (that catch writes a fake assistant error row). + // Not gated on isFirstTurn — the helper no-ops once + // settings.titleSource === "llm", and retries if a prior + // after() died before the title write. + try { + const assistantIsError = + errMsg !== null || + abnormalFinish !== null || + rows.length === 0 || + rows.some((r) => r.source?.kind === "error"); + const assistantText = rows + .filter( + (r) => + r.role === "assistant" && r.source?.kind !== "error", + ) + .map((r) => (typeof r.content === "string" ? r.content : "")) + .join("\n"); + await maybeGenerateAndPersistTitle({ + rowId, + userText: newUserContent, + assistantText, + assistantIsError, + }); + } catch (titleErr) { + console.error( + "❌ [quick-ask] Title generation failed:", + titleErr, + ); + } } catch (err) { console.error("❌ [quick-ask] Turn persist failed:", err); // Persist a trailing error row so a reopened tab sees the diff --git a/src/services/canvas-turn-enrichments.ts b/src/services/canvas-turn-enrichments.ts index 41c4f1be94..15eacfb82c 100644 --- a/src/services/canvas-turn-enrichments.ts +++ b/src/services/canvas-turn-enrichments.ts @@ -13,14 +13,26 @@ * the user's next 3 questions → `FOLLOW_UP_QUESTIONS`. * - `emitProvenance` — fetch stakgraph provenance for the concepts * learned this turn → `PROVENANCE_DATA`. + * - `generateConversationTitle` / `maybeGenerateAndPersistTitle` — + * one-shot LLM title for org-canvas chats. Does NOT go through + * Bifrost (`workspaceId` is null on those rows). Best-effort; never + * throws. Gated by `settings.titleSource === "llm"` so it runs at + * most once per conversation. */ import { ModelMessage, generateObject } from "ai"; import { z } from "zod"; import { getModel, getApiKeyForProvider } from "@/lib/ai/provider"; import { getBifrostForLLM } from "@/services/bifrost/orchestrator"; -import { getWorkspaceChannelName, PUSHER_EVENTS, pusherServer } from "@/lib/pusher"; +import { + getWorkspaceChannelName, + notifyCanvasConversationUpdated, + PUSHER_EVENTS, + pusherServer, +} from "@/lib/pusher"; import { swarmFetch } from "@/lib/ai/concepts"; +import { TITLE_MAX_LENGTH } from "@/lib/ai/conversationHelpers"; +import { db } from "@/lib/db"; /** * Provenance data shape returned by `${swarmUrl}/gitree/provenance`. @@ -190,3 +202,121 @@ export async function emitProvenance(args: { console.error("❌ Error generating provenance:", error); } } + +const TITLE_WORD_CAP = 6; + +const conversationTitleSchema = z.object({ + title: z.string(), +}); + +/** + * Trim, strip wrapping quotes, collapse whitespace, keep the first + * {@link TITLE_WORD_CAP} words, and cap at {@link TITLE_MAX_LENGTH}. + * Returns `null` when nothing usable remains. + */ +export function sanitizeGeneratedTitle(raw: string): string | null { + let title = raw.trim().replace(/^["'`]+/, "").replace(/["'`]+$/, ""); + title = title.replace(/\s+/g, " ").trim(); + if (!title) return null; + const words = title.split(" ").filter(Boolean).slice(0, TITLE_WORD_CAP); + title = words.join(" "); + if (!title) return null; + return title.slice(0, TITLE_MAX_LENGTH); +} + +/** + * One-shot LLM title from this turn's user + assistant text. + * + * Org-canvas rows have `workspaceId: null` and already skip Bifrost + * enrichments — this must not reintroduce `getBifrostForLLM`. Uses the + * default Anthropic key. Never throws: logs and returns `null` on + * failure or empty/unusable output. + */ +export async function generateConversationTitle( + userText: string, + assistantText: string, +): Promise { + try { + const apiKey = getApiKeyForProvider("anthropic"); + const model = getModel("anthropic", apiKey); + + const result = await generateObject({ + model, + schema: conversationTitleSchema, + prompt: `User:\n${userText}\n\nAssistant:\n${assistantText}`, + system: + "Write a conversation title as a few words in simple English (about 2-6 words). Not a sentence, not a long phrase, and not a truncated copy of the user's message. No quotes, no trailing punctuation. Example: Auth token refresh", + temperature: 0.2, + }); + + const sanitized = sanitizeGeneratedTitle(result.object.title ?? ""); + if (!sanitized) return null; + console.log("✅ Conversation title generated:", sanitized); + return sanitized; + } catch (error) { + console.error("❌ Error generating conversation title:", error); + return null; + } +} + +/** + * After a successful org-canvas assistant persist, generate a short + * title and write it onto `SharedConversation.title` once. + * + * `rowId` MUST already have passed this request's org/user authorization + * (the same `canvasConversationRowId` validated via + * `persistCanvasUserMessage` / org membership). Do not pass a + * client-supplied conversation id. + * + * Never throws. Skips error-only / empty assistant turns, and no-ops + * once `settings.titleSource === "llm"`. Does not detect the + * `generateTitle()` placeholder by string equality — a short first + * user message can equal a valid LLM title. Retry-safe: if a prior + * `after()` died before this write, `titleSource` is still unset and + * a later successful non-error turn will generate. + */ +export async function maybeGenerateAndPersistTitle(args: { + rowId: string; + userText: string; + assistantText: string; + assistantIsError: boolean; +}): Promise { + const { rowId, userText, assistantText, assistantIsError } = args; + try { + if (assistantIsError) return; + if (!assistantText.trim()) return; + + const row = await db.sharedConversation.findUnique({ + where: { id: rowId }, + select: { title: true, settings: true }, + }); + if (!row) return; + + const settings = + row.settings && + typeof row.settings === "object" && + !Array.isArray(row.settings) + ? (row.settings as Record) + : {}; + if (settings.titleSource === "llm") return; + + const title = await generateConversationTitle(userText, assistantText); + if (!title) return; + + // jsonb `||` merge so a concurrent after() writing promptConcepts / + // promptPrefix is not clobbered by a full settings replace. Title + // is a scalar column so it can sit on the same UPDATE. + const patch = JSON.stringify({ titleSource: "llm" }); + await db.$executeRaw` + UPDATE shared_conversations + SET title = ${title}, + settings = COALESCE(settings, '{}'::jsonb) || ${patch}::jsonb + WHERE id = ${rowId} + `; + + notifyCanvasConversationUpdated(rowId, "user-turn"); + console.log("✅ Conversation title persisted:", title); + } catch (error) { + console.error("❌ Error persisting conversation title:", error); + } +} diff --git a/src/types/shared-conversation.ts b/src/types/shared-conversation.ts index 0c34913e91..e38fc1d348 100644 --- a/src/types/shared-conversation.ts +++ b/src/types/shared-conversation.ts @@ -2,6 +2,9 @@ export interface ConversationSettings { extraWorkspaceSlugs?: string[]; + // Set to "llm" after a successful generateConversationTitle write. + // Once present, later turns must not overwrite SharedConversation.title. + titleSource?: "llm"; // Cached swarm concepts (the expensive `listConcepts` result) for // org-canvas conversations. Written server-side by `/api/ask/quick` on // the first turn and reused on later turns to skip the swarm fetch. The