fix: prevent malformed_streaming_fragment sentinel from leaking through TUI/raw formatter paths

This commit is contained in:
Mason Huang
2026-04-29 20:34:33 +08:00
parent 946f26f73b
commit b8b3686445
10 changed files with 86 additions and 9 deletions

View File

@@ -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,

View File

@@ -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 =>

View File

@@ -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", () => {

View File

@@ -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,

View File

@@ -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])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g,

View File

@@ -15,6 +15,11 @@ const CLOUDFLARE_HTML_ERROR_CODES = new Set([521, 522, 523, 524, 525, 526, 530])
const STANDALONE_HTML_ERROR_HINT_RE =
/\bcloudflare\b|cdn-cgi\/challenge-platform|challenge-error-text|enable javascript and cookies to continue|access denied|forbidden|service unavailable|bad gateway|web server is down|captcha|attention required/i;
export const MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE =
"OpenClaw transport error: malformed_streaming_fragment";
export const MALFORMED_STREAMING_FRAGMENT_USER_MESSAGE =
"LLM streaming response contained a malformed fragment. Please try again.";
type ErrorPayload = Record<string, unknown>;
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) {

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -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", () => {

View File

@@ -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", () => {