@robota-sdk/agent-command 3.0.0-beta.79 → 3.0.0-beta.81

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 (200) hide show
  1. package/README.md +11 -2
  2. package/dist/node/index.cjs +76 -38
  3. package/dist/node/index.d.cts +946 -0
  4. package/dist/node/index.d.cts.map +1 -0
  5. package/dist/node/index.d.ts +564 -51
  6. package/dist/node/index.d.ts.map +1 -1
  7. package/dist/node/index.js +76 -38
  8. package/dist/node/index.js.map +1 -1
  9. package/package.json +34 -16
  10. package/src/advisor/advisor-command-module.ts +47 -0
  11. package/src/advisor/advisor-command.test.ts +102 -0
  12. package/src/advisor/advisor-command.ts +55 -0
  13. package/src/advisor/advisor-cost-functional.test.ts +92 -0
  14. package/src/advisor/index.ts +6 -0
  15. package/src/agent/__tests__/agent-command.test.ts +39 -8
  16. package/src/agent/agent-command-module.ts +14 -7
  17. package/src/agent/agent-command-parser.ts +1 -1
  18. package/src/agent/agent-command.ts +3 -3
  19. package/src/background/__tests__/background-command-module.test.ts +59 -52
  20. package/src/background/background-command-module.ts +2 -1
  21. package/src/background/background-command.ts +3 -3
  22. package/src/command-module-utils.ts +33 -0
  23. package/src/compact/__tests__/compact-command-module.test.ts +31 -23
  24. package/src/compact/compact-command-module.ts +10 -1
  25. package/src/compact/compact-command.ts +6 -3
  26. package/src/context/__tests__/context-command-module.test.ts +214 -63
  27. package/src/context/auto-compact-format.ts +25 -0
  28. package/src/context/context-breakdown.ts +295 -0
  29. package/src/context/context-command-module.ts +45 -14
  30. package/src/context/context-command.ts +34 -257
  31. package/src/default/__tests__/__snapshots__/model-exposure.test.ts.snap +110 -0
  32. package/src/default/__tests__/default-command-modules.test.ts +59 -7
  33. package/src/default/__tests__/model-exposure.test.ts +241 -0
  34. package/src/default/__tests__/org-policy-forwarding.test.ts +98 -0
  35. package/src/default/default-command-modules.ts +130 -18
  36. package/src/default/index.ts +4 -1
  37. package/src/devices/__tests__/devices-command-module.test.ts +212 -0
  38. package/src/devices/devices-command-module.ts +224 -0
  39. package/src/devices/devices-command-port.ts +82 -0
  40. package/src/devices/index.ts +11 -0
  41. package/src/doctor/__tests__/doctor-command-module.test.ts +145 -0
  42. package/src/doctor/__tests__/doctor-display-vocabulary.test.ts +38 -0
  43. package/src/doctor/__tests__/doctor-fixture.ts +145 -0
  44. package/src/doctor/__tests__/doctor-redaction.test.ts +29 -0
  45. package/src/doctor/__tests__/doctor-runner.test.ts +464 -0
  46. package/src/doctor/doctor-command-module.ts +132 -0
  47. package/src/doctor/doctor-extensions-probe.ts +299 -0
  48. package/src/doctor/doctor-node-deps.ts +89 -0
  49. package/src/doctor/doctor-redaction.ts +99 -0
  50. package/src/doctor/doctor-render.ts +70 -0
  51. package/src/doctor/doctor-repair.ts +136 -0
  52. package/src/doctor/doctor-runner.ts +122 -0
  53. package/src/doctor/doctor-settings-probe.ts +239 -0
  54. package/src/doctor/doctor-storage-probe.ts +187 -0
  55. package/src/doctor/doctor-types.ts +109 -0
  56. package/src/doctor/index.ts +32 -0
  57. package/src/editor/__tests__/editor-command-functional.test.ts +49 -16
  58. package/src/editor/editor-command-module.ts +6 -5
  59. package/src/editor/editor-command.ts +10 -5
  60. package/src/effort/effort-command-module.ts +51 -0
  61. package/src/effort/effort-command.test.ts +140 -0
  62. package/src/effort/effort-command.ts +122 -0
  63. package/src/effort/index.ts +6 -0
  64. package/src/exit/__tests__/exit-command-module.test.ts +16 -9
  65. package/src/exit/exit-command-module.ts +2 -1
  66. package/src/exit/exit-command.ts +6 -6
  67. package/src/fork/__tests__/fork-command.test.ts +184 -0
  68. package/src/fork/fork-command-module.ts +61 -0
  69. package/src/fork/fork-command.ts +162 -0
  70. package/src/fork/index.ts +8 -0
  71. package/src/git/__tests__/fake-git-port.ts +28 -0
  72. package/src/git/__tests__/git-command-module.test.ts +103 -0
  73. package/src/git/__tests__/git-commit.test.ts +185 -0
  74. package/src/git/__tests__/git-diff.test.ts +125 -0
  75. package/src/git/__tests__/git-process.test.ts +221 -0
  76. package/src/git/__tests__/git-status.test.ts +117 -0
  77. package/src/git/git-command-module.ts +139 -0
  78. package/src/git/git-commit.ts +251 -0
  79. package/src/git/git-diff.ts +129 -0
  80. package/src/git/git-process.ts +169 -0
  81. package/src/git/git-status.ts +134 -0
  82. package/src/git/index.ts +17 -0
  83. package/src/goal/__tests__/goal-command.test.ts +9 -4
  84. package/src/goal/goal-command-module.ts +2 -1
  85. package/src/goal/goal-command.ts +6 -9
  86. package/src/handoff/__tests__/handoff-command.test.ts +218 -0
  87. package/src/handoff/handoff-command-module.ts +48 -0
  88. package/src/handoff/handoff-command.ts +187 -0
  89. package/src/handoff/index.ts +6 -0
  90. package/src/help/__tests__/help-command-module.test.ts +63 -40
  91. package/src/help/__tests__/help-command.test.ts +46 -37
  92. package/src/help/help-command-module.ts +2 -1
  93. package/src/help/help-command.ts +3 -3
  94. package/src/index.ts +124 -2
  95. package/src/keybindings/__tests__/keybindings-command-module.test.ts +57 -0
  96. package/src/keybindings/index.ts +6 -0
  97. package/src/keybindings/keybindings-command-module.ts +74 -0
  98. package/src/language/__tests__/language-command-module.test.ts +33 -28
  99. package/src/language/language-command-module.ts +2 -1
  100. package/src/language/language-command.ts +8 -6
  101. package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +630 -0
  102. package/src/mcp-activation/__tests__/mcp-model-view.test.ts +211 -0
  103. package/src/mcp-activation/index.ts +12 -0
  104. package/src/mcp-activation/mcp-activation-command-module.ts +92 -0
  105. package/src/mcp-activation/mcp-activation-command.ts +448 -0
  106. package/src/mcp-activation/mcp-model-notice.ts +85 -0
  107. package/src/mcp-activation/mcp-model-status.ts +99 -0
  108. package/src/memory/__tests__/memory-command-module.test.ts +167 -74
  109. package/src/memory/memory-command-module.ts +18 -1
  110. package/src/memory/memory-command.ts +38 -38
  111. package/src/mode/__tests__/mode-command-module.test.ts +51 -38
  112. package/src/mode/mode-command-module.ts +2 -1
  113. package/src/mode/mode-command.ts +15 -7
  114. package/src/output-style/__tests__/output-style-command.test.ts +96 -0
  115. package/src/output-style/index.ts +6 -0
  116. package/src/output-style/output-style-command-module.ts +143 -0
  117. package/src/peers/__tests__/peers-command.test.ts +241 -0
  118. package/src/peers/index.ts +9 -0
  119. package/src/peers/peers-command-module.ts +47 -0
  120. package/src/peers/peers-command.ts +142 -0
  121. package/src/permissions/__tests__/permissions-command-module.test.ts +149 -37
  122. package/src/permissions/permissions-command-module.ts +2 -1
  123. package/src/permissions/permissions-command.ts +46 -4
  124. package/src/plan/__tests__/plan-command.test.ts +129 -0
  125. package/src/plan/index.ts +9 -0
  126. package/src/plan/plan-command-module.ts +49 -0
  127. package/src/plan/plan-command.ts +92 -0
  128. package/src/plugin/__tests__/plugin-command-module.test.ts +46 -40
  129. package/src/plugin/__tests__/plugin-host-boundary.test.ts +11 -0
  130. package/src/plugin/plugin-command-module.ts +3 -1
  131. package/src/plugin/plugin-command.ts +17 -16
  132. package/src/preset/__tests__/host-supplied-preset-registry.test.ts +98 -0
  133. package/src/preset/__tests__/preset-command-module.test.ts +102 -56
  134. package/src/preset/preset-command-module.ts +26 -8
  135. package/src/preset/preset-command.ts +85 -27
  136. package/src/provider/__tests__/org-policy.test.ts +4 -5
  137. package/src/provider/__tests__/provider-command-module.test.ts +11 -14
  138. package/src/provider/__tests__/provider-startup-host-settings.test.ts +16 -0
  139. package/src/provider/__tests__/scripted-interaction.ts +7 -5
  140. package/src/provider/provider-command-execution.ts +4 -4
  141. package/src/provider/provider-command-module.ts +2 -1
  142. package/src/provider/provider-command-profile-lifecycle.ts +4 -4
  143. package/src/provider/provider-command-profile-operations.ts +3 -3
  144. package/src/provider/provider-command-profile.ts +1 -1
  145. package/src/provider/provider-command-setup.ts +4 -4
  146. package/src/provider/provider-startup.ts +62 -35
  147. package/src/remote-control/__tests__/remote-control-command-module.test.ts +36 -0
  148. package/src/remote-control/__tests__/remote-control-command.test.ts +216 -0
  149. package/src/remote-control/index.ts +8 -0
  150. package/src/remote-control/remote-control-command-module.ts +61 -0
  151. package/src/remote-control/remote-control-command.ts +138 -0
  152. package/src/reset/__tests__/reset-command-module.test.ts +5 -5
  153. package/src/reset/reset-command-module.ts +2 -1
  154. package/src/reset/reset-command.ts +3 -4
  155. package/src/rewind/__tests__/rewind-command-module.test.ts +40 -0
  156. package/src/rewind/rewind-command-module.ts +2 -1
  157. package/src/rewind/rewind-command.ts +73 -6
  158. package/src/sandbox/__tests__/sandbox-command.test.ts +80 -0
  159. package/src/sandbox/index.ts +2 -0
  160. package/src/sandbox/sandbox-command-module.ts +56 -0
  161. package/src/sandbox/sandbox-command.ts +94 -0
  162. package/src/schedule/__tests__/loop-command-functional.test.ts +386 -0
  163. package/src/schedule/__tests__/loop-command.test.ts +360 -0
  164. package/src/schedule/__tests__/loop-jitter.test.ts +25 -0
  165. package/src/schedule/__tests__/monitor-model-permission-functional.test.ts +126 -0
  166. package/src/schedule/__tests__/schedule-command.test.ts +143 -3
  167. package/src/schedule/__tests__/schedule-redos.test.ts +81 -0
  168. package/src/schedule/index.ts +3 -0
  169. package/src/schedule/loop-command.ts +333 -0
  170. package/src/schedule/loop-jitter.ts +41 -0
  171. package/src/schedule/schedule-command-module.ts +92 -14
  172. package/src/schedule/schedule-command.ts +98 -9
  173. package/src/schedule/schedule-spec-parser.ts +5 -1
  174. package/src/session/__tests__/rename-host-persistence.test.ts +72 -0
  175. package/src/session/__tests__/session-command-module.test.ts +210 -45
  176. package/src/session/session-command-module.ts +68 -37
  177. package/src/session/session-command.ts +147 -80
  178. package/src/settings/settings-command-module.ts +3 -2
  179. package/src/shell/__tests__/resolve-shell-host.test.ts +11 -0
  180. package/src/shell/__tests__/shell-command-functional.test.ts +23 -1
  181. package/src/shell/resolve-shell.ts +2 -2
  182. package/src/shell/shell-command-module.ts +6 -5
  183. package/src/shell/shell-command.ts +9 -5
  184. package/src/skills/__tests__/skills-command-module.test.ts +69 -28
  185. package/src/skills/index.ts +1 -0
  186. package/src/skills/skills-command-module.ts +22 -5
  187. package/src/skills/skills-command.ts +14 -13
  188. package/src/statusline/__tests__/statusline-command-module.test.ts +5 -4
  189. package/src/statusline/statusline-command-module.ts +2 -1
  190. package/src/statusline/statusline-command.ts +4 -4
  191. package/src/theme/__tests__/theme-command.test.ts +204 -0
  192. package/src/theme/index.ts +12 -0
  193. package/src/theme/theme-command-module.ts +211 -0
  194. package/src/user-local/__tests__/user-local-command.test.ts +145 -110
  195. package/src/user-local/user-local-command-constants.ts +1 -1
  196. package/src/user-local/user-local-command-module.ts +6 -5
  197. package/src/user-local/user-local-command.ts +12 -6
  198. package/src/user-local/user-local-memory-command.ts +17 -7
  199. package/src/plugins/default-plugin-command-adapter.ts +0 -164
  200. package/src/plugins/default-plugin-command-source-loader.ts +0 -31
