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 @@ -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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
32 changes: 32 additions & 0 deletions backend/__tests__/unit/controllers/messageController.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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');
Expand Down Expand Up @@ -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);
Expand Down
22 changes: 22 additions & 0 deletions backend/__tests__/unit/controllers/pgMessageController.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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' });
Expand Down
40 changes: 39 additions & 1 deletion backend/__tests__/unit/routes/hosted.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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);
Expand Down
19 changes: 18 additions & 1 deletion backend/__tests__/unit/services/onboardingMarks.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading