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
8 changes: 8 additions & 0 deletions backend/__tests__/unit/routes/agentBinding.phase2.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,14 @@ describe('placement request', () => {
expect((await User.findById(bot._id)).botMetadata).toMatchObject({ machineId: null, requestedMachineId: 'machine-b' });
});

it('returns when each active computer was added, so a shared name can be told apart', async () => {
const placement = await request(app).get('/api/agent-binding/placement').query({ agentName: 'wren-test' }).expect(200);
const machineA = await Machine.findOne({ machineId: 'machine-a' }).lean();
expect(placement.body.activeMachines).toContainEqual({
machineId: 'machine-a', name: 'Mac A', createdAt: machineA.createdAt.toISOString(),
});
});

it('does not offer or select an expired daemon credential', async () => {
await User.updateOne({ _id: bot._id }, { $set: { 'botMetadata.machineId': 'machine-a' } });
await AgentCredential.updateOne({ machineId: 'machine-b' }, { $set: { expiresAt: new Date(Date.now() - 1000) } });
Expand Down
9 changes: 9 additions & 0 deletions backend/__tests__/unit/services/machineService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,15 @@ describe('ADR-026 machine lifecycle service', () => {
);
});

it('lists an owner’s computers with the createdAt the database stored, so a picker can date a shared name', async () => {
const ownerUserId = new mongoose.Types.ObjectId();
const saved = await Machine.create({ ownerUserId, machineId: 'mach-round-trip', name: 'Sams-MacBook' });

const { machines } = await machineService.listMachinesForOwner(ownerUserId);

expect(machines).toEqual([expect.objectContaining({ machineId: 'mach-round-trip', createdAt: saved.createdAt })]);
});

it('returns only the credential-bound machine for daemon status', async () => {
const ownerUserId = new mongoose.Types.ObjectId();
const first = await machineService.registerMachine({ ownerUserId, name: 'First Mac' });
Expand Down
8 changes: 5 additions & 3 deletions backend/routes/agentBinding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,15 +181,17 @@ router.get('/placement', bindingRateLimit, auth, async (req: express.Request & {
isBot: true, 'botMetadata.agentName': agentName, 'botMetadata.instanceId': instanceId,
}).select('botMetadata.machineId botMetadata.requestedMachineId').lean();
if (!identity) return res.status(404).json({ message: 'Agent identity not found' });
const machines = await Machine.find({ ownerUserId: req.user?.id }).select('machineId name').lean();
const activeMachines = (await Promise.all(machines.map(async (machine: { machineId: string; name: string }) => ({
const machines = await Machine.find({ ownerUserId: req.user?.id }).select('machineId name createdAt').lean();
type PlacementMachine = { machineId: string; name: string; createdAt: Date };
const activeMachines = (await Promise.all(machines.map(async (machine: PlacementMachine) => ({
machineId: machine.machineId,
name: machine.name,
createdAt: machine.createdAt,
active: Boolean(await AgentCredential.exists({ kind: 'daemon', ownerUserId: req.user?.id,
machineId: machine.machineId, status: 'active',
$or: [{ expiresAt: null }, { expiresAt: { $gt: new Date() } }] }))
&& await machineHasLiveAuthorization(machine.machineId, req.user!.id!),
})))).filter((machine) => machine.active).map(({ machineId, name }) => ({ machineId, name }));
})))).filter((machine) => machine.active).map(({ machineId, name, createdAt }) => ({ machineId, name, createdAt }));
const boundMachineId = identity.botMetadata?.machineId || null;
return res.json({
boundMachineId,
Expand Down
4 changes: 4 additions & 0 deletions backend/services/machineService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ export interface MachineView {
id: string;
machineId: string;
name: string;
// When the owner added this computer. Two computers can share a name, and
// the pickers tell them apart by it.
createdAt: Date | null;
lastSeenAt: Date | null;
status: 'online' | 'offline';
agentStates: IMachineAgentState[];
Expand Down Expand Up @@ -66,6 +69,7 @@ const serializeMachine = (machine: IMachine | LeanMachine, now = new Date()): Ma
id: asId(machine._id),
machineId: String(machine.machineId),
name: String(machine.name),
createdAt: machine.createdAt ? new Date(machine.createdAt as string | Date) : null,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
lastSeenAt,
status: lastSeenAt && now.getTime() - lastSeenAt.getTime() <= MACHINE_OFFLINE_AFTER_MS
? 'online'
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1765,6 +1765,7 @@
"machine": {
"online": "online",
"offline": "offline",
"addedOn": "added {{date}}",
"heading": "Now starting:",
"running": "Running on {{machine}} — say hi in the pod.",
"waiting": "Waiting for the daemon on {{machine}} to pick it up…",
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 @@ -1825,6 +1825,7 @@
"machine": {
"online": "在线",
"offline": "离线",
"addedOn": "添加于 {{date}}",
"heading": "正在启动:",
"running": "已在 {{machine}} 上运行——去 pod 里打个招呼吧。",
"waiting": "正在等待 {{machine}} 上的守护进程接管…",
Expand Down
43 changes: 43 additions & 0 deletions frontend/src/v2/__tests__/V2AgentBYOMachine.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -389,4 +389,47 @@ describe('BYO on-my-computer mode', () => {
expect(screen.queryByTestId('byo-machine-result')).toBeNull();
expect(screen.queryByText(/has no runtime token/)).toBeNull();
});

test('two computers with one name get distinct labels and the online one is the default', async () => {
mockGet({
machines: [
{ id: 'm2', machineId: 'mach-new', name: 'Sams-MacBook', status: 'online', createdAt: '2026-10-24T09:00:00.000Z', agentStates: [] },
{ id: 'm1', machineId: 'mach-old', name: 'Sams-MacBook', status: 'offline', createdAt: '2026-09-06T09:00:00.000Z', agentStates: [] },
],
});
renderPage();
await waitFor(() => expect(screen.getByTestId('byo-mode-machine')).toBeInTheDocument());
fireEvent.click(screen.getByTestId('byo-mode-machine'));
const select = screen.getByTestId('byo-machine-select');
const labels = Array.from(select.querySelectorAll('option')).map((option) => option.textContent);
expect(labels[0]).toMatch(/^Sams-MacBook · added Oct 24, .* \(online\)$/);
expect(labels[1]).toMatch(/^Sams-MacBook · added Sep 6, .* \(offline\)$/);
expect(select).toHaveValue('mach-new');
});

test('a unique computer name renders without a date', async () => {
mockGet({
machines: [
{ id: 'm1', machineId: 'mach-a', name: 'Sam’s MacBook', status: 'online', createdAt: '2026-09-06T09:00:00.000Z', agentStates: [] },
],
});
renderPage();
await waitFor(() => expect(screen.getByTestId('byo-mode-machine')).toBeInTheDocument());
fireEvent.click(screen.getByTestId('byo-mode-machine'));
const labels = Array.from(screen.getByTestId('byo-machine-select').querySelectorAll('option')).map((option) => option.textContent);
expect(labels).toEqual(['Sam’s MacBook (online)']);
});

test('the default computer is the online one even when an offline one is listed first', async () => {
mockGet({
machines: [
{ id: 'm1', machineId: 'mach-off', name: 'Old Mac', status: 'offline', createdAt: '2026-09-06T09:00:00.000Z', agentStates: [] },
{ id: 'm2', machineId: 'mach-on', name: 'Studio', status: 'online', createdAt: '2026-10-01T09:00:00.000Z', agentStates: [] },
],
});
renderPage();
await waitFor(() => expect(screen.getByTestId('byo-mode-machine')).toBeInTheDocument());
fireEvent.click(screen.getByTestId('byo-mode-machine'));
expect(screen.getByTestId('byo-machine-select')).toHaveValue('mach-on');
});
});
16 changes: 16 additions & 0 deletions frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,22 @@ test('revoked Runtime row offers an active computer and moves in one request', a
expect(axios.post).toHaveBeenCalledTimes(1);
});

test('two computers with one name are told apart by when each was added', async () => {
axios.get.mockImplementation((url) => Promise.resolve({ data: url.includes('/agent-binding/placement')
? { boundMachineId: 'old', requestedMachineId: null, boundMachineRevoked: true,
activeMachines: [
{ machineId: 'new', name: 'Sams-MacBook', createdAt: '2026-10-24T09:00:00.000Z' },
{ machineId: 'older', name: 'Sams-MacBook', createdAt: '2026-09-06T09:00:00.000Z' },
{ machineId: 'solo', name: 'Studio', createdAt: '2026-09-01T09:00:00.000Z' },
] } : response }));
renderSection();
const select = await screen.findByLabelText('Run on');
const labels = Array.from(select.querySelectorAll('option')).map((option) => option.textContent);
expect(labels[0]).toMatch(/^Sams-MacBook · added Oct 24, /);
expect(labels[1]).toMatch(/^Sams-MacBook · added Sep 6, /);
expect(labels[2]).toBe('Studio');
});

test('without an active computer, the setup hint includes daemon installation', async () => {
renderSection();
expect(await screen.findByText(/Register another computer to move this agent/)).toBeInTheDocument();
Expand Down
50 changes: 50 additions & 0 deletions frontend/src/v2/__tests__/machineLabel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { machineDisplayName, defaultMachine } from '../utils/machineLabel';

// The label is a function of the list it sits in: the same computer reads
// differently when a same-named sibling exists. t echoes the key and date so the
// assertions read the composed string.
const t = (key: string, options?: Record<string, string>) => `${key.split('.').pop()} ${options?.date ?? ''}`;

const older = { machineId: 'mach-old-1111', name: 'Sams-MacBook', createdAt: '2026-09-06T12:00:00.000Z' };
const newer = { machineId: 'mach-new-2222', name: 'Sams-MacBook', createdAt: '2026-10-24T12:00:00.000Z' };

test('a unique computer name is returned unchanged', () => {
expect(machineDisplayName(older, [older, { machineId: 'x', name: 'Studio' }], t, 'en')).toBe('Sams-MacBook');
});

test('a shared name gets the date it was added, and the two differ', () => {
const all = [newer, older];
const a = machineDisplayName(newer, all, t, 'en');
const b = machineDisplayName(older, all, t, 'en');
expect(a).toMatch(/^Sams-MacBook · addedOn Oct 24, /);
expect(b).toMatch(/^Sams-MacBook · addedOn Sep 6, /);
expect(a).not.toBe(b);
});

test('zh-CN reads the added date in Chinese, not English', () => {
const label = machineDisplayName(newer, [newer, older], (key, options) => `添加于 ${options?.date ?? key}`, 'zh-CN');
// Chinese month and day, no English month. The 12:00Z fixture keeps the date
// the same from UTC-11 to UTC+11.
expect(label).toMatch(/^Sams-MacBook · 添加于 10月24日 /);
expect(label).not.toMatch(/Oct|AM|PM/);
});

test('two computers added in the same minute still get distinct labels', () => {
const twinA = { machineId: 'mach-aaaa-0001', name: 'Sams-MacBook', createdAt: '2026-10-24T12:00:00.000Z' };
const twinB = { machineId: 'mach-bbbb-0002', name: 'Sams-MacBook', createdAt: '2026-10-24T12:00:30.000Z' };
const all = [twinA, twinB];
expect(machineDisplayName(twinA, all, t, 'en')).toMatch(/ · 0001$/);
expect(machineDisplayName(twinB, all, t, 'en')).toMatch(/ · 0002$/);
});

test('a shared name with no timestamp falls back to the machine ID tail', () => {
const undated = { machineId: 'mach-cccc-0003', name: 'Sams-MacBook', createdAt: null };
expect(machineDisplayName(undated, [undated, newer], t, 'en')).toBe('Sams-MacBook · 0003');
});

test('the default is the first online computer, else the first listed', () => {
const online = { status: 'online' as const, id: 'a' };
const offline = { status: 'offline' as const, id: 'b' };
expect(defaultMachine([offline, online])).toBe(online);
expect(defaultMachine([offline])).toBe(offline);
});
13 changes: 9 additions & 4 deletions frontend/src/v2/components/V2AgentBYO.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import axios from '../../utils/axiosConfig';
import { recordByoStep, type ByoStep } from '../utils/byoTelemetry';
import getApiBaseUrl from '../../utils/apiBaseUrl';
import { daemonSetupCommand } from '../utils/daemonSetupCommand';
import { defaultMachine, machineDisplayName } from '../utils/machineLabel';
import V2FeaturePage from './V2FeaturePage';
import V2Avatar from './V2Avatar';
import V2Hint from './V2Hint';
Expand Down Expand Up @@ -61,6 +62,7 @@ type MachineRow = {
id: string;
machineId: string;
name: string;
createdAt?: string | null;
status: 'online' | 'offline';
agentStates?: { agentName: string; instanceId: string; state: string }[];
};
Expand Down Expand Up @@ -119,7 +121,7 @@ const sanitizeAgentName = (raw: string): string => raw
.slice(0, 64);

const V2AgentBYO: React.FC = () => {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const api = useV2Api();
const navigate = useNavigate();
// Deep-link prefill (?pod=&name=) — the approved connect_local_agent card
Expand Down Expand Up @@ -274,7 +276,10 @@ const V2AgentBYO: React.FC = () => {
// must not open onto a page that shows no command. Elsewhere the full page
// says why and what works instead.
const showCompactByo = compactByo && listenerAvailable && !showCompactHosted && !personaCard && mode === 'byo';
const selectedMachineName = machines.find((m) => m.machineId === machineId)?.name || machineId;
const selectedMachine = machines.find((m) => m.machineId === machineId);
const selectedMachineName = selectedMachine
? machineDisplayName(selectedMachine, machines, t, i18n.language)
: machineId;
// Model choice for the on-my-computer path. Adapter and model travel
// together: a Codex model slug must never be left behind if the user flips
// back to Claude or auto-detect, and vice versa.
Expand Down Expand Up @@ -541,7 +546,7 @@ const V2AgentBYO: React.FC = () => {
if (cancelled) return;
if (Array.isArray(machineList?.machines) && machineList.machines.length > 0) {
setMachines(machineList.machines);
setMachineId((prev) => prev || machineList.machines[0].machineId);
setMachineId((prev) => prev || defaultMachine(machineList.machines)?.machineId || '');
}
} catch {
// No daemon machines — the "on my computer" card simply doesn't show.
Expand Down Expand Up @@ -1270,7 +1275,7 @@ const V2AgentBYO: React.FC = () => {
>
{machines.map((m) => (
<option key={m.machineId} value={m.machineId}>
{m.name} ({t(m.status === 'online' ? 'agentByo.machine.online' : 'agentByo.machine.offline')})
{machineDisplayName(m, machines, t, i18n.language)} ({t(m.status === 'online' ? 'agentByo.machine.online' : 'agentByo.machine.offline')})
</option>
))}
</select>
Expand Down
7 changes: 4 additions & 3 deletions frontend/src/v2/components/V2AgentRuntimeGrants.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { machineDisplayName } from '../utils/machineLabel';
import { useV2Api } from '../hooks/useV2Api';
import './V2AgentRuntimeGrants.css';
import V2AgentProviderKey from './V2AgentProviderKey';
Expand Down Expand Up @@ -28,7 +29,7 @@ interface Placement {
boundMachineId: string | null;
requestedMachineId: string | null;
boundMachineRevoked: boolean;
activeMachines: Array<{ machineId: string; name: string }>;
activeMachines: Array<{ machineId: string; name: string; createdAt?: string | null }>;
}

interface PlacementFailure {
Expand Down Expand Up @@ -70,7 +71,7 @@ const dateLabel = (value: string | null): string => {
const V2AgentRuntimeGrants: React.FC<Props> = ({
name, instanceId, runtime, onSaveRuntime,
}) => {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const api = useV2Api();
const [grants, setGrants] = useState<AgentGrant[]>([]);
const [loading, setLoading] = useState(true);
Expand Down Expand Up @@ -193,7 +194,7 @@ const V2AgentRuntimeGrants: React.FC<Props> = ({
placement.activeMachines.length > 0 ? <>
<label htmlFor="agent-runtime-machine">{t('agentManage.runtime.moveLabel')}</label>
<select id="agent-runtime-machine" value={machineChoice} onChange={(event) => setMachineChoice(event.target.value)}>
{placement.activeMachines.map((machine) => <option key={machine.machineId} value={machine.machineId}>{machine.name}</option>)}
{placement.activeMachines.map((machine) => <option key={machine.machineId} value={machine.machineId}>{machineDisplayName(machine, placement.activeMachines, t, i18n.language)}</option>)}
</select>
<button type="button" disabled={!machineChoice || moving} onClick={() => { void moveAgent(); }}>
{moving ? t('agentManage.runtime.moving') : t('agentManage.runtime.move')}
Expand Down
38 changes: 38 additions & 0 deletions frontend/src/v2/utils/machineLabel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Two computers can share a name (a reinstall registers a second one under the
// same hostname). A picker then needs something to tell them apart, so a name
// shared with another of the owner's computers gets when it was added. Two
// added in the same minute, or with no timestamp, fall back to the tail of the
// machine ID. A unique name is returned unchanged.
export interface NamedMachine {
machineId: string;
name: string;
createdAt?: string | null;
}

type Translate = (key: string, options?: Record<string, string>) => string;

export function machineDisplayName(
machine: NamedMachine,
all: NamedMachine[],
t: Translate,
language: string,
): string {
const shared = all.filter((other) => other.machineId !== machine.machineId && other.name === machine.name);
if (shared.length === 0) return machine.name;
const idTail = machine.machineId.slice(-4);
if (!machine.createdAt) return `${machine.name} · ${idTail}`;
const addedAt = (m: NamedMachine) => new Date(m.createdAt as string).toLocaleString(language, {
month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit',
});
const stamp = addedAt(machine);
const collides = shared.some((other) => other.createdAt && addedAt(other) === stamp);
return collides
? `${machine.name} · ${t('agentByo.machine.addedOn', { date: stamp })} · ${idTail}`
: `${machine.name} · ${t('agentByo.machine.addedOn', { date: stamp })}`;
}

// The list arrives sorted by lastSeenAt, newest first. Prefer the first online
// computer, so the default is the one that can take the agent right now.
export function defaultMachine<T extends { status: 'online' | 'offline' }>(machines: T[]): T | undefined {
return machines.find((machine) => machine.status === 'online') ?? machines[0];
}
Loading