diff --git a/src/utils/agent-sessions/readers/claude.test.ts b/src/utils/agent-sessions/readers/claude.test.ts index 2ac869489f..9f0f6920db 100644 --- a/src/utils/agent-sessions/readers/claude.test.ts +++ b/src/utils/agent-sessions/readers/claude.test.ts @@ -3,6 +3,8 @@ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { SafeJSON } from "@genesiscz/utils/json"; +import { claudeHistoryReader } from "../compact-readers"; +import { searchHistorySource } from "../search-source"; import type { NativeSessionSource } from "../types"; import { createClaudeHistoryOperations, readClaudeMetadata, readClaudeRecords, scanClaudeRecords } from "./claude"; @@ -305,6 +307,96 @@ test("large metadata reads preserve head and tail fields with a bounded search c expect(result.metadata?.summary).toBe("Latest tail summary"); }); +test("Claude Code's own session name (ai-title) is the title until a /rename, which still wins", async () => { + const { path, source } = fixture(); + const prompt = { + type: "user", + sessionId: MAIN_ID, + timestamp: "2026-09-01T11:00:00.000Z", + message: { content: "pls research whether we are affected" }, + }; + writeFileSync( + path, + jsonl( + prompt, + { type: "ai-title", aiTitle: "Early guess", sessionId: MAIN_ID }, + { type: "ai-title", aiTitle: "Hotfix research", sessionId: MAIN_ID } + ) + ); + + const named = await readClaudeMetadata(source); + + expect(named.metadata?.summary).toBe("Hotfix research"); + expect(named.metadata?.customTitle).toBeNull(); + expect(named.metadata?.firstPrompt).toBe("pls research whether we are affected"); + + writeFileSync( + path, + jsonl( + prompt, + { type: "ai-title", aiTitle: "Hotfix research", sessionId: MAIN_ID }, + { type: "custom-title", customTitle: "My own name", sessionId: MAIN_ID } + ) + ); + + const renamed = await readClaudeMetadata(source); + + expect(renamed.metadata?.customTitle).toBe("My own name"); + expect(renamed.metadata?.summary).toBe("Hotfix research"); +}); + +test("ai-title is searchable as a system record when its text is absent from the prompt", async () => { + const { path, source } = fixture(); + const title = { type: "ai-title", aiTitle: "Hotfix research", sessionId: MAIN_ID }; + writeFileSync( + path, + jsonl( + { + type: "user", + sessionId: MAIN_ID, + timestamp: "2026-09-01T11:00:00.000Z", + message: { content: "Investigate the deployment" }, + }, + title + ) + ); + const { metadata } = await readClaudeMetadata(source); + + if (!metadata) { + throw new Error("Fixture metadata missing"); + } + + const result = await searchHistorySource({ + source, + reader: claudeHistoryReader, + metadata: { + ...metadata, + providerId: "claude", + sourceKey: "fixture-source", + parentNativeId: null, + projectDirectory: metadata.projectDirectory ?? null, + lastTimestamp: metadata.lastTimestamp ?? null, + archived: false, + resumeMode: "native", + identityStatus: "resolved", + boundedFields: metadata.boundedFields ?? [], + }, + filters: { query: "Hotfix research" }, + }); + + expect(result?.matchedLocators).toEqual(["jsonl:2"]); + expect(result?.matchedText).toBe("Hotfix research"); + expect(result?.firstPrompt).toBe("Investigate the deployment"); + const selected = await readClaudeRecords(source, { locators: result?.matchedLocators ?? [] }); + expect(selected.complete).toBe(true); + expect(selected.records[0]).toMatchObject({ + role: "system", + metadataChanges: { summary: "Hotfix research" }, + entries: [{ role: "system", text: "Hotfix research", line: 2 }], + original: SafeJSON.stringify(title, { strict: true }), + }); +}); + test("compact operations factory binds metadata, scan, and selected-record reads", async () => { const { path, source } = fixture(); writeFileSync( diff --git a/src/utils/agent-sessions/readers/claude.ts b/src/utils/agent-sessions/readers/claude.ts index c42af7c93a..f2075b0738 100644 --- a/src/utils/agent-sessions/readers/claude.ts +++ b/src/utils/agent-sessions/readers/claude.ts @@ -218,17 +218,30 @@ function contentEntries(options: { return entries; } +/** + * The name Claude Code gave the session itself: an `ai-title` line (the name its /resume list and the terminal tab + * show), or the `summary` line older versions wrote. Both are stored as the summary, the newest line winning; a + * `/rename` (`custom-title`) still comes first. Without `ai-title` the widget listed "Kalendora hotfix research" under + * its first prompt, and Martin could not find it (2026-10-24). + */ +function autoTitleOf(row: JsonRecord): string { + return row.type === "ai-title" ? asText(row.aiTitle) : row.type === "summary" ? asText(row.summary) : ""; +} + function claudeRecordContext(row: JsonRecord): Pick { const timestamp = asText(row.timestamp) || undefined; const role = row.type === "user" || row.type === "assistant" ? row.type - : row.type === "queue-operation" || row.type === "summary" || row.type === "custom-title" + : row.type === "queue-operation" || + row.type === "summary" || + row.type === "ai-title" || + row.type === "custom-title" ? "system" : undefined; const sessionId = asText(row.sessionId); const customTitle = row.type === "custom-title" ? asText(row.customTitle) : ""; - const summary = row.type === "summary" ? asText(row.summary) : ""; + const summary = autoTitleOf(row); const gitBranch = asText(row.gitBranch); const cwd = asText(row.cwd); const metadataChanges = { @@ -257,10 +270,15 @@ function recordEntries(row: JsonRecord, line: number, tools: ToolContext): Nativ tools, }); } - if (row.type === "queue-operation" || row.type === "summary" || row.type === "custom-title") { + if ( + row.type === "queue-operation" || + row.type === "summary" || + row.type === "ai-title" || + row.type === "custom-title" + ) { const text = - row.type === "summary" - ? asText(row.summary) + row.type === "summary" || row.type === "ai-title" + ? autoTitleOf(row) : row.type === "custom-title" ? asText(row.customTitle) : asText(row.content); @@ -454,8 +472,8 @@ async function readClaudeMetadataUncounted( function apply(row: JsonRecord): void { validRecords++; - if (row.type === "summary" && asText(row.summary)) { - summary = asText(row.summary); + if (autoTitleOf(row)) { + summary = autoTitleOf(row); } if (row.type === "custom-title" && asText(row.customTitle)) { customTitle = asText(row.customTitle); @@ -602,7 +620,7 @@ export type ClaudeHistoryOperations = Pick, "parse export function createClaudeHistoryOperations(): ClaudeHistoryOperations { return { - parserVersion: "5", + parserVersion: "7", readMetadata: readClaudeMetadata, scan: scanClaudeRecords, readRecords: readClaudeRecords,