@genesislcap/ai-assistant 15.19.0 → 15.19.1

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 (288) hide show
  1. package/package.json +17 -18
  2. package/api-extractor.json +0 -11
  3. package/dist/dts/components/chat-driver/chat-driver.compact.test.d.ts +0 -2
  4. package/dist/dts/components/chat-driver/chat-driver.compact.test.d.ts.map +0 -1
  5. package/dist/dts/components/chat-driver/chat-driver.invocation-scope.test.d.ts +0 -2
  6. package/dist/dts/components/chat-driver/chat-driver.invocation-scope.test.d.ts.map +0 -1
  7. package/dist/dts/components/chat-driver/chat-driver.test.d.ts +0 -2
  8. package/dist/dts/components/chat-driver/chat-driver.test.d.ts.map +0 -1
  9. package/dist/dts/components/chat-driver/chat-driver.thinking-policy.test.d.ts +0 -2
  10. package/dist/dts/components/chat-driver/chat-driver.thinking-policy.test.d.ts.map +0 -1
  11. package/dist/dts/components/chat-driver/chat-driver.trace-capture.test.d.ts +0 -2
  12. package/dist/dts/components/chat-driver/chat-driver.trace-capture.test.d.ts.map +0 -1
  13. package/dist/dts/components/chat-driver/chat-driver.turn-usage.test.d.ts +0 -2
  14. package/dist/dts/components/chat-driver/chat-driver.turn-usage.test.d.ts.map +0 -1
  15. package/dist/dts/components/chat-interaction-wrapper/chat-interaction-wrapper.test.d.ts +0 -2
  16. package/dist/dts/components/chat-interaction-wrapper/chat-interaction-wrapper.test.d.ts.map +0 -1
  17. package/dist/dts/components/orchestrating-driver/orchestrating-driver.budget.test.d.ts +0 -2
  18. package/dist/dts/components/orchestrating-driver/orchestrating-driver.budget.test.d.ts.map +0 -1
  19. package/dist/dts/components/orchestrating-driver/orchestrating-driver.cost.test.d.ts +0 -2
  20. package/dist/dts/components/orchestrating-driver/orchestrating-driver.cost.test.d.ts.map +0 -1
  21. package/dist/dts/components/orchestrating-driver/orchestrating-driver.pin.test.d.ts +0 -2
  22. package/dist/dts/components/orchestrating-driver/orchestrating-driver.pin.test.d.ts.map +0 -1
  23. package/dist/dts/components/settings-modal/settings-modal.styles.test.d.ts +0 -2
  24. package/dist/dts/components/settings-modal/settings-modal.styles.test.d.ts.map +0 -1
  25. package/dist/dts/components/settings-modal/settings-modal.template.test.d.ts +0 -2
  26. package/dist/dts/components/settings-modal/settings-modal.template.test.d.ts.map +0 -1
  27. package/dist/dts/config/define-stateful-agent.test.d.ts +0 -2
  28. package/dist/dts/config/define-stateful-agent.test.d.ts.map +0 -1
  29. package/dist/dts/config/validate-providers.test.d.ts +0 -2
  30. package/dist/dts/config/validate-providers.test.d.ts.map +0 -1
  31. package/dist/dts/main/blocked-state.test.d.ts +0 -2
  32. package/dist/dts/main/blocked-state.test.d.ts.map +0 -1
  33. package/dist/dts/main/budget-meter.test.d.ts +0 -2
  34. package/dist/dts/main/budget-meter.test.d.ts.map +0 -1
  35. package/dist/dts/main/cost-session-banking.test.d.ts +0 -2
  36. package/dist/dts/main/cost-session-banking.test.d.ts.map +0 -1
  37. package/dist/dts/main/file-attachments.test.d.ts +0 -2
  38. package/dist/dts/main/file-attachments.test.d.ts.map +0 -1
  39. package/dist/dts/main/interaction-cost.test.d.ts +0 -2
  40. package/dist/dts/main/interaction-cost.test.d.ts.map +0 -1
  41. package/dist/dts/main/main.styles.test.d.ts +0 -2
  42. package/dist/dts/main/main.styles.test.d.ts.map +0 -1
  43. package/dist/dts/main/persistence-broken-sources.test.d.ts +0 -2
  44. package/dist/dts/main/persistence-broken-sources.test.d.ts.map +0 -1
  45. package/dist/dts/main/popout-interaction-gate.test.d.ts +0 -2
  46. package/dist/dts/main/popout-interaction-gate.test.d.ts.map +0 -1
  47. package/dist/dts/state/ai-assistant-slice.test.d.ts +0 -2
  48. package/dist/dts/state/ai-assistant-slice.test.d.ts.map +0 -1
  49. package/dist/dts/state/debug-event-log.test.d.ts +0 -2
  50. package/dist/dts/state/debug-event-log.test.d.ts.map +0 -1
  51. package/dist/dts/state/driver-registry.test.d.ts +0 -2
  52. package/dist/dts/state/driver-registry.test.d.ts.map +0 -1
  53. package/dist/dts/state/interaction-context.test.d.ts +0 -2
  54. package/dist/dts/state/interaction-context.test.d.ts.map +0 -1
  55. package/dist/dts/state/persistence/diagnostics-cursors.test.d.ts +0 -2
  56. package/dist/dts/state/persistence/diagnostics-cursors.test.d.ts.map +0 -1
  57. package/dist/dts/state/persistence/diagnostics.test.d.ts +0 -2
  58. package/dist/dts/state/persistence/diagnostics.test.d.ts.map +0 -1
  59. package/dist/dts/state/persistence/session-persistence-provider.test.d.ts +0 -2
  60. package/dist/dts/state/persistence/session-persistence-provider.test.d.ts.map +0 -1
  61. package/dist/dts/state/persistence/session-persistence.integration.test.d.ts +0 -2
  62. package/dist/dts/state/persistence/session-persistence.integration.test.d.ts.map +0 -1
  63. package/dist/dts/state/persistence/session-persister.test.d.ts +0 -2
  64. package/dist/dts/state/persistence/session-persister.test.d.ts.map +0 -1
  65. package/dist/dts/state/persistence/session-snapshot.test.d.ts +0 -2
  66. package/dist/dts/state/persistence/session-snapshot.test.d.ts.map +0 -1
  67. package/dist/dts/state/persistence/stateful-restore.e2e.test.d.ts +0 -2
  68. package/dist/dts/state/persistence/stateful-restore.e2e.test.d.ts.map +0 -1
  69. package/dist/dts/utils/animation-exclusivity.test.d.ts +0 -2
  70. package/dist/dts/utils/animation-exclusivity.test.d.ts.map +0 -1
  71. package/dist/dts/utils/collect-session-models.test.d.ts +0 -2
  72. package/dist/dts/utils/collect-session-models.test.d.ts.map +0 -1
  73. package/dist/dts/utils/condense-history.test.d.ts +0 -2
  74. package/dist/dts/utils/condense-history.test.d.ts.map +0 -1
  75. package/dist/dts/utils/cost-session-history.test.d.ts +0 -2
  76. package/dist/dts/utils/cost-session-history.test.d.ts.map +0 -1
  77. package/dist/dts/utils/derive-cost-session-title.test.d.ts +0 -2
  78. package/dist/dts/utils/derive-cost-session-title.test.d.ts.map +0 -1
  79. package/dist/dts/utils/flatten-sub-agent-messages.test.d.ts +0 -2
  80. package/dist/dts/utils/flatten-sub-agent-messages.test.d.ts.map +0 -1
  81. package/dist/dts/utils/history-transform.test.d.ts +0 -2
  82. package/dist/dts/utils/history-transform.test.d.ts.map +0 -1
  83. package/dist/dts/utils/message-partition.test.d.ts +0 -2
  84. package/dist/dts/utils/message-partition.test.d.ts.map +0 -1
  85. package/dist/dts/utils/resolve-cost-history-config.test.d.ts +0 -2
  86. package/dist/dts/utils/resolve-cost-history-config.test.d.ts.map +0 -1
  87. package/dist/dts/utils/resolve-preference-baseline.test.d.ts +0 -2
  88. package/dist/dts/utils/resolve-preference-baseline.test.d.ts.map +0 -1
  89. package/dist/dts/utils/strip-agent-handlers.test.d.ts +0 -2
  90. package/dist/dts/utils/strip-agent-handlers.test.d.ts.map +0 -1
  91. package/dist/dts/utils/sum-costs.test.d.ts +0 -2
  92. package/dist/dts/utils/sum-costs.test.d.ts.map +0 -1
  93. package/dist/dts/utils/sum-tokens.test.d.ts +0 -2
  94. package/dist/dts/utils/sum-tokens.test.d.ts.map +0 -1
  95. package/dist/dts/utils/sum-usage.test.d.ts +0 -2
  96. package/dist/dts/utils/sum-usage.test.d.ts.map +0 -1
  97. package/dist/dts/utils/usage-rows.test.d.ts +0 -2
  98. package/dist/dts/utils/usage-rows.test.d.ts.map +0 -1
  99. package/dist/esm/components/chat-driver/chat-driver.compact.test.js +0 -93
  100. package/dist/esm/components/chat-driver/chat-driver.invocation-scope.test.js +0 -238
  101. package/dist/esm/components/chat-driver/chat-driver.test.js +0 -2770
  102. package/dist/esm/components/chat-driver/chat-driver.thinking-policy.test.js +0 -137
  103. package/dist/esm/components/chat-driver/chat-driver.trace-capture.test.js +0 -200
  104. package/dist/esm/components/chat-driver/chat-driver.turn-usage.test.js +0 -268
  105. package/dist/esm/components/chat-interaction-wrapper/chat-interaction-wrapper.test.js +0 -104
  106. package/dist/esm/components/orchestrating-driver/orchestrating-driver.budget.test.js +0 -312
  107. package/dist/esm/components/orchestrating-driver/orchestrating-driver.cost.test.js +0 -76
  108. package/dist/esm/components/orchestrating-driver/orchestrating-driver.pin.test.js +0 -61
  109. package/dist/esm/components/settings-modal/settings-modal.styles.test.js +0 -80
  110. package/dist/esm/components/settings-modal/settings-modal.template.test.js +0 -91
  111. package/dist/esm/config/define-stateful-agent.test.js +0 -53
  112. package/dist/esm/config/validate-providers.test.js +0 -100
  113. package/dist/esm/main/blocked-state.test.js +0 -1065
  114. package/dist/esm/main/budget-meter.test.js +0 -359
  115. package/dist/esm/main/cost-session-banking.test.js +0 -538
  116. package/dist/esm/main/file-attachments.test.js +0 -164
  117. package/dist/esm/main/interaction-cost.test.js +0 -107
  118. package/dist/esm/main/main.styles.test.js +0 -161
  119. package/dist/esm/main/persistence-broken-sources.test.js +0 -180
  120. package/dist/esm/main/popout-interaction-gate.test.js +0 -54
  121. package/dist/esm/state/ai-assistant-slice.test.js +0 -229
  122. package/dist/esm/state/debug-event-log.test.js +0 -117
  123. package/dist/esm/state/driver-registry.test.js +0 -55
  124. package/dist/esm/state/interaction-context.test.js +0 -78
  125. package/dist/esm/state/persistence/diagnostics-cursors.test.js +0 -30
  126. package/dist/esm/state/persistence/diagnostics.test.js +0 -280
  127. package/dist/esm/state/persistence/session-persistence-provider.test.js +0 -110
  128. package/dist/esm/state/persistence/session-persistence.integration.test.js +0 -113
  129. package/dist/esm/state/persistence/session-persister.test.js +0 -613
  130. package/dist/esm/state/persistence/session-snapshot.test.js +0 -256
  131. package/dist/esm/state/persistence/stateful-restore.e2e.test.js +0 -158
  132. package/dist/esm/utils/animation-exclusivity.test.js +0 -52
  133. package/dist/esm/utils/collect-session-models.test.js +0 -53
  134. package/dist/esm/utils/condense-history.test.js +0 -547
  135. package/dist/esm/utils/cost-session-history.test.js +0 -174
  136. package/dist/esm/utils/derive-cost-session-title.test.js +0 -37
  137. package/dist/esm/utils/flatten-sub-agent-messages.test.js +0 -139
  138. package/dist/esm/utils/history-transform.test.js +0 -158
  139. package/dist/esm/utils/message-partition.test.js +0 -90
  140. package/dist/esm/utils/resolve-cost-history-config.test.js +0 -32
  141. package/dist/esm/utils/resolve-preference-baseline.test.js +0 -69
  142. package/dist/esm/utils/strip-agent-handlers.test.js +0 -81
  143. package/dist/esm/utils/sum-costs.test.js +0 -114
  144. package/dist/esm/utils/sum-tokens.test.js +0 -51
  145. package/dist/esm/utils/sum-usage.test.js +0 -164
  146. package/dist/esm/utils/usage-rows.test.js +0 -189
  147. package/dist/tsconfig.tsbuildinfo +0 -1
  148. package/license.txt +0 -46
  149. package/sandbox/tsconfig.json +0 -8
  150. package/src/channel/ai-activity-bus.ts +0 -209
  151. package/src/channel/ai-activity-channel.ts +0 -146
  152. package/src/chat-driver-node.ts +0 -159
  153. package/src/components/activity-halo/activity-halo.ts +0 -106
  154. package/src/components/agent-picker/agent-picker.constants.ts +0 -2
  155. package/src/components/agent-picker/agent-picker.styles.ts +0 -24
  156. package/src/components/agent-picker/agent-picker.template.ts +0 -54
  157. package/src/components/agent-picker/agent-picker.ts +0 -99
  158. package/src/components/agent-picker/index.ts +0 -1
  159. package/src/components/ai-driver/ai-driver.ts +0 -166
  160. package/src/components/ai-driver/index.ts +0 -1
  161. package/src/components/chat-bubble/chat-bubble.styles.ts +0 -194
  162. package/src/components/chat-bubble/chat-bubble.template.ts +0 -67
  163. package/src/components/chat-bubble/chat-bubble.ts +0 -436
  164. package/src/components/chat-bubble/index.ts +0 -1
  165. package/src/components/chat-driver/align-event-globals.ts +0 -23
  166. package/src/components/chat-driver/chat-driver.compact.test.ts +0 -131
  167. package/src/components/chat-driver/chat-driver.invocation-scope.test.ts +0 -285
  168. package/src/components/chat-driver/chat-driver.test.ts +0 -3679
  169. package/src/components/chat-driver/chat-driver.thinking-policy.test.ts +0 -185
  170. package/src/components/chat-driver/chat-driver.trace-capture.test.ts +0 -251
  171. package/src/components/chat-driver/chat-driver.ts +0 -3925
  172. package/src/components/chat-driver/chat-driver.turn-usage.test.ts +0 -362
  173. package/src/components/chat-driver/index.ts +0 -1
  174. package/src/components/chat-interaction-wrapper/chat-interaction-wrapper.styles.ts +0 -7
  175. package/src/components/chat-interaction-wrapper/chat-interaction-wrapper.template.ts +0 -6
  176. package/src/components/chat-interaction-wrapper/chat-interaction-wrapper.test.ts +0 -144
  177. package/src/components/chat-interaction-wrapper/chat-interaction-wrapper.ts +0 -243
  178. package/src/components/chat-interaction-wrapper/index.ts +0 -3
  179. package/src/components/chat-markdown/chat-markdown.ts +0 -89
  180. package/src/components/chat-markdown/index.ts +0 -1
  181. package/src/components/flowing-waves-indicator.ts +0 -286
  182. package/src/components/halo-overlay.ts +0 -184
  183. package/src/components/orchestrating-driver/index.ts +0 -1
  184. package/src/components/orchestrating-driver/orchestrating-driver.budget.test.ts +0 -438
  185. package/src/components/orchestrating-driver/orchestrating-driver.cost.test.ts +0 -107
  186. package/src/components/orchestrating-driver/orchestrating-driver.pin.test.ts +0 -96
  187. package/src/components/orchestrating-driver/orchestrating-driver.ts +0 -813
  188. package/src/components/plasma-orb-indicator.ts +0 -281
  189. package/src/components/popout-manager/index.ts +0 -1
  190. package/src/components/popout-manager/popout-manager.ts +0 -212
  191. package/src/components/settings-modal/index.ts +0 -2
  192. package/src/components/settings-modal/settings-modal.styles.test.ts +0 -94
  193. package/src/components/settings-modal/settings-modal.styles.ts +0 -853
  194. package/src/components/settings-modal/settings-modal.template.test.ts +0 -124
  195. package/src/components/settings-modal/settings-modal.template.ts +0 -943
  196. package/src/components/waves-indicator.ts +0 -241
  197. package/src/config/config.ts +0 -608
  198. package/src/config/define-stateful-agent.test.ts +0 -64
  199. package/src/config/define-stateful-agent.ts +0 -642
  200. package/src/config/fallback-agents.ts +0 -29
  201. package/src/config/index.ts +0 -2
  202. package/src/config/validate-providers.test.ts +0 -148
  203. package/src/config/validate-providers.ts +0 -58
  204. package/src/index.ts +0 -29
  205. package/src/main/blocked-state.test.ts +0 -1444
  206. package/src/main/budget-meter.test.ts +0 -477
  207. package/src/main/cost-session-banking.test.ts +0 -679
  208. package/src/main/file-attachments.test.ts +0 -215
  209. package/src/main/index.ts +0 -3
  210. package/src/main/interaction-cost.test.ts +0 -140
  211. package/src/main/main.styles.test.ts +0 -216
  212. package/src/main/main.styles.ts +0 -995
  213. package/src/main/main.template.ts +0 -1119
  214. package/src/main/main.ts +0 -5432
  215. package/src/main/main.types.ts +0 -225
  216. package/src/main/persistence-broken-sources.test.ts +0 -219
  217. package/src/main/popout-interaction-gate.test.ts +0 -87
  218. package/src/provider/ai-provider-switcher.ts +0 -40
  219. package/src/provider/assistant-app-settings.ts +0 -84
  220. package/src/state/ai-assistant-slice.test.ts +0 -336
  221. package/src/state/ai-assistant-slice.ts +0 -604
  222. package/src/state/debug-event-log.test.ts +0 -154
  223. package/src/state/debug-event-log.ts +0 -358
  224. package/src/state/driver-registry.test.ts +0 -70
  225. package/src/state/driver-registry.ts +0 -79
  226. package/src/state/interaction-context.test.ts +0 -90
  227. package/src/state/interaction-context.ts +0 -101
  228. package/src/state/persistence/build-timeline-entries.ts +0 -66
  229. package/src/state/persistence/diagnostics-cursors.test.ts +0 -53
  230. package/src/state/persistence/diagnostics-cursors.ts +0 -80
  231. package/src/state/persistence/diagnostics.test.ts +0 -346
  232. package/src/state/persistence/diagnostics.ts +0 -220
  233. package/src/state/persistence/index.ts +0 -10
  234. package/src/state/persistence/persister-registry.ts +0 -55
  235. package/src/state/persistence/session-persistence-provider.test.ts +0 -136
  236. package/src/state/persistence/session-persistence-provider.ts +0 -226
  237. package/src/state/persistence/session-persistence.integration.test.ts +0 -141
  238. package/src/state/persistence/session-persister.test.ts +0 -787
  239. package/src/state/persistence/session-persister.ts +0 -917
  240. package/src/state/persistence/session-snapshot.test.ts +0 -352
  241. package/src/state/persistence/session-snapshot.ts +0 -344
  242. package/src/state/persistence/stateful-restore.e2e.test.ts +0 -229
  243. package/src/state/session-store.ts +0 -56
  244. package/src/styles/ai-colours.ts +0 -30
  245. package/src/styles/menu.ts +0 -187
  246. package/src/styles/settings-section.ts +0 -40
  247. package/src/suggestions/chat-suggestions.ts +0 -186
  248. package/src/tags/index.ts +0 -1
  249. package/src/types/ai-chat-widget.ts +0 -67
  250. package/src/types/interaction-context.ts +0 -82
  251. package/src/utils/animated-panel-toggle.ts +0 -62
  252. package/src/utils/animation-exclusivity.test.ts +0 -72
  253. package/src/utils/animation-exclusivity.ts +0 -40
  254. package/src/utils/banked-usage-baselines.ts +0 -90
  255. package/src/utils/collect-session-models.test.ts +0 -82
  256. package/src/utils/collect-session-models.ts +0 -65
  257. package/src/utils/condense-history.test.ts +0 -746
  258. package/src/utils/condense-history.ts +0 -448
  259. package/src/utils/cost-session-history.test.ts +0 -226
  260. package/src/utils/cost-session-history.ts +0 -218
  261. package/src/utils/derive-cost-session-title.test.ts +0 -63
  262. package/src/utils/derive-cost-session-title.ts +0 -76
  263. package/src/utils/flatten-sub-agent-messages.test.ts +0 -163
  264. package/src/utils/flatten-sub-agent-messages.ts +0 -88
  265. package/src/utils/format-usd.ts +0 -26
  266. package/src/utils/history-transform.test.ts +0 -228
  267. package/src/utils/history-transform.ts +0 -163
  268. package/src/utils/index.ts +0 -3
  269. package/src/utils/logger.ts +0 -3
  270. package/src/utils/message-partition.test.ts +0 -129
  271. package/src/utils/message-partition.ts +0 -115
  272. package/src/utils/resolve-cost-history-config.test.ts +0 -38
  273. package/src/utils/resolve-cost-history-config.ts +0 -38
  274. package/src/utils/resolve-preference-baseline.test.ts +0 -84
  275. package/src/utils/resolve-preference-baseline.ts +0 -80
  276. package/src/utils/strip-agent-handlers.test.ts +0 -99
  277. package/src/utils/strip-agent-handlers.ts +0 -53
  278. package/src/utils/sum-costs.test.ts +0 -136
  279. package/src/utils/sum-costs.ts +0 -18
  280. package/src/utils/sum-tokens.test.ts +0 -93
  281. package/src/utils/sum-tokens.ts +0 -68
  282. package/src/utils/sum-usage.test.ts +0 -191
  283. package/src/utils/sum-usage.ts +0 -208
  284. package/src/utils/tool-fold.ts +0 -181
  285. package/src/utils/usage-rows.test.ts +0 -237
  286. package/src/utils/usage-rows.ts +0 -187
  287. package/src/utils/with-timeout.ts +0 -24
  288. package/tsconfig.json +0 -9
