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 223e2001f5..1253610350 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 { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import pino from "pino"; @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { ClaudeAgentClient } from "./agent.js"; import { claudeProjectDirSync } from "./project-dir.js"; +import { readClaudeReplayHistory } from "./transcript-history.js"; /** * A resumed Claude agent whose transcript is missing or unreadable opens with an empty timeline. @@ -90,4 +91,14 @@ describe("ClaudeAgentSession persisted history load", () => { }), ); }); + + test("propagates read errors when a transcript disappears before replay", () => { + const file = transcriptPath(); + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, ""); + const history = readClaudeReplayHistory(file); + rmSync(file); + + expect(() => [...history.parentEntries]).toThrow("ENOENT"); + }); }); diff --git a/packages/server/src/server/agent/providers/claude/transcript-history.ts b/packages/server/src/server/agent/providers/claude/transcript-history.ts index c342e4aa58..8cddab5ddd 100644 --- a/packages/server/src/server/agent/providers/claude/transcript-history.ts +++ b/packages/server/src/server/agent/providers/claude/transcript-history.ts @@ -26,14 +26,18 @@ interface RecordLocation { function* locatedRecords( file: string, ): Generator<{ entry: TranscriptEntry; location: RecordLocation }> { - let fd: number; + const fd = fs.openSync(file, "r"); try { - fd = fs.openSync(file, "r"); - } catch { - return; - } - try { - let remaining = fs.fstatSync(fd).size; + const stat = fs.fstatSync(fd); + // Opening a directory succeeds on some platforms; it must never look like empty history. + if (stat.isDirectory()) { + throw Object.assign(new Error(`EISDIR: illegal operation on a directory, read '${file}'`), { + code: "EISDIR", + syscall: "read", + path: file, + }); + } + let remaining = stat.size; let offset = 0; let pieces: Buffer[] = []; let length = 0; @@ -110,17 +114,12 @@ function workflowRecords(files: readonly string[]): Iterable { let fd: number | null = null; try { for (const location of locations) { - if (currentFile !== location.file) { + if (fd === null || currentFile !== location.file) { if (fd !== null) fs.closeSync(fd); fd = null; currentFile = location.file; - try { - fd = fs.openSync(currentFile, "r"); - } catch { - continue; - } + fd = fs.openSync(currentFile, "r"); } - if (fd === null) continue; const buffer = Buffer.allocUnsafe(location.length); let read = 0; while (read < buffer.length) {