@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.
- package/CHANGELOG.md +27 -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/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 +131 -0
- package/src/mcp-activation/mcp-activation-command-module.ts +6 -0
- package/src/mcp-activation/mcp-activation-command.ts +54 -1
- 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 +81 -1
- package/src/plugin/plugin-command.ts +47 -4
- 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
|
@@ -54,7 +54,7 @@ function revocationText(result: ICommandMCPOAuthLogoutResult): string {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
const USAGE =
|
|
57
|
-
'Usage: /mcp [status] | /mcp <approve|reject|revoke|logout> <serverId> | /mcp login <serverId> [--no-browser]';
|
|
57
|
+
'Usage: /mcp [status] | /mcp <approve|reject|revoke|logout> <serverId> | /mcp login <serverId> [--no-browser] | /mcp reload';
|
|
58
58
|
const LOGIN_USAGE = 'Usage: /mcp login <serverId> [--no-browser]';
|
|
59
59
|
|
|
60
60
|
/**
|
|
@@ -147,6 +147,7 @@ async function listResult(mcp: ICommandMCPActivationAdapter | undefined): Promis
|
|
|
147
147
|
data: {
|
|
148
148
|
servers: entries.map((entry) => ({
|
|
149
149
|
serverId: entry.serverId,
|
|
150
|
+
...(entry.displayName === undefined ? {} : { displayName: entry.displayName }),
|
|
150
151
|
source: entry.source,
|
|
151
152
|
status: entry.status,
|
|
152
153
|
allowed: entry.allowed,
|
|
@@ -154,6 +155,12 @@ async function listResult(mcp: ICommandMCPActivationAdapter | undefined): Promis
|
|
|
154
155
|
definitionFingerprint: entry.definitionFingerprint,
|
|
155
156
|
securityIdentity: entry.securityIdentity,
|
|
156
157
|
...(oauth.has(entry.serverId) ? { oauth: oauth.get(entry.serverId) } : {}),
|
|
158
|
+
// #3282 §4 part b-2: the Settings screen's MCP Servers section.
|
|
159
|
+
...(entry.connection === undefined ? {} : { connection: entry.connection }),
|
|
160
|
+
...(entry.connectionFailureReason === undefined
|
|
161
|
+
? {}
|
|
162
|
+
: { connectionFailureReason: entry.connectionFailureReason }),
|
|
163
|
+
...(entry.toolNames === undefined ? {} : { toolNames: entry.toolNames }),
|
|
157
164
|
})),
|
|
158
165
|
sourceProblems,
|
|
159
166
|
},
|
|
@@ -183,6 +190,7 @@ export async function executeMCPActivationCommand(
|
|
|
183
190
|
: mcpModelStatusResult(adapter(context));
|
|
184
191
|
}
|
|
185
192
|
if (verb === 'login') return loginResult(context, serverId);
|
|
193
|
+
if (verb === 'reload') return reloadResult(context);
|
|
186
194
|
|
|
187
195
|
if (verb !== 'approve' && verb !== 'reject' && verb !== 'revoke' && verb !== 'logout') {
|
|
188
196
|
return { message: `Unknown argument. ${USAGE}`, success: false };
|
|
@@ -250,6 +258,51 @@ async function logoutResult(
|
|
|
250
258
|
};
|
|
251
259
|
}
|
|
252
260
|
|
|
261
|
+
/**
|
|
262
|
+
* #3282 §4 part b-2: `/mcp reload` and the Settings screen's "Reload servers" button — retries
|
|
263
|
+
* every server not currently connected and adds whatever tools it now offers to the live session,
|
|
264
|
+
* the same way a completed sign-in does.
|
|
265
|
+
*/
|
|
266
|
+
async function reloadResult(context: TMCPActivationCommandContext): Promise<ICommandResult> {
|
|
267
|
+
const mcp = adapter(context);
|
|
268
|
+
if (!mcp) {
|
|
269
|
+
return {
|
|
270
|
+
message: 'MCP activation management is not available in this environment.',
|
|
271
|
+
success: true,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
if (mcp.reload === undefined) {
|
|
275
|
+
return { message: 'Reloading MCP servers is not available in this environment.', success: false };
|
|
276
|
+
}
|
|
277
|
+
const result = await mcp.reload();
|
|
278
|
+
const added =
|
|
279
|
+
result.tools.length === 0 ? [] : await context.getSession().addTools(result.tools);
|
|
280
|
+
if (result.tools.length > 0 && result.reloadToken !== undefined) {
|
|
281
|
+
mcp.reloadToolsAdded?.(result.reloadToken, added);
|
|
282
|
+
}
|
|
283
|
+
const dropped = result.tools.length - added.length;
|
|
284
|
+
const parts = [
|
|
285
|
+
result.connectedServerIds.length === 0
|
|
286
|
+
? 'No server newly connected.'
|
|
287
|
+
: `Connected: ${result.connectedServerIds.join(', ')}.`,
|
|
288
|
+
result.failedServerIds.length === 0
|
|
289
|
+
? undefined
|
|
290
|
+
: `Still not connected: ${result.failedServerIds.join(', ')} (see /mcp status).`,
|
|
291
|
+
dropped === 0
|
|
292
|
+
? undefined
|
|
293
|
+
: `${dropped} of the new tools ${dropped === 1 ? 'was' : 'were'} left out: the session already has a tool by that name.`,
|
|
294
|
+
].filter((part): part is string => part !== undefined);
|
|
295
|
+
return {
|
|
296
|
+
message: `Reloaded MCP servers. ${parts.join(' ')}`.trim(),
|
|
297
|
+
success: true,
|
|
298
|
+
data: {
|
|
299
|
+
connectedServerIds: result.connectedServerIds,
|
|
300
|
+
failedServerIds: result.failedServerIds,
|
|
301
|
+
tools: [...added],
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
253
306
|
let loginPrompts = 0;
|
|
254
307
|
|
|
255
308
|
/** Before the browser opens: the URL it will show, and the choice to paste or cancel instead. */
|
|
@@ -101,10 +101,15 @@ function createMockRuntimeSession() {
|
|
|
101
101
|
};
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
async function createInteractiveSession(
|
|
104
|
+
async function createInteractiveSession(
|
|
105
|
+
cwd = makeProject(),
|
|
106
|
+
permissionMode: 'default' | 'plan' = 'default',
|
|
107
|
+
): Promise<InteractiveSession> {
|
|
108
|
+
const runtime = createMockRuntimeSession();
|
|
109
|
+
runtime.getPermissionMode.mockReturnValue(permissionMode);
|
|
105
110
|
return new InteractiveSession({
|
|
106
111
|
cwd,
|
|
107
|
-
session:
|
|
112
|
+
session: runtime as never,
|
|
108
113
|
memoryStore: await createMemoryStore(cwd),
|
|
109
114
|
commandModules: [createMemoryCommandModule()],
|
|
110
115
|
});
|
|
@@ -362,4 +367,37 @@ describe('executeMemoryCommand', () => {
|
|
|
362
367
|
expect(unknownResult.success).toBe(false);
|
|
363
368
|
expect(existsSync(join(cwd, '.robota', 'memory', 'MEMORY.md'))).toBe(false);
|
|
364
369
|
});
|
|
370
|
+
|
|
371
|
+
describe('in plan mode', () => {
|
|
372
|
+
it('refuses a memory the model asks to save, and writes nothing', async () => {
|
|
373
|
+
const cwd = makeProject();
|
|
374
|
+
const session = await createInteractiveSession(cwd, 'plan');
|
|
375
|
+
|
|
376
|
+
const result = await session.executeModelCommand('memory', 'add project build Use pnpm.');
|
|
377
|
+
|
|
378
|
+
expect(result?.success).toBe(false);
|
|
379
|
+
expect(result?.message).toContain('Plan mode saves no memory');
|
|
380
|
+
expect(existsSync(join(cwd, '.robota', 'memory', 'MEMORY.md'))).toBe(false);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// ARCH-047: project mutation is Linux-only (stable root-anchored host); refused elsewhere.
|
|
384
|
+
it.runIf(process.platform === 'linux')(
|
|
385
|
+
'still saves a memory the user adds by hand',
|
|
386
|
+
async () => {
|
|
387
|
+
const cwd = makeProject();
|
|
388
|
+
const session = await createInteractiveSession(cwd, 'plan');
|
|
389
|
+
|
|
390
|
+
const result = await session.executeCommand('memory', 'add project build Use pnpm.');
|
|
391
|
+
|
|
392
|
+
expect(result?.success).toBe(true);
|
|
393
|
+
expect(existsSync(join(cwd, '.robota', 'memory', 'MEMORY.md'))).toBe(true);
|
|
394
|
+
},
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
it('lets the model read memory', async () => {
|
|
398
|
+
const session = await createInteractiveSession(makeProject(), 'plan');
|
|
399
|
+
const result = await session.executeModelCommand('memory', 'list');
|
|
400
|
+
expect(result?.success).toBe(true);
|
|
401
|
+
});
|
|
402
|
+
});
|
|
365
403
|
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3282 §4c review finding: `/memory` in a served (GUI) session throws instead of answering, because
|
|
3
|
+
* `executeMemoryCommand` never caught what `context.getMemoryStore()` throws when the host composed no
|
|
4
|
+
* memory store (off by default, or unavailable on this host — `WorkspaceAuthorityRequiredError`). The
|
|
5
|
+
* uncaught rejection reaches the transport's `command` handler
|
|
6
|
+
* (`session-message-handler.ts`'s `.then(_, error => deliver({ type: 'protocol_error', ... }))`), which
|
|
7
|
+
* the GUI renders as a `protocol_error` toast — the "red toast" the audit found — instead of the same
|
|
8
|
+
* plain `command_result` every other unavailable-command case gets (mirrors `rewind-command.ts`'s
|
|
9
|
+
* `formatError`, which already catches `EditCheckpointsUnavailableError` the same way).
|
|
10
|
+
*/
|
|
11
|
+
import { describe, expect, it } from 'vitest';
|
|
12
|
+
|
|
13
|
+
import { WorkspaceAuthorityRequiredError } from '@robota-sdk/agent-framework';
|
|
14
|
+
|
|
15
|
+
import { executeMemoryCommand } from '../memory-command.js';
|
|
16
|
+
|
|
17
|
+
import type { ICommandHostMemory, ICommandHostWorkspace } from '@robota-sdk/agent-framework';
|
|
18
|
+
|
|
19
|
+
function contextWithUnavailableMemory(): ICommandHostMemory & ICommandHostWorkspace {
|
|
20
|
+
return {
|
|
21
|
+
getUsedMemoryReferences: () => [],
|
|
22
|
+
recordMemoryEvent: () => {},
|
|
23
|
+
getMemoryStore: () => {
|
|
24
|
+
throw new WorkspaceAuthorityRequiredError("Project memory isn't available for this folder.");
|
|
25
|
+
},
|
|
26
|
+
getCwd: () => '/workspace',
|
|
27
|
+
getCommandInvocationSource: () => 'user',
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('executeMemoryCommand — unavailable memory store', () => {
|
|
32
|
+
it('answers list with a plain failure instead of rejecting', async () => {
|
|
33
|
+
const result = await executeMemoryCommand(contextWithUnavailableMemory(), 'list');
|
|
34
|
+
expect(result.success).toBe(false);
|
|
35
|
+
expect(result.message).toBe("Project memory isn't available for this folder.");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('answers show, pending, used and add the same way — never a throw', async () => {
|
|
39
|
+
const context = contextWithUnavailableMemory();
|
|
40
|
+
for (const args of ['show', 'pending', 'used', 'add project topic some durable fact']) {
|
|
41
|
+
await expect(executeMemoryCommand(context, args)).resolves.toMatchObject({
|
|
42
|
+
success: false,
|
|
43
|
+
message: "Project memory isn't available for this folder.",
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('still lets an ordinary Error through as a plain failure, not a throw', async () => {
|
|
49
|
+
const context: ICommandHostMemory & ICommandHostWorkspace = {
|
|
50
|
+
getUsedMemoryReferences: () => [],
|
|
51
|
+
recordMemoryEvent: () => {},
|
|
52
|
+
getMemoryStore: () => {
|
|
53
|
+
throw new Error('unexpected store failure');
|
|
54
|
+
},
|
|
55
|
+
getCwd: () => '/workspace',
|
|
56
|
+
getCommandInvocationSource: () => 'user',
|
|
57
|
+
};
|
|
58
|
+
await expect(executeMemoryCommand(context, 'list')).resolves.toEqual({
|
|
59
|
+
success: false,
|
|
60
|
+
message: 'unexpected store failure',
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -12,7 +12,8 @@ import type { ICommand, ICommandSource } from '@robota-sdk/agent-interface-comma
|
|
|
12
12
|
/**
|
|
13
13
|
* Model-invocable: remembering and recalling project conventions is the model's own work. The
|
|
14
14
|
* subcommand flags (in `buildMemoryCommandSubcommands`) keep `approve`/`reject` user-only, because
|
|
15
|
-
* pending candidates exist so the user reviews what the model proposed to remember
|
|
15
|
+
* pending candidates exist so the user reviews what the model proposed to remember, and the command
|
|
16
|
+
* refuses the model's `add` in plan mode, which writes nothing.
|
|
16
17
|
*/
|
|
17
18
|
const MEMORY_COMMAND_MODEL_DESCRIPTION =
|
|
18
19
|
'Read and write this project’s durable memory. Use `list` or `show [topic]` to look up stored ' +
|
|
@@ -20,7 +21,8 @@ const MEMORY_COMMAND_MODEL_DESCRIPTION =
|
|
|
20
21
|
'to save a durable preference, project convention, feedback item or reference worth reusing ' +
|
|
21
22
|
'across sessions (never secrets, credentials or transient facts); `pending` to see candidates ' +
|
|
22
23
|
'awaiting the user’s review; `used` to report which memory items informed this turn. Bare lists ' +
|
|
23
|
-
'topics.
|
|
24
|
+
'topics. In plan mode `add` is refused: save after plan mode ends. Returns the requested topics ' +
|
|
25
|
+
'or entries, or a confirmation of what was saved. Approving ' +
|
|
24
26
|
'or rejecting a pending candidate is the user’s review: suggest `/memory approve <id>` or ' +
|
|
25
27
|
'`/memory reject <id>`.';
|
|
26
28
|
|
|
@@ -10,7 +10,9 @@ import {
|
|
|
10
10
|
|
|
11
11
|
import type {
|
|
12
12
|
IAppendMemoryInput,
|
|
13
|
+
ICommandHostAdapterAccess,
|
|
13
14
|
ICommandHostMemory,
|
|
15
|
+
ICommandHostSessionAccess,
|
|
14
16
|
ICommandHostWorkspace,
|
|
15
17
|
IMemoryStore,
|
|
16
18
|
} from '@robota-sdk/agent-framework';
|
|
@@ -36,6 +38,24 @@ function formatError(error: Error | string): ICommandResult {
|
|
|
36
38
|
};
|
|
37
39
|
}
|
|
38
40
|
|
|
41
|
+
/**
|
|
42
|
+
* `context.getMemoryStore()` throws `WorkspaceAuthorityRequiredError` when the host composed no
|
|
43
|
+
* memory store — off by default, or unavailable on this host (mirrors `EditCheckpointsUnavailableError`
|
|
44
|
+
* for `/rewind`, `rewind-command.ts`'s `formatError`). Uncaught, that throw reaches the GUI as a raw
|
|
45
|
+
* `protocol_error` (a red toast) instead of the SAME plain `command_result` every other unavailable-
|
|
46
|
+
* command case gets — this is the one call in the command that can throw before any subcommand branch
|
|
47
|
+
* runs, so it is guarded once, here, rather than in each branch below.
|
|
48
|
+
*/
|
|
49
|
+
function getMemoryStoreOrError(
|
|
50
|
+
context: ICommandHostMemory,
|
|
51
|
+
): { ok: true; store: IMemoryStore } | { ok: false; result: ICommandResult } {
|
|
52
|
+
try {
|
|
53
|
+
return { ok: true, store: createCommandMemoryStores(context) };
|
|
54
|
+
} catch (error) {
|
|
55
|
+
return { ok: false, result: formatError(error instanceof Error ? error : String(error)) };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
39
59
|
async function formatList(store: IMemoryStore): Promise<ICommandResult> {
|
|
40
60
|
const summary = await store.list();
|
|
41
61
|
const topics =
|
|
@@ -190,15 +210,27 @@ function formatUsed(context: ICommandHostMemory): ICommandResult {
|
|
|
190
210
|
};
|
|
191
211
|
}
|
|
192
212
|
|
|
213
|
+
/** The host's view of the session's permission mode, when it has one. */
|
|
214
|
+
type TMemoryCommandModeAccess = ICommandHostAdapterAccess &
|
|
215
|
+
Partial<Pick<ICommandHostSessionAccess, 'getSession'>>;
|
|
216
|
+
|
|
217
|
+
function isPlanMode(context: TMemoryCommandModeAccess): boolean {
|
|
218
|
+
const adapter = context.getCommandHostAdapters?.().permissionMode;
|
|
219
|
+
const mode = adapter?.getPermissionMode() ?? context.getSession?.().getPermissionMode();
|
|
220
|
+
return mode === 'plan';
|
|
221
|
+
}
|
|
222
|
+
|
|
193
223
|
export async function executeMemoryCommand(
|
|
194
|
-
context: ICommandHostMemory & ICommandHostWorkspace,
|
|
224
|
+
context: ICommandHostMemory & ICommandHostWorkspace & TMemoryCommandModeAccess,
|
|
195
225
|
rawArgs: string,
|
|
196
226
|
): Promise<ICommandResult> {
|
|
197
227
|
const args = rawArgs.trim().split(/\s+/).filter(Boolean);
|
|
198
228
|
const subcommand = args[SUBCOMMAND_INDEX] ?? 'list';
|
|
199
229
|
// SELFHOST-008 P1R: the single injected durable-memory port (or fs default) — authoritative for all
|
|
200
230
|
// `/memory` operations, so a surface that swaps the store is honored here too (no split-brain).
|
|
201
|
-
const
|
|
231
|
+
const resolved = getMemoryStoreOrError(context);
|
|
232
|
+
if (!resolved.ok) return resolved.result;
|
|
233
|
+
const store = resolved.store;
|
|
202
234
|
|
|
203
235
|
if (subcommand === 'list') return formatList(store);
|
|
204
236
|
if (subcommand === 'show') return formatShow(store, args[TYPE_INDEX]);
|
|
@@ -207,6 +239,14 @@ export async function executeMemoryCommand(
|
|
|
207
239
|
if (subcommand === 'reject') return rejectPending(context, store, args[TYPE_INDEX]);
|
|
208
240
|
if (subcommand === 'used') return formatUsed(context);
|
|
209
241
|
if (subcommand === 'add') {
|
|
242
|
+
// Plan mode changes nothing, and saved memory is read back into every later session. The user
|
|
243
|
+
// can still save by hand; the model waits until plan mode ends.
|
|
244
|
+
if (context.getCommandInvocationSource() === 'model' && isPlanMode(context)) {
|
|
245
|
+
return {
|
|
246
|
+
message: 'Plan mode saves no memory. Save it after plan mode ends.',
|
|
247
|
+
success: false,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
210
250
|
const input = parseAdd(args);
|
|
211
251
|
if (!input) return usage();
|
|
212
252
|
if (hasSensitiveCommandMemoryContent(input.text)) {
|
|
@@ -97,6 +97,15 @@ describe('createModeCommandModule', () => {
|
|
|
97
97
|
'bypassPermissions',
|
|
98
98
|
'auto',
|
|
99
99
|
]);
|
|
100
|
+
// #3282 §2: a plain label beside every description, so a client never shows the raw id.
|
|
101
|
+
expect(entry?.subcommands?.map((subcommand) => subcommand.displayName)).toEqual([
|
|
102
|
+
'Plan only',
|
|
103
|
+
'Ask first',
|
|
104
|
+
'Accept edits',
|
|
105
|
+
'Skip all checks',
|
|
106
|
+
'Auto',
|
|
107
|
+
]);
|
|
108
|
+
expect(entry?.subcommands?.every((subcommand) => subcommand.description.length > 0)).toBe(true);
|
|
100
109
|
expect(command).toEqual(
|
|
101
110
|
expect.objectContaining({
|
|
102
111
|
name: 'mode',
|
|
@@ -150,6 +159,37 @@ describe('createModeCommandModule', () => {
|
|
|
150
159
|
expect(context.setPermissionMode).not.toHaveBeenCalled();
|
|
151
160
|
});
|
|
152
161
|
|
|
162
|
+
it('#3282 §2: the picker shows plain labels with their descriptions, never a raw id', async () => {
|
|
163
|
+
const executor = new SystemCommandExecutor([
|
|
164
|
+
...(createModeCommandModule().systemCommands ?? []),
|
|
165
|
+
]);
|
|
166
|
+
const context = createCommandHostContext();
|
|
167
|
+
const ask = vi.fn().mockResolvedValue({ type: 'answer', values: ['plan'] });
|
|
168
|
+
const contextWithAsk = createTestCommandHost({
|
|
169
|
+
overrides: { ...context, getUserInteraction: () => ({ ask }) },
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
await executor.execute('mode', contextWithAsk, '');
|
|
173
|
+
|
|
174
|
+
expect(ask).toHaveBeenCalledTimes(1);
|
|
175
|
+
const request = ask.mock.calls[0]![0];
|
|
176
|
+
expect(request.options).toEqual([
|
|
177
|
+
{ value: 'plan', label: 'Plan only', description: 'Plan only, no execution' },
|
|
178
|
+
{ value: 'default', label: 'Ask first', description: 'Ask before risky actions' },
|
|
179
|
+
{ value: 'acceptEdits', label: 'Accept edits', description: 'Auto-approve file edits' },
|
|
180
|
+
{
|
|
181
|
+
value: 'bypassPermissions',
|
|
182
|
+
label: 'Skip all checks',
|
|
183
|
+
description: 'Skip all permission checks',
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
value: 'auto',
|
|
187
|
+
label: 'Auto',
|
|
188
|
+
description: 'A model classifier approves or blocks risky actions',
|
|
189
|
+
},
|
|
190
|
+
]);
|
|
191
|
+
});
|
|
192
|
+
|
|
153
193
|
it('asks the user to pick a mode when no arg is given and a renderer is attached (CMD-004)', async () => {
|
|
154
194
|
const executor = new SystemCommandExecutor([
|
|
155
195
|
...(createModeCommandModule().systemCommands ?? []),
|
package/src/mode/mode-command.ts
CHANGED
|
@@ -28,7 +28,7 @@ async function resolveModeViaAsk(
|
|
|
28
28
|
if (!ui) return undefined;
|
|
29
29
|
const options = buildPermissionModeSubcommands().map((sub) => ({
|
|
30
30
|
value: sub.name,
|
|
31
|
-
label: sub.name,
|
|
31
|
+
label: sub.displayName ?? sub.name,
|
|
32
32
|
description: sub.description,
|
|
33
33
|
}));
|
|
34
34
|
const response = await ui.ask(selectAction('mode', 'Select interaction mode', options));
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { SystemCommandExecutor } from '@robota-sdk/agent-framework';
|
|
4
|
+
import { createTestCommandHost } from '@robota-sdk/agent-framework/testing';
|
|
5
|
+
|
|
6
|
+
import { createModelCommandEntry, createModelCommandModule } from '../model-command-module.js';
|
|
7
|
+
|
|
8
|
+
import type { IProviderCommandModuleOptions, IProviderProfileSettings } from '@robota-sdk/agent-framework';
|
|
9
|
+
import type { IProviderDefinition } from '@robota-sdk/agent-core';
|
|
10
|
+
|
|
11
|
+
function buildOptions(): IProviderCommandModuleOptions {
|
|
12
|
+
const providers: Record<string, IProviderProfileSettings> = {
|
|
13
|
+
anthropic: { type: 'anthropic', model: 'claude-sonnet-4-6' },
|
|
14
|
+
};
|
|
15
|
+
const definitions: readonly IProviderDefinition[] = [
|
|
16
|
+
{
|
|
17
|
+
type: 'anthropic',
|
|
18
|
+
displayName: 'Anthropic',
|
|
19
|
+
modelCatalog: { status: 'unavailable' },
|
|
20
|
+
createProvider: () => {
|
|
21
|
+
throw new Error('not used');
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
];
|
|
25
|
+
const document = { currentProvider: 'anthropic', providers };
|
|
26
|
+
return {
|
|
27
|
+
providerDefinitions: definitions,
|
|
28
|
+
settings: {
|
|
29
|
+
readMergedSettings: () => document,
|
|
30
|
+
readTargetSettings: () => document,
|
|
31
|
+
writeTargetSettings: vi.fn(),
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('createModelCommandModule (#3282 §2)', () => {
|
|
37
|
+
it('is user-only: never model-invocable', () => {
|
|
38
|
+
const entry = createModelCommandEntry();
|
|
39
|
+
expect(entry.modelInvocable).toBe(false);
|
|
40
|
+
|
|
41
|
+
const module = createModelCommandModule(buildOptions());
|
|
42
|
+
const command = module.systemCommands?.[0];
|
|
43
|
+
expect(command?.modelInvocable).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('carries a model-facing description, name and argument hint', () => {
|
|
47
|
+
const entry = createModelCommandEntry();
|
|
48
|
+
expect(entry).toEqual(
|
|
49
|
+
expect.objectContaining({
|
|
50
|
+
name: 'model',
|
|
51
|
+
displayName: 'Model',
|
|
52
|
+
description: 'Show/change the model',
|
|
53
|
+
argumentHint: '[<id>]',
|
|
54
|
+
source: 'model',
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('provides the metadata and the executable command from one module owner', () => {
|
|
60
|
+
const module = createModelCommandModule(buildOptions());
|
|
61
|
+
expect(module.name).toBe('agent-command-model');
|
|
62
|
+
const entry = module.commandSources?.[0]?.getCommands()[0];
|
|
63
|
+
const command = module.systemCommands?.[0];
|
|
64
|
+
expect(entry?.name).toBe('model');
|
|
65
|
+
expect(command?.name).toBe('model');
|
|
66
|
+
expect(command?.lifecycle).toBe('inline');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('runs "/model <id>" through the SDK command executor', async () => {
|
|
70
|
+
const executor = new SystemCommandExecutor([
|
|
71
|
+
...(createModelCommandModule(buildOptions()).systemCommands ?? []),
|
|
72
|
+
]);
|
|
73
|
+
const host = createTestCommandHost({ session: { getModelId: () => 'claude-sonnet-4-6' } });
|
|
74
|
+
|
|
75
|
+
const result = await executor.execute('model', host, 'not-a-real-model');
|
|
76
|
+
|
|
77
|
+
expect(result?.success).toBe(false);
|
|
78
|
+
expect(result?.message).toBe('Unknown model "not-a-real-model". Run /model to see the choices.');
|
|
79
|
+
});
|
|
80
|
+
});
|