mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
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:
parent
56096eb859
commit
0ac8933721
14 changed files with 117 additions and 9 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)");
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue