| 1 | import { describe, expect, it } from 'vitest';
|
| 2 | import {
|
| 3 | approveAgentSetup,
|
| 4 | createDashboardAgent,
|
| 5 | createHostedProviderAgent,
|
| 6 | getOnboardingState,
|
| 7 | requestAgentSetup,
|
| 8 | resolveInstallation,
|
| 9 | updateAgentProfile,
|
| 10 | type OnboardingKind,
|
| 11 | type OnboardingStore,
|
| 12 | } from '../packages/persistence/src/onboarding.js';
|
| 13 |
|
| 14 | class MemoryOnboardingStore implements OnboardingStore {
|
| 15 | private rows = new Map<string, { kind: OnboardingKind; recordId: string; payload: unknown }>();
|
| 16 |
|
| 17 | async list(operatorId: string) {
|
| 18 | return [...this.rows.entries()]
|
| 19 | .filter(([key]) => key.startsWith(`${operatorId}|`))
|
| 20 | .map(([, row]) => row);
|
| 21 | }
|
| 22 |
|
| 23 | async upsert(operatorId: string, kind: OnboardingKind, recordId: string, payload: unknown) {
|
| 24 | this.rows.set(`${operatorId}|${kind}|${recordId}`, { kind, recordId, payload });
|
| 25 | }
|
| 26 | }
|
| 27 |
|
| 28 | describe('agent self-onboarding', () => {
|
| 29 | it('creates an externally hosted agent profile for dashboard quickstart', async () => {
|
| 30 | const store = new MemoryOnboardingStore();
|
| 31 | const created = await createDashboardAgent(store, 'owner', {
|
| 32 | agentName: 'Research assistant',
|
| 33 | projectName: 'My agents',
|
| 34 | description: 'Returns sourced research',
|
| 35 | capabilities: ['research', 'research'],
|
| 36 | });
|
| 37 | expect(created.agent).toMatchObject({
|
| 38 | name: 'Research assistant',
|
| 39 | framework: 'Custom agent',
|
| 40 | agentMode: 'persistent_runtime',
|
| 41 | transport: 'direct_a2a',
|
| 42 | autoPublish: false,
|
| 43 | capabilities: ['research'],
|
| 44 | });
|
| 45 | const retried = await createDashboardAgent(store, 'owner', {
|
| 46 | agentName: 'Research assistant',
|
| 47 | projectName: 'My agents',
|
| 48 | description: 'Returns sourced research',
|
| 49 | capabilities: ['research'],
|
| 50 | });
|
| 51 | expect(retried.agent.agentId).toBe(created.agent.agentId);
|
| 52 | expect((await getOnboardingState(store, 'owner')).agentProfiles).toHaveLength(1);
|
| 53 | });
|
| 54 |
|
| 55 | it('creates a separate owner-managed identity for a hosted provider', async () => {
|
| 56 | const store = new MemoryOnboardingStore();
|
| 57 | const created = await createHostedProviderAgent(store, 'owner', {
|
| 58 | provider: 'botpress',
|
| 59 | agentName: 'Recruiting agent',
|
| 60 | projectName: 'Recruiting',
|
| 61 | description: 'Matches candidates to roles',
|
| 62 | capabilities: ['candidate matching'],
|
| 63 | limitations: ['no final hiring decisions'],
|
| 64 | });
|
| 65 | expect(created.agent).toMatchObject({
|
| 66 | name: 'Recruiting agent',
|
| 67 | framework: 'Botpress',
|
| 68 | identityMode: 'owner_managed',
|
| 69 | agentMode: 'persistent_runtime',
|
| 70 | autoPublish: false,
|
| 71 | capabilities: ['candidate matching'],
|
| 72 | });
|
| 73 | const state = await getOnboardingState(store, 'owner');
|
| 74 | expect(state.projects).toEqual([expect.objectContaining({ name: 'Recruiting' })]);
|
| 75 | expect(state.agentProfiles).toEqual([
|
| 76 | expect.objectContaining({ agentId: created.agent.agentId }),
|
| 77 | ]);
|
| 78 | expect(state.installations).toEqual([]);
|
| 79 | });
|
| 80 |
|
| 81 | it('requires owner confirmation before binding an installation', async () => {
|
| 82 | const store = new MemoryOnboardingStore();
|
| 83 | const request = await requestAgentSetup(store, 'owner-a', {
|
| 84 | clientId: 'codex-installation-a',
|
| 85 | agentName: 'Research agent',
|
| 86 | projectName: 'Market research',
|
| 87 | framework: 'Codex',
|
| 88 | capabilities: ['research'],
|
| 89 | limitations: ['No purchases'],
|
| 90 | description: 'Browses public sources',
|
| 91 | agentVersion: '2.0.0',
|
| 92 | });
|
| 93 |
|
| 94 | expect(request.status).toBe('pending');
|
| 95 | expect(request).toMatchObject({
|
| 96 | autoPublish: true,
|
| 97 | autoAcceptPolicy: 'safe_matching',
|
| 98 | autoAcceptTaskCategories: ['research'],
|
| 99 | });
|
| 100 | await expect(resolveInstallation(store, 'owner-a', 'codex-installation-a')).resolves.toEqual({
|
| 101 | status: 'unbound',
|
| 102 | });
|
| 103 |
|
| 104 | const approved = await approveAgentSetup(store, 'owner-a', request.requestId);
|
| 105 | expect(approved).toMatchObject({
|
| 106 | status: 'connected',
|
| 107 | agent: {
|
| 108 | name: 'Research agent',
|
| 109 | capabilities: ['research'],
|
| 110 | agentVersion: '2.0.0',
|
| 111 | transport: 'direct_a2a',
|
| 112 | agentMode: 'temporary_chat',
|
| 113 | },
|
| 114 | project: { name: 'Market research' },
|
| 115 | });
|
| 116 | await expect(
|
| 117 | resolveInstallation(store, 'owner-a', 'codex-installation-a'),
|
| 118 | ).resolves.toMatchObject({ status: 'connected', agent: { name: 'Research agent' } });
|
| 119 | await expect(resolveInstallation(store, 'owner-b', 'codex-installation-a')).resolves.toEqual({
|
| 120 | status: 'unbound',
|
| 121 | });
|
| 122 | });
|
| 123 |
|
| 124 | it('supports unrelated agents and confirmed installation switching', async () => {
|
| 125 | const store = new MemoryOnboardingStore();
|
| 126 | const first = await requestAgentSetup(store, 'owner', {
|
| 127 | clientId: 'client-a',
|
| 128 | agentName: 'Coding agent',
|
| 129 | projectName: 'Product A',
|
| 130 | });
|
| 131 | const second = await requestAgentSetup(store, 'owner', {
|
| 132 | clientId: 'client-b',
|
| 133 | agentName: 'Support agent',
|
| 134 | projectName: 'Product B',
|
| 135 | });
|
| 136 | const firstBinding = await approveAgentSetup(store, 'owner', first.requestId);
|
| 137 | const secondBinding = await approveAgentSetup(store, 'owner', second.requestId);
|
| 138 | if (firstBinding.status !== 'connected' || secondBinding.status !== 'connected')
|
| 139 | throw new Error('Expected connected agents');
|
| 140 |
|
| 141 | const switchRequest = await requestAgentSetup(store, 'owner', {
|
| 142 | clientId: 'client-a',
|
| 143 | action: 'switch',
|
| 144 | existingAgentId: secondBinding.agent.agentId,
|
| 145 | });
|
| 146 | const beforeSwitch = await resolveInstallation(store, 'owner', 'client-a');
|
| 147 | expect(beforeSwitch.status).toBe('connected');
|
| 148 | if (beforeSwitch.status !== 'connected') throw new Error('Expected connected installation');
|
| 149 | expect(beforeSwitch.agent.agentId).toBe(firstBinding.agent.agentId);
|
| 150 | await approveAgentSetup(store, 'owner', switchRequest.requestId);
|
| 151 | const afterSwitch = await resolveInstallation(store, 'owner', 'client-a');
|
| 152 | expect(afterSwitch.status).toBe('connected');
|
| 153 | if (afterSwitch.status !== 'connected') throw new Error('Expected connected installation');
|
| 154 | expect(afterSwitch.agent.agentId).toBe(secondBinding.agent.agentId);
|
| 155 | const state = await getOnboardingState(store, 'owner');
|
| 156 | expect(state.projects).toHaveLength(2);
|
| 157 | expect(state.agentProfiles).toHaveLength(2);
|
| 158 | });
|
| 159 |
|
| 160 | it('updates only the profile bound to the authenticated installation', async () => {
|
| 161 | const store = new MemoryOnboardingStore();
|
| 162 | const request = await requestAgentSetup(store, 'owner', {
|
| 163 | clientId: 'client-a',
|
| 164 | agentName: 'Planner',
|
| 165 | projectName: 'Launch',
|
| 166 | });
|
| 167 | await approveAgentSetup(store, 'owner', request.requestId);
|
| 168 | const updated = await updateAgentProfile(store, 'owner', 'client-a', {
|
| 169 | capabilities: ['planning', 'coordination'],
|
| 170 | });
|
| 171 | expect(updated.capabilities).toEqual(['planning', 'coordination']);
|
| 172 | expect(updated.transport).toBe('direct_a2a');
|
| 173 | expect(updated.agentMode).toBe('temporary_chat');
|
| 174 | await expect(
|
| 175 | updateAgentProfile(store, 'owner', 'different-client', { name: 'Hijacked' }),
|
| 176 | ).rejects.toThrow('not connected');
|
| 177 | });
|
| 178 | });
|