@robota-sdk/agent-command 3.0.0-beta.82 → 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 (94) hide show
  1. package/CHANGELOG.md +1071 -0
  2. package/README.md +71 -62
  3. package/dist/node/index.cjs +81 -72
  4. package/dist/node/index.d.cts +136 -52
  5. package/dist/node/index.d.cts.map +1 -1
  6. package/dist/node/index.d.ts +136 -52
  7. package/dist/node/index.d.ts.map +1 -1
  8. package/dist/node/index.js +79 -70
  9. package/dist/node/index.js.map +1 -1
  10. package/package.json +16 -10
  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/command-module-utils.test.ts +37 -0
  15. package/src/command-module-utils.ts +2 -0
  16. package/src/default/__tests__/__snapshots__/model-exposure.test.ts.snap +2 -2
  17. package/src/default/__tests__/default-command-modules.test.ts +54 -14
  18. package/src/default/__tests__/model-exposure.test.ts +18 -3
  19. package/src/default/__tests__/org-policy-forwarding.test.ts +10 -2
  20. package/src/default/default-command-modules.ts +15 -8
  21. package/src/devices/__tests__/devices-command-module.test.ts +99 -12
  22. package/src/devices/devices-command-module.ts +108 -24
  23. package/src/devices/devices-command-port.ts +62 -1
  24. package/src/devices/index.ts +3 -0
  25. package/src/doctor/doctor-node-deps.ts +6 -7
  26. package/src/editor/editor-command-module.ts +5 -0
  27. package/src/effort/effort-command.test.ts +65 -2
  28. package/src/effort/effort-command.ts +19 -5
  29. package/src/events/__tests__/events-command.test.ts +73 -0
  30. package/src/events/events-command-module.ts +49 -0
  31. package/src/events/events-command.ts +69 -0
  32. package/src/events/index.ts +2 -0
  33. package/src/git/__tests__/fake-git-port.ts +9 -2
  34. package/src/git/__tests__/git-command-module.test.ts +10 -2
  35. package/src/git/git-command-module.ts +3 -4
  36. package/src/git/git-commit.ts +6 -3
  37. package/src/git/index.ts +4 -1
  38. package/src/goal/goal-command.ts +3 -1
  39. package/src/handoff/__tests__/handoff-command-module.test.ts +2 -1
  40. package/src/handoff/handoff-command-module.ts +1 -1
  41. package/src/handoff/handoff-command.ts +2 -2
  42. package/src/help/__tests__/help-command-module.test.ts +3 -0
  43. package/src/help/__tests__/help-command.test.ts +3 -0
  44. package/src/index.ts +14 -0
  45. package/src/keybindings/keybindings-command-module.ts +17 -3
  46. package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +172 -0
  47. package/src/mcp-activation/mcp-activation-command-module.ts +6 -0
  48. package/src/mcp-activation/mcp-activation-command.ts +62 -4
  49. package/src/memory/__tests__/memory-command-module.test.ts +40 -2
  50. package/src/memory/__tests__/memory-command-unavailable.test.ts +63 -0
  51. package/src/memory/memory-command-module.ts +4 -2
  52. package/src/memory/memory-command.ts +42 -2
  53. package/src/mode/__tests__/mode-command-module.test.ts +40 -0
  54. package/src/mode/mode-command.ts +1 -1
  55. package/src/model/__tests__/model-command-module.test.ts +80 -0
  56. package/src/model/__tests__/model-command.test.ts +240 -0
  57. package/src/model/index.ts +6 -0
  58. package/src/model/model-command-module.ts +56 -0
  59. package/src/model/model-command.ts +223 -0
  60. package/src/peers/__tests__/peers-command-module.test.ts +24 -0
  61. package/src/peers/__tests__/peers-command.test.ts +78 -0
  62. package/src/peers/peers-command-module.ts +1 -1
  63. package/src/peers/peers-command.ts +33 -4
  64. package/src/plugin/__tests__/plugin-command-module.test.ts +81 -1
  65. package/src/plugin/plugin-command.ts +47 -4
  66. package/src/provider/__tests__/org-policy.test.ts +4 -0
  67. package/src/provider/__tests__/provider-command-module.test.ts +276 -14
  68. package/src/provider/__tests__/scripted-interaction.ts +12 -2
  69. package/src/provider/provider-command-execution.ts +54 -8
  70. package/src/provider/provider-command-profile-lifecycle.ts +48 -2
  71. package/src/provider/provider-command-profile-operations.ts +14 -0
  72. package/src/provider/provider-command-profile.ts +7 -4
  73. package/src/provider/provider-command-setup.ts +14 -1
  74. package/src/rewind/__tests__/rewind-command-module.test.ts +32 -1
  75. package/src/rewind/rewind-command.ts +23 -2
  76. package/src/schedule/__tests__/loop-command-functional.test.ts +13 -0
  77. package/src/schedule/__tests__/loop-command.test.ts +238 -4
  78. package/src/schedule/__tests__/schedule-redos.test.ts +33 -0
  79. package/src/schedule/loop-command.ts +98 -4
  80. package/src/schedule/schedule-command-module.ts +8 -4
  81. package/src/settings/settings-command-module.ts +2 -2
  82. package/src/shell/shell-command-module.ts +5 -0
  83. package/src/terminal-client/__tests__/terminal-client-commands.test.ts +216 -0
  84. package/src/terminal-client/index.ts +5 -0
  85. package/src/terminal-client/terminal-client-commands.ts +109 -0
  86. package/src/theme/__tests__/theme-command.test.ts +1 -1
  87. package/src/theme/theme-command-module.ts +12 -2
  88. package/src/user-local/__tests__/user-local-command.test.ts +2 -2
  89. package/src/git/__tests__/git-diff.test.ts +0 -125
  90. package/src/git/__tests__/git-process.test.ts +0 -221
  91. package/src/git/__tests__/git-status.test.ts +0 -117
  92. package/src/git/git-diff.ts +0 -129
  93. package/src/git/git-process.ts +0 -169
  94. package/src/git/git-status.ts +0 -134
