@machina.ai/cell-cli-core 1.6.1-rc1 → 1.8.2-rc1

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 (259) hide show
  1. package/dist/index.d.ts +4 -3
  2. package/dist/index.js +4 -3
  3. package/dist/index.js.map +1 -1
  4. package/dist/package.json +1 -1
  5. package/dist/src/agents/codebase-investigator.d.ts +11 -0
  6. package/dist/src/agents/codebase-investigator.js +73 -0
  7. package/dist/src/agents/codebase-investigator.js.map +1 -0
  8. package/dist/src/agents/executor.d.ts +88 -0
  9. package/dist/src/agents/executor.js +417 -0
  10. package/dist/src/agents/executor.js.map +1 -0
  11. package/dist/src/agents/executor.test.d.ts +6 -0
  12. package/dist/src/agents/executor.test.js +419 -0
  13. package/dist/src/agents/executor.test.js.map +1 -0
  14. package/dist/src/agents/invocation.d.ts +43 -0
  15. package/dist/src/agents/invocation.js +100 -0
  16. package/dist/src/agents/invocation.js.map +1 -0
  17. package/dist/src/agents/invocation.test.d.ts +6 -0
  18. package/dist/src/agents/invocation.test.js +206 -0
  19. package/dist/src/agents/invocation.test.js.map +1 -0
  20. package/dist/src/agents/registry.d.ts +35 -0
  21. package/dist/src/agents/registry.js +58 -0
  22. package/dist/src/agents/registry.js.map +1 -0
  23. package/dist/src/agents/registry.test.d.ts +6 -0
  24. package/dist/src/agents/registry.test.js +146 -0
  25. package/dist/src/agents/registry.test.js.map +1 -0
  26. package/dist/src/agents/schema-utils.d.ts +39 -0
  27. package/dist/src/agents/schema-utils.js +57 -0
  28. package/dist/src/agents/schema-utils.js.map +1 -0
  29. package/dist/src/agents/schema-utils.test.d.ts +6 -0
  30. package/dist/src/agents/schema-utils.test.js +144 -0
  31. package/dist/src/agents/schema-utils.test.js.map +1 -0
  32. package/dist/src/agents/subagent-tool-wrapper.d.ts +36 -0
  33. package/dist/src/agents/subagent-tool-wrapper.js +47 -0
  34. package/dist/src/agents/subagent-tool-wrapper.js.map +1 -0
  35. package/dist/src/agents/subagent-tool-wrapper.test.d.ts +6 -0
  36. package/dist/src/agents/subagent-tool-wrapper.test.js +105 -0
  37. package/dist/src/agents/subagent-tool-wrapper.test.js.map +1 -0
  38. package/dist/src/agents/types.d.ts +116 -0
  39. package/dist/src/agents/types.js +17 -0
  40. package/dist/src/agents/types.js.map +1 -0
  41. package/dist/src/agents/utils.d.ts +15 -0
  42. package/dist/src/agents/utils.js +29 -0
  43. package/dist/src/agents/utils.js.map +1 -0
  44. package/dist/src/agents/utils.test.d.ts +6 -0
  45. package/dist/src/agents/utils.test.js +87 -0
  46. package/dist/src/agents/utils.test.js.map +1 -0
  47. package/dist/src/config/config.d.ts +15 -8
  48. package/dist/src/config/config.js +43 -14
  49. package/dist/src/config/config.js.map +1 -1
  50. package/dist/src/config/constants.d.ts +11 -0
  51. package/dist/src/config/constants.js +16 -0
  52. package/dist/src/config/constants.js.map +1 -0
  53. package/dist/src/core/baseLlmClient.d.ts +4 -0
  54. package/dist/src/core/baseLlmClient.js +24 -23
  55. package/dist/src/core/baseLlmClient.js.map +1 -1
  56. package/dist/src/core/baseLlmClient.test.js +76 -13
  57. package/dist/src/core/baseLlmClient.test.js.map +1 -1
  58. package/dist/src/core/client.d.ts +3 -2
  59. package/dist/src/core/client.js +37 -48
  60. package/dist/src/core/client.js.map +1 -1
  61. package/dist/src/core/client.test.js +277 -119
  62. package/dist/src/core/client.test.js.map +1 -1
  63. package/dist/src/core/coreToolScheduler.test.js +33 -23
  64. package/dist/src/core/coreToolScheduler.test.js.map +1 -1
  65. package/dist/src/core/geminiChat.d.ts +4 -3
  66. package/dist/src/core/geminiChat.js +55 -61
  67. package/dist/src/core/geminiChat.js.map +1 -1
  68. package/dist/src/core/geminiChat.test.js +241 -29
  69. package/dist/src/core/geminiChat.test.js.map +1 -1
  70. package/dist/src/core/logger.test.js +16 -16
  71. package/dist/src/core/logger.test.js.map +1 -1
  72. package/dist/src/core/nonInteractiveToolExecutor.test.js +11 -11
  73. package/dist/src/core/nonInteractiveToolExecutor.test.js.map +1 -1
  74. package/dist/src/core/prompts.js +3 -2
  75. package/dist/src/core/prompts.js.map +1 -1
  76. package/dist/src/core/turn.d.ts +1 -4
  77. package/dist/src/core/turn.js +2 -12
  78. package/dist/src/core/turn.js.map +1 -1
  79. package/dist/src/generated/git-commit.d.ts +2 -2
  80. package/dist/src/generated/git-commit.js +2 -2
  81. package/dist/src/ide/detect-ide.d.ts +45 -14
  82. package/dist/src/ide/detect-ide.js +32 -69
  83. package/dist/src/ide/detect-ide.js.map +1 -1
  84. package/dist/src/ide/detect-ide.test.js +29 -46
  85. package/dist/src/ide/detect-ide.test.js.map +1 -1
  86. package/dist/src/ide/ide-client.d.ts +4 -4
  87. package/dist/src/ide/ide-client.js +30 -29
  88. package/dist/src/ide/ide-client.js.map +1 -1
  89. package/dist/src/ide/ide-client.test.js +8 -21
  90. package/dist/src/ide/ide-client.test.js.map +1 -1
  91. package/dist/src/ide/ide-installer.d.ts +2 -2
  92. package/dist/src/ide/ide-installer.js +7 -9
  93. package/dist/src/ide/ide-installer.js.map +1 -1
  94. package/dist/src/ide/ide-installer.test.js +20 -13
  95. package/dist/src/ide/ide-installer.test.js.map +1 -1
  96. package/dist/src/index.d.ts +5 -2
  97. package/dist/src/index.js +5 -2
  98. package/dist/src/index.js.map +1 -1
  99. package/dist/src/mcp/oauth-provider.d.ts +4 -1
  100. package/dist/src/mcp/oauth-provider.js +31 -25
  101. package/dist/src/mcp/oauth-provider.js.map +1 -1
  102. package/dist/src/mcp/sa-impersonation-provider.d.ts +33 -0
  103. package/dist/src/mcp/sa-impersonation-provider.js +130 -0
  104. package/dist/src/mcp/sa-impersonation-provider.js.map +1 -0
  105. package/dist/src/mcp/sa-impersonation-provider.test.d.ts +6 -0
  106. package/dist/src/mcp/sa-impersonation-provider.test.js +117 -0
  107. package/dist/src/mcp/sa-impersonation-provider.test.js.map +1 -0
  108. package/dist/src/policy/policy-engine.js +11 -2
  109. package/dist/src/policy/policy-engine.js.map +1 -1
  110. package/dist/src/policy/policy-engine.test.js +45 -0
  111. package/dist/src/policy/policy-engine.test.js.map +1 -1
  112. package/dist/src/routing/strategies/compositeStrategy.js +4 -3
  113. package/dist/src/routing/strategies/compositeStrategy.js.map +1 -1
  114. package/dist/src/services/chatRecordingService.d.ts +1 -1
  115. package/dist/src/services/chatRecordingService.js +1 -1
  116. package/dist/src/services/chatRecordingService.js.map +1 -1
  117. package/dist/src/services/fileSystemService.d.ts +9 -0
  118. package/dist/src/services/fileSystemService.js +11 -0
  119. package/dist/src/services/fileSystemService.js.map +1 -1
  120. package/dist/src/services/shellExecutionService.d.ts +2 -0
  121. package/dist/src/services/shellExecutionService.js +48 -7
  122. package/dist/src/services/shellExecutionService.js.map +1 -1
  123. package/dist/src/services/shellExecutionService.test.js +13 -4
  124. package/dist/src/services/shellExecutionService.test.js.map +1 -1
  125. package/dist/src/telemetry/activity-detector.d.ts +41 -0
  126. package/dist/src/telemetry/activity-detector.js +61 -0
  127. package/dist/src/telemetry/activity-detector.js.map +1 -0
  128. package/dist/src/telemetry/activity-detector.test.d.ts +6 -0
  129. package/dist/src/telemetry/activity-detector.test.js +136 -0
  130. package/dist/src/telemetry/activity-detector.test.js.map +1 -0
  131. package/dist/src/telemetry/activity-types.d.ts +19 -0
  132. package/dist/src/telemetry/activity-types.js +21 -0
  133. package/dist/src/telemetry/activity-types.js.map +1 -0
  134. package/dist/src/telemetry/clearcut-logger/clearcut-logger.d.ts +6 -2
  135. package/dist/src/telemetry/clearcut-logger/clearcut-logger.js +155 -102
  136. package/dist/src/telemetry/clearcut-logger/clearcut-logger.js.map +1 -1
  137. package/dist/src/telemetry/clearcut-logger/clearcut-logger.test.d.ts +1 -0
  138. package/dist/src/telemetry/clearcut-logger/clearcut-logger.test.js +178 -33
  139. package/dist/src/telemetry/clearcut-logger/clearcut-logger.test.js.map +1 -1
  140. package/dist/src/telemetry/clearcut-logger/event-metadata-key.d.ts +108 -100
  141. package/dist/src/telemetry/clearcut-logger/event-metadata-key.js +116 -100
  142. package/dist/src/telemetry/clearcut-logger/event-metadata-key.js.map +1 -1
  143. package/dist/src/telemetry/config.d.ts +31 -0
  144. package/dist/src/telemetry/config.js +74 -0
  145. package/dist/src/telemetry/config.js.map +1 -0
  146. package/dist/src/telemetry/config.test.d.ts +6 -0
  147. package/dist/src/telemetry/config.test.js +124 -0
  148. package/dist/src/telemetry/config.test.js.map +1 -0
  149. package/dist/src/telemetry/constants.d.ts +6 -12
  150. package/dist/src/telemetry/constants.js +7 -12
  151. package/dist/src/telemetry/constants.js.map +1 -1
  152. package/dist/src/telemetry/index.d.ts +5 -1
  153. package/dist/src/telemetry/index.js +10 -0
  154. package/dist/src/telemetry/index.js.map +1 -1
  155. package/dist/src/telemetry/loggers.d.ts +3 -1
  156. package/dist/src/telemetry/loggers.js +78 -11
  157. package/dist/src/telemetry/loggers.js.map +1 -1
  158. package/dist/src/telemetry/loggers.test.circular.js +3 -3
  159. package/dist/src/telemetry/loggers.test.circular.js.map +1 -1
  160. package/dist/src/telemetry/loggers.test.js +126 -11
  161. package/dist/src/telemetry/loggers.test.js.map +1 -1
  162. package/dist/src/telemetry/metrics.d.ts +309 -11
  163. package/dist/src/telemetry/metrics.js +424 -110
  164. package/dist/src/telemetry/metrics.js.map +1 -1
  165. package/dist/src/telemetry/metrics.test.js +538 -15
  166. package/dist/src/telemetry/metrics.test.js.map +1 -1
  167. package/dist/src/telemetry/sdk.js +1 -1
  168. package/dist/src/telemetry/sdk.js.map +1 -1
  169. package/dist/src/telemetry/sdk.test.js +13 -0
  170. package/dist/src/telemetry/sdk.test.js.map +1 -1
  171. package/dist/src/telemetry/types.d.ts +19 -3
  172. package/dist/src/telemetry/types.js +37 -6
  173. package/dist/src/telemetry/types.js.map +1 -1
  174. package/dist/src/telemetry/uiTelemetry.d.ts +1 -1
  175. package/dist/src/telemetry/uiTelemetry.js +2 -3
  176. package/dist/src/telemetry/uiTelemetry.js.map +1 -1
  177. package/dist/src/telemetry/uiTelemetry.test.js +13 -13
  178. package/dist/src/telemetry/uiTelemetry.test.js.map +1 -1
  179. package/dist/src/test-utils/mock-tool.d.ts +28 -3
  180. package/dist/src/test-utils/mock-tool.js +71 -1
  181. package/dist/src/test-utils/mock-tool.js.map +1 -1
  182. package/dist/src/tools/glob.js +2 -1
  183. package/dist/src/tools/glob.js.map +1 -1
  184. package/dist/src/tools/ls.js +1 -1
  185. package/dist/src/tools/ls.js.map +1 -1
  186. package/dist/src/tools/mcp-client.d.ts +2 -12
  187. package/dist/src/tools/mcp-client.js +22 -65
  188. package/dist/src/tools/mcp-client.js.map +1 -1
  189. package/dist/src/tools/mcp-client.test.js +9 -154
  190. package/dist/src/tools/mcp-client.test.js.map +1 -1
  191. package/dist/src/tools/message-bus-integration.test.d.ts +6 -0
  192. package/dist/src/tools/message-bus-integration.test.js +183 -0
  193. package/dist/src/tools/message-bus-integration.test.js.map +1 -0
  194. package/dist/src/tools/shell.js +5 -2
  195. package/dist/src/tools/shell.js.map +1 -1
  196. package/dist/src/tools/smart-edit.d.ts +19 -0
  197. package/dist/src/tools/smart-edit.js +105 -3
  198. package/dist/src/tools/smart-edit.js.map +1 -1
  199. package/dist/src/tools/smart-edit.test.js +83 -5
  200. package/dist/src/tools/smart-edit.test.js.map +1 -1
  201. package/dist/src/tools/tool-error.d.ts +1 -0
  202. package/dist/src/tools/tool-error.js +1 -0
  203. package/dist/src/tools/tool-error.js.map +1 -1
  204. package/dist/src/tools/tool-registry.test.js +10 -10
  205. package/dist/src/tools/tool-registry.test.js.map +1 -1
  206. package/dist/src/tools/tools.d.ts +11 -3
  207. package/dist/src/tools/tools.js +94 -3
  208. package/dist/src/tools/tools.js.map +1 -1
  209. package/dist/src/tools/write-todos.d.ts +25 -0
  210. package/dist/src/tools/write-todos.js +150 -0
  211. package/dist/src/tools/write-todos.js.map +1 -0
  212. package/dist/src/tools/write-todos.test.d.ts +6 -0
  213. package/dist/src/tools/write-todos.test.js +89 -0
  214. package/dist/src/tools/write-todos.test.js.map +1 -0
  215. package/dist/src/utils/bfsFileSearch.d.ts +1 -1
  216. package/dist/src/utils/flashFallback.test.js +2 -2
  217. package/dist/src/utils/flashFallback.test.js.map +1 -1
  218. package/dist/src/utils/getFolderStructure.d.ts +1 -1
  219. package/dist/src/utils/getFolderStructure.js +1 -1
  220. package/dist/src/utils/getFolderStructure.js.map +1 -1
  221. package/dist/src/utils/llm-edit-fixer.js +11 -1
  222. package/dist/src/utils/llm-edit-fixer.js.map +1 -1
  223. package/dist/src/utils/llm-edit-fixer.test.js +81 -0
  224. package/dist/src/utils/llm-edit-fixer.test.js.map +1 -1
  225. package/dist/src/utils/memoryDiscovery.d.ts +1 -1
  226. package/dist/src/utils/memoryDiscovery.js +1 -1
  227. package/dist/src/utils/memoryDiscovery.js.map +1 -1
  228. package/dist/src/utils/memoryImportProcessor.js +13 -20
  229. package/dist/src/utils/memoryImportProcessor.js.map +1 -1
  230. package/dist/src/utils/memoryImportProcessor.test.js +14 -0
  231. package/dist/src/utils/memoryImportProcessor.test.js.map +1 -1
  232. package/dist/src/utils/retry.d.ts +3 -1
  233. package/dist/src/utils/retry.js +20 -5
  234. package/dist/src/utils/retry.js.map +1 -1
  235. package/dist/src/utils/retry.test.js +31 -2
  236. package/dist/src/utils/retry.test.js.map +1 -1
  237. package/dist/src/utils/schemaValidator.js +11 -1
  238. package/dist/src/utils/schemaValidator.js.map +1 -1
  239. package/dist/src/utils/schemaValidator.test.d.ts +6 -0
  240. package/dist/src/utils/schemaValidator.test.js +113 -0
  241. package/dist/src/utils/schemaValidator.test.js.map +1 -0
  242. package/dist/src/utils/shell-utils.js +5 -1
  243. package/dist/src/utils/shell-utils.js.map +1 -1
  244. package/dist/src/utils/shell-utils.test.js +5 -0
  245. package/dist/src/utils/shell-utils.test.js.map +1 -1
  246. package/dist/src/utils/terminalSerializer.d.ts +1 -4
  247. package/dist/src/utils/terminalSerializer.js +3 -3
  248. package/dist/src/utils/terminalSerializer.js.map +1 -1
  249. package/dist/src/utils/thoughtUtils.d.ts +21 -0
  250. package/dist/src/utils/thoughtUtils.js +39 -0
  251. package/dist/src/utils/thoughtUtils.js.map +1 -0
  252. package/dist/src/utils/thoughtUtils.test.d.ts +6 -0
  253. package/dist/src/utils/thoughtUtils.test.js +78 -0
  254. package/dist/src/utils/thoughtUtils.test.js.map +1 -0
  255. package/dist/tsconfig.tsbuildinfo +1 -1
  256. package/package.json +2 -2
  257. package/dist/src/test-utils/tools.d.ts +0 -45
  258. package/dist/src/test-utils/tools.js +0 -105
  259. package/dist/src/test-utils/tools.js.map +0 -1
