fix(gateway): record display-cap truncation structurally on projected chat messages (#126342)

* fix(gateway): record display-cap truncation structurally on projected chat messages

The chat display projection truncates message text at the display cap and
appends an in-band "...(truncated)..." sentinel, but it discarded the
truncation fact: truncateChatHistoryText returns { text, truncated } and
every call site folded `truncated` into the local `changed` flag, which
only decides whether a block is replaced. session.message payloads and
chat.history rows therefore carried no structural signal, so any consumer
other than the Control UI could not tell a bounded preview from the
authoritative message, and the Control UI itself fell back to
substring-matching the sentinel inside rendered Markdown.

Track `truncated` as its own fact through the block and message
sanitizers and, where the cap is applied, record
`__openclaw: { truncated: true, reason: "display-cap" }` on the projected
message — the sibling of the existing `reason: "oversized"` transcript
marker. Existing `__openclaw` metadata (transcript id/seq, sender
profile) is preserved, and an upstream `oversized` reason is never
overwritten. The cap itself is unchanged (#95318).

Coverage: the display-cap marker on both history transports (WebSocket
and SSE), inside content blocks, absent on untruncated rows, oversized
reason preserved; a live session.message event over a real gateway
subscriber; and the chat.history -> chat.message.get round-trip asserting
the marker on the capped row and its absence on the full row. Two
existing exact-shape assertions in server-methods.test.ts updated to the
new contract.

Closes #126229

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013n5eSiz5Uy57civqEnUMAS

* fix(ui): gate the full-message fetch flag to assistant rows

Review follow-up. The Gateway now marks every display-capped projection,
user rows included. The Control UI's shouldFetchFullMessage consulted
that marker role-agnostically, while the expander that consumes the flag
renders loaded content for assistant rows alone — so the flag's contract
and its consumer disagreed for capped user rows.

Gate the flag at its producer: assistant-only, covering both the
metadata arm and the sentinel arm. The two consumers in
chat-message-group.ts key purely off this flag and need no change.

Tested at the flag itself (chat-message-markdown.test.ts): a capped user
row now yields shouldFetchFullMessage=false, a capped assistant row stays
true, an untruncated assistant row stays false; the user-row case fails
on the pre-fix code. Note: driving renderChatThread with a materialized
capped user row, reply wired, and this gate removed produced zero loader
calls, so the render path already blocks the fetch upstream; this change
aligns the flag's contract with that behavior rather than closing an
observed request.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013n5eSiz5Uy57civqEnUMAS

* fix(gateway): carry tool-result diff truncation into the display-cap marker

Review follow-up. projectToolResultDetails capped details.diff through
truncateChatHistoryText but kept only .text, so a display-capped
tool-result diff never reached the message-level marker and its
projected history/event row stayed indistinguishable from a complete one
— the same discard the first commit fixed for text fields, one level down.

Return { details, truncated } from the helper and aggregate the fact at
both call sites: the tool-result content block inside a message and the
tool-result-shaped message. Workspace-conflict details keep precedence
and are unaffected.

Covered on both tool-result shapes (marker present, sentinel present) and
the negative (diff within the cap stays unmarked); the positive case
fails on the pre-fix helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013n5eSiz5Uy57civqEnUMAS

* fix(ui): detect display-cap truncation by the structural marker alone

Review follow-up. The Gateway now records every display-cap truncation as
__openclaw.truncated, so the Control UI's sentinel fallback — treating
literal Markdown ending in "...(truncated)..." as proof of a cap — is
obsolete and wrong: an ordinary assistant reply that merely contains that
text would spuriously enter the full-message load path. The in-band
sentinel is just Markdown to the UI.

Use only __openclaw.truncated === true. No gateway-version compatibility
is carried, per the Control UI coupling policy (UI ships with its
Gateway). Fixtures in chat-message.test.ts and chat-transcript-render
.test.ts that expressed truncation via the sentinel now carry the marker,
as the Gateway emits; a new case proves a markerless reply containing the
sentinel text is not fetched, and fails with the fallback present.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013n5eSiz5Uy57civqEnUMAS

* test(ui): seed the display-cap marker in the message-actions e2e mock

The sentinel-only fetch fallback is gone, so the mock Gateway in
chat-message-actions.e2e.test.ts — which seeded the truncated assistant
message with a preview ending in "...(truncated)..." but no structural
marker — no longer triggers the full-message load, and the test timed
out waiting for chat.message.get. Seed __openclaw.truncated/reason as
the Gateway now emits. Reproduced the CI timeout locally at the prior
head and confirmed the pass with real Chromium.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013n5eSiz5Uy57civqEnUMAS

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Nehanth Narendrula
2026-08-22 15:46:27 -04:00
committed by GitHub
parent d80a66cb11
commit 736b0c424b
11 changed files with 249 additions and 26 deletions
@@ -65,19 +65,24 @@ export function isToolResultHistoryBlockType(type: unknown): boolean {
export function projectToolResultDetails(
details: unknown,
maxChars: number,
): Record<string, unknown> | undefined {
): { details: Record<string, unknown> | undefined; truncated: boolean } {
const record = readRecord(details);
if (!record) {
return undefined;
return { details: undefined, truncated: false };
}
const projected: Record<string, unknown> = {};
// The diff is the one display-capped field here; surface the fact so the
// message-level marker covers capped tool-result details too.
let truncated = false;
for (const key of ["changed", "created"] as const) {
if (typeof record[key] === "boolean") {
projected[key] = record[key];
}
}
if (typeof record.diff === "string" && record.diff.trim()) {
projected.diff = truncateChatHistoryText(record.diff, maxChars).text;
const diff = truncateChatHistoryText(record.diff, maxChars);
projected.diff = diff.text;
truncated = diff.truncated;
}
if (Array.isArray(record.approvalReviews)) {
const reviews = record.approvalReviews
@@ -109,7 +114,7 @@ export function projectToolResultDetails(
mcpApp: preview.mcpApp,
};
}
return Object.keys(projected).length > 0 ? projected : undefined;
return { details: Object.keys(projected).length > 0 ? projected : undefined, truncated };
}
export function messageHasToolResultShape(message: Record<string, unknown>): boolean {
+39 -10
View File
@@ -142,29 +142,34 @@ function projectChatHistoryMediaFacts(value: unknown): unknown[] | undefined {
export function sanitizeChatHistoryContentBlock(
block: unknown,
opts?: { preserveExactToolPayload?: boolean; maxChars?: number },
): { block: unknown; changed: boolean } {
): { block: unknown; changed: boolean; truncated: boolean } {
if (!block || typeof block !== "object") {
return { block, changed: false };
return { block, changed: false, truncated: false };
}
const entry = { ...(block as Record<string, unknown>) };
let changed = false;
// Display-cap truncation is a fact consumers need (to fetch the full row), so
// it is tracked apart from `changed`, which also covers metadata stripping.
let truncated = false;
const preserveExactToolPayload =
opts?.preserveExactToolPayload === true || isToolHistoryBlockType(entry.type);
const maxChars = opts?.maxChars ?? DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS;
if (isToolResultHistoryBlockType(entry.type) && "details" in entry) {
const projectedDetails = projectToolResultDetails(entry.details, maxChars);
if (projectedDetails) {
entry.details = projectedDetails;
if (projectedDetails.details) {
entry.details = projectedDetails.details;
} else {
delete entry.details;
}
changed = true;
truncated ||= projectedDetails.truncated;
}
if (typeof entry.text === "string") {
if (!preserveExactToolPayload) {
const res = truncateChatHistoryText(entry.text, maxChars);
entry.text = res.text;
changed ||= res.truncated;
truncated ||= res.truncated;
}
}
if (typeof entry.content === "string") {
@@ -172,22 +177,26 @@ export function sanitizeChatHistoryContentBlock(
const res = truncateChatHistoryText(entry.content, maxChars);
entry.content = res.text;
changed ||= res.truncated;
truncated ||= res.truncated;
}
}
if (typeof entry.partialJson === "string" && !preserveExactToolPayload) {
const res = truncateChatHistoryText(entry.partialJson, maxChars);
entry.partialJson = res.text;
changed ||= res.truncated;
truncated ||= res.truncated;
}
if (typeof entry.arguments === "string" && !preserveExactToolPayload) {
const res = truncateChatHistoryText(entry.arguments, maxChars);
entry.arguments = res.text;
changed ||= res.truncated;
truncated ||= res.truncated;
}
if (typeof entry.thinking === "string") {
const res = truncateChatHistoryText(entry.thinking, maxChars);
entry.thinking = res.text;
changed ||= res.truncated;
truncated ||= res.truncated;
}
if ("thinkingSignature" in entry) {
delete entry.thinkingSignature;
@@ -199,7 +208,7 @@ export function sanitizeChatHistoryContentBlock(
}
const mediaChanged = projectChatHistoryMediaBlock(entry);
changed ||= mediaChanged;
return { block: changed ? entry : block, changed };
return { block: changed ? entry : block, changed, truncated };
}
function sanitizeAssistantPhasedContentBlocks(content: unknown[]): {
@@ -372,6 +381,7 @@ export function sanitizeChatHistoryMessage(
}
const entry = { ...(message as Record<string, unknown>) };
let changed = false;
let truncated = false;
if ("providerReplay" in entry) {
delete entry.providerReplay;
changed = true;
@@ -407,17 +417,19 @@ export function sanitizeChatHistoryMessage(
typeof entry.tool_call_id === "string";
if ("details" in entry) {
const projectedDetails =
projectWorkspaceConflictDetails(entry) ??
(messageHasToolResultShape(entry)
const conflictDetails = projectWorkspaceConflictDetails(entry);
const toolResultDetails =
!conflictDetails && messageHasToolResultShape(entry)
? projectToolResultDetails(entry.details, maxChars)
: undefined);
: undefined;
const projectedDetails = conflictDetails ?? toolResultDetails?.details;
if (projectedDetails) {
entry.details = projectedDetails;
} else {
delete entry.details;
}
changed = true;
truncated ||= toolResultDetails?.truncated === true;
}
if (entry.role !== "assistant") {
@@ -464,6 +476,7 @@ export function sanitizeChatHistoryMessage(
const res = truncateChatHistoryText(controlStripped, maxChars);
entry.content = res.text;
changed ||= res.truncated;
truncated ||= res.truncated;
}
} else if (Array.isArray(entry.content)) {
const updated = entry.content.map((block) => {
@@ -486,12 +499,13 @@ export function sanitizeChatHistoryMessage(
const text = stripSuppressedControlReplyToken(contentBlock.text);
return text === contentBlock.text
? sanitized
: { block: { ...contentBlock, text }, changed: true };
: { block: { ...contentBlock, text }, changed: true, truncated: sanitized.truncated };
});
if (updated.some((item) => item.changed)) {
entry.content = updated.map((item) => item.block);
changed = true;
}
truncated ||= updated.some((item) => item.truncated);
if (entry.role === "assistant" && Array.isArray(entry.content)) {
const mixedToolContent = projectAssistantMixedToolContent(entry.content, maxChars);
if (mixedToolContent) {
@@ -521,9 +535,24 @@ export function sanitizeChatHistoryMessage(
const res = truncateChatHistoryText(controlStripped, maxChars);
entry.text = res.text;
changed ||= res.truncated;
truncated ||= res.truncated;
}
}
if (truncated) {
// Record the display cap where it is applied so any session.message or
// chat.history consumer can tell a bounded preview from the full row and
// fetch it via chat.message.get. An upstream "oversized" transcript
// marker already explains the truncation; never overwrite its reason.
const meta = readRecord(entry["__openclaw"]);
entry["__openclaw"] = {
...meta,
truncated: true,
reason: typeof meta?.reason === "string" ? meta.reason : "display-cap",
};
changed = true;
}
return { message: changed ? entry : message, changed };
}
@@ -366,6 +366,86 @@ describe("transcript metadata projection", () => {
);
}
});
it("records a display-cap marker on every history transport when text is truncated", () => {
const message = { role: "assistant", content: "x".repeat(9_000), timestamp: 1 };
for (const messages of projectHistoryTransports(message)) {
const projected = messages[0] as Record<string, unknown>;
expect(JSON.stringify(projected.content)).toContain("...(truncated)...");
// Structured fact, so consumers fetch the full row via chat.message.get
// instead of sniffing the in-band sentinel.
expect(projected["__openclaw"]).toEqual({ truncated: true, reason: "display-cap" });
}
});
it("marks display-cap truncation inside content blocks and keeps existing metadata", () => {
const [projected] = sanitizeChatHistoryMessages(
[
{
role: "assistant",
content: [{ type: "text", text: "block text ".repeat(20) }],
__openclaw: { id: "message-9", senderId: "assistant-1" },
},
],
16,
) as Record<string, unknown>[];
expect(projected?.["__openclaw"]).toEqual({
id: "message-9",
senderId: "assistant-1",
truncated: true,
reason: "display-cap",
});
});
it("leaves untruncated messages without a truncation marker", () => {
const [projected] = sanitizeChatHistoryMessages(
[{ role: "assistant", content: "short", timestamp: 1 }],
16,
) as Record<string, unknown>[];
expect(projected?.["__openclaw"]).toBeUndefined();
});
it("marks display-cap truncation of a tool-result diff on both tool-result shapes", () => {
const longDiff = "+line\n".repeat(40);
const [blockShaped, messageShaped] = sanitizeChatHistoryMessages(
[
{
role: "assistant",
content: [
{ type: "toolResult", toolName: "edit", details: { changed: true, diff: longDiff } },
],
},
{ role: "toolResult", toolName: "edit", details: { changed: true, diff: longDiff } },
],
32,
) as Record<string, unknown>[];
for (const projected of [blockShaped, messageShaped]) {
expect(JSON.stringify(projected)).toContain("...(truncated)...");
expect(projected?.["__openclaw"]).toMatchObject({ truncated: true, reason: "display-cap" });
}
});
it("leaves a tool-result diff within the cap unmarked", () => {
const [projected] = sanitizeChatHistoryMessages(
[{ role: "toolResult", toolName: "edit", details: { changed: true, diff: "+ok" } }],
32,
) as Record<string, unknown>[];
expect(projected?.["__openclaw"]).toBeUndefined();
});
it("does not overwrite an upstream oversized reason with display-cap", () => {
const [projected] = sanitizeChatHistoryMessages(
[
{
role: "assistant",
content: "still long enough to cap ".repeat(4),
__openclaw: { truncated: true, reason: "oversized" },
},
],
16,
) as Record<string, unknown>[];
expect(projected?.["__openclaw"]).toEqual({ truncated: true, reason: "oversized" });
});
});
describe("managed inbound media fact projection", () => {
@@ -888,7 +888,12 @@ describe("sanitizeChatHistoryMessages", () => {
);
expect(result).toEqual([
assistantHistoryMessage(`${prefix}\n...(truncated)...`, { timestamp: 1 }),
assistantHistoryMessage(`${prefix}\n...(truncated)...`, {
timestamp: 1,
// The display cap is recorded structurally so consumers need not sniff
// the in-band sentinel to know the row is a bounded preview.
__openclaw: { truncated: true, reason: "display-cap" },
}),
]);
});
@@ -2074,6 +2079,7 @@ describe("projectRecentChatDisplayMessages", () => {
assistantAudioAttachmentHistoryMessage(
`${projectedVisibleText.slice(0, 24)}\n...(truncated)...`,
1,
{ __openclaw: { truncated: true, reason: "display-cap" } },
),
]);
});
@@ -6363,12 +6363,19 @@ describe("gateway server chat", () => {
const historyMessages = await fetchHistoryMessages(ws, { maxChars: 5 });
expect(JSON.stringify(historyMessages)).toContain("abcde\\n...(truncated)...");
// The capped row is structurally marked so a client can detect the bounded
// preview without sniffing the sentinel, then fetch the durable content.
expect(
(historyMessages[0] as Record<string, unknown> | undefined)?.["__openclaw"],
).toMatchObject({ truncated: true, reason: "display-cap" });
const full = await fetchChatMessage(ws, makeMainMessageParams("msg-full-assistant"));
expect(full.ok).toBe(true);
expect(full.unavailableReason).toBeUndefined();
expect(JSON.stringify(full.message)).toContain("abcdefghij");
expect(JSON.stringify(full.message)).not.toContain("...(truncated)...");
const fullMeta = (full.message as Record<string, unknown> | undefined)?.["__openclaw"];
expect((fullMeta as { truncated?: unknown } | undefined)?.truncated).toBeUndefined();
});
});
@@ -1977,6 +1977,39 @@ describe("session.message websocket events", () => {
});
});
test("marks display-cap truncation structurally on live session.message events", async () => {
const storePath = await createSessionStoreFile();
await writeSessionStore({
entries: { main: { sessionId: "sess-main", updatedAt: Date.now() } },
storePath,
});
const transcriptMessage = {
role: "assistant",
content: [{ type: "text", text: "x".repeat(9_000) }],
timestamp: Date.now(),
};
await persistSessionTranscriptTurn(
{ agentId: "main", sessionId: "sess-main", sessionKey: "agent:main:main", storePath },
{ messages: [{ message: transcriptMessage }], updateMode: "none" },
);
await withOperatorSessionSubscriber(async (ws) => {
const { messageEvent } = await emitTranscriptUpdateAndCollectMessageEvent({
ws,
sessionKey: "agent:main:main",
sessionFile: "agent:main:main",
message: transcriptMessage,
messageId: "msg-capped",
});
const payload = requireRecord(messageEvent.payload, "capped message payload");
const message = requireRecord(payload.message, "capped message");
// The preview is bounded by the display cap and says so structurally, so a
// non-UI consumer can fetch the full row instead of sniffing the sentinel.
expect(JSON.stringify(message.content)).toContain("...(truncated)...");
expect(message["__openclaw"]).toMatchObject({ truncated: true, reason: "display-cap" });
});
});
test("prefers carried transcript sequence for live session events", async () => {
const storePath = await createSessionStoreFile();
await writeSessionStore({
+8 -1
View File
@@ -222,7 +222,14 @@ describeControlUiE2e("Control UI chat message actions", () => {
role: "assistant",
content: [{ type: "text", text: truncatedPreview }],
timestamp: Date.now() + 4,
__openclaw: { id: "assistant-full-message", seq: 5 },
// The Gateway records a display-cap structurally; the sentinel alone is
// ordinary Markdown to the UI.
__openclaw: {
id: "assistant-full-message",
seq: 5,
truncated: true,
reason: "display-cap",
},
},
],
methodResponses: {
@@ -0,0 +1,53 @@
/* @vitest-environment jsdom */
// Contract for the full-message fetch flag: the Gateway marks every display-
// capped projection (user rows included), but the expander that consumes this
// flag renders loaded content for assistant rows alone.
import { describe, expect, it } from "vitest";
import { resolveMessageActionDetails } from "./chat-message-markdown.ts";
const cappedMeta = { id: "msg-1", truncated: true, reason: "display-cap" };
describe("resolveMessageActionDetails full-message fetch flag", () => {
it.each([
{ role: "assistant", shouldFetch: true },
{ role: "user", shouldFetch: false },
])(
"role=$role capped by metadata -> shouldFetchFullMessage=$shouldFetch",
({ role, shouldFetch }) => {
const details = resolveMessageActionDetails({
message: { role, content: "Preview\n...(truncated)...", __openclaw: cappedMeta },
messageId: "msg-1",
canFetchFullMessage: true,
onReply: () => {},
senderLabel: role,
});
expect(details?.shouldFetchFullMessage).toBe(shouldFetch);
},
);
it("does not fetch an assistant message that merely contains the sentinel text", () => {
// The in-band "...(truncated)..." is ordinary Markdown to the UI; without the
// Gateway's structural marker it is not evidence of a display cap.
const details = resolveMessageActionDetails({
message: {
role: "assistant",
content: "Quoting a log line:\n...(truncated)...\nand continuing normally.",
__openclaw: { id: "msg-3" },
},
messageId: "msg-3",
canFetchFullMessage: true,
senderLabel: "assistant",
});
expect(details?.shouldFetchFullMessage).toBe(false);
});
it("does not fetch an untruncated assistant message", () => {
const details = resolveMessageActionDetails({
message: { role: "assistant", content: "Complete.", __openclaw: { id: "msg-2" } },
messageId: "msg-2",
canFetchFullMessage: true,
senderLabel: "assistant",
});
expect(details?.shouldFetchFullMessage).toBe(false);
});
});
@@ -129,13 +129,16 @@ export function resolveMessageActionDetails(params: {
const normalizedMessage = normalizeMessage(message);
const role = normalizeRoleForGrouping(normalizedMessage.role);
const previewMarkdown = resolveMessageReplyText(message);
// Loaded text must not erase the preview's truncation fact or collapse its disclosure.
// The Gateway records every display-cap truncation as __openclaw.truncated, so
// that marker is the whole contract: sniffing the in-band sentinel would fetch
// for any reply that merely contains the text. Assistant-only because the
// expander renders loaded content for assistant rows alone.
const shouldFetchFullMessage = Boolean(
role === "assistant" &&
canFetchFullMessage &&
messageId &&
!record.openclawMessageToolMirror &&
(transcriptMeta?.truncated === true ||
(role === "assistant" && previewMarkdown.includes("\n...(truncated)..."))),
transcriptMeta?.truncated === true,
);
const expansion =
role === "assistant" && shouldFetchFullMessage && messageId
@@ -5624,7 +5624,7 @@ describe("grouped chat rendering", () => {
{
role: "assistant",
content: [{ type: "text", text: preview }],
__openclaw: { id: "assistant-disclosure-actions", seq: 1 },
__openclaw: { id: "assistant-disclosure-actions", seq: 1, truncated: true },
},
{
sessionKey: "agent:main:main",
@@ -5757,7 +5757,7 @@ describe("grouped chat rendering", () => {
message: {
role: "assistant",
content: [{ type: "text", text: "abcde\n...(truncated)..." }],
__openclaw: { id: "msg-truncated-marker", seq: 1 },
__openclaw: { id: "msg-truncated-marker", seq: 1, truncated: true },
},
messageId: "msg-truncated-marker",
},
@@ -5802,7 +5802,7 @@ describe("grouped chat rendering", () => {
{
role: "assistant",
content: [{ type: "text", text: "abcde\n...(truncated)..." }],
__openclaw: { id: "msg-retry-error", seq: 1 },
__openclaw: { id: "msg-retry-error", seq: 1, truncated: true },
},
{
sessionKey: "global",
@@ -5828,7 +5828,7 @@ describe("grouped chat rendering", () => {
{
role: "assistant",
content: [{ type: "text", text: "abcde\n...(truncated)..." }],
__openclaw: { id: "msg-retry-exhausted", seq: 1 },
__openclaw: { id: "msg-retry-exhausted", seq: 1, truncated: true },
},
{
sessionKey: "global",
@@ -5872,7 +5872,7 @@ describe("grouped chat rendering", () => {
renderAssistantMessage(container, {
role: "assistant",
content: [{ type: "text", text: "abcde\n...(truncated)..." }],
__openclaw: { id: "msg-no-loader", seq: 1 },
__openclaw: { id: "msg-no-loader", seq: 1, truncated: true },
});
expect(container.querySelector(".chat-message-disclosure__toggle")).toBeNull();
@@ -256,7 +256,7 @@ describe("chat transcript rendering", () => {
{
role: "assistant",
content: "Preview\n...(truncated)...",
__openclaw: { id: "assistant-full-1" },
__openclaw: { id: "assistant-full-1", truncated: true },
timestamp: 1_000,
},
]),
@@ -295,7 +295,7 @@ describe("chat transcript rendering", () => {
{
role: "assistant",
content: "Preview\n...(truncated)...",
__openclaw: { id: "assistant-retry-1" },
__openclaw: { id: "assistant-retry-1", truncated: true },
timestamp: 1_000,
},
]),