@@ -484,6 +484,47 @@ describe('/mcp login', () => {
484
484
  expect(h.added).toEqual([]);
485
485
  });
486
486
 
487
+ it('asks nothing for a sign-in that has already ended', async () => {
488
+ const ended = new AbortController();
489
+ ended.abort();
490
+ let choice: string | undefined;
491
+ const h = harness(
492
+ async (request) => {
493
+ choice = await request.confirmBrowser!(PROMPT, ended.signal);
494
+ await expect(request.readRedirect!(PROMPT, ended.signal)).rejects.toThrow();
495
+ return {
496
+ serverId: request.serverId,
497
+ failure: 'cancelled',
498
+ preRegisteredClient: false,
499
+ tools: [],
500
+ };
501
+ },
502
+ { type: 'answer', values: ['open'], text: 'http://127.0.0.1:1/callback?code=c&state=s' },
503
+ );
504
+ await h.run('login files');
505
+ expect(choice).toBe('cancel');
506
+ expect(h.asked).toEqual([]);
507
+ });
508
+
509
+ it('never opens the browser for a sign-in that ended while the user was choosing', async () => {
510
+ const signIn = new AbortController();
511
+ let choice: string | undefined;
512
+ const h = harness(
513
+ async (request) => {
514
+ choice = await request.confirmBrowser!(PROMPT, signIn.signal);
515
+ return signedIn(request.serverId);
516
+ },
517
+ () => {
518
+ // The sign-in times out while the question is still open; the user then picks `open`.
519
+ signIn.abort();
520
+ return { type: 'answer', values: ['open'] };
521
+ },
522
+ );
523
+ await h.run('login files');
524
+ expect(h.asked).toHaveLength(1);
525
+ expect(choice).toBe('cancel');
526
+ });
527
+
487
528
  it('never asks for a client secret, and names the terminal command instead', async () => {
488
529
  const h = harness(async (request) => signedIn(request.serverId));
489
530
  const result = await h.run('login files --client-secret');
@@ -614,6 +655,137 @@ describe('/mcp login', () => {
614
655
  });
615
656
  });
616
657
 
