fix(gateway): bound sessions list transcript usage

This commit is contained in:
Vincent Koc
2026-05-01 00:33:35 -07:00
parent aec83af23d
commit ecf6cbf75d
4 changed files with 99 additions and 6 deletions

View File

@@ -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.

View File

@@ -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, [

View File

@@ -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;

View File

@@ -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,
}),
);