@robota-sdk/agent-command 3.0.0-beta.100
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 +1351 -0
- package/LICENSE +661 -0
- package/README.md +100 -0
- package/dist/node/index.cjs +121 -0
- package/dist/node/index.d.cts +1053 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +1053 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +122 -0
- package/dist/node/index.js.map +1 -0
- package/package.json +83 -0
- package/src/advisor/advisor-command-module.ts +47 -0
- package/src/advisor/advisor-command.test.ts +102 -0
- package/src/advisor/advisor-command.ts +55 -0
- package/src/advisor/advisor-cost-functional.test.ts +92 -0
- package/src/advisor/index.ts +6 -0
- package/src/agent/__tests__/agent-command.test.ts +597 -0
- package/src/agent/agent-command-module.ts +89 -0
- package/src/agent/agent-command-parser.ts +180 -0
- package/src/agent/agent-command.ts +255 -0
- package/src/agent/index.ts +7 -0
- package/src/background/__tests__/background-command-module.test.ts +261 -0
- package/src/background/background-command-module.ts +52 -0
- package/src/background/background-command.ts +65 -0
- package/src/background/index.ts +6 -0
- package/src/command-module-utils.test.ts +37 -0
- package/src/command-module-utils.ts +35 -0
- package/src/compact/__tests__/compact-command-module.test.ts +181 -0
- package/src/compact/compact-command-module.ts +59 -0
- package/src/compact/compact-command.ts +33 -0
- package/src/compact/index.ts +6 -0
- package/src/context/__tests__/context-command-module.test.ts +502 -0
- package/src/context/auto-compact-format.ts +25 -0
- package/src/context/context-breakdown.ts +295 -0
- package/src/context/context-command-module.ts +82 -0
- package/src/context/context-command.ts +291 -0
- package/src/context/index.ts +6 -0
- package/src/default/__tests__/__snapshots__/model-exposure.test.ts.snap +121 -0
- package/src/default/__tests__/default-command-modules.test.ts +192 -0
- package/src/default/__tests__/model-exposure.test.ts +269 -0
- package/src/default/__tests__/org-policy-forwarding.test.ts +106 -0
- package/src/default/__tests__/provider-environment-forwarding.test.ts +98 -0
- package/src/default/default-command-modules.ts +247 -0
- package/src/default/index.ts +5 -0
- package/src/devices/__tests__/devices-command-module.test.ts +299 -0
- package/src/devices/devices-command-module.ts +308 -0
- package/src/devices/devices-command-port.ts +143 -0
- package/src/devices/index.ts +14 -0
- package/src/doctor/__tests__/doctor-command-module.test.ts +145 -0
- package/src/doctor/__tests__/doctor-display-vocabulary.test.ts +38 -0
- package/src/doctor/__tests__/doctor-fixture.ts +146 -0
- package/src/doctor/__tests__/doctor-redaction.test.ts +29 -0
- package/src/doctor/__tests__/doctor-runner.test.ts +484 -0
- package/src/doctor/doctor-command-module.ts +132 -0
- package/src/doctor/doctor-extensions-probe.ts +323 -0
- package/src/doctor/doctor-node-deps.ts +88 -0
- package/src/doctor/doctor-redaction.ts +99 -0
- package/src/doctor/doctor-render.ts +70 -0
- package/src/doctor/doctor-repair.ts +136 -0
- package/src/doctor/doctor-runner.ts +122 -0
- package/src/doctor/doctor-settings-probe.ts +239 -0
- package/src/doctor/doctor-storage-probe.ts +187 -0
- package/src/doctor/doctor-types.ts +109 -0
- package/src/doctor/index.ts +32 -0
- package/src/editor/__tests__/editor-command-functional.test.ts +124 -0
- package/src/editor/editor-command-module.ts +53 -0
- package/src/editor/editor-command.ts +58 -0
- package/src/editor/index.ts +7 -0
- package/src/editor/resolve-editor.ts +21 -0
- package/src/effort/effort-command-module.ts +51 -0
- package/src/effort/effort-command.test.ts +203 -0
- package/src/effort/effort-command.ts +136 -0
- package/src/effort/index.ts +6 -0
- package/src/events/__tests__/events-command.test.ts +73 -0
- package/src/events/events-command-module.ts +49 -0
- package/src/events/events-command.ts +69 -0
- package/src/events/index.ts +2 -0
- package/src/exit/__tests__/exit-command-module.test.ts +61 -0
- package/src/exit/exit-command-module.ts +47 -0
- package/src/exit/exit-command.ts +26 -0
- package/src/exit/index.ts +6 -0
- package/src/fork/__tests__/fork-command.test.ts +184 -0
- package/src/fork/fork-command-module.ts +61 -0
- package/src/fork/fork-command.ts +162 -0
- package/src/fork/index.ts +8 -0
- package/src/git/__tests__/fake-git-port.ts +35 -0
- package/src/git/__tests__/git-command-module.test.ts +111 -0
- package/src/git/__tests__/git-commit.test.ts +185 -0
- package/src/git/git-command-module.ts +138 -0
- package/src/git/git-commit.ts +254 -0
- package/src/git/index.ts +20 -0
- package/src/goal/__tests__/goal-command.test.ts +80 -0
- package/src/goal/goal-command-module.ts +49 -0
- package/src/goal/goal-command.ts +69 -0
- package/src/goal/index.ts +6 -0
- package/src/handoff/__tests__/handoff-command-module.test.ts +25 -0
- package/src/handoff/__tests__/handoff-command.test.ts +218 -0
- package/src/handoff/handoff-command-module.ts +49 -0
- package/src/handoff/handoff-command.ts +187 -0
- package/src/handoff/index.ts +6 -0
- package/src/help/__tests__/help-command-module.test.ts +134 -0
- package/src/help/__tests__/help-command.test.ts +130 -0
- package/src/help/help-command-module.ts +47 -0
- package/src/help/help-command.ts +11 -0
- package/src/help/index.ts +6 -0
- package/src/index.ts +163 -0
- package/src/keybindings/__tests__/keybindings-command-module.test.ts +57 -0
- package/src/keybindings/index.ts +6 -0
- package/src/keybindings/keybindings-command-module.ts +88 -0
- package/src/language/__tests__/language-command-module.test.ts +126 -0
- package/src/language/index.ts +6 -0
- package/src/language/language-command-module.ts +55 -0
- package/src/language/language-command.ts +52 -0
- package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +895 -0
- package/src/mcp-activation/__tests__/mcp-model-view.test.ts +242 -0
- package/src/mcp-activation/__tests__/mcp-skill-command.test.ts +141 -0
- package/src/mcp-activation/index.ts +12 -0
- package/src/mcp-activation/mcp-activation-command-module.ts +123 -0
- package/src/mcp-activation/mcp-activation-command.ts +662 -0
- package/src/mcp-activation/mcp-model-notice.ts +99 -0
- package/src/mcp-activation/mcp-model-status.ts +104 -0
- package/src/memory/__tests__/memory-command-module.test.ts +476 -0
- package/src/memory/__tests__/memory-command-unavailable.test.ts +63 -0
- package/src/memory/index.ts +6 -0
- package/src/memory/memory-command-module.ts +77 -0
- package/src/memory/memory-command.ts +326 -0
- package/src/mode/__tests__/mode-command-module.test.ts +230 -0
- package/src/mode/index.ts +6 -0
- package/src/mode/mode-command-module.ts +55 -0
- package/src/mode/mode-command.ts +67 -0
- package/src/model/__tests__/model-command-module.test.ts +80 -0
- package/src/model/__tests__/model-command.test.ts +265 -0
- package/src/model/index.ts +6 -0
- package/src/model/model-command-module.ts +56 -0
- package/src/model/model-command.ts +249 -0
- package/src/output-style/__tests__/output-style-command.test.ts +96 -0
- package/src/output-style/index.ts +6 -0
- package/src/output-style/output-style-command-module.ts +143 -0
- package/src/peers/__tests__/peers-command-module.test.ts +24 -0
- package/src/peers/__tests__/peers-command.test.ts +399 -0
- package/src/peers/index.ts +9 -0
- package/src/peers/peers-command-module.ts +49 -0
- package/src/peers/peers-command.ts +220 -0
- package/src/permissions/__tests__/permissions-command-module.test.ts +276 -0
- package/src/permissions/index.ts +6 -0
- package/src/permissions/permissions-command-module.ts +55 -0
- package/src/permissions/permissions-command.ts +89 -0
- package/src/plan/__tests__/plan-command.test.ts +129 -0
- package/src/plan/index.ts +9 -0
- package/src/plan/plan-command-module.ts +49 -0
- package/src/plan/plan-command.ts +92 -0
- package/src/plugin/__tests__/plugin-active-turn.test.ts +100 -0
- package/src/plugin/__tests__/plugin-command-module.test.ts +300 -0
- package/src/plugin/__tests__/plugin-host-boundary.test.ts +11 -0
- package/src/plugin/index.ts +7 -0
- package/src/plugin/plugin-command-module.ts +85 -0
- package/src/plugin/plugin-command.ts +271 -0
- package/src/preset/__tests__/host-supplied-preset-registry.test.ts +98 -0
- package/src/preset/__tests__/preset-command-module.test.ts +220 -0
- package/src/preset/index.ts +6 -0
- package/src/preset/preset-command-module.ts +80 -0
- package/src/preset/preset-command.ts +148 -0
- package/src/provider/__tests__/optional-provider-credentials.test.ts +130 -0
- package/src/provider/__tests__/org-policy.test.ts +174 -0
- package/src/provider/__tests__/provider-command-module.test.ts +884 -0
- 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/__tests__/provider-setup-flow.test.ts +244 -0
- package/src/provider/__tests__/provider-startup-host-settings.test.ts +16 -0
- package/src/provider/__tests__/scripted-interaction.ts +40 -0
- package/src/provider/index.ts +32 -0
- package/src/provider/provider-command-execution.ts +235 -0
- package/src/provider/provider-command-module.ts +82 -0
- package/src/provider/provider-command-profile-lifecycle.ts +239 -0
- package/src/provider/provider-command-profile-operations.ts +218 -0
- package/src/provider/provider-command-profile.ts +116 -0
- package/src/provider/provider-command-setup.ts +79 -0
- package/src/provider/provider-connection-setup.ts +287 -0
- package/src/provider/provider-connection-validation.ts +38 -0
- package/src/provider/provider-onboarding.ts +73 -0
- 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 +326 -0
- package/src/provider/provider-startup.ts +225 -0
- package/src/remote-control/__tests__/remote-control-command-module.test.ts +36 -0
- package/src/remote-control/__tests__/remote-control-command.test.ts +216 -0
- package/src/remote-control/index.ts +8 -0
- package/src/remote-control/remote-control-command-module.ts +61 -0
- package/src/remote-control/remote-control-command.ts +138 -0
- package/src/reset/__tests__/reset-command-module.test.ts +63 -0
- package/src/reset/index.ts +2 -0
- package/src/reset/reset-command-module.ts +47 -0
- package/src/reset/reset-command.ts +10 -0
- package/src/rewind/__tests__/rewind-command-module.test.ts +287 -0
- package/src/rewind/index.ts +2 -0
- package/src/rewind/rewind-command-module.ts +56 -0
- package/src/rewind/rewind-command.ts +279 -0
- package/src/sandbox/__tests__/sandbox-command.test.ts +80 -0
- package/src/sandbox/index.ts +2 -0
- package/src/sandbox/sandbox-command-module.ts +56 -0
- package/src/sandbox/sandbox-command.ts +94 -0
- package/src/schedule/__tests__/loop-command-functional.test.ts +399 -0
- package/src/schedule/__tests__/loop-command.test.ts +594 -0
- package/src/schedule/__tests__/loop-jitter.test.ts +25 -0
- package/src/schedule/__tests__/monitor-model-permission-functional.test.ts +126 -0
- package/src/schedule/__tests__/schedule-command.test.ts +247 -0
- package/src/schedule/__tests__/schedule-redos.test.ts +114 -0
- package/src/schedule/index.ts +12 -0
- package/src/schedule/loop-command.ts +427 -0
- package/src/schedule/loop-jitter.ts +41 -0
- package/src/schedule/schedule-command-module.ts +184 -0
- package/src/schedule/schedule-command.ts +161 -0
- package/src/schedule/schedule-spec-parser.ts +70 -0
- package/src/session/__tests__/model-pricing.test.ts +66 -0
- package/src/session/__tests__/rename-host-persistence.test.ts +72 -0
- package/src/session/__tests__/session-command-module.test.ts +589 -0
- package/src/session/index.ts +17 -0
- package/src/session/model-pricing.ts +26 -0
- package/src/session/session-command-module.ts +197 -0
- package/src/session/session-command.ts +272 -0
- package/src/settings/index.ts +7 -0
- package/src/settings/settings-command-module.ts +47 -0
- package/src/shell/__tests__/resolve-shell-host.test.ts +11 -0
- package/src/shell/__tests__/shell-command-functional.test.ts +118 -0
- package/src/shell/index.ts +8 -0
- package/src/shell/resolve-shell.ts +25 -0
- package/src/shell/shell-command-module.ts +53 -0
- package/src/shell/shell-command.ts +48 -0
- package/src/shell/spawn-inherited.ts +32 -0
- package/src/skills/__tests__/skill-read-command.test.ts +64 -0
- package/src/skills/__tests__/skills-command-module.test.ts +200 -0
- package/src/skills/index.ts +7 -0
- package/src/skills/skill-read-command.ts +60 -0
- package/src/skills/skills-command-module.ts +76 -0
- package/src/skills/skills-command.ts +108 -0
- package/src/statusline/__tests__/statusline-command-module.test.ts +96 -0
- package/src/statusline/index.ts +6 -0
- package/src/statusline/statusline-command-module.ts +55 -0
- package/src/statusline/statusline-command.ts +80 -0
- package/src/terminal-client/__tests__/terminal-client-commands.test.ts +216 -0
- package/src/terminal-client/index.ts +5 -0
- package/src/terminal-client/terminal-client-commands.ts +109 -0
- package/src/theme/__tests__/theme-command.test.ts +204 -0
- package/src/theme/index.ts +12 -0
- package/src/theme/theme-command-module.ts +221 -0
- package/src/user-local/__tests__/user-local-command.test.ts +180 -0
- package/src/user-local/index.ts +13 -0
- package/src/user-local/user-local-command-constants.ts +5 -0
- package/src/user-local/user-local-command-module.ts +65 -0
- package/src/user-local/user-local-command.ts +214 -0
- package/src/user-local/user-local-memory-command.ts +160 -0
|
@@ -0,0 +1,265 @@
|
|
|
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
|
+
|
|
241
|
+
// #3459: the host withholds its own credentials from `process.env`; the profile's `$ENV:` key is
|
|
242
|
+
// checked against the snapshot it hands the command instead.
|
|
243
|
+
it('switches to a profile whose key is only in the host snapshot', async () => {
|
|
244
|
+
const writeTargetSettings = vi.fn();
|
|
245
|
+
const host = createTestCommandHost({ session: { getModelId: () => 'claude-sonnet-4-6' } });
|
|
246
|
+
const keyed = DEFINITIONS.map((definition) =>
|
|
247
|
+
definition.type === 'openai' ? { ...definition, requiresApiKey: true } : definition,
|
|
248
|
+
);
|
|
249
|
+
const options = {
|
|
250
|
+
...buildOptions({ writeTargetSettings }),
|
|
251
|
+
providerDefinitions: keyed,
|
|
252
|
+
env: { OPENAI_API_KEY: 'snapshot-key' },
|
|
253
|
+
};
|
|
254
|
+
const live = process.env['OPENAI_API_KEY'];
|
|
255
|
+
delete process.env['OPENAI_API_KEY'];
|
|
256
|
+
try {
|
|
257
|
+
const result = await executeModelCommand(host, 'gpt-5.1', options);
|
|
258
|
+
|
|
259
|
+
expect(result.success).toBe(true);
|
|
260
|
+
expect(result.hostActions).toEqual([{ type: 'provider-hot-swap', profileName: 'my-openai' }]);
|
|
261
|
+
} finally {
|
|
262
|
+
if (live !== undefined) process.env['OPENAI_API_KEY'] = live;
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
});
|
|
@@ -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,249 @@
|
|
|
1
|
+
import { selectAction } from '@robota-sdk/agent-core';
|
|
2
|
+
import {
|
|
3
|
+
buildModelListSnapshot,
|
|
4
|
+
formatOrgPolicyViolationMessage,
|
|
5
|
+
resolveModelListSelection,
|
|
6
|
+
setCurrentProvider,
|
|
7
|
+
upsertProviderProfile,
|
|
8
|
+
} from '@robota-sdk/agent-framework';
|
|
9
|
+
import { validateProviderConnection } from '../provider/provider-connection-validation.js';
|
|
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
|
|
49
|
+
? settings.providers?.[settings.currentProvider]
|
|
50
|
+
: undefined;
|
|
51
|
+
return buildModelListSnapshot(
|
|
52
|
+
settings.providers,
|
|
53
|
+
settings.currentProvider,
|
|
54
|
+
readCurrentModel(context, currentProfile),
|
|
55
|
+
options.providerDefinitions,
|
|
56
|
+
allowFiltered ? options.orgPolicy?.allowedProviders : undefined,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function executeModelCommand(
|
|
61
|
+
context: TModelCommandContext,
|
|
62
|
+
args: string,
|
|
63
|
+
options: IProviderCommandModuleOptions,
|
|
64
|
+
): Promise<ICommandResult> {
|
|
65
|
+
const settings = options.settings.readMergedSettings();
|
|
66
|
+
const id = args.trim();
|
|
67
|
+
|
|
68
|
+
if (id.length === 0) {
|
|
69
|
+
const visible = buildSnapshot(context, settings, options, true);
|
|
70
|
+
return pickModel(context.getUserInteraction(), visible, context, options);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const full = buildSnapshot(context, settings, options, false);
|
|
74
|
+
const selection = resolveModelListSelection(full, id);
|
|
75
|
+
if (selection === undefined) {
|
|
76
|
+
return {
|
|
77
|
+
message: `Unknown model "${id}". Run /model to see the choices.`,
|
|
78
|
+
success: false,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (selection.profileName === settings.currentProvider) {
|
|
83
|
+
return switchModelWithinCurrentProfile(context, selection, settings, options);
|
|
84
|
+
}
|
|
85
|
+
return switchModelAcrossProfiles(selection, settings, options);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* With an interactive renderer attached, drive the inline picker (CMD-004); without one — headless,
|
|
90
|
+
* automation, or a model-invoked caller, though this command is never offered to a model — return the
|
|
91
|
+
* list as plain text instead of guessing a choice.
|
|
92
|
+
*/
|
|
93
|
+
async function pickModel(
|
|
94
|
+
ui: IUserInteraction | undefined,
|
|
95
|
+
snapshot: IModelListSnapshot,
|
|
96
|
+
context: TModelCommandContext,
|
|
97
|
+
options: IProviderCommandModuleOptions,
|
|
98
|
+
): Promise<ICommandResult> {
|
|
99
|
+
if (snapshot.groups.length === 0) {
|
|
100
|
+
return {
|
|
101
|
+
message: 'No provider profiles configured. Run /provider add to add one.',
|
|
102
|
+
success: true,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
if (!ui) {
|
|
106
|
+
return { message: formatModelListText(snapshot), success: true };
|
|
107
|
+
}
|
|
108
|
+
const modelOptions = snapshot.groups.flatMap((group) =>
|
|
109
|
+
group.models.map((model) => ({
|
|
110
|
+
value: model.id,
|
|
111
|
+
label: formatModelOptionLabel(snapshot, group.profileName, group.providerLabel, model),
|
|
112
|
+
})),
|
|
113
|
+
);
|
|
114
|
+
const response = await ui.ask(
|
|
115
|
+
selectAction('model', 'Select model', modelOptions, { maxVisible: 8 }),
|
|
116
|
+
);
|
|
117
|
+
if (response.type !== 'answer' || response.values[0] === undefined) {
|
|
118
|
+
return { message: 'Model selection cancelled.', success: true };
|
|
119
|
+
}
|
|
120
|
+
return executeModelCommand(context, response.values[0], options);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function formatModelListText(snapshot: IModelListSnapshot): string {
|
|
124
|
+
const lines: string[] = [];
|
|
125
|
+
for (const group of snapshot.groups) {
|
|
126
|
+
lines.push(`${group.providerLabel} (${group.profileName})`);
|
|
127
|
+
for (const model of group.models) {
|
|
128
|
+
const marker =
|
|
129
|
+
model.id === snapshot.currentModel && group.profileName === snapshot.currentProfile
|
|
130
|
+
? '*'
|
|
131
|
+
: '-';
|
|
132
|
+
lines.push(` ${marker} ${model.label}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return lines.join('\n');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function formatModelOptionLabel(
|
|
139
|
+
snapshot: IModelListSnapshot,
|
|
140
|
+
profileName: string,
|
|
141
|
+
providerLabel: string,
|
|
142
|
+
model: { id: string; label: string },
|
|
143
|
+
): string {
|
|
144
|
+
const marker =
|
|
145
|
+
model.id === snapshot.currentModel && profileName === snapshot.currentProfile ? '* ' : '';
|
|
146
|
+
const sharedLabelCount = snapshot.groups.filter(
|
|
147
|
+
(group) => group.providerLabel === providerLabel,
|
|
148
|
+
).length;
|
|
149
|
+
const disambiguator = sharedLabelCount > 1 ? ` (${profileName})` : '';
|
|
150
|
+
return `${marker}${providerLabel}: ${model.label}${disambiguator}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The id belongs to the CURRENT profile's provider (#3282 §2): hot-swap the live model first, and
|
|
155
|
+
* persist it to the current profile only once that succeeds — a failed swap must leave settings
|
|
156
|
+
* exactly as they were.
|
|
157
|
+
*/
|
|
158
|
+
async function switchModelWithinCurrentProfile(
|
|
159
|
+
context: ICommandHostSessionAccess,
|
|
160
|
+
selection: IModelListSelection,
|
|
161
|
+
settings: TProviderSettingsDocument,
|
|
162
|
+
options: IProviderCommandModuleOptions,
|
|
163
|
+
): Promise<ICommandResult> {
|
|
164
|
+
const currentProfileName = selection.profileName;
|
|
165
|
+
try {
|
|
166
|
+
await context.getSession().applyModelOptions({ model: selection.model.id });
|
|
167
|
+
} catch (error) {
|
|
168
|
+
return {
|
|
169
|
+
message: `Could not switch to "${selection.model.label}": ${error instanceof Error ? error.message : String(error)}`,
|
|
170
|
+
success: false,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
const target = options.settings.readTargetSettings();
|
|
174
|
+
// Based on MERGED (not target-only) so a profile that lives in a lower-priority settings layer
|
|
175
|
+
// keeps its other fields (type, apiKey, baseURL) — only `model` changes at the target layer.
|
|
176
|
+
const currentProfile =
|
|
177
|
+
settings.providers?.[currentProfileName] ?? target.providers?.[currentProfileName] ?? {};
|
|
178
|
+
options.settings.writeTargetSettings(
|
|
179
|
+
upsertProviderProfile(target, currentProfileName, {
|
|
180
|
+
...currentProfile,
|
|
181
|
+
model: selection.model.id,
|
|
182
|
+
}),
|
|
183
|
+
);
|
|
184
|
+
return { message: `Model: ${selection.model.label}`, success: true };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The id belongs to ANOTHER profile's provider (#3282 §2): switch to that profile through the same
|
|
189
|
+
* validate-before-write path `/provider switch` uses, with the requested model folded into the same
|
|
190
|
+
* settings write, then hot-swap through the existing `provider-hot-swap` host action — which re-reads
|
|
191
|
+
* settings at execution time, so it picks up the model this write just persisted.
|
|
192
|
+
*/
|
|
193
|
+
async function switchModelAcrossProfiles(
|
|
194
|
+
selection: IModelListSelection,
|
|
195
|
+
settings: TProviderSettingsDocument,
|
|
196
|
+
options: IProviderCommandModuleOptions,
|
|
197
|
+
): Promise<ICommandResult> {
|
|
198
|
+
const { orgPolicy } = options;
|
|
199
|
+
// Checked BEFORE anything is read or written — exactly buildProviderSwitch's order — so a switch
|
|
200
|
+
// org policy refuses changes nothing on disk, the same guarantee /provider switch already gives.
|
|
201
|
+
if (orgPolicy?.allowedProviders && !orgPolicy.allowedProviders.includes(selection.profileName)) {
|
|
202
|
+
return {
|
|
203
|
+
message: formatOrgPolicyViolationMessage(
|
|
204
|
+
`Provider "${selection.profileName}" is not allowed by your organization policy. Allowed: ${orgPolicy.allowedProviders.join(', ')}.`,
|
|
205
|
+
orgPolicy.adminContact,
|
|
206
|
+
),
|
|
207
|
+
success: false,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
const profile = settings.providers?.[selection.profileName];
|
|
211
|
+
if (profile === undefined) {
|
|
212
|
+
return {
|
|
213
|
+
message: `Provider profile "${selection.profileName}" was not found.`,
|
|
214
|
+
success: false,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
const updatedProfile: IProviderProfileSettings = { ...profile, model: selection.model.id };
|
|
218
|
+
try {
|
|
219
|
+
await validateProviderConnection(selection.profileName, updatedProfile, options);
|
|
220
|
+
} catch (error) {
|
|
221
|
+
return {
|
|
222
|
+
message: `Could not switch to "${selection.model.label}": ${error instanceof Error ? error.message : String(error)}`,
|
|
223
|
+
success: false,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
const target = options.settings.readTargetSettings();
|
|
227
|
+
const merged = options.settings.readMergedSettings();
|
|
228
|
+
if (
|
|
229
|
+
merged.currentProvider !== settings.currentProvider ||
|
|
230
|
+
JSON.stringify(merged.providers?.[selection.profileName]) !== JSON.stringify(profile)
|
|
231
|
+
) {
|
|
232
|
+
return {
|
|
233
|
+
success: false,
|
|
234
|
+
message: 'Provider settings changed while switching. Run /model again.',
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
const withModel = upsertProviderProfile(target, selection.profileName, updatedProfile);
|
|
238
|
+
const next =
|
|
239
|
+
target.providers?.[selection.profileName] !== undefined ||
|
|
240
|
+
merged.providers?.[selection.profileName] !== undefined
|
|
241
|
+
? { ...withModel, currentProvider: selection.profileName }
|
|
242
|
+
: setCurrentProvider(withModel, selection.profileName);
|
|
243
|
+
options.settings.writeTargetSettings(next);
|
|
244
|
+
return {
|
|
245
|
+
message: `Model: ${selection.model.label}`,
|
|
246
|
+
success: true,
|
|
247
|
+
hostActions: [{ type: 'provider-hot-swap', profileName: selection.profileName }],
|
|
248
|
+
};
|
|
249
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { createTestCommandHost } from '@robota-sdk/agent-framework/testing';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
createOutputStyleCommandEntry,
|
|
7
|
+
executeOutputStyleCommand,
|
|
8
|
+
} from '../output-style-command-module.js';
|
|
9
|
+
|
|
10
|
+
const styles = [
|
|
11
|
+
{
|
|
12
|
+
id: 'default',
|
|
13
|
+
name: 'Default',
|
|
14
|
+
description: 'Balanced responses.',
|
|
15
|
+
tokenCost: 'baseline',
|
|
16
|
+
source: 'built-in',
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
id: 'concise',
|
|
20
|
+
name: 'Concise',
|
|
21
|
+
description: 'Lead with the answer.',
|
|
22
|
+
tokenCost: 'low',
|
|
23
|
+
source: 'built-in',
|
|
24
|
+
},
|
|
25
|
+
] as const;
|
|
26
|
+
|
|
27
|
+
function context() {
|
|
28
|
+
const ask = vi.fn().mockResolvedValue({ type: 'answer', values: ['concise'] });
|
|
29
|
+
const host = createTestCommandHost({
|
|
30
|
+
overrides: {
|
|
31
|
+
getActiveOutputStyleId: () => 'default',
|
|
32
|
+
getUserInteraction: () => ({ ask }),
|
|
33
|
+
getCommandHostAdapters: () => ({
|
|
34
|
+
outputStyleRegistry: {
|
|
35
|
+
listOutputStyles: () => styles,
|
|
36
|
+
getOutputStyle: (id) =>
|
|
37
|
+
id === 'concise'
|
|
38
|
+
? {
|
|
39
|
+
id: 'concise',
|
|
40
|
+
name: 'Concise',
|
|
41
|
+
instructions: 'Lead with the answer.',
|
|
42
|
+
keepCodingInstructions: true,
|
|
43
|
+
tokenCost: 'low',
|
|
44
|
+
}
|
|
45
|
+
: undefined,
|
|
46
|
+
},
|
|
47
|
+
}),
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
return { host, ask };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('/output-style', () => {
|
|
54
|
+
it('lists styles with the active marker and token-cost metadata', async () => {
|
|
55
|
+
const { host } = context();
|
|
56
|
+
const result = await executeOutputStyleCommand(host, 'list');
|
|
57
|
+
|
|
58
|
+
expect(result.success).toBe(true);
|
|
59
|
+
expect(result.message).toContain(
|
|
60
|
+
'* default — Default: Balanced responses. [input cost: baseline]',
|
|
61
|
+
);
|
|
62
|
+
expect(result.message).toContain(
|
|
63
|
+
' concise — Concise: Lead with the answer. [input cost: low]',
|
|
64
|
+
);
|
|
65
|
+
expect(result.data).toEqual({ outputStyles: styles, active: 'default' });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('uses the inline picker when no id is supplied and emits only a style id action', async () => {
|
|
69
|
+
const { host, ask } = context();
|
|
70
|
+
const result = await executeOutputStyleCommand(host, '');
|
|
71
|
+
|
|
72
|
+
expect(ask).toHaveBeenCalledTimes(1);
|
|
73
|
+
expect(result).toMatchObject({
|
|
74
|
+
success: true,
|
|
75
|
+
hostActions: [{ type: 'output-style-change', styleId: 'concise' }],
|
|
76
|
+
data: { outputStyle: 'concise' },
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('rejects unknown ids and never emits an action', async () => {
|
|
81
|
+
const { host } = context();
|
|
82
|
+
const result = await executeOutputStyleCommand(host, 'missing');
|
|
83
|
+
|
|
84
|
+
expect(result.success).toBe(false);
|
|
85
|
+
expect(result.message).toContain('Unknown output style');
|
|
86
|
+
expect(result.hostActions).toBeUndefined();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('is operator-only', () => {
|
|
90
|
+
expect(createOutputStyleCommandEntry()).toMatchObject({
|
|
91
|
+
name: 'output-style',
|
|
92
|
+
modelInvocable: false,
|
|
93
|
+
userInvocable: true,
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
});
|