@robota-sdk/agent-command 3.0.0-beta.84 → 3.0.0-beta.86

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robota-sdk/agent-command",
3
- "version": "3.0.0-beta.84",
3
+ "version": "3.0.0-beta.86",
4
4
  "description": "Slash-command modules for Robota SDK sessions (/help, /permissions, /mcp, /rewind, …)",
5
5
  "type": "module",
6
6
  "main": "dist/node/index.js",
@@ -40,12 +40,12 @@
40
40
  "CHANGELOG.md"
41
41
  ],
42
42
  "dependencies": {
43
- "@robota-sdk/agent-core": "3.0.0-beta.84",
44
- "@robota-sdk/agent-framework": "3.0.0-beta.84",
45
- "@robota-sdk/agent-interface-command": "3.0.0-beta.84",
46
- "@robota-sdk/agent-interface-execution": "3.0.0-beta.84",
47
- "@robota-sdk/agent-interface-session": "3.0.0-beta.84",
48
- "@robota-sdk/agent-preset": "3.0.0-beta.84"
43
+ "@robota-sdk/agent-core": "3.0.0-beta.86",
44
+ "@robota-sdk/agent-framework": "3.0.0-beta.86",
45
+ "@robota-sdk/agent-interface-command": "3.0.0-beta.86",
46
+ "@robota-sdk/agent-interface-execution": "3.0.0-beta.86",
47
+ "@robota-sdk/agent-interface-session": "3.0.0-beta.86",
48
+ "@robota-sdk/agent-preset": "3.0.0-beta.86"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@types/node": "^22.19.21",
@@ -139,6 +139,7 @@ export function installBrokenHome(fixture: IDoctorFixture): void {
139
139
  JSON.stringify({
140
140
  mcpServers: {
141
141
  ghost: { command: 'robota-doctor-missing-binary', env: { GHOST_TOKEN: MARKERS.mcp } },
142
+ ready: { type: 'http', url: 'https://mcp.example.test' },
142
143
  },
143
144
  }),
144
145
  );
@@ -53,13 +53,16 @@ describe('runDoctor (OBSERVABILITY-1991)', () => {
53
53
  expect(byId(neutral, 'provider.resolution').detail?.join(' ')).not.toMatch(/robota/i);
54
54
  expect(byId(neutral, 'workspace.trust').detail?.join(' ')).not.toMatch(/robota/i);
55
55
 
56
- const hosted = await runDoctor({
57
- ...f.inputs,
58
- diagnosticGuidance: {
59
- providerResolution: 'Run atlas configure.',
60
- projectTrust: 'Run atlas trust.',
56
+ const hosted = await runDoctor(
57
+ {
58
+ ...f.inputs,
59
+ diagnosticGuidance: {
60
+ providerResolution: 'Run atlas configure.',
61
+ projectTrust: 'Run atlas trust.',
62
+ },
61
63
  },
62
- }, f.deps);
64
+ f.deps,
65
+ );
63
66
  expect(byId(hosted, 'provider.resolution').detail).toEqual(['Run atlas configure.']);
64
67
  expect(byId(hosted, 'workspace.trust').detail).toEqual(['Run atlas trust.']);
65
68
  });
@@ -143,6 +146,9 @@ describe('runDoctor (OBSERVABILITY-1991)', () => {
143
146
  const ghost = byId(report, 'mcp.plugin.mcp-plugin@fixture-market.ghost');
144
147
  expect(ghost).toMatchObject({ status: 'warn' });
145
148
  expect(ghost.cause).toContain('robota-doctor-missing-binary');
149
+ const declaration = byId(report, 'mcp.plugin.mcp-plugin@fixture-market.ready');
150
+ expect(declaration.status).toBe('warn');
151
+ expect(declaration.detail?.join(' ')).toContain('not a runtime source');
146
152
  expect(byId(report, 'mcp.activation').status).toBe('not-configured');
147
153
  expect(byId(report, 'mcp.connection').status).toBe('not-probed');
148
154
  expect(byId(report, 'hooks.execution').status).toBe('not-probed');
@@ -197,6 +203,20 @@ describe('runDoctor (OBSERVABILITY-1991)', () => {
197
203
  expect(byId(report, 'settings.user.robota')).toMatchObject({ status: 'fail' });
198
204
  });
199
205
 
206
+ it('an unparseable user settings file is a failed plugin check, not a crash', async () => {
207
+ const f = fixture({ env: {} });
208
+ f.mkdir('.robota');
209
+ f.mkdir('.robota/sessions');
210
+ f.write('.robota/settings.json', '{ "enabledPlugins": { "helper": false }, }');
211
+
212
+ const report = await runDoctor(f.inputs, f.deps);
213
+
214
+ expect(byId(report, 'plugins')).toMatchObject({
215
+ status: 'fail',
216
+ cause: expect.stringContaining('not valid JSON'),
217
+ });
218
+ });
219
+
200
220
  it('TC-04: derives the reachability host from profile baseURL, then defaults.baseURL, then endpoint', async () => {
201
221
  const profile = fixture({ env: {} });
202
222
  profile.write(
@@ -179,22 +179,29 @@ function hookChecks(
179
179
 
180
180
  function mcpServerCheck(server: IBundlePluginMcpServer, deps: IDoctorDeps): IDoctorCheck {
181
181
  const id = `mcp.plugin.${server.pluginId}.${server.name}`;
182
+ const detail = [
183
+ 'Declaration only: plugin .mcp.json is not a runtime source. Copy a supported definition to mcpServers in settings, then use /mcp approve <name>.',
184
+ ];
182
185
  if (server.transport === 'stdio' && server.command !== undefined) {
183
186
  return deps.resolveCommand(server.command)
184
187
  ? {
185
188
  id,
186
- label: 'MCP server (plugin)',
187
- status: 'ok',
189
+ label: 'MCP declaration (plugin)',
190
+ status: 'warn',
188
191
  path: server.mcpPath,
189
192
  cause: `stdio command ${server.command} resolves`,
193
+ detail,
190
194
  }
191
195
  : {
192
196
  id,
193
- label: 'MCP server (plugin)',
197
+ label: 'MCP declaration (plugin)',
194
198
  status: 'warn',
195
199
  path: server.mcpPath,
196
200
  cause: `stdio command ${server.command} not found on PATH`,
197
- detail: server.envKeys.length === 0 ? [] : [`env keys: ${server.envKeys.join(', ')}`],
201
+ detail: [
202
+ ...detail,
203
+ ...(server.envKeys.length === 0 ? [] : [`env keys: ${server.envKeys.join(', ')}`]),
204
+ ],
198
205
  };
199
206
  }
200
207
  if (server.transport === 'http' && server.url !== undefined) {
@@ -202,28 +209,31 @@ function mcpServerCheck(server: IBundlePluginMcpServer, deps: IDoctorDeps): IDoc
202
209
  new URL(server.url);
203
210
  return {
204
211
  id,
205
- label: 'MCP server (plugin)',
206
- status: 'ok',
212
+ label: 'MCP declaration (plugin)',
213
+ status: 'warn',
207
214
  path: server.mcpPath,
208
215
  cause: `url ${server.url}`,
216
+ detail,
209
217
  };
210
218
  } catch {
211
219
  // allow-fallback: an unparseable url is the finding
212
220
  return {
213
221
  id,
214
- label: 'MCP server (plugin)',
222
+ label: 'MCP declaration (plugin)',
215
223
  status: 'warn',
216
224
  path: server.mcpPath,
217
225
  cause: 'url is not parseable',
226
+ detail,
218
227
  };
219
228
  }
220
229
  }
221
230
  return {
222
231
  id,
223
- label: 'MCP server (plugin)',
232
+ label: 'MCP declaration (plugin)',
224
233
  status: 'warn',
225
234
  path: server.mcpPath,
226
235
  cause: 'neither command nor url declared',
236
+ detail,
227
237
  };
228
238
  }
229
239
 
@@ -287,11 +297,25 @@ export function probeExtensions(
287
297
  // Enablement is read the way the session loader reads it — from the user settings file through the
288
298
  // plugin settings store — so the doctor and the session agree even where that store's own policy is
289
299
  // a separate open item (recorded on #2670).
290
- const plugins = loadHostBundlePluginInspectionFromScopes(inputs.pluginsDirs, {
291
- settingsPath: inputs.userSettingsPath,
292
- });
300
+ let plugins: IBundlePluginInspection[];
301
+ let settingsProblem: string | undefined;
302
+ try {
303
+ plugins = loadHostBundlePluginInspectionFromScopes(inputs.pluginsDirs, {
304
+ settingsPath: inputs.userSettingsPath,
305
+ });
306
+ } catch (error) {
307
+ // Unreadable plugin settings load no plugin in a session. The doctor says so, and still
308
+ // inspects every installed plugin so its other problems are not hidden behind this one.
309
+ settingsProblem = error instanceof Error ? error.message : String(error);
310
+ plugins = loadHostBundlePluginInspectionFromScopes(inputs.pluginsDirs, { enabledPlugins: {} });
311
+ }
312
+ const pluginSummary = pluginChecks(plugins).map((check) =>
313
+ settingsProblem !== undefined && check.id === 'plugins'
314
+ ? { ...check, status: 'fail' as const, cause: `no plugin loads: ${settingsProblem}` }
315
+ : check,
316
+ );
293
317
  return [
294
- ...pluginChecks(plugins),
318
+ ...pluginSummary,
295
319
  ...skillChecks(inputs),
296
320
  ...hookChecks(settings, plugins, deps),
297
321
  ...mcpChecks(inputs, plugins, deps),
@@ -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
 
@@ -659,13 +659,14 @@ describe('/mcp reload (#3282 §4 part b-2)', () => {
659
659
  function harness(
660
660
  reload: ICommandMCPActivationAdapter['reload'],
661
661
  taken: (names: string[]) => string[] = (names) => names,
662
+ approve: ICommandMCPActivationAdapter['approve'] = async () => summary(),
662
663
  ) {
663
664
  const added: IToolWithEventService[][] = [];
664
665
  const reloadToolsAddedCalls: { token: string; added: readonly string[] }[] = [];
665
666
  const port: ICommandMCPActivationAdapter = {
666
667
  list: () => [],
667
- approve: async () => summary(),
668
- reject: async () => summary(),
668
+ approve,
669
+ reject: async () => summary({ status: 'rejected', allowed: false }),
669
670
  revoke: async () => summary(),
670
671
  reload,
671
672
  reloadToolsAdded: (token, names) => reloadToolsAddedCalls.push({ token, added: names }),
@@ -689,7 +690,11 @@ describe('/mcp reload (#3282 §4 part b-2)', () => {
689
690
  }
690
691
 
691
692
  const forecast = new FunctionTool(
692
- { name: 'weather__forecast', description: 'Forecast', parameters: { type: 'object', properties: {} } },
693
+ {
694
+ name: 'weather__forecast',
695
+ description: 'Forecast',
696
+ parameters: { type: 'object', properties: {} },
697
+ },
693
698
  async () => 'sunny',
694
699
  );
695
700
 
@@ -721,6 +726,94 @@ describe('/mcp reload (#3282 §4 part b-2)', () => {
721
726
  expect(result.message).toContain('No server newly connected.');
722
727
  });
723
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
+
724
817
  it('is refused when the host offers no reload', async () => {
725
818
  const h = harness(undefined);
726
819
  const result = await h.run('reload');
@@ -213,7 +213,7 @@ export async function executeMCPActivationCommand(
213
213
 
214
214
  try {
215
215
  const result = await mcp[verb](serverId);
216
- return {
216
+ const decided = {
217
217
  message: `MCP server ${serverId} is now ${result.status}. ${result.reason}`,
218
218
  success:
219
219
  result.status === 'approved' || result.status === 'rejected' || result.status === 'revoked',
@@ -227,6 +227,24 @@ export async function executeMCPActivationCommand(
227
227
  securityIdentity: result.securityIdentity,
228
228
  },
229
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
+ }
230
248
  } catch (error) {
231
249
  return {
232
250
  message: error instanceof Error ? error.message : String(error),
@@ -272,13 +290,17 @@ async function reloadResult(context: TMCPActivationCommandContext): Promise<ICom
272
290
  };
273
291
  }
274
292
  if (mcp.reload === undefined) {
275
- return { message: 'Reloading MCP servers is not available in this environment.', success: false };
293
+ return {
294
+ message: 'Reloading MCP servers is not available in this environment.',
295
+ success: false,
296
+ };
276
297
  }
277
298
  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);
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);
282
304
  }
283
305
  const dropped = result.tools.length - added.length;
284
306
  const parts = [
@@ -46,10 +46,7 @@ function createCommandSessionRuntime() {
46
46
  });
47
47
  }
48
48
 
49
- function createCommandHostContext(
50
- adapter?: ICommandPluginAdapter,
51
- locality?: 'local' | 'remote',
52
- ) {
49
+ function createCommandHostContext(adapter?: ICommandPluginAdapter, locality?: 'local' | 'remote') {
53
50
  const checkpoint = {
54
51
  id: 'checkpoint_1',
55
52
  sessionId: 'session_1',
@@ -111,7 +108,9 @@ describe('createPluginCommandModule', () => {
111
108
  }),
112
109
  expect.objectContaining({
113
110
  name: 'reload-plugins',
114
- description: 'Reload all plugin resources',
111
+ description: expect.stringContaining(
112
+ 'refresh the command list, skill routing and model skill catalogue',
113
+ ),
115
114
  source: 'plugin-manager',
116
115
  modelInvocable: false,
117
116
  }),
@@ -278,7 +277,8 @@ describe('executeReloadPluginsCommand', () => {
278
277
  executeReloadPluginsCommand(createCommandHostContext(adapter), ''),
279
278
  ).resolves.toEqual({
280
279
  success: true,
281
- message: 'Reloaded 3 plugin resources.',
280
+ message:
281
+ 'Reloaded 3 plugin resources. Plugin commands and skills are active now; hook changes apply to new sessions.',
282
282
  data: { pluginRegistryReloaded: true },
283
283
  });
284
284
  expect(adapter.reloadPlugins).toHaveBeenCalled();
@@ -294,6 +294,7 @@ describe('executeReloadPluginsCommand', () => {
294
294
  ).resolves.toEqual({
295
295
  success: false,
296
296
  message: 'Plugin error: manifest failed',
297
+ data: { pluginRegistryReloaded: true },
297
298
  });
298
299
  });
299
300
  });
@@ -259,11 +259,10 @@ export async function executeReloadPluginsCommand(
259
259
  result.loadedPluginCount === 1
260
260
  ? '1 plugin resource'
261
261
  : `${result.loadedPluginCount} plugin resources`;
262
- return `Reloaded ${suffix}.`;
262
+ return `Reloaded ${suffix}. Plugin commands and skills are active now; hook changes apply to new sessions.`;
263
263
  }).then((result) => {
264
- if (!result.success) return result;
265
- // CMD-004 Stage E: the semantic reload already ran HOST-side above (`adapter.reloadPlugins()`);
266
- // the requester-local registry/autocomplete refresh rides the result as a data hint.
264
+ // A failed reload must also refresh the admitted snapshot: malformed settings must not leave
265
+ // previously loaded code executable. Preserve the error while requesting the same refresh.
267
266
  return {
268
267
  ...result,
269
268
  data: { ...result.data, pluginRegistryReloaded: true },