@robota-sdk/agent-command 3.0.0-beta.83 → 3.0.0-beta.84

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.
Files changed (61) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +71 -62
  3. package/dist/node/index.cjs +75 -72
  4. package/dist/node/index.d.cts +37 -45
  5. package/dist/node/index.d.cts.map +1 -1
  6. package/dist/node/index.d.ts +37 -45
  7. package/dist/node/index.d.ts.map +1 -1
  8. package/dist/node/index.js +78 -75
  9. package/dist/node/index.js.map +1 -1
  10. package/package.json +9 -8
  11. package/src/agent/__tests__/agent-command.test.ts +62 -0
  12. package/src/agent/agent-command-parser.ts +2 -2
  13. package/src/agent/agent-command.ts +20 -0
  14. package/src/default/__tests__/__snapshots__/model-exposure.test.ts.snap +2 -2
  15. package/src/default/__tests__/default-command-modules.test.ts +5 -4
  16. package/src/default/__tests__/model-exposure.test.ts +2 -0
  17. package/src/default/default-command-modules.ts +8 -0
  18. package/src/effort/effort-command.test.ts +65 -2
  19. package/src/effort/effort-command.ts +19 -5
  20. package/src/git/__tests__/fake-git-port.ts +9 -2
  21. package/src/git/git-command-module.ts +3 -4
  22. package/src/git/git-commit.ts +6 -3
  23. package/src/git/index.ts +4 -1
  24. package/src/goal/goal-command.ts +3 -1
  25. package/src/index.ts +6 -0
  26. package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +131 -0
  27. package/src/mcp-activation/mcp-activation-command-module.ts +6 -0
  28. package/src/mcp-activation/mcp-activation-command.ts +54 -1
  29. package/src/memory/__tests__/memory-command-module.test.ts +40 -2
  30. package/src/memory/__tests__/memory-command-unavailable.test.ts +63 -0
  31. package/src/memory/memory-command-module.ts +4 -2
  32. package/src/memory/memory-command.ts +42 -2
  33. package/src/mode/__tests__/mode-command-module.test.ts +40 -0
  34. package/src/mode/mode-command.ts +1 -1
  35. package/src/model/__tests__/model-command-module.test.ts +80 -0
  36. package/src/model/__tests__/model-command.test.ts +240 -0
  37. package/src/model/index.ts +6 -0
  38. package/src/model/model-command-module.ts +56 -0
  39. package/src/model/model-command.ts +223 -0
  40. package/src/plugin/__tests__/plugin-command-module.test.ts +81 -1
  41. package/src/plugin/plugin-command.ts +47 -4
  42. package/src/provider/__tests__/org-policy.test.ts +4 -0
  43. package/src/provider/__tests__/provider-command-module.test.ts +276 -14
  44. package/src/provider/__tests__/scripted-interaction.ts +12 -2
  45. package/src/provider/provider-command-execution.ts +54 -8
  46. package/src/provider/provider-command-profile-lifecycle.ts +48 -2
  47. package/src/provider/provider-command-profile-operations.ts +14 -0
  48. package/src/provider/provider-command-profile.ts +7 -4
  49. package/src/provider/provider-command-setup.ts +14 -1
  50. package/src/rewind/__tests__/rewind-command-module.test.ts +2 -1
  51. package/src/schedule/__tests__/loop-command-functional.test.ts +13 -0
  52. package/src/schedule/__tests__/loop-command.test.ts +238 -4
  53. package/src/schedule/loop-command.ts +95 -3
  54. package/src/schedule/schedule-command-module.ts +8 -4
  55. package/src/settings/settings-command-module.ts +2 -2
  56. package/src/git/__tests__/git-diff.test.ts +0 -125
  57. package/src/git/__tests__/git-process.test.ts +0 -221
  58. package/src/git/__tests__/git-status.test.ts +0 -117
  59. package/src/git/git-diff.ts +0 -129
  60. package/src/git/git-process.ts +0 -169
  61. package/src/git/git-status.ts +0 -134
