mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-25 02:32:47 +00:00
Compare commits
2
Commits
main
...
c32d82fd40
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c32d82fd40 | ||
|
|
499665efbb |
@@ -2830,9 +2830,11 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.startFreshConversationSession();
|
||||
return;
|
||||
}
|
||||
const currentSessionId = this.claudeSessionId;
|
||||
await revertClaudeConversation({
|
||||
sdk: this.rewindSdk,
|
||||
sessionId: this.claudeSessionId,
|
||||
sessionId: currentSessionId,
|
||||
transcriptPath: currentSessionId ? this.resolveHistoryPath(currentSessionId) : null,
|
||||
messageId: target.messageId,
|
||||
resolveMessageId: (messageId) => this.resolveClaudeMessageId(messageId),
|
||||
setSessionId: (sessionId) => {
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import type { Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
import {
|
||||
revertClaudeConversation,
|
||||
revertClaudeConversationAndFiles,
|
||||
revertClaudeFiles,
|
||||
realClaudeRewindSdk,
|
||||
} from "./rewind.js";
|
||||
import { FakeClaudeSdk } from "./test-rewind-claude-sdk.js";
|
||||
|
||||
@@ -16,6 +21,7 @@ describe("Claude rewind", () => {
|
||||
await revertClaudeConversation({
|
||||
sdk: claude,
|
||||
sessionId,
|
||||
transcriptPath: "/claude/projects/repo/original-session.jsonl",
|
||||
messageId: "user-message-1",
|
||||
setSessionId: (nextSessionId) => {
|
||||
sessionId = nextSessionId;
|
||||
@@ -33,6 +39,7 @@ describe("Claude rewind", () => {
|
||||
await revertClaudeConversation({
|
||||
sdk: claude,
|
||||
sessionId,
|
||||
transcriptPath: "/claude/projects/repo/original-session.jsonl",
|
||||
messageId: "timeline-message-1",
|
||||
resolveMessageId: () => "claude-jsonl-message-1",
|
||||
setSessionId: (nextSessionId) => {
|
||||
@@ -76,6 +83,7 @@ describe("Claude rewind", () => {
|
||||
sdk: claude,
|
||||
query: claude.createQuery() as Query,
|
||||
sessionId,
|
||||
transcriptPath: "/claude/projects/repo/original-session.jsonl",
|
||||
messageId: "user-message-1",
|
||||
setSessionId: (nextSessionId) => {
|
||||
sessionId = nextSessionId;
|
||||
@@ -87,3 +95,126 @@ describe("Claude rewind", () => {
|
||||
expect(sessionId).toBe("forked-before-rehydrate");
|
||||
});
|
||||
});
|
||||
|
||||
interface TranscriptLine {
|
||||
type: "user" | "assistant";
|
||||
uuid: string;
|
||||
parentUuid: string | null;
|
||||
text: string;
|
||||
}
|
||||
|
||||
function writeTranscript(
|
||||
transcriptPath: string,
|
||||
sessionId: string,
|
||||
lines: Array<TranscriptLine | string>,
|
||||
): void {
|
||||
const entries = lines.map((line) => {
|
||||
if (typeof line === "string") return line;
|
||||
const { type, uuid, parentUuid, text } = line;
|
||||
return JSON.stringify({
|
||||
type,
|
||||
uuid,
|
||||
parentUuid,
|
||||
sessionId,
|
||||
cwd: "/repo",
|
||||
timestamp: new Date().toISOString(),
|
||||
message: { role: type, content: text },
|
||||
});
|
||||
});
|
||||
writeFileSync(transcriptPath, `${entries.join("\n")}\n`, "utf8");
|
||||
}
|
||||
|
||||
function readMessageTexts(transcriptPath: string): string[] {
|
||||
const entries = readFileSync(transcriptPath, "utf8")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line) as { message?: { content: string } });
|
||||
return entries.flatMap((entry) => (entry.message ? [entry.message.content] : []));
|
||||
}
|
||||
|
||||
describe("Claude rewind against the Claude SDK", () => {
|
||||
let tempRoot: string | null = null;
|
||||
const previousConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
|
||||
afterEach(() => {
|
||||
if (tempRoot) rmSync(tempRoot, { recursive: true, force: true });
|
||||
tempRoot = null;
|
||||
if (previousConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = previousConfigDir;
|
||||
}
|
||||
});
|
||||
|
||||
test("forks a session that lives in a provider's own CLAUDE_CONFIG_DIR", async () => {
|
||||
tempRoot = mkdtempSync(path.join(tmpdir(), "claude-rewind-config-dir-"));
|
||||
// The provider sets CLAUDE_CONFIG_DIR for Claude Code; the daemon's own environment does not.
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
const projectDir = path.join(tempRoot, "custom-claude-dir", "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" },
|
||||
]);
|
||||
let currentSessionId = sessionId;
|
||||
|
||||
await revertClaudeConversation({
|
||||
sdk: realClaudeRewindSdk,
|
||||
sessionId,
|
||||
transcriptPath,
|
||||
messageId: firstReply,
|
||||
setSessionId: (nextSessionId) => {
|
||||
currentSessionId = nextSessionId;
|
||||
},
|
||||
});
|
||||
|
||||
expect(currentSessionId).not.toBe(sessionId);
|
||||
expect(readMessageTexts(path.join(projectDir, `${currentSessionId}.jsonl`))).toEqual([
|
||||
"first",
|
||||
"first reply",
|
||||
]);
|
||||
});
|
||||
|
||||
test("skips transcript lines that are not entries when forking", async () => {
|
||||
tempRoot = mkdtempSync(path.join(tmpdir(), "claude-rewind-config-dir-"));
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
const projectDir = path.join(tempRoot, "custom-claude-dir", "projects", "-repo");
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
const sessionId = randomUUID();
|
||||
const firstUser = randomUUID();
|
||||
const firstReply = randomUUID();
|
||||
const transcriptPath = path.join(projectDir, `${sessionId}.jsonl`);
|
||||
writeTranscript(transcriptPath, sessionId, [
|
||||
"null",
|
||||
{ type: "user", uuid: firstUser, parentUuid: null, text: "first" },
|
||||
"[1, 2]",
|
||||
'"text"',
|
||||
"{}",
|
||||
{ type: "assistant", uuid: firstReply, parentUuid: firstUser, text: "first reply" },
|
||||
{ type: "user", uuid: randomUUID(), parentUuid: firstReply, text: "second" },
|
||||
]);
|
||||
let currentSessionId = sessionId;
|
||||
|
||||
await revertClaudeConversation({
|
||||
sdk: realClaudeRewindSdk,
|
||||
sessionId,
|
||||
transcriptPath,
|
||||
messageId: firstReply,
|
||||
setSessionId: (nextSessionId) => {
|
||||
currentSessionId = nextSessionId;
|
||||
},
|
||||
});
|
||||
|
||||
expect(readMessageTexts(path.join(projectDir, `${currentSessionId}.jsonl`))).toEqual([
|
||||
"first",
|
||||
"first reply",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,29 +1,90 @@
|
||||
import { forkSession as claudeForkSession, type Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { appendFile, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
forkSession as claudeForkSession,
|
||||
type Query,
|
||||
type SessionStore,
|
||||
type SessionStoreEntry,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
export interface ClaudeRewindSdk {
|
||||
forkSession(
|
||||
sessionId: string,
|
||||
options: { upToMessageId: string },
|
||||
options: { upToMessageId: string; transcriptPath: string },
|
||||
): Promise<{ sessionId: string }>;
|
||||
}
|
||||
|
||||
// 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: claudeForkSession,
|
||||
forkSession: (sessionId, options) =>
|
||||
claudeForkSession(sessionId, {
|
||||
upToMessageId: options.upToMessageId,
|
||||
sessionStore: projectFolderSessionStore(path.dirname(options.transcriptPath)),
|
||||
}),
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
export async function revertClaudeConversation(input: {
|
||||
sdk: ClaudeRewindSdk;
|
||||
sessionId: string | null;
|
||||
transcriptPath: string | null;
|
||||
messageId: string;
|
||||
resolveMessageId?: (messageId: string) => string | Promise<string>;
|
||||
setSessionId: (sessionId: string) => void;
|
||||
}): Promise<void> {
|
||||
if (!input.sessionId) {
|
||||
if (!input.sessionId || !input.transcriptPath) {
|
||||
throw new Error("Claude session is not ready for rewind");
|
||||
}
|
||||
const messageId = (await input.resolveMessageId?.(input.messageId)) ?? input.messageId;
|
||||
const fork = await input.sdk.forkSession(input.sessionId, {
|
||||
upToMessageId: messageId,
|
||||
transcriptPath: input.transcriptPath,
|
||||
});
|
||||
input.setSessionId(fork.sessionId);
|
||||
}
|
||||
@@ -44,6 +105,7 @@ export async function revertClaudeConversationAndFiles(input: {
|
||||
sdk: ClaudeRewindSdk;
|
||||
query: Query;
|
||||
sessionId: string | null;
|
||||
transcriptPath: string | null;
|
||||
messageId: string;
|
||||
resolveMessageId?: (messageId: string) => string | Promise<string>;
|
||||
setSessionId: (sessionId: string) => void;
|
||||
|
||||
@@ -12,7 +12,7 @@ export class FakeClaudeSdk implements ClaudeRewindSdk {
|
||||
|
||||
async forkSession(
|
||||
_sessionId: string,
|
||||
options: { upToMessageId: string },
|
||||
options: { upToMessageId: string; transcriptPath: string },
|
||||
): Promise<{ sessionId: string }> {
|
||||
this.recordedForks.push({ upToMessageId: options.upToMessageId });
|
||||
return { sessionId: this.nextSessionId };
|
||||
|
||||
Reference in New Issue
Block a user