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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +27 -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/effort/effort-command.test.ts +65 -2
  19. package/src/effort/effort-command.ts +19 -5
  20. package/src/git/__tests__/fake-git-port.ts +9 -2
  21. package/src/git/git-command-module.ts +3 -4
  22. package/src/git/git-commit.ts +6 -3
  23. package/src/git/index.ts +4 -1
  24. package/src/goal/goal-command.ts +3 -1
  25. package/src/index.ts +6 -0
  26. package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +131 -0
  27. package/src/mcp-activation/mcp-activation-command-module.ts +6 -0
  28. package/src/mcp-activation/mcp-activation-command.ts +54 -1
  29. package/src/memory/__tests__/memory-command-module.test.ts +40 -2
  30. package/src/memory/__tests__/memory-command-unavailable.test.ts +63 -0
  31. package/src/memory/memory-command-module.ts +4 -2
  32. package/src/memory/memory-command.ts +42 -2
  33. package/src/mode/__tests__/mode-command-module.test.ts +40 -0
  34. package/src/mode/mode-command.ts +1 -1
  35. package/src/model/__tests__/model-command-module.test.ts +80 -0
  36. package/src/model/__tests__/model-command.test.ts +240 -0
  37. package/src/model/index.ts +6 -0
  38. package/src/model/model-command-module.ts +56 -0
  39. package/src/model/model-command.ts +223 -0
  40. package/src/plugin/__tests__/plugin-command-module.test.ts +81 -1
  41. package/src/plugin/plugin-command.ts +47 -4
  42. package/src/provider/__tests__/org-policy.test.ts +4 -0
  43. package/src/provider/__tests__/provider-command-module.test.ts +276 -14
  44. package/src/provider/__tests__/scripted-interaction.ts +12 -2
  45. package/src/provider/provider-command-execution.ts +54 -8
  46. package/src/provider/provider-command-profile-lifecycle.ts +48 -2
  47. package/src/provider/provider-command-profile-operations.ts +14 -0
  48. package/src/provider/provider-command-profile.ts +7 -4
  49. package/src/provider/provider-command-setup.ts +14 -1
  50. package/src/rewind/__tests__/rewind-command-module.test.ts +2 -1
  51. package/src/schedule/__tests__/loop-command-functional.test.ts +13 -0
  52. package/src/schedule/__tests__/loop-command.test.ts +238 -4
  53. package/src/schedule/loop-command.ts +95 -3
  54. package/src/schedule/schedule-command-module.ts +8 -4
  55. package/src/settings/settings-command-module.ts +2 -2
  56. package/src/git/__tests__/git-diff.test.ts +0 -125
  57. package/src/git/__tests__/git-process.test.ts +0 -221
  58. package/src/git/__tests__/git-status.test.ts +0 -117
  59. package/src/git/git-diff.ts +0 -129
  60. package/src/git/git-process.ts +0 -169
  61. package/src/git/git-status.ts +0 -134
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@robota-sdk/agent-command",
3
- "version": "3.0.0-beta.83",
4
- "description": "Consolidated command module implementations for Robota SDK CLI",
3
+ "version": "3.0.0-beta.84",
4
+ "description": "Slash-command modules for Robota SDK sessions (/help, /permissions, /mcp, /rewind, …)",
5
5
  "type": "module",
6
6
  "main": "dist/node/index.js",
7
7
  "types": "dist/node/index.d.ts",
@@ -40,12 +40,12 @@
40
40
  "CHANGELOG.md"
41
41
  ],
42
42
  "dependencies": {
43
- "@robota-sdk/agent-core": "3.0.0-beta.83",
44
- "@robota-sdk/agent-framework": "3.0.0-beta.83",
45
- "@robota-sdk/agent-interface-command": "3.0.0-beta.83",
46
- "@robota-sdk/agent-interface-execution": "3.0.0-beta.83",
47
- "@robota-sdk/agent-interface-session": "3.0.0-beta.83",
48
- "@robota-sdk/agent-preset": "3.0.0-beta.83"
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"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@types/node": "^22.19.21",
@@ -60,6 +60,7 @@
60
60
  "url": "https://github.com/woojubb/robota.git",
61
61
  "directory": "packages/agent-command"
62
62
  },