658
+ describe('/mcp reload (#3282 §4 part b-2)', () => {
659
+ function harness(
660
+ reload: ICommandMCPActivationAdapter['reload'],
661
+ taken: (names: string[]) => string[] = (names) => names,
662
+ ) {
663
+ const added: IToolWithEventService[][] = [];
664
+ const reloadToolsAddedCalls: { token: string; added: readonly string[] }[] = [];
665
+ const port: ICommandMCPActivationAdapter = {
666
+ list: () => [],
667
+ approve: async () => summary(),
668
+ reject: async () => summary(),
669
+ revoke: async () => summary(),
670
+ reload,
671
+ reloadToolsAdded: (token, names) => reloadToolsAddedCalls.push({ token, added: names }),
672
+ };
673
+ const host = createTestCommandHost({
674
+ overrides: { getCommandHostAdapters: () => ({ mcpActivation: port }) },
675
+ });
676
+ const session = host.getSession();
677
+ host.getSession = () => ({
678
+ ...session,
679
+ addTools: async (tools) => {
680
+ added.push([...tools]);
681
+ return taken(tools.map((tool) => tool.schema.name));
682
+ },
683
+ });
684
+ return {
685
+ added,
686
+ reloadToolsAddedCalls,
687
+ run: (args: string) => executeMCPActivationCommand(host, args),
688
+ };
689
+ }
690
+
691
+ const forecast = new FunctionTool(
692
+ { name: 'weather__forecast', description: 'Forecast', parameters: { type: 'object', properties: {} } },
693
+ async () => 'sunny',
694
+ );
695
+
696
+ it('retries not-connected servers, adds the new tools to the session, and names what changed', async () => {
697
+ const h = harness(async () => ({
698
+ tools: [forecast],
699
+ connectedServerIds: ['weather'],
700
+ failedServerIds: ['flaky'],
701
+ reloadToken: 'token-1',
702
+ }));
703
+
704
+ const result = await h.run('reload');
705
+
706
+ expect(result.success).toBe(true);
707
+ expect(result.message).toContain('Connected: weather.');
708
+ expect(result.message).toContain('Still not connected: flaky');
709
+ expect(h.added).toEqual([[forecast]]);
710
+ expect(result.data).toEqual({
711
+ connectedServerIds: ['weather'],
712
+ failedServerIds: ['flaky'],
713
+ tools: ['weather__forecast'],
714
+ });
715
+ });
716
+
717
+ it('says plainly when nothing newly connected', async () => {
718
+ const h = harness(async () => ({ tools: [], connectedServerIds: [], failedServerIds: [] }));
719
+ const result = await h.run('reload');
720
+ expect(result.success).toBe(true);
721
+ expect(result.message).toContain('No server newly connected.');
722
+ });
723
+
724
+ it('is refused when the host offers no reload', async () => {
725
+ const h = harness(undefined);
726
+ const result = await h.run('reload');
727
+ expect(result.success).toBe(false);
728
+ expect(result.message).toBe('Reloading MCP servers is not available in this environment.');
729
+ });
730
+
731
+ it('tells the port which reloaded tools the session actually took, keyed to the reload it came from', async () => {
732
+ const h = harness(
733
+ async () => ({
734
+ tools: [forecast],
735
+ connectedServerIds: ['weather'],
736
+ failedServerIds: [],
737
+ reloadToken: 'token-1',
738
+ }),
739
+ (names) => names,
740
+ );
741
+
742
+ const result = await h.run('reload');
743
+
744
+ expect(result.success).toBe(true);
745
+ expect(h.reloadToolsAddedCalls).toEqual([{ token: 'token-1', added: ['weather__forecast'] }]);
746
+ });
747
+
748
+ it('tells the port when a reloaded tool collided and was dropped', async () => {
749
+ const h = harness(
750
+ async () => ({
751
+ tools: [forecast],
752
+ connectedServerIds: ['weather'],
753
+ failedServerIds: [],
754
+ reloadToken: 'token-1',
755
+ }),
756
+ () => [],
757
+ );
758
+
759
+ const result = await h.run('reload');
760
+
761
+ expect(result.success).toBe(true);
762
+ expect(h.reloadToolsAddedCalls).toEqual([{ token: 'token-1', added: [] }]);
763
+ });
764
+
765
+ it('never calls it when the reload has no token to acknowledge (nothing staged)', async () => {
766
+ // A defensive case: a host that returned tools without a token (should not happen in practice,
767
+ // since the composition only omits one when there is nothing to stage) must not be acknowledged
768
+ // — there would be nothing for the token to correlate to.
769
+ const h = harness(async () => ({
770
+ tools: [forecast],
771
+ connectedServerIds: ['weather'],
772
+ failedServerIds: [],
773
+ }));
774
+
775
+ await h.run('reload');
776
+
777
+ expect(h.reloadToolsAddedCalls).toEqual([]);
778
+ });
779
+
780
+ it('never calls it when nothing newly connected', async () => {
781
+ const h = harness(async () => ({ tools: [], connectedServerIds: [], failedServerIds: [] }));
782
+
783
+ await h.run('reload');
784
+
785
+ expect(h.reloadToolsAddedCalls).toEqual([]);
786
+ });
787
+ });
788
+
617
789
  describe('/mcp command entry', () => {
618
790
  it('opens only `status` to the model, and describes login for the user', () => {
619
791
  const entry = createMCPActivationCommandEntry();
@@ -35,6 +35,12 @@ export function createMCPActivationCommandEntry(): ICommand {
35
35
  source: 'mcp-activation',
36
36
  modelInvocable: true,
37
37
  },
38
+ {
39
+ name: 'reload',
40
+ description: 'Retry connecting every MCP server that is not currently connected',
41
+ source: 'mcp-activation',
42
+ modelInvocable: false,
43
+ },
38
44
  ...(['approve', 'reject', 'revoke', 'login', 'logout'] as const).map((verb) => ({
39
45
  name: verb,
40
46
  description: USER_ONLY_VERB_DESCRIPTION[verb],
@@ -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. */
@@ -259,7 +312,10 @@ function browserConfirmer(
259
312
  ): ICommandMCPOAuthLoginRequest['confirmBrowser'] {
260
313
  const ui = context.getUserInteraction();
261
314
  if (ui === undefined) return undefined;
262
- return async (prompt) => {
315
+ return async (prompt, signal) => {
316
+ // The ask port cannot withdraw a question, so a sign-in that has already ended is not asked
317
+ // about, and an answer that arrives after it ended never opens a browser for it.
318
+ if (signal.aborted) return 'cancel';
263
319
  loginPrompts += 1;
264
320
  const answer = await ui.ask({
265
321
  id: `mcp-login-browser-${loginPrompts}`,
@@ -275,7 +331,7 @@ function browserConfirmer(
275
331
  maxSelect: 1,
276
332
  default: { values: ['open'] },
277
333
  });
278
- if (answer.type !== 'answer') return 'cancel';
334
+ if (answer.type !== 'answer' || signal.aborted) return 'cancel';
279
335
  const choice = answer.values[0];
280
336
  return choice === 'open' || choice === 'paste' ? choice : 'cancel';
281
337
  };
@@ -288,7 +344,9 @@ function redirectReader(
288
344
  ): ICommandMCPOAuthLoginRequest['readRedirect'] {
289
345
  const ui = context.getUserInteraction();
290
346
  if (ui === undefined) return undefined;
291
- return async (prompt) => {
347
+ return async (prompt, signal) => {
348
+ // A sign-in that has already ended (cancelled or timed out) does not ask for a paste.
349
+ signal.throwIfAborted();
292
350
  loginPrompts += 1;
293
351
  const answer = await ui.ask({
294
352
  id: `mcp-login-redirect-${loginPrompts}`,
@@ -101,10 +101,15 @@ function createMockRuntimeSession() {
101
101
  };
102
102
  }
103
103
 
104
- async function createInteractiveSession(cwd = makeProject()): Promise<InteractiveSession> {
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: createMockRuntimeSession() as never,
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. Returns the requested topics or entries, or a confirmation of what was saved. Approving ' +
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 store = createCommandMemoryStores(context);
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 ?? []),
@@ -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));