{error}
From 86a66262455a23e605a02cf49afc273eb435ddd4 Mon Sep 17 00:00:00 2001
From: Sawyer
Date: Thu, 3 Sep 2026 16:33:11 -0700
Subject: [PATCH 3/3] Surface catalog envelope copy through describeChatError
getResolvedCatalog throws InferenceSettingsApiError, whose message is
the envelope userMessage. describeChatError only handled ChatApiError,
so a catalog 500 always fell through to "Couldn't load the models."
---
packages/chat-ui/src/api.ts | 32 ++++++++----
packages/chat-ui/test/agents-section.test.tsx | 48 +++++++++++++++--
packages/chat-ui/test/api.test.ts | 51 +++++++++++++++++++
3 files changed, 117 insertions(+), 14 deletions(-)
diff --git a/packages/chat-ui/src/api.ts b/packages/chat-ui/src/api.ts
index 2e7c5b21a..bb1f42b2f 100644
--- a/packages/chat-ui/src/api.ts
+++ b/packages/chat-ui/src/api.ts
@@ -15,6 +15,7 @@ import { parseParticipants } from "@corbits/chat/participants";
import type { ParticipantRecord } from "@corbits/chat/participants";
import type { WorkbenchOnboardingStep } from "@corbits/chat/blocks";
import { UnauthenticatedError } from "@corbits/api-query";
+import { InferenceSettingsApiError } from "@corbits/inference-settings";
import { jimmyAgentRequest } from "@workbench/templates";
import { CHAT_STRINGS } from "./strings";
@@ -215,25 +216,38 @@ export class ChatApiError extends Error {
/**
* Plain-language copy for a failed chat request — never `error.message`
* or `String(error)` verbatim, which for a `ChatApiError` embeds the raw
- * request path (see `request()` below). Every chat-ui/chat-adjacent
- * catch block that surfaces an error to the user should call this
- * rather than format one of its own, so the class of leak (a raw
- * `/api/...` URL or bare status code in user-facing copy) has exactly
- * one place to fix.
+ * request path (see `request()` below). `InferenceSettingsApiError`
+ * carries the envelope `userMessage` (safe to show) rather than a path,
+ * so a 500 from `getResolvedCatalog` surfaces that sentence instead of
+ * the generic fallback. Every chat-ui/chat-adjacent catch block that
+ * surfaces an error to the user should call this rather than format one
+ * of its own, so the class of leak (a raw `/api/...` URL or bare status
+ * code in user-facing copy) has exactly one place to fix.
*/
export function describeChatError(cause: unknown, fallback: string): string {
- if (!(cause instanceof ChatApiError)) return fallback;
- switch (cause.status) {
+ const statused =
+ cause instanceof ChatApiError || cause instanceof InferenceSettingsApiError
+ ? cause
+ : null;
+ if (statused === null) return fallback;
+ switch (statused.status) {
case 401:
return "You're signed out. Sign in again to continue.";
case 403:
return "You don't have access to this.";
case undefined:
return "Couldn't reach the server. Check your connection and try again.";
- default:
- return cause.status >= 500
+ default: {
+ if (
+ cause instanceof InferenceSettingsApiError &&
+ statused.message.trim() !== ""
+ ) {
+ return statused.message;
+ }
+ return statused.status >= 500
? "Something went wrong on our end. Try again in a moment."
: fallback;
+ }
}
}
diff --git a/packages/chat-ui/test/agents-section.test.tsx b/packages/chat-ui/test/agents-section.test.tsx
index 7b1c96166..d5fc8c6ff 100644
--- a/packages/chat-ui/test/agents-section.test.tsx
+++ b/packages/chat-ui/test/agents-section.test.tsx
@@ -72,6 +72,10 @@ function stubFetch(options: {
})[];
/** When true, every catalog (`/models`) read fails. */
readonly catalogFails?: boolean;
+ /** HTTP status for a failing catalog read (default 500). */
+ readonly catalogFailStatus?: number;
+ /** When true, a failing catalog read throws (network), not an HTTP envelope. */
+ readonly catalogNetworkFails?: boolean;
/** Fail the first N catalog reads, then succeed — used to exercise Retry. */
readonly catalogFailUntil?: number;
readonly addCapabilityFails?: boolean;
@@ -135,18 +139,24 @@ function stubFetch(options: {
catalogCalls += 1;
const catalogShouldFail =
options.catalogFails === true ||
+ options.catalogNetworkFails === true ||
+ options.catalogFailStatus !== undefined ||
(options.catalogFailUntil !== undefined &&
catalogCalls <= options.catalogFailUntil);
if (catalogShouldFail) {
+ if (options.catalogNetworkFails === true) {
+ throw new TypeError("Failed to fetch");
+ }
+ const status = options.catalogFailStatus ?? 500;
return json(
{
error: {
- code: "internal",
- userMessage: "catalog boom",
+ code: status === 401 ? "unauthenticated" : "internal",
+ userMessage: status === 401 ? "signed out boom" : "catalog boom",
refId: "ref_catalog",
},
},
- 500,
+ status,
);
}
return json(
@@ -893,7 +903,8 @@ describe("Agents section — Model select (CL-6272.3)", () => {
expect(select?.disabled).toBe(true);
const alert = el.querySelector(".chat-dialog-error")?.textContent;
- expect(alert).toBe("Couldn't load the models.");
+ expect(alert).toBe("catalog boom");
+ expect(alert).not.toBe("Couldn't load the models.");
expect(el.textContent).not.toContain(
"No connected providers yet — connect one in Shared Settings.",
);
@@ -907,6 +918,33 @@ describe("Agents section — Model select (CL-6272.3)", () => {
expect(settingsHop?.textContent).toBe("Shared Settings");
});
+ test("a catalog 401 reads as signed-out copy, not the envelope or generic fallback", async () => {
+ stubFetch({ catalogFailStatus: 401 });
+ const el = mount(baseProps());
+ await settle();
+ openAgent(el, "myra");
+ await settle();
+
+ const alert = el.querySelector(".chat-dialog-error")?.textContent;
+ expect(alert).toBe("You're signed out. Sign in again to continue.");
+ expect(alert).not.toBe("signed out boom");
+ expect(alert).not.toBe("Couldn't load the models.");
+ });
+
+ test("a catalog network failure reads as a connection error, not the generic fallback", async () => {
+ stubFetch({ catalogNetworkFails: true });
+ const el = mount(baseProps());
+ await settle();
+ openAgent(el, "myra");
+ await settle();
+
+ const alert = el.querySelector(".chat-dialog-error")?.textContent;
+ expect(alert).toBe(
+ "Couldn't reach the server. Check your connection and try again.",
+ );
+ expect(alert).not.toBe("Couldn't load the models.");
+ });
+
test("Retry after a catalog load failure recovers the picker", async () => {
stubFetch({
catalogFailUntil: 1,
@@ -925,7 +963,7 @@ describe("Agents section — Model select (CL-6272.3)", () => {
expect(modelSelect(el)?.disabled).toBe(true);
expect(el.querySelector(".chat-dialog-error")?.textContent).toBe(
- "Couldn't load the models.",
+ "catalog boom",
);
act(() => {
diff --git a/packages/chat-ui/test/api.test.ts b/packages/chat-ui/test/api.test.ts
index 2094503cf..0685467cd 100644
--- a/packages/chat-ui/test/api.test.ts
+++ b/packages/chat-ui/test/api.test.ts
@@ -5,9 +5,11 @@
import { afterEach, describe, expect, test } from "bun:test";
import { UnauthenticatedError } from "@corbits/api-query";
+import { InferenceSettingsApiError } from "@corbits/inference-settings";
import {
ChatApiError,
createWorkbench,
+ describeChatError,
runDisplayName,
inviteAgent,
JIMMY_QUICK_CREATE,
@@ -745,3 +747,52 @@ describe("runDisplayName", () => {
).toBe("Untitled agent");
});
});
+
+describe("describeChatError", () => {
+ const fallback = "Couldn't load the models.";
+
+ test("surfaces an InferenceSettingsApiError envelope userMessage on 500", () => {
+ expect(
+ describeChatError(
+ new InferenceSettingsApiError("catalog boom", 500),
+ fallback,
+ ),
+ ).toBe("catalog boom");
+ });
+
+ test("maps an InferenceSettingsApiError 401 the same way as ChatApiError", () => {
+ expect(
+ describeChatError(
+ new InferenceSettingsApiError("signed out boom", 401),
+ fallback,
+ ),
+ ).toBe("You're signed out. Sign in again to continue.");
+ expect(describeChatError(new ChatApiError("boom", 401), fallback)).toBe(
+ "You're signed out. Sign in again to continue.",
+ );
+ });
+
+ test("maps a network InferenceSettingsApiError the same way as ChatApiError", () => {
+ expect(
+ describeChatError(
+ new InferenceSettingsApiError("Failed to fetch"),
+ fallback,
+ ),
+ ).toBe("Couldn't reach the server. Check your connection and try again.");
+ expect(
+ describeChatError(new ChatApiError("Failed to fetch"), fallback),
+ ).toBe("Couldn't reach the server. Check your connection and try again.");
+ });
+
+ test("never leaks a ChatApiError path on 500", () => {
+ expect(
+ describeChatError(
+ new ChatApiError(
+ "The server answered 500 for /api/tenants/tnt_1/models.",
+ 500,
+ ),
+ fallback,
+ ),
+ ).toBe("Something went wrong on our end. Try again in a moment.");
+ });
+});