Skip to content

Commit e5bab02

Browse files
committed
fix: complete UTF-16-safe restart and chat boundaries
1 parent 989e576 commit e5bab02

8 files changed

Lines changed: 69 additions & 7 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: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -843,10 +843,7 @@ export function scheduleGatewaySigusr1Restart(opts?: {
843843
? Math.floor(opts.delayMs)
844844
: 2000;
845845
const delayMs = Math.min(Math.max(delayMsRaw, 0), 60_000);
846-
const reason =
847-
typeof opts?.reason === "string" && opts.reason.trim()
848-
? truncateUtf16Safe(opts.reason.trim(), 200)
849-
: undefined;
846+
const reason = normalizeRestartIntentReason(opts?.reason);
850847
const hasSigusr1Listener = process.listenerCount("SIGUSR1") > 0;
851848
const mode = hasSigusr1Listener ? "emit" : process.platform === "win32" ? "supervisor" : "signal";
852849
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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
21
// Chat-owned composer, queue, status, context, and run controls.
2+
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
33
import { html, nothing, type TemplateResult } from "lit";
44
import { ifDefined } from "lit/directives/if-defined.js";
55
import { ref } from "lit/directives/ref.js";

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
21
// Chat-owned message thread presentation and thread-local interaction state.
2+
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
33
import { html, nothing, type TemplateResult } from "lit";
44
import { guard } from "lit/directives/guard.js";
55
import { ref } from "lit/directives/ref.js";

0 commit comments

Comments
 (0)