From 7addcf4e79c107d9b5f2faa6470ba3c7cf472967 Mon Sep 17 00:00:00 2001 From: vincentkoc <25068+vincentkoc@users.noreply.github.com> Date: Mon, 27 Apr 2026 22:40:58 +0000 Subject: [PATCH] fix: suppress streaming JSON parse errors without hiding provider diagnostics --- CHANGELOG.md | 1 + ...d-helpers.formatassistanterrortext.test.ts | 18 ++++++++- src/agents/pi-embedded-helpers/errors.test.ts | 39 +++++++++++++++++++ .../sanitize-user-facing-text.ts | 14 ++++++- 4 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 src/agents/pi-embedded-helpers/errors.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b59755a9ccf..c656ed998eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Agents/errors: suppress malformed streaming tool-call JSON fragments before they reach chat surfaces while preserving provider request-validation diagnostics. Fixes #59076; keeps #59080 as duplicate coverage. (#59118) Thanks @singleGanghood. - CLI/models: restore provider-filtered `models list --all --provider ` rows for providers without manifest/static catalog coverage, including Anthropic and Amazon Bedrock, while keeping the compatibility fallback off expensive availability and resolver paths. Thanks @shakkernerd. - CLI/tools: keep the Gateway `tools.*` RPC namespace out of plugin command discovery and managed proxy startup, so stray commands like `openclaw tools effective` fail quickly instead of cold-loading plugin metadata. Refs #73477. Thanks @oromeis. - CLI/status: keep default text `openclaw status --usage` on metadata-only channel scans unless `--deep` or `--all` is set, and send stray `openclaw tools --help` through the precomputed root-help fast path so latency-triage commands avoid plugin/runtime cold loads before printing. Refs #73477 and #74220. Thanks @oromeis and @NianJiuZst. diff --git a/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts b/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts index f856cd1d50a..34a520f5b6e 100644 --- a/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts +++ b/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts @@ -369,13 +369,27 @@ describe("formatAssistantErrorText", () => { ); }); - it("sanitizes 'Unexpected token' JSON parse errors (#59076)", () => { - const msg = makeAssistantError("Unexpected token < in JSON at position 0"); + it("sanitizes context-proven streaming 'Unexpected token' JSON parse errors (#59076)", () => { + const msg = makeAssistantError( + 'Could not parse Anthropic SSE event content_block_delta: Unexpected token } in JSON at position 14; data={"type":"content_block_delta","delta":{"type":"input_json_delta","partial_json":"}"},"index":1}', + ); expect(formatAssistantErrorText(msg)).toBe( "LLM streaming response contained a malformed fragment. Please try again.", ); }); + it("does not broadly rewrite non-streaming 'Unexpected token' JSON parse errors", () => { + const msg = makeAssistantError("Unexpected token < in JSON at position 0"); + expect(formatAssistantErrorText(msg)).toBe("Unexpected token < in JSON at position 0"); + }); + + it("does not rewrite non-streaming provider JSON request-validation diagnostics", () => { + const msg = makeAssistantError("Expected value in JSON at position 12 for messages.0.content"); + expect(formatAssistantErrorText(msg)).toBe( + "Expected value in JSON at position 12 for messages.0.content", + ); + }); + it("keeps provider request-validation JSON diagnostics actionable", () => { const msg = makeAssistantError( '{"type":"error","error":{"type":"invalid_request_error","message":"Expected value in JSON at position 12 for messages.0.content"}}', diff --git a/src/agents/pi-embedded-helpers/errors.test.ts b/src/agents/pi-embedded-helpers/errors.test.ts new file mode 100644 index 00000000000..53cee0368c2 --- /dev/null +++ b/src/agents/pi-embedded-helpers/errors.test.ts @@ -0,0 +1,39 @@ +import type { AssistantMessage } from "@mariozechner/pi-ai"; +import { describe, expect, it } from "vitest"; +import { makeAssistantMessageFixture } from "../test-helpers/assistant-message-fixtures.js"; +import { formatAssistantErrorText } from "./errors.js"; + +describe("formatAssistantErrorText streaming JSON parse classification", () => { + const makeAssistantError = (errorMessage: string): AssistantMessage => + makeAssistantMessageFixture({ + errorMessage, + content: [{ type: "text", text: errorMessage }], + }); + + it("suppresses raw streaming tool-call fragment parse failures", () => { + const msg = makeAssistantError( + "Expected ',' or '}' after property value in JSON at position 334 (line 1 column 335)", + ); + expect(formatAssistantErrorText(msg)).toBe( + "LLM streaming response contained a malformed fragment. Please try again.", + ); + }); + + it("suppresses structured Anthropic tool-call delta parse failures", () => { + const msg = makeAssistantError( + 'Could not parse Anthropic SSE event content_block_delta: Unexpected end of JSON input; data={"type":"content_block_delta","delta":{"type":"input_json_delta","partial_json":"{\\"path\\":"},"index":0}', + ); + expect(formatAssistantErrorText(msg)).toBe( + "LLM streaming response contained a malformed fragment. Please try again.", + ); + }); + + 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", + ); + }); +}); diff --git a/src/agents/pi-embedded-helpers/sanitize-user-facing-text.ts b/src/agents/pi-embedded-helpers/sanitize-user-facing-text.ts index ad331f49631..378e0e6639c 100644 --- a/src/agents/pi-embedded-helpers/sanitize-user-facing-text.ts +++ b/src/agents/pi-embedded-helpers/sanitize-user-facing-text.ts @@ -213,7 +213,19 @@ export function isStreamingJsonParseError(raw: string): boolean { if (!raw) { return false; } - return /\b(?:expected|unexpected)\b.+\bin json\b.+\bposition\b/i.test(raw); + const trimmed = raw.trim(); + if ( + /\bcould not parse anthropic sse event\b/i.test(trimmed) && + /\b(?:content_block_delta|input_json_delta|partial_json|tool_use)\b/i.test(trimmed) && + (/\b(?:expected|unexpected|unterminated)\b.+\bin json\b.+\bposition\b/i.test(trimmed) || + /\bunexpected end of json input\b/i.test(trimmed)) + ) { + return true; + } + + return /^(?:Expected (?:',' or '\}' after property value|double-quoted property name|':' after property name|',' or '\]' after array element)|Unterminated string) in JSON at position \d+(?: \(line \d+ column \d+\))?$/i.test( + trimmed, + ); } function hasRateLimitTpmHint(raw: string): boolean {