From 23bea51e78fff4995a4d63b350be1ccb60597dc0 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 5 Oct 2026 13:04:54 -0500 Subject: [PATCH] Show agents using each connection pool List configured company agents with shared avatars and profile links below the pool settings. Include paused agents and exclude terminated agents and other pools. Cover populated and empty states in the production browser flow and host stories. Co-Authored-By: Paperclip --- doc/connections/AI-CONNECTION-ROUTERS.md | 5 ++++- .../connection-pools.spec.ts | 19 +++++++++++++++++++ .../AiConnectionPoolConnector.tsx | 11 +++++++++++ .../ConnectionPoolConnectorHost.tsx | 6 ++++++ .../connection-pool-connector.stories.tsx | 8 +++++++- 5 files changed, 47 insertions(+), 2 deletions(-) diff --git a/doc/connections/AI-CONNECTION-ROUTERS.md b/doc/connections/AI-CONNECTION-ROUTERS.md index 8f1ea2e4e90..2b077726c7b 100644 --- a/doc/connections/AI-CONNECTION-ROUTERS.md +++ b/doc/connections/AI-CONNECTION-ROUTERS.md @@ -55,6 +55,8 @@ budget, and the UI labels that retry as **Pool exhausted**. Operators choose **Connectors → AI connection pool → Add connection pool**, select existing accounts, arrange their order, and create a paused connection. The normal account page supports rename, member changes, enabling, and removal. +**Used by** lists the company’s non-terminated agents configured to use the pool, +with their avatars and links to their profiles. Paused agents remain listed. Optional usage and runtime defaults live under **Advanced**. Creating another account opens the normal catalog in a new tab so the pool draft stays intact. The catalog marks experimental-disabled or unavailable plugins as unavailable. @@ -111,6 +113,7 @@ pnpm exec playwright test --config tests/ai-connections-app/playwright.config.ts These opt-in tests exercise the shipped app, installed plugin and database without mocking browser routes or pool APIs. They cover catalog navigation, setup, ordering, paused defaults, rename, member changes, refresh persistence, stale edits and -removal. Cleanup archives only the test's own pool and verifies that existing +removal. With a test agent already bound to a saved pool, they also verify +the **Used by** list, avatars and profile links. Cleanup archives only the test's own pool and verifies that existing accounts and pools remain intact. This suite does not execute agents or probe live usage; provider and restart acceptance remain separate gated test drives. diff --git a/tests/ai-connections-app/connection-pools.spec.ts b/tests/ai-connections-app/connection-pools.spec.ts index 41e174cd455..bcf73a8d5f7 100644 --- a/tests/ai-connections-app/connection-pools.spec.ts +++ b/tests/ai-connections-app/connection-pools.spec.ts @@ -62,6 +62,7 @@ test("native catalog setup, account edits, conflicts and removal persist through await expect(page.getByRole("link", { name: "Settings", exact: true })).toBeVisible(); await expect(page.getByRole("link", { name: "Review", exact: true })).toHaveCount(0); await expect(page.getByRole("checkbox", { name: "Enable this pool", exact: true })).not.toBeChecked(); + await expect(page.getByRole("region", { name: "Used by", exact: true })).toContainText("No agents yet."); const name = `Pool E2E ${Date.now()}`; await page.getByRole("button", { name: "Rename app", exact: true }).click(); @@ -127,3 +128,21 @@ test("an unavailable dynamic router stays on an actionable setup page", async ({ await expect(page.getByRole("alert")).toContainText("This connection pool plugin is unavailable. Enable it in Plugins."); await expect(page).toHaveURL(/\/apps\/connect\?/); }); + +test("a saved pool shows its configured agents with avatars and profile links", async ({ page, request }) => { + const pools = await listPools(request); + const response = await request.get(`/api/companies/${companyId}/agents`); + expect(response.ok()).toBe(true); + const agents = await response.json() as Array<{ id: string; name: string; status: string; runtimeConfig: { aiConnection?: { mode: string; connectionId?: string } } }>; + const pool = pools.find(candidate => agents.some(agent => agent.status !== "terminated" && agent.runtimeConfig.aiConnection?.mode === "router" && agent.runtimeConfig.aiConnection.connectionId === candidate.id)); + test.skip(!pool, "Bind a test agent to a saved pool to verify populated usage"); + const expected = agents.filter(agent => agent.status !== "terminated" && agent.runtimeConfig.aiConnection?.mode === "router" && agent.runtimeConfig.aiConnection.connectionId === pool!.id).sort((a, b) => a.name.localeCompare(b.name)); + await page.goto(`/${prefix}/apps/${pool!.id}/permissions`); + const usedBy = page.getByRole("region", { name: "Used by", exact: true }); + await expect(usedBy.getByRole("link")).toHaveCount(expected.length); + for (const agent of expected) { + const link = usedBy.getByRole("link", { name: agent.name, exact: true }); + await expect(link).toHaveAttribute("href", `/${prefix}/agents/${agent.id}`); + await expect(link.locator('[data-slot="agent-avatar"]')).toBeVisible(); + } +}); diff --git a/ui/src/components/ai-connections/AiConnectionPoolConnector.tsx b/ui/src/components/ai-connections/AiConnectionPoolConnector.tsx index bf40642e483..7eb2ee6e9d1 100644 --- a/ui/src/components/ai-connections/AiConnectionPoolConnector.tsx +++ b/ui/src/components/ai-connections/AiConnectionPoolConnector.tsx @@ -5,6 +5,7 @@ import { aiConnectionRouterAppDefinition, type AiConnectionPool, type AiConnecti import { aiConnectionsApi } from "@/api/ai-connections"; import { aiConnectionPoolsApi, type PoolInspection } from "@/api/ai-connection-pools"; import { toolsApi } from "@/api/tools"; +import { agentsApi } from "@/api/agents"; import { useCompany } from "@/context/CompanyContext"; import { useBreadcrumbs } from "@/context/BreadcrumbContext"; import { useToast } from "@/context/ToastContext"; @@ -14,6 +15,7 @@ import { AppLogo } from "@/pages/apps/AppLogo"; import { AppDetailHeader } from "@/pages/apps/AppDetail"; import { StepHeader } from "@/features/connections/ConnectionSetupHeader"; import { Button } from "@/components/ui/button"; +import { AgentIdentity } from "@/components/AgentIdentity"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; @@ -62,6 +64,8 @@ function PoolConnector({ companyId, pluginKey, connection }: { companyId: string const unavailable = galleryQuery.isSuccess && (!entry || entry.availability?.available === false); const canManage = Boolean(accountsQuery.data?.canManageConnections); const disabled = busy || !canManage || unavailable; + const agentsQuery = useQuery({ queryKey: queryKeys.agents.list(companyId), queryFn: () => agentsApi.list(companyId), enabled: Boolean(connection) && canManage }); + const usingAgents = (agentsQuery.data ?? []).filter(agent => agent.status !== "terminated" && agent.runtimeConfig?.aiConnection?.mode === "router" && agent.runtimeConfig.aiConnection.connectionId === connection?.id).sort((a, b) => a.name.localeCompare(b.name)); useEffect(() => { if (connection && !editing && canManage) { const pool = poolsQuery.data?.find(pool => pool.id === connection.id && pool.pluginKey === pluginKey); @@ -150,6 +154,13 @@ function PoolConnector({ companyId, pluginKey, connection }: { companyId: string {draft.mode === "usage_aware" && } {draft.members.map((member, index) =>

{accounts.find(account => account.id === member.binding.connectionId)?.name ?? "Connection unavailable"}

)} +
+