@@ -2,10 +2,12 @@
2
2
  * FLOW-005: `/schedule` and `/monitor` command executors — create agent-wake background tasks.
3
3
  */
4
4
 
5
+ import { MonitorCommandRefusedError } from '@robota-sdk/agent-framework';
5
6
  import { parseScheduleSpec } from './schedule-spec-parser.js';
6
7
 
7
8
  import type { IAgentJobHostContext } from '@robota-sdk/agent-framework';
8
- import type { ICommandResult } from '@robota-sdk/agent-interface-transport';
9
+ import type { ICommandResult } from '@robota-sdk/agent-interface-command';
10
+ import type { IBackgroundTaskState } from '@robota-sdk/agent-interface-execution';
9
11
 
10
12
  const MAX_LABEL_LENGTH = 48;
11
13
 
@@ -14,13 +16,89 @@ function labelFor(prefix: string, instruction: string): string {
14
16
  return `${prefix}: ${snippet}${instruction.length > MAX_LABEL_LENGTH ? '…' : ''}`;
15
17
  }
16
18
 
19
+ // SELFHOST-012: management subcommands over the existing scheduler. Create (FLOW-005) stays the default form —
20
+ // a create spec always begins with `in`/`cron`, so these keywords never collide with it.
21
+ const MANAGE_USAGE =
22
+ 'Usage: /schedule list | pause <id> | resume <id> | edit <id> <spec> (or a create spec: in <N><s|m|h|d> … | cron "<expr>" …)';
23
+
24
+ /** One-line list view: id, status, cadence, label, and next-fire time. The `[status]` already conveys paused. */
25
+ function formatScheduleList(schedules: IBackgroundTaskState[]): string {
26
+ if (schedules.length === 0) return 'No schedules.';
27
+ return schedules
28
+ .map((s) => {
29
+ // `listSchedules()` only ever returns scheduled-kind tasks; the kind check is what lets
30
+ // TypeScript narrow to the member that carries `schedule`/`nextFireAt` (#2079).
31
+ const cadence =
32
+ s.kind === 'scheduled' ? (s.schedule?.cronExpression ?? '(unknown)') : '(unknown)';
33
+ const when = s.kind === 'scheduled' && s.nextFireAt ? `next ${s.nextFireAt}` : '—';
34
+ return `- ${s.id} [${s.status}] ${cadence} — ${s.label} (${when})`;
35
+ })
36
+ .join('\n');
37
+ }
38
+
39
+ async function executeScheduleManagement(
40
+ host: IAgentJobHostContext,
41
+ action: string,
42
+ rest: string,
43
+ now: number,
44
+ ): Promise<ICommandResult | undefined> {
45
+ if (action === 'list') {
46
+ const schedules = host.listSchedules();
47
+ return {
48
+ message: formatScheduleList(schedules),
49
+ success: true,
50
+ data: { count: schedules.length },
51
+ };
52
+ }
53
+ if (action === 'pause' || action === 'resume') {
54
+ const id = rest.split(/\s+/)[0];
55
+ if (!id) return { message: MANAGE_USAGE, success: false };
56
+ if (action === 'pause') {
57
+ await host.pauseSchedule(id);
58
+ return { message: `Schedule paused: ${id}`, success: true, data: { taskId: id } };
59
+ }
60
+ await host.resumeSchedule(id);
61
+ return { message: `Schedule resumed: ${id}`, success: true, data: { taskId: id } };
62
+ }
63
+ if (action === 'edit') {
64
+ const idSpaceIdx = rest.indexOf(' ');
65
+ const id = idSpaceIdx === -1 ? rest : rest.slice(0, idSpaceIdx);
66
+ const spec = idSpaceIdx === -1 ? '' : rest.slice(idSpaceIdx + 1).trim();
67
+ if (!id || !spec) return { message: MANAGE_USAGE, success: false };
68
+ const parsed = parseScheduleSpec(spec, now);
69
+ if (!parsed.ok) return { message: parsed.error, success: false };
70
+ await host.editSchedule(id, {
71
+ cronExpression: parsed.spec.cronExpression,
72
+ agentInstruction: parsed.spec.instruction,
73
+ // CMD-009: `list` renders the label, so it must follow the instruction it summarizes.
74
+ label: labelFor('Scheduled', parsed.spec.instruction),
75
+ });
76
+ return {
77
+ message: `Schedule updated: ${id} (cron \`${parsed.spec.cronExpression}\`).`,
78
+ success: true,
79
+ data: { taskId: id, cronExpression: parsed.spec.cronExpression },
80
+ };
81
+ }
82
+ return undefined; // not a management subcommand — fall through to create
83
+ }
84
+
17
85
  export async function executeScheduleCommand(
18
86
  host: IAgentJobHostContext,
19
87
  args: string,
20
88
  now: number = Date.now(),
21
89
  ): Promise<ICommandResult> {
90
+ const trimmed = args.trim();
91
+ const spaceIdx = trimmed.indexOf(' ');
92
+ const action = (spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx)).toLowerCase();
93
+ const rest = spaceIdx === -1 ? '' : trimmed.slice(spaceIdx + 1).trim();
94
+
95
+ const managed = await executeScheduleManagement(host, action, rest, now);
96
+ if (managed) return managed;
97
+
98
+ // Default: create (FLOW-005 form). On a parse failure also surface the management usage — a mistyped
99
+ // subcommand (e.g. `pasue <id>`) falls through here, and the create-only error hides list/pause/resume/edit.
22
100
  const parsed = parseScheduleSpec(args, now);
