fix(text): keep diagnostic truncation UTF-16 safe

Co-authored-by: chengzhichao-xydt <chengzhichao-xydt@users.noreply.github.com>
Co-authored-by: wangmiao0668000666 <wang.miao86@xydigit.com>
Co-authored-by: 黄剑雄0668001315 <huang.jianxiong@xydigit.com>
This commit is contained in:
Vincent Koc 2026-07-09 04:00:44 -07:00
parent 56096eb859
commit 0ac8933721
No known key found for this signature in database
14 changed files with 117 additions and 9 deletions

View file

@ -9,11 +9,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
type SpeechSynthesisTarget = SpeechSynthesisRequest["target"];
const runFfmpegMock = vi.hoisted(() => vi.fn<(args: string[]) => Promise<string | void>>());
const debugLogMock = vi.hoisted(() => vi.fn());
vi.mock("openclaw/plugin-sdk/media-runtime", () => ({
runFfmpeg: runFfmpegMock,
}));
vi.mock("openclaw/plugin-sdk/runtime-env", () => ({
createSubsystemLogger: () => ({ debug: debugLogMock }),
}));
import { buildCliSpeechProvider } from "./speech-provider.js";
const TEST_CFG = {} as OpenClawConfig;
@ -256,6 +261,27 @@ describe("buildCliSpeechProvider", () => {
}
});
it.each(["synthesize", "synthesizeTelephony"] as const)(
"keeps %s debug previews free of lone surrogates",
async (method) => {
const text = `${"a".repeat(49)}😀tail`;
const providerConfig = { command: "missing-openclaw-tts-test-command" };
const run =
method === "synthesize"
? synthesize({ providerConfig, text })
: buildCliSpeechProvider().synthesizeTelephony?.({
text,
cfg: TEST_CFG,
providerConfig,
timeoutMs: 1000,
});
await expect(run).rejects.toThrow();
const preview = String(debugLogMock.mock.calls[0]?.[0]);
expect(Buffer.from(preview).toString()).toBe(preview);
},
);
it("can synthesize through a real local CLI fixture and ffmpeg", async () => {
if (process.env.OPENCLAW_LIVE_TEST !== "1") {
return;

View file

@ -12,6 +12,7 @@ import type {
SpeechTelephonySynthesisRequest,
} from "openclaw/plugin-sdk/speech-core";
import { tempWorkspace, resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
const log = createSubsystemLogger("tts-local-cli");
@ -374,7 +375,7 @@ export function buildCliSpeechProvider(): SpeechProviderPlugin {
throw new Error("CLI TTS not configured");
}
log.debug(`synthesize: text=${req.text.slice(0, 50)}...`);
log.debug(`synthesize: text=${truncateUtf16Safe(req.text, 50)}...`);
const temp = await tempWorkspace({
rootDir: resolvePreferredOpenClawTmpDir(),
@ -447,7 +448,7 @@ export function buildCliSpeechProvider(): SpeechProviderPlugin {
throw new Error("CLI TTS not configured");
}
log.debug(`synthesizeTelephony: text=${req.text.slice(0, 50)}...`);
log.debug(`synthesizeTelephony: text=${truncateUtf16Safe(req.text, 50)}...`);
const temp = await tempWorkspace({
rootDir: resolvePreferredOpenClawTmpDir(),

View file

@ -1582,7 +1582,7 @@ export function formatAssistantErrorText(
// Never return raw unhandled errors - log for debugging but return safe message
if (raw.length > 600) {
log.warn(`Long error truncated: ${raw.slice(0, 200)}`);
log.warn(`Long error truncated: ${truncateUtf16Safe(raw, 200)}`);
}
return raw.length > 600 ? `${truncateUtf16Safe(raw, 600)}` : raw;
}

View file

@ -1028,6 +1028,25 @@ describe("handleMessageUpdate commentary phase", () => {
});
describe("handleMessageEnd", () => {
it("keeps duplicate-reply diagnostics free of lone surrogates", () => {
const text = `${"a".repeat(49)}😀tail`;
const ctx = createMessageEndContext({
consumeReplyDirectives: vi.fn((value: string) => ({ text: value })),
state: { messagingToolSentTextsNormalized: [`${"a".repeat(49)}tail`] },
});
void handleMessageEnd(ctx, {
type: "message_end",
message: { role: "assistant", content: [{ type: "text", text }] },
} as never);
const diagnostic = (ctx.log.debug as ReturnType<typeof vi.fn>).mock.calls
.flat()
.find((value) => String(value).startsWith("Skipping message_end block reply"));
expect(diagnostic).toEqual(expect.any(String));
expect(Buffer.from(String(diagnostic)).toString()).toBe(diagnostic);
});
it("persists streamed usage when the final assistant snapshot is zeroed", () => {
const ctx = createMessageEndContext({
state: {

View file

@ -4,6 +4,7 @@
*/
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import { createInlineCodeState } from "../../packages/markdown-core/src/code-spans.js";
import {
@ -1306,7 +1307,7 @@ export function handleMessageEnd(
)
) {
ctx.log.debug(
`Skipping message_end block reply - already sent via messaging tool: ${text.slice(0, 50)}...`,
`Skipping message_end block reply - already sent via messaging tool: ${truncateUtf16Safe(text, 50)}...`,
);
} else {
const alreadyDeliveredFinalText = Boolean(

View file

@ -2,6 +2,7 @@
* Subscribes to embedded-agent sessions and streams formatted replies/events.
*/
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import type { InlineCodeState } from "../../packages/markdown-core/src/code-spans.js";
import {
buildCodeSpanIndex,
@ -1057,7 +1058,9 @@ export function subscribeEmbeddedAgentSession(params: SubscribeEmbeddedAgentSess
messagingToolSentTextsNormalized,
));
if (isMessagingDuplicate) {
log.debug(`Skipping block reply - already sent via messaging tool: ${chunk.slice(0, 50)}...`);
log.debug(
`Skipping block reply - already sent via messaging tool: ${truncateUtf16Safe(chunk, 50)}...`,
);
if (prefixReplayCandidate) {
markBlockReplyTextHandled();
}

View file

@ -410,6 +410,29 @@ function expectRestartHandoffCall(expected: {
}
describe("runGatewayLoop", () => {
it("keeps truncated startup failure reasons free of lone surrogates", async () => {
await withIsolatedSignals(async () => {
const failure = `${"a".repeat(499)}😀tail`;
const { runtime } = createRuntimeWithExitSignal();
const completeBoot = vi.fn();
const { runGatewayLoop } = await import("./run-loop.js");
await expect(
runGatewayLoop({
start: vi.fn(async () => {
throw new Error(failure);
}) as unknown as Parameters<typeof runGatewayLoop>[0]["start"],
runtime: runtime as unknown as Parameters<typeof runGatewayLoop>[0]["runtime"],
completeBoot,
}),
).rejects.toThrow(failure);
const reason =
(completeBoot.mock.calls[0]?.[0] as { reason?: string } | undefined)?.reason ?? "";
expect(reason).toHaveLength(499);
expect(Buffer.from(reason).toString()).toBe(reason);
});
});
it("exits 0 on SIGTERM after graceful close", async () => {
vi.clearAllMocks();

View file

@ -1,6 +1,7 @@
// In-process gateway run loop, restart signaling, drain, and update respawn handling.
import { randomUUID } from "node:crypto";
import net from "node:net";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { clearRuntimeConfigSnapshot } from "../../config/runtime-snapshot.js";
import {
captureGatewayRestartTraceHandoff,
@ -918,7 +919,7 @@ export async function runGatewayLoop(params: {
} catch (err) {
params.completeBoot?.({
outcome: "startup_failed",
reason: formatErrorMessage(err).slice(0, 500),
reason: truncateUtf16Safe(formatErrorMessage(err), 500),
});
// On initial startup, let the error propagate so the outer handler
// can report "Gateway failed to start" and exit non-zero. Only

View file

@ -44,4 +44,11 @@ describe("formatPluginLine", () => {
expect(output).not.toContain("\u001B[31m");
expect(output.match(/activation reason:/g)).toHaveLength(1);
});
it("keeps truncated descriptions free of lone surrogates", () => {
const output = formatPluginLine(
createPluginRecord({ id: "demo", description: `${"a".repeat(56)}😀tail` }),
);
expect(Buffer.from(output).toString()).toBe(output);
});
});

View file

@ -1,4 +1,5 @@
// Text formatter for plugin list rows and verbose plugin details.
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js";
import { theme } from "../../packages/terminal-core/src/theme.js";
import type { PluginRecord } from "../plugins/registry.js";
@ -16,7 +17,7 @@ export function formatPluginLine(plugin: PluginRecord, verbose = false): string
const desc = plugin.description
? theme.muted(
plugin.description.length > 60
? `${plugin.description.slice(0, 57)}...`
? `${truncateUtf16Safe(plugin.description, 57)}...`
: plugin.description,
)
: theme.muted("(no description)");

View file

@ -16,6 +16,15 @@ function requireFirstLog(logger: ReturnType<typeof vi.fn>): string {
}
describe("fireAndForgetHook", () => {
it("keeps truncated error logs free of lone surrogates", async () => {
const logger = vi.fn();
fireAndForgetHook(Promise.reject(new Error(`${"a".repeat(499)}😀tail`)), "hook", logger);
await Promise.resolve();
const message = requireFirstLog(logger);
expect(Buffer.from(message).toString()).toBe(message);
});
it("logs rejection errors as sanitized single-line messages", async () => {
const logger = vi.fn();
fireAndForgetHook(

View file

@ -1,4 +1,5 @@
// Fire-and-forget hook helpers schedule hook work without blocking hot paths.
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { logVerbose } from "../globals.js";
import { formatErrorMessage } from "../infra/errors.js";
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
@ -72,7 +73,7 @@ export function formatHookErrorForLog(err: unknown): string {
const formatted = replaceLogControlCharacters(formatErrorMessage(err))
.replace(/\s+/g, " ")
.trim();
return (formatted || "unknown error").slice(0, MAX_HOOK_LOG_MESSAGE_LENGTH);
return truncateUtf16Safe(formatted || "unknown error", MAX_HOOK_LOG_MESSAGE_LENGTH);
}
/** Run a hook promise without awaiting it, logging rejection safely. */

View file

@ -162,6 +162,21 @@ describe("gateway restart handoff", () => {
expect(persisted?.reason).toBe("plugin source changed");
});
it("keeps truncated restart reasons free of lone surrogates", () => {
const env = createHandoffEnv();
const handoff = expectWrittenHandoff({
env,
pid: 1,
reason: `${"a".repeat(199)}😀tail`,
restartKind: "full-process",
supervisorMode: "external",
});
expect(handoff.reason).toHaveLength(199);
expect(Buffer.from(handoff.reason ?? "").toString()).toBe(handoff.reason);
expect(readGatewayRestartHandoffSync(env)?.reason).toBe(handoff.reason);
});
it("persists restart trace timing for supervised process handoff", () => {
const env = createHandoffEnv();

View file

@ -1,5 +1,6 @@
// Persists short-lived gateway restart handoff metadata.
import { randomUUID } from "node:crypto";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { createSubsystemLogger } from "../logging/subsystem.js";
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
import {
@ -108,7 +109,7 @@ function normalizePid(pid: number | undefined): number | null {
function normalizeText(value: unknown, maxLength: number): string | undefined {
const text = typeof value === "string" ? value.trim() : "";
return text ? text.slice(0, maxLength) : undefined;
return text ? truncateUtf16Safe(text, maxLength) : undefined;
}
function normalizeCreatedAt(value: number | undefined): number {