63
+ "homepage": "https://docs.robota.io/en/packages/agent-command/",
63
64
  "publishConfig": {
64
65
  "access": "public"
65
66
  },
@@ -15,6 +15,7 @@ function createMockSession(overrides?: Record<string, unknown>) {
15
15
  { name: 'general-purpose', description: 'General-purpose task execution agent.' },
16
16
  { name: 'Plan', description: 'Read-only planning agent.' },
17
17
  ]),
18
+ setDefaultAgentType: vi.fn(),
18
19
  listAgentJobs: vi.fn().mockReturnValue([]),
19
20
  readBackgroundTaskLog: vi.fn().mockResolvedValue({ taskId: 'agent_1', lines: [] }),
20
21
  spawnAgentJob: vi.fn().mockResolvedValue({
@@ -518,6 +519,67 @@ describe('agent command module', () => {
518
519
  expect(result?.uiIntents).toEqual([{ type: 'show-agent-switcher' }]);
519
520
  });
520
521
 
522
+ it('#3282 §4: /agent <name> with nothing after it selects a default agent instead of failing', async () => {
523
+ const module = createAgentCommandModule();
524
+ const executor = new SystemCommandExecutor([
525
+ ...createSystemCommands(),
526
+ ...(module.systemCommands ?? []),
527
+ ]);
528
+ const session = createMockSession();
529
+
530
+ const result = await executor.execute('agent', session, 'Plan');
531
+
532
+ expect(result?.success).toBe(true);
533
+ expect(result?.message).toBe('Default agent: Plan');
534
+ expect(result?.data).toEqual({ agentType: 'Plan' });
535
+ expect(
536
+ (session as unknown as { setDefaultAgentType: ReturnType<typeof vi.fn> }).setDefaultAgentType,
537
+ ).toHaveBeenCalledWith('Plan');
538
+ expect(
539
+ (session as unknown as { spawnAgentJob: ReturnType<typeof vi.fn> }).spawnAgentJob,
540
+ ).not.toHaveBeenCalled();
541
+ });
542
+
543
+ it('#3282 §4: a single unrecognized word is still a prompt for the default agent, not a selection', async () => {
544
+ const module = createAgentCommandModule();
545
+ const executor = new SystemCommandExecutor([
546
+ ...createSystemCommands(),
547
+ ...(module.systemCommands ?? []),
548
+ ]);
549
+ const session = createMockSession();
550
+
551
+ const result = await executor.execute('agent', session, 'NotARealAgent');
552
+
553
+ expect(result?.success).toBe(true);
554
+ expect(
555
+ (session as unknown as { spawnAgentJob: ReturnType<typeof vi.fn> }).spawnAgentJob,
556
+ ).toHaveBeenCalledWith({
557
+ agentType: 'general-purpose',
558
+ label: 'general-purpose',
559
+ mode: 'background',
560
+ prompt: 'NotARealAgent',
561
+ });
562
+ expect(
563
+ (session as unknown as { setDefaultAgentType: ReturnType<typeof vi.fn> }).setDefaultAgentType,
564
+ ).not.toHaveBeenCalled();
565
+ });
566
+
567
+ it('#3282 §4: a known agent name followed by a prompt still runs a job, not a selection', async () => {
568
+ const module = createAgentCommandModule();
569
+ const executor = new SystemCommandExecutor([
570
+ ...createSystemCommands(),
571
+ ...(module.systemCommands ?? []),
572
+ ]);
573
+ const session = createMockSession();
574
+
575
+ const result = await executor.execute('agent', session, 'Plan "draft architecture"');
576
+
577
+ expect(result?.success).toBe(true);
578
+ expect(
579
+ (session as unknown as { setDefaultAgentType: ReturnType<typeof vi.fn> }).setDefaultAgentType,
580
+ ).not.toHaveBeenCalled();
581
+ });
582
+
521
583
  it('shows text list when agent list subcommand is used explicitly', async () => {
522
584
  const module = createAgentCommandModule();
523
585
  const executor = new SystemCommandExecutor([
@@ -1,6 +1,6 @@
1
- import type { TBackgroundTaskIsolation } from '@robota-sdk/agent-interface-execution';
1
+ import { DEFAULT_AGENT_TYPE } from '@robota-sdk/agent-framework';
2
2
 
3
- const DEFAULT_AGENT_TYPE = 'general-purpose';
3
+ import type { TBackgroundTaskIsolation } from '@robota-sdk/agent-interface-execution';
4
4
 
5
5
  export type TAgentMode = 'background';
6
6
 
@@ -44,6 +44,21 @@ function executeOpenSwitcher(): ICommandResult {
44
44
  return { message: '', uiIntents: [{ type: 'show-agent-switcher' }], success: true };
45
45
  }
46
46
 
47
+ /**
48
+ * #3282 §4: `/agent <name>` with nothing after the name sets the session's default agent type —
49
+ * the one `/agent <prompt>` falls back to, and the one the agent switcher shows checked. Distinct
50
+ * from `executeRun`, which a name FOLLOWED by a prompt still reaches (a prompt makes it a run, not
51
+ * a selection).
52
+ */
53
+ function executeSelectDefaultAgent(session: IAgentJobHostContext, agentType: string): ICommandResult {
54
+ session.setDefaultAgentType(agentType);
55
+ return {
56
+ message: `Default agent: ${agentType}`,
57
+ success: true,
58
+ data: { agentType },
59
+ };
60
+ }
61
+
47
62
  async function executeList(session: IAgentJobHostContext): Promise<ICommandResult> {
48
63
  const agents = session.listAgentDefinitions();
49
64
  const jobs = session.listAgentJobs();
@@ -223,6 +238,11 @@ export async function executeAgentCommand(
223
238
  if (action === 'send') return executeSend(session, tokens);
224
239
  if (action === 'stop' || action === 'cancel') return executeStop(session, tokens);
225
240
  if (action === 'close') return executeClose(session, tokens);
241
+ // `/agent <name>` alone (a known agent, nothing after it) selects a default rather than failing
242
+ // the "run" parse for want of a prompt — the same one-token shape the switcher sends.
243
+ if (tokens.length === 0 && getAvailableAgentNames(session).has(action)) {
244
+ return executeSelectDefaultAgent(session, action);
245
+ }
226
246
  return executeRun(session, [action, ...tokens]);
227
247
  } catch (error) {
228
248
  return { message: formatError(error), success: false };
@@ -44,7 +44,7 @@ Run it with no arguments.",
44
44
  },
45
45
  "loop": {
46
46
  "argumentHint": "[prompt] | <N><s|m|h|d> [prompt] | <prompt> every <N> <unit> | list | stop <id>",
47
- "description": "Repeat a prompt on a fixed cadence (\`<N><s|m|h|d> [prompt]\`) or self-paced (bare or prompt only: each iteration chooses its next delay or stops). Use it when the user asks for recurring work in this session. \`list\` shows active loops; \`stop <id>\` ends one. Returns the loop id and its next fire time, or the loop list.",
47
+ "description": "Repeat a prompt on a fixed cadence (\`<N><s|m|h|d> <prompt>\`) or self-paced (\`<prompt>\`: each iteration chooses its next delay or stops). Use it when the user asks for recurring work in this session, and always give your own prompt: any form that would fall back to the host's default maintenance prompt — bare \`/loop\` or an interval alone (\`<N><s|m|h|d>\`) — needs a person to confirm it and is always refused for you, whether or not one is attached. \`list\` shows active loops; \`stop <id>\` ends one, even mid-turn. Returns the loop id and its next fire time, the loop list, or a refusal naming the command to suggest.",
48
48
  "kind": "builtin-command",
49
49
  "modelInvocable": true,
50
50
  "name": "loop",
@@ -64,7 +64,7 @@ Subcommands you may run (bare \`mcp\` is also allowed):
64
64
  },
65
65
  "memory": {
66
66
  "argumentHint": "[list | show [topic] | add <user|feedback|project|reference> <topic> <text> | pending | used]",
67
- "description": "Read and write this project’s durable memory. Use \`list\` or \`show [topic]\` to look up stored conventions, preferences or references before asking the user again; \`add <type> <topic> <text>\` to save a durable preference, project convention, feedback item or reference worth reusing across sessions (never secrets, credentials or transient facts); \`pending\` to see candidates awaiting the user’s review; \`used\` to report which memory items informed this turn. Bare lists topics. Returns the requested topics or entries, or a confirmation of what was saved. Approving or rejecting a pending candidate is the user’s review: suggest \`/memory approve <id>\` or \`/memory reject <id>\`.
67
+ "description": "Read and write this project’s durable memory. Use \`list\` or \`show [topic]\` to look up stored conventions, preferences or references before asking the user again; \`add <type> <topic> <text>\` to save a durable preference, project convention, feedback item or reference worth reusing across sessions (never secrets, credentials or transient facts); \`pending\` to see candidates awaiting the user’s review; \`used\` to report which memory items informed this turn. Bare lists topics. In plan mode \`add\` is refused: save after plan mode ends. Returns the requested topics or entries, or a confirmation of what was saved. Approving or rejecting a pending candidate is the user’s review: suggest \`/memory approve <id>\` or \`/memory reject <id>\`.
68
68
  Subcommands you may run (bare \`memory\` is also allowed):
69
69
  - list: List project memory topics
70
70
  - show: Show project memory index or a topic
@@ -50,10 +50,10 @@ describe('createDefaultCommandModules — PRESET-004 module-selection delta', ()
50
50
  const names = moduleNames(baseOptions);
51
51
  // No-regression: the default set length is the documented 32 modules (SELFHOST-002 added
52
52
  // `/plan`; PEER-004 added `/peers`; CLI-1994 added `/fork`; MCP-2520 added `/mcp`; CLI-1988 added
53
- // `/output-style`; FLOW-008 added `/effort`; `/advisor` joined beside it). The list below is the
54
- // assertion that matters — a length on its own can be restored by any substitution, and the count
55
- // exists only to catch an addition that also removed something.
56
- expect(names).toHaveLength(38);
53
+ // `/output-style`; FLOW-008 added `/effort`; `/advisor` joined beside it; #3282 §2 added `/model`).
54
+ // The list below is the assertion that matters — a length on its own can be restored by any
55
+ // substitution, and the count exists only to catch an addition that also removed something.
56
+ expect(names).toHaveLength(39);
57
57
  expect(names).toEqual([
58
58
  'agent-command-skills',
59
59
  'agent-command-help',
@@ -94,6 +94,7 @@ describe('createDefaultCommandModules — PRESET-004 module-selection delta', ()
94
94
  'agent-command-handoff',
95
95
  'agent-command-remote-control',
96
96
  'agent-command-provider',
97
+ 'agent-command-model',
97
98
  ]);
98
99
  });
99
100
 
@@ -109,6 +109,8 @@ const USER_ONLY_COMMANDS = [
109
109
  'events',
110
110
  // Account and credential actions.
111
111
  'provider',
112
+ // Cost and capability decisions: the person chooses, never the model on its own (#3282 §2).
113
+ 'model',
112
114
  // Permission widening or permission-mode changes.
113
115
  'mode',
114
116
  'permissions',
@@ -20,6 +20,7 @@ import { createLanguageCommandModule } from '../language/index.js';
20
20
  import { createMCPActivationCommandModule } from '../mcp-activation/index.js';
21
21
  import { createMemoryCommandModule } from '../memory/index.js';
22
22
  import { createModeCommandModule } from '../mode/index.js';
23
+ import { createModelCommandModule } from '../model/index.js';
23
24
  import { createSandboxCommandModule } from '../sandbox/index.js';
24
25
  import { createOutputStyleCommandModule } from '../output-style/index.js';
25
26
  import { createEventsCommandModule } from '../events/index.js';
@@ -204,6 +205,13 @@ export function createDefaultCommandModules({
204
205
  settings: providerSettingsAdapter,
205
206
  ...(orgPolicy === undefined ? {} : { orgPolicy }),
206
207
  }),
208
+ // #3282 §2: shares the provider command's settings adapter and definitions — one settings write
209
+ // owner, never a second copy that could drift from what `/provider switch` reads and writes.
210
+ createModelCommandModule({
211
+ providerDefinitions,
212
+ settings: providerSettingsAdapter,
213
+ ...(orgPolicy === undefined ? {} : { orgPolicy }),
214
+ }),
207
215
  ];
208
216
  const builtModuleNames = modules.map((module) => module.name);
209
217
  return {
@@ -5,6 +5,37 @@ import { executeEffortCommand } from './effort-command.js';
5
5
  import { createTestCommandHost } from '@robota-sdk/agent-framework/testing';
6
6
 
7
7
  describe('/effort', () => {
8
+ it('#3282 §2: reports plainly when the model has no matching effort control at all', async () => {
9
+ const host = createTestCommandHost({
10
+ overrides: {
11
+ getCommandHostAdapters: () => ({
12
+ settings: { read: () => ({}), write: vi.fn() },
13
+ effort: {
14
+ getResolution: () => ({
15
+ requested: 'low',
16
+ effective: 'low',
17
+ source: 'command',
18
+ disposition: 'not-applied',
19
+ modelDefault: 'high',
20
+ }),
21
+ apply: async () => ({
22
+ requested: 'low',
23
+ effective: 'low',
24
+ source: 'command',
25
+ disposition: 'not-applied',
26
+ modelDefault: 'high',
27
+ }),
28
+ },
29
+ }),
30
+ },
31
+ });
32
+
33
+ const result = await executeEffortCommand(host, 'low');
34
+
35
+ expect(result.success).toBe(true);
36
+ expect(result.message).toBe("This model doesn't support effort levels, so it will use its default.");
37
+ });
38
+
8
39
  it('applies a requested level and returns the complete resolution', async () => {
9
40
  const applyModelOptions = vi.fn();
10
41
  const writeSettings = vi.fn();
@@ -36,6 +67,7 @@ describe('/effort', () => {
36
67
  const result = await executeEffortCommand(host, 'low');
37
68
 
38
69
  expect(result.success).toBe(true);
70
+ expect(result.message).toBe('Effort: Low');
39
71
  expect(result.data).toMatchObject({
40
72
  effort: { requested: 'low', effective: 'low', source: 'command', disposition: 'applied' },
41
73
  });
@@ -50,7 +82,7 @@ describe('/effort', () => {
50
82
  const result = await executeEffortCommand(host, '');
51
83
 
52
84
  expect(result.success).toBe(true);
53
- expect(result.message).toContain('high');
85
+ expect(result.message).toBe('Effort: High');
54
86
  expect(applyModelOptions).not.toHaveBeenCalled();
55
87
  });
56
88
 
@@ -133,8 +165,39 @@ describe('/effort', () => {
133
165
  const result = await executeEffortCommand(host, '');
134
166
 
135
167
  expect(result.success).toBe(true);
136
- expect(result.message).toContain('effective=medium');
168
+ expect(result.message).toBe('Effort: Medium');
137
169
  expect(applyModelOptions).not.toHaveBeenCalled();
138
170
  expect(writeSettings).not.toHaveBeenCalled();
139
171
  });
172
+
173
+ it('#3282 §2: uses the full plain word for xhigh, not the internal id', async () => {
174
+ const host = createTestCommandHost({
175
+ overrides: {
176
+ getCommandHostAdapters: () => ({
177
+ settings: { read: () => ({}), write: vi.fn() },
178
+ effort: {
179
+ getResolution: () => ({
180
+ requested: 'auto',
181
+ effective: 'high',
182
+ source: 'model-default',
183
+ disposition: 'model-default',
184
+ modelDefault: 'high',
185
+ }),
186
+ apply: async (selection) => ({
187
+ requested: selection,
188
+ effective: selection === 'auto' ? 'high' : selection,
189
+ source: 'command',
190
+ disposition: 'applied',
191
+ modelDefault: 'high',
192
+ }),
193
+ },
194
+ }),
195
+ },
196
+ });
197
+
198
+ const result = await executeEffortCommand(host, 'xhigh');
199
+
200
+ expect(result.success).toBe(true);
201
+ expect(result.message).toBe('Effort: Extra high');
202
+ });
140
203
  });
@@ -1,5 +1,5 @@
1
1
  import { MODEL_EFFORT_VALUES, selectAction } from '@robota-sdk/agent-core';
2
- import { parseModelEffort, resolveModelEffort } from '@robota-sdk/agent-framework';
2
+ import { EFFORT_LEVEL_LABELS, parseModelEffort, resolveModelEffort } from '@robota-sdk/agent-framework';
3
3
 
4
4
  import type {
5
5
  ICommandHostAdapterAccess,
@@ -14,13 +14,27 @@ import type { ICommandResult } from '@robota-sdk/agent-interface-command';
14
14
 
15
15
  const EFFORT_SELECTIONS: readonly TEffortSelection[] = ['auto', ...MODEL_EFFORT_VALUES];
16
16
 
17
+ /**
18
+ * #3282 §2 — re-exported for callers already importing it from here; the canonical definition moved
19
+ * to `agent-framework` (`effort/effort-resolution.ts`) so headless `/goal` output (in that same
20
+ * package) can use the same labels without a circular dependency on `agent-command`.
21
+ */
22
+ export { EFFORT_LEVEL_LABELS };
23
+
24
+ /**
25
+ * Plain outcome text (#3282 §2), replacing the earlier `Model effort: requested=…, effective=…,
26
+ * source=…, disposition=…` internal-field dump. `not-applied` means the model has no matching native
27
+ * control at all — the earlier fields carried no information a person could act on for that case, so
28
+ * it gets its own plain sentence instead of a label.
29
+ */
17
30
  function formatEffortMessage(resolution: {
18
31
  requested: TEffortSelection;
19
- effective: string;
20
- source: string;
21
32
  disposition: string;
22
33
  }): string {
23
- return `Model effort: requested=${resolution.requested}, effective=${resolution.effective}, source=${resolution.source}, disposition=${resolution.disposition}.`;
34
+ if (resolution.disposition === 'not-applied') {
35
+ return "This model doesn't support effort levels, so it will use its default.";
36
+ }
37
+ return `Effort: ${EFFORT_LEVEL_LABELS[resolution.requested]}`;
24
38
  }
25
39
 
26
40
  async function askForEffort(
@@ -32,7 +46,7 @@ async function askForEffort(
32
46
  selectAction(
33
47
  'effort',
34
48
  'Select model effort',
35
- EFFORT_SELECTIONS.map((value) => ({ value, label: value })),
49
+ EFFORT_SELECTIONS.map((value) => ({ value, label: EFFORT_LEVEL_LABELS[value] })),
36
50
  ),
37
51
  );
38
52
  const selected = response.type === 'answer' ? response.values[0] : undefined;
@@ -1,5 +1,12 @@
1
- /** A scripted `IGitProcessPort`: answers by argv, records every call, throws on an unscripted one. */
2
- import type { IGitProcessPort, TGitProcessOutcome } from '../git-process.js';
1
+ /**
2
+ * A scripted `IGitProcessPort`: answers by argv, records every call, throws on an unscripted one.
3
+ *
4
+ * The port and outcome types moved to `@robota-sdk/agent-framework` with the git process module
5
+ * (#3282 §4c); this double stays here (duplicated, not imported cross-package) because it is a tiny,
6
+ * self-contained test fixture, the same call `agent-framework`'s own `git/__tests__/fake-git-port.ts`
7
+ * makes for its side.
8
+ */
9
+ import type { IGitProcessPort, TGitProcessOutcome } from '@robota-sdk/agent-framework';
3
10
 
4
11
  export interface IFakeGitPort extends IGitProcessPort {
5
12
  readonly calls: readonly (readonly string[])[];
@@ -5,15 +5,14 @@
5
5
  * on the user path (`SystemCommandExecutor.executeCommand` calls `execute` directly).
6
6
  */
7
7
  import { executeGitCommit } from './git-commit.js';
8
- import { executeGitDiff } from './git-diff.js';
9
- import { createGitProcess } from './git-process.js';
10
- import { executeGitStatus } from './git-status.js';
11
8
 
12
- import type { IGitProcessPort } from './git-process.js';
9
+ import { createGitProcess, executeGitDiff, executeGitStatus } from '@robota-sdk/agent-framework';
10
+
13
11
  import type {
14
12
  ICommandHostUserInteraction,
15
13
  ICommandHostWorkspace,
16
14
  ICommandModule,
15
+ IGitProcessPort,
17
16
  ISystemCommand,
18
17
  } from '@robota-sdk/agent-framework';
19
18
  import type { ICommand, ICommandResult, ICommandSource } from '@robota-sdk/agent-interface-command';
@@ -9,10 +9,13 @@
9
9
  */
10
10
  import { confirmAction, isConfirmed, textAction } from '@robota-sdk/agent-core';
11
11
 
12
- import { gitFailureMessage } from './git-process.js';
13
- import { parseStatusPorcelainV2, GIT_STATUS_ARGS } from './git-status.js';
12
+ import {
13
+ gitFailureMessage,
14
+ parseStatusPorcelainV2,
15
+ GIT_STATUS_ARGS,
16
+ } from '@robota-sdk/agent-framework';
14
17
 
15
- import type { IGitProcessPort } from './git-process.js';
18
+ import type { IGitProcessPort } from '@robota-sdk/agent-framework';
16
19
  import type { IUserInteraction } from '@robota-sdk/agent-core';
17
20
  import type { ICommandResult } from '@robota-sdk/agent-interface-command';
18
21
 
package/src/git/index.ts CHANGED
@@ -1,3 +1,6 @@
1
+ // The git process port and the status/diff readers moved to `@robota-sdk/agent-framework` (#3282
2
+ // §4c) so the Project panel's session-level reads and this command run through the exact same
3
+ // functions; re-exported here so an existing `from '@robota-sdk/agent-command'` import keeps working.
1
4
  export {
2
5
  createGitProcess,
3
6
  gitEnvironment,
@@ -6,7 +9,7 @@ export {
6
9
  type IGitProcessRunOptions,
7
10
  type TGitProcessFailureReason,
8
11
  type TGitProcessOutcome,
9
- } from './git-process.js';
12
+ } from '@robota-sdk/agent-framework';
10
13
  export {
11
14
  createGitCommandEntry,
12
15
  createGitCommandModule,
@@ -4,6 +4,8 @@
4
4
  * command host context; all loop logic lives in agent-framework.
5
5
  */
6
6
 
7
+ import { isGoalCancelVerb } from '@robota-sdk/agent-framework';
8
+
7
9
  import type { ICommandHostGoal } from '@robota-sdk/agent-framework';
8
10
  import type { ICommandResult } from '@robota-sdk/agent-interface-command';
9
11
  import type { IGoalState } from '@robota-sdk/agent-interface-session';
@@ -45,7 +47,7 @@ export async function executeGoalCommand(
45
47
  : { message: 'No goal is set.', success: true };
46
48
  }
47
49
 
48
- if (verb === 'cancel' || verb === 'stop') {
50
+ if (isGoalCancelVerb(trimmed)) {
49
51
  const stopped = context.cancelGoal();
50
52
  return stopped
51
53
  ? { message: `Goal cancelled: ${stopped.objective}`, success: true }
package/src/index.ts CHANGED
@@ -123,6 +123,12 @@ export {
123
123
  } from './mcp-activation/mcp-model-notice.js';
124
124
  export type { TMCPUserAction, TMCPUserActionSurface } from './mcp-activation/mcp-model-notice.js';
125
125
  export * from './mode/index.js';
126
+ export {
127
+ createModelCommandEntry,
128
+ createModelCommandModule,
129
+ ModelCommandSource,
130
+ } from './model/model-command-module.js';
131
+ export { executeModelCommand } from './model/model-command.js';
126
132
  export {
127
133
  createSandboxCommandModule,
128
134
  executeSandboxCommand,
@@ -655,6 +655,137 @@ 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
+ ) {
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
+
658
789
  describe('/mcp command entry', () => {
659
790
  it('opens only `status` to the model, and describes login for the user', () => {
660
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],