diff --git a/apps/web/src/agents-api.ts b/apps/web/src/agents-api.ts index a8f5e7016..2e1bda0ec 100644 --- a/apps/web/src/agents-api.ts +++ b/apps/web/src/agents-api.ts @@ -126,14 +126,16 @@ export function listAgentInstances( ).then((page) => page.data); } -/** The tenant's visible, enabled catalog models, for the create-agent - * form's model picker. Never invented client-side — only what the - * catalog actually resolves against at launch time. */ +/** The tenant's visible, enabled catalog models for the create-agent form's + * model picker. Uses `/catalog/models` (paginated `ModelResponse`), not the + * bare-array discovery route at `/models` (`ModelInfo[]`) — those are + * different wire shapes. Disabled rows are filtered out here because the + * catalog may retain them. */ export function listCatalogModels( tenantId: string, ): Promise { return getJSON( - `/api/tenants/${tenantId}/models?limit=${PAGE_LIMIT}`, + `/api/tenants/${tenantId}/catalog/models?limit=${PAGE_LIMIT}`, ModelsPage, ).then((page) => page.data.filter((model) => !model.disabled)); } @@ -162,13 +164,51 @@ export type AgentDirectoryData = { readonly definitions: readonly AgentDefinition[]; readonly instances: readonly AgentInstance[]; readonly models: readonly CatalogModel[]; + /** Set when the model catalog failed independently; definitions and + * instances still load so the page stays usable. */ + readonly modelsError?: string; }; +type ModelsOutcome = + | { readonly ok: true; readonly models: readonly CatalogModel[] } + | { readonly ok: false; readonly message: string }; + /** - * Loads a bench's full agent directory in one shot, re-fetching whenever - * `tenantId` changes or `reloadKey` is bumped — the same "no push, refetch - * on demand" convention `useAPIQuery` uses, so a freshly created - * definition shows up the moment the create dialog closes. + * Loads a bench's agent directory. Definitions and instances are required; + * the model catalog is best-effort so a picker failure never blanks the page. + */ +export async function loadAgentDirectory( + tenantId: string, +): Promise { + const [definitions, instances, modelsOutcome] = await Promise.all([ + listAgentDefinitions(tenantId), + listAgentInstances(tenantId), + listCatalogModels(tenantId).then( + (models): ModelsOutcome => ({ ok: true, models }), + (cause: unknown): ModelsOutcome => ({ + ok: false, + message: cause instanceof Error ? cause.message : String(cause), + }), + ), + ]); + + if (modelsOutcome.ok) { + return { tenantId, definitions, instances, models: modelsOutcome.models }; + } + return { + tenantId, + definitions, + instances, + models: [], + modelsError: modelsOutcome.message, + }; +} + +/** + * Loads a bench's full agent directory, re-fetching whenever `tenantId` + * changes or `reloadKey` is bumped — the same "no push, refetch on demand" + * convention `useAPIQuery` uses, so a freshly created definition shows up + * the moment the create dialog closes. */ export function useAgentDirectory( tenantId: string | undefined, @@ -182,17 +222,10 @@ export function useAgentDirectory( if (tenantId === undefined) return; let cancelled = false; setState({ kind: "loading" }); - Promise.all([ - listAgentDefinitions(tenantId), - listAgentInstances(tenantId), - listCatalogModels(tenantId), - ]) - .then(([definitions, instances, models]) => { + loadAgentDirectory(tenantId) + .then((data) => { if (cancelled) return; - setState({ - kind: "ready", - data: { tenantId, definitions, instances, models }, - }); + setState({ kind: "ready", data }); }) .catch((cause: unknown) => { if (cancelled) return; diff --git a/apps/web/src/pages/agents-page.tsx b/apps/web/src/pages/agents-page.tsx index eefe7b812..04c48733d 100644 --- a/apps/web/src/pages/agents-page.tsx +++ b/apps/web/src/pages/agents-page.tsx @@ -440,6 +440,9 @@ export function AgentsPage({ onOpenChange={setCreateOpen} tenantId={directory.data.tenantId} models={directory.data.models} + {...(directory.data.modelsError !== undefined + ? { modelsError: directory.data.modelsError } + : {})} onCreated={onAgentCreated} /> )} diff --git a/apps/web/src/pages/create-agent-dialog.tsx b/apps/web/src/pages/create-agent-dialog.tsx index 204e052f3..4dff441f3 100644 --- a/apps/web/src/pages/create-agent-dialog.tsx +++ b/apps/web/src/pages/create-agent-dialog.tsx @@ -119,12 +119,15 @@ export function CreateAgentDialog({ onOpenChange, tenantId, models, + modelsError, onCreated, }: { readonly open: boolean; readonly onOpenChange: (open: boolean) => void; readonly tenantId: string; readonly models: readonly CatalogModel[]; + /** Inline note when the catalog failed; the rest of the form still works. */ + readonly modelsError?: string; readonly onCreated: (definition: AgentDefinition) => void; }) { const [values, setValues] = useState(EMPTY_VALUES); @@ -224,6 +227,11 @@ export function CreateAgentDialog({ ))} )} + {modelsError !== undefined && ( +

+ Model catalog unavailable — the agent will use the bench default. +

+ )} { + globalThis.fetch = realFetch; +}); + +type RecordedCall = { readonly path: string }; + +function stubFetch(respond: (path: string) => Response): RecordedCall[] { + const calls: RecordedCall[] = []; + globalThis.fetch = ((input: RequestInfo | URL) => { + const full = + typeof input === "string" + ? input + : `${new URL(String(input)).pathname}${new URL(String(input)).search}`; + calls.push({ path: typeof input === "string" ? input : full }); + // Matchers key on the path-with-query the client builds. + return Promise.resolve(respond(typeof input === "string" ? input : full)); + }) as typeof fetch; + return calls; +} + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + +const modelFixture = { + id: "mdl_1", + tenantId: "tnt_1", + canonicalName: "anthropic/claude-sonnet-4", + displayName: "Claude Sonnet 4", + description: null, + disabled: false, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", +}; + +const disabledModel = { + ...modelFixture, + id: "mdl_2", + canonicalName: "disabled/model", + disabled: true, +}; + +const definitionFixture = { + id: "wfd_1", + tenantId: "tnt_1", + name: "Researcher", + description: "Answers research questions", + currentVersion: "1", + status: "deployed" as const, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", +}; + +const instanceFixture = { + id: "ins_1", + definitionId: "wfd_1", + definitionName: "Researcher", + tenantId: "tnt_1", + address: "ins_1@acme.localhost", + status: "running" as const, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", +}; + +describe("listCatalogModels", () => { + test("fetches the paginated catalog models endpoint", async () => { + const calls = stubFetch((path) => { + expect(path.startsWith("/api/tenants/tnt_1/catalog/models")).toBe(true); + return json({ data: [modelFixture, disabledModel], nextCursor: null }); + }); + + const models = await listCatalogModels("tnt_1"); + expect(calls[0]?.path).toContain("/api/tenants/tnt_1/catalog/models"); + expect(models).toEqual([modelFixture]); + }); + + test("rejects the bare-array discovery shape the wrong endpoint returns", async () => { + stubFetch(() => + json([ + { + id: "mdl_1", + canonicalName: "anthropic/claude-sonnet-4", + offerings: [], + }, + ]), + ); + + await expect(listCatalogModels("tnt_1")).rejects.toThrow( + /Unexpected response shape/, + ); + }); +}); + +describe("loadAgentDirectory", () => { + test("loads definitions and instances even when the model catalog fails", async () => { + stubFetch((path) => { + if (path.includes("/workflows/definitions")) { + return json({ data: [definitionFixture], nextCursor: null }); + } + if (path.includes("/workflows/runs")) { + return json({ data: [instanceFixture], nextCursor: null }); + } + if (path.includes("/catalog/models")) { + return json({ error: { message: "catalog down" } }, 503); + } + return json({ error: { message: "unexpected" } }, 500); + }); + + const directory = await loadAgentDirectory("tnt_1"); + expect(directory.definitions).toEqual([definitionFixture]); + expect(directory.instances).toEqual([instanceFixture]); + expect(directory.models).toEqual([]); + expect(directory.modelsError).toMatch(/503|catalog/i); + }); + + test("surfaces a definitions failure as a hard error", async () => { + stubFetch((path) => { + if (path.includes("/workflows/definitions")) { + return json({ error: { message: "nope" } }, 500); + } + if (path.includes("/workflows/runs")) { + return json({ data: [instanceFixture], nextCursor: null }); + } + if (path.includes("/catalog/models")) { + return json({ data: [modelFixture], nextCursor: null }); + } + return json({ error: { message: "unexpected" } }, 500); + }); + + await expect(loadAgentDirectory("tnt_1")).rejects.toThrow(/500/); + }); + + test("returns ready models when the catalog succeeds", async () => { + stubFetch((path) => { + if (path.includes("/workflows/definitions")) { + return json({ data: [definitionFixture], nextCursor: null }); + } + if (path.includes("/workflows/runs")) { + return json({ data: [instanceFixture], nextCursor: null }); + } + if (path.includes("/catalog/models")) { + return json({ data: [modelFixture], nextCursor: null }); + } + return json({ error: { message: "unexpected" } }, 500); + }); + + const directory = await loadAgentDirectory("tnt_1"); + expect(directory.models).toEqual([modelFixture]); + expect(directory.modelsError).toBeUndefined(); + }); +});