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
69 changes: 51 additions & 18 deletions apps/web/src/agents-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<readonly CatalogModel[]> {
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));
}
Expand Down Expand Up @@ -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<AgentDirectoryData> {
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,
Expand All @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/pages/agents-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
/>
)}
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/pages/create-agent-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<FormValues>(EMPTY_VALUES);
Expand Down Expand Up @@ -224,6 +227,11 @@ export function CreateAgentDialog({
))}
</ul>
)}
{modelsError !== undefined && (
<p className="mb-3 text-sm text-muted-foreground" role="status">
Model catalog unavailable — the agent will use the bench default.
</p>
)}
<IntakeForm
fields={fields}
values={values}
Expand Down
162 changes: 162 additions & 0 deletions apps/web/test/agents-api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
// The Agents page directory client: model catalog path/shape and failure
// isolation so a broken picker never blanks definitions and instances.

import { afterEach, describe, expect, test } from "bun:test";

import { listCatalogModels, loadAgentDirectory } from "../src/agents-api";

const realFetch = globalThis.fetch;

afterEach(() => {
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();
});
});
Loading