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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +71 -62
  3. package/dist/node/index.cjs +75 -72
  4. package/dist/node/index.d.cts +37 -45
  5. package/dist/node/index.d.cts.map +1 -1
  6. package/dist/node/index.d.ts +37 -45
  7. package/dist/node/index.d.ts.map +1 -1
  8. package/dist/node/index.js +78 -75
  9. package/dist/node/index.js.map +1 -1
  10. package/package.json +9 -8
  11. package/src/agent/__tests__/agent-command.test.ts +62 -0
  12. package/src/agent/agent-command-parser.ts +2 -2
  13. package/src/agent/agent-command.ts +20 -0
  14. package/src/default/__tests__/__snapshots__/model-exposure.test.ts.snap +2 -2
  15. package/src/default/__tests__/default-command-modules.test.ts +5 -4
  16. package/src/default/__tests__/model-exposure.test.ts +2 -0
  17. package/src/default/default-command-modules.ts +8 -0
  18. package/src/doctor/__tests__/doctor-fixture.ts +1 -0
  19. package/src/doctor/__tests__/doctor-runner.test.ts +26 -6
  20. package/src/doctor/doctor-extensions-probe.ts +36 -12
  21. package/src/effort/effort-command.test.ts +65 -2
  22. package/src/effort/effort-command.ts +19 -5
  23. package/src/git/__tests__/fake-git-port.ts +9 -2
  24. package/src/git/git-command-module.ts +3 -4
  25. package/src/git/git-commit.ts +6 -3
  26. package/src/git/index.ts +4 -1
  27. package/src/goal/goal-command.ts +3 -1
  28. package/src/index.ts +6 -0
  29. package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +225 -1
  30. package/src/mcp-activation/mcp-activation-command-module.ts +6 -0
  31. package/src/mcp-activation/mcp-activation-command.ts +77 -2
  32. package/src/memory/__tests__/memory-command-module.test.ts +40 -2
  33. package/src/memory/__tests__/memory-command-unavailable.test.ts +63 -0
  34. package/src/memory/memory-command-module.ts +4 -2
  35. package/src/memory/memory-command.ts +42 -2
  36. package/src/mode/__tests__/mode-command-module.test.ts +40 -0
  37. package/src/mode/mode-command.ts +1 -1
  38. package/src/model/__tests__/model-command-module.test.ts +80 -0
  39. package/src/model/__tests__/model-command.test.ts +240 -0
  40. package/src/model/index.ts +6 -0
  41. package/src/model/model-command-module.ts +56 -0
  42. package/src/model/model-command.ts +223 -0
  43. package/src/plugin/__tests__/plugin-command-module.test.ts +84 -3
  44. package/src/plugin/plugin-command.ts +50 -8
  45. package/src/provider/__tests__/org-policy.test.ts +4 -0
  46. package/src/provider/__tests__/provider-command-module.test.ts +276 -14
  47. package/src/provider/__tests__/scripted-interaction.ts +12 -2
  48. package/src/provider/provider-command-execution.ts +54 -8
  49. package/src/provider/provider-command-profile-lifecycle.ts +48 -2
  50. package/src/provider/provider-command-profile-operations.ts +14 -0
  51. package/src/provider/provider-command-profile.ts +7 -4
  52. package/src/provider/provider-command-setup.ts +14 -1
  53. package/src/rewind/__tests__/rewind-command-module.test.ts +2 -1
  54. package/src/schedule/__tests__/loop-command-functional.test.ts +13 -0
  55. package/src/schedule/__tests__/loop-command.test.ts +238 -4
  56. package/src/schedule/loop-command.ts +95 -3
  57. package/src/schedule/schedule-command-module.ts +8 -4
  58. package/src/settings/settings-command-module.ts +2 -2
  59. package/src/git/__tests__/git-diff.test.ts +0 -125
  60. package/src/git/__tests__/git-process.test.ts +0 -221
  61. package/src/git/__tests__/git-status.test.ts +0 -117
  62. package/src/git/git-diff.ts +0 -129
  63. package/src/git/git-process.ts +0 -169
  64. package/src/git/git-status.ts +0 -134
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from 'vitest';
1
+ import { describe, expect, it, vi } from 'vitest';
2
2
 
3
3
  import { createTestCommandHost } from '@robota-sdk/agent-framework/testing';
