Skip to content

Commit 50a4bb0

Browse files
authored
fix(gateway): never return an empty chat.history transcript (#92383)
Summary: - The PR changes gateway chat-history byte-budget fallback behavior to return a small metadata-free unavailable sentinel instead of an empty transcript, with focused budget tests. - PR surface: Source +20, Tests +73. Total +93 across 2 files. - Reproducibility: yes. Source inspection shows current main reaches `messages: []` when the full history, las ... d copied oversized placeholder all exceed `maxBytes`; I did not run tests because this review is read-only. Automerge notes: - PR branch already contained follow-up commit before automerge: test: access __openclaw via bracket notation for no-underscore-dangle Validation: - ClawSweeper review passed for head f2fa246. - Required merge gates passed before the squash merge. Prepared head SHA: f2fa246 Review: #92383 (comment) Co-authored-by: Hidetsugu55 <183473679+Hidetsugu55@users.noreply.github.com>
1 parent de17d5b commit 50a4bb0

2 files changed

Lines changed: 94 additions & 1 deletion

File tree

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// Covers the chat.history final byte-budget fallback, including the sentinel
2+
// that prevents an empty (blank) transcript from being returned to the dashboard.
3+
import { describe, expect, it } from "vitest";
4+
import { enforceChatHistoryFinalBudget } from "./chat.js";
5+
6+
type DisplayMessage = {
7+
role?: string;
8+
content?: Array<{ type?: string; text?: string }>;
9+
};
10+
11+
function firstText(messages: unknown[]): string {
12+
const msg = messages[0] as DisplayMessage | undefined;
13+
return msg?.content?.[0]?.text ?? "";
14+
}
15+
16+
describe("enforceChatHistoryFinalBudget", () => {
17+
it("passes through history that already fits the budget", () => {
18+
const messages = [
19+
{ role: "user", content: [{ type: "text", text: "hello" }] },
20+
{ role: "assistant", content: [{ type: "text", text: "hi" }] },
21+
];
22+
const result = enforceChatHistoryFinalBudget({ messages, maxBytes: 1_000_000 });
23+
expect(result.messages).toEqual(messages);
24+
expect(result.placeholderCount).toBe(0);
25+
});
26+
27+
it("returns the empty array unchanged for empty input", () => {
28+
const result = enforceChatHistoryFinalBudget({ messages: [], maxBytes: 10 });
29+
expect(result.messages).toEqual([]);
30+
expect(result.placeholderCount).toBe(0);
31+
});
32+
33+
it("keeps just the last message when the full set is over budget but the last fits", () => {
34+
const big = { role: "user", content: [{ type: "text", text: "x".repeat(4000) }] };
35+
const last = { role: "assistant", content: [{ type: "text", text: "ok" }] };
36+
const result = enforceChatHistoryFinalBudget({ messages: [big, last], maxBytes: 2_000 });
37+
expect(result.messages).toEqual([last]);
38+
expect(result.placeholderCount).toBe(0);
39+
});
40+
41+
it("falls back to a small placeholder when even the last message is too large", () => {
42+
const last = {
43+
role: "assistant",
44+
timestamp: 1,
45+
content: [{ type: "text", text: "y".repeat(4000) }],
46+
__openclaw: { id: "abc", seq: 7 },
47+
};
48+
const result = enforceChatHistoryFinalBudget({ messages: [last], maxBytes: 2_000 });
49+
expect(result.messages).toHaveLength(1);
50+
expect(firstText(result.messages)).toContain("chat.history omitted: message too large");
51+
expect(result.placeholderCount).toBe(1);
52+
});
53+
54+
it("returns a metadata-free sentinel (never an empty transcript) when even the placeholder is over budget", () => {
55+
// A pathological message whose oversized-placeholder copy is itself too
56+
// large because it carries very large transcript metadata.
57+
const hugeId = "z".repeat(4000);
58+
const message = {
59+
role: "user",
60+
timestamp: 1,
61+
content: [{ type: "text", text: "hi" }],
62+
__openclaw: { id: hugeId, seq: 1 },
63+
};
64+
const result = enforceChatHistoryFinalBudget({ messages: [message], maxBytes: 1_000 });
65+
66+
// The critical guarantee: the dashboard never receives an empty history.
67+
expect(result.messages).toHaveLength(1);
68+
expect(firstText(result.messages)).toContain("chat.history unavailable");
69+
// The sentinel does not carry the oversized source metadata.
70+
expect((result.messages[0] as Record<string, unknown>)["__openclaw"]).toBeUndefined();
71+
expect(result.placeholderCount).toBe(1);
72+
});
73+
});

src/gateway/server-methods/chat.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -537,6 +537,22 @@ export { sanitizeChatSendMessageInput } from "../chat-input-sanitize.js";
537537

538538
export const CHAT_HISTORY_MAX_SINGLE_MESSAGE_BYTES = 128 * 1024;
539539
const CHAT_HISTORY_OVERSIZED_PLACEHOLDER = "[chat.history omitted: message too large]";
540+
const CHAT_HISTORY_UNAVAILABLE_SENTINEL =
541+
"[chat.history unavailable: transcript too large to display; the full history is preserved on disk]";
542+
543+
/**
544+
* A minimal, metadata-free notice returned when even a single oversized
545+
* placeholder cannot fit the chat-history byte budget. Returning this instead
546+
* of an empty array guarantees the dashboard never renders a blank transcript,
547+
* which otherwise reads to the operator as total history loss.
548+
*/
549+
function buildChatHistoryUnavailableSentinel(): Record<string, unknown> {
550+
return {
551+
role: "assistant",
552+
timestamp: Date.now(),
553+
content: [{ type: "text", text: CHAT_HISTORY_UNAVAILABLE_SENTINEL }],
554+
};
555+
}
540556
const CHAT_STARTUP_OPTIONAL_MODEL_CATALOG_TIMEOUT_MS = 25;
541557
const MANAGED_OUTGOING_IMAGE_PATH_PREFIX = "/api/chat/media/outgoing/";
542558
let chatHistoryPlaceholderEmitCount = 0;
@@ -1653,7 +1669,11 @@ export function enforceChatHistoryFinalBudget(params: { messages: unknown[]; max
16531669
if (jsonUtf8Bytes([placeholder]) <= maxBytes) {
16541670
return { messages: [placeholder], placeholderCount: 1 };
16551671
}
1656-
return { messages: [], placeholderCount: 0 };
1672+
// The oversized placeholder still does not fit (e.g. the source message
1673+
// carried very large metadata). Never return an empty history — that renders
1674+
// as a blank transcript and reads as data loss even though the on-disk
1675+
// transcript is intact. Fall back to a small metadata-free sentinel.
1676+
return { messages: [buildChatHistoryUnavailableSentinel()], placeholderCount: 1 };
16571677
}
16581678

16591679
function resolveTranscriptPath(params: {

0 commit comments

Comments
 (0)