From c32d82fd40a3134e4ed3df67c41514cfec10a4a4 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:16:56 +0200 Subject: [PATCH] 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. --- .../agent/providers/claude/rewind.test.ts | 52 +++++++++++++++++-- .../server/agent/providers/claude/rewind.ts | 14 ++++- 2 files changed, 60 insertions(+), 6 deletions(-) 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 28a6bd38fa..2d6d3853c2 100644 --- a/packages/server/src/server/agent/providers/claude/rewind.test.ts +++ b/packages/server/src/server/agent/providers/claude/rewind.test.ts @@ -103,9 +103,15 @@ interface TranscriptLine { text: string; } -function writeTranscript(transcriptPath: string, sessionId: string, lines: TranscriptLine[]): void { - const entries = lines.map(({ type, uuid, parentUuid, text }) => - JSON.stringify({ +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, @@ -113,8 +119,8 @@ function writeTranscript(transcriptPath: string, sessionId: string, lines: Trans cwd: "/repo", timestamp: new Date().toISOString(), message: { role: type, content: text }, - }), - ); + }); + }); writeFileSync(transcriptPath, `${entries.join("\n")}\n`, "utf8"); } @@ -175,4 +181,40 @@ describe("Claude rewind against the Claude SDK", () => { "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 f3c831575a..3b228aacc6 100644 --- a/packages/server/src/server/agent/providers/claude/rewind.ts +++ b/packages/server/src/server/agent/providers/claude/rewind.ts @@ -49,15 +49,27 @@ function parseTranscriptEntries(text: string): SessionStoreEntry[] { const entries: SessionStoreEntry[] = []; for (const line of text.split("\n")) { if (!line.trim()) continue; + let parsed: unknown; try { - entries.push(JSON.parse(line) as SessionStoreEntry); + 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;