@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
@@ -1,5 +1,11 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
- import type { IHistoryEntry } from '@robota-sdk/agent-core';
2
+ import {
3
+ createAssistantMessage,
4
+ createToolMessage,
5
+ createUserMessage,
6
+ messageToHistoryEntry,
7
+ } from '@robota-sdk/agent-core';
8
+ import type { IHistoryEntry, IToolSchema } from '@robota-sdk/agent-core';
3
9
  import type {
4
10
  ICommandHostContext,
5
11
  ICommandSessionRuntime,
@@ -8,11 +14,16 @@ import type {
8
14
  IContextReferenceRemoveResult,
9
15
  TAutoCompactThresholdSource,
10
16
  } from '@robota-sdk/agent-framework';
11
- import type { IContextReferenceItem } from '@robota-sdk/agent-interface-transport';
12
- import { SystemCommandExecutor } from '@robota-sdk/agent-framework';
17
+ import type { IContextReferenceItem } from '@robota-sdk/agent-interface-session';
18
+ import { CommandRegistry, SystemCommandExecutor } from '@robota-sdk/agent-framework';
13
19
  import { createContextCommandModule } from '../context-command-module.js';
20
+ import type { ICommandHostContextWindow } from '@robota-sdk/agent-framework';
21
+ import {
22
+ createTestCommandHost,
23
+ createTestSessionRuntime,
24
+ } from '@robota-sdk/agent-framework/testing';
14
25
 
15
- type TContextWindowState = ReturnType<ICommandHostContext['getContextState']>;
26
+ type TContextWindowState = ReturnType<ICommandHostContextWindow['getContextState']>;
16
27
  type TPermissionMode = ReturnType<ICommandSessionRuntime['getPermissionMode']>;
17
28
 
18
29
  const CONTEXT_STATE: TContextWindowState = {
@@ -58,12 +69,9 @@ const SYSTEM_REFERENCE: IContextReferenceItem = {
58
69
  lastUsedAt: '2026-05-05T00:00:00.000Z',
59
70
  };
60
71
 
61
- function createRuntime(
62
- state: { threshold: number | false },
63
- history: IHistoryEntry[] = [],
64
- ): ICommandSessionRuntime {
72
+ function createRuntime(state: { threshold: number | false }, history: IHistoryEntry[] = []) {
65
73
  let mode: TPermissionMode = 'default';
66
- return {
74
+ return createTestSessionRuntime({
67
75
  clearHistory: vi.fn(),
68
76
  compact: vi.fn().mockResolvedValue(undefined),
69
77
  getContextState: () => CONTEXT_STATE,
@@ -80,10 +88,12 @@ function createRuntime(
80
88
  setAutoCompactThreshold: (threshold) => {
81
89
  state.threshold = threshold;
82
90
  },
83
- };
91
+ });
84
92
  }
85
93
 
86
- function createCommandHostContext(threshold: number | false = 0.835): ICommandHostContext & {
94
+ function createCommandHostContext(threshold: number | false = 0.835): ReturnType<
95
+ typeof createTestCommandHost
96
+ > & {
87
97
  settings: Record<string, number | false>;
88
98
  source: TAutoCompactThresholdSource;
89
99
  references: IContextReferenceItem[];
@@ -96,6 +106,63 @@ function createCommandHostContext(threshold: number | false = 0.835): ICommandHo
96
106
  };
97
107
  const runtime = createRuntime(state);
98
108
  return {
109
+ ...createTestCommandHost({
110
+ overrides: {
111
+ getSession: () => runtime,
112
+ getContextState: () => CONTEXT_STATE,
113
+ getAutoCompactThreshold: () => state.threshold,
114
+ getAutoCompactThresholdSource: () => state.source,
115
+ setAutoCompactThreshold: (nextThreshold, source = 'session') => {
116
+ state.threshold = nextThreshold;
117
+ state.source = source;
118
+ },
119
+ getCommandHostAdapters: () => ({
120
+ settings: {
121
+ read: () => state.settings,
122
+ write: (settings) => {
123
+ const value = settings.autoCompactThreshold;
124
+ state.settings =
125
+ typeof value === 'number' || value === false ? { autoCompactThreshold: value } : {};
126
+ },
127
+ },
128
+ }),
129
+ compactContext: vi.fn(),
130
+ listContextReferences: () => [...state.references],
131
+ addContextReference: async (path): Promise<IContextReferenceAddResult> => {
132
+ const reference = {
133
+ ...MANUAL_REFERENCE,
134
+ relativePath: path,
135
+ sourcePath: `/workspace/${path}`,
136
+ originalReference: `@${path}`,
137
+ };
138
+ state.references = [...state.references, reference];
139
+ return { reference, evicted: [], diagnostics: [] };
140
+ },
141
+ removeContextReference: (path): IContextReferenceRemoveResult => {
142
+ const removed = state.references.find((reference) => reference.relativePath === path);
143
+ state.references = state.references.filter(
144
+ (reference) => reference.relativePath !== path,
145
+ );
146
+ return removed ? { removed } : {};
147
+ },
148
+ clearContextReferences: (): IContextReferenceClearResult => {
149
+ const removed = [...state.references];
150
+ state.references = [];
151
+ return { removed };
152
+ },
153
+ getCwd: () => '/workspace',
154
+ listCommands: () => [],
155
+ listEditCheckpoints: () => [],
156
+ restoreEditCheckpoint: vi.fn(),
157
+ rollbackEditCheckpoint: vi.fn(),
158
+ getUsedMemoryReferences: () => [],
159
+ recordMemoryEvent: vi.fn(),
160
+ listBackgroundTasks: () => [],
161
+ readBackgroundTaskLog: vi.fn().mockResolvedValue({ taskId: 'task_1', lines: [] }),
162
+ cancelBackgroundTask: vi.fn(),
163
+ closeBackgroundTask: vi.fn(),
164
+ },
165
+ }),
99
166
  get settings() {
100
167
  return state.settings;
101
168
  },
@@ -105,57 +172,6 @@ function createCommandHostContext(threshold: number | false = 0.835): ICommandHo
105
172
  get references() {
106
173
  return state.references;
107
174
  },
108
- getSession: () => runtime,
109
- getContextState: () => CONTEXT_STATE,
110
- getAutoCompactThreshold: () => state.threshold,
111
- getAutoCompactThresholdSource: () => state.source,
112
- setAutoCompactThreshold: (nextThreshold, source = 'session') => {
113
- state.threshold = nextThreshold;
114
- state.source = source;
115
- },
116
- getCommandHostAdapters: () => ({
117
- settings: {
118
- read: () => state.settings,
119
- write: (settings) => {
120
- const value = settings.autoCompactThreshold;
121
- state.settings =
122
- typeof value === 'number' || value === false ? { autoCompactThreshold: value } : {};
123
- },
124
- },
125
- }),
126
- compactContext: vi.fn(),
127
- listContextReferences: () => [...state.references],
128
- addContextReference: async (path): Promise<IContextReferenceAddResult> => {
129
- const reference = {
130
- ...MANUAL_REFERENCE,
131
- relativePath: path,
132
- sourcePath: `/workspace/${path}`,
133
- originalReference: `@${path}`,
134
- };
135
- state.references = [...state.references, reference];
136
- return { reference, evicted: [], diagnostics: [] };
137
- },
138
- removeContextReference: (path): IContextReferenceRemoveResult => {
139
- const removed = state.references.find((reference) => reference.relativePath === path);
140
- state.references = state.references.filter((reference) => reference.relativePath !== path);
141
- return removed ? { removed } : {};
142
- },
143
- clearContextReferences: (): IContextReferenceClearResult => {
144
- const removed = [...state.references];
145
- state.references = [];
146
- return { removed };
147
- },
148
- getCwd: () => '/workspace',
149
- listCommands: () => [],
150
- listEditCheckpoints: () => [],
151
- restoreEditCheckpoint: vi.fn(),
152
- rollbackEditCheckpoint: vi.fn(),
153
- getUsedMemoryReferences: () => [],
154
- recordMemoryEvent: vi.fn(),
155
- listBackgroundTasks: () => [],
156
- readBackgroundTaskLog: vi.fn().mockResolvedValue({ taskId: 'task_1', lines: [] }),
157
- cancelBackgroundTask: vi.fn(),
158
- closeBackgroundTask: vi.fn(),
159
175
  };
160
176
  }
161
177
 
@@ -164,6 +180,34 @@ function createExecutor(): SystemCommandExecutor {
164
180
  }
165
181
 
166
182
  describe('createContextCommandModule', () => {
183
+ it('preserves argument hints and specialized subcommands in both command projections', () => {
184
+ const module = createContextCommandModule();
185
+ const registry = new CommandRegistry();
186
+ registry.addModule(module);
187
+ const executor = new SystemCommandExecutor([...(module.systemCommands ?? [])]);
188
+ const entry = registry.getCommands().find((command) => command.name === 'context');
189
+ const command = executor.getCommand('context');
190
+ expect(entry?.argumentHint).toBe('list | add <path> | remove <path> | clear | auto ...');
191
+ expect(command?.argumentHint).toBe('list | add <path> | remove <path> | clear | auto ...');
192
+ expect(command?.subcommands?.map((subcommand) => subcommand.name)).toEqual([
193
+ 'list',
194
+ 'add',
195
+ 'remove',
196
+ 'clear',
197
+ 'auto',
198
+ ]);
199
+ expect(command?.subcommands).toEqual(registry.getSubcommands('context'));
200
+ expect(command).toMatchObject({
201
+ modelInvocable: true,
202
+ userInvocable: true,
203
+ requiresPermission: false,
204
+ });
205
+ expect(command?.lifecycle ?? 'inline').toBe('inline');
206
+ expect(command?.description).toBe(entry?.description);
207
+ expect(command?.modelDescription).toBe(entry?.modelDescription);
208
+ expect(executor.isModelInvocable('context')).toBe(true);
209
+ });
210
+
167
211
  it('provides context metadata and an executable command', () => {
168
212
  const module = createContextCommandModule();
169
213
  const command = module.systemCommands?.[0];
@@ -179,7 +223,7 @@ describe('createContextCommandModule', () => {
179
223
  expect(command).toEqual(
180
224
  expect.objectContaining({
181
225
  name: 'context',
182
- modelInvocable: false,
226
+ modelInvocable: true,
183
227
  }),
184
228
  );
185
229
  });
@@ -335,6 +379,39 @@ describe('createContextCommandModule', () => {
335
379
  expect(result?.message).toContain('References: 2 active, 0 observed');
336
380
  });
337
381
 
382
+ it('lists only the tool results still in context, not turns compaction summarized away', async () => {
383
+ const call = (id: string, name: string, arg: string) =>
384
+ createAssistantMessage(null, {
385
+ toolCalls: [
386
+ { id, type: 'function', function: { name, arguments: JSON.stringify({ path: arg }) } },
387
+ ],
388
+ });
389
+ const compacted = [
390
+ createUserMessage('old task'),
391
+ call('old-1', 'Read', 'compacted-away.ts'),
392
+ createToolMessage('old result', { toolCallId: 'old-1' }),
393
+ ];
394
+ const live = [
395
+ createUserMessage('new task'),
396
+ call('new-1', 'Read', 'still-live.ts'),
397
+ createToolMessage('new result', { toolCallId: 'new-1' }),
398
+ ];
399
+ const runtime = createTestSessionRuntime({
400
+ getContextState: () => CONTEXT_STATE,
401
+ getFullHistory: () => [...compacted, ...live].map(messageToHistoryEntry),
402
+ getHistory: () => live,
403
+ });
404
+ const context = createTestCommandHost({
405
+ overrides: { getSession: () => runtime, getContextState: () => CONTEXT_STATE },
406
+ });
407
+
408
+ const result = await createExecutor().execute('context', context, 'list');
409
+
410
+ expect(result?.message).toContain('still-live.ts');
411
+ expect(result?.message).not.toContain('compacted-away.ts');
412
+ expect(result?.message).toContain('Conversation history — 1 turn |');
413
+ });
414
+
338
415
  it('shows full context breakdown with empty sections when no references', async () => {
339
416
  const context = createCommandHostContext();
340
417
 
@@ -349,3 +426,77 @@ describe('createContextCommandModule', () => {
349
426
  expect(result?.message).not.toContain('~');
350
427
  });
351
428
  });
429
+
430
+ /**
431
+ * CLI-1990 TC-12 — `/context` reports what the TOOL SCHEMAS cost.
432
+ *
433
+ * Before this, no surface separated the tool schemas from the system prompt they are billed
434
+ * alongside: `estimateSerializedContextTokens` covered messages only, and the tool line in the
435
+ * breakdown counted tool RESULTS. So the saving deferral exists to produce had no observable at all.
436
+ * These cases pin the figure and, crucially, that it FALLS when tools are deferred — a smaller
437
+ * offered set is a smaller request, which is the whole claim.
438
+ */
439
+ function toolSchema(name: string): IToolSchema {
440
+ return {
441
+ name,
442
+ description: `the ${name} tool, described at a realistic length for a request`,
443
+ parameters: {
444
+ type: 'object',
445
+ properties: { path: { type: 'string', description: 'what to operate on' } },
446
+ },
447
+ };
448
+ }
449
+
450
+ function createHostWithTools(schemas: IToolSchema[]) {
451
+ const runtime = createTestSessionRuntime({
452
+ getContextState: () => CONTEXT_STATE,
453
+ getOfferedToolSchemas: () => schemas,
454
+ });
455
+ return createTestCommandHost({
456
+ overrides: {
457
+ getSession: () => runtime,
458
+ getContextState: () => CONTEXT_STATE,
459
+ listContextReferences: () => [],
460
+ },
461
+ });
462
+ }
463
+
464
+ describe('CLI-1990 TC-12 — /context reports toolSchemaTokens', () => {
465
+ it('reports a figure greater than zero for a session with tools', async () => {
466
+ const context = createHostWithTools([toolSchema('Read'), toolSchema('Write')]);
467
+
468
+ const result = await createExecutor().execute('context', context, 'list');
469
+
470
+ expect(result?.success).toBe(true);
471
+ const tokens = (result?.data as { toolSchemaTokens: number }).toolSchemaTokens;
472
+ expect(tokens).toBeGreaterThan(0);
473
+ expect(result?.message).toContain('Tool schemas (sent every turn)');
474
+ expect(result?.message).toContain('Read');
475
+ expect(result?.message).toContain('Write');
476
+ });
477
+
478
+ it('reports a SMALLER figure when tools are deferred — the saving, made observable', async () => {
479
+ const all = [toolSchema('Read'), toolSchema('Write'), toolSchema('Grep')];
480
+ const full = await createExecutor().execute('context', createHostWithTools(all), 'list');
481
+ // With deferral engaged the withheld schemas are not in the offered set, so they are not sent.
482
+ const deferred = await createExecutor().execute(
483
+ 'context',
484
+ createHostWithTools(all.slice(0, 1)),
485
+ 'list',
486
+ );
487
+
488
+ const fullTokens = (full?.data as { toolSchemaTokens: number }).toolSchemaTokens;
489
+ const deferredTokens = (deferred?.data as { toolSchemaTokens: number }).toolSchemaTokens;
490
+ expect(deferredTokens).toBeGreaterThan(0);
491
+ expect(deferredTokens).toBeLessThan(fullTokens);
492
+ });
493
+
494
+ it('reports zero, and no token label, for a session offering no tools', async () => {
495
+ const context = createHostWithTools([]);
496
+
497
+ const result = await createExecutor().execute('context', context, 'list');
498
+
499
+ expect((result?.data as { toolSchemaTokens: number }).toolSchemaTokens).toBe(0);
500
+ expect(result?.message).toContain('Tool schemas (sent every turn):\n (none)');
501
+ });
502
+ });
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Leaf formatting helpers for the auto-compact threshold line. Split out of `context-command.ts`
3
+ * so `context-breakdown.ts` can use {@link formatAutoCompactLine} without importing back from
4
+ * `context-command.ts`, which previously created an import cycle between the two.
5
+ */
6
+ import type { TAutoCompactThreshold, TAutoCompactThresholdSource } from '@robota-sdk/agent-framework';
7
+
8
+ const PERCENT = 100;
9
+
10
+ export function formatThreshold(threshold: TAutoCompactThreshold): string {
11
+ if (threshold === false) {
12
+ return 'disabled';
13
+ }
14
+ return `${Math.round(threshold * PERCENT)}%`;
15
+ }
16
+
17
+ export function formatAutoCompactLine(
18
+ threshold: TAutoCompactThreshold,
19
+ source: TAutoCompactThresholdSource,
20
+ ): string {
21
+ if (threshold === false) {
22
+ return `Auto compact: disabled (${source})`;
23
+ }
24
+ return `Auto compact: ${formatThreshold(threshold)} (${source})`;
25
+ }
@@ -0,0 +1,295 @@
1
+ /**
2
+ * The `/context` transcript breakdown: token accounting over the history plus its rendering.
3
+ *
4
+ * Split out of `context-command.ts` when that file passed its size floor. The seam is a real one and
5
+ * the role ports named it: this half only READS (`TContextReadHost`), while the command surface it
6
+ * came from dispatches subcommands and writes thresholds and references.
7
+ */
8
+
9
+ import { CONTEXT_ESTIMATE_CHARS_PER_TOKEN, estimateToolSchemaTokens } from '@robota-sdk/agent-core';
10
+ import {
11
+ listCommandContextReferences,
12
+ readAutoCompactThreshold,
13
+ readAutoCompactThresholdSource,
14
+ readCommandContextState,
15
+ } from '@robota-sdk/agent-framework';
16
+
17
+ import { formatAutoCompactLine } from './auto-compact-format.js';
18
+
19
+ import type { IHistoryEntry, TUniversalMessage } from '@robota-sdk/agent-core';
20
+ import type {
21
+ ICommandHostContextReferences,
22
+ ICommandHostContextWindow,
23
+ ICommandHostSessionAccess,
24
+ } from '@robota-sdk/agent-framework';
25
+ import type { ICommandResult } from '@robota-sdk/agent-interface-command';
26
+ import type { IContextReferenceItem } from '@robota-sdk/agent-interface-session';
27
+
28
+ /** What the breakdown reads — no adapter role, no writes. */
29
+ type TContextReadHost = ICommandHostContextReferences &
30
+ ICommandHostContextWindow &
31
+ ICommandHostSessionAccess;
32
+
33
+ const TOOL_ARG_MAX_LEN = 60;
34
+
35
+ function estimateTokens(charLength: number): number {
36
+ return Math.ceil(charLength / CONTEXT_ESTIMATE_CHARS_PER_TOKEN);
37
+ }
38
+
39
+ export function formatContextReferenceLine(reference: IContextReferenceItem): string {
40
+ return [
41
+ reference.relativePath,
42
+ `[${reference.loadType}, ${reference.status}]`,
43
+ `~${estimateTokens(reference.byteLength).toLocaleString()} tokens`,
44
+ ].join(' ');
45
+ }
46
+
47
+ // ── History analysis ────────────────────────────────────────────────────────
48
+
49
+ interface IToolResultSummary {
50
+ toolName: string;
51
+ displayArg: string;
52
+ }
53
+
54
+ interface IMessageTokensByRole {
55
+ systemTokens: number;
56
+ userTokens: number;
57
+ userCount: number;
58
+ assistantTokens: number;
59
+ assistantCount: number;
60
+ toolTokens: number;
61
+ toolCallCount: number;
62
+ totalTokens: number;
63
+ }
64
+
65
+ interface IToolDisplayList {
66
+ turnCount: number;
67
+ toolResults: IToolResultSummary[];
68
+ totalToolCallCount: number;
69
+ }
70
+
71
+ function parseToolCallArgs(argsJson: string): Record<string, unknown> | null {
72
+ try {
73
+ return JSON.parse(argsJson) as Record<string, unknown>;
74
+ } catch {
75
+ // allow-fallback: tool call arguments JSON may be malformed; display-only degradation, not a terminal path
76
+ return null;
77
+ }
78
+ }
79
+
80
+ function extractFirstToolArg(argsJson: string): string {
81
+ const parsed = parseToolCallArgs(argsJson);
82
+ if (parsed === null) return '';
83
+ const first = Object.values(parsed)[0];
84
+ const raw = typeof first === 'string' ? first : JSON.stringify(first);
85
+ return raw.length > TOOL_ARG_MAX_LEN ? `${raw.slice(0, TOOL_ARG_MAX_LEN)}…` : raw;
86
+ }
87
+
88
+ function computeMessageTokensByRole(rawMessages: TUniversalMessage[]): IMessageTokensByRole {
89
+ let systemTokens = 0;
90
+ let userTokens = 0;
91
+ let userCount = 0;
92
+ let assistantTokens = 0;
93
+ let assistantCount = 0;
94
+ let toolTokens = 0;
95
+ let toolCallCount = 0;
96
+
97
+ for (const msg of rawMessages) {
98
+ const t = Math.ceil(JSON.stringify(msg).length / CONTEXT_ESTIMATE_CHARS_PER_TOKEN);
99
+ if (msg.role === 'system') {
100
+ systemTokens += t;
101
+ } else if (msg.role === 'user') {
102
+ userTokens += t;
103
+ userCount++;
104
+ } else if (msg.role === 'assistant') {
105
+ assistantTokens += t;
106
+ assistantCount++;
107
+ } else if (msg.role === 'tool') {
108
+ toolTokens += t;
109
+ toolCallCount++;
110
+ }
111
+ }
112
+
113
+ return {
114
+ systemTokens,
115
+ userTokens,
116
+ userCount,
117
+ assistantTokens,
118
+ assistantCount,
119
+ toolTokens,
120
+ toolCallCount,
121
+ totalTokens: systemTokens + userTokens + assistantTokens + toolTokens,
122
+ };
123
+ }
124
+
125
+ /** One conversation item as the display reads it: its role and its message payload. */
126
+ interface IChatItem {
127
+ readonly type: string;
128
+ readonly data: unknown;
129
+ }
130
+
131
+ /** Every chat entry of the full history, including turns compaction has since summarized away. */
132
+ export function buildToolDisplayList(history: IHistoryEntry[]): IToolDisplayList {
133
+ return buildDisplayList(
134
+ history
135
+ .filter((entry) => entry.category === 'chat')
136
+ .map((entry) => ({ type: entry.type, data: entry.data })),
137
+ );
138
+ }
139
+
140
+ /** Only what the model still has in context — what `/context list` accounts for. */
141
+ export function buildLiveToolDisplayList(messages: readonly TUniversalMessage[]): IToolDisplayList {
142
+ return buildDisplayList(messages.map((message) => ({ type: message.role, data: message })));
143
+ }
144
+
145
+ function buildDisplayList(items: readonly IChatItem[]): IToolDisplayList {
146
+ type TToolCallEntry = { name: string; firstArg: string };
147
+ const toolCallMap = new Map<string, TToolCallEntry>();
148
+
149
+ for (const entry of items) {
150
+ if (entry.type !== 'assistant') continue;
151
+ const data = entry.data as {
152
+ toolCalls?: Array<{ id: string; function: { name: string; arguments: string } }>;
153
+ };
154
+ for (const tc of data.toolCalls ?? []) {
155
+ toolCallMap.set(tc.id, {
156
+ name: tc.function.name,
157
+ firstArg: extractFirstToolArg(tc.function.arguments),
158
+ });
159
+ }
160
+ }
161
+
162
+ let turnCount = 0;
163
+ let totalToolCallCount = 0;
164
+ const toolDisplayMap = new Map<string, IToolResultSummary>();
165
+
166
+ for (const entry of items) {
167
+ if (entry.type === 'user') {
168
+ turnCount++;
169
+ } else if (entry.type === 'tool') {
170
+ const data = entry.data as { toolCallId?: string };
171
+ const tc = toolCallMap.get(data.toolCallId ?? '');
172
+ const toolName = tc?.name ?? 'tool';
173
+ const displayArg = tc?.firstArg ?? '';
174
+ totalToolCallCount++;
175
+ toolDisplayMap.set(`${toolName}:${displayArg}`, { toolName, displayArg });
176
+ }
177
+ }
178
+
179
+ return {
180
+ turnCount,
181
+ toolResults: [...toolDisplayMap.values()],
182
+ totalToolCallCount,
183
+ };
184
+ }
185
+
186
+ // ── Full context breakdown (for /context list) ──────────────────────────────
187
+
188
+ function formatSection(title: string, tokens: number, lines: string[]): string {
189
+ const tokenLabel = tokens > 0 ? ` — ~${tokens.toLocaleString()} tokens` : '';
190
+ const header = `${title}${tokenLabel}:`;
191
+ return lines.length === 0
192
+ ? `${header}\n (none)`
193
+ : [header, ...lines.map((l) => ` ${l}`)].join('\n');
194
+ }
195
+
196
+ export function formatFullContextBreakdown(context: TContextReadHost): ICommandResult {
197
+ const state = readCommandContextState(context);
198
+ const autoCompactThreshold = readAutoCompactThreshold(context);
199
+ const autoCompactThresholdSource = readAutoCompactThresholdSource(context);
200
+ const rawMessages = context.getSession().getHistory();
201
+ const msgTokens = computeMessageTokensByRole(rawMessages);
202
+ // CLI-1990: what the TOOL SCHEMAS cost on every request — the offered set, so a deferred tool that
203
+ // has not been loaded is not counted, because it is not sent. Until now nothing separated this
204
+ // from the system prompt it is billed alongside, so the saving deferral produces had no observable.
205
+ const offeredToolSchemas = context.getSession().getOfferedToolSchemas();
206
+ const toolSchemaTokens = estimateToolSchemaTokens(offeredToolSchemas);
207
+ // The live messages, like the token counts beside it: a turn compaction summarized away is no
208
+ // longer in context, so listing its tool results would describe a context the model does not have.
209
+ const display = buildLiveToolDisplayList(rawMessages);
210
+ const references = listCommandContextReferences(context);
211
+
212
+ const systemRefs = references.filter((r) => r.loadType === 'system');
213
+ const manualRefs = references.filter((r) => r.loadType === 'manual');
214
+ const promptRefs = references.filter((r) => r.loadType === 'prompt-reference');
215
+
216
+ const systemRefTokens = systemRefs.reduce((s, r) => s + estimateTokens(r.byteLength), 0);
217
+ const manualTokens = manualRefs.reduce((s, r) => s + estimateTokens(r.byteLength), 0);
218
+ const promptRefTokens = promptRefs.reduce((s, r) => s + estimateTokens(r.byteLength), 0);
219
+
220
+ const convTurnLabel =
221
+ display.turnCount === 0
222
+ ? 'Conversation history — 0 turns'
223
+ : `Conversation history — ${display.turnCount} turn${display.turnCount !== 1 ? 's' : ''} | ~${msgTokens.totalTokens.toLocaleString()} tokens`;
224
+
225
+ const toolResultLines = display.toolResults.map(
226
+ (t) => `${t.toolName}${t.displayArg ? `: ${t.displayArg}` : ''}`,
227
+ );
228
+
229
+ const uniqueToolCount = display.toolResults.length;
230
+ const totalToolCallCount = display.totalToolCallCount;
231
+ const toolResultsLabel =
232
+ totalToolCallCount === 0
233
+ ? 'Tool results (0): (none)'
234
+ : uniqueToolCount < totalToolCallCount
235
+ ? `Tool results (${uniqueToolCount} unique / ${totalToolCallCount} calls, ~${msgTokens.toolTokens.toLocaleString()} tokens):`
236
+ : `Tool results (${uniqueToolCount}, ~${msgTokens.toolTokens.toLocaleString()} tokens):`;
237
+
238
+ const conversationLines: string[] =
239
+ display.turnCount === 0
240
+ ? []
241
+ : [
242
+ `User (${msgTokens.userCount}): ~${msgTokens.userTokens.toLocaleString()} tokens`,
243
+ `Assistant (${msgTokens.assistantCount}): ~${msgTokens.assistantTokens.toLocaleString()} tokens`,
244
+ ...(msgTokens.systemTokens > 0
245
+ ? [`System messages: ~${msgTokens.systemTokens.toLocaleString()} tokens`]
246
+ : []),
247
+ totalToolCallCount > 0
248
+ ? [toolResultsLabel, ...toolResultLines.map((l) => ` ${l}`)].join('\n')
249
+ : toolResultsLabel,
250
+ ];
251
+
252
+ const convSection =
253
+ display.turnCount === 0
254
+ ? `${convTurnLabel}:\n (none)`
255
+ : [`${convTurnLabel}:`, ...conversationLines.map((l) => ` ${l}`)].join('\n');
256
+
257
+ const message = [
258
+ `Context: ${state.usedTokens.toLocaleString()} / ${state.maxTokens.toLocaleString()} tokens (${Math.round(state.usedPercentage)}%)`,
259
+ formatAutoCompactLine(autoCompactThreshold, autoCompactThresholdSource),
260
+ '',
261
+ formatSection(
262
+ 'System prompt (active every turn)',
263
+ systemRefTokens,
264
+ systemRefs.map(formatContextReferenceLine),
265
+ ),
266
+ '',
267
+ formatSection(
268
+ 'Tool schemas (sent every turn)',
269
+ toolSchemaTokens,
270
+ offeredToolSchemas.map((schema) => schema.name),
271
+ ),
272
+ '',
273
+ convSection,
274
+ '',
275
+ formatSection('Manually added', manualTokens, manualRefs.map(formatContextReferenceLine)),
276
+ '',
277
+ formatSection(
278
+ 'Prompt references (@-syntax)',
279
+ promptRefTokens,
280
+ promptRefs.map(formatContextReferenceLine),
281
+ ),
282
+ ].join('\n');
283
+
284
+ return {
285
+ success: true,
286
+ message,
287
+ data: {
288
+ references,
289
+ history: { turnCount: display.turnCount, toolResults: display.toolResults },
290
+ // CLI-1990: machine-readable beside the rendered line, so a caller can compare the figure
291
+ // across configurations rather than parsing the message back out.
292
+ toolSchemaTokens,
293
+ },
294
+ };
295
+ }