From c5aeca3fc4b68711e1ec905e495db176ce8c7e3d Mon Sep 17 00:00:00 2001 From: qwist1233-cpu Date: Sun, 1 Nov 2026 11:55:27 +0300 Subject: [PATCH] fix(claude): read the session rename from the end of a large transcript Importing a Claude session read the whole transcript into one UTF-8 string to look for its `custom-title` record, so a transcript past V8's maximum string length failed the import with `RangeError: Invalid string length` before the incremental history reader ever ran. The scan now reads the file backwards a chunk at a time, which keeps the original intent of not walking the transcript and never holds it as one string. Records without the needle are not decoded, so a transcript with no rename costs a byte search per chunk. Closes #8451 Co-Authored-By: Claude Opus 5 --- .../claude/agent.import-titles.test.ts | 46 +++++++ .../server/agent/providers/claude/agent.ts | 122 +++++++++++++----- 2 files changed, 138 insertions(+), 30 deletions(-) diff --git a/packages/server/src/server/agent/providers/claude/agent.import-titles.test.ts b/packages/server/src/server/agent/providers/claude/agent.import-titles.test.ts index e20b8d6faf..0e30f39dae 100644 --- a/packages/server/src/server/agent/providers/claude/agent.import-titles.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.import-titles.test.ts @@ -1,3 +1,4 @@ +import { constants as bufferConstants } from "node:buffer"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; @@ -236,4 +237,49 @@ describe("importing a renamed Claude session", () => { await expect(importSession(sessionId)).rejects.toThrow(); }); + + test("reads the rename from a transcript too large to hold as one string", async () => { + const sessionId = "oversized-transcript"; + const projectDir = claudeProjectDirSync(SESSION_CWD, { configDir }); + await fs.mkdir(projectDir, { recursive: true }); + const transcript = path.join(projectDir, `${sessionId}.jsonl`); + // A hole stands in for the long session this used to fail on: the file only + // has to be longer than a string can be, and the rename sits at its end. + await fs.writeFile(transcript, ""); + await fs.truncate(transcript, bufferConstants.MAX_STRING_LENGTH + 1); + await fs.appendFile( + transcript, + `\n${[ + userRecord({ sessionId, content: "Trace the slow query" }), + { type: "custom-title", customTitle: "Slow query hunt", sessionId }, + ] + .map((record) => JSON.stringify(record)) + .join("\n")}\n`, + ); + + const imported = await importSession(sessionId); + + expect(imported.config.title).toBe("Slow query hunt"); + }); + + test("reads the rename from behind more records than one read covers", async () => { + const sessionId = "late-records-rename"; + await writeSession({ + sessionId, + records: [ + userRecord({ sessionId, content: "Review this project" }), + { type: "custom-title", customTitle: "Scrollback review", sessionId }, + // One record longer than a single read, then enough records to leave the + // rename several reads back from the end of the file. + userRecord({ sessionId, content: "x".repeat(128 * 1024) }), + ...Array.from({ length: 64 }, (_, index) => + userRecord({ sessionId, content: `${"y".repeat(4 * 1024)} ${index}` }), + ), + ], + }); + + const imported = await importSession(sessionId); + + expect(imported.config.title).toBe("Scrollback review"); + }); }); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index d232185cbb..64cbdb6777 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -6853,40 +6853,104 @@ function normalizeClaudeSessionTitle(title: string | null): string | null { } const CLAUDE_CUSTOM_TITLE_TYPE = "custom-title"; +const CLAUDE_CUSTOM_TITLE_NEEDLE = Buffer.from(`"${CLAUDE_CUSTOM_TITLE_TYPE}"`); +const CLAUDE_TITLE_SCAN_CHUNK_BYTES = 64 * 1024; + +function readClaudeCustomTitleFromRecord(record: Buffer): string | null { + // The needle also matches a prompt that quotes it, so a record still has to + // parse as a `custom-title` entry carrying a usable name. Records without the + // needle are never decoded, so a long one costs a byte search and nothing more. + if (!record.includes(CLAUDE_CUSTOM_TITLE_NEEDLE)) { + return null; + } + let parsed: unknown; + try { + parsed = JSON.parse(record.toString("utf8").trim()); + } catch { + return null; + } + const entry = toObjectRecord(parsed); + if (!entry || entry.isSidechain || entry.type !== CLAUDE_CUSTOM_TITLE_TYPE) { + return null; + } + return normalizeClaudeSessionTitle( + typeof entry.customTitle === "string" ? entry.customTitle : null, + ); +} + +/** + * Reads the records of one window back to front, reporting where the first of + * them starts: what precedes that is a record the window cut in half, which the + * read before it completes. + */ +function scanClaudeCustomTitleBackwards(window: Buffer): { + title: string | null; + recordStart: number; +} { + if (!window.includes(CLAUDE_CUSTOM_TITLE_NEEDLE)) { + // Nothing here to parse. A record the window cut in half is carried over + // whole rather than split, so a needle inside it is still seen once the read + // before it completes that record. + const firstNewline = window.indexOf(10); + return { title: null, recordStart: firstNewline === -1 ? window.length : firstNewline }; + } + // A newline never falls inside a UTF-8 sequence, so splitting the bytes on it + // leaves every complete record decodable on its own. + let recordEnd = window.length; + for ( + let at = window.lastIndexOf(10); + at >= 0; + at = at === 0 ? -1 : window.lastIndexOf(10, at - 1) + ) { + const title = readClaudeCustomTitleFromRecord(window.subarray(at + 1, recordEnd)); + if (title) { + return { title, recordStart: recordEnd }; + } + recordEnd = at; + } + return { title: null, recordStart: recordEnd }; +} /** * The `custom-title` record is re-emitted every time the session is saved, so - * the current name is the last usable one in the file. Searching backwards finds it without - * walking the transcript, which matters because sessions reach tens of - * megabytes. + * the current name is the last usable one in the file. Reading backwards a chunk + * at a time finds it without walking the transcript, which matters because + * sessions reach tens of megabytes, and never holds the file as one string, so a + * transcript past V8's maximum string length still imports. */ -function readLastClaudeCustomTitle(content: string): string | null { - const needle = `"${CLAUDE_CUSTOM_TITLE_TYPE}"`; - let at = content.lastIndexOf(needle); - while (at >= 0) { - const lineStart = content.lastIndexOf("\n", at) + 1; - const lineEnd = content.indexOf("\n", at); - const line = lineEnd === -1 ? content.slice(lineStart) : content.slice(lineStart, lineEnd); - let parsed: unknown; - try { - parsed = JSON.parse(line.trim()); - } catch { - parsed = undefined; - } - const entry = toObjectRecord(parsed); - if (entry && !entry.isSidechain && entry.type === CLAUDE_CUSTOM_TITLE_TYPE) { - const title = normalizeClaudeSessionTitle( - typeof entry.customTitle === "string" ? entry.customTitle : null, +async function readLastClaudeCustomTitle(filePath: string): Promise { + const handle = await fsPromises.open(filePath, "r"); + try { + const stats = await handle.stat(); + if (!stats.isFile()) { + // Opening a directory succeeds on Windows even though reading it does not, + // so reject the way reading the transcript as a file would. + throw Object.assign( + new Error(`EISDIR: illegal operation on a directory, read '${filePath}'`), + { code: "EISDIR", path: filePath }, ); - if (title) { - return title; - } } - // The needle also matches a prompt that quotes it, and a record can carry no - // usable name, so keep looking backwards for one that does. - at = at <= 0 ? -1 : content.lastIndexOf(needle, at - 1); + let end = stats.size; + // What precedes the window's first newline is the tail of a record that the + // next read backwards completes. + let pending = Buffer.alloc(0); + while (end > 0) { + const length = Math.min(CLAUDE_TITLE_SCAN_CHUNK_BYTES, end); + const chunk = Buffer.alloc(length); + await handle.read(chunk, 0, length, end - length); + const window = pending.length > 0 ? Buffer.concat([chunk, pending]) : chunk; + const scan = scanClaudeCustomTitleBackwards(window); + if (scan.title) { + return scan.title; + } + pending = Buffer.from(window.subarray(0, scan.recordStart)); + end -= length; + } + // The first record in the file has no newline in front of it. + return readClaudeCustomTitleFromRecord(pending); + } finally { + await handle.close(); } - return null; } function isErrnoException(error: unknown): error is NodeJS.ErrnoException { @@ -6898,9 +6962,8 @@ async function readClaudeRenamedSessionTitle(input: { sessionId: string; configDir: string; }): Promise { - let content: string; try { - content = await fsPromises.readFile(await claudeTranscriptPath(input), "utf8"); + return await readLastClaudeCustomTitle(await claudeTranscriptPath(input)); } catch (error) { // A session with no transcript on disk has no rename to carry over. Every // other read failure is a real fault and stays explicit. @@ -6909,7 +6972,6 @@ async function readClaudeRenamedSessionTitle(input: { } throw error; } - return readLastClaudeCustomTitle(content); } function normalizeImportablePromptPreview(text: string): string | null {