fix(gateway): report omitted chat-history messages in truncation log (#96788)

Summary:
- The PR moves Gateway `chat.history` omission accounting to a whole-pipeline reporter and adds focused helper plus real WebSocket request regression tests.
- PR surface: Source +35, Tests +219. Total +254 across 4 files.
- Reproducibility: yes. Current-main source shows the zero-count keep-last helper branch and the positive-coun ... he PR body includes a negative-control real WebSocket run where the same request test fails before the fix.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(gateway): count unique omitted chat-history messages + prove diag…
- PR branch already contained follow-up commit before automerge: test(gateway): prove chat.history request emits omission diagnostic

Validation:
- ClawSweeper review passed for head 414f885880.
- Required merge gates passed before the squash merge.

Prepared head SHA: 414f885880
Review: https://github.com/openclaw/openclaw/pull/96788#issuecomment-4799553366

Co-authored-by: ZengWen-DT <ceng.wen@xydigit.com>
Approved-by: takhoffman
This commit is contained in:
Wynne668
2026-06-25 20:17:27 +00:00
committed by GitHub
parent d2da8c79d9
commit c68484acc4
4 changed files with 281 additions and 27 deletions
@@ -21,21 +21,21 @@ describe("enforceChatHistoryFinalBudget", () => {
];
const result = enforceChatHistoryFinalBudget({ messages, maxBytes: 1_000_000 });
expect(result.messages).toEqual(messages);
expect(result.placeholderCount).toBe(0);
});
it("returns the empty array unchanged for empty input", () => {
const result = enforceChatHistoryFinalBudget({ messages: [], maxBytes: 10 });
expect(result.messages).toEqual([]);
expect(result.placeholderCount).toBe(0);
});
it("keeps just the last message when the full set is over budget but the last fits", () => {
const big = { role: "user", content: [{ type: "text", text: "x".repeat(4000) }] };
const last = { role: "assistant", content: [{ type: "text", text: "ok" }] };
const result = enforceChatHistoryFinalBudget({ messages: [big, last], maxBytes: 2_000 });
// The same last-message reference survives so callers can detect which
// originals were omitted by identity.
expect(result.messages).toEqual([last]);
expect(result.placeholderCount).toBe(0);
expect(result.messages[0]).toBe(last);
});
it("falls back to a small placeholder when even the last message is too large", () => {
@@ -48,7 +48,8 @@ describe("enforceChatHistoryFinalBudget", () => {
const result = enforceChatHistoryFinalBudget({ messages: [last], maxBytes: 2_000 });
expect(result.messages).toHaveLength(1);
expect(firstText(result.messages)).toContain("chat.history omitted: message too large");
expect(result.placeholderCount).toBe(1);
// The placeholder is a new object, not the oversized original.
expect(result.messages[0]).not.toBe(last);
});
it("returns a metadata-free sentinel (never an empty transcript) when even the placeholder is over budget", () => {
@@ -68,6 +69,5 @@ describe("enforceChatHistoryFinalBudget", () => {
expect(firstText(result.messages)).toContain("chat.history unavailable");
// The sentinel does not carry the oversized source metadata.
expect((result.messages[0] as Record<string, unknown>)["__openclaw"]).toBeUndefined();
expect(result.placeholderCount).toBe(1);
});
});
@@ -0,0 +1,121 @@
// Real-behavior proof that the chat.history budget pipeline emits the
// `payload.large` / `truncated` diagnostic whenever older history is omitted,
// and that the omitted count reflects unique source messages (a message that is
// first replaced and then trimmed is not double-counted). These run the real
// production helpers and capture the real diagnostic event bus output.
import { describe, expect, it } from "vitest";
import { onDiagnosticEvent } from "../../infra/diagnostic-events.js";
import type { DiagnosticPayloadLargeEvent } from "../../infra/diagnostic-events.js";
import { capArrayByJsonBytes } from "../session-utils.js";
import {
enforceChatHistoryFinalBudget,
replaceOversizedChatHistoryMessages,
reportOmittedChatHistory,
} from "./chat.js";
type Captured = DiagnosticPayloadLargeEvent[];
// Mirrors the production sequence in handleChatHistoryRequest: replace oversized
// messages, cap the array by byte budget, enforce the final budget, then report
// omissions. Captures any emitted `payload.large` diagnostic event.
function runHistoryBudgetPipeline(params: {
messages: unknown[];
maxHistoryBytes: number;
perMessageHardCap: number;
}): { emittedCount: number; events: Captured; replacedCount: number; frontCapDropped: number } {
const { messages, maxHistoryBytes, perMessageHardCap } = params;
const events: Captured = [];
const unsubscribe = onDiagnosticEvent((evt) => {
if (evt.type === "payload.large") {
events.push(evt);
}
});
try {
const replaced = replaceOversizedChatHistoryMessages({
messages,
maxSingleMessageBytes: perMessageHardCap,
});
const capped = capArrayByJsonBytes(replaced.messages, maxHistoryBytes).items;
const bounded = enforceChatHistoryFinalBudget({ messages: capped, maxBytes: maxHistoryBytes });
const emittedCount = reportOmittedChatHistory({
originalMessages: messages,
finalMessages: bounded.messages,
normalizedBytes: Buffer.byteLength(JSON.stringify(messages), "utf8"),
maxHistoryBytes,
logDebug: () => {},
});
return {
emittedCount,
events,
replacedCount: replaced.replacedCount,
frontCapDropped: replaced.messages.length - capped.length,
};
} finally {
unsubscribe();
}
}
function textMessage(role: string, text: string): Record<string, unknown> {
return { role, content: [{ type: "text", text }] };
}
describe("chat.history truncation logging (real diagnostic bus)", () => {
it("emits a truncated diagnostic when history is trimmed to the last message", () => {
const big = textMessage("user", "x".repeat(8000));
const last = textMessage("assistant", "ok");
const result = runHistoryBudgetPipeline({
messages: [big, last],
maxHistoryBytes: 2_000,
perMessageHardCap: 2_000,
});
expect(result.events).toHaveLength(1);
const event = result.events[0];
expect(event.surface).toBe("gateway.chat.history");
expect(event.action).toBe("truncated");
expect(event.reason).toBe("chat_history_budget");
expect(event.count).toBe(1);
expect(result.emittedCount).toBe(1);
});
it("emits no diagnostic when nothing is omitted", () => {
const result = runHistoryBudgetPipeline({
messages: [textMessage("user", "hello"), textMessage("assistant", "hi")],
maxHistoryBytes: 1_000_000,
perMessageHardCap: 1_000_000,
});
expect(result.events).toHaveLength(0);
expect(result.emittedCount).toBe(0);
});
it("counts a replaced-then-trimmed message once, not twice", () => {
// `huge` is oversized so it is replaced with a small placeholder, then the
// placeholder sits at the front and is dropped by the byte cap. The naive
// sum of replacedCount + front-cap drops would count `huge` twice.
const huge = textMessage("user", "h".repeat(8000));
const big1 = textMessage("assistant", "a".repeat(2000));
const big2 = textMessage("user", "b".repeat(2000));
const last = textMessage("assistant", "ok");
const messages = [huge, big1, big2, last];
const result = runHistoryBudgetPipeline({
messages,
maxHistoryBytes: 4_000,
perMessageHardCap: 3_000,
});
// Scenario preconditions: a message was replaced AND front-capped, so the
// old additive count would have over-reported.
expect(result.replacedCount).toBeGreaterThan(0);
expect(result.frontCapDropped).toBeGreaterThan(0);
const naiveAdditive = result.replacedCount + result.frontCapDropped;
// The emitted count equals the number of original messages that lost their
// verbatim representation, and is strictly less than the double-counting sum.
expect(result.events).toHaveLength(1);
expect(result.events[0].count).toBe(result.emittedCount);
expect(result.emittedCount).toBe(2);
expect(naiveAdditive).toBeGreaterThan(result.emittedCount);
});
});
@@ -0,0 +1,98 @@
// Real-behavior proof that a full `chat.history` Gateway request — issued over a
// real WebSocket to a real booted Gateway server, reading a real on-disk
// transcript — emits the `payload.large` / `truncated` diagnostic when older
// history is omitted by the byte budget. This drives the actual server method
// (`handleChatHistoryRequest`), not the budget helpers in isolation, so it
// covers the caller gate that previously swallowed front-cap and drop-to-last
// omissions. It imports only the WebSocket harness and the diagnostic bus (no
// changed symbols), so the same test reproduces the missing diagnostic on
// pre-fix `main`.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
import type { WebSocket } from "ws";
import {
onDiagnosticEvent,
type DiagnosticPayloadLargeEvent,
} from "../../infra/diagnostic-events.js";
import { setMaxChatHistoryMessagesBytesForTest } from "../server-constants.js";
import { installGatewayTestHooks, rpcReq, testState, writeSessionStore } from "../test-helpers.js";
import { installConnectedControlUiServerSuite } from "../test-with-server.js";
installGatewayTestHooks({ scope: "suite" });
let ws: WebSocket;
installConnectedControlUiServerSuite((started) => {
ws = started.ws;
});
describe("chat.history request emits truncation diagnostic (real WS gateway)", () => {
test("a real chat.history request logs payload.large when older history is omitted", async () => {
const SESSION_ID = "sess-omission-proof";
const MESSAGE_COUNT = 12;
const TEXT_BYTES = 2_000;
// Budget far below the seeded transcript but well above one message, so the
// front byte cap drops older messages without per-message placeholdering.
const BUDGET_BYTES = 8_000;
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-chat-history-omit-"));
const captured: DiagnosticPayloadLargeEvent[] = [];
const unsubscribe = onDiagnosticEvent((evt) => {
if (evt.type === "payload.large" && evt.surface === "gateway.chat.history") {
captured.push(evt);
}
});
setMaxChatHistoryMessagesBytesForTest(BUDGET_BYTES);
testState.sessionStorePath = path.join(dir, "sessions.json");
try {
await writeSessionStore({
entries: {
main: {
sessionId: SESSION_ID,
sessionFile: path.join(dir, `${SESSION_ID}.jsonl`),
updatedAt: Date.now(),
},
},
});
const messages = Array.from({ length: MESSAGE_COUNT }, (_, i) => ({
role: i % 2 === 0 ? "user" : "assistant",
content: [{ type: "text", text: `m${i} ${"x".repeat(TEXT_BYTES)}` }],
timestamp: i + 1,
}));
const lines = messages.map((message) => JSON.stringify({ message }));
await fs.writeFile(path.join(dir, `${SESSION_ID}.jsonl`), lines.join("\n"), "utf-8");
const res = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", {
sessionKey: "main",
limit: 1000,
});
expect(res.ok).toBe(true);
const returned = res.payload?.messages ?? [];
// The response keeps only the survivors under budget (never empty), so the
// request genuinely omitted older history.
expect(returned.length).toBeGreaterThan(0);
expect(returned.length).toBeLessThan(MESSAGE_COUNT);
expect(captured).toHaveLength(1);
const event = captured[0];
expect(event.action).toBe("truncated");
expect(event.reason).toBe("chat_history_budget");
expect(event.count).toBeGreaterThan(0);
expect(event.count).toBe(MESSAGE_COUNT - returned.length);
// Print the real runtime diagnostic so a `run-vitest` run shows the
// captured Gateway event (used as the PR real-behavior proof).
console.log(
`chat.history real-request diagnostic: returned=${returned.length} ` +
`event=${JSON.stringify(event)}`,
);
} finally {
unsubscribe();
setMaxChatHistoryMessagesBytesForTest(undefined);
testState.sessionStorePath = undefined;
await fs.rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
}
});
});
+57 -22
View File
@@ -577,7 +577,7 @@ function buildChatHistoryUnavailableSentinel(): Record<string, unknown> {
}
const CHAT_STARTUP_OPTIONAL_MODEL_CATALOG_TIMEOUT_MS = 25;
const MANAGED_OUTGOING_IMAGE_PATH_PREFIX = "/api/chat/media/outgoing/";
let chatHistoryPlaceholderEmitCount = 0;
let chatHistoryOmittedEmitCount = 0;
const chatHistoryManagedImageCleanupState = new Map<string, Promise<void>>();
const CHANNEL_AGNOSTIC_SESSION_SCOPES = new Set([
"main",
@@ -1672,30 +1672,73 @@ export function replaceOversizedChatHistoryMessages(params: {
return { messages: replacedCount > 0 ? next : messages, replacedCount };
}
// Enforces the final byte budget for chat.history. Returns only the surviving
// messages; how many original messages were omitted is measured end-to-end by
// reportOmittedChatHistory, which alone sees the full replace/cap/final pipeline
// and so can count unique omitted originals without double-counting.
export function enforceChatHistoryFinalBudget(params: { messages: unknown[]; maxBytes: number }): {
messages: unknown[];
placeholderCount: number;
} {
const { messages, maxBytes } = params;
if (messages.length === 0) {
return { messages, placeholderCount: 0 };
return { messages };
}
if (jsonUtf8Bytes(messages) <= maxBytes) {
return { messages, placeholderCount: 0 };
return { messages };
}
const last = messages.at(-1);
if (last && jsonUtf8Bytes([last]) <= maxBytes) {
return { messages: [last], placeholderCount: 0 };
return { messages: [last] };
}
const placeholder = buildOversizedHistoryPlaceholder(last);
if (jsonUtf8Bytes([placeholder]) <= maxBytes) {
return { messages: [placeholder], placeholderCount: 1 };
return { messages: [placeholder] };
}
// The oversized placeholder still does not fit (e.g. the source message
// carried very large metadata). Never return an empty history — that renders
// as a blank transcript and reads as data loss even though the on-disk
// transcript is intact. Fall back to a small metadata-free sentinel.
return { messages: [buildChatHistoryUnavailableSentinel()], placeholderCount: 1 };
return { messages: [buildChatHistoryUnavailableSentinel()] };
}
// Counts how many of the original chat.history messages lost their verbatim
// representation by the time the budget pipeline finished — whether they were
// replaced with a placeholder, dropped by the front byte cap, or collapsed by
// the final budget. Identity membership counts each omitted original exactly
// once (a message that is first replaced and then trimmed is not counted twice),
// and emits the truncation diagnostic so operators see when history is omitted.
// Returns the omitted count (0 when nothing was omitted, so no diagnostic fires).
export function reportOmittedChatHistory(params: {
originalMessages: unknown[];
finalMessages: unknown[];
normalizedBytes: number;
maxHistoryBytes: number;
logDebug: (message: string) => void;
}): number {
const { originalMessages, finalMessages, normalizedBytes, maxHistoryBytes, logDebug } = params;
const survivors = new Set(finalMessages);
let omittedCount = 0;
for (const message of originalMessages) {
if (!survivors.has(message)) {
omittedCount += 1;
}
}
if (omittedCount === 0) {
return 0;
}
chatHistoryOmittedEmitCount += omittedCount;
logLargePayload({
surface: "gateway.chat.history",
action: "truncated",
bytes: normalizedBytes,
limitBytes: maxHistoryBytes,
count: omittedCount,
reason: "chat_history_budget",
});
logDebug(
`chat.history omitted oversized payloads count=${omittedCount} total=${chatHistoryOmittedEmitCount}`,
);
return omittedCount;
}
function resolveTranscriptPath(params: {
@@ -2760,21 +2803,13 @@ async function handleChatHistoryRequest({
});
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;
logLargePayload({
surface: "gateway.chat.history",
action: "truncated",
bytes: jsonUtf8Bytes(normalized),
limitBytes: maxHistoryBytes,
count: placeholderCount,
reason: "chat_history_budget",
});
context.logGateway.debug(
`chat.history omitted oversized payloads placeholders=${placeholderCount} total=${chatHistoryPlaceholderEmitCount}`,
);
}
reportOmittedChatHistory({
originalMessages: normalized,
finalMessages: bounded.messages,
normalizedBytes: jsonUtf8Bytes(normalized),
maxHistoryBytes,
logDebug: (message) => context.logGateway.debug(message),
});
const modelCatalog = await modelCatalogPromise;
const defaultAgentId = resolveDefaultAgentId(cfg);
const startupMetadata = includeMetadata