diff --git a/packages/app/e2e/browser/composer-recall.spec.ts b/packages/app/e2e/browser/composer-recall.spec.ts index 368d23409c..a58a8ff59e 100644 --- a/packages/app/e2e/browser/composer-recall.spec.ts +++ b/packages/app/e2e/browser/composer-recall.spec.ts @@ -3,7 +3,7 @@ import { composerLocator, expectComposerDraft, expectComposerVisible, - submitMessage, + fillComposerDraft, typeIntoFocusedComposer, } from "../support/helpers/composer"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; @@ -12,8 +12,14 @@ function userMessages(page: Page) { 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) { - await submitMessage(page, text); + await fillComposerDraft(page, text); + await sendDraftWhenReady(page); await expect(userMessages(page)).toHaveCount(expectedCount, { timeout: 30_000 }); await expectComposerDraft(page, ""); } @@ -49,7 +55,7 @@ test.describe("Composer recall", () => { await pressInComposer(page, "ArrowUp"); 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).last()).toContainText("add tests for login"); } finally { 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..a826645d4c 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,64 @@ 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"); + }); + + 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"); + }); }); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index d232185cbb..ebb37e13f8 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -6853,40 +6853,79 @@ 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, + ); +} /** * 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; + // Retain unfinished record pieces in reverse file order. Assemble each + // 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 { @@ -6898,9 +6937,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 +6947,6 @@ async function readClaudeRenamedSessionTitle(input: { } throw error; } - return readLastClaudeCustomTitle(content); } function normalizeImportablePromptPreview(text: string): string | null {