Add Storybook coverage for connection pool integration

Mount production selectors, composer controls, usage waits, run evidence, retry details, and experimental settings in 19 fixture stories. Share the production retry card and router setting so their previews stay current. Synchronize the public agent runtime binding type and guard direct-account repair against router bindings.

Verified UI and server typechecks, 110 targeted UI tests, token gates, the Storybook build, all 19 browser previews, and selector/adoption interactions.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-03 14:38:17 -05:00
1 parent 8e37fbff57
commit 74ed4efead
7 files changed
+157 -33

No files matched your search

+15
View File
@@ -55,3 +55,18 @@ recovery context. These records stay in the instance database.
Tests: `cd server && pnpm exec vitest run src/__tests__/ai-connection-router.test.ts`
plus the existing AI connection, retry accounting, run-dispatch and UI suites.
## UI review in Storybook
Run `pnpm --filter @paperclipai/ui storybook` from Core, then open
**AI Connections / Connection pools**. The 19 stories use production components
for pool selection, legacy-session adoption, unavailable/read-only selections,
mixed-provider composer models and effort, mobile composer settings, usage waits,
run selections and override notes, scheduled retries, and the experimental flag.
`AllCoreSurfaces` provides an overview; individual stories expose the expanded
menus and adoption dialog. Run `pnpm --filter @paperclipai/ui build-storybook`
to build the preview.
Pool configuration stories belong to the private Cloud plugin's own Storybook
in `extensions/plugin-connection-pool/storybook/`. Both previews use fictional
accounts; they do not call live providers or mutate a Paperclip instance.
+1 -1
View File
@@ -25,7 +25,7 @@ export interface AgentPermissions extends Record<string, unknown> {
}
export type AgentRuntimeConfig = Record<string, unknown> & {
aiConnection?: import("../ai-connections.js").AiConnectionBinding;
aiConnection?: import("../ai-connection-router.js").AiRuntimeConnectionBinding;
};
export type AgentInstructionsBundleMode = "managed" | "external";
+20
View File
@@ -0,0 +1,20 @@
import { Link } from "@/lib/router";
import { cn } from "@/lib/utils";
import { describeRunRetryState } from "@/lib/runRetryState";
/** Shared with the run page so Storybook exercises the production retry copy. */
export function RunRetryDetails({ run, agentRouteId }: {
run: Parameters<typeof describeRunRetryState>[0];
agentRouteId: string;
}) {
const retry = describeRunRetryState(run);
if (!retry) return null;
return <div className="rounded-md border border-border/70 bg-accent/20 px-3 py-2 text-xs leading-5">
<div className="flex flex-wrap items-center gap-2">
<span className={cn("rounded-md border px-1.5 py-0.5 text-(length:--text-micro) font-medium", retry.tone)}>{retry.badgeLabel}</span>
{retry.retryOfRunId && <Link to={`/agents/${agentRouteId}/runs/${retry.retryOfRunId}`} className="font-mono text-foreground hover:underline">{retry.retryOfRunId.slice(0, 8)}</Link>}
</div>
{retry.detail && <p className="mt-2 text-muted-foreground">{retry.detail}</p>}
{retry.secondary && <p className="text-muted-foreground">{retry.secondary}</p>}
</div>;
}
@@ -198,7 +198,7 @@ export function ConnectionIntentInteractionBody({
} else {
const agent = await agentsApi.get(interaction.payload.requestingAgentId, interaction.companyId);
const current = agent.runtimeConfig.aiConnection;
if (!current || current.mode === "responsible_user" || current.connectionId !== previous.id || current.grantId !== previous.grantId) {
if (!current || current.mode === "responsible_user" || current.mode === "router" || current.connectionId !== previous.id || current.grantId !== previous.grantId) {
throw new Error("The agent’s AI connection changed. Reload the task and try again.");
}
if (result.generation !== setupGeneration.current) return;
+2 -26
View File
@@ -64,7 +64,7 @@ import { buildSameOriginWebSocketUrl } from "../lib/websocket-url";
import { tryCreateWebSocket } from "../lib/websocket";
import { formatDate, relativeTime, formatTokens, visibleRunCostUsd } from "../lib/utils";
import { cn } from "../lib/utils";
import { describeRunRetryState } from "../lib/runRetryState";
import { RunRetryDetails } from "../components/RunRetryDetails";
import { Button } from "@/components/ui/button";
import { Tabs } from "@/components/ui/tabs";
import { PageTabBar } from "../components/PageTabBar";
@@ -3569,7 +3569,6 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig }
const sessionChanged = run.sessionIdBefore && run.sessionIdAfter && run.sessionIdBefore !== run.sessionIdAfter;
const sessionId = run.sessionIdAfter || run.sessionIdBefore;
const hasNonZeroExit = run.exitCode !== null && run.exitCode !== 0;
const retryState = describeRunRetryState(run);
return (
<div className="space-y-4 min-w-0">
@@ -3800,30 +3799,7 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig }
{run.signal && <span className="text-muted-foreground ml-1">(signal: {run.signal})</span>}
</div>
)}
{retryState && (
<div className="rounded-md border border-border/70 bg-accent/20 px-3 py-2 text-xs leading-5">
<div className="flex flex-wrap items-center gap-2">
<span
className={cn(
"rounded-md border px-1.5 py-0.5 text-(length:--text-micro) font-medium",
retryState.tone,
)}
>
{retryState.badgeLabel}
</span>
{retryState.retryOfRunId ? (
<Link
to={`/agents/${agentRouteId}/runs/${retryState.retryOfRunId}`}
className="font-mono text-foreground hover:underline"
>
{retryState.retryOfRunId.slice(0, 8)}
</Link>
) : null}
</div>
{retryState.detail ? <p className="mt-2 text-muted-foreground">{retryState.detail}</p> : null}
{retryState.secondary ? <p className="text-muted-foreground">{retryState.secondary}</p> : null}
</div>
)}
<RunRetryDetails run={run} agentRouteId={agentRouteId} />
</div>
{/* Right column: metrics */}
+17 -5
View File
@@ -118,6 +118,22 @@ function ExperimentalToggleCard({
);
}
/** The actual router opt-in card, also mounted directly in Storybook. */
export function AiConnectionRoutersSetting({ checked, onCheckedChange, disabled = false, managed }: {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
disabled?: boolean;
managed?: ManagedSettingMetadata;
}) {
return <ExperimentalToggleCard
title="AI connection routers"
description="Allow experimental plugins to rotate new tasks through authorized AI accounts. Each task keeps its account and harness; exhausted tasks wait for usage to recover. Install a compatible router plugin and enable a pool separately."
checked={checked} onCheckedChange={onCheckedChange} disabled={disabled}
settingKey="enableAiConnectionRouters" managed={managed}
ariaLabel="Toggle AI connection routers experimental setting"
/>;
}
export function InstanceExperimentalSettings() {
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
@@ -305,15 +321,11 @@ export function InstanceExperimentalSettings() {
ariaLabel="Toggle agent chat experimental setting"
/>
<ExperimentalToggleCard
title="AI connection routers"
description="Allow experimental plugins to rotate new tasks through authorized AI accounts. Each task keeps its account and harness; exhausted tasks wait for usage to recover. Install a compatible router plugin and enable a pool separately."
<AiConnectionRoutersSetting
checked={experimentalQuery.data?.enableAiConnectionRouters === true}
onCheckedChange={(checked) => toggleMutation.mutate({ enableAiConnectionRouters: checked })}
disabled={toggleMutation.isPending}
settingKey="enableAiConnectionRouters"
managed={managedKeys.enableAiConnectionRouters}
ariaLabel="Toggle AI connection routers experimental setting"
/>
<ExperimentalToggleCard
@@ -0,0 +1,101 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, within } from "storybook/test";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Agent, AiConnectionList, AiConnectionPool, AiConnectionRouterSelection, AiRuntimeConnectionBinding, Issue } from "@paperclipai/shared";
import { AiConnectionField } from "@/components/ai-connections/AiConnectionField";
import { AiConnectionPoolRunDetails } from "@/components/ai-connections/AiConnectionPoolRunDetails";
import { IssueMonitorBanner, IssueMonitorComposerStrip } from "@/components/IssueMonitorBanner";
import { ComposerRunSettingsPicker } from "@/components/task-chat/ComposerRunSettingsPicker";
import type { ComposerRunSettings } from "@/components/task-chat/composer-run-settings";
import { RunRetryDetails } from "@/components/RunRetryDetails";
import { AiConnectionRoutersSetting } from "@/pages/InstanceExperimentalSettings";
import { queryKeys } from "@/lib/queryKeys";
import { storybookAgents, storybookIssues } from "../fixtures/paperclipData";
const companyId = "company-storybook", agentId = "agent-codex";
const poolId = "10000000-0000-4000-8000-000000000001";
const pool: AiConnectionPool = {
id: poolId, companyId, pluginKey: "paperclip-cloud.plugin-connection-pool", revision: 3,
name: "Research accounts", enabled: true, mode: "round_robin", thresholdPercent: 90,
members: [
{ id: "20000000-0000-4000-8000-000000000001", binding: { mode: "delegated", provider: "openai", method: "subscription", connectionId: "30000000-0000-4000-8000-000000000001", grantId: "40000000-0000-4000-8000-000000000001" }, profile: { provider: "codex", model: "gpt-5.6-sol", effort: "high" } },
{ id: "20000000-0000-4000-8000-000000000002", binding: { mode: "delegated", provider: "anthropic", method: "subscription", connectionId: "30000000-0000-4000-8000-000000000002", grantId: "40000000-0000-4000-8000-000000000002" }, profile: { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-5" } },
],
};
const accounts: AiConnectionList = { currentUserId: "user-board", canManageConnections: true, pools: [pool], connections: pool.members.map((m, index) => ({
id: m.binding.connectionId!, grantId: m.binding.grantId!, companyId, provider: m.binding.provider, method: m.binding.method!, name: index === 0 ? "My OpenAI subscription" : "My Claude subscription", ownership: "personal", ownerUserId: "user-board", isDefault: true, status: "connected",
})) };
const models = [{ id: "gpt-5.6-sol", label: "GPT-5.6 Sol · Codex" }, { id: "claude-sonnet-5", label: "Claude Sonnet 5 · Claude ACP" }];
const agent: Agent = { ...storybookAgents.find(a => a.id === agentId)!, adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.6-sol" }, runtimeConfig: { aiConnection: { mode: "router", connectionId: poolId } } };
type Scenario = "overview" | "selector" | "legacy" | "adoption" | "unavailable" | "read-only" | "models" | "effort" | "mobile" | "claude-model" | "quota" | "checking" | "run-codex" | "run-claude" | "fallback" | "retry" | "flag-off" | "flag-on" | "flag-managed";
function PoolComposer({ scenario }: { scenario: Scenario }) {
const [settings, setSettings] = useState<ComposerRunSettings>({ model: scenario === "claude-model" ? "claude-sonnet-5" : "gpt-5.6-sol", effort: null, fast: false });
return <div className="space-y-3 rounded-xl border bg-card p-4">
<p className="text-sm">Choose settings for the next turn. The task keeps its account and harness.</p>
<ComposerRunSettingsPicker companyId={companyId} assigneeValue={`agent:${agentId}`} currentAssigneeValue={`agent:${agentId}`}
options={[{ id: `agent:${agentId}`, label: agent.name }]} agents={new Map([[agentId, agent]])}
settings={settings} onSettingsChange={setSettings} onAssigneeChange={() => setSettings({ model: null, effort: null, fast: false })}
modelOptionsOverride={models} mobile={scenario === "mobile"} initialOpen={scenario !== "overview"}
initialView={scenario === "models" || scenario === "mobile" ? "models" : "settings"} />
</div>;
}
function PoolWait({ checking = false }: { checking?: boolean }) {
const [issue] = useState<Issue>(() => ({ ...storybookIssues[0]!, status: "in_progress", executionState: null,
scheduledRetry: { status: "scheduled_retry", scheduledRetryReason: "ai_connection_pool_wait", scheduledRetryAt: new Date(Date.now() + 60_000).toISOString(), scheduledRetryAttempt: 1 } as Issue["scheduledRetry"],
}));
return <div className="space-y-4"><IssueMonitorBanner issue={issue} onCheckNow={() => undefined} checkingNow={checking} /><IssueMonitorComposerStrip issue={issue} onCheckNow={() => undefined} checkingNow={checking} /></div>;
}
function PoolRun({ scenario }: { scenario: Scenario }) {
const claude = scenario === "run-claude", member = pool.members[claude ? 1 : 0]!;
const selection: AiConnectionRouterSelection = { poolId, memberId: member.id, binding: member.binding,
runtimeConfig: { ...member.profile, ...(claude ? {} : { modelReasoningEffort: "high" }) },
notes: scenario === "fallback" ? ["Model override is unavailable for this member; using its default.", "Effort override is unavailable for the effective model; using its default."] : [],
};
return <div className="space-y-4"><AiConnectionPoolRunDetails context={{ aiRouterSelection: selection, aiConnection: { accountName: claude ? "My Claude subscription" : "My OpenAI subscription" } }} />
{scenario === "retry" && <RunRetryDetails agentRouteId={agentId} run={{ status: "scheduled_retry", scheduledRetryReason: "ai_connection_pool_wait", scheduledRetryAt: new Date(Date.now() + 60_000).toISOString(), scheduledRetryAttempt: 1, retryOfRunId: "run-pool-wait" }} />}</div>;
}
function PoolSurfaces({ scenario = "overview" }: { scenario?: Scenario }) {
const [client] = useState(() => {
const query = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
query.setQueryData(["ai-connections", companyId, agentId], { ...accounts, pools: scenario === "unavailable" ? [] : accounts.pools });
query.setQueryData(queryKeys.instance.settings, {});
return query;
});
const [binding, setBinding] = useState<AiRuntimeConnectionBinding | undefined>(scenario === "adoption" ? undefined : { mode: "router", connectionId: poolId });
const [enabled, setEnabled] = useState(scenario === "flag-on");
const overview = scenario === "overview";
return <QueryClientProvider client={client}><main className="mx-auto max-w-3xl space-y-6 p-6">
<header className="space-y-2"><h1 className="text-xl font-semibold">Experimental connection pools</h1><p className="text-sm text-muted-foreground">Core integration · production components with fixture accounts. Configure the pool itself in Cloud’s plugin Storybook.</p></header>
{(overview || scenario.startsWith("flag")) && <AiConnectionRoutersSetting checked={enabled} onCheckedChange={setEnabled} managed={scenario === "flag-managed" ? { managed: true, managedBy: "paperclip-cloud" } : undefined} />}
{(overview || ["selector", "legacy", "adoption", "unavailable", "read-only"].includes(scenario)) && <section className="space-y-2"><h2 className="text-sm font-semibold">Agent connection</h2><AiConnectionField companyId={companyId} agentId={agentId} agentName={agent.name}
adapterType={scenario === "legacy" ? "claude_local" : "codex_local"} routerAdapterType={scenario === "legacy" ? "claude_local" : "paperclip_runner"}
value={binding} onChange={setBinding} legacy={scenario === "adoption"} readOnly={scenario === "read-only"} /></section>}
{(overview || ["models", "effort", "mobile", "claude-model"].includes(scenario)) && <section className="space-y-2"><h2 className="text-sm font-semibold">Composer model and effort</h2><PoolComposer scenario={scenario} /></section>}
{(overview || scenario === "quota" || scenario === "checking") && <section><h2 className="text-sm font-semibold">Usage recovery</h2><PoolWait checking={scenario === "checking"} /></section>}
{(overview || ["run-codex", "run-claude", "fallback", "retry"].includes(scenario)) && <section className="space-y-2"><h2 className="text-sm font-semibold">Run account and runtime</h2><PoolRun scenario={scenario === "overview" ? "fallback" : scenario} /></section>}
</main></QueryClientProvider>;
}
const meta = { title: "AI Connections/Connection pools", component: PoolSurfaces, parameters: { layout: "fullscreen", docs: { description: { component: "All user-facing Core additions for the private connection-pool plugin. These stories mount the production selector, composer, quota banners, run details/retry metadata, and instance setting. Accounts are fixtures; no live provider calls or instance mutations occur." } } }, render: args => <PoolSurfaces key={args.scenario} {...args} /> } satisfies Meta<typeof PoolSurfaces>;
export default meta;
type Story = StoryObj<typeof meta>;
export const AllCoreSurfaces: Story = { args: { scenario: "overview" } };
export const ConnectionSelector: Story = { args: { scenario: "selector" }, play: async ({ canvasElement }) => { const c = within(canvasElement); await userEvent.selectOptions(c.getByRole("combobox", { name: /^AI connection/ }), ""); await expect(c.getByText("Responsible user’s connection", { exact: false })).toBeVisible(); await userEvent.selectOptions(c.getByRole("combobox", { name: /^AI connection/ }), poolId); await expect(c.getByText(/New tasks rotate/)).toBeVisible(); } };
export const CompatibleLegacyHarness: Story = { args: { scenario: "legacy" } };
export const ExistingSessionAdoption: Story = { args: { scenario: "adoption" }, play: async ({ canvasElement }) => { const c = within(canvasElement); await userEvent.click(c.getByRole("button", { name: "Choose a managed connection" })); await userEvent.selectOptions(c.getByRole("combobox", { name: /^AI connection/ }), poolId); await expect(await within(canvasElement.ownerDocument.body).findByRole("dialog")).toHaveTextContent("Task-pinned connection pool"); } };
export const PoolUnavailable: Story = { args: { scenario: "unavailable" } };
export const ReadOnlySelection: Story = { args: { scenario: "read-only" } };
export const ComposerModelsFromBothProviders: Story = { args: { scenario: "models" } };
export const ComposerCodexEffort: Story = { args: { scenario: "effort" } };
export const ComposerClaudeModelOnly: Story = { args: { scenario: "claude-model" } };
export const MobileComposer: Story = { args: { scenario: "mobile" } };
export const PoolExhaustedBannerAndStrip: Story = { args: { scenario: "quota" } };
export const UsageRecheckInProgress: Story = { args: { scenario: "checking" } };
export const PinnedCodexRun: Story = { args: { scenario: "run-codex" } };
export const PinnedClaudeRun: Story = { args: { scenario: "run-claude" } };
export const IndependentOverrideFallbackNotes: Story = { args: { scenario: "fallback" } };
export const ScheduledQuotaRetry: Story = { args: { scenario: "retry" } };
export const ExperimentalOffByDefault: Story = { args: { scenario: "flag-off" } };
export const ExperimentalEnabled: Story = { args: { scenario: "flag-on" } };
export const ExperimentalManagedByCloud: Story = { args: { scenario: "flag-managed" } };