diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index db4438f0db..5e66a30db7 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1389,6 +1389,7 @@ "podRequired": "Pick a pod to install into.", "tokenEmpty": "Install succeeded but token issuance returned empty. Retry, or contact ops.", "installFailed": "Install failed.", + "nameIsHosted": "{{name}} is your hosted agent's name, and Commonly runs that one. Choose a different name for the agent you run yourself.", "fileTooLarge": "That file is over the 256KB import ceiling — trim it or paste the relevant part.", "importFailed": "Import failed.", "hostedCap": "You already have a hosted agent (beta allows {{cap}}). Connect your own runtime for more.", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 6bcc60ec01..d847e12fdd 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -1449,6 +1449,7 @@ "podRequired": "请选择要安装到的 Pod。", "tokenEmpty": "安装成功,但令牌签发返回为空。请重试,或联系运维。", "installFailed": "安装失败。", + "nameIsHosted": "{{name}} 是你在 Commonly 上运行的托管智能体的名字。请给你自己运行的智能体换一个名字。", "fileTooLarge": "该文件超过 256KB 的导入上限——请精简,或只粘贴相关部分。", "importFailed": "导入失败。", "hostedCap": "你已有一个托管智能体(测试期允许{{cap}}个)。如需更多,请自带运行时。", diff --git a/frontend/src/v2/__tests__/V2AgentBYOListenVerify.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOListenVerify.test.tsx index dc3fb13b2a..0a2c5bcf82 100644 --- a/frontend/src/v2/__tests__/V2AgentBYOListenVerify.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentBYOListenVerify.test.tsx @@ -99,13 +99,14 @@ describe('V2AgentBYO listening verification', () => { await act(async () => { jest.advanceTimersByTime(4000); }); expect(screen.queryByTestId('byo-listen-ok')).not.toBeInTheDocument(); - // Second poll: the just-issued agent (sam-agent) authenticates. + // Second poll: the just-issued agent (sam-claude: your own agent defaults + // to `-`) authenticates. axios.get.mockResolvedValueOnce({ data: { issued: true, connected: true, lastUsedAt: new Date().toISOString(), - connectedAgent: { agentName: 'sam-agent', instanceId: 'default', podId: 'p1' }, + connectedAgent: { agentName: 'sam-claude', instanceId: 'default', podId: 'p1' }, }, }); await act(async () => { jest.advanceTimersByTime(4000); }); diff --git a/frontend/src/v2/__tests__/V2AgentBYOMachine.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOMachine.test.tsx index 53b4a548ad..525bf00e39 100644 --- a/frontend/src/v2/__tests__/V2AgentBYOMachine.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentBYOMachine.test.tsx @@ -156,13 +156,15 @@ describe('BYO on-my-computer mode', () => { fireEvent.click(screen.getByText('Add to this computer')); await waitFor(() => expect(screen.getByTestId('byo-machine-result')).toBeInTheDocument()); + // A computer placement defaults to `-`, never to the + // hosted agent's `-agent` (C14 rehearsal, 2026-10-20). expect(axios.post).toHaveBeenCalledWith('/api/registry/install', expect.objectContaining({ - agentName: 'sam-agent', + agentName: 'sam-local', podId: 'p1', config: expect.objectContaining({ runtime: { runtimeType: 'wrapper' } }), }), expect.anything()); expect(axios.post).toHaveBeenCalledWith('/api/agent-binding/request', { - agentName: 'sam-agent', instanceId: 'default', machineId: 'mach-a', + agentName: 'sam-local', instanceId: 'default', machineId: 'mach-a', }, expect.anything()); // Honest until the daemon reports it: waiting, not live. expect(screen.getByTestId('byo-machine-waiting')).toBeInTheDocument(); diff --git a/frontend/src/v2/__tests__/V2AgentBYOName.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOName.test.tsx new file mode 100644 index 0000000000..7b44569dd8 --- /dev/null +++ b/frontend/src/v2/__tests__/V2AgentBYOName.test.tsx @@ -0,0 +1,149 @@ +// @ts-nocheck +// The default agent name follows where it runs (C14 rehearsal, 2026-10-20). +// "Run it here" takes `-agent`. When "Use my own agent" defaulted to the +// same name, a person who had just started a hosted agent was refused at the +// token step with "Commonly runs this agent, so it has no runtime token": the +// own agent shared the hosted one's name. +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import V2AgentBYO from '../components/V2AgentBYO'; +import { AuthContext } from '../../context/AuthContext'; +import { PERSONA_CARDS } from '../agents/personaCatalogData'; + +jest.mock('../components/V2NavRail', () => () => null); +jest.mock('axios', () => { + const mock = { + get: jest.fn(), + post: jest.fn(), + patch: jest.fn(), + delete: jest.fn(), + defaults: { baseURL: '', headers: { common: {} } }, + interceptors: { + request: { use: jest.fn(), eject: jest.fn() }, + response: { use: jest.fn(), eject: jest.fn() }, + }, + }; + return { __esModule: true, default: mock, ...mock }; +}); + +const axios = jest.requireMock('axios').default; + +const authValue = { + currentUser: { _id: 'u1', username: 'sam' }, + user: { _id: 'u1', username: 'sam' }, + token: 'user-jwt', + loading: false, + error: null, + isAuthenticated: true, + register: jest.fn(), + login: jest.fn(), + logout: jest.fn(), + updateProfile: jest.fn(), +}; + +const pods = [{ _id: 'p1', name: 'Workspace', type: 'chat' }]; + +// Hosting on, so the page asks where the agent runs. +const mockGet = () => { + axios.get.mockImplementation((url) => { + if (url.startsWith('/api/hosted/availability')) { + return Promise.resolve({ data: { configured: true, caps: { agentsPerUser: 1, turnsPerDay: 30 } } }); + } + if (url === '/api/pods') return Promise.resolve({ data: pods }); + return Promise.resolve({ data: {} }); + }); +}; + +const renderAt = (path = '/v2/agents/byo') => { + window.history.pushState({}, '', path); + return render( + + + + + , + ); +}; + +const nameInput = () => screen.getByPlaceholderText('my-mcp-agent'); + +afterEach(() => { + jest.clearAllMocks(); + window.history.pushState({}, '', '/'); +}); + +describe('BYO default agent name follows where it runs', () => { + test('hosted keeps -agent; your own agent is -claude', async () => { + mockGet(); + renderAt(); + await waitFor(() => expect(screen.getByTestId('byo-mode-hosted')).toBeInTheDocument()); + expect(nameInput()).toHaveValue('sam-agent'); + + fireEvent.click(screen.getByTestId('byo-mode-hosted')); + expect(nameInput()).toHaveValue('sam-agent'); + + fireEvent.click(screen.getByTestId('byo-mode-byo')); + expect(nameInput()).toHaveValue('sam-claude'); + + // And back: the defaults never leave the two modes on one name. + fireEvent.click(screen.getByTestId('byo-mode-hosted')); + expect(nameInput()).toHaveValue('sam-agent'); + }); + + test('a Codex link defaults your own agent to -codex', async () => { + mockGet(); + renderAt('/v2/agents/byo?adapter=codex'); + await waitFor(() => expect(screen.getByTestId('byo-mode-byo')).toBeInTheDocument()); + fireEvent.click(screen.getByTestId('byo-mode-byo')); + expect(nameInput()).toHaveValue('sam-codex'); + }); + + // sprint-review on #2365: with ?persona= and no ?name=, nothing marks the + // name as chosen, so only the persona branch's place in defaultNameFor keeps + // a mode click from renaming it. + test('a persona link without ?name= keeps the persona name through both modes', async () => { + const card = PERSONA_CARDS.find((c) => c.availability === 'connect'); + mockGet(); + renderAt(`/v2/agents/byo?persona=${card.key}`); + await waitFor(() => expect(screen.getByTestId('byo-mode-byo')).toBeInTheDocument()); + expect(nameInput()).toHaveValue(`sam-${card.key}`); + fireEvent.click(screen.getByTestId('byo-mode-byo')); + expect(nameInput()).toHaveValue(`sam-${card.key}`); + fireEvent.click(screen.getByTestId('byo-mode-hosted')); + expect(nameInput()).toHaveValue(`sam-${card.key}`); + }); + + test('a name the person typed is never replaced by a mode switch', async () => { + mockGet(); + renderAt(); + await waitFor(() => expect(screen.getByTestId('byo-mode-byo')).toBeInTheDocument()); + fireEvent.change(nameInput(), { target: { value: 'my-reviewer' } }); + fireEvent.click(screen.getByTestId('byo-mode-byo')); + expect(nameInput()).toHaveValue('my-reviewer'); + fireEvent.click(screen.getByTestId('byo-mode-hosted')); + expect(nameInput()).toHaveValue('my-reviewer'); + }); + + test("a name that is the person's hosted agent's is explained, not 'has no runtime token'", async () => { + mockGet(); + axios.post + .mockResolvedValueOnce({ data: { ok: true } }) // /api/registry/install ("already installed" tolerance aside) + .mockRejectedValueOnce({ + response: { + status: 409, + data: { + code: 'in_process_agent_no_token', + error: 'Commonly runs this agent, so it has no runtime token. To run an agent yourself, connect your own agent instead.', + }, + }, + }); + renderAt(); + await waitFor(() => expect(screen.getByTestId('byo-mode-byo')).toBeInTheDocument()); + fireEvent.click(screen.getByTestId('byo-mode-byo')); + fireEvent.change(nameInput(), { target: { value: 'sam-agent' } }); + fireEvent.click(screen.getByRole('button', { name: /get my connect command/i })); + expect(await screen.findByText(/sam-agent is your hosted agent's name/)).toBeInTheDocument(); + expect(screen.queryByText(/has no runtime token/)).toBeNull(); + }); +}); diff --git a/frontend/src/v2/components/V2AgentBYO.tsx b/frontend/src/v2/components/V2AgentBYO.tsx index 904f5789f9..34e880d67e 100644 --- a/frontend/src/v2/components/V2AgentBYO.tsx +++ b/frontend/src/v2/components/V2AgentBYO.tsx @@ -30,6 +30,9 @@ const DEFAULT_SCOPES = [ 'posts:write', 'posts:read', 'memory:read', 'memory:write', ]; const DEFAULT_AGENT_NAME = 'my-mcp-agent'; +// The token routes refuse a name that belongs to an agent Commonly runs (the +// person's hosted agent): say that, not "has no runtime token". +const IN_PROCESS_NAME_CODE = 'in_process_agent_no_token'; const DEFAULT_POD_TYPE = 'chat'; // @latest matters: `agent run` learned to bootstrap from these env vars in // 0.1.9 — a machine with an older global install must upgrade, not skip the @@ -150,16 +153,20 @@ const V2AgentBYO: React.FC = () => { // shared literal default ("my-mcp-agent") 409s for every user after the // first one to accept it. Seed from the username so defaults never collide. const { currentUser } = useAuth(); - const defaultAgentName = (() => { - const u = (currentUser?.username || '').toLowerCase().replace(/[^a-z0-9-]/g, ''); - if (personaCard) return u ? `${u}-${personaCard.key}` : personaCard.key; - return u ? `${u}-agent` : DEFAULT_AGENT_NAME; - })(); - const [name, setName] = useState(prefill.name || defaultAgentName); - // currentUser can resolve after mount — refresh the default if untouched. - useEffect(() => { - setName((prev) => (prev === DEFAULT_AGENT_NAME && defaultAgentName !== DEFAULT_AGENT_NAME ? defaultAgentName : prev)); - }, [defaultAgentName]); + const userSlug = (currentUser?.username || '').toLowerCase().replace(/[^a-z0-9-]/g, ''); + // The default name follows where the agent runs (C14 rehearsal, 2026-10-20): + // "Run it here" takes `-agent`, so an own agent defaulting to the same + // name was refused at the token step once the hosted one existed. Your own + // agent defaults to `-`, a computer placement to + // `-`. A name the person typed is never replaced. + const defaultNameFor = (runMode: string | null, runner: string): string => { + if (personaCard) return userSlug ? `${userSlug}-${personaCard.key}` : personaCard.key; + if (!userSlug) return DEFAULT_AGENT_NAME; + if (runMode === 'byo' || runMode === 'machine') return `${userSlug}-${runner || 'local'}`; + return `${userSlug}-agent`; + }; + const [name, setName] = useState(prefill.name || defaultNameFor(null, '')); + const [nameTouched, setNameTouched] = useState(Boolean(prefill.name)); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [issued, setIssued] = useState<{ token: string; agentName: string; podId: string; issuedAt: number } | null>(null); @@ -251,6 +258,12 @@ const V2AgentBYO: React.FC = () => { // back to Claude or auto-detect, and vice versa. const [adapter, setAdapter] = useState(''); const [model, setModel] = useState(''); + // Re-default while the person hasn't typed a name: when the user resolves + // after mount, and when the mode or the runner changes. + const defaultAgentName = defaultNameFor(mode, mode === 'machine' ? adapter : listenAdapter); + useEffect(() => { + if (!nameTouched) setName(defaultAgentName); + }, [defaultAgentName, nameTouched]); const [placed, setPlaced] = useState<{ agentName: string; machineId: string; machineName: string } | null>(null); const [placedState, setPlacedState] = useState<'waiting' | 'running' | 'slow'>('waiting'); @@ -499,8 +512,12 @@ const V2AgentBYO: React.FC = () => { machineName: selectedMachineName, }); } catch (err) { - const e = err as { response?: { data?: { error?: string; message?: string } }; message?: string }; - setError(e.response?.data?.message || e.response?.data?.error || e.message || t('agentByo.errors.installFailed')); + const e = err as { response?: { data?: { error?: string; message?: string; code?: string } }; message?: string }; + // Defensive: neither route this path calls returns the in-process code + // today (sprint-review on #2365). It reads right the day one does. + setError(e.response?.data?.code === IN_PROCESS_NAME_CODE + ? t('agentByo.errors.nameIsHosted', { name: cleanName }) + : e.response?.data?.message || e.response?.data?.error || e.message || t('agentByo.errors.installFailed')); } finally { setSubmitting(false); } @@ -569,8 +586,10 @@ const V2AgentBYO: React.FC = () => { setMemoryError(null); } } catch (err) { - const e = err as { response?: { data?: { error?: string; message?: string } }; message?: string }; - setError(e.response?.data?.error || e.response?.data?.message || e.message || t('agentByo.errors.installFailed')); + const e = err as { response?: { data?: { error?: string; message?: string; code?: string } }; message?: string }; + setError(e.response?.data?.code === IN_PROCESS_NAME_CODE + ? t('agentByo.errors.nameIsHosted', { name: cleanName }) + : e.response?.data?.error || e.response?.data?.message || e.message || t('agentByo.errors.installFailed')); } finally { setSubmitting(false); } @@ -974,7 +993,7 @@ const V2AgentBYO: React.FC = () => { setName(e.target.value)} + onChange={(e) => { setNameTouched(true); setName(e.target.value); }} placeholder={DEFAULT_AGENT_NAME} className="v2-byo__input" />