4
4
 
@@ -655,6 +655,230 @@ describe('/mcp login', () => {
655
655
  });
656
656
  });
657
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
+ approve: ICommandMCPActivationAdapter['approve'] = async () => summary(),
663
+ ) {
664
+ const added: IToolWithEventService[][] = [];
665
+ const reloadToolsAddedCalls: { token: string; added: readonly string[] }[] = [];
666
+ const port: ICommandMCPActivationAdapter = {
667
+ list: () => [],
668
+ approve,
669
+ reject: async () => summary({ status: 'rejected', allowed: false }),
670
+ revoke: async () => summary(),
671
+ reload,
672
+ reloadToolsAdded: (token, names) => reloadToolsAddedCalls.push({ token, added: names }),
673
+ };
674
+ const host = createTestCommandHost({
675
+ overrides: { getCommandHostAdapters: () => ({ mcpActivation: port }) },
676
+ });
677
+ const session = host.getSession();
678
+ host.getSession = () => ({
679
+ ...session,
680
+ addTools: async (tools) => {
681
+ added.push([...tools]);
682
+ return taken(tools.map((tool) => tool.schema.name));
683
+ },
684
+ });
685
+ return {
686
+ added,
687
+ reloadToolsAddedCalls,
688
+ run: (args: string) => executeMCPActivationCommand(host, args),
689
+ };
690
+ }
691
+
692
+ const forecast = new FunctionTool(
693
+ {
694
+ name: 'weather__forecast',
695
+ description: 'Forecast',
696
+ parameters: { type: 'object', properties: {} },
697
+ },
698
+ async () => 'sunny',
699
+ );
700
+
701
+ it('retries not-connected servers, adds the new tools to the session, and names what changed', async () => {
702
+ const h = harness(async () => ({
703
+ tools: [forecast],
704
+ connectedServerIds: ['weather'],
705
+ failedServerIds: ['flaky'],
706
+ reloadToken: 'token-1',
707
+ }));
708
+
709
+ const result = await h.run('reload');
710
+
711
+ expect(result.success).toBe(true);
712
+ expect(result.message).toContain('Connected: weather.');
713
+ expect(result.message).toContain('Still not connected: flaky');
714
+ expect(h.added).toEqual([[forecast]]);
715
+ expect(result.data).toEqual({
716
+ connectedServerIds: ['weather'],
717
+ failedServerIds: ['flaky'],
718
+ tools: ['weather__forecast'],
719
+ });
720
+ });
721
+
722
+ it('says plainly when nothing newly connected', async () => {
723
+ const h = harness(async () => ({ tools: [], connectedServerIds: [], failedServerIds: [] }));
724
+ const result = await h.run('reload');
725
+ expect(result.success).toBe(true);
726
+ expect(result.message).toContain('No server newly connected.');
727
+ });
728
+
729
+ it('an approval connects the server in the running session', async () => {
730
+ const reload = vi.fn(async () => ({
731
+ tools: [forecast],
732
+ connectedServerIds: ['weather'],
733
+ failedServerIds: [],
734
+ reloadToken: 'token-1',
735
+ }));
736
+ const h = harness(reload, undefined, async () =>
737
+ summary({ serverId: 'weather', status: 'approved', allowed: true, reason: 'Approved.' }),
738
+ );
739
+
740
+ const result = await h.run('approve weather');
741
+
742
+ expect(reload).toHaveBeenCalledOnce();
743
+ expect(result.success).toBe(true);
744
+ expect(result.message).toContain('is now approved');
745
+ expect(result.message).toContain('Connected: weather.');
746
+ expect(h.added).toEqual([[forecast]]);
747
+ });
748
+
749
+ it('acknowledges an empty-tool connection before approving another server', async () => {
750
+ let released = false;
751
+ const h = harness(
752
+ async () =>
753
+ released
754
+ ? {
755
+ tools: [forecast],
756
+ connectedServerIds: ['weather'],
757
+ failedServerIds: [],
758
+ reloadToken: 'second',
759
+ }
760
+ : { tools: [], connectedServerIds: ['empty'], failedServerIds: [], reloadToken: 'first' },
761
+ undefined,
762
+ async (id) => summary({ serverId: id, status: 'approved', allowed: true }),
763
+ );
764
+ await h.run('approve empty');
765
+ released = h.reloadToolsAddedCalls.some((call) => call.token === 'first');
766
+ const result = await h.run('approve weather');
767
+ expect(result.message).toContain('Connected: weather.');
768
+ expect(h.reloadToolsAddedCalls).toEqual([
769
+ { token: 'first', added: [] },
770
+ { token: 'second', added: ['weather__forecast'] },
771
+ ]);
772
+ });
773
+
774
+ it('preserves a saved approval when connecting fails, and suggests reload', async () => {
775
+ const h = harness(
776
+ async () => {
777
+ throw new Error('connection failed');
778
+ },
779
+ undefined,
780
+ async () => summary({ status: 'approved', allowed: true }),
781
+ );
782
+ const result = await h.run('approve weather');
783
+ expect(result.success).toBe(true);
784
+ expect(result.data).toMatchObject({ status: 'approved', allowed: true });
785
+ expect(result.message).toContain('connection failed');
786
+ expect(result.message).toContain('/mcp reload');
787
+ });
788
+
789
+ it('releases a staged reload even when adding tools throws', async () => {
790
+ const h = harness(
791
+ async () => ({
792
+ tools: [forecast],
793
+ connectedServerIds: ['weather'],
794
+ failedServerIds: [],
795
+ reloadToken: 'token-failed',
796
+ }),
797
+ () => {
798
+ throw new Error('cannot add tools');
799
+ },
800
+ async () => summary({ status: 'approved', allowed: true }),
801
+ );
802
+ const result = await h.run('approve weather');
803
+ expect(result.success).toBe(true);
804
+ expect(h.reloadToolsAddedCalls).toEqual([{ token: 'token-failed', added: [] }]);
805
+ });
806
+
807
+ it('a decision that is not an approval connects nothing', async () => {
808
+ const reload = vi.fn(async () => ({ tools: [], connectedServerIds: [], failedServerIds: [] }));
809
+ const h = harness(reload);
810
+
811
+ await h.run('reject weather');
812
+ await h.run('approve weather');
813
+
814
+ expect(reload).not.toHaveBeenCalled();
815
+ });
816
+
817
+ it('is refused when the host offers no reload', async () => {
818
+ const h = harness(undefined);
819
+ const result = await h.run('reload');
820
+ expect(result.success).toBe(false);
821
+ expect(result.message).toBe('Reloading MCP servers is not available in this environment.');
822
+ });
823
+
824
+ it('tells the port which reloaded tools the session actually took, keyed to the reload it came from', async () => {
825
+ const h = harness(
826
+ async () => ({
827
+ tools: [forecast],
828
+ connectedServerIds: ['weather'],
829
+ failedServerIds: [],
830
+ reloadToken: 'token-1',
831
+ }),
832
+ (names) => names,
833
+ );
834
+
835
+ const result = await h.run('reload');
836
+
837
+ expect(result.success).toBe(true);
838
+ expect(h.reloadToolsAddedCalls).toEqual([{ token: 'token-1', added: ['weather__forecast'] }]);
839
+ });
840
+
841
+ it('tells the port when a reloaded tool collided and was dropped', async () => {
842
+ const h = harness(
843
+ async () => ({
844
+ tools: [forecast],
845
+ connectedServerIds: ['weather'],
846
+ failedServerIds: [],
847
+ reloadToken: 'token-1',
848
+ }),
849
+ () => [],
850
+ );
851
+
852
+ const result = await h.run('reload');
853
+
854
+ expect(result.success).toBe(true);
855
+ expect(h.reloadToolsAddedCalls).toEqual([{ token: 'token-1', added: [] }]);
856
+ });
857
+
858
+ it('never calls it when the reload has no token to acknowledge (nothing staged)', async () => {
859
+ // A defensive case: a host that returned tools without a token (should not happen in practice,
860
+ // since the composition only omits one when there is nothing to stage) must not be acknowledged
861
+ // — there would be nothing for the token to correlate to.
862
+ const h = harness(async () => ({
863
+ tools: [forecast],
864
+ connectedServerIds: ['weather'],
865
+ failedServerIds: [],
866
+ }));
867
+
868
+ await h.run('reload');
869
+
870
+ expect(h.reloadToolsAddedCalls).toEqual([]);
871
+ });
872
+
873
+ it('never calls it when nothing newly connected', async () => {
874
+ const h = harness(async () => ({ tools: [], connectedServerIds: [], failedServerIds: [] }));
875
+
876
+ await h.run('reload');
877
+
878
+ expect(h.reloadToolsAddedCalls).toEqual([]);
879
+ });
880
+ });
881
+
658
882
  describe('/mcp command entry', () => {
659
883
  it('opens only `status` to the model, and describes login for the user', () => {
660
884
  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 };
@@ -205,7 +213,7 @@ export async function executeMCPActivationCommand(
205
213
 
206
214
  try {
207
215
  const result = await mcp[verb](serverId);
208
- return {
216
+ const decided = {
209
217
  message: `MCP server ${serverId} is now ${result.status}. ${result.reason}`,
210
218
  success:
211
219
  result.status === 'approved' || result.status === 'rejected' || result.status === 'revoked',
@@ -219,6 +227,24 @@ export async function executeMCPActivationCommand(
219
227
  securityIdentity: result.securityIdentity,
220
228
  },
221
229
  };
230
+ // An approval takes effect now: the session connects the server as `/mcp reload` would.
231
+ if (verb !== 'approve' || result.status !== 'approved' || mcp.reload === undefined) {
232
+ return decided;
233
+ }
234
+ try {
235
+ const reloaded = await reloadResult(context);
236
+ return {
237
+ ...decided,
238
+ message: `${decided.message} ${reloaded.message}`,
239
+ data: { ...decided.data, reload: reloaded.data ?? {} },
240
+ };
241
+ } catch (error) {
242
+ const reason = error instanceof Error ? error.message : String(error);
243
+ return {
244
+ ...decided,
245
+ message: `${decided.message} Could not connect now: ${reason}. Run /mcp reload to retry.`,
246
+ };
247
+ }
222
248
  } catch (error) {
223
249
  return {
224
250
  message: error instanceof Error ? error.message : String(error),
@@ -250,6 +276,55 @@ async function logoutResult(
250
276
  };
251
277
  }
252
278
 
279
+ /**
280
+ * #3282 §4 part b-2: `/mcp reload` and the Settings screen's "Reload servers" button — retries
281
+ * every server not currently connected and adds whatever tools it now offers to the live session,
282
+ * the same way a completed sign-in does.
283
+ */
284
+ async function reloadResult(context: TMCPActivationCommandContext): Promise<ICommandResult> {
285
+ const mcp = adapter(context);
286
+ if (!mcp) {
287
+ return {
288
+ message: 'MCP activation management is not available in this environment.',
289
+ success: true,
290
+ };
291
+ }
292
+ if (mcp.reload === undefined) {
293
+ return {
294
+ message: 'Reloading MCP servers is not available in this environment.',
295
+ success: false,
296
+ };
297
+ }
298
+ const result = await mcp.reload();
299
+ let added: readonly string[] = [];
300
+ try {
301
+ added = result.tools.length === 0 ? [] : await context.getSession().addTools(result.tools);
302
+ } finally {
303
+ if (result.reloadToken !== undefined) mcp.reloadToolsAdded?.(result.reloadToken, added);
304
+ }
305
+ const dropped = result.tools.length - added.length;
306
+ const parts = [
307
+ result.connectedServerIds.length === 0
308
+ ? 'No server newly connected.'
309
+ : `Connected: ${result.connectedServerIds.join(', ')}.`,
310
+ result.failedServerIds.length === 0
311
+ ? undefined
312
+ : `Still not connected: ${result.failedServerIds.join(', ')} (see /mcp status).`,
313
+ dropped === 0
314
+ ? undefined
315
+ : `${dropped} of the new tools ${dropped === 1 ? 'was' : 'were'} left out: the session already has a tool by that name.`,
316
+ ].filter((part): part is string => part !== undefined);
317
+ return {
318
+ message: `Reloaded MCP servers. ${parts.join(' ')}`.trim(),
319
+ success: true,
320
+ data: {
321
+ connectedServerIds: result.connectedServerIds,
322
+ failedServerIds: result.failedServerIds,
323
+ tools: [...added],
324
+ },
325
+ };
326
+ }
327
+
253
328
  let loginPrompts = 0;
254
329
 
255
330
  /** 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(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));