Skip to content

Commit 23e0be3

Browse files
authored
fix(gateway): bound async session list transcript reads
Keep async sessions.list title/preview hydration on bounded transcript head/tail reads instead of full transcript index builds. Validation: - pnpm test:serial src/gateway/session-utils.fs.test.ts - pnpm test:serial src/gateway/server.sessions.list-changed.test.ts - pnpm exec oxfmt --check --threads=1 src/gateway/session-utils.fs.ts src/gateway/session-utils.fs.test.ts CHANGELOG.md - OPENCLAW_TESTBOX=1 pnpm check:changed on tbx_01kqnw1j8japk3d8z24s6cv141
1 parent bfee47d commit 23e0be3

3 files changed

Lines changed: 101 additions & 30 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai
1313

1414
### Fixes
1515

16+
- Gateway/sessions: keep async `sessions.list` title and preview hydration bounded to transcript head/tail reads so Control UI polling cannot full-scan large session transcripts every refresh. Thanks @vincentkoc.
1617
- Gateway: preserve stack diagnostics when `chat.send` or agent attachment parsing/staging fails, improving image-send failure triage. Refs #63432. (#75135) Thanks @keen0206.
1718
- Maintainer workflow: push prepared PR heads through GitHub's verified commit API by default and require an explicit override before git-protocol pushes can publish unsigned commits. Thanks @BunsDev.
1819
- Feishu: resolve setup/status probes through the selected/default account so multi-account configs with account-scoped app credentials show as configured and probeable. Fixes #72930. Thanks @brokemac79.

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
readSessionMessagesAsync,
2525
readSessionMessages,
2626
readSessionTitleFieldsFromTranscript,
27+
readSessionTitleFieldsFromTranscriptAsync,
2728
readSessionPreviewItemsFromTranscript,
2829
resolveSessionTranscriptCandidates,
2930
} from "./session-utils.fs.js";
@@ -471,6 +472,23 @@ describe("readSessionTitleFieldsFromTranscript cache", () => {
471472
expect(readSpy.mock.calls.length).toBeGreaterThan(readsAfterFirst);
472473
readSpy.mockRestore();
473474
});
475+
476+
test("keeps async title extraction bounded like the sync path", async () => {
477+
const sessionId = "test-cache-async-bounded";
478+
writeTranscript(tmpDir, sessionId, [
479+
{ type: "session", version: 1, id: sessionId },
480+
...Array.from({ length: 30 }, (_, index) => ({
481+
message: { role: "assistant", content: `filler ${index} ${"x".repeat(512)}` },
482+
})),
483+
{ message: { role: "user", content: "late title should not require a full scan" } },
484+
{ message: { role: "assistant", content: "tail preview" } },
485+
]);
486+
487+
await expect(readSessionTitleFieldsFromTranscriptAsync(sessionId, storePath)).resolves.toEqual({
488+
firstUserMessage: null,
489+
lastMessagePreview: "tail preview",
490+
});
491+
});
474492
});
475493

