/** * Unit tests for the Session title policy layer (the generation logic lives in * core `session.generateTitle`; a fake implementation is injected here): * persisting + event push + usage accounted as token usage, idempotency (no * regeneration when a title already exists / generation is in flight), and * silent failure. * The Chinese conversation/title fixtures are intentional: a Session title must * follow the conversation language (zh chat → zh title), including the fallback * path that derives the title from the conversation text itself. */ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import type { DatabaseSync } from "node:sqlite"; import type { OmniMessage, SessionTitleResult } from "@prismshadow/penguin-core"; import { openDatabase } from "../src/db/database.js"; import { SessionsRepo } from "../src/db/repos/sessions.js"; import type { SessionRow } from "../src/db/repos/sessions.js"; import { ChannelHub } from "../src/runtime/channel.js"; import type { ChannelEvent } from "../src/runtime/channel.js"; import { TitleGenerator } from "../src/runtime/title-generator.js"; import type { UsageContext } from "../src/runtime/usage-recorder.js"; import { waitFor } from "./helpers.js"; const ROW: SessionRow = { sessionId: "session-t1", projectId: "p1", agentId: "a1", modelId: "m1", provider: "custom", workspace: "/tmp/w", approvalMode: "always-ask", title: null, createdAt: "2026-07-07T00:00:00.000Z", }; const CTX: UsageContext = { projectId: "p1", agentId: "a1", sessionId: "session-t1", modelId: "m1", provider: "custom", }; /** Fake session: generateTitle returns the given result and records the call count/arguments. */ function fakeSession(result: SessionTitleResult, calls: { count: number; args: unknown[] }) { return { generateTitle: async (args: unknown) => { calls.count += 1; calls.args.push(args); return result; }, }; } describe("title-generator", () => { let db: DatabaseSync; let sessions: SessionsRepo; let channels: ChannelHub; let recorded: OmniMessage[]; const makeGenerator = (): TitleGenerator => new TitleGenerator({ sessions, channels, recorder: { record: async (_ctx, msg) => { recorded.push(msg); }, }, log: () => {}, }); const captureChannel = (): ChannelEvent[] => { const events: ChannelEvent[] = []; channels.get(ROW.sessionId).subscribe((e) => events.push(e)); return events; }; const serverEvents = (events: ChannelEvent[]): { type: string; [k: string]: unknown }[] => events .filter((e) => e.event === "server_event") .map((e) => JSON.parse(e.data) as { type: string }); beforeEach(() => { db = openDatabase(":memory:"); sessions = new SessionsRepo(db); sessions.insert(ROW); channels = new ChannelHub(); recorded = []; }); afterEach(() => { channels.dispose(); db.close(); }); it("title generation: persists + pushes session_title + usage converted and recorded; material defaults to Session self-collection", async () => { const events = captureChannel(); const calls = { count: 0, args: [] as unknown[] }; const gen = makeGenerator(); gen.maybeGenerate( CTX, fakeSession( { title: "Tailwind theme setup", usage: { cache_read: 1, cache_write: 2, output: 3, total: 6 }, }, calls, ), { fallbackText: "explain @theme" }, ); await waitFor(() => sessions.findById(ROW.sessionId)?.title !== null); expect(sessions.findById(ROW.sessionId)?.title).toBe("Tailwind theme setup"); // No material override passed: generateTitle is called with no argument, and the core Session gathers its own material. expect(calls.args[0]).toBeUndefined(); expect( serverEvents(events).some( (e) => e.type === "session_title" && e.title === "Tailwind theme setup", ), ).toBe(true); // usage is converted into token_usage and handed to the recorder (metered normally, same as a real call). const usageMsg = recorded.find((m) => (m.payload as { type?: string }).type === "token_usage"); expect((usageMsg?.payload as { request?: { total: number } }).request?.total).toBe(6); }); it("material override (sub-session scenario) is passed to generateTitle verbatim", async () => { const calls = { count: 0, args: [] as unknown[] }; const gen = makeGenerator(); const material = { userText: "sub-session prompt", assistantText: "sub-session reply" }; gen.maybeGenerate(CTX, fakeSession({ title: "Sub title", usage: null }, calls), { fallbackText: "sub-session prompt", material, }); await waitFor(() => sessions.findById(ROW.sessionId)?.title !== null); expect(calls.args[0]).toEqual({ material }); expect(sessions.findById(ROW.sessionId)?.title).toBe("Sub title"); }); it("an existing title is never regenerated (no one-shot request issued)", async () => { sessions.updateTitle(ROW.sessionId, "existing title"); const calls = { count: 0, args: [] as unknown[] }; makeGenerator().maybeGenerate(CTX, fakeSession({ title: "new title", usage: null }, calls), { fallbackText: "u", }); await new Promise((r) => setTimeout(r, 20)); expect(calls.count).toBe(0); expect(sessions.findById(ROW.sessionId)?.title).toBe("existing title"); }); it("when the LLM returns null (failed request / empty result), the fallback material's first line is persisted; usage still recorded", async () => { const calls = { count: 0, args: [] as unknown[] }; const gen = makeGenerator(); gen.maybeGenerate( CTX, fakeSession( { title: null, usage: { cache_read: 0, cache_write: 0, output: 1, total: 1 } }, calls, ), { fallbackText: "Configure the Tailwind theme\nsecond line" }, ); // Fallback = the material's first non-empty line, sanitized and truncated, guaranteeing a title is always produced. await waitFor(() => sessions.findById(ROW.sessionId)?.title !== null); expect(sessions.findById(ROW.sessionId)?.title).toBe("Configure the Tailwind theme"); // The one-off request's usage is still recorded normally. await waitFor(() => recorded.length > 0); }); it("fallback strips a leading [use_skills] block so the marker never becomes the title", async () => { const calls = { count: 0, args: [] as unknown[] }; const gen = makeGenerator(); gen.maybeGenerate(CTX, fakeSession({ title: null, usage: null }, calls), { fallbackText: "[use_skills]\nskills: web-design\n[/use_skills]\nBuild a landing page", }); await waitFor(() => sessions.findById(ROW.sessionId)?.title !== null); // Not "[use_skills]" — the marker block is removed before the first line is taken. expect(sessions.findById(ROW.sessionId)?.title).toBe("Build a landing page"); }); it("fallback also strips the legacy angle-bracket block (material from old Traces)", async () => { const calls = { count: 0, args: [] as unknown[] }; const gen = makeGenerator(); gen.maybeGenerate(CTX, fakeSession({ title: null, usage: null }, calls), { fallbackText: "\nskills: web-design\n\nBuild a landing page", }); await waitFor(() => sessions.findById(ROW.sessionId)?.title !== null); expect(sessions.findById(ROW.sessionId)?.title).toBe("Build a landing page"); }); it("LLM returns null and the fallback material is blank → the title stays NULL (retryable next time)", async () => { const calls = { count: 0, args: [] as unknown[] }; const gen = makeGenerator(); gen.maybeGenerate(CTX, fakeSession({ title: null, usage: null }, calls), { fallbackText: " ", }); await waitFor(() => calls.count >= 1); expect(sessions.findById(ROW.sessionId)?.title).toBeNull(); }); it("LLM returns null and the fallback material is pure punctuation → falls back to the truncated original text (never NULL)", async () => { const calls = { count: 0, args: [] as unknown[] }; const gen = makeGenerator(); // sanitizeTitle strips "???" down to empty — the fallback must revert to the truncated original text so a title is still produced. gen.maybeGenerate(CTX, fakeSession({ title: null, usage: null }, calls), { fallbackText: "???", }); await waitFor(() => sessions.findById(ROW.sessionId)?.title !== null); expect(sessions.findById(ROW.sessionId)?.title).toBe("???"); }); });