From fdb7444eedf6e683e6734da614c51ccbfa57eb7a Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:47:39 -0700 Subject: [PATCH] feat(first-session): the welcome leads with "Run an agent here, nothing to install", and a hosted agent shows live the moment it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA walk 3 (fresh stranger, production 47ba879b): signup to a live hosted agent took 18.8s raw, but only because the walk asked Scout for hosting. Scout's answer to "Connect an agent I already use" offers only the local path, so a stranger who doesn't ask never learns that nothing needs installing. And for its first 4 seconds the Running panel said "is live" and "Starting…" at once. Lead with hosted: - Where this instance can host (hostedEngineAvailable), the welcome's starts open with "Run an agent here, nothing to install" (SCOUT_HOSTED_START). It navigates to Bring your own in this workspace with hosting chosen (?mode=hosted, #2250), so a live agent is two clicks from the first screen. The two sent starts follow unchanged. Where the instance can't host, the welcome is exactly as before. - Scout's prompt knows the new start, and its first reply to "Connect an agent I already use" adds one clause saying Commonly can run one here instead. Live when live: - The native provision response says ready: true, since native turns run in-process. A Durable Object provision doesn't, because its worker isn't live until it polls. - V2AgentBYO shows running at once when provision says ready, with no status round trip. Otherwise it checks status immediately, then every 4s, instead of waiting for the first interval. Tests: the welcome leads with the hosted start aimed at this pod when the instance can host (the existing arm keeps the two-start welcome when it can't); native provision says ready and Durable Object provision doesn't; a ready provision shows running with no status call; without ready, the first check runs before any interval. Mutations: dropping the hosted start, the ready field, the immediate check or the ready shortcut each fail an arm. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157AG2wo8mJYqKezXNs48Ya --- .../authController.guideIdentity.test.js | 23 +++++++++++++++ backend/__tests__/unit/routes/hosted.test.js | 5 +++- backend/config/native-agents/scout.ts | 21 +++++++++++--- backend/controllers/authController.ts | 16 ++++++++-- backend/routes/hosted.ts | 3 ++ .../v2/__tests__/V2AgentBYOHosted.test.tsx | 29 +++++++++++++++++++ frontend/src/v2/components/V2AgentBYO.tsx | 20 +++++++++---- 7 files changed, 105 insertions(+), 12 deletions(-) diff --git a/backend/__tests__/unit/controllers/authController.guideIdentity.test.js b/backend/__tests__/unit/controllers/authController.guideIdentity.test.js index f7dc3e31d4..73fc78d14b 100644 --- a/backend/__tests__/unit/controllers/authController.guideIdentity.test.js +++ b/backend/__tests__/unit/controllers/authController.guideIdentity.test.js @@ -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'); diff --git a/backend/__tests__/unit/routes/hosted.test.js b/backend/__tests__/unit/routes/hosted.test.js index 21c4908049..414c8ea843 100644 --- a/backend/__tests__/unit/routes/hosted.test.js +++ b/backend/__tests__/unit/routes/hosted.test.js @@ -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( @@ -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(); diff --git a/backend/config/native-agents/scout.ts b/backend/config/native-agents/scout.ts index 687f11b70d..c201690504 100644 --- a/backend/config/native-agents/scout.ts +++ b/backend/config/native-agents/scout.ts @@ -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. 中文也可以,直接说。'; @@ -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 ' diff --git a/backend/controllers/authController.ts b/backend/controllers/authController.ts index 0ee57038f1..c1f2002512 100644 --- a/backend/controllers/authController.ts +++ b/backend/controllers/authController.ts @@ -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 @@ -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, @@ -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 })), + ], }, }); } diff --git a/backend/routes/hosted.ts b/backend/routes/hosted.ts index e73b1fe125..4edc35ca91 100644 --- a/backend/routes/hosted.ts +++ b/backend/routes/hosted.ts @@ -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, diff --git a/frontend/src/v2/__tests__/V2AgentBYOHosted.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOHosted.test.tsx index 86370d5bb8..4ef8a60239 100644 --- a/frontend/src/v2/__tests__/V2AgentBYOHosted.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentBYOHosted.test.tsx @@ -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(); diff --git a/frontend/src/v2/components/V2AgentBYO.tsx b/frontend/src/v2/components/V2AgentBYO.tsx index 3f5f751d33..71a4e43f44 100644 --- a/frontend/src/v2/components/V2AgentBYO.tsx +++ b/frontend/src/v2/components/V2AgentBYO.tsx @@ -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. @@ -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 | undefined; setHostedState('starting'); - const timer = setInterval(async () => { + const check = async () => { ticks += 1; try { const data = await api.get<{ runtime?: { lastPollAt?: number | null; ready?: boolean } }>( @@ -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]); @@ -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;