import { describe, expect, it, vi } from "vitest"; import { THREAD_TITLE_CONTEXT_UNAVAILABLE_MESSAGE, type ThreadTitleContext } from "../../../../src/domain/threads/title-generation-model"; import { createThreadTitleService, type ThreadTitleService, type ThreadTitleServiceHost, } from "../../../../src/features/threads/workflows/thread-title-service"; describe("ThreadTitleService", () => { it("generates a title from visible context without saving it", async () => { const generateThreadTitle = vi.fn().mockResolvedValue("Generated title"); const withClient = vi.fn().mockRejectedValue(new Error("should not read persisted context")); const service = titleService({ visibleContext: () => titleContext("visible request", "visible response"), generateThreadTitle, transport: { persistedContext: withClient, generateTitle: generateThreadTitle, }, }); await expect(service.generateTitle("thread")).resolves.toBe("Generated title"); expect(withClient).not.toHaveBeenCalled(); expect(generateThreadTitle).toHaveBeenCalledWith(titleContext("visible request", "visible response"), expect.any(AbortSignal)); }); it("throws the existing unavailable-context error when no context can be resolved", async () => { const service = titleService(); await expect(service.generateTitle("thread")).rejects.toThrow(THREAD_TITLE_CONTEXT_UNAVAILABLE_MESSAGE); }); it("prefers visible completed-turn context over completed summaries", () => { const service = titleService({ visibleCompletedTurnContext: () => titleContext("visible turn", "visible answer"), }); expect(service.completedTurnContext("turn", { userText: "summary turn", assistantText: "summary answer" })).toEqual( titleContext("visible turn", "visible answer"), ); }); it("falls back from visible completed-turn context to completed summaries", () => { const service = titleService({ visibleCompletedTurnContext: () => null, }); expect(service.completedTurnContext("turn", { userText: "summary turn", assistantText: "summary answer" })).toEqual( titleContext("summary turn", "summary answer"), ); }); it("cancels stale title work and starts later work with a fresh signal", async () => { let resolveOldTitle!: (title: string | null) => void; const generateTitle = vi .fn() .mockImplementationOnce( (_context: ThreadTitleContext, _signal: AbortSignal) => new Promise((resolve) => { resolveOldTitle = resolve; }), ) .mockResolvedValueOnce("Fresh title"); const service = titleService({ visibleContext: () => titleContext("request", "response"), transport: { persistedContext: vi.fn().mockResolvedValue(null), generateTitle, }, }); const staleTitle = service.generateTitle("thread"); await Promise.resolve(); const staleSignal = generateTitle.mock.calls[0]?.[1]; service.invalidate(); expect(staleSignal?.aborted).toBe(true); await expect(service.generateTitle("thread")).resolves.toBe("Fresh title"); resolveOldTitle("Stale title"); await expect(staleTitle).rejects.toThrow("Thread title generation cancelled."); }); }); function titleService(options: Partial = {}): ThreadTitleService { return createThreadTitleService({ transport: { persistedContext: vi.fn().mockResolvedValue(null), generateTitle: vi.fn().mockResolvedValue(null), }, ...options, }); } function titleContext(userRequest: string, assistantResponse: string): ThreadTitleContext { return { userRequest, assistantResponse }; }