diff --git a/backend/__tests__/unit/config/nativeAgents.scoutKnowledge.test.js b/backend/__tests__/unit/config/nativeAgents.scoutKnowledge.test.js index 7746c45e37..f1bdc62b99 100644 --- a/backend/__tests__/unit/config/nativeAgents.scoutKnowledge.test.js +++ b/backend/__tests__/unit/config/nativeAgents.scoutKnowledge.test.js @@ -125,6 +125,15 @@ describe('Scout onboarding knowledge', () => { // QA 2026-10-16: Scout said "200 turns a day" while the card and the meter // said 30, because the prompt kept its own copy of the plan line. The limits // now reach Scout only through the hosted offer's description. + // Vera on #2297: the welcome's hosted start runs in place on the person's own + // welcome (services/welcomeHostedStart.ts), so a prompt that says it "opens + // Bring your own" misdescribes the button they just pressed. + test('says the welcome\'s hosted start runs in place, with the page only as the fallback', () => { + expect(prompt).toMatch(/click on it starts a hosted agent right here in their workspace/); + expect(prompt).toMatch(/only if that cannot run does it open Bring your own/); + expect(prompt).not.toMatch(/nothing to install", which opens Bring your own/); + }); + test('quotes no hosted limit from memory, and hands off to Run it here with a button', () => { expect(prompt).not.toMatch(/\d+\s+turns (a|per) day/i); expect(prompt).toContain('commonly_offer_hosted_agent'); diff --git a/backend/__tests__/unit/controllers/authController.guideIdentity.test.js b/backend/__tests__/unit/controllers/authController.guideIdentity.test.js index 73fc78d14b..1238b0476e 100644 --- a/backend/__tests__/unit/controllers/authController.guideIdentity.test.js +++ b/backend/__tests__/unit/controllers/authController.guideIdentity.test.js @@ -130,8 +130,10 @@ describe('workspace onboarding guide identity fork (2026-08-13)', () => { }); // 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. + // hosting, nothing to install. It sends no message: on the person's own + // welcome it runs in place (TASK-290), with the beta limits under it, and its + // link opens Bring your own with hosting chosen, in this workspace, for + // anyone it does not run for. test('leads the starts with Run an agent here, aimed at this workspace, where the instance can host', async () => { const saved = { ...process.env }; try { @@ -142,9 +144,15 @@ describe('workspace onboarding guide identity fork (2026-08-13)', () => { const { SCOUT_STARTS, SCOUT_HOSTED_START } = require('../../../config/native-agents/scout'); await authController.finishWorkspaceOnboarding({ _id: 'pod-123' }, 'User-1'); + // eslint-disable-next-line global-require + const { hostedCaps } = require('../../../services/hostedRuntimeService'); 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', + id: 'run-hosted', + label: SCOUT_HOSTED_START.label, + href: '/v2/agents/byo?pod=pod-123&mode=hosted', + action: 'run-hosted', + caps: hostedCaps(), }); expect(starts.slice(1)).toEqual(SCOUT_STARTS.map(({ id, label }) => ({ id, label }))); } finally { diff --git a/backend/__tests__/unit/controllers/messageController.test.js b/backend/__tests__/unit/controllers/messageController.test.js index 51400e1441..d55c6cffbd 100644 --- a/backend/__tests__/unit/controllers/messageController.test.js +++ b/backend/__tests__/unit/controllers/messageController.test.js @@ -9,6 +9,7 @@ jest.mock('../../../models/Pod'); jest.mock('../../../models/Message', () => ({ create: jest.fn(), findById: jest.fn(), + find: jest.fn(), })); jest.mock('../../../models/pg/Message'); jest.mock('../../../services/agentMentionService', () => { @@ -31,6 +32,12 @@ jest.mock('../../../services/threadRootResolver', () => ({ })); jest.mock('../../../services/onboardingMarks', () => ({ markPodOpened: jest.fn(), markStartPicked: jest.fn() })); const { markPodOpened, markStartPicked } = require('../../../services/onboardingMarks'); +// The gate's own rules are pinned in services/welcomeHostedStart.test.js; here, +// that every page served goes through it. Pass-through unless a test says so. +jest.mock('../../../services/welcomeHostedStart', () => ({ + gateWelcomeHostedActions: jest.fn(async (messages) => messages), +})); +const { gateWelcomeHostedActions } = require('../../../services/welcomeHostedStart'); const socketConfig = require('../../../config/socket'); const { resolveThreadRoot, assertReplyTargetInPod } = require('../../../services/threadRootResolver'); @@ -83,6 +90,31 @@ describe('messageController', () => { expect(res.json).toHaveBeenCalledWith(mockMessages); }); + // TASK-290: the welcome's in-place hosted start is decided by its author, + // server-side, on the page as served, on both stores. + it('serves every page through the welcome start gate, for this pod and reader', async () => { + const page = [{ id: 41, user_id: 'bot-1', payload: { kind: 'starts', starts: [] } }]; + const gated = [{ id: 41, gated: true }]; + Pod.findById.mockResolvedValue({ members: ['u1'] }); + PGMessage.findByPodId.mockResolvedValue(page); + gateWelcomeHostedActions.mockResolvedValueOnce(gated); + const res = { status: jest.fn().mockReturnThis(), json: jest.fn() }; + await messageController.getMessages({ params: { podId: 'p1' }, query: {}, user: { id: 'u1' } }, res); + expect(gateWelcomeHostedActions).toHaveBeenCalledWith(page, 'p1', 'u1'); + expect(res.json).toHaveBeenCalledWith(gated); + + // The Mongo fallback serves through the same gate. + PGMessage.findByPodId.mockRejectedValue(new Error('pg down')); + MongoMessage.find.mockReturnValue({ + populate: () => ({ sort: () => ({ limit: async () => [] }) }), + }); + gateWelcomeHostedActions.mockResolvedValueOnce(gated); + const fallback = { status: jest.fn().mockReturnThis(), json: jest.fn() }; + await messageController.getMessages({ params: { podId: 'p1' }, query: {}, user: { id: 'u1' } }, fallback); + expect(gateWelcomeHostedActions).toHaveBeenLastCalledWith([], 'p1', 'u1'); + expect(fallback.json).toHaveBeenCalledWith(gated); + }); + it('marks a first look at the pod only on the first page, after access is granted', async () => { const pod = { members: ['u1'], createdBy: 'u1' }; Pod.findById.mockResolvedValue(pod); diff --git a/backend/__tests__/unit/controllers/pgMessageController.test.js b/backend/__tests__/unit/controllers/pgMessageController.test.js index 2a5d9c1128..9e779e0e25 100644 --- a/backend/__tests__/unit/controllers/pgMessageController.test.js +++ b/backend/__tests__/unit/controllers/pgMessageController.test.js @@ -4,12 +4,16 @@ const PGMessage = require('../../../models/pg/Message'); const MongoPod = require('../../../models/Pod'); const AgentMentionService = require('../../../services/agentMentionService'); const { AgentInstallation } = require('../../../models/AgentRegistry'); +const { gateWelcomeHostedActions } = require('../../../services/welcomeHostedStart'); jest.mock('../../../models/pg/Pod'); jest.mock('../../../models/pg/Message'); jest.mock('../../../models/Pod'); jest.mock('../../../services/agentMentionService'); jest.mock('../../../models/AgentRegistry'); +jest.mock('../../../services/welcomeHostedStart', () => ({ + gateWelcomeHostedActions: jest.fn(async (messages) => messages), +})); // Mongo membership is the decision since TASK-162, so every arm that expects a // status other than 404 has to say what the pod's `members` holds. `null` is a @@ -48,6 +52,24 @@ describe('pgMessageController', () => { expect(res.status).toHaveBeenCalledWith(404); }); + it('gates hosted welcome payloads before returning legacy PG messages', async () => { + PGPod.findById.mockResolvedValue({ type: 'chat' }); + mongoPod(['u1']); + const raw = [{ + id: 'm1', + payload: { kind: 'starts', starts: [{ id: 'run-hosted', action: 'run-hosted', caps: { turnsPerDay: 200 } }] }, + }]; + const gated = [{ id: 'm1', payload: { kind: 'starts', starts: [{ id: 'run-hosted' }] } }]; + PGMessage.findByPodId.mockResolvedValue(raw); + gateWelcomeHostedActions.mockResolvedValueOnce(gated); + const res = jsonRes(); + + await controller.getMessages({ params: { podId: 'p1' }, query: {}, userId: 'u1' }, res); + + expect(gateWelcomeHostedActions).toHaveBeenCalledWith(raw, 'p1', 'u1'); + expect(res.json).toHaveBeenCalledWith(gated); + }); + it('returns mention delivery feedback for a legacy PG message post', async () => { const message = { id: 'm1', content: 'hello @recorder', userId: { username: 'sam' } }; PGPod.findById.mockResolvedValue({ type: 'chat' }); diff --git a/backend/__tests__/unit/routes/hosted.test.js b/backend/__tests__/unit/routes/hosted.test.js index 852e48bf48..0ca127bb7f 100644 --- a/backend/__tests__/unit/routes/hosted.test.js +++ b/backend/__tests__/unit/routes/hosted.test.js @@ -73,7 +73,17 @@ jest.mock('../../../services/nativeRuntimeService', () => ({ hostedEngineAvailable: (engine) => (engine === 'native' ? mockNativeAvailable() : mockHosted.isConfigured()), })); const mockMarkByoOpened = jest.fn(); -jest.mock('../../../services/onboardingMarks', () => ({ markByoOpened: (...args) => mockMarkByoOpened(...args) })); +const mockMarkWelcomeHostedStart = jest.fn(); +jest.mock('../../../services/onboardingMarks', () => ({ + markByoOpened: (...args) => mockMarkByoOpened(...args), + markWelcomeHostedStart: (...args) => mockMarkWelcomeHostedStart(...args), +})); +// The welcome start's own checks are pinned in services/welcomeHostedStart.test.js; +// here only the route's wiring: who asks, and what comes back. +const mockResolveWelcomeStart = jest.fn(); +jest.mock('../../../services/welcomeHostedStart', () => ({ + resolveWelcomeHostedStart: (...args) => mockResolveWelcomeStart(...args), +})); const mockCompleteConnectAgent = jest.fn(); // Spread the real module so only the writer is replaced (Sprint Review on // #2252: a factory that lists exports silently drops the rest). @@ -184,6 +194,34 @@ describe('/api/hosted', () => { expect(mockFindOne).not.toHaveBeenCalled(); }); + it('welcome-start asks for the caller and the message, and relays the answer or the refusal', async () => { + const plan = { podId: 'pod-1', existing: null, agentName: 'ada-agent' }; + mockResolveWelcomeStart.mockResolvedValueOnce(plan); + const ok = await request(app).post('/api/hosted/welcome-start').send({ messageId: '41' }); + expect(mockResolveWelcomeStart).toHaveBeenCalledWith({ messageId: '41', userId: 'owner-1' }); + expect(ok.status).toBe(200); + expect(ok.body).toEqual(plan); + // The pick is recorded as the person's first start (onboardingMarks). + expect(mockMarkWelcomeHostedStart).toHaveBeenCalledWith('owner-1'); + mockMarkWelcomeHostedStart.mockClear(); + + mockResolveWelcomeStart.mockResolvedValueOnce({ + error: { status: 403, body: { code: 'not_welcome_start', message: 'no' } }, + }); + const refused = await request(app).post('/api/hosted/welcome-start').send({ messageId: '41' }); + expect(refused.status).toBe(403); + expect(refused.body.code).toBe('not_welcome_start'); + expect(mockMarkWelcomeHostedStart).not.toHaveBeenCalled(); + }); + + it('welcome-start 503s before asking anything when this instance cannot host a new agent', async () => { + mockHosted.isConfigured.mockReturnValue(false); + const res = await request(app).post('/api/hosted/welcome-start').send({ messageId: '41' }); + expect(res.status).toBe(503); + expect(res.body.code).toBe('hosted_runtime_unconfigured'); + expect(mockResolveWelcomeStart).not.toHaveBeenCalled(); + }); + it('400s on a malformed agent name', async () => { const res = await request(app).post('/api/hosted/provision').send({ agentName: 'Bad Name!' }); expect(res.status).toBe(400); diff --git a/backend/__tests__/unit/services/onboardingMarks.test.js b/backend/__tests__/unit/services/onboardingMarks.test.js index 493e997084..6000c325fa 100644 --- a/backend/__tests__/unit/services/onboardingMarks.test.js +++ b/backend/__tests__/unit/services/onboardingMarks.test.js @@ -9,7 +9,7 @@ jest.mock('../../../models/User', () => ({ })); const { - markPodOpened, markStartPicked, markByoOpened, resetOnboardingMarksForTest, + markPodOpened, markStartPicked, markByoOpened, markWelcomeHostedStart, resetOnboardingMarksForTest, } = require('../../../services/onboardingMarks'); const flush = () => new Promise((r) => setImmediate(r)); @@ -107,6 +107,23 @@ describe('markStartPicked', () => { markStartPicked('u1', { createdBy: 'u1' }, 'Start a pod for a project'); expect(mockUpdateOne).toHaveBeenCalledTimes(2); }); + + // TASK-290: the welcome's hosted start sends no message, so the hosted route + // records it once the server has checked the click (services/welcomeHostedStart). + test('the welcome hosted start records run-hosted as the first start, under the same write-once rule', () => { + markWelcomeHostedStart('u1'); + expect(mockUpdateOne).toHaveBeenCalledTimes(1); + const [filter, update] = mockUpdateOne.mock.calls[0]; + expect(filter).toEqual({ _id: 'u1', isBot: { $ne: true }, 'onboarding.firstStartAt': { $exists: false } }); + expect(update.$set['onboarding.firstStartId']).toBe('run-hosted'); + expect(update.$set['onboarding.firstStartAt']).toBeInstanceOf(Date); + + // One first start per person, whichever start it was. + markStartPicked('u1', { createdBy: 'u1' }, 'Start a pod for a project'); + markWelcomeHostedStart('u1'); + markWelcomeHostedStart(''); + expect(mockUpdateOne).toHaveBeenCalledTimes(1); + }); }); // The first visit to Bring your own, read from the page's own load request. diff --git a/backend/__tests__/unit/services/welcomeHostedStart.test.js b/backend/__tests__/unit/services/welcomeHostedStart.test.js new file mode 100644 index 0000000000..faba1e8c52 --- /dev/null +++ b/backend/__tests__/unit/services/welcomeHostedStart.test.js @@ -0,0 +1,192 @@ +// Scout's welcome "Run an agent here" runs in place (TASK-290). Two properties +// are pinned here, both on real models: +// (1) the action survives only on the person's own welcome, decided by its +// AUTHOR (the Scout they installed in a pod they created), never by a +// field in the message; every other copy keeps only its link; +// (2) only that person can run it, and a second click or a second tab gets +// the hosted agent they already hold, never a second install. +// Pod chat lives in Postgres; the one message lookup is stubbed. + +const mockPgFindById = jest.fn(); +jest.mock('../../../models/pg/Message', () => ({ + __esModule: true, + default: { findById: (...args) => mockPgFindById(...args) }, +})); + +const User = require('../../../models/User'); +const Pod = require('../../../models/Pod'); +const { AgentInstallation } = require('../../../models/AgentRegistry'); +const { + setupMongoDb, closeMongoDb, clearMongoDb, createTestUser, createTestPod, +} = require('../../utils/testUtils'); +const { + gateWelcomeHostedActions, + resolveWelcomeHostedStart, +} = require('../../../services/welcomeHostedStart'); + +const HREF = '/v2/agents/byo?pod=POD&mode=hosted'; +const welcomePayload = () => ({ + kind: 'starts', + starts: [ + { + id: 'run-hosted', + label: 'Run an agent here, nothing to install', + href: HREF, + action: 'run-hosted', + caps: { agentsPerUser: 1, turnsPerDay: 30 }, + }, + { id: 'connect-agent', label: 'Connect an agent I already use' }, + ], +}); + +const bot = (username) => createTestUser(User, { + username, email: `${username}@agents.test`, isBot: true, +}); + +let owner; let member; let pod; let scoutBot; let otherBot; let otherScout; + +const installScout = (podId, installedBy, instanceId) => AgentInstallation.create({ + agentName: 'scout', + instanceId, + podId, + version: '1.0.0', + status: 'active', + installedBy, +}); + +const message = (overrides = {}) => ({ + id: 41, + pod_id: String(pod._id), + user_id: String(scoutBot._id), + content: 'I\'m Scout.', + payload: welcomePayload(), + ...overrides, +}); + +const hostedActionOf = (m) => m.payload.starts.find((s) => s.id === 'run-hosted').action; + +beforeAll(async () => { await setupMongoDb(); }); +afterAll(async () => { await closeMongoDb(); }); + +beforeEach(async () => { + await clearMongoDb(); + mockPgFindById.mockReset(); + owner = await createTestUser(User, { username: 'ada', email: 'ada@example.com' }); + member = await createTestUser(User, { username: 'bob', email: 'bob@example.com' }); + pod = await createTestPod(Pod, owner._id, { name: 'My Workspace', members: [owner._id, member._id] }); + // buildAgentUsername('scout', 'uada') === 'scout-uada' + scoutBot = await bot('scout-uada'); + otherBot = await bot('helper'); + otherScout = await bot('scout-ubob'); + await installScout(pod._id, owner._id, 'uada'); +}); + +describe('(1) the action renders only on the owner\'s own welcome', () => { + test('keeps it on the welcome the owner\'s own Scout posted in the pod they created', async () => { + const [out] = await gateWelcomeHostedActions([message()], pod._id, owner._id); + expect(hostedActionOf(out)).toBe('run-hosted'); + expect(out.payload.starts[0].caps).toEqual({ agentsPerUser: 1, turnsPerDay: 30 }); + }); + + test('drops it, keeping the link, when anyone else posted the same payload', async () => { + const page = [ + message({ id: 1, user_id: String(otherBot._id) }), + message({ id: 2, user_id: String(otherScout._id) }), + message({ id: 3, user_id: String(owner._id) }), + message({ id: 4, user_id: String(member._id) }), + ]; + const out = await gateWelcomeHostedActions(page, pod._id, owner._id); + out.forEach((m) => { + const start = m.payload.starts.find((s) => s.id === 'run-hosted'); + expect(start.action).toBeUndefined(); + expect(start.href).toBe(HREF); + expect(start.caps).toBeUndefined(); + }); + // The page's own objects are not modified. + expect(hostedActionOf(page[0])).toBe('run-hosted'); + }); + + test('drops it for another reader, even on the real welcome', async () => { + const [out] = await gateWelcomeHostedActions([message()], pod._id, member._id); + expect(hostedActionOf(out)).toBeUndefined(); + expect(out.payload.starts[0].caps).toBeUndefined(); + }); + + test('drops it when the reader installed Scout but did not create the pod', async () => { + const theirPod = await createTestPod(Pod, member._id, { name: 'Bob\'s workspace', members: [member._id, owner._id] }); + await installScout(theirPod._id, owner._id, 'uada'); + const [out] = await gateWelcomeHostedActions( + [message({ pod_id: String(theirPod._id) })], + theirPod._id, + owner._id, + ); + expect(hostedActionOf(out)).toBeUndefined(); + expect(out.payload.starts[0].caps).toBeUndefined(); + }); + + test('drops it when the pod\'s Scout was installed by someone other than its creator', async () => { + const theirPod = await createTestPod(Pod, owner._id, { name: 'Another workspace', members: [owner._id, member._id] }); + await installScout(theirPod._id, member._id, 'ubob'); + const [out] = await gateWelcomeHostedActions( + [message({ pod_id: String(theirPod._id), user_id: String(otherScout._id) })], + theirPod._id, + owner._id, + ); + expect(hostedActionOf(out)).toBeUndefined(); + expect(out.payload.starts[0].caps).toBeUndefined(); + }); + + test('a page with no hosted action passes through untouched', async () => { + const page = [{ id: 9, user_id: String(otherBot._id), payload: { kind: 'starts', starts: [{ id: 'x', label: 'x', href: HREF }] } }]; + expect(await gateWelcomeHostedActions(page, pod._id, owner._id)).toBe(page); + }); +}); + +describe('(2) only the owner can run it, and a repeat click gets the same agent', () => { + test('the owner with no hosted agent gets a free name for this pod', async () => { + mockPgFindById.mockResolvedValue(message()); + const result = await resolveWelcomeHostedStart({ messageId: '41', userId: owner._id }); + expect(result).toEqual({ podId: String(pod._id), existing: null, agentName: 'ada-agent' }); + }); + + test('a second click, or one in a second tab, gets the hosted agent already held', async () => { + await AgentInstallation.create({ + agentName: 'ada-agent', + instanceId: 'default', + podId: pod._id, + version: '1.0.0', + status: 'active', + installedBy: owner._id, + config: { runtime: { runtimeType: 'hosted' } }, + }); + mockPgFindById.mockResolvedValue(message()); + const result = await resolveWelcomeHostedStart({ messageId: '41', userId: owner._id }); + expect(result).toEqual({ + podId: String(pod._id), + existing: { agentName: 'ada-agent', instanceId: 'default', podId: String(pod._id) }, + agentName: null, + }); + }); + + test.each([ + ['another member of the pod', () => ({ userId: member._id, msg: message() })], + ['a message another agent posted', () => ({ userId: owner._id, msg: message({ user_id: String(otherBot._id) }) })], + ['a message the owner posted', () => ({ userId: owner._id, msg: message({ user_id: String(owner._id) }) })], + ['a start that only links (Scout\'s hosted offer)', () => ({ + userId: owner._id, + msg: message({ payload: { kind: 'starts', starts: [{ id: 'run-hosted', label: 'Run it here', href: HREF }] } }), + })], + ['a message that does not exist', () => ({ userId: owner._id, msg: null })], + ])('refuses %s', async (_label, build) => { + const { userId, msg } = build(); + mockPgFindById.mockResolvedValue(msg); + const result = await resolveWelcomeHostedStart({ messageId: '41', userId }); + expect(result).toEqual({ error: { status: 403, body: expect.objectContaining({ code: 'not_welcome_start' }) } }); + }); + + test('a malformed message id is refused before any lookup', async () => { + const result = await resolveWelcomeHostedStart({ messageId: { $gt: '' }, userId: owner._id }); + expect(result.error.status).toBe(400); + expect(mockPgFindById).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/config/native-agents/scout.ts b/backend/config/native-agents/scout.ts index 908ba3b322..bf3738aa9c 100644 --- a/backend/config/native-agents/scout.ts +++ b/backend/config/native-agents/scout.ts @@ -34,8 +34,9 @@ export const SCOUT_STARTS = [ /** * 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. + * sends no message: on the person's own welcome a click starts a hosted agent + * in place (TASK-290, services/welcomeHostedStart.ts); anywhere else its href, + * added by the signup path, opens Bring your own with hosting chosen. */ export const SCOUT_HOSTED_START = { id: 'run-hosted', label: 'Run an agent here, nothing to install' } as const; @@ -65,8 +66,10 @@ export const scoutApp = { + '"Invite a teammate". They are the onboarding checklist. Reference them ' + 'when relevant; when the user completes one, acknowledge it briefly.\n' + '- 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 ' + + `led with "${SCOUT_HOSTED_START.label}". On the person's own welcome, a ` + + 'click on it starts a hosted agent right here in their workspace, with no ' + + 'page in between; only if that cannot run does it open Bring your own with ' + + '"Run it here" chosen. Either way it 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 ` diff --git a/backend/controllers/authController.ts b/backend/controllers/authController.ts index 39417a2e39..106550a96a 100644 --- a/backend/controllers/authController.ts +++ b/backend/controllers/authController.ts @@ -302,12 +302,25 @@ const finishWorkspaceOnboarding = async (pod: any, userId: any) => { // 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. + // nothing to install. One click starts a hosted agent right here + // (TASK-290, services/welcomeHostedStart.ts), with the beta limits + // under the button. The href stays for anyone the thread will not + // run it for: it opens Bring your own with hosting chosen. // eslint-disable-next-line global-require const { hostedEngineAvailable } = require('../services/nativeRuntimeService'); + // eslint-disable-next-line global-require + const { hostedCapsForOwner } = require('../services/hostedRuntimeService'); + // eslint-disable-next-line global-require + const { WELCOME_HOSTED_ACTION } = require('../services/welcomeHostedStart'); const hostedHref = `/v2/agents/byo?pod=${encodeURIComponent(pod._id.toString())}&mode=hosted`; - const hostedStart = hostedEngineAvailable() ? [{ ...SCOUT_HOSTED_START, href: hostedHref }] : []; + const hostedStart = hostedEngineAvailable() + ? [{ + ...SCOUT_HOSTED_START, + href: hostedHref, + action: WELCOME_HOSTED_ACTION, + caps: await hostedCapsForOwner(userId), + }] + : []; await AgentMessageService.postMessage({ agentName: scoutApp.agentName, instanceId: scoutInstanceId, diff --git a/backend/controllers/messageController.ts b/backend/controllers/messageController.ts index 132e997a95..ec969b9a60 100644 --- a/backend/controllers/messageController.ts +++ b/backend/controllers/messageController.ts @@ -13,6 +13,8 @@ const { deliverMessageToAgents } = require('../services/messageAgentDeliveryServ const DMService = require('../services/dmService'); // eslint-disable-next-line @typescript-eslint/no-require-imports const { markPodOpened, markStartPicked } = require('../services/onboardingMarks'); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { gateWelcomeHostedActions } = require('../services/welcomeHostedStart'); // eslint-disable-next-line global-require const { syncPodFromMongo } = require('../services/pgPodSyncService'); @@ -143,7 +145,7 @@ exports.getMessages = async (req: AuthRequest, res: Response): Promise => // eslint-disable-next-line no-console console.warn('[messages] reaction enrichment skipped:', (rxnErr as Error).message); } - res.json(messages); + res.json(await gateWelcomeHostedActions(messages as Array>, podId, userId)); return; } catch (pgErr) { const e = pgErr as { message?: string }; @@ -156,7 +158,7 @@ exports.getMessages = async (req: AuthRequest, res: Response): Promise => .populate('userId', 'username profilePicture') .sort({ createdAt: -1 }) .limit(limit); - res.json(messages.map(normalizeMongo)); + res.json(await gateWelcomeHostedActions(messages.map(normalizeMongo), podId, userId)); } catch (err) { const e = err as { message?: string; kind?: string }; console.error('Error in getMessages:', e.message); diff --git a/backend/controllers/pgMessageController.ts b/backend/controllers/pgMessageController.ts index 2a4fcb1518..2c080d336d 100644 --- a/backend/controllers/pgMessageController.ts +++ b/backend/controllers/pgMessageController.ts @@ -12,6 +12,8 @@ const MongoPod = require('../models/Pod'); const { deliverMessageToAgents } = require('../services/messageAgentDeliveryService'); // eslint-disable-next-line global-require const { syncPodFromMongo } = require('../services/pgPodSyncService'); +// eslint-disable-next-line global-require +const { gateWelcomeHostedActions } = require('../services/welcomeHostedStart'); interface AuthRequest extends Request { userId?: string; @@ -115,7 +117,7 @@ exports.getMessages = async (req: AuthRequest, res: Response): Promise => } const messages = await PGMessage.findByPodId(podId, limit, before); - res.json(messages); + res.json(await gateWelcomeHostedActions(messages, podId, userId)); } catch (err) { const e = err as { message?: string; kind?: string }; console.error('Error in PG getMessages:', e.message); diff --git a/backend/routes/hosted.ts b/backend/routes/hosted.ts index d60234ec8b..97dc29e477 100644 --- a/backend/routes/hosted.ts +++ b/backend/routes/hosted.ts @@ -30,7 +30,8 @@ const { hostedEngineOf, readHostedConfig, } = require('../services/hostedEngine'); -const { markByoOpened } = require('../services/onboardingMarks'); +const { markByoOpened, markWelcomeHostedStart } = require('../services/onboardingMarks'); +const { resolveWelcomeHostedStart } = require('../services/welcomeHostedStart'); const { revokeRuntimeTokensForAgent, issueRuntimeTokenForAgent, @@ -200,6 +201,28 @@ router.get('/availability', hostedRateLimit, auth, async (req: any, res: any) => return res.json({ configured: engineAvailable(engine), engine, caps }); }); +/** + * POST /api/hosted/welcome-start { messageId } + * One click on Scout's signup welcome, "Run an agent here" (TASK-290): answers + * the hosted agent the caller already holds, or a free name to install in the + * welcome's pod. Only the person whose workspace welcome it is gets an answer; + * services/welcomeHostedStart.ts checks the message's author and the pod's + * creator. Install and provision stay with their own routes. + */ +router.post('/welcome-start', hostedRateLimit, auth, async (req: any, res: any) => { + try { + if (!requireConfigured(res, engineForNewHostedInstall())) return undefined; + const userId = getUserId(req); + const result = await resolveWelcomeHostedStart({ messageId: req.body?.messageId, userId }); + if ('error' in result) return res.status(result.error.status).json(result.error.body); + markWelcomeHostedStart(userId); + return res.json(result); + } catch (error: any) { + console.error('[hosted] welcome start failed:', error?.message || error); + return res.status(500).json({ message: 'Hosted start failed' }); + } +}); + /** * POST /api/hosted/provision { agentName, instanceId? } */ diff --git a/backend/services/approvalActionService.ts b/backend/services/approvalActionService.ts index 7351421b8d..b6cc319233 100644 --- a/backend/services/approvalActionService.ts +++ b/backend/services/approvalActionService.ts @@ -221,7 +221,7 @@ const toolWord = (requestedName: string): string => { * "my-claude" works once on the whole instance. The proposing agent gets a * concrete name that will work instead of guessing again (2026-10-14). */ -const suggestLocalAgentName = async (requestedName: string, ownerUserId: unknown): Promise => { +export const suggestLocalAgentName = async (requestedName: string, ownerUserId: unknown): Promise => { const owner = await User.findById(ownerUserId) as { username?: string } | null; if (!owner) return null; // A username with no ASCII letters or digits (e.g. '中文') slugs to nothing; diff --git a/backend/services/onboardingMarks.ts b/backend/services/onboardingMarks.ts index 59ee7244e9..afa453522a 100644 --- a/backend/services/onboardingMarks.ts +++ b/backend/services/onboardingMarks.ts @@ -15,7 +15,11 @@ * The first-session starts add a second: firstStartId and firstStartAt, the * first time a person posts one of Scout's starts in a pod they created. A * click on a start sends its label as their message, so the server reads the - * pick from the message itself and trusts nothing new from the client. + * pick from the message itself and trusts nothing new from the client. The + * welcome's "Run an agent here" sends no message: it records firstStartId + * 'run-hosted' when the server resolves a click on the person's own welcome + * (POST /api/hosted/welcome-start, TASK-290), so a person who started there, + * and so never opened Bring your own, still shows which start they picked. * * byoOpenedAt is the first time a person opens the Bring your own agent page, * read from the page's own load request (GET /api/hosted/availability, which @@ -57,17 +61,31 @@ export function markStartPicked( const text = typeof content === 'string' ? content.trim() : ''; const start = SCOUT_STARTS.find(({ label }) => label === text); if (!start) return; + recordFirstStart(id, start.id); +} + +/** + * The welcome's hosted start, once the server has checked the click came from + * the person's own welcome (services/welcomeHostedStart.ts). + */ +export function markWelcomeHostedStart(userId: unknown): void { + const id = String(userId || ''); + if (!id) return; + recordFirstStart(id, 'run-hosted'); +} + +function recordFirstStart(id: string, startId: string): void { if (startPickedMarked.has(id)) return; startPickedMarked.add(id); try { User.updateOne( { _id: id, isBot: { $ne: true }, 'onboarding.firstStartAt': { $exists: false } }, - { $set: { 'onboarding.firstStartId': start.id, 'onboarding.firstStartAt': new Date() } }, + { $set: { 'onboarding.firstStartId': startId, 'onboarding.firstStartAt': new Date() } }, ).catch(() => { startPickedMarked.delete(id); }); } catch { - // Telemetry fails open: it must never break sending a message. + // Telemetry fails open: it must never break the request that triggers it. startPickedMarked.delete(id); } } diff --git a/backend/services/welcomeHostedStart.ts b/backend/services/welcomeHostedStart.ts new file mode 100644 index 0000000000..7299064746 --- /dev/null +++ b/backend/services/welcomeHostedStart.ts @@ -0,0 +1,167 @@ +/** + * Scout's welcome "Run an agent here", run on click (TASK-290). + * + * The signup welcome is the one message whose hosted start runs in place: one + * click installs and starts a Commonly-hosted agent in the person's own + * workspace, with no Bring your own page in between (walk 5 spent ~10 s + * there). The click is the consent. Every other hosted offer, Scout's + * commonly_offer_hosted_agent included, keeps its link to that page. + * + * Which message is the welcome is decided by who posted it, never by a field + * in it: the author must be the Scout this person installed in a pod they + * created. The `action` in the payload only asks to run in place; these checks + * decide whether it may, both when the thread is read and when the click + * arrives. A start that fails them keeps its link, which works as before. + */ +// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require +const Pod = require('../models/Pod'); +// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require +const { AgentInstallation } = require('../models/AgentRegistry'); +// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require +const User = require('../models/User'); +// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require +const AgentIdentityService = require('./agentIdentityService'); +// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require +const HostedRuntime = require('./hostedRuntimeService'); + +export const WELCOME_HOSTED_START_ID = 'run-hosted'; +export const WELCOME_HOSTED_ACTION = 'run-hosted'; + +interface StartLike { + id?: unknown; + action?: unknown; + [key: string]: unknown; +} + +interface MessageLike { + id?: unknown; + pod_id?: unknown; + user_id?: unknown; + payload?: unknown; +} + +const startsOf = (payload: unknown): StartLike[] | null => { + const p = payload as { kind?: unknown; starts?: unknown } | null | undefined; + if (!p || p.kind !== 'starts' || !Array.isArray(p.starts)) return null; + return p.starts as StartLike[]; +}; + +const isHostedAction = (start: StartLike | null | undefined): boolean => Boolean( + start && start.id === WELCOME_HOSTED_START_ID && start.action === WELCOME_HOSTED_ACTION, +); + +/** Whether a message payload asks for the hosted start to run in place. */ +export const offersWelcomeHostedAction = (payload: unknown): boolean => ( + (startsOf(payload) || []).some(isHostedAction) +); + +/** + * The bot User id of the Scout this person installed in a pod they created, + * or null. A pod someone else created, or one with no Scout of theirs, has no + * welcome this person may run. + */ +export const workspaceScoutUserId = async (podId: unknown, userId: unknown): Promise => { + const uid = String(userId || ''); + const pid = String(podId || ''); + if (!uid || !pid) return null; + const pod = await Pod.findById(pid).select('createdBy').lean(); + if (!pod || String(pod.createdBy || '') !== uid) return null; + const scout = await AgentInstallation.findOne({ + agentName: 'scout', + podId: pid, + installedBy: uid, + status: 'active', + }).select('instanceId').lean(); + if (!scout) return null; + const bot = await User.findOne({ + username: AgentIdentityService.buildAgentUsername('scout', String(scout.instanceId || 'default')), + isBot: true, + }).select('_id').lean(); + return bot?._id ? String(bot._id) : null; +}; + +/** + * Read-side gate for a page of pod messages. A hosted start keeps its action + * only on this viewer's own welcome; anywhere else the action and owner plan + * caps are dropped and the start keeps its link. Pages with no such start pass + * through untouched and cost no lookup. + */ +export const gateWelcomeHostedActions = async ( + messages: T[], + podId: unknown, + userId: unknown, +): Promise => { + if (!Array.isArray(messages) || !messages.some((m) => offersWelcomeHostedAction(m?.payload))) return messages; + const scoutId = await workspaceScoutUserId(podId, userId).catch(() => null); + return messages.map((message) => { + if (!offersWelcomeHostedAction(message.payload)) return message; + if (scoutId && String(message.user_id ?? '') === scoutId) return message; + const payload = message.payload as Record; + const starts = (startsOf(payload) || []).map((start) => { + if (!isHostedAction(start)) return start; + const { action: _action, caps: _caps, ...rest } = start; + return rest; + }); + return { ...message, payload: { ...payload, starts } }; + }); +}; + +export type WelcomeHostedStartResult = + | { error: { status: number; body: { code: string; message: string } } } + | { + podId: string; + // The hosted agent this person already holds. A second click, or a click + // in a second tab, gets the same agent back instead of a second install. + existing: { agentName: string; instanceId: string; podId: string } | null; + // A free name for a new install in the welcome's pod; null when existing. + agentName: string | null; + }; + +const notWelcome = (): WelcomeHostedStartResult => ({ + error: { + status: 403, + body: { code: 'not_welcome_start', message: 'This start can only be run from your own workspace welcome' }, + }, +}); + +/** + * What one click on the welcome's hosted start should start: the hosted agent + * this person already holds, or a free name to install in the welcome's pod. + * The install itself goes through POST /api/registry/install, which stays the + * only authority on what may be installed (cap, names, identity). + */ +export const resolveWelcomeHostedStart = async ( + { messageId, userId }: { messageId: unknown; userId: unknown }, +): Promise => { + // Pod chat lives in Postgres, where a message id is a serial integer. + const id = String(messageId ?? ''); + if (!/^\d{1,15}$/.test(id)) { + return { error: { status: 400, body: { code: 'invalid_message_id', message: 'messageId is required' } } }; + } + // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require + const PGMessage = require('../models/pg/Message').default; + const message = await PGMessage.findById(id).catch(() => null) as MessageLike | null; + if (!message || !offersWelcomeHostedAction(message.payload)) return notWelcome(); + const scoutId = await workspaceScoutUserId(message.pod_id, userId); + if (!scoutId || String(message.user_id ?? '') !== scoutId) return notWelcome(); + + const podId = String(message.pod_id); + const [held] = await HostedRuntime.listHostedInstallationsForUser(userId); + if (held) return { podId, existing: held, agentName: null }; + + // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require + const { suggestLocalAgentName } = require('./approvalActionService'); + const agentName = await suggestLocalAgentName('agent', userId); + if (!agentName) { + return { + error: { + status: 409, + body: { + code: 'agent_name_unavailable', + message: 'No free agent name was found; choose one on the Bring your own page', + }, + }, + }; + } + return { podId, existing: null, agentName }; +}; diff --git a/frontend/src/v2/__tests__/V2ThreadStarts.test.tsx b/frontend/src/v2/__tests__/V2ThreadStarts.test.tsx index ad59fd9df2..1a53b73ede 100644 --- a/frontend/src/v2/__tests__/V2ThreadStarts.test.tsx +++ b/frontend/src/v2/__tests__/V2ThreadStarts.test.tsx @@ -86,7 +86,9 @@ const message = (id, userId, username, content, minute, extra = {}) => ({ ...extra, }); -const Harness = ({ initial, sendSpy, onOpenInvite = undefined }) => { +const OWN_POD = { _id: 'pod-1', name: 'My Workspace', type: 'chat', createdBy: 'u1' }; + +const Harness = ({ initial, sendSpy, onOpenInvite = undefined, pod = OWN_POD }) => { const [messages, setMessages] = useState(initial); const sendMessage = async (...args) => { sendSpy(...args); @@ -95,7 +97,7 @@ const Harness = ({ initial, sendSpy, onOpenInvite = undefined }) => { return sent; }; const detail = { - pod: { _id: 'pod-1', name: 'My Workspace', type: 'chat', createdBy: 'u1' }, + pod, members: [{ _id: 'u1', username: 'maya', isBot: false }], messages, agents: [{ agentName: 'scout', instanceId: 'u1a2b3c4d5e', displayName: 'Scout', status: 'active' }], @@ -112,11 +114,11 @@ const LocationProbe = () => { return {`${location.pathname}${location.search}`}; }; -const renderThread = (initial, sendSpy = jest.fn(), { onOpenInvite = undefined } = {}) => { +const renderThread = (initial, sendSpy = jest.fn(), { onOpenInvite = undefined, pod = OWN_POD } = {}) => { render( - + , @@ -345,3 +347,146 @@ describe('Scout starts and the composer after a hosted agent goes live', () => { await waitFor(() => expect(composer).toHaveValue('@maya-agent ')); }); }); + +// TASK-290: on the person's own welcome, "Run an agent here" starts a hosted +// agent in place. The click is the consent; there is no page between. Which +// welcome may run it is the server's call (services/welcomeHostedStart.ts); +// these pin the thread's half: only the pod's creator runs it, a repeat click +// gets the same agent, and anything refused falls back to the link. +describe('"Run an agent here" runs in place on the owner\'s own welcome (TASK-290)', () => { + const axios = jest.requireMock('axios').default; + const HOSTED = 'Run an agent here, nothing to install'; + const HREF = '/v2/agents/byo?pod=pod-1&mode=hosted'; + const welcomeRunsHere = { + ...welcome, + payload: { + kind: 'starts', + starts: [ + { + id: 'run-hosted', label: HOSTED, href: HREF, action: 'run-hosted', caps: { agentsPerUser: 1, turnsPerDay: 30 }, + }, + ...welcome.payload.starts, + ], + }, + }; + const PLAN = { podId: 'pod-1', existing: null, agentName: 'maya-agent' }; + const HELD = { podId: 'pod-1', existing: { agentName: 'maya-agent', instanceId: 'default', podId: 'pod-1' }, agentName: null }; + const calls = (url) => axios.post.mock.calls.filter(([called]) => called === url); + // Answers by URL, consumed in order; { reject } answers with an error. + const answer = (routes) => { + axios.post.mockImplementation((url) => { + const next = (routes[url] || []).shift(); + if (!next) return Promise.resolve({ data: {} }); + return next.reject ? Promise.reject(next.reject) : Promise.resolve({ data: next }); + }); + }; + const refusal = (status, data) => ({ reject: { response: { status, data } } }); + + beforeEach(() => { axios.post.mockClear(); }); + afterEach(() => { axios.post.mockImplementation(() => Promise.resolve({ data: {} })); }); + + test('the beta limits sit under the button, and describe it', () => { + renderThread([welcomeRunsHere]); + expect(screen.getByRole('button', { name: HOSTED })) + .toHaveAccessibleDescription('Beta: 1 hosted agent per account, 30 turns a day.'); + }); + + test('one click installs and starts the agent in this pod, sends nothing, and the start goes away', async () => { + answer({ + '/api/hosted/welcome-start': [PLAN], + '/api/registry/install': [{ success: true }], + '/api/hosted/provision': [{ ready: true }], + }); + const sendSpy = renderThread([welcomeRunsHere]); + fireEvent.click(screen.getByRole('button', { name: HOSTED })); + expect(await screen.findByRole('button', { name: 'Starting…' })).toBeDisabled(); + await waitFor(() => expect(screen.queryByRole('button', { name: /Run an agent here|Starting/ })).not.toBeInTheDocument()); + + expect(calls('/api/hosted/welcome-start').map(([, body]) => body)).toEqual([{ messageId: 'welcome' }]); + expect(calls('/api/registry/install')).toHaveLength(1); + expect(calls('/api/registry/install')[0][1]).toMatchObject({ + agentName: 'maya-agent', podId: 'pod-1', config: { runtime: { runtimeType: 'hosted' } }, + }); + expect(calls('/api/hosted/provision').map(([, body]) => body)).toEqual([{ agentName: 'maya-agent' }]); + expect(sendSpy).not.toHaveBeenCalled(); + expect(screen.getByTestId('location')).toHaveTextContent(/^\/$/); + expect(screen.getByRole('button', { name: CONNECT })).toBeInTheDocument(); + }); + + test('a double click starts one agent', async () => { + answer({ + '/api/hosted/welcome-start': [PLAN], + '/api/registry/install': [{ success: true }], + '/api/hosted/provision': [{ ready: true }], + }); + renderThread([welcomeRunsHere]); + const button = screen.getByRole('button', { name: HOSTED }); + fireEvent.click(button); + fireEvent.click(button); + await waitFor(() => expect(calls('/api/hosted/provision')).toHaveLength(1)); + expect(calls('/api/hosted/welcome-start')).toHaveLength(1); + expect(calls('/api/registry/install')).toHaveLength(1); + }); + + test.each([ + ['the existing install', refusal(400, { error: 'Agent already installed in this pod' })], + ['the cap', refusal(403, { code: 'hosted_cap_reached', cap: 1 })], + ])('when another tab got there first, %s answers with that agent: this click starts it, with no error', async (_label, installAnswer) => { + answer({ + '/api/hosted/welcome-start': [PLAN, HELD], + '/api/registry/install': [installAnswer], + '/api/hosted/provision': [{ ready: true }], + }); + renderThread([welcomeRunsHere]); + fireEvent.click(screen.getByRole('button', { name: HOSTED })); + await waitFor(() => expect(screen.queryByRole('button', { name: /Run an agent here|Starting/ })).not.toBeInTheDocument()); + expect(calls('/api/registry/install')).toHaveLength(1); + expect(calls('/api/hosted/provision').map(([, body]) => body)).toEqual([{ agentName: 'maya-agent', instanceId: 'default' }]); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + test('a person who did not create the pod gets the link, and nothing is started', () => { + renderThread([welcomeRunsHere], jest.fn(), { pod: { ...OWN_POD, createdBy: { _id: 'u2' } } }); + fireEvent.click(screen.getByRole('button', { name: HOSTED })); + expect(screen.getByTestId('location')).toHaveTextContent(HREF); + expect(axios.post).not.toHaveBeenCalled(); + }); + + test('the owner is recognised from a populated creator too', async () => { + answer({ '/api/hosted/welcome-start': [refusal(503, { code: 'hosted_runtime_unconfigured' })] }); + renderThread([welcomeRunsHere], jest.fn(), { pod: { ...OWN_POD, createdBy: { _id: 'u1', username: 'maya' } } }); + fireEvent.click(screen.getByRole('button', { name: HOSTED })); + await waitFor(() => expect(calls('/api/hosted/welcome-start')).toHaveLength(1)); + }); + + test('a start the server will not run here opens the page instead', async () => { + answer({ '/api/hosted/welcome-start': [refusal(403, { code: 'not_welcome_start' })] }); + renderThread([welcomeRunsHere]); + fireEvent.click(screen.getByRole('button', { name: HOSTED })); + await waitFor(() => expect(screen.getByTestId('location')).toHaveTextContent(HREF)); + expect(calls('/api/registry/install')).toHaveLength(0); + }); + + test('a name the install refuses opens the page, where a name can be chosen', async () => { + answer({ + '/api/hosted/welcome-start': [PLAN, PLAN], + '/api/registry/install': [refusal(409, { code: 'agent_name_taken', error: 'taken' })], + }); + renderThread([welcomeRunsHere]); + fireEvent.click(screen.getByRole('button', { name: HOSTED })); + await waitFor(() => expect(screen.getByTestId('location')).toHaveTextContent(HREF)); + expect(calls('/api/hosted/provision')).toHaveLength(0); + }); + + test('a failure says why under the button, and the button can be pressed again', async () => { + answer({ + '/api/hosted/welcome-start': [PLAN, PLAN], + '/api/registry/install': [refusal(503, { code: 'hosted_runtime_unconfigured' })], + }); + renderThread([welcomeRunsHere]); + fireEvent.click(screen.getByRole('button', { name: HOSTED })); + expect(await screen.findByRole('alert')).toHaveTextContent("Hosting isn't available on this instance yet."); + expect(screen.getByRole('button', { name: HOSTED })).toBeEnabled(); + expect(calls('/api/hosted/provision')).toHaveLength(0); + }); +}); diff --git a/frontend/src/v2/__tests__/useWelcomeHostedStart.test.tsx b/frontend/src/v2/__tests__/useWelcomeHostedStart.test.tsx new file mode 100644 index 0000000000..4b5247f5b5 --- /dev/null +++ b/frontend/src/v2/__tests__/useWelcomeHostedStart.test.tsx @@ -0,0 +1,38 @@ +// The welcome's hosted start, one click to a live agent (TASK-290). Two clicks +// that land before the button can disable (a fast double click, a keyboard +// repeat) still start one agent: the second is dropped while the first runs. +import { act, renderHook } from '@testing-library/react'; +import { useWelcomeHostedStart } from '../hooks/useWelcomeHostedStart'; +import type { V2ApiClient } from '../hooks/useV2Api'; + +const START = { id: 'run-hosted', label: 'Run an agent here', href: '/v2/agents/byo?pod=pod-1&mode=hosted', action: 'run-hosted' }; + +test('a second run while the first is in flight sends nothing', async () => { + let finishResolve: (value: unknown) => void = () => {}; + const post = jest.fn((url: string) => { + if (url === '/api/hosted/welcome-start') { + return new Promise((resolve) => { finishResolve = resolve; }); + } + return Promise.resolve({ ready: true }); + }); + const api = { post, get: jest.fn(), patch: jest.fn(), put: jest.fn(), del: jest.fn() } as unknown as V2ApiClient; + const onLive = jest.fn(); + const { result } = renderHook(() => useWelcomeHostedStart(api, { onLive, onOpenPage: jest.fn() })); + + let first: Promise = Promise.resolve(); + let second: Promise = Promise.resolve(); + act(() => { + first = result.current.run('41', START); + second = result.current.run('41', START); + }); + await act(async () => { + finishResolve({ podId: 'pod-1', existing: null, agentName: 'maya-agent' }); + await Promise.all([first, second]); + }); + + expect(post.mock.calls.map(([url]) => url)).toEqual([ + '/api/hosted/welcome-start', '/api/registry/install', '/api/hosted/provision', + ]); + expect(onLive).toHaveBeenCalledTimes(1); + expect(result.current.state).toEqual({ messageId: '41', status: 'live' }); +}); diff --git a/frontend/src/v2/components/V2AgentBYO.tsx b/frontend/src/v2/components/V2AgentBYO.tsx index 904f5789f9..818e11204c 100644 --- a/frontend/src/v2/components/V2AgentBYO.tsx +++ b/frontend/src/v2/components/V2AgentBYO.tsx @@ -24,11 +24,8 @@ import { useV2Api } from '../hooks/useV2Api'; import { useAuth } from '../../context/AuthContext'; import { V2Pod } from '../hooks/useV2Pods'; import { PERSONA_CARDS } from '../agents/personaCatalogData'; +import { DEFAULT_INSTALL_SCOPES } from '../utils/agentInstallScopes'; -const DEFAULT_SCOPES = [ - 'context:read', 'summaries:read', 'messages:write', 'messages:read', - 'posts:write', 'posts:read', 'memory:read', 'memory:write', -]; const DEFAULT_AGENT_NAME = 'my-mcp-agent'; const DEFAULT_POD_TYPE = 'chat'; // @latest matters: `agent run` learned to bootstrap from these env vars in @@ -428,7 +425,7 @@ const V2AgentBYO: React.FC = () => { await api.post('/api/registry/install', { agentName: cleanName, podId, - scopes: DEFAULT_SCOPES, + scopes: DEFAULT_INSTALL_SCOPES, config: { runtime: { runtimeType: 'hosted' }, ...(personaCard ? { persona: personaCard.key } : {}) }, displayName: personaCard?.name || cleanName, // #1649: the seat carries the persona's own sentence, so Your Team never shows a placeholder. @@ -471,7 +468,7 @@ const V2AgentBYO: React.FC = () => { await api.post('/api/registry/install', { agentName: cleanName, podId, - scopes: DEFAULT_SCOPES, + scopes: DEFAULT_INSTALL_SCOPES, config: { runtime: { runtimeType: 'wrapper', @@ -532,7 +529,7 @@ const V2AgentBYO: React.FC = () => { await api.post('/api/registry/install', { agentName: cleanName, podId, - scopes: DEFAULT_SCOPES, + scopes: DEFAULT_INSTALL_SCOPES, config: { runtime: { runtimeType: 'webhook' }, ...(personaCard ? { persona: personaCard.key } : {}) }, displayName: personaCard?.name || cleanName, // #1649: the seat carries the persona's own sentence, so Your Team never shows a placeholder. diff --git a/frontend/src/v2/components/V2Thread.tsx b/frontend/src/v2/components/V2Thread.tsx index e6141d4621..87f35910de 100644 --- a/frontend/src/v2/components/V2Thread.tsx +++ b/frontend/src/v2/components/V2Thread.tsx @@ -26,6 +26,7 @@ import type { V2InviteTab } from './V2InviteModal'; import { useV2ThreadState } from '../hooks/useV2ThreadState'; import { useV2ThreadMentions } from '../hooks/useV2ThreadMentions'; +import { useWelcomeHostedStart, WELCOME_HOSTED_ACTION } from '../hooks/useWelcomeHostedStart'; import { buildThreadView, freezeOrphanReplyIds } from '../utils/threadView'; import { agentKeyFor } from '../utils/agentKey'; import { @@ -348,10 +349,24 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in // and 60s refresh keeps the states honest without hammering the endpoint. // Declared here because the starts below read it too. const [agentStates, setAgentStates] = useState([]); + // Bumped to read agent states again at once, e.g. when a hosted agent the + // welcome started has just gone live. + const [agentStatesReload, setAgentStatesReload] = useState(0); + const viewerId = currentUser?._id || null; + // The welcome's "Run an agent here" runs in place only for the person whose + // workspace this is (TASK-290). The server already drops its action on any + // other reader's copy; this keeps a live-pushed copy honest too. Anyone + // else gets its link, which opens Bring your own. + // createdBy is populated on GET /api/pods/:id and a bare id elsewhere. + const podCreator = pod?.createdBy as { _id?: string } | string | undefined; + const podCreatorId = typeof podCreator === 'string' ? podCreator : podCreator?._id; + const viewerOwnsPod = Boolean(viewerId) && String(podCreatorId || '') === String(viewerId); + const onHostedLive = useCallback(() => setAgentStatesReload((n) => n + 1), []); + const hostedStart = useWelcomeHostedStart(api, { onLive: onHostedLive, onOpenPage: navigate }); // Walk 4 (2026-10-16): "Run an agent here" stayed under the welcome after // the agent was live, an offer the beta's one slot can no longer honour. - const viewerHoldsHostedAgent = agentStates.some((agent) => agent.isOwner && agent.hosted); - const viewerId = currentUser?._id || null; + const viewerHoldsHostedAgent = hostedStart.state?.status === 'live' + || agentStates.some((agent) => agent.isOwner && agent.hosted); const startsByMessageId = useMemo(() => { // A start that navigates must stay inside the app: an in-app /v2/ path // only, never a protocol-relative or absolute URL from a payload. A start @@ -475,7 +490,7 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in load(); const timer = setInterval(load, 60_000); return () => { cancelled = true; clearInterval(timer); }; - }, [pod?._id, api]); + }, [pod?._id, api, agentStatesReload]); // Lookup by every handle a mention could use: instanceId (what the // dropdown inserts for non-default instances) and agentName. @@ -1636,12 +1651,17 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in onReply={isReadOnly ? undefined : aimAtMessage} onThread={isReadOnly ? undefined : aimAtMessageThread} startsByMessageId={isReadOnly ? undefined : startsByMessageId} - onStart={isReadOnly ? undefined : (start) => { + onStart={isReadOnly ? undefined : (start, messageId) => { + if (start.action === WELCOME_HOSTED_ACTION && viewerOwnsPod) { + void hostedStart.run(messageId, start); + return; + } if (start.href) { navigate(start.href); return; } if (start.action === 'invite') { onOpenInvite?.(PEOPLE_INVITE_TAB); return; } void handleSend(start.label); }} startsDisabled={sending} + hostedStart={hostedStart.state} onQuoteNavigate={onQuoteNavigate} onDecisionRuled={handleDecisionRuled} onAimAtThread={aimAtThread} diff --git a/frontend/src/v2/components/V2ThreadMessages.tsx b/frontend/src/v2/components/V2ThreadMessages.tsx index 6c8fe6b992..4138352e75 100644 --- a/frontend/src/v2/components/V2ThreadMessages.tsx +++ b/frontend/src/v2/components/V2ThreadMessages.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { HistorySearchState, V2Message, V2Start } from '../hooks/useV2PodDetail'; import { UseV2ThreadState } from '../hooks/useV2ThreadState'; +import { WELCOME_HOSTED_ACTION, type WelcomeHostedStartState } from '../hooks/useWelcomeHostedStart'; import { ThreadViewItem } from '../utils/threadView'; import { isGroupedWithPrevious } from '../utils/messageGrouping'; import V2MessageRow from './V2MessageRow'; @@ -27,8 +28,10 @@ interface V2ThreadMessagesProps { // Scout's starts, keyed by the message that offers them; already filtered to // the ones this person has not sent (V2Thread). A click sends the label. startsByMessageId?: Map; - onStart?: (start: V2Start) => void; + onStart?: (start: V2Start, messageId: string) => void; startsDisabled?: boolean; + // The welcome's hosted start while it runs in place, or after it failed. + hostedStart?: WelcomeHostedStartState; onQuoteNavigate?: (messageId: string | number) => void; onDecisionRuled?: (decisionId: string, ruling: V2DecisionRuling) => void; onAimAtThread: (rootId: string, preview: string) => void; @@ -130,6 +133,7 @@ const V2ThreadMessages: React.FC = ({ startsByMessageId, onStart, startsDisabled = false, + hostedStart = null, onQuoteNavigate, onDecisionRuled, onAimAtThread, @@ -285,17 +289,34 @@ const V2ThreadMessages: React.FC = ({ /> {onStart && (startsByMessageId?.get(String(message.id))?.length ?? 0) > 0 && (
- {startsByMessageId?.get(String(message.id))?.map((start) => ( - - ))} + {startsByMessageId?.get(String(message.id))?.map((start) => { + const hostedHere = start.action === WELCOME_HOSTED_ACTION + && hostedStart?.messageId === String(message.id) ? hostedStart : null; + const starting = hostedHere?.status === 'starting'; + const noteId = start.caps ? `v2-start-note-${message.id}-${start.id}` : undefined; + return ( + + + {start.caps && ( +

+ {t('agentByo.compact.limits', { agents: start.caps.agentsPerUser, turns: start.caps.turnsPerDay })} +

+ )} + {hostedHere?.status === 'failed' && ( +

{hostedHere.error}

+ )} +
+ ); + })}
)} {agentDeliveryHint?.messageId === message.id && ( diff --git a/frontend/src/v2/hooks/useV2PodDetail.ts b/frontend/src/v2/hooks/useV2PodDetail.ts index 7f78b14400..dbc2767f9a 100644 --- a/frontend/src/v2/hooks/useV2PodDetail.ts +++ b/frontend/src/v2/hooks/useV2PodDetail.ts @@ -10,6 +10,8 @@ export interface V2Start { label: string; href?: string; action?: string; + // The welcome's hosted start carries the beta limits shown under it. + caps?: { agentsPerUser: number; turnsPerDay: number }; } export interface V2Message { diff --git a/frontend/src/v2/hooks/useWelcomeHostedStart.ts b/frontend/src/v2/hooks/useWelcomeHostedStart.ts new file mode 100644 index 0000000000..9c6f85049a --- /dev/null +++ b/frontend/src/v2/hooks/useWelcomeHostedStart.ts @@ -0,0 +1,101 @@ +import { useCallback, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { V2ApiClient } from './useV2Api'; +import type { V2Start } from './useV2PodDetail'; +import { DEFAULT_INSTALL_SCOPES } from '../utils/agentInstallScopes'; + +/** The action on Scout's welcome "Run an agent here" (backend/services/welcomeHostedStart.ts). */ +export const WELCOME_HOSTED_ACTION = 'run-hosted'; + +interface WelcomeStartPlan { + podId: string; + existing: { agentName: string; instanceId: string; podId: string } | null; + agentName: string | null; +} + +export type WelcomeHostedStartState = + | { messageId: string; status: 'starting' } + | { messageId: string; status: 'live' } + | { messageId: string; status: 'failed'; error: string } + | null; + +type ApiError = { + response?: { status?: number; data?: { code?: string; message?: string; error?: string; cap?: number } }; + message?: string; +}; + +// Answers that mean "this click cannot run here": the page it links to can. +const OPEN_PAGE_CODES = new Set(['not_welcome_start', 'agent_name_unavailable']); + +// The install refused the name (taken, reserved, or bound to another +// identity). The page it links to lets the person choose one. +class NameRefused extends Error {} + +/** + * One click on the welcome's "Run an agent here" starts a hosted agent in + * place (TASK-290): the click is the consent, and there is no page between. + * + * The server answers what to start (POST /api/hosted/welcome-start): the + * hosted agent this person already holds, or a free name for a new one. The + * install and provision are the same calls the Bring your own page makes. A + * failed install asks again: when another click (here or in another tab) got + * there first, the answer is now that agent, and this click starts it too. + */ +export function useWelcomeHostedStart( + api: V2ApiClient, + { onLive, onOpenPage }: { onLive: () => void; onOpenPage: (href: string) => void }, +) { + const { t } = useTranslation(); + const [state, setState] = useState(null); + const inFlight = useRef(false); + + const run = useCallback(async (messageId: string, start: V2Start) => { + if (inFlight.current) return; + inFlight.current = true; + setState({ messageId, status: 'starting' }); + try { + const resolve = () => api.post('/api/hosted/welcome-start', { messageId }); + let plan = await resolve(); + if (!plan.existing && plan.agentName) { + try { + await api.post('/api/registry/install', { + agentName: plan.agentName, + podId: plan.podId, + scopes: DEFAULT_INSTALL_SCOPES, + config: { runtime: { runtimeType: 'hosted' } }, + displayName: plan.agentName, + }); + } catch (installError) { + plan = await resolve(); + if (!plan.existing) { + throw (installError as ApiError).response?.status === 409 ? new NameRefused() : installError; + } + } + } + const agent = plan.existing || { agentName: plan.agentName as string, instanceId: undefined }; + await api.post('/api/hosted/provision', { + agentName: agent.agentName, + ...(agent.instanceId ? { instanceId: agent.instanceId } : {}), + }); + setState({ messageId, status: 'live' }); + onLive(); + } catch (err) { + const data = (err as ApiError).response?.data; + const openPage = err instanceof NameRefused || Boolean(data?.code && OPEN_PAGE_CODES.has(data.code)); + if (openPage && start.href) { + setState(null); + onOpenPage(start.href); + return; + } + let error: string; + if (data?.code === 'hosted_cap_reached') error = t('agentByo.errors.hostedCap', { cap: data.cap ?? 1 }); + else if (data?.code === 'hosted_runtime_unconfigured') error = t('agentByo.errors.hostedUnavailable'); + else error = data?.message || data?.error || t('agentByo.errors.installFailed'); + setState({ messageId, status: 'failed', error }); + } finally { + inFlight.current = false; + } + }, [api, onLive, onOpenPage, t]); + + return { state, run }; +} diff --git a/frontend/src/v2/utils/agentInstallScopes.ts b/frontend/src/v2/utils/agentInstallScopes.ts new file mode 100644 index 0000000000..296810d5ac --- /dev/null +++ b/frontend/src/v2/utils/agentInstallScopes.ts @@ -0,0 +1,9 @@ +/** + * The scopes a self-serve agent install asks for: Bring your own, Run it here, + * On my computer, and the welcome's hosted start. One list, so a hosted agent + * started from the welcome can do exactly what one started from the page can. + */ +export const DEFAULT_INSTALL_SCOPES = [ + 'context:read', 'summaries:read', 'messages:write', 'messages:read', + 'posts:write', 'posts:read', 'memory:read', 'memory:write', +]; diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index 8e13e7466f..57fa20e8b8 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -1998,6 +1998,19 @@ body.modern-ui.v2-canvas { cursor: default; } +/* A line that belongs to the start above it: the hosted start's beta limits, + or why it could not start. Same slot and size as the delivery hint. */ +.v2-chat__start-note { + margin: -2px 0 2px; + color: var(--v2-text-tertiary); + font-size: 12px; + line-height: 1.4; +} + +.v2-chat__start-note--error { + color: var(--v2-danger); +} + @media (max-width: 760px) { /* The row's single act is a touch target on a phone: full width of the row, 44 tall. This must override the MINIMUM, not a height — a `height` here