From bdcb8fd0a4fa10fb07c20fb9dc33284fc3fc342c Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 20 Aug 2026 15:45:22 -0700 Subject: [PATCH 1/5] Add tests for the agent detail page Covers the page at /agents/: the identity card's editable display name beside its immutable slug, the default-model select, the full system-prompt editor, the skills section, recent runs linking into the Insights run surface, and the Duplicate/Archive/Save trio in the top bar's action slot. Every edit is asserted on the request body that leaves the page, so a second write path would fail the suite rather than pass it quietly. Also covers the archive/restore mutation this needs on the owning package: PUT /agent-definitions/:id/status writes only the row's lifecycle status, leaves the definition's asset and git history intact, refuses anything outside the schema's two states, and 404s for an unknown definition or a workbench host. --- apps/web/test/agent-detail-page.test.tsx | 436 ++++++++++++++++++ apps/web/test/routes.test.tsx | 11 +- packages/agent-directory/test/routes.test.ts | 104 ++++- .../agent-directory/test/validation.test.ts | 17 + 4 files changed, 565 insertions(+), 3 deletions(-) create mode 100644 apps/web/test/agent-detail-page.test.tsx diff --git a/apps/web/test/agent-detail-page.test.tsx b/apps/web/test/agent-detail-page.test.tsx new file mode 100644 index 000000000..15beb6871 --- /dev/null +++ b/apps/web/test/agent-detail-page.test.tsx @@ -0,0 +1,436 @@ +// The agent detail page (CL-6414) at `/agents/`: the identity card, +// the system-prompt editor, the skills section, recent runs, and the +// Duplicate/Archive/Save trio in the top bar's action slot. Mounted against +// a stubbed hub so every edit is asserted where it matters — on the request +// body that leaves the page, through the mutations +// `@corbits/agent-directory` already owns, never a write path of this +// page's own. + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { + AgentDetailPage, + duplicateHandle, + recentRunsForDefinition, +} from "../src/pages/agent-detail-page"; +import type { AgentDefinitionWithDisplayName } from "../src/agents-directory"; +import type { + AgentDefinitionDetail, + AgentInstance, + CatalogModel, +} from "../src/agents-api"; + +const definition: AgentDefinitionWithDisplayName = { + id: "wfd_1", + tenantId: "tnt_1", + name: "triage-bot", + displayName: "Triage bot", + description: "Triage bot", + currentVersion: "v1", + status: "deployed", + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-01T00:00:00.000Z", +}; + +const detail: AgentDefinitionDetail = { + name: "Triage bot", + systemPrompt: "You sort inbound issues.", + model: "claude-sonnet", + skills: ["triage"], +}; + +const models: readonly CatalogModel[] = [ + { + id: "mdl_1", + tenantId: "tnt_1", + canonicalName: "claude-sonnet", + displayName: "Claude Sonnet", + disabled: false, + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-01T00:00:00.000Z", + } as unknown as CatalogModel, + { + id: "mdl_2", + tenantId: "tnt_1", + canonicalName: "claude-opus", + displayName: "Claude Opus", + disabled: false, + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-01T00:00:00.000Z", + } as unknown as CatalogModel, +]; + +function run(overrides: Partial = {}): AgentInstance { + return { + id: "run_1", + definitionId: "wfd_1", + definitionName: "triage-bot", + tenantId: "tnt_1", + address: "triage-bot@example.test", + status: "running", + createdAt: "2026-08-10T09:00:00.000Z", + updatedAt: "2026-08-10T09:00:00.000Z", + ...overrides, + } as AgentInstance; +} + +const noop = () => undefined; + +function renderPage( + props: Partial[0]> = {}, +) { + return renderToStaticMarkup( + , + ); +} + +describe("recentRunsForDefinition", () => { + test("keeps only this definition's runs, newest first", () => { + const recent = recentRunsForDefinition( + [ + run({ id: "run_old", createdAt: "2026-08-01T00:00:00.000Z" }), + run({ id: "run_other", definitionId: "wfd_other" }), + run({ id: "run_new", createdAt: "2026-08-20T00:00:00.000Z" }), + ], + "wfd_1", + ); + expect(recent.map((entry) => entry.id)).toEqual(["run_new", "run_old"]); + }); +}); + +describe("duplicateHandle", () => { + test("suffixes the original slug, staying kebab-safe", () => { + expect(duplicateHandle("triage-bot")).toBe("triage-bot-copy"); + }); +}); + +describe("AgentDetailPage render", () => { + test("renders the slug immutably in mono beside the editable display name", () => { + const markup = renderPage(); + expect(markup).toContain('id="agent-display-name"'); + expect(markup).toContain('value="Triage bot"'); + expect(markup).toContain("font-mono"); + expect(markup).toContain("triage-bot"); + expect(markup).not.toContain('id="agent-slug"'); + }); + + test("crumbs read Agents → display name, linking back to the roster", () => { + const markup = renderPage(); + expect(markup).toContain('href="/agents"'); + expect(markup).toContain("Triage bot"); + }); + + test("puts Duplicate, Archive, and Save in the top bar's action slot", () => { + const markup = renderPage(); + const actions = + markup.split('data-testid="stage-top-bar-actions"')[1] ?? ""; + expect(actions).toContain('aria-label="Duplicate this agent"'); + expect(actions).toContain('aria-label="Archive this agent"'); + expect(actions).toContain('aria-label="Save this agent"'); + }); + + test("an archived agent offers Restore instead of Archive", () => { + const markup = renderPage({ + definition: { ...definition, status: "stopped" }, + }); + expect(markup).toContain('aria-label="Restore this agent"'); + expect(markup).not.toContain('aria-label="Archive this agent"'); + expect(markup).toContain("Archived"); + }); + + test("offers the full system prompt in an editor seeded from the definition", () => { + const markup = renderPage(); + expect(markup).toContain("System prompt"); + expect(markup).toContain("You sort inbound issues."); + }); + + test("lists the agent's own runs, each linking into the Insights run surface", () => { + const markup = renderPage(); + expect(markup).toContain('href="/insights/runs/run_1"'); + }); + + test("teaches what recent runs will hold when the agent has never run", () => { + const markup = renderPage({ runs: [] }); + expect(markup).toContain("No runs yet"); + }); + + test("offers no description field — a definition's description IS its display name", () => { + const markup = renderPage(); + expect(markup).not.toContain('id="agent-description"'); + }); +}); + +// --- Edits round-tripping through the API --- + +const realFetch = globalThis.fetch; +let requests: { method: string; url: string; body: unknown }[] = []; + +function json(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function stubFetch(): void { + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const body = + init?.body !== undefined ? JSON.parse(String(init.body)) : undefined; + requests.push({ method: init?.method ?? "GET", url, body }); + if (url.endsWith("/skills") && (init?.method ?? "GET") === "GET") { + return Promise.resolve(json({ skills: [] })); + } + if (url.includes("/skills?")) { + return Promise.resolve(json({ data: [], nextCursor: null })); + } + if (url.endsWith("/skills")) { + return Promise.resolve(json({ skills: ["triage"] })); + } + if (url.endsWith("/capabilities")) { + return Promise.resolve( + json({ skills: ["triage"], model: "claude-opus" }), + ); + } + if (url.endsWith("/status")) { + return Promise.resolve(json({ id: "wfd_1", status: "stopped" })); + } + if (url.endsWith("/agent-definitions")) { + return Promise.resolve( + json( + { + id: "wfd_2", + tenantId: "tnt_1", + name: "triage-bot-copy", + description: "Triage bot copy", + currentVersion: "v1", + status: "deployed", + createdAt: "2026-08-20T00:00:00.000Z", + updatedAt: "2026-08-20T00:00:00.000Z", + skills: ["triage"], + }, + 201, + ), + ); + } + return Promise.resolve( + json({ name: "Triage bot", systemPrompt: "You sort inbound issues." }), + ); + }) as typeof fetch; +} + +let container: HTMLDivElement | null = null; +let root: Root | null = null; + +beforeEach(() => { + requests = []; + stubFetch(); +}); + +afterEach(() => { + globalThis.fetch = realFetch; + if (root !== null) { + act(() => root?.unmount()); + root = null; + } + container?.remove(); + container = null; +}); + +async function mount( + overrides: Partial[0]> = {}, +) { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { + root?.render( + , + ); + }); + await act(async () => { + await Promise.resolve(); + }); + return container; +} + +function setValue( + element: HTMLInputElement | HTMLTextAreaElement, + value: string, +) { + const setter = Object.getOwnPropertyDescriptor( + element instanceof HTMLTextAreaElement + ? HTMLTextAreaElement.prototype + : HTMLInputElement.prototype, + "value", + )?.set; + setter?.call(element, value); + element.dispatchEvent(new Event("input", { bubbles: true })); +} + +function byLabel(node: HTMLElement, label: string): HTMLElement { + const found = node.querySelector(`[aria-label="${label}"]`); + if (found === null) throw new Error(`no element labelled "${label}"`); + return found; +} + +describe("AgentDetailPage edits", () => { + test("Save is inert until something actually changes", async () => { + const node = await mount(); + const save = byLabel(node, "Save this agent") as HTMLButtonElement; + expect(save.disabled).toBe(true); + }); + + test("a renamed agent with a rewritten prompt saves through the instructions route", async () => { + let saved = 0; + const node = await mount({ onSaved: () => (saved += 1) }); + const name = node.querySelector("#agent-display-name"); + const prompt = node.querySelector( + "#agent-system-prompt", + ); + if (name === null || prompt === null) throw new Error("editor not mounted"); + + await act(async () => { + setValue(name, "Inbox triage"); + setValue(prompt, "You sort inbound issues, briskly."); + }); + await act(async () => { + byLabel(node, "Save this agent").click(); + await Promise.resolve(); + }); + + const put = requests.find( + (request) => + request.method === "PUT" && + request.url === "/api/tenants/tnt_1/agent-definitions/wfd_1", + ); + expect(put?.body).toEqual({ + name: "Inbox triage", + systemPrompt: "You sort inbound issues, briskly.", + }); + expect(saved).toBe(1); + }); + + test("a changed default model saves through the guided capability route, not a new one", async () => { + const node = await mount(); + const select = node.querySelector("#agent-model"); + if (select === null) throw new Error("model select not mounted"); + + await act(async () => { + select.value = "claude-opus"; + select.dispatchEvent(new Event("change", { bubbles: true })); + }); + await act(async () => { + byLabel(node, "Save this agent").click(); + await Promise.resolve(); + }); + + const post = requests.find((request) => + request.url.endsWith("/agent-definitions/wfd_1/capabilities"), + ); + expect(post?.body).toEqual({ kind: "model", canonicalName: "claude-opus" }); + // An untouched field is never rewritten. + expect( + requests.some( + (request) => + request.url === "/api/tenants/tnt_1/agent-definitions/wfd_1" && + request.method === "PUT", + ), + ).toBe(false); + }); + + test("Duplicate creates a second definition from this one's authored state", async () => { + const duplicated: string[] = []; + const node = await mount({ + onDuplicated: (slug: string) => { + duplicated.push(slug); + }, + }); + await act(async () => { + byLabel(node, "Duplicate this agent").click(); + await Promise.resolve(); + }); + + const post = requests.find( + (request) => request.url === "/api/tenants/tnt_1/agent-definitions", + ); + expect(post?.body).toEqual({ + name: "Triage bot copy", + handle: "triage-bot-copy", + systemPrompt: "You sort inbound issues.", + model: "claude-sonnet", + skills: ["triage"], + }); + expect(duplicated).toEqual(["triage-bot-copy"]); + }); + + test("Archive takes two clicks and writes the stopped status, never a delete", async () => { + let changed = 0; + const node = await mount({ onStatusChanged: () => (changed += 1) }); + const archive = byLabel(node, "Archive this agent"); + + await act(async () => { + archive.click(); + }); + expect(requests.some((request) => request.url.endsWith("/status"))).toBe( + false, + ); + + await act(async () => { + archive.click(); + await Promise.resolve(); + }); + + const put = requests.find((request) => request.url.endsWith("/status")); + expect(put?.method).toBe("PUT"); + expect(put?.body).toEqual({ status: "stopped" }); + expect(requests.some((request) => request.method === "DELETE")).toBe(false); + expect(changed).toBe(1); + }); + + test("a failed save says so and keeps the person's edit", async () => { + const node = await mount(); + globalThis.fetch = (() => + Promise.resolve( + json({ error: { message: "The agent is locked." } }, 500), + )) as unknown as typeof fetch; + const prompt = node.querySelector( + "#agent-system-prompt", + ); + if (prompt === null) throw new Error("editor not mounted"); + + await act(async () => { + setValue(prompt, "New instructions."); + }); + await act(async () => { + byLabel(node, "Save this agent").click(); + await Promise.resolve(); + }); + + expect(node.textContent).toContain("saving this agent"); + expect(prompt.value).toBe("New instructions."); + }); +}); diff --git a/apps/web/test/routes.test.tsx b/apps/web/test/routes.test.tsx index e04e7cb3a..f58563cdb 100644 --- a/apps/web/test/routes.test.tsx +++ b/apps/web/test/routes.test.tsx @@ -343,8 +343,17 @@ describe("routes render", () => { }); } + // Agents is the one detail route whose real screen has landed (CL-6414), + // so it titles itself with the slug and lights its roster row without a + // placeholder's "Back to" affordance. + test("/agents/ titles the agent's own page with its roster row lit", async () => { + const markup = await renderApp("/agents/triage-bot"); + expect(stagePageTitle(markup)).toBe("triage-bot"); + expect(activeFooterLabel(markup)).toBe("Agents"); + expect(markup).not.toContain("still being built"); + }); + test.each([ - ["/agents/triage-bot", "triage-bot", "Agents"], ["/skills/pr-review", "pr-review", "Skills"], ["/plugins/linear", "linear", "Plugins"], ["/routines/weekly-digest", "weekly-digest", "Routines"], diff --git a/packages/agent-directory/test/routes.test.ts b/packages/agent-directory/test/routes.test.ts index 6022ce13a..ed5457f75 100644 --- a/packages/agent-directory/test/routes.test.ts +++ b/packages/agent-directory/test/routes.test.ts @@ -669,7 +669,12 @@ test("PUT /:definitionId/skills rejects a blank skill name with a 400", async () function fakeInstructionsDb( row: { id: string; assetId: string | null; name: string } | undefined, options: { readonly failSecondUpdate?: boolean } = {}, -): DB["db"] & { readonly updateCalls: readonly unknown[] } { +): DB["db"] & { + readonly updateCalls: readonly unknown[]; + /** `.set(...)` calls made straight on `db.update`, outside a + * transaction — what the status route writes. */ + readonly directUpdateCalls: readonly unknown[]; +} { const updateCalls: unknown[] = []; const committedUpdateCalls: unknown[] = []; const makeUpdater = (target: unknown[]) => () => ({ @@ -708,7 +713,11 @@ function fakeInstructionsDb( updateCalls.push(...txCalls); }, updateCalls, - } as unknown as DB["db"] & { readonly updateCalls: readonly unknown[] }; + directUpdateCalls: committedUpdateCalls, + } as unknown as DB["db"] & { + readonly updateCalls: readonly unknown[]; + readonly directUpdateCalls: readonly unknown[]; + }; } test("GET /:definitionId returns the agent's display name and system prompt", async () => { @@ -1010,6 +1019,97 @@ test("PUT /:definitionId updates the definition's row and its asset's row togeth expect(failingDb.updateCalls).toEqual([]); }); +// --- PUT /:definitionId/status (archive and restore) --- + +test("archiving a definition writes the stopped status and touches nothing else", async () => { + const db = fakeInstructionsDb({ + id: "def_1", + assetId: "ast_1", + name: "research-buddy", + }); + let populateCalled = false; + const app = buildApp( + fakeAssetService({ + populateAsset: () => { + populateCalled = true; + return Promise.resolve({ commitSha: "deadbeef" }); + }, + }), + db, + ); + const response = await put(app, "/def_1/status", { status: "stopped" }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ id: "def_1", status: "stopped" }); + expect(db.directUpdateCalls).toEqual([ + { status: "stopped", updatedAt: expect.any(Date) }, + ]); + // Archiving is a row-status change: the definition's asset and its git + // history are never rewritten, which is what makes a restore possible. + expect(populateCalled).toBe(false); +}); + +test("restoring a definition writes the deployed status back", async () => { + const db = fakeInstructionsDb({ + id: "def_1", + assetId: "ast_1", + name: "research-buddy", + }); + const app = buildApp(fakeAssetService(), db); + const response = await put(app, "/def_1/status", { status: "deployed" }); + expect(response.status).toBe(200); + expect(db.directUpdateCalls).toEqual([ + { status: "deployed", updatedAt: expect.any(Date) }, + ]); +}); + +test("a status outside the schema's two lifecycle states is a 400, never written", async () => { + const db = fakeInstructionsDb({ + id: "def_1", + assetId: "ast_1", + name: "research-buddy", + }); + const app = buildApp(fakeAssetService(), db); + const response = await put(app, "/def_1/status", { status: "deleted" }); + expect(response.status).toBe(400); + expect(db.directUpdateCalls).toEqual([]); +}); + +test("status 404s for an unknown definition and for a workbench host", async () => { + const unknown = buildApp(fakeAssetService(), fakeInstructionsDb(undefined)); + expect( + (await put(unknown, "/def_missing/status", { status: "stopped" })).status, + ).toBe(404); + + const host = buildApp( + fakeAssetService(), + fakeInstructionsDb({ + id: "def_host", + assetId: "ast_host", + name: `run-${"a".repeat(32)}`, + }), + ); + expect( + (await put(host, "/def_host/status", { status: "stopped" })).status, + ).toBe(404); +}); + +test("status scopes its grant check per definition id and requires update", async () => { + const requireGrant = capturingRequireGrant(); + const app = buildApp( + fakeAssetService(), + fakeInstructionsDb({ + id: "def_1", + assetId: "ast_1", + name: "research-buddy", + }), + requireGrant, + ); + await put(app, "/def_1/status", { status: "stopped" }); + expect(requireGrant.calls).toEqual([ + { resource: "workflow-definition:def_1", action: "update" }, + ]); +}); + test("a create request indexes its pinned skills into the stored system prompt", async () => { let writtenFiles: Record | undefined; const app = buildApp( diff --git a/packages/agent-directory/test/validation.test.ts b/packages/agent-directory/test/validation.test.ts index 6d437d85d..83d7c7a5a 100644 --- a/packages/agent-directory/test/validation.test.ts +++ b/packages/agent-directory/test/validation.test.ts @@ -4,6 +4,7 @@ import { type } from "arktype"; import { CreateAgentDefinitionInput, UpdateAgentSkillsInput, + UpdateDefinitionStatusInput, } from "../src/validation"; const VALID = { @@ -141,3 +142,19 @@ test("UpdateAgentSkillsInput rejects a duplicate skill name", () => { }); expect(result instanceof type.errors).toBe(true); }); + +test("UpdateDefinitionStatusInput accepts the two lifecycle states", () => { + expect( + UpdateDefinitionStatusInput({ status: "stopped" }) instanceof type.errors, + ).toBe(false); + expect( + UpdateDefinitionStatusInput({ status: "deployed" }) instanceof type.errors, + ).toBe(false); +}); + +test("UpdateDefinitionStatusInput rejects any other status, deletion included", () => { + expect( + UpdateDefinitionStatusInput({ status: "deleted" }) instanceof type.errors, + ).toBe(true); + expect(UpdateDefinitionStatusInput({}) instanceof type.errors).toBe(true); +}); From eb09750cf7bd3d373eaa1c3093ba36122db16053 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 20 Aug 2026 15:45:35 -0700 Subject: [PATCH 2/5] Agents: full detail page at /agents/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the agent placeholder with the real screen: an identity card (editable display name, immutable slug in muted mono, lifecycle state), a default-model select over the bench's catalog, a full system-prompt editor, the pinned-skills picker, and recent runs linking into the Insights run surface. Duplicate, Archive/Restore, and Save live in the StageTopBar action slot, with crumbs Agents -> display name. Every write reuses a mutation that already existed: display name and system prompt through PUT /agent-definitions/:id, the model through the guided capability route, skills through PUT .../skills, and a duplicate through the same POST /agent-definitions the create panel uses. One Save writes only the dirty parts. Archive is the one lifecycle verb that had no route, so it lands in @corbits/agent-directory as PUT /agent-definitions/:id/status: it moves the row between `deployed` and `stopped` and touches nothing else, which is what makes restoring it a single write back rather than a re-create. Delete stays out — tearing down a definition's asset and history has no route, and archiving is the reversible lifecycle the platform backs. A description field stays out too: a definition's row description IS its display name, and the purpose blurb inside its workflow.json has neither a read nor a write route today. --- apps/web/src/agents-api.ts | 76 +++ apps/web/src/pages/agent-detail-page.tsx | 569 +++++++++++++++++++++ apps/web/src/pages/detail-placeholders.tsx | 15 +- apps/web/src/routes.tsx | 11 +- packages/agent-directory/src/index.ts | 2 + packages/agent-directory/src/routes.ts | 47 ++ packages/agent-directory/src/validation.ts | 12 + 7 files changed, 714 insertions(+), 18 deletions(-) create mode 100644 apps/web/src/pages/agent-detail-page.tsx diff --git a/apps/web/src/agents-api.ts b/apps/web/src/agents-api.ts index 739031913..3300f1c06 100644 --- a/apps/web/src/agents-api.ts +++ b/apps/web/src/agents-api.ts @@ -245,6 +245,82 @@ export function getAgentCapabilities( ); } +const AgentDefinitionDetailResponse = type({ + name: "string", + systemPrompt: "string", + "model?": "string", + skills: "string[]", +}); +export type AgentDefinitionDetail = typeof AgentDefinitionDetailResponse.infer; + +/** Everything the agent detail page edits, read from the one route that + * owns a definition's authored state (`GET /agent-definitions/:id`): its + * display name, its system prompt, its pinned skills, and the model it + * resolves against. `name` here is the display name the definition's row + * carries, never its immutable slug. */ +export function getAgentDefinitionDetail( + tenantId: string, + definitionId: string, +): Promise { + return getJSON( + `/api/tenants/${tenantId}/agent-definitions/${encodeURIComponent(definitionId)}`, + AgentDefinitionDetailResponse, + ); +} + +/** Replaces a definition's display name and system prompt in one write — + * the same route the per-workbench Assistant editor saves through, never + * a second write path of this page's own. */ +export function updateAgentInstructions( + tenantId: string, + definitionId: string, + input: { readonly name: string; readonly systemPrompt: string }, +): Promise<{ readonly name: string; readonly systemPrompt: string }> { + return postJSON( + `/api/tenants/${tenantId}/agent-definitions/${encodeURIComponent(definitionId)}`, + type({ name: "string", systemPrompt: "string" }), + input, + "PUT", + ); +} + +const AgentCapabilitiesWriteResponse = type({ + skills: "string[]", + "model?": "string", +}); + +/** Sets the model a definition resolves against, through the guided + * capability-add route — which re-checks the name against the tenant's + * live catalog, so a model this bench cannot actually reach is refused + * rather than written. */ +export function setAgentModel( + tenantId: string, + definitionId: string, + canonicalName: string, +): Promise<{ readonly model?: string }> { + return postJSON( + `/api/tenants/${tenantId}/agent-definitions/${encodeURIComponent(definitionId)}/capabilities`, + AgentCapabilitiesWriteResponse, + { kind: "model", canonicalName }, + ); +} + +/** Archives (`stopped`) or restores (`deployed`) a definition. Nothing is + * deleted either way — an archived agent keeps its row, its asset, and its + * history, and simply stops appearing anywhere a person can launch it. */ +export function setAgentDefinitionStatus( + tenantId: string, + definitionId: string, + status: "deployed" | "stopped", +): Promise<{ readonly status: string }> { + return postJSON( + `/api/tenants/${tenantId}/agent-definitions/${encodeURIComponent(definitionId)}/status`, + type({ id: "string", status: "string" }), + { status }, + "PUT", + ); +} + const DefinitionSkillsMap = type({ skills: { "[string]": "string[]" } }); /** Every attached-skill list for the given definitions, keyed by definition diff --git a/apps/web/src/pages/agent-detail-page.tsx b/apps/web/src/pages/agent-detail-page.tsx new file mode 100644 index 000000000..816560e8c --- /dev/null +++ b/apps/web/src/pages/agent-detail-page.tsx @@ -0,0 +1,569 @@ +// The agent's own page (CL-6414), addressed by its immutable slug — +// `/agents/`. Everything a person can author about an agent lives +// here: its display name, the model it resolves against, the system prompt +// it follows on every turn, the skills it has pinned, and the runs it has +// produced. It replaces the roster's quick-peek panel, which had outgrown +// being a panel (DESIGN.md, "Detail Pages"). +// +// Every write goes through a mutation that already existed +// (`@corbits/agent-directory`'s routes, via `../agents-api.ts`) — this page +// invents no write path of its own: +// +// display name + system prompt PUT /agent-definitions/:id +// default model POST /agent-definitions/:id/capabilities +// pinned skills PUT /agent-definitions/:id/skills +// archive / restore PUT /agent-definitions/:id/status +// duplicate POST /agent-definitions +// +// One Save writes every dirty part, in that order, and nothing else — an +// untouched field is never rewritten. Two fields a person might expect are +// deliberately absent: the slug (immutable by design, so it renders as +// muted mono text rather than an input) and a separate description. A +// definition's row `description` IS its display name — that is where +// `deriveDisplayName` reads it from — and the purpose blurb inside the +// definition's own `workflow.json` has neither a read nor a write route +// today, so this page shows no description field rather than a control +// that silently edits the display name twice. Delete is likewise absent: +// archiving is the reversible lifecycle the platform actually backs, and +// tearing down a definition's asset and history has no route. + +import { + Badge, + Button, + Card, + ConfirmButton, + PageShell, + Section, + Select, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Textarea, +} from "@corbits/react-ui"; +import type { BadgeTone } from "@corbits/react-ui"; +import { Copy, Robot } from "@corbits/icons"; +import { useEffect, useState, type ReactNode } from "react"; +import { useQueryClient } from "@tanstack/react-query"; + +import { describeApiError, QueryView } from "@corbits/api-query"; +import { RichEmptyState } from "@corbits/react-ui"; + +import type { + AgentDefinitionDetail, + AgentInstance, + CatalogModel, +} from "../agents-api"; +import { + getAgentDefinitionDetail, + useAgentDirectory, + createAgentDefinition, + setAgentDefinitionStatus, + setAgentModel, + updateAgentInstructions, + updateAgentSkills, +} from "../agents-api"; +import type { AgentDefinitionWithDisplayName } from "../agents-directory"; +import { purposeAgentDefinitions } from "../agents-directory"; +import { useBench } from "../bench-context"; +import { runDetailPath } from "../insights-deeplinks"; +import { Link } from "../navigation"; +import { AGENTS_PATH_PREFIX } from "../path-ids"; +import { tenantKeys } from "../query-client"; +import { StageTopBar } from "../shell/stage-top-bar"; +import { AgentSkillsPicker } from "./agent-skills-picker"; +import { slugify } from "./create-agent-panel"; + +const STATUS_TONE: Record<"deployed" | "stopped", BadgeTone> = { + deployed: "success", + stopped: "neutral", +}; + +const RUN_STATUS_TONE: Record = { + deployed: "success", + running: "success", + updating: "warning", + error: "danger", + stopped: "neutral", +}; + +/** How many of a definition's runs the page lists. Recent history, not a + * runs browser — Insights owns that surface, and every row here links + * into it. */ +const RECENT_RUN_LIMIT = 10; + +function sameSkillSet(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((name) => b.includes(name)); +} + +/** The runs to show for a definition: its own, newest first, capped. */ +export function recentRunsForDefinition( + runs: readonly AgentInstance[], + definitionId: string, +): readonly AgentInstance[] { + return runs + .filter((run) => run.definitionId === definitionId) + .toSorted((left, right) => right.createdAt.localeCompare(left.createdAt)) + .slice(0, RECENT_RUN_LIMIT); +} + +/** The handle a duplicate is created under: the original's slug plus a + * `-copy` suffix, kebab-safe. A second duplicate collides on the handle + * and surfaces the route's own 409 rather than guessing `-copy-2`. */ +export function duplicateHandle(slug: string): string { + return slugify(`${slug}-copy`); +} + +type WriteState = + | { readonly kind: "idle" } + | { readonly kind: "busy" } + | { readonly kind: "error"; readonly message: string }; + +export function AgentDetailPage({ + tenantId, + definition, + detail, + models, + runs, + onSaved, + onDuplicated, + onStatusChanged, +}: { + readonly tenantId: string; + readonly definition: AgentDefinitionWithDisplayName; + readonly detail: AgentDefinitionDetail; + readonly models: readonly CatalogModel[]; + readonly runs: readonly AgentInstance[]; + readonly onSaved: () => void; + readonly onDuplicated: (slug: string) => void; + readonly onStatusChanged: () => void; +}) { + const [displayName, setDisplayName] = useState(detail.name); + const [systemPrompt, setSystemPrompt] = useState(detail.systemPrompt); + const [model, setModel] = useState(detail.model ?? ""); + const [skills, setSkills] = useState(detail.skills); + const [save, setSave] = useState({ kind: "idle" }); + const [lifecycle, setLifecycle] = useState({ kind: "idle" }); + + const archived = definition.status === "stopped"; + const trimmedName = displayName.trim(); + const trimmedPrompt = systemPrompt.trim(); + const instructionsDirty = + trimmedName !== detail.name || trimmedPrompt !== detail.systemPrompt; + const modelDirty = model !== "" && model !== (detail.model ?? ""); + const skillsDirty = !sameSkillSet(skills, detail.skills); + const dirty = instructionsDirty || modelDirty || skillsDirty; + const saveable = + dirty && trimmedName !== "" && trimmedPrompt !== "" && save.kind !== "busy"; + + async function onSave() { + setSave({ kind: "busy" }); + try { + if (instructionsDirty) { + await updateAgentInstructions(tenantId, definition.id, { + name: trimmedName, + systemPrompt: trimmedPrompt, + }); + } + if (modelDirty) { + await setAgentModel(tenantId, definition.id, model); + } + if (skillsDirty) { + await updateAgentSkills(tenantId, definition.id, [...skills]); + } + setSave({ kind: "idle" }); + onSaved(); + } catch (cause: unknown) { + setSave({ + kind: "error", + message: describeApiError(cause, "saving this agent"), + }); + } + } + + async function onDuplicate() { + setLifecycle({ kind: "busy" }); + try { + const handle = duplicateHandle(definition.name); + await createAgentDefinition(tenantId, { + name: `${detail.name} copy`, + handle, + systemPrompt: detail.systemPrompt, + ...(detail.model !== undefined ? { model: detail.model } : {}), + skills: [...detail.skills], + }); + setLifecycle({ kind: "idle" }); + onDuplicated(handle); + } catch (cause: unknown) { + setLifecycle({ + kind: "error", + message: describeApiError(cause, "duplicating this agent"), + }); + } + } + + async function onToggleArchived() { + setLifecycle({ kind: "busy" }); + try { + await setAgentDefinitionStatus( + tenantId, + definition.id, + archived ? "deployed" : "stopped", + ); + setLifecycle({ kind: "idle" }); + onStatusChanged(); + } catch (cause: unknown) { + setLifecycle({ + kind: "error", + message: describeApiError( + cause, + archived ? "restoring this agent" : "archiving this agent", + ), + }); + } + } + + const recent = recentRunsForDefinition(runs, definition.id); + + return ( +
+ + + void onToggleArchived()} + aria-label={ + archived ? "Restore this agent" : "Archive this agent" + } + > + {archived ? "Restore" : "Archive"} + + + + } + /> +
+ +
+ {save.kind === "error" ? ( +

+ {save.message} +

+ ) : null} + {lifecycle.kind === "error" ? ( +

+ {lifecycle.message} +

+ ) : null} + + +
+ + setDisplayName(event.target.value)} + /> +

+ {definition.name} +

+

+ The handle above is this agent's address and its URL. It + never changes. +

+
+
+ + {archived ? "Archived" : "Active"} + + + {archived + ? "Archived — nobody can start a new chat with it until it is restored." + : "Active — anyone in this bench can start a chat with it."} + +
+
+ + {models.length === 0 ? ( +

+ No models in this bench's catalog yet — connect a + provider in Settings and this agent can pick one. +

+ ) : ( + + )} +
+
+ +
+