diff --git a/src/agents/anthropic-transport-stream.ts b/src/agents/anthropic-transport-stream.ts index 1ee3cfc759b..6f4dd277ca7 100644 --- a/src/agents/anthropic-transport-stream.ts +++ b/src/agents/anthropic-transport-stream.ts @@ -9,6 +9,7 @@ import { type SimpleStreamOptions, type ThinkingLevel, } from "@mariozechner/pi-ai"; +import { MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE } from "../shared/assistant-error-format.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { applyAnthropicPayloadPolicyToParams, @@ -24,7 +25,6 @@ import { createWritableTransportEventStream, failTransportStream, finalizeTransportStream, - MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE, mergeTransportHeaders, sanitizeNonEmptyTransportPayloadText, sanitizeTransportPayloadText, diff --git a/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts b/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts index 78405eed667..9d1a18ee5bc 100644 --- a/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts +++ b/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts @@ -1,5 +1,6 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; import { describe, expect, it } from "vitest"; +import { MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE } from "../shared/assistant-error-format.js"; import { BILLING_ERROR_USER_MESSAGE, formatBillingErrorMessage, @@ -10,7 +11,6 @@ import { sanitizeUserFacingText, } from "./pi-embedded-helpers.js"; import { makeAssistantMessageFixture } from "./test-helpers/assistant-message-fixtures.js"; -import { MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE } from "./transport-stream-shared.js"; describe("formatAssistantErrorText", () => { const makeAssistantError = (errorMessage: string): AssistantMessage => diff --git a/src/agents/pi-embedded-helpers/errors.test.ts b/src/agents/pi-embedded-helpers/errors.test.ts index ad8fab14805..a8b38d51368 100644 --- a/src/agents/pi-embedded-helpers/errors.test.ts +++ b/src/agents/pi-embedded-helpers/errors.test.ts @@ -1,7 +1,7 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; import { describe, expect, it } from "vitest"; +import { MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE } from "../../shared/assistant-error-format.js"; import { makeAssistantMessageFixture } from "../test-helpers/assistant-message-fixtures.js"; -import { MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE } from "../transport-stream-shared.js"; import { formatAssistantErrorText } from "./errors.js"; describe("formatAssistantErrorText streaming JSON parse classification", () => { 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 c7aa101fe79..a6037a05b5b 100644 --- a/src/agents/pi-embedded-helpers/sanitize-user-facing-text.ts +++ b/src/agents/pi-embedded-helpers/sanitize-user-facing-text.ts @@ -3,6 +3,7 @@ import { extractLeadingHttpStatus, formatRawAssistantErrorForUi, isCloudflareOrHtmlErrorPage, + MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE, parseApiErrorInfo, parseApiErrorPayload, } from "../../shared/assistant-error-format.js"; @@ -14,7 +15,6 @@ import { import { formatExecDeniedUserMessage } from "../exec-approval-result.js"; import { stripInternalRuntimeContext } from "../internal-runtime-context.js"; import { stableStringify } from "../stable-stringify.js"; -import { MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE } from "../transport-stream-shared.js"; import { isBillingErrorMessage, isOverloadedErrorMessage, diff --git a/src/agents/transport-stream-shared.ts b/src/agents/transport-stream-shared.ts index 2bb7ac00d91..08a72bb6a75 100644 --- a/src/agents/transport-stream-shared.ts +++ b/src/agents/transport-stream-shared.ts @@ -20,10 +20,6 @@ type TransportOutputShape = { }; export const EMPTY_TOOL_RESULT_TEXT = "(no output)"; - -export const MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE = - "OpenClaw transport error: malformed_streaming_fragment"; - export function sanitizeTransportPayloadText(text: string): string { return text.replace( /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?; export type ApiErrorInfo = { @@ -188,6 +193,10 @@ export function formatRawAssistantErrorForUi(raw?: string): string { return "LLM request failed with an unknown error."; } + if (trimmed === MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE) { + return MALFORMED_STREAMING_FRAGMENT_USER_MESSAGE; + } + const leadingStatus = extractLeadingHttpStatus(trimmed); const isHtmlChallenge = isCloudflareOrHtmlErrorPage(trimmed); if (leadingStatus && isHtmlChallenge) { diff --git a/src/tui/tui-event-handlers.test.ts b/src/tui/tui-event-handlers.test.ts index 5f4a66662dc..f85ec260656 100644 --- a/src/tui/tui-event-handlers.test.ts +++ b/src/tui/tui-event-handlers.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE } from "../shared/assistant-error-format.js"; import { createEventHandlers } from "./tui-event-handlers.js"; import type { AgentEvent, BtwEvent, ChatEvent, TuiStateAccess } from "./tui-types.js"; @@ -753,6 +754,42 @@ describe("tui-event-handlers: handleAgentEvent", () => { expect(chatLog.dropAssistant).not.toHaveBeenCalledWith("run-error-envelope"); }); + it("renders malformed streaming fragment text when chat final only has event errorMessage", () => { + const { state, chatLog, handleChatEvent } = createHandlersHarness({ + state: { activeChatRunId: null }, + }); + + handleChatEvent({ + runId: "run-malformed-final", + sessionKey: state.currentSessionKey, + state: "final", + message: { content: [] }, + errorMessage: MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE, + }); + + expect(chatLog.finalizeAssistant).toHaveBeenCalledWith( + "LLM streaming response contained a malformed fragment. Please try again.", + "run-malformed-final", + ); + }); + + it("renders malformed streaming fragment text for chat error events", () => { + const { state, chatLog, handleChatEvent } = createHandlersHarness({ + state: { activeChatRunId: null }, + }); + + handleChatEvent({ + runId: "run-malformed-error", + sessionKey: state.currentSessionKey, + state: "error", + errorMessage: MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE, + }); + + expect(chatLog.addSystem).toHaveBeenCalledWith( + "run error: LLM streaming response contained a malformed fragment. Please try again.", + ); + }); + it("shows a concise /auth hint for local auth failures", () => { const { chatLog, handleChatEvent } = createHandlersHarness({ localMode: true, diff --git a/src/tui/tui-event-handlers.ts b/src/tui/tui-event-handlers.ts index 3a1271cf51e..d71bf837a8d 100644 --- a/src/tui/tui-event-handlers.ts +++ b/src/tui/tui-event-handlers.ts @@ -1,5 +1,6 @@ import { isAuthErrorMessage } from "../agents/pi-embedded-helpers.js"; import { parseAgentSessionKey } from "../sessions/session-key-utils.js"; +import { formatRawAssistantErrorForUi } from "../shared/assistant-error-format.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { asString, extractTextFromMessage, isCommandMessage } from "./tui-formatters.js"; import { TuiStreamAssembler } from "./tui-stream-assembler.js"; @@ -450,7 +451,8 @@ export function createEventHandlers(context: EventHandlerContext) { forgetLocalBtwRunId?.(evt.runId); const wasActiveRun = state.activeChatRunId === evt.runId; const errorMessage = evt.errorMessage ?? "unknown"; - chatLog.addSystem(resolveAuthErrorHint(errorMessage) ?? `run error: ${errorMessage}`); + const renderedError = formatRawAssistantErrorForUi(errorMessage); + chatLog.addSystem(resolveAuthErrorHint(errorMessage) ?? `run error: ${renderedError}`); terminateRun({ runId: evt.runId, wasActiveRun, status: "error" }); maybeRefreshHistoryForRun(evt.runId); } diff --git a/src/tui/tui-formatters.test.ts b/src/tui/tui-formatters.test.ts index 6d03ec79218..5d759a4a9eb 100644 --- a/src/tui/tui-formatters.test.ts +++ b/src/tui/tui-formatters.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE } from "../shared/assistant-error-format.js"; import { extractContentFromMessage, extractTextFromMessage, @@ -42,6 +43,17 @@ describe("extractTextFromMessage", () => { expect(text).toContain("This request would exceed your account's rate limit."); }); + it("renders malformed streaming fragment errors with friendly text", () => { + const text = extractTextFromMessage({ + role: "assistant", + content: [], + stopReason: "error", + errorMessage: MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE, + }); + + expect(text).toBe("LLM streaming response contained a malformed fragment. Please try again."); + }); + it("falls back to a generic message when errorMessage is missing", () => { const text = extractTextFromMessage({ role: "assistant", @@ -275,6 +287,16 @@ describe("extractContentFromMessage", () => { expect(text).toContain("HTTP 429"); }); + + it("formats malformed streaming fragment errors when content is not an array", () => { + const text = extractContentFromMessage({ + role: "assistant", + stopReason: "error", + errorMessage: MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE, + }); + + expect(text).toBe("LLM streaming response contained a malformed fragment. Please try again."); + }); }); describe("isCommandMessage", () => { diff --git a/src/tui/tui.test.ts b/src/tui/tui.test.ts index b9d41065472..15f8c68739d 100644 --- a/src/tui/tui.test.ts +++ b/src/tui/tui.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE } from "../shared/assistant-error-format.js"; import { getSlashCommands, parseCommand } from "./commands.js"; import { createBackspaceDeduper, @@ -40,6 +41,16 @@ describe("resolveFinalAssistantText", () => { }), ).toContain("HTTP 401"); }); + + it("formats malformed streaming fragment errors when final and streamed text are empty", () => { + expect( + resolveFinalAssistantText({ + finalText: "", + streamedText: "", + errorMessage: MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE, + }), + ).toBe("LLM streaming response contained a malformed fragment. Please try again."); + }); }); describe("tui slash commands", () => {