Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 003ce4e248 fix(claude): scan large transcript records once
Retain unfinished record chunks and assemble each record once instead of copying and rescanning a growing buffer on every backward read. Cover import of a large record after the last explicit rename.
2026-11-01 09:36:26 +00:00
paseo-bot[bot]andqwist1233-cpu a208ccda17 test(app): wait for send readiness in composer recall
The previous prompt can appear and clear its draft before the send acknowledgement arrives. Wait for Send to be enabled before pressing Enter so recall setup does not silently leave the next prompt unsent.

Co-authored-by: qwist1233-cpu <qwist1233@gmail.com>
2026-11-01 09:27:52 +00:00
qwist1233-cpuandClaude Opus 5 c5aeca3fc4 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 <noreply@anthropic.com>
2026-11-01 11:55:27 +03:00
3 changed files with 137 additions and 33 deletions
@@ -3,7 +3,7 @@ import {
composerLocator, composerLocator,
expectComposerDraft, expectComposerDraft,
expectComposerVisible, expectComposerVisible,
submitMessage, fillComposerDraft,
typeIntoFocusedComposer, typeIntoFocusedComposer,
} from "../support/helpers/composer"; } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -12,8 +12,14 @@ function userMessages(page: Page) {
return page.getByTestId("user-message"); return page.getByTestId("user-message");
} }
async function sendDraftWhenReady(page: Page) {
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled();
await pressInComposer(page, "Enter");
}
async function sendAndWaitForMessage(page: Page, text: string, expectedCount: number) { async function sendAndWaitForMessage(page: Page, text: string, expectedCount: number) {
await submitMessage(page, text); await fillComposerDraft(page, text);
await sendDraftWhenReady(page);
await expect(userMessages(page)).toHaveCount(expectedCount, { timeout: 30_000 }); await expect(userMessages(page)).toHaveCount(expectedCount, { timeout: 30_000 });
await expectComposerDraft(page, ""); await expectComposerDraft(page, "");
} }
@@ -49,7 +55,7 @@ test.describe("Composer recall", () => {
await pressInComposer(page, "ArrowUp"); await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "add tests for login"); await expectComposerDraft(page, "add tests for login");
await pressInComposer(page, "Enter"); await sendDraftWhenReady(page);
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 }); await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
await expect(userMessages(page).last()).toContainText("add tests for login"); await expect(userMessages(page).last()).toContainText("add tests for login");
} finally { } finally {
@@ -1,3 +1,4 @@
import { constants as bufferConstants } from "node:buffer";
import * as fs from "node:fs/promises"; import * as fs from "node:fs/promises";
import * as os from "node:os"; import * as os from "node:os";
import * as path from "node:path"; import * as path from "node:path";
@@ -236,4 +237,64 @@ describe("importing a renamed Claude session", () => {
await expect(importSession(sessionId)).rejects.toThrow(); 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");
});
test("imports a large record after the last rename without repeatedly copying it", async () => {
const sessionId = "large-record-after-rename";
await writeSession({
sessionId,
records: [
{ type: "custom-title", customTitle: "Large attachment review", sessionId },
userRecord({ sessionId, content: "x".repeat(96 * 1024 * 1024) }),
],
});
const imported = await importSession(sessionId);
expect(imported.config.title).toBe("Large attachment review");
});
}); });
@@ -6853,40 +6853,79 @@ function normalizeClaudeSessionTitle(title: string | null): string | null {
} }
const CLAUDE_CUSTOM_TITLE_TYPE = "custom-title"; 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,
);
}
/** /**
* The `custom-title` record is re-emitted every time the session is saved, so * 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 * the current name is the last usable one in the file. Reading backwards a chunk
* walking the transcript, which matters because sessions reach tens of * at a time finds it without walking the transcript, which matters because
* megabytes. * 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 { async function readLastClaudeCustomTitle(filePath: string): Promise<string | null> {
const needle = `"${CLAUDE_CUSTOM_TITLE_TYPE}"`; const handle = await fsPromises.open(filePath, "r");
let at = content.lastIndexOf(needle); try {
while (at >= 0) { const stats = await handle.stat();
const lineStart = content.lastIndexOf("\n", at) + 1; if (!stats.isFile()) {
const lineEnd = content.indexOf("\n", at); // Opening a directory succeeds on Windows even though reading it does not,
const line = lineEnd === -1 ? content.slice(lineStart) : content.slice(lineStart, lineEnd); // so reject the way reading the transcript as a file would.
let parsed: unknown; throw Object.assign(
try { new Error(`EISDIR: illegal operation on a directory, read '${filePath}'`),
parsed = JSON.parse(line.trim()); { code: "EISDIR", path: filePath },
} 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,
); );
if (title) {
return title;
}
} }
// The needle also matches a prompt that quotes it, and a record can carry no let end = stats.size;
// usable name, so keep looking backwards for one that does. // Retain unfinished record pieces in reverse file order. Assemble each
at = at <= 0 ? -1 : content.lastIndexOf(needle, at - 1); // complete record once, so a large tool/image record costs linear work.
let pieces: Buffer[] = [];
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);
let recordEnd = chunk.length;
for (
let at = chunk.lastIndexOf(10);
at >= 0;
at = at === 0 ? -1 : chunk.lastIndexOf(10, at - 1)
) {
pieces.push(chunk.subarray(at + 1, recordEnd));
const record = Buffer.concat(pieces.toReversed());
const title = readClaudeCustomTitleFromRecord(record);
if (title) return title;
pieces = [];
recordEnd = at;
}
if (recordEnd > 0) pieces.push(chunk.subarray(0, recordEnd));
end -= length;
}
// The first record in the file has no newline in front of it.
return readClaudeCustomTitleFromRecord(Buffer.concat(pieces.toReversed()));
} finally {
await handle.close();
} }
return null;
} }
function isErrnoException(error: unknown): error is NodeJS.ErrnoException { function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
@@ -6898,9 +6937,8 @@ async function readClaudeRenamedSessionTitle(input: {
sessionId: string; sessionId: string;
configDir: string; configDir: string;
}): Promise<string | null> { }): Promise<string | null> {
let content: string;
try { try {
content = await fsPromises.readFile(await claudeTranscriptPath(input), "utf8"); return await readLastClaudeCustomTitle(await claudeTranscriptPath(input));
} catch (error) { } catch (error) {
// A session with no transcript on disk has no rename to carry over. Every // A session with no transcript on disk has no rename to carry over. Every
// other read failure is a real fault and stays explicit. // other read failure is a real fault and stays explicit.
@@ -6909,7 +6947,6 @@ async function readClaudeRenamedSessionTitle(input: {
} }
throw error; throw error;
} }
return readLastClaudeCustomTitle(content);
} }
function normalizeImportablePromptPreview(text: string): string | null { function normalizeImportablePromptPreview(text: string): string | null {