Skip to content

Commit 223e79b

Browse files
masatohoshinoclaude
andcommitted
fix(anthropic): complete transcript reverse-scan windows across short reads
readLocalClaudeTranscriptPage filled each reverse-scan window with a single positional read and threw "Claude transcript changed while it was being read" whenever bytesRead !== size. A positional read may return fewer bytes than requested inside an unchanged file, so a benign short read failed the transcript page load with a message implying the file changed. The forward metadata scan in the same file already loops short reads; the reverse scan did not. Fill each window across short reads, advancing both the buffer offset and the file position. A zero-byte read before the window is filled is premature EOF and still throws the same error. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
1 parent e0478e2 commit 223e79b

2 files changed

Lines changed: 144 additions & 6 deletions

File tree

extensions/anthropic/session-catalog.test.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import fs from "node:fs/promises";
2+
import type { FileHandle } from "node:fs/promises";
23
import os from "node:os";
34
import path from "node:path";
45
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
@@ -201,6 +202,52 @@ function sdkCliMessage(sessionId: string, text: string): Record<string, unknown>
201202
};
202203
}
203204

205+
// Fault-injects short positional reads on the target transcript's FileHandle so
206+
// tests can drive the reverse-scan pager through partial reads deterministically
207+
// (production short reads are non-deterministic and cannot be induced reliably).
208+
// `plan` returns the byte count each read is allowed to satisfy; returning 0
209+
// simulates a genuine mid-window EOF (truncation). Forward metadata-scan handles
210+
// (first read at position 0) and the reverse pager handle (first read at a high
211+
// offset) are distinguishable via `firstPosition`.
212+
function injectTranscriptShortReads(
213+
sessionId: string,
214+
plan: (input: {
215+
length: number;
216+
position: number;
217+
call: number;
218+
firstPosition: number;
219+
}) => number,
220+
): void {
221+
const realOpen = fs.open.bind(fs);
222+
vi.spyOn(fs, "open").mockImplementation((async (target: unknown, ...rest: unknown[]) => {
223+
const handle = await (realOpen as (...args: unknown[]) => Promise<FileHandle>)(target, ...rest);
224+
if (typeof target === "string" && target.endsWith(`${sessionId}.jsonl`)) {
225+
const realRead = handle.read.bind(handle) as (
226+
buffer: Buffer,
227+
offset: number,
228+
length: number,
229+
position: number,
230+
) => Promise<{ bytesRead: number; buffer: Buffer }>;
231+
let call = 0;
232+
let firstPosition = -1;
233+
(handle as { read: unknown }).read = (
234+
buffer: Buffer,
235+
offset: number,
236+
length: number,
237+
position: number,
238+
) => {
239+
if (firstPosition < 0) {
240+
firstPosition = position;
241+
}
242+
const allowed = plan({ length, position, call, firstPosition });
243+
call += 1;
244+
return realRead(buffer, offset, allowed, position);
245+
};
246+
}
247+
return handle;
248+
}) as typeof fs.open);
249+
}
250+
204251
afterEach(async () => {
205252
vi.restoreAllMocks();
206253
nodeHostMocks.runNodePtyCommand.mockClear();
@@ -1105,6 +1152,87 @@ describe("Claude session catalog", () => {
11051152
expect(older.nextCursor).toBeUndefined();
11061153
});
11071154

1155+
it("pages transcripts identically when every reverse-scan read returns short", async () => {
1156+
const home = await createHome();
1157+
const sessionId = "short-read-session";
1158+
// > TRANSCRIPT_READ_CHUNK_BYTES (128 KiB) so the reverse scan spans multiple
1159+
// windows, each of which is now split into many consecutive short reads.
1160+
const oldUser = "old user ".repeat(20_000);
1161+
await writeProject({
1162+
home,
1163+
entries: [
1164+
{
1165+
sessionId,
1166+
fullPath: path.join(home, ".claude", "projects", "-workspace", `${sessionId}.jsonl`),
1167+
summary: "Transcript",
1168+
modified: "2026-07-04T00:00:00.000Z",
1169+
isSidechain: false,
1170+
},
1171+
],
1172+
transcripts: {
1173+
[sessionId]: [
1174+
{ type: "queue-operation", sessionId },
1175+
message(sessionId, "user", oldUser, 1),
1176+
message(sessionId, "assistant", "old assistant", 2),
1177+
message(sessionId, "user", "new user", 3),
1178+
message(sessionId, "assistant", "new assistant", 4),
1179+
],
1180+
},
1181+
});
1182+
1183+
// Every read satisfies at most 4 KiB, so each 128 KiB window is completed
1184+
// across dozens of non-zero short reads. Fails before the fill-loop fix: the
1185+
// first short read tripped the "transcript changed" throw.
1186+
injectTranscriptShortReads(sessionId, ({ length }) => Math.min(length, 4096));
1187+
1188+
const latest = await readLocalClaudeTranscriptPage({ threadId: sessionId, limit: 2 }, home);
1189+
expect(latest.items.map((item) => item.text)).toEqual(["new assistant", "new user"]);
1190+
expect(latest.nextCursor).toEqual(expect.any(String));
1191+
1192+
const older = await readLocalClaudeTranscriptPage(
1193+
{ threadId: sessionId, limit: 2, cursor: latest.nextCursor },
1194+
home,
1195+
);
1196+
expect(older.items.map((item) => item.text)).toEqual(["old assistant", oldUser]);
1197+
expect(older.nextCursor).toBeUndefined();
1198+
});
1199+
1200+
it("still reports a truncated transcript when a reverse-scan read hits EOF mid-window", async () => {
1201+
const home = await createHome();
1202+
const sessionId = "truncated-read-session";
1203+
const oldUser = "old user ".repeat(20_000);
1204+
await writeProject({
1205+
home,
1206+
entries: [
1207+
{
1208+
sessionId,
1209+
fullPath: path.join(home, ".claude", "projects", "-workspace", `${sessionId}.jsonl`),
1210+
summary: "Transcript",
1211+
modified: "2026-07-04T00:00:00.000Z",
1212+
isSidechain: false,
1213+
},
1214+
],
1215+
transcripts: {
1216+
[sessionId]: [
1217+
message(sessionId, "user", oldUser, 1),
1218+
message(sessionId, "assistant", "new assistant", 2),
1219+
],
1220+
},
1221+
});
1222+
1223+
// Reverse pager handle (first read at a high offset): return a partial read,
1224+
// then a 0-byte read = the file was truncated out from under us mid-window,
1225+
// which must still surface the hard error. Forward metadata-scan handles
1226+
// (first read at position 0) read normally so the session still resolves.
1227+
injectTranscriptShortReads(sessionId, ({ length, call, firstPosition }) =>
1228+
firstPosition === 0 ? length : call === 0 ? Math.min(length, 8) : 0,
1229+
);
1230+
1231+
await expect(
1232+
readLocalClaudeTranscriptPage({ threadId: sessionId, limit: 2 }, home),
1233+
).rejects.toThrow("Claude transcript changed while it was being read");
1234+
});
1235+
11081236
it("advertises terminal resume only when the store and Claude binary exist", async () => {
11091237
const home = await createHome();
11101238
const commands = createClaudeSessionNodeHostCommands();

extensions/anthropic/session-catalog.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -745,13 +745,23 @@ export async function readLocalClaudeTranscriptPage(
745745
);
746746
position -= size;
747747
const chunk = Buffer.allocUnsafe(size);
748-
const { bytesRead } = await handle.read(chunk, 0, size, position);
749-
if (bytesRead !== size) {
750-
throw new Error("Claude transcript changed while it was being read");
748+
// Positional reads can return fewer bytes than requested even inside a
749+
// stat'd, unchanged file, so fill the whole window across short reads
750+
// (mirrors the forward metadata scan above). Advance both the buffer
751+
// offset and the file position each retry. A zero-byte read before the
752+
// window is filled is a genuine premature EOF = the transcript was
753+
// truncated mid-read, which stays a hard error.
754+
let filled = 0;
755+
while (filled < size) {
756+
const { bytesRead } = await handle.read(chunk, filled, size - filled, position + filled);
757+
if (bytesRead === 0) {
758+
throw new Error("Claude transcript changed while it was being read");
759+
}
760+
filled += bytesRead;
751761
}
752-
scanned += bytesRead;
753-
let right = bytesRead;
754-
for (let index = bytesRead - 1; index >= 0; index -= 1) {
762+
scanned += filled;
763+
let right = filled;
764+
for (let index = filled - 1; index >= 0; index -= 1) {
755765
if (chunk[index] !== 0x0a) {
756766
continue;
757767
}

0 commit comments

Comments
 (0)