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 3c44e60e6..b403588da 100644 --- a/packages/trueforge-ui/docs/server.md +++ b/packages/trueforge-ui/docs/server.md @@ -567,13 +567,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 @@ -593,8 +594,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 299bde754..8ec5d6445 100644 --- a/packages/trueforge-ui/src/atoms/AgentsLibrary.tsx +++ b/packages/trueforge-ui/src/atoms/AgentsLibrary.tsx @@ -2,15 +2,19 @@ import { useCallback, useEffect, useState } from 'react'; +import { useToasterOptional } from '../containers/ToasterContainer.js'; import { useSessionShareSearch } from '../hooks/useSessionShareSearch.js'; import { Icon } from '../icons/Icon.js'; -import { useOptionalAgentSessionsServer } from '../server/ServerContext.js'; +import { useOptionalAgentSessionsServer, useOptionalServer } from '../server/ServerContext.js'; import { libraryAgentId, useShellMode } from '../server/ShellModeContext.js'; import type { AgentLibraryEntry, AgentSpec } from '../server/types.js'; import { useSlot } from '../theme/SlotsProvider.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'; +import { CenteredModal } from './primitives/CenteredModal.js'; +import { DropdownMenu, DropdownMenuItem } from './primitives/DropdownMenu.js'; import SearchInput from './primitives/SearchInput.js'; import { Skeleton } from './primitives/Skeleton.js'; import { Tooltip } from './primitives/Tooltip.js'; @@ -22,9 +26,11 @@ export type AgentsLibraryProps = { export type AgentLibraryRowProps = { agent: AgentLibraryEntry; showEdit: boolean; + showDelete: boolean; onOpen?: () => void; onTry: () => void; onEdit: () => void; + onDelete: () => void; }; /** Short label for model fqns like `provider/gpt-4.1` → `gpt-4.1`. */ @@ -33,7 +39,15 @@ function displayModelLabel(modelName: string): string { return slash >= 0 ? modelName.slice(slash + 1) : modelName; } -export function AgentLibraryRow({ agent, showEdit, onOpen, onTry, onEdit }: AgentLibraryRowProps) { +export function AgentLibraryRow({ + agent, + showEdit, + showDelete, + onOpen, + onTry, + onEdit, + onDelete, +}: AgentLibraryRowProps) { const spec = agent.agentSpec; const modelName = spec?.model.name; const skillsCount = spec?.skills?.length ?? 0; @@ -42,6 +56,7 @@ export function AgentLibraryRow({ agent, showEdit, onOpen, onTry, onEdit }: Agen const mcpNames = (spec?.mcpServers ?? []).map(m => (m as { name?: string }).name).filter(Boolean); const connectorsTitle = mcpNames.length ? `Connectors: ${mcpNames.join(', ')}` : `${mcpCount} connectors`; const skillsTitle = skillNames.length ? `Skills: ${skillNames.join(', ')}` : `${skillsCount} skills`; + const showActions = showEdit || showDelete; return (
{ + if (onOpen == null || (event.target instanceof Element && event.target.closest('button') != null)) return; + onOpen(); + }} onKeyDown={event => { if (onOpen == null || event.target !== event.currentTarget || (event.key !== 'Enter' && event.key !== ' ')) { return; @@ -93,22 +111,39 @@ export function AgentLibraryRow({ agent, showEdit, onOpen, onTry, onEdit }: Agen ) : null} - {showEdit ? ( - + } > - - Edit - + {showEdit ? ( + + + Edit + + ) : null} + {showDelete ? ( + + + Delete + + ) : null} + ) : null} -

Agents Library

- + <> +
+
+ +

Agents Library

+
-
-
- - {isSearching ? ( -

- Searching… -

- ) : null} +
+
+ + {isSearching ? ( +

+ Searching… +

+ ) : null} +
+
+ {isInitialLoading ? ( +
+ {Array.from({ length: 6 }, (_, i) => ( + + ))} +
+ ) : error ? ( +

{error}

+ ) : agents.length === 0 ? ( +

+ {query.trim() + ? `No agents match "${query.trim()}".` + : 'No agents yet. Build one in a chat, then save it as an agent.'} +

+ ) : ( + <> + {agents.map(agent => { + const agentSpec = agent.agentSpec; + const agentId = agent.agentId; + const showEdit = canEdit && agentSpec != null; + return ( + { + updateShareSearch({ + agentId, + tab: 'overview', + sessionId: null, + view: null, + timeRange: null, + }); + shell.openLibraryAgent(agentId); + }, + } + : {})} + onTry={() => handleTry(agent)} + onEdit={() => { + if (agentSpec != null) handleEdit(agent, agentSpec); + }} + onDelete={() => requestDelete(agent)} + /> + ); + })} + {hasMore ? ( +
+ {loadingMore ? ( + + Loading more… + + ) : null} +
+ ) : null} + + )} +
-
- {isInitialLoading ? ( -
- {Array.from({ length: 6 }, (_, i) => ( - - ))} -
- ) : error ? ( -

{error}

- ) : agents.length === 0 ? ( -

- {query.trim() - ? `No agents match "${query.trim()}".` - : 'No agents yet. Build one in a chat, then save it as an agent.'} +

+ + { + if (!nextOpen) closeDeleteConfirmation(); + }} + title={agentPendingDelete == null ? 'Delete agent' : `Delete ${agentPendingDelete.name}?`} + description="This removes the agent from the library. Existing chats will stay in your history." + contentSized + > +
+ {deleteError != null ? ( +

+ {deleteError}

- ) : ( - <> - {agents.map(agent => { - const agentSpec = agent.agentSpec; - const agentId = agent.agentId; - const showEdit = canEdit && agentSpec != null; - return ( - { - updateShareSearch({ - agentId, - tab: 'overview', - sessionId: null, - view: null, - timeRange: null, - }); - shell.openLibraryAgent(agentId); - }, - } - : {})} - onTry={() => handleTry(agent)} - onEdit={() => { - if (agentSpec != null) handleEdit(agent, agentSpec); - }} - /> - ); - })} - {hasMore ? ( -
- {loadingMore ? ( - - Loading more… - - ) : null} -
- ) : null} - - )} + ) : null} +
+ + +
-
-
+ + ); } 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 14fe8c24a..13a5e8eb4 100644 --- a/packages/trueforge-ui/src/server/createTrueFoundryServer.ts +++ b/packages/trueforge-ui/src/server/createTrueFoundryServer.ts @@ -81,13 +81,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 0b5e0ed79..bab47d2f1 100644 --- a/packages/trueforge-ui/test/atoms/AgentsLibrary.test.tsx +++ b/packages/trueforge-ui/test/atoms/AgentsLibrary.test.tsx @@ -6,6 +6,7 @@ import { afterEach, 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'; @@ -62,15 +63,24 @@ function renderLibrary( ); } -function LibraryHarness({ children, onSelectAgent }: { children?: ReactNode; onSelectAgent?: (name: string) => void }) { +function LibraryHarness({ + children, + onSelectAgent, + withToaster = false, +}: { + children?: ReactNode; + onSelectAgent?: (name: string) => void; + withToaster?: boolean; +}) { const shell = useShellMode(); + const library = ; return ( <> {shell.libraryAgentId ?? ''} - + {withToaster ? {library} : library} {children} ); @@ -157,14 +167,17 @@ describe('AgentsLibrary', () => { }); it('shows Edit when composer is enabled and agentSpec is present', async () => { - const server = mockServer([ - { - name: 'writer', - agentId: 'writer-id', - agentSpec: { model: { name: 'openai-main/gpt-4.1' }, skills: [{ id: 's1', name: 'Skill' }] }, - }, - { name: 'try-only', agentId: 'try-only' }, - ]); + const server = createMockAgentUIServer({ + searchAgents: vi.fn(async () => [ + { + name: 'writer', + agentId: 'writer-id', + agentSpec: { model: { name: 'openai-main/gpt-4.1' }, skills: [{ id: 's1', name: 'Skill' }] }, + }, + { name: 'try-only', agentId: 'try-only' }, + ]), + deleteAgent: vi.fn(async () => {}), + }); renderLibrary(, { server, @@ -173,10 +186,12 @@ describe('AgentsLibrary', () => { fireEvent.click(screen.getByRole('button', { name: 'Open library' })); - await waitFor(() => { - expect(screen.getByRole('button', { name: 'Edit agent writer' })).toBeInTheDocument(); - }); - expect(screen.queryByRole('button', { name: 'Edit agent try-only' })).not.toBeInTheDocument(); + const writerActions = await screen.findByRole('button', { name: 'Agent actions for writer' }); + fireEvent.click(writerActions); + expect(screen.getByRole('menuitem', { name: 'Edit' })).toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: 'Delete' })).toBeInTheDocument(); + expect(screen.queryByRole('menuitem', { name: /Manage Schedules/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('menuitem', { name: /Clone/i })).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Try agent try-only' })).toBeInTheDocument(); }); @@ -229,7 +244,77 @@ describe('AgentsLibrary', () => { await waitFor(() => { expect(screen.getByRole('button', { name: 'Try agent writer' })).toBeInTheDocument(); }); - expect(screen.queryByRole('button', { name: 'Edit agent writer' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Agent actions for writer' })).not.toBeInTheDocument(); + }); + + it('hides Delete when the server does not support deleting agents', async () => { + const server = mockServer([{ name: 'writer', agentId: 'writer-id' }]); + + renderLibrary(, { server }); + fireEvent.click(screen.getByRole('button', { name: 'Open library' })); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Try agent writer' })).toBeInTheDocument(); + }); + expect(screen.queryByRole('button', { name: 'Agent actions for 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 }); + + renderLibrary(, { server }); + fireEvent.click(screen.getByRole('button', { name: 'Open library' })); + + fireEvent.click(await screen.findByRole('button', { name: 'Agent actions for writer' })); + fireEvent.click(screen.getByRole('menuitem', { name: 'Delete' })); + + 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: 'Agent actions for writer' })).not.toBeInTheDocument(); + const successToast = await screen.findByRole('alert'); + expect(successToast).toHaveTextContent('writer deleted'); + expect(successToast).toBeVisible(); + expect(successToast.closest('dialog')).toBeNull(); + }); + + 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, + }); + + renderLibrary(, { server }); + fireEvent.click(screen.getByRole('button', { name: 'Open library' })); + + const actions = await screen.findByRole('button', { name: 'Agent actions for writer' }); + fireEvent.click(actions); + fireEvent.click(screen.getByRole('menuitem', { name: 'Delete' })); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(deleteAgent).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: 'Agent actions for writer' })); + fireEvent.click(screen.getByRole('menuitem', { name: 'Delete' })); + 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: 'Agent actions for writer' })).toBeInTheDocument(); }); it('shows create-one guidance when there are no agents yet', async () => { 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 3e07de2f0..556e94cd7 100644 --- a/packages/trueforge-ui/test/server/createTrueFoundryServer.test.ts +++ b/packages/trueforge-ui/test/server/createTrueFoundryServer.test.ts @@ -57,6 +57,7 @@ describe('createTrueFoundryServer', () => { expect(server.listSessions).toBe(chatServer.listSessions); expect(server.catalog).toBeUndefined(); expect(server.sessions).toBe(sessions); + expect(server.deleteAgent).toBeUndefined(); await expect(server.getCapabilities()).resolves.toEqual(capabilities); await expect(server.getModels()).resolves.toHaveLength(1); @@ -73,6 +74,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(),