Skip to content

Commit 1825c9f

Browse files
wings1029claudesteipete
authored
fix: keep emoji intact at remaining text truncation boundaries (#101754)
* fix: keep emoji intact at remaining text truncation boundaries Replace .slice(0, N) with truncateUtf16Safe() in five remaining user-visible text truncation sites that were missed by earlier UTF-16 safety sweeps: - restart.ts: restart reason text (2 sites) - chat-composer.ts: reply target text preview - chat-thread.ts: thread text preview + message text extraction All sites truncate user-generated content where emoji or other supplementary-plane characters could produce lone surrogates. Co-Authored-By: Claude <[email protected]> * fix: complete UTF-16-safe restart and chat boundaries --------- Co-authored-by: Claude <[email protected]> Co-authored-by: Peter Steinberger <[email protected]>
1 parent 30b3a7c commit 1825c9f

8 files changed

Lines changed: 76 additions & 10 deletions

File tree

src/gateway/server-methods/restart.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,18 @@ describe("gateway.restart.request handler", () => {
9595
expectRestartRequest(false);
9696
});
9797

98+
it("backs off before an emoji that crosses the reason limit", async () => {
99+
mockScheduledRestart({ safe: true, summary: "safe to restart now" });
100+
101+
await invokeRestartRequest({ reason: "x".repeat(199) + "🧠tail" });
102+
103+
expect(requestSafeGatewayRestart).toHaveBeenCalledWith({
104+
reason: "x".repeat(199),
105+
delayMs: 0,
106+
skipDeferral: false,
107+
});
108+
});
109+
98110
it("rejects non-object params without scheduling a restart", async () => {
99111
const respond = await invokeRestartRequest("operator");
100112

src/gateway/server-methods/restart.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// Gateway RPC handlers for safe gateway restart requests and preflight state.
2+
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
23
import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js";
34
import {
45
createSafeGatewayRestartPreflight,
@@ -13,7 +14,9 @@ function isRestartRequestParams(value: unknown): value is Record<string, unknown
1314
function normalizeReason(value: unknown): string | undefined {
1415
// Restart reasons are operator-visible log context, not payload storage.
1516
// Trim and cap them before passing through to the coordinator.
16-
return typeof value === "string" && value.trim() ? value.trim().slice(0, 200) : undefined;
17+
return typeof value === "string" && value.trim()
18+
? truncateUtf16Safe(value.trim(), 200)
19+
: undefined;
1720
}
1821

1922
function normalizeSkipDeferral(value: unknown): boolean {

src/infra/infra-runtime.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,15 @@ describe("infra runtime", () => {
136136
await vi.runAllTimersAsync();
137137
});
138138

139+
it("backs off before an emoji that crosses the restart reason limit", () => {
140+
const restart = scheduleGatewaySigusr1Restart({
141+
delayMs: 0,
142+
reason: "x".repeat(199) + "🧠tail",
143+
});
144+
145+
expect(restart.reason).toBe("x".repeat(199));
146+
});
147+
139148
it("tracks external restart policy", () => {
140149
expect(isGatewaySigusr1RestartExternallyAllowed()).toBe(false);
141150
setGatewaySigusr1RestartPolicy({ allowExternal: true });

src/infra/restart-intent.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,15 @@ describe("gateway restart intent", () => {
141141
expect(fs.existsSync(legacyIntentPath(env))).toBe(false);
142142
});
143143

144+
it("backs off before an emoji that crosses the persisted reason limit", () => {
145+
const env = createIntentEnv();
146+
insertIntentRow(env, { reason: "x".repeat(199) + "🧠tail" });
147+
148+
expect(consumeGatewayRestartIntentPayloadSync(env)).toEqual({
149+
reason: "x".repeat(199),
150+
});
151+
});
152+
144153
it("overwrites the previous pending intent row", () => {
145154
const env = createIntentEnv();
146155
expect(

src/infra/restart.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { spawnSync } from "node:child_process";
33
import os from "node:os";
44
import path from "node:path";
5+
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
56
import { getRuntimeConfig } from "../config/config.js";
67
import {
78
resolveGatewayLaunchAgentLabel,
@@ -279,7 +280,7 @@ function readGatewayRestartIntentPayloadSync(
279280

280281
function normalizeRestartIntentReason(reason: string | undefined): string | undefined {
281282
const normalized = reason?.trim();
282-
return normalized ? normalized.slice(0, 200) : undefined;
283+
return normalized ? truncateUtf16Safe(normalized, 200) : undefined;
283284
}
284285

285286
export function consumeGatewayRestartIntentPayloadSync(
@@ -842,10 +843,7 @@ export function scheduleGatewaySigusr1Restart(opts?: {
842843
? Math.floor(opts.delayMs)
843844
: 2000;
844845
const delayMs = Math.min(Math.max(delayMsRaw, 0), 60_000);
845-
const reason =
846-
typeof opts?.reason === "string" && opts.reason.trim()
847-
? opts.reason.trim().slice(0, 200)
848-
: undefined;
846+
const reason = normalizeRestartIntentReason(opts?.reason);
849847
const hasSigusr1Listener = process.listenerCount("SIGUSR1") > 0;
850848
const mode = hasSigusr1Listener ? "emit" : process.platform === "win32" ? "supervisor" : "signal";
851849
const nowMs = Date.now();

ui/src/pages/chat/chat-view.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4049,6 +4049,24 @@ describe("right-click Reply", () => {
40494049
expect(target.senderLabel).toBe("User");
40504050
});
40514051

4052+
it("backs off before an emoji that crosses the reply target limit", () => {
4053+
const onSetReply = vi.fn();
4054+
const container = renderChatView({ onSetReply });
4055+
const section = container.querySelector<HTMLElement>(".card.chat");
4056+
const group = document.createElement("div");
4057+
group.className = "chat-group";
4058+
const bubble = document.createElement("div");
4059+
bubble.className = "chat-bubble";
4060+
bubble.dataset.messageText = "x".repeat(499) + "🧠tail";
4061+
group.appendChild(bubble);
4062+
section!.querySelector(".chat-thread-inner")!.appendChild(group);
4063+
4064+
bubble.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true }));
4065+
document.querySelector<HTMLButtonElement>(".chat-reply-context-menu button")!.click();
4066+
4067+
expect(onSetReply.mock.calls[0][0].text).toBe("x".repeat(499));
4068+
});
4069+
40524070
it("keeps the native context menu when Reply is unavailable", () => {
40534071
const container = renderChatView();
40544072
const section = container.querySelector<HTMLElement>(".card.chat");
@@ -4129,6 +4147,20 @@ describe("right-click Reply", () => {
41294147
expect(dismiss).not.toBeNull();
41304148
});
41314149

4150+
it("backs off before an emoji that crosses the reply preview limit", () => {
4151+
const container = renderChatView({
4152+
replyTarget: {
4153+
messageId: "msg-emoji",
4154+
text: "x".repeat(119) + "🧠tail",
4155+
senderLabel: "User",
4156+
},
4157+
});
4158+
4159+
expect(container.querySelector(".chat-reply-preview__text")?.textContent).toBe(
4160+
`${"x".repeat(119)}...`,
4161+
);
4162+
});
4163+
41324164
it("calls onClearReply when dismiss button is clicked", () => {
41334165
const onClearReply = vi.fn();
41344166
const container = renderChatView({

ui/src/pages/chat/components/chat-composer.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// Chat-owned composer, queue, status, context, and run controls.
2+
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
23
import { html, nothing, type TemplateResult } from "lit";
34
import { ifDefined } from "lit/directives/if-defined.js";
45
import { ref } from "lit/directives/ref.js";
@@ -7,11 +8,11 @@ import type { GatewaySessionRow, SessionGoal, SessionsListResult } from "../../.
78
import { normalizeChatSendShortcut, type ChatSendShortcut } from "../../../app/settings.ts";
89
import { icons, type IconName } from "../../../components/icons.ts";
910
import { toSanitizedMarkdownHtml } from "../../../components/markdown.ts";
11+
import "../../../components/tooltip.ts";
1012
import {
1113
renderProviderQuotaPill,
1214
type ProviderQuotaPillProps,
1315
} from "../../../components/provider-quota-pill.ts";
14-
import "../../../components/tooltip.ts";
1516
import { t } from "../../../i18n/index.ts";
1617
import type { ChatAttachment, ChatQueueItem } from "../../../lib/chat/chat-types.ts";
1718
import {
@@ -2216,7 +2217,8 @@ export function renderChatComposer(props: ChatComposerProps) {
22162217
>Replying to ${props.replyTarget.senderLabel ?? "message"}</span
22172218
>
22182219
<span class="chat-reply-preview__text"
2219-
>${props.replyTarget.text.slice(0, 120)}${props.replyTarget.text.length > 120
2220+
>${truncateUtf16Safe(props.replyTarget.text, 120)}${props.replyTarget.text
2221+
.length > 120
22202222
? "..."
22212223
: ""}</span
22222224
>

ui/src/pages/chat/components/chat-thread.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// Chat-owned message thread presentation and thread-local interaction state.
2+
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
23
import { html, nothing, type TemplateResult } from "lit";
34
import { guard } from "lit/directives/guard.js";
45
import { ref } from "lit/directives/ref.js";
@@ -431,7 +432,7 @@ export function renderChatPinnedMessages(
431432
>${role === "user" ? userRoleLabel : "Assistant"}</span
432433
>
433434
<span class="agent-chat__pinned-text"
434-
>${text.slice(0, 100)}${text.length > 100 ? "..." : ""}</span
435+
>${truncateUtf16Safe(text, 100)}${text.length > 100 ? "..." : ""}</span
435436
>
436437
<openclaw-tooltip content="Unpin">
437438
<button
@@ -531,7 +532,7 @@ function handleChatContextMenu(event: MouseEvent, props: ChatThreadProps) {
531532
}
532533
const senderEl = group.querySelector(".chat-sender-name");
533534
const senderLabel = senderEl?.textContent?.trim() ?? undefined;
534-
const text = (bubble as HTMLElement).dataset.messageText?.trim().slice(0, 500) ?? "";
535+
const text = truncateUtf16Safe((bubble as HTMLElement).dataset.messageText?.trim() ?? "", 500);
535536
if (!text) {
536537
return;
537538
}

0 commit comments

Comments
 (0)