Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -1449,6 +1449,7 @@
"podRequired": "请选择要安装到的 Pod。",
"tokenEmpty": "安装成功,但令牌签发返回为空。请重试,或联系运维。",
"installFailed": "安装失败。",
"nameIsHosted": "{{name}} 是你在 Commonly 上运行的托管智能体的名字。请给你自己运行的智能体换一个名字。",
"fileTooLarge": "该文件超过 256KB 的导入上限——请精简,或只粘贴相关部分。",
"importFailed": "导入失败。",
"hostedCap": "你已有一个托管智能体(测试期允许{{cap}}个)。如需更多,请自带运行时。",
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/v2/__tests__/V2AgentBYOListenVerify.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<user>-<adapter>`) 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); });
Expand Down
6 changes: 4 additions & 2 deletions frontend/src/v2/__tests__/V2AgentBYOMachine.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<user>-<adapter|local>`, never to the
// hosted agent's `<user>-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();
Expand Down
149 changes: 149 additions & 0 deletions frontend/src/v2/__tests__/V2AgentBYOName.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
// @ts-nocheck
// The default agent name follows where it runs (C14 rehearsal, 2026-10-20).
// "Run it here" takes `<user>-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(
<AuthContext.Provider value={authValue}>
<MemoryRouter initialEntries={[path]}>
<V2AgentBYO />
</MemoryRouter>
</AuthContext.Provider>,
);
};

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 <user>-agent; your own agent is <user>-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 <user>-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();
});
});
49 changes: 34 additions & 15 deletions frontend/src/v2/components/V2AgentBYO.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string>(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 `<user>-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 `<user>-<claude|codex>`, a computer placement to
// `<user>-<adapter|local>`. 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<string>(prefill.name || defaultNameFor(null, ''));
const [nameTouched, setNameTouched] = useState<boolean>(Boolean(prefill.name));
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [issued, setIssued] = useState<{ token: string; agentName: string; podId: string; issuedAt: number } | null>(null);
Expand Down Expand Up @@ -251,6 +258,12 @@ const V2AgentBYO: React.FC = () => {
// back to Claude or auto-detect, and vice versa.
const [adapter, setAdapter] = useState<MachineAdapter>('');
const [model, setModel] = useState<string>('');
// 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');

Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -974,7 +993,7 @@ const V2AgentBYO: React.FC = () => {
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
onChange={(e) => { setNameTouched(true); setName(e.target.value); }}
placeholder={DEFAULT_AGENT_NAME}
className="v2-byo__input"
/>
Expand Down
Loading