diff --git a/doc/connections/AI-CONNECTIONS.md b/doc/connections/AI-CONNECTIONS.md index 1bd9d7a81da..6a12c8930e3 100644 --- a/doc/connections/AI-CONNECTIONS.md +++ b/doc/connections/AI-CONNECTIONS.md @@ -59,6 +59,12 @@ the selected connection owns its provider routing. Changing those separately may make a binding incompatible; saving then requires a compatible choice. Agent configuration cannot grant access to another account. +Agent account choices and legacy-adoption confirmations show the sign-in method, +personal or company-shared scope, and available account or owner identity beside +the connection name. Accounts with the same provider name can be distinguished +before selection. These labels use existing authorized account metadata, never +credentials or authentication paths. + Personal defaults are unique per company, user, and provider. A Claude bot can use one user’s subscription and another user’s API key without changing its harness or model. Explicit shared selections remain pinned to the selected account and method. The first successful personal connection sets a default only when none exists. The additive `ai_provider_defaults` table preserves the legacy per-method preferences. Migration selects each user’s most recently updated provider preference (including unavailable accounts), and rerunning it never overwrites a provider default. New writes maintain the legacy table for older servers. A database trigger propagates older servers’ explicit default updates to the provider default. Inserting an additional method default does not replace an existing provider default. diff --git a/maps/feature-map/agents.md b/maps/feature-map/agents.md index bb3b705cd1d..e19eb2205b6 100644 --- a/maps/feature-map/agents.md +++ b/maps/feature-map/agents.md @@ -128,6 +128,14 @@ from the hiring picker; API fixture creation does not prove this UI journey. Automated: [instruction service](../../server/src/__tests__/agent-instructions-service.test.ts) and [instruction revisions](../../server/src/__tests__/agent-instruction-revisions.test.ts) cover file/revision contracts. +The [AI account field regression](../../ui/src/components/ai-connections/AiConnectionField.test.tsx) +checks duplicate provider names, account identity in choices and adoption confirmation, +the exact selected grant, and disabled unavailable accounts with authorized metadata fixtures. +Manually compare a personal API key and shared subscription with the same name in +Harness / Runtime. Confirm their method, scope and available identity before saving. +Reload the saved choice and verify the next eligible task uses it; labels alone +do not establish credential validity or change account access. + Manual: Save a harmless instruction edit, reload, and verify its revision and effect in the next eligible run. Pause and resume the disposable agent; confirm pause prevents new work and does not erase previous runs. For failed setup, use an unavailable model or invalid command on a disposable agent. diff --git a/ui/src/components/ai-connections/AiConnectionField.test.tsx b/ui/src/components/ai-connections/AiConnectionField.test.tsx index cc9e6395173..ecf976d41cb 100644 --- a/ui/src/components/ai-connections/AiConnectionField.test.tsx +++ b/ui/src/components/ai-connections/AiConnectionField.test.tsx @@ -17,7 +17,6 @@ vi.mock("./AiConnectionCredentialStep", () => ({ return
Provider sign-in
; }, })); -vi.mock("./AiConnectionManagement", () => ({ AiConnectionLegacyNotice: () => null })); vi.mock("./AiProviderSetup", () => ({ AiProviderSetup: () =>
Advanced provider setup
})); vi.mock("@/pages/apps/AppLogo", () => ({ AppLogo: () => null })); @@ -82,6 +81,33 @@ it("reconnects the unavailable personal default in place", async () => { expect(onChange).toHaveBeenCalledWith({ provider: "anthropic", method: "subscription", mode: "responsible_user" }); }); +it("identifies duplicate-named accounts before adopting the exact personal grant", async () => { + const personal = account({ id: "00000000-0000-4000-8000-000000000001", grantId: "00000000-0000-4000-8000-000000000002", provider: "openai", method: "api_key", name: "OpenAI", ownerName: "Avery", status: "connected", isDefault: false }); + const shared = account({ id: "00000000-0000-4000-8000-000000000003", grantId: "00000000-0000-4000-8000-000000000004", provider: "openai", method: "subscription", name: "OpenAI", accountLabel: "engineering@example.test", ownership: "shared", ownerUserId: undefined, status: "connected", isDefault: false }); + const sharedKey = account({ id: "00000000-0000-4000-8000-000000000005", grantId: "00000000-0000-4000-8000-000000000006", provider: "openai", method: "api_key", name: "OpenAI", ownerName: "Engineering", ownership: "shared", ownerUserId: undefined, status: "connected", isDefault: false }); + const unavailable = account({ id: "expired-account", grantId: "expired-grant", provider: "openai", method: "api_key", name: "Expired account", ownerName: "Avery" }); + mocks.list.mockResolvedValue({ currentUserId: "owner", connections: [shared, personal, sharedKey, unavailable], canManageConnections: true }); + flushSync(() => root.render( + + )); + await settle(); + await click("Choose a managed connection"); + const trigger = container.querySelector('[role="combobox"][aria-label="Connection"]')!; + flushSync(() => trigger.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }))); + await settle(); + const choices = [...document.querySelectorAll('[role="option"]')]; + expect(choices.map(choice => choice.textContent)).toContain("OpenAI · API key · Personal · Avery"); + expect(choices.map(choice => choice.textContent)).toContain("OpenAI · ChatGPT subscription · Company shared · engineering@example.test"); + expect(choices.map(choice => choice.textContent)).toContain("OpenAI · API key · Company shared · Engineering"); + expect(choices.find(choice => choice.textContent?.includes("Expired account"))?.getAttribute("aria-disabled")).toBe("true"); + flushSync(() => choices.find(choice => choice.textContent === "OpenAI · API key · Personal · Avery")!.click()); + await settle(); + expect(document.querySelector('[role="dialog"]')?.textContent).toContain("OpenAI · API key · Personal · Avery"); + expect(onChange).not.toHaveBeenCalled(); + await click("Use this binding when saved"); + expect(onChange).toHaveBeenCalledExactlyOnceWith({ provider: "openai", method: "api_key", mode: "delegated", connectionId: personal.id, grantId: personal.grantId }); +}); + it("opens provider setup directly when connecting from the advanced mode", async () => { await mount([], true, true); await click("Connect another account"); @@ -163,7 +189,7 @@ it("offers Pi account selection before choosing its required model", async () => }); it.each(["openai", "anthropic", "google", "openrouter"] as const)("keeps a matching saved Pi %s API-key account selectable", async provider => { - const saved = account({ provider, method: "api_key", name: "Pi provider account", status: "connected" }); + const saved = account({ provider, method: "api_key", name: "Pi provider account", accountLabel: "avery@example.test", status: "connected" }); mocks.list.mockResolvedValue({ currentUserId: "owner", connections: [saved], canManageConnections: true }); flushSync(() => root.render( trigger.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }))); await settle(); const choice = [...document.querySelectorAll('[role="option"]')].find(node => node.textContent?.includes("Pi provider account")); diff --git a/ui/src/components/ai-connections/AiConnectionField.tsx b/ui/src/components/ai-connections/AiConnectionField.tsx index 5fcbe5e2757..d7d40336afd 100644 --- a/ui/src/components/ai-connections/AiConnectionField.tsx +++ b/ui/src/components/ai-connections/AiConnectionField.tsx @@ -14,6 +14,7 @@ import { AiConnectionSelect } from "./AiConnectionSelect"; import { AiProviderSetup } from "./AiProviderSetup"; import { AiConnectionCredentialStep } from "./AiConnectionCredentialStep"; import { AiConnectionLegacyNotice } from "./AiConnectionManagement"; +import { aiConnectionDisplayLabel } from "./model"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { @@ -89,6 +90,11 @@ export function AiConnectionField({ enabled: Boolean(provider), }); const personalDefault = accounts.data?.connections.find((account) => account.provider === provider && account.isDefault && account.ownership === "personal" && account.ownerUserId === accounts.data.currentUserId); + const pendingAccount = pendingAdoption?.mode === "responsible_user" + ? personalDefault + : pendingAdoption && pendingAdoption.mode !== "router" + ? accounts.data?.connections.find(account => account.id === pendingAdoption.connectionId && account.grantId === pendingAdoption.grantId) + : undefined; const selectDefault = useMutation({ mutationFn: async (result: NonNullable) => { // Reconnect retains the existing default and its access. A new account @@ -177,10 +183,8 @@ export function AiConnectionField({ {pendingAdoption?.mode !== "router" &&

{pendingAdoption?.mode === "responsible_user" - ? `Responsible user’s default. For you: ${accounts.data?.connections.find((account) => account.isDefault && account.provider === provider)?.name ?? "Not connected"}. Other users use their own default.` - : accounts.data?.connections.find( - (account) => account.id === pendingAdoption?.connectionId, - )?.name} + ? `Responsible user’s default. For you: ${pendingAccount ? aiConnectionDisplayLabel(pendingAccount) : "Not connected"}. Other users use their own default.` + : pendingAccount && aiConnectionDisplayLabel(pendingAccount)}

} {pendingAdoption?.mode !== "router" &&

After adoption, missing credentials block execution. Previous diff --git a/ui/src/components/ai-connections/AiConnectionSelect.tsx b/ui/src/components/ai-connections/AiConnectionSelect.tsx index 4eb82c01f1a..1eb36785f1e 100644 --- a/ui/src/components/ai-connections/AiConnectionSelect.tsx +++ b/ui/src/components/ai-connections/AiConnectionSelect.tsx @@ -14,15 +14,15 @@ import { } from "@/components/ui/select"; import { AppLogo } from "@/pages/apps/AppLogo"; import { Button } from "@/components/ui/button"; -import { aiConnectionProblem, personalAiDefault } from "./model"; +import { aiConnectionDisplayLabel, aiConnectionProblem, personalAiDefault } from "./model"; import type { AiConnectionPickerProps } from "./AiConnectionPicker"; import type { AiConnectionSummary } from "./model"; -function ConnectionLabel({ connection, label }: { connection: AiConnectionSummary; label?: string }) { +function ConnectionLabel({ connection, label, wrap = false }: { connection: AiConnectionSummary; label?: string; wrap?: boolean }) { return ( - {label ?? connection.name} + {label ?? aiConnectionDisplayLabel(connection)} ); } @@ -119,7 +119,7 @@ export function AiConnectionSelect({ onValueChange={change} disabled={readOnly || loading || Boolean(error)} > - + {incompatible && selected && ( - + )} {compatible.map((c) => ( @@ -144,7 +144,7 @@ export function AiConnectionSelect({ value={c.id} disabled={Boolean(aiConnectionProblem(c))} > - + ))} {(canUseDefault || value?.mode === "responsible_user") && ( diff --git a/ui/src/components/ai-connections/model.ts b/ui/src/components/ai-connections/model.ts index e6e645be500..3ce4ad2e080 100644 --- a/ui/src/components/ai-connections/model.ts +++ b/ui/src/components/ai-connections/model.ts @@ -57,6 +57,16 @@ export function aiMethodLabel(provider: AiProvider, method: AiAuthMethod) { : provider === "github" ? "Personal access token" : "API key"; } +export function aiConnectionDisplayLabel(connection: AiConnectionSummary) { + const personal = connection.ownership === "personal"; + return [ + connection.name, + aiMethodLabel(connection.provider, connection.method), + personal ? "Personal" : "Company shared", + connection.accountLabel || connection.ownerName, + ].filter(Boolean).join(" · "); +} + export function matchesAiRequirement( connection: AiConnectionSummary, requirement: AiConnectionRequirement, diff --git a/ui/storybook/stories/agents/bulk-settings.stories.tsx b/ui/storybook/stories/agents/bulk-settings.stories.tsx index b1ae65d3042..690ab6ae5e0 100644 --- a/ui/storybook/stories/agents/bulk-settings.stories.tsx +++ b/ui/storybook/stories/agents/bulk-settings.stories.tsx @@ -120,7 +120,7 @@ export const ConnectionJourney: Story = { play: async ({ canvasElement }) => { await expect(await screen.findByText("1 other selected agent won’t change.")).toBeVisible(); await userEvent.click(screen.getByRole("checkbox", { name: "Change connection" })); await userEvent.click(await screen.findByRole("combobox", { name: "Connection" })); - await userEvent.click(screen.getByRole("option", { name: "OpenAI · Production" })); + await userEvent.click(screen.getByRole("option", { name: /^OpenAI · Production ·/ })); await expect(screen.getByRole("button", { name: "Update 4 agents" })).toBeEnabled(); await userEvent.click(screen.getByRole("button", { name: "Update 4 agents" })); await expect(await within(canvasElement).findByText("Updated 4 Codex agents.")).toBeVisible(); @@ -131,7 +131,7 @@ export const RetryJourney: Story = { args: { failOne: true }, play: async ({ can const screen = await selectCodex(canvasElement); await userEvent.click(await screen.findByRole("checkbox", { name: "Change connection" })); await userEvent.click(await screen.findByRole("combobox", { name: "Connection" })); - await userEvent.click(screen.getByRole("option", { name: "OpenAI · Production" })); + await userEvent.click(screen.getByRole("option", { name: /^OpenAI · Production ·/ })); await expect(screen.getByRole("button", { name: "Update 4 agents" })).toBeEnabled(); await userEvent.click(screen.getByRole("button", { name: "Update 4 agents" })); await expect(await screen.findByText("3 updated · 1 couldn’t be saved")).toBeVisible(); diff --git a/ui/storybook/stories/design-proposals/bulk-agent-settings.stories.tsx b/ui/storybook/stories/design-proposals/bulk-agent-settings.stories.tsx index f92685f6e65..54222b132fe 100644 --- a/ui/storybook/stories/design-proposals/bulk-agent-settings.stories.tsx +++ b/ui/storybook/stories/design-proposals/bulk-agent-settings.stories.tsx @@ -46,7 +46,7 @@ export const ConnectionJourney: Story = { await step("Changing only the connection keeps every model", async () => { await userEvent.click(screen.getByRole("checkbox", { name: "Change connection" })); await userEvent.click(screen.getByRole("combobox", { name: "Connection" })); - await userEvent.click(screen.getByRole("option", { name: "OpenAI · Production" })); + await userEvent.click(screen.getByRole("option", { name: /^OpenAI · Production ·/ })); await userEvent.click(screen.getByRole("button", { name: "Update 4 agents" })); await expect(await canvas.findByText("Updated 4 Codex agents.")).toBeVisible(); await expect(canvas.getByTestId("agent-row-scout")).toHaveTextContent("gpt-5.3-codex"); @@ -100,7 +100,7 @@ export const RetryJourney: Story = { play: async ({ canvasElement }) => { const screen = within(canvasElement.ownerDocument.body); await userEvent.click(await screen.findByRole("combobox", { name: "Connection" })); - await userEvent.click(screen.getByRole("option", { name: "OpenAI · Production" })); + await userEvent.click(screen.getByRole("option", { name: /^OpenAI · Production ·/ })); await userEvent.click(screen.getByRole("button", { name: "Update 4 agents" })); await expect(await screen.findByText("3 updated · 1 couldn’t be saved")).toBeVisible(); await expect(within(canvasElement).getByTestId("agent-row-atlas")).toHaveTextContent("OpenAI · Production"); diff --git a/ui/storybook/stories/runs-adapters/provider-routing-06-production.stories.tsx b/ui/storybook/stories/runs-adapters/provider-routing-06-production.stories.tsx index 540420d7ec1..35235a5ba95 100644 --- a/ui/storybook/stories/runs-adapters/provider-routing-06-production.stories.tsx +++ b/ui/storybook/stories/runs-adapters/provider-routing-06-production.stories.tsx @@ -61,6 +61,13 @@ const accounts: AiManagedConnectionSummary[] = [ isDefault: true, status: "connected", }, + ...["engineering@example.test", "design@example.test"].map((accountLabel, index) => ({ + id: `00000000-0000-4000-8000-00000000000${index + 8}`, + grantId: `00000000-0000-4000-8000-00000000001${index}`, + companyId, provider: "openai" as const, method: "subscription" as const, + name: "OpenAI", ownership: "shared" as const, accountLabel, + isDefault: false, status: "connected" as const, + })), ]; function Providers({ canManageConnections = true }: { canManageConnections?: boolean }) { const [client] = useState(() => { @@ -200,7 +207,7 @@ export const NewAgentConnectionModes: Story = { await expect(canvas.getByRole("radio", { name: "Advanced Custom Gateway" })).toBeChecked(); await expect(canvas.getByRole("button", { name: "Use connection" })).toBeDisabled(); await userEvent.click(canvas.getByRole("combobox", { name: "Connection" })); - await userEvent.click(within(canvasElement.ownerDocument.body).getByRole("option", { name: "Company OpenRouter" })); + await userEvent.click(within(canvasElement.ownerDocument.body).getByRole("option", { name: "Company OpenRouter · API key · Company shared" })); await userEvent.click(canvas.getByRole("button", { name: "Use connection" })); await expect(canvas.getByRole("status")).toHaveTextContent("Continue to Configure with openrouter."); await userEvent.click(canvas.getByRole("radio", { name: "OpenAI Subscription" })); @@ -259,13 +266,15 @@ export const CompatibleConnectionDropdown: Story = { body.getByRole("option", { name: "Responsible user’s default" }), ).toHaveAttribute("data-state", "checked"); await expect( - body.getByRole("option", { name: "Company OpenRouter" }), + body.getByRole("option", { name: "Company OpenRouter · API key · Company shared" }), ).toBeVisible(); + await expect(body.getByRole("option", { name: "OpenAI · ChatGPT subscription · Company shared · engineering@example.test" })).toBeVisible(); + await expect(body.getByRole("option", { name: "OpenAI · ChatGPT subscription · Company shared · design@example.test" })).toBeVisible(); await expect( - body.queryByRole("option", { name: "My Claude subscription" }), + body.queryByRole("option", { name: /My Claude subscription/ }), ).not.toBeInTheDocument(); await userEvent.click( - body.getByRole("option", { name: "Company OpenRouter" }), + body.getByRole("option", { name: "Company OpenRouter · API key · Company shared" }), ); await expect( within(canvasElement).getByRole("combobox", { name: "Connection" }),