fix(gateway): preserve chat.history context under hard caps

This commit is contained in:
Sebastian
2026-02-16 21:50:01 -05:00
parent f6e68b917b
commit 81fd771cb9
5 changed files with 134 additions and 18 deletions

View File

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

View File

@@ -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`)

View File

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

View File

@@ -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<string, unk
};
}
function enforceChatHistoryHardCap(messages: unknown[], maxBytes: number): unknown[] {
function replaceOversizedChatHistoryMessages(params: {
messages: unknown[];
maxSingleMessageBytes: number;
}): { messages: unknown[]; replacedCount: number } {
const { messages, maxSingleMessageBytes } = params;
if (messages.length === 0) {
return messages;
return { messages, replacedCount: 0 };
}
const normalized = messages.map((message) => {
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,
});

View File

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