Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 23 additions & 9 deletions packages/chat-ui/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
}
}
}

Expand Down
3 changes: 3 additions & 0 deletions packages/chat-ui/src/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
46 changes: 41 additions & 5 deletions packages/chat-ui/src/workbench-settings/agents-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -238,20 +238,29 @@ function AgentDetailEditor({
const [catalogState, setCatalogState] = useState<CatalogState>({
kind: "loading",
});
const [catalogReloadKey, setCatalogReloadKey] = useState(0);

useEffect(() => {
let cancelled = false;
setCatalogState({ kind: "loading" });
getResolvedCatalog(tenantId)
.then((models) => {
if (!cancelled) setCatalogState({ kind: "ready", models });
})
.catch(() => {
if (!cancelled) setCatalogState({ kind: "error" });
.catch((cause: unknown) => {
if (cancelled) return;
setCatalogState({
kind: "error",
message: describeChatError(
cause,
CHAT_STRINGS.workbenchSettingsAgentDetailCatalogError,
),
});
});
return () => {
cancelled = true;
};
}, [tenantId]);
}, [tenantId, catalogReloadKey]);

useEffect(() => {
let cancelled = false;
Expand Down Expand Up @@ -345,6 +354,7 @@ function AgentDetailEditor({
agent={agent}
detail={state.detail}
catalogState={catalogState}
onRetryCatalog={() => setCatalogReloadKey((value) => value + 1)}
onChanged={(detail) => setState({ kind: "ready", detail })}
/>

Expand Down Expand Up @@ -430,7 +440,7 @@ type ModelOption = { readonly canonicalName: string; readonly label: string };

type CatalogState =
| { readonly kind: "loading" }
| { readonly kind: "error" }
| { readonly kind: "error"; readonly message: string }
| { readonly kind: "ready"; readonly models: readonly ModelInfo[] };

/** Only a model with at least one offering is actually launchable for this
Expand Down Expand Up @@ -479,13 +489,15 @@ function ModelSelect({
agent,
detail,
catalogState,
onRetryCatalog,
onChanged,
}: {
readonly tenantId: string;
readonly workbenchId: string;
readonly agent: WorkbenchAgent;
readonly detail: AgentDetail;
readonly catalogState: CatalogState;
readonly onRetryCatalog: () => void;
readonly onChanged: (detail: AgentDetail) => void;
}) {
const [saving, setSaving] = useState(false);
Expand Down Expand Up @@ -534,7 +546,11 @@ function ModelSelect({
<select
value={detail.model ?? ""}
onChange={(event) => handleChange(event.target.value)}
disabled={saving || catalogState.kind === "loading"}
disabled={
saving ||
catalogState.kind === "loading" ||
catalogState.kind === "error"
}
>
{detail.model === undefined ? (
<option value="">
Expand All @@ -557,6 +573,26 @@ function ModelSelect({
{CHAT_STRINGS.workbenchSettingsAgentDetailNoConnectedModels}
</p>
) : null}
{catalogState.kind === "error" ? (
<>
<p className="chat-dialog-error" role="alert">
{catalogState.message}
</p>
<div className="chat-settings-field-actions">
<Button
type="button"
variant="outline"
size="sm"
onClick={onRetryCatalog}
>
{CHAT_STRINGS.workbenchSettingsAgentDetailCatalogRetryAction}
</Button>
<a href="/settings/connections">
{CHAT_STRINGS.workbenchSettingsAgentDetailCatalogSettingsAction}
</a>
</div>
</>
) : null}
{error !== null ? (
<p className="chat-dialog-error" role="alert">
{error}
Expand Down
119 changes: 119 additions & 0 deletions packages/chat-ui/test/agents-section.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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?: (
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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)}`,
Expand Down Expand Up @@ -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", () => {
Expand Down
51 changes: 51 additions & 0 deletions packages/chat-ui/test/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.");
});
});
Loading