@@ -1,448 +0,0 @@
1
- import type { ChatMessage, CondensePolicy, CondenseTrigger } from '@genesislcap/foundation-ai';
2
-
3
- /**
4
- * Tool-context condensation: collapse stale tool payloads out of the history
5
- * sent to the model, while leaving stored history untouched.
6
- *
7
- * The driver owns the registry (tool handlers populate it via `condenseWhen`)
8
- * and the cadence (this runs before every provider call). This module owns the
9
- * pure application: given the registry + the current loop iteration, produce a
10
- * rewritten copy of the history with stale args/results replaced by stubs. Kept
11
- * pure (no driver/session state, no logging) so it unit-tests in isolation —
12
- * the one-time `context.condensed` signal is delivered via an `onCondensed`
13
- * callback rather than reaching into the debug-event log directly.
14
- *
15
- * @packageDocumentation
16
- */
17
-
18
- /**
19
- * Below this serialized size a tool payload isn't worth condensing — the stub
20
- * would save little and cost a breadcrumb. A driver-level floor so the public
21
- * `condenseWhen` API needs no per-policy threshold field.
22
- */
23
- export const CONDENSE_MIN_CHARS = 1000;
24
-
25
- /** Key the collapsed args are stored under — a tool-call's args must stay a Record. */
26
- export const CONDENSED_ARGS_KEY = 'condensed';
27
-
28
- /**
29
- * Rough chars-per-token divisor for the `tokensSaved` estimate. No tokenizer is
30
- * available in this stack; ~4 chars/token is the usual English approximation.
31
- */
32
- const APPROX_CHARS_PER_TOKEN = 4;
33
-
34
- /**
35
- * A `condenseWhen` policy registered against a tool-call id, with its age clock
36
- * and report-once flags. The driver holds these in a `Map<toolCallId, …>`;
37
- * {@link applyCondensation} reads them and flips the `reported*` flags so the
38
- * `context.condensed` signal fires once per payload, not once per provider call.
39
- */
40
- export interface RegisteredCondensePolicy {
41
- policy: CondensePolicy;
42
- /**
43
- * Monotonic model-call sequence (`modelCallSeq`) when `condenseWhen` was called
44
- * — the origin of the age clock. Driver-lifetime and NOT reset per turn, so age
45
- * is measured consistently across turn boundaries (the field name is historical;
46
- * it is not the per-turn loop iteration).
47
- */
48
- iteration: number;
49
- /** Turn (`sendMessage`) sequence when the call was made — the `turnEnd` clock. */
50
- turn: number;
51
- /** Agent-activation sequence when the call was made — the `agentEnd` clock. */
52
- activation: number;
53
- /** App-controlled phase epoch when the call was made — the `phaseEnd` clock. */
54
- phaseEpoch: number;
55
- /** `context.condensed` already emitted for the args payload (fire-once). */
56
- reportedArgs?: boolean;
57
- /** `context.condensed` already emitted for the response payload (fire-once). */
58
- reportedResponse?: boolean;
59
- /**
60
- * The first trigger to fire for this call, latched on the pass it fired.
61
- *
62
- * "Fired", not "collapsed": it is written during trigger resolution, before the
63
- * per-payload size floor is applied, so an entry whose payloads are all below the floor
64
- * can latch without anything ever rendering. That is deliberate and safe — both payloads
65
- * of a call resolve off the same clocks in the same pass, so they can never disagree
66
- * about which trigger fired, and a payload's size is fixed for the life of the call
67
- * (condensation never touches stored history), so a below-floor payload can never later
68
- * become a large one under a stale reason.
69
- *
70
- * Trigger resolution picks the first LISTED trigger that is *currently* true, so without
71
- * this it re-evaluates on every provider call and can change after the collapse: with the
72
- * usual `on: [{superseded}, {agentEnd}]`, a payload collapsed at `agentEnd` silently
73
- * re-resolves to `superseded` as soon as a newer call on the same key lands. Any stub that
74
- * embeds the reason (`pointer` does) is then rewritten — an in-place history mutation that
75
- * invalidates the prompt cache from that position a second time, for a payload that had
76
- * already collapsed. Latching keeps the rendered stub stable, which is sound because a
77
- * collapse is monotonic and never reverts, so the first trigger to fire is the correct
78
- * one to keep.
79
- */
80
- firedTrigger?: CondenseTrigger;
81
- }
82
-
83
- /**
84
- * The driver's live state at the moment of a provider call — the clocks every
85
- * trigger is evaluated against. Passed by the driver; kept as an interface so the
86
- * transform stays a pure function of (history, policies, clocks).
87
- */
88
- export interface CondenseContext {
89
- /** Monotonic model-call sequence now — the `age` clock. */
90
- modelCall: number;
91
- /** Monotonic turn (`sendMessage`) sequence now — the `turnEnd` clock. */
92
- turn: number;
93
- /** Whether the agent activation that made a call has since ended — the `agentEnd` clock. */
94
- activationEnded: (activation: number) => boolean;
95
- /**
96
- * Whether a later phase epoch is current than the one a call was made in — the
97
- * `phaseEnd` clock. True once `endPhase()` has been called since the call.
98
- */
99
- phaseEnded: (phaseEpoch: number) => boolean;
100
- /**
101
- * Collapse in batches of this many model-calls instead of continuously. A positive
102
- * integer; decimals are floored and anything below `1` becomes `1`. Omit (or `1`) to
103
- * collapse as soon as a trigger fires, which is the historical behaviour.
104
- *
105
- * Condensation rewrites a payload *in place*, so the model-bound history is not
106
- * append-only and every prompt-cache entry written before the rewrite is invalidated
107
- * from that position on. Collapsing the moment each trigger fires therefore breaks the
108
- * cache on nearly every call of a re-read loop — the case condensation exists to
109
- * contain. Batching holds the collapse set still between boundaries so the prefix is
110
- * append-only in between, trading up to `batchCalls` calls' worth of extra context for
111
- * one cache break per batch instead of one per call.
112
- *
113
- * `agentEnd` and `phaseEnd` are exempt and still collapse as they fire: they mark a
114
- * discontinuity that invalidates the cached prefix anyway, so deferring them would hold
115
- * stale payloads longer for nothing.
116
- */
117
- batchCalls?: number;
118
- }
119
-
120
- /**
121
- * Structured detail for a `context.condensed` meta-event. The index signature
122
- * lets it satisfy `recordMetaEvent`'s `Record<string, unknown>` detail param
123
- * while the named fields stay strictly typed.
124
- */
125
- export interface CondensedEventDetail {
126
- [key: string]: unknown;
127
- /** Tool-loop iteration the collapsed call was made on. */
128
- turn: number;
129
- toolCallId: string;
130
- tool: string;
131
- target: 'args' | 'response';
132
- /** Human-readable trigger, e.g. `superseded:Foo.tsx` or `age:3`. */
133
- trigger: string;
134
- /** Length of the replacement stub. */
135
- stubLen: number;
136
- /** Estimated input tokens saved this collapse (char/4 — no tokenizer in-stack). */
137
- tokensSaved: number;
138
- }
139
-
140
- /** Short human-readable reason for the stub text, per fired trigger. */
141
- function triggerReason(trigger: CondenseTrigger): string {
142
- switch (trigger.kind) {
143
- case 'age':
144
- return 'aged out';
145
- case 'turnEnd':
146
- return 'turn ended';
147
- case 'agentEnd':
148
- return 'agent finished';
149
- case 'phaseEnd':
150
- return 'phase ended';
151
- default:
152
- return 'superseded';
153
- }
154
- }
155
-
156
- /**
157
- * Framework-generated stub, shared by `pointer` and `drop`. Names the tool, the superseded key
158
- * (the only surviving identifier once args are elided — the model cannot otherwise tell WHICH
159
- * file a collapsed `vfs_write` wrote) and the original size.
160
- *
161
- * `restorable` is the ONLY difference between the two modes: `pointer` promises that re-calling
162
- * reproduces the content and is valid solely for idempotent tools; `drop` makes no such promise
163
- * and must not carry the clause. Identifying information is not what separates them — a stub too
164
- * bare to say what the call did is how GENC-1494 started.
165
- */
166
- function condenseStub(
167
- target: 'args' | 'response',
168
- tool: string,
169
- trigger: CondenseTrigger,
170
- origLen: number,
171
- restorable: boolean,
172
- /**
173
- * Images elided alongside the text. Reported separately because the model reads this stub as
174
- * fact: folding base64 bytes into the char count would tell it a 15KB render was "~15447
175
- * chars" of prose. One variable cannot be both the size FLOOR (bytes, correctly) and a
176
- * model-facing description (characters).
177
- */
178
- imageCount = 0,
179
- ): string {
180
- const what = target === 'args' ? 'args' : 'result';
181
- const key = trigger.kind === 'superseded' ? ` ${trigger.by}` : '';
182
- const restore = restorable ? '; re-call to restore' : '';
183
- const images = imageCount > 0 ? ` + ${imageCount} image${imageCount === 1 ? '' : 's'}` : '';
184
- return `[${tool}${key} — ${what} elided, ~${origLen} chars${images} (${triggerReason(trigger)})${restore}]`;
185
- }
186
-
187
- /** Stable label for the `context.condensed` meta-event's `trigger` field. */
188
- function triggerLabel(trigger: CondenseTrigger): string {
189
- switch (trigger.kind) {
190
- case 'age':
191
- return `age:${trigger.turns}`;
192
- case 'superseded':
193
- return `superseded:${trigger.by}`;
194
- default:
195
- return trigger.kind; // 'turnEnd' | 'agentEnd' | 'phaseEnd'
196
- }
197
- }
198
-
199
- function estimateTokensSaved(origLen: number, stubLen: number): number {
200
- return Math.max(0, Math.ceil((origLen - stubLen) / APPROX_CHARS_PER_TOKEN));
201
- }
202
-
203
- /**
204
- * Whether a trigger marks a genuine discontinuity rather than firing continuously, and so is
205
- * exempt from batching.
206
- *
207
- * `agentEnd` advances only when the active agent's name changes (or a sub-agent completes /
208
- * the agent is released), which swaps the system prompt and tool definitions — those render
209
- * before the messages, so the cached prefix is invalidated at that point regardless. `phaseEnd`
210
- * is the app declaring the same kind of boundary explicitly, and falls back to `agentEnd`.
211
- * Deferring either to the next batch would buy no cache and only hold stale payloads longer.
212
- *
213
- * The rest fire continuously (`superseded`, `age`) or at a point where the history is otherwise
214
- * append-only (`turnEnd` — a new turn appends a user message, leaving the previous entry live),
215
- * so batching them is exactly where the cache is won.
216
- */
217
- function marksDiscontinuity(kind: CondenseTrigger['kind']): boolean {
218
- return kind === 'agentEnd' || kind === 'phaseEnd';
219
- }
220
-
221
- /**
222
- * Collapse stale tool payloads (declared via `condenseWhen`) out of the
223
- * model-bound history. Pure over the `history` array — it emits new message
224
- * objects and never mutates the input messages (mirroring the agent-masking
225
- * transform). It DOES, however, write two latches onto the matching `policies`
226
- * entry the first time a payload collapses, both because the collapse itself
227
- * re-runs before every provider call: the `reported*` flag (the report-once gate
228
- * that keeps `onCondensed` firing once per (tool call, payload)) and
229
- * `firedTrigger` (which pins the reason so a reason-bearing stub is rendered
230
- * once and never rewritten).
231
- *
232
- * Triggers (a policy may list several via `on: Trigger[]` — the FIRST to fire
233
- * collapses the payload; all are monotonic, so the collapse never reverts):
234
- * - `superseded` — among all registered calls sharing a `by` key, the LAST in
235
- * history order survives; every earlier one's targeted payload collapses. A
236
- * re-call with the same key becomes the new survivor and re-arms the rest.
237
- * - `age` — the result is first visible one model-call after the call, so `turns`
238
- * is how many model-calls may see the full result before it collapses
239
- * (fires when `modelCall − callModelCall > turns`): `turns: 1` is seen once,
240
- * then collapses. The clock is monotonic across turns.
241
- * - `turnEnd` — collapses once the turn (`sendMessage`) that made the call has
242
- * ended (fires when `turn > callTurn`): full for the rest of that request,
243
- * gone on every later one.
244
- * - `agentEnd` — collapses once the agent activation that made the call has ended
245
- * (a swap to another agent, or `releaseAgent`/`completeSubAgent`): kept across
246
- * all of the agent's turns, dropped when its flow finishes.
247
- * - `phaseEnd` — collapses once the app advances its phase epoch via `endPhase()`
248
- * after the call (a sub-`agentEnd` boundary the author declares), OR at
249
- * `agentEnd` as a backstop if no boundary is ever declared.
250
- *
251
- * The tool-call/result envelope is never removed (providers reject orphaned
252
- * calls/results) — only the args object or the result content is replaced.
253
- *
254
- * @param history - The to-model history copy to rewrite (not mutated).
255
- * @param policies - The driver's registry, keyed by tool-call id. Its entries'
256
- * `reported*` flags are flipped as payloads first collapse.
257
- * @param ctx - The driver's live clocks (model-call / turn / agent-activation).
258
- * @param onCondensed - Invoked once per payload at its full→stub transition.
259
- */
260
- export function applyCondensation(
261
- history: ChatMessage[],
262
- policies: Map<string, RegisteredCondensePolicy>,
263
- ctx: CondenseContext,
264
- onCondensed: (detail: CondensedEventDetail) => void,
265
- ): ChatMessage[] {
266
- if (policies.size === 0) return history;
267
-
268
- const triggersOf = (entry: RegisteredCondensePolicy): CondenseTrigger[] =>
269
- Array.isArray(entry.policy.on) ? entry.policy.on : [entry.policy.on];
270
-
271
- // One pass: resolve supersession survivors and a toolCallId → tool-name
272
- // lookup. For each `superseded` key the LAST call in history order survives.
273
- // The name lookup lets a tool message (which carries only an id) name its tool
274
- // in stubs and events.
275
- // Batch boundary: the collapse set is resolved as of this model-call and stays put
276
- // until the next boundary, so the prefix is append-only in between. `batchCalls` of 1
277
- // (the default) makes this `ctx.modelCall` — i.e. no batching.
278
- const batchCalls = Math.max(1, Math.floor(ctx.batchCalls ?? 1));
279
- const asOf =
280
- batchCalls === 1 ? ctx.modelCall : Math.floor(ctx.modelCall / batchCalls) * batchCalls;
281
- // A call made after the boundary is invisible to this pass in BOTH directions: it
282
- // cannot supersede an older call (which would collapse the older payload early), and
283
- // it cannot itself collapse (which would elide the very result just fetched). Unbatched,
284
- // the boundary is the live clock and this gate is off entirely — a call registered on
285
- // the current model-call is still eligible, as it always was.
286
- const withinBatch = (entry: RegisteredCondensePolicy): boolean =>
287
- batchCalls === 1 || entry.iteration <= asOf;
288
-
289
- const latestByKey = new Map<string, string>();
290
- const nameById = new Map<string, string>();
291
- for (const msg of history) {
292
- if (!msg.toolCalls?.length) continue;
293
- for (const tc of msg.toolCalls) {
294
- nameById.set(tc.id, tc.name);
295
- const entry = policies.get(tc.id);
296
- if (!entry || !withinBatch(entry)) continue;
297
- for (const t of triggersOf(entry)) {
298
- if (t.kind === 'superseded') latestByKey.set(t.by, tc.id);
299
- }
300
- }
301
- }
302
-
303
- const triggerFires = (
304
- toolCallId: string,
305
- entry: RegisteredCondensePolicy,
306
- trig: CondenseTrigger,
307
- ): boolean => {
308
- switch (trig.kind) {
309
- // Result first visible one model-call after the call, so collapse one call
310
- // past the last allowed view: turns:1 → seen once, then gone. Monotonic
311
- // clock, so this holds across turn boundaries too.
312
- case 'age':
313
- return asOf - entry.iteration > trig.turns;
314
- case 'turnEnd':
315
- return ctx.turn > entry.turn;
316
- case 'agentEnd':
317
- return ctx.activationEnded(entry.activation);
318
- // Fires when the app advanced the phase epoch since the call — OR, as a
319
- // backstop, when the whole activation has ended, so a phase boundary that
320
- // is never declared still collapses at `agentEnd` rather than never.
321
- case 'phaseEnd':
322
- return ctx.phaseEnded(entry.phaseEpoch) || ctx.activationEnded(entry.activation);
323
- default:
324
- return latestByKey.get(trig.by) !== toolCallId; // superseded by a newer call
325
- }
326
- };
327
-
328
- // The first listed trigger that fires — drives the collapse, the stub reason,
329
- // and the event label. `undefined` means the payload stays full.
330
- //
331
- // Latched on first fire (see `firedTrigger`): re-resolving on every pass lets the reason
332
- // change under a payload that has already collapsed, which rewrites any reason-bearing stub
333
- // and costs a second cache break. Both payloads of a call share the latch — they are
334
- // evaluated in the same pass off the same clocks, so they never disagree about which
335
- // trigger fired; only the size floor decides whether each one renders.
336
- const firstFired = (
337
- toolCallId: string,
338
- entry: RegisteredCondensePolicy,
339
- ): CondenseTrigger | undefined => {
340
- if (entry.firedTrigger) return entry.firedTrigger;
341
- const fired = triggersOf(entry).find(
342
- (t) =>
343
- (marksDiscontinuity(t.kind) || withinBatch(entry)) && triggerFires(toolCallId, entry, t),
344
- );
345
- if (fired) entry.firedTrigger = fired;
346
- return fired;
347
- };
348
-
349
- return history.map((msg) => {
350
- // Tool-call ARGS live on the assistant message.
351
- if (msg.toolCalls?.length) {
352
- let changed = false;
353
- const toolCalls = msg.toolCalls.map((tc) => {
354
- const entry = policies.get(tc.id);
355
- if (!entry?.policy.args) return tc;
356
- const fired = firstFired(tc.id, entry);
357
- if (!fired) return tc;
358
- const origLen = JSON.stringify(tc.args ?? {}).length;
359
- if (origLen < CONDENSE_MIN_CHARS) return tc;
360
- changed = true;
361
- const result = entry.policy.args;
362
- // Args must stay a Record, so every mode stashes its stub under a single key.
363
- // Spread `tc` so providerMetadata (e.g. the Gemini reasoning signature that must
364
- // round-trip) and UI fields survive.
365
- //
366
- // GENC-1494: `drop` used to collapse args to a bare `{}`. That is indistinguishable
367
- // from a call the model made with NO arguments, and the model reads its own history
368
- // as fact: a ui-builder run saw its elided `vfs_write` calls as `vfs_write {}` beside
369
- // a live `{"status":"buffered","path":"…"}` result, concluded it was calling the tool
370
- // without content, and burned two hours re-writing files that were already correct.
371
- // A bare marker is not enough either — it still fails to say WHICH file the call
372
- // wrote, since `path` went with the args. So `drop` renders the same identifying stub
373
- // as `pointer`, minus only the restore promise it cannot honour.
374
- const args: Record<string, unknown> = {
375
- [CONDENSED_ARGS_KEY]:
376
- result === 'drop' || result === 'pointer'
377
- ? condenseStub('args', tc.name, fired, origLen, result === 'pointer')
378
- : result.replaceWith,
379
- };
380
- if (!entry.reportedArgs) {
381
- entry.reportedArgs = true;
382
- const stubLen = JSON.stringify(args).length;
383
- onCondensed({
384
- turn: entry.iteration,
385
- toolCallId: tc.id,
386
- tool: tc.name,
387
- target: 'args',
388
- trigger: triggerLabel(fired),
389
- stubLen,
390
- tokensSaved: estimateTokensSaved(origLen, stubLen),
391
- });
392
- }
393
- return { ...tc, args };
394
- });
395
- return changed ? { ...msg, toolCalls } : msg;
396
- }
397
-
398
- // The tool RESULT lives on the tool message.
399
- if (msg.toolResult) {
400
- const entry = policies.get(msg.toolResult.toolCallId);
401
- // Defensive `?? 0` for restored/malformed history — `content` is typed
402
- // `string`, but a 0-length payload is simply below the floor and skipped.
403
- //
404
- // Attachment bytes COUNT toward the floor (GENC-1508). An image-bearing result is
405
- // typically a few words of text — "rendered the mockup" — plus ~15KB of base64, so
406
- // sizing on `content` alone leaves it permanently under CONDENSE_MIN_CHARS and the
407
- // image rides every subsequent turn. That is the exact payload condensation exists
408
- // to shed, so it must be what is measured.
409
- const textLen = msg.toolResult.content?.length ?? 0;
410
- const images = msg.toolResult.attachments ?? [];
411
- // Bytes gate the FLOOR (an image result is a few words of text plus ~15KB of base64, so
412
- // sizing on text alone would never condense it); `textLen` and the image count are what
413
- // the model is told. See condenseStub.
414
- const origLen = textLen + images.reduce((n, att) => n + (att.data?.length ?? 0), 0);
415
- const fired = entry?.policy.response
416
- ? firstFired(msg.toolResult.toolCallId, entry)
417
- : undefined;
418
- if (entry && fired && origLen >= CONDENSE_MIN_CHARS) {
419
- const tool = nameById.get(msg.toolResult.toolCallId) ?? msg.toolResult.toolCallId;
420
- const result = entry.policy.response!; // `fired` is only set when response is declared
421
- // Kept non-empty — Anthropic rejects empty tool_result content.
422
- const content =
423
- result === 'drop' || result === 'pointer'
424
- ? condenseStub('response', tool, fired, textLen, result === 'pointer', images.length)
425
- : result.replaceWith;
426
- if (!entry.reportedResponse) {
427
- entry.reportedResponse = true;
428
- onCondensed({
429
- turn: entry.iteration,
430
- toolCallId: msg.toolResult.toolCallId,
431
- tool,
432
- target: 'response',
433
- trigger: triggerLabel(fired),
434
- stubLen: content.length,
435
- tokensSaved: estimateTokensSaved(origLen, content.length),
436
- });
437
- }
438
- // `attachments` is dropped, not spread through: the stub says the payload was
439
- // elided, and leaving the images attached would make that a lie AND shed none of
440
- // the bytes the condensation was performed to shed.
441
- const { attachments: _elided, ...restOfResult } = msg.toolResult;
442
- return { ...msg, toolResult: { ...restOfResult, content } };
443
- }
444
- }
445
-
446
- return msg;
447
- });
448
- }
@@ -1,226 +0,0 @@
1
- import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
2
- import type { CostSessionRecord } from './cost-session-history';
3
- import {
4
- clearCostSessionHistory,
5
- costHistoryStorageKey,
6
- formatCostSessionDate,
7
- loadCostSessionHistory,
8
- resolveBankedUsage,
9
- saveCostSessionHistory,
10
- sortRecordsByRecency,
11
- upsertRecord,
12
- } from './cost-session-history';
13
- import { emptyUsage } from './sum-usage';
14
-
15
- const scope = `test-${Date.now()}`;
16
-
17
- const usage = (costUsd: number): CostSessionRecord['usage'] => ({
18
- costUsd,
19
- uncachedInputTokens: 100_000,
20
- cacheReadTokens: 1_200_000,
21
- cacheWriteTokens: 60_000,
22
- outputTokens: 40_000,
23
- });
24
-
25
- const sampleRecord = (overrides: Partial<CostSessionRecord> = {}): CostSessionRecord => ({
26
- id: 'rec-1',
27
- projectKey: 'proj-1',
28
- title: 'Equity Options Pricer',
29
- updatedAt: '2026-06-14T12:00:00.000Z',
30
- usage: usage(1.86),
31
- // Present by default so `delete record.banked` in the legacy-row cases actually removes
32
- // something, and so the storage round-trip covers it.
33
- banked: usage(0.5),
34
- models: [{ model: 'claude-sonnet-4-6', provider: 'anthropic' }],
35
- ...overrides,
36
- });
37
-
38
- const suite = createLogicSuite('costSessionHistory');
39
-
40
- suite('uses a scoped storage key', () => {
41
- assert.is(costHistoryStorageKey('my-app'), 'genesis-ai-assistant:cost-history:my-app');
42
- });
43
-
44
- suite('upsertRecord prepends a project it has not seen', () => {
45
- const first = upsertRecord([], sampleRecord({ id: 'a', projectKey: 'a', title: 'First' }));
46
- const both = upsertRecord(first, sampleRecord({ id: 'b', projectKey: 'b', title: 'Second' }));
47
- assert.is(both.length, 2);
48
- // Insertion order, not recency — display ordering is sortRecordsByRecency's job.
49
- assert.is(both[0]?.id, 'b', 'most recently added is prepended');
50
- assert.is(both[1]?.id, 'a');
51
- });
52
-
53
- suite('upsertRecord replaces a project in place rather than stacking a second row', () => {
54
- const seeded = [
55
- sampleRecord({ id: 'b', projectKey: 'b', usage: usage(2) }),
56
- sampleRecord({ id: 'a', projectKey: 'a', usage: usage(1) }),
57
- ];
58
- // Re-reporting a project must REPLACE its row. Appending here is what made a single
59
- // project's spend count once per refresh in any total summed over the list.
60
- const next = upsertRecord(seeded, sampleRecord({ id: 'a', projectKey: 'a', usage: usage(9.5) }));
61
- assert.is(next.length, 2);
62
- // Replaced in place rather than moved — the stored array is not a recency ordering.
63
- assert.is(next[1]?.projectKey, 'a');
64
- assert.is(next[1]?.usage.costUsd, 9.5);
65
- assert.is(
66
- next.reduce((sum, r) => sum + r.usage.costUsd, 0),
67
- 11.5,
68
- );
69
- });
70
-
71
- suite('round-trips records through storage', () => {
72
- clearCostSessionHistory(scope);
73
- const records = upsertRecord([], sampleRecord({ id: 'a', projectKey: 'a', usage: usage(3.5) }));
74
- saveCostSessionHistory(scope, records);
75
- const loaded = loadCostSessionHistory(scope);
76
- assert.is(loaded.length, 1);
77
- assert.is(loaded[0]?.usage.costUsd, 3.5);
78
- assert.is(loaded[0]?.usage.cacheReadTokens, 1_200_000, 'buckets survive serialization');
79
- assert.is(loaded[0]?.banked?.costUsd, 0.5, 'banked survives serialization');
80
- clearCostSessionHistory(scope);
81
- });
82
-
83
- suite('discards legacy rows that predate the per-project key', () => {
84
- clearCostSessionHistory(scope);
85
- const legacy = sampleRecord({ id: 'old' }) as Partial<CostSessionRecord>;
86
- delete legacy.projectKey;
87
- saveCostSessionHistory(scope, [legacy as CostSessionRecord, sampleRecord({ id: 'new' })]);
88
- const loaded = loadCostSessionHistory(scope);
89
- // A keyless row can never be matched to a project, so pooling it with the new rows
90
- // would permanently double-count whichever project it came from.
91
- assert.is(loaded.length, 1);
92
- assert.is(loaded[0]?.id, 'new');
93
- clearCostSessionHistory(scope);
94
- });
95
-
96
- suite('discards rows whose usage is not fully numeric', () => {
97
- clearCostSessionHistory(scope);
98
- const broken = sampleRecord({ id: 'broken' });
99
- // A half-shaped `usage` is worse than a missing row: it sums as NaN and poisons every
100
- // total on the Usage tab, with no clue where the NaN came from.
101
- delete (broken.usage as Partial<CostSessionRecord['usage']>).outputTokens;
102
- saveCostSessionHistory(scope, [broken, sampleRecord({ id: 'ok', projectKey: 'ok' })]);
103
- const loaded = loadCostSessionHistory(scope);
104
- assert.is(loaded.length, 1);
105
- assert.is(loaded[0]?.id, 'ok');
106
- clearCostSessionHistory(scope);
107
- });
108
-
109
- suite('discards rows whose banked figure is half-shaped', () => {
110
- clearCostSessionHistory(scope);
111
- const broken = sampleRecord({ id: 'broken', banked: { ...usage(2) } });
112
- // A malformed `banked` poisons the lifetime total exactly as a malformed `usage` does,
113
- // since the two are added together to produce it.
114
- delete (broken.banked as Partial<CostSessionRecord['usage']>).cacheWriteTokens;
115
- saveCostSessionHistory(scope, [broken, sampleRecord({ id: 'ok', projectKey: 'ok' })]);
116
- const loaded = loadCostSessionHistory(scope);
117
- assert.is(loaded.length, 1);
118
- assert.is(loaded[0]?.id, 'ok');
119
- clearCostSessionHistory(scope);
120
- });
121
-
122
- suite('keeps a row whose banked figure is absent', () => {
123
- clearCostSessionHistory(scope);
124
- const legacy = sampleRecord({ id: 'legacy' });
125
- delete legacy.banked;
126
- saveCostSessionHistory(scope, [legacy]);
127
- assert.is(loadCostSessionHistory(scope).length, 1, 'absent is valid, unlike half-shaped');
128
- clearCostSessionHistory(scope);
129
- });
130
-
131
- suite('formats session dates for display', () => {
132
- assert.is(formatCostSessionDate('2026-06-14T12:00:00.000Z'), 'Jun 14, 2026');
133
- });
134
-
135
- suite('sortRecordsByRecency puts the most recently worked-on project first', () => {
136
- const records = [
137
- sampleRecord({ id: 'old', projectKey: 'old', updatedAt: '2026-01-01T00:00:00.000Z' }),
138
- sampleRecord({ id: 'newest', projectKey: 'newest', updatedAt: '2026-08-04T09:00:00.000Z' }),
139
- sampleRecord({ id: 'mid', projectKey: 'mid', updatedAt: '2026-05-01T00:00:00.000Z' }),
140
- ];
141
- const sorted = sortRecordsByRecency(records);
142
- assert.equal(
143
- sorted.map((r) => r.id),
144
- ['newest', 'mid', 'old'],
145
- );
146
- // Read-time ordering, so the caller's array must be left alone.
147
- assert.equal(
148
- records.map((r) => r.id),
149
- ['old', 'newest', 'mid'],
150
- 'input not mutated',
151
- );
152
- });
153
-
154
- suite('sortRecordsByRecency overrides insertion order, which upsert leaves alone', () => {
155
- // The behaviour this fixes: revisiting a project replaces its row IN PLACE, so insertion order
156
- // strands it wherever it was first seen. Display order must come from updatedAt instead.
157
- let records = upsertRecord(
158
- [],
159
- sampleRecord({ id: 'a', projectKey: 'a', updatedAt: '2026-01-01T00:00:00.000Z' }),
160
- );
161
- records = upsertRecord(
162
- records,
163
- sampleRecord({ id: 'b', projectKey: 'b', updatedAt: '2026-02-01T00:00:00.000Z' }),
164
- );
165
- // Work on 'a' again — newer than 'b', but upsert keeps it in slot 1.
166
- records = upsertRecord(
167
- records,
168
- sampleRecord({ id: 'a', projectKey: 'a', updatedAt: '2026-03-01T00:00:00.000Z' }),
169
- );
170
- assert.is(records[1]?.id, 'a', 'stored order is unchanged by the revisit');
171
- assert.is(sortRecordsByRecency(records)[0]?.id, 'a', 'display order tracks the revisit');
172
- });
173
-
174
- suite('banks nothing for a project with no prior row', () => {
175
- assert.equal(resolveBankedUsage(undefined, true), emptyUsage());
176
- assert.equal(resolveBankedUsage(undefined, false), emptyUsage());
177
- });
178
-
179
- suite('carries the prior banked figure forward when the transcript is authoritative', () => {
180
- // Restored transcript: it already re-proves everything after the row's own banked figure,
181
- // so only that figure carries over. Taking `usage` here is the compounding double-count.
182
- const prior = sampleRecord({ usage: usage(10), banked: { ...usage(4) } });
183
- assert.is(resolveBankedUsage(prior, true).costUsd, 4);
184
- });
185
-
186
- suite('banks the whole prior total when the transcript starts empty', () => {
187
- // Nothing on screen accounts for the previous total, so all of it must carry over or the
188
- // next turn rewrites the row down to just that turn.
189
- const prior = sampleRecord({ usage: usage(10), banked: { ...usage(4) } });
190
- assert.is(resolveBankedUsage(prior, false).costUsd, 10);
191
- });
192
-
193
- suite('treats a row predating the banked field as nothing banked', () => {
194
- const legacy = sampleRecord({ usage: usage(7) });
195
- delete legacy.banked;
196
- assert.equal(resolveBankedUsage(legacy, true), emptyUsage());
197
- // Without persistence the legacy total still carries, so upgrading loses no spend.
198
- assert.is(resolveBankedUsage(legacy, false).costUsd, 7);
199
- });
200
-
201
- suite('is stable across repeated reloads — the regression this guards', () => {
202
- // Simulate the reload cycle: usage = banked + whatever the transcript proves. With a
203
- // restored transcript (worth 6 on top of 4 banked) the row must land on 10 every time,
204
- // not 10 → 14 → 18 as it did when a reload re-banked the restored history.
205
- const transcript = 6;
206
- let row = sampleRecord({ usage: usage(10), banked: { ...usage(4) } });
207
- for (let reload = 0; reload < 3; reload += 1) {
208
- const banked = resolveBankedUsage(row, true);
209
- row = { ...row, banked, usage: { ...usage(banked.costUsd + transcript) } };
210
- assert.is(row.usage.costUsd, 10, `reload ${reload}`);
211
- }
212
- });
213
-
214
- suite('accumulates across reloads when the transcript does not persist', () => {
215
- // The mirror case: each page-load starts from an empty transcript, so the row must grow by
216
- // whatever that load spends rather than being overwritten by it.
217
- let row = sampleRecord({ usage: usage(10), banked: { ...usage(4) } });
218
- for (const spend of [2, 3]) {
219
- const banked = resolveBankedUsage(row, false);
220
- row = { ...row, banked, usage: { ...usage(banked.costUsd + spend) } };
221
- }
222
- // 10 banked, +2 → 12; then 12 banked, +3 → 15. Never decreases.
223
- assert.is(row.usage.costUsd, 15);
224
- });
225
-
226
- suite.run();