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
59 changes: 35 additions & 24 deletions apps/web/src/agents-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,8 +396,8 @@ export function setAgentDefinitionStatus(
const DefinitionSkillsMap = type({ skills: { "[string]": "string[]" } });

/** Every attached-skill list for the given definitions, keyed by definition
* id. Best-effort at the call site — a bench with no skills backed asset
* yet just gets `[]` for everything, never an error that blanks the page. */
* id. Call sites treat failure as its own outcome (`skillsError`) rather than
* coercing to `{}` — empty attachments and a failed read are different. */
export function listAgentSkills(
tenantId: string,
definitionIds: readonly string[],
Expand Down Expand Up @@ -435,20 +435,32 @@ export type AgentDirectoryData = {
/** Set when the model catalog failed independently; definitions and
* instances still load so the page stays usable. */
readonly modelsError?: string;
/** Set when the attached-skills batch failed independently; definitions
* and instances still load. Distinct from an empty `definitionSkills`
* map — failure must never read as "no skills attached". */
readonly skillsError?: string;
};

type ModelsOutcome =
| { readonly ok: true; readonly models: readonly CatalogModel[] }
| { readonly ok: false; readonly message: string };

type SkillsOutcome =
| {
readonly ok: true;
readonly definitionSkills: Record<string, readonly string[]>;
}
| { readonly ok: false; readonly message: string };

/**
* Loads a bench's agent directory. Definitions and instances are required;
* the model catalog and each definition's attached skills are best-effort
* so either failing alone never blanks the page. `instances` comes from
* `listTopLevelRuns`, which already excludes every folded run (workbench
* host, invited agent) server-side — see `@corbits/folded-runs`'s
* `scope-routes.ts` — so this page never has to derive that exclusion
* itself from a tenant's workbenches.
* so either failing alone never blanks the page. Failures surface as
* `modelsError` / `skillsError` rather than silent empty collections.
* `instances` comes from `listTopLevelRuns`, which already excludes every
* folded run (workbench host, invited agent) server-side — see
* `@corbits/folded-runs`'s `scope-routes.ts` — so this page never has to
* derive that exclusion itself from a tenant's workbenches.
*/
export async function loadAgentDirectory(
tenantId: string,
Expand All @@ -465,34 +477,33 @@ export async function loadAgentDirectory(
),
]);

const definitionSkills = await listAgentSkills(
const skillsOutcome = await listAgentSkills(
tenantId,
definitions.map((definition) => definition.id),
).catch(() => ({}) as Record<string, readonly string[]>);

if (modelsOutcome.ok) {
return {
tenantId,
definitions,
instances,
models: modelsOutcome.models,
definitionSkills,
};
}
).then(
(definitionSkills): SkillsOutcome => ({ ok: true, definitionSkills }),
(cause: unknown): SkillsOutcome => ({
ok: false,
message: cause instanceof Error ? cause.message : String(cause),
}),
);

return {
tenantId,
definitions,
instances,
models: [],
definitionSkills,
modelsError: modelsOutcome.message,
models: modelsOutcome.ok ? modelsOutcome.models : [],
definitionSkills: skillsOutcome.ok ? skillsOutcome.definitionSkills : {},
...(modelsOutcome.ok ? {} : { modelsError: modelsOutcome.message }),
...(skillsOutcome.ok ? {} : { skillsError: skillsOutcome.message }),
};
}

