From 566218d682d2853085a08cb4001c549c2a71c481 Mon Sep 17 00:00:00 2001 From: Okan YILDIRIM <63511519+ragokan@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:58:30 +0300 Subject: [PATCH] fix(claude): avoid probing file targets during history replay (#7789) --- .../claude/agent.history-load.test.ts | 234 +++++++++++++++++- .../server/agent/providers/claude/agent.ts | 9 + 2 files changed, 230 insertions(+), 13 deletions(-) diff --git a/packages/server/src/server/agent/providers/claude/agent.history-load.test.ts b/packages/server/src/server/agent/providers/claude/agent.history-load.test.ts index a8237d87d0..b355f367b3 100644 --- a/packages/server/src/server/agent/providers/claude/agent.history-load.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.history-load.test.ts @@ -1,4 +1,4 @@ -import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import fs, { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import pino from "pino"; @@ -7,6 +7,9 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { ClaudeAgentClient } from "./agent.js"; import { claudeProjectDirSync } from "./project-dir.js"; import { withClaudeReplayHistory } from "./transcript-history.js"; +import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js"; +import type { ClaudeQueryFactory } from "./query.js"; +import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js"; /** * A resumed Claude agent whose transcript is missing or unreadable opens with an empty timeline. @@ -29,19 +32,18 @@ describe("ClaudeAgentSession persisted history load", () => { let configDir: string; let records: LogRecord[]; - async function resume(): Promise { + async function resume(queryFactory?: ClaudeQueryFactory): Promise { const client = new ClaudeAgentClient({ logger: pino({ level: "info" }, { write: (line: string) => records.push(JSON.parse(line)) }), - queryFactory: vi.fn(() => { - throw new Error("history load must not start a query"); - }), + queryFactory: + queryFactory ?? + (() => { + throw new Error("history load must not start a query"); + }), + resolveBinary: async () => process.execPath, resolveVersion: async () => "2.1.220", }); - const session = await client.resumeSession( - { provider: "claude", sessionId: SESSION_ID }, - { cwd }, - ); - await session.close(); + return client.resumeSession({ provider: "claude", sessionId: SESSION_ID }, { cwd }); } function transcriptPath(): string { @@ -58,12 +60,218 @@ describe("ClaudeAgentSession persisted history load", () => { }); afterEach(() => { + vi.restoreAllMocks(); vi.unstubAllEnvs(); rmSync(tempRoot, { recursive: true, force: true }); }); + test.each(["file_path", "files"])( + "replays %s without probing targets and preserves live existence checks", + async (field) => { + const existingPath = path.join(cwd, "existing.txt"); + const missingPath = path.join(cwd, "missing.txt"); + writeFileSync(existingPath, "existing"); + const targets = [existingPath, missingPath]; + const inputs = targets.map((target) => ({ + [field]: field === "files" ? [target] : target, + })); + const toolBlocks = inputs.map((input, index) => ({ + type: "tool_use", + id: `change-${index}`, + name: "FileChange", + input, + })); + const resultBlocks = toolBlocks.map((block) => ({ + type: "tool_result", + tool_use_id: block.id, + content: "done", + })); + const transcript = [ + { type: "user", message: { content: "Change files" } }, + { type: "assistant", message: { content: toolBlocks } }, + { type: "user", message: { content: resultBlocks } }, + { type: "assistant", message: { content: "Finished" } }, + ]; + mkdirSync(path.dirname(transcriptPath()), { recursive: true }); + writeFileSync(transcriptPath(), transcript.map((entry) => JSON.stringify(entry)).join("\n")); + + // Observe the real filesystem calls; neither existing nor missing targets are mocked. + const exists = vi.spyOn(fs, "existsSync"); + const liveMessages = [ + { type: "assistant", message: { content: toolBlocks } }, + { type: "user", message: { content: resultBlocks } }, + { type: "result", subtype: "success", total_cost_usd: 0 }, + ]; + const queryFactory = vi.fn(); + queryFactory.mockReturnValue({ + async next() { + const value = liveMessages.shift(); + return value ? { done: false, value } : { done: true, value: undefined }; + }, + close() {}, + async return() { + return { done: true, value: undefined }; + }, + async supportedCommands() { + return []; + }, + [Symbol.asyncIterator]() { + return this; + }, + }); + const session = await resume(queryFactory); + try { + const history: AgentStreamEvent[] = []; + for await (const event of session.streamHistory()) history.push(event); + expect(exists.mock.calls.filter(([target]) => targets.includes(String(target)))).toEqual( + [], + ); + const running = inputs.map((input, index) => ({ + type: "tool_call", + callId: `change-${index}`, + name: "FileChange", + status: "running", + error: null, + detail: { type: "unknown", input, output: null }, + })); + const completed = inputs.map((input, index) => ({ + type: "tool_call", + callId: `change-${index}`, + name: "FileChange", + status: "completed", + error: null, + detail: { + type: "unknown", + input, + output: { + output: "done", + files: [{ path: index === 0 ? "existing.txt" : "missing.txt", kind: "update" }], + }, + }, + })); + expect(history).toEqual( + [ + { type: "user_message", text: "Change files" }, + ...running, + ...completed, + { type: "assistant_message", text: "Finished" }, + ].map((item) => ({ type: "timeline", provider: "claude", timestamp: undefined, item })), + ); + + exists.mockClear(); + const live = await collectSessionTurnEvents(session, "Change files again"); + expect(exists.mock.calls.filter(([target]) => targets.includes(String(target)))).toEqual([ + [existingPath], + [missingPath], + ]); + const liveCompleted = live.flatMap((event) => + event.type === "timeline" && + event.item.type === "tool_call" && + event.item.status === "completed" + ? [event.item] + : [], + ); + const missing = completed[1]; + expect(liveCompleted).toEqual([ + completed[0], + { + ...missing, + detail: { + ...missing.detail, + output: { output: "done", files: [{ path: "missing.txt", kind: "add" }] }, + }, + }, + ]); + } finally { + await session.close(); + } + }, + ); + + test.each(["parent", "subagent"])( + "replays %s Write and Edit calls without probing their targets", + async (scope) => { + const filePath = path.join(cwd, "unavailable", "file.txt"); + const content = ["Write", "Edit"].map((name) => ({ + type: "tool_use", + id: name, + name, + input: { file_path: filePath }, + })); + const entry = { type: "assistant", message: { content } }; + mkdirSync(path.dirname(transcriptPath()), { recursive: true }); + if (scope === "subagent") { + const directory = path.join(path.dirname(transcriptPath()), SESSION_ID, "subagents"); + mkdirSync(directory, { recursive: true }); + writeFileSync( + path.join(directory, "agent-child.meta.json"), + JSON.stringify({ toolUseId: "task" }), + ); + writeFileSync( + path.join(directory, "agent-child.jsonl"), + JSON.stringify({ + ...entry, + isSidechain: true, + agentId: "child", + }), + ); + writeFileSync( + transcriptPath(), + JSON.stringify({ + type: "assistant", + message: { + content: [ + { + type: "tool_use", + id: "task", + name: "Task", + input: { description: "Change files" }, + }, + ], + }, + }), + ); + } else { + writeFileSync(transcriptPath(), JSON.stringify(entry)); + } + + const exists = vi.spyOn(fs, "existsSync"); + const session = await resume(); + try { + const items = []; + for await (const event of session.streamHistory()) { + if (scope === "parent" && event.type === "timeline") items.push(event.item); + if (event.type === "provider_subagent" && event.event.type === "timeline") { + items.push(event.event.item); + } + } + expect(exists.mock.calls.filter(([target]) => target === filePath)).toEqual([]); + expect(items).toEqual([ + { + type: "tool_call", + callId: "Write", + name: "Write", + status: "running", + error: null, + detail: { type: "write", filePath }, + }, + { + type: "tool_call", + callId: "Edit", + name: "Edit", + status: "running", + error: null, + detail: { type: "edit", filePath }, + }, + ]); + } finally { + await session.close(); + } + }, + ); + test("logs the resolved transcript path when the transcript does not exist", async () => { - await resume(); + await (await resume()).close(); expect(records).toContainEqual( expect.objectContaining({ @@ -79,7 +287,7 @@ describe("ClaudeAgentSession persisted history load", () => { // A directory at the transcript path exists but cannot be read as a file. mkdirSync(transcriptPath(), { recursive: true }); - await resume(); + await (await resume()).close(); expect(records).toContainEqual( expect.objectContaining({ @@ -148,7 +356,7 @@ describe("ClaudeAgentSession persisted history load", () => { chmodSync(path.join(configDir, "projects"), 0o000); try { - await resume(); + await (await resume()).close(); } finally { chmodSync(path.join(configDir, "projects"), 0o755); } diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index a00f504f1a..71a279834d 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -2326,6 +2326,7 @@ class ClaudeAgentSession implements AgentSession { { type: "provider_subagent" } >[] = []; private historyPending = false; + private replayingHistory = false; private turnState: TurnState = "idle"; private nextTurnOrdinal = 1; private cancelCurrentTurn: (() => void) | null = null; @@ -5253,6 +5254,7 @@ class ClaudeAgentSession implements AgentSession { private loadPersistedHistory(sessionId: string): void { let historyPath: string | null = null; + this.replayingHistory = true; try { this.taskState.reset(); historyPath = this.resolveHistoryPath(sessionId); @@ -5272,6 +5274,8 @@ class ClaudeAgentSession implements AgentSession { { err: error, sessionId, historyPath }, "Failed to load Claude history from transcript", ); + } finally { + this.replayingHistory = false; } } @@ -6039,6 +6043,11 @@ class ClaudeAgentSession implements AgentSession { } private detectFileKind(filePath: string): string { + // A historical target may be on an unreachable mount; its current state says nothing + // about whether the past operation created or updated it. + if (this.replayingHistory) { + return "update"; + } try { return fs.existsSync(filePath) ? "update" : "add"; } catch {