Used by

+ {agentsQuery.isPending ?

Loading agents…

+ : agentsQuery.isError ?

Couldn’t load agents.

+ : usingAgents.length ?
    {usingAgents.map(agent =>
  • )}
+ :

No agents yet.

} +
} }
diff --git a/ui/storybook/prototypes/ConnectionPoolConnectorHost.tsx b/ui/storybook/prototypes/ConnectionPoolConnectorHost.tsx index 857aa601c4d..53145ca3c48 100644 --- a/ui/storybook/prototypes/ConnectionPoolConnectorHost.tsx +++ b/ui/storybook/prototypes/ConnectionPoolConnectorHost.tsx @@ -37,6 +37,12 @@ export function ConnectionPoolConnectorHost({ pluginKey = "example.pool-router", const body = typeof init?.body === "string" ? JSON.parse(init.body) : {}; const reply = (value: unknown) => Response.json(value); if (path === `/api/companies/${companyId}/chat-endpoints`) return reply([]); + if (path === `/api/companies/${companyId}/agents`) return reply(["setup", "empty", "catalog", "unavailable"].includes(scenario) ? [] : [ + { id: "50000000-0000-4000-8000-000000000001", companyId, name: "Researcher", status: "idle", runtimeConfig: { aiConnection: { mode: "router", connectionId: poolId } } }, + { id: "50000000-0000-4000-8000-000000000002", companyId, name: "Writer", status: "paused", runtimeConfig: { aiConnection: { mode: "router", connectionId: poolId } } }, + { id: "50000000-0000-4000-8000-000000000003", companyId, name: "Other pool agent", status: "idle", runtimeConfig: { aiConnection: { mode: "router", connectionId: "another-pool" } } }, + { id: "50000000-0000-4000-8000-000000000004", companyId, name: "Former researcher", status: "terminated", runtimeConfig: { aiConnection: { mode: "router", connectionId: poolId } } }, + ]); if (path === `/api/companies/${companyId}/user-directory`) return reply({ users: [{ principalId: "dotta", status: "active", user: { name: "Dotta", email: "dotta@example.test" } }] }); if (path === `/api/companies/${companyId}/tools/apps/attention`) return reply({ apps: [] }); if (path === `/api/companies/${companyId}/tools/gallery`) return reply({ apps: catalog, capabilities: { canCreateOrganizationGrant: true, canSetCompanyInstall: true } }); diff --git a/ui/storybook/stories/connection-pool-connector.stories.tsx b/ui/storybook/stories/connection-pool-connector.stories.tsx index cd8f5bf1b24..0cae2c91607 100644 --- a/ui/storybook/stories/connection-pool-connector.stories.tsx +++ b/ui/storybook/stories/connection-pool-connector.stories.tsx @@ -6,7 +6,12 @@ export default meta; type Story = StoryObj; export const Catalog: Story = {}; export const Setup: Story = { args: { scenario: "setup" } }; -export const Manage: Story = { args: { scenario: "manage" } }; +export const Manage: Story = { args: { scenario: "manage" }, play: async ({ canvasElement }) => { + const usedBy = within(await within(canvasElement).findByRole("region", { name: "Used by" })); + await expect(await usedBy.findByRole("link", { name: "Researcher" })).toBeVisible(); + await expect(usedBy.getByRole("link", { name: "Writer" })).toBeVisible(); + await expect(usedBy.getAllByRole("link")).toHaveLength(2); +} }; export const CreateFromConnectors: Story = { play: async ({ canvasElement }) => { const c = within(canvasElement); await userEvent.click(await c.findByRole("button", { name: "Add connection pool AI connection pool" })); @@ -18,4 +23,5 @@ export const CreateFromConnectors: Story = { play: async ({ canvasElement }) => await userEvent.click(c.getByRole("button", { name: "Create pool" })); await expect(await c.findByRole("checkbox", { name: "Enable this pool" })).not.toBeChecked(); await expect(c.getByRole("heading", { level: 1, name: "AI connection pool" })).toBeVisible(); + await expect(await within(c.getByRole("region", { name: "Used by" })).findByText("No agents yet.")).toBeVisible(); } };