@robota-sdk/agent-command 3.0.0-beta.83 → 3.0.0-beta.85
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 +69 -0
- package/README.md +71 -62
- package/dist/node/index.cjs +75 -72
- package/dist/node/index.d.cts +37 -45
- package/dist/node/index.d.cts.map +1 -1
- package/dist/node/index.d.ts +37 -45
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +78 -75
- package/dist/node/index.js.map +1 -1
- package/package.json +9 -8
- package/src/agent/__tests__/agent-command.test.ts +62 -0
- package/src/agent/agent-command-parser.ts +2 -2
- package/src/agent/agent-command.ts +20 -0
- package/src/default/__tests__/__snapshots__/model-exposure.test.ts.snap +2 -2
- package/src/default/__tests__/default-command-modules.test.ts +5 -4
- package/src/default/__tests__/model-exposure.test.ts +2 -0
- package/src/default/default-command-modules.ts +8 -0
- package/src/doctor/__tests__/doctor-fixture.ts +1 -0
- package/src/doctor/__tests__/doctor-runner.test.ts +26 -6
- package/src/doctor/doctor-extensions-probe.ts +36 -12
- package/src/effort/effort-command.test.ts +65 -2
- package/src/effort/effort-command.ts +19 -5
- package/src/git/__tests__/fake-git-port.ts +9 -2
- package/src/git/git-command-module.ts +3 -4
- package/src/git/git-commit.ts +6 -3
- package/src/git/index.ts +4 -1
- package/src/goal/goal-command.ts +3 -1
- package/src/index.ts +6 -0
- package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +225 -1
- package/src/mcp-activation/mcp-activation-command-module.ts +6 -0
- package/src/mcp-activation/mcp-activation-command.ts +77 -2
- package/src/memory/__tests__/memory-command-module.test.ts +40 -2
- package/src/memory/__tests__/memory-command-unavailable.test.ts +63 -0
- package/src/memory/memory-command-module.ts +4 -2
- package/src/memory/memory-command.ts +42 -2
- package/src/mode/__tests__/mode-command-module.test.ts +40 -0
- package/src/mode/mode-command.ts +1 -1
- package/src/model/__tests__/model-command-module.test.ts +80 -0
- package/src/model/__tests__/model-command.test.ts +240 -0
- package/src/model/index.ts +6 -0
- package/src/model/model-command-module.ts +56 -0
- package/src/model/model-command.ts +223 -0
- package/src/plugin/__tests__/plugin-command-module.test.ts +84 -3
- package/src/plugin/plugin-command.ts +50 -8
- package/src/provider/__tests__/org-policy.test.ts +4 -0
- package/src/provider/__tests__/provider-command-module.test.ts +276 -14
- package/src/provider/__tests__/scripted-interaction.ts +12 -2
- package/src/provider/provider-command-execution.ts +54 -8
- package/src/provider/provider-command-profile-lifecycle.ts +48 -2
- package/src/provider/provider-command-profile-operations.ts +14 -0
- package/src/provider/provider-command-profile.ts +7 -4
- package/src/provider/provider-command-setup.ts +14 -1
- package/src/rewind/__tests__/rewind-command-module.test.ts +2 -1
- package/src/schedule/__tests__/loop-command-functional.test.ts +13 -0
- package/src/schedule/__tests__/loop-command.test.ts +238 -4
- package/src/schedule/loop-command.ts +95 -3
- package/src/schedule/schedule-command-module.ts +8 -4
- package/src/settings/settings-command-module.ts +2 -2
- package/src/git/__tests__/git-diff.test.ts +0 -125
- package/src/git/__tests__/git-process.test.ts +0 -221
- package/src/git/__tests__/git-status.test.ts +0 -117
- package/src/git/git-diff.ts +0 -129
- package/src/git/git-process.ts +0 -169
- package/src/git/git-status.ts +0 -134
|
@@ -46,7 +46,7 @@ function createCommandSessionRuntime() {
|
|
|
46
46
|
});
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
function createCommandHostContext(adapter?: ICommandPluginAdapter) {
|
|
49
|
+
function createCommandHostContext(adapter?: ICommandPluginAdapter, locality?: 'local' | 'remote') {
|
|
50
50
|
const checkpoint = {
|
|
51
51
|
id: 'checkpoint_1',
|
|
52
52
|
sessionId: 'session_1',
|
|
@@ -66,6 +66,7 @@ function createCommandHostContext(adapter?: ICommandPluginAdapter) {
|
|
|
66
66
|
}),
|
|
67
67
|
getAutoCompactThreshold: () => 0.8,
|
|
68
68
|
getCommandHostAdapters: () => (adapter === undefined ? {} : { plugin: adapter }),
|
|
69
|
+
...(locality ? { getCommandSurfaceLocality: () => locality } : {}),
|
|
69
70
|
compactContext: async () => undefined,
|
|
70
71
|
getCwd: () => '/workspace',
|
|
71
72
|
listCommands: () => [],
|
|
@@ -107,7 +108,9 @@ describe('createPluginCommandModule', () => {
|
|
|
107
108
|
}),
|
|
108
109
|
expect.objectContaining({
|
|
109
110
|
name: 'reload-plugins',
|
|
110
|
-
description:
|
|
111
|
+
description: expect.stringContaining(
|
|
112
|
+
'refresh the command list, skill routing and model skill catalogue',
|
|
113
|
+
),
|
|
111
114
|
source: 'plugin-manager',
|
|
112
115
|
modelInvocable: false,
|
|
113
116
|
}),
|
|
@@ -186,6 +189,82 @@ describe('executePluginCommand', () => {
|
|
|
186
189
|
message: 'Usage: /plugin disable <name>@<marketplace>',
|
|
187
190
|
});
|
|
188
191
|
});
|
|
192
|
+
|
|
193
|
+
it('lists installed plugins through the adapter, for the Settings screen (#3282 §4 part b-2)', async () => {
|
|
194
|
+
const adapter = createPluginAdapter({
|
|
195
|
+
listInstalled: vi.fn().mockResolvedValue([
|
|
196
|
+
{ name: 'formatter@robota', description: 'Formats code.', enabled: true },
|
|
197
|
+
{ name: 'linter@robota', description: 'Lints code.', enabled: false },
|
|
198
|
+
]),
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const result = await executePluginCommand(createCommandHostContext(adapter), 'list');
|
|
202
|
+
|
|
203
|
+
expect(result.success).toBe(true);
|
|
204
|
+
expect(result.data).toEqual({
|
|
205
|
+
plugins: [
|
|
206
|
+
{ name: 'formatter@robota', description: 'Formats code.', enabled: true },
|
|
207
|
+
{ name: 'linter@robota', description: 'Lints code.', enabled: false },
|
|
208
|
+
],
|
|
209
|
+
});
|
|
210
|
+
expect(result.message).toContain('formatter@robota');
|
|
211
|
+
expect(result.message).toContain('linter@robota (disabled)');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('no plugins installed reports a plain empty list', async () => {
|
|
215
|
+
const adapter = createPluginAdapter();
|
|
216
|
+
const result = await executePluginCommand(createCommandHostContext(adapter), 'list');
|
|
217
|
+
expect(result).toEqual({
|
|
218
|
+
success: true,
|
|
219
|
+
message: 'No plugins are installed.',
|
|
220
|
+
data: { plugins: [] },
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe('install/uninstall stay local-surface-only (#3282 §4 part b-2)', () => {
|
|
225
|
+
it('runs install from a local surface', async () => {
|
|
226
|
+
const adapter = createPluginAdapter();
|
|
227
|
+
const result = await executePluginCommand(
|
|
228
|
+
createCommandHostContext(adapter, 'local'),
|
|
229
|
+
'install demo@community',
|
|
230
|
+
);
|
|
231
|
+
expect(result.success).toBe(true);
|
|
232
|
+
expect(adapter.install).toHaveBeenCalledWith('demo@community');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('refuses install from a remote surface with a plain message, writing nothing', async () => {
|
|
236
|
+
const adapter = createPluginAdapter();
|
|
237
|
+
const result = await executePluginCommand(
|
|
238
|
+
createCommandHostContext(adapter, 'remote'),
|
|
239
|
+
'install demo@community',
|
|
240
|
+
);
|
|
241
|
+
expect(result.success).toBe(false);
|
|
242
|
+
expect(result.message).toMatch(/only works from the desktop app or the page opened here/);
|
|
243
|
+
expect(result.message).toMatch(/not from a remote device/);
|
|
244
|
+
expect(adapter.install).not.toHaveBeenCalled();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('refuses uninstall from a remote surface with a plain message, writing nothing', async () => {
|
|
248
|
+
const adapter = createPluginAdapter();
|
|
249
|
+
const result = await executePluginCommand(
|
|
250
|
+
createCommandHostContext(adapter, 'remote'),
|
|
251
|
+
'uninstall demo@community',
|
|
252
|
+
);
|
|
253
|
+
expect(result.success).toBe(false);
|
|
254
|
+
expect(result.message).toMatch(/not from a remote device/);
|
|
255
|
+
expect(adapter.uninstall).not.toHaveBeenCalled();
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('never refuses enable/disable/reload from a remote surface — only install/uninstall run new code', async () => {
|
|
259
|
+
const adapter = createPluginAdapter();
|
|
260
|
+
const enableResult = await executePluginCommand(
|
|
261
|
+
createCommandHostContext(adapter, 'remote'),
|
|
262
|
+
'enable demo@community',
|
|
263
|
+
);
|
|
264
|
+
expect(enableResult.success).toBe(true);
|
|
265
|
+
expect(adapter.enable).toHaveBeenCalledWith('demo@community');
|
|
266
|
+
});
|
|
267
|
+
});
|
|
189
268
|
});
|
|
190
269
|
|
|
191
270
|
describe('executeReloadPluginsCommand', () => {
|
|
@@ -198,7 +277,8 @@ describe('executeReloadPluginsCommand', () => {
|
|
|
198
277
|
executeReloadPluginsCommand(createCommandHostContext(adapter), ''),
|
|
199
278
|
).resolves.toEqual({
|
|
200
279
|
success: true,
|
|
201
|
-
message:
|
|
280
|
+
message:
|
|
281
|
+
'Reloaded 3 plugin resources. Plugin commands and skills are active now; hook changes apply to new sessions.',
|
|
202
282
|
data: { pluginRegistryReloaded: true },
|
|
203
283
|
});
|
|
204
284
|
expect(adapter.reloadPlugins).toHaveBeenCalled();
|
|
@@ -214,6 +294,7 @@ describe('executeReloadPluginsCommand', () => {
|
|
|
214
294
|
).resolves.toEqual({
|
|
215
295
|
success: false,
|
|
216
296
|
message: 'Plugin error: manifest failed',
|
|
297
|
+
data: { pluginRegistryReloaded: true },
|
|
217
298
|
});
|
|
218
299
|
});
|
|
219
300
|
});
|
|
@@ -3,9 +3,13 @@ import {
|
|
|
3
3
|
resolvePluginCommandAdapter,
|
|
4
4
|
} from '@robota-sdk/agent-framework';
|
|
5
5
|
|
|
6
|
-
import type { ICommandHostAdapterAccess } from '@robota-sdk/agent-framework';
|
|
6
|
+
import type { ICommandHostAdapterAccess, ICommandHostWorkspace } from '@robota-sdk/agent-framework';
|
|
7
7
|
import type { ICommandPluginAdapter, ICommandResult } from '@robota-sdk/agent-interface-command';
|
|
8
8
|
|
|
9
|
+
/** What `/plugin` reaches: its adapter port, and — for install/uninstall — this connection's locality. */
|
|
10
|
+
export type TPluginCommandContext = ICommandHostAdapterAccess &
|
|
11
|
+
Partial<Pick<ICommandHostWorkspace, 'getCommandSurfaceLocality'>>;
|
|
12
|
+
|
|
9
13
|
function getSubcommandParts(args: string): { subcommand: string; subArgs: string } {
|
|
10
14
|
const parts = args
|
|
11
15
|
.trim()
|
|
@@ -28,6 +32,20 @@ function getPluginAdapter(context: ICommandHostAdapterAccess): ICommandPluginAda
|
|
|
28
32
|
return resolvePluginCommandAdapter(context);
|
|
29
33
|
}
|
|
30
34
|
|
|
35
|
+
const REMOTE_INSTALL_REFUSAL =
|
|
36
|
+
'Installing and uninstalling plugins runs code on this computer, so it only works from the ' +
|
|
37
|
+
'desktop app or the page opened here — not from a remote device.';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* #3282 §4 part b-2: the same rule the GUI's Settings screen applies before it ever sends the
|
|
41
|
+
* write — installing/uninstalling stays local-surface-only because it runs third-party code.
|
|
42
|
+
* `getCommandSurfaceLocality` absent (an older host, a test double, the in-process TUI) reads as
|
|
43
|
+
* `'local'`, matching the allow-by-default posture the rest of the command layer already uses.
|
|
44
|
+
*/
|
|
45
|
+
function isRemoteSurface(context: TPluginCommandContext): boolean {
|
|
46
|
+
return (context.getCommandSurfaceLocality?.() ?? 'local') === 'remote';
|
|
47
|
+
}
|
|
48
|
+
|
|
31
49
|
async function executePluginOperation(
|
|
32
50
|
context: ICommandHostAdapterAccess,
|
|
33
51
|
operation: (adapter: ICommandPluginAdapter) => Promise<string>,
|
|
@@ -123,9 +141,10 @@ function executePluginManager(): ICommandResult {
|
|
|
123
141
|
}
|
|
124
142
|
|
|
125
143
|
function executeInstallCommand(
|
|
126
|
-
context:
|
|
144
|
+
context: TPluginCommandContext,
|
|
127
145
|
pluginId: string,
|
|
128
146
|
): Promise<ICommandResult> {
|
|
147
|
+
if (isRemoteSurface(context)) return Promise.resolve(usage(REMOTE_INSTALL_REFUSAL));
|
|
129
148
|
return executePluginIdOperation(
|
|
130
149
|
context,
|
|
131
150
|
pluginId,
|
|
@@ -138,9 +157,10 @@ function executeInstallCommand(
|
|
|
138
157
|
}
|
|
139
158
|
|
|
140
159
|
function executeUninstallCommand(
|
|
141
|
-
context:
|
|
160
|
+
context: TPluginCommandContext,
|
|
142
161
|
pluginId: string,
|
|
143
162
|
): Promise<ICommandResult> {
|
|
163
|
+
if (isRemoteSurface(context)) return Promise.resolve(usage(REMOTE_INSTALL_REFUSAL));
|
|
144
164
|
return executePluginIdOperation(
|
|
145
165
|
context,
|
|
146
166
|
pluginId,
|
|
@@ -152,6 +172,27 @@ function executeUninstallCommand(
|
|
|
152
172
|
);
|
|
153
173
|
}
|
|
154
174
|
|
|
175
|
+
/** #3282 §4 part b-2: the Settings screen's Plugins section reads through this, like `/mcp status`. */
|
|
176
|
+
async function executeListInstalledCommand(
|
|
177
|
+
context: ICommandHostAdapterAccess,
|
|
178
|
+
): Promise<ICommandResult> {
|
|
179
|
+
const adapter = getPluginAdapter(context);
|
|
180
|
+
if (adapter === undefined) {
|
|
181
|
+
return { success: true, message: 'Plugin management is not available.', data: { plugins: [] } };
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
const plugins = await adapter.listInstalled();
|
|
185
|
+
const message =
|
|
186
|
+
plugins.length === 0
|
|
187
|
+
? 'No plugins are installed.'
|
|
188
|
+
: `Installed plugins:\n${plugins.map((plugin) => ` ${plugin.name}${plugin.enabled ? '' : ' (disabled)'} — ${plugin.description}`).join('\n')}`;
|
|
189
|
+
return { success: true, message, data: { plugins } };
|
|
190
|
+
} catch (error) {
|
|
191
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
192
|
+
return { success: false, message: `Plugin error: ${message}` };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
155
196
|
function executeEnableCommand(
|
|
156
197
|
context: ICommandHostAdapterAccess,
|
|
157
198
|
pluginId: string,
|
|
@@ -183,7 +224,7 @@ function executeDisableCommand(
|
|
|
183
224
|
}
|
|
184
225
|
|
|
185
226
|
export async function executePluginCommand(
|
|
186
|
-
context:
|
|
227
|
+
context: TPluginCommandContext,
|
|
187
228
|
args: string,
|
|
188
229
|
): Promise<ICommandResult> {
|
|
189
230
|
const { subcommand, subArgs } = getSubcommandParts(args);
|
|
@@ -191,6 +232,8 @@ export async function executePluginCommand(
|
|
|
191
232
|
case '':
|
|
192
233
|
case 'manage':
|
|
193
234
|
return executePluginManager();
|
|
235
|
+
case 'list':
|
|
236
|
+
return executeListInstalledCommand(context);
|
|
194
237
|
case 'install':
|
|
195
238
|
return executeInstallCommand(context, subArgs);
|
|
196
239
|
case 'uninstall':
|
|
@@ -216,11 +259,10 @@ export async function executeReloadPluginsCommand(
|
|
|
216
259
|
result.loadedPluginCount === 1
|
|
217
260
|
? '1 plugin resource'
|
|
218
261
|
: `${result.loadedPluginCount} plugin resources`;
|
|
219
|
-
return `Reloaded ${suffix}.`;
|
|
262
|
+
return `Reloaded ${suffix}. Plugin commands and skills are active now; hook changes apply to new sessions.`;
|
|
220
263
|
}).then((result) => {
|
|
221
|
-
|
|
222
|
-
//
|
|
223
|
-
// the requester-local registry/autocomplete refresh rides the result as a data hint.
|
|
264
|
+
// A failed reload must also refresh the admitted snapshot: malformed settings must not leave
|
|
265
|
+
// previously loaded code executable. Preserve the error while requesting the same refresh.
|
|
224
266
|
return {
|
|
225
267
|
...result,
|
|
226
268
|
data: { ...result.data, pluginRegistryReloaded: true },
|
|
@@ -97,6 +97,10 @@ describe('org policy enforcement in provider commands', () => {
|
|
|
97
97
|
});
|
|
98
98
|
|
|
99
99
|
it('allows /provider switch when target is in allowedProviders', async () => {
|
|
100
|
+
// #3282: the switch now validates the target profile against `providerDefinitions` before
|
|
101
|
+
// writing anything, the same way the hot-swap it triggers would build the provider — so the
|
|
102
|
+
// profile needs a resolvable credential, same as a real one would.
|
|
103
|
+
vi.stubEnv('OPENAI_API_KEY', 'sk-test');
|
|
100
104
|
const { adapter } = createSettingsAdapter({
|
|
101
105
|
currentProvider: 'anthropic',
|
|
102
106
|
providers: {
|
|
@@ -169,6 +169,31 @@ describe('createProviderCommandModule', () => {
|
|
|
169
169
|
expect(requests[1]?.title).toBe('Provider profile: anthropic');
|
|
170
170
|
});
|
|
171
171
|
|
|
172
|
+
it('#3282 §2: the profile action menu offers Switch, Edit, Test, Duplicate and Delete — no separate Cancel option (the picker\'s own Cancel affordance is the only one)', async () => {
|
|
173
|
+
const { adapter } = createSettingsAdapter({
|
|
174
|
+
currentProvider: 'openai',
|
|
175
|
+
providers: {
|
|
176
|
+
openai: { type: 'openai', model: 'supergemma4-26b-uncensored-v2' },
|
|
177
|
+
anthropic: { type: 'anthropic', model: 'claude-sonnet-4-6' },
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const { context, requests } = scriptedContext([
|
|
182
|
+
{ type: 'answer', values: ['anthropic'] },
|
|
183
|
+
{ type: 'cancelled' },
|
|
184
|
+
]);
|
|
185
|
+
await createExecutor(adapter).execute('provider', context, '');
|
|
186
|
+
|
|
187
|
+
const actionMenu = requests[1];
|
|
188
|
+
expect(actionMenu?.options?.map((option) => option.value)).toEqual([
|
|
189
|
+
'switch',
|
|
190
|
+
'edit',
|
|
191
|
+
'test',
|
|
192
|
+
'duplicate',
|
|
193
|
+
'delete',
|
|
194
|
+
]);
|
|
195
|
+
});
|
|
196
|
+
|
|
172
197
|
it('switches provider immediately via /provider switch without confirmation dialog', async () => {
|
|
173
198
|
const { adapter, readTarget } = createSettingsAdapter(
|
|
174
199
|
{
|
|
@@ -323,7 +348,7 @@ describe('createProviderCommandModule', () => {
|
|
|
323
348
|
|
|
324
349
|
// The confirm prompt must actually be issued — guards against a silent drop of the
|
|
325
350
|
// confirmation step that would still "delete and pass".
|
|
326
|
-
expect(requests[2]?.title).toBe('Delete
|
|
351
|
+
expect(requests[2]?.title).toBe('Delete profile "anthropic"?');
|
|
327
352
|
expect(completed?.message).toBe('Provider profile deleted: anthropic.');
|
|
328
353
|
expect(readTarget()).toEqual({
|
|
329
354
|
currentProvider: 'openai',
|
|
@@ -360,7 +385,7 @@ describe('createProviderCommandModule', () => {
|
|
|
360
385
|
const completed = await createExecutor(adapter).execute('provider', context, 'list');
|
|
361
386
|
|
|
362
387
|
// The confirm precedes the replacement picker — assert both so neither step can be dropped silently.
|
|
363
|
-
expect(requests[2]?.title).toBe('Delete
|
|
388
|
+
expect(requests[2]?.title).toBe('Delete profile "anthropic"?');
|
|
364
389
|
expect(requests[3]?.title).toBe('Replacement provider for anthropic');
|
|
365
390
|
expect(readTarget()).toEqual({
|
|
366
391
|
currentProvider: 'openai',
|
|
@@ -399,14 +424,20 @@ describe('createProviderCommandModule', () => {
|
|
|
399
424
|
expect(result?.message).toContain('not stored in the active write target');
|
|
400
425
|
});
|
|
401
426
|
|
|
402
|
-
it('owns provider setup flow
|
|
427
|
+
it('owns provider setup flow, writes settings, and hot-swaps a session actually in setup mode (#3282 §3)', async () => {
|
|
403
428
|
const { adapter, readTarget } = createSettingsAdapter({}, {});
|
|
404
429
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
430
|
+
// #3282 §3: the session's own live `isSetupRequired()` says so — a served runtime's setup mode has
|
|
431
|
+
// no session to restart into, so this hot-swaps a running placeholder instead. Restarting a live
|
|
432
|
+
// session that already has a provider (adding a second profile, below) is unaffected.
|
|
433
|
+
const { context, requests } = scriptedContext(
|
|
434
|
+
[
|
|
435
|
+
{ type: 'answer', values: [], text: '' },
|
|
436
|
+
{ type: 'answer', values: [], text: '' },
|
|
437
|
+
{ type: 'answer', values: [], text: '' },
|
|
438
|
+
],
|
|
439
|
+
true,
|
|
440
|
+
);
|
|
410
441
|
const completed = await createExecutor(adapter).execute('provider', context, 'add openai');
|
|
411
442
|
|
|
412
443
|
expect(requests[0]?.title).toBe('OpenAI-compatible base URL');
|
|
@@ -434,12 +465,26 @@ describe('createProviderCommandModule', () => {
|
|
|
434
465
|
},
|
|
435
466
|
},
|
|
436
467
|
});
|
|
468
|
+
expect(completed?.hostActions).toEqual([{ type: 'provider-hot-swap', profileName: 'openai' }]);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it('restarts (never hot-swaps) an env-default session with no persisted provider either (#3282 §3)', async () => {
|
|
472
|
+
// A session that started from a recognized provider env key (zero-config) has no `currentProvider`
|
|
473
|
+
// persisted, exactly like a served runtime's setup-mode session — but it is NOT in setup mode.
|
|
474
|
+
// Inferring "first ever" from settings alone would hot-swap it silently; only the session's own
|
|
475
|
+
// `isSetupRequired()` (false here, the default) tells the two apart.
|
|
476
|
+
const { adapter, readTarget } = createSettingsAdapter({}, {});
|
|
477
|
+
|
|
478
|
+
const { context } = scriptedContext([
|
|
479
|
+
{ type: 'answer', values: [], text: '' },
|
|
480
|
+
{ type: 'answer', values: [], text: '' },
|
|
481
|
+
{ type: 'answer', values: [], text: '' },
|
|
482
|
+
]);
|
|
483
|
+
const completed = await createExecutor(adapter).execute('provider', context, 'add openai');
|
|
484
|
+
|
|
485
|
+
expect(readTarget()).toMatchObject({ currentProvider: 'openai' });
|
|
437
486
|
expect(completed?.hostActions).toEqual([
|
|
438
|
-
{
|
|
439
|
-
type: 'session-restart',
|
|
440
|
-
reason: 'other',
|
|
441
|
-
message: 'Provider setup restart',
|
|
442
|
-
},
|
|
487
|
+
{ type: 'session-restart', reason: 'other', message: 'Provider setup restart' },
|
|
443
488
|
]);
|
|
444
489
|
});
|
|
445
490
|
|
|
@@ -493,8 +538,13 @@ describe('createProviderCommandModule', () => {
|
|
|
493
538
|
{ type: 'answer', values: [], text: '' },
|
|
494
539
|
{ type: 'answer', values: [], text: '' },
|
|
495
540
|
]);
|
|
496
|
-
await createExecutor(adapter).execute('provider', context, 'add openai');
|
|
541
|
+
const completed = await createExecutor(adapter).execute('provider', context, 'add openai');
|
|
497
542
|
|
|
543
|
+
// #3282 §3: this session is not in setup mode (scriptedContext's isSetupRequired default, false) —
|
|
544
|
+
// it restarts to pick up the new profile, exactly as an ordinary session with one already active.
|
|
545
|
+
expect(completed?.hostActions).toEqual([
|
|
546
|
+
{ type: 'session-restart', reason: 'other', message: 'Provider setup restart' },
|
|
547
|
+
]);
|
|
498
548
|
expect(readTarget()).toMatchObject({
|
|
499
549
|
currentProvider: 'openai-2',
|
|
500
550
|
providers: {
|
|
@@ -542,4 +592,216 @@ describe('createProviderCommandModule', () => {
|
|
|
542
592
|
expect(result?.message).toContain('manual configuration can continue');
|
|
543
593
|
expect(probe).toHaveBeenCalled();
|
|
544
594
|
});
|
|
595
|
+
|
|
596
|
+
// #3282 §4b: direct, non-interactive forms of Edit/Duplicate/Delete — the Settings screen already
|
|
597
|
+
// knows which profile a button belongs to, so it names it directly instead of repeating the
|
|
598
|
+
// profile-action menu's own "which profile" ask.
|
|
599
|
+
describe('direct edit/duplicate/delete subcommands (#3282 §4b)', () => {
|
|
600
|
+
it('edits a named profile directly, skipping the "which profile" ask', async () => {
|
|
601
|
+
const { adapter, readTarget } = createSettingsAdapter(
|
|
602
|
+
{
|
|
603
|
+
currentProvider: 'anthropic',
|
|
604
|
+
providers: {
|
|
605
|
+
anthropic: { type: 'anthropic', model: 'claude-sonnet-4-6', apiKey: 'sk-ant-secret' },
|
|
606
|
+
},
|
|
607
|
+
},
|
|
608
|
+
{},
|
|
609
|
+
);
|
|
610
|
+
|
|
611
|
+
const { context, requests } = scriptedContext([
|
|
612
|
+
{ type: 'answer', values: [], text: '' },
|
|
613
|
+
{ type: 'answer', values: [], text: 'claude-opus-4-5' },
|
|
614
|
+
]);
|
|
615
|
+
const completed = await createExecutor(adapter).execute('provider', context, 'edit anthropic');
|
|
616
|
+
|
|
617
|
+
expect(requests[0]?.title).toBe('anthropic API key');
|
|
618
|
+
expect(readTarget()).toMatchObject({
|
|
619
|
+
providers: { anthropic: { model: 'claude-opus-4-5', apiKey: 'sk-ant-secret' } },
|
|
620
|
+
});
|
|
621
|
+
expect(completed?.message).toBe('Provider anthropic updated. Switching...');
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
it('requires an interactive renderer to edit', async () => {
|
|
625
|
+
const { adapter } = createSettingsAdapter({
|
|
626
|
+
currentProvider: 'anthropic',
|
|
627
|
+
providers: { anthropic: { type: 'anthropic', model: 'claude-sonnet-4-6' } },
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
const result = await createExecutor(adapter).execute('provider', headlessContext, 'edit anthropic');
|
|
631
|
+
|
|
632
|
+
expect(result?.success).toBe(false);
|
|
633
|
+
expect(result?.message).toContain('interactive session');
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
it('duplicates a named profile directly, skipping the "which profile" ask', async () => {
|
|
637
|
+
const { adapter, readTarget } = createSettingsAdapter({
|
|
638
|
+
currentProvider: 'openai',
|
|
639
|
+
providers: {
|
|
640
|
+
openai: { type: 'openai', model: 'supergemma4-26b-uncensored-v2', apiKey: 'lm-studio' },
|
|
641
|
+
},
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
const { context, requests } = scriptedContext([{ type: 'answer', values: [], text: '' }]);
|
|
645
|
+
const completed = await createExecutor(adapter).execute('provider', context, 'duplicate openai');
|
|
646
|
+
|
|
647
|
+
expect(requests[0]?.title).toBe('Duplicate openai as');
|
|
648
|
+
expect(readTarget()).toMatchObject({
|
|
649
|
+
providers: { 'openai-copy': { type: 'openai', model: 'supergemma4-26b-uncensored-v2' } },
|
|
650
|
+
});
|
|
651
|
+
expect(completed?.hostActions).toBeUndefined();
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
it('the Settings screen\'s own write (--confirmed) deletes with no confirm ask and no replacement ask', async () => {
|
|
655
|
+
const { adapter, readTarget } = createSettingsAdapter(
|
|
656
|
+
{
|
|
657
|
+
currentProvider: 'openai',
|
|
658
|
+
providers: {
|
|
659
|
+
openai: { type: 'openai', model: 'supergemma4-26b-uncensored-v2' },
|
|
660
|
+
anthropic: { type: 'anthropic', model: 'claude-sonnet-4-6' },
|
|
661
|
+
},
|
|
662
|
+
},
|
|
663
|
+
{
|
|
664
|
+
currentProvider: 'openai',
|
|
665
|
+
providers: {
|
|
666
|
+
openai: { type: 'openai', model: 'supergemma4-26b-uncensored-v2' },
|
|
667
|
+
anthropic: { type: 'anthropic', model: 'claude-sonnet-4-6' },
|
|
668
|
+
},
|
|
669
|
+
},
|
|
670
|
+
);
|
|
671
|
+
|
|
672
|
+
const { context, requests } = scriptedContext([]);
|
|
673
|
+
const completed = await createExecutor(adapter).execute(
|
|
674
|
+
'provider',
|
|
675
|
+
context,
|
|
676
|
+
'delete anthropic --confirmed',
|
|
677
|
+
);
|
|
678
|
+
|
|
679
|
+
expect(requests).toEqual([]);
|
|
680
|
+
expect(completed?.message).toBe('Provider profile deleted: anthropic.');
|
|
681
|
+
expect(readTarget()).toEqual({
|
|
682
|
+
currentProvider: 'openai',
|
|
683
|
+
providers: { openai: { type: 'openai', model: 'supergemma4-26b-uncensored-v2' } },
|
|
684
|
+
});
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
it('a plainly-typed delete confirms first, interactively, before deleting', async () => {
|
|
688
|
+
const { adapter, readTarget } = createSettingsAdapter(
|
|
689
|
+
{
|
|
690
|
+
currentProvider: 'openai',
|
|
691
|
+
providers: {
|
|
692
|
+
openai: { type: 'openai', model: 'supergemma4-26b-uncensored-v2' },
|
|
693
|
+
anthropic: { type: 'anthropic', model: 'claude-sonnet-4-6' },
|
|
694
|
+
},
|
|
695
|
+
},
|
|
696
|
+
{
|
|
697
|
+
currentProvider: 'openai',
|
|
698
|
+
providers: {
|
|
699
|
+
openai: { type: 'openai', model: 'supergemma4-26b-uncensored-v2' },
|
|
700
|
+
anthropic: { type: 'anthropic', model: 'claude-sonnet-4-6' },
|
|
701
|
+
},
|
|
702
|
+
},
|
|
703
|
+
);
|
|
704
|
+
|
|
705
|
+
const { context, requests } = scriptedContext([{ type: 'answer', values: ['yes'] }]);
|
|
706
|
+
const completed = await createExecutor(adapter).execute('provider', context, 'delete anthropic');
|
|
707
|
+
|
|
708
|
+
expect(requests[0]?.title).toBe('Delete profile "anthropic"?');
|
|
709
|
+
expect(completed?.message).toBe('Provider profile deleted: anthropic.');
|
|
710
|
+
expect(readTarget()).toEqual({
|
|
711
|
+
currentProvider: 'openai',
|
|
712
|
+
providers: { openai: { type: 'openai', model: 'supergemma4-26b-uncensored-v2' } },
|
|
713
|
+
});
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
it('a declined confirm cancels a plainly-typed delete, deleting nothing', async () => {
|
|
717
|
+
const settings = {
|
|
718
|
+
currentProvider: 'openai',
|
|
719
|
+
providers: {
|
|
720
|
+
openai: { type: 'openai', model: 'supergemma4-26b-uncensored-v2' },
|
|
721
|
+
anthropic: { type: 'anthropic', model: 'claude-sonnet-4-6' },
|
|
722
|
+
},
|
|
723
|
+
};
|
|
724
|
+
const { adapter, readTarget } = createSettingsAdapter(settings, settings);
|
|
725
|
+
|
|
726
|
+
const { context } = scriptedContext([{ type: 'answer', values: ['no'] }]);
|
|
727
|
+
const result = await createExecutor(adapter).execute('provider', context, 'delete anthropic');
|
|
728
|
+
|
|
729
|
+
expect(result).toEqual({ message: 'Provider delete cancelled.', success: true });
|
|
730
|
+
expect(readTarget().providers?.['anthropic']).toBeDefined();
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
it('a headless (non-interactive) delete proceeds without asking, matching /clear\'s convention', async () => {
|
|
734
|
+
const settings = {
|
|
735
|
+
currentProvider: 'openai',
|
|
736
|
+
providers: {
|
|
737
|
+
openai: { type: 'openai', model: 'supergemma4-26b-uncensored-v2' },
|
|
738
|
+
anthropic: { type: 'anthropic', model: 'claude-sonnet-4-6' },
|
|
739
|
+
},
|
|
740
|
+
};
|
|
741
|
+
const { adapter, readTarget } = createSettingsAdapter(settings, settings);
|
|
742
|
+
|
|
743
|
+
const result = await createExecutor(adapter).execute('provider', headlessContext, 'delete anthropic');
|
|
744
|
+
|
|
745
|
+
expect(result?.message).toBe('Provider profile deleted: anthropic.');
|
|
746
|
+
expect(readTarget().providers?.['anthropic']).toBeUndefined();
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
it('refuses to delete the profile in use, unlike the menu\'s Delete which asks for a replacement', async () => {
|
|
750
|
+
const { adapter, readTarget } = createSettingsAdapter(
|
|
751
|
+
{
|
|
752
|
+
currentProvider: 'anthropic',
|
|
753
|
+
providers: {
|
|
754
|
+
openai: { type: 'openai', model: 'supergemma4-26b-uncensored-v2' },
|
|
755
|
+
anthropic: { type: 'anthropic', model: 'claude-sonnet-4-6' },
|
|
756
|
+
},
|
|
757
|
+
},
|
|
758
|
+
{
|
|
759
|
+
currentProvider: 'anthropic',
|
|
760
|
+
providers: {
|
|
761
|
+
openai: { type: 'openai', model: 'supergemma4-26b-uncensored-v2' },
|
|
762
|
+
anthropic: { type: 'anthropic', model: 'claude-sonnet-4-6' },
|
|
763
|
+
},
|
|
764
|
+
},
|
|
765
|
+
);
|
|
766
|
+
|
|
767
|
+
const { context, requests } = scriptedContext([]);
|
|
768
|
+
const result = await createExecutor(adapter).execute(
|
|
769
|
+
'provider',
|
|
770
|
+
context,
|
|
771
|
+
'delete anthropic --confirmed',
|
|
772
|
+
);
|
|
773
|
+
|
|
774
|
+
expect(result?.success).toBe(false);
|
|
775
|
+
expect(result?.message).toBe('Cannot delete the profile in use. Switch to another profile first.');
|
|
776
|
+
expect(requests).toEqual([]); // never asks — a modal write has nowhere to ask a follow-up
|
|
777
|
+
expect(readTarget().providers?.['anthropic']).toBeDefined(); // nothing was deleted
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
it('refuses to delete the only provider profile', async () => {
|
|
781
|
+
const { adapter } = createSettingsAdapter(
|
|
782
|
+
{ currentProvider: 'openai', providers: { openai: { type: 'openai', model: 'x' } } },
|
|
783
|
+
{ currentProvider: 'openai', providers: { openai: { type: 'openai', model: 'x' } } },
|
|
784
|
+
);
|
|
785
|
+
|
|
786
|
+
const result = await createExecutor(adapter).execute('provider', headlessContext, 'delete openai');
|
|
787
|
+
|
|
788
|
+
expect(result?.success).toBe(false);
|
|
789
|
+
expect(result?.message).toBe('Cannot delete the only provider profile.');
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
it('reports an unknown profile for a direct delete', async () => {
|
|
793
|
+
const { adapter } = createSettingsAdapter({
|
|
794
|
+
currentProvider: 'openai',
|
|
795
|
+
providers: {
|
|
796
|
+
openai: { type: 'openai', model: 'x' },
|
|
797
|
+
anthropic: { type: 'anthropic', model: 'y' },
|
|
798
|
+
},
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
const result = await createExecutor(adapter).execute('provider', headlessContext, 'delete ghost');
|
|
802
|
+
|
|
803
|
+
expect(result?.success).toBe(false);
|
|
804
|
+
expect(result?.message).toBe('Provider profile "ghost" was not found.');
|
|
805
|
+
});
|
|
806
|
+
});
|
|
545
807
|
});
|
|
@@ -8,7 +8,15 @@ import type { IActionRequest, IUserInteraction, TActionResponse } from '@robota-
|
|
|
8
8
|
* the rendered action shape) and returns the next scripted answer; once the script is exhausted it
|
|
9
9
|
* returns `{ type: 'cancelled' }`, mirroring a user dismissing the prompt.
|
|
10
10
|
*/
|
|
11
|
-
export function scriptedContext(
|
|
11
|
+
export function scriptedContext(
|
|
12
|
+
answers: readonly TActionResponse[],
|
|
13
|
+
/**
|
|
14
|
+
* #3282 §3: whether THIS session is running with no provider configured yet — `/provider add`
|
|
15
|
+
* reads it (never settings) to decide hot-swap vs. restart. Defaults to `false`, an ordinary
|
|
16
|
+
* session, matching every test written before setup mode existed.
|
|
17
|
+
*/
|
|
18
|
+
isSetupRequired = false,
|
|
19
|
+
): {
|
|
12
20
|
context: ReturnType<typeof createTestCommandHost>;
|
|
13
21
|
requests: IActionRequest[];
|
|
14
22
|
} {
|
|
@@ -25,6 +33,8 @@ export function scriptedContext(answers: readonly TActionResponse[]): {
|
|
|
25
33
|
// ARCH-029: the double answers "no capability of that kind" for everything else, so this fixture
|
|
26
34
|
// states exactly the one capability it scripts. The double cast it replaced —
|
|
27
35
|
// `as Partial<…> as ICommandHostContext` — asserted conformance twice over one member.
|
|
28
|
-
const context = createTestCommandHost({
|
|
36
|
+
const context = createTestCommandHost({
|
|
37
|
+
overrides: { getUserInteraction: () => ui, isSetupRequired: () => isSetupRequired },
|
|
38
|
+
});
|
|
29
39
|
return { context, requests };
|
|
30
40
|
}
|