fix: preserve emoji at remaining bounded-text edges (#101711)
Some checks are pending
CI / runner-admission (push) Waiting to run
CI / preflight (push) Blocked by required conditions
CI / security-fast (push) Blocked by required conditions
CI / pnpm-store-warmup (push) Blocked by required conditions
CI / build-artifacts (push) Blocked by required conditions
CI / native-i18n (push) Blocked by required conditions
CI / (push) Blocked by required conditions
CI / QA Smoke CI (matrix) (push) Blocked by required conditions
CI / QA Smoke CI (telegram 1/2) (push) Blocked by required conditions
CI / QA Smoke CI (telegram 2/2) (push) Blocked by required conditions
CI / QA Smoke CI (push) Blocked by required conditions
CI / -1 (push) Blocked by required conditions
CI / -2 (push) Blocked by required conditions
CI / checks-node-compat-node22 (push) Blocked by required conditions
CI / -3 (push) Blocked by required conditions
CI / check-bundled-channel-config-metadata (push) Blocked by required conditions
CI / check-dependencies (push) Blocked by required conditions
CI / check-guards (push) Blocked by required conditions
CI / check-lint (push) Blocked by required conditions
CI / check-prod-types (push) Blocked by required conditions
CI / check-shrinkwrap (push) Blocked by required conditions
CI / check-test-types (push) Blocked by required conditions
CI / check-additional-boundaries-a (push) Blocked by required conditions
CI / check-additional-boundaries-bcd (push) Blocked by required conditions
CI / check-additional-extension-bundled (push) Blocked by required conditions
CI / check-additional-extension-channels (push) Blocked by required conditions
CI / check-additional-extension-package-boundary (push) Blocked by required conditions
CI / check-additional-runtime-topology-architecture (push) Blocked by required conditions
CI / check-session-accessor-boundary (push) Blocked by required conditions
CI / check-session-transcript-reader-boundary (push) Blocked by required conditions
CI / check-docs (push) Blocked by required conditions
CI / skills-python (push) Blocked by required conditions
CI / -4 (push) Blocked by required conditions
CI / -5 (push) Blocked by required conditions
CI / macos-swift (push) Blocked by required conditions
CI / ios-build (push) Blocked by required conditions
CI / -6 (push) Blocked by required conditions
CI / ci-timings-summary (push) Blocked by required conditions
ClawSweeper Dispatch / dispatch (push) Waiting to run
CodeQL / Security High (actions) (push) Waiting to run
CodeQL / Security High (channel-runtime-boundary) (push) Waiting to run
CodeQL / Security High (core-auth-secrets) (push) Waiting to run
CodeQL / Security High (mcp-process-tool-boundary) (push) Waiting to run
CodeQL / Security High (network-ssrf-boundary) (push) Waiting to run
CodeQL / Security High (plugin-trust-boundary) (push) Waiting to run
CodeQL / Security High (process-exec-boundary) (push) Waiting to run
Control UI Locale Refresh / plan (push) Waiting to run
Control UI Locale Refresh / Refresh (push) Blocked by required conditions
Control UI Locale Refresh / Commit control UI locale refresh (push) Blocked by required conditions
Docs Sync Publish Repo / sync-publish-repo (push) Waiting to run
Docs / docs (push) Waiting to run
Native App Locale Refresh / Refresh native ar (push) Waiting to run
Native App Locale Refresh / Refresh native de (push) Waiting to run
Native App Locale Refresh / Refresh native es (push) Waiting to run
Native App Locale Refresh / Refresh native fa (push) Waiting to run
Native App Locale Refresh / Refresh native fr (push) Waiting to run
Native App Locale Refresh / Refresh native hi (push) Waiting to run
Native App Locale Refresh / Refresh native id (push) Waiting to run
Native App Locale Refresh / Refresh native it (push) Waiting to run
Native App Locale Refresh / Refresh native ja-JP (push) Waiting to run
Native App Locale Refresh / Refresh native ko (push) Waiting to run
Native App Locale Refresh / Refresh native nl (push) Waiting to run
Native App Locale Refresh / Refresh native pl (push) Waiting to run
Native App Locale Refresh / Refresh native pt-BR (push) Waiting to run
Native App Locale Refresh / Refresh native ru (push) Waiting to run
Native App Locale Refresh / Refresh native sv (push) Waiting to run
Native App Locale Refresh / Refresh native th (push) Waiting to run
Native App Locale Refresh / Refresh native tr (push) Waiting to run
Native App Locale Refresh / Refresh native uk (push) Waiting to run
Native App Locale Refresh / Refresh native vi (push) Waiting to run
Native App Locale Refresh / Refresh native zh-CN (push) Waiting to run
Native App Locale Refresh / Refresh native zh-TW (push) Waiting to run
Native App Locale Refresh / Commit native locale refresh (push) Blocked by required conditions
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Waiting to run
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Blocked by required conditions
Plugin Init Scaffold Validation / Validate provider scaffold (push) Waiting to run
Plugin NPM Release / preview_plugins_npm (push) Waiting to run
Plugin NPM Release / Validate release publish approval (push) Blocked by required conditions
Plugin NPM Release / preview_plugin_pack (push) Blocked by required conditions
Plugin NPM Release / publish_plugins_npm (push) Blocked by required conditions
Plugin NPM Release / verify_plugins_npm (push) Blocked by required conditions
Workflow Sanity / no-tabs (push) Waiting to run
Workflow Sanity / actionlint (push) Waiting to run
Workflow Sanity / generated-doc-baselines (push) Waiting to run

* fix: keep remaining text bounds UTF-16 safe

Co-authored-by: ben.li <li.yang6@xydigit.com>
Co-authored-by: 0668001336 <wang.lizhang@xydigit.com>
Co-authored-by: Alix-007 <li.long15@xydigit.com>

* docs: credit bounded Unicode fixes

* test: update UTF-16 browser import boundary

* chore: leave Unicode note to release changelog

---------

Co-authored-by: ben.li <li.yang6@xydigit.com>
Co-authored-by: 0668001336 <wang.lizhang@xydigit.com>
Co-authored-by: Alix-007 <li.long15@xydigit.com>
This commit is contained in:
Peter Steinberger 2026-07-07 15:48:21 +01:00 committed by GitHub
parent ed28c57e20
commit 87fe266c5e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 170 additions and 24 deletions

View file

@ -2175,11 +2175,13 @@ describe("diagnostics-otel service", () => {
test("bounds plugin-emitted log attributes and omits source paths", async () => {
const service = createDiagnosticsOtelService();
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { logs: true });
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { logs: true, captureContent: true });
await service.start(ctx);
const boundaryMessage = `${"x".repeat(4095)}🚀tail`;
const boundaryAttribute = `${"y".repeat(4095)}🚀tail`;
const attributes = Object.create(null) as Record<string, string>;
attributes.good = "y".repeat(6000);
attributes.good = boundaryAttribute;
attributes["bad key"] = "drop-me";
attributes[PROTO_KEY] = "pollute";
attributes["constructor"] = "pollute";
@ -2189,7 +2191,7 @@ describe("diagnostics-otel service", () => {
emitDiagnosticEvent({
type: "log.record",
level: "INFO",
message: "x".repeat(6000),
message: boundaryMessage,
attributes,
code: {
filepath: "/Users/alice/openclaw/src/private.ts",
@ -2204,11 +2206,10 @@ describe("diagnostics-otel service", () => {
attributes: Record<string, unknown>;
body: string;
};
expect(emitCall.body.length).toBeLessThanOrEqual(4200);
expect(String(emitCall.attributes["openclaw.good"])).toMatch(/^y+/);
expect(emitCall.body).toBe(`${"x".repeat(4095)}...(truncated)`);
expect(emitCall.attributes["openclaw.good"]).toBe(`${"y".repeat(4095)}...(truncated)`);
expect(emitCall.attributes["code.lineno"]).toBe(42);
expect(emitCall.attributes["code.function"]).toBe("handler");
expect(String(emitCall.attributes["openclaw.good"]).length).toBeLessThanOrEqual(4200);
expect(Object.hasOwn(emitCall.attributes, `openclaw.${PROTO_KEY}`)).toBe(false);
expect(Object.hasOwn(emitCall.attributes, "openclaw.constructor")).toBe(false);
expect(Object.hasOwn(emitCall.attributes, "openclaw.prototype")).toBe(false);

View file

@ -40,6 +40,7 @@ import { waitForDiagnosticEventsDrained } from "openclaw/plugin-sdk/diagnostic-r
import { createNodeProxyAgent } from "openclaw/plugin-sdk/fetch-runtime";
import { registerUnhandledRejectionHandler } from "openclaw/plugin-sdk/runtime-env";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import type {
DiagnosticEventMetadata,
DiagnosticEventPayload,
@ -682,7 +683,7 @@ function addUpstreamRequestIdSpanEvent(
}
function clampOtelLogText(value: string, maxChars: number): string {
return value.length > maxChars ? `${value.slice(0, maxChars)}...(truncated)` : value;
return value.length > maxChars ? `${truncateUtf16Safe(value, maxChars)}...(truncated)` : value;
}
function normalizeOtelLogString(value: string, maxChars: number): string {

View file

@ -8,6 +8,7 @@ import {
wrapWebContent,
} from "openclaw/plugin-sdk/provider-web-search";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
// Internal-only bounds (the model-facing tool schema declares its own copies).
const PARALLEL_MAX_SEARCH_COUNT = 40;
@ -58,7 +59,7 @@ export function normalizeParallelObjective(value: string | undefined): string |
}
return trimmed.length <= PARALLEL_MAX_OBJECTIVE_CHARS
? trimmed
: trimmed.slice(0, PARALLEL_MAX_OBJECTIVE_CHARS);
: truncateUtf16Safe(trimmed, PARALLEL_MAX_OBJECTIVE_CHARS);
}
export function normalizeParallelClientModel(value: string | undefined): string | undefined {
@ -68,7 +69,7 @@ export function normalizeParallelClientModel(value: string | undefined): string
}
return trimmed.length <= PARALLEL_CLIENT_MODEL_MAX_LENGTH
? trimmed
: trimmed.slice(0, PARALLEL_CLIENT_MODEL_MAX_LENGTH);
: truncateUtf16Safe(trimmed, PARALLEL_CLIENT_MODEL_MAX_LENGTH);
}
// Parallel's API caps each entry at 200 chars and accepts up to 5 queries. We
@ -90,7 +91,7 @@ export function normalizeParallelSearchQueries(value: unknown): string[] {
const capped =
trimmed.length <= PARALLEL_MAX_SEARCH_QUERY_CHARS
? trimmed
: trimmed.slice(0, PARALLEL_MAX_SEARCH_QUERY_CHARS);
: truncateUtf16Safe(trimmed, PARALLEL_MAX_SEARCH_QUERY_CHARS);
if (seen.has(capped)) {
continue;
}

View file

@ -254,6 +254,7 @@ describe("parallel web search provider", () => {
expect(testing.normalizeParallelObjective(undefined)).toBeUndefined();
expect(testing.normalizeParallelObjective("")).toBeUndefined();
expect((testing.normalizeParallelObjective("x".repeat(6000)) ?? "").length).toBe(5000);
expect(testing.normalizeParallelObjective(`${"x".repeat(4999)}🚀tail`)).toBe("x".repeat(4999));
});
it("normalizes search_queries: trim, drop blanks, dedupe, cap length, cap count", () => {
@ -270,6 +271,9 @@ describe("parallel web search provider", () => {
expect(testing.normalizeParallelSearchQueries(undefined)).toEqual([]);
expect(testing.normalizeParallelSearchQueries("openclaw github")).toEqual([]);
expect(testing.normalizeParallelSearchQueries(["x".repeat(250)])).toEqual(["x".repeat(200)]);
expect(testing.normalizeParallelSearchQueries([`${"x".repeat(199)}🚀tail`])).toEqual([
"x".repeat(199),
]);
const six = ["a", "b", "c", "d", "e", "f"];
expect(testing.normalizeParallelSearchQueries(six)).toEqual(["a", "b", "c", "d", "e"]);
});
@ -289,6 +293,7 @@ describe("parallel web search provider", () => {
expect(testing.normalizeParallelClientModel(" gpt-5.5 ")).toBe("gpt-5.5");
expect(testing.normalizeParallelClientModel(undefined)).toBeUndefined();
expect((testing.normalizeParallelClientModel("a".repeat(200)) ?? "").length).toBe(100);
expect(testing.normalizeParallelClientModel(`${"m".repeat(99)}🚀tail`)).toBe("m".repeat(99));
});
it("normalizes the Parallel /v1/search response shape", () => {

View file

@ -1938,6 +1938,24 @@ describe("WorkboardStore", () => {
await expect(store.buildWorkerContext(card.id)).resolves.toContain("Failure screenshot");
});
it("keeps worker-context text bounds UTF-16 safe", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({
title: "Bound context",
metadata: {
comments: [
{
id: "comment-1",
body: `${"x".repeat(398)}🚀tail`,
createdAt: 10,
},
],
},
});
await expect(store.buildWorkerContext(card.id)).resolves.toContain(`- ${"x".repeat(398)}`);
});
it("scopes idempotent creates and stats by board", async () => {
const store = new WorkboardStore(createMemoryStore());
const ops = await store.create({

View file

@ -5,6 +5,7 @@ import {
MAX_DATE_TIMESTAMP_MS,
resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import type {
PersistedWorkboardAttachment,
PersistedWorkboardBoard,
@ -2039,7 +2040,7 @@ function capText(value: string | undefined, max: number): string | undefined {
if (!value) {
return undefined;
}
return value.length <= max ? value : `${value.slice(0, Math.max(0, max - 1))}`;
return value.length <= max ? value : `${truncateUtf16Safe(value, Math.max(0, max - 1))}`;
}
function cardBoardId(card: WorkboardCard): string {

View file

@ -392,6 +392,21 @@ describe("compaction-safeguard tool failures", () => {
expect(section).toContain("exec (exitCode=2): failed");
});
it("keeps bounded tool-failure text UTF-16 safe", () => {
const failures = collectToolFailures([
{
role: "toolResult",
toolCallId: "call-boundary",
toolName: "exec",
isError: true,
content: [{ type: "text", text: `${"x".repeat(236)}🚀tail` }],
timestamp: Date.now(),
},
]);
expect(failures[0]?.summary).toBe(`${"x".repeat(236)}...`);
});
it("caps the number of failures and adds overflow line", () => {
const messages: AgentMessage[] = Array.from({ length: 9 }, (_, idx) => ({
role: "toolResult",
@ -454,6 +469,18 @@ describe("compaction-safeguard summary budgets", () => {
expect(capped.endsWith(SUMMARY_TRUNCATED_MARKER)).toBe(true);
});
it("keeps compaction summary prefixes UTF-16 safe", () => {
const prefixBudget = MAX_COMPACTION_SUMMARY_CHARS - SUMMARY_TRUNCATED_MARKER.length;
const oversized = `${"x".repeat(prefixBudget - 1)}🚀${"z".repeat(
SUMMARY_TRUNCATED_MARKER.length + 10,
)}`;
expect(capCompactionSummary(oversized)).toBe(
`${"x".repeat(prefixBudget - 1)}${SUMMARY_TRUNCATED_MARKER}`,
);
expect(capCompactionSummary(`${"x".repeat(9)}🚀tail`, 10)).toBe("x".repeat(9));
});
it("preserves workspace critical rules suffix when capping", () => {
const suffix =
"\n\n<workspace-critical-rules>\n## Session Startup\nRead AGENTS.md\n</workspace-critical-rules>";
@ -522,6 +549,10 @@ describe("compaction-safeguard summary budgets", () => {
expect(capped).toContain("<read-files>");
expect(capped).toContain("## Session Startup");
});
it("keeps an oversized preserved suffix UTF-16 safe at its leading edge", () => {
expect(capCompactionSummaryPreservingSuffix("body", "A🚀tail", 5)).toBe("tail");
});
});
describe("computeAdaptiveChunkRatio", () => {
@ -906,6 +937,18 @@ describe("compaction-safeguard recent-turn preservation", () => {
expect(section).toContain("[non-text content: image]");
});
it("keeps bounded preserved-turn text UTF-16 safe", () => {
const section = formatPreservedTurnsSection([
{
role: "user",
content: `${"x".repeat(599)}🚀tail`,
timestamp: 1,
},
]);
expect(section).toContain(`- User: ${"x".repeat(599)}...`);
});
it("does not add non-text placeholders for text-only content blocks", () => {
const section = formatPreservedTurnsSection([
{
@ -2692,6 +2735,14 @@ describe("readWorkspaceContextForSummary", () => {
expect(result).not.toContain("Ignore me");
});
it("keeps bounded workspace rules UTF-16 safe", async () => {
const heading = "## Session Startup\n";
const safePrefix = `${heading}${"x".repeat(1_999 - heading.length)}`;
const result = await withWorkspaceSummary(`${safePrefix}🚀tail\n`, ["Session Startup"]);
expect(result).toContain(`${safePrefix}\n...[truncated]...`);
});
it("reads workspace context from the configured workspace instead of process cwd", async () => {
const processRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-compaction-cwd-"));
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-compaction-workspace-"));

View file

@ -1,10 +1,12 @@
/** Extension that safeguards compaction with structured summaries and quality repair. */
import fs from "node:fs";
import path from "node:path";
import { sliceUtf16Safe, truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { extractSections } from "../../auto-reply/reply/post-compaction-context.js";
import { isAbortError } from "../../infra/abort-signal.js";
import { openRootFile } from "../../infra/boundary-file-read.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { isAbortError } from "../../infra/abort-signal.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import {
getCompactionProvider,
@ -404,7 +406,7 @@ function truncateFailureText(text: string, maxChars: number): string {
if (text.length <= maxChars) {
return text;
}
return `${text.slice(0, Math.max(0, maxChars - 3))}...`;
return `${truncateUtf16Safe(text, Math.max(0, maxChars - 3))}...`;
}
function formatToolFailureMeta(details: unknown): string | undefined {
@ -568,9 +570,9 @@ function capCompactionSummary(summary: string, maxChars = MAX_COMPACTION_SUMMARY
const budget = Math.max(0, maxChars - marker.length);
if (budget <= 0) {
// Marker cannot fit; keep body prefix instead of a partial marker fragment.
return summary.slice(0, maxChars);
return truncateUtf16Safe(summary, maxChars);
}
return `${summary.slice(0, budget)}${marker}`;
return `${truncateUtf16Safe(summary, budget)}${marker}`;
}
function capCompactionSummaryPreservingSuffix(
@ -586,7 +588,7 @@ function capCompactionSummaryPreservingSuffix(
}
if (suffix.length >= maxChars) {
// Preserve tail (workspace rules, diagnostics) over head (preserved turns).
return suffix.slice(-maxChars);
return sliceUtf16Safe(suffix, -maxChars);
}
const bodyBudget = Math.max(0, maxChars - suffix.length);
const cappedBody = capCompactionSummary(summaryBody, bodyBudget);
@ -790,7 +792,7 @@ function formatContextMessages(messages: AgentMessage[]): string[] {
}
const trimmed =
rendered.length > MAX_RECENT_TURN_TEXT_CHARS
? `${rendered.slice(0, MAX_RECENT_TURN_TEXT_CHARS)}...`
? `${truncateUtf16Safe(rendered, MAX_RECENT_TURN_TEXT_CHARS)}...`
: rendered;
return `- ${roleLabel}: ${trimmed}`;
})
@ -884,7 +886,7 @@ async function readWorkspaceContextForSummary(
const combined = sections.join("\n\n");
const safeContent =
combined.length > MAX_SUMMARY_CONTEXT_CHARS
? combined.slice(0, MAX_SUMMARY_CONTEXT_CHARS) + "\n...[truncated]..."
? `${truncateUtf16Safe(combined, MAX_SUMMARY_CONTEXT_CHARS)}\n...[truncated]...`
: combined;
return `\n\n<workspace-critical-rules>\n${safeContent}\n</workspace-critical-rules>`;

View file

@ -114,6 +114,13 @@ describe("system-presence", () => {
expect(entry?.text).toBe("Node: relay-host · mode operator");
});
it("keeps fallback text keys UTF-16 safe", () => {
const keyPrefix = `presence-${randomUUID()}`.padEnd(63, "x");
const update = updateSystemPresence({ text: `${keyPrefix}🚀tail` });
expect(update.key).toBe(keyPrefix);
});
it("prunes stale non-self entries after TTL", () => {
vi.useFakeTimers();
vi.setSystemTime(Date.now());

View file

@ -7,6 +7,7 @@ import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "@openclaw/normalization-core/string-coerce";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { resolveRuntimeServiceVersion } from "../version.js";
import { pickBestEffortPrimaryLanIPv4 } from "./network-discovery-display.js";
@ -201,7 +202,7 @@ export function updateSystemPresence(payload: SystemPresencePayload): SystemPres
normalizePresenceKey(parsed.instanceId) ||
normalizePresenceKey(parsed.host) ||
parsed.ip ||
parsed.text.slice(0, 64) ||
truncateUtf16Safe(parsed.text, 64) ||
normalizeLowercaseStringOrEmpty(os.hostname());
const hadExisting = entries.has(key);
const existing = entries.get(key) ?? ({} as SystemPresence);

View file

@ -47,6 +47,7 @@ describe("slash command browser import", () => {
);
expect(importDeclarations(commands)).toEqual([
'import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";',
'import type { CommandEntry } from "../../../../packages/gateway-protocol/src/index.js";',
'import { buildBuiltinChatCommands } from "../../../../src/auto-reply/commands-registry.shared.js";',
'import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts";',

View file

@ -234,7 +234,8 @@ describe("parseSlashCommand", () => {
it("caps remote command payload size and long metadata before it reaches UI state", () => {
const longName = "x".repeat(260);
const longDescription = "d".repeat(2_500);
const longDescription = `${"d".repeat(1_999)}🚀tail`;
const boundaryArgName = `${"n".repeat(199)}🚀tail`;
const oversizedCommand = {
name: "plugin-0",
textAliases: Array.from({ length: 25 }, (_, aliasIndex) => `/plugin-0-${aliasIndex}`),
@ -243,7 +244,7 @@ describe("parseSlashCommand", () => {
scope: "both" as const,
acceptsArgs: true,
args: Array.from({ length: 25 }, (_, argIndex) => ({
name: `${longName}-${argIndex}`,
name: argIndex === 0 ? boundaryArgName : `${longName}-${argIndex}`,
description: longDescription,
type: "string" as const,
choices: Array.from({ length: 55 }, (_Local, choiceIndex) => ({
@ -268,8 +269,9 @@ describe("parseSlashCommand", () => {
expect(remoteCommands).toHaveLength(500);
const first = remoteCommands[0];
expect(first.aliases).toHaveLength(19);
expect(first.description.length).toBeLessThanOrEqual(2_000);
expect(first.description).toBe("d".repeat(1_999));
expect(first.args?.split(" ")).toHaveLength(20);
expect(first.args?.split(" ")[0]).toBe("[" + "n".repeat(199) + "]");
expect(first.argOptions).toHaveLength(50);
});

View file

@ -1,4 +1,6 @@
// Control UI chat domain owns pure slash command rules.
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import type { CommandEntry } from "../../../../packages/gateway-protocol/src/index.js";
import { buildBuiltinChatCommands } from "../../../../src/auto-reply/commands-registry.shared.js";
import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts";
@ -253,7 +255,7 @@ function normalizeSlashIdentifier(raw: string): string | null {
function clampText(value: unknown, maxLength: number): string {
const text = typeof value === "string" ? value : "";
return text.length > maxLength ? text.slice(0, maxLength) : text;
return text.length > maxLength ? truncateUtf16Safe(text, maxLength) : text;
}
function asRecord(value: unknown): Record<string, unknown> | null {

View file

@ -562,6 +562,55 @@ describe("renderWorkboard", () => {
expect(container.textContent).toContain("Needs proof.");
});
it("keeps bounded diagnostic badges UTF-16 safe", () => {
const host = {};
const state = getWorkboardState(host);
state.loaded = true;
state.cards = [
{
id: "card-boundary",
title: "Boundary badge",
status: "todo",
priority: "normal",
labels: [],
position: 1000,
createdAt: 1,
updatedAt: 1,
metadata: {
diagnostics: [
{
kind: "protocol_violation",
severity: "warning",
title: `${"x".repeat(62)}🚀tail`,
detail: "Boundary detail.",
firstSeenAt: 1,
lastSeenAt: 1,
count: 1,
},
],
},
},
];
const container = document.createElement("div");
render(
renderWorkboard({
host,
client: null,
connected: true,
pluginEnabled: true,
agentsList: null,
sessions: [],
onOpenSession: () => undefined,
}),
container,
);
expect(container.querySelector(".workboard-card__badge--warning")?.textContent?.trim()).toBe(
`${"x".repeat(62)}`,
);
});
it("renders sub-minute heartbeat ages with the duration count interpolation", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-13T12:00:00Z"));

View file

@ -1,4 +1,6 @@
// Control UI view renders workboard screen content.
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { html, nothing, type TemplateResult } from "lit";
import { ref } from "lit/directives/ref.js";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
@ -206,7 +208,9 @@ function formatAge(value: number | undefined): string {
function truncateBadgeText(value: string, maxLength = 64): string {
const trimmed = value.trim();
return trimmed.length <= maxLength ? trimmed : `${trimmed.slice(0, maxLength - 1)}`;
return trimmed.length <= maxLength
? trimmed
: `${truncateUtf16Safe(trimmed, Math.max(0, maxLength - 1))}`;
}
function canMutate(props: WorkboardProps): boolean {