mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 04:47:03 -06:00
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:
committed by
GitHub
parent
d80a66cb11
commit
736b0c424b
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]),
|
||||
|
||||
Reference in New Issue
Block a user