/** * Session title generation unit tests: prompt shape, sanitization rules, single-shot request * driving (fake LLM), and Session.generateTitle's composition-layer wiring (no real requests sent). */ import { describe, it, expect } from "vitest"; import { assistantText, emptyTokenCounts, sanitizeTitle, Session, stripConversationMarkers, thinkingMessage, tokenUsage, userText, } from "../src/index.js"; // Prompt/request internals are no longer exported via the barrel: imported directly from the internal module. import { buildTitlePrompt, generateTitleWithLLM } from "../src/internal/session-title.js"; import type { EnvironmentInterface, LLMInterface, LLMOutcome, OmniMessage, SessionMetaPayload, } from "../src/index.js"; /** A fake LLM: yields the given messages and finishes with the outcome; records the prompt received. */ function fakeLLM( outputs: OmniMessage[], outcome: LLMOutcome = { status: "completed" }, seenPrompts: string[] = [], ): LLMInterface { return { async *streamGenerate({ newMessages }) { const first = newMessages[0]; if (first) seenPrompts.push((first.payload as { text: string }).text); for (const msg of outputs) yield msg; return outcome; }, }; } const fakeEnvironment: EnvironmentInterface = { listTools: async () => [], // eslint-disable-next-line require-yield executeTool: async function* () { throw new Error("not used"); }, toolPermission: () => undefined, }; const META: SessionMetaPayload = { session_id: "session-title-1", provider: "custom", model_id: "m1", model_context_window: 1000, system_prompt: "sp", tools: [], agent_state: "/tmp/state", workspace: "/tmp/w", }; describe("session-title", () => { it("generateTitleWithLLM: collects model text and usage, returns the sanitized result", async () => { const seen: string[] = []; const result = await generateTitleWithLLM( fakeLLM( [ thinkingMessage("想一下"), // thinking does not count assistantText("「Tailwind 主题配置」。"), tokenUsage(emptyTokenCounts(), { cache_read: 1, cache_write: 2, output: 3, total: 6 }), ], { status: "completed" }, seen, ), { userText: "解释 @theme", assistantText: "好的……" }, ); expect(result.title).toBe("Tailwind 主题配置"); expect(result.usage).toEqual({ cache_read: 1, cache_write: 2, output: 3, total: 6 }); expect(seen[0]).toBe(buildTitlePrompt("解释 @theme", "好的……")); expect(seen[0]).toContain("SAME language"); }); it("sends no request when material is empty; title is null when the outcome is not completed (usage kept)", async () => { const seen: string[] = []; const empty = await generateTitleWithLLM(fakeLLM([], { status: "completed" }, seen), { userText: " ", assistantText: "a", }); expect(empty).toEqual({ title: null, usage: null }); expect(seen).toHaveLength(0); const failed = await generateTitleWithLLM( fakeLLM( [ assistantText("partial"), tokenUsage(emptyTokenCounts(), { cache_read: 0, cache_write: 0, output: 1, total: 1 }), ], { status: "failed", message: "401" }, ), { userText: "u", assistantText: "a" }, ); expect(failed.title).toBeNull(); expect(failed.usage?.total).toBe(1); }); it("still generates with empty assistant material (tool-only turn): uses only the user request, prompt omits the assistant section", async () => { const seen: string[] = []; const result = await generateTitleWithLLM( fakeLLM([assistantText("配置 Tailwind 主题")], { status: "completed" }, seen), { userText: "帮我配置 @theme", assistantText: "" }, ); expect(result.title).toBe("配置 Tailwind 主题"); expect(seen[0]).toBe(buildTitlePrompt("帮我配置 @theme", "")); expect(seen[0]).not.toContain("[Assistant]"); }); it("sanitizeTitle: strips quotes/punctuation to a fixed point, collapses whitespace, truncates overlong input, returns null for empty", () => { expect(sanitizeTitle("“ 构建配置 说明 。”")).toBe("构建配置 说明"); expect(sanitizeTitle("『标题』!")).toBe("标题"); expect(sanitizeTitle(" \n ")).toBeNull(); expect(sanitizeTitle("x".repeat(50))).toHaveLength(30); // A leaked [use_skills] block is stripped from the model output. expect(sanitizeTitle("[use_skills]\nskills: web-design\n[/use_skills]\n构建落地页")).toBe( "构建落地页", ); }); it("stripConversationMarkers: removes machine marker blocks, keeps the human body", () => { // The skill-invocation block that wraps a first user message must not reach the title. expect( stripConversationMarkers( "[use_skills]\nskills: penguin-sdk, web-design\n[/use_skills]\n做一个 RAG 应用", ), ).toBe("做一个 RAG 应用"); // Handoff and scheduled-task markers are stripped too; ordinary bracketed text stays. expect(stripConversationMarkers("[handoff_from]data_analyst[/handoff_from]继续分析")).toBe( "继续分析", ); // The /model switch origin block (the new session's first message) must not leak into the title either. expect( stripConversationMarkers( "[model_switch_from]\nsession: session-01\ntrace: /t/x_001.jsonl\n[/model_switch_from]\n继续这个任务", ), ).toBe("继续这个任务"); expect(stripConversationMarkers("render a