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/src/strings.ts b/packages/chat-ui/src/strings.ts
index 4a9790311..4017e1361 100644
--- a/packages/chat-ui/src/strings.ts
+++ b/packages/chat-ui/src/strings.ts
@@ -432,6 +432,9 @@ export const CHAT_STRINGS = {
) => `${canonicalName} · ${providerName}`,
workbenchSettingsAgentDetailNoConnectedModels:
"No connected providers yet — connect one in Shared Settings.",
+ workbenchSettingsAgentDetailCatalogError: "Couldn't load the models.",
+ workbenchSettingsAgentDetailCatalogRetryAction: "Retry",
+ workbenchSettingsAgentDetailCatalogSettingsAction: "Shared Settings",
workbenchSettingsAgentDetailHistoryTitle: "History",
workbenchSettingsAgentDetailHistoryHint:
"Every change to this agent's instructions and capabilities, oldest actions first.",
diff --git a/packages/chat-ui/src/workbench-settings/agents-section.tsx b/packages/chat-ui/src/workbench-settings/agents-section.tsx
index ee45f8040..d5f8e2ee1 100644
--- a/packages/chat-ui/src/workbench-settings/agents-section.tsx
+++ b/packages/chat-ui/src/workbench-settings/agents-section.tsx
@@ -238,20 +238,29 @@ function AgentDetailEditor({
const [catalogState, setCatalogState] = useState
+ {catalogState.message} +
+ + > + ) : null} {error !== null ? ({error} diff --git a/packages/chat-ui/test/agents-section.test.tsx b/packages/chat-ui/test/agents-section.test.tsx index d3d1f90ed..d5fc8c6ff 100644 --- a/packages/chat-ui/test/agents-section.test.tsx +++ b/packages/chat-ui/test/agents-section.test.tsx @@ -70,6 +70,14 @@ function stubFetch(options: { readonly catalogModels?: readonly (ModelFixture & { readonly offerings: readonly OfferingFixture[]; })[]; + /** 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; readonly restoreFails?: boolean; readonly onSave?: ( @@ -100,6 +108,7 @@ function stubFetch(options: { offerings: [{ providerName: "anthropic" }], }, ]; + let catalogCalls = 0; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const path = typeof input === "string" ? input : String(input); @@ -127,6 +136,29 @@ function stubFetch(options: { return json({ settings: {}, contextWindow: 20 }); } if (/\/models$/.test(path)) { + 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: status === 401 ? "unauthenticated" : "internal", + userMessage: status === 401 ? "signed out boom" : "catalog boom", + refId: "ref_catalog", + }, + }, + status, + ); + } return json( catalogModels.map((model, index) => ({ id: `model_${String(index)}`, @@ -859,6 +891,93 @@ describe("Agents section — Model select (CL-6272.3)", () => { expect(modelSelect(el)?.value).toBe(""); expect(modelSelect(el)?.options[0]?.textContent).toBe("No model set"); }); + + test("a catalog load failure reads as an error, not an empty picker (CL-6831)", async () => { + stubFetch({ catalogFails: true }); + const el = mount(baseProps()); + await settle(); + openAgent(el, "myra"); + await settle(); + + const select = modelSelect(el); + expect(select?.disabled).toBe(true); + + const alert = el.querySelector(".chat-dialog-error")?.textContent; + 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.", + ); + expect(el.textContent).not.toContain("No connected providers yet"); + + expect(findButton(el, "Retry")).toBeDefined(); + const settingsHop = el.querySelector( + 'a[href="/settings/connections"]', + ) as HTMLAnchorElement | null; + expect(settingsHop).not.toBeNull(); + 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, + catalogModels: [ + { + canonicalName: "anthropic/claude-sonnet", + displayName: "Claude Sonnet", + offerings: [{ providerName: "anthropic" }], + }, + ], + }); + const el = mount(baseProps()); + await settle(); + openAgent(el, "myra"); + await settle(); + + expect(modelSelect(el)?.disabled).toBe(true); + expect(el.querySelector(".chat-dialog-error")?.textContent).toBe( + "catalog boom", + ); + + act(() => { + findButton(el, "Retry")?.click(); + }); + await settle(); + + expect(el.querySelector(".chat-dialog-error")).toBeNull(); + const select = modelSelect(el); + expect(select?.disabled).toBe(false); + expect( + Array.from(select?.options ?? []).map((option) => option.textContent), + ).toContain("Claude Sonnet · Anthropic"); + }); }); describe("Agents section — History", () => { 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."); + }); +});