@robota-sdk/agent-command 3.0.0-beta.92 → 3.0.0-beta.93
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/dist/node/index.cjs +78 -75
- package/dist/node/index.d.cts +13 -4
- package/dist/node/index.d.cts.map +1 -1
- package/dist/node/index.d.ts +13 -4
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +83 -80
- package/dist/node/index.js.map +1 -1
- package/package.json +7 -7
- package/src/default/default-command-modules.ts +15 -1
- package/src/model/model-command.ts +41 -16
- package/src/provider/__tests__/provider-connection-setup.test.ts +567 -0
- package/src/provider/__tests__/provider-reference-switch.test.ts +113 -0
- package/src/provider/provider-command-execution.ts +23 -5
- package/src/provider/provider-command-module.ts +8 -2
- package/src/provider/provider-command-profile-operations.ts +60 -12
- package/src/provider/provider-command-profile.ts +8 -0
- package/src/provider/provider-command-setup.ts +11 -60
- package/src/provider/provider-connection-setup.ts +287 -0
- package/src/provider/provider-connection-validation.ts +38 -0
- package/src/provider/provider-onboarding.ts +11 -2
- package/src/provider/provider-prompt-interaction.ts +28 -0
- package/src/provider/provider-setup-ask.ts +67 -0
- package/src/provider/provider-setup-flow.ts +5 -1
- package/src/provider/provider-startup.ts +63 -8
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type {
|
|
3
|
+
TProviderCredentialReference,
|
|
4
|
+
IProviderDefinition,
|
|
5
|
+
IUserInteraction,
|
|
6
|
+
} from '@robota-sdk/agent-core';
|
|
7
|
+
import { createTestCommandHost } from '@robota-sdk/agent-framework/testing';
|
|
8
|
+
import { createNodeHostSettingsSource } from '@robota-sdk/agent-framework';
|
|
9
|
+
import type {
|
|
10
|
+
IProviderConnectionHost,
|
|
11
|
+
TProviderSettingsDocument,
|
|
12
|
+
} from '@robota-sdk/agent-framework';
|
|
13
|
+
import { executeProviderCommand } from '../provider-command-execution.js';
|
|
14
|
+
import { runProviderStartupSetup } from '../provider-startup.js';
|
|
15
|
+
import { scriptedContext } from './scripted-interaction.js';
|
|
16
|
+
import { createProviderCommandModule } from '../provider-command-module.js';
|
|
17
|
+
|
|
18
|
+
const reference: TProviderCredentialReference = {
|
|
19
|
+
service: 'fixture.providers',
|
|
20
|
+
account: 'attempt-1',
|
|
21
|
+
};
|
|
22
|
+
const definition: IProviderDefinition = {
|
|
23
|
+
type: 'openrouter',
|
|
24
|
+
displayName: 'OpenRouter',
|
|
25
|
+
connectionMethods: ['api-key', 'browser'],
|
|
26
|
+
defaults: { model: 'maker/model', baseURL: 'https://openrouter.ai/api/v1' },
|
|
27
|
+
setupSteps: [
|
|
28
|
+
{ key: 'apiKey', title: 'API key', masked: true, required: true },
|
|
29
|
+
{ key: 'model', title: 'Model', defaultValue: 'maker/model' },
|
|
30
|
+
],
|
|
31
|
+
requiresApiKey: true,
|
|
32
|
+
createProvider: () => {
|
|
33
|
+
throw new Error('not used');
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function fixture(initial: TProviderSettingsDocument = {}) {
|
|
38
|
+
let settings = initial;
|
|
39
|
+
const connect = vi.fn<IProviderConnectionHost['connect']>(async (request, persist) => {
|
|
40
|
+
await persist(reference, request.signal ?? new AbortController().signal);
|
|
41
|
+
return reference;
|
|
42
|
+
});
|
|
43
|
+
return {
|
|
44
|
+
read: () => settings,
|
|
45
|
+
options: {
|
|
46
|
+
providerDefinitions: [definition],
|
|
47
|
+
env: { ROUTER_KEY: 'synthetic-environment-secret' },
|
|
48
|
+
connectionHost: { connect },
|
|
49
|
+
settings: {
|
|
50
|
+
readMergedSettings: () => settings,
|
|
51
|
+
readTargetSettings: () => settings,
|
|
52
|
+
writeTargetSettings: (next: TProviderSettingsDocument) => {
|
|
53
|
+
settings = next;
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
connect,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe('service connection setup', () => {
|
|
62
|
+
it('gives browser key recovery guidance without exposing an unsafe host error', async () => {
|
|
63
|
+
const { options, connect, read } = fixture();
|
|
64
|
+
connect.mockRejectedValueOnce(new Error('unsafe-host-secret-and-response'));
|
|
65
|
+
const { context } = scriptedContext([{ type: 'answer', values: ['browser'] }]);
|
|
66
|
+
const result = await executeProviderCommand(context, 'add openrouter', options);
|
|
67
|
+
expect(result.success).toBe(false);
|
|
68
|
+
expect(result.message).toContain('https://openrouter.ai/keys');
|
|
69
|
+
expect(result.message).toContain('may remain');
|
|
70
|
+
expect(result.message).toContain('ordinary OpenRouter API key');
|
|
71
|
+
expect(result.message).not.toContain('unsafe-host-secret-and-response');
|
|
72
|
+
expect(read()).toEqual({});
|
|
73
|
+
});
|
|
74
|
+
it('offers both methods and stores a manual key through the host without echoing it', async () => {
|
|
75
|
+
const { options, connect, read } = fixture();
|
|
76
|
+
const { context, requests } = scriptedContext(
|
|
77
|
+
[
|
|
78
|
+
{ type: 'answer', values: ['api-key'] },
|
|
79
|
+
{ type: 'answer', values: [], text: 'synthetic-manual-secret' },
|
|
80
|
+
{ type: 'answer', values: [], text: '' },
|
|
81
|
+
],
|
|
82
|
+
true,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const result = await executeProviderCommand(context, 'add openrouter', options);
|
|
86
|
+
|
|
87
|
+
expect(result.success).toBe(true);
|
|
88
|
+
expect(requests[0]).toMatchObject({
|
|
89
|
+
id: 'provider-connection-method',
|
|
90
|
+
options: [
|
|
91
|
+
{ value: 'api-key', label: 'API key' },
|
|
92
|
+
{ value: 'browser', label: 'Browser' },
|
|
93
|
+
],
|
|
94
|
+
});
|
|
95
|
+
expect(requests[1]).toMatchObject({ masked: true });
|
|
96
|
+
expect(connect).toHaveBeenCalledWith(
|
|
97
|
+
expect.objectContaining({
|
|
98
|
+
method: 'api-key',
|
|
99
|
+
apiKey: 'synthetic-manual-secret',
|
|
100
|
+
profile: 'openrouter',
|
|
101
|
+
}),
|
|
102
|
+
expect.any(Function),
|
|
103
|
+
);
|
|
104
|
+
expect(read().providers?.openrouter).toEqual({
|
|
105
|
+
type: 'openrouter',
|
|
106
|
+
model: 'maker/model',
|
|
107
|
+
baseURL: 'https://openrouter.ai/api/v1',
|
|
108
|
+
apiKeyRef: reference,
|
|
109
|
+
});
|
|
110
|
+
expect(JSON.stringify([read(), requests, result])).not.toContain('synthetic-manual-secret');
|
|
111
|
+
expect(result.hostActions).toEqual([{ type: 'provider-hot-swap', profileName: 'openrouter' }]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('keeps environment entry independent of the browser and credential store', async () => {
|
|
115
|
+
const { options, connect, read } = fixture();
|
|
116
|
+
const { context } = scriptedContext([
|
|
117
|
+
{ type: 'answer', values: ['api-key'] },
|
|
118
|
+
{ type: 'answer', values: [], text: '$ENV:ROUTER_KEY' },
|
|
119
|
+
{ type: 'answer', values: [], text: '' },
|
|
120
|
+
]);
|
|
121
|
+
|
|
122
|
+
const result = await executeProviderCommand(context, 'add openrouter', options);
|
|
123
|
+
|
|
124
|
+
expect(result.success).toBe(true);
|
|
125
|
+
expect(connect).not.toHaveBeenCalled();
|
|
126
|
+
expect(read().providers?.openrouter?.apiKey).toBe('$ENV:ROUTER_KEY');
|
|
127
|
+
expect(read().providers?.openrouter?.apiKeyRef).toBeUndefined();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('keeps an existing stored connection when its model is edited', async () => {
|
|
131
|
+
const profile = {
|
|
132
|
+
type: 'openrouter',
|
|
133
|
+
model: 'maker/model',
|
|
134
|
+
apiKeyRef: reference,
|
|
135
|
+
baseURL: 'https://openrouter.ai/api/v1',
|
|
136
|
+
};
|
|
137
|
+
const { options, connect, read } = fixture({
|
|
138
|
+
currentProvider: 'router',
|
|
139
|
+
providers: { router: profile },
|
|
140
|
+
});
|
|
141
|
+
const { context } = scriptedContext([
|
|
142
|
+
{ type: 'answer', values: [], text: 'maker/another-model' },
|
|
143
|
+
]);
|
|
144
|
+
|
|
145
|
+
const result = await executeProviderCommand(context, 'edit router', options);
|
|
146
|
+
|
|
147
|
+
expect(result.success).toBe(true);
|
|
148
|
+
expect(connect).not.toHaveBeenCalled();
|
|
149
|
+
expect(read().providers?.router).toEqual({ ...profile, model: 'maker/another-model' });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('requires explicit reconnect and preserves a working profile when the host fails', async () => {
|
|
153
|
+
const initial = {
|
|
154
|
+
currentProvider: 'router',
|
|
155
|
+
providers: {
|
|
156
|
+
router: { type: 'openrouter', model: 'maker/model', apiKey: '$ENV:ROUTER_KEY' },
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
const { options, connect, read } = fixture(initial);
|
|
160
|
+
connect.mockRejectedValueOnce(new Error('synthetic host failure'));
|
|
161
|
+
const { context } = scriptedContext([
|
|
162
|
+
{ type: 'answer', values: ['api-key'] },
|
|
163
|
+
{ type: 'answer', values: [], text: 'synthetic-replacement-secret' },
|
|
164
|
+
{ type: 'answer', values: [], text: '' },
|
|
165
|
+
]);
|
|
166
|
+
|
|
167
|
+
const result = await executeProviderCommand(context, 'reconnect router', options);
|
|
168
|
+
|
|
169
|
+
expect(connect).toHaveBeenCalledOnce();
|
|
170
|
+
expect(result.success).toBe(false);
|
|
171
|
+
expect(result.message).toContain('/provider reconnect router');
|
|
172
|
+
expect(read()).toEqual(initial);
|
|
173
|
+
expect(result.message).not.toContain('synthetic-replacement-secret');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it.each([
|
|
177
|
+
{ origin: 'environment', credential: { apiKey: '$ENV:ROUTER_KEY' } },
|
|
178
|
+
{ origin: 'stored key', credential: { apiKeyRef: reference } },
|
|
179
|
+
])(
|
|
180
|
+
'restarts after replacing the active $origin connection while preserving duplicate profiles',
|
|
181
|
+
async ({ credential }) => {
|
|
182
|
+
const previous = { type: 'openrouter', model: 'maker/model', ...credential };
|
|
183
|
+
const initial = {
|
|
184
|
+
currentProvider: 'router',
|
|
185
|
+
providers: { router: previous, duplicate: previous },
|
|
186
|
+
};
|
|
187
|
+
const { options, connect, read } = fixture(initial);
|
|
188
|
+
const nextReference = { ...reference, account: 'attempt-2' };
|
|
189
|
+
connect.mockImplementationOnce(async (request, persist) => {
|
|
190
|
+
await persist(nextReference, request.signal ?? new AbortController().signal);
|
|
191
|
+
return nextReference;
|
|
192
|
+
});
|
|
193
|
+
const { context } = scriptedContext([
|
|
194
|
+
{ type: 'answer', values: ['api-key'] },
|
|
195
|
+
{ type: 'answer', values: [], text: 'synthetic-new-account-key' },
|
|
196
|
+
{ type: 'answer', values: [], text: '' },
|
|
197
|
+
]);
|
|
198
|
+
|
|
199
|
+
const result = await executeProviderCommand(context, 'reconnect router', options);
|
|
200
|
+
|
|
201
|
+
expect(result.success).toBe(true);
|
|
202
|
+
expect(result.hostActions).toEqual([
|
|
203
|
+
{ type: 'session-restart', reason: 'other', message: 'Provider connection restart' },
|
|
204
|
+
]);
|
|
205
|
+
expect(read().currentProvider).toBe('router');
|
|
206
|
+
expect(read().providers?.router?.apiKeyRef).toEqual(nextReference);
|
|
207
|
+
expect(read().providers?.router?.apiKey).toBeUndefined();
|
|
208
|
+
expect(read().providers?.duplicate).toEqual(previous);
|
|
209
|
+
expect(JSON.stringify([read(), result])).not.toContain('synthetic-new-account-key');
|
|
210
|
+
},
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
it('reconnects an inactive profile without restarting or changing the active connection', async () => {
|
|
214
|
+
const active = { type: 'openrouter', model: 'maker/model', apiKey: '$ENV:ROUTER_KEY' };
|
|
215
|
+
const initial = {
|
|
216
|
+
currentProvider: 'working',
|
|
217
|
+
providers: {
|
|
218
|
+
working: active,
|
|
219
|
+
router: { type: 'openrouter', model: 'maker/model', apiKeyRef: reference },
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
const { options, connect, read } = fixture(initial);
|
|
223
|
+
const nextReference = { ...reference, account: 'attempt-2' };
|
|
224
|
+
connect.mockImplementationOnce(async (request, persist) => {
|
|
225
|
+
await persist(nextReference, request.signal ?? new AbortController().signal);
|
|
226
|
+
return nextReference;
|
|
227
|
+
});
|
|
228
|
+
const { context } = scriptedContext([
|
|
229
|
+
{ type: 'answer', values: ['api-key'] },
|
|
230
|
+
{ type: 'answer', values: [], text: 'synthetic-inactive-key' },
|
|
231
|
+
{ type: 'answer', values: [], text: '' },
|
|
232
|
+
]);
|
|
233
|
+
|
|
234
|
+
const result = await executeProviderCommand(context, 'reconnect router', options);
|
|
235
|
+
|
|
236
|
+
expect(result.success).toBe(true);
|
|
237
|
+
expect(result.hostActions).toBeUndefined();
|
|
238
|
+
expect(read().currentProvider).toBe('working');
|
|
239
|
+
expect(read().providers?.working).toEqual(active);
|
|
240
|
+
expect(read().providers?.router?.apiKeyRef).toEqual(nextReference);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('provides browser cancellation and returns to method selection before key setup', async () => {
|
|
244
|
+
const { options, connect, read } = fixture();
|
|
245
|
+
const ids: string[] = [];
|
|
246
|
+
const ui: IUserInteraction = {
|
|
247
|
+
ask: async (request) => {
|
|
248
|
+
ids.push(request.id);
|
|
249
|
+
if (request.id === 'provider-connection-method')
|
|
250
|
+
return {
|
|
251
|
+
type: 'answer',
|
|
252
|
+
values: [ids.filter((id) => id === request.id).length === 1 ? 'browser' : 'api-key'],
|
|
253
|
+
};
|
|
254
|
+
if (request.id.startsWith('provider-connection-progress')) return { type: 'cancelled' };
|
|
255
|
+
if (request.id === 'provider-connection-key')
|
|
256
|
+
return { type: 'answer', values: [], text: 'synthetic-fallback-secret' };
|
|
257
|
+
return { type: 'answer', values: [], text: 'maker/model' };
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
connect.mockImplementationOnce(
|
|
261
|
+
(request) =>
|
|
262
|
+
new Promise((_resolve, reject) => {
|
|
263
|
+
request.onProgress?.('awaiting-approval');
|
|
264
|
+
request.signal?.addEventListener('abort', () => reject(new Error('cancelled')), {
|
|
265
|
+
once: true,
|
|
266
|
+
});
|
|
267
|
+
}),
|
|
268
|
+
);
|
|
269
|
+
const context = createTestCommandHost({ overrides: { getUserInteraction: () => ui } });
|
|
270
|
+
|
|
271
|
+
const result = await executeProviderCommand(context, 'add openrouter', options);
|
|
272
|
+
|
|
273
|
+
expect(result.success).toBe(true);
|
|
274
|
+
expect(connect.mock.calls.map(([request]) => request.method)).toEqual(['browser', 'api-key']);
|
|
275
|
+
expect(connect.mock.calls[0]?.[0].signal?.aborted).toBe(true);
|
|
276
|
+
expect(read().providers?.openrouter?.apiKeyRef).toEqual(reference);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('offers first-run OpenRouter connection without claiming a pre-existing key is required', async () => {
|
|
280
|
+
const { options, connect, read } = fixture();
|
|
281
|
+
const labels: string[] = [];
|
|
282
|
+
const answers = ['4', '1', 'synthetic-startup-secret', '', ''];
|
|
283
|
+
const prompt = vi.fn(async (label: string) => {
|
|
284
|
+
labels.push(label);
|
|
285
|
+
return answers.shift() ?? '';
|
|
286
|
+
});
|
|
287
|
+
const store = {
|
|
288
|
+
kind: 'host' as const,
|
|
289
|
+
scope: 'user' as const,
|
|
290
|
+
displayName: 'fixture settings',
|
|
291
|
+
source: createNodeHostSettingsSource('user', '/fixture/settings.json'),
|
|
292
|
+
read: options.settings.readTargetSettings,
|
|
293
|
+
write: options.settings.writeTargetSettings,
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
await runProviderStartupSetup(
|
|
297
|
+
'/fixture',
|
|
298
|
+
{
|
|
299
|
+
settingsSources: [],
|
|
300
|
+
settingsStores: [store],
|
|
301
|
+
connectionHost: options.connectionHost,
|
|
302
|
+
},
|
|
303
|
+
prompt,
|
|
304
|
+
{ writeLine: () => {}, writeError: () => {} } as never,
|
|
305
|
+
[definition],
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
expect(labels[0]).toContain('Connect with OpenRouter');
|
|
309
|
+
expect(connect).toHaveBeenCalledOnce();
|
|
310
|
+
expect(read().providers?.openrouter?.apiKeyRef).toEqual(reference);
|
|
311
|
+
expect(labels.some((label) => label.includes('base URL'))).toBe(false);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('withdraws browser progress after authentication and only then asks for the model', async () => {
|
|
315
|
+
const { options, connect, read } = fixture();
|
|
316
|
+
const events: string[] = [];
|
|
317
|
+
const ui: IUserInteraction = {
|
|
318
|
+
ask: (request, askOptions) => {
|
|
319
|
+
events.push(request.id);
|
|
320
|
+
if (request.id === 'provider-connection-method')
|
|
321
|
+
return Promise.resolve({ type: 'answer', values: ['browser'] });
|
|
322
|
+
if (request.id.startsWith('provider-connection-progress')) {
|
|
323
|
+
return new Promise((resolve) =>
|
|
324
|
+
askOptions?.signal?.addEventListener('abort', () => resolve({ type: 'cancelled' }), {
|
|
325
|
+
once: true,
|
|
326
|
+
}),
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
return Promise.resolve({ type: 'answer', values: [], text: 'maker/selected' });
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
connect.mockImplementationOnce(async (request, persist) => {
|
|
333
|
+
events.push('authenticated');
|
|
334
|
+
request.onProgress?.('awaiting-approval');
|
|
335
|
+
request.onProgress?.('validating');
|
|
336
|
+
await persist(reference, request.signal ?? new AbortController().signal);
|
|
337
|
+
return reference;
|
|
338
|
+
});
|
|
339
|
+
const context = createTestCommandHost({
|
|
340
|
+
overrides: { getUserInteraction: () => ui, isSetupRequired: () => true },
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
const result = await executeProviderCommand(context, 'add openrouter', options);
|
|
344
|
+
|
|
345
|
+
expect(result.success).toBe(true);
|
|
346
|
+
expect(events.indexOf('authenticated')).toBeLessThan(events.indexOf('provider-setup-model'));
|
|
347
|
+
expect(events).not.toContain('provider-connection-key');
|
|
348
|
+
expect(connect.mock.calls[0]?.[0].signal?.aborted).toBe(false);
|
|
349
|
+
expect(read().providers?.openrouter?.model).toBe('maker/selected');
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('honors explicit browser cancellation submitted immediately before a progress transition', async () => {
|
|
353
|
+
const { options, connect, read } = fixture();
|
|
354
|
+
let methods = 0;
|
|
355
|
+
let submitCancel = (): void => {
|
|
356
|
+
throw new Error('No progress prompt is active.');
|
|
357
|
+
};
|
|
358
|
+
const modelPrompts: string[] = [];
|
|
359
|
+
const ui: IUserInteraction = {
|
|
360
|
+
ask: (request, askOptions) => {
|
|
361
|
+
if (request.id === 'provider-connection-method') {
|
|
362
|
+
methods += 1;
|
|
363
|
+
return Promise.resolve(
|
|
364
|
+
methods === 1 ? { type: 'answer', values: ['browser'] } : { type: 'cancelled' },
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
if (request.id.startsWith('provider-connection-progress')) {
|
|
368
|
+
return new Promise((resolve) => {
|
|
369
|
+
submitCancel = () => resolve({ type: 'answer', values: ['cancel'] });
|
|
370
|
+
askOptions?.signal?.addEventListener('abort', () => resolve({ type: 'cancelled' }), {
|
|
371
|
+
once: true,
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
modelPrompts.push(request.id);
|
|
376
|
+
return Promise.resolve({ type: 'answer', values: [], text: 'maker/selected' });
|
|
377
|
+
},
|
|
378
|
+
};
|
|
379
|
+
connect.mockImplementationOnce(async (request, persist) => {
|
|
380
|
+
request.onProgress?.('awaiting-approval');
|
|
381
|
+
submitCancel();
|
|
382
|
+
// The next stage withdraws the old prompt before its answer continuation runs.
|
|
383
|
+
request.onProgress?.('validating');
|
|
384
|
+
await Promise.resolve();
|
|
385
|
+
request.signal?.throwIfAborted();
|
|
386
|
+
await persist(reference, request.signal ?? new AbortController().signal);
|
|
387
|
+
return reference;
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
const result = await executeProviderCommand(
|
|
391
|
+
createTestCommandHost({ overrides: { getUserInteraction: () => ui } }),
|
|
392
|
+
'add openrouter',
|
|
393
|
+
options,
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
expect(connect.mock.calls[0]?.[0].signal?.aborted).toBe(true);
|
|
397
|
+
expect(modelPrompts).toEqual([]);
|
|
398
|
+
expect(read()).toEqual({});
|
|
399
|
+
expect(result.hostActions).toBeUndefined();
|
|
400
|
+
expect(methods).toBe(2);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('rejects a staged connection when model selection is cancelled', async () => {
|
|
404
|
+
const initial = {
|
|
405
|
+
providers: { router: { type: 'openrouter', model: 'maker/model', apiKeyRef: reference } },
|
|
406
|
+
};
|
|
407
|
+
const { options, connect, read } = fixture(initial);
|
|
408
|
+
const { context } = scriptedContext([
|
|
409
|
+
{ type: 'answer', values: ['api-key'] },
|
|
410
|
+
{ type: 'answer', values: [], text: 'synthetic-replacement' },
|
|
411
|
+
{ type: 'cancelled' },
|
|
412
|
+
{ type: 'cancelled' },
|
|
413
|
+
]);
|
|
414
|
+
const rejectedPersistence = vi.fn();
|
|
415
|
+
connect.mockImplementationOnce(async (request, persist) => {
|
|
416
|
+
try {
|
|
417
|
+
await persist(
|
|
418
|
+
{ ...reference, account: 'attempt-2' },
|
|
419
|
+
request.signal ?? new AbortController().signal,
|
|
420
|
+
);
|
|
421
|
+
} catch (error) {
|
|
422
|
+
rejectedPersistence();
|
|
423
|
+
throw error;
|
|
424
|
+
}
|
|
425
|
+
return reference;
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
const result = await executeProviderCommand(context, 'reconnect router', options);
|
|
429
|
+
|
|
430
|
+
expect(rejectedPersistence).toHaveBeenCalledOnce();
|
|
431
|
+
expect(read()).toEqual(initial);
|
|
432
|
+
expect(result.hostActions).toBeUndefined();
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('does not overwrite a profile or active choice changed while authentication is pending', async () => {
|
|
436
|
+
const initial = {
|
|
437
|
+
currentProvider: 'router',
|
|
438
|
+
providers: { router: { type: 'openrouter', model: 'maker/model', apiKeyRef: reference } },
|
|
439
|
+
};
|
|
440
|
+
const { options, connect, read } = fixture(initial);
|
|
441
|
+
const updated = {
|
|
442
|
+
...initial,
|
|
443
|
+
currentProvider: 'another',
|
|
444
|
+
providers: {
|
|
445
|
+
...initial.providers,
|
|
446
|
+
router: { ...initial.providers.router, model: 'maker/newer-choice' },
|
|
447
|
+
},
|
|
448
|
+
};
|
|
449
|
+
connect.mockImplementationOnce(async (request, persist) => {
|
|
450
|
+
options.settings.writeTargetSettings(updated);
|
|
451
|
+
await persist(
|
|
452
|
+
{ ...reference, account: 'attempt-2' },
|
|
453
|
+
request.signal ?? new AbortController().signal,
|
|
454
|
+
);
|
|
455
|
+
return reference;
|
|
456
|
+
});
|
|
457
|
+
const { context } = scriptedContext([
|
|
458
|
+
{ type: 'answer', values: ['api-key'] },
|
|
459
|
+
{ type: 'answer', values: [], text: 'synthetic-replacement' },
|
|
460
|
+
{ type: 'answer', values: [], text: '' },
|
|
461
|
+
]);
|
|
462
|
+
|
|
463
|
+
const result = await executeProviderCommand(context, 'reconnect router', options);
|
|
464
|
+
|
|
465
|
+
expect(result.success).toBe(false);
|
|
466
|
+
expect(read()).toEqual(updated);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it('offers only environment entry under environment-only organization policy', async () => {
|
|
470
|
+
const { options, connect, read } = fixture();
|
|
471
|
+
const { context, requests } = scriptedContext([
|
|
472
|
+
{ type: 'answer', values: ['api-key'] },
|
|
473
|
+
{ type: 'answer', values: [], text: 'synthetic-disallowed-literal' },
|
|
474
|
+
{ type: 'answer', values: ['api-key'] },
|
|
475
|
+
{ type: 'answer', values: [], text: '$ENV:ROUTER_KEY' },
|
|
476
|
+
{ type: 'answer', values: [], text: '' },
|
|
477
|
+
]);
|
|
478
|
+
|
|
479
|
+
const result = await executeProviderCommand(context, 'add openrouter', {
|
|
480
|
+
...options,
|
|
481
|
+
orgPolicy: { requireApiKeyFromEnv: true },
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
expect(result.success).toBe(true);
|
|
485
|
+
expect(requests[0]?.options?.map((option) => option.value)).toEqual(['api-key']);
|
|
486
|
+
expect(connect).not.toHaveBeenCalled();
|
|
487
|
+
expect(read().providers?.openrouter?.apiKey).toBe('$ENV:ROUTER_KEY');
|
|
488
|
+
expect(JSON.stringify([requests, read(), result])).not.toContain(
|
|
489
|
+
'synthetic-disallowed-literal',
|
|
490
|
+
);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it('withdraws model selection when the host connection deadline expires', async () => {
|
|
494
|
+
const { options, connect, read } = fixture();
|
|
495
|
+
const hostController = new AbortController();
|
|
496
|
+
let modelSignal: AbortSignal | undefined;
|
|
497
|
+
const ui: IUserInteraction = {
|
|
498
|
+
ask: (request, askOptions) => {
|
|
499
|
+
if (request.id === 'provider-connection-method')
|
|
500
|
+
return Promise.resolve({ type: 'answer', values: ['api-key'] });
|
|
501
|
+
if (request.id === 'provider-connection-key')
|
|
502
|
+
return Promise.resolve({ type: 'answer', values: [], text: 'synthetic-deadline-key' });
|
|
503
|
+
modelSignal = askOptions?.signal;
|
|
504
|
+
return new Promise((resolve) => {
|
|
505
|
+
askOptions?.signal?.addEventListener('abort', () => resolve({ type: 'cancelled' }), {
|
|
506
|
+
once: true,
|
|
507
|
+
});
|
|
508
|
+
hostController.abort();
|
|
509
|
+
});
|
|
510
|
+
},
|
|
511
|
+
};
|
|
512
|
+
const modelCancelled = vi.fn();
|
|
513
|
+
connect.mockImplementationOnce(async (_request, persist) => {
|
|
514
|
+
try {
|
|
515
|
+
await persist(reference, hostController.signal);
|
|
516
|
+
} catch (error) {
|
|
517
|
+
modelCancelled();
|
|
518
|
+
throw error;
|
|
519
|
+
}
|
|
520
|
+
return reference;
|
|
521
|
+
});
|
|
522
|
+
// After the aborted attempt, leave setup instead of starting another connection.
|
|
523
|
+
const firstAsk = ui.ask;
|
|
524
|
+
let choices = 0;
|
|
525
|
+
ui.ask = (request, askOptions) =>
|
|
526
|
+
request.id === 'provider-connection-method' && ++choices > 1
|
|
527
|
+
? Promise.resolve({ type: 'cancelled' })
|
|
528
|
+
: firstAsk(request, askOptions);
|
|
529
|
+
|
|
530
|
+
const result = await executeProviderCommand(
|
|
531
|
+
createTestCommandHost({ overrides: { getUserInteraction: () => ui } }),
|
|
532
|
+
'add openrouter',
|
|
533
|
+
options,
|
|
534
|
+
);
|
|
535
|
+
|
|
536
|
+
expect(modelSignal).toBe(hostController.signal);
|
|
537
|
+
expect(modelCancelled).toHaveBeenCalledOnce();
|
|
538
|
+
expect(read()).toEqual({});
|
|
539
|
+
expect(result.hostActions).toBeUndefined();
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it('keeps missing environment references out of the host and offers concrete retry guidance', async () => {
|
|
543
|
+
const { options, connect, read } = fixture();
|
|
544
|
+
const { context, requests } = scriptedContext([
|
|
545
|
+
{ type: 'answer', values: ['api-key'] },
|
|
546
|
+
{ type: 'answer', values: [], text: '$ENV:UNSET_ROUTER_KEY' },
|
|
547
|
+
{ type: 'cancelled' },
|
|
548
|
+
]);
|
|
549
|
+
|
|
550
|
+
const result = await executeProviderCommand(context, 'add openrouter', options);
|
|
551
|
+
|
|
552
|
+
expect(connect).not.toHaveBeenCalled();
|
|
553
|
+
expect(requests[2]?.description).toContain('Set its variable');
|
|
554
|
+
expect(read()).toEqual({});
|
|
555
|
+
expect(result.hostActions).toBeUndefined();
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
it('keeps connect and reconnect user-only at both command registration boundaries', () => {
|
|
559
|
+
const { options } = fixture();
|
|
560
|
+
const module = createProviderCommandModule(options);
|
|
561
|
+
const entry = module.commandSources?.[0]?.getCommands()[0];
|
|
562
|
+
expect(entry?.modelInvocable).toBe(false);
|
|
563
|
+
expect(module.systemCommands?.[0]?.modelInvocable).toBe(false);
|
|
564
|
+
expect(entry?.argumentHint).toContain('reconnect <profile>');
|
|
565
|
+
expect(entry?.description).toContain('/provider reconnect <profile>');
|
|
566
|
+
});
|
|
567
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { expect, it, vi } from 'vitest';
|
|
2
|
+
import type { TProviderCredentialResolver } from '@robota-sdk/agent-core';
|
|
3
|
+
import { createTestCommandHost } from '@robota-sdk/agent-framework/testing';
|
|
4
|
+
import { SystemCommandExecutor } from '@robota-sdk/agent-framework';
|
|
5
|
+
import type {
|
|
6
|
+
IProviderCommandModuleOptions,
|
|
7
|
+
TProviderSettingsDocument,
|
|
8
|
+
} from '@robota-sdk/agent-framework';
|
|
9
|
+
import { executeProviderCommand } from '../provider-command-execution.js';
|
|
10
|
+
import { executeModelCommand } from '../../model/model-command.js';
|
|
11
|
+
import { createDefaultCommandModules } from '../../default/default-command-modules.js';
|
|
12
|
+
|
|
13
|
+
function fixture() {
|
|
14
|
+
const initial: TProviderSettingsDocument = {
|
|
15
|
+
currentProvider: 'current',
|
|
16
|
+
providers: {
|
|
17
|
+
current: {
|
|
18
|
+
type: 'current-fixture',
|
|
19
|
+
model: 'current-model',
|
|
20
|
+
apiKey: 'synthetic-existing-key',
|
|
21
|
+
},
|
|
22
|
+
router: {
|
|
23
|
+
type: 'fixture',
|
|
24
|
+
model: 'router-model',
|
|
25
|
+
apiKeyRef: { service: 'fixture', account: 'missing' },
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
let settings = initial;
|
|
30
|
+
const resolveCredential = vi.fn<TProviderCredentialResolver>(async () => {
|
|
31
|
+
throw new Error('Saved connection unavailable.');
|
|
32
|
+
});
|
|
33
|
+
const options: IProviderCommandModuleOptions = {
|
|
34
|
+
providerDefinitions: [
|
|
35
|
+
{
|
|
36
|
+
type: 'current-fixture',
|
|
37
|
+
requiresApiKey: false,
|
|
38
|
+
createProvider: () => {
|
|
39
|
+
throw new Error('not used');
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
type: 'fixture',
|
|
44
|
+
requiresApiKey: true,
|
|
45
|
+
modelCatalog: { status: 'live', entries: [{ id: 'router-model', displayName: 'Router' }] },
|
|
46
|
+
createProvider: () => {
|
|
47
|
+
throw new Error('not used');
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
resolveCredential,
|
|
52
|
+
settings: {
|
|
53
|
+
readMergedSettings: () => settings,
|
|
54
|
+
readTargetSettings: () => settings,
|
|
55
|
+
writeTargetSettings: (next) => {
|
|
56
|
+
settings = next;
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
return { initial, options, resolveCredential, read: () => settings };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
it('resolves a stored provider connection before changing the active profile', async () => {
|
|
64
|
+
const { initial, options, resolveCredential, read } = fixture();
|
|
65
|
+
const result = await executeProviderCommand(createTestCommandHost(), 'switch router', options);
|
|
66
|
+
expect(resolveCredential).toHaveBeenCalledOnce();
|
|
67
|
+
expect(result.success).toBe(false);
|
|
68
|
+
expect(read()).toEqual(initial);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('resolves a stored connection before persisting a cross-profile model choice', async () => {
|
|
72
|
+
const { initial, options, resolveCredential, read } = fixture();
|
|
73
|
+
const context = createTestCommandHost({ session: { getModelId: () => 'current-model' } });
|
|
74
|
+
const result = await executeModelCommand(context, 'router-model', options);
|
|
75
|
+
expect(resolveCredential).toHaveBeenCalledOnce();
|
|
76
|
+
expect(result.success).toBe(false);
|
|
77
|
+
expect(read()).toEqual(initial);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it.each([
|
|
81
|
+
['agent-command-provider', 'provider', 'switch router'],
|
|
82
|
+
['agent-command-model', 'model', 'router-model'],
|
|
83
|
+
])(
|
|
84
|
+
'forwards the host resolver into %s and retains only the reference after switching',
|
|
85
|
+
async (moduleName, command, args) => {
|
|
86
|
+
const { initial, options, resolveCredential, read } = fixture();
|
|
87
|
+
resolveCredential.mockImplementationOnce(async (config) => ({
|
|
88
|
+
...config,
|
|
89
|
+
apiKey: 'synthetic-resolved-secret',
|
|
90
|
+
}));
|
|
91
|
+
const { modules } = createDefaultCommandModules({
|
|
92
|
+
cwd: '/fixture',
|
|
93
|
+
userLocalStorageRoot: '/fixture/local',
|
|
94
|
+
providerDefinitions: options.providerDefinitions,
|
|
95
|
+
providerSettingsAdapter: options.settings,
|
|
96
|
+
providerCredentialResolver: resolveCredential,
|
|
97
|
+
});
|
|
98
|
+
const module = modules.find((candidate) => candidate.name === moduleName);
|
|
99
|
+
const executor = new SystemCommandExecutor([...(module?.systemCommands ?? [])]);
|
|
100
|
+
|
|
101
|
+
const result = await executor.execute(
|
|
102
|
+
command,
|
|
103
|
+
createTestCommandHost({ session: { getModelId: () => 'current-model' } }),
|
|
104
|
+
args,
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
expect(result?.success).toBe(true);
|
|
108
|
+
expect(resolveCredential).toHaveBeenCalledOnce();
|
|
109
|
+
expect(read().currentProvider).toBe('router');
|
|
110
|
+
expect(read().providers?.router).toEqual(initial.providers?.router);
|
|
111
|
+
expect(JSON.stringify([read(), result])).not.toContain('synthetic-resolved-secret');
|
|
112
|
+
},
|
|
113
|
+
);
|