Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions src/utils/agent-sessions/readers/claude.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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(
Expand Down
34 changes: 26 additions & 8 deletions src/utils/agent-sessions/readers/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HistorySourceRecord, "timestamp" | "role" | "metadataChanges"> {
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);
Comment thread
genesiscz marked this conversation as resolved.
const gitBranch = asText(row.gitBranch);
const cwd = asText(row.cwd);
const metadataChanges = {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -602,7 +620,7 @@ export type ClaudeHistoryOperations = Pick<NativeSessionReader<"claude">, "parse

export function createClaudeHistoryOperations(): ClaudeHistoryOperations {
return {
parserVersion: "5",
parserVersion: "7",
readMetadata: readClaudeMetadata,
scan: scanClaudeRecords,
readRecords: readClaudeRecords,
Expand Down
Loading