Files
openclaw/src/agents/embedded-agent-helpers/errors.test.ts
chengzhichao-xydt aa8f78788c 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 <chengzhichao-xydt@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-07-11 15:43:52 -07:00

133 lines
4.5 KiB
TypeScript

// Covers assistant error formatting for streaming, sandbox, and context errors.
import type { AssistantMessage } from "openclaw/plugin-sdk/llm";
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 {
extractFailoverSignalDetails,
formatAssistantErrorText,
isLikelyContextOverflowError,
} from "./errors.js";
const { toolPolicyAuditInfo } = vi.hoisted(() => ({
toolPolicyAuditInfo: vi.fn(),
}));
vi.mock("../../logging/subsystem.js", () => ({
createSubsystemLogger: () => ({
debug: vi.fn(),
error: vi.fn(),
info: toolPolicyAuditInfo,
warn: vi.fn(),
}),
}));
describe("formatAssistantErrorText streaming JSON parse classification", () => {
beforeEach(() => {
toolPolicyAuditInfo.mockClear();
});
const makeAssistantError = (errorMessage: string): AssistantMessage =>
makeAssistantMessageFixture({
errorMessage,
content: [{ type: "text", text: errorMessage }],
});
it("suppresses transport-classified malformed streaming fragments", () => {
// Transport JSON fragmentation is not user-authored content and should get
// stable retry copy instead of raw parser text.
const msg = makeAssistantError(MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE);
expect(formatAssistantErrorText(msg)).toBe(
"LLM streaming response contained a malformed fragment. Please try again.",
);
});
it("does not suppress unclassified JSON.parse text", () => {
const msg = makeAssistantError(
"Expected ',' or '}' after property value in JSON at position 334 (line 1 column 335)",
);
expect(formatAssistantErrorText(msg)).toBe(
"Expected ',' or '}' after property value in JSON at position 334 (line 1 column 335)",
);
});
it("keeps non-streaming provider request-validation syntax diagnostics", () => {
const msg = makeAssistantError(
'{"type":"error","error":{"type":"invalid_request_error","message":"Expected value in JSON at position 12 for messages.0.content"}}',
);
expect(formatAssistantErrorText(msg)).toBe(
"LLM request rejected: Expected value in JSON at position 12 for messages.0.content",
);
});
it("audits a sandbox tool-policy block once per assistant error", () => {
// Formatting may be called multiple times for the same error; audit logs
// should stay deduplicated per blocked assistant error.
const cfg: OpenClawConfig = {
agents: {
defaults: {
sandbox: { mode: "non-main", scope: "agent" },
},
},
tools: {
sandbox: {
tools: {
deny: ["browser"],
},
},
},
};
const msg = makeAssistantError("unknown tool: browser");
expect(
formatAssistantErrorText(msg, { cfg, sessionKey: "agent:main:mobilechat:g1" }),
).toContain('Tool "browser" blocked by sandbox tool policy');
expect(
formatAssistantErrorText(msg, { cfg, sessionKey: "agent:main:mobilechat:g1" }),
).toContain('Tool "browser" blocked by sandbox tool policy');
expect(toolPolicyAuditInfo).toHaveBeenCalledTimes(1);
expect(toolPolicyAuditInfo).toHaveBeenCalledWith(
"sandbox tool policy blocked browser via tools.sandbox.tools.deny; matched browser",
{
tool: "browser",
ruleKind: "deny",
ruleSource: "global",
configKey: "tools.sandbox.tools.deny",
matchedRule: "browser",
sandboxMode: "non-main",
},
);
});
});
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(
isLikelyContextOverflowError(
"Codex ran out of room in the model's context window. Start a new thread or clear earlier history before retrying.",
),
).toBe(true);
});
});