@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.
- package/CHANGELOG.md +69 -0
- package/README.md +71 -62
- package/dist/node/index.cjs +75 -72
- package/dist/node/index.d.cts +37 -45
- package/dist/node/index.d.cts.map +1 -1
- package/dist/node/index.d.ts +37 -45
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +78 -75
- package/dist/node/index.js.map +1 -1
- package/package.json +9 -8
- package/src/agent/__tests__/agent-command.test.ts +62 -0
- package/src/agent/agent-command-parser.ts +2 -2
- package/src/agent/agent-command.ts +20 -0
- package/src/default/__tests__/__snapshots__/model-exposure.test.ts.snap +2 -2
- package/src/default/__tests__/default-command-modules.test.ts +5 -4
- package/src/default/__tests__/model-exposure.test.ts +2 -0
- package/src/default/default-command-modules.ts +8 -0
- package/src/doctor/__tests__/doctor-fixture.ts +1 -0
- package/src/doctor/__tests__/doctor-runner.test.ts +26 -6
- package/src/doctor/doctor-extensions-probe.ts +36 -12
- package/src/effort/effort-command.test.ts +65 -2
- package/src/effort/effort-command.ts +19 -5
- package/src/git/__tests__/fake-git-port.ts +9 -2
- package/src/git/git-command-module.ts +3 -4
- package/src/git/git-commit.ts +6 -3
- package/src/git/index.ts +4 -1
- package/src/goal/goal-command.ts +3 -1
- package/src/index.ts +6 -0
- package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +225 -1
- package/src/mcp-activation/mcp-activation-command-module.ts +6 -0
- package/src/mcp-activation/mcp-activation-command.ts +77 -2
- package/src/memory/__tests__/memory-command-module.test.ts +40 -2
- package/src/memory/__tests__/memory-command-unavailable.test.ts +63 -0
- package/src/memory/memory-command-module.ts +4 -2
- package/src/memory/memory-command.ts +42 -2
- package/src/mode/__tests__/mode-command-module.test.ts +40 -0
- package/src/mode/mode-command.ts +1 -1
- package/src/model/__tests__/model-command-module.test.ts +80 -0
- package/src/model/__tests__/model-command.test.ts +240 -0
- package/src/model/index.ts +6 -0
- package/src/model/model-command-module.ts +56 -0
- package/src/model/model-command.ts +223 -0
- package/src/plugin/__tests__/plugin-command-module.test.ts +84 -3
- package/src/plugin/plugin-command.ts +50 -8
- package/src/provider/__tests__/org-policy.test.ts +4 -0
- package/src/provider/__tests__/provider-command-module.test.ts +276 -14
- package/src/provider/__tests__/scripted-interaction.ts +12 -2
- package/src/provider/provider-command-execution.ts +54 -8
- package/src/provider/provider-command-profile-lifecycle.ts +48 -2
- package/src/provider/provider-command-profile-operations.ts +14 -0
- package/src/provider/provider-command-profile.ts +7 -4
- package/src/provider/provider-command-setup.ts +14 -1
- package/src/rewind/__tests__/rewind-command-module.test.ts +2 -1
- package/src/schedule/__tests__/loop-command-functional.test.ts +13 -0
- package/src/schedule/__tests__/loop-command.test.ts +238 -4
- package/src/schedule/loop-command.ts +95 -3
- package/src/schedule/schedule-command-module.ts +8 -4
- package/src/settings/settings-command-module.ts +2 -2
- package/src/git/__tests__/git-diff.test.ts +0 -125
- package/src/git/__tests__/git-process.test.ts +0 -221
- package/src/git/__tests__/git-status.test.ts +0 -117
- package/src/git/git-diff.ts +0 -129
- package/src/git/git-process.ts +0 -169
- package/src/git/git-status.ts +0 -134
|
@@ -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
|
-
|
|
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(
|
|
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));
|