/**
* Loads a bench's full agent directory. One query owns definitions +
* instances + models (models are best-effort inside `loadAgentDirectory`) so
* the page keeps a single loading/error envelope. Pass no reloadKey —
* instances + models + skills (models and skills are best-effort inside
* `loadAgentDirectory`, surfacing `modelsError` / `skillsError`) so the
* page keeps a single loading/error envelope. Pass no reloadKey —
* invalidate `tenantKeys.agentDirectory(tenantId)` after create.
*/
export function useAgentDirectory(
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/pages/agent-detail-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ export function AgentDetailPage({
onSaved,
onDuplicated,
onStatusChanged,
skillsError,
}: {
readonly tenantId: string;
readonly definition: AgentDefinitionWithDisplayName;
Expand All @@ -191,6 +192,9 @@ export function AgentDetailPage({
readonly onSaved: (report: SaveReport) => void;
readonly onDuplicated: (slug: string) => Promise<void>;
readonly onStatusChanged: () => void;
/** Set when the bench directory's attached-skills batch failed. Distinct
* from an agent that simply has no pins. */
readonly skillsError?: string;
}) {
const [displayName, setDisplayName] = useState(definition.displayName);
const [systemPrompt, setSystemPrompt] = useState(detail.systemPrompt);
Expand Down Expand Up @@ -477,6 +481,11 @@ export function AgentDetailPage({
title="Skills"
description="Pinned skills this agent can load while it works."
>
{skillsError !== undefined ? (
<p className="mb-3 text-sm text-destructive" role="alert">
Could not load agent skills: {skillsError}
</p>
) : null}
<AgentSkillsPicker
tenantId={tenantId}
selected={[...skills]}
Expand Down Expand Up @@ -692,6 +701,9 @@ export function AgentDetailRoute({
setReloadKey((key) => key + 1);
void refreshDirectory();
}}
{...(directory.data.skillsError !== undefined
? { skillsError: directory.data.skillsError }
: {})}
/>
);
}
15 changes: 15 additions & 0 deletions apps/web/src/pages/agents-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,7 @@ export function AgentsPage({
onCreateOpenChange,
onCreated,
onArchiveSelected,
skillsError,
}: {
readonly tenantId: string | null;
readonly definitions: readonly AgentDefinitionWithDisplayName[];
Expand All @@ -386,6 +387,9 @@ export function AgentsPage({
readonly onCreateOpenChange: (open: boolean) => void;
readonly onCreated: (definition: AgentDefinition) => void;
readonly onArchiveSelected: (ids: readonly string[]) => void;
/** Set when the directory's attached-skills batch failed; distinct from
* agents that simply have no skills pinned. */
readonly skillsError?: string;
}) {
const selected = definitions.find((d) => d.id === selectedId) ?? null;
const definitionIds = useMemo(
Expand Down Expand Up @@ -422,6 +426,14 @@ export function AgentsPage({
<div className="flex min-h-0 flex-1">
<div className="min-h-0 min-w-0 flex-1 overflow-auto">
<PageShell width="full" className="page-fill">
{skillsError !== undefined ? (
<p
className="px-4 pb-3 text-sm text-destructive sm:px-7"
role="alert"
>
Could not load agent skills: {skillsError}
</p>
) : null}
{definitions.length === 0 ? (
<RichEmptyState
icon={<Robot />}
Expand Down Expand Up @@ -702,6 +714,9 @@ export function AgentsRoute({
toast(archiveResultToast(result));
});
}}
{...(directory.data.skillsError !== undefined
? { skillsError: directory.data.skillsError }
: {})}
/>
);
}
7 changes: 7 additions & 0 deletions apps/web/test/agent-detail-page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,13 @@ describe("AgentDetailPage render", () => {
expect(markup).toContain("skills");
expect(markup).toContain("The registry is unreachable.");
});

test("CL-6836: skillsError is an alert above Skills, never silent empty pins", () => {
const markup = renderPage({ skillsError: "500: down" });
expect(markup).toContain('role="alert"');
expect(markup).toContain("Could not load agent skills");
expect(markup).toContain("500: down");
});
});

describe("describeSaveReport", () => {
Expand Down
6 changes: 5 additions & 1 deletion apps/web/test/agents-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,9 +217,10 @@ describe("loadAgentDirectory", () => {

const directory = await loadAgentDirectory("tnt_1");
expect(directory.definitionSkills).toEqual({ wfd_1: ["web-research"] });
expect(directory.skillsError).toBeUndefined();
});

test("a broken skills endpoint degrades to no attachments rather than blanking the page", async () => {
test("CL-6836: a broken skills endpoint keeps the page and surfaces skillsError, never silent empty", async () => {
stubFetch((path) => {
if (path.includes("/workflows/definitions")) {
return json({ data: [definitionFixture], nextCursor: null });
Expand All @@ -237,7 +238,10 @@ describe("loadAgentDirectory", () => {
});

const directory = await loadAgentDirectory("tnt_1");
expect(directory.definitions).toEqual([definitionFixture]);
expect(directory.instances).toEqual([instanceFixture]);
expect(directory.definitionSkills).toEqual({});
expect(directory.skillsError).toMatch(/500|down/i);
});

test("reads instances from the server-scoped top-level-runs endpoint, never /workflows/runs", async () => {
Expand Down
41 changes: 41 additions & 0 deletions apps/web/test/agents-page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -329,4 +329,45 @@ describe("AgentsPage", () => {
expect(markup).not.toContain("Delete");
expect(markup).not.toContain("data-bulk-action");
});

test("CL-6836: skillsError is an alert, never silent 'no skills'", () => {
const markup = renderToStaticMarkup(
<AgentsPage
tenantId="tnt_1"
definitions={[triage]}
workbenches={new Map()}
instances={[]}
now={NOW}
selectedId={null}
onSelect={noop}
createOpen={false}
onCreateOpenChange={noop}
onCreated={noop}
onArchiveSelected={noop}
skillsError="500: down"
/>,
);
expect(markup).toContain('role="alert"');
expect(markup).toContain("Could not load agent skills");
expect(markup).toContain("500: down");
});

test("CL-6836: without skillsError, the skills failure alert is absent", () => {
const markup = renderToStaticMarkup(
<AgentsPage
tenantId="tnt_1"
definitions={[triage]}
workbenches={new Map()}
instances={[]}
now={NOW}
selectedId={null}
onSelect={noop}
createOpen={false}
onCreateOpenChange={noop}
onCreated={noop}
onArchiveSelected={noop}
/>,
);
expect(markup).not.toContain("Could not load agent skills");
});
});
Loading