Skip to content

Commit 0ac8933

Browse files
vincentkocchengzhichao-xydtwangmiao0668000666Pandah97
committed
fix(text): keep diagnostic truncation UTF-16 safe
Co-authored-by: chengzhichao-xydt <[email protected]> Co-authored-by: wangmiao0668000666 <[email protected]> Co-authored-by: 黄剑雄0668001315 <[email protected]>
1 parent 56096eb commit 0ac8933

14 files changed

Lines changed: 117 additions & 9 deletions

extensions/tts-local-cli/speech-provider.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
99
type SpeechSynthesisTarget = SpeechSynthesisRequest["target"];
1010

1111
const runFfmpegMock = vi.hoisted(() => vi.fn<(args: string[]) => Promise<string | void>>());
12+
const debugLogMock = vi.hoisted(() => vi.fn());
1213

1314
vi.mock("openclaw/plugin-sdk/media-runtime", () => ({
1415
runFfmpeg: runFfmpegMock,
1516
}));
1617

18+
vi.mock("openclaw/plugin-sdk/runtime-env", () => ({
19+
createSubsystemLogger: () => ({ debug: debugLogMock }),
20+
}));
21+
1722
import { buildCliSpeechProvider } from "./speech-provider.js";
1823

1924
const TEST_CFG = {} as OpenClawConfig;
@@ -256,6 +261,27 @@ describe("buildCliSpeechProvider", () => {
256261
}
257262
});
258263