@@ -4,7 +4,7 @@
4
4
  * SPDX-License-Identifier: Apache-2.0
5
5
  */
6
6
  import { describe, it, expect, vi, beforeEach, afterEach, } from 'vitest';
7
- import { findIndexAfterFraction, isThinkingDefault, isThinkingSupported, GeminiClient, } from './client.js';
7
+ import { findCompressSplitPoint, isThinkingDefault, isThinkingSupported, GeminiClient, } from './client.js';
8
8
  import { AuthType, } from './contentGenerator.js';
9
9
  import {} from './geminiChat.js';
10
10
  import { CompressionStatus, GeminiEventType, Turn, } from './turn.js';
@@ -15,6 +15,7 @@ import { setSimulate429 } from '../utils/testUtils.js';
15
15
  import { tokenLimit } from './tokenLimits.js';
16
16
  import { ideContextStore } from '../ide/ideContext.js';
17
17
  import { ClearcutLogger } from '../telemetry/clearcut-logger/clearcut-logger.js';
18
+ import { uiTelemetryService } from '../telemetry/uiTelemetry.js';
18
19
  // Mock fs module to prevent actual file system operations during tests
19
20
  const mockFileSystem = new Map();
20
21
  vi.mock('node:fs', () => {
@@ -76,6 +77,12 @@ vi.mock('../telemetry/index.js', () => ({
76
77
  logApiError: vi.fn(),
77
78
  }));
78
79
  vi.mock('../ide/ideContext.js');
80
+ vi.mock('../telemetry/uiTelemetry.js', () => ({
81
+ uiTelemetryService: {
82
+ setLastPromptTokenCount: vi.fn(),
83
+ getLastPromptTokenCount: vi.fn(),
84
+ },
85
+ }));
79
86
  /**
80
87
  * Array.fromAsync ponyfill, which will be available in es 2024.
81
88
  *
@@ -88,42 +95,60 @@ async function fromAsync(promise) {
88
95
  }
89
96
  return results;
90
97
  }
91
- describe('findIndexAfterFraction', () => {
92
- const history = [
93
- { role: 'user', parts: [{ text: 'This is the first message.' }] }, // JSON length: 66
94
- { role: 'model', parts: [{ text: 'This is the second message.' }] }, // JSON length: 68
95
- { role: 'user', parts: [{ text: 'This is the third message.' }] }, // JSON length: 66
96
- { role: 'model', parts: [{ text: 'This is the fourth message.' }] }, // JSON length: 68
97
- { role: 'user', parts: [{ text: 'This is the fifth message.' }] }, // JSON length: 65
98
- ];
99
- // Total length: 333
98
+ describe('findCompressSplitPoint', () => {
100
99
  it('should throw an error for non-positive numbers', () => {
101
- expect(() => findIndexAfterFraction(history, 0)).toThrow('Fraction must be between 0 and 1');
100
+ expect(() => findCompressSplitPoint([], 0)).toThrow('Fraction must be between 0 and 1');
102
101
  });
103
102
  it('should throw an error for a fraction greater than or equal to 1', () => {
104
- expect(() => findIndexAfterFraction(history, 1)).toThrow('Fraction must be between 0 and 1');
103
+ expect(() => findCompressSplitPoint([], 1)).toThrow('Fraction must be between 0 and 1');
104
+ });
105
+ it('should handle an empty history', () => {
106
+ expect(findCompressSplitPoint([], 0.5)).toBe(0);
105
107
  });
106
108
  it('should handle a fraction in the middle', () => {
107
- // 333 * 0.5 = 166.5
108
- // 0: 66
109
- // 1: 66 + 68 = 134
110
- // 2: 134 + 66 = 200
111
- // 200 >= 166.5, so index is 3
112
- expect(findIndexAfterFraction(history, 0.5)).toBe(3);
109
+ const history = [
110
+ { role: 'user', parts: [{ text: 'This is the first message.' }] }, // JSON length: 66 (19%)
111
+ { role: 'model', parts: [{ text: 'This is the second message.' }] }, // JSON length: 68 (40%)
112
+ { role: 'user', parts: [{ text: 'This is the third message.' }] }, // JSON length: 66 (60%)
113
+ { role: 'model', parts: [{ text: 'This is the fourth message.' }] }, // JSON length: 68 (80%)
114
+ { role: 'user', parts: [{ text: 'This is the fifth message.' }] }, // JSON length: 65 (100%)
115
+ ];
116
+ expect(findCompressSplitPoint(history, 0.5)).toBe(4);
113
117
  });
114
- it('should handle a fraction that results in the last index', () => {
115
- // 333 * 0.9 = 299.7
116
- // ...
117
- // 3: 200 + 68 = 268
118
- // 4: 268 + 65 = 333
119
- // 333 >= 299.7, so index is 5
120
- expect(findIndexAfterFraction(history, 0.9)).toBe(5);
118
+ it('should handle a fraction of last index', () => {
119
+ const history = [
120
+ { role: 'user', parts: [{ text: 'This is the first message.' }] }, // JSON length: 66 (19%)
121
+ { role: 'model', parts: [{ text: 'This is the second message.' }] }, // JSON length: 68 (40%)
122
+ { role: 'user', parts: [{ text: 'This is the third message.' }] }, // JSON length: 66 (60%)
123
+ { role: 'model', parts: [{ text: 'This is the fourth message.' }] }, // JSON length: 68 (80%)
124
+ { role: 'user', parts: [{ text: 'This is the fifth message.' }] }, // JSON length: 65 (100%)
125
+ ];
126
+ expect(findCompressSplitPoint(history, 0.9)).toBe(4);
121
127
  });
122
- it('should handle an empty history', () => {
123
- expect(findIndexAfterFraction([], 0.5)).toBe(0);
128
+ it('should handle a fraction of after last index', () => {
129
+ const history = [
130
+ { role: 'user', parts: [{ text: 'This is the first message.' }] }, // JSON length: 66 (24%%)
131
+ { role: 'model', parts: [{ text: 'This is the second message.' }] }, // JSON length: 68 (50%)
132
+ { role: 'user', parts: [{ text: 'This is the third message.' }] }, // JSON length: 66 (74%)
133
+ { role: 'model', parts: [{ text: 'This is the fourth message.' }] }, // JSON length: 68 (100%)
134
+ ];
135
+ expect(findCompressSplitPoint(history, 0.8)).toBe(4);
136
+ });
137
+ it('should return earlier splitpoint if no valid ones are after threshhold', () => {
138
+ const history = [
139
+ { role: 'user', parts: [{ text: 'This is the first message.' }] },
140
+ { role: 'model', parts: [{ text: 'This is the second message.' }] },
141
+ { role: 'user', parts: [{ text: 'This is the third message.' }] },
142
+ { role: 'model', parts: [{ functionCall: {} }] },
143
+ ];
144
+ // Can't return 4 because the previous item has a function call.
145
+ expect(findCompressSplitPoint(history, 0.99)).toBe(2);
124
146
  });
125
147
  it('should handle a history with only one item', () => {
126
- expect(findIndexAfterFraction(history.slice(0, 1), 0.5)).toBe(1);
148
+ const historyWithEmptyParts = [
149
+ { role: 'user', parts: [{ text: 'Message 1' }] },
150
+ ];
151
+ expect(findCompressSplitPoint(historyWithEmptyParts, 0.5)).toBe(0);
127
152
  });
128
153
  it('should handle history with weird parts', () => {
129
154
  const historyWithEmptyParts = [
@@ -131,7 +156,7 @@ describe('findIndexAfterFraction', () => {
131
156
  { role: 'model', parts: [{ fileData: { fileUri: 'derp' } }] },
132
157
  { role: 'user', parts: [{ text: 'Message 2' }] },
133
158
  ];
134
- expect(findIndexAfterFraction(historyWithEmptyParts, 0.5)).toBe(2);
159
+ expect(findCompressSplitPoint(historyWithEmptyParts, 0.5)).toBe(2);
135
160
  });
136
161
  });
137
162
  describe('isThinkingSupported', () => {
@@ -168,6 +193,7 @@ describe('Gemini Client (client.ts)', () => {
168
193
  let mockGenerateContentFn;
169
194
  beforeEach(async () => {
170
195
  vi.resetAllMocks();
196
+ vi.mocked(uiTelemetryService.setLastPromptTokenCount).mockClear();
171
197
  mockGenerateContentFn = vi.fn().mockResolvedValue({
172
198
  candidates: [{ content: { parts: [{ text: '{"key": "value"}' }] } }],
173
199
  });
@@ -176,7 +202,6 @@ describe('Gemini Client (client.ts)', () => {
176
202
  mockContentGenerator = {
177
203
  generateContent: mockGenerateContentFn,
178
204
  generateContentStream: vi.fn(),
179
- countTokens: vi.fn().mockResolvedValue({ totalTokens: 100 }),
180
205
  batchEmbedContents: vi.fn(),
181
206
  };
182
207
  // Because the GeminiClient constructor kicks off an async process (startChat)
@@ -299,72 +324,128 @@ describe('Gemini Client (client.ts)', () => {
299
324
  function setup({ chatHistory = [
300
325
  { role: 'user', parts: [{ text: 'Long conversation' }] },
301
326
  { role: 'model', parts: [{ text: 'Long response' }] },
302
- ], } = {}) {
303
- const mockChat = {
304
- getHistory: vi.fn().mockReturnValue(chatHistory),
327
+ ], originalTokenCount = 1000, summaryText = 'This is a summary.', } = {}) {
328
+ const mockOriginalChat = {
329
+ getHistory: vi.fn((_curated) => chatHistory),
305
330
  setHistory: vi.fn(),
306
331
  };
307
- vi.mocked(mockContentGenerator.countTokens)
308
- .mockResolvedValueOnce({ totalTokens: 1000 })
309
- .mockResolvedValueOnce({ totalTokens: 5000 });
310
- client['chat'] = mockChat;
311
- client['startChat'] = vi.fn().mockResolvedValue({ ...mockChat });
312
- return { client, mockChat };
332
+ client['chat'] = mockOriginalChat;
333
+ vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(originalTokenCount);
334
+ mockGenerateContentFn.mockResolvedValue({
335
+ candidates: [
336
+ {
337
+ content: {
338
+ role: 'model',
339
+ parts: [{ text: summaryText }],
340
+ },
341
+ },
342
+ ],
343
+ });
344
+ // Calculate what the new history will be
345
+ const splitPoint = findCompressSplitPoint(chatHistory, 0.7); // 1 - 0.3
346
+ const historyToKeep = chatHistory.slice(splitPoint);
347
+ // This is the history that the new chat will have.
348
+ // It includes the default startChat history + the extra history from tryCompressChat
349
+ const newCompressedHistory = [
350
+ // Mocked envParts + canned response from startChat
351
+ {
352
+ role: 'user',
353
+ parts: [{ text: 'Mocked env context' }],
354
+ },
355
+ {
356
+ role: 'model',
357
+ parts: [{ text: 'Got it. Thanks for the context!' }],
358
+ },
359
+ // extraHistory from tryCompressChat
360
+ {
361
+ role: 'user',
362
+ parts: [{ text: summaryText }],
363
+ },
364
+ {
365
+ role: 'model',
366
+ parts: [{ text: 'Got it. Thanks for the additional context!' }],
367
+ },
368
+ ...historyToKeep,
369
+ ];
370
+ const mockNewChat = {
371
+ getHistory: vi.fn().mockReturnValue(newCompressedHistory),
372
+ setHistory: vi.fn(),
373
+ };
374
+ client['startChat'] = vi
375
+ .fn()
376
+ .mockResolvedValue(mockNewChat);
377
+ const totalChars = newCompressedHistory.reduce((total, content) => total + JSON.stringify(content).length, 0);
378
+ const estimatedNewTokenCount = Math.floor(totalChars / 4);
379
+ return {
380
+ client,
381
+ mockOriginalChat,
382
+ mockNewChat,
383
+ estimatedNewTokenCount,
384
+ };
313
385
  }
314
386
  describe('when compression inflates the token count', () => {
315
387
  it('allows compression to be forced/manual after a failure', async () => {
316
- const { client } = setup();
317
- vi.mocked(mockContentGenerator.countTokens).mockResolvedValue({
318
- totalTokens: 1000,
388
+ // Call 1 (Fails): Setup with a long summary to inflate tokens
389
+ const longSummary = 'long summary '.repeat(100);
390
+ const { client, estimatedNewTokenCount: inflatedTokenCount } = setup({
391
+ originalTokenCount: 100,
392
+ summaryText: longSummary,
319
393
  });
394
+ expect(inflatedTokenCount).toBeGreaterThan(100); // Ensure setup is correct
320
395
  await client.tryCompressChat('prompt-id-4', false); // Fails
321
- const result = await client.tryCompressChat('prompt-id-4', true);
396
+ // Call 2 (Forced): Re-setup with a short summary
397
+ const shortSummary = 'short';
398
+ const { estimatedNewTokenCount: compressedTokenCount } = setup({
399
+ originalTokenCount: 100,
400
+ summaryText: shortSummary,
401
+ });
402
+ expect(compressedTokenCount).toBeLessThanOrEqual(100); // Ensure setup is correct
403
+ const result = await client.tryCompressChat('prompt-id-4', true); // Forced
322
404
  expect(result).toEqual({
323
405
  compressionStatus: CompressionStatus.COMPRESSED,
324
- newTokenCount: 1000,
325
- originalTokenCount: 1000,
406
+ newTokenCount: compressedTokenCount,
407
+ originalTokenCount: 100,
326
408
  });
327
409
  });
328
410
  it('yields the result even if the compression inflated the tokens', async () => {
329
- const { client } = setup();
330
- vi.mocked(mockContentGenerator.countTokens).mockResolvedValue({
331
- totalTokens: 1000,
411
+ const longSummary = 'long summary '.repeat(100);
412
+ const { client, estimatedNewTokenCount } = setup({
413
+ originalTokenCount: 100,
414
+ summaryText: longSummary,
332
415
  });
416
+ expect(estimatedNewTokenCount).toBeGreaterThan(100); // Ensure setup is correct
333
417
  const result = await client.tryCompressChat('prompt-id-4', false);
334
418
  expect(result).toEqual({
335
419
  compressionStatus: CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
336
- newTokenCount: 5000,
337
- originalTokenCount: 1000,
420
+ newTokenCount: estimatedNewTokenCount,
421
+ originalTokenCount: 100,
338
422
  });
423
+ // IMPORTANT: The change in client.ts means setLastPromptTokenCount is NOT called on failure
424
+ expect(uiTelemetryService.setLastPromptTokenCount).not.toHaveBeenCalled();
339
425
  });
340
426
  it('does not manipulate the source chat', async () => {
341
- const { client, mockChat } = setup();
342
- await client.tryCompressChat('prompt-id-4', false);
343
- expect(client['chat']).toBe(mockChat); // a new chat session was not created
344
- });
345
- it('restores the history back to the original', async () => {
346
- vi.mocked(tokenLimit).mockReturnValue(1000);
347
- vi.mocked(mockContentGenerator.countTokens).mockResolvedValue({
348
- totalTokens: 999,
349
- });
350
- const originalHistory = [
351
- { role: 'user', parts: [{ text: 'what is your wisdom?' }] },
352
- { role: 'model', parts: [{ text: 'some wisdom' }] },
353
- { role: 'user', parts: [{ text: 'ahh that is a good a wisdom' }] },
354
- ];
355
- const { client } = setup({
356
- chatHistory: originalHistory,
427
+ const longSummary = 'long summary '.repeat(100);
428
+ const { client, mockOriginalChat, estimatedNewTokenCount } = setup({
429
+ originalTokenCount: 100,
430
+ summaryText: longSummary,
357
431
  });
358
- const { compressionStatus } = await client.tryCompressChat('prompt-id-4', false);
359
- expect(compressionStatus).toBe(CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT);
360
- expect(client['chat']?.setHistory).toHaveBeenCalledWith(originalHistory);
432
+ expect(estimatedNewTokenCount).toBeGreaterThan(100); // Ensure setup is correct
433
+ await client.tryCompressChat('prompt-id-4', false);
434
+ // On failure, the chat should NOT be replaced
435
+ expect(client['chat']).toBe(mockOriginalChat);
361
436
  });
362
437
  it('will not attempt to compress context after a failure', async () => {
363
- const { client } = setup();
364
- await client.tryCompressChat('prompt-id-4', false);
438
+ const longSummary = 'long summary '.repeat(100);
439
+ const { client, estimatedNewTokenCount } = setup({
440
+ originalTokenCount: 100,
441
+ summaryText: longSummary,
442
+ });
443
+ expect(estimatedNewTokenCount).toBeGreaterThan(100); // Ensure setup is correct
444
+ await client.tryCompressChat('prompt-id-4', false); // This fails and sets hasFailedCompressionAttempt = true
445
+ // This call should now be a NOOP
365
446
  const result = await client.tryCompressChat('prompt-id-5', false);
366
- // it counts tokens for {original, compressed} and then never again
367
- expect(mockContentGenerator.countTokens).toHaveBeenCalledTimes(2);
447
+ // generateContent (for summary) should only have been called once
448
+ expect(mockGenerateContentFn).toHaveBeenCalledTimes(1);
368
449
  expect(result).toEqual({
369
450
  compressionStatus: CompressionStatus.NOOP,
370
451
  newTokenCount: 0,
@@ -378,17 +459,16 @@ describe('Gemini Client (client.ts)', () => {
378
459
  mockGetHistory.mockReturnValue([
379
460
  { role: 'user', parts: [{ text: '...history...' }] },
380
461
  ]);
381
- vi.mocked(mockContentGenerator.countTokens).mockResolvedValue({
382
- totalTokens: MOCKED_TOKEN_LIMIT * 0.699, // TOKEN_THRESHOLD_FOR_SUMMARIZATION = 0.7
383
- });
462
+ const originalTokenCount = MOCKED_TOKEN_LIMIT * 0.699;
463
+ vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(originalTokenCount);
384
464
  const initialChat = client.getChat();
385
465
  const result = await client.tryCompressChat('prompt-id-2', false);
386
466
  const newChat = client.getChat();
387
467
  expect(tokenLimit).toHaveBeenCalled();
388
468
  expect(result).toEqual({
389
469
  compressionStatus: CompressionStatus.NOOP,
390
- newTokenCount: 699,
391
- originalTokenCount: 699,
470
+ newTokenCount: originalTokenCount,
471
+ originalTokenCount,
392
472
  });
393
473
  expect(newChat).toBe(initialChat);
394
474
  });
@@ -400,21 +480,40 @@ describe('Gemini Client (client.ts)', () => {
400
480
  vi.spyOn(client['config'], 'getChatCompression').mockReturnValue({
401
481
  contextPercentageThreshold: MOCKED_CONTEXT_PERCENTAGE_THRESHOLD,
402
482
  });
403
- mockGetHistory.mockReturnValue([
404
- { role: 'user', parts: [{ text: '...history...' }] },
405
- ]);
483
+ const history = [{ role: 'user', parts: [{ text: '...history...' }] }];
484
+ mockGetHistory.mockReturnValue(history);
406
485
  const originalTokenCount = MOCKED_TOKEN_LIMIT * MOCKED_CONTEXT_PERCENTAGE_THRESHOLD;
407
- const newTokenCount = 100;
408
- vi.mocked(mockContentGenerator.countTokens)
409
- .mockResolvedValueOnce({ totalTokens: originalTokenCount }) // First call for the check
410
- .mockResolvedValueOnce({ totalTokens: newTokenCount }); // Second call for the new history
486
+ vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(originalTokenCount);
487
+ // We need to control the estimated new token count.
488
+ // We mock startChat to return a chat with a known history.
489
+ const summaryText = 'This is a summary.';
490
+ const splitPoint = findCompressSplitPoint(history, 0.7);
491
+ const historyToKeep = history.slice(splitPoint);
492
+ const newCompressedHistory = [
493
+ { role: 'user', parts: [{ text: 'Mocked env context' }] },
494
+ { role: 'model', parts: [{ text: 'Got it. Thanks for the context!' }] },
495
+ { role: 'user', parts: [{ text: summaryText }] },
496
+ {
497
+ role: 'model',
498
+ parts: [{ text: 'Got it. Thanks for the additional context!' }],
499
+ },
500
+ ...historyToKeep,
501
+ ];
502
+ const mockNewChat = {
503
+ getHistory: vi.fn().mockReturnValue(newCompressedHistory),
504
+ };
505
+ client['startChat'] = vi
506
+ .fn()
507
+ .mockResolvedValue(mockNewChat);
508
+ const totalChars = newCompressedHistory.reduce((total, content) => total + JSON.stringify(content).length, 0);
509
+ const newTokenCount = Math.floor(totalChars / 4);
411
510
  // Mock the summary response from the chat
412
511
  mockGenerateContentFn.mockResolvedValue({
413
512
  candidates: [
414
513
  {
415
514
  content: {
416
515
  role: 'model',
417
- parts: [{ text: 'This is a summary.' }],
516
+ parts: [{ text: summaryText }],
418
517
  },
419
518
  },
420
519
  ],
@@ -424,6 +523,8 @@ describe('Gemini Client (client.ts)', () => {
424
523
  tokens_before: originalTokenCount,
425
524
  tokens_after: newTokenCount,
426
525
  }));
526
+ expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledWith(newTokenCount);
527
+ expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledTimes(1);
427
528
  });
428
529
  it('should trigger summarization if token count is at threshold with contextPercentageThreshold setting', async () => {
429
530
  const MOCKED_TOKEN_LIMIT = 1000;
@@ -432,21 +533,39 @@ describe('Gemini Client (client.ts)', () => {
432
533
  vi.spyOn(client['config'], 'getChatCompression').mockReturnValue({
433
534
  contextPercentageThreshold: MOCKED_CONTEXT_PERCENTAGE_THRESHOLD,
434
535
  });
435
- mockGetHistory.mockReturnValue([
436
- { role: 'user', parts: [{ text: '...history...' }] },
437
- ]);
536
+ const history = [{ role: 'user', parts: [{ text: '...history...' }] }];
537
+ mockGetHistory.mockReturnValue(history);
438
538
  const originalTokenCount = MOCKED_TOKEN_LIMIT * MOCKED_CONTEXT_PERCENTAGE_THRESHOLD;
439
- const newTokenCount = 100;
440
- vi.mocked(mockContentGenerator.countTokens)
441
- .mockResolvedValueOnce({ totalTokens: originalTokenCount }) // First call for the check
442
- .mockResolvedValueOnce({ totalTokens: newTokenCount }); // Second call for the new history
539
+ vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(originalTokenCount);
540
+ // Mock summary and new chat
541
+ const summaryText = 'This is a summary.';
542
+ const splitPoint = findCompressSplitPoint(history, 0.7);
543
+ const historyToKeep = history.slice(splitPoint);
544
+ const newCompressedHistory = [
545
+ { role: 'user', parts: [{ text: 'Mocked env context' }] },
546
+ { role: 'model', parts: [{ text: 'Got it. Thanks for the context!' }] },
547
+ { role: 'user', parts: [{ text: summaryText }] },
548
+ {
549
+ role: 'model',
550
+ parts: [{ text: 'Got it. Thanks for the additional context!' }],
551
+ },
552
+ ...historyToKeep,
553
+ ];
554
+ const mockNewChat = {
555
+ getHistory: vi.fn().mockReturnValue(newCompressedHistory),
556
+ };
557
+ client['startChat'] = vi
558
+ .fn()
559
+ .mockResolvedValue(mockNewChat);
560
+ const totalChars = newCompressedHistory.reduce((total, content) => total + JSON.stringify(content).length, 0);
561
+ const newTokenCount = Math.floor(totalChars / 4);
443
562
  // Mock the summary response from the chat
444
563
  mockGenerateContentFn.mockResolvedValue({
445
564
  candidates: [
446
565
  {
447
566
  content: {
448
567
  role: 'model',
449
- parts: [{ text: 'This is a summary.' }],
568
+ parts: [{ text: summaryText }],
450
569
  },
451
570
  },
452
571
  ],
@@ -468,7 +587,7 @@ describe('Gemini Client (client.ts)', () => {
468
587
  it('should not compress across a function call response', async () => {
469
588
  const MOCKED_TOKEN_LIMIT = 1000;
470
589
  vi.mocked(tokenLimit).mockReturnValue(MOCKED_TOKEN_LIMIT);
471
- mockGetHistory.mockReturnValue([
590
+ const history = [
472
591
  { role: 'user', parts: [{ text: '...history 1...' }] },
473
592
  { role: 'model', parts: [{ text: '...history 2...' }] },
474
593
  { role: 'user', parts: [{ text: '...history 3...' }] },
@@ -485,19 +604,43 @@ describe('Gemini Client (client.ts)', () => {
485
604
  { role: 'model', parts: [{ text: '...history 10...' }] },
486
605
  // Instead we will break here.
487
606
  { role: 'user', parts: [{ text: '...history 10...' }] },
488
- ]);
607
+ ];
608
+ mockGetHistory.mockReturnValue(history);
489
609
  const originalTokenCount = 1000 * 0.7;
490
- const newTokenCount = 100;
491
- vi.mocked(mockContentGenerator.countTokens)
492
- .mockResolvedValueOnce({ totalTokens: originalTokenCount }) // First call for the check
493
- .mockResolvedValueOnce({ totalTokens: newTokenCount }); // Second call for the new history
610
+ vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(originalTokenCount);
611
+ // Mock summary and new chat
612
+ const summaryText = 'This is a summary.';
613
+ const splitPoint = findCompressSplitPoint(history, 0.7); // This should be 10
614
+ expect(splitPoint).toBe(10); // Verify split point logic
615
+ const historyToKeep = history.slice(splitPoint); // Should keep last user message
616
+ expect(historyToKeep).toEqual([
617
+ { role: 'user', parts: [{ text: '...history 10...' }] },
618
+ ]);
619
+ const newCompressedHistory = [
620
+ { role: 'user', parts: [{ text: 'Mocked env context' }] },
621
+ { role: 'model', parts: [{ text: 'Got it. Thanks for the context!' }] },
622
+ { role: 'user', parts: [{ text: summaryText }] },
623
+ {
624
+ role: 'model',
625
+ parts: [{ text: 'Got it. Thanks for the additional context!' }],
626
+ },
627
+ ...historyToKeep,
628
+ ];
629
+ const mockNewChat = {
630
+ getHistory: vi.fn().mockReturnValue(newCompressedHistory),
631
+ };
632
+ client['startChat'] = vi
633
+ .fn()
634
+ .mockResolvedValue(mockNewChat);
635
+ const totalChars = newCompressedHistory.reduce((total, content) => total + JSON.stringify(content).length, 0);
636
+ const newTokenCount = Math.floor(totalChars / 4);
494
637
  // Mock the summary response from the chat
495
638
  mockGenerateContentFn.mockResolvedValue({
496
639
  candidates: [
497
640
  {
498
641
  content: {
499
642
  role: 'model',
500
- parts: [{ text: 'This is a summary.' }],
643
+ parts: [{ text: summaryText }],
501
644
  },
502
645
  },
503
646
  ],
@@ -515,35 +658,53 @@ describe('Gemini Client (client.ts)', () => {
515
658
  });
516
659
  // Assert that the chat was reset
517
660
  expect(newChat).not.toBe(initialChat);
518
- // 1. standard start context message
519
- // 2. standard canned user start message
520
- // 3. compressed summary message
521
- // 4. standard canned user summary message
522
- // 5. The last user message (not the last 3 because that would start with a function response)
661
+ // 1. standard start context message (env)
662
+ // 2. standard canned model response
663
+ // 3. compressed summary message (user)
664
+ // 4. standard canned model response
665
+ // 5. The last user message (historyToKeep)
523
666
  expect(newChat.getHistory().length).toEqual(5);
524
667
  });
525
668
  it('should always trigger summarization when force is true, regardless of token count', async () => {
526
- mockGetHistory.mockReturnValue([
527
- { role: 'user', parts: [{ text: '...history...' }] },
528
- ]);
529
- const originalTokenCount = 10; // Well below threshold
530
- const newTokenCount = 5;
531
- vi.mocked(mockContentGenerator.countTokens)
532
- .mockResolvedValueOnce({ totalTokens: originalTokenCount })
533
- .mockResolvedValueOnce({ totalTokens: newTokenCount });
669
+ const history = [{ role: 'user', parts: [{ text: '...history...' }] }];
670
+ mockGetHistory.mockReturnValue(history);
671
+ const originalTokenCount = 100; // Well below threshold, but > estimated new count
672
+ vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(originalTokenCount);
673
+ // Mock summary and new chat
674
+ const summaryText = 'This is a summary.';
675
+ const splitPoint = findCompressSplitPoint(history, 0.7);
676
+ const historyToKeep = history.slice(splitPoint);
677
+ const newCompressedHistory = [
678
+ { role: 'user', parts: [{ text: 'Mocked env context' }] },
679
+ { role: 'model', parts: [{ text: 'Got it. Thanks for the context!' }] },
680
+ { role: 'user', parts: [{ text: summaryText }] },
681
+ {
682
+ role: 'model',
683
+ parts: [{ text: 'Got it. Thanks for the additional context!' }],
684
+ },
685
+ ...historyToKeep,
686
+ ];
687
+ const mockNewChat = {
688
+ getHistory: vi.fn().mockReturnValue(newCompressedHistory),
689
+ };
690
+ client['startChat'] = vi
691
+ .fn()
692
+ .mockResolvedValue(mockNewChat);
693
+ const totalChars = newCompressedHistory.reduce((total, content) => total + JSON.stringify(content).length, 0);
694
+ const newTokenCount = Math.floor(totalChars / 4);
534
695
  // Mock the summary response from the chat
535
696
  mockGenerateContentFn.mockResolvedValue({
536
697
  candidates: [
537
698
  {
538
699
  content: {
539
700
  role: 'model',
540
- parts: [{ text: 'This is a summary.' }],
701
+ parts: [{ text: summaryText }],
541
702
  },
542
703
  },
543
704
  ],
544
705
  });
545
706
  const initialChat = client.getChat();
546
- const result = await client.tryCompressChat('prompt-id-1', false); // force = true
707
+ const result = await client.tryCompressChat('prompt-id-1', true); // force = true
547
708
  const newChat = client.getChat();
548
709
  expect(mockGenerateContentFn).toHaveBeenCalled();
549
710
  expect(result).toEqual({
@@ -581,9 +742,6 @@ describe('Gemini Client (client.ts)', () => {
581
742
  compressionStatus: CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
582
743
  },
583
744
  { compressionStatus: CompressionStatus.NOOP },
584
- {
585
- compressionStatus: CompressionStatus.COMPRESSION_FAILED_TOKEN_COUNT_ERROR,
586
- },
587
745
  ])('does not emit a compression event when the status is $compressionStatus', async ({ compressionStatus }) => {
588
746
  // Arrange
589
747
  const mockStream = (async function* () {