feat(ai-chat): surface the real cause in the error banner

The AI chat error banner always showed a generic "Something went wrong"
with no reason. The server already forwards the provider cause into the
stream (e.g. "Cannot connect to API: read ECONNRESET"), but the client
hid it behind a static heading.

- describeChatError now returns { title, detail }: a short heading naming
  the cause category plus a one-line explanation.
- Add classifyProviderError: maps connection reset, timeout, rate limit,
  context-window overflow, quota and auth failures to clear categories;
  the 403/503 gating responses are preserved; unknown errors fall back to
  the verbatim provider text.
- Match HTTP status codes only as the leading token and textual signatures
  only against the message head (before "| response body:"), so a number
  or phrase in the response-body snippet never mislabels the cause.
- Use the new {title, detail} in all three banners: chat-thread,
  share-ai-widget and the persisted-error banner in message-item.
- Cover the classifier with 20 unit tests (categories + regressions).
This commit is contained in:
claude_code
2026-06-21 18:54:43 +03:00
parent 0cfc3c8f89
commit 0bbf94c154
5 changed files with 310 additions and 48 deletions

View File

@@ -6,48 +6,163 @@ import { describeChatError } from "./error-message";
const t = (key: string) => key;
describe("describeChatError", () => {
it('surfaces a provider "402: ..." stream error verbatim', () => {
expect(describeChatError("402: Insufficient credits", t)).toBe(
"402: Insufficient credits",
);
});
it('does NOT misclassify a body that merely contains "403" (no "statusCode":403)', () => {
// A provider message mentioning the number 403 must be surfaced verbatim,
// never folded into the "AI chat is disabled" gating message.
const msg = "429: rate limited after 403 attempts";
expect(describeChatError(msg, t)).toBe(msg);
});
it('maps a {"statusCode":403} body to the disabled message', () => {
it('maps a {"statusCode":403} body to the disabled heading', () => {
const body = '{"statusCode":403,"message":"Forbidden"}';
expect(describeChatError(body, t)).toBe(
"AI chat is disabled for this workspace.",
);
expect(describeChatError(body, t)).toEqual({
title: "AI chat is disabled",
detail: "AI chat is disabled for this workspace.",
});
});
it('maps a {"statusCode":503} body to the not-configured message', () => {
it('maps a {"statusCode":503} body to the not-configured heading', () => {
const body = '{"statusCode":503,"message":"Service Unavailable"}';
expect(describeChatError(body, t)).toBe(
"The AI provider is not configured. Ask an administrator to set it up.",
expect(describeChatError(body, t)).toEqual({
title: "AI provider not configured",
detail:
"The AI provider is not configured. Ask an administrator to set it up.",
});
});
it("classifies a dropped connection (ECONNRESET) as a lost-connection error", () => {
expect(
describeChatError("Cannot connect to API: read ECONNRESET", t).title,
).toBe("Lost connection to the AI provider");
});
it('classifies "fetch failed" as a lost-connection error', () => {
expect(describeChatError("fetch failed", t).title).toBe(
"Lost connection to the AI provider",
);
});
it('falls back to the generic message for "An error occurred."', () => {
expect(describeChatError("An error occurred.", t)).toBe(
"The AI agent could not respond. Please try again.",
it("classifies ETIMEDOUT as a timeout", () => {
expect(describeChatError("ETIMEDOUT", t).title).toBe(
"The AI provider timed out",
);
});
it('falls back to the generic message for "Internal server error"', () => {
expect(describeChatError("Internal server error", t)).toBe(
"The AI agent could not respond. Please try again.",
it('classifies "504: Gateway Timeout" as a timeout', () => {
expect(describeChatError("504: Gateway Timeout", t).title).toBe(
"The AI provider timed out",
);
});
it("falls back to the generic message for empty input", () => {
expect(describeChatError("", t)).toBe(
"The AI agent could not respond. Please try again.",
it('classifies "429: Too Many Requests" as rate limited', () => {
expect(describeChatError("429: Too Many Requests", t).title).toBe(
"Rate limited by the AI provider",
);
});
it('does NOT misclassify a body that merely contains "403" as disabled', () => {
// Regression intent: a provider message mentioning the number 403 must never
// be folded into the "AI chat is disabled" gating heading. Here the 429
// signature wins (checked before any bare-403 logic exists), so it maps to
// the rate-limit category instead.
const view = describeChatError("429: rate limited after 403 attempts", t);
expect(view.title).toBe("Rate limited by the AI provider");
expect(view.title).not.toBe("AI chat is disabled");
});
it("classifies a context-window overflow as too-large", () => {
expect(
describeChatError(
"This model's maximum context length is 128000 tokens",
t,
).title,
).toBe("The conversation is too large");
});
it('classifies "402: Insufficient credits" as quota exceeded', () => {
expect(describeChatError("402: Insufficient credits", t).title).toBe(
"AI provider quota exceeded",
);
});
it('classifies "401: Unauthorized" as an auth failure', () => {
expect(describeChatError("401: Unauthorized", t).title).toBe(
"AI provider authentication failed",
);
});
it("falls back to the generic heading + detail for empty input", () => {
expect(describeChatError("", t)).toEqual({
title: "Something went wrong",
detail: "The AI agent could not respond. Please try again.",
});
});
it('falls back to the generic heading + detail for "An error occurred."', () => {
expect(describeChatError("An error occurred.", t)).toEqual({
title: "Something went wrong",
detail: "The AI agent could not respond. Please try again.",
});
});
it('falls back to the generic heading + detail for "Internal server error"', () => {
expect(describeChatError("Internal server error", t)).toEqual({
title: "Something went wrong",
detail: "The AI agent could not respond. Please try again.",
});
});
it("surfaces an unknown-but-informative provider detail verbatim under the generic heading", () => {
expect(describeChatError("418: I'm a teapot", t)).toEqual({
title: "Something went wrong",
detail: "418: I'm a teapot",
});
});
it("does NOT treat a number inside the response body as a leading status code (no auth)", () => {
// The real status (500) leads the string; the "401" lives in the snippet and
// must not trigger the auth category. The verbatim provider text is surfaced.
const body =
"500: Server error | response body: model gpt-4o-401-preview not found";
expect(describeChatError(body, t)).toEqual({
title: "Something went wrong",
detail: body,
});
});
it("does NOT treat a passing mention of billing as a quota error", () => {
// "billing" is no longer a quota signature; the verbatim text is surfaced.
const body = "502: Bad Gateway | response body: see our billing page";
expect(describeChatError(body, t)).toEqual({
title: "Something went wrong",
detail: body,
});
});
it('still rate-limits "429: rate limited after 403 attempts" and never disables', () => {
const view = describeChatError("429: rate limited after 403 attempts", t);
expect(view.title).toBe("Rate limited by the AI provider");
expect(view.title).not.toBe("AI chat is disabled");
});
it('does NOT treat "rate limit" inside the response body as a rate-limit error', () => {
// The textual rate-limit phrase lives only in the response-body snippet, and
// the leading 500 is not a classified numeric code, so it must not leak into
// the rate-limit category. (The detail itself falls back to the generic line
// here because the leading message contains "Internal Server Error", which
// providerDetail suppresses — the title is what this case pins.)
const body =
"500: Internal Server Error | response body: rate limit info: see our docs";
expect(describeChatError(body, t).title).toBe("Something went wrong");
expect(describeChatError(body, t).title).not.toBe(
"Rate limited by the AI provider",
);
});
it('does NOT treat ETIMEDOUT inside the response body as a timeout', () => {
// The 503 leads the string but is not a classified numeric code, and the
// ETIMEDOUT signature appears only in the body, so it must not leak into the
// timeout category; the verbatim text is surfaced under the generic heading.
const body = "503: x | response body: ETIMEDOUT appears in this log line";
expect(describeChatError(body, t)).toEqual({
title: "Something went wrong",
detail: body,
});
expect(describeChatError(body, t).title).not.toBe(
"The AI provider timed out",
);
});
});