264+
it.each(["synthesize", "synthesizeTelephony"] as const)(
265+
"keeps %s debug previews free of lone surrogates",
266+
async (method) => {
267+
const text = `${"a".repeat(49)}😀tail`;
268+
const providerConfig = { command: "missing-openclaw-tts-test-command" };
269+
const run =
270+
method === "synthesize"
271+
? synthesize({ providerConfig, text })
272+
: buildCliSpeechProvider().synthesizeTelephony?.({
273+
text,
274+
cfg: TEST_CFG,
275+
providerConfig,
276+
timeoutMs: 1000,
277+
});
278+
await expect(run).rejects.toThrow();
279+
280+
const preview = String(debugLogMock.mock.calls[0]?.[0]);
281+
expect(Buffer.from(preview).toString()).toBe(preview);
282+
},
283+
);
284+
259285
it("can synthesize through a real local CLI fixture and ffmpeg", async () => {
260286
if (process.env.OPENCLAW_LIVE_TEST !== "1") {
261287
return;

extensions/tts-local-cli/speech-provider.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {
1212
SpeechTelephonySynthesisRequest,
1313
} from "openclaw/plugin-sdk/speech-core";
1414
import { tempWorkspace, resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
15+
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
1516

1617
const log = createSubsystemLogger("tts-local-cli");
1718

@@ -374,7 +375,7 @@ export function buildCliSpeechProvider(): SpeechProviderPlugin {
374375
throw new Error("CLI TTS not configured");
375376
}
376377

377-
log.debug(`synthesize: text=${req.text.slice(0, 50)}...`);
378+
log.debug(`synthesize: text=${truncateUtf16Safe(req.text, 50)}...`);
378379

379380
const temp = await tempWorkspace({
380381
rootDir: resolvePreferredOpenClawTmpDir(),
@@ -447,7 +448,7 @@ export function buildCliSpeechProvider(): SpeechProviderPlugin {
447448
throw new Error("CLI TTS not configured");
448449
}
449450

450-
log.debug(`synthesizeTelephony: text=${req.text.slice(0, 50)}...`);
451+
log.debug(`synthesizeTelephony: text=${truncateUtf16Safe(req.text, 50)}...`);
451452

452453
const temp = await tempWorkspace({
453454
rootDir: resolvePreferredOpenClawTmpDir(),

src/agents/embedded-agent-helpers/errors.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1582,7 +1582,7 @@ export function formatAssistantErrorText(
15821582

15831583
// Never return raw unhandled errors - log for debugging but return safe message
15841584
if (raw.length > 600) {
1585-
log.warn(`Long error truncated: ${raw.slice(0, 200)}`);
1585+
log.warn(`Long error truncated: ${truncateUtf16Safe(raw, 200)}`);
15861586
}
15871587
return raw.length > 600 ? `${truncateUtf16Safe(raw, 600)}…` : raw;
15881588
}

src/agents/embedded-agent-subscribe.handlers.messages.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1028,6 +1028,25 @@ describe("handleMessageUpdate commentary phase", () => {
10281028
});
10291029

10301030
describe("handleMessageEnd", () => {
1031+
it("keeps duplicate-reply diagnostics free of lone surrogates", () => {
1032+
const text = `${"a".repeat(49)}😀tail`;
1033+
const ctx = createMessageEndContext({
1034+
consumeReplyDirectives: vi.fn((value: string) => ({ text: value })),
1035+
state: { messagingToolSentTextsNormalized: [`${"a".repeat(49)}tail`] },
1036+
});
1037+
1038+
void handleMessageEnd(ctx, {
1039+
type: "message_end",
1040+
message: { role: "assistant", content: [{ type: "text", text }] },
1041+
} as never);
1042+
1043+
const diagnostic = (ctx.log.debug as ReturnType<typeof vi.fn>).mock.calls
1044+
.flat()
1045+
.find((value) => String(value).startsWith("Skipping message_end block reply"));
1046+
expect(diagnostic).toEqual(expect.any(String));
1047+
expect(Buffer.from(String(diagnostic)).toString()).toBe(diagnostic);
1048+
});
1049+
10311050
it("persists streamed usage when the final assistant snapshot is zeroed", () => {
10321051
const ctx = createMessageEndContext({
10331052
state: {

src/agents/embedded-agent-subscribe.handlers.messages.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
*/
55
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
66
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
7+
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
78
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
89
import { createInlineCodeState } from "../../packages/markdown-core/src/code-spans.js";
910
import {
@@ -1306,7 +1307,7 @@ export function handleMessageEnd(
13061307
)
13071308
) {
13081309
ctx.log.debug(
1309-
`Skipping message_end block reply - already sent via messaging tool: ${text.slice(0, 50)}...`,
1310+
`Skipping message_end block reply - already sent via messaging tool: ${truncateUtf16Safe(text, 50)}...`,
13101311
);
13111312
} else {
13121313
const alreadyDeliveredFinalText = Boolean(

src/agents/embedded-agent-subscribe.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* Subscribes to embedded-agent sessions and streams formatted replies/events.
33
*/
44
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
5+
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
56
import type { InlineCodeState } from "../../packages/markdown-core/src/code-spans.js";
67
import {
78
buildCodeSpanIndex,
@@ -1057,7 +1058,9 @@ export function subscribeEmbeddedAgentSession(params: SubscribeEmbeddedAgentSess
10571058
messagingToolSentTextsNormalized,
10581059
));
10591060
if (isMessagingDuplicate) {
1060-
log.debug(`Skipping block reply - already sent via messaging tool: ${chunk.slice(0, 50)}...`);
1061+
log.debug(
1062+
`Skipping block reply - already sent via messaging tool: ${truncateUtf16Safe(chunk, 50)}...`,
1063+
);
10611064
if (prefixReplayCandidate) {
10621065
markBlockReplyTextHandled();
10631066
}

src/cli/gateway-cli/run-loop.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,29 @@ function expectRestartHandoffCall(expected: {
410410
}
411411

412412
describe("runGatewayLoop", () => {
413+
it("keeps truncated startup failure reasons free of lone surrogates", async () => {
414+
await withIsolatedSignals(async () => {
415+
const failure = `${"a".repeat(499)}😀tail`;
416+
const { runtime } = createRuntimeWithExitSignal();
417+
const completeBoot = vi.fn();
418+
const { runGatewayLoop } = await import("./run-loop.js");
419+
await expect(
420+
runGatewayLoop({
421+
start: vi.fn(async () => {
422+
throw new Error(failure);
423+
}) as unknown as Parameters<typeof runGatewayLoop>[0]["start"],
424+
runtime: runtime as unknown as Parameters<typeof runGatewayLoop>[0]["runtime"],
425+
completeBoot,
426+
}),
427+
).rejects.toThrow(failure);
428+
429+
const reason =
430+
(completeBoot.mock.calls[0]?.[0] as { reason?: string } | undefined)?.reason ?? "";
431+
expect(reason).toHaveLength(499);
432+
expect(Buffer.from(reason).toString()).toBe(reason);
433+
});
434+
});
435+
413436
it("exits 0 on SIGTERM after graceful close", async () => {
414437
vi.clearAllMocks();
415438

src/cli/gateway-cli/run-loop.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// In-process gateway run loop, restart signaling, drain, and update respawn handling.
22
import { randomUUID } from "node:crypto";
33
import net from "node:net";
4+
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
45
import { clearRuntimeConfigSnapshot } from "../../config/runtime-snapshot.js";
56
import {
67
captureGatewayRestartTraceHandoff,
@@ -918,7 +919,7 @@ export async function runGatewayLoop(params: {
918919
} catch (err) {
919920
params.completeBoot?.({
920921
outcome: "startup_failed",
921-
reason: formatErrorMessage(err).slice(0, 500),
922+
reason: truncateUtf16Safe(formatErrorMessage(err), 500),
922923
});
923924
// On initial startup, let the error propagate so the outer handler
924925
// can report "Gateway failed to start" and exit non-zero. Only

src/cli/plugins-list-format.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,11 @@ describe("formatPluginLine", () => {
4444
expect(output).not.toContain("\u001B[31m");
4545
expect(output.match(/activation reason:/g)).toHaveLength(1);
4646
});
47+
48+
it("keeps truncated descriptions free of lone surrogates", () => {
49+
const output = formatPluginLine(
50+
createPluginRecord({ id: "demo", description: `${"a".repeat(56)}😀tail` }),
51+
);
52+
expect(Buffer.from(output).toString()).toBe(output);
53+
});
4754
});

src/cli/plugins-list-format.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// Text formatter for plugin list rows and verbose plugin details.
2+
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
23
import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js";
34
import { theme } from "../../packages/terminal-core/src/theme.js";
45
import type { PluginRecord } from "../plugins/registry.js";
@@ -16,7 +17,7 @@ export function formatPluginLine(plugin: PluginRecord, verbose = false): string
1617
const desc = plugin.description
1718
? theme.muted(
1819
plugin.description.length > 60
19-
? `${plugin.description.slice(0, 57)}...`
20+
? `${truncateUtf16Safe(plugin.description, 57)}...`
2021
: plugin.description,
2122
)
2223
: theme.muted("(no description)");

0 commit comments

Comments
 (0)