mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 14:12:51 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
003ce4e248 | ||
|
|
a208ccda17 | ||
|
|
c5aeca3fc4 | ||
|
|
fa89738e3a |
@@ -135,32 +135,6 @@ jobs:
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Backport electron-builder#10172 to the pinned build tool only.
|
||||
# Remove this step when app-builder-lib is upgraded to a release containing it.
|
||||
- name: Repair temporary keychain password in signing tool
|
||||
shell: bash
|
||||
run: |
|
||||
node <<'KEYCHAIN_FIX'
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const manifestPath = require.resolve('app-builder-lib/package.json');
|
||||
if (JSON.parse(fs.readFileSync(manifestPath, 'utf8')).version !== '26.8.1') {
|
||||
throw new Error('Reassess/remove the signing backport for the new app-builder-lib version');
|
||||
}
|
||||
const sourcePath = path.join(path.dirname(manifestPath), 'out/codeSign/macCodeSign.js');
|
||||
let source = fs.readFileSync(sourcePath, 'utf8');
|
||||
const replacements = [
|
||||
['importCerts(keychainFile, certPaths, cscPasswords)', 'importCerts(keychainFile, certPaths, cscPasswords, keychainPassword)'],
|
||||
['importCerts(keychainFile, paths, keyPasswords)', 'importCerts(keychainFile, paths, keyPasswords, keychainPassword)'],
|
||||
['["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, keychainFile]', '["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", keychainPassword, keychainFile]'],
|
||||
];
|
||||
for (const [before, after] of replacements) {
|
||||
if (source.split(before).length !== 2) throw new Error('Unexpected signing tool source; refusing to patch');
|
||||
source = source.replace(before, after);
|
||||
}
|
||||
fs.writeFileSync(sourcePath, source);
|
||||
KEYCHAIN_FIX
|
||||
|
||||
- name: Set desktop package version from tag
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -127,7 +127,7 @@ buildNpmPackage {
|
||||
# The Nix output is not a distributable DMG, so leave it unsigned and
|
||||
# disable the hardened runtime that requires a matching signature.
|
||||
CSC_IDENTITY_AUTO_DISCOVERY=false \
|
||||
../../node_modules/.bin/electron-builder \
|
||||
npm exec --offline -- electron-builder \
|
||||
--config electron-builder.yml \
|
||||
--dir \
|
||||
--mac \
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-CeT2PtYIAFwvBC9LIku8VkTl0pvKxYzSCn0QW2ManfU=
|
||||
sha256-KN/AVZV/o0TleLLpmggSrdJqjFDJ/NJYVHiC54xb4jQ=
|
||||
|
||||
Generated
+1018
-1295
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"@types/node": "24.6.0",
|
||||
"@types/ws": "^8.5.14",
|
||||
"electron": "44.2.0",
|
||||
"electron-builder": "26.8.1",
|
||||
"electron-builder": "26.18.0",
|
||||
"typescript": "5.9.3",
|
||||
"unzip-crx-3": "^0.2.0",
|
||||
"vitest": "^4.1.6",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string | null> {
|
||||
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<string | null> {
|
||||
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 {
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { createReadStream } from "node:fs";
|
||||
import { appendFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import type { SessionStore, SessionStoreEntry } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
const APPEND_CHUNK_LENGTH = 1 << 20;
|
||||
|
||||
export function projectFolderSessionStore(projectFolder: string): SessionStore {
|
||||
const transcriptPath = (sessionId: string) => path.join(projectFolder, `${sessionId}.jsonl`);
|
||||
return {
|
||||
async load({ sessionId }) {
|
||||
const input = createReadStream(transcriptPath(sessionId), { encoding: "utf8" });
|
||||
const lines = createInterface({ input, crlfDelay: Infinity });
|
||||
const entries: SessionStoreEntry[] = [];
|
||||
try {
|
||||
for await (const line of lines) {
|
||||
const entry = parseTranscriptEntry(line);
|
||||
if (entry) entries.push(entry);
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
||||
throw error;
|
||||
} finally {
|
||||
lines.close();
|
||||
input.destroy();
|
||||
}
|
||||
return entries;
|
||||
},
|
||||
async append({ sessionId }, entries) {
|
||||
let chunk = "";
|
||||
for (const entry of entries) {
|
||||
chunk += `${JSON.stringify(entry)}\n`;
|
||||
if (chunk.length >= APPEND_CHUNK_LENGTH) {
|
||||
await appendFile(transcriptPath(sessionId), chunk, "utf8");
|
||||
chunk = "";
|
||||
}
|
||||
}
|
||||
await appendFile(transcriptPath(sessionId), chunk, "utf8");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseTranscriptEntry(line: string): SessionStoreEntry | null {
|
||||
if (!line.trim()) return null;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch {
|
||||
// Skip a line that is not JSON, as the SDK's own transcript reader does.
|
||||
return null;
|
||||
}
|
||||
return isSessionStoreEntry(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function isSessionStoreEntry(value: unknown): value is SessionStoreEntry {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
typeof (value as { type?: unknown }).type === "string"
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import path from "node:path";
|
||||
import { parentPort, workerData } from "node:worker_threads";
|
||||
import { forkSession } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { projectFolderSessionStore } from "./rewind-session-store.js";
|
||||
|
||||
export interface ClaudeForkRequest {
|
||||
sessionId: string;
|
||||
upToMessageId: string;
|
||||
transcriptPath: string;
|
||||
}
|
||||
|
||||
export type ClaudeForkReply =
|
||||
| { kind: "forked"; sessionId: string }
|
||||
| {
|
||||
kind: "failed";
|
||||
message: string;
|
||||
stack: string | undefined;
|
||||
properties: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const port = parentPort!;
|
||||
const request: ClaudeForkRequest = workerData;
|
||||
try {
|
||||
// On its own, the SDK looks for the session under the daemon's CLAUDE_CONFIG_DIR, while a
|
||||
// provider can point Claude Code at another one. Forking through a store rooted at the
|
||||
// transcript's folder reads the session where Claude Code wrote it and writes the fork beside it.
|
||||
const fork = await forkSession(request.sessionId, {
|
||||
upToMessageId: request.upToMessageId,
|
||||
sessionStore: projectFolderSessionStore(path.dirname(request.transcriptPath)),
|
||||
});
|
||||
port.postMessage({ kind: "forked", sessionId: fork.sessionId } satisfies ClaudeForkReply);
|
||||
} catch (cause) {
|
||||
const error = cause instanceof Error ? cause : new Error(String(cause));
|
||||
const failure = { kind: "failed", message: error.message, stack: error.stack } as const;
|
||||
try {
|
||||
port.postMessage({ ...failure, properties: { ...error } } satisfies ClaudeForkReply);
|
||||
} catch (cloneError) {
|
||||
if (!(cloneError instanceof DOMException) || cloneError.name !== "DataCloneError") {
|
||||
throw cloneError;
|
||||
}
|
||||
port.postMessage({ ...failure, properties: {} } satisfies ClaudeForkReply);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { subscribe, unsubscribe } from "node:diagnostics_channel";
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
@@ -11,11 +10,9 @@ import {
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { Worker } from "node:worker_threads";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { forkSession, type Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
import { measureMainThreadWork } from "../../../../test-utils/main-thread-work.js";
|
||||
import {
|
||||
findSteeredMessageParent,
|
||||
revertClaudeConversation,
|
||||
@@ -23,7 +20,6 @@ import {
|
||||
revertClaudeFiles,
|
||||
realClaudeRewindSdk,
|
||||
} from "./rewind.js";
|
||||
import { projectFolderSessionStore } from "./rewind-session-store.js";
|
||||
import { FakeClaudeSdk } from "./test-rewind-claude-sdk.js";
|
||||
|
||||
describe("Claude rewind", () => {
|
||||
@@ -282,196 +278,8 @@ describe("Claude rewind against the Claude SDK", () => {
|
||||
"tool output",
|
||||
]);
|
||||
});
|
||||
|
||||
test("writes the fork beside the original and leaves the original untouched", async () => {
|
||||
tempRoot = mkdtempSync(path.join(tmpdir(), "claude-rewind-fork-"));
|
||||
const projectDir = path.join(tempRoot, "projects", "-repo");
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
const sessionId = randomUUID();
|
||||
const firstUser = randomUUID();
|
||||
const firstReply = randomUUID();
|
||||
const secondUser = randomUUID();
|
||||
const transcriptPath = path.join(projectDir, `${sessionId}.jsonl`);
|
||||
writeTranscript(transcriptPath, sessionId, [
|
||||
{ type: "user", uuid: firstUser, parentUuid: null, text: "first" },
|
||||
{ type: "assistant", uuid: firstReply, parentUuid: firstUser, text: "first reply" },
|
||||
{ type: "user", uuid: secondUser, parentUuid: firstReply, text: "second" },
|
||||
{ type: "assistant", uuid: randomUUID(), parentUuid: secondUser, text: "second reply" },
|
||||
]);
|
||||
const original = readFileSync(transcriptPath, "utf8");
|
||||
|
||||
const fork = await realClaudeRewindSdk.forkSession(sessionId, {
|
||||
upToMessageId: secondUser,
|
||||
transcriptPath,
|
||||
});
|
||||
|
||||
const forkPath = path.join(projectDir, `${fork.sessionId}.jsonl`);
|
||||
expect(readdirSync(projectDir).sort()).toEqual(
|
||||
[`${sessionId}.jsonl`, `${fork.sessionId}.jsonl`].sort(),
|
||||
);
|
||||
expect(readMessageTexts(forkPath)).toEqual(["first", "first reply", "second"]);
|
||||
const forkSessionIds = readFileSync(forkPath, "utf8")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => (JSON.parse(line) as { sessionId: string }).sessionId);
|
||||
expect(forkSessionIds).toEqual([
|
||||
fork.sessionId,
|
||||
fork.sessionId,
|
||||
fork.sessionId,
|
||||
fork.sessionId,
|
||||
]);
|
||||
expect(readFileSync(transcriptPath, "utf8")).toBe(original);
|
||||
});
|
||||
|
||||
test("rejects with the SDK's own error when the transcript is missing", async () => {
|
||||
tempRoot = mkdtempSync(path.join(tmpdir(), "claude-rewind-missing-"));
|
||||
const sessionId = randomUUID();
|
||||
|
||||
await expect(
|
||||
realClaudeRewindSdk.forkSession(sessionId, {
|
||||
upToMessageId: randomUUID(),
|
||||
transcriptPath: path.join(tempRoot, `${sessionId}.jsonl`),
|
||||
}),
|
||||
).rejects.toThrow(new Error(`Session ${sessionId} not found`));
|
||||
});
|
||||
|
||||
test("rejects with the name and message of the error the SDK raises", async () => {
|
||||
tempRoot = mkdtempSync(path.join(tmpdir(), "claude-rewind-invalid-"));
|
||||
const sessionId = randomUUID();
|
||||
const sdkError = await forkSession(sessionId, { upToMessageId: "not-a-uuid" }).then(
|
||||
() => null,
|
||||
(error: Error) => error,
|
||||
);
|
||||
expect(sdkError).toBeInstanceOf(Error);
|
||||
|
||||
await expect(
|
||||
realClaudeRewindSdk.forkSession(sessionId, {
|
||||
upToMessageId: "not-a-uuid",
|
||||
transcriptPath: path.join(tempRoot, `${sessionId}.jsonl`),
|
||||
}),
|
||||
).rejects.toMatchObject({ name: sdkError?.name, message: sdkError?.message });
|
||||
});
|
||||
|
||||
test("keeps a filesystem error's code when the fork fails in the worker", async () => {
|
||||
tempRoot = mkdtempSync(path.join(tmpdir(), "claude-rewind-eisdir-"));
|
||||
const sessionId = randomUUID();
|
||||
const transcriptPath = path.join(tempRoot, `${sessionId}.jsonl`);
|
||||
mkdirSync(transcriptPath);
|
||||
|
||||
await expect(
|
||||
realClaudeRewindSdk.forkSession(sessionId, { upToMessageId: randomUUID(), transcriptPath }),
|
||||
).rejects.toMatchObject({ name: "Error", code: "EISDIR" });
|
||||
});
|
||||
|
||||
test("forks one conversation at a time", async () => {
|
||||
tempRoot = mkdtempSync(path.join(tmpdir(), "claude-rewind-queue-"));
|
||||
const projectDir = path.join(tempRoot, "projects", "-repo");
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
const forks = [randomUUID(), randomUUID()].map((sessionId) => {
|
||||
const user = randomUUID();
|
||||
const transcriptPath = path.join(projectDir, `${sessionId}.jsonl`);
|
||||
writeTranscript(transcriptPath, sessionId, [
|
||||
{ type: "user", uuid: user, parentUuid: null, text: "first" },
|
||||
{ type: "assistant", uuid: randomUUID(), parentUuid: user, text: "first reply" },
|
||||
]);
|
||||
return { sessionId, upToMessageId: user, transcriptPath };
|
||||
});
|
||||
const events: string[] = [];
|
||||
let started = 0;
|
||||
function recordWorker(message: unknown): void {
|
||||
if (typeof message !== "object" || message === null || !("worker" in message)) return;
|
||||
if (!(message.worker instanceof Worker)) return;
|
||||
const fork = ++started;
|
||||
events.push(`start ${fork}`);
|
||||
message.worker.once("message", () => events.push(`reply ${fork}`));
|
||||
}
|
||||
|
||||
subscribe("worker_threads", recordWorker);
|
||||
try {
|
||||
await Promise.all(
|
||||
forks.map(({ sessionId, upToMessageId, transcriptPath }) =>
|
||||
realClaudeRewindSdk.forkSession(sessionId, { upToMessageId, transcriptPath }),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
unsubscribe("worker_threads", recordWorker);
|
||||
}
|
||||
|
||||
expect(events).toEqual(["start 1", "reply 1", "start 2", "reply 2"]);
|
||||
});
|
||||
|
||||
test("forks a long transcript without blocking the daemon's event loop", async () => {
|
||||
tempRoot = mkdtempSync(path.join(tmpdir(), "claude-rewind-long-"));
|
||||
const projectDir = path.join(tempRoot, "projects", "-repo");
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
const sessionId = randomUUID();
|
||||
const transcriptPath = path.join(projectDir, `${sessionId}.jsonl`);
|
||||
const turns = 43_000;
|
||||
const lastUser = writeLongTranscript(transcriptPath, sessionId, turns);
|
||||
|
||||
const { result: fork, blockingMs } = await measureMainThreadWork(
|
||||
"claude fork main-thread work",
|
||||
() => realClaudeRewindSdk.forkSession(sessionId, { upToMessageId: lastUser, transcriptPath }),
|
||||
);
|
||||
|
||||
expect(blockingMs).toBeLessThan(50);
|
||||
const forkLines = readFileSync(path.join(projectDir, `${fork.sessionId}.jsonl`), "utf8")
|
||||
.trim()
|
||||
.split("\n");
|
||||
expect(forkLines).toHaveLength(turns * 2);
|
||||
expect(JSON.parse(forkLines[turns * 2 - 2]!).forkedFrom).toEqual({
|
||||
sessionId,
|
||||
messageUuid: lastUser,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Claude project folder session store", () => {
|
||||
let root: string | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (root) rmSync(root, { recursive: true, force: true });
|
||||
root = null;
|
||||
});
|
||||
|
||||
test("appends entries after existing ones, byte for byte, and loads them back", async () => {
|
||||
root = mkdtempSync(path.join(tmpdir(), "claude-rewind-store-"));
|
||||
const store = projectFolderSessionStore(root);
|
||||
const key = { projectKey: "-repo", sessionId: randomUUID() };
|
||||
const transcriptPath = path.join(root, `${key.sessionId}.jsonl`);
|
||||
const existing = { type: "user", uuid: randomUUID(), message: { content: "already there" } };
|
||||
writeFileSync(transcriptPath, `${JSON.stringify(existing)}\n`);
|
||||
const entries = Array.from({ length: 4_000 }, (_, index) => ({
|
||||
type: "assistant",
|
||||
uuid: randomUUID(),
|
||||
message: { content: `reply ${index} ✓ ${"x".repeat(1_000)}` },
|
||||
}));
|
||||
|
||||
await store.append(key, entries);
|
||||
|
||||
expect(readFileSync(transcriptPath, "utf8")).toBe(
|
||||
[existing, ...entries].map((entry) => `${JSON.stringify(entry)}\n`).join(""),
|
||||
);
|
||||
expect(await store.load(key)).toEqual([existing, ...entries]);
|
||||
});
|
||||
});
|
||||
|
||||
function writeLongTranscript(transcriptPath: string, sessionId: string, turns: number): string {
|
||||
const toolOutput = 'line 1: "quoted" output\twith\\escapes\n'.repeat(4);
|
||||
const lines: TranscriptLine[] = [];
|
||||
let parentUuid: string | null = null;
|
||||
let lastUser = "";
|
||||
for (let turn = 0; turn < turns; turn++) {
|
||||
lastUser = randomUUID();
|
||||
const reply = randomUUID();
|
||||
lines.push({ type: "user", uuid: lastUser, parentUuid, text: `question ${turn}` });
|
||||
lines.push({ type: "assistant", uuid: reply, parentUuid: lastUser, text: toolOutput });
|
||||
parentUuid = reply;
|
||||
}
|
||||
writeTranscript(transcriptPath, sessionId, lines);
|
||||
return lastUser;
|
||||
}
|
||||
|
||||
test.runIf(process.platform === "linux")(
|
||||
"releases the transcript descriptor after an early steer match",
|
||||
async () => {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { createReadStream } from "node:fs";
|
||||
import { appendFile, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import { Worker } from "node:worker_threads";
|
||||
import type { Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import pLimit from "p-limit";
|
||||
import { resolveWorkerEntrypoint } from "../../../../utils/worker-entrypoint.js";
|
||||
import type { ClaudeForkReply, ClaudeForkRequest } from "./rewind-worker.js";
|
||||
import {
|
||||
forkSession as claudeForkSession,
|
||||
type Query,
|
||||
type SessionStore,
|
||||
type SessionStoreEntry,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
export interface ClaudeRewindSdk {
|
||||
forkSession(
|
||||
@@ -13,42 +16,60 @@ export interface ClaudeRewindSdk {
|
||||
): Promise<{ sessionId: string }>;
|
||||
}
|
||||
|
||||
const source = import.meta.url.endsWith(".ts");
|
||||
const workerUrl = new URL(source ? "./rewind-worker.ts" : "./rewind-worker.js", import.meta.url);
|
||||
const rewindWorker = resolveWorkerEntrypoint(workerUrl);
|
||||
const forks = pLimit(1);
|
||||
|
||||
// On its own, the SDK looks for the session under the daemon's CLAUDE_CONFIG_DIR, while a
|
||||
// provider can point Claude Code at another one. Forking through a store rooted at the
|
||||
// transcript's folder reads the session where Claude Code wrote it and writes the fork beside it.
|
||||
export const realClaudeRewindSdk: ClaudeRewindSdk = {
|
||||
forkSession: (sessionId, options) =>
|
||||
forks(() =>
|
||||
forkInWorker({
|
||||
sessionId,
|
||||
upToMessageId: options.upToMessageId,
|
||||
transcriptPath: options.transcriptPath,
|
||||
}),
|
||||
),
|
||||
claudeForkSession(sessionId, {
|
||||
upToMessageId: options.upToMessageId,
|
||||
sessionStore: projectFolderSessionStore(path.dirname(options.transcriptPath)),
|
||||
}),
|
||||
};
|
||||
|
||||
function forkInWorker(request: ClaudeForkRequest): Promise<{ sessionId: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const worker = new Worker(rewindWorker.entrypoint, {
|
||||
eval: rewindWorker.eval,
|
||||
execArgv: [],
|
||||
workerData: request,
|
||||
});
|
||||
worker.once("message", (reply: ClaudeForkReply) => {
|
||||
void worker.terminate();
|
||||
if (reply.kind === "forked") {
|
||||
resolve({ sessionId: reply.sessionId });
|
||||
return;
|
||||
function projectFolderSessionStore(projectFolder: string): SessionStore {
|
||||
const transcriptPath = (sessionId: string) => path.join(projectFolder, `${sessionId}.jsonl`);
|
||||
return {
|
||||
async load({ sessionId }) {
|
||||
let text: string;
|
||||
try {
|
||||
text = await readFile(transcriptPath(sessionId), "utf8");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
||||
throw error;
|
||||
}
|
||||
const error = new Error(reply.message);
|
||||
error.stack = reply.stack;
|
||||
reject(Object.assign(error, reply.properties));
|
||||
});
|
||||
worker.once("error", reject);
|
||||
worker.once("exit", (code) => reject(new Error(`Claude rewind worker exited (code ${code})`)));
|
||||
});
|
||||
return parseTranscriptEntries(text);
|
||||
},
|
||||
async append({ sessionId }, entries) {
|
||||
const lines = entries.map((entry) => `${JSON.stringify(entry)}\n`).join("");
|
||||
await appendFile(transcriptPath(sessionId), lines, "utf8");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseTranscriptEntries(text: string): SessionStoreEntry[] {
|
||||
const entries: SessionStoreEntry[] = [];
|
||||
for (const line of text.split("\n")) {
|
||||
if (!line.trim()) continue;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch {
|
||||
// Skip a line that is not JSON, as the SDK's own transcript reader does.
|
||||
continue;
|
||||
}
|
||||
if (isSessionStoreEntry(parsed)) entries.push(parsed);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function isSessionStoreEntry(value: unknown): value is SessionStoreEntry {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
typeof (value as { type?: unknown }).type === "string"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { Worker } from "node:worker_threads";
|
||||
import pLimit from "p-limit";
|
||||
import { ZodError } from "zod";
|
||||
import { resolveWorkerEntrypoint } from "../../utils/worker-entrypoint.js";
|
||||
import type * as build from "./compiler-build.js";
|
||||
import type { PluginManifest } from "./manifest.js";
|
||||
import type { CompilerReply } from "./compiler-worker.js";
|
||||
@@ -36,7 +37,9 @@ const workerUrl = new URL(
|
||||
);
|
||||
// Resolve the development loader once at module initialization, never during a
|
||||
// compile request. Packaged workers execute ordinary JavaScript without tsx.
|
||||
const compilerWorker = resolveWorkerEntrypoint(workerUrl);
|
||||
const workerEntrypoint = source
|
||||
? `import(${JSON.stringify(pathToFileURL(createRequire(import.meta.url).resolve("tsx/esm/api")).href)}).then(({ register }) => { register(); return import(${JSON.stringify(workerUrl.href)}); });`
|
||||
: workerUrl;
|
||||
|
||||
// Reuse the isolate during a build batch (notably daemon bootstrap), then release
|
||||
// TypeScript's heap when idle. Only this owner admits work or stops the worker.
|
||||
@@ -71,10 +74,7 @@ class PluginCompiler {
|
||||
}
|
||||
|
||||
private startWorker(): Worker {
|
||||
const worker = new Worker(compilerWorker.entrypoint, {
|
||||
eval: compilerWorker.eval,
|
||||
execArgv: [],
|
||||
});
|
||||
const worker = new Worker(workerEntrypoint, { eval: source, execArgv: [] });
|
||||
this.worker = worker;
|
||||
// Keep error/exit ownership while idle too. The next caller gets a fresh
|
||||
// isolate after a crash; author build commands are never retried.
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { parentPort, workerData } from "node:worker_threads";
|
||||
import { expandTilde } from "../path.js";
|
||||
|
||||
const port = parentPort!;
|
||||
const input: string = workerData;
|
||||
port.postMessage(expandTilde(input));
|
||||
@@ -1,34 +0,0 @@
|
||||
import { once } from "node:events";
|
||||
import { Worker } from "node:worker_threads";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { expandTilde } from "./path.js";
|
||||
import { resolveWorkerEntrypoint } from "./worker-entrypoint.js";
|
||||
|
||||
describe("worker entrypoint", () => {
|
||||
test("starts a compiled worker from its own URL", () => {
|
||||
const entry = resolveWorkerEntrypoint(
|
||||
new URL("file:///app/dist/server/server/plugins/compiler-worker.js"),
|
||||
);
|
||||
|
||||
expect({ ...entry, entrypoint: String(entry.entrypoint) }).toEqual({
|
||||
entrypoint: "file:///app/dist/server/server/plugins/compiler-worker.js",
|
||||
eval: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("runs a TypeScript worker, with its .js imports, through tsx", async () => {
|
||||
const entry = resolveWorkerEntrypoint(
|
||||
new URL("./test-fixtures/expand-tilde-worker.ts", import.meta.url),
|
||||
);
|
||||
const worker = new Worker(entry.entrypoint, {
|
||||
eval: entry.eval,
|
||||
execArgv: [],
|
||||
workerData: "~/project",
|
||||
});
|
||||
|
||||
const [expanded] = await once(worker, "message");
|
||||
|
||||
expect(expanded).toBe(expandTilde("~/project"));
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
export interface WorkerEntrypoint {
|
||||
entrypoint: string | URL;
|
||||
eval: boolean;
|
||||
}
|
||||
|
||||
export function resolveWorkerEntrypoint(workerUrl: URL): WorkerEntrypoint {
|
||||
if (!workerUrl.pathname.endsWith(".ts")) return { entrypoint: workerUrl, eval: false };
|
||||
const tsxUrl = pathToFileURL(createRequire(import.meta.url).resolve("tsx/esm/api")).href;
|
||||
return {
|
||||
entrypoint: `import(${JSON.stringify(tsxUrl)}).then(({ register }) => { register(); return import(${JSON.stringify(workerUrl.href)}); });`,
|
||||
eval: true,
|
||||
};
|
||||
}
|
||||
@@ -680,51 +680,46 @@ test("macOS jobs use latest runners while preserving release architectures", ()
|
||||
assert.match(release, /runner: macos-26-intel\n\s+electron_arch: x64/);
|
||||
});
|
||||
|
||||
test("macOS signing repair keeps certificate and keychain passwords separate", () => {
|
||||
const workflow = readFileSync(new URL(".github/workflows/desktop-release.yml", repoRoot), "utf8");
|
||||
const match = workflow.match(/<<'KEYCHAIN_FIX'\n([\s\S]*?)\n KEYCHAIN_FIX/);
|
||||
assert.ok(match, "the release workflow repairs the pinned signing tool");
|
||||
const script = match[1].replace(/^ /gm, "");
|
||||
const original = [
|
||||
"return await importCerts(keychainFile, certPaths, cscPasswords);",
|
||||
"async function importCerts(keychainFile, paths, keyPasswords) {",
|
||||
'["import", paths[i], "-P", password]',
|
||||
'["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, keychainFile]',
|
||||
"}",
|
||||
].join("\n");
|
||||
const directory = mkdtempSync(join(tmpdir(), "signing-repair-"));
|
||||
test("Nix desktop packaging resolves the workspace-local builder", () => {
|
||||
const source = readFileSync(new URL("nix/desktop-package.nix", repoRoot), "utf8");
|
||||
const invocation = source.match(/^\s+([^\n]*electron-builder) \\\n/m)?.[1];
|
||||
assert.ok(invocation, "Nix invokes the desktop packaging tool");
|
||||
const directory = mkdtempSync(join(tmpdir(), "nix-builder-resolution-"));
|
||||
try {
|
||||
const packageDirectory = join(directory, "node_modules/app-builder-lib");
|
||||
mkdirSync(join(packageDirectory, "out/codeSign"), { recursive: true });
|
||||
const manifest = join(packageDirectory, "package.json");
|
||||
const sourcePath = join(packageDirectory, "out/codeSign/macCodeSign.js");
|
||||
for (const [version, source, succeeds] of [
|
||||
["26.8.1", original, true],
|
||||
["26.8.1", original.replace("cscPasswords);", "differentArguments);"), false],
|
||||
["26.18.0", original, false],
|
||||
]) {
|
||||
writeFileSync(manifest, JSON.stringify({ name: "app-builder-lib", version }));
|
||||
writeFileSync(sourcePath, source);
|
||||
const result = spawnSync(process.execPath, ["-e", script], {
|
||||
cwd: directory,
|
||||
encoding: "utf8",
|
||||
});
|
||||
const actual = readFileSync(sourcePath, "utf8");
|
||||
if (succeeds) {
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.ok(
|
||||
actual.includes("importCerts(keychainFile, certPaths, cscPasswords, keychainPassword)"),
|
||||
);
|
||||
assert.ok(
|
||||
actual.includes("importCerts(keychainFile, paths, keyPasswords, keychainPassword)"),
|
||||
);
|
||||
assert.ok(actual.includes('["import", paths[i], "-P", password]'));
|
||||
assert.ok(actual.includes('"-k", keychainPassword, keychainFile]'));
|
||||
} else {
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.equal(actual, source, "unexpected tool inputs must fail without a partial write");
|
||||
}
|
||||
}
|
||||
const desktop = join(directory, "packages/desktop");
|
||||
mkdirSync(join(desktop, "node_modules/.bin"), { recursive: true });
|
||||
mkdirSync(join(desktop, "node_modules/electron-builder"));
|
||||
writeFileSync(
|
||||
join(desktop, "node_modules/electron-builder/package.json"),
|
||||
JSON.stringify({
|
||||
name: "electron-builder",
|
||||
version: "1.0.0",
|
||||
bin: { "electron-builder": "../.bin/electron-builder" },
|
||||
}),
|
||||
);
|
||||
writeFileSync(
|
||||
join(directory, "package.json"),
|
||||
JSON.stringify({ private: true, workspaces: ["packages/desktop"] }),
|
||||
);
|
||||
writeFileSync(
|
||||
join(desktop, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "desktop-fixture",
|
||||
private: true,
|
||||
devDependencies: { "electron-builder": "1.0.0" },
|
||||
}),
|
||||
);
|
||||
writeFileSync(
|
||||
join(desktop, "node_modules/.bin/electron-builder"),
|
||||
'#!/bin/sh\nprintf "workspace-builder\\n"\n',
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
const result = spawnSync("sh", ["-c", `${invocation} --version`], {
|
||||
cwd: desktop,
|
||||
encoding: "utf8",
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(result.stdout.trim(), "workspace-builder");
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { cp, mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
@@ -10,12 +9,6 @@ import { promisify } from "node:util";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const bridgeModule = "packages/server/dist/server/server/agent/providers/opencode/bridge.js";
|
||||
const claudeDist = "packages/server/dist/server/server/agent/providers/claude";
|
||||
const workerThreadEntrypoints = [
|
||||
"packages/server/dist/server/server/plugins/compiler-worker.js",
|
||||
`${claudeDist}/rewind-worker.js`,
|
||||
`${claudeDist}/rewind-session-store.js`,
|
||||
];
|
||||
|
||||
// Mirrors nix/package.nix's installPhase: copy every traced path into $out.
|
||||
async function installTracedDaemon(outRoot) {
|
||||
@@ -24,14 +17,12 @@ async function installTracedDaemon(outRoot) {
|
||||
[path.join(repoRoot, "scripts/trace-daemon.mjs")],
|
||||
{ cwd: repoRoot, maxBuffer: 64 * 1024 * 1024 },
|
||||
);
|
||||
const files = stdout.split("\n").filter(Boolean);
|
||||
for (const file of files) {
|
||||
for (const file of stdout.split("\n").filter(Boolean)) {
|
||||
await cp(path.join(repoRoot, file), path.join(outRoot, file), {
|
||||
recursive: true,
|
||||
verbatimSymlinks: true,
|
||||
});
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
test("traced daemon closure ships the OpenCode bridge plugin for every OpenCode version", async () => {
|
||||
@@ -53,32 +44,3 @@ test("traced daemon closure ships the OpenCode bridge plugin for every OpenCode
|
||||
await rm(outRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("traced daemon closure ships the worker-thread entrypoints and what they load", async () => {
|
||||
const outRoot = await mkdtemp(path.join(os.tmpdir(), "trace-daemon-dist-"));
|
||||
try {
|
||||
const files = await installTracedDaemon(outRoot);
|
||||
for (const file of workerThreadEntrypoints) {
|
||||
assert.ok(files.includes(file), `${file} is missing from the traced closure`);
|
||||
}
|
||||
|
||||
const { realClaudeRewindSdk } = await import(
|
||||
pathToFileURL(path.join(outRoot, claudeDist, "rewind.js")).href
|
||||
);
|
||||
const sessionId = randomUUID();
|
||||
const user = randomUUID();
|
||||
const transcriptPath = path.join(outRoot, `${sessionId}.jsonl`);
|
||||
await writeFile(
|
||||
transcriptPath,
|
||||
`${JSON.stringify({ type: "user", uuid: user, parentUuid: null, sessionId, message: { role: "user", content: "first" } })}\n`,
|
||||
);
|
||||
const fork = await realClaudeRewindSdk.forkSession(sessionId, {
|
||||
upToMessageId: user,
|
||||
transcriptPath,
|
||||
});
|
||||
const forked = await readFile(path.join(outRoot, `${fork.sessionId}.jsonl`), "utf8");
|
||||
assert.equal(JSON.parse(forked.split("\n")[0]).forkedFrom.messageUuid, user);
|
||||
} finally {
|
||||
await rm(outRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user