diff --git a/backend/__tests__/unit/routes/hosted.test.js b/backend/__tests__/unit/routes/hosted.test.js index a09d7ccc57..301b0cbb39 100644 --- a/backend/__tests__/unit/routes/hosted.test.js +++ b/backend/__tests__/unit/routes/hosted.test.js @@ -66,6 +66,10 @@ jest.mock('../../../services/nativeRuntimeService', () => ({ })); const mockMarkByoOpened = jest.fn(); jest.mock('../../../services/onboardingMarks', () => ({ markByoOpened: (...args) => mockMarkByoOpened(...args) })); +const mockCompleteConnectAgent = jest.fn(); +jest.mock('../../../services/starterTaskService', () => ({ + completeConnectAgentStarterTask: (...args) => mockCompleteConnectAgent(...args), +})); jest.mock('../../../routes/registry/tokens', () => ({ revokeRuntimeTokensForAgent: (...args) => mockRevokeRuntimeTokens(...args), issueRuntimeTokenForAgent: (...args) => mockIssueToken(...args), @@ -285,6 +289,9 @@ describe('/api/hosted', () => { }); expect(mockHosted.provisionAgent).toHaveBeenCalledWith({ agentName: 'scout', instanceId: 'demo', runtimeToken: 'cm_agent_secret' }); expect(installation.config.get('hosted').provisionedAt).toBeInstanceOf(Date); + // The worker's first use of its token completes the onboarding card, as a + // BYO agent's does; provision does not claim it is listening yet. + expect(mockCompleteConnectAgent).not.toHaveBeenCalled(); }); it('rotates: revokes the old credential rows, clears the stored hashes, mints fresh', async () => { @@ -467,6 +474,19 @@ describe('/api/hosted on the native engine', () => { expect(JSON.stringify(res.body)).not.toMatch(/cm_agent_/); }); + // QA 2026-10-16: "Connect your first agent" stayed pending after a hosted + // agent went live, because the card completes on a runtime token's first + // use and a native hosted agent has no token. Provision is its live moment. + it('provision completes the "Connect your first agent" card in the agent\'s pod', async () => { + mockFindOne.mockResolvedValue(nativeInstallation()); + const res = await request(app).post('/api/hosted/provision').send({ agentName: 'scout' }); + expect(res.status).toBe(200); + expect(mockCompleteConnectAgent).toHaveBeenCalledTimes(1); + const [{ podIds, agentLabel }] = mockCompleteConnectAgent.mock.calls[0]; + expect(podIds.map(String)).toEqual(['pod-1']); + expect(agentLabel).toBeTruthy(); + }); + it('provision of a native install 503s when the native runtime is unavailable, even with the worker up', async () => { mockHosted.isConfigured.mockReturnValue(true); mockNativeAvailable.mockReturnValue(false); diff --git a/backend/routes/hosted.ts b/backend/routes/hosted.ts index bf3b9b23c8..b0c724ca8f 100644 --- a/backend/routes/hosted.ts +++ b/backend/routes/hosted.ts @@ -21,6 +21,7 @@ const auth = require('../middleware/auth'); const { AgentInstallation } = require('../models/AgentRegistry'); const User = require('../models/User'); const AgentIdentityService = require('../services/agentIdentityService'); +const { completeConnectAgentStarterTask } = require('../services/starterTaskService'); const hostedRuntime = require('../services/hostedRuntimeService'); const { isNativeRuntimeAvailable } = require('../services/nativeRuntimeService'); const { @@ -258,6 +259,14 @@ router.post('/provision', hostedRateLimit, auth, async (req: any, res: any) => { { _id: installation._id }, { $set: { 'config.hosted.provisionedAt': hosted.provisionedAt || new Date() } }, ); + // Live from here, and with no runtime token there is no first use to + // complete "Connect your first agent" the way a BYO or Durable Object + // agent's does (starterTaskService). QA 2026-10-16: the card sat pending + // while the hosted agent answered. Never throws, never awaited. + void completeConnectAgentStarterTask({ + podIds: [installation.podId], + agentLabel: agentUser.botMetadata?.displayName || installation.displayName || agentName, + }); return res.json({ provisioned: true, engine, diff --git a/backend/services/starterTaskService.ts b/backend/services/starterTaskService.ts index 6a52931c5f..a2bc2c86e3 100644 --- a/backend/services/starterTaskService.ts +++ b/backend/services/starterTaskService.ts @@ -10,8 +10,10 @@ * starter task done" while the card sat in Pending forever. * * Caller contract: fire-and-forget from the token-auth first-use paths - * (agentRuntimeAuth HTTP + agentWebSocketService WS). Never throws; a board - * write must not be able to fail an auth request. + * (agentRuntimeAuth HTTP + agentWebSocketService WS), and from the native + * hosted provision (routes/hosted.ts), where no token exists to be first-used + * and the agent is live once provisioned. Never throws; a board write must not + * be able to fail an auth or provision request. */ import mongoose from 'mongoose'; import Task from '../models/Task';