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.
This commit is contained in:
paseo-bot[bot]
2026-10-07 19:16:56 +02:00
parent 499665efbb
commit c32d82fd40
2 changed files with 60 additions and 6 deletions
@@ -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<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,
@@ -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",
]);
});
});
@@ -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;