mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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 head414f885880. - Required merge gates passed before the squash merge. Prepared head SHA:414f885880Review: 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:
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user