Skip to content

Commit aec83af

Browse files
committed
fix(gateway): bound chat history transcript reads
1 parent 4ee6068 commit aec83af

5 files changed

Lines changed: 201 additions & 44 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
1616
### Fixes
1717

1818
- Google Meet: interrupt Realtime provider output when local barge-in clears playback, so command-pair audio stops model speech instead of only restarting Chrome playback. Fixes #73850. (#73834) Thanks @shhtheonlyperson.
19+
- Gateway/chat: bound chat-history transcript reads to the requested display window so large session logs no longer OOM the Gateway when clients ask for a small history page. Thanks @vincentkoc.
1920
- Voice Call/Twilio: honor stored pre-connect TwiML before realtime webhook shortcuts and reject DTMF sequences outside conversation mode, so Meet PIN entry cannot be skipped or silently dropped. Thanks @donkeykong91 and @PfanP.
2021
- Google Meet/Voice Call: play Twilio Meet DTMF before opening the realtime media stream and carry the intro as the initial Voice Call message, so the greeting is generated after Meet admits the phone participant instead of racing a live-call TwiML update. Thanks @donkeykong91 and @PfanP.
2122
- Google Meet/Voice Call: make Twilio setup preflight honor explicit `--transport twilio` and fail local/private Voice Call webhook URLs, including IPv6 loopback and unique-local forms, before joins. Thanks @donkeykong91 and @PfanP.

