Skip to content

Commit 0a5552f

Browse files
committed
fix: use truncateUtf16Safe to avoid splitting UTF-16 surrogate pairs
Replace .slice(0, N) with truncateUtf16Safe in call sites where bounded text truncation could split a surrogate pair. - workboard: capText(), truncateBadgeText() - diagnostics-otel: clampOtelLogText() - session-cost-usage: user message content (3 sites) - heartbeat-runner: event preview text (6 sites) - chat/commands: clampText() - task-completion-contract: task title - system-presence: presence text
1 parent 27e8ea1 commit 0a5552f

8 files changed

Lines changed: 26 additions & 17 deletions

File tree

extensions/diagnostics-otel/src/service.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { waitForDiagnosticEventsDrained } from "openclaw/plugin-sdk/diagnostic-r
3636
import { createNodeProxyAgent } from "openclaw/plugin-sdk/fetch-runtime";
3737
import { registerUnhandledRejectionHandler } from "openclaw/plugin-sdk/runtime-env";
3838
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
39+
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
3940
import type {
4041
DiagnosticEventMetadata,
4142
DiagnosticEventPayload,
@@ -678,7 +679,7 @@ function addUpstreamRequestIdSpanEvent(
678679
}
679680

680681
function clampOtelLogText(value: string, maxChars: number): string {
681-
return value.length > maxChars ? `${value.slice(0, maxChars)}...(truncated)` : value;
682+
return value.length > maxChars ? `${truncateUtf16Safe(value, maxChars)}...(truncated)` : value;
682683
}
683684

684685
function normalizeOtelLogString(value: string, maxChars: number): string {

extensions/workboard/src/store.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
MAX_DATE_TIMESTAMP_MS,
66
resolveExpiresAtMsFromDurationMs,
77
} from "openclaw/plugin-sdk/number-runtime";
8+
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
89
import type {
910
PersistedWorkboardAttachment,
1011
PersistedWorkboardBoard,
@@ -2039,7 +2040,7 @@ function capText(value: string | undefined, max: number): string | undefined {
20392040
if (!value) {
20402041
return undefined;
20412042
}
2042-
return value.length <= max ? value : `${value.slice(0, Math.max(0, max - 1))}…`;
2043+
return value.length <= max ? value : `${truncateUtf16Safe(value, Math.max(0, max - 1))}…`;
20432044
}
20442045

20452046
function cardBoardId(card: WorkboardCard): string {

src/infra/heartbeat-runner.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ import {
107107
} from "../routing/session-key.js";
108108
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
109109
import { createLazyRuntimeModule } from "../shared/lazy-runtime.js";
110-
import { escapeRegExp } from "../utils.js";
110+
import { escapeRegExp, truncateUtf16Safe } from "../utils.js";
111111
import { MAX_SAFE_TIMEOUT_DELAY_MS, resolveSafeTimeoutDelayMs } from "../utils/timer-delay.js";
112112
import { loadOrCreateDeviceIdentity } from "./device-identity.js";
113113
import { formatErrorMessage, hasErrnoCode } from "./errors.js";
@@ -2007,7 +2007,7 @@ export async function runHeartbeatOnce(opts: {
20072007
emitHeartbeatEvent({
20082008
status: "ok-token",
20092009
reason: opts.reason,
2010-
preview: heartbeatToolResponse.summary.slice(0, 200),
2010+
preview: truncateUtf16Safe(heartbeatToolResponse.summary, 200),
20112011
durationMs: Date.now() - startedAt,
20122012
channel: delivery.channel !== "none" ? delivery.channel : undefined,
20132013
accountId: delivery.accountId,
@@ -2161,7 +2161,7 @@ export async function runHeartbeatOnce(opts: {
21612161
emitHeartbeatEvent({
21622162
status: "skipped",
21632163
reason: "duplicate",
2164-
preview: normalized.text.slice(0, 200),
2164+
preview: truncateUtf16Safe(normalized.text, 200),
21652165
durationMs: Date.now() - startedAt,
21662166
hasMedia: false,
21672167
channel: delivery.channel !== "none" ? delivery.channel : undefined,
@@ -2190,7 +2190,7 @@ export async function runHeartbeatOnce(opts: {
21902190
emitHeartbeatEvent({
21912191
status: "skipped",
21922192
reason: delivery.reason ?? "no-target",
2193-
preview: previewText?.slice(0, 200),
2193+
preview: previewText != null ? truncateUtf16Safe(previewText, 200) : undefined,
21942194
durationMs: Date.now() - startedAt,
21952195
hasMedia: mediaUrls.length > 0,
21962196
accountId: delivery.accountId,
@@ -2210,7 +2210,7 @@ export async function runHeartbeatOnce(opts: {
22102210
emitHeartbeatEvent({
22112211
status: "skipped",
22122212
reason: "alerts-disabled",
2213-
preview: previewText?.slice(0, 200),
2213+
preview: previewText != null ? truncateUtf16Safe(previewText, 200) : undefined,
22142214
durationMs: Date.now() - startedAt,
22152215
channel: delivery.channel,
22162216
hasMedia: mediaUrls.length > 0,
@@ -2233,7 +2233,7 @@ export async function runHeartbeatOnce(opts: {
22332233
emitHeartbeatEvent({
22342234
status: "skipped",
22352235
reason: readiness.reason,
2236-
preview: previewText?.slice(0, 200),
2236+
preview: previewText != null ? truncateUtf16Safe(previewText, 200) : undefined,
22372237
durationMs: Date.now() - startedAt,
22382238
hasMedia: mediaUrls.length > 0,
22392239
channel: delivery.channel,
@@ -2317,7 +2317,7 @@ export async function runHeartbeatOnce(opts: {
23172317
to: delivery.to,
23182318
...(deliveredAgentRunFailure ? { reason: "agent-runner-failure" } : {}),
23192319
...(!deliveredAgentRunFailure && !visibleSendSucceeded ? { reason: send.reason } : {}),
2320-
preview: previewText?.slice(0, 200),
2320+
preview: previewText != null ? truncateUtf16Safe(previewText, 200) : undefined,
23212321
durationMs: Date.now() - startedAt,
23222322
hasMedia: mediaUrls.length > 0,
23232323
channel: delivery.channel,

src/infra/session-cost-usage.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import type { SessionEntry } from "../config/sessions/types.js";
2222
import type { OpenClawConfig } from "../config/types.openclaw.js";
2323
import { createSubsystemLogger } from "../logging/subsystem.js";
2424
import { stripEnvelope, stripMessageIdHints } from "../shared/chat-envelope.js";
25+
import { truncateUtf16Safe } from "../utils.js";
2526
import { runTasksWithConcurrency } from "../utils/run-with-concurrency.js";
2627
import { countToolResults, extractToolCallNames } from "../utils/transcript-tools.js";
2728
import {
@@ -2159,7 +2160,7 @@ export async function discoverAllSessions(params?: {
21592160
if (message?.role === "user") {
21602161
const content = message.content;
21612162
if (typeof content === "string") {
2162-
firstUserMessage = content.slice(0, 100);
2163+
firstUserMessage = truncateUtf16Safe(content, 100);
21632164
} else if (Array.isArray(content)) {
21642165
for (const block of content) {
21652166
if (
@@ -2169,7 +2170,7 @@ export async function discoverAllSessions(params?: {
21692170
) {
21702171
const text = (block as Record<string, unknown>).text;
21712172
if (typeof text === "string") {
2172-
firstUserMessage = text.slice(0, 100);
2173+
firstUserMessage = truncateUtf16Safe(text, 100);
21732174
}
21742175
break;
21752176
}
@@ -2752,7 +2753,7 @@ export async function loadSessionLogs(params: {
27522753
// Truncate very long content
27532754
const maxLen = 2000;
27542755
if (content.length > maxLen) {
2755-
content = content.slice(0, maxLen) + "…";
2756+
content = `${truncateUtf16Safe(content, maxLen)}…`;
27562757
}
27572758

27582759
// Get timestamp

src/infra/system-presence.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
normalizeOptionalLowercaseString,
88
normalizeOptionalString,
99
} from "@openclaw/normalization-core/string-coerce";
10+
import { truncateUtf16Safe } from "../utils.js";
1011
import { resolveRuntimeServiceVersion } from "../version.js";
1112
import { pickBestEffortPrimaryLanIPv4 } from "./network-discovery-display.js";
1213

@@ -201,7 +202,7 @@ export function updateSystemPresence(payload: SystemPresencePayload): SystemPres
201202
normalizePresenceKey(parsed.instanceId) ||
202203
normalizePresenceKey(parsed.host) ||
203204
parsed.ip ||
204-
parsed.text.slice(0, 64) ||
205+
truncateUtf16Safe(parsed.text, 64) ||
205206
normalizeLowercaseStringOrEmpty(os.hostname());
206207
const hadExisting = entries.has(key);
207208
const existing = entries.get(key) ?? ({} as SystemPresence);

src/tasks/task-completion-contract.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// Defines task terminal outcome contracts used by completion handling.
2+
import { truncateUtf16Safe } from "../utils.js";
23
import type { TaskTerminalOutcome } from "./task-registry.types.js";
34

45
/** Terminal fields required when a mandatory detached task completion is invalid. */
@@ -25,7 +26,7 @@ function normalizeCompletionFailureReason(value: string | null | undefined): str
2526
if (!normalized) {
2627
return "";
2728
}
28-
return normalized.length <= 160 ? normalized : `${normalized.slice(0, 159)}...`;
29+
return normalized.length <= 160 ? normalized : `${truncateUtf16Safe(normalized, 159)}...`;
2930
}
3031

3132
function matchesProgressOnlyPrefix(value: string): boolean {

ui/src/lib/chat/commands.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
12
// Control UI chat domain owns pure slash command rules.
23
import type { CommandEntry } from "../../../../packages/gateway-protocol/src/index.js";
34
import { buildBuiltinChatCommands } from "../../../../src/auto-reply/commands-registry.shared.js";
@@ -253,7 +254,7 @@ function normalizeSlashIdentifier(raw: string): string | null {
253254

254255
function clampText(value: unknown, maxLength: number): string {
255256
const text = typeof value === "string" ? value : "";
256-
return text.length > maxLength ? text.slice(0, maxLength) : text;
257+
return text.length > maxLength ? truncateUtf16Safe(text, maxLength) : text;
257258
}
258259

259260
function asRecord(value: unknown): Record<string, unknown> | null {

ui/src/pages/workboard/view.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1+
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
12
// Control UI view renders workboard screen content.
23
import { html, nothing, type TemplateResult } from "lit";
34
import { ref } from "lit/directives/ref.js";
45
import type { GatewayBrowserClient } from "../../api/gateway.ts";
56
import type { AgentsListResult, GatewaySessionRow } from "../../api/types.ts";
6-
import { icons } from "../../components/icons.ts";
77
import "../../components/tooltip.ts";
8+
import { icons } from "../../components/icons.ts";
89
import { t } from "../../i18n/index.ts";
910
import { formatDateMs, formatDateTimeMs } from "../../lib/format.ts";
1011
import {
@@ -206,7 +207,9 @@ function formatAge(value: number | undefined): string {
206207

207208
function truncateBadgeText(value: string, maxLength = 64): string {
208209
const trimmed = value.trim();
209-
return trimmed.length <= maxLength ? trimmed : `${trimmed.slice(0, maxLength - 1)}…`;
210+
return trimmed.length <= maxLength
211+
? trimmed
212+
: `${truncateUtf16Safe(trimmed, Math.max(0, maxLength - 1))}…`;
210213
}
211214

212215
function canMutate(props: WorkboardProps): boolean {

0 commit comments

Comments
 (0)