@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.
- package/CHANGELOG.md +1071 -0
- package/README.md +71 -62
- package/dist/node/index.cjs +81 -72
- package/dist/node/index.d.cts +136 -52
- package/dist/node/index.d.cts.map +1 -1
- package/dist/node/index.d.ts +136 -52
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +79 -70
- package/dist/node/index.js.map +1 -1
- package/package.json +16 -10
- 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/command-module-utils.test.ts +37 -0
- package/src/command-module-utils.ts +2 -0
- package/src/default/__tests__/__snapshots__/model-exposure.test.ts.snap +2 -2
- package/src/default/__tests__/default-command-modules.test.ts +54 -14
- package/src/default/__tests__/model-exposure.test.ts +18 -3
- package/src/default/__tests__/org-policy-forwarding.test.ts +10 -2
- package/src/default/default-command-modules.ts +15 -8
- package/src/devices/__tests__/devices-command-module.test.ts +99 -12
- package/src/devices/devices-command-module.ts +108 -24
- package/src/devices/devices-command-port.ts +62 -1
- package/src/devices/index.ts +3 -0
- package/src/doctor/doctor-node-deps.ts +6 -7
- package/src/editor/editor-command-module.ts +5 -0
- package/src/effort/effort-command.test.ts +65 -2
- package/src/effort/effort-command.ts +19 -5
- 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/git/__tests__/fake-git-port.ts +9 -2
- package/src/git/__tests__/git-command-module.test.ts +10 -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/handoff/__tests__/handoff-command-module.test.ts +2 -1
- package/src/handoff/handoff-command-module.ts +1 -1
- package/src/handoff/handoff-command.ts +2 -2
- package/src/help/__tests__/help-command-module.test.ts +3 -0
- package/src/help/__tests__/help-command.test.ts +3 -0
- package/src/index.ts +14 -0
- package/src/keybindings/keybindings-command-module.ts +17 -3
- package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +172 -0
- package/src/mcp-activation/mcp-activation-command-module.ts +6 -0
- package/src/mcp-activation/mcp-activation-command.ts +62 -4
- 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/peers/__tests__/peers-command-module.test.ts +24 -0
- package/src/peers/__tests__/peers-command.test.ts +78 -0
- package/src/peers/peers-command-module.ts +1 -1
- package/src/peers/peers-command.ts +33 -4
- 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 +32 -1
- package/src/rewind/rewind-command.ts +23 -2
- package/src/schedule/__tests__/loop-command-functional.test.ts +13 -0
- package/src/schedule/__tests__/loop-command.test.ts +238 -4
- package/src/schedule/__tests__/schedule-redos.test.ts +33 -0
- package/src/schedule/loop-command.ts +98 -4
- package/src/schedule/schedule-command-module.ts +8 -4
- package/src/settings/settings-command-module.ts +2 -2
- package/src/shell/shell-command-module.ts +5 -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 +1 -1
- package/src/theme/theme-command-module.ts +12 -2
- package/src/user-local/__tests__/user-local-command.test.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
|
@@ -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(
|
|
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));
|