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
14 changes: 14 additions & 0 deletions backend/__tests__/unit/services/agentStateService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,20 @@ describe('deriveAgentState', () => {
expect(stopped.state).not.toBe('reachable');
});

// Walk 4 (2026-10-16): the shell hides Scout's "Run an agent here" start
// once the viewer holds a hosted agent, so the row says which agents are hosted.
test('marks a hosted install as hosted, and no other runtime class', () => {
const hosted = deriveAgentState({
agentName: 'lee-agent', instanceId: 'default', installedBy: 'u1',
config: { runtime: { runtimeType: 'hosted' } },
}, [], 'u1', NOW);
expect(hosted).toMatchObject({ hosted: true, isOwner: true });
const others = [{ runtimeType: 'native' }, { runtimeType: 'webhook', host: 'byo' }, { runtimeType: 'moltbot' }, {}];
for (const runtime of others) {
expect(deriveAgentState({ agentName: 'x', config: { runtime } }, [], 'u1', NOW).hosted).toBe(false);
}
});

test('a push webhook (webhookUrl set) is reachable regardless of token use', () => {
const row = deriveAgentState(
byoInstall({ config: { runtime: { runtimeType: 'webhook', host: 'byo', webhookUrl: 'https://x.example/cap' } } }),
Expand Down
4 changes: 4 additions & 0 deletions backend/services/agentStateService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ export interface AgentStateRow {
state: AgentReachState;
lastUsedAt: string | null;
isOwner: boolean;
/** A Commonly-hosted agent ("Run it here"); the beta allows one per account. */
hosted: boolean;
fixCommand?: string;
}

Expand Down Expand Up @@ -108,6 +110,8 @@ export function deriveAgentState(
state,
lastUsedAt: lastUsedAt ? lastUsedAt.toISOString() : null,
isOwner,
// The shell hides Scout's "Run it here" start once the viewer holds one.
hosted: runtimeType === 'hosted',
...(isOwner && needsFix ? { fixCommand: `commonly agent run ${agentName}` } : {}),
};
}
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1457,6 +1457,14 @@
"after": "Then reload this page — an “On my computer” option appears, and adding agents to that machine never needs a terminal again.",
"copy": "Copy",
"copied": "Copied"
},
"compact": {
"title": "Run an agent here",
"lead": "Commonly runs it for you. Nothing to install, and it is live a few seconds after you press the button.",
"joins": "It joins {{pod}} as {{name}}.",
"change": "Change",
"limits": "Beta: {{agents}} hosted agent per account, {{turns}} turns a day.",
"otherWays": "Other ways to connect"
}
},
"compare": {
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -1517,6 +1517,14 @@
"after": "然后刷新本页——会出现“在我的电脑上”选项,之后往那台电脑添加智能体再也不需要终端。",
"copy": "复制",
"copied": "已复制"
},
"compact": {
"title": "在这里运行智能体",
"lead": "由 Commonly 代为运行,无需安装,点下按钮几秒后即可上线。",
"joins": "它会以 {{name}} 的身份加入 {{pod}}。",
"change": "更改",
"limits": "测试期:每个账号{{agents}}个托管智能体,每天{{turns}}轮。",
"otherWays": "其他接入方式"
}
},
"agentProfile": {
Expand Down
67 changes: 58 additions & 9 deletions frontend/src/v2/__tests__/V2AgentBYOHosted.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
// token. When it cannot, the page is exactly the BYO page it was.
import React from 'react';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom';
import V2AgentBYO from '../components/V2AgentBYO';
import { AuthContext } from '../../context/AuthContext';

Expand Down Expand Up @@ -190,21 +190,69 @@ describe('V2AgentBYO — Run it here', () => {
);
};

test('a hosted offer link (?mode=hosted) opens on Run it here, one click from live', async () => {
// Walk 4 (2026-10-16): arriving from a "Run it here" button, a stranger read
// 130 words before reaching it. The link has already chosen hosting, so the
// page is just the act, where it lands and the limits.
test('a hosted offer link (?mode=hosted) opens the compact page, one click from live', async () => {
mockGet({ configured: true, status: { runtime: { lastPollAt: 1 } } });
axios.post.mockResolvedValue({ data: { ok: true } });
renderAt('/v2/agents/byo?pod=p1&mode=hosted');

await waitFor(() => expect(screen.getByTestId('byo-mode-hosted')).toHaveAttribute('aria-pressed', 'true'));
expect(screen.getByTestId('byo-mode-byo')).toHaveAttribute('aria-pressed', 'false');
fireEvent.click(screen.getByRole('button', { name: /^run it here$/i }));
const compact = await screen.findByTestId('byo-hosted-compact');
expect(screen.getByRole('heading', { name: 'Run an agent here' })).toBeInTheDocument();
await waitFor(() => expect(compact).toHaveTextContent('It joins Workspace as sam-agent.'));
await waitFor(() => expect(compact).toHaveTextContent('Beta: 1 hosted agent per account, 200 turns a day.'));
// None of the full page's choice and setup copy is in the way.
expect(screen.queryByTestId('byo-mode-hosted')).not.toBeInTheDocument();
expect(screen.queryByTestId('byo-add-computer')).not.toBeInTheDocument();

const act = screen.getByRole('button', { name: /^run it here$/i });
await waitFor(() => expect(act).toBeEnabled());
fireEvent.click(act);
await screen.findByTestId('byo-hosted-result');
const installCall = axios.post.mock.calls.find(([url]) => url === '/api/registry/install');
expect(installCall[1]).toMatchObject({ agentName: 'sam-agent', podId: 'p1', config: { runtime: { runtimeType: 'hosted' } } });
window.history.pushState({}, '', '/');
});

test('"Other ways to connect" opens the full page with Run it here still chosen', async () => {
mockGet({ configured: true, status: { runtime: { lastPollAt: 1 } } });
renderAt('/v2/agents/byo?pod=p1&mode=hosted');

fireEvent.click(await screen.findByTestId('byo-other-ways'));
expect(screen.queryByTestId('byo-hosted-compact')).not.toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Bring your own agent' })).toBeInTheDocument();
expect(screen.getByTestId('byo-mode-hosted')).toHaveAttribute('aria-pressed', 'true');
window.history.pushState({}, '', '/');
});

// Walk 4: typing the agent's handle cost 9.6s. "Go to your pod" hands the
// composer "@<agent> " through navigation state.
test('Go to your pod opens the pod with the composer addressed to the new agent', async () => {
mockGet({ configured: true, status: { runtime: { lastPollAt: 1 } } });
axios.post.mockResolvedValue({ data: { provisioned: true, ready: true } });
let landed;
const Probe = () => { landed = useLocation(); return null; };
window.history.pushState({}, '', '/v2/agents/byo?pod=p1&mode=hosted');
render(
<AuthContext.Provider value={authValue}>
<MemoryRouter initialEntries={['/v2/agents/byo?pod=p1&mode=hosted']}>
<Routes>
<Route path="/v2/agents/byo" element={<V2AgentBYO />} />
<Route path="/v2/pods/:podId" element={<Probe />} />
</Routes>
</MemoryRouter>
</AuthContext.Provider>,
);
const act = await screen.findByRole('button', { name: /^run it here$/i });
await waitFor(() => expect(act).toBeEnabled());
fireEvent.click(act);
fireEvent.click(await screen.findByRole('button', { name: /go to your pod/i }));
expect(landed.pathname).toBe('/v2/pods/p1');
expect(landed.state).toEqual({ composeDraft: '@sam-agent ' });
window.history.pushState({}, '', '/');
});

test('a hosted offer link offers no act until the instance says it can host', async () => {
let answerAvailability;
axios.get.mockImplementation((url) => {
Expand All @@ -216,12 +264,13 @@ describe('V2AgentBYO — Run it here', () => {
});
renderAt('/v2/agents/byo?pod=p1&mode=hosted');

const podPicker = await screen.findByRole('combobox', { name: /install into pod/i });
await waitFor(() => expect(podPicker).toHaveValue('p1'));
expect(screen.queryByRole('button', { name: /^run it here$/i })).not.toBeInTheDocument();
// The compact page shows at once, with the act disabled until the
// instance answers that it can host.
expect(await screen.findByTestId('byo-hosted-compact')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^run it here$/i })).toBeDisabled();

await act(async () => { answerAvailability({ data: { configured: true, caps: { agentsPerUser: 1, turnsPerDay: 30 } } }); });
expect(await screen.findByRole('button', { name: /^run it here$/i })).toBeEnabled();
await waitFor(() => expect(screen.getByRole('button', { name: /^run it here$/i })).toBeEnabled());
window.history.pushState({}, '', '/');
});

Expand Down
85 changes: 84 additions & 1 deletion frontend/src/v2/__tests__/V2ThreadStarts.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
// so nothing else in the pod (a join row, Scout's follow-ups, another member)
// can take the offer away before it is seen.
import React, { useState } from 'react';
import { fireEvent, render, screen, within } from '@testing-library/react';
import {
fireEvent, render, screen, waitFor, within,
} from '@testing-library/react';
import { MemoryRouter, useLocation } from 'react-router-dom';
import V2Thread from '../components/V2Thread';
import { AuthContext } from '../../context/AuthContext';
Expand Down Expand Up @@ -262,3 +264,84 @@ describe("V2Thread — a new pod's next steps", () => {
expect(startsGroup()).toBeNull();
});
});

// Walk 4 (2026-10-16): two first-minute frictions on the hosted path.
describe('Scout starts and the composer after a hosted agent goes live', () => {
const axios = jest.requireMock('axios').default;
const HOSTED = 'Run an agent here, nothing to install';
const welcomeWithHosted = {
...welcome,
payload: {
kind: 'starts',
starts: [
{ id: 'run-hosted', label: HOSTED, href: '/v2/agents/byo?pod=pod-1&mode=hosted' },
...welcome.payload.starts,
],
},
};
const agentStates = (agents) => {
axios.get.mockImplementation((url) => (typeof url === 'string' && url.endsWith('/agent-states')
? Promise.resolve({ data: { agents } })
: Promise.resolve({ data: {} })));
};
afterEach(() => { axios.get.mockImplementation(() => Promise.resolve({ data: {} })); });

test('"Run an agent here" goes away once the viewer holds a hosted agent; the other starts stay', async () => {
agentStates([{ agentName: 'maya-agent', instanceId: 'default', state: 'reachable', isOwner: true, hosted: true }]);
renderThread([welcomeWithHosted]);
expect(await screen.findByRole('button', { name: CONNECT })).toBeInTheDocument();
await waitFor(() => expect(screen.queryByRole('button', { name: HOSTED })).not.toBeInTheDocument());
expect(screen.getByRole('button', { name: POD_START })).toBeInTheDocument();
});

test('it stays while the viewer holds none, including when someone else does', async () => {
agentStates([{ agentName: 'lee-agent', instanceId: 'default', state: 'reachable', isOwner: false, hosted: true }]);
renderThread([welcomeWithHosted]);
await waitFor(() => expect(axios.get)
.toHaveBeenCalledWith(expect.stringContaining('/agent-states'), expect.anything()));
expect(await screen.findByRole('button', { name: HOSTED })).toBeInTheDocument();
});

// The real-browser walk caught what the test above could not: the pod
// arrives after the first render, and a hook placed after the `!pod` early
// return crashed the thread ("Rendered more hooks than during the previous
// render"). This renders the loading state first, then the pod.
test('the draft arrives when the pod loads after the first render, without a hooks crash', async () => {
const LateHarness = () => {
const [ready, setReady] = useState(false);
React.useEffect(() => { const id = setTimeout(() => setReady(true), 0); return () => clearTimeout(id); }, []);
const detail = {
pod: ready ? { _id: 'pod-1', name: 'My Workspace', type: 'chat', createdBy: 'u1' } : null,
members: [{ _id: 'u1', username: 'maya', isBot: false }],
messages: ready ? [welcome] : [],
agents: [],
sendMessage: jest.fn(),
loading: !ready,
error: null,
refresh: jest.fn(),
};
return <V2Thread detail={detail} />;
};
render(
<AuthContext.Provider value={authValue}>
<MemoryRouter initialEntries={[{ pathname: '/v2/pods/pod-1', state: { composeDraft: '@maya-agent ' } }]}>
<LateHarness />
</MemoryRouter>
</AuthContext.Provider>,
);
const composer = await screen.findByPlaceholderText(/message my workspace/i);
await waitFor(() => expect(composer).toHaveValue('@maya-agent '));
});

test('a page that sends someone here can address the composer: the draft arrives, focused, once', async () => {
render(
<AuthContext.Provider value={authValue}>
<MemoryRouter initialEntries={[{ pathname: '/v2/pods/pod-1', state: { composeDraft: '@maya-agent ' } }]}>
<Harness initial={[welcome]} sendSpy={jest.fn()} />
</MemoryRouter>
</AuthContext.Provider>,
);
const composer = await screen.findByPlaceholderText(/message my workspace/i);
await waitFor(() => expect(composer).toHaveValue('@maya-agent '));
});
});
60 changes: 55 additions & 5 deletions frontend/src/v2/components/V2AgentBYO.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,11 @@ const V2AgentBYO: React.FC = () => {
return prefill.name ? 'byo' : null;
});
const [hosted, setHosted] = useState<{ agentName: string; podId: string; ready?: boolean } | null>(null);
// Walk 4 (2026-10-16): a stranger who arrived from a "Run it here" button
// read 130 words (31s) before reaching it. They already chose hosting, so
// the page shows only the act, where it lands and the limits; the full page
// is one click away.
const [compactHosted, setCompactHosted] = useState<boolean>(prefill.hosted);
const [hostedState, setHostedState] = useState<'starting' | 'running' | 'slow'>('starting');
// ADR-026 Phase 2: "on my computer" — install + placement request; the
// daemon on the chosen machine adopts, provisions, and starts the agent.
Expand All @@ -178,6 +183,10 @@ const V2AgentBYO: React.FC = () => {
const mode = chosenMode === 'hosted' && !hosting?.configured
? (choosingMode ? null : 'byo')
: chosenMode ?? (choosingMode ? null : 'byo');
// Shown from first paint for a hosted link (no flash of the full page while
// availability loads); the act stays disabled until the instance says it can
// host, and an instance that cannot falls back to the full page.
const showCompactHosted = compactHosted && !personaCard && (mode === 'hosted' || !choicesKnown);
// One lookup, two readers (TASK-163): the preview note and the placement
// payload must name the SAME machine, so it is derived once here rather
// than restated at each call site.
Expand Down Expand Up @@ -660,9 +669,9 @@ const V2AgentBYO: React.FC = () => {

return (
<V2FeaturePage
eyebrow={t('agentByo.eyebrow')}
title={t('agentByo.title')}
description={t('agentByo.description')}
eyebrow={showCompactHosted && !hosted ? t('agentByo.mode.onCommonly') : t('agentByo.eyebrow')}
title={showCompactHosted && !hosted ? t('agentByo.compact.title') : t('agentByo.title')}
description={showCompactHosted && !hosted ? t('agentByo.compact.lead') : t('agentByo.description')}
showPodsSidebar={false}
>
<div className="v2-byo__layout">
Expand All @@ -678,7 +687,46 @@ const V2AgentBYO: React.FC = () => {
</div>
</div>
)}
{!issued && !hosted && !placed && (
{showCompactHosted && !issued && !hosted && !placed && (
<div className="v2-byo__form" data-testid="byo-hosted-compact">
{/* Each line waits for its own data: no "joins as" or "0 turns" on first paint. */}
{pods.some((p) => p._id === podId) && (
<p className="v2-byo__hint">
{t('agentByo.compact.joins', {
name: sanitizeAgentName(name) || DEFAULT_AGENT_NAME,
pod: pods.find((p) => p._id === podId)?.name || '',
})}
{' '}
<button type="button" className="v2-byo__link" onClick={() => setCompactHosted(false)}>
{t('agentByo.compact.change')}
</button>
</p>
)}
{error && <div className="v2-byo__error">{error}</div>}
<button
type="button"
onClick={submit}
disabled={submitting || !podId || mode !== 'hosted'}
className="v2-byo__submit"
>
{submitting ? t('agentByo.actions.starting') : t('agentByo.actions.runHere')}
</button>
{hosting?.configured && (
<p className="v2-byo__hint">
{t('agentByo.compact.limits', { agents: hosting.caps.agentsPerUser, turns: hosting.caps.turnsPerDay })}
</p>
)}
<button
type="button"
className="v2-byo__link"
data-testid="byo-other-ways"
onClick={() => setCompactHosted(false)}
>
{t('agentByo.compact.otherWays')}
</button>
</div>
)}
{!showCompactHosted && !issued && !hosted && !placed && (
<div className="v2-byo__form">
{(hosting?.configured || machines.length > 0) && (
<>
Expand Down Expand Up @@ -942,7 +990,9 @@ const V2AgentBYO: React.FC = () => {
<div className="v2-byo__cta-row">
<button
type="button"
onClick={() => navigate(`/v2/pods/${hosted.podId}`)}
// Walk 4: typing the agent's 32-character handle cost 9.6s. The
// pod opens with the composer already addressed to it.
onClick={() => navigate(`/v2/pods/${hosted.podId}`, { state: { composeDraft: `@${hosted.agentName} ` } })}
className="v2-byo__submit"
>
{t('agentByo.actions.goToPod')}
Expand Down
Loading
Loading