@robota-sdk/agent-command 3.0.0-beta.100

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 (251) hide show
  1. package/CHANGELOG.md +1351 -0
  2. package/LICENSE +661 -0
  3. package/README.md +100 -0
  4. package/dist/node/index.cjs +121 -0
  5. package/dist/node/index.d.cts +1053 -0
  6. package/dist/node/index.d.cts.map +1 -0
  7. package/dist/node/index.d.ts +1053 -0
  8. package/dist/node/index.d.ts.map +1 -0
  9. package/dist/node/index.js +122 -0
  10. package/dist/node/index.js.map +1 -0
  11. package/package.json +83 -0
  12. package/src/advisor/advisor-command-module.ts +47 -0
  13. package/src/advisor/advisor-command.test.ts +102 -0
  14. package/src/advisor/advisor-command.ts +55 -0
  15. package/src/advisor/advisor-cost-functional.test.ts +92 -0
  16. package/src/advisor/index.ts +6 -0
  17. package/src/agent/__tests__/agent-command.test.ts +597 -0
  18. package/src/agent/agent-command-module.ts +89 -0
  19. package/src/agent/agent-command-parser.ts +180 -0
  20. package/src/agent/agent-command.ts +255 -0
  21. package/src/agent/index.ts +7 -0
  22. package/src/background/__tests__/background-command-module.test.ts +261 -0
  23. package/src/background/background-command-module.ts +52 -0
  24. package/src/background/background-command.ts +65 -0
  25. package/src/background/index.ts +6 -0
  26. package/src/command-module-utils.test.ts +37 -0
  27. package/src/command-module-utils.ts +35 -0
  28. package/src/compact/__tests__/compact-command-module.test.ts +181 -0
  29. package/src/compact/compact-command-module.ts +59 -0
  30. package/src/compact/compact-command.ts +33 -0
  31. package/src/compact/index.ts +6 -0
  32. package/src/context/__tests__/context-command-module.test.ts +502 -0
  33. package/src/context/auto-compact-format.ts +25 -0
  34. package/src/context/context-breakdown.ts +295 -0
  35. package/src/context/context-command-module.ts +82 -0
  36. package/src/context/context-command.ts +291 -0
  37. package/src/context/index.ts +6 -0
  38. package/src/default/__tests__/__snapshots__/model-exposure.test.ts.snap +121 -0
  39. package/src/default/__tests__/default-command-modules.test.ts +192 -0
  40. package/src/default/__tests__/model-exposure.test.ts +269 -0
  41. package/src/default/__tests__/org-policy-forwarding.test.ts +106 -0
  42. package/src/default/__tests__/provider-environment-forwarding.test.ts +98 -0
  43. package/src/default/default-command-modules.ts +247 -0
  44. package/src/default/index.ts +5 -0
  45. package/src/devices/__tests__/devices-command-module.test.ts +299 -0
  46. package/src/devices/devices-command-module.ts +308 -0
  47. package/src/devices/devices-command-port.ts +143 -0
  48. package/src/devices/index.ts +14 -0
  49. package/src/doctor/__tests__/doctor-command-module.test.ts +145 -0
  50. package/src/doctor/__tests__/doctor-display-vocabulary.test.ts +38 -0
  51. package/src/doctor/__tests__/doctor-fixture.ts +146 -0
  52. package/src/doctor/__tests__/doctor-redaction.test.ts +29 -0
  53. package/src/doctor/__tests__/doctor-runner.test.ts +484 -0
  54. package/src/doctor/doctor-command-module.ts +132 -0
  55. package/src/doctor/doctor-extensions-probe.ts +323 -0
  56. package/src/doctor/doctor-node-deps.ts +88 -0
  57. package/src/doctor/doctor-redaction.ts +99 -0
  58. package/src/doctor/doctor-render.ts +70 -0
  59. package/src/doctor/doctor-repair.ts +136 -0
  60. package/src/doctor/doctor-runner.ts +122 -0
  61. package/src/doctor/doctor-settings-probe.ts +239 -0
  62. package/src/doctor/doctor-storage-probe.ts +187 -0
  63. package/src/doctor/doctor-types.ts +109 -0
  64. package/src/doctor/index.ts +32 -0
  65. package/src/editor/__tests__/editor-command-functional.test.ts +124 -0
  66. package/src/editor/editor-command-module.ts +53 -0
  67. package/src/editor/editor-command.ts +58 -0
  68. package/src/editor/index.ts +7 -0
  69. package/src/editor/resolve-editor.ts +21 -0
  70. package/src/effort/effort-command-module.ts +51 -0
  71. package/src/effort/effort-command.test.ts +203 -0
  72. package/src/effort/effort-command.ts +136 -0
  73. package/src/effort/index.ts +6 -0
  74. package/src/events/__tests__/events-command.test.ts +73 -0
  75. package/src/events/events-command-module.ts +49 -0
  76. package/src/events/events-command.ts +69 -0
  77. package/src/events/index.ts +2 -0
  78. package/src/exit/__tests__/exit-command-module.test.ts +61 -0
  79. package/src/exit/exit-command-module.ts +47 -0
  80. package/src/exit/exit-command.ts +26 -0
  81. package/src/exit/index.ts +6 -0
  82. package/src/fork/__tests__/fork-command.test.ts +184 -0
  83. package/src/fork/fork-command-module.ts +61 -0
  84. package/src/fork/fork-command.ts +162 -0
  85. package/src/fork/index.ts +8 -0
  86. package/src/git/__tests__/fake-git-port.ts +35 -0
  87. package/src/git/__tests__/git-command-module.test.ts +111 -0
  88. package/src/git/__tests__/git-commit.test.ts +185 -0
  89. package/src/git/git-command-module.ts +138 -0
  90. package/src/git/git-commit.ts +254 -0
  91. package/src/git/index.ts +20 -0
  92. package/src/goal/__tests__/goal-command.test.ts +80 -0
  93. package/src/goal/goal-command-module.ts +49 -0
  94. package/src/goal/goal-command.ts +69 -0
  95. package/src/goal/index.ts +6 -0
  96. package/src/handoff/__tests__/handoff-command-module.test.ts +25 -0
  97. package/src/handoff/__tests__/handoff-command.test.ts +218 -0
  98. package/src/handoff/handoff-command-module.ts +49 -0
  99. package/src/handoff/handoff-command.ts +187 -0
  100. package/src/handoff/index.ts +6 -0
  101. package/src/help/__tests__/help-command-module.test.ts +134 -0
  102. package/src/help/__tests__/help-command.test.ts +130 -0
  103. package/src/help/help-command-module.ts +47 -0
  104. package/src/help/help-command.ts +11 -0
  105. package/src/help/index.ts +6 -0
  106. package/src/index.ts +163 -0
  107. package/src/keybindings/__tests__/keybindings-command-module.test.ts +57 -0
  108. package/src/keybindings/index.ts +6 -0
  109. package/src/keybindings/keybindings-command-module.ts +88 -0
  110. package/src/language/__tests__/language-command-module.test.ts +126 -0
  111. package/src/language/index.ts +6 -0
  112. package/src/language/language-command-module.ts +55 -0
  113. package/src/language/language-command.ts +52 -0
  114. package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +895 -0
  115. package/src/mcp-activation/__tests__/mcp-model-view.test.ts +242 -0
  116. package/src/mcp-activation/__tests__/mcp-skill-command.test.ts +141 -0
  117. package/src/mcp-activation/index.ts +12 -0
  118. package/src/mcp-activation/mcp-activation-command-module.ts +123 -0
  119. package/src/mcp-activation/mcp-activation-command.ts +662 -0
  120. package/src/mcp-activation/mcp-model-notice.ts +99 -0
  121. package/src/mcp-activation/mcp-model-status.ts +104 -0
  122. package/src/memory/__tests__/memory-command-module.test.ts +476 -0
  123. package/src/memory/__tests__/memory-command-unavailable.test.ts +63 -0
  124. package/src/memory/index.ts +6 -0
  125. package/src/memory/memory-command-module.ts +77 -0
  126. package/src/memory/memory-command.ts +326 -0
  127. package/src/mode/__tests__/mode-command-module.test.ts +230 -0
  128. package/src/mode/index.ts +6 -0
  129. package/src/mode/mode-command-module.ts +55 -0
  130. package/src/mode/mode-command.ts +67 -0
  131. package/src/model/__tests__/model-command-module.test.ts +80 -0
  132. package/src/model/__tests__/model-command.test.ts +265 -0
  133. package/src/model/index.ts +6 -0
  134. package/src/model/model-command-module.ts +56 -0
  135. package/src/model/model-command.ts +249 -0
  136. package/src/output-style/__tests__/output-style-command.test.ts +96 -0
  137. package/src/output-style/index.ts +6 -0
  138. package/src/output-style/output-style-command-module.ts +143 -0
  139. package/src/peers/__tests__/peers-command-module.test.ts +24 -0
  140. package/src/peers/__tests__/peers-command.test.ts +399 -0
  141. package/src/peers/index.ts +9 -0
  142. package/src/peers/peers-command-module.ts +49 -0
  143. package/src/peers/peers-command.ts +220 -0
  144. package/src/permissions/__tests__/permissions-command-module.test.ts +276 -0
  145. package/src/permissions/index.ts +6 -0
  146. package/src/permissions/permissions-command-module.ts +55 -0
  147. package/src/permissions/permissions-command.ts +89 -0
  148. package/src/plan/__tests__/plan-command.test.ts +129 -0
  149. package/src/plan/index.ts +9 -0
  150. package/src/plan/plan-command-module.ts +49 -0
  151. package/src/plan/plan-command.ts +92 -0
  152. package/src/plugin/__tests__/plugin-active-turn.test.ts +100 -0
  153. package/src/plugin/__tests__/plugin-command-module.test.ts +300 -0
  154. package/src/plugin/__tests__/plugin-host-boundary.test.ts +11 -0
  155. package/src/plugin/index.ts +7 -0
  156. package/src/plugin/plugin-command-module.ts +85 -0
  157. package/src/plugin/plugin-command.ts +271 -0
  158. package/src/preset/__tests__/host-supplied-preset-registry.test.ts +98 -0
  159. package/src/preset/__tests__/preset-command-module.test.ts +220 -0
  160. package/src/preset/index.ts +6 -0
  161. package/src/preset/preset-command-module.ts +80 -0
  162. package/src/preset/preset-command.ts +148 -0
  163. package/src/provider/__tests__/optional-provider-credentials.test.ts +130 -0
  164. package/src/provider/__tests__/org-policy.test.ts +174 -0
  165. package/src/provider/__tests__/provider-command-module.test.ts +884 -0
  166. package/src/provider/__tests__/provider-connection-setup.test.ts +567 -0
  167. package/src/provider/__tests__/provider-reference-switch.test.ts +113 -0
  168. package/src/provider/__tests__/provider-setup-flow.test.ts +244 -0
  169. package/src/provider/__tests__/provider-startup-host-settings.test.ts +16 -0
  170. package/src/provider/__tests__/scripted-interaction.ts +40 -0
  171. package/src/provider/index.ts +32 -0
  172. package/src/provider/provider-command-execution.ts +235 -0
  173. package/src/provider/provider-command-module.ts +82 -0
  174. package/src/provider/provider-command-profile-lifecycle.ts +239 -0
  175. package/src/provider/provider-command-profile-operations.ts +218 -0
  176. package/src/provider/provider-command-profile.ts +116 -0
  177. package/src/provider/provider-command-setup.ts +79 -0
  178. package/src/provider/provider-connection-setup.ts +287 -0
  179. package/src/provider/provider-connection-validation.ts +38 -0
  180. package/src/provider/provider-onboarding.ts +73 -0
  181. package/src/provider/provider-prompt-interaction.ts +28 -0
  182. package/src/provider/provider-setup-ask.ts +67 -0
  183. package/src/provider/provider-setup-flow.ts +326 -0
  184. package/src/provider/provider-startup.ts +225 -0
  185. package/src/remote-control/__tests__/remote-control-command-module.test.ts +36 -0
  186. package/src/remote-control/__tests__/remote-control-command.test.ts +216 -0
  187. package/src/remote-control/index.ts +8 -0
  188. package/src/remote-control/remote-control-command-module.ts +61 -0
  189. package/src/remote-control/remote-control-command.ts +138 -0
  190. package/src/reset/__tests__/reset-command-module.test.ts +63 -0
  191. package/src/reset/index.ts +2 -0
  192. package/src/reset/reset-command-module.ts +47 -0
  193. package/src/reset/reset-command.ts +10 -0
  194. package/src/rewind/__tests__/rewind-command-module.test.ts +287 -0
  195. package/src/rewind/index.ts +2 -0
  196. package/src/rewind/rewind-command-module.ts +56 -0
  197. package/src/rewind/rewind-command.ts +279 -0
  198. package/src/sandbox/__tests__/sandbox-command.test.ts +80 -0
  199. package/src/sandbox/index.ts +2 -0
  200. package/src/sandbox/sandbox-command-module.ts +56 -0
  201. package/src/sandbox/sandbox-command.ts +94 -0
  202. package/src/schedule/__tests__/loop-command-functional.test.ts +399 -0
  203. package/src/schedule/__tests__/loop-command.test.ts +594 -0
  204. package/src/schedule/__tests__/loop-jitter.test.ts +25 -0
  205. package/src/schedule/__tests__/monitor-model-permission-functional.test.ts +126 -0
  206. package/src/schedule/__tests__/schedule-command.test.ts +247 -0
  207. package/src/schedule/__tests__/schedule-redos.test.ts +114 -0
  208. package/src/schedule/index.ts +12 -0
  209. package/src/schedule/loop-command.ts +427 -0
  210. package/src/schedule/loop-jitter.ts +41 -0
  211. package/src/schedule/schedule-command-module.ts +184 -0
  212. package/src/schedule/schedule-command.ts +161 -0
  213. package/src/schedule/schedule-spec-parser.ts +70 -0
  214. package/src/session/__tests__/model-pricing.test.ts +66 -0
  215. package/src/session/__tests__/rename-host-persistence.test.ts +72 -0
  216. package/src/session/__tests__/session-command-module.test.ts +589 -0
  217. package/src/session/index.ts +17 -0
  218. package/src/session/model-pricing.ts +26 -0
  219. package/src/session/session-command-module.ts +197 -0
  220. package/src/session/session-command.ts +272 -0
  221. package/src/settings/index.ts +7 -0
  222. package/src/settings/settings-command-module.ts +47 -0
  223. package/src/shell/__tests__/resolve-shell-host.test.ts +11 -0
  224. package/src/shell/__tests__/shell-command-functional.test.ts +118 -0
  225. package/src/shell/index.ts +8 -0
  226. package/src/shell/resolve-shell.ts +25 -0
  227. package/src/shell/shell-command-module.ts +53 -0
  228. package/src/shell/shell-command.ts +48 -0
  229. package/src/shell/spawn-inherited.ts +32 -0
  230. package/src/skills/__tests__/skill-read-command.test.ts +64 -0
  231. package/src/skills/__tests__/skills-command-module.test.ts +200 -0
  232. package/src/skills/index.ts +7 -0
  233. package/src/skills/skill-read-command.ts +60 -0
  234. package/src/skills/skills-command-module.ts +76 -0
  235. package/src/skills/skills-command.ts +108 -0
  236. package/src/statusline/__tests__/statusline-command-module.test.ts +96 -0
  237. package/src/statusline/index.ts +6 -0
  238. package/src/statusline/statusline-command-module.ts +55 -0
  239. package/src/statusline/statusline-command.ts +80 -0
  240. package/src/terminal-client/__tests__/terminal-client-commands.test.ts +216 -0
  241. package/src/terminal-client/index.ts +5 -0
  242. package/src/terminal-client/terminal-client-commands.ts +109 -0
  243. package/src/theme/__tests__/theme-command.test.ts +204 -0
  244. package/src/theme/index.ts +12 -0
  245. package/src/theme/theme-command-module.ts +221 -0
  246. package/src/user-local/__tests__/user-local-command.test.ts +180 -0
  247. package/src/user-local/index.ts +13 -0
  248. package/src/user-local/user-local-command-constants.ts +5 -0
  249. package/src/user-local/user-local-command-module.ts +65 -0
  250. package/src/user-local/user-local-command.ts +214 -0
  251. package/src/user-local/user-local-memory-command.ts +160 -0
