|
1 | 1 | import fs from "node:fs/promises"; |
| 2 | +import type { FileHandle } from "node:fs/promises"; |
2 | 3 | import os from "node:os"; |
3 | 4 | import path from "node:path"; |
4 | 5 | import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; |
@@ -201,6 +202,52 @@ function sdkCliMessage(sessionId: string, text: string): Record<string, unknown> |
201 | 202 | }; |
202 | 203 | } |
203 | 204 |
|
| 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 | + |
204 | 251 | afterEach(async () => { |
205 | 252 | vi.restoreAllMocks(); |
206 | 253 | nodeHostMocks.runNodePtyCommand.mockClear(); |
@@ -1105,6 +1152,87 @@ describe("Claude session catalog", () => { |
1105 | 1152 | expect(older.nextCursor).toBeUndefined(); |
1106 | 1153 | }); |
1107 | 1154 |
|
| 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 | + |
1108 | 1236 | it("advertises terminal resume only when the store and Claude binary exist", async () => { |
1109 | 1237 | const home = await createHome(); |
1110 | 1238 | const commands = createClaudeSessionNodeHostCommands(); |
|
0 commit comments