@@ -0,0 +1,240 @@
1
+ /**
2
+ * #3282 §2 — `/model`.
3
+ *
4
+ * `buildProviderSwitch` (#3282 §1 fix, PR #3297) validates a cross-profile switch BEFORE writing
5
+ * settings, so a switch that would fail changes nothing on disk. `/model` follows the same shape for
6
+ * its cross-profile branch, and for the same-profile branch goes one step further: it performs the
7
+ * live hot-swap itself (via `applyModelOptions`, the same seam `/effort` and `/preset` use) and
8
+ * persists only once THAT succeeds — never a static validation standing in for the real attempt.
9
+ */
10
+
11
+ import { describe, expect, it, vi } from 'vitest';
12
+
13
+ import { createTestCommandHost } from '@robota-sdk/agent-framework/testing';
14
+
15
+ import { executeModelCommand } from '../model-command.js';
16
+
17
+ import type { IProviderCommandModuleOptions, IProviderProfileSettings } from '@robota-sdk/agent-framework';
18
+ import type { IProviderDefinition } from '@robota-sdk/agent-core';
19
+
20
+ const DEFINITIONS: readonly IProviderDefinition[] = [
21
+ {
22
+ type: 'anthropic',
23
+ displayName: 'Anthropic',
24
+ modelCatalog: {
25
+ status: 'live',
26
+ entries: [
27
+ { id: 'claude-sonnet-4-6', displayName: 'Claude Sonnet 4.6' },
28
+ { id: 'claude-haiku-4-5', displayName: 'Claude Haiku 4.5' },
29
+ ],
30
+ },
31
+ createProvider: () => {
32
+ throw new Error('not used');
33
+ },
34
+ },
35
+ {
36
+ type: 'openai',
37
+ displayName: 'OpenAI',
38
+ modelCatalog: { status: 'unavailable' },
39
+ createProvider: () => {
40
+ throw new Error('not used');
41
+ },
42
+ },
43
+ ];
44
+
45
+ function providers(): Record<string, IProviderProfileSettings> {
46
+ return {
47
+ anthropic: { type: 'anthropic', model: 'claude-sonnet-4-6' },
48
+ 'my-openai': { type: 'openai', model: 'gpt-5.1', apiKey: '$ENV:OPENAI_API_KEY' },
49
+ };
50
+ }
51
+
52
+ function buildOptions(overrides: {
53
+ writeTargetSettings?: ReturnType<typeof vi.fn>;
54
+ currentProvider?: string;
55
+ providers?: Record<string, IProviderProfileSettings>;
56
+ allowedProviders?: string[];
57
+ }): IProviderCommandModuleOptions {
58
+ const providerMap = overrides.providers ?? providers();
59
+ const document = { currentProvider: overrides.currentProvider ?? 'anthropic', providers: providerMap };
60
+ return {
61
+ providerDefinitions: DEFINITIONS,
62
+ settings: {
63
+ readMergedSettings: () => document,
64
+ readTargetSettings: () => document,
65
+ writeTargetSettings: overrides.writeTargetSettings ?? vi.fn(),
66
+ },
67
+ ...(overrides.allowedProviders !== undefined
68
+ ? { orgPolicy: { allowedProviders: overrides.allowedProviders } }
69
+ : {}),
70
+ };
71
+ }
72
+
73
+ describe('/model', () => {
74
+ it('hot-swaps within the current profile and persists only after the swap succeeds', async () => {
75
+ const applyModelOptions = vi.fn().mockResolvedValue(undefined);
76
+ const writeTargetSettings = vi.fn();
77
+ const host = createTestCommandHost({
78
+ session: { applyModelOptions, getModelId: () => 'claude-sonnet-4-6' },
79
+ });
80
+ const options = buildOptions({ writeTargetSettings });
81
+
82
+ const result = await executeModelCommand(host, 'claude-haiku-4-5', options);
83
+
84
+ expect(result).toEqual({ message: 'Model: Claude Haiku 4.5', success: true });
85
+ expect(applyModelOptions).toHaveBeenCalledWith({ model: 'claude-haiku-4-5' });
86
+ expect(writeTargetSettings).toHaveBeenCalledTimes(1);
87
+ const written = writeTargetSettings.mock.calls[0]![0];
88
+ expect(written.providers.anthropic).toMatchObject({ model: 'claude-haiku-4-5' });
89
+ });
90
+
91
+ it('a failed same-profile swap persists nothing', async () => {
92
+ const applyModelOptions = vi.fn().mockRejectedValue(new Error('boom'));
93
+ const writeTargetSettings = vi.fn();
94
+ const host = createTestCommandHost({
95
+ session: { applyModelOptions, getModelId: () => 'claude-sonnet-4-6' },
96
+ });
97
+ const options = buildOptions({ writeTargetSettings });
98
+
99
+ const result = await executeModelCommand(host, 'claude-haiku-4-5', options);
100
+
101
+ expect(result.success).toBe(false);
102
+ expect(result.message).toContain('boom');
103
+ expect(writeTargetSettings).not.toHaveBeenCalled();
104
+ });
105
+
106
+ it('a cross-profile id switches profile through the provider-hot-swap host action', async () => {
107
+ const writeTargetSettings = vi.fn();
108
+ const host = createTestCommandHost({
109
+ session: { getModelId: () => 'claude-sonnet-4-6' },
110
+ });
111
+ const options = buildOptions({ writeTargetSettings });
112
+
113
+ const result = await executeModelCommand(host, 'gpt-5.1', options);
114
+
115
+ expect(result.success).toBe(true);
116
+ expect(result.message).toBe('Model: gpt-5.1');
117
+ expect(result.hostActions).toEqual([{ type: 'provider-hot-swap', profileName: 'my-openai' }]);
118
+ expect(writeTargetSettings).toHaveBeenCalledTimes(1);
119
+ const written = writeTargetSettings.mock.calls[0]![0];
120
+ expect(written.currentProvider).toBe('my-openai');
121
+ expect(written.providers['my-openai']).toMatchObject({ model: 'gpt-5.1' });
122
+ });
123
+
124
+ it('an org policy still allowing the target profile leaves a cross-profile switch working', async () => {
125
+ const writeTargetSettings = vi.fn();
126
+ const host = createTestCommandHost({ session: { getModelId: () => 'claude-sonnet-4-6' } });
127
+ const options = buildOptions({ writeTargetSettings, allowedProviders: ['anthropic', 'my-openai'] });
128
+
129
+ const result = await executeModelCommand(host, 'gpt-5.1', options);
130
+
131
+ expect(result.success).toBe(true);
132
+ expect(result.hostActions).toEqual([{ type: 'provider-hot-swap', profileName: 'my-openai' }]);
133
+ expect(writeTargetSettings).toHaveBeenCalledTimes(1);
134
+ });
135
+
136
+ it('refuses a cross-profile switch to a profile org policy disallows, changing nothing on disk', async () => {
137
+ const writeTargetSettings = vi.fn();
138
+ const host = createTestCommandHost({ session: { getModelId: () => 'claude-sonnet-4-6' } });
139
+ // Typing the id resolves it (resolution is never filtered) so the refusal names the profile
140
+ // specifically, rather than the id falling through to a generic "Unknown model" error.
141
+ const options = buildOptions({ writeTargetSettings, allowedProviders: ['anthropic'] });
142
+
143
+ const result = await executeModelCommand(host, 'gpt-5.1', options);
144
+
145
+ expect(result.success).toBe(false);
146
+ expect(result.message).toContain('organization policy');
147
+ expect(result.message).toContain('my-openai');
148
+ expect(result.hostActions).toBeUndefined();
149
+ expect(writeTargetSettings).not.toHaveBeenCalled();
150
+ });
151
+
152
+ it('the listing omits a profile org policy disallows', async () => {
153
+ const host = createTestCommandHost({ session: { getModelId: () => 'claude-sonnet-4-6' } });
154
+ const options = buildOptions({ allowedProviders: ['anthropic'] });
155
+
156
+ const result = await executeModelCommand(host, '', options);
157
+
158
+ expect(result.success).toBe(true);
159
+ expect(result.message).toContain('Anthropic (anthropic)');
160
+ expect(result.message).not.toContain('OpenAI');
161
+ expect(result.message).not.toContain('my-openai');
162
+ });
163
+
164
+ it('an unknown id gives a plain error naming /model', async () => {
165
+ const host = createTestCommandHost({ session: { getModelId: () => 'claude-sonnet-4-6' } });
166
+ const options = buildOptions({});
167
+
168
+ const result = await executeModelCommand(host, 'not-a-real-model', options);
169
+
170
+ expect(result).toEqual({
171
+ message: 'Unknown model "not-a-real-model". Run /model to see the choices.',
172
+ success: false,
173
+ });
174
+ });
175
+
176
+ it('prefers the current profile when an id is offered by more than one profile', async () => {
177
+ const applyModelOptions = vi.fn().mockResolvedValue(undefined);
178
+ const writeTargetSettings = vi.fn();
179
+ const shared = {
180
+ anthropic: { type: 'anthropic', model: 'claude-sonnet-4-6' },
181
+ 'anthropic-2': { type: 'anthropic', model: 'claude-haiku-4-5' },
182
+ };
183
+ const host = createTestCommandHost({
184
+ session: { applyModelOptions, getModelId: () => 'claude-sonnet-4-6' },
185
+ });
186
+ const options = buildOptions({ writeTargetSettings, providers: shared, currentProvider: 'anthropic' });
187
+
188
+ const result = await executeModelCommand(host, 'claude-haiku-4-5', options);
189
+
190
+ // Resolved against the CURRENT profile's catalog entry, not "anthropic-2" — applyModelOptions
191
+ // (same-profile hot-swap), not a provider-hot-swap host action, proves which branch ran.
192
+ expect(result.success).toBe(true);
193
+ expect(applyModelOptions).toHaveBeenCalledWith({ model: 'claude-haiku-4-5' });
194
+ expect(result.hostActions).toBeUndefined();
195
+ });
196
+
197
+ it('lists models as plain text without an interactive renderer', async () => {
198
+ const host = createTestCommandHost({ session: { getModelId: () => 'claude-sonnet-4-6' } });
199
+ const options = buildOptions({});
200
+
201
+ const result = await executeModelCommand(host, '', options);
202
+
203
+ expect(result.success).toBe(true);
204
+ expect(result.message).toContain('Anthropic (anthropic)');
205
+ expect(result.message).toContain('* Claude Sonnet 4.6');
206
+ expect(result.message).toContain('OpenAI (my-openai)');
207
+ });
208
+
209
+ it('drives the inline picker and applies the chosen model when a renderer is attached', async () => {
210
+ const applyModelOptions = vi.fn().mockResolvedValue(undefined);
211
+ const writeTargetSettings = vi.fn();
212
+ const host = createTestCommandHost({
213
+ session: { applyModelOptions, getModelId: () => 'claude-sonnet-4-6' },
214
+ overrides: {
215
+ getUserInteraction: () => ({
216
+ ask: async () => ({ type: 'answer', values: ['claude-haiku-4-5'] }),
217
+ }),
218
+ },
219
+ });
220
+ const options = buildOptions({ writeTargetSettings });
221
+
222
+ const result = await executeModelCommand(host, '', options);
223
+
224
+ expect(result).toEqual({ message: 'Model: Claude Haiku 4.5', success: true });
225
+ });
226
+
227
+ it('reports cancellation without changing anything when the picker is cancelled', async () => {
228
+ const writeTargetSettings = vi.fn();
229
+ const host = createTestCommandHost({
230
+ session: { getModelId: () => 'claude-sonnet-4-6' },
231
+ overrides: { getUserInteraction: () => ({ ask: async () => ({ type: 'cancelled' }) }) },
232
+ });
233
+ const options = buildOptions({ writeTargetSettings });
234
+
235
+ const result = await executeModelCommand(host, '', options);
236
+
237
+ expect(result).toEqual({ message: 'Model selection cancelled.', success: true });
238
+ expect(writeTargetSettings).not.toHaveBeenCalled();
239
+ });
240
+ });
@@ -0,0 +1,6 @@
1
+ export {
2
+ createModelCommandEntry,
3
+ createModelCommandModule,
4
+ ModelCommandSource,
5
+ } from './model-command-module.js';
6
+ export { executeModelCommand } from './model-command.js';
@@ -0,0 +1,56 @@
1
+ import { executeModelCommand } from './model-command.js';
2
+
3
+ import type {
4
+ ICommandModule as TCommandModule,
5
+ IProviderCommandModuleOptions,
6
+ ISystemCommand as TSystemCommand,
7
+ } from '@robota-sdk/agent-framework';
8
+ import type { ICommand, ICommandSource } from '@robota-sdk/agent-interface-command';
9
+ export type { IProviderCommandModuleOptions };
10
+
11
+ const MODEL_COMMAND_DESCRIPTION = 'Show/change the model';
12
+
13
+ export function createModelCommandEntry(): ICommand {
14
+ return {
15
+ name: 'model',
16
+ displayName: 'Model',
17
+ description: MODEL_COMMAND_DESCRIPTION,
18
+ source: 'model',
19
+ argumentHint: '[<id>]',
20
+ example: '/model claude-sonnet-4-6',
21
+ // User-only: switching the model changes cost and capability, so the person decides.
22
+ modelInvocable: false,
23
+ };
24
+ }
25
+
26
+ function createModelSystemCommand(options: IProviderCommandModuleOptions): TSystemCommand {
27
+ const entry = createModelCommandEntry();
28
+ return {
29
+ name: entry.name,
30
+ displayName: entry.displayName,
31
+ description: entry.description,
32
+ example: entry.example,
33
+ requiresPermission: false,
34
+ userInvocable: true,
35
+ modelInvocable: false,
36
+ argumentHint: entry.argumentHint,
37
+ lifecycle: 'inline',
38
+ execute: (context, args) => executeModelCommand(context, args, options),
39
+ };
40
+ }
41
+
42
+ export class ModelCommandSource implements ICommandSource {
43
+ readonly name = 'model';
44
+
45
+ getCommands(): ICommand[] {
46
+ return [createModelCommandEntry()];
47
+ }
48
+ }
49
+
50
+ export function createModelCommandModule(options: IProviderCommandModuleOptions): TCommandModule {
51
+ return {
52
+ name: 'agent-command-model',
53
+ commandSources: [new ModelCommandSource()],
54
+ systemCommands: [createModelSystemCommand(options)],
55
+ };
56
+ }
@@ -0,0 +1,223 @@
1
+ import { selectAction } from '@robota-sdk/agent-core';
2
+ import {
3
+ buildModelListSnapshot,
4
+ formatOrgPolicyViolationMessage,
5
+ resolveModelListSelection,
6
+ setCurrentProvider,
7
+ upsertProviderProfile,
8
+ validateProviderProfile,
9
+ } from '@robota-sdk/agent-framework';
10
+
11
+ import type { IUserInteraction } from '@robota-sdk/agent-core';
12
+ import type {
13
+ ICommandHostSessionAccess,
14
+ ICommandHostUserInteraction,
15
+ IModelListSelection,
16
+ IProviderCommandModuleOptions,
17
+ IProviderProfileSettings,
18
+ TProviderSettingsDocument,
19
+ } from '@robota-sdk/agent-framework';
20
+ import type { ICommandResult } from '@robota-sdk/agent-interface-command';
21
+ import type { IModelListSnapshot } from '@robota-sdk/agent-interface-session';
22
+
23
+ type TModelCommandContext = ICommandHostSessionAccess & ICommandHostUserInteraction;
24
+
25
+ /** The current model, read from the live session; falls back to the profile's configured one. */
26
+ function readCurrentModel(
27
+ context: ICommandHostSessionAccess,
28
+ currentProfile: IProviderProfileSettings | undefined,
29
+ ): string {
30
+ return context.getSession().getModelId() ?? currentProfile?.model ?? '';
31
+ }
32
+
33
+ /**
34
+ * `allowFiltered` controls whether a profile org policy disallows is left out of the snapshot.
35
+ * The PICKER (no id typed) always filters: a disallowed profile must never be offered as a choice.
36
+ * RESOLVING a typed/clicked id never filters: `switchModelAcrossProfiles` is the one place that
37
+ * decides a disallowed target, exactly as `buildProviderSwitch` decides it for `/provider switch` —
38
+ * a filtered resolution snapshot would turn that decision into an unreachable "Unknown model"
39
+ * instead of the specific, actionable organization-policy refusal.
40
+ */
41
+ function buildSnapshot(
42
+ context: ICommandHostSessionAccess,
43
+ settings: TProviderSettingsDocument,
44
+ options: IProviderCommandModuleOptions,
45
+ allowFiltered: boolean,
46
+ ): IModelListSnapshot {
47
+ const currentProfile =
48
+ settings.currentProvider !== undefined ? settings.providers?.[settings.currentProvider] : undefined;
49
+ return buildModelListSnapshot(
50
+ settings.providers,
51
+ settings.currentProvider,
52
+ readCurrentModel(context, currentProfile),
53
+ options.providerDefinitions,
54
+ allowFiltered ? options.orgPolicy?.allowedProviders : undefined,
55
+ );
56
+ }
57
+
58
+ export async function executeModelCommand(
59
+ context: TModelCommandContext,
60
+ args: string,
61
+ options: IProviderCommandModuleOptions,
62
+ ): Promise<ICommandResult> {
63
+ const settings = options.settings.readMergedSettings();
64
+ const id = args.trim();
65
+
66
+ if (id.length === 0) {
67
+ const visible = buildSnapshot(context, settings, options, true);
68
+ return pickModel(context.getUserInteraction(), visible, context, options);
69
+ }
70
+
71
+ const full = buildSnapshot(context, settings, options, false);
72
+ const selection = resolveModelListSelection(full, id);
73
+ if (selection === undefined) {
74
+ return {
75
+ message: `Unknown model "${id}". Run /model to see the choices.`,
76
+ success: false,
77
+ };
78
+ }
79
+
80
+ if (selection.profileName === settings.currentProvider) {
81
+ return switchModelWithinCurrentProfile(context, selection, settings, options);
82
+ }
83
+ return switchModelAcrossProfiles(selection, settings, options);
84
+ }
85
+
86
+ /**
87
+ * With an interactive renderer attached, drive the inline picker (CMD-004); without one — headless,
88
+ * automation, or a model-invoked caller, though this command is never offered to a model — return the
89
+ * list as plain text instead of guessing a choice.
90
+ */
91
+ async function pickModel(
92
+ ui: IUserInteraction | undefined,
93
+ snapshot: IModelListSnapshot,
94
+ context: TModelCommandContext,
95
+ options: IProviderCommandModuleOptions,
96
+ ): Promise<ICommandResult> {
97
+ if (snapshot.groups.length === 0) {
98
+ return { message: 'No provider profiles configured. Run /provider add to add one.', success: true };
99
+ }
100
+ if (!ui) {
101
+ return { message: formatModelListText(snapshot), success: true };
102
+ }
103
+ const modelOptions = snapshot.groups.flatMap((group) =>
104
+ group.models.map((model) => ({
105
+ value: model.id,
106
+ label: formatModelOptionLabel(snapshot, group.profileName, group.providerLabel, model),
107
+ })),
108
+ );
109
+ const response = await ui.ask(selectAction('model', 'Select model', modelOptions, { maxVisible: 8 }));
110
+ if (response.type !== 'answer' || response.values[0] === undefined) {
111
+ return { message: 'Model selection cancelled.', success: true };
112
+ }
113
+ return executeModelCommand(context, response.values[0], options);
114
+ }
115
+
116
+ function formatModelListText(snapshot: IModelListSnapshot): string {
117
+ const lines: string[] = [];
118
+ for (const group of snapshot.groups) {
119
+ lines.push(`${group.providerLabel} (${group.profileName})`);
120
+ for (const model of group.models) {
121
+ const marker =
122
+ model.id === snapshot.currentModel && group.profileName === snapshot.currentProfile ? '*' : '-';
123
+ lines.push(` ${marker} ${model.label}`);
124
+ }
125
+ }
126
+ return lines.join('\n');
127
+ }
128
+
129
+ function formatModelOptionLabel(
130
+ snapshot: IModelListSnapshot,
131
+ profileName: string,
132
+ providerLabel: string,
133
+ model: { id: string; label: string },
134
+ ): string {
135
+ const marker = model.id === snapshot.currentModel && profileName === snapshot.currentProfile ? '* ' : '';
136
+ const sharedLabelCount = snapshot.groups.filter((group) => group.providerLabel === providerLabel).length;
137
+ const disambiguator = sharedLabelCount > 1 ? ` (${profileName})` : '';
138
+ return `${marker}${providerLabel}: ${model.label}${disambiguator}`;
139
+ }
140
+
141
+ /**
142
+ * The id belongs to the CURRENT profile's provider (#3282 §2): hot-swap the live model first, and
143
+ * persist it to the current profile only once that succeeds — a failed swap must leave settings
144
+ * exactly as they were.
145
+ */
146
+ async function switchModelWithinCurrentProfile(
147
+ context: ICommandHostSessionAccess,
148
+ selection: IModelListSelection,
149
+ settings: TProviderSettingsDocument,
150
+ options: IProviderCommandModuleOptions,
151
+ ): Promise<ICommandResult> {
152
+ const currentProfileName = selection.profileName;
153
+ try {
154
+ await context.getSession().applyModelOptions({ model: selection.model.id });
155
+ } catch (error) {
156
+ return {
157
+ message: `Could not switch to "${selection.model.label}": ${error instanceof Error ? error.message : String(error)}`,
158
+ success: false,
159
+ };
160
+ }
161
+ const target = options.settings.readTargetSettings();
162
+ // Based on MERGED (not target-only) so a profile that lives in a lower-priority settings layer
163
+ // keeps its other fields (type, apiKey, baseURL) — only `model` changes at the target layer.
164
+ const currentProfile = settings.providers?.[currentProfileName] ?? target.providers?.[currentProfileName] ?? {};
165
+ options.settings.writeTargetSettings(
166
+ upsertProviderProfile(target, currentProfileName, { ...currentProfile, model: selection.model.id }),
167
+ );
168
+ return { message: `Model: ${selection.model.label}`, success: true };
169
+ }
170
+
171
+ /**
172
+ * The id belongs to ANOTHER profile's provider (#3282 §2): switch to that profile through the same
173
+ * validate-before-write path `/provider switch` uses, with the requested model folded into the same
174
+ * settings write, then hot-swap through the existing `provider-hot-swap` host action — which re-reads
175
+ * settings at execution time, so it picks up the model this write just persisted.
176
+ */
177
+ function switchModelAcrossProfiles(
178
+ selection: IModelListSelection,
179
+ settings: TProviderSettingsDocument,
180
+ options: IProviderCommandModuleOptions,
181
+ ): ICommandResult {
182
+ const { orgPolicy } = options;
183
+ // Checked BEFORE anything is read or written — exactly buildProviderSwitch's order — so a switch
184
+ // org policy refuses changes nothing on disk, the same guarantee /provider switch already gives.
185
+ if (orgPolicy?.allowedProviders && !orgPolicy.allowedProviders.includes(selection.profileName)) {
186
+ return {
187
+ message: formatOrgPolicyViolationMessage(
188
+ `Provider "${selection.profileName}" is not allowed by your organization policy. Allowed: ${orgPolicy.allowedProviders.join(', ')}.`,
189
+ orgPolicy.adminContact,
190
+ ),
191
+ success: false,
192
+ };
193
+ }
194
+ const profile = settings.providers?.[selection.profileName];
195
+ if (profile === undefined) {
196
+ return { message: `Provider profile "${selection.profileName}" was not found.`, success: false };
197
+ }
198
+ const updatedProfile: IProviderProfileSettings = { ...profile, model: selection.model.id };
199
+ try {
200
+ validateProviderProfile(selection.profileName, updatedProfile, {
201
+ providerDefinitions: options.providerDefinitions,
202
+ });
203
+ } catch (error) {
204
+ return {
205
+ message: `Could not switch to "${selection.model.label}": ${error instanceof Error ? error.message : String(error)}`,
206
+ success: false,
207
+ };
208
+ }
209
+ const target = options.settings.readTargetSettings();
210
+ const merged = options.settings.readMergedSettings();
211
+ const withModel = upsertProviderProfile(target, selection.profileName, updatedProfile);
212
+ const next =
213
+ target.providers?.[selection.profileName] !== undefined ||
214
+ merged.providers?.[selection.profileName] !== undefined
215
+ ? { ...withModel, currentProvider: selection.profileName }
216
+ : setCurrentProvider(withModel, selection.profileName);
217
+ options.settings.writeTargetSettings(next);
218
+ return {
219
+ message: `Model: ${selection.model.label}`,
220
+ success: true,
221
+ hostActions: [{ type: 'provider-hot-swap', profileName: selection.profileName }],
222
+ };
223
+ }
@@ -46,7 +46,10 @@ function createCommandSessionRuntime() {
46
46
  });