@@ -0,0 +1,589 @@
1
+ import { describe, expect, it, vi } from 'vitest';
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';
10
+ import { InteractiveSession, SystemCommandExecutor } from '@robota-sdk/agent-framework';
11
+ import { createSessionCommandModule } from '../session-command-module.js';
12
+ import {
13
+ createTestCommandHost,
14
+ createTestSessionRuntime,
15
+ } from '@robota-sdk/agent-framework/testing';
16
+
17
+ function createRuntime() {
18
+ return createTestSessionRuntime({
19
+ clearHistory: vi.fn(),
20
+ compact: async () => undefined,
21
+ getContextState: () => ({
22
+ maxTokens: 100,
23
+ usedTokens: 0,
24
+ usedPercentage: 0,
25
+ remainingPercentage: 100,
26
+ }),
27
+ getPermissionMode: () => 'default',
28
+ setPermissionMode: () => undefined,
29
+ getSessionId: () => 'session_1',
30
+ getMessageCount: () => 5,
31
+ getSessionAllowedTools: () => [],
32
+ getAutoCompactThreshold: () => false,
33
+ getFullHistory: () => [],
34
+ getHistory: () => [],
35
+ });
36
+ }
37
+
38
+ function createCommandContext() {
39
+ const runtime = createRuntime();
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,
60
+ },
61
+ });
62
+ }
63
+
64
+ describe('createSessionCommandModule', () => {
65
+ it('provides clear metadata and user-only executable command from one module owner', () => {
66
+ const module = createSessionCommandModule();
67
+ const command = module.systemCommands?.find((item) => item.name === 'clear');
68
+ const entry = module.commandSources?.[0]?.getCommands().find((item) => item.name === 'clear');
69
+
70
+ expect(module.name).toBe('agent-command-session');
71
+ expect(entry).toEqual(
72
+ expect.objectContaining({
73
+ name: 'clear',
74
+ description: 'Clear conversation history',
75
+ source: 'session',
76
+ modelInvocable: false,
77
+ }),
78
+ );
79
+ expect(command).toEqual(
80
+ expect.objectContaining({
81
+ name: 'clear',
82
+ lifecycle: 'inline',
83
+ userInvocable: true,
84
+ modelInvocable: false,
85
+ }),
86
+ );
87
+ });
88
+
89
+ it('provides rename metadata and user-only executable command from the same module owner', () => {
90
+ const module = createSessionCommandModule();
91
+ const command = module.systemCommands?.find((item) => item.name === 'rename');
92
+ const entry = module.commandSources?.[0]?.getCommands().find((item) => item.name === 'rename');
93
+
94
+ expect(module.commandSources?.[0]?.getCommands().map((item) => item.name)).toEqual([
95
+ 'clear',
96
+ 'rename',
97
+ 'cd',
98
+ 'resume',
99
+ 'cost',
100
+ 'validate-session',
101
+ ]);
102
+ expect(module.systemCommands?.map((item) => item.name)).toEqual([
103
+ 'clear',
104
+ 'rename',
105
+ 'cd',
106
+ 'resume',
107
+ 'cost',
108
+ 'validate-session',
109
+ ]);
110
+ expect(entry).toEqual(
111
+ expect.objectContaining({
112
+ name: 'rename',
113
+ description: 'Rename the current session',
114
+ source: 'session',
115
+ modelInvocable: false,
116
+ }),
117
+ );
118
+ expect(command).toEqual(
119
+ expect.objectContaining({
120
+ name: 'rename',
121
+ lifecycle: 'inline',
122
+ userInvocable: true,
123
+ modelInvocable: false,
124
+ }),
125
+ );
126
+ expect(entry?.argumentHint).toBe('<name>');
127
+ expect(command?.argumentHint).toBe(entry?.argumentHint);
128
+ });
129
+
130
+ it('provides resume metadata and user-only executable command from the same module owner', () => {
131
+ const module = createSessionCommandModule();
132
+ const command = module.systemCommands?.find((item) => item.name === 'resume');
133
+ const entry = module.commandSources?.[0]?.getCommands().find((item) => item.name === 'resume');
134
+
135
+ expect(entry).toEqual(
136
+ expect.objectContaining({
137
+ name: 'resume',
138
+ description: 'Resume a previous session',
139
+ source: 'session',
140
+ modelInvocable: false,
141
+ }),
142
+ );
143
+ expect(command).toEqual(
144
+ expect.objectContaining({
145
+ name: 'resume',
146
+ lifecycle: 'inline',
147
+ userInvocable: true,
148
+ modelInvocable: false,
149
+ }),
150
+ );
151
+ });
152
+
153
+ it('provides cost metadata and a model-invocable report with a user-only budget from the same module owner', () => {
154
+ const module = createSessionCommandModule();
155
+ const command = module.systemCommands?.find((item) => item.name === 'cost');
156
+ const entry = module.commandSources?.[0]?.getCommands().find((item) => item.name === 'cost');
157
+
158
+ expect(entry).toEqual(
159
+ expect.objectContaining({
160
+ name: 'cost',
161
+ description: expect.stringContaining('token usage'),
162
+ source: 'session',
163
+ modelInvocable: true,
164
+ }),
165
+ );
166
+ expect(command).toEqual(
167
+ expect.objectContaining({
168
+ name: 'cost',
169
+ lifecycle: 'inline',
170
+ userInvocable: true,
171
+ modelInvocable: true,
172
+ }),
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);
180
+ });
181
+
182
+ it('provides validate-session metadata and user-only executable command from the same module owner', () => {
183
+ const module = createSessionCommandModule();
184
+ const command = module.systemCommands?.find((item) => item.name === 'validate-session');
185
+ const entry = module.commandSources?.[0]
186
+ ?.getCommands()
187
+ .find((item) => item.name === 'validate-session');
188
+
189
+ expect(entry).toEqual(
190
+ expect.objectContaining({
191
+ name: 'validate-session',
192
+ description: 'Validate current session replay log',
193
+ source: 'session',
194
+ modelInvocable: false,
195
+ }),
196
+ );
197
+ expect(command).toEqual(
198
+ expect.objectContaining({
199
+ name: 'validate-session',
200
+ lifecycle: 'inline',
201
+ userInvocable: true,
202
+ modelInvocable: false,
203
+ }),
204
+ );
205
+ });
206
+
207
+ it('clears conversation history through the session command API', async () => {
208
+ const clearConversationHistory = vi.fn();
209
+ const context = {
210
+ ...createCommandContext(),
211
+ clearConversationHistory,
212
+ };
213
+ const executor = new SystemCommandExecutor([
214
+ ...(createSessionCommandModule().systemCommands ?? []),
215
+ ]);
216
+
217
+ const result = await executor.execute('clear', context, '');
218
+
219
+ expect(clearConversationHistory).toHaveBeenCalledTimes(1);
220
+ expect(result).toEqual({
221
+ success: true,
222
+ message: 'Conversation cleared.',
223
+ });
224
+ });
225
+
226
+ it('CMD-004: confirms before clearing and proceeds on yes', async () => {
227
+ const clearConversationHistory = vi.fn();
228
+ const context = {
229
+ ...createCommandContext(),
230
+ clearConversationHistory,
231
+ getUserInteraction: () => ({
232
+ ask: async () => ({ type: 'answer' as const, values: ['yes'] }),
233
+ }),
234
+ };
235
+ const executor = new SystemCommandExecutor([
236
+ ...(createSessionCommandModule().systemCommands ?? []),
237
+ ]);
238
+
239
+ const result = await executor.execute('clear', context, '');
240
+
241
+ expect(clearConversationHistory).toHaveBeenCalledTimes(1);
242
+ expect(result?.success).toBe(true);
243
+ });
244
+
245
+ it('CMD-004: cancels the clear when the user declines', async () => {
246
+ const clearConversationHistory = vi.fn();
247
+ const context = {
248
+ ...createCommandContext(),
249
+ clearConversationHistory,
250
+ getUserInteraction: () => ({
251
+ ask: async () => ({ type: 'answer' as const, values: ['no'] }),
252
+ }),
253
+ };
254
+ const executor = new SystemCommandExecutor([
255
+ ...(createSessionCommandModule().systemCommands ?? []),
256
+ ]);
257
+
258
+ const result = await executor.execute('clear', context, '');
259
+
260
+ expect(clearConversationHistory).not.toHaveBeenCalled();
261
+ expect(result?.message).toBe('Clear cancelled.');
262
+ });
263
+
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.
270
+ const runtime = createRuntime();
271
+ const clearConversationHistory = vi.fn();
272
+ const context = {
273
+ ...createCommandContext(),
274
+ getSession: () => runtime,
275
+ clearConversationHistory,
276
+ };
277
+ const executor = new SystemCommandExecutor([
278
+ ...(createSessionCommandModule().systemCommands ?? []),
279
+ ]);
280
+
281
+ const result = await executor.execute('clear', context, '');
282
+
283
+ expect(clearConversationHistory).toHaveBeenCalledTimes(1);
284
+ expect(runtime.clearHistory).not.toHaveBeenCalled();
285
+ expect(result?.success).toBe(true);
286
+ });
287
+
288
+ it('clears InteractiveSession history when executed through a composed session', async () => {
289
+ const clearHistory = vi.fn();
290
+ const runtime = {
291
+ ...createRuntime(),
292
+ clearHistory,
293
+ run: vi.fn().mockResolvedValue('answer'),
294
+ abort: vi.fn(),
295
+ assertInputAllowed: vi.fn(),
296
+ getHistory: vi.fn().mockReturnValue([]),
297
+ injectMessage: vi.fn(),
298
+ getSystemMessage: vi.fn().mockReturnValue('system'),
299
+ getToolSchemas: vi.fn().mockReturnValue([]),
300
+ // SELFHOST-004 P6: the span collector subscribes to the session bus each turn.
301
+ getEventService: () => ({ subscribe: () => {}, unsubscribe: () => {} }),
302
+ };
303
+ const session = new InteractiveSession({
304
+ session: runtime as never,
305
+ commandModules: [createSessionCommandModule()],
306
+ });
307
+ await session.submit('hello');
308
+ expect(session.getFullHistory().length).toBeGreaterThan(0);
309
+
310
+ const result = await session.executeCommand('clear', '');
311
+
312
+ expect(result).toEqual({
313
+ success: true,
314
+ message: 'Conversation cleared.',
315
+ });
316
+ expect(clearHistory).toHaveBeenCalledTimes(1);
317
+ expect(session.getFullHistory()).toEqual([]);
318
+ });
319
+
320
+ it('renames the current session through a typed host effect', async () => {
321
+ const executor = new SystemCommandExecutor([
322
+ ...(createSessionCommandModule().systemCommands ?? []),
323
+ ]);
324
+
325
+ const result = await executor.execute('rename', createCommandContext(), ' my-session ');
326
+
327
+ expect(result).toEqual({
328
+ success: true,
329
+ message: 'Session renamed to "my-session".',
330
+ data: { name: 'my-session' },
331
+ hostActions: [{ type: 'session-rename', name: 'my-session' }],
332
+ });
333
+ });
334
+
335
+ it('returns usage when rename is missing a session name', async () => {
336
+ const executor = new SystemCommandExecutor([
337
+ ...(createSessionCommandModule().systemCommands ?? []),
338
+ ]);
339
+
340
+ const result = await executor.execute('rename', createCommandContext(), ' ');
341
+
342
+ expect(result).toEqual({
343
+ success: false,
344
+ message: 'Usage: rename <name>',
345
+ });
346
+ });
347
+
348
+ it('requests the host session picker through a typed effect', async () => {
349
+ const executor = new SystemCommandExecutor([
350
+ ...(createSessionCommandModule().systemCommands ?? []),
351
+ ]);
352
+
353
+ const result = await executor.execute('resume', createCommandContext(), '');
354
+
355
+ expect(result).toEqual({
356
+ success: true,
357
+ message: 'Opening session picker...',
358
+ data: { triggerResumePicker: true },
359
+ uiIntents: [{ type: 'show-session-picker' }],
360
+ });
361
+ });
362
+
363
+ it('shows session info through the session command API (no token data yet)', async () => {
364
+ const executor = new SystemCommandExecutor([
365
+ ...(createSessionCommandModule().systemCommands ?? []),
366
+ ]);
367
+
368
+ const result = await executor.execute('cost', createCommandContext(), '');
369
+
370
+ expect(result?.success).toBe(true);
371
+ expect(result?.message).toContain('session_1');
372
+ expect(result?.message).toContain('Messages: 5');
373
+ expect(result?.message).toContain('not yet available');
374
+ });
375
+
376
+ function costContext(records: readonly ISessionUsageRecord[], modelId = 'claude-sonnet-4-5') {
377
+ const runtime = { ...createRuntime(), getModelId: () => modelId };
378
+ return { ...createCommandContext(), getSession: () => runtime, getSessionUsage: () => records };
379
+ }
380
+
381
+ async function cost(records: readonly ISessionUsageRecord[], modelId?: string) {
382
+ const executor = new SystemCommandExecutor([
383
+ ...(createSessionCommandModule().systemCommands ?? []),
384
+ ]);
385
+ return executor.execute('cost', costContext(records, modelId), '');
386
+ }
387
+
388
+ const advisorRecord: ISessionUsageRecord = {
389
+ promptTokens: 5_000,
390
+ completionTokens: 2_000,
391
+ costUsd: 1.5,
392
+ source: { scope: 'tool', id: 'advisor:big-model', label: 'Advisor (big-model)' },
393
+ };
394
+
395
+ it('shows token counts and estimated cost when session has usage data', async () => {
396
+ const result = await cost([{ promptTokens: 45_000, completionTokens: 12_000 }]);
397
+
398
+ expect(result?.success).toBe(true);
399
+ expect(result?.message).toContain('45,000');
400
+ expect(result?.message).toContain('12,000');
401
+ expect(result?.message).toContain('$');
402
+ expect((result?.data as Record<string, unknown>)?.inputTokens).toBe(45_000);
403
+ expect((result?.data as Record<string, unknown>)?.outputTokens).toBe(12_000);
404
+ expect((result?.data as Record<string, unknown>)?.estimatedCostUsd).toBeDefined();
405
+ });
406
+
407
+ it('totals main and advisor usage, pricing the advisor on its own model', async () => {
408
+ const own = calculateModelCost('claude-sonnet-4-5', 40_000, 10_000)!;
409
+ const result = await cost([{ promptTokens: 40_000, completionTokens: 10_000 }, advisorRecord]);
410
+ const data = result?.data as Record<string, unknown>;
411
+
412
+ expect(data.inputTokens).toBe(45_000);
413
+ expect(data.outputTokens).toBe(12_000);
414
+ expect(data.estimatedCostUsd).toBeCloseTo(own + 1.5, 10);
415
+ expect(result?.message).toContain('mixed: claude-sonnet-4-5, Advisor (big-model)');
416
+ });
417
+
418
+ it('keeps the advisor price when the main model has none, and says the total is partial', async () => {
419
+ const result = await cost(
420
+ [{ promptTokens: 40_000, completionTokens: 10_000 }, advisorRecord],
421
+ 'unpriced-model',
422
+ );
423
+ const data = result?.data as Record<string, unknown>;
424
+ expect(data.estimatedCostUsd).toBe(1.5);
425
+ expect(data.costPartial).toBe(true);
426
+ expect(result?.message).toContain('some usage could not be priced');
427
+ });
428
+
429
+ it('does not price an unpriced advisor model at the main model rate', async () => {
430
+ const own = calculateModelCost('claude-sonnet-4-5', 40_000, 10_000)!;
431
+ const { costUsd: _price, ...unpricedAdvisor } = advisorRecord;
432
+ const result = await cost([
433
+ { promptTokens: 40_000, completionTokens: 10_000 },
434
+ unpricedAdvisor,
435
+ ]);
436
+ const data = result?.data as Record<string, unknown>;
437
+ expect(data.estimatedCostUsd).toBeCloseTo(own, 10);
438
+ expect(data.costPartial).toBe(true);
439
+ });
440
+
441
+ /**
442
+ * CMD-007 (issue #2058): the command talks to an injected `ICommandCostBudgetAdapter` and never to
443
+ * the filesystem — so the tests hold the budget in memory, with no casts and no temp directory.
444
+ * The file adapter's own policy (atomic write, symlink refusal) is tested where it lives, in agent-cli.
445
+ */
446
+ function memoryBudgetAdapter(initial?: ICommandCostBudget): ICommandCostBudgetAdapter & {
447
+ writes: string[];
448
+ } {
449
+ let stored = initial;
450
+ const adapter = {
451
+ writes: [] as string[],
452
+ read: () => stored,
453
+ write: (budget: ICommandCostBudget) => {
454
+ adapter.writes.push(`write:${budget.monthly}`);
455
+ stored = budget;
456
+ },
457
+ clear: () => {
458
+ adapter.writes.push('clear');
459
+ stored = undefined;
460
+ },
461
+ };
462
+ return adapter;
463
+ }
464
+
465
+ function budgetContext(adapter: ICommandCostBudgetAdapter | undefined) {
466
+ return {
467
+ ...createCommandContext(),
468
+ getCommandHostAdapters: () => (adapter ? { costBudget: adapter } : {}),
469
+ };
470
+ }
471
+
472
+ it('sets a monthly budget via /cost budget subcommand through the injected adapter', async () => {
473
+ const executor = new SystemCommandExecutor([
474
+ ...(createSessionCommandModule().systemCommands ?? []),
475
+ ]);
476
+ const adapter = memoryBudgetAdapter();
477
+
478
+ const result = await executor.execute('cost', budgetContext(adapter), 'budget 5.00');
479
+
480
+ expect(result?.success).toBe(true);
481
+ expect(result?.message).toContain('$5.00');
482
+ expect(adapter.read()).toEqual({ monthly: 5 });
483
+ });
484
+
485
+ it('reads back a persisted budget and clears it through the adapter (set → restart → read)', async () => {
486
+ const executor = new SystemCommandExecutor([
487
+ ...(createSessionCommandModule().systemCommands ?? []),
488
+ ]);
489
+ // "restart": a fresh context over the same stored document
490
+ const adapter = memoryBudgetAdapter({ monthly: 5 });
491
+ const shown = await executor.execute('cost', budgetContext(adapter), 'budget');
492
+ expect(shown?.message).toContain('Monthly budget: $5.00');
493
+
494
+ const cleared = await executor.execute('cost', budgetContext(adapter), 'budget clear');
495
+ expect(cleared?.success).toBe(true);
496
+ expect(adapter.writes).toEqual(['clear']);
497
+ const after = await executor.execute('cost', budgetContext(adapter), 'budget');
498
+ expect(after?.message).toContain('No budget set');
499
+ });
500
+
501
+ it("surfaces the adapter's typed write failure instead of a filesystem error", async () => {
502
+ const executor = new SystemCommandExecutor([
503
+ ...(createSessionCommandModule().systemCommands ?? []),
504
+ ]);
505
+ const refusing: ICommandCostBudgetAdapter = {
506
+ read: () => undefined,
507
+ write: () => {
508
+ throw new Error('Refused: budget.json is a symbolic link');
509
+ },
510
+ clear: () => {
511
+ throw new Error('Refused: budget.json is a symbolic link');
512
+ },
513
+ };
514
+ for (const args of ['budget clear', 'budget 5.00']) {
515
+ const result = await executor.execute('cost', budgetContext(refusing), args);
516
+ expect(result?.success).toBe(false);
517
+ expect(result?.message).toContain('symbolic link');
518
+ }
519
+ });
520
+
521
+ it('says budget persistence is unavailable when the host composed no adapter', async () => {
522
+ const executor = new SystemCommandExecutor([
523
+ ...(createSessionCommandModule().systemCommands ?? []),
524
+ ]);
525
+ const result = await executor.execute('cost', budgetContext(undefined), 'budget 5.00');
526
+ expect(result?.success).toBe(false);
527
+ expect(result?.message).toContain('not available');
528
+ });
529
+
530
+ it('rejects invalid budget amount', async () => {
531
+ const executor = new SystemCommandExecutor([
532
+ ...(createSessionCommandModule().systemCommands ?? []),
533
+ ]);
534
+
535
+ const result = await executor.execute('cost', createCommandContext(), 'budget notanumber');
536
+
537
+ expect(result?.success).toBe(false);
538
+ expect(result?.message).toContain('Usage:');
539
+ });
540
+
541
+ it('validates the current session replay log through the SDK common API', async () => {
542
+ const executor = new SystemCommandExecutor([
543
+ ...(createSessionCommandModule().systemCommands ?? []),
544
+ ]);
545
+ const context = {
546
+ ...createCommandContext(),
547
+ validateCurrentSessionReplayLog: () => ({
548
+ logFile: '/workspace/.fixture-state/logs/session_1.jsonl',
549
+ entryCount: 2,
550
+ validation: {
551
+ ok: false,
552
+ issues: [
553
+ {
554
+ code: 'PROVIDER_NATIVE_RAW_PAYLOAD_MISSING' as const,
555
+ message: 'Provider request exec-1:1 has no provider-native payload.',
556
+ executionId: 'exec-1',
557
+ round: 1,
558
+ },
559
+ ],
560
+ },
561
+ }),
562
+ };
563
+
564
+ const result = await executor.execute('validate-session', context, '');
565
+
566
+ expect(result?.success).toBe(false);
567
+ expect(result?.message).toContain('PROVIDER_NATIVE_RAW_PAYLOAD_MISSING');
568
+ expect(result?.data).toEqual({
569
+ logFile: '/workspace/.fixture-state/logs/session_1.jsonl',
570
+ entryCount: 2,
571
+ issueCount: 1,
572
+ ok: false,
573
+ });
574
+ });
575
+ });
576
+
577
+ describe('/cd (issue #3081)', () => {
578
+ it('requests the host-executed move with the path as typed, and is user-only', async () => {
579
+ const module = createSessionCommandModule();
580
+ const entry = module.commandSources?.[0]?.getCommands().find((item) => item.name === 'cd');
581
+ const command = module.systemCommands?.find((item) => item.name === 'cd');
582
+ expect(entry?.modelInvocable).toBe(false);
583
+ expect(entry?.userInvocable).toBe(true);
584
+ const result = await command!.execute({} as never, ' ../api ');
585
+ expect(result.hostActions).toEqual([{ type: 'workspace-move', path: '../api' }]);
586
+ const usage = await command!.execute({} as never, ' ');
587
+ expect(usage.success).toBe(false);
588
+ });
589
+ });
@@ -0,0 +1,17 @@
1
+ export {
2
+ executeClearCommand,
3
+ executeCostCommand,
4
+ executeRenameCommand,
5
+ executeResumeCommand,
6
+ executeValidateSessionCommand,
7
+ CLEAR_COMMAND_MESSAGE,
8
+ } from './session-command.js';
9
+ export {
10
+ SessionCommandSource,
11
+ createClearCommandEntry,
12
+ createCostCommandEntry,
13
+ createRenameCommandEntry,
14
+ createResumeCommandEntry,
15
+ createSessionCommandModule,
16
+ createValidateSessionCommandEntry,
17
+ } from './session-command-module.js';
@@ -0,0 +1,26 @@
1
+ import { calculateModelCost } from '@robota-sdk/agent-core';
2
+
3
+ /**
4
+ * Returns USD cost, or undefined if the model is not in the pricing table.
5
+ * Delegates to the agent-core pricing SSOT (`calculateModelCost`); this package owns only the
6
+ * command-facing display helpers below.
7
+ */
8
+ export function calculateCost(
9
+ modelId: string,
10
+ inputTokens: number,
11
+ outputTokens: number,
12
+ ): number | undefined {
13
+ return calculateModelCost(modelId, inputTokens, outputTokens);
14
+ }
15
+
16
+ /** Format a USD amount for display (e.g. "$0.0043"). */
17
+ export function formatUsd(amount: number): string {
18
+ if (amount < 0.01) return `$${amount.toFixed(4)}`;
19
+ if (amount < 1) return `$${amount.toFixed(3)}`;
20
+ return `$${amount.toFixed(2)}`;
21
+ }
22
+
23
+ /** Format a token count with comma separators (e.g. "45,000"). */
24
+ export function formatTokens(count: number): string {
25
+ return count.toLocaleString('en-US');
26
+ }