Skip to content

Commit e7b0e99

Browse files
committed
fix(gateway): fill bounded transcript windows across short reads
1 parent ed7d9ef commit e7b0e99

2 files changed

Lines changed: 148 additions & 0 deletions

File tree

src/gateway/session-utils.fs.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1749,6 +1749,9 @@ function readRecentMessagesFromTranscript(
17491749
const readLen = Math.min(size, readBytes);
17501750
const buf = Buffer.alloc(readLen);
17511751
const bytesRead = readFileWindowFullySync(fd, buf, readStart);
1752+
if (bytesRead <= 0) {
1753+
return [];
1754+
}
17521755

17531756
const chunk = buf.toString("utf-8", 0, bytesRead);
17541757
const lines = chunk.split(/\r?\n/).filter((l) => l.trim());
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
// Covers transcript readers when positional reads return short (POSIX allows
2+
// fewer bytes than requested); bounded windows must not drop or corrupt data.
3+
import fs from "node:fs";
4+
import os from "node:os";
5+
import path from "node:path";
6+
import { afterAll, beforeAll, afterEach, describe, expect, test, vi } from "vitest";
7+
import {
8+
readRecentSessionUsageFromTranscript,
9+
readSessionPreviewItemsFromTranscript,
10+
readSessionTitleFieldsFromTranscript,
11+
readSessionTitleFieldsFromTranscriptAsync,
12+
} from "./session-utils.fs.js";
13+
14+
const SHORT_READ_CAP_BYTES = 16;
15+
16+
let tmpDir = "";
17+
let storePath = "";
18+
beforeAll(() => {
19+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-session-short-read-"));
20+
storePath = path.join(tmpDir, "sessions.json");
21+
});
22+
afterAll(() => {
23+
if (tmpDir) {
24+
fs.rmSync(tmpDir, { recursive: true, force: true });
25+
}
26+
});
27+
afterEach(() => {
28+
vi.restoreAllMocks();
29+
});
30+
31+
function writeTranscript(sessionId: string, lines: unknown[]): string {
32+
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
33+
fs.writeFileSync(transcriptPath, lines.map((line) => JSON.stringify(line)).join("\n"), "utf-8");
34+
return transcriptPath;
35+
}
36+
37+
function capSyncReads() {
38+
const realReadSync = fs.readSync.bind(fs);
39+
const cappedReadSync = (
40+
fd: number,
41+
buffer: NodeJS.ArrayBufferView,
42+
offset: number,
43+
length: number,
44+
position: fs.ReadPosition | null,
45+
): number => realReadSync(fd, buffer, offset, Math.min(length, SHORT_READ_CAP_BYTES), position);
46+
vi.spyOn(fs, "readSync").mockImplementation(cappedReadSync as typeof fs.readSync);
47+
}
48+
49+
function capAsyncReads() {
50+
const realOpen = fs.promises.open.bind(fs.promises);
51+
vi.spyOn(fs.promises, "open").mockImplementation(async (...args) => {
52+
const handle = await realOpen(...(args as Parameters<typeof realOpen>));
53+
const realRead = handle.read.bind(handle);
54+
return new Proxy(handle, {
55+
get(target, prop, receiver) {
56+
if (prop === "read") {
57+
return (
58+
buffer: NodeJS.ArrayBufferView,
59+
offset: number,
60+
length: number,
61+
position: number | null,
62+
) => realRead(buffer, offset, Math.min(length, SHORT_READ_CAP_BYTES), position);
63+
}
64+
return Reflect.get(target, prop, receiver);
65+
},
66+
});
67+
});
68+
}
69+
70+
function basicTranscript(sessionId: string): unknown[] {
71+
return [
72+
{ type: "session", version: 1, id: sessionId },
73+
{ message: { role: "user", content: "first user message" } },
74+
{ message: { role: "assistant", content: "middle reply" } },
75+
{ message: { role: "assistant", content: "final preview text" } },
76+
];
77+
}
78+
79+
describe("transcript readers with short positional reads", () => {
80+
test("sync title fields keep the real last message preview", () => {
81+
const sessionId = "short-read-title-sync";
82+
writeTranscript(sessionId, basicTranscript(sessionId));
83+
capSyncReads();
84+
85+
const fields = readSessionTitleFieldsFromTranscript(sessionId, storePath);
86+
87+
expect(fields.firstUserMessage).toBe("first user message");
88+
expect(fields.lastMessagePreview).toBe("final preview text");
89+
});
90+
91+
test("async title fields keep the real last message preview", async () => {
92+
const sessionId = "short-read-title-async";
93+
writeTranscript(sessionId, basicTranscript(sessionId));
94+
capAsyncReads();
95+
96+
const fields = await readSessionTitleFieldsFromTranscriptAsync(sessionId, storePath);
97+
98+
expect(fields.firstUserMessage).toBe("first user message");
99+
expect(fields.lastMessagePreview).toBe("final preview text");
100+
});
101+
102+
test("preview items deliver the transcript tail", () => {
103+
const sessionId = "short-read-preview-items";
104+
writeTranscript(sessionId, basicTranscript(sessionId));
105+
capSyncReads();
106+
107+
const items = readSessionPreviewItemsFromTranscript(
108+
sessionId,
109+
storePath,
110+
undefined,
111+
undefined,
112+
3,
113+
120,
114+
);
115+
116+
expect(items.map((item) => item.text)).toContain("final preview text");
117+
});
118+
119+
test("recent usage snapshot still finds the trailing usage line", () => {
120+
const sessionId = "short-read-usage";
121+
writeTranscript(sessionId, [
122+
{ type: "session", version: 1, id: sessionId },
123+
{ message: { role: "user", content: `filler ${"x".repeat(256)}` } },
124+
{
125+
message: {
126+
role: "assistant",
127+
provider: "openai",
128+
model: "gpt-5.4",
129+
usage: { input: 900, output: 100, cost: { total: 0.003 } },
130+
},
131+
},
132+
]);
133+
capSyncReads();
134+
135+
const usage = readRecentSessionUsageFromTranscript(
136+
sessionId,
137+
storePath,
138+
undefined,
139+
undefined,
140+
64 * 1024,
141+
);
142+
143+
expect(usage).toMatchObject({ modelProvider: "openai", inputTokens: 900, outputTokens: 100 });
144+
});
145+
});

0 commit comments

Comments
 (0)