diff --git a/apps/web/src/agents-api.ts b/apps/web/src/agents-api.ts index c2f243344..629d0f186 100644 --- a/apps/web/src/agents-api.ts +++ b/apps/web/src/agents-api.ts @@ -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[], @@ -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; + } + | { 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, @@ -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); - - 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( diff --git a/apps/web/src/pages/agent-detail-page.tsx b/apps/web/src/pages/agent-detail-page.tsx index 490f2c093..4d2645731 100644 --- a/apps/web/src/pages/agent-detail-page.tsx +++ b/apps/web/src/pages/agent-detail-page.tsx @@ -179,6 +179,7 @@ export function AgentDetailPage({ onSaved, onDuplicated, onStatusChanged, + skillsError, }: { readonly tenantId: string; readonly definition: AgentDefinitionWithDisplayName; @@ -191,6 +192,9 @@ export function AgentDetailPage({ readonly onSaved: (report: SaveReport) => void; readonly onDuplicated: (slug: string) => Promise; 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); @@ -477,6 +481,11 @@ export function AgentDetailPage({ title="Skills" description="Pinned skills this agent can load while it works." > + {skillsError !== undefined ? ( +

+ Could not load agent skills: {skillsError} +

+ ) : null} key + 1); void refreshDirectory(); }} + {...(directory.data.skillsError !== undefined + ? { skillsError: directory.data.skillsError } + : {})} /> ); } diff --git a/apps/web/src/pages/agents-page.tsx b/apps/web/src/pages/agents-page.tsx index c583b0d99..6f6db7a79 100644 --- a/apps/web/src/pages/agents-page.tsx +++ b/apps/web/src/pages/agents-page.tsx @@ -371,6 +371,7 @@ export function AgentsPage({ onCreateOpenChange, onCreated, onArchiveSelected, + skillsError, }: { readonly tenantId: string | null; readonly definitions: readonly AgentDefinitionWithDisplayName[]; @@ -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( @@ -422,6 +426,14 @@ export function AgentsPage({
+ {skillsError !== undefined ? ( +

+ Could not load agent skills: {skillsError} +

+ ) : null} {definitions.length === 0 ? ( } @@ -702,6 +714,9 @@ export function AgentsRoute({ toast(archiveResultToast(result)); }); }} + {...(directory.data.skillsError !== undefined + ? { skillsError: directory.data.skillsError } + : {})} /> ); } diff --git a/apps/web/test/agent-detail-page.test.tsx b/apps/web/test/agent-detail-page.test.tsx index 25344f6fb..4a302bcb7 100644 --- a/apps/web/test/agent-detail-page.test.tsx +++ b/apps/web/test/agent-detail-page.test.tsx @@ -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", () => { diff --git a/apps/web/test/agents-api.test.ts b/apps/web/test/agents-api.test.ts index 6558c1af6..1046f8449 100644 --- a/apps/web/test/agents-api.test.ts +++ b/apps/web/test/agents-api.test.ts @@ -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 }); @@ -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 () => { diff --git a/apps/web/test/agents-page.test.tsx b/apps/web/test/agents-page.test.tsx index 25ec80982..6daf10b74 100644 --- a/apps/web/test/agents-page.test.tsx +++ b/apps/web/test/agents-page.test.tsx @@ -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( + , + ); + 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( + , + ); + expect(markup).not.toContain("Could not load agent skills"); + }); });