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 <noreply@anthropic.com>

* 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 <khevamann@users.noreply.github.com>
Co-authored-by: rogerluan <rogerluan@users.noreply.github.com>

---------

Co-authored-by: Roger Oba <rogerluan.oba@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Kheva Mann <khevamann@users.noreply.github.com>
Co-authored-by: rogerluan <rogerluan@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-10-21 18:54:11 +02:00
committed by GitHub
co-authored by Claude Opus 5.5 Kheva Mann rogerluan Roger Oba
parent e522e6ea8e
commit ce67efa9e7
2 changed files with 145 additions and 0 deletions
@@ -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<AgentTimelineItem[]> {
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\n<total_tokens>1000 tokens left</total_tokens>",
},
{
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",
}),
]);
});
});
@@ -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)) {