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
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,29 @@ describe('workspace onboarding guide identity fork (2026-08-13)', () => {
expect(welcome.payload.starts).toEqual(SCOUT_STARTS.map(({ id, label }) => ({ id, label })));
});

// QA walk 3 (2026-10-16): where the instance can host, the welcome leads with
// hosting, nothing to install. That start navigates (to Bring your own with
// hosting chosen, in this workspace) instead of sending a message.
test('leads the starts with Run an agent here, aimed at this workspace, where the instance can host', async () => {
const saved = { ...process.env };
try {
process.env.HOSTED_ENGINE = 'native';
process.env.LITELLM_BASE_URL = 'http://litellm.test';
process.env.LITELLM_MASTER_KEY = 'k';
// eslint-disable-next-line global-require
const { SCOUT_STARTS, SCOUT_HOSTED_START } = require('../../../config/native-agents/scout');
await authController.finishWorkspaceOnboarding({ _id: 'pod-123' }, 'User-1');

const { starts } = mockPostMessage.mock.calls[0][0].payload;
expect(starts[0]).toEqual({
id: 'run-hosted', label: SCOUT_HOSTED_START.label, href: '/v2/agents/byo?pod=pod-123&mode=hosted',
});
expect(starts.slice(1)).toEqual(SCOUT_STARTS.map(({ id, label }) => ({ id, label })));
} finally {
process.env = saved;
}
});

