diff --git a/CHANGELOG.md b/CHANGELOG.md index 54b1bb336f6..e0ae84b59d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/gateway/session-utils.fs.test.ts b/src/gateway/session-utils.fs.test.ts index df897afc4fd..d5d04f02dd9 100644 --- a/src/gateway/session-utils.fs.test.ts +++ b/src/gateway/session-utils.fs.test.ts @@ -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"); + }); +}); diff --git a/src/gateway/session-utils.fs.ts b/src/gateway/session-utils.fs.ts index 79d7204d82a..ddb0a026df7 100644 --- a/src/gateway/session-utils.fs.ts +++ b/src/gateway/session-utils.fs.ts @@ -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; const message =