476494
describe("readSessionMessages", () => {

src/gateway/session-utils.fs.ts

Lines changed: 82 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ const transcriptMessageCountCache = new Map<
4242
>();
4343
const MAX_TRANSCRIPT_MESSAGE_COUNT_CACHE_ENTRIES = 5000;
4444
const TRANSCRIPT_ASYNC_READ_CHUNK_BYTES = 64 * 1024;
45+
type TranscriptFileHandle = Awaited<ReturnType<typeof fs.promises.open>>;
4546

4647
function readSessionTitleFieldsCacheKey(
4748
filePath: string,
@@ -813,43 +814,47 @@ export async function readSessionTitleFieldsFromTranscriptAsync(
813814
if (cached) {
814815
return cached;
815816
}
816-
const index = await readSessionTranscriptIndex(filePath);
817-
if (!index) {
818-
return { firstUserMessage: null, lastMessagePreview: null };
817+
818+
if (stat.size === 0) {
819+
const empty = { firstUserMessage: null, lastMessagePreview: null };
820+
setCachedSessionTitleFields(cacheKey, stat, empty);
821+
return empty;
819822
}
820823

821-
let firstUserMessage: string | null = null;
822-
for (const entry of index.entries) {
823-
const msg = entry.record.message as TranscriptMessage | undefined;
824-
if (msg?.role !== "user") {
825-
continue;
826-
}
827-
if (opts?.includeInterSession !== true && hasInterSessionUserProvenance(msg)) {
828-
continue;
829-
}
830-
const text = extractTextFromContent(msg.content);
831-
if (text) {
832-
firstUserMessage = text;
833-
break;
824+
let handle: TranscriptFileHandle | null = null;
825+
try {
826+
handle = await fs.promises.open(filePath, "r");
827+
828+
let firstUserMessage: string | null = null;
829+
try {
830+
const chunk = await readTranscriptHeadChunkAsync(handle);
831+
if (chunk) {
832+
firstUserMessage = extractFirstUserMessageFromTranscriptChunk(chunk, opts);
833+
}
834+
} catch {
835+
// ignore head read errors
834836
}
835-
}
836837

837-
let lastMessagePreview: string | null = null;
838-
for (const entry of index.entries.toReversed()) {
839-
const msg = entry.record.message as TranscriptMessage | undefined;
840-
if (!msg || (msg.role !== "user" && msg.role !== "assistant")) {
841-
continue;
838+
let lastMessagePreview: string | null = null;
839+
try {
840+
lastMessagePreview = await readLastMessagePreviewFromOpenTranscriptAsync({
841+
handle,
842+
size: stat.size,
843+
});
844+
} catch {
845+
// ignore tail read errors
842846
}
843-
const text = extractTextFromContent(msg.content);
844-
if (text) {
845-
lastMessagePreview = text;
846-
break;
847+
848+
const result = { firstUserMessage, lastMessagePreview };
849+
setCachedSessionTitleFields(cacheKey, stat, result);
850+
return result;
851+
} catch {
852+
return { firstUserMessage: null, lastMessagePreview: null };
853+
} finally {
854+
if (handle) {
855+
await handle.close().catch(() => undefined);
847856
}
848857
}
849-
850-
const result = { firstUserMessage, lastMessagePreview };
851-
setCachedSessionTitleFields(cacheKey, stat, result);
852-
return result;
853858
}
854859

855860
function extractTextFromContent(content: TranscriptMessage["content"]): string | null {
@@ -883,6 +888,18 @@ function readTranscriptHeadChunk(fd: number, maxBytes = 8192): string | null {
883888
return buf.toString("utf-8", 0, bytesRead);
884889
}
885890

891+
async function readTranscriptHeadChunkAsync(
892+
handle: TranscriptFileHandle,
893+
maxBytes = 8192,
894+
): Promise<string | null> {
895+
const buffer = Buffer.alloc(maxBytes);
896+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
897+
if (bytesRead <= 0) {
898+
return null;
899+
}
900+
return buffer.toString("utf-8", 0, bytesRead);
901+
}
902+
886903
function extractFirstUserMessageFromTranscriptChunk(
887904
chunk: string,
888905
opts?: { includeInterSession?: boolean },
@@ -993,6 +1010,41 @@ function readLastMessagePreviewFromOpenTranscript(params: {
9931010
return null;
9941011
}
9951012

1013+
async function readLastMessagePreviewFromOpenTranscriptAsync(params: {
1014+
handle: TranscriptFileHandle;
1015+
size: number;
1016+
}): Promise<string | null> {
1017+
const readStart = Math.max(0, params.size - LAST_MSG_MAX_BYTES);
1018+
const readLen = Math.min(params.size, LAST_MSG_MAX_BYTES);
1019+
const buffer = Buffer.alloc(readLen);
1020+
const { bytesRead } = await params.handle.read(buffer, 0, readLen, readStart);
1021+
if (bytesRead <= 0) {
1022+
return null;
1023+
}
1024+
1025+
const chunk = buffer.toString("utf-8", 0, bytesRead);
1026+
const lines = chunk.split(/\r?\n/).filter((line) => line.trim());
1027+
const tailLines = lines.slice(-LAST_MSG_MAX_LINES);
1028+
1029+
for (let i = tailLines.length - 1; i >= 0; i--) {
1030+
const line = tailLines[i];
1031+
try {
1032+
const parsed = JSON.parse(line);
1033+
const msg = parsed?.message as TranscriptMessage | undefined;
1034+
if (msg?.role !== "user" && msg?.role !== "assistant") {
1035+
continue;
1036+
}
1037+
const text = extractTextFromContent(msg.content);
1038+
if (text) {
1039+
return text;
1040+
}
1041+
} catch {
1042+
// skip malformed
1043+
}
1044+
}
1045+
return null;
1046+
}
1047+
9961048
export function readLastMessagePreviewFromTranscript(
9971049
sessionId: string,
9981050
storePath: string | undefined,

0 commit comments

Comments
 (0)