23
- if (!parsed.ok) return { message: parsed.error, success: false };
101
+ if (!parsed.ok) return { message: `${parsed.error}\n${MANAGE_USAGE}`, success: false };
24
102
 
25
103
  const { cronExpression, instruction, recurring } = parsed.spec;
26
104
  const task = await host.spawnScheduledWake({
@@ -43,7 +121,10 @@ const MONITOR_USAGE = 'Usage: /monitor "<command>" "<pattern>" <instruction>';
43
121
  function parseMonitorArgs(
44
122
  args: string,
45
123
  ): { command: string; matchPattern: string; instruction: string } | null {
46
- const match = /^["']([^"']+)["']\s+["']([^"']+)["']\s+(.+)$/.exec(args.trim());
124
+ // `(\S.*)` — not `(.+)`: `\s+(.+)` is ambiguous (`.` also matches spaces), so a failing input
125
+ // runs in O(n^2). Requiring a non-space first character pins the split point after the greedy
126
+ // `\s+` and makes the match linear, without changing which inputs are accepted.
127
+ const match = /^["']([^"']+)["']\s+["']([^"']+)["']\s+(\S.*)$/.exec(args.trim());
47
128
  if (!match) return null;
48
129
  const instruction = match[3]!.trim();
49
130
  if (instruction.length === 0) return null;
@@ -57,12 +138,20 @@ export async function executeMonitorCommand(
57
138
  const parsed = parseMonitorArgs(args);
58
139
  if (!parsed) return { message: MONITOR_USAGE, success: false };
59
140
 
60
- const task = await host.spawnMonitorWake({
61
- label: labelFor('Monitor', parsed.instruction),
62
- command: parsed.command,
63
- matchPattern: parsed.matchPattern,
64
- agentInstruction: parsed.instruction,
65
- });
141
+ let task: IBackgroundTaskState;
142
+ try {
143
+ task = await host.spawnMonitorWake({
144
+ label: labelFor('Monitor', parsed.instruction),
145
+ command: parsed.command,
146
+ matchPattern: parsed.matchPattern,
147
+ agentInstruction: parsed.instruction,
148
+ });
149
+ } catch (error) {
150
+ if (error instanceof MonitorCommandRefusedError) {
151
+ return { message: error.message, success: false };
152
+ }
153
+ throw error;
154
+ }
66
155
 
67
156
  return {
68
157
  message: `Monitoring \`${parsed.command}\` for /${parsed.matchPattern}/ — task ${task.id}.`,
@@ -50,7 +50,11 @@ export function parseScheduleSpec(args: string, now: number): TScheduleParseResu
50
50
 
51
51
  if (trimmed.startsWith('cron ')) {
52
52
  const rest = trimmed.slice(5).trim();
53
- const quoted = /^["']([^"']+)["']\s+(.+)$/.exec(rest);
53
+ // `(\S.*)` — not `(.+)`: `\s+(.+)` is ambiguous (`.` also matches spaces), so a failing
54
+ // input runs in O(n^2). Requiring the instruction to start with a non-space pins the split
55
+ // point after the greedy `\s+`, making the match linear. Behaviour is unchanged because the
56
+ // greedy `\s+` already consumed every leading space and the capture is trimmed below.
57
+ const quoted = /^["']([^"']+)["']\s+(\S.*)$/.exec(rest);
54
58
  if (!quoted) {
55
59
  return { ok: false, error: `cron form needs a quoted expression. ${USAGE}` };
56
60
  }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * CMD-004 Phase 2 (TC-10) — red-first `/rename` host-side persistence proof.
3
+ *
4
+ * Pre-Stage-B, `executeRenameCommand` only RETURNS the `session-renamed` effect and the mutation
5
+ * lives in the TUI renderer (`useSideEffects.ts` calls `interactiveSession.setName`), so a session
6
+ * with NO TUI attached renames nothing — these tests were recorded FAILING against that state
7
+ * (evidence in the spec's Evidence Log). Stage B makes the HOST execute the rename inside the
8
+ * `executeCommand` pipeline (direct-on-session action), strips the applied action from the returned
9
+ * result (so the TUI's duplicate application cannot fire — no double rename), and broadcasts
10
+ * `session_renamed` so every attached surface updates its title.
11
+ */
12
+
13
+ import { describe, expect, it, vi } from 'vitest';
14
+ import { InteractiveSession } from '@robota-sdk/agent-framework';
15
+ import type { ISessionRenamedEvent } from '@robota-sdk/agent-interface-session';
16
+ import { createSessionCommandModule } from '../session-command-module.js';
17
+
18
+ function createRuntimeSession(): Record<string, unknown> {
19
+ return {
20
+ run: vi.fn().mockResolvedValue('answer'),
21
+ abort: vi.fn(),
22
+ clearHistory: vi.fn(),
23
+ getHistory: vi.fn().mockReturnValue([]),
24
+ injectMessage: vi.fn(),
25
+ getContextState: () => ({
26
+ maxTokens: 100,
27
+ usedTokens: 0,
28
+ usedPercentage: 0,
29
+ remainingPercentage: 100,
30
+ }),
31
+ getSessionId: () => 'session_rename_tc10',
32
+ getMessageCount: () => 0,
33
+ getSystemMessage: vi.fn().mockReturnValue('system'),
34
+ getToolSchemas: vi.fn().mockReturnValue([]),
35
+ getEventService: () => ({ subscribe: () => {}, unsubscribe: () => {} }),
36
+ };
37
+ }
38
+
39
+ describe('CMD-004 TC-10 — /rename persists host-side without any TUI handler', () => {
40
+ it('mutates the session name via executeCommand alone and broadcasts session_renamed', async () => {
41
+ const session = new InteractiveSession({
42
+ session: createRuntimeSession() as never,
43
+ commandModules: [createSessionCommandModule()],
44
+ });
45
+ const renamedEvents: ISessionRenamedEvent[] = [];
46
+ session.on('session_renamed', (event) => renamedEvents.push(event));
47
+
48
+ const result = await session.executeCommand('rename', 'My Renamed Session');
49
+
50
+ expect(result?.success).toBe(true);
51
+ expect(result?.message).toBe('Session renamed to "My Renamed Session".');
52
+ // The HOST executed the mutation — no surface handler involved (the defect this TC pins).
53
+ expect(session.getName()).toBe('My Renamed Session');
54
+ // Broadcast so every attached surface (incl. co-driving ones) updates its title.
55
+ expect(renamedEvents).toEqual([{ name: 'My Renamed Session' }]);
56
+ // The applied host action is CONSUMED from the returned result — no surface can double-apply it.
57
+ expect(result?.hostActions).toBeUndefined();
58
+ });
59
+
60
+ it('applies the rename exactly once (single-execution guard)', async () => {
61
+ const session = new InteractiveSession({
62
+ session: createRuntimeSession() as never,
63
+ commandModules: [createSessionCommandModule()],
64
+ });
65
+ const setNameSpy = vi.spyOn(session, 'setName');
66
+
67
+ await session.executeCommand('rename', 'once');
68
+
69
+ expect(setNameSpy).toHaveBeenCalledTimes(1);
70
+ expect(setNameSpy).toHaveBeenCalledWith('once');
71
+ });
72
+ });
@@ -1,10 +1,21 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
- import type { ICommandHostContext, ICommandSessionRuntime } from '@robota-sdk/agent-framework';
2
+ import { calculateModelCost } from '@robota-sdk/agent-core';
3
+ import type {
4
+ ICommandCostBudget,
5
+ ICommandCostBudgetAdapter,
6
+ ICommandHostContext,
7
+ ICommandSessionRuntime,
8
+ ISessionUsageRecord,
9
+ } from '@robota-sdk/agent-framework';
3
10
  import { InteractiveSession, SystemCommandExecutor } from '@robota-sdk/agent-framework';
4
11
  import { createSessionCommandModule } from '../session-command-module.js';
12
+ import {
13
+ createTestCommandHost,
14
+ createTestSessionRuntime,
15
+ } from '@robota-sdk/agent-framework/testing';
5
16
 
6
- function createRuntime(): ICommandSessionRuntime {
7
- return {
17
+ function createRuntime() {
18
+ return createTestSessionRuntime({
8
19
  clearHistory: vi.fn(),
9
20
  compact: async () => undefined,
10
21
  getContextState: () => ({
@@ -21,31 +32,33 @@ function createRuntime(): ICommandSessionRuntime {
21
32
  getAutoCompactThreshold: () => false,
22
33
  getFullHistory: () => [],
23
34
  getHistory: () => [],
24
- };
35
+ });
25
36
  }
26
37
 
27
- function createCommandContext(): ICommandHostContext {
38
+ function createCommandContext() {
28
39
  const runtime = createRuntime();
29
- return {
30
- getSession: () => runtime,
31
- getContextState: () => runtime.getContextState(),
32
- getAutoCompactThreshold: () => 0.835,
33
- compactContext: async () => undefined,
34
- getCwd: () => '/workspace',
35
- listEditCheckpoints: () => [],
36
- restoreEditCheckpoint: async () => {
37
- throw new Error('not used');
38
- },
39
- rollbackEditCheckpoint: async () => {
40
- throw new Error('not used');
40
+ return createTestCommandHost({
41
+ overrides: {
42
+ getSession: () => runtime,
43
+ getContextState: () => runtime.getContextState(),
44
+ getAutoCompactThreshold: () => 0.835,
45
+ compactContext: async () => undefined,
46
+ getCwd: () => '/workspace',
47
+ listEditCheckpoints: () => [],
48
+ restoreEditCheckpoint: async () => {
49
+ throw new Error('not used');
50
+ },
51
+ rollbackEditCheckpoint: async () => {
52
+ throw new Error('not used');
53
+ },
54
+ getUsedMemoryReferences: () => [],
55
+ recordMemoryEvent: () => undefined,
56
+ listBackgroundTasks: () => [],
57
+ readBackgroundTaskLog: async (taskId) => ({ taskId, lines: [] }),
58
+ cancelBackgroundTask: async () => undefined,
59
+ closeBackgroundTask: async () => undefined,
41
60
  },
42
- getUsedMemoryReferences: () => [],
43
- recordMemoryEvent: () => undefined,
44
- listBackgroundTasks: () => [],
45
- readBackgroundTaskLog: async (taskId) => ({ taskId, lines: [] }),
46
- cancelBackgroundTask: async () => undefined,
47
- closeBackgroundTask: async () => undefined,
48
- };
61
+ });
49
62
  }
50
63
 
51
64
  describe('createSessionCommandModule', () => {
@@ -81,6 +94,7 @@ describe('createSessionCommandModule', () => {
81
94
  expect(module.commandSources?.[0]?.getCommands().map((item) => item.name)).toEqual([
82
95
  'clear',
83
96
  'rename',
97
+ 'cd',
84
98
  'resume',
85
99
  'cost',
86
100
  'validate-session',
@@ -88,6 +102,7 @@ describe('createSessionCommandModule', () => {
88
102
  expect(module.systemCommands?.map((item) => item.name)).toEqual([
89
103
  'clear',
90
104
  'rename',
105
+ 'cd',
91
106
  'resume',
92
107
  'cost',
93
108
  'validate-session',
@@ -108,6 +123,8 @@ describe('createSessionCommandModule', () => {
108
123
  modelInvocable: false,
109
124
  }),
110
125
  );
126
+ expect(entry?.argumentHint).toBe('<name>');
127
+ expect(command?.argumentHint).toBe(entry?.argumentHint);
111
128
  });
112
129
 
113
130
  it('provides resume metadata and user-only executable command from the same module owner', () => {
@@ -133,7 +150,7 @@ describe('createSessionCommandModule', () => {
133
150
  );
134
151
  });
135
152
 
136
- it('provides cost metadata and user-only executable command from the same module owner', () => {
153
+ it('provides cost metadata and a model-invocable report with a user-only budget from the same module owner', () => {
137
154
  const module = createSessionCommandModule();
138
155
  const command = module.systemCommands?.find((item) => item.name === 'cost');
139
156
  const entry = module.commandSources?.[0]?.getCommands().find((item) => item.name === 'cost');
@@ -143,7 +160,7 @@ describe('createSessionCommandModule', () => {
143
160
  name: 'cost',
144
161
  description: expect.stringContaining('token usage'),
145
162
  source: 'session',
146
- modelInvocable: false,
163
+ modelInvocable: true,
147
164
  }),
148
165
  );
149
166
  expect(command).toEqual(
@@ -151,9 +168,15 @@ describe('createSessionCommandModule', () => {
151
168
  name: 'cost',
152
169
  lifecycle: 'inline',
153
170
  userInvocable: true,
154
- modelInvocable: false,
171
+ modelInvocable: true,
155
172
  }),
156
173
  );
174
+ expect(entry?.argumentHint).toBe('[budget [<amount>|clear]]');
175
+ expect(command?.argumentHint).toBe(entry?.argumentHint);
176
+ expect(entry?.subcommands).toEqual([
177
+ expect.objectContaining({ name: 'budget', modelInvocable: false }),
178
+ ]);
179
+ expect(command?.subcommands).toEqual(entry?.subcommands);
157
180
  });
158
181
 
159
182
  it('provides validate-session metadata and user-only executable command from the same module owner', () => {
@@ -197,7 +220,6 @@ describe('createSessionCommandModule', () => {
197
220
  expect(result).toEqual({
198
221
  success: true,
199
222
  message: 'Conversation cleared.',
200
- effects: [{ type: 'conversation-history-cleared' }],
201
223
  });
202
224
  });
203
225
 
@@ -217,7 +239,7 @@ describe('createSessionCommandModule', () => {
217
239
  const result = await executor.execute('clear', context, '');
218
240
 
219
241
  expect(clearConversationHistory).toHaveBeenCalledTimes(1);
220
- expect(result?.effects).toEqual([{ type: 'conversation-history-cleared' }]);
242
+ expect(result?.success).toBe(true);
221
243
  });
222
244
 
223
245
  it('CMD-004: cancels the clear when the user declines', async () => {
@@ -239,11 +261,18 @@ describe('createSessionCommandModule', () => {
239
261
  expect(result?.message).toBe('Clear cancelled.');
240
262
  });
241
263
 
242
- it('falls back to runtime clearHistory when the host has not implemented the richer API', async () => {
264
+ it('ARCH-029 TC-09: /clear takes the host path only — it never reaches past it into the runtime', async () => {
265
+ // The deleted fallback called `getSession().clearHistory()` when the host member was absent.
266
+ // Those are not the same operation: the host's clear also broadcasts `history_cleared` to
267
+ // every attached surface (CMD-004 Stage E), so the fallback cleared ONE surface and left the
268
+ // others still showing the transcript. With the member required there is one path, and this
269
+ // pins that the runtime is not reached behind the host's back.
243
270
  const runtime = createRuntime();
271
+ const clearConversationHistory = vi.fn();
244
272
  const context = {
245
273
  ...createCommandContext(),
246
274
  getSession: () => runtime,
275
+ clearConversationHistory,
247
276
  };
248
277
  const executor = new SystemCommandExecutor([
249
278
  ...(createSessionCommandModule().systemCommands ?? []),
@@ -251,7 +280,8 @@ describe('createSessionCommandModule', () => {
251
280
 
252
281
  const result = await executor.execute('clear', context, '');
253
282
 
254
- expect(runtime.clearHistory).toHaveBeenCalledTimes(1);
283
+ expect(clearConversationHistory).toHaveBeenCalledTimes(1);
284
+ expect(runtime.clearHistory).not.toHaveBeenCalled();
255
285
  expect(result?.success).toBe(true);
256
286
  });
257
287
 
@@ -266,6 +296,8 @@ describe('createSessionCommandModule', () => {
266
296
  injectMessage: vi.fn(),
267
297
  getSystemMessage: vi.fn().mockReturnValue('system'),
268
298
  getToolSchemas: vi.fn().mockReturnValue([]),
299
+ // SELFHOST-004 P6: the span collector subscribes to the session bus each turn.
300
+ getEventService: () => ({ subscribe: () => {}, unsubscribe: () => {} }),
269
301
  };
270
302
  const session = new InteractiveSession({
271
303
  session: runtime as never,
@@ -279,7 +311,6 @@ describe('createSessionCommandModule', () => {
279
311
  expect(result).toEqual({
280
312
  success: true,
281
313
  message: 'Conversation cleared.',
282
- effects: [{ type: 'conversation-history-cleared' }],
283
314
  });
284
315
  expect(clearHistory).toHaveBeenCalledTimes(1);
285
316
  expect(session.getFullHistory()).toEqual([]);
@@ -296,7 +327,7 @@ describe('createSessionCommandModule', () => {
296
327
  success: true,
297
328
  message: 'Session renamed to "my-session".',
298
329
  data: { name: 'my-session' },
299
- effects: [{ type: 'session-renamed', name: 'my-session' }],
330
+ hostActions: [{ type: 'session-rename', name: 'my-session' }],
300
331
  });
301
332
  });
302
333
 
@@ -324,7 +355,7 @@ describe('createSessionCommandModule', () => {
324
355
  success: true,
325
356
  message: 'Opening session picker...',
326
357
  data: { triggerResumePicker: true },
327
- effects: [{ type: 'session-picker-requested' }],
358
+ uiIntents: [{ type: 'show-session-picker' }],
328
359
  });
329
360
  });
330
361
 
@@ -341,18 +372,27 @@ describe('createSessionCommandModule', () => {
341
372
  expect(result?.message).toContain('not yet available');
342
373
  });
343
374
 
344
- it('shows token counts and estimated cost when session has usage data', async () => {
345
- const runtime = {
346
- ...createRuntime(),
347
- getSessionTokenUsage: () => ({ inputTokens: 45_000, outputTokens: 12_000 }),
348
- getModelId: () => 'claude-sonnet-4-5',
349
- };
350
- const context = { ...createCommandContext(), getSession: () => runtime };
375
+ function costContext(records: readonly ISessionUsageRecord[], modelId = 'claude-sonnet-4-5') {
376
+ const runtime = { ...createRuntime(), getModelId: () => modelId };
377
+ return { ...createCommandContext(), getSession: () => runtime, getSessionUsage: () => records };
378
+ }
379
+
380
+ async function cost(records: readonly ISessionUsageRecord[], modelId?: string) {
351
381
  const executor = new SystemCommandExecutor([
352
382
  ...(createSessionCommandModule().systemCommands ?? []),
353
383
  ]);
384
+ return executor.execute('cost', costContext(records, modelId), '');
385
+ }
386
+
387
+ const advisorRecord: ISessionUsageRecord = {
388
+ promptTokens: 5_000,
389
+ completionTokens: 2_000,
390
+ costUsd: 1.5,
391
+ source: { scope: 'tool', id: 'advisor:big-model', label: 'Advisor (big-model)' },
392
+ };
354
393
 
355
- const result = await executor.execute('cost', context, '');
394
+ it('shows token counts and estimated cost when session has usage data', async () => {
395
+ const result = await cost([{ promptTokens: 45_000, completionTokens: 12_000 }]);
356
396
 
357
397
  expect(result?.success).toBe(true);
358
398
  expect(result?.message).toContain('45,000');
@@ -363,16 +403,127 @@ describe('createSessionCommandModule', () => {
363
403
  expect((result?.data as Record<string, unknown>)?.estimatedCostUsd).toBeDefined();
364
404
  });
365
405
 
366
- it('sets a monthly budget via /cost budget subcommand', async () => {
406
+ it('totals main and advisor usage, pricing the advisor on its own model', async () => {
407
+ const own = calculateModelCost('claude-sonnet-4-5', 40_000, 10_000)!;
408
+ const result = await cost([{ promptTokens: 40_000, completionTokens: 10_000 }, advisorRecord]);
409
+ const data = result?.data as Record<string, unknown>;
410
+
411
+ expect(data.inputTokens).toBe(45_000);
412
+ expect(data.outputTokens).toBe(12_000);
413
+ expect(data.estimatedCostUsd).toBeCloseTo(own + 1.5, 10);
414
+ expect(result?.message).toContain('mixed: claude-sonnet-4-5, Advisor (big-model)');
415
+ });
416
+
417
+ it('keeps the advisor price when the main model has none, and says the total is partial', async () => {
418
+ const result = await cost(
419
+ [{ promptTokens: 40_000, completionTokens: 10_000 }, advisorRecord],
420
+ 'unpriced-model',
421
+ );
422
+ const data = result?.data as Record<string, unknown>;
423
+ expect(data.estimatedCostUsd).toBe(1.5);
424
+ expect(data.costPartial).toBe(true);
425
+ expect(result?.message).toContain('some usage could not be priced');
426
+ });
427
+
428
+ it('does not price an unpriced advisor model at the main model rate', async () => {
429
+ const own = calculateModelCost('claude-sonnet-4-5', 40_000, 10_000)!;
430
+ const { costUsd: _price, ...unpricedAdvisor } = advisorRecord;
431
+ const result = await cost([
432
+ { promptTokens: 40_000, completionTokens: 10_000 },
433
+ unpricedAdvisor,
434
+ ]);
435
+ const data = result?.data as Record<string, unknown>;
436
+ expect(data.estimatedCostUsd).toBeCloseTo(own, 10);
437
+ expect(data.costPartial).toBe(true);
438
+ });
439
+
440
+ /**
441
+ * CMD-007 (issue #2058): the command talks to an injected `ICommandCostBudgetAdapter` and never to
442
+ * the filesystem — so the tests hold the budget in memory, with no casts and no temp directory.
443
+ * The file adapter's own policy (atomic write, symlink refusal) is tested where it lives, in agent-cli.
444
+ */
445
+ function memoryBudgetAdapter(initial?: ICommandCostBudget): ICommandCostBudgetAdapter & {
446
+ writes: string[];
447
+ } {
448
+ let stored = initial;
449
+ const adapter = {
450
+ writes: [] as string[],
451
+ read: () => stored,
452
+ write: (budget: ICommandCostBudget) => {
453
+ adapter.writes.push(`write:${budget.monthly}`);
454
+ stored = budget;
455
+ },
456
+ clear: () => {
457
+ adapter.writes.push('clear');
458
+ stored = undefined;
459
+ },
460
+ };
461
+ return adapter;
462
+ }
463
+
464
+ function budgetContext(adapter: ICommandCostBudgetAdapter | undefined) {
465
+ return {
466
+ ...createCommandContext(),
467
+ getCommandHostAdapters: () => (adapter ? { costBudget: adapter } : {}),
468
+ };
469
+ }
470
+
471
+ it('sets a monthly budget via /cost budget subcommand through the injected adapter', async () => {
367
472
  const executor = new SystemCommandExecutor([
368
473
  ...(createSessionCommandModule().systemCommands ?? []),
369
474
  ]);
370
- const context = { ...createCommandContext(), getCwd: () => '/tmp/robota-test-budget' };
475
+ const adapter = memoryBudgetAdapter();
371
476
 
372
- const result = await executor.execute('cost', context, 'budget 5.00');
477
+ const result = await executor.execute('cost', budgetContext(adapter), 'budget 5.00');
373
478
 
374
479
  expect(result?.success).toBe(true);
375
480
  expect(result?.message).toContain('$5.00');
481
+ expect(adapter.read()).toEqual({ monthly: 5 });
482
+ });
483
+
484
+ it('reads back a persisted budget and clears it through the adapter (set → restart → read)', async () => {
485
+ const executor = new SystemCommandExecutor([
486
+ ...(createSessionCommandModule().systemCommands ?? []),
487
+ ]);
488
+ // "restart": a fresh context over the same stored document
489
+ const adapter = memoryBudgetAdapter({ monthly: 5 });
490
+ const shown = await executor.execute('cost', budgetContext(adapter), 'budget');
491
+ expect(shown?.message).toContain('Monthly budget: $5.00');
492
+
493
+ const cleared = await executor.execute('cost', budgetContext(adapter), 'budget clear');
494
+ expect(cleared?.success).toBe(true);
495
+ expect(adapter.writes).toEqual(['clear']);
496
+ const after = await executor.execute('cost', budgetContext(adapter), 'budget');
497
+ expect(after?.message).toContain('No budget set');
498
+ });
499
+
500
+ it("surfaces the adapter's typed write failure instead of a filesystem error", async () => {
501
+ const executor = new SystemCommandExecutor([
502
+ ...(createSessionCommandModule().systemCommands ?? []),
503
+ ]);
504
+ const refusing: ICommandCostBudgetAdapter = {
505
+ read: () => undefined,
506
+ write: () => {
507
+ throw new Error('Refused: budget.json is a symbolic link');
508
+ },
509
+ clear: () => {
510
+ throw new Error('Refused: budget.json is a symbolic link');
511
+ },
512
+ };
513
+ for (const args of ['budget clear', 'budget 5.00']) {
514
+ const result = await executor.execute('cost', budgetContext(refusing), args);
515
+ expect(result?.success).toBe(false);
516
+ expect(result?.message).toContain('symbolic link');
517
+ }
518
+ });
519
+
520
+ it('says budget persistence is unavailable when the host composed no adapter', async () => {
521
+ const executor = new SystemCommandExecutor([
522
+ ...(createSessionCommandModule().systemCommands ?? []),
523
+ ]);
524
+ const result = await executor.execute('cost', budgetContext(undefined), 'budget 5.00');
525
+ expect(result?.success).toBe(false);
526
+ expect(result?.message).toContain('not available');
376
527
  });
377
528
 
378
529
  it('rejects invalid budget amount', async () => {
@@ -421,3 +572,17 @@ describe('createSessionCommandModule', () => {
421
572
  });
422
573
  });
423
574
  });
575
+
576
+ describe('/cd (issue #3081)', () => {
577
+ it('requests the host-executed move with the path as typed, and is user-only', async () => {
578
+ const module = createSessionCommandModule();
579
+ const entry = module.commandSources?.[0]?.getCommands().find((item) => item.name === 'cd');
580
+ const command = module.systemCommands?.find((item) => item.name === 'cd');
581
+ expect(entry?.modelInvocable).toBe(false);
582
+ expect(entry?.userInvocable).toBe(true);
583
+ const result = await command!.execute({} as never, ' ../api ');
584
+ expect(result.hostActions).toEqual([{ type: 'workspace-move', path: '../api' }]);
585
+ const usage = await command!.execute({} as never, ' ');
586
+ expect(usage.success).toBe(false);
587
+ });
588
+ });