diff --git a/src/gateway/chat-display-projection.canvas.ts b/src/gateway/chat-display-projection.canvas.ts index 641e70e5c50f..5a62618dc997 100644 --- a/src/gateway/chat-display-projection.canvas.ts +++ b/src/gateway/chat-display-projection.canvas.ts @@ -65,19 +65,24 @@ export function isToolResultHistoryBlockType(type: unknown): boolean { export function projectToolResultDetails( details: unknown, maxChars: number, -): Record | undefined { +): { details: Record | undefined; truncated: boolean } { const record = readRecord(details); if (!record) { - return undefined; + return { details: undefined, truncated: false }; } const projected: Record = {}; + // 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): boolean { diff --git a/src/gateway/chat-display-projection.sanitize.ts b/src/gateway/chat-display-projection.sanitize.ts index db7fa5b4efde..7a95521d5d11 100644 --- a/src/gateway/chat-display-projection.sanitize.ts +++ b/src/gateway/chat-display-projection.sanitize.ts @@ -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) }; 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) }; 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 }; } diff --git a/src/gateway/chat-display-projection.test.ts b/src/gateway/chat-display-projection.test.ts index 246ea168dfcd..6494bedb9407 100644 --- a/src/gateway/chat-display-projection.test.ts +++ b/src/gateway/chat-display-projection.test.ts @@ -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; + 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[]; + 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[]; + 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[]; + 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[]; + 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[]; + expect(projected?.["__openclaw"]).toEqual({ truncated: true, reason: "oversized" }); + }); }); describe("managed inbound media fact projection", () => { diff --git a/src/gateway/server-methods/server-methods.test.ts b/src/gateway/server-methods/server-methods.test.ts index be721368b98c..a9c926547970 100644 --- a/src/gateway/server-methods/server-methods.test.ts +++ b/src/gateway/server-methods/server-methods.test.ts @@ -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" } }, ), ]); }); diff --git a/src/gateway/server.chat.gateway-server-chat-b.test.ts b/src/gateway/server.chat.gateway-server-chat-b.test.ts index 1c5622648e16..5bb74205ee8e 100644 --- a/src/gateway/server.chat.gateway-server-chat-b.test.ts +++ b/src/gateway/server.chat.gateway-server-chat-b.test.ts @@ -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 | 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 | undefined)?.["__openclaw"]; + expect((fullMeta as { truncated?: unknown } | undefined)?.truncated).toBeUndefined(); }); }); diff --git a/src/gateway/session-message-events.test.ts b/src/gateway/session-message-events.test.ts index c2f1af9b763b..a06f5a7dbbfe 100644 --- a/src/gateway/session-message-events.test.ts +++ b/src/gateway/session-message-events.test.ts @@ -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({ diff --git a/ui/src/e2e/chat-message-actions.e2e.test.ts b/ui/src/e2e/chat-message-actions.e2e.test.ts index b5383d892eb8..0fcd12ef998f 100644 --- a/ui/src/e2e/chat-message-actions.e2e.test.ts +++ b/ui/src/e2e/chat-message-actions.e2e.test.ts @@ -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: { diff --git a/ui/src/pages/chat/components/chat-message-markdown.test.ts b/ui/src/pages/chat/components/chat-message-markdown.test.ts new file mode 100644 index 000000000000..bf283f6932a6 --- /dev/null +++ b/ui/src/pages/chat/components/chat-message-markdown.test.ts @@ -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); + }); +}); diff --git a/ui/src/pages/chat/components/chat-message-markdown.ts b/ui/src/pages/chat/components/chat-message-markdown.ts index 483da647a389..6ccf5d3a041d 100644 --- a/ui/src/pages/chat/components/chat-message-markdown.ts +++ b/ui/src/pages/chat/components/chat-message-markdown.ts @@ -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 diff --git a/ui/src/pages/chat/components/chat-message.test.ts b/ui/src/pages/chat/components/chat-message.test.ts index 0056136ec045..a85df672a24f 100644 --- a/ui/src/pages/chat/components/chat-message.test.ts +++ b/ui/src/pages/chat/components/chat-message.test.ts @@ -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(); diff --git a/ui/src/pages/chat/components/chat-transcript-render.test.ts b/ui/src/pages/chat/components/chat-transcript-render.test.ts index 643704b417b9..81384c826b32 100644 --- a/ui/src/pages/chat/components/chat-transcript-render.test.ts +++ b/ui/src/pages/chat/components/chat-transcript-render.test.ts @@ -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, }, ]),