From 0614e96ee0c448d7badb3523ee1560b1847f36ae Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:12:33 +0000 Subject: [PATCH] fix(claude): rewind works when the provider sets CLAUDE_CONFIG_DIR (#6325) * fix(claude): rewind a session that lives in the provider's CLAUDE_CONFIG_DIR Rewinding the conversation forks the Claude session through the SDK's forkSession, which looks for the transcript under the daemon's own CLAUDE_CONFIG_DIR or ~/.claude. A provider whose env sets CLAUDE_CONFIG_DIR has its transcripts elsewhere, so every rewind failed with "Session not found". Fork through a session store rooted at the folder of the transcript the agent already resolves with the provider's config dir, so the SDK reads the session where Claude Code wrote it and writes the fork beside it. Closes #6321 * fix(claude): pass only transcript entries to the rewind session store The session store parsed each transcript line and cast it to a session entry without checking it. Keep only JSON objects with a string type, and cover transcripts with non-entry lines in the real-SDK rewind test. --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../server/agent/providers/claude/agent.ts | 4 +- .../agent/providers/claude/rewind.test.ts | 133 +++++++++++++++++- .../server/agent/providers/claude/rewind.ts | 70 ++++++++- .../claude/test-rewind-claude-sdk.ts | 2 +- 4 files changed, 202 insertions(+), 7 deletions(-) diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 9936969e7d..8ea12610ea 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -2898,9 +2898,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) => { diff --git a/packages/server/src/server/agent/providers/claude/rewind.test.ts b/packages/server/src/server/agent/providers/claude/rewind.test.ts index 99243bb9c4..2d6d3853c2 100644 --- a/packages/server/src/server/agent/providers/claude/rewind.test.ts +++ b/packages/server/src/server/agent/providers/claude/rewind.test.ts @@ -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, +): 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", + ]); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/rewind.ts b/packages/server/src/server/agent/providers/claude/rewind.ts index 9c3e3d708d..3b228aacc6 100644 --- a/packages/server/src/server/agent/providers/claude/rewind.ts +++ b/packages/server/src/server/agent/providers/claude/rewind.ts @@ -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; setSessionId: (sessionId: string) => void; }): Promise { - 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; setSessionId: (sessionId: string) => void; diff --git a/packages/server/src/server/agent/providers/claude/test-rewind-claude-sdk.ts b/packages/server/src/server/agent/providers/claude/test-rewind-claude-sdk.ts index 496d3bc209..d607b5c063 100644 --- a/packages/server/src/server/agent/providers/claude/test-rewind-claude-sdk.ts +++ b/packages/server/src/server/agent/providers/claude/test-rewind-claude-sdk.ts @@ -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 };