From ce67efa9e7faa8024a60e78c7b6f0ecf5ca21c5c Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:54:11 +0200 Subject: [PATCH] Keep Claude request context out of restored chats (#7590) * 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 * fix(claude): keep request context out of restored chats Claude request-journal records contain message-shaped tool context. Only conversation records should enter the ordinary history mapper, after compaction and background task notifications are handled. Co-authored-by: Kheva Mann Co-authored-by: rogerluan --------- Co-authored-by: Roger Oba Co-authored-by: Claude Opus 5.5 Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> Co-authored-by: Kheva Mann Co-authored-by: rogerluan --- .../claude/agent.request-journal.test.ts | 140 ++++++++++++++++++ .../server/agent/providers/claude/agent.ts | 5 + 2 files changed, 145 insertions(+) create mode 100644 packages/server/src/server/agent/providers/claude/agent.request-journal.test.ts diff --git a/packages/server/src/server/agent/providers/claude/agent.request-journal.test.ts b/packages/server/src/server/agent/providers/claude/agent.request-journal.test.ts new file mode 100644 index 0000000000..9be65da964 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/agent.request-journal.test.ts @@ -0,0 +1,140 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import type { AgentTimelineItem } from "../../agent-sdk-types.js"; +import { createTestLogger } from "../../../../test-utils/test-logger.js"; +import { ClaudeAgentClient } from "./agent.js"; +import { claudeProjectDirSync } from "./project-dir.js"; + +describe("Claude session request journal replay", () => { + let root: string; + let cwd: string; + let configDir: string; + + beforeEach(() => { + root = mkdtempSync(path.join(os.tmpdir(), "claude-request-journal-")); + cwd = path.join(root, "repo"); + configDir = path.join(root, "claude"); + mkdirSync(cwd); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + async function replay(records: unknown[]): Promise { + const historyDir = claudeProjectDirSync(cwd, { configDir }); + mkdirSync(historyDir, { recursive: true }); + writeFileSync( + path.join(historyDir, "journal-session.jsonl"), + records.map((record) => JSON.stringify(record)).join("\n"), + ); + const client = new ClaudeAgentClient({ logger: createTestLogger() }); + const session = await client.resumeSession( + { provider: "claude", sessionId: "journal-session" }, + { cwd }, + { env: { CLAUDE_CONFIG_DIR: configDir } }, + ); + try { + const items: AgentTimelineItem[] = []; + for await (const event of session.streamHistory()) { + if (event.type === "timeline") items.push(event.item); + } + return items; + } finally { + await session.close(); + } + } + + test.each(["api-request-shape", "api-request", "api-request-blob", "future-journal-record"])( + "ignores %s content while preserving the conversation and tool calls", + async (type) => { + const items = await replay([ + { type: "user", message: { role: "user", content: "Read package.json" } }, + { + type: "assistant", + message: { + role: "assistant", + content: [ + { + type: "tool_use", + id: "read-1", + name: "Read", + input: { file_path: "package.json" }, + }, + ], + }, + }, + ...["user", "system", "assistant"].map((role) => ({ + type, + message: { + role, + content: [ + { + type: "text", + text: "SessionStart hook output\n# MCP Server Instructions\n1000 tokens left", + }, + { + type: "tool_addition", + tool: { + name: "deferred_tool", + description: "Deferred tool", + input_schema: { type: "object", properties: {} }, + }, + }, + { + type: "tool_use", + id: "journal-only", + name: "Read", + input: { file_path: "old.json" }, + }, + ], + }, + })), + { + type: "assistant", + message: { role: "assistant", content: [{ type: "text", text: "paseo" }] }, + }, + ]); + + expect(items).toEqual([ + expect.objectContaining({ type: "user_message", text: "Read package.json" }), + expect.objectContaining({ type: "tool_call", callId: "read-1", name: "Read" }), + expect.objectContaining({ type: "assistant_message", text: "paseo" }), + ]); + }, + ); + + test("preserves system compaction and background task notifications", async () => { + const items = await replay([ + { + type: "system", + subtype: "compact_boundary", + compactMetadata: { trigger: "manual", preTokens: 1000 }, + }, + { + type: "system", + subtype: "task_notification", + uuid: "note-1", + task_id: "task-1", + status: "completed", + summary: "Background work finished", + }, + ]); + expect(items).toEqual([ + expect.objectContaining({ + type: "compaction", + status: "completed", + trigger: "manual", + preTokens: 1000, + }), + expect.objectContaining({ + type: "tool_call", + name: "task_notification", + status: "completed", + }), + ]); + }); +}); 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)) {