From 81fd771cb9989dbafea74f8912016a2cf8017ff2 Mon Sep 17 00:00:00 2001 From: Sebastian <19554889+sebslight@users.noreply.github.com> Date: Mon, 16 Feb 2026 21:50:01 -0500 Subject: [PATCH] fix(gateway): preserve chat.history context under hard caps --- CHANGELOG.md | 1 + docs/web/control-ui.md | 1 + docs/web/webchat.md | 1 + src/gateway/server-methods/chat.ts | 66 +++++++++++---- ...ver.chat.gateway-server-chat-b.e2e.test.ts | 83 +++++++++++++++++++ 5 files changed, 134 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7768025dcd..c9082126bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Gateway/Channels: wire `gateway.channelHealthCheckMinutes` into strict config validation, treat implicit account status as managed for health checks, and harden channel auto-restart flow (preserve restart-attempt caps across crash loops, propagate enabled/configured runtime flags, and stop pending restart backoff after manual stop). Thanks @steipete. +- Gateway/WebChat: hard-cap `chat.history` oversized payloads by truncating high-cost fields and replacing over-budget entries with placeholders, so history fetches stay within configured byte limits and avoid chat UI freezes. (#18505) - UI/Usage: replace lingering undefined `var(--text-muted)` usage with `var(--muted)` in usage date-range and chart styles to keep muted text visible across themes. (#17975) Thanks @jogelin. - UI/Usage: preserve selected-range totals when timeline data is downsampled by bucket-aggregating timeseries points (instead of dropping intermediate points), so filtered tokens/cost stay accurate. (#17959) Thanks @jogelin. - UI/Sessions: refresh the sessions table only after successful deletes and preserve delete errors on cancel/failure paths, so deleted sessions disappear automatically without masking delete failures. (#18507) diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 2547cc0b46..fad37a47a1 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -92,6 +92,7 @@ Cron jobs panel notes: - `chat.send` is **non-blocking**: it acks immediately with `{ runId, status: "started" }` and the response streams via `chat` events. - Re-sending with the same `idempotencyKey` returns `{ status: "in_flight" }` while running, and `{ status: "ok" }` after completion. +- `chat.history` responses are size-bounded for UI safety. When transcript entries are too large, Gateway may truncate long text fields, omit heavy metadata blocks, and replace oversized messages with a placeholder (`[chat.history omitted: message too large]`). - `chat.inject` appends an assistant note to the session transcript and broadcasts a `chat` event for UI-only updates (no agent run, no channel delivery). - Stop: - Click **Stop** (calls `chat.abort`) diff --git a/docs/web/webchat.md b/docs/web/webchat.md index 657e00ef8b..9853e37215 100644 --- a/docs/web/webchat.md +++ b/docs/web/webchat.md @@ -24,6 +24,7 @@ Status: the macOS/iOS SwiftUI chat UI talks directly to the Gateway WebSocket. ## How it works (behavior) - The UI connects to the Gateway WebSocket and uses `chat.history`, `chat.send`, and `chat.inject`. +- `chat.history` is bounded for stability: Gateway may truncate long text fields, omit heavy metadata, and replace oversized entries with `[chat.history omitted: message too large]`. - `chat.inject` appends an assistant note directly to the transcript and broadcasts it to the UI (no agent run). - Aborted runs can keep partial assistant output visible in the UI. - Gateway persists aborted partial assistant text into transcript history when buffered output exists, and marks those entries with abort metadata. diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 635156cd8b..1af817aa1e 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -1,12 +1,13 @@ +import { CURRENT_SESSION_VERSION, SessionManager } from "@mariozechner/pi-coding-agent"; import fs from "node:fs"; import path from "node:path"; -import { CURRENT_SESSION_VERSION, SessionManager } from "@mariozechner/pi-coding-agent"; +import type { MsgContext } from "../../auto-reply/templating.js"; +import type { GatewayRequestContext, GatewayRequestHandlers } from "./types.js"; import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import { resolveThinkingDefault } from "../../agents/model-selection.js"; import { resolveAgentTimeoutMs } from "../../agents/timeout.js"; import { dispatchInboundMessage } from "../../auto-reply/dispatch.js"; import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js"; -import type { MsgContext } from "../../auto-reply/templating.js"; import { createReplyPrefixOptions } from "../../channels/reply-prefix.js"; import { resolveSessionFilePath } from "../../config/sessions.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js"; @@ -41,7 +42,6 @@ import { import { formatForLog } from "../ws-log.js"; import { injectTimestamp, timestampOptsFromConfig } from "./agent-timestamp.js"; import { normalizeRpcAttachmentsToChatAttachments } from "./attachment-normalize.js"; -import type { GatewayRequestContext, GatewayRequestHandlers } from "./types.js"; type TranscriptAppendResult = { ok: boolean; @@ -63,6 +63,7 @@ type AbortedPartialSnapshot = { const CHAT_HISTORY_TEXT_MAX_CHARS = 12_000; const CHAT_HISTORY_MAX_SINGLE_MESSAGE_BYTES = 128 * 1024; const CHAT_HISTORY_OVERSIZED_PLACEHOLDER = "[chat.history omitted: message too large]"; +let chatHistoryPlaceholderEmitCount = 0; function stripDisallowedChatControlChars(message: string): string { let output = ""; @@ -219,29 +220,45 @@ function buildOversizedHistoryPlaceholder(message?: unknown): Record { - if (jsonUtf8Bytes(message) <= CHAT_HISTORY_MAX_SINGLE_MESSAGE_BYTES) { + let replacedCount = 0; + const next = messages.map((message) => { + if (jsonUtf8Bytes(message) <= maxSingleMessageBytes) { return message; } + replacedCount += 1; return buildOversizedHistoryPlaceholder(message); }); - const softCapped = capArrayByJsonBytes(normalized, maxBytes).items; - if (jsonUtf8Bytes(softCapped) <= maxBytes) { - return softCapped; + return { messages: replacedCount > 0 ? next : messages, replacedCount }; +} + +function enforceChatHistoryFinalBudget(params: { messages: unknown[]; maxBytes: number }): { + messages: unknown[]; + placeholderCount: number; +} { + const { messages, maxBytes } = params; + if (messages.length === 0) { + return { messages, placeholderCount: 0 }; } - const last = softCapped.at(-1); + if (jsonUtf8Bytes(messages) <= maxBytes) { + return { messages, placeholderCount: 0 }; + } + const last = messages.at(-1); if (last && jsonUtf8Bytes([last]) <= maxBytes) { - return [last]; + return { messages: [last], placeholderCount: 0 }; } - const placeholder = buildOversizedHistoryPlaceholder(); + const placeholder = buildOversizedHistoryPlaceholder(last); if (jsonUtf8Bytes([placeholder]) <= maxBytes) { - return [placeholder]; + return { messages: [placeholder], placeholderCount: 1 }; } - return []; + return { messages: [], placeholderCount: 0 }; } function resolveTranscriptPath(params: { @@ -572,8 +589,21 @@ export const chatHandlers: GatewayRequestHandlers = { const sliced = rawMessages.length > max ? rawMessages.slice(-max) : rawMessages; const sanitized = stripEnvelopeFromMessages(sliced); const normalized = sanitizeChatHistoryMessages(sanitized); - const capped = capArrayByJsonBytes(normalized, getMaxChatHistoryMessagesBytes()).items; - const bounded = enforceChatHistoryHardCap(capped, getMaxChatHistoryMessagesBytes()); + const maxHistoryBytes = getMaxChatHistoryMessagesBytes(); + const perMessageHardCap = Math.min(CHAT_HISTORY_MAX_SINGLE_MESSAGE_BYTES, maxHistoryBytes); + const replaced = replaceOversizedChatHistoryMessages({ + messages: normalized, + maxSingleMessageBytes: perMessageHardCap, + }); + const capped = capArrayByJsonBytes(replaced.messages, maxHistoryBytes).items; + const bounded = enforceChatHistoryFinalBudget({ messages: capped, maxBytes: maxHistoryBytes }); + const placeholderCount = replaced.replacedCount + bounded.placeholderCount; + if (placeholderCount > 0) { + chatHistoryPlaceholderEmitCount += placeholderCount; + context.logGateway.debug( + `chat.history omitted oversized payloads placeholders=${placeholderCount} total=${chatHistoryPlaceholderEmitCount}`, + ); + } let thinkingLevel = entry?.thinkingLevel; if (!thinkingLevel) { const configured = cfg.agents?.defaults?.thinkingDefault; @@ -595,7 +625,7 @@ export const chatHandlers: GatewayRequestHandlers = { respond(true, { sessionKey, sessionId, - messages: bounded, + messages: bounded.messages, thinkingLevel, verboseLevel, }); diff --git a/src/gateway/server.chat.gateway-server-chat-b.e2e.test.ts b/src/gateway/server.chat.gateway-server-chat-b.e2e.test.ts index 3f8b4b11d9..183b4f7d86 100644 --- a/src/gateway/server.chat.gateway-server-chat-b.e2e.test.ts +++ b/src/gateway/server.chat.gateway-server-chat-b.e2e.test.ts @@ -184,6 +184,89 @@ describe("gateway server chat", () => { } }); + test("chat.history keeps recent small messages when latest message is oversized", async () => { + const tempDirs: string[] = []; + const { server, ws } = await startServerWithClient(); + try { + const historyMaxBytes = 64 * 1024; + __setMaxChatHistoryMessagesBytesForTest(historyMaxBytes); + await connectOk(ws); + + const sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-")); + tempDirs.push(sessionDir); + testState.sessionStorePath = path.join(sessionDir, "sessions.json"); + + await writeSessionStore({ + entries: { + main: { sessionId: "sess-main", updatedAt: Date.now() }, + }, + }); + + const baseText = "s".repeat(1_200); + const lines: string[] = []; + for (let i = 0; i < 30; i += 1) { + lines.push( + JSON.stringify({ + message: { + role: "user", + timestamp: Date.now() + i, + content: [{ type: "text", text: `small-${i}:${baseText}` }], + }, + }), + ); + } + + const hugeNestedText = "z".repeat(450_000); + lines.push( + JSON.stringify({ + message: { + role: "assistant", + timestamp: Date.now() + 1_000, + content: [ + { + type: "tool_result", + toolUseId: "tool-1", + output: { + nested: { + payload: hugeNestedText, + }, + }, + }, + ], + }, + }), + ); + + await fs.writeFile( + path.join(sessionDir, "sess-main.jsonl"), + `${lines.join("\n")}\n`, + "utf-8", + ); + + const historyRes = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", { + sessionKey: "main", + limit: 1000, + }); + expect(historyRes.ok).toBe(true); + + const messages = historyRes.payload?.messages ?? []; + const serialized = JSON.stringify(messages); + const bytes = Buffer.byteLength(serialized, "utf8"); + + expect(bytes).toBeLessThanOrEqual(historyMaxBytes); + expect(messages.length).toBeGreaterThan(1); + expect(serialized).toContain("small-29:"); + expect(serialized).toContain("[chat.history omitted: message too large]"); + expect(serialized.includes(hugeNestedText.slice(0, 256))).toBe(false); + } finally { + __setMaxChatHistoryMessagesBytesForTest(); + testState.sessionStorePath = undefined; + ws.close(); + await server.close(); + await Promise.all(tempDirs.map((dir) => fs.rm(dir, { recursive: true, force: true }))); + } + }); + test("smoke: supports abort and idempotent completion", async () => { const tempDirs: string[] = []; const { server, ws } = await startServerWithClient();