diff --git a/CHANGELOG.md b/CHANGELOG.md index becc3bb43c3..286311a16d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai ### Fixes - 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. +- Gateway/sessions: use bounded tail reads for sessions-list transcript usage fallbacks, keeping large session stores responsive when rows request derived previews. Thanks @vincentkoc. - 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. - 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. - 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. diff --git a/src/gateway/session-utils.fs.test.ts b/src/gateway/session-utils.fs.test.ts index 008fcc3d998..3a34f9ada0f 100644 --- a/src/gateway/session-utils.fs.test.ts +++ b/src/gateway/session-utils.fs.test.ts @@ -8,6 +8,7 @@ import { readFirstUserMessageFromTranscript, readLastMessagePreviewFromTranscript, readLatestSessionUsageFromTranscript, + readRecentSessionUsageFromTranscript, readRecentSessionMessages, readSessionMessages, readSessionTitleFieldsFromTranscript, @@ -947,6 +948,48 @@ describe("readLatestSessionUsageFromTranscript", () => { expect(snapshot?.costUsd).toBeCloseTo(0.0063, 8); }); + test("bounds recent usage reads for bulk session listing", () => { + const sessionId = "usage-recent-large"; + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + const lines = [ + JSON.stringify({ type: "session", version: 1, id: sessionId }), + ...Array.from({ length: 2500 }, (_, index) => + JSON.stringify({ + message: { role: "user", content: `filler ${index} ${"x".repeat(700)}` }, + }), + ), + JSON.stringify({ + message: { + role: "assistant", + provider: "openai", + model: "gpt-5.4", + usage: { + input: 900, + output: 100, + cost: { total: 0.003 }, + }, + }, + }), + ]; + fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); + const readFileSpy = vi.spyOn(fs, "readFileSync"); + + try { + expect( + readRecentSessionUsageFromTranscript(sessionId, storePath, undefined, undefined, 64 * 1024), + ).toMatchObject({ + modelProvider: "openai", + model: "gpt-5.4", + inputTokens: 900, + outputTokens: 100, + totalTokens: 900, + }); + expect(readFileSpy).not.toHaveBeenCalled(); + } finally { + readFileSpy.mockRestore(); + } + }); + test("returns null when the transcript has no assistant usage snapshot", () => { const sessionId = "usage-empty"; writeTranscript(tmpDir, sessionId, [ diff --git a/src/gateway/session-utils.fs.ts b/src/gateway/session-utils.fs.ts index 32ffb820fd2..3a8205cb93d 100644 --- a/src/gateway/session-utils.fs.ts +++ b/src/gateway/session-utils.fs.ts @@ -730,6 +730,39 @@ export function readLatestSessionUsageFromTranscript( }); } +export function readRecentSessionUsageFromTranscript( + sessionId: string, + storePath: string | undefined, + sessionFile: string | undefined, + agentId: string | undefined, + maxBytes: number, +): SessionTranscriptUsageSnapshot | null { + const filePath = findExistingTranscriptPath(sessionId, storePath, sessionFile, agentId); + if (!filePath) { + return null; + } + + return withOpenTranscriptFd(filePath, (fd) => { + const stat = fs.fstatSync(fd); + if (stat.size === 0) { + return null; + } + const readLen = Math.min(stat.size, Math.max(1024, Math.floor(maxBytes))); + const readStart = Math.max(0, stat.size - readLen); + const buf = Buffer.alloc(readLen); + const bytesRead = fs.readSync(fd, buf, 0, readLen, readStart); + if (bytesRead <= 0) { + return null; + } + const chunk = buf + .toString("utf-8", 0, bytesRead) + .split(/\r?\n/) + .slice(readStart > 0 ? 1 : 0) + .join("\n"); + return extractLatestUsageFromTranscriptChunk(chunk); + }); +} + const PREVIEW_READ_SIZES = [64 * 1024, 256 * 1024, 1024 * 1024]; const PREVIEW_MAX_LINES = 200; diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 66a0f8d4e80..269c938643a 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -87,6 +87,7 @@ import { } from "./session-store-key.js"; import { readLatestSessionUsageFromTranscript, + readRecentSessionUsageFromTranscript, readSessionTitleFieldsFromTranscript, } from "./session-utils.fs.js"; import type { @@ -105,6 +106,7 @@ export { readFirstUserMessageFromTranscript, readLastMessagePreviewFromTranscript, readLatestSessionUsageFromTranscript, + readRecentSessionUsageFromTranscript, readRecentSessionMessages, readSessionTitleFieldsFromTranscript, readSessionPreviewItemsFromTranscript, @@ -403,6 +405,7 @@ function resolveTranscriptUsageFallback(params: { storePath: string; fallbackProvider?: string; fallbackModel?: string; + maxTranscriptBytes?: number; }): { estimatedCostUsd?: number; totalTokens?: number; @@ -419,12 +422,21 @@ function resolveTranscriptUsageFallback(params: { const agentId = parsed?.agentId ? normalizeAgentId(parsed.agentId) : resolveDefaultAgentId(params.cfg); - const snapshot = readLatestSessionUsageFromTranscript( - entry.sessionId, - params.storePath, - entry.sessionFile, - agentId, - ); + const snapshot = + typeof params.maxTranscriptBytes === "number" + ? readRecentSessionUsageFromTranscript( + entry.sessionId, + params.storePath, + entry.sessionFile, + agentId, + params.maxTranscriptBytes, + ) + : readLatestSessionUsageFromTranscript( + entry.sessionId, + params.storePath, + entry.sessionFile, + agentId, + ); if (!snapshot) { return null; } @@ -1300,6 +1312,7 @@ export function buildGatewaySessionRow(params: { now?: number; includeDerivedTitles?: boolean; includeLastMessage?: boolean; + transcriptUsageMaxBytes?: number; }): GatewaySessionRow { const { cfg, storePath, store, key, entry } = params; const now = params.now ?? Date.now(); @@ -1408,6 +1421,7 @@ export function buildGatewaySessionRow(params: { storePath, fallbackProvider: resolvedModel.provider, fallbackModel: resolvedModel.model ?? DEFAULT_MODEL, + maxTranscriptBytes: params.transcriptUsageMaxBytes, }) : null; const preferLiveSubagentModelIdentity = @@ -1614,6 +1628,7 @@ export function listSessionsFromStore(params: { }): SessionsListResult { const { cfg, storePath, store, opts } = params; const now = Date.now(); + const sessionListTranscriptUsageMaxBytes = 64 * 1024; const includeGlobal = opts.includeGlobal === true; const includeUnknown = opts.includeUnknown === true; @@ -1720,6 +1735,7 @@ export function listSessionsFromStore(params: { now, includeDerivedTitles, includeLastMessage, + transcriptUsageMaxBytes: sessionListTranscriptUsageMaxBytes, }), );