mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 14:12:51 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
003ce4e248 | ||
|
|
a208ccda17 | ||
|
|
c5aeca3fc4 |
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user