import { describe, test, expect, beforeEach, afterEach, mock, spyOn } from "bun:test"; import { mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from "fs"; import { join } from "path"; // Test fixtures const TEST_DIR = "/tmp/session-title-tests"; const SAMPLE_TRANSCRIPT = ` {"type":"file-history-snapshot","messageId":"abc"} {"message":{"role":"user","content":"Help me fix the login bug"},"type":"user"} {"message":{"role":"assistant","content":[{"type":"text","text":"I'll help you fix that."}]},"type":"assistant"} {"message":{"role":"user","content":"/status"},"type":"user"} {"message":{"role":"user","content":"Actually, let's refactor the auth module instead"},"type":"user"} `.trim(); const SAMPLE_TRANSCRIPT_ARRAY_CONTENT = ` {"message":{"role":"user","content":[{"type":"text","text":"Review the API endpoints"}]},"type":"user"} {"message":{"role":"assistant","content":[{"type":"text","text":"Looking at endpoints..."}]},"type":"assistant"} `.trim(); // Helper to create test transcript function createTranscript(content: string): string { const path = join(TEST_DIR, "transcript.jsonl"); writeFileSync(path, content); return path; } // --- Unit Tests for getBookendMessages --- describe("getBookendMessages", () => { beforeEach(() => { mkdirSync(TEST_DIR, { recursive: true }); }); afterEach(() => { rmSync(TEST_DIR, { recursive: true, force: true }); }); test("extracts first and last user messages", async () => { const path = createTranscript(SAMPLE_TRANSCRIPT); // Import fresh to avoid caching issues const { getBookendMessages } = await import("./generate-core.ts"); const result = getBookendMessages(path); expect(result.first).toBe("Help me fix the login bug"); expect(result.last).toBe("Actually, let's refactor the auth module instead"); }); test("skips command messages", async () => { const path = createTranscript(SAMPLE_TRANSCRIPT); const { getBookendMessages } = await import("./generate-core.ts"); const result = getBookendMessages(path); // Should not include the /status command expect(result.first).not.toContain("command"); expect(result.last).not.toContain("command"); }); test("handles array content format", async () => { const path = createTranscript(SAMPLE_TRANSCRIPT_ARRAY_CONTENT); const { getBookendMessages } = await import("./generate-core.ts"); const result = getBookendMessages(path); expect(result.first).toBe("Review the API endpoints"); expect(result.last).toBe("Review the API endpoints"); // Same message, first === last }); test("returns nulls for missing file", async () => { const { getBookendMessages } = await import("./generate-core.ts"); const result = getBookendMessages("/nonexistent/path.jsonl"); expect(result.first).toBeNull(); expect(result.last).toBeNull(); }); test("returns nulls for empty transcript", async () => { const path = createTranscript(""); const { getBookendMessages } = await import("./generate-core.ts"); const result = getBookendMessages(path); expect(result.first).toBeNull(); expect(result.last).toBeNull(); }); test("truncates long messages to 100 chars", async () => { const longMessage = "A".repeat(200); const transcript = `{"message":{"role":"user","content":"${longMessage}"},"type":"user"}`; const path = createTranscript(transcript); const { getBookendMessages } = await import("./generate-core.ts"); const result = getBookendMessages(path); expect(result.first?.length).toBeLessThanOrEqual(100); }); test("handles sentence boundaries correctly", async () => { const transcript = `{"message":{"role":"user","content":"Check the ~/.claude directory. Then fix it."},"type":"user"}`; // portability: allow const path = createTranscript(transcript); const { getBookendMessages } = await import("./generate-core.ts"); const result = getBookendMessages(path); // Should get first sentence, not break on ~/. expect(result.first).toBe("Check the ~/.claude directory."); // portability: allow }); }); // --- Unit Tests for getProjectName --- describe("getProjectName", () => { test("extracts name from git remote URL", async () => { const { getProjectName } = await import("./generate-core.ts"); // This will use the actual git repo we're in const name = getProjectName(process.env.HOME + "/.claude"); expect(name).toBe("dotclaude"); }); test("falls back to basename for non-git directory", async () => { const { getProjectName } = await import("./generate-core.ts"); const name = getProjectName("/tmp"); expect(name).toBe("tmp"); }); }); // --- Unit Tests for evolveTitle --- describe("evolveTitle", () => { test("returns project session fallback when no messages", async () => { const { evolveTitle } = await import("./generate-core.ts"); const title = await evolveTitle(null, null, null); // Legacy wrapper uses "unknown" as project name expect(title).toBe("unknown session"); }); test("seeds with first message when no current title", async () => { const { evolveTitle } = await import("./generate-core.ts"); const title = await evolveTitle(null, "Fix the login bug", "Fix the login bug"); // Without API, should return the seed message truncated expect(title).toBe("Fix the login bug"); }); test("seeds with first message when current title is timestamp", async () => { const { evolveTitle } = await import("./generate-core.ts"); const title = await evolveTitle("Session 10:30 AM", "Fix the login bug", "Now refactoring"); // Should re-seed since current is just a timestamp expect(title).toBe("Fix the login bug"); }); test("keeps current title without API when messages differ", async () => { const { evolveTitle } = await import("./generate-core.ts"); const title = await evolveTitle( "Fixing login bugs", "Fix the login bug", "Now add rate limiting" // Different message ); // Without API, falls back to keeping current title expect(title).toBe("Fixing login bugs"); }); }); // --- Unit Tests for extractSessionContext --- describe("extractSessionContext", () => { beforeEach(() => { mkdirSync(TEST_DIR, { recursive: true }); }); afterEach(() => { rmSync(TEST_DIR, { recursive: true, force: true }); }); test("extracts git branch from user message metadata", async () => { const transcript = ` {"type":"user","message":{"role":"user","content":"Fix the auth bug"},"gitBranch":"feat/add-oauth"} `.trim(); const path = createTranscript(transcript); const { extractSessionContext } = await import("./generate-core.ts"); const ctx = extractSessionContext(path, "test-project"); expect(ctx.gitBranch).toBe("feat/add-oauth"); expect(ctx.firstMessage).toBe("Fix the auth bug"); }); test("extracts explicit summary from transcript", async () => { const transcript = ` {"type":"summary","summary":"PR #95 Phase 1 Refactoring: Structural Decomposition"} {"type":"user","message":{"role":"user","content":"Continue the refactor"},"gitBranch":"main"} `.trim(); const path = createTranscript(transcript); const { extractSessionContext } = await import("./generate-core.ts"); const ctx = extractSessionContext(path, "test-project"); expect(ctx.explicitSummary).toBe("PR #95 Phase 1 Refactoring: Structural Decomposition"); }); test("extracts modified files from tool calls", async () => { const transcript = ` {"type":"user","message":{"role":"user","content":"Update the auth module"}} {"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Edit","input":{"file_path":"/src/auth.ts"}},{"type":"tool_use","name":"Write","input":{"file_path":"/src/utils.ts"}}]}} `.trim(); const path = createTranscript(transcript); const { extractSessionContext } = await import("./generate-core.ts"); const ctx = extractSessionContext(path, "test-project"); expect(ctx.modifiedFiles).toContain("auth.ts"); expect(ctx.modifiedFiles).toContain("utils.ts"); }); test("counts user messages", async () => { const path = createTranscript(SAMPLE_TRANSCRIPT); const { extractSessionContext } = await import("./generate-core.ts"); const ctx = extractSessionContext(path, "test-project"); expect(ctx.messageCount).toBe(2); // Two valid user messages }); test("returns empty context for missing file", async () => { const { extractSessionContext } = await import("./generate-core.ts"); const ctx = extractSessionContext("/nonexistent/path.jsonl", "test-project"); expect(ctx.firstMessage).toBeNull(); expect(ctx.gitBranch).toBeNull(); expect(ctx.modifiedFiles).toEqual([]); }); }); // --- Unit Tests for fallback behavior --- describe("fallback chain", () => { beforeEach(() => { mkdirSync(TEST_DIR, { recursive: true }); }); afterEach(() => { rmSync(TEST_DIR, { recursive: true, force: true }); }); test("uses branch name as fallback when available", async () => { const transcript = ` {"type":"user","message":{"role":"user","content":"short"},"gitBranch":"feat/add-oauth-flow"} `.trim(); const path = createTranscript(transcript); const { extractSessionContext, evolveTitleWithContext } = await import("./generate-core.ts"); const ctx = extractSessionContext(path, "test-project"); // "short" is < 10 chars so it's skipped, branch is used const result = await evolveTitleWithContext(null, ctx, null); expect(result.title).toBe("add oauth flow"); }); test("uses first message when branch is main", async () => { const transcript = ` {"type":"user","message":{"role":"user","content":"Help me fix the login bug"},"gitBranch":"main"} `.trim(); const path = createTranscript(transcript); const { extractSessionContext, evolveTitleWithContext } = await import("./generate-core.ts"); const ctx = extractSessionContext(path, "test-project"); const result = await evolveTitleWithContext(null, ctx, null); expect(result.title).toBe("Help me fix the login bug"); }); test("uses project name session when no other context", async () => { const { extractSessionContext, evolveTitleWithContext } = await import("./generate-core.ts"); const ctx = extractSessionContext(undefined, "my-project"); const result = await evolveTitleWithContext(null, ctx, null); expect(result.title).toBe("my-project session"); }); test("prefers explicit summary over other fallbacks", async () => { const transcript = ` {"type":"summary","summary":"Important feature work"} {"type":"user","message":{"role":"user","content":"short"},"gitBranch":"feat/something"} `.trim(); const path = createTranscript(transcript); const { extractSessionContext, evolveTitleWithContext } = await import("./generate-core.ts"); const ctx = extractSessionContext(path, "test-project"); const result = await evolveTitleWithContext(null, ctx, null); expect(result.title).toBe("Important feature work"); }); }); // --- Unit Tests for extractUserText --- describe("extractUserText", () => { test("strips system instruction wrappers", async () => { const { extractUserText } = await import("./generate-core.ts"); const content = "Do stuffHelp me fix the login bug"; expect(extractUserText(content)).toBe("Help me fix the login bug"); }); test("returns null for confirmation patterns", async () => { const { extractUserText } = await import("./generate-core.ts"); expect(extractUserText("yes")).toBeNull(); expect(extractUserText("yes please")).toBeNull(); expect(extractUserText("ok")).toBeNull(); expect(extractUserText("sounds good")).toBeNull(); expect(extractUserText("thanks")).toBeNull(); expect(extractUserText("sure, go ahead")).toBeNull(); expect(extractUserText("LGTM")).toBeNull(); expect(extractUserText("ship it")).toBeNull(); }); test("returns null for short messages", async () => { const { extractUserText } = await import("./generate-core.ts"); expect(extractUserText("fix it")).toBeNull(); // < 15 chars expect(extractUserText("do the thing")).toBeNull(); // < 15 chars }); test("returns null for messages starting with <", async () => { const { extractUserText } = await import("./generate-core.ts"); expect(extractUserText("/status")).toBeNull(); }); test("returns valid substantive messages", async () => { const { extractUserText } = await import("./generate-core.ts"); expect(extractUserText("Help me fix the login bug")).toBe("Help me fix the login bug"); expect(extractUserText("Refactor the authentication module to use OAuth 2.0")).toBe("Refactor the authentication module to use OAuth 2.0"); }); test("handles array content format", async () => { const { extractUserText } = await import("./generate-core.ts"); const content = [{ type: "text", text: "Help me fix the login bug" }]; expect(extractUserText(content)).toBe("Help me fix the login bug"); }); }); // --- Unit Tests for primaryRequest extraction --- describe("primaryRequest extraction", () => { beforeEach(() => { mkdirSync(TEST_DIR, { recursive: true }); }); afterEach(() => { rmSync(TEST_DIR, { recursive: true, force: true }); }); test("finds longest substantive message when first is short", async () => { const transcript = ` {"type":"user","message":{"role":"user","content":"yes please continue"}} {"type":"user","message":{"role":"user","content":"Help me fix the OAuth redirect loop in the auth module"}} {"type":"user","message":{"role":"user","content":"ok thanks"}} `.trim(); const path = createTranscript(transcript); const { extractSessionContext } = await import("./generate-core.ts"); const ctx = extractSessionContext(path, "test-project"); // "yes please continue" is a confirmation, so skipped // "ok thanks" is a confirmation, so skipped // Only the OAuth message should be found expect(ctx.primaryRequest).toBe("Help me fix the OAuth redirect loop in the auth module"); expect(ctx.messageCount).toBe(1); // Only one substantive message }); test("finds longest message among multiple substantive ones", async () => { const transcript = ` {"type":"user","message":{"role":"user","content":"Fix the login bug"}} {"type":"user","message":{"role":"user","content":"Actually, I need to refactor the entire authentication module to use OAuth 2.0 instead"}} {"type":"user","message":{"role":"user","content":"Add a logout button"}} `.trim(); const path = createTranscript(transcript); const { extractSessionContext } = await import("./generate-core.ts"); const ctx = extractSessionContext(path, "test-project"); // Second message is longest expect(ctx.primaryRequest).toContain("refactor the entire authentication"); expect(ctx.messageCount).toBe(3); }); test("strips system instruction tags before finding primary", async () => { const transcript = ` {"type":"user","message":{"role":"user","content":"Context hereImplement the new feature following best practices"}} `.trim(); const path = createTranscript(transcript); const { extractSessionContext } = await import("./generate-core.ts"); const ctx = extractSessionContext(path, "test-project"); expect(ctx.primaryRequest).toBe("Implement the new feature following best practices"); }); test("falls back to branch name when only confirmations present", async () => { const transcript = ` {"type":"user","message":{"role":"user","content":"yes"},"gitBranch":"feat/add-oauth-flow"} {"type":"user","message":{"role":"user","content":"ok"}} `.trim(); const path = createTranscript(transcript); const { extractSessionContext, evolveTitleWithContext } = await import("./generate-core.ts"); const ctx = extractSessionContext(path, "test-project"); const result = await evolveTitleWithContext(null, ctx, null); // No substantive messages, so falls back to branch expect(ctx.primaryRequest).toBeNull(); expect(result.title).toBe("add oauth flow"); }); }); // --- Integration Tests --- describe("integration", () => { const SESSION_ID = "test-session-123"; const PROJECT_DIR = join(TEST_DIR, "session-titles", "test-project"); let savedApiKey: string | undefined; beforeEach(() => { mkdirSync(PROJECT_DIR, { recursive: true }); // Disable API key to test fallback behavior savedApiKey = process.env.ANTHROPIC_API_KEY; delete process.env.ANTHROPIC_API_KEY; }); afterEach(() => { rmSync(TEST_DIR, { recursive: true, force: true }); // Restore API key if (savedApiKey) process.env.ANTHROPIC_API_KEY = savedApiKey; }); test("creates title and metadata files", async () => { const transcriptPath = createTranscript(SAMPLE_TRANSCRIPT); const titleFile = join(PROJECT_DIR, `${SESSION_ID}.txt`); const metaFile = join(PROJECT_DIR, `${SESSION_ID}.meta`); const { writeTitle } = await import("./generate-core.ts"); await writeTitle(PROJECT_DIR, SESSION_ID, null, transcriptPath, "test-project"); expect(existsSync(titleFile)).toBe(true); expect(existsSync(metaFile)).toBe(true); // Now uses primaryRequest (longest substantive message) const content = readFileSync(titleFile, "utf-8"); expect(content).toBe("Actually, let's refactor the auth module instead"); const meta = JSON.parse(readFileSync(metaFile, "utf-8")); expect(meta.title).toBe("Actually, let's refactor the auth module instead"); expect(meta.shiftCount).toBe(0); }); test("creates log file on title change", async () => { const transcriptPath = createTranscript(SAMPLE_TRANSCRIPT); const logFile = join(PROJECT_DIR, `${SESSION_ID}.log`); const { writeTitle } = await import("./generate-core.ts"); await writeTitle(PROJECT_DIR, SESSION_ID, null, transcriptPath, "test-project"); expect(existsSync(logFile)).toBe(true); const content = readFileSync(logFile, "utf-8"); // Now uses primaryRequest (longest substantive message) expect(content).toContain("Actually, let's refactor the auth module instead"); expect(content).toMatch(/^\d{4}-\d{2}-\d{2}T/); // ISO timestamp }); test("does not append to log when context unchanged", async () => { const transcriptPath = createTranscript(SAMPLE_TRANSCRIPT); const titleFile = join(PROJECT_DIR, `${SESSION_ID}.txt`); const metaFile = join(PROJECT_DIR, `${SESSION_ID}.meta`); const logFile = join(PROJECT_DIR, `${SESSION_ID}.log`); // Pre-set the title and metadata to match what would be generated writeFileSync(titleFile, "Help me fix the login bug"); const lastMessage = "Actually, let's refactor the auth module instead"; // Compute hash the same way as the module let hash = 0; for (let i = 0; i < lastMessage.length; i++) { hash = ((hash << 5) - hash) + lastMessage.charCodeAt(i); hash |= 0; } writeFileSync(metaFile, JSON.stringify({ title: "Help me fix the login bug", shiftCount: 0, lastMessageHash: hash.toString(36), lastMessage: lastMessage })); const { writeTitle } = await import("./generate-core.ts"); await writeTitle(PROJECT_DIR, SESSION_ID, "Help me fix the login bug", transcriptPath, "test-project"); // Log should not exist since context didn't change expect(existsSync(logFile)).toBe(false); }); test("tracks shift count in metadata", async () => { const metaFile = join(PROJECT_DIR, `${SESSION_ID}.meta`); // Set up initial metadata with shift count writeFileSync(metaFile, JSON.stringify({ title: "Initial task", shiftCount: 2, lastMessageHash: "old-hash", lastMessage: "old message" })); const transcriptPath = createTranscript(SAMPLE_TRANSCRIPT); const { writeTitle } = await import("./generate-core.ts"); // This should update since message changed, but without API it keeps current title await writeTitle(PROJECT_DIR, SESSION_ID, "Initial task", transcriptPath, "test-project"); const meta = JSON.parse(readFileSync(metaFile, "utf-8")); // Without API, should keep existing shift count expect(meta.shiftCount).toBe(2); }); }); // --- Unit Tests for sanitizeTitle --- describe("sanitizeTitle", () => { test("returns clean title unchanged", async () => { const { sanitizeTitle } = await import("./generate-core.ts"); expect(sanitizeTitle("Fix OAuth redirect loop")).toBe("Fix OAuth redirect loop"); expect(sanitizeTitle("Add rate limiting to API")).toBe("Add rate limiting to API"); }); test("strips verbose preambles", async () => { const { sanitizeTitle } = await import("./generate-core.ts"); expect(sanitizeTitle("Based on the context, Fix OAuth Loop")).toBe("Fix OAuth Loop"); expect(sanitizeTitle("Here's a title: Debug CI Pipeline")).toBe("Debug CI Pipeline"); expect(sanitizeTitle("The title is Add User Auth")).toBe("Add User Auth"); expect(sanitizeTitle("Title: Refactor Auth Module")).toBe("Refactor Auth Module"); }); test("strips surrounding quotes", async () => { const { sanitizeTitle } = await import("./generate-core.ts"); expect(sanitizeTitle('"Fix OAuth redirect loop"')).toBe("Fix OAuth redirect loop"); expect(sanitizeTitle("'Add rate limiting to API'")).toBe("Add rate limiting to API"); }); test("takes first line only", async () => { const { sanitizeTitle } = await import("./generate-core.ts"); const multiline = "Fix OAuth Loop\nThis is extra explanation"; expect(sanitizeTitle(multiline)).toBe("Fix OAuth Loop"); }); test("rejects titles that are too long", async () => { const { sanitizeTitle } = await import("./generate-core.ts"); const longTitle = "This is a very long title with way too many words that should be rejected"; expect(sanitizeTitle(longTitle)).toBeNull(); const tooManyChars = "A".repeat(65); expect(sanitizeTitle(tooManyChars)).toBeNull(); }); test("rejects titles that are too short", async () => { const { sanitizeTitle } = await import("./generate-core.ts"); expect(sanitizeTitle("Fix")).toBeNull(); // One word expect(sanitizeTitle("OK")).toBeNull(); // Too short expect(sanitizeTitle("")).toBeNull(); // Empty }); test("handles complex verbose responses", async () => { const { sanitizeTitle } = await import("./generate-core.ts"); const verbose = 'Based on the context of "Setting Up Self-Hosted Runners", here\'s a precise title:\n"Configure GitHub Self-Hosted Runners"'; expect(sanitizeTitle(verbose)).toBe("Configure GitHub Self-Hosted Runners"); }); test("strips shift-indicator prefixes from model output", async () => { const { sanitizeTitle } = await import("./generate-core.ts"); expect(sanitizeTitle("(2) Master Git Merge Techniques")).toBe("Master Git Merge Techniques"); expect(sanitizeTitle("(3) (3) Update README Strategy")).toBe("Update README Strategy"); }); test("strips multiline reasoning after blank line", async () => { const { sanitizeTitle } = await import("./generate-core.ts"); const withReasoning = "Master Git Merge Techniques\n\nRationale:\n- Shifted from licensing\n- Now discussing merge"; expect(sanitizeTitle(withReasoning)).toBe("Master Git Merge Techniques"); }); test("strips apology preambles", async () => { const { sanitizeTitle } = await import("./generate-core.ts"); expect(sanitizeTitle("I apologize, but I recommend: Fix Auth Module")).toBe("Fix Auth Module"); expect(sanitizeTitle("I recommend: Debug Release Pipeline")).toBe("Debug Release Pipeline"); }); });