From aa8f78788c76d5eea60ce40e2a18efcb4cb0ef87 Mon Sep 17 00:00:00 2001 From: chengzhichao-xydt Date: Sun, 12 Jul 2026 06:43:52 +0800 Subject: [PATCH] fix(agents): keep failover detail truncation UTF-16 safe (#104487) * fix(agents): keep failover detail truncation UTF-16 safe Replace raw .slice(0, MAX_FAILOVER_DETAIL_CHARS) with truncateUtf16Safe so provider error details containing emoji or other non-BMP characters are not split at a surrogate-pair boundary before failover classification. Adds a regression test via extractFailoverSignalDetails. * test(agents): tighten failover UTF-16 coverage --------- Co-authored-by: chengzhichao-xydt Co-authored-by: Peter Steinberger --- .../embedded-agent-helpers/errors.test.ts | 25 ++++++++++++++++++- src/agents/embedded-agent-helpers/errors.ts | 2 +- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/agents/embedded-agent-helpers/errors.test.ts b/src/agents/embedded-agent-helpers/errors.test.ts index ad94a3941c37..51e657c96b36 100644 --- a/src/agents/embedded-agent-helpers/errors.test.ts +++ b/src/agents/embedded-agent-helpers/errors.test.ts @@ -4,7 +4,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE } from "../../shared/assistant-error-format.js"; import { makeAssistantMessageFixture } from "../test-helpers/assistant-message-fixtures.js"; -import { formatAssistantErrorText, isLikelyContextOverflowError } from "./errors.js"; +import { + extractFailoverSignalDetails, + formatAssistantErrorText, + isLikelyContextOverflowError, +} from "./errors.js"; const { toolPolicyAuditInfo } = vi.hoisted(() => ({ toolPolicyAuditInfo: vi.fn(), @@ -98,6 +102,25 @@ describe("formatAssistantErrorText streaming JSON parse classification", () => { }); }); +describe("extractFailoverSignalDetails", () => { + it.each([ + { + name: "backs off before a split surrogate pair", + input: `${"a".repeat(999)}🎉!`, + expected: "a".repeat(999), + }, + { + name: "keeps the full ASCII budget", + input: "a".repeat(1001), + expected: "a".repeat(1000), + }, + ])("$name in nested provider details", ({ input, expected }) => { + const details = extractFailoverSignalDetails({ error: { body: { detail: input } } }); + + expect(details).toEqual([expected]); + }); +}); + describe("isLikelyContextOverflowError", () => { it("detects Codex promptError wording for a full context window", () => { expect( diff --git a/src/agents/embedded-agent-helpers/errors.ts b/src/agents/embedded-agent-helpers/errors.ts index 47ce868e33f3..1c63aa499705 100644 --- a/src/agents/embedded-agent-helpers/errors.ts +++ b/src/agents/embedded-agent-helpers/errors.ts @@ -308,7 +308,7 @@ function normalizeFailoverDetailString(value: string | undefined): string | unde return undefined; } return trimmed.length > MAX_FAILOVER_DETAIL_CHARS - ? trimmed.slice(0, MAX_FAILOVER_DETAIL_CHARS) + ? truncateUtf16Safe(trimmed, MAX_FAILOVER_DETAIL_CHARS) : trimmed; }