src/gateway/server-methods/chat.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ import {
100100
resolveGatewayModelSupportsImages,
101101
resolveGatewaySessionThinkingDefault,
102102
resolveDeletedAgentIdFromSessionKey,
103-
readSessionMessages,
103+
readRecentSessionMessages,
104104
resolveSessionModelRef,
105105
} from "../session-utils.js";
106106
import { formatForLog } from "../ws-log.js";
@@ -1662,25 +1662,30 @@ export const chatHandlers: GatewayRequestHandlers = {
16621662
const sessionId = entry?.sessionId;
16631663
const sessionAgentId = resolveSessionAgentId({ sessionKey, config: cfg });
16641664
const resolvedSessionModel = resolveSessionModelRef(cfg, entry, sessionAgentId);
1665+
const hardMax = 1000;
1666+
const defaultLimit = 200;
1667+
const requested = typeof limit === "number" ? limit : defaultLimit;
1668+
const max = Math.min(hardMax, requested);
1669+
const maxHistoryBytes = getMaxChatHistoryMessagesBytes();
16651670
const localMessages =
1666-
sessionId && storePath ? readSessionMessages(sessionId, storePath, entry?.sessionFile) : [];
1671+
sessionId && storePath
1672+
? readRecentSessionMessages(sessionId, storePath, entry?.sessionFile, {
1673+
maxMessages: max,
1674+
maxBytes: Math.max(maxHistoryBytes * 2, 1024 * 1024),
1675+
})
1676+
: [];
16671677
const rawMessages = augmentChatHistoryWithCliSessionImports({
16681678
entry,
16691679
provider: resolvedSessionModel.provider,
16701680
localMessages,
16711681
});
1672-
const hardMax = 1000;
1673-
const defaultLimit = 200;
1674-
const requested = typeof limit === "number" ? limit : defaultLimit;
1675-
const max = Math.min(hardMax, requested);
16761682
const effectiveMaxChars = resolveEffectiveChatHistoryMaxChars(cfg, maxChars);
16771683
const normalized = augmentChatHistoryWithCanvasBlocks(
16781684
projectRecentChatDisplayMessages(rawMessages, {
16791685
maxChars: effectiveMaxChars,
16801686
maxMessages: max,
16811687
}),
16821688
);
1683-
const maxHistoryBytes = getMaxChatHistoryMessagesBytes();
16841689
const perMessageHardCap = Math.min(CHAT_HISTORY_MAX_SINGLE_MESSAGE_BYTES, maxHistoryBytes);
16851690
const replaced = replaceOversizedChatHistoryMessages({
16861691
messages: normalized,

src/gateway/session-utils.fs.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
readFirstUserMessageFromTranscript,
99
readLastMessagePreviewFromTranscript,
1010
readLatestSessionUsageFromTranscript,
11+
readRecentSessionMessages,
1112
readSessionMessages,
1213
readSessionTitleFieldsFromTranscript,
1314
readSessionPreviewItemsFromTranscript,
@@ -501,6 +502,66 @@ describe("readSessionMessages", () => {
501502
expect(typeof marker.timestamp).toBe("number");
502503
});
503504

505+
test("reads recent messages from the transcript tail without loading the whole file", () => {
506+
const sessionId = "test-session-recent-tail";
507+
writeTranscript(tmpDir, sessionId, [
508+
{ type: "session", version: 1, id: sessionId },
509+
{ message: { role: "user", content: "old" } },
510+
{ message: { role: "assistant", content: "middle" } },
511+
{ message: { role: "user", content: "recent" } },
512+
{ message: { role: "assistant", content: "latest" } },
513+
]);
514+
515+
const out = readRecentSessionMessages(sessionId, storePath, undefined, {
516+
maxMessages: 2,
517+
maxBytes: 1024,
518+
});
519+
520+
expect(out).toEqual([
521+
expect.objectContaining({
522+
role: "user",
523+
content: "recent",
524+
__openclaw: expect.objectContaining({ seq: 3 }),
525+
}),
526+
expect.objectContaining({
527+
role: "assistant",
528+
content: "latest",
529+
__openclaw: expect.objectContaining({ seq: 4 }),
530+
}),
531+
]);
532+
});
533+
534+
test("bounds recent-message reads for large append-only transcripts", () => {
535+
const sessionId = "test-session-recent-large";
536+
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
537+
const lines = [
538+
JSON.stringify({ type: "session", version: 1, id: sessionId }),
539+
...Array.from({ length: 2500 }, (_, index) =>
540+
JSON.stringify({
541+
message: {
542+
role: index % 2 === 0 ? "user" : "assistant",
543+
content: `message ${index} ${"x".repeat(700)}`,
544+
},
545+
}),
546+
),
547+
JSON.stringify({ message: { role: "assistant", content: "tail" } }),
548+
];
549+
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
550+
const readFileSpy = vi.spyOn(fs, "readFileSync");
551+
552+
try {
553+
const out = readRecentSessionMessages(sessionId, storePath, undefined, {
554+
maxMessages: 1,
555+
maxBytes: 64 * 1024,
556+
});
557+
expect(out).toHaveLength(1);
558+
expect(out[0]).toMatchObject({ role: "assistant", content: "tail" });
559+
expect(readFileSpy).not.toHaveBeenCalled();
560+
} finally {
561+
readFileSpy.mockRestore();
562+
}
563+
});
564+
504565
test("reads only the active branch when transcript rewrites abandon older entries", () => {
505566
const sessionId = "test-session-active-branch";
506567
const sessionFile = path.join(tmpDir, `${sessionId}.jsonl`);

src/gateway/session-utils.fs.ts

Lines changed: 126 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -104,17 +104,7 @@ export function readSessionMessages(
104104
}
105105

106106
const lines = fs.readFileSync(filePath, "utf-8").split(/\r?\n/);
107-
const hasTreeEntries = lines.some((line) => {
108-
if (!line.trim()) {
109-
return false;
110-
}
111-
try {
112-
const parsed = JSON.parse(line) as { type?: unknown; id?: unknown; parentId?: unknown };
113-
return parsed.type !== "session" && typeof parsed.id === "string" && "parentId" in parsed;
114-
} catch {
115-
return false;
116-
}
117-
});
107+
const hasTreeEntries = lines.some(hasSessionTreeEntry);
118108
let branchEntries: SessionEntry[] | null = null;
119109
if (hasTreeEntries) {
120110
try {
@@ -166,33 +156,10 @@ export function readSessionMessages(
166156
}
167157
try {
168158
const parsed = JSON.parse(line);
169-
if (parsed?.message) {
159+
const message = parsedSessionEntryToMessage(parsed, messageSeq + 1);
160+
if (message) {
170161
messageSeq += 1;
171-
messages.push(
172-
attachOpenClawTranscriptMeta(parsed.message, {
173-
...(typeof parsed.id === "string" ? { id: parsed.id } : {}),
174-
seq: messageSeq,
175-
}),
176-
);
177-
continue;
178-
}
179-
180-
// Compaction entries are not "message" records, but they're useful context for debugging.
181-
// Emit a lightweight synthetic message that the Web UI can render as a divider.
182-
if (parsed?.type === "compaction") {
183-
const ts = typeof parsed.timestamp === "string" ? Date.parse(parsed.timestamp) : Number.NaN;
184-
const timestamp = Number.isFinite(ts) ? ts : Date.now();
185-
messageSeq += 1;
186-
messages.push({
187-
role: "system",
188-
content: [{ type: "text", text: "Compaction" }],
189-
timestamp,
190-
__openclaw: {
191-
kind: "compaction",
192-
id: typeof parsed.id === "string" ? parsed.id : undefined,
193-
seq: messageSeq,
194-
},
195-
});
162+
messages.push(message);
196163
}
197164
} catch {
198165
// ignore bad lines
@@ -201,6 +168,128 @@ export function readSessionMessages(
201168
return messages;
202169
}
203170

171+
export type ReadRecentSessionMessagesOptions = {
172+
maxMessages: number;
173+
maxBytes?: number;
174+
maxLines?: number;
175+
};
176+
177+
const RECENT_SESSION_MESSAGES_DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
178+
179+
export function readRecentSessionMessages(
180+
sessionId: string,
181+
storePath: string | undefined,
182+
sessionFile?: string,
183+
opts?: ReadRecentSessionMessagesOptions,
184+
): unknown[] {
185+
const maxMessages = Math.max(0, Math.floor(opts?.maxMessages ?? 0));
186+
if (maxMessages === 0) {
187+
return [];
188+
}
189+
190+
const filePath = findExistingTranscriptPath(sessionId, storePath, sessionFile);
191+
if (!filePath) {
192+
return [];
193+
}
194+
195+
let stat: fs.Stats;
196+
try {
197+
stat = fs.statSync(filePath);
198+
} catch {
199+
return [];
200+
}
201+
if (stat.size === 0) {
202+
return [];
203+
}
204+
205+
const maxBytes = Math.max(
206+
1024,
207+
Math.floor(opts?.maxBytes ?? RECENT_SESSION_MESSAGES_DEFAULT_MAX_BYTES),
208+
);
209+
const readLen = Math.min(stat.size, maxBytes);
210+
const readStart = Math.max(0, stat.size - readLen);
211+
const maxLines = Math.max(maxMessages, Math.floor(opts?.maxLines ?? maxMessages * 20 + 20));
212+
213+
return (
214+
withOpenTranscriptFd(filePath, (fd) => {
215+
const buf = Buffer.alloc(readLen);
216+
const bytesRead = fs.readSync(fd, buf, 0, readLen, readStart);
217+
if (bytesRead <= 0) {
218+
return [];
219+
}
220+
const chunk = buf.toString("utf-8", 0, bytesRead);
221+
const lines = chunk
222+
.split(/\r?\n/)
223+
.slice(readStart > 0 ? 1 : 0)
224+
.filter((line) => line.trim().length > 0)
225+
.slice(-maxLines);
226+
227+
if (lines.some(hasSessionTreeEntry)) {
228+
return readSessionMessages(sessionId, storePath, sessionFile).slice(-maxMessages);
229+
}
230+
231+
const messages: unknown[] = [];
232+
let messageSeq = 0;
233+
for (const line of lines) {
234+
try {
235+
const parsed = JSON.parse(line);
236+
const message = parsedSessionEntryToMessage(parsed, messageSeq + 1);
237+
if (message) {
238+
messageSeq += 1;
239+
messages.push(message);
240+
}
241+
} catch {
242+
// ignore bad tail lines
243+
}
244+
}
245+
return messages.slice(-maxMessages);
246+
}) ?? []
247+
);
248+
}
249+
250+
function hasSessionTreeEntry(line: string): boolean {
251+
if (!line.trim()) {
252+
return false;
253+
}
254+
try {
255+
const parsed = JSON.parse(line) as { type?: unknown; id?: unknown; parentId?: unknown };
256+
return parsed.type !== "session" && typeof parsed.id === "string" && "parentId" in parsed;
257+
} catch {
258+
return false;
259+
}
260+
}
261+
262+
function parsedSessionEntryToMessage(parsed: unknown, seq: number): unknown | null {
263+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
264+
return null;
265+
}
266+
const entry = parsed as Record<string, unknown>;
267+
if (entry.message) {
268+
return attachOpenClawTranscriptMeta(entry.message, {
269+
...(typeof entry.id === "string" ? { id: entry.id } : {}),
270+
seq,
271+
});
272+
}
273+
274+
// Compaction entries are not "message" records, but they're useful context for debugging.
275+
// Emit a lightweight synthetic message that the Web UI can render as a divider.
276+
if (entry.type === "compaction") {
277+
const ts = typeof entry.timestamp === "string" ? Date.parse(entry.timestamp) : Number.NaN;
278+
const timestamp = Number.isFinite(ts) ? ts : Date.now();
279+
return {
280+
role: "system",
281+
content: [{ type: "text", text: "Compaction" }],
282+
timestamp,
283+
__openclaw: {
284+
kind: "compaction",
285+
id: typeof entry.id === "string" ? entry.id : undefined,
286+
seq,
287+
},
288+
};
289+
}
290+
return null;
291+
}
292+
204293
export {
205294
archiveFileOnDisk,
206295
archiveSessionTranscripts,

src/gateway/session-utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ export {
105105
readFirstUserMessageFromTranscript,
106106
readLastMessagePreviewFromTranscript,
107107
readLatestSessionUsageFromTranscript,
108+
readRecentSessionMessages,
108109
readSessionTitleFieldsFromTranscript,
109110
readSessionPreviewItemsFromTranscript,
110111
readSessionMessages,

0 commit comments

Comments
 (0)