@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,917 +0,0 @@
1
- import type { ChatMessage } from '@genesislcap/foundation-ai';
2
- import type { AiDriver } from '../../components/ai-driver/ai-driver';
3
- import { OrchestratingDriver } from '../../components/orchestrating-driver/orchestrating-driver';
4
- import { flattenSubAgentMessages } from '../../utils/flatten-sub-agent-messages';
5
- import { logger } from '../../utils/logger';
6
- import {
7
- isSessionNoticeMarker,
8
- SESSION_RESTORE_FAILED_LABEL,
9
- SESSION_RESTORED_LABEL,
10
- } from '../../utils/message-partition';
11
- import { withTimeout } from '../../utils/with-timeout';
12
- import { recordMetaEvent, type MetaEventType } from '../debug-event-log';
13
- import type { SessionStoreReturn } from '../session-store';
14
- import { diagnosticsMessageKey, type DiagnosticEntry } from './diagnostics';
15
- import { getDiagnosticsCursors } from './diagnostics-cursors';
16
- import type { SessionPersistenceConfig, SessionPreferences } from './session-persistence-provider';
17
- import {
18
- createPersistedSession,
19
- isRestorableSession,
20
- keepThroughLastUserMessage,
21
- type PersistedSession,
22
- reconcileAgentSnapshots,
23
- resolveNonResumableNotice,
24
- toLoadSessionPayload,
25
- trimToRestorableBoundary,
26
- unknownAgentSnapshots,
27
- } from './session-snapshot';
28
-
29
- /**
30
- * Backstop for a slow/hung persistence provider on startup restore (GENC-1351
31
- * §6). Deliberately long: silently losing a restorable session is a far worse
32
- * outcome than a long "Restoring…" wait, so we give a slow backend plenty of room
33
- * and only give up (→ fresh session + a "couldn't restore" notice) when a load is
34
- * genuinely stuck. A healthy server responds well under this. Bounds both the
35
- * session `load` and the preferences load.
36
- */
37
- const RESTORE_TIMEOUT_MS = 30_000;
38
-
39
- /**
40
- * Default throttle between diagnostics appends (GENC-1351 §5.8) when the provider
41
- * doesn't specify `diagnosticsDebounceMs`. Coarser than the restore-core save — the
42
- * debug log is heavy and doesn't need per-change freshness.
43
- */
44
- const DEFAULT_DIAGNOSTICS_DEBOUNCE_MS = 4_000;
45
-
46
- /**
47
- * Fallback notice shown on return when an agent declared its state non-resumable
48
- * (GENC-1351 §5.9) and set no `notResumableMessage`. Paired with the composer
49
- * pre-filled with the flow-activating prompt, so "send it again" is one keystroke.
50
- */
51
- /**
52
- * Consecutive failed saves before persistence is declared broken (GENC-1511). Two, not one: a
53
- * single failure is usually a transient blip that the next autosave clears on its own, and a
54
- * modal about a problem that has already fixed itself trains people to dismiss the one that
55
- * matters. At the 1 s autosave debounce this is ~2 s of exposure.
56
- */
57
- const SAVE_FAILURES_BEFORE_BROKEN = 2;
58
-
59
- const DEFAULT_NOT_RESUMABLE_MESSAGE =
60
- "Sorry, I didn't manage to finish the task from your previous message. Send it again if you'd like me to try it again";
61
-
62
- /**
63
- * Dependencies the persister reads through — all getters so it always sees current
64
- * state and never holds a stale reference (the driver can be rebuilt on
65
- * agents-change, and the creating view can disconnect). Injected rather than read
66
- * from the module registries directly so the class is unit-testable with fakes.
67
- *
68
- * @internal
69
- */
70
- export interface SessionPersisterDeps {
71
- /** Session identity — stable for the persister's lifetime. */
72
- stateKey: string;
73
- /** Current host persistence config (`enabled` + `provider`). Read fresh each op. */
74
- getPersistence: () => SessionPersistenceConfig;
75
- /** Names of the currently-configured agents (for the restore orphan check). */
76
- getAgentNames: () => string[];
77
- /** The session's shared driver, or `undefined` before it exists. */
78
- getDriver: () => AiDriver | undefined;
79
- /** The session's shared store, or `undefined` before it exists. */
80
- getStore: () => SessionStoreReturn | undefined;
81
- /**
82
- * Build the current diagnostic entries (message/turn/event/meta-snapshot). Stays
83
- * element-owned — it reads the host's agents, active-agent debug snapshot, meta
84
- * events, etc. — and is diffed against the shared cursors here (GENC-1351 §5.10).
85
- */
86
- getDiagnosticEntries: () => DiagnosticEntry[];
87
- /**
88
- * Persistence has failed repeatedly and it is now a safe moment to tell the user (GENC-1511).
89
- * Called at most once per broken episode — see {@link SessionPersister.persistSession} for the
90
- * gating, which waits for the active agent to be RESUMABLE so we never advise a reload at a
91
- * moment when reloading would throw away in-flight work the snapshot cannot carry.
92
- */
93
- onPersistenceBroken?: (detail: {
94
- /** Messages in the live transcript that the store does not have. */
95
- unsavedMessages: number;
96
- /** `savedAt` of the last snapshot that landed, if one ever did. */
97
- lastSavedAt?: string;
98
- /** Why the writes are failing, as reported by the provider. */
99
- reason: string;
100
- }) => void;
101
- /** A save landed after a broken episode — the warning can be withdrawn (GENC-1511). */
102
- onPersistenceRecovered?: () => void;
103
- }
104
-
105
- /**
106
- * Owns **restore-core** persistence for one session (GENC-1351 §5.10) — the
107
- * save/restore/freeze of the {@link PersistedSession} snapshot plus the
108
- * carry-forward cache. Created once per session via `persister-registry.ts` and
109
- * shared by every element view, so there is a single save timer and a single
110
- * cache: the per-view double-write races (and the corruption they caused under a
111
- * non-atomic backend) are gone by construction.
112
- *
113
- * Owns all provider I/O for the session: the restore-core snapshot (save/restore/
114
- * freeze), the user-preferences blob (load/save — the element keeps only the
115
- * `chatConfig`-defaults reconciler), and the diagnostics-append scheduling (one
116
- * timer per session; entry-building stays element-owned via `getDiagnosticEntries`).
117
- *
118
- * @internal
119
- */
120
- export class SessionPersister {
121
- private _saveTimer?: ReturnType<typeof setTimeout>;
122
- private _lastSaveAt = 0;
123
- private _diagnosticsTimer?: ReturnType<typeof setTimeout>;
124
- private _lastDiagnosticsAt = 0;
125
- /**
126
- * The flow-owner's last-known-good serialized image, carried forward when a
127
- * post-restore autosave fires before the agent re-activates (its live capture is
128
- * empty). Seeded on restore. See {@link reconcileAgentSnapshots}.
129
- */
130
- private _cache: Record<string, unknown> = {};
131
- /**
132
- * How many messages the last SUCCESSFUL write persisted. Two jobs, both GENC-1511:
133
- * it is the monotonic floor for a freeze boundary (the stored transcript must never
134
- * shrink), and it is what lets a running flow skip a re-write when its boundary has
135
- * not moved. Content-keyed, so unlike the `interrupted` latch it replaces it can never
136
- * become a one-way "never write again" state.
137
- */
138
- private _persistedMessages = 0;
139
- /**
140
- * Whether the CURRENT non-resumable episode has already stamped its `interrupted`
141
- * marker. Reset by every resumable save, so each new episode stamps exactly once and
142
- * the "boundary unmoved" skip below can never swallow the stamp itself (GENC-1511).
143
- */
144
- private _interruptedStamped = false;
145
- /** Consecutive failed saves; reset by any success. GENC-1511. */
146
- private _saveFailures = 0;
147
- /** When the failure count crossed {@link SAVE_FAILURES_BEFORE_BROKEN}; `undefined` = healthy. */
148
- private _brokenSince?: number;
149
- /** Whether this broken episode has already been reported to the host. */
150
- private _brokenReported = false;
151
- /** `savedAt` of the last snapshot that actually landed, for the host's message. */
152
- private _lastSavedAt?: string;
153
- /** Provider's reason for the most recent failure, for the host's message. */
154
- private _lastFailureReason = '';
155
- /**
156
- * The loaded preferences blob (GENC-1351 §5.10 Phase 2), owned here so it's
157
- * **shared per session** — the element's `applyPreferenceBaseline` reconciler reads
158
- * it via {@link storedPreferences}. Kept fresh on every save, so a later re-reconcile
159
- * uses the latest choices, not the stale startup blob. `null` = loaded, none stored.
160
- */
161
- private _storedPreferences: SessionPreferences | null = null;
162
- /**
163
- * Whether the user has changed any preference this session. **Shared per session**
164
- * (was per-element, which let the two views disagree and one clobber the other's
165
- * choice — issue #1). Once set, the reconciler freezes so `chatConfig` defaults
166
- * can't overwrite a live user choice, regardless of which view toggled it.
167
- */
168
- private _preferencesUserChanged = false;
169
-
170
- constructor(private deps: SessionPersisterDeps) {}
171
-
172
- /**
173
- * Re-point the dependency getters at the currently-live element view. The
174
- * persister outlives any single element (it's cached per session in
175
- * `persister-registry.ts`), so when a later element mounts on the same session
176
- * — e.g. an SPA teardown + remount — it MUST rebind, or the persister keeps
177
- * calling the torn-down creator's `getDiagnosticEntries`/`getPersistence`/
178
- * `getAgentNames` (which read a detached driver/store → empty), silently halting
179
- * diagnostics + autosave capture (GENC-1351 §5.10). `stateKey` is unchanged.
180
- *
181
- * @internal
182
- */
183
- rebindDeps(deps: SessionPersisterDeps): void {
184
- this.deps = deps;
185
- }
186
-
187
- /** The loaded preferences blob (shared per session); read by the element's reconciler. */
188
- get storedPreferences(): SessionPreferences | null {
189
- return this._storedPreferences;
190
- }
191
-
192
- /** True once the user has changed any preference this session (shared per session). */
193
- get preferencesUserChanged(): boolean {
194
- return this._preferencesUserChanged;
195
- }
196
-
197
- private get stateKey(): string {
198
- return this.deps.stateKey;
199
- }
200
-
201
- private logMeta(type: MetaEventType, detail?: Record<string, unknown>): void {
202
- recordMetaEvent(this.stateKey, type, detail);
203
- }
204
-
205
- /**
206
- * Throttled autosave. Called by every view on each transcript change; the single
207
- * shared timer coalesces them into at most one save per the provider's
208
- * `saveDebounceMs`. `saveDebounceMs: 0` (e.g. `sessionStorage`) means save as
209
- * soon as the current tick settles.
210
- */
211
- scheduleSave(): void {
212
- const { provider, enabled } = this.deps.getPersistence();
213
- if (!enabled || !provider) return;
214
- if (this._saveTimer != null) return; // a save is already pending — coalesce
215
- const elapsed = Date.now() - this._lastSaveAt;
216
- const delay = Math.max(0, provider.saveDebounceMs - elapsed);
217
- this._saveTimer = setTimeout(() => {
218
- this._saveTimer = undefined;
219
- this._lastSaveAt = Date.now();
220
- void this.persistSession();
221
- }, delay);
222
- }
223
-
224
- /**
225
- * Cancel a pending debounced autosave **without flushing it** (GENC-1351 §5.12).
226
- * Called before a session switch/re-key so the outgoing transcript can never be
227
- * written to the incoming session's file after the key changes — the clobber
228
- * guard for in-place project switching.
229
- */
230
- cancelPendingSave(): void {
231
- if (this._saveTimer != null) {
232
- clearTimeout(this._saveTimer);
233
- this._saveTimer = undefined;
234
- }
235
- }
236
-
237
- /**
238
- * Record a save outcome and, when persistence has been failing, decide whether now is the
239
- * moment to tell the user (GENC-1511).
240
- *
241
- * The gate is `resumable`, NOT "is a turn running". `busy` was the obvious choice and it is
242
- * useless here: a bootstrap journey is ONE turn — measured at 2817 s containing 82 widget
243
- * interactions — so waiting for a turn boundary would hold the warning for 47 minutes. What
244
- * actually matters is whether a reload can be resumed from the stored snapshot, and that is
245
- * exactly what `resumable` already answers for the freeze. Bootstrap keeps everything in its
246
- * machine context, so it is always resumable and gets told immediately; UI Builder keeps
247
- * in-flight work in an out-of-band VFS buffer, so it waits for `idle`/`done` — which is also
248
- * the point at which its files have been flushed and a reload costs nothing.
249
- */
250
- private recordSaveOutcome(ok: boolean, reason: string, savedAt?: string): void {
251
- if (ok) {
252
- const wasBroken = this._brokenSince !== undefined;
253
- this._saveFailures = 0;
254
- this._brokenSince = undefined;
255
- this._lastSavedAt = savedAt ?? this._lastSavedAt;
256
- if (wasBroken) {
257
- // Withdraw the warning: a save landing means the store is caught up, which we have
258
- // watched happen twice in the wild — a broken window is not necessarily permanent.
259
- this._brokenReported = false;
260
- this.deps.onPersistenceRecovered?.();
261
- }
262
- return;
263
- }
264
- this._saveFailures += 1;
265
- this._lastFailureReason = reason;
266
- if (this._saveFailures >= SAVE_FAILURES_BEFORE_BROKEN && this._brokenSince === undefined) {
267
- this._brokenSince = Date.now();
268
- }
269
- }
270
-
271
- /**
272
- * Surface a broken episode if it is now safe to advise a reload.
273
- *
274
- * Gated on `resumable` and NOTHING else. An earlier draft added a 90 s backstop so a flow that
275
- * errored and stuck mid-state could not hold the warning forever — that was wrong, and the
276
- * review caught it: the modal's only action is a reload, so firing it while the agent is
277
- * non-resumable forces the user to destroy exactly the out-of-band, in-flight work the gate
278
- * exists to protect. Being told late is bad; being told to do the damaging thing is worse.
279
- * Surfacing a stuck flow earlier needs non-blocking warning UI, not this modal.
280
- */
281
- private maybeReportBroken(resumable: boolean, liveMessages: number): void {
282
- if (this._brokenSince === undefined || this._brokenReported || !resumable) return;
283
- this._brokenReported = true;
284
- this.deps.onPersistenceBroken?.({
285
- unsavedMessages: Math.max(0, liveMessages - this._persistedMessages),
286
- lastSavedAt: this._lastSavedAt,
287
- reason: this._lastFailureReason,
288
- });
289
- }
290
-
291
- /**
292
- * Non-resumable-workflow gate (GENC-1351 §5.9): consult the active agent (the flow-owner
293
- * during a locked flow, like `serialize`). A thrown predicate fails safe to persisting; no
294
- * live active agent (e.g. the post-restore, pre-reactivation save) leaves it resumable and
295
- * persists normally.
296
- *
297
- * Extracted from `persistSession` so that method stays under the complexity cap, and because
298
- * this answer now has two consumers: the freeze decision, and whether it is safe to advise the
299
- * user to reload (GENC-1511).
300
- */
301
- private async resolveResumable(
302
- liveActive: ReturnType<NonNullable<AiDriver['getActiveAgent']>> | undefined,
303
- key: string,
304
- agentSnapshots: Record<string, unknown>,
305
- messages: ChatMessage[],
306
- s: SessionStoreReturn['store']['aiAssistant'],
307
- ): Promise<boolean> {
308
- if (!liveActive?.resumable) return true;
309
- try {
310
- return await liveActive.resumable({
311
- agentName: liveActive.name,
312
- sessionKey: key,
313
- state: agentSnapshots[liveActive.name],
314
- messages,
315
- isFlowOwner: s.flowOwnerAgentName === liveActive.name,
316
- });
317
- } catch (e) {
318
- logger.error('Session save: resumable() threw — persisting (fail-safe):', e);
319
- return true;
320
- }
321
- }
322
-
323
- /**
324
- * Capture the current session to durable storage (GENC-1351 §5.1). Reads the
325
- * full transcript from the driver and the restorable scalars from the store, and
326
- * captures the active stateful agent's `serialize()` into `agentSnapshots`.
327
- */
328
- private async persistSession(): Promise<void> {
329
- const { provider, enabled } = this.deps.getPersistence();
330
- if (!enabled || !provider) return;
331
- const key = this.stateKey;
332
- const ref = this.deps.getStore();
333
- const driver = this.deps.getDriver();
334
- if (!ref) return;
335
- const s = ref.store.aiAssistant;
336
- // Strip the display-only restore notices (restored / couldn't-restore) so they
337
- // never persist or accumulate across reload→save→reload cycles.
338
- const messages = (driver?.getRawHistory?.() ?? s.messages).filter(
339
- (m) => !isSessionNoticeMarker(m),
340
- );
341
- if (!messages.length) return; // nothing worth persisting (e.g. a just-cleared session)
342
- // Serialize the live active agent from the DRIVER (the instance whose
343
- // `onActivate` ran), not via the host's `agents` — a host whose `agents` getter
344
- // mints fresh configs (the showcase) would otherwise hand us a never-activated
345
- // closure whose `serialize()` returns undefined.
346
- const liveSnapshots: Record<string, unknown> = {};
347
- const liveActive = driver?.getActiveAgent?.();
348
- if (liveActive?.serialize) {
349
- try {
350
- const snap = liveActive.serialize();
351
- if (snap !== undefined) liveSnapshots[liveActive.name] = snap;
352
- } catch (e) {
353
- logger.error('Session save: agent serialize() failed:', e);
354
- }
355
- }
356
- // A post-restore autosave (triggered by the restore's own history change) runs
357
- // BEFORE the pinned stateful agent re-activates, so the live capture is empty
358
- // even though a flow is mid-stream. Writing `{}` would wipe the machine and
359
- // reset the flow on the next reload — so carry the flow-owner's last-known-good
360
- // image forward (GENC-1351 §6). Pure + unit-tested.
361
- const { snapshots: agentSnapshots, cache } = reconcileAgentSnapshots(
362
- liveSnapshots,
363
- s.flowOwnerAgentName,
364
- this._cache,
365
- );
366
- this._cache = cache;
367
-
368
- const resumable = await this.resolveResumable(liveActive, key, agentSnapshots, messages, s);
369
-
370
- // Report a broken episode at the first SAFE moment, which is what `resumable` decides.
371
- // Deliberately before either branch acts, so the warning does not wait on the very write
372
- // that is failing.
373
- this.maybeReportBroken(resumable, messages.length);
374
-
375
- if (!resumable) {
376
- // FREEZE THE FLOW STATE, NOT THE TRANSCRIPT (GENC-1511).
377
- //
378
- // The intent of §5.9 is the one stated on `PersistedSession.interrupted`: the
379
- // conversation is persisted and restored in full, and only the running agent's
380
- // flow-state is dropped. The original implementation did the opposite whenever a
381
- // snapshot already existed — it froze at the **stored** blob (`existing ?? …`),
382
- // so everything since the last resumable save was silently rolled back, transcript
383
- // included, and `if (existing?.interrupted) return` then latched that rollback in
384
- // for the life of the page. A UI-builder session spent 3h17m in that state: 256
385
- // loads, zero writes, and a reload that took the user back to a mid-bootstrap
386
- // snapshot from three hours earlier.
387
- //
388
- // So the freeze target is now ALWAYS the live transcript through the last user
389
- // message — the conversation plus the prompt that started the flow, with the
390
- // agent's in-flight turns and flow-state dropped. Advancing on each new boundary
391
- // means a long-running flow can no longer cost the user the work that preceded it.
392
- const { resumeMessage, resumePrompt } = resolveNonResumableNotice({
393
- notResumableMessage: liveActive?.notResumableMessage,
394
- flowActivationPrompt: s.flowActivationPrompt,
395
- messages,
396
- defaultMessage: DEFAULT_NOT_RESUMABLE_MESSAGE,
397
- });
398
- // Never regress the stored transcript. `keepThroughLastUserMessage` alone can be
399
- // SHORTER than what is already persisted — an agent that goes non-resumable without
400
- // a fresh user prompt (auto-advance, a resumed journey step) would otherwise drop
401
- // completed exchanges that were already safely stored. Taking the max of the two
402
- // makes the boundary monotonic: it advances with each new prompt and never rewinds.
403
- const boundary = Math.max(
404
- keepThroughLastUserMessage(messages).length,
405
- this._persistedMessages,
406
- );
407
- const frozen = createPersistedSession({
408
- sessionKey: key,
409
- host: window.location.host,
410
- messages: messages.slice(0, Math.min(boundary, messages.length)),
411
- activeAgentName: s.activeAgent?.name,
412
- pinnedAgentName: s.pinnedAgentName,
413
- flowOwnerAgentName: null,
414
- sessionCostUsd: s.sessionUsage.costUsd,
415
- contextTokens: s.contextTokens,
416
- contextLimit: s.contextLimit,
417
- activeModel: s.activeModel,
418
- activeProviderName: s.activeProviderName,
419
- agentSnapshots: {},
420
- });
421
- // Skip the write when the frozen boundary has not moved — a flow autosaves on every
422
- // transcript change, and while it runs the last user message (and therefore the
423
- // frozen content) is unchanged, so re-writing it would put one ~MB PUT on the wire
424
- // per tool call for nothing. Keyed on CONTENT, unlike the old `interrupted` latch:
425
- // it re-arms the moment the boundary moves, and the resumable path below clears it,
426
- // so it can never become the one-way "never write again" state it replaces. It also
427
- // drops the `provider.load` this branch used to issue on every single autosave.
428
- if (this._persistedMessages === frozen.messages.length && this._interruptedStamped) {
429
- return;
430
- }
431
- this.logMeta('persistence.suppressed', {
432
- agent: liveActive?.name ?? null,
433
- isFlowOwner: s.flowOwnerAgentName === liveActive?.name,
434
- frozeAtMessages: frozen.messages.length,
435
- custom: !!liveActive?.notResumableMessage,
436
- });
437
- try {
438
- await provider.save(key, {
439
- ...frozen,
440
- interrupted: { message: resumeMessage, prompt: resumePrompt },
441
- });
442
- this._persistedMessages = frozen.messages.length;
443
- this._interruptedStamped = true;
444
- this.recordSaveOutcome(true, '', frozen.savedAt);
445
- logger.debug('Flow non-resumable — froze snapshot + stamped interrupted marker', { key });
446
- } catch (e) {
447
- // GENC-1511: this catch used to be the ONE swallowed failure left after the save-failed
448
- // event was added to the resumable branch — so a session that broke while an agent was
449
- // mid-flow still reported nothing at all, which is precisely the blind spot the
450
- // user-facing warning exists to cover.
451
- this.recordSaveOutcome(false, (e as Error)?.message ?? String(e));
452
- // Deliberately NOT reporting from here. This is the non-resumable branch and a reload is
453
- // the modal's only action, so advising one now would destroy the in-flight work the
454
- // snapshot could not carry. `recordSaveOutcome` has already set `_brokenSince`, so the
455
- // next `persistSession` whose `resolveResumable()` returns true reports it — late, but at
456
- // a point where the advice is safe to take.
457
- //
458
- // (An earlier version called `maybeReportBroken(false, …)` here. Review caught that it was
459
- // an unconditional no-op — that method returns immediately on `!resumable` — and that its
460
- // comment pointed at a 90 s backstop which had itself been removed earlier in review.)
461
- this.logMeta('persistence.save-failed', {
462
- messages: frozen.messages.length,
463
- agents: [],
464
- active: liveActive?.name ?? null,
465
- hasSerialize: !!liveActive?.serialize,
466
- frozen: true,
467
- error: (e as Error)?.name ?? typeof e,
468
- message: (e as Error)?.message ?? String(e),
469
- });
470
- logger.error('Session save (interrupted marker) failed:', e);
471
- }
472
- return;
473
- }
474
-
475
- // Resumable → normal save (this overwrites any prior `interrupted` marker, so a
476
- // later non-resumable episode stamps afresh).
477
- const snapshot = createPersistedSession({
478
- sessionKey: key,
479
- host: window.location.host,
480
- messages: [...messages],
481
- activeAgentName: s.activeAgent?.name,
482
- pinnedAgentName: s.pinnedAgentName,
483
- flowOwnerAgentName: s.flowOwnerAgentName,
484
- sessionCostUsd: s.sessionUsage.costUsd,
485
- contextTokens: s.contextTokens,
486
- contextLimit: s.contextLimit,
487
- activeModel: s.activeModel,
488
- activeProviderName: s.activeProviderName,
489
- agentSnapshots,
490
- });
491
- // Observability (GENC-1351): record what each autosave captured for stateful
492
- // agents — `agents: []` here means no machine snapshot was taken (the bug to
493
- // watch for: the active agent's `serialize()` returned nothing).
494
- //
495
- // GENC-1511: this event is emitted AFTER the write resolves, never before. It used
496
- // to be logged ahead of `provider.save`, which made it a record of an ATTEMPT while
497
- // reading as a record of a SAVE — and the failure below was swallowed into
498
- // `logger.error`, which is not wired to Sentry. A session whose writes had been
499
- // failing for three hours therefore showed 52 cheerful `persistence.saved` events
500
- // and no failures anywhere: not in the debug log, not in Loki, not in Sentry. The
501
- // whole incident was invisible until the box's nginx access log was read by hand.
502
- const captured = {
503
- messages: messages.length,
504
- agents: Object.keys(agentSnapshots),
505
- active: liveActive?.name ?? null,
506
- hasSerialize: !!liveActive?.serialize,
507
- agentSnapshots,
508
- };
509
- try {
510
- await provider.save(key, snapshot);
511
- this._persistedMessages = messages.length;
512
- this._interruptedStamped = false;
513
- this.recordSaveOutcome(true, '', snapshot.savedAt);
514
- this.logMeta('persistence.saved', captured);
515
- logger.debug('Session saved', { key, messages: messages.length });
516
- } catch (e) {
517
- // `high` importance, and deliberately verbose: this is the one event that says
518
- // "the transcript on screen is no longer backed by the store". `name` separates a
519
- // provider/transport rejection from a serialisation fault — a `TypeError` here
520
- // means the body never reached the wire at all, because a provider builds its
521
- // request body inside the `fetch` init, so a throw while serialising creates no
522
- // request and leaves no server-side trace.
523
- this.recordSaveOutcome(false, (e as Error)?.message ?? String(e));
524
- // Report straight away rather than waiting for the next attempt: we are on the resumable
525
- // path, so a reload is safe NOW, and a one-cycle delay is a second of extra exposure for
526
- // no benefit.
527
- this.maybeReportBroken(true, messages.length);
528
- // Deliberately NOT `...captured`: that carries the raw `agentSnapshots` object, and
529
- // `recordMetaEvent` stores the detail BY REFERENCE. When the save failed *because* that
530
- // object is circular — the fault this catch exists for — the high-importance failure event
531
- // became circular too, so `assembleDebugLog` threw while stringifying it and
532
- // `downloadDebugLog` could not produce the log this modal tells the user to save. Names
533
- // and scalars only; the success event above may keep the snapshots, because a landed write
534
- // has already proved them serialisable.
535
- this.logMeta('persistence.save-failed', {
536
- messages: captured.messages,
537
- agents: captured.agents,
538
- active: captured.active,
539
- hasSerialize: captured.hasSerialize,
540
- error: (e as Error)?.name ?? typeof e,
541
- message: (e as Error)?.message ?? String(e),
542
- });
543
- logger.error('Session save failed:', e);
544
- }
545
- }
546
-
547
- /**
548
- * Page-reload restore (GENC-1351 §5.3). With a stored snapshot for this session,
549
- * hydrate the store and reseed the driver so the conversation continues. No-op
550
- * when disabled, absent, or version/host mismatched. An `interrupted` snapshot
551
- * (§5.9) restores the conversation but shows a "start again" assistant message
552
- * instead of rehydrating the mid-flight flow.
553
- */
554
- async restore(): Promise<void> {
555
- const { provider } = this.deps.getPersistence();
556
- if (!provider) return;
557
- const key = this.stateKey;
558
- const driver = this.deps.getDriver();
559
-
560
- // Bound a slow/hung provider so a stuck load can't brick startup. On timeout
561
- // (or a load rejection) `withTimeout` throws — its late result is discarded by
562
- // the race, so it can never clobber a now-live session (§6). Crucially, a throw
563
- // is distinct from a resolved `null`: a throw means "there may have been a
564
- // session but we couldn't fetch it" → surface a notice; `null` means "nothing
565
- // stored" → start fresh silently.
566
- let snapshot: PersistedSession | null;
567
- try {
568
- snapshot = await withTimeout(provider.load(key), RESTORE_TIMEOUT_MS);
569
- } catch (e) {
570
- logger.warn('Session restore: load failed or timed out — starting fresh', e);
571
- this.showRestoreFailedNotice();
572
- return;
573
- }
574
- if (!snapshot) return; // genuinely nothing stored → fresh session, no notice
575
-
576
- try {
577
- if (!isRestorableSession(snapshot, { host: window.location.host })) {
578
- // A saved snapshot existed but can't be used (schema bump / cross-host) —
579
- // tell the user rather than silently dropping their conversation.
580
- logger.debug('Session restore skipped: version/host mismatch', { key });
581
- this.showRestoreFailedNotice();
582
- return;
583
- }
584
- // Trim a trailing incomplete turn (orphaned tool call / placeholder / pending
585
- // interaction) so a snapshot saved mid-loop restores to the last
586
- // provider-valid boundary (§5.3 step 2).
587
- const trimmed = trimToRestorableBoundary(snapshot.messages).filter(
588
- (m) => !isSessionNoticeMarker(m),
589
- );
590
- const ref = this.deps.getStore();
591
- if (!ref) return;
592
- // Diagnostics watermark (§5.8): messages present at restore already live in a
593
- // prior load's diagnostics stream — record their identities in the shared
594
- // per-session cursors so this load's forward capture never re-appends them
595
- // (only genuinely new activity streams). Shared, so the element view that
596
- // never runs restore honours the same watermark.
597
- getDiagnosticsCursors(key).restoredMsgKeys = new Set(
598
- flattenSubAgentMessages(trimmed)
599
- .map((e) => diagnosticsMessageKey(e as unknown as DiagnosticEntry))
600
- .filter((k): k is string => k !== null),
601
- );
602
- // Prime each stateful agent's persisted machine/state image FIRST — before
603
- // seeding Redux or driver history — so the image is waiting on the driver no
604
- // matter when the agent re-activates. The flow-owner lock self-heals on
605
- // activation (OrchestratingDriver.applyAgent re-sets it).
606
- driver?.primeRestoredAgentState?.(snapshot.agentSnapshots);
607
- // Seed the carry-forward cache so the immediate post-restore autosave (fired
608
- // by the history change below, before any agent re-activates) preserves the
609
- // machine snapshot instead of overwriting it with an empty one (GENC-1351).
610
- this._cache = { ...snapshot.agentSnapshots };
611
- // Seed the monotonic freeze floor too (GENC-1511): a flow that goes non-resumable
612
- // straight after a restore must not freeze BELOW what the restore just brought back.
613
- this._persistedMessages = trimmed.length;
614
- // Observability (GENC-1351): record exactly what the restore primed, so a
615
- // post-reload debug-log export shows whether a stateful agent's snapshot
616
- // actually came back (meta events reset on reload, so this is the only window
617
- // onto the saved→loaded handoff).
618
- this.logMeta('persistence.restored', {
619
- agents: Object.keys(snapshot.agentSnapshots ?? {}),
620
- agentSnapshots: snapshot.agentSnapshots,
621
- pinnedAgentName: snapshot.pinnedAgentName,
622
- flowOwnerAgentName: snapshot.flowOwnerAgentName,
623
- });
624
- // Loudly surface saved agent state that can't be rehydrated because the agent
625
- // isn't in the current set — otherwise the flow silently fails to resume, a
626
- // real footgun for hosts with dynamic agent arrays. Non-fatal: the transcript
627
- // and any known agents still restore.
628
- const orphanedAgents = unknownAgentSnapshots(snapshot, this.deps.getAgentNames());
629
- if (orphanedAgents.length) {
630
- logger.error(
631
- `Session restore: saved state for agent(s) [${orphanedAgents.join(', ')}] cannot be ` +
632
- `rehydrated — not in the configured agents, so their flow will not resume. ` +
633
- `(The transcript and any known agents still restore.)`,
634
- { key },
635
- );
636
- this.logMeta('persistence.restore-orphan', {
637
- orphanedAgents,
638
- knownAgents: this.deps.getAgentNames(),
639
- });
640
- }
641
- // Trailing rows appended to the restored transcript:
642
- // • interrupted flow (§5.9) → the user's triggering message (when captured) followed
643
- // by a real `assistant` "start again" message. Both are normal chat bubbles,
644
- // persisted as part of the conversation, so the exchange reads naturally — the user
645
- // sees what they asked and the assistant's reply explaining it didn't complete
646
- // (instead of an assistant reply to an invisible message). The frozen snapshot never
647
- // rehydrates the in-flight flow (its `agentSnapshots` are the last *resumable* state),
648
- // so the triggering message is text only — none of the dropped work comes back.
649
- // • otherwise → the subtle "restored" `system-event` divider (§10-O2),
650
- // display-only and stripped again on the next save.
651
- const trailerTs = new Date().toISOString();
652
- let trailers: ChatMessage[];
653
- if (snapshot.interrupted) {
654
- trailers = [];
655
- // Re-append the triggering user message UNLESS the frozen transcript already
656
- // ends with it. The two freeze paths differ: an *existing* prior snapshot is
657
- // older than the trigger (so the trigger is absent and must be re-added), but
658
- // the *fresh-freeze fallback* keeps the transcript through the last user
659
- // message (`keepThroughLastUserMessage`), so the trigger is already present —
660
- // re-appending it there would duplicate the bubble (GENC-1351 §5.9).
661
- const lastTrimmed = trimmed[trimmed.length - 1];
662
- const promptAlreadyPresent =
663
- lastTrimmed?.role === 'user' && lastTrimmed.content === snapshot.interrupted.prompt;
664
- if (snapshot.interrupted.prompt && !promptAlreadyPresent) {
665
- trailers.push({
666
- role: 'user',
667
- content: snapshot.interrupted.prompt,
668
- timestamp: trailerTs,
669
- } as ChatMessage);
670
- }
671
- trailers.push({
672
- role: 'assistant',
673
- content: snapshot.interrupted.message,
674
- timestamp: trailerTs,
675
- } as ChatMessage);
676
- } else if (trimmed.length === 0) {
677
- trailers = [];
678
- } else {
679
- trailers = [
680
- {
681
- role: 'system-event',
682
- content: SESSION_RESTORED_LABEL,
683
- timestamp: trailerTs,
684
- } as ChatMessage,
685
- ];
686
- }
687
- const messages = [...trimmed, ...trailers];
688
- ref.actions.aiAssistant.loadSession(toLoadSessionPayload({ ...snapshot, messages }));
689
- driver?.loadHistory?.([...messages]);
690
- // Interrupted flow (§5.9): pre-fill the composer with the prompt that started
691
- // the flow so "send it again" is one keystroke, and record it for observability.
692
- if (snapshot.interrupted) {
693
- if (snapshot.interrupted.prompt) {
694
- ref.actions.aiAssistant.setInputValue(snapshot.interrupted.prompt);
695
- }
696
- this.logMeta('persistence.not-resumable', {
697
- hadPrompt: !!snapshot.interrupted.prompt,
698
- messages: trimmed.length,
699
- });
700
- }
701
- // Propagate the restored pin AND flow-owner lock to the DRIVER's routing (GENC-1351).
702
- // `loadSession` only hydrates the store; the driver learns them via `setPinnedAgent`/
703
- // `setFlowOwner`, which otherwise ran at `wireDriver` time — before restore, when the
704
- // store values were still null. Without this the first post-restore message falls
705
- // through to the classifier and re-routes away from the flow agent. The pin covers a
706
- // user-pinned agent; the flow-owner lock covers a *classifier-routed* stateful flow
707
- // (flow-owned but unpinned) — `resolvePinnedAgent` reads `flowOwnerAgentName ??
708
- // pinnedAgentName`, so restoring both resumes the flow without re-classifying, and the
709
- // driver's routing agrees with the restored (picker-locked) store state.
710
- if (driver instanceof OrchestratingDriver) {
711
- driver.setPinnedAgent(snapshot.pinnedAgentName ?? null);
712
- driver.setFlowOwner(snapshot.flowOwnerAgentName ?? null);
713
- }
714
- logger.debug('Session restored', { key, messages: messages.length });
715
- } catch (e) {
716
- logger.error('Session restore failed:', e);
717
- this.showRestoreFailedNotice();
718
- }
719
- }
720
-
721
- /**
722
- * Surface a non-fatal "couldn't restore" notice after a failed restore (load
723
- * timeout/error, or an unusable snapshot) so a vanished conversation isn't a
724
- * silent mystery on reload (GENC-1351 §6). Reuses the display-only `system-event`
725
- * divider (like the "Restored…" one) — stripped before persist, so it never lands
726
- * in the fresh session's saved history. `loadHistory` is safe here: this path only
727
- * runs when nothing was restored, so the driver is empty.
728
- */
729
- private showRestoreFailedNotice(): void {
730
- this.deps.getDriver()?.loadHistory?.([
731
- {
732
- role: 'system-event',
733
- content: SESSION_RESTORE_FAILED_LABEL,
734
- timestamp: new Date().toISOString(),
735
- } as ChatMessage,
736
- ]);
737
- }
738
-
739
- /**
740
- * Drop the restore-core snapshot (the "remember chat" toggle-off and the Clear
741
- * action) and reset the carry-forward cache. Diagnostics + preferences are
742
- * separate and untouched here.
743
- */
744
- async clearRestoreCore(): Promise<void> {
745
- this._cache = {};
746
- this._persistedMessages = 0;
747
- this._interruptedStamped = false;
748
- const { provider } = this.deps.getPersistence();
749
- const key = this.stateKey;
750
- if (!provider) return;
751
- try {
752
- await provider.clear(key);
753
- } catch (e) {
754
- logger.error('Session clear failed:', e);
755
- }
756
- }
757
-
758
- // ── Preferences (GENC-1351 §5.10 Phase 2) ──────────────────────────────────
759
- // The persister owns the provider I/O for the stored preferences blob; the
760
- // element keeps `gatherPreferences` + the `chatConfig`-defaults reconciler
761
- // (`applyPreferenceBaseline`), which needs `this.chatConfig`.
762
-
763
- /**
764
- * Load the remembered preferences blob (bounded by {@link RESTORE_TIMEOUT_MS}) into
765
- * {@link storedPreferences}. A throw propagates to the caller (the element wraps it
766
- * and falls back to config defaults); on success `null` = no provider / none stored.
767
- */
768
- async loadPreferences(): Promise<void> {
769
- const { provider } = this.deps.getPersistence();
770
- if (!provider) {
771
- this._storedPreferences = null;
772
- return;
773
- }
774
- const prefs = await withTimeout(
775
- Promise.resolve(provider.loadPreferences?.(this.stateKey)),
776
- RESTORE_TIMEOUT_MS,
777
- );
778
- this._storedPreferences = prefs ?? null;
779
- }
780
-
781
- /**
782
- * Persist the preferences blob. Only ever called on a genuine user change, so it
783
- * also records that (freezing the reconciler — shared per session) and keeps
784
- * {@link storedPreferences} fresh. Fires **regardless of "remember chat"** — display
785
- * choices are the user's — needing only a provider that implements `savePreferences`.
786
- */
787
- savePreferences(prefs: SessionPreferences): void {
788
- this._preferencesUserChanged = true;
789
- this._storedPreferences = prefs;
790
- const { provider } = this.deps.getPersistence();
791
- if (!provider?.savePreferences) return;
792
- void provider
793
- .savePreferences(this.stateKey, prefs)
794
- .catch((e) => logger.error('Persistence: savePreferences failed:', e));
795
- }
796
-
797
- // ── Diagnostics append (GENC-1351 §5.10 Phase 3) ───────────────────────────
798
- // One append scheduler per session (was per-view). Entry *building* stays
799
- // element-owned (`getDiagnosticEntries`); the cursor-diffing + append live here.
800
-
801
- /**
802
- * Throttled diagnostics append (GENC-1351 §5.8). On each transcript change, if the
803
- * provider persists diagnostics, coalesce and append the new activity since the
804
- * last flush (coarser than the restore-core save — the log is heavy). Keyed on
805
- * provider capability only, **independent of "remember chat"**. No-op without a
806
- * diagnostics-capable provider, so it's free for other consumers.
807
- */
808
- scheduleDiagnostics(): void {
809
- const { provider } = this.deps.getPersistence();
810
- if (!provider?.appendDiagnostics) return;
811
- // Don't capture mid-restore: the watermark (restoredMsgKeys) is set during
812
- // restore, and a flush before it lands would re-append the restored history.
813
- // `restoring` is slice-backed (shared), so every view holds off (§5.8/§6).
814
- if (this.deps.getStore()?.store.aiAssistant.restoring) return;
815
- if (this._diagnosticsTimer != null) return; // coalesce
816
- const debounce = provider.diagnosticsDebounceMs ?? DEFAULT_DIAGNOSTICS_DEBOUNCE_MS;
817
- const delay = Math.max(0, debounce - (Date.now() - this._lastDiagnosticsAt));
818
- this._diagnosticsTimer = setTimeout(() => {
819
- this._diagnosticsTimer = undefined;
820
- this._lastDiagnosticsAt = Date.now();
821
- void this.flushDiagnostics();
822
- }, delay);
823
- }
824
-
825
- /**
826
- * Append the diagnostic entries created since the last flush to the provider's
827
- * forward-only stream. Best-effort — a failed write is logged and never touches
828
- * the chat. (The cursors advance on collect, so a dropped batch isn't retried;
829
- * acceptable for a forensic stream that isn't the source of truth.) Also called
830
- * directly by the download path to land the current delta before reassembly.
831
- */
832
- async flushDiagnostics(): Promise<void> {
833
- const { provider } = this.deps.getPersistence();
834
- if (!provider?.appendDiagnostics) return;
835
- const delta = this.collectDiagnosticsDelta();
836
- if (!delta.length) return;
837
- try {
838
- await provider.appendDiagnostics(this.stateKey, delta);
839
- } catch (e) {
840
- logger.error('Diagnostics append failed:', e);
841
- }
842
- }
843
-
844
- /**
845
- * The diagnostic entries not yet appended this load — the forward-capture delta
846
- * (§5.8). Skips messages present at restore or already appended; emits each meta-event
847
- * (by index) once and each turn (by turn-index) once unpriced plus once more when its
848
- * per-call `usage` lands; appends a `meta-snapshot` only when the block changed.
849
- * Advances the shared per-session cursors as it goes.
850
- *
851
- * TODO(GENC-1461, longer-term): this whole delta step exists only because the element *pulls* the
852
- * ENTIRE debug log on every throttled flush (`getDiagnosticEntries()` returns the full timeline),
853
- * so we must diff it against per-session cursors to append only the new tail — and messages, unlike
854
- * events (monotonic `index`) and turns (`turnIndex`), have no stable positional cursor (compaction
855
- * rewrites ranges, tool-call messages are annotated in place, synthetic messages are spliced in,
856
- * restore reloads them), forcing a content-derived identity (`diagnosticsMessageKey`). That
857
- * identity is inherently fragile: it was too coarse for the reasoning/answer/tool-call split and
858
- * silently dropped co-timestamped messages (GENC-1461; fixed for now by adding `category`). The
859
- * robust fix is to invert the flow — have the driver *push* each diagnostic entry exactly once at
860
- * creation (an append-only event stream) instead of the element pulling-and-diffing — which removes
861
- * the message-identity key and this dedup entirely. Bigger change; deferred.
862
- */
863
- private collectDiagnosticsDelta(): DiagnosticEntry[] {
864
- const cursors = getDiagnosticsCursors(this.stateKey);
865
- const delta: DiagnosticEntry[] = [];
866
- for (const entry of this.deps.getDiagnosticEntries()) {
867
- if (entry.kind === 'message') {
868
- const key = diagnosticsMessageKey(entry);
869
- if (key !== null) {
870
- if (cursors.restoredMsgKeys.has(key) || cursors.emittedMsgKeys.has(key)) continue;
871
- cursors.emittedMsgKeys.add(key);
872
- }
873
- delta.push(entry);
874
- } else if (entry.kind === 'turn') {
875
- // A turn entry is created BEFORE its model call and priced when the response
876
- // lands, so a flush that falls in between persists it unpriced. Emit it a second
877
- // time once `usage` is there — keyed separately so it happens at most once per
878
- // turn — and let `assembleDebugLog` collapse the pair onto the priced copy.
879
- // Without this the stitched lifetime log silently loses per-call cost for any
880
- // turn that outlived a flush, which is most of the slow ones.
881
- const key = String(entry.turnIndex ?? '');
882
- const emitKey = entry.usage ? `${key}::usage` : key;
883
- if (cursors.emittedTurnKeys.has(emitKey)) continue;
884
- cursors.emittedTurnKeys.add(emitKey);
885
- delta.push(entry);
886
- } else if (entry.kind === 'event') {
887
- const index = typeof entry.index === 'number' ? entry.index : -1;
888
- if (index <= cursors.lastEventIndex) continue;
889
- cursors.lastEventIndex = index;
890
- delta.push(entry);
891
- } else {
892
- // meta-snapshot — append only when the near-static config actually changed.
893
- // Prefer the entry's `dedupSignature` (the config-only projection the element
894
- // builds), so per-flush churn in the volatile fields (export timestamp,
895
- // context tokens/cost, live debug snapshot) no longer forces a re-append of
896
- // the bulky block every time (GENC-1351 §5.8 / #12). Falls back to the full
897
- // `meta` for any entry that predates the signature.
898
- const sig =
899
- typeof entry.dedupSignature === 'string'
900
- ? entry.dedupSignature
901
- : JSON.stringify(entry.meta);
902
- if (sig === cursors.lastMetaJson) continue;
903
- cursors.lastMetaJson = sig;
904
- delta.push(entry);
905
- }
906
- }
907
- return delta;
908
- }
909
-
910
- /** Cancel any pending timers. Called on explicit session teardown. */
911
- dispose(): void {
912
- clearTimeout(this._saveTimer);
913
- this._saveTimer = undefined;
914
- clearTimeout(this._diagnosticsTimer);
915
- this._diagnosticsTimer = undefined;
916
- }
917
- }