diff --git a/src/agents/embedded-agent-helpers/provider-runtime-failure.test.ts b/src/agents/embedded-agent-helpers/provider-runtime-failure.test.ts index fbc519819d3a..ee29fc7b14d1 100644 --- a/src/agents/embedded-agent-helpers/provider-runtime-failure.test.ts +++ b/src/agents/embedded-agent-helpers/provider-runtime-failure.test.ts @@ -17,6 +17,12 @@ function expectNotFailoverSample(sample: string) { } describe("classifyProviderRuntimeFailureKind", () => { + it("classifies complete HTML after an HTTP reason phrase as upstream_html", () => { + const raw = "HTTP 502 Bad Gateway\n\ndown"; + + expect(classifyProviderRuntimeFailureKind(raw)).toBe("upstream_html"); + }); + it("classifies generic resource-exhausted codes as rate_limit", () => { expect( classifyProviderRuntimeFailureKind({ diff --git a/src/agents/embedded-agent-helpers/provider-runtime-failure.ts b/src/agents/embedded-agent-helpers/provider-runtime-failure.ts index 94327e996277..56b71d254299 100644 --- a/src/agents/embedded-agent-helpers/provider-runtime-failure.ts +++ b/src/agents/embedded-agent-helpers/provider-runtime-failure.ts @@ -1,5 +1,6 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { extractLeadingHttpStatus } from "../../shared/assistant-error-format.js"; +import { extractHttpResponseBody } from "../../shared/http-error-response.js"; import { classifyOAuthRefreshFailure } from "../auth-profiles/oauth-refresh-failure.js"; import { formatExecDeniedUserMessage } from "../exec-approval-result.js"; import { @@ -70,7 +71,7 @@ function isHtmlErrorResponse(raw: string, status?: number): boolean { if (typeof inferred !== "number" || inferred < 400) { return false; } - const rest = extractLeadingHttpStatus(candidate)?.rest ?? candidate; + const rest = extractHttpResponseBody(extractLeadingHttpStatus(candidate))?.body ?? candidate; return HTML_BODY_RE.test(rest) && HTML_CLOSE_RE.test(rest); } function isCloudflareChallengeResponse(message: string): boolean { diff --git a/src/shared/assistant-error-format.html.test.ts b/src/shared/assistant-error-format.html.test.ts index 55dd903abf01..729e0fa895ba 100644 --- a/src/shared/assistant-error-format.html.test.ts +++ b/src/shared/assistant-error-format.html.test.ts @@ -17,6 +17,16 @@ describe("isCloudflareOrHtmlErrorPage", () => { expect(isCloudflareOrHtmlErrorPage(htmlError)).toBe(true); }); + it("detects complete 5xx HTML pages after an HTTP reason phrase", () => { + const htmlError = "HTTP 502 Bad Gateway\n\ndown"; + expect(isCloudflareOrHtmlErrorPage(htmlError)).toBe(true); + }); + + it("does not flag partial HTML after an HTTP reason phrase", () => { + const partialHtml = "HTTP 502 Bad Gateway\n\ndown"; + expect(isCloudflareOrHtmlErrorPage(partialHtml)).toBe(false); + }); + it("detects standalone Cloudflare challenge HTML pages", () => { // HTML challenge pages are provider transport failures, not model text. const htmlError = ` diff --git a/src/shared/assistant-error-format.test.ts b/src/shared/assistant-error-format.test.ts index 6093a3664cad..c3ca52044e2b 100644 --- a/src/shared/assistant-error-format.test.ts +++ b/src/shared/assistant-error-format.test.ts @@ -81,6 +81,18 @@ describe("extractErrorHttpStatus", () => { }); describe("HTTP status consumers", () => { + it("does not return raw HTML after an HTTP reason phrase", () => { + const raw = [ + "HTTP 502 Bad Gateway", + "", + "

502

", + ].join("\n"); + + expect(formatRawAssistantErrorForUi(raw)).toBe( + "The AI service is temporarily unavailable (HTTP 502). Please try again in a moment.", + ); + }); + it("formats only status lines inside the HTTP range", () => { expect(formatRawAssistantErrorForUi("100 Continue")).toBe("HTTP 100: Continue"); expect(formatRawAssistantErrorForUi("599 Provider Error")).toBe("HTTP 599: Provider Error"); diff --git a/src/shared/assistant-error-format.ts b/src/shared/assistant-error-format.ts index dd1deae82379..bc1a830eb81c 100644 --- a/src/shared/assistant-error-format.ts +++ b/src/shared/assistant-error-format.ts @@ -1,5 +1,6 @@ // Assistant error formatting helpers normalize assistant-visible error payloads. import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; +import { extractHttpResponseBody } from "./http-error-response.js"; const ERROR_PAYLOAD_PREFIX_RE = /^(?:error|(?:[a-z][\w-]*\s+)?api\s*error|apierror|openai\s*error|anthropic\s*error|gateway\s*error|codex\s*error)(?:\s+\d{3})?[:\s-]+/i; const HTTP_STATUS_DELIMITER_RE = /(?:\s*:\s*|\s+)/; @@ -160,7 +161,7 @@ export function isCloudflareOrHtmlErrorPage(raw: string): boolean { return true; } - const status = extractLeadingHttpStatus(trimmed); + const status = extractHttpResponseBody(extractLeadingHttpStatus(trimmed)); if (!status || status.code < 500) { return false; } @@ -170,7 +171,7 @@ export function isCloudflareOrHtmlErrorPage(raw: string): boolean { } return ( - status.code < 600 && HTML_ERROR_PREFIX_RE.test(status.rest) && HTML_CLOSE_RE.test(status.rest) + status.code < 600 && HTML_ERROR_PREFIX_RE.test(status.body) && HTML_CLOSE_RE.test(status.body) ); } diff --git a/src/shared/http-error-response.ts b/src/shared/http-error-response.ts new file mode 100644 index 000000000000..8e5279eeedd8 --- /dev/null +++ b/src/shared/http-error-response.ts @@ -0,0 +1,17 @@ +const HTML_ERROR_PREFIX_RE = /^\s*(?: { + it("hides complete HTML error pages after an HTTP reason phrase", () => { + const raw = "HTTP 502 Bad Gateway\n\ndown"; + + const rendered = resolveFinalAssistantText({ errorMessage: raw }); + + expect(rendered).toBe( + "The AI service is temporarily unavailable (HTTP 502). Please try again in a moment.", + ); + expect(rendered).not.toContain(""); + }); +}); + describe("formatTuiFooter", () => { it("shows session modes and the process delivery mode in one compact summary", () => { expect(