From 505ca9cc468304ad4ff8cb3dc4f3c90034c1a9ee Mon Sep 17 00:00:00 2001 From: arnestrickmann <115920878+arnestrickmann@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:16:49 -0700 Subject: [PATCH] add separate Claude Duet agent option --- packages/api/src/agents/create-agent-login.ts | 5 +- packages/api/src/agents/route.test.ts | 10 +++ .../integrations/src/agent-providers.test.ts | 30 ++++++- packages/integrations/src/agent-providers.ts | 28 ++++++- packages/integrations/src/index.ts | 1 + .../public/agent-providers/claude-duet.png | Bin 0 -> 6958 bytes .../web/src/components/agent-setup.test.tsx | 38 +++++++-- packages/web/src/components/agent-setup.tsx | 31 ++++++-- .../web/src/pages/connect-agent-page.test.tsx | 64 +++++++++++++++ packages/web/src/pages/connect-agent-page.tsx | 4 +- .../web/src/pages/new-agent-page.test.tsx | 75 ++++++++++++++++++ packages/web/src/pages/new-agent-page.tsx | 19 +++-- 12 files changed, 280 insertions(+), 25 deletions(-) create mode 100644 packages/web/public/agent-providers/claude-duet.png create mode 100644 packages/web/src/pages/connect-agent-page.test.tsx create mode 100644 packages/web/src/pages/new-agent-page.test.tsx diff --git a/packages/api/src/agents/create-agent-login.ts b/packages/api/src/agents/create-agent-login.ts index dbf3b087..54137dba 100644 --- a/packages/api/src/agents/create-agent-login.ts +++ b/packages/api/src/agents/create-agent-login.ts @@ -2,7 +2,7 @@ import {Err, Ok, type Result} from 'ts-results-es'; import {ApiErr, type ApiError, Do, requirePresent} from '@proxy/utils'; import {createAgent, isProviderConflict, isUsernameConflict, type AgentRow} from '@proxy/db/agent'; -import {CUSTOM_AGENT_NAME_MAX, findAgentProvider, type AgentProvider} from '@proxy/integrations'; +import {CUSTOM_AGENT_NAME_MAX, findAgentProvider, loginProviderIdFor, type AgentProvider} from '@proxy/integrations'; import {generatePassword, generateUsername} from './credentials'; @@ -31,7 +31,8 @@ function nameFor(provider: AgentProvider, name: string | undefined): Result> { return Do(async ($) => { - const provider = $(requirePresent(findAgentProvider(providerId), ApiErr.parseError(`Unknown agent ${providerId}`))); + const product = $(requirePresent(findAgentProvider(providerId), ApiErr.parseError(`Unknown agent ${providerId}`))); + const provider = $(requirePresent(findAgentProvider(loginProviderIdFor(product)), ApiErr.parseError(`Unknown login provider for ${providerId}`))); const name = $(nameFor(provider, givenName)); const password = generatePassword(); const passwordHash = await Bun.password.hash(password); diff --git a/packages/api/src/agents/route.test.ts b/packages/api/src/agents/route.test.ts index 3b65315c..14e332e8 100644 --- a/packages/api/src/agents/route.test.ts +++ b/packages/api/src/agents/route.test.ts @@ -92,6 +92,16 @@ describe('handleCreateAgentRoute', () => { expect(result.unwrapErr().kind).toBe('parse_error'); }); + test('stores Claude Duet under its shared Claude login', async () => { + const {handleCreateAgentRoute} = await import('./route'); + await handleCreateAgentRoute(makeRequest({}, {providerId: 'claude-duet'})); + + expect(createAgent.mock.calls[0]?.[0]).toMatchObject({ + providerId: 'claude', + name: 'Claude', + }); + }); + test('names a custom agent as the person did, and any number of them', async () => { const {handleCreateAgentRoute} = await import('./route'); await handleCreateAgentRoute(makeRequest({}, {providerId: 'custom', name: ' Cursor '})); diff --git a/packages/integrations/src/agent-providers.test.ts b/packages/integrations/src/agent-providers.test.ts index 848ce4d1..0477cc64 100644 --- a/packages/integrations/src/agent-providers.test.ts +++ b/packages/integrations/src/agent-providers.test.ts @@ -1,6 +1,11 @@ import {describe, expect, test} from 'bun:test'; -import {AGENT_PROVIDERS, findAgentProvider, suggestAgentProvider} from './agent-providers'; +import { + AGENT_PROVIDERS, + findAgentProvider, + loginProviderIdFor, + suggestAgentProvider, +} from './agent-providers'; describe('agent providers', () => { test('have unique ids', () => { @@ -9,6 +14,20 @@ describe('agent providers', () => { ); }); + test('shared login providers are direct, valid account logins', () => { + for (const product of AGENT_PROVIDERS) { + if (!product.loginProviderId) { + continue; + } + + const loginProvider = findAgentProvider(product.loginProviderId); + expect(loginProvider).toBeDefined(); + expect(loginProvider?.id).not.toBe(product.id); + expect(loginProvider?.custom).not.toBe(true); + expect(loginProvider?.loginProviderId).toBeUndefined(); + } + }); + test('each has its favicon bundled with the web app', async () => { for (const provider of AGENT_PROVIDERS.filter((candidate) => !candidate.custom)) { expect( @@ -30,7 +49,10 @@ describe('agent providers', () => { } }); - test('uses the same Claude Duet logo on the website and in the README', async () => { + test('uses the same Claude Duet logo in the app, on the website and in the README', async () => { + const appLogo = await Bun.file( + new URL('../../web/public/agent-providers/claude-duet.png', import.meta.url), + ).bytes(); const websiteLogo = await Bun.file( new URL('../../website/public/logos/claude-duet.png', import.meta.url), ).bytes(); @@ -38,11 +60,15 @@ describe('agent providers', () => { new URL('../../../docs/assets/agents/claude-duet.png', import.meta.url), ).bytes(); + expect(appLogo).toEqual(readmeLogo); expect(websiteLogo).toEqual(readmeLogo); }); test('finds a provider by id', () => { expect(findAgentProvider('dot')?.name).toBe('Dots'); + const duet = findAgentProvider('claude-duet'); + expect(duet?.name).toBe('Claude Duet'); + expect(duet && loginProviderIdFor(duet)).toBe('claude'); expect(findAgentProvider('other')).toBeUndefined(); }); diff --git a/packages/integrations/src/agent-providers.ts b/packages/integrations/src/agent-providers.ts index 8c3f0a49..f83e6ed6 100644 --- a/packages/integrations/src/agent-providers.ts +++ b/packages/integrations/src/agent-providers.ts @@ -5,6 +5,7 @@ export type AgentProviderId = | 'dot' | 'poke' | 'claude' + | 'claude-duet' | 'claude-code' | 'claude-desktop' | 'hermes' @@ -15,6 +16,8 @@ export type AgentProvider = { id: AgentProviderId; name: string; aliases?: string[]; + /** A distinct product that uses another provider's account-wide agent login. */ + loginProviderId?: AgentProviderId; company: string; /** * How the agent gets its access: `mcp` adds Personal Agent Proxy as a connector and signs in @@ -40,7 +43,9 @@ export type AgentProvider = { faviconUrl?: string; }; -// The agents an organization can make a login for: one each, and any number of custom ones. +// The agent products a person can connect. Most have one login each; a product with a +// loginProviderId shares that provider's account-wide login. An organization can add any number +// of custom agents. export const AGENT_PROVIDERS: AgentProvider[] = [ { id: 'grok-bot', @@ -87,12 +92,20 @@ export const AGENT_PROVIDERS: AgentProvider[] = [ { id: 'claude', name: 'Claude', - aliases: ['Claude Duet'], company: 'Anthropic', connects: 'mcp', local: false, faviconUrl: '/agent-providers/claude.png', }, + { + id: 'claude-duet', + name: 'Claude Duet', + loginProviderId: 'claude', + company: 'Anthropic', + connects: 'mcp', + local: false, + faviconUrl: '/agent-providers/claude-duet.png', + }, { id: 'claude-code', name: 'Claude Code', @@ -140,6 +153,11 @@ export function findAgentProvider(id: string): AgentProvider | undefined { return AGENT_PROVIDERS.find((provider) => provider.id === id); } +/** The provider id stored for a product's login. Some products share one account-wide login. */ +export function loginProviderIdFor(provider: AgentProvider): AgentProviderId { + return provider.loginProviderId ?? provider.id; +} + /** The longest name a custom agent can be given. */ export const CUSTOM_AGENT_NAME_MAX = 60; @@ -163,5 +181,9 @@ export function suggestAgentProvider(clientName: string): AgentProvider | undefi best = {provider, length: candidate.length}; } } - return best?.provider; + if (!best) { + return undefined; + } + + return findAgentProvider(loginProviderIdFor(best.provider)); } diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index 0d222458..ca8d4683 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -2,6 +2,7 @@ export { AGENT_PROVIDERS, CUSTOM_AGENT_NAME_MAX, findAgentProvider, + loginProviderIdFor, suggestAgentProvider, } from './agent-providers'; export type {AgentProvider, AgentProviderId} from './agent-providers'; diff --git a/packages/web/public/agent-providers/claude-duet.png b/packages/web/public/agent-providers/claude-duet.png new file mode 100644 index 0000000000000000000000000000000000000000..effc3b0bd87819c85b32279263ae6b2426a90594 GIT binary patch literal 6958 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 A0{{R3 literal 0 HcmV?d00001 diff --git a/packages/web/src/components/agent-setup.test.tsx b/packages/web/src/components/agent-setup.test.tsx index 5ad50031..63fec3f0 100644 --- a/packages/web/src/components/agent-setup.test.tsx +++ b/packages/web/src/components/agent-setup.test.tsx @@ -123,19 +123,47 @@ describe('AgentSetup', () => { expect(screen.getByText(/Claude Code opens Personal Agent Proxy to sign in/)).toBeDefined(); }); - test('adds Claude and Claude Duet through their shared Claude account connector', () => { + test('offers Claude and Claude Duet as separate products', () => { + renderAgentChoice(); + + expect(screen.getByRole('button', {name: 'Claude Needs a public https address'})).toBeDefined(); + expect( + screen.getByRole('button', {name: 'Claude Duet Needs a public https address'}), + ).toBeDefined(); + }); + + test('adds Claude through its Claude account connector', () => { renderAgentSetup('claude'); - expect(screen.getByText(/Claude Duet uses the same connectors as Claude/)).toBeDefined(); expect(screen.getByRole('link', {name: 'Open Claude'}).getAttribute('href')).toBe( 'https://claude.ai/customize/connectors', ); expect(screen.getByText(/choose Register automatically under OAuth client/)).toBeDefined(); + expect(screen.getByText(/Choose Claude when asked which agent it works as/)).toBeDefined(); + }); + + test('adds Claude Duet as its own product through the shared Claude login', () => { + const claude: AgentLogin = { + id: 'agent-1', + providerId: 'claude', + name: 'Claude', + username: 'claude-k7q2', + createdAt: '2026-10-01T00:00:00Z', + lastActiveAt: null, + revokedAt: null, + grants: [], + }; + const continued: Array = []; + renderAgentSetup('claude-duet', [claude], (agent) => continued.push(agent)); + + expect(screen.getByText('Add Personal Agent Proxy to Claude Duet')).toBeDefined(); expect( - screen.getByText( - /That login gives Claude and Claude Duet the same access and activity history/, - ), + screen.getByText(/Claude Duet uses the connectors in your Claude account/), ).toBeDefined(); + expect(screen.getByText(/Claude Duet shares that login/)).toBeDefined(); + + fireEvent.keyDown(window, {key: 'Enter', metaKey: true}); + expect(continued).toEqual([claude]); }); test('adds a custom MCP agent, however many there are already', () => { diff --git a/packages/web/src/components/agent-setup.tsx b/packages/web/src/components/agent-setup.tsx index 0bead87d..875368d2 100644 --- a/packages/web/src/components/agent-setup.tsx +++ b/packages/web/src/components/agent-setup.tsx @@ -1,4 +1,9 @@ -import {findAgentProvider, type AgentProvider, type AgentProviderId} from '@proxy/integrations'; +import { + findAgentProvider, + loginProviderIdFor, + type AgentProvider, + type AgentProviderId, +} from '@proxy/integrations'; import {CheckIcon, LoaderCircleIcon} from 'lucide-react'; import {useEffect, useState, type ReactNode} from 'react'; import {useRevalidator, useSearchParams} from 'react-router'; @@ -73,7 +78,9 @@ export function AgentSetup({ const [error, setError] = useState(null); const chosen = chosenId ? findAgentProvider(chosenId) : undefined; const existingFor = (provider: AgentProvider) => - live.find((agent) => agent.providerId === provider.id && !known.has(agent.id)) ?? null; + live.find( + (agent) => agent.providerId === loginProviderIdFor(provider) && !known.has(agent.id), + ) ?? null; const existing = chosen ? existingFor(chosen) : null; useCommandEnter( () => { @@ -186,7 +193,7 @@ export function AgentSetup({ return ( @@ -269,14 +276,23 @@ const MCP_STEPS: Partial< > > = { claude: { + where: 'In Claude, open Customize, then Connectors, and choose Add custom connector.', + url: 'https://claude.ai/customize/connectors', + openLabel: 'Claude', + afterAdd: + 'Name the connector, paste the server address, and continue. Choose Sign in now, then choose Register automatically under OAuth client.', + authorize: + 'Claude opens Personal Agent Proxy to sign in. Choose Claude when asked which agent it works as.', + }, + 'claude-duet': { where: - 'In Claude, open Customize, then Connectors, and choose Add custom connector. Claude Duet uses the same connectors as Claude.', + 'Claude Duet uses the connectors in your Claude account. In Claude, open Customize, then Connectors, and choose Add custom connector.', url: 'https://claude.ai/customize/connectors', openLabel: 'Claude', afterAdd: 'Name the connector, paste the server address, and continue. Choose Sign in now, then choose Register automatically under OAuth client.', authorize: - 'Claude opens Personal Agent Proxy to sign in. Choose Claude when asked which agent it works as. That login gives Claude and Claude Duet the same access and activity history.', + 'Claude opens Personal Agent Proxy to sign in. Choose Claude when asked which agent it works as. Claude Duet shares that login, its access, and its activity history.', }, 'claude-code': { where: 'Run the command above in a terminal.', @@ -347,7 +363,10 @@ function McpAgent({ <> {provider.custom && !name ? 'Any agent that speaks MCP' : subject} works through Personal - Agent Proxy as a connector, signed in with its own login. + Agent Proxy as a connector + {provider.loginProviderId + ? `, sharing the ${findAgentProvider(provider.loginProviderId)?.name ?? 'account'} login.` + : ', signed in with its own login.'} {!reachable && } {!PUBLIC && provider.custom && } diff --git a/packages/web/src/pages/connect-agent-page.test.tsx b/packages/web/src/pages/connect-agent-page.test.tsx new file mode 100644 index 00000000..5bccd0ac --- /dev/null +++ b/packages/web/src/pages/connect-agent-page.test.tsx @@ -0,0 +1,64 @@ +/// + +import {afterEach, describe, expect, test} from 'bun:test'; +import {cleanup, render, screen} from '@testing-library/react'; +import {createMemoryRouter, RouterProvider} from 'react-router'; +import type {AgentLogin} from '@/lib/types'; +import {ConnectAgentPage} from '@/pages/connect-agent-page'; + +afterEach(() => { + cleanup(); +}); + +const claude: AgentLogin = { + id: 'agent-1', + providerId: 'claude', + name: 'Claude', + username: 'claude-k7q2', + createdAt: '2026-10-01T00:00:00Z', + lastActiveAt: null, + revokedAt: null, + grants: [], +}; + +function renderConsent(agents: AgentLogin[]) { + const router = createMemoryRouter( + [ + { + path: '/agent-auth/connect', + loader: () => ({ + kind: 'ok', + request: 'sealed-request', + details: { + clientName: 'Claude Duet', + redirectHost: 'claude.ai', + suggestedProviderId: 'claude', + }, + agents, + }), + element: , + }, + ], + {initialEntries: ['/agent-auth/connect']}, + ); + + render(); +} + +describe('ConnectAgentPage', () => { + test('connects Claude Duet through an existing Claude login', async () => { + renderConsent([claude]); + + const existing = await screen.findByRole('radio', {name: 'Claude Anthropic'}); + expect(existing).toHaveProperty('checked', true); + expect(screen.queryByRole('radio', {name: /Claude Duet/})).toBeNull(); + }); + + test('offers one canonical Claude login when Claude Duet has none yet', async () => { + renderConsent([]); + + const newClaude = await screen.findByRole('radio', {name: 'Claude New login'}); + expect(newClaude).toHaveProperty('checked', true); + expect(screen.queryByRole('radio', {name: /Claude Duet/})).toBeNull(); + }); +}); diff --git a/packages/web/src/pages/connect-agent-page.tsx b/packages/web/src/pages/connect-agent-page.tsx index c8939010..30a27a8f 100644 --- a/packages/web/src/pages/connect-agent-page.tsx +++ b/packages/web/src/pages/connect-agent-page.tsx @@ -64,7 +64,9 @@ function Consent({ consent: {allow: true, agentId: agent.id} as const, })), ...AGENT_PROVIDERS.filter( - (provider) => provider.custom || !agents.some((agent) => agent.providerId === provider.id), + (provider) => + provider.loginProviderId === undefined && + (provider.custom || !agents.some((agent) => agent.providerId === provider.id)), ).map((provider) => ({ key: `new-${provider.id}`, providerId: provider.id, diff --git a/packages/web/src/pages/new-agent-page.test.tsx b/packages/web/src/pages/new-agent-page.test.tsx new file mode 100644 index 00000000..ee4548ee --- /dev/null +++ b/packages/web/src/pages/new-agent-page.test.tsx @@ -0,0 +1,75 @@ +/// + +import {afterEach, describe, expect, test} from 'bun:test'; +import {cleanup, render, screen} from '@testing-library/react'; +import {TooltipProvider} from '@proxy/ui/components/tooltip'; +import {ThemeProvider} from '@proxy/ui/components/theme-provider'; +import {createMemoryRouter, RouterProvider} from 'react-router'; +import {HumanSide} from '@/components/human-side'; +import type {AgentLogin} from '@/lib/types'; +import {NewAgentPage} from '@/pages/new-agent-page'; + +afterEach(() => { + cleanup(); +}); + +function claudeLogin(revokedAt: string | null): AgentLogin { + return { + id: 'agent-1', + providerId: 'claude', + name: 'Claude', + username: 'claude-k7q2', + createdAt: '2026-10-01T00:00:00Z', + lastActiveAt: null, + revokedAt, + grants: [], + }; +} + +function renderNewAgentPage(agents: AgentLogin[]) { + const router = createMemoryRouter( + [ + { + id: 'human', + loader: () => ({ + user: {id: 'user-1', email: 'person@example.com', name: null, image: null}, + onboarded: true, + }), + element: , + children: [ + { + path: '/agents/new', + loader: () => ({agents}), + element: , + }, + ], + }, + ], + {initialEntries: ['/agents/new']}, + ); + + return render( + + + + + , + ); +} + +describe('NewAgentPage', () => { + test('offers Claude Duet separately when its shared Claude login is connected', async () => { + renderNewAgentPage([claudeLogin(null)]); + + expect(screen.queryByRole('button', {name: /^Claude Connected$/})).toBeNull(); + const duet = await screen.findByRole('button', {name: 'Claude Duet Connected'}); + expect(duet.querySelector('img')?.getAttribute('src')).toBe('/agent-providers/claude-duet.png'); + }); + + test('does not offer Claude Duet until its revoked shared login is restored', async () => { + renderNewAgentPage([claudeLogin('2026-10-21T00:00:00Z')]); + + expect(await screen.findByText('Which agent do you use?')).toBeDefined(); + expect(screen.queryByRole('button', {name: /Claude Duet/})).toBeNull(); + }); +}); diff --git a/packages/web/src/pages/new-agent-page.tsx b/packages/web/src/pages/new-agent-page.tsx index cc4dbb6d..b5896ce4 100644 --- a/packages/web/src/pages/new-agent-page.tsx +++ b/packages/web/src/pages/new-agent-page.tsx @@ -1,18 +1,25 @@ -import {AGENT_PROVIDERS} from '@proxy/integrations'; +import {AGENT_PROVIDERS, loginProviderIdFor} from '@proxy/integrations'; import {useLoaderData, useNavigate} from 'react-router'; import {AgentSetup} from '@/components/agent-setup'; import {AppShell, PageTitle} from '@/components/app-shell'; import {BackButton} from '@/components/back-button'; import type {agentsLoader} from '@/loaders'; -// A new agent, set up as in the welcome flow: chosen from the companies without a login yet, or a -// custom MCP agent, then handed its login or added as a connector, and on to its page. +// A new agent, set up as in the welcome flow: chosen from the products without a login yet, a +// product that shares another's login, or a custom MCP agent, then handed its login or added as a +// connector, and on to its page. export function NewAgentPage() { const {agents} = useLoaderData(); const navigate = useNavigate(); - const providers = AGENT_PROVIDERS.filter( - (provider) => provider.custom || !agents.some((agent) => agent.providerId === provider.id), - ); + const providers = AGENT_PROVIDERS.filter((provider) => { + if (provider.loginProviderId !== undefined) { + return !agents.some( + (agent) => agent.providerId === loginProviderIdFor(provider) && agent.revokedAt !== null, + ); + } + + return provider.custom || !agents.some((agent) => agent.providerId === provider.id); + }); return (