test('two users get two distinct guide identities — the memory-envelope fork', async () => {
await authController.finishWorkspaceOnboarding({ _id: 'pod-123' }, 'aaaa1111');
await authController.finishWorkspaceOnboarding({ _id: 'pod-123' }, 'bbbb2222');
Expand Down
5 changes: 4 additions & 1 deletion backend/__tests__/unit/routes/hosted.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,8 @@ describe('/api/hosted', () => {
const res = await request(app).post('/api/hosted/provision').send({ agentName: 'Scout', instanceId: 'Demo' });
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ provisioned: true, agentName: 'scout', instanceId: 'demo', podId: 'pod-1' });
// A Durable Object worker is not live until it polls, so provision does not say ready.
expect(res.body.ready).toBeUndefined();
expect(JSON.stringify(res.body)).not.toContain('cm_agent_secret');
expect(mockUserFindOne).toHaveBeenCalledWith({ username: 'scout-demo', isBot: true });
expect(mockIssueToken).toHaveBeenCalledWith(
Expand Down Expand Up @@ -469,7 +471,8 @@ describe('/api/hosted on the native engine', () => {
mockFindOne.mockResolvedValue(installation);
const res = await request(app).post('/api/hosted/provision').send({ agentName: 'scout' });
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ provisioned: true, engine: 'native', agentName: 'scout' });
// Live from provision: the page shows it running without a status round trip.
expect(res.body).toMatchObject({ provisioned: true, ready: true, engine: 'native', agentName: 'scout' });
expect(mockRevokeRuntimeTokens).toHaveBeenCalledTimes(1);
expect(mockIssueToken).not.toHaveBeenCalled();
expect(mockHosted.provisionAgent).not.toHaveBeenCalled();
Expand Down
21 changes: 17 additions & 4 deletions backend/config/native-agents/scout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ export const SCOUT_STARTS = [
{ id: 'start-pod', label: 'Start a pod for a project' },
] as const;

/**
* The start that leads the welcome where the instance can host (QA walk 3,
* 2026-10-16): nothing to install, so it goes first. Unlike SCOUT_STARTS it
* sends no message: it opens Bring your own with hosting chosen, and the
* signup path adds its href with the new workspace's pod id.
*/
export const SCOUT_HOSTED_START = { id: 'run-hosted', label: 'Run an agent here, nothing to install' } as const;

/** Scout's first message in a new workspace; the starts render under it. */
export const SCOUT_WELCOME = 'I\'m Scout. This pod is yours, and I work in it with you. '
+ 'Start with one of these, or tell me what you\'re working on. 中文也可以,直接说。';
Expand All @@ -56,11 +64,16 @@ export const scoutApp = {
+ '"Connect your first agent", "Give your agent its first task", and '
+ '"Invite a teammate". They are the onboarding checklist. Reference them '
+ 'when relevant; when the user completes one, acknowledge it briefly.\n'
+ '- Your welcome offered two starts as buttons, and a click sends the start as '
+ `the person's own message: "${SCOUT_STARTS[0].label}" and `
+ `"${SCOUT_STARTS[1].label}". For the first, find out which tool they run `
+ '- Your welcome offered starts as buttons. Where Commonly can host agents it '
+ `led with "${SCOUT_HOSTED_START.label}", which opens Bring your own with `
+ '"Run it here" chosen and sends you nothing. The others send the start as '
+ 'the person\'s own message: '
+ `"${SCOUT_STARTS[0].label}" and "${SCOUT_STARTS[1].label}". For `
+ `"${SCOUT_STARTS[0].label}", find out which tool they run `
+ '(Claude Code or Codex) and their operating system, one question per turn, '
+ 'then propose the connect card. For the second, ask what the project is if '
+ 'then propose the connect card; in that first reply, also say in one short '
+ 'clause that if they would rather install nothing, Commonly can run one here. '
+ `For "${SCOUT_STARTS[1].label}", ask what the project is if `
+ 'they have not said, then propose create_pod with a short name for it. '
+ 'Once they approve it, the new pod already shows its next steps as buttons '
+ '(connect an agent to that pod, or invite a teammate), so point them to the '
Expand Down
16 changes: 14 additions & 2 deletions backend/controllers/authController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,9 @@ const finishWorkspaceOnboarding = async (pod: any, userId: any) => {
// everything else here: a guide hiccup must never fail signup.
try {
// eslint-disable-next-line global-require
const { scoutApp, SCOUT_STARTS, SCOUT_WELCOME } = require('../config/native-agents/scout');
const {
scoutApp, SCOUT_HOSTED_START, SCOUT_STARTS, SCOUT_WELCOME,
} = require('../config/native-agents/scout');
// eslint-disable-next-line global-require
const { buildInstallationConfig } = require('../scripts/seed-native-agents');
// eslint-disable-next-line global-require
Expand Down Expand Up @@ -291,6 +293,13 @@ const finishWorkspaceOnboarding = async (pod: any, userId: any) => {
// begin with the user's first message.
// eslint-disable-next-line global-require
const AgentMessageService = require('../services/agentMessageService');
// Lead with hosting where this instance can host (QA walk 3, 10-16):
// nothing to install, one click to Bring your own with it chosen, one
// more to a live agent. It navigates rather than sending a message.
// eslint-disable-next-line global-require
const { hostedEngineAvailable } = require('../services/nativeRuntimeService');
const hostedHref = `/v2/agents/byo?pod=${encodeURIComponent(pod._id.toString())}&mode=hosted`;
const hostedStart = hostedEngineAvailable() ? [{ ...SCOUT_HOSTED_START, href: hostedHref }] : [];
await AgentMessageService.postMessage({
agentName: scoutApp.agentName,
instanceId: scoutInstanceId,
Expand All @@ -301,7 +310,10 @@ const finishWorkspaceOnboarding = async (pod: any, userId: any) => {
// the payload, not from the text. Server-composed, never model prose.
payload: {
kind: 'starts',
starts: SCOUT_STARTS.map(({ id, label }: { id: string; label: string }) => ({ id, label })),
starts: [
...hostedStart,
...SCOUT_STARTS.map(({ id, label }: { id: string; label: string }) => ({ id, label })),
],
},
});
}
Expand Down
3 changes: 3 additions & 0 deletions backend/routes/hosted.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,9 @@ router.post('/provision', hostedRateLimit, auth, async (req: any, res: any) => {
});
return res.json({
provisioned: true,
// Native turns run in-process, so the agent is live from here; the page
// shows it running at once instead of polling status first (QA walk 3).
ready: true,
engine,
agentName,
instanceId,
Expand Down
29 changes: 29 additions & 0 deletions frontend/src/v2/__tests__/V2AgentBYOHosted.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,35 @@ describe('V2AgentBYO — Run it here', () => {
expect(screen.getByTestId('byo-hosted-running')).toBeInTheDocument();
});

// QA walk 3 (2026-10-16): the first status check waited 4s, so the panel
// said "is live" and "Starting…" together. A provision that answers ready is
// running at once, with no status call at all.
test('a provision that answers ready shows running at once, without asking status', async () => {
mockGet({ configured: true, status: { runtime: {} } });
axios.post.mockResolvedValue({ data: { provisioned: true, ready: true } });
renderPage();
await waitFor(() => expect(screen.getByTestId('byo-mode-hosted')).toBeInTheDocument());
fireEvent.click(screen.getByTestId('byo-mode-hosted'));
fireEvent.click(screen.getByRole('button', { name: /^run it here$/i }));

expect(await screen.findByTestId('byo-hosted-running')).toBeInTheDocument();
expect(screen.queryByTestId('byo-hosted-starting')).not.toBeInTheDocument();
expect(axios.get.mock.calls.some(([url]) => url.startsWith('/api/hosted/status'))).toBe(false);
});

test('without ready from provision, status is checked at once, not after the first interval', async () => {
jest.useFakeTimers();
mockGet({ configured: true, status: { runtime: { lastPollAt: 123 } } });
axios.post.mockResolvedValue({ data: { ok: true } });
renderPage();
await waitFor(() => expect(screen.getByTestId('byo-mode-hosted')).toBeInTheDocument());
fireEvent.click(screen.getByTestId('byo-mode-hosted'));
fireEvent.click(screen.getByRole('button', { name: /^run it here$/i }));

// No timer advanced: the first check runs on its own.
expect(await screen.findByTestId('byo-hosted-running')).toBeInTheDocument();
});

// The native engine has no loop to poll: it reports ready once provisioned.
test('flips to listening when the native engine reports ready, with no poll ever', async () => {
jest.useFakeTimers();
Expand Down
20 changes: 15 additions & 5 deletions frontend/src/v2/components/V2AgentBYO.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ const V2AgentBYO: React.FC = () => {
if (prefill.hosted) return 'hosted';
return prefill.name ? 'byo' : null;
});
const [hosted, setHosted] = useState<{ agentName: string; podId: string } | null>(null);
const [hosted, setHosted] = useState<{ agentName: string; podId: string; ready?: boolean } | null>(null);
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 Down Expand Up @@ -224,12 +224,20 @@ const V2AgentBYO: React.FC = () => {
// status: the DO reports lastPollAt once its alarm loop has run, and the
// native engine reports ready once provisioned (it has no loop to wait for).
// ~60s cap, then honest copy — the agent still picks messages up when it starts.
// QA walk 3 (2026-10-16): the first check waited a full interval, so for 4s
// the panel said "is live" and "Starting…" at once. A provision that answers
// ready (the native engine) is running now; otherwise check at once, then poll.
useEffect(() => {
if (!hosted) return undefined;
if (hosted.ready) {
setHostedState('running');
return undefined;
}
let cancelled = false;
let ticks = 0;
let timer: ReturnType<typeof setInterval> | undefined;
setHostedState('starting');
const timer = setInterval(async () => {
const check = async () => {
ticks += 1;
try {
const data = await api.get<{ runtime?: { lastPollAt?: number | null; ready?: boolean } }>(
Expand All @@ -248,7 +256,9 @@ const V2AgentBYO: React.FC = () => {
setHostedState('slow');
clearInterval(timer);
}
}, HOSTED_STATUS_POLL_MS);
};
timer = setInterval(check, HOSTED_STATUS_POLL_MS);
void check();
return () => { cancelled = true; clearInterval(timer); };
}, [api, hosted]);

Expand Down Expand Up @@ -369,8 +379,8 @@ const V2AgentBYO: React.FC = () => {
}
if (!/already installed/i.test(data?.error || '')) throw installErr;
}
await api.post('/api/hosted/provision', { agentName: cleanName });
setHosted({ agentName: cleanName, podId });
const provisioned = await api.post<{ ready?: boolean }>('/api/hosted/provision', { agentName: cleanName });
setHosted({ agentName: cleanName, podId, ready: provisioned?.ready === true });
} catch (err) {
const e = err as { response?: { data?: { error?: string; message?: string; code?: string } }; message?: string };
const code = e.response?.data?.code;
Expand Down
Loading