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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-05 13:04:54 -05:00
1 parent c152cd9d85
commit 23bea51e78
5 files changed
+47 -2

No files matched your search

+4 -1
View File
@@ -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.
@@ -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();
}
});
@@ -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" && <label className="flex items-center gap-3 text-sm">Skip at<Input className="w-20" aria-label="Usage threshold" type="number" min={1} max={100} step={1} value={draft.thresholdPercent} onChange={event => setDraft({ ...draft, thresholdPercent: Number(event.target.value) })} />% usage</label>}
{draft.members.map((member, index) => <div key={member.id} className="space-y-2"><p className="text-sm font-medium">{accounts.find(account => account.id === member.binding.connectionId)?.name ?? "Connection unavailable"}</p><div className="grid gap-3 sm:grid-cols-2"><label className="space-y-1 text-xs text-muted-foreground">Model<Input aria-label={`Model for connection ${index + 1}`} value={member.profile.model} onChange={event => setDraft({ ...draft, members: draft.members.map(row => row.id === member.id ? { ...row, profile: { ...row.profile, model: event.target.value } } : row) })} /></label><label className="space-y-1 text-xs text-muted-foreground">Effort (optional)<Input aria-label={`Effort for connection ${index + 1}`} value={member.profile.effort ?? ""} onChange={event => { const { effort: _old, ...profile } = member.profile; setDraft({ ...draft, members: draft.members.map(row => row.id === member.id ? { ...row, profile: { ...profile, ...(event.target.value.trim() ? { effort: event.target.value.trim() } : {}) } } : row) }); }} /></label></div></div>)}
</fieldset></details>
<section aria-labelledby="pool-used-by" className="space-y-3">
<h2 id="pool-used-by" className="text-sm font-semibold">Used by</h2>
{agentsQuery.isPending ? <p role="status" className="text-sm text-muted-foreground">Loading agents…</p>
: agentsQuery.isError ? <div className="flex items-center gap-2"><p role="alert" className="text-sm text-muted-foreground">Couldn’t load agents.</p><Button variant="ghost" size="sm" onClick={() => void agentsQuery.refetch()}>Retry</Button></div>
: usingAgents.length ? <ul className="flex flex-wrap gap-x-6 gap-y-3">{usingAgents.map(agent => <li key={agent.id} className="min-w-0 max-w-full"><Link to={`/agents/${agent.id}`} className="inline-flex max-w-full rounded-sm hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"><AgentIdentity agent={agent} /></Link></li>)}</ul>
: <p className="text-sm text-muted-foreground">No agents yet.</p>}
</section>
</>}
</div>}
<div className="mt-6 flex items-center justify-between gap-3 border-t border-border pt-4">
@@ -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 } });
@@ -6,7 +6,12 @@ export default meta;
type Story = StoryObj<typeof meta>;
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();
} };