From 041b993939214558ab8cf76816dc8a298fc7ee00 Mon Sep 17 00:00:00 2001 From: Jayesh Savaliya Date: Tue, 25 Aug 2026 16:07:01 +0530 Subject: [PATCH 1/2] feat(ui): add agent deletion controls --- .changeset/fresh-agents-delete.md | 5 + docs/ui-sdk/reference/server.mdx | 4 +- .../setup-custom-servers/custom-server.mdx | 4 +- .../docs/createTrueFoundryServer.temp.ts | 7 +- packages/trueforge-ui/docs/server.md | 18 +- .../trueforge-ui/src/atoms/AgentsLibrary.tsx | 239 +++++++++++++----- .../builderServer.ts | 9 + .../src/server/createTrueFoundryServer.ts | 8 +- .../test/atoms/AgentsLibrary.test.tsx | 89 +++++++ .../harnessBuilderServer.test.ts | 44 ++++ .../server/createTrueFoundryServer.test.ts | 25 ++ 11 files changed, 367 insertions(+), 85 deletions(-) create mode 100644 .changeset/fresh-agents-delete.md diff --git a/.changeset/fresh-agents-delete.md b/.changeset/fresh-agents-delete.md new file mode 100644 index 000000000..4971be7f6 --- /dev/null +++ b/.changeset/fresh-agents-delete.md @@ -0,0 +1,5 @@ +--- +'@truefoundry/trueforge-ui': minor +--- + +Add deletion controls to the Agents Library and wire the bundled TrueForge adapter to the existing agent delete API. diff --git a/docs/ui-sdk/reference/server.mdx b/docs/ui-sdk/reference/server.mdx index 77e561986..077695730 100644 --- a/docs/ui-sdk/reference/server.mdx +++ b/docs/ui-sdk/reference/server.mdx @@ -11,8 +11,8 @@ createTrueFoundryServer(options: CreateTrueFoundryServerOptions): TrueFoundrySer Options: `{ chatServer, getCapabilities, getModels, getSkills, getMcp, searchAgents, saveAgent, deleteAgent?, catalog? }`. Combines a chat server with your agent-management callbacks into a complete `AgentUIServer`. -`deleteAgent` and `catalog` are the only optional entries; if you omit `deleteAgent`, calling it -throws. +`deleteAgent` and `catalog` are the only optional entries; if you omit `deleteAgent`, the Agents +Library hides its delete controls. ## Context diff --git a/docs/ui-sdk/setup-custom-servers/custom-server.mdx b/docs/ui-sdk/setup-custom-servers/custom-server.mdx index 5b08e1f6b..fd8745398 100644 --- a/docs/ui-sdk/setup-custom-servers/custom-server.mdx +++ b/docs/ui-sdk/setup-custom-servers/custom-server.mdx @@ -167,7 +167,7 @@ Spreading `nextPageToken` in only when present keeps the result valid under `exa - In a hand-written `AgentUIServer` like the one above, omitting these leaves the corresponding affordances unavailable rather than broken. (This differs from the [`createTrueFoundryServer` composition helper](/ui-sdk/setup-custom-servers/connect-with-truefoundry#truefoundry-chat-your-own-builder), where an omitted `deleteAgent` throws when called.) + Omitting these leaves the corresponding affordances unavailable rather than broken, including when using the [`createTrueFoundryServer` composition helper](/ui-sdk/setup-custom-servers/connect-with-truefoundry#truefoundry-chat-your-own-builder). @@ -208,4 +208,4 @@ Attachment handling defaults to `trueFoundryAttachmentAdapter`. Override it thro ```tsx -``` \ No newline at end of file +``` diff --git a/packages/trueforge-ui/docs/createTrueFoundryServer.temp.ts b/packages/trueforge-ui/docs/createTrueFoundryServer.temp.ts index 258c61e13..021846a2c 100644 --- a/packages/trueforge-ui/docs/createTrueFoundryServer.temp.ts +++ b/packages/trueforge-ui/docs/createTrueFoundryServer.temp.ts @@ -279,12 +279,7 @@ export function createTrueFoundryServer< getMcp: opts.getMcp, searchAgents: opts.searchAgents, saveAgent: opts.saveAgent, - async deleteAgent(req: { agentName: string }) { - if (!opts.deleteAgent) { - throw new Error('deleteAgent is host-owned. Pass deleteAgent to createTrueFoundryServer.'); - } - await opts.deleteAgent(req); - }, + ...(opts.deleteAgent !== undefined ? { deleteAgent: opts.deleteAgent } : {}), getGatewayClients: () => ({ client, privateClient }), }; diff --git a/packages/trueforge-ui/docs/server.md b/packages/trueforge-ui/docs/server.md index 392688a92..1f9625aaa 100644 --- a/packages/trueforge-ui/docs/server.md +++ b/packages/trueforge-ui/docs/server.md @@ -562,13 +562,14 @@ const server = createTrueFoundryServer({ ### Catalog — complete for this UI -| UI call | Required response fields | -| --------------------------- | ---------------------------------------------------------- | -| `getModels()` | `name`, `provider`, `apiModel`, `modelId` | -| `getSkills()` | `id`, `name`; `fqn?`, `description?` | -| `getMcp()` | `id`, `name`; `description?` | -| `searchAgents(req?)` | `name`; optional display fields on `AgentLibraryEntry` | -| `saveAgent` / `deleteAgent` | On port; **not called by UI yet** (optional until Save UI) | +| UI call | Required response fields | +| -------------------- | --------------------------------------------------------- | +| `getModels()` | `name`, `provider`, `apiModel`, `modelId` | +| `getSkills()` | `id`, `name`; `fqn?`, `description?` | +| `getMcp()` | `id`, `name`; `description?` | +| `searchAgents(req?)` | `name`; optional display fields on `AgentLibraryEntry` | +| `saveAgent` | Called by the Save Agent flow | +| `deleteAgent` | Called from the Agents Library when the method is present | ### Chat — method list complete; standalone BYO is not @@ -588,8 +589,7 @@ const server = createTrueFoundryServer({ enabled. **On the port but unused by this UI today:** -`listOwnedSessions`, non-stream `createTurn`, `deleteSession`, -`saveAgent`, `deleteAgent`. +`listOwnedSessions`, non-stream `createTurn`. ### v1 BYO guidance diff --git a/packages/trueforge-ui/src/atoms/AgentsLibrary.tsx b/packages/trueforge-ui/src/atoms/AgentsLibrary.tsx index e0a1755d3..50551e366 100644 --- a/packages/trueforge-ui/src/atoms/AgentsLibrary.tsx +++ b/packages/trueforge-ui/src/atoms/AgentsLibrary.tsx @@ -2,9 +2,12 @@ import { useEffect, useState } from 'react'; +import { useToasterOptional } from '../containers/ToasterContainer.js'; import { Icon } from '../icons/Icon.js'; +import { useOptionalServer } from '../server/ServerContext.js'; import { libraryAgentId, useOptionalShellMode } from '../server/ShellModeContext.js'; import type { AgentLibraryEntry, AgentSpec } from '../server/types.js'; +import { getErrorMessage } from '../utils/getErrorMessage.js'; import { auiButtonClass } from './lib/buttonClasses.js'; import { cn } from './lib/cn.js'; import { useSearchAgentsList } from './lib/useSearchAgentsList.js'; @@ -22,8 +25,10 @@ export type AgentsLibraryProps = { type AgentLibraryRowProps = { agent: AgentLibraryEntry; showEdit: boolean; + showDelete: boolean; onTry: () => void; onEdit: () => void; + onDelete: () => void; }; /** Short label for model fqns like `provider/gpt-4.1` → `gpt-4.1`. */ @@ -32,7 +37,7 @@ function displayModelLabel(modelName: string): string { return slash >= 0 ? modelName.slice(slash + 1) : modelName; } -function AgentLibraryRow({ agent, showEdit, onTry, onEdit }: AgentLibraryRowProps) { +function AgentLibraryRow({ agent, showEdit, showDelete, onTry, onEdit, onDelete }: AgentLibraryRowProps) { const spec = agent.agentSpec; const modelName = spec?.model.name; const skillsCount = spec?.skills?.length ?? 0; @@ -80,6 +85,21 @@ function AgentLibraryRow({ agent, showEdit, onTry, onEdit }: AgentLibraryRowProp ) : null} + {showDelete ? ( + + ) : null} {showEdit ? ( + + - - + + ); } diff --git a/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/builderServer.ts b/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/builderServer.ts index 26055dca7..cbad74b59 100644 --- a/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/builderServer.ts +++ b/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/builderServer.ts @@ -111,5 +111,14 @@ export function createHarnessBuilderServer( const created = await client.agents.create({ name: agentName, manifest }); return { agentId: created.data.id }; }, + + async deleteAgent({ agentName }) { + const { data } = await client.agents.list(); + const existing = data.find(agent => agent.name === agentName); + if (existing === undefined) { + return; + } + await client.agents.delete(existing.id); + }, }; } diff --git a/packages/trueforge-ui/src/server/createTrueFoundryServer.ts b/packages/trueforge-ui/src/server/createTrueFoundryServer.ts index 96d192f32..c9d0bbfc1 100644 --- a/packages/trueforge-ui/src/server/createTrueFoundryServer.ts +++ b/packages/trueforge-ui/src/server/createTrueFoundryServer.ts @@ -74,13 +74,7 @@ export function createTrueFoundryServer< getMcp: opts.getMcp, searchAgents: opts.searchAgents, saveAgent: opts.saveAgent, - deleteAgent: async req => { - if (opts.deleteAgent) { - await opts.deleteAgent(req); - return; - } - throw new Error('deleteAgent is host-owned. Pass deleteAgent to createTrueFoundryServer.'); - }, + ...(opts.deleteAgent !== undefined ? { deleteAgent: opts.deleteAgent } : {}), }; const server: TrueFoundryServer = { diff --git a/packages/trueforge-ui/test/atoms/AgentsLibrary.test.tsx b/packages/trueforge-ui/test/atoms/AgentsLibrary.test.tsx index e82a6ccdb..27d384248 100644 --- a/packages/trueforge-ui/test/atoms/AgentsLibrary.test.tsx +++ b/packages/trueforge-ui/test/atoms/AgentsLibrary.test.tsx @@ -183,6 +183,95 @@ describe('AgentsLibrary', () => { expect(screen.queryByRole('button', { name: 'Edit agent writer' })).not.toBeInTheDocument(); }); + it('hides Delete when the server does not support deleting agents', async () => { + const server = mockServer([{ name: 'writer', agentId: 'writer-id' }]); + + render( + + + + undefined} /> + + + , + ); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Try agent writer' })).toBeInTheDocument(); + }); + expect(screen.queryByRole('button', { name: 'Delete agent writer' })).not.toBeInTheDocument(); + }); + + it('confirms deletion, calls the server port, and refreshes the library', async () => { + const searchAgents = vi + .fn() + .mockResolvedValueOnce([{ name: 'writer', agentId: 'writer-id' }]) + .mockResolvedValueOnce([]); + const deleteAgent = vi.fn(async () => {}); + const server = createMockAgentUIServer({ searchAgents, deleteAgent }); + + render( + + + + undefined} /> + + + , + ); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Delete agent writer' })).toBeInTheDocument(); + }); + fireEvent.click(screen.getByRole('button', { name: 'Delete agent writer' })); + + expect(screen.getByRole('dialog', { name: 'Delete writer?' })).toBeInTheDocument(); + expect(screen.getByText('Existing chats will stay in your history.', { exact: false })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Delete agent' })); + + await waitFor(() => { + expect(deleteAgent).toHaveBeenCalledWith({ agentName: 'writer' }); + expect(searchAgents).toHaveBeenCalledTimes(2); + }); + expect(screen.queryByRole('button', { name: 'Delete agent writer' })).not.toBeInTheDocument(); + }); + + it('allows cancellation and keeps a failed deletion available for retry', async () => { + const deleteAgent = vi.fn(async () => { + throw new Error('Delete request failed'); + }); + const server = createMockAgentUIServer({ + searchAgents: vi.fn(async () => [{ name: 'writer', agentId: 'writer-id' }]), + deleteAgent, + }); + + render( + + + + undefined} /> + + + , + ); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Delete agent writer' })).toBeInTheDocument(); + }); + fireEvent.click(screen.getByRole('button', { name: 'Delete agent writer' })); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(deleteAgent).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: 'Delete agent writer' })); + fireEvent.click(screen.getByRole('button', { name: 'Delete agent' })); + + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent('Delete request failed'); + }); + expect(screen.getByRole('dialog', { name: 'Delete writer?' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Delete agent writer' })).toBeInTheDocument(); + }); + it('shows create-one guidance when there are no agents yet', async () => { const server = mockServer([]); diff --git a/packages/trueforge-ui/test/plugins/trueforge-agent-server-adapter/harnessBuilderServer.test.ts b/packages/trueforge-ui/test/plugins/trueforge-agent-server-adapter/harnessBuilderServer.test.ts index 433b8608a..73f03c2a0 100644 --- a/packages/trueforge-ui/test/plugins/trueforge-agent-server-adapter/harnessBuilderServer.test.ts +++ b/packages/trueforge-ui/test/plugins/trueforge-agent-server-adapter/harnessBuilderServer.test.ts @@ -300,4 +300,48 @@ describe('harnessBuilderServer', () => { }, }); }); + + it('deleteAgent resolves the immutable id and uses the agents delete route', async () => { + const requests: { method: string; url: string }[] = []; + const fetchMock: typeof fetch = async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + const method = init?.method ?? 'GET'; + if (url.endsWith('/api/v1/agents') && method === 'GET') { + return Response.json({ + data: [{ id: 'agt_1', name: 'writer', manifest: { model: { name: 'test/model' } } }], + }); + } + if (url.endsWith('/api/v1/agents/agt_1') && method === 'DELETE') { + requests.push({ method, url }); + return Response.json({}); + } + return new Response(`Unexpected request: ${method} ${url}`, { status: 500 }); + }; + + const builder = createHarnessBuilderServer({ fetch: fetchMock }); + if (builder.deleteAgent === undefined) { + throw new Error('Expected deleteAgent to be implemented'); + } + await builder.deleteAgent({ agentName: 'writer' }); + + assert.equal(requests.length, 1); + assert.match(requests[0]?.url ?? '', /\/api\/v1\/agents\/agt_1$/); + }); + + it('deleteAgent is idempotent when the agent name is already absent', async () => { + const fetchMock: typeof fetch = async input => { + const url = input instanceof Request ? input.url : String(input); + if (url.endsWith('/api/v1/agents')) { + return Response.json({ data: [] }); + } + return new Response(`Unexpected request: ${url}`, { status: 500 }); + }; + + const builder = createHarnessBuilderServer({ fetch: fetchMock }); + if (builder.deleteAgent === undefined) { + throw new Error('Expected deleteAgent to be implemented'); + } + + await assert.doesNotReject(builder.deleteAgent({ agentName: 'already-gone' })); + }); }); diff --git a/packages/trueforge-ui/test/server/createTrueFoundryServer.test.ts b/packages/trueforge-ui/test/server/createTrueFoundryServer.test.ts index a40c26614..6563eb939 100644 --- a/packages/trueforge-ui/test/server/createTrueFoundryServer.test.ts +++ b/packages/trueforge-ui/test/server/createTrueFoundryServer.test.ts @@ -49,6 +49,7 @@ describe('createTrueFoundryServer', () => { expect(server.createSession).toBe(chatServer.createSession); expect(server.listSessions).toBe(chatServer.listSessions); expect(server.catalog).toBeUndefined(); + expect(server.deleteAgent).toBeUndefined(); await expect(server.getCapabilities()).resolves.toEqual(capabilities); await expect(server.getModels()).resolves.toHaveLength(1); @@ -65,6 +66,30 @@ describe('createTrueFoundryServer', () => { expect(saveAgent).toHaveBeenCalled(); }); + it('attaches and delegates the optional deleteAgent callback', async () => { + const chatServer = createMockAgentUIServer(); + const deleteAgent = vi.fn(async () => {}); + const server = createTrueFoundryServer({ + chatServer, + getCapabilities: async () => ({ + data: { sandbox: { enabled: false }, skill: { enabled: false } }, + }), + getModels: async () => [], + getSkills: async () => [], + getMcp: async () => [], + searchAgents: async () => [], + saveAgent: async () => ({}), + deleteAgent, + }); + + if (server.deleteAgent === undefined) { + throw new Error('Expected deleteAgent to be attached'); + } + await server.deleteAgent({ agentName: 'writer' }); + + expect(deleteAgent).toHaveBeenCalledWith({ agentName: 'writer' }); + }); + it('attaches optional catalog when provided', async () => { const chatServer = createMockAgentUIServer({ createSession: vi.fn(), From 44c4a6565cccca9ba4059a39a32b05bed6dd6f6f Mon Sep 17 00:00:00 2001 From: Jayesh Savaliya Date: Tue, 25 Aug 2026 16:50:19 +0530 Subject: [PATCH 2/2] fix(ui): show agent deletion success toast --- packages/trueforge-ui/src/atoms/AgentsLibrary.tsx | 3 ++- .../trueforge-ui/test/atoms/AgentsLibrary.test.tsx | 12 +++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/trueforge-ui/src/atoms/AgentsLibrary.tsx b/packages/trueforge-ui/src/atoms/AgentsLibrary.tsx index 50551e366..fa91ad162 100644 --- a/packages/trueforge-ui/src/atoms/AgentsLibrary.tsx +++ b/packages/trueforge-ui/src/atoms/AgentsLibrary.tsx @@ -221,7 +221,8 @@ export function AgentsLibrary({ open, onOpenChange, onSelectAgent }: AgentsLibra shell?.invalidateAgentsList(); setAgentPendingDelete(null); - toaster?.showSuccess({ title: `${agent.name} deleted` }); + // Let the native dialog close before ToastStack chooses its portal target. + window.setTimeout(() => toaster?.showSuccess({ title: `${agent.name} deleted` }), 0); } catch (caught) { if (toaster == null) { setDeleteError(getErrorMessage(caught, 'Could not delete agent.')); diff --git a/packages/trueforge-ui/test/atoms/AgentsLibrary.test.tsx b/packages/trueforge-ui/test/atoms/AgentsLibrary.test.tsx index 27d384248..2e5826dd1 100644 --- a/packages/trueforge-ui/test/atoms/AgentsLibrary.test.tsx +++ b/packages/trueforge-ui/test/atoms/AgentsLibrary.test.tsx @@ -5,6 +5,7 @@ import { beforeAll, describe, expect, it, vi } from 'vitest'; import { AgentsLibrary } from '@/atoms/AgentsLibrary.js'; import { AgentsLibraryButton } from '@/atoms/AgentsLibraryButton.js'; import { CenteredModal } from '@/atoms/primitives/CenteredModal.js'; +import { ToasterProvider } from '@/containers/ToasterContainer.js'; import { ServerProvider } from '@/server/ServerContext.js'; import { ShellModeProvider, useShellMode } from '@/server/ShellModeContext.js'; import type { AgentUIServer } from '@/server/types.js'; @@ -213,9 +214,11 @@ describe('AgentsLibrary', () => { render( - - undefined} /> - + + + undefined} /> + + , ); @@ -234,6 +237,9 @@ describe('AgentsLibrary', () => { expect(searchAgents).toHaveBeenCalledTimes(2); }); expect(screen.queryByRole('button', { name: 'Delete agent writer' })).not.toBeInTheDocument(); + const successToast = await screen.findByRole('alert'); + expect(successToast).toHaveTextContent('writer deleted'); + expect(successToast.closest('dialog')).toHaveAttribute('aria-label', 'Agents Library'); }); it('allows cancellation and keeps a failed deletion available for retry', async () => {