fix(gateway): preserve oversized transcript placeholders

This commit is contained in:
Vincent Koc
2026-05-02 21:16:16 -07:00
parent e8756d99ae
commit a32fe82bd2
3 changed files with 114 additions and 0 deletions

View File

@@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
- 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.
- CLI/plugins: reject missing plugin ids before config writes in `plugins enable` and `plugins disable` so a typo no longer persists a stale config entry. (#73554) Thanks @ai-hpc.
- Agents/sessions: preserve delivered trailing assistant replies during session-file repair so Telegram/WebChat history is not rewritten to drop already-delivered responses. Fixes #76329. Thanks @obviyus.
- Gateway/chat history: preserve oversized transcript turns as explicit omitted-message placeholders while avoiding large JSONL parse stalls. Thanks @vincentkoc.
- Gateway: preserve stack diagnostics when `chat.send` or agent attachment parsing/staging fails, improving image-send failure triage. Refs #63432. (#75135) Thanks @keen0206.
- 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.
- 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.

View File

@@ -1672,3 +1672,91 @@ describe("archiveSessionTranscripts", () => {
expect(fs.existsSync(transcriptPath)).toBe(false);
});
});
describe("oversized transcript line guards", () => {
let tmpDir: string;
let storePath: string;
registerTempSessionStore("openclaw-session-fs-oversized-", (nextTmpDir, nextStorePath) => {
tmpDir = nextTmpDir;
storePath = nextStorePath;
});
test("readRecentSessionMessagesAsync replaces oversized JSONL lines with placeholders", async () => {
const sessionId = "test-oversized-recent";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const oversizedContent = "x".repeat(300 * 1024);
const lines = [
JSON.stringify({ type: "session", version: 1, id: sessionId }),
JSON.stringify({ message: { role: "user", content: "start" } }),
JSON.stringify({ message: { role: "assistant", content: oversizedContent } }),
JSON.stringify({ message: { role: "user", content: "after oversized" } }),
];
fs.writeFileSync(transcriptPath, `${lines.join("\n")}\n`, "utf-8");
const out = await readRecentSessionMessagesAsync(sessionId, storePath, undefined, {
maxMessages: 10,
});
const serialized = JSON.stringify(out);
expect(serialized).not.toContain(oversizedContent);
expect(serialized).toContain("[chat.history omitted: message too large]");
expect(serialized).toContain("after oversized");
});
test("readRecentSessionUsageFromTranscriptAsync skips oversized lines", async () => {
const sessionId = "test-oversized-usage";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const oversizedContent = "y".repeat(300 * 1024);
const lines = [
JSON.stringify({ type: "session", version: 1, id: sessionId }),
JSON.stringify({
message: {
role: "assistant",
content: oversizedContent,
usage: { input: 9999, output: 9999 },
provider: "oversized-provider",
model: "oversized-model",
},
}),
JSON.stringify({
message: {
role: "assistant",
content: "normal",
usage: { input: 100, output: 50 },
provider: "test-provider",
model: "test-model",
},
}),
];
fs.writeFileSync(transcriptPath, `${lines.join("\n")}\n`, "utf-8");
const usage = await readRecentSessionUsageFromTranscriptAsync(
sessionId,
storePath,
undefined,
undefined,
512 * 1024,
);
expect(usage).not.toBeNull();
expect(usage?.modelProvider).not.toBe("oversized-provider");
expect(usage?.modelProvider).toBe("test-provider");
});
test("readSessionTitleFieldsFromTranscriptAsync delegates to bounded sync reader", async () => {
const sessionId = "test-async-title-bounded";
writeTranscript(
tmpDir,
sessionId,
buildBasicSessionTranscript(sessionId, "User says hi", "Bot says hello"),
);
const syncResult = readSessionTitleFieldsFromTranscript(sessionId, storePath);
const asyncResult = await readSessionTitleFieldsFromTranscriptAsync(sessionId, storePath);
expect(asyncResult).toEqual(syncResult);
expect(asyncResult.firstUserMessage).toBe("User says hi");
expect(asyncResult.lastMessagePreview).toBe("Bot says hello");
});
});

View File

@@ -266,11 +266,33 @@ async function readRecentTranscriptTailLinesAsync(
}
}
const MAX_TRANSCRIPT_PARSE_LINE_BYTES = 256 * 1024;
const TRANSCRIPT_OVERSIZED_MESSAGE_PLACEHOLDER = "[chat.history omitted: message too large]";
function isOversizedTranscriptLine(line: string): boolean {
return Buffer.byteLength(line, "utf8") > MAX_TRANSCRIPT_PARSE_LINE_BYTES;
}
function buildOversizedTranscriptRecord(): TailTranscriptRecord {
return {
record: {
message: {
role: "assistant",
content: [{ type: "text", text: TRANSCRIPT_OVERSIZED_MESSAGE_PLACEHOLDER }],
__openclaw: { truncated: true, reason: "oversized" },
},
},
};
}
function normalizeTailEntryString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
}
function parseTailTranscriptRecord(line: string): TailTranscriptRecord | null {
if (isOversizedTranscriptLine(line)) {
return buildOversizedTranscriptRecord();
}
try {
const parsed = JSON.parse(line) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
@@ -1097,6 +1119,9 @@ function resolvePositiveUsageNumber(value: unknown): number | undefined {
function extractUsageSnapshotFromTranscriptLine(
line: string,
): SessionTranscriptUsageSnapshot | null {
if (isOversizedTranscriptLine(line)) {
return null;
}
try {
const parsed = JSON.parse(line) as Record<string, unknown>;
const message =