47
47
  }
48
48
 
49
- function createCommandHostContext(adapter?: ICommandPluginAdapter) {
49
+ function createCommandHostContext(
50
+ adapter?: ICommandPluginAdapter,
51
+ locality?: 'local' | 'remote',
52
+ ) {
50
53
  const checkpoint = {
51
54
  id: 'checkpoint_1',
52
55
  sessionId: 'session_1',
@@ -66,6 +69,7 @@ function createCommandHostContext(adapter?: ICommandPluginAdapter) {
66
69
  }),
67
70
  getAutoCompactThreshold: () => 0.8,
68
71
  getCommandHostAdapters: () => (adapter === undefined ? {} : { plugin: adapter }),
72
+ ...(locality ? { getCommandSurfaceLocality: () => locality } : {}),
69
73
  compactContext: async () => undefined,
70
74
  getCwd: () => '/workspace',
71
75
  listCommands: () => [],
@@ -186,6 +190,82 @@ describe('executePluginCommand', () => {
186
190
  message: 'Usage: /plugin disable <name>@<marketplace>',
187
191
  });
188
192
  });
193
+
194
+ it('lists installed plugins through the adapter, for the Settings screen (#3282 §4 part b-2)', async () => {
195
+ const adapter = createPluginAdapter({
196
+ listInstalled: vi.fn().mockResolvedValue([
197
+ { name: 'formatter@robota', description: 'Formats code.', enabled: true },
198
+ { name: 'linter@robota', description: 'Lints code.', enabled: false },
199
+ ]),
200
+ });
201
+
202
+ const result = await executePluginCommand(createCommandHostContext(adapter), 'list');
203
+
204
+ expect(result.success).toBe(true);
205
+ expect(result.data).toEqual({
206
+ plugins: [
207
+ { name: 'formatter@robota', description: 'Formats code.', enabled: true },
208
+ { name: 'linter@robota', description: 'Lints code.', enabled: false },
209
+ ],
210
+ });
211
+ expect(result.message).toContain('formatter@robota');
212
+ expect(result.message).toContain('linter@robota (disabled)');
213
+ });
214
+
215
+ it('no plugins installed reports a plain empty list', async () => {
216
+ const adapter = createPluginAdapter();
217
+ const result = await executePluginCommand(createCommandHostContext(adapter), 'list');
218
+ expect(result).toEqual({
219
+ success: true,
220
+ message: 'No plugins are installed.',
221
+ data: { plugins: [] },
222
+ });
223
+ });
224
+
225
+ describe('install/uninstall stay local-surface-only (#3282 §4 part b-2)', () => {
226
+ it('runs install from a local surface', async () => {
227
+ const adapter = createPluginAdapter();
228
+ const result = await executePluginCommand(
229
+ createCommandHostContext(adapter, 'local'),
230
+ 'install demo@community',
231
+ );
232
+ expect(result.success).toBe(true);
233
+ expect(adapter.install).toHaveBeenCalledWith('demo@community');
234
+ });
235
+
236
+ it('refuses install from a remote surface with a plain message, writing nothing', async () => {
237
+ const adapter = createPluginAdapter();
238
+ const result = await executePluginCommand(
239
+ createCommandHostContext(adapter, 'remote'),
240
+ 'install demo@community',
241
+ );
242
+ expect(result.success).toBe(false);
243
+ expect(result.message).toMatch(/only works from the desktop app or the page opened here/);
244
+ expect(result.message).toMatch(/not from a remote device/);
245
+ expect(adapter.install).not.toHaveBeenCalled();
246
+ });
247
+
248
+ it('refuses uninstall from a remote surface with a plain message, writing nothing', async () => {
249
+ const adapter = createPluginAdapter();
250
+ const result = await executePluginCommand(
251
+ createCommandHostContext(adapter, 'remote'),
252
+ 'uninstall demo@community',
253
+ );
254
+ expect(result.success).toBe(false);
255
+ expect(result.message).toMatch(/not from a remote device/);
256
+ expect(adapter.uninstall).not.toHaveBeenCalled();
257
+ });
258
+
259
+ it('never refuses enable/disable/reload from a remote surface — only install/uninstall run new code', async () => {
260
+ const adapter = createPluginAdapter();
261
+ const enableResult = await executePluginCommand(
262
+ createCommandHostContext(adapter, 'remote'),
263
+ 'enable demo@community',
264
+ );
265
+ expect(enableResult.success).toBe(true);
266
+ expect(adapter.enable).toHaveBeenCalledWith('demo@community');
267
+ });
268
+ });
189
269
  });
190
270
 
191
271
  describe('executeReloadPluginsCommand', () => {