From 2425282e6d5589186d4c6d1d113c8faa84383dce Mon Sep 17 00:00:00 2001 From: Roger Oba Date: Wed, 21 Oct 2026 12:06:03 -0300 Subject: [PATCH] fix(server): skip Claude request-journal records when loading history Claude Code 2.1.304+ journals each API request into the transcript as api-request-blob records. They carry a message with a role and content, so the history loader rendered them as assistant messages: restored timelines gained the session's system context, including SessionStart hook output and skill and tool listings. Only user and assistant records are conversation turns once the system record handling has run, so short-circuit every other type. Closes #7102 Co-Authored-By: Claude Opus 5.5 --- .../agent/providers/claude/agent.test.ts | 33 +++++++++++++++++++ .../server/agent/providers/claude/agent.ts | 5 +++ 2 files changed, 38 insertions(+) diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index d6b869f1e0..f699988f6a 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -141,6 +141,39 @@ describe("convertClaudeHistoryEntry", () => { ]); }); + test("ignores Claude Code request-journal records that carry a message", () => { + // Claude Code 2.1.x journals each API request into the transcript. These records carry a + // message with a role and content, but they are copies of the request, not conversation turns. + const entries = [ + { + type: "api-request-blob", + message: { + role: "system", + content: [ + { type: "text", text: "SessionStart:startup hook success: hook output" }, + { type: "tool_addition", name: "Read" }, + ], + }, + }, + { + type: "api-request-blob", + message: { + role: "user", + content: [{ type: "text", text: "skills list" }], + }, + }, + ]; + const leakedTimeline: AgentTimelineItem[] = [ + { type: "assistant_message", text: "SessionStart:startup hook success: hook output" }, + ]; + const mapBlocks = vi.fn().mockReturnValue(leakedTimeline); + + for (const entry of entries) { + expect(convertClaudeHistoryEntry(entry, mapBlocks)).toEqual([]); + } + expect(mapBlocks).not.toHaveBeenCalled(); + }); + test("converts compact boundary metadata variants", () => { const fixtures = [ { diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 6a70d28d6a..3d59851038 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -6403,6 +6403,11 @@ function convertClaudeHistoryEntryPreamble( if (entry.type === "user" && isSyntheticHistoryUserEntry(entry)) { return { shortCircuit: [] }; } + // Only user and assistant records are conversation turns. Claude Code 2.1.x also journals each + // API request as `api-request-blob` records whose message replays the system context. + if (entry.type !== "user" && entry.type !== "assistant") { + return { shortCircuit: [] }; + } const message = entry?.message; if (!message || !("content" in message)) {