@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,787 +0,0 @@
1
- import type { ChatMessage } from '@genesislcap/foundation-ai';
2
- import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
3
- import { clearMetaEventRegistry, getMetaEvents } from '../debug-event-log';
4
- import type { DiagnosticEntry } from './diagnostics';
5
- import { clearDiagnosticsCursorRegistry } from './diagnostics-cursors';
6
- import { clearPersisterRegistry } from './persister-registry';
7
- import type { SessionPreferences } from './session-persistence-provider';
8
- import { SessionPersister, type SessionPersisterDeps } from './session-persister';
9
- import { createPersistedSession, type PersistedSession } from './session-snapshot';
10
-
11
- // GENC-1351 §5.10: the shared per-session persister owns restore-core save/restore/
12
- // freeze. Unit-tested in isolation with fake driver/store/provider — the win the
13
- // extraction buys (this logic was previously only exercised by manual testing).
14
-
15
- const HOST = window.location.host;
16
- const KEY = 'session-1';
17
- const msg = (o: Partial<ChatMessage>): ChatMessage =>
18
- ({ role: 'user', content: '', ...o }) as ChatMessage;
19
- const tick = (): Promise<void> =>
20
- new Promise((r) => {
21
- setTimeout(r, 0);
22
- });
23
-
24
- interface Fakes {
25
- saved: PersistedSession[];
26
- cleared: number;
27
- loadResult: PersistedSession | null;
28
- loadedHistory: ChatMessage[][];
29
- primed: unknown[];
30
- loadSessionCalls: unknown[];
31
- inputValues: string[];
32
- history: ChatMessage[];
33
- activeAgent: Record<string, unknown> | undefined;
34
- enabled: boolean;
35
- restoring: boolean;
36
- /** Diagnostic entries the element would build (Phase 3). */
37
- diagnosticEntries: DiagnosticEntry[];
38
- /** Batches passed to provider.appendDiagnostics. */
39
- appended: DiagnosticEntry[][];
40
- /** Blobs passed to provider.savePreferences. */
41
- savedPrefs: SessionPreferences[];
42
- /** What provider.loadPreferences returns. */
43
- loadPrefsResult: SessionPreferences | undefined;
44
- /** Set false to omit `appendDiagnostics`/`savePreferences` from the provider. */
45
- diagnosticsCapable: boolean;
46
- /** When set, `provider.save` rejects with it (GENC-1511 save-failure telemetry). */
47
- saveError: Error | undefined;
48
- /** Details passed to `onPersistenceBroken` (GENC-1511 user-facing warning). */
49
- brokenReports: { unsavedMessages: number; lastSavedAt?: string; reason: string }[];
50
- /** How many times `onPersistenceRecovered` fired. */
51
- recovered: number;
52
- }
53
-
54
- function makePersister(over: Partial<Fakes> = {}): { p: SessionPersister; f: Fakes } {
55
- const f: Fakes = {
56
- saved: [],
57
- cleared: 0,
58
- loadResult: null,
59
- loadedHistory: [],
60
- primed: [],
61
- loadSessionCalls: [],
62
- inputValues: [],
63
- history: [],
64
- activeAgent: undefined,
65
- enabled: true,
66
- restoring: false,
67
- diagnosticEntries: [],
68
- appended: [],
69
- savedPrefs: [],
70
- loadPrefsResult: undefined,
71
- diagnosticsCapable: true,
72
- saveError: undefined,
73
- brokenReports: [],
74
- recovered: 0,
75
- ...over,
76
- };
77
- const provider = {
78
- saveDebounceMs: 0,
79
- diagnosticsDebounceMs: 0,
80
- save: async (_k: string, snap: PersistedSession) => {
81
- if (f.saveError) throw f.saveError;
82
- f.saved.push(snap);
83
- },
84
- load: async () => f.loadResult,
85
- clear: async () => {
86
- f.cleared += 1;
87
- },
88
- loadPreferences: async () => f.loadPrefsResult,
89
- ...(f.diagnosticsCapable
90
- ? {
91
- appendDiagnostics: async (_k: string, entries: DiagnosticEntry[]) => {
92
- f.appended.push(entries);
93
- },
94
- savePreferences: async (_k: string, prefs: SessionPreferences) => {
95
- f.savedPrefs.push(prefs);
96
- },
97
- }
98
- : {}),
99
- };
100
- const driver = {
101
- getRawHistory: () => f.history,
102
- getActiveAgent: () => f.activeAgent,
103
- loadHistory: (h: ChatMessage[]) => f.loadedHistory.push(h),
104
- primeRestoredAgentState: (s: unknown) => f.primed.push(s),
105
- setPinnedAgent: () => {},
106
- };
107
- const store = {
108
- store: {
109
- aiAssistant: {
110
- messages: f.history,
111
- flowOwnerAgentName: null,
112
- flowActivationPrompt: null,
113
- activeAgent: undefined,
114
- pinnedAgentName: null,
115
- // Inline rather than via `emptyUsage()` to keep this mock self-contained. The
116
- // shape matters: the persister reads `sessionUsage.costUsd`, so a mock missing
117
- // the object crashes the autosave timer rather than failing an assertion.
118
- sessionUsage: {
119
- costUsd: 0,
120
- uncachedInputTokens: 0,
121
- cacheReadTokens: 0,
122
- cacheWriteTokens: 0,
123
- outputTokens: 0,
124
- },
125
- get restoring() {
126
- return f.restoring;
127
- },
128
- },
129
- },
130
- actions: {
131
- aiAssistant: {
132
- loadSession: (p: unknown) => f.loadSessionCalls.push(p),
133
- setInputValue: (v: string) => f.inputValues.push(v),
134
- },
135
- },
136
- };
137
- const deps: SessionPersisterDeps = {
138
- stateKey: KEY,
139
- getPersistence: () => ({ enabled: f.enabled, provider: provider as never }),
140
- getAgentNames: () => [],
141
- getDriver: () => driver as never,
142
- getStore: () => store as never,
143
- getDiagnosticEntries: () => f.diagnosticEntries,
144
- onPersistenceBroken: (d) => f.brokenReports.push(d),
145
- onPersistenceRecovered: () => {
146
- f.recovered += 1;
147
- },
148
- };
149
- return { p: new SessionPersister(deps), f };
150
- }
151
-
152
- const Suite = createLogicSuite('SessionPersister');
153
-
154
- Suite.before.each(() => {
155
- clearPersisterRegistry();
156
- clearDiagnosticsCursorRegistry();
157
- clearMetaEventRegistry();
158
- });
159
-
160
- Suite('restore() hydrates the store + driver and appends a "Restored" divider', async () => {
161
- const snapshot = createPersistedSession({
162
- sessionKey: KEY,
163
- host: HOST,
164
- messages: [msg({ role: 'user', content: 'hi' }), msg({ role: 'assistant', content: 'hello' })],
165
- pinnedAgentName: null,
166
- flowOwnerAgentName: null,
167
- sessionCostUsd: 0,
168
- });
169
- const { p, f } = makePersister({ loadResult: snapshot });
170
- await p.restore();
171
- assert.is(f.loadSessionCalls.length, 1, 'store hydrated once');
172
- assert.is(f.loadedHistory.length, 1, 'driver history loaded once');
173
- const loaded = f.loadedHistory[0];
174
- assert.is(loaded.length, 3, 'two restored messages + a trailer');
175
- assert.is(loaded[2].role, 'system-event');
176
- assert.is(f.inputValues.length, 0, 'no composer prefill for a normal restore');
177
- });
178
-
179
- Suite(
180
- 'restore() of an interrupted snapshot re-appends the triggering message + notice + pre-fills',
181
- async () => {
182
- const snapshot: PersistedSession = {
183
- ...createPersistedSession({
184
- sessionKey: KEY,
185
- host: HOST,
186
- // The frozen snapshot is the prior *resumable* conversation — it does NOT
187
- // contain the interrupted flow's triggering message; that is re-appended from
188
- // the `interrupted.prompt` so the exchange reads as a conversation.
189
- messages: [
190
- msg({ role: 'user', content: 'hi' }),
191
- msg({ role: 'assistant', content: 'hello' }),
192
- ],
193
- pinnedAgentName: null,
194
- flowOwnerAgentName: null,
195
- sessionCostUsd: 0,
196
- }),
197
- interrupted: { message: 'I did not finish — send it again', prompt: 'do the thing' },
198
- };
199
- const { p, f } = makePersister({ loadResult: snapshot });
200
- await p.restore();
201
- const loaded = f.loadedHistory[0];
202
- // …prior conversation, user('do the thing'), assistant(notice)
203
- assert.is(
204
- loaded[loaded.length - 2].role,
205
- 'user',
206
- 'triggering message re-appended before the notice',
207
- );
208
- assert.is(loaded[loaded.length - 2].content, 'do the thing');
209
- assert.is(loaded[loaded.length - 1].role, 'assistant', 'notice is an assistant bubble');
210
- assert.is(loaded[loaded.length - 1].content, 'I did not finish — send it again');
211
- assert.equal(f.inputValues, ['do the thing'], 'composer pre-filled with the flow prompt');
212
- },
213
- );
214
-
215
- Suite(
216
- 'restore() of an interrupted snapshot without a captured prompt appends only the notice',
217
- async () => {
218
- const snapshot: PersistedSession = {
219
- ...createPersistedSession({
220
- sessionKey: KEY,
221
- host: HOST,
222
- messages: [
223
- msg({ role: 'user', content: 'hi' }),
224
- msg({ role: 'assistant', content: 'hello' }),
225
- ],
226
- pinnedAgentName: null,
227
- flowOwnerAgentName: null,
228
- sessionCostUsd: 0,
229
- }),
230
- interrupted: { message: 'I did not finish — send it again' },
231
- };
232
- const { p, f } = makePersister({ loadResult: snapshot });
233
- await p.restore();
234
- const loaded = f.loadedHistory[0];
235
- assert.is(loaded.length, 3, 'prior 2 messages + notice only — no synthesized user message');
236
- assert.is(loaded[loaded.length - 1].role, 'assistant', 'notice is an assistant bubble');
237
- assert.equal(f.inputValues, [], 'no composer pre-fill without a captured prompt');
238
- },
239
- );
240
-
241
- Suite(
242
- 'restore() of a fresh-freeze interrupted snapshot does NOT duplicate the trigger message',
243
- async () => {
244
- // The fresh-freeze fallback (no prior resumable snapshot) keeps the transcript
245
- // THROUGH the last user message (`keepThroughLastUserMessage`), so the triggering
246
- // prompt is already the final message. restore() must NOT re-append it from
247
- // `interrupted.prompt` — doing so duplicated the bubble on a fresh session that
248
- // went non-resumable on its first turn (GENC-1351 §5.9).
249
- const snapshot: PersistedSession = {
250
- ...createPersistedSession({
251
- sessionKey: KEY,
252
- host: HOST,
253
- messages: [msg({ role: 'user', content: 'cancel trade T123' })],
254
- pinnedAgentName: null,
255
- flowOwnerAgentName: null,
256
- sessionCostUsd: 0,
257
- }),
258
- interrupted: { message: 'I did not finish — send it again', prompt: 'cancel trade T123' },
259
- };
260
- const { p, f } = makePersister({ loadResult: snapshot });
261
- await p.restore();
262
- const loaded = f.loadedHistory[0];
263
- assert.is(loaded.filter((m) => m.role === 'user').length, 1, 'trigger appears once');
264
- assert.is(loaded.length, 2, 'trigger + notice only — no duplicate user bubble');
265
- assert.is(loaded[0].content, 'cancel trade T123');
266
- assert.is(loaded[1].role, 'assistant', 'notice is an assistant bubble');
267
- assert.equal(f.inputValues, ['cancel trade T123'], 'composer still pre-filled with the prompt');
268
- },
269
- );
270
-
271
- Suite('restore() with nothing stored is a silent no-op', async () => {
272
- const { p, f } = makePersister({ loadResult: null });
273
- await p.restore();
274
- assert.is(f.loadSessionCalls.length, 0);
275
- assert.is(f.loadedHistory.length, 0);
276
- });
277
-
278
- Suite('scheduleSave() persists the current transcript (single debounced save)', async () => {
279
- const history = [msg({ role: 'user', content: 'save me' })];
280
- const { p, f } = makePersister({ history });
281
- p.scheduleSave();
282
- p.scheduleSave(); // second view — must coalesce into ONE save
283
- await tick();
284
- await tick();
285
- assert.is(f.saved.length, 1, 'coalesced to a single save');
286
- assert.is(f.saved[0].messages.length, 1);
287
- assert.is(f.saved[0].messages[0].content, 'save me');
288
- assert.is(f.saved[0].interrupted, undefined, 'a normal save carries no interrupted marker');
289
- });
290
-
291
- Suite('scheduleSave() is a no-op when persistence is disabled', async () => {
292
- const { p, f } = makePersister({ enabled: false, history: [msg({ content: 'x' })] });
293
- p.scheduleSave();
294
- await tick();
295
- await tick();
296
- assert.is(f.saved.length, 0);
297
- });
298
-
299
- Suite(
300
- 'freeze: a non-resumable agent keeps the conversation and drops only in-flight work',
301
- async () => {
302
- // Prior persisted (resumable) snapshot with real conversation.
303
- const prior = createPersistedSession({
304
- sessionKey: KEY,
305
- host: HOST,
306
- messages: [
307
- msg({ role: 'user', content: 'earlier' }),
308
- msg({ role: 'assistant', content: 'ok' }),
309
- ],
310
- pinnedAgentName: 'Trade Ops',
311
- flowOwnerAgentName: null,
312
- sessionCostUsd: 0,
313
- });
314
- const { p, f } = makePersister({
315
- loadResult: prior,
316
- // in-flight transcript has extra agent work we must NOT persist
317
- history: [
318
- msg({ role: 'user', content: 'earlier' }),
319
- msg({ role: 'assistant', content: 'ok' }),
320
- msg({ role: 'assistant', content: 'in-flight work' }),
321
- ],
322
- activeAgent: {
323
- name: 'Trade Ops',
324
- resumable: async () => false,
325
- notResumableMessage: 'start again',
326
- serialize: () => ({}),
327
- },
328
- });
329
- p.scheduleSave();
330
- await tick();
331
- await tick();
332
- assert.is(f.saved.length, 1, 'one freeze-stamp write');
333
- const written = f.saved[0];
334
- assert.ok(written.interrupted, 'stamped with an interrupted marker');
335
- assert.is(written.interrupted?.message, 'start again');
336
- // GENC-1511: the boundary is the LIVE transcript through the last user message, floored
337
- // at whatever was already persisted — never the stored blob, which is what silently
338
- // rolled a session back three hours. Here the last user message is index 0, so the floor
339
- // (nothing persisted by this persister yet) leaves 1; the in-flight assistant turn is
340
- // dropped either way, which is the property that matters.
341
- assert.is(written.messages.length, 1, 'in-flight agent work is not persisted');
342
- assert.is(written.messages[0].content, 'earlier', 'the conversation itself is kept');
343
- assert.is(written.flowOwnerAgentName, null, 'flow ownership dropped');
344
- assert.equal(written.agentSnapshots, {}, 'flow state dropped');
345
- },
346
- );
347
-
348
- Suite('freeze: the boundary never regresses below what was already persisted', async () => {
349
- // A resumable save lands the full transcript; the agent then goes non-resumable WITHOUT
350
- // a new user prompt (auto-advance, a resumed journey step). `keepThroughLastUserMessage`
351
- // alone would rewind to message 1 and drop the completed exchange — the floor stops it.
352
- const history = [
353
- msg({ role: 'user', content: 'earlier' }),
354
- msg({ role: 'assistant', content: 'ok' }),
355
- ];
356
- const agent = { name: 'Trade Ops', resumable: async () => true, serialize: () => ({}) };
357
- const { p, f } = makePersister({ history, activeAgent: agent });
358
- p.scheduleSave();
359
- await tick();
360
- assert.is(f.saved.length, 1, 'the resumable save landed');
361
- assert.is(f.saved[0].messages.length, 2, 'full transcript persisted');
362
-
363
- agent.resumable = async () => false;
364
- history.push(msg({ role: 'assistant', content: 'in-flight work' }));
365
- p.scheduleSave();
366
- await tick();
367
- await tick();
368
- assert.is(f.saved.length, 2, 'the freeze wrote');
369
- assert.is(f.saved[1].messages.length, 2, 'floored at the already-persisted length');
370
- assert.ok(f.saved[1].interrupted, 'and stamped interrupted');
371
- });
372
-
373
- Suite('freeze: an unmoved boundary skips the write, and never latches', async () => {
374
- const history = [
375
- msg({ role: 'user', content: 'do it' }),
376
- msg({ role: 'assistant', content: 'working' }),
377
- ];
378
- const agent = {
379
- name: 'Trade Ops',
380
- resumable: async () => false,
381
- serialize: () => ({}),
382
- };
383
- const { p, f } = makePersister({ history, activeAgent: agent });
384
- p.scheduleSave();
385
- await tick();
386
- await tick();
387
- assert.is(f.saved.length, 1, 'first freeze writes the boundary');
388
-
389
- // More in-flight turns, same last user message → nothing new to freeze, no write.
390
- history.push(msg({ role: 'assistant', content: 'still working' }));
391
- p.scheduleSave();
392
- await tick();
393
- await tick();
394
- assert.is(f.saved.length, 1, 'unmoved boundary does not re-write');
395
-
396
- // The old `interrupted` latch made that state permanent. A new prompt must still land.
397
- history.push(msg({ role: 'user', content: 'next thing' }));
398
- p.scheduleSave();
399
- await tick();
400
- await tick();
401
- assert.is(f.saved.length, 2, 'a moved boundary writes again — the skip is not a latch');
402
- assert.is(f.saved[1].messages.length, 4, 'frozen through the new user message');
403
- });
404
-
405
- Suite('clearRestoreCore() drops the snapshot', async () => {
406
- const { p, f } = makePersister();
407
- await p.clearRestoreCore();
408
- assert.is(f.cleared, 1);
409
- });
410
-
411
- // ── Phase 2: preferences ────────────────────────────────────────────────────
412
-
413
- Suite(
414
- 'loadPreferences() populates storedPreferences from the provider blob (null when none)',
415
- async () => {
416
- const prefs: SessionPreferences = { enabled: true, showToolCalls: true };
417
- const { p } = makePersister({ loadPrefsResult: prefs });
418
- await p.loadPreferences();
419
- assert.equal(p.storedPreferences, prefs);
420
-
421
- const { p: p2 } = makePersister(); // provider present, nothing stored
422
- await p2.loadPreferences();
423
- assert.is(p2.storedPreferences, null);
424
- },
425
- );
426
-
427
- Suite(
428
- 'savePreferences() persists, records the (shared) user change, and refreshes storedPreferences',
429
- () => {
430
- const blob: SessionPreferences = { enabled: false, showNarration: true };
431
- const { p, f } = makePersister();
432
- assert.is(p.preferencesUserChanged, false);
433
- p.savePreferences(blob);
434
- assert.equal(f.savedPrefs, [blob]);
435
- assert.is(p.preferencesUserChanged, true, 'the shared latch is set so the reconciler freezes');
436
- assert.equal(p.storedPreferences, blob, 'stored blob kept fresh for a later re-reconcile');
437
-
438
- // No savePreferences on the provider → no write, but the user change is still
439
- // recorded (freezing the baseline regardless of a persistence-capable backend).
440
- const { p: p2, f: f2 } = makePersister({ diagnosticsCapable: false });
441
- p2.savePreferences(blob);
442
- assert.is(f2.savedPrefs.length, 0);
443
- assert.is(p2.preferencesUserChanged, true);
444
- },
445
- );
446
-
447
- // ── Phase 3: diagnostics-append ───────────────────────────────────────────────
448
-
449
- const evt = (index: number): DiagnosticEntry => ({
450
- kind: 'event',
451
- type: 'turn.start',
452
- index,
453
- timestamp: `2026-01-01T00:00:0${index}.000Z`,
454
- });
455
-
456
- const metaSnap = (dedupSignature: string, meta: unknown): DiagnosticEntry => ({
457
- kind: 'meta-snapshot',
458
- timestamp: '2026-01-01T00:00:00.000Z',
459
- meta,
460
- dedupSignature,
461
- });
462
-
463
- // A diagnostic MESSAGE entry as the reasoning/narration/answer split (GENC-1411) produces them —
464
- // several messages from ONE response, sharing timestamp + role, differing by `category` / content.
465
- const diagMsg = (over: Partial<DiagnosticEntry> = {}): DiagnosticEntry => ({
466
- kind: 'message',
467
- role: 'assistant',
468
- timestamp: '2026-01-01T00:00:05.000Z',
469
- content: '',
470
- ...over,
471
- });
472
-
473
- Suite('scheduleDiagnostics() appends the collected delta once per window', async () => {
474
- const { p, f } = makePersister({ diagnosticEntries: [evt(0), evt(1)] });
475
- p.scheduleDiagnostics();
476
- p.scheduleDiagnostics(); // second view → coalesced
477
- await tick();
478
- await tick();
479
- assert.is(f.appended.length, 1, 'one append despite two schedules');
480
- assert.is(f.appended[0].length, 2);
481
- });
482
-
483
- Suite('scheduleDiagnostics() holds off while the session is restoring', async () => {
484
- const { p, f } = makePersister({ diagnosticEntries: [evt(0)], restoring: true });
485
- p.scheduleDiagnostics();
486
- await tick();
487
- await tick();
488
- assert.is(f.appended.length, 0, 'no capture mid-restore (watermark not yet set)');
489
- });
490
-
491
- Suite('flushDiagnostics() appends immediately and de-dups via the shared cursors', async () => {
492
- const { p, f } = makePersister({ diagnosticEntries: [evt(0), evt(1)] });
493
- await p.flushDiagnostics();
494
- assert.is(f.appended.length, 1, 'first flush appends the two events');
495
- assert.is(f.appended[0].length, 2);
496
- // Nothing new since — the event cursor has advanced, so the delta is empty.
497
- await p.flushDiagnostics();
498
- assert.is(f.appended.length, 1, 'no re-append of already-emitted events');
499
- });
500
-
501
- Suite('flushDiagnostics() is a no-op without a diagnostics-capable provider', async () => {
502
- const { p, f } = makePersister({ diagnosticsCapable: false, diagnosticEntries: [evt(0)] });
503
- await p.flushDiagnostics();
504
- assert.is(f.appended.length, 0);
505
- });
506
-
507
- Suite(
508
- 'flushDiagnostics() dedups meta-snapshots by dedupSignature, not the volatile meta',
509
- async () => {
510
- // Regression for #12: the guard compared JSON.stringify(entry.meta), which embeds a
511
- // fresh timestamp + per-turn context, so it never matched → the bulky block was
512
- // re-appended on every flush. It must dedup on the stable config signature instead.
513
- const { p, f } = makePersister({
514
- diagnosticEntries: [metaSnap('cfg-1', { timestamp: 't1', context: { tokens: 1 } })],
515
- });
516
- await p.flushDiagnostics();
517
- assert.is(f.appended.length, 1, 'first meta-snapshot appended');
518
-
519
- // Same config signature, but the volatile meta changed (new timestamp + tokens) → skip.
520
- f.diagnosticEntries = [metaSnap('cfg-1', { timestamp: 't2', context: { tokens: 999 } })];
521
- await p.flushDiagnostics();
522
- assert.is(f.appended.length, 1, 'unchanged config → no re-append despite volatile churn');
523
-
524
- // Config signature changed → append again.
525
- f.diagnosticEntries = [metaSnap('cfg-2', { timestamp: 't3', context: { tokens: 999 } })];
526
- await p.flushDiagnostics();
527
- assert.is(f.appended.length, 2, 'changed config → re-appended');
528
- },
529
- );
530
-
531
- Suite(
532
- 'flushDiagnostics() keeps co-timestamped split messages (reasoning + answer), not deduped as one',
533
- async () => {
534
- // GENC-1461: the reasoning/narration/answer split emits several messages from ONE response, so
535
- // they share timestamp + role + subAgentOf. The forward-capture keyed only on those, so it
536
- // emitted the reasoning message and dropped the answer as a "duplicate" — a server generation's
537
- // answer message vanished from the persisted download while its reasoning survived. Both must be
538
- // captured.
539
- const reasoning = diagMsg({ category: 'reasoning', content: 'thinking…' });
540
- const answer = diagMsg({ content: 'the generated code' }); // no category = the answer/tool-call slot
541
- const { p, f } = makePersister({ diagnosticEntries: [reasoning, answer] });
542
- await p.flushDiagnostics();
543
- assert.is(f.appended.length, 1, 'one flush');
544
- assert.is(
545
- f.appended[0].length,
546
- 2,
547
- 'both the reasoning and the answer message are captured, not collapsed to one',
548
- );
549
- },
550
- );
551
-
552
- Suite('flushDiagnostics() re-emits a turn once its per-call usage lands', async () => {
553
- // A turn entry is created BEFORE its model call and priced when the response returns,
554
- // so a flush landing in between persists it unpriced. Emitting it exactly once more —
555
- // when `usage` is there — is what keeps per-call cost in a stitched lifetime log;
556
- // `assembleDebugLog` collapses the pair back onto the priced copy.
557
- const inFlight: DiagnosticEntry = {
558
- kind: 'turn',
559
- turnIndex: '0',
560
- timestamp: '2026-01-01T00:00:01.000Z',
561
- toolNames: [],
562
- };
563
- const { p, f } = makePersister({ diagnosticEntries: [inFlight] });
564
- await p.flushDiagnostics();
565
- assert.is(f.appended.length, 1, 'the unpriced turn is captured');
566
- assert.is(f.appended[0][0].usage, undefined);
567
-
568
- const priced: DiagnosticEntry = {
569
- ...inFlight,
570
- usage: {
571
- costUsd: 0.25,
572
- uncachedInputTokens: 100,
573
- cacheReadTokens: 900,
574
- cacheWriteTokens: 0,
575
- outputTokens: 40,
576
- },
577
- };
578
- f.diagnosticEntries = [priced];
579
- await p.flushDiagnostics();
580
- assert.is(f.appended.length, 2, 'the priced copy is appended');
581
- assert.equal(f.appended[1][0].usage, priced.usage);
582
-
583
- // ...and only once. A throttled flush runs on every transcript change, so a turn that
584
- // re-emitted per flush would multiply the biggest entries in the stream.
585
- await p.flushDiagnostics();
586
- assert.is(f.appended.length, 2, 'no further re-append of the priced turn');
587
- });
588
-
589
- Suite(
590
- 'flushDiagnostics() still dedups a genuine re-append of the same message across flushes',
591
- async () => {
592
- // The dedup must still stop the SAME message being re-appended on every throttled flush.
593
- const m = diagMsg({ category: 'reasoning', content: 'thinking…' });
594
- const { p, f } = makePersister({ diagnosticEntries: [m] });
595
- await p.flushDiagnostics();
596
- assert.is(f.appended.length, 1, 'first flush appends the message');
597
- await p.flushDiagnostics();
598
- assert.is(f.appended.length, 1, 'unchanged message → no re-append');
599
- },
600
- );
601
-
602
- // GENC-1511: the save telemetry has to describe what HAPPENED, not what was attempted.
603
- // `persistence.saved` used to be emitted before `provider.save` was awaited and the
604
- // rejection was swallowed into `logger.error`, so a session whose writes had been
605
- // failing for hours reported nothing but successful saves — in the debug log, in Loki
606
- // and in Sentry alike.
607
- Suite('a save that lands emits persistence.saved and no failure event', async () => {
608
- const { p, f } = makePersister({ history: [msg({ content: 'hi' })] });
609
- p.scheduleSave();
610
- await tick();
611
- assert.is(f.saved.length, 1, 'the write landed');
612
- const types = getMetaEvents(KEY).map((e) => e.type);
613
- assert.ok(types.includes('persistence.saved'), 'success is recorded');
614
- assert.not.ok(types.includes('persistence.save-failed'), 'no failure recorded');
615
- });
616
-
617
- Suite('a save that throws emits persistence.save-failed and NOT persistence.saved', async () => {
618
- const { p, f } = makePersister({
619
- history: [msg({ content: 'hi' })],
620
- saveError: Object.assign(new TypeError('Converting circular structure to JSON'), {}),
621
- });
622
- p.scheduleSave();
623
- await tick();
624
- assert.is(f.saved.length, 0, 'nothing was written');
625
- const events = getMetaEvents(KEY);
626
- const types = events.map((e) => e.type);
627
- assert.not.ok(
628
- types.includes('persistence.saved'),
629
- 'a failed write must never report itself as saved',
630
- );
631
- const failed = events.find((e) => e.type === 'persistence.save-failed');
632
- assert.ok(failed, 'the failure is recorded');
633
- // `name` is what separates a transport rejection from a serialisation fault: a
634
- // TypeError means the body never reached the wire, so there is no server-side trace.
635
- assert.is((failed?.detail as Record<string, unknown>)?.error, 'TypeError');
636
- assert.is((failed?.detail as Record<string, unknown>)?.messages, 1);
637
- });
638
-
639
- // GENC-1511 — the user-facing warning. The gate is `resumable`, not `busy`: a bootstrap journey
640
- // is ONE turn (measured at 2817 s with 82 widget interactions inside it), so a turn-boundary gate
641
- // would hold the warning for 47 minutes.
642
- Suite('a single failed save does NOT report broken — one blip is not an outage', async () => {
643
- const { p, f } = makePersister({
644
- history: [msg({ content: 'hi' })],
645
- saveError: new Error('transient'),
646
- });
647
- p.scheduleSave();
648
- await tick();
649
- assert.is(f.brokenReports.length, 0, 'a modal about a self-healing blip trains dismissal');
650
- });
651
-
652
- Suite('two consecutive failures report broken when the agent is resumable', async () => {
653
- const history = [msg({ content: 'one' })];
654
- const { p, f } = makePersister({ history, saveError: new Error('boom') });
655
- p.scheduleSave();
656
- await tick();
657
- history.push(msg({ content: 'two' }));
658
- p.scheduleSave();
659
- await tick();
660
- assert.is(f.brokenReports.length, 1, 'reported once, at the second failure');
661
- assert.is(f.brokenReports[0].reason, 'boom');
662
- assert.is(f.brokenReports[0].unsavedMessages, 2, 'counts what the store does not have');
663
-
664
- // ...and only once per episode, however many more saves fail.
665
- history.push(msg({ content: 'three' }));
666
- p.scheduleSave();
667
- await tick();
668
- assert.is(f.brokenReports.length, 1, 'not re-reported while still broken');
669
- });
670
-
671
- Suite('a NON-resumable agent holds the report back until it is safe to reload', async () => {
672
- const history = [msg({ content: 'one' }), msg({ content: 'two' })];
673
- const agent = {
674
- name: 'UI Builder',
675
- resumable: async () => false,
676
- serialize: () => ({}),
677
- };
678
- const { p, f } = makePersister({ history, activeAgent: agent, saveError: new Error('boom') });
679
- // Unrolled deliberately: these saves must happen in ORDER, so the lint rule's suggested
680
- // Promise.all would change what is being tested.
681
- history.push(msg({ role: 'assistant', content: 'turn 0' }));
682
- p.scheduleSave();
683
- await tick();
684
- history.push(msg({ role: 'assistant', content: 'turn 1' }));
685
- p.scheduleSave();
686
- await tick();
687
- history.push(msg({ role: 'assistant', content: 'turn 2' }));
688
- p.scheduleSave();
689
- await tick();
690
- assert.is(f.brokenReports.length, 0, 'advising a reload mid-flow would discard the VFS buffer');
691
-
692
- // Back at rest: now a reload is safe, so now the user is told.
693
- agent.resumable = async () => true;
694
- history.push(msg({ content: 'next' }));
695
- p.scheduleSave();
696
- await tick();
697
- assert.is(f.brokenReports.length, 1, 'reported at the first resumable moment');
698
- });
699
-
700
- Suite('a save that lands withdraws the warning', async () => {
701
- const history = [msg({ content: 'one' })];
702
- const fakes = { history, saveError: new Error('boom') as Error | undefined };
703
- const { p, f } = makePersister(fakes);
704
- p.scheduleSave();
705
- await tick();
706
- history.push(msg({ content: 'two' }));
707
- p.scheduleSave();
708
- await tick();
709
- assert.is(f.brokenReports.length, 1, 'broken');
710
-
711
- f.saveError = undefined; // the store comes back — observed twice in the wild
712
- history.push(msg({ content: 'three' }));
713
- p.scheduleSave();
714
- await tick();
715
- assert.is(f.recovered, 1, 'the warning is withdrawn rather than left stale');
716
- assert.ok(f.saved.length > 0, 'and the write landed');
717
- });
718
-
719
- Suite('a failed FREEZE write is reported, not swallowed', async () => {
720
- // This catch was the one silent failure left after save-failed was added to the resumable
721
- // branch: a session breaking while an agent was mid-flow reported nothing at all.
722
- const history = [msg({ content: 'do it' }), msg({ role: 'assistant', content: 'working' })];
723
- const { p, f } = makePersister({
724
- history,
725
- activeAgent: { name: 'UI Builder', resumable: async () => false, serialize: () => ({}) },
726
- saveError: new Error('freeze write failed'),
727
- });
728
- p.scheduleSave();
729
- await tick();
730
- await tick();
731
- const failed = getMetaEvents(KEY).filter((e) => e.type === 'persistence.save-failed');
732
- assert.is(failed.length, 1, 'the freeze path now emits the failure event');
733
- assert.is(
734
- (failed[0].detail as Record<string, unknown>)?.frozen,
735
- true,
736
- 'marked as the freeze path',
737
- );
738
- });
739
-
740
- // GENC-1511 review [P1]: the failure event must stay serialisable even when the thing that broke
741
- // the save is the agent snapshot itself. `recordMetaEvent` stores detail BY REFERENCE, so
742
- // spreading the captured snapshot made the high-importance event circular too — and then
743
- // `assembleDebugLog` threw while stringifying it, so `downloadDebugLog` could not produce the log
744
- // the blocking modal tells the user to save. Reproduced by Matt; regression-guarded here.
745
- Suite('the save-failed event stays serialisable when the agent snapshot is circular', async () => {
746
- const circular: Record<string, unknown> = { machine: 'generating' };
747
- circular.self = circular;
748
- const { p } = makePersister({
749
- history: [msg({ content: 'hi' })],
750
- activeAgent: { name: 'UI Builder', serialize: () => circular },
751
- saveError: new TypeError('Converting circular structure to JSON'),
752
- });
753
- p.scheduleSave();
754
- await tick();
755
- const events = getMetaEvents(KEY);
756
- const failed = events.find((e) => e.type === 'persistence.save-failed');
757
- assert.ok(failed, 'the failure is recorded');
758
- assert.not.ok(
759
- (failed?.detail as Record<string, unknown>)?.agentSnapshots,
760
- 'the raw snapshot must NOT be in the failure detail',
761
- );
762
- // The whole point: the debug log must still be producible.
763
- JSON.stringify(events);
764
- });
765
-
766
- Suite('a non-resumable agent is NEVER told to reload, however long it stays broken', async () => {
767
- // GENC-1511 review [P1]: an earlier draft bypassed the gate after 90 s. The modal's only action
768
- // is a reload, so firing it here forces the user to discard the out-of-band in-flight work the
769
- // gate exists to protect.
770
- const history = [msg({ content: 'do it' })];
771
- const { p, f } = makePersister({
772
- history,
773
- activeAgent: { name: 'UI Builder', resumable: async () => false, serialize: () => ({}) },
774
- saveError: new Error('boom'),
775
- });
776
- history.push(msg({ role: 'assistant', content: 'a' }));
777
- p.scheduleSave();
778
- await tick();
779
- await tick();
780
- history.push(msg({ role: 'assistant', content: 'b' }));
781
- p.scheduleSave();
782
- await tick();
783
- await tick();
784
- assert.is(f.brokenReports.length, 0, 'no unsafe reload prompt, no matter how many failures');
785
- });
786
-
787
- Suite.run();