@flamingo-stack/openframe-frontend-core 0.0.508 → 0.0.509

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 (306) hide show
  1. package/dist/chat-protocol/decode.d.ts +58 -0
  2. package/dist/chat-protocol/decode.d.ts.map +1 -0
  3. package/dist/chat-protocol/encode.d.ts +44 -0
  4. package/dist/chat-protocol/encode.d.ts.map +1 -0
  5. package/dist/chat-protocol/env-flag.d.ts +35 -0
  6. package/dist/chat-protocol/env-flag.d.ts.map +1 -0
  7. package/dist/chat-protocol/events.d.ts +216 -0
  8. package/dist/chat-protocol/events.d.ts.map +1 -0
  9. package/dist/chat-protocol/frames.d.ts +213 -0
  10. package/dist/chat-protocol/frames.d.ts.map +1 -0
  11. package/dist/chat-protocol/index.cjs +506 -0
  12. package/dist/chat-protocol/index.cjs.map +1 -0
  13. package/dist/chat-protocol/index.d.ts +18 -0
  14. package/dist/chat-protocol/index.d.ts.map +1 -0
  15. package/dist/chat-protocol/index.js +490 -0
  16. package/dist/chat-protocol/index.js.map +1 -0
  17. package/dist/chat-protocol/ip-normalize.d.ts +44 -0
  18. package/dist/chat-protocol/ip-normalize.d.ts.map +1 -0
  19. package/dist/chat-protocol/nats-decoder.d.ts +26 -0
  20. package/dist/chat-protocol/nats-decoder.d.ts.map +1 -0
  21. package/dist/{chunk-3SQ5KXHQ.cjs → chunk-2T4GTV25.cjs} +9 -9
  22. package/dist/{chunk-3SQ5KXHQ.cjs.map → chunk-2T4GTV25.cjs.map} +1 -1
  23. package/dist/{chunk-CWAOV2FK.cjs → chunk-3CGZPAGR.cjs} +3 -3
  24. package/dist/{chunk-CWAOV2FK.cjs.map → chunk-3CGZPAGR.cjs.map} +1 -1
  25. package/dist/{chunk-TLMJHMXJ.js → chunk-3QSR22IC.js} +12 -12
  26. package/dist/chunk-3QSR22IC.js.map +1 -0
  27. package/dist/{chunk-TWYNR4TZ.cjs → chunk-4G5TLHJD.cjs} +7141 -4799
  28. package/dist/chunk-4G5TLHJD.cjs.map +1 -0
  29. package/dist/{chunk-ADPMHWOE.js → chunk-72XAID7Y.js} +4 -4
  30. package/dist/{chunk-PAGKRNWK.js → chunk-7WZHBQ4J.js} +2 -2
  31. package/dist/{chunk-OCEKO5CW.js → chunk-AEELJC4N.js} +12106 -9764
  32. package/dist/chunk-AEELJC4N.js.map +1 -0
  33. package/dist/{chunk-Z42BGM6Q.cjs → chunk-BT2WWDFP.cjs} +5 -5
  34. package/dist/{chunk-Z42BGM6Q.cjs.map → chunk-BT2WWDFP.cjs.map} +1 -1
  35. package/dist/{chunk-7LEL3RIX.cjs → chunk-C7OY3PQP.cjs} +11 -11
  36. package/dist/{chunk-7LEL3RIX.cjs.map → chunk-C7OY3PQP.cjs.map} +1 -1
  37. package/dist/{chunk-RHGGEKPQ.cjs → chunk-CIOLOQKQ.cjs} +4 -4
  38. package/dist/{chunk-RHGGEKPQ.cjs.map → chunk-CIOLOQKQ.cjs.map} +1 -1
  39. package/dist/{chunk-TSZHM74B.js → chunk-DC2TKS7C.js} +2 -2
  40. package/dist/{chunk-M2GOR3XQ.cjs → chunk-DYPYR7FR.cjs} +87 -87
  41. package/dist/{chunk-M2GOR3XQ.cjs.map → chunk-DYPYR7FR.cjs.map} +1 -1
  42. package/dist/{chunk-I64ABCDX.js → chunk-I2B6X77L.js} +2 -2
  43. package/dist/{chunk-NIZDKTGL.cjs → chunk-K5MSCMDK.cjs} +37 -37
  44. package/dist/{chunk-NIZDKTGL.cjs.map → chunk-K5MSCMDK.cjs.map} +1 -1
  45. package/dist/{chunk-DTYRYB2N.js → chunk-K6QTZ7CZ.js} +2 -2
  46. package/dist/{chunk-4LTMXDUS.js → chunk-KBN7PMFT.js} +2 -2
  47. package/dist/{chunk-TBMV7I5N.js → chunk-KXZO2WZX.js} +2 -2
  48. package/dist/{chunk-QTYZMP6D.cjs → chunk-LBAKLT6T.cjs} +62 -62
  49. package/dist/chunk-LBAKLT6T.cjs.map +1 -0
  50. package/dist/{chunk-KEBYLU3U.js → chunk-LFUPI7VX.js} +2 -2
  51. package/dist/{chunk-LDTR4IVZ.cjs → chunk-M37PBG4N.cjs} +31 -31
  52. package/dist/{chunk-LDTR4IVZ.cjs.map → chunk-M37PBG4N.cjs.map} +1 -1
  53. package/dist/{chunk-WA7RR64F.js → chunk-PO5O654H.js} +6 -5
  54. package/dist/chunk-PO5O654H.js.map +1 -0
  55. package/dist/{chunk-WK4N5VBX.cjs → chunk-QJJISLPA.cjs} +28 -27
  56. package/dist/chunk-QJJISLPA.cjs.map +1 -0
  57. package/dist/{chunk-2F44PLTV.cjs → chunk-T7MCSA55.cjs} +26 -26
  58. package/dist/{chunk-2F44PLTV.cjs.map → chunk-T7MCSA55.cjs.map} +1 -1
  59. package/dist/{chunk-WJQHJD7J.js → chunk-TWSKTJHW.js} +2 -2
  60. package/dist/{chunk-ZSQHZYCO.cjs → chunk-UQDMJ6DH.cjs} +13 -13
  61. package/dist/{chunk-ZSQHZYCO.cjs.map → chunk-UQDMJ6DH.cjs.map} +1 -1
  62. package/dist/{chunk-TZRUCD56.js → chunk-V3ZAAFE3.js} +5 -5
  63. package/dist/{chunk-LRGHJPET.js → chunk-XGIFOHKE.js} +2 -2
  64. package/dist/{chunk-POOMO3PA.cjs → chunk-YQEPRYT5.cjs} +7 -7
  65. package/dist/{chunk-POOMO3PA.cjs.map → chunk-YQEPRYT5.cjs.map} +1 -1
  66. package/dist/components/case-studies/index.cjs +8 -8
  67. package/dist/components/case-studies/index.js +2 -2
  68. package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
  69. package/dist/components/chat/chat-message-list.d.ts.map +1 -1
  70. package/dist/components/chat/hooks/index.d.ts +0 -1
  71. package/dist/components/chat/hooks/index.d.ts.map +1 -1
  72. package/dist/components/chat/hooks/use-chat.d.ts.map +1 -1
  73. package/dist/components/chat/hooks/use-chunk-catchup.d.ts.map +1 -1
  74. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts +15 -48
  75. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
  76. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts +10 -57
  77. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
  78. package/dist/components/chat/index.cjs +10 -2
  79. package/dist/components/chat/index.cjs.map +1 -1
  80. package/dist/components/chat/index.d.ts +1 -0
  81. package/dist/components/chat/index.d.ts.map +1 -1
  82. package/dist/components/chat/index.js +21 -13
  83. package/dist/components/chat/stream/chat-dialog-store.d.ts +184 -0
  84. package/dist/components/chat/stream/chat-dialog-store.d.ts.map +1 -0
  85. package/dist/components/chat/stream/chat-stream-reducer.d.ts +380 -0
  86. package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +1 -0
  87. package/dist/components/chat/stream/delta-batcher.d.ts +51 -0
  88. package/dist/components/chat/stream/delta-batcher.d.ts.map +1 -0
  89. package/dist/components/chat/stream/index.d.ts +17 -0
  90. package/dist/components/chat/stream/index.d.ts.map +1 -0
  91. package/dist/components/chat/stream/message-mutations.d.ts +77 -0
  92. package/dist/components/chat/stream/message-mutations.d.ts.map +1 -0
  93. package/dist/components/chat/stream/use-chat-stream-reducer.d.ts +22 -0
  94. package/dist/components/chat/stream/use-chat-stream-reducer.d.ts.map +1 -0
  95. package/dist/components/chat/types/api.types.d.ts +1 -81
  96. package/dist/components/chat/types/api.types.d.ts.map +1 -1
  97. package/dist/components/chat/types/processing.types.d.ts +2 -93
  98. package/dist/components/chat/types/processing.types.d.ts.map +1 -1
  99. package/dist/components/chat/types/unified-chat-state.types.d.ts +15 -0
  100. package/dist/components/chat/types/unified-chat-state.types.d.ts.map +1 -1
  101. package/dist/components/chat/utils/extract-incomplete-message-state.d.ts +34 -6
  102. package/dist/components/chat/utils/extract-incomplete-message-state.d.ts.map +1 -1
  103. package/dist/components/chat/utils/history-merge.d.ts.map +1 -1
  104. package/dist/components/chat/utils/index.d.ts +2 -3
  105. package/dist/components/chat/utils/index.d.ts.map +1 -1
  106. package/dist/components/chat/utils/process-historical-messages.d.ts +59 -11
  107. package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
  108. package/dist/components/contact/index.cjs +3 -3
  109. package/dist/components/contact/index.js +2 -2
  110. package/dist/components/docs/index.cjs +5 -5
  111. package/dist/components/docs/index.js +4 -4
  112. package/dist/components/embeds/index.cjs +3 -3
  113. package/dist/components/embeds/index.js +2 -2
  114. package/dist/components/faq/index.cjs +3 -3
  115. package/dist/components/faq/index.js +2 -2
  116. package/dist/components/features/index.cjs +2 -2
  117. package/dist/components/features/index.js +1 -1
  118. package/dist/components/help-center-pages/index.cjs +22 -22
  119. package/dist/components/help-center-pages/index.js +13 -13
  120. package/dist/components/index.cjs +174 -132
  121. package/dist/components/index.cjs.map +1 -1
  122. package/dist/components/index.js +65 -23
  123. package/dist/components/index.js.map +1 -1
  124. package/dist/components/meeting-scheduler/index.cjs +34 -34
  125. package/dist/components/meeting-scheduler/index.js +3 -3
  126. package/dist/components/navigation/index.cjs +2 -2
  127. package/dist/components/navigation/index.js +1 -1
  128. package/dist/components/onboarding-guides/index.cjs +5 -5
  129. package/dist/components/onboarding-guides/index.js +4 -4
  130. package/dist/components/onboarding-guides/onboarding-guide-detail-view.d.ts.map +1 -1
  131. package/dist/components/related-content/index.cjs +3 -3
  132. package/dist/components/related-content/index.js +2 -2
  133. package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
  134. package/dist/components/tickets/index.cjs +6 -6
  135. package/dist/components/tickets/index.js +5 -5
  136. package/dist/components/ui/index.cjs +44 -2
  137. package/dist/components/ui/index.cjs.map +1 -1
  138. package/dist/components/ui/index.d.ts +1 -2
  139. package/dist/components/ui/index.d.ts.map +1 -1
  140. package/dist/components/ui/index.js +55 -13
  141. package/dist/components/ui/markdown/base-components.d.ts +51 -0
  142. package/dist/components/ui/markdown/base-components.d.ts.map +1 -0
  143. package/dist/components/ui/markdown/engine.d.ts +99 -0
  144. package/dist/components/ui/markdown/engine.d.ts.map +1 -0
  145. package/dist/components/ui/markdown/heading-ids.d.ts +65 -0
  146. package/dist/components/ui/markdown/heading-ids.d.ts.map +1 -0
  147. package/dist/components/ui/markdown/index.d.ts +18 -0
  148. package/dist/components/ui/markdown/index.d.ts.map +1 -0
  149. package/dist/components/ui/markdown/mermaid-diagram.d.ts +79 -0
  150. package/dist/components/ui/markdown/mermaid-diagram.d.ts.map +1 -0
  151. package/dist/components/ui/markdown/rich/embed-overrides.d.ts +14 -0
  152. package/dist/components/ui/markdown/rich/embed-overrides.d.ts.map +1 -0
  153. package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts +40 -0
  154. package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts.map +1 -0
  155. package/dist/components/ui/markdown/rich/shortcodes.d.ts +8 -0
  156. package/dist/components/ui/markdown/rich/shortcodes.d.ts.map +1 -0
  157. package/dist/components/ui/markdown/sanitize.d.ts +153 -0
  158. package/dist/components/ui/markdown/sanitize.d.ts.map +1 -0
  159. package/dist/components/ui/markdown/simple-markdown-renderer.d.ts +23 -0
  160. package/dist/components/ui/markdown/simple-markdown-renderer.d.ts.map +1 -0
  161. package/dist/components/ui/markdown/streaming.d.ts +78 -0
  162. package/dist/components/ui/markdown/streaming.d.ts.map +1 -0
  163. package/dist/components/ui/markdown/text-size.d.ts +34 -0
  164. package/dist/components/ui/markdown/text-size.d.ts.map +1 -0
  165. package/dist/index.cjs +60 -2
  166. package/dist/index.cjs.map +1 -1
  167. package/dist/index.js +71 -13
  168. package/dist/utils/index.cjs +169 -40
  169. package/dist/utils/index.cjs.map +1 -1
  170. package/dist/utils/index.d.ts +1 -0
  171. package/dist/utils/index.d.ts.map +1 -1
  172. package/dist/utils/index.js +162 -41
  173. package/dist/utils/index.js.map +1 -1
  174. package/dist/utils/markdown-fences.d.ts +42 -0
  175. package/dist/utils/markdown-fences.d.ts.map +1 -0
  176. package/dist/utils/markdown-heading-id.d.ts +85 -0
  177. package/dist/utils/markdown-heading-id.d.ts.map +1 -0
  178. package/dist/utils/markdown-section-extractor.d.ts.map +1 -1
  179. package/package.json +7 -1
  180. package/src/chat-protocol/__tests__/__snapshots__/nats-decoder-golden.test.ts.snap +319 -0
  181. package/src/chat-protocol/__tests__/chat-protocol.test.ts +516 -0
  182. package/src/chat-protocol/__tests__/env-flag.test.ts +52 -0
  183. package/src/chat-protocol/__tests__/ip-normalize.test.ts +136 -0
  184. package/src/chat-protocol/__tests__/nats-decoder-golden.test.ts +237 -0
  185. package/src/chat-protocol/decode.ts +278 -0
  186. package/src/chat-protocol/encode.ts +71 -0
  187. package/src/chat-protocol/env-flag.ts +39 -0
  188. package/src/chat-protocol/events.ts +254 -0
  189. package/src/chat-protocol/frames.ts +245 -0
  190. package/src/chat-protocol/index.ts +21 -0
  191. package/src/chat-protocol/ip-normalize.ts +146 -0
  192. package/src/chat-protocol/nats-decoder.ts +264 -0
  193. package/src/components/chat/.chat-message-list.md +10 -6
  194. package/src/components/chat/__tests__/chat-message-list.test.tsx +450 -9
  195. package/src/components/chat/__tests__/chat-message-streaming-memo.test.tsx +207 -0
  196. package/src/components/chat/__tests__/chat-pending-turn.test.tsx +250 -0
  197. package/src/components/chat/chat-message-enhanced.tsx +122 -20
  198. package/src/components/chat/chat-message-list.tsx +515 -133
  199. package/src/components/chat/embeddable-chat.tsx +6 -0
  200. package/src/components/chat/hooks/.index.md +30 -33
  201. package/src/components/chat/hooks/.use-nats-chat-adapter.md +36 -55
  202. package/src/components/chat/hooks/__tests__/__snapshots__/conversation-id-persistence-golden.test.ts.snap +37 -0
  203. package/src/components/chat/hooks/__tests__/__snapshots__/sse-stream-golden.test.ts.snap +0 -0
  204. package/src/components/chat/hooks/__tests__/chunk-catchup-dialog-staleness.test.ts +92 -0
  205. package/src/components/chat/hooks/__tests__/conversation-id-persistence-golden.test.ts +438 -0
  206. package/src/components/chat/hooks/__tests__/sse-stream-golden.test.ts +318 -0
  207. package/src/components/chat/hooks/index.ts +0 -1
  208. package/src/components/chat/hooks/use-chat.ts +8 -0
  209. package/src/components/chat/hooks/use-chunk-catchup.ts +26 -1
  210. package/src/components/chat/hooks/use-nats-chat-adapter.ts +254 -825
  211. package/src/components/chat/hooks/use-sse-chat-adapter.ts +380 -684
  212. package/src/components/chat/index.ts +5 -0
  213. package/src/components/chat/stream/__tests__/__snapshots__/chat-stream-reducer-golden.test.ts.snap +944 -0
  214. package/src/components/chat/stream/__tests__/chat-dialog-store.test.ts +806 -0
  215. package/src/components/chat/stream/__tests__/chat-stream-reducer-golden.test.ts +467 -0
  216. package/src/components/chat/stream/__tests__/chat-stream-reducer.test.ts +1330 -0
  217. package/src/components/chat/stream/__tests__/delta-batcher.test.ts +255 -0
  218. package/src/components/chat/stream/__tests__/use-chat-stream-reducer.test.ts +105 -0
  219. package/src/components/chat/stream/chat-dialog-store.ts +536 -0
  220. package/src/components/chat/stream/chat-stream-reducer.ts +1928 -0
  221. package/src/components/chat/stream/delta-batcher.ts +168 -0
  222. package/src/components/chat/stream/index.ts +55 -0
  223. package/src/components/chat/stream/message-mutations.ts +340 -0
  224. package/src/components/chat/stream/use-chat-stream-reducer.ts +126 -0
  225. package/src/components/chat/thinking-display.tsx +1 -1
  226. package/src/components/chat/types/.api.types.md +39 -49
  227. package/src/components/chat/types/.processing.types.md +29 -58
  228. package/src/components/chat/types/.unified-chat-state.types.md +2 -2
  229. package/src/components/chat/types/api.types.ts +6 -73
  230. package/src/components/chat/types/processing.types.ts +11 -53
  231. package/src/components/chat/types/unified-chat-state.types.ts +16 -0
  232. package/src/components/chat/utils/.extract-incomplete-message-state.md +21 -3
  233. package/src/components/chat/utils/.history-merge.md +44 -3
  234. package/src/components/chat/utils/.index.md +31 -40
  235. package/src/components/chat/utils/__tests__/__snapshots__/process-historical-messages-golden.test.ts.snap +420 -0
  236. package/src/components/chat/utils/__tests__/__snapshots__/segment-accumulator-golden.test.ts.snap +605 -0
  237. package/src/components/chat/utils/__tests__/extract-incomplete-message-state.test.ts +106 -0
  238. package/src/components/chat/utils/__tests__/history-merge.test.ts +160 -0
  239. package/src/components/chat/utils/__tests__/process-historical-messages-approvals.test.ts +61 -0
  240. package/src/components/chat/utils/__tests__/process-historical-messages-golden.test.ts +394 -0
  241. package/src/components/chat/utils/__tests__/segment-accumulator-golden.test.ts +270 -0
  242. package/src/components/chat/utils/extract-incomplete-message-state.ts +93 -6
  243. package/src/components/chat/utils/history-merge.ts +85 -5
  244. package/src/components/chat/utils/index.ts +5 -8
  245. package/src/components/chat/utils/process-historical-messages.ts +361 -385
  246. package/src/components/onboarding-guides/onboarding-guide-detail-view.tsx +22 -4
  247. package/src/components/shared/legal-document/legal-document-page.tsx +1 -1
  248. package/src/components/shared/product-release/release-detail-page.tsx +19 -4
  249. package/src/components/ui/__tests__/__snapshots__/markdown-parity.test.tsx.snap +2420 -0
  250. package/src/components/ui/__tests__/markdown-parity.test.tsx +2540 -0
  251. package/src/components/ui/index.ts +1 -2
  252. package/src/components/ui/markdown/__tests__/mermaid-security.test.ts +176 -0
  253. package/src/components/ui/markdown/__tests__/mermaid-stale-render.test.tsx +151 -0
  254. package/src/components/ui/markdown/__tests__/sanitize-invariant.test.ts +354 -0
  255. package/src/components/ui/markdown/__tests__/sanitize-render.test.tsx +118 -0
  256. package/src/components/ui/markdown/__tests__/streaming.test.tsx +436 -0
  257. package/src/components/ui/markdown/base-components.tsx +360 -0
  258. package/src/components/ui/markdown/engine.tsx +315 -0
  259. package/src/components/ui/markdown/heading-ids.ts +239 -0
  260. package/src/components/ui/markdown/index.ts +49 -0
  261. package/src/components/ui/markdown/mermaid-diagram.tsx +323 -0
  262. package/src/components/ui/markdown/rich/embed-overrides.tsx +199 -0
  263. package/src/components/ui/markdown/rich/rich-markdown-renderer.tsx +184 -0
  264. package/src/components/ui/markdown/rich/shortcodes.ts +170 -0
  265. package/src/components/ui/markdown/sanitize.ts +3584 -0
  266. package/src/components/ui/markdown/simple-markdown-renderer.tsx +28 -0
  267. package/src/components/ui/markdown/streaming.ts +390 -0
  268. package/src/components/ui/markdown/text-size.ts +106 -0
  269. package/src/components/ui/release-changelog-section.tsx +1 -1
  270. package/src/components/ui/ticket-info-section.tsx +1 -1
  271. package/src/utils/index.ts +1 -0
  272. package/src/utils/markdown-fences.ts +127 -0
  273. package/src/utils/markdown-heading-id.ts +348 -0
  274. package/src/utils/markdown-section-extractor.ts +41 -56
  275. package/dist/chunk-OCEKO5CW.js.map +0 -1
  276. package/dist/chunk-QTYZMP6D.cjs.map +0 -1
  277. package/dist/chunk-TLMJHMXJ.js.map +0 -1
  278. package/dist/chunk-TWYNR4TZ.cjs.map +0 -1
  279. package/dist/chunk-WA7RR64F.js.map +0 -1
  280. package/dist/chunk-WK4N5VBX.cjs.map +0 -1
  281. package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts +0 -6
  282. package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts.map +0 -1
  283. package/dist/components/chat/utils/chunk-parser.d.ts +0 -25
  284. package/dist/components/chat/utils/chunk-parser.d.ts.map +0 -1
  285. package/dist/components/ui/rich-markdown-renderer.d.ts +0 -34
  286. package/dist/components/ui/rich-markdown-renderer.d.ts.map +0 -1
  287. package/dist/components/ui/simple-markdown-renderer.d.ts +0 -73
  288. package/dist/components/ui/simple-markdown-renderer.d.ts.map +0 -1
  289. package/src/components/chat/hooks/.use-realtime-chunk-processor.md +0 -77
  290. package/src/components/chat/hooks/use-realtime-chunk-processor.ts +0 -489
  291. package/src/components/chat/utils/.chunk-parser.md +0 -62
  292. package/src/components/chat/utils/chunk-parser.ts +0 -262
  293. package/src/components/ui/.simple-markdown-renderer.md +0 -52
  294. package/src/components/ui/rich-markdown-renderer.tsx +0 -1223
  295. package/src/components/ui/simple-markdown-renderer.tsx +0 -964
  296. /package/dist/{chunk-ADPMHWOE.js.map → chunk-72XAID7Y.js.map} +0 -0
  297. /package/dist/{chunk-PAGKRNWK.js.map → chunk-7WZHBQ4J.js.map} +0 -0
  298. /package/dist/{chunk-TSZHM74B.js.map → chunk-DC2TKS7C.js.map} +0 -0
  299. /package/dist/{chunk-I64ABCDX.js.map → chunk-I2B6X77L.js.map} +0 -0
  300. /package/dist/{chunk-DTYRYB2N.js.map → chunk-K6QTZ7CZ.js.map} +0 -0
  301. /package/dist/{chunk-4LTMXDUS.js.map → chunk-KBN7PMFT.js.map} +0 -0
  302. /package/dist/{chunk-TBMV7I5N.js.map → chunk-KXZO2WZX.js.map} +0 -0
  303. /package/dist/{chunk-KEBYLU3U.js.map → chunk-LFUPI7VX.js.map} +0 -0
  304. /package/dist/{chunk-WJQHJD7J.js.map → chunk-TWSKTJHW.js.map} +0 -0
  305. /package/dist/{chunk-TZRUCD56.js.map → chunk-V3ZAAFE3.js.map} +0 -0
  306. /package/dist/{chunk-LRGHJPET.js.map → chunk-XGIFOHKE.js.map} +0 -0
@@ -0,0 +1,806 @@
1
+ /**
2
+ * Phase-3 tests — `createChatDialogStore` cross-side projection.
3
+ *
4
+ * Exactly two operations fan out across sides of the same dialogId:
5
+ * approval-resolution by requestId and tool-execution merge by execId.
6
+ * The projection is recomputed from state (never re-injected into the other
7
+ * side's seq stream), so per-side seq gates stay untouched and replays are
8
+ * idempotent with full referential stability on untouched messages.
9
+ */
10
+
11
+ import { describe, it, expect } from 'vitest'
12
+ import {
13
+ createChatDialogStore,
14
+ DEFAULT_MAX_REDUCERS,
15
+ MAX_PENDING_RETAIN_KEYS,
16
+ } from '../chat-dialog-store'
17
+ import type { EvictedReducerState } from '../chat-dialog-store'
18
+ import type { ChatStreamEvent } from '../../../../chat-protocol/events'
19
+
20
+ /** ES2020-compatible `arr.at(-1)`. The package pins `lib: ES2020`
21
+ * (target ES2020 is the shipped contract), where `Array.prototype.at`
22
+ * is not declared — the source tree uses `xs[xs.length - 1]` for the
23
+ * same reason. */
24
+ const last = <T,>(xs: readonly T[] | undefined): T | undefined =>
25
+ xs === undefined ? undefined : xs[xs.length - 1]
26
+
27
+ const DIALOG = 'dlg-1'
28
+
29
+ const batchApproval = (seq?: number): ChatStreamEvent => ({
30
+ type: 'approval-request',
31
+ requestId: 'req-1',
32
+ approvalType: 'CLIENT',
33
+ toolCalls: [
34
+ {
35
+ toolExecutionRequestId: 'exec-1',
36
+ toolName: 'run_command',
37
+ requiresApproval: true,
38
+ approvalType: 'CLIENT',
39
+ toolCallArguments: null,
40
+ },
41
+ ],
42
+ ...(seq != null ? { seq } : {}),
43
+ })
44
+
45
+ function seedBothSides(store: ReturnType<typeof createChatDialogStore>) {
46
+ // Side A (client view) and side B (technician mirror) both saw the turn
47
+ // with the approval card — each through its OWN seq stream.
48
+ for (const side of ['main', 'observer'] as const) {
49
+ store.apply(DIALOG, side, { type: 'turn-start', seq: 1 })
50
+ store.apply(DIALOG, side, { type: 'text-delta', text: 'needs approval', seq: 2 })
51
+ store.apply(DIALOG, side, batchApproval(3))
52
+ store.apply(DIALOG, side, { type: 'turn-end', seq: 4 })
53
+ }
54
+ }
55
+
56
+ describe('createChatDialogStore — cross-side projection', () => {
57
+ it('(a) a same-id TEXT event on one side does NOT touch the other side', () => {
58
+ const store = createChatDialogStore()
59
+ seedBothSides(store)
60
+ const bBefore = store.getSnapshot(DIALOG, 'observer')
61
+
62
+ store.apply(DIALOG, 'main', { type: 'text-delta', text: 'main-only continuation', seq: 5 })
63
+
64
+ expect(store.getSnapshot(DIALOG, 'observer')).toBe(bBefore)
65
+ const aTrailing = last(store.getSnapshot(DIALOG, 'main').messages)
66
+ const bTrailing = last(store.getSnapshot(DIALOG, 'observer').messages)
67
+ expect(JSON.stringify(aTrailing)).not.toEqual(JSON.stringify(bTrailing))
68
+ })
69
+
70
+ it('(b) approval resolved on side A updates side B matching card (status + resolver)', () => {
71
+ const store = createChatDialogStore()
72
+ seedBothSides(store)
73
+
74
+ store.apply(DIALOG, 'main', {
75
+ type: 'approval-resolved',
76
+ requestId: 'req-1',
77
+ status: 'approved',
78
+ approvalType: 'CLIENT',
79
+ resolvedByName: 'Alice Client',
80
+ seq: 5,
81
+ })
82
+
83
+ for (const side of ['main', 'observer'] as const) {
84
+ const messages = store.getSnapshot(DIALOG, side).messages
85
+ const card = messages
86
+ .flatMap((m) => m.segments ?? [])
87
+ .find((s) => s.type === 'approval_batch')
88
+ expect(card).toBeDefined()
89
+ expect((card as { status?: string }).status).toBe('approved')
90
+ expect((card as { resolvedByName?: string | null }).resolvedByName).toBe('Alice Client')
91
+ // Status map mirrored on both sides so future replays render resolved.
92
+ expect(store.getSnapshot(DIALOG, side).approvalStatuses['req-1']).toBe('approved')
93
+ }
94
+ })
95
+
96
+ it('(b2) tool execution on side A merges into side B batch card WITHOUT appending', () => {
97
+ const store = createChatDialogStore()
98
+ seedBothSides(store)
99
+ store.apply(DIALOG, 'main', {
100
+ type: 'approval-resolved',
101
+ requestId: 'req-1',
102
+ status: 'approved',
103
+ seq: 5,
104
+ })
105
+
106
+ store.apply(DIALOG, 'main', {
107
+ type: 'tool-execution',
108
+ data: {
109
+ type: 'EXECUTED_TOOL',
110
+ integratedToolType: 'SHELL',
111
+ toolFunction: 'run_command',
112
+ result: 'ok',
113
+ success: true,
114
+ toolExecutionRequestId: 'exec-1',
115
+ },
116
+ seq: 6,
117
+ })
118
+
119
+ const b = store.getSnapshot(DIALOG, 'observer')
120
+ const bSegments = b.messages.flatMap((m) => m.segments ?? [])
121
+ const batch = bSegments.find((s) => s.type === 'approval_batch')
122
+ expect(
123
+ (batch as { data: { executions?: Record<string, unknown> } }).data.executions,
124
+ ).toEqual({ 'exec-1': { status: 'done', result: 'ok', success: true } })
125
+ // Merge-only projection: no standalone tool card appeared on side B.
126
+ expect(bSegments.filter((s) => s.type === 'tool_execution')).toHaveLength(0)
127
+ })
128
+
129
+ it('(c) replaying side A events after the projection: no double-application + Object.is stability on side B untouched messages', () => {
130
+ const store = createChatDialogStore()
131
+ seedBothSides(store)
132
+
133
+ const resolve: ChatStreamEvent = {
134
+ type: 'approval-resolved',
135
+ requestId: 'req-1',
136
+ status: 'approved',
137
+ approvalType: 'CLIENT',
138
+ resolvedByName: 'Alice Client',
139
+ seq: 5,
140
+ }
141
+ const executedEvent: ChatStreamEvent = {
142
+ type: 'tool-execution',
143
+ data: {
144
+ type: 'EXECUTED_TOOL',
145
+ integratedToolType: 'SHELL',
146
+ toolFunction: 'run_command',
147
+ result: 'ok',
148
+ success: true,
149
+ toolExecutionRequestId: 'exec-1',
150
+ },
151
+ seq: 6,
152
+ }
153
+ store.apply(DIALOG, 'main', resolve)
154
+ store.apply(DIALOG, 'main', executedEvent)
155
+
156
+ const aState = store.getSnapshot(DIALOG, 'main')
157
+ const bState = store.getSnapshot(DIALOG, 'observer')
158
+ const bMessages = bState.messages
159
+
160
+ // Replay side A's events (reconnect back-fill). Side A drops them via
161
+ // its seq gate; the recomputed projection over side B is a value-level
162
+ // no-op → identical references end-to-end.
163
+ store.apply(DIALOG, 'main', resolve)
164
+ store.apply(DIALOG, 'main', executedEvent)
165
+
166
+ expect(store.getSnapshot(DIALOG, 'main')).toBe(aState)
167
+ const bAfter = store.getSnapshot(DIALOG, 'observer')
168
+ expect(bAfter.messages).toBe(bMessages)
169
+ bAfter.messages.forEach((m, i) => expect(m).toBe(bMessages[i]))
170
+ expect(bAfter).toBe(bState)
171
+ })
172
+
173
+ it("(c2) the projection never advances the other side's seq gate", () => {
174
+ const store = createChatDialogStore()
175
+ seedBothSides(store)
176
+
177
+ // Side A resolves at a HIGH seq…
178
+ store.apply(DIALOG, 'main', {
179
+ type: 'approval-resolved',
180
+ requestId: 'req-1',
181
+ status: 'approved',
182
+ seq: 100,
183
+ })
184
+ // …side B's own stream continues at ITS low seq — must still apply.
185
+ store.apply(DIALOG, 'observer', { type: 'text-delta', text: ' b-continues', seq: 5 })
186
+ const bTrailing = last(store.getSnapshot(DIALOG, 'observer').messages)
187
+ const texts = (bTrailing?.segments ?? []).filter((s) => s.type === 'text') as Array<{
188
+ text: string
189
+ }>
190
+ expect(last(texts)?.text).toContain('b-continues')
191
+ })
192
+
193
+ /**
194
+ * The projection reaches the sides that EXIST when the event lands — it is a
195
+ * fan-out over the live reducer map, not a durable log. A side materialized
196
+ * later (panel opened after the fact, or its key LRU-evicted and recreated)
197
+ * therefore starts from history + the parked status map, NOT from replayed
198
+ * projections.
199
+ *
200
+ * Untested until now, and the tempting "fix" — having `otherSides` create the
201
+ * missing sides — is wrong twice over: it would materialize reducers for
202
+ * panels nobody is rendering (defeating the LRU cap that exists to bound
203
+ * exactly that), and it would seed them with a card they never received the
204
+ * APPROVAL_REQUEST for. This fixture states the host contract so that
205
+ * refactor fails loudly instead of silently changing the memory profile.
206
+ */
207
+ it('(d) a side materialized AFTER the projection does not receive it retroactively', () => {
208
+ const store = createChatDialogStore()
209
+ // ONLY side A exists when the resolution arrives.
210
+ store.apply(DIALOG, 'main', { type: 'turn-start', seq: 1 })
211
+ store.apply(DIALOG, 'main', batchApproval(2))
212
+ store.apply(DIALOG, 'main', {
213
+ type: 'approval-resolved',
214
+ requestId: 'req-1',
215
+ status: 'approved',
216
+ seq: 3,
217
+ })
218
+
219
+ // The projection created NO reducer for the absent side.
220
+ expect(store.getSnapshot(DIALOG, 'observer').messages).toHaveLength(0)
221
+ expect(store.getSnapshot(DIALOG, 'observer').approvalStatuses).toEqual({})
222
+
223
+ // Side B opens now and replays its own history: it must learn the
224
+ // resolution from the host (parked / persisted statuses), not from the
225
+ // projection that already fired.
226
+ store.apply(DIALOG, 'observer', { type: 'turn-start', seq: 1 })
227
+ store.apply(DIALOG, 'observer', batchApproval(2))
228
+ expect(store.getSnapshot(DIALOG, 'observer').approvalStatuses).toEqual({})
229
+
230
+ store.mutate(DIALOG, 'observer', (r) => r.mergeApprovalStatuses({ 'req-1': 'approved' }))
231
+ expect(store.getSnapshot(DIALOG, 'observer').approvalStatuses).toEqual({
232
+ 'req-1': 'approved',
233
+ })
234
+ })
235
+ })
236
+
237
+ describe('createChatDialogStore — snapshot purity + reducer lifecycle', () => {
238
+ it('getSnapshot on an unknown key creates NO entry (returns the shared frozen EMPTY_STATE)', () => {
239
+ const store = createChatDialogStore()
240
+
241
+ const a = store.getSnapshot('never-seen-a')
242
+ const b = store.getSnapshot('never-seen-b', 'observer')
243
+ // One shared instance across every absent key → nothing was created
244
+ // per-key, and useSyncExternalStore sees a stable identity.
245
+ expect(a).toBe(b)
246
+ expect(a).toBe(store.getServerSnapshot())
247
+ expect(Object.isFrozen(a)).toBe(true)
248
+ expect(a.messages).toEqual([])
249
+ expect(a.streamingPhase).toBe('idle')
250
+
251
+ // Behavioral proof that no entries were inserted: a real reducer created
252
+ // BEFORE far more phantom reads than the LRU cap survives untouched.
253
+ store.apply('real', 'main', { type: 'turn-start', seq: 1 })
254
+ store.apply('real', 'main', { type: 'text-delta', text: 'kept', seq: 2 })
255
+ for (let i = 0; i < DEFAULT_MAX_REDUCERS * 3; i += 1) {
256
+ store.getSnapshot(`phantom-${i}`)
257
+ }
258
+ const kept = store.getSnapshot('real', 'main')
259
+ expect(last(last(kept.messages)?.segments)).toEqual({ type: 'text', text: 'kept' })
260
+ })
261
+
262
+ it('evicts least-recently-used reducers past the cap, keeping the recently-applied ones', () => {
263
+ const store = createChatDialogStore({ maxReducers: 2 })
264
+
265
+ for (const id of ['d1', 'd2', 'd3']) {
266
+ finishedTurn(store, id, id)
267
+ }
268
+
269
+ // d1 is the LRU → evicted; its snapshot falls back to EMPTY_STATE.
270
+ expect(store.getSnapshot('d1', 'main').messages).toEqual([])
271
+ for (const id of ['d2', 'd3']) {
272
+ expect(last(last(store.getSnapshot(id, 'main').messages)?.segments)).toEqual({
273
+ type: 'text',
274
+ text: id,
275
+ })
276
+ }
277
+ })
278
+
279
+ it('applying to an existing reducer refreshes its LRU position', () => {
280
+ const store = createChatDialogStore({ maxReducers: 2 })
281
+ finishedTurn(store, 'd1', 'd1')
282
+ finishedTurn(store, 'd2', 'd2')
283
+ // Touch d1 so d2 becomes the least-recently-used…
284
+ store.apply('d1', 'main', { type: 'text-delta', text: '!', seq: 5 })
285
+ store.apply('d1', 'main', { type: 'turn-end', seq: 6 })
286
+ // …then a third dialog evicts d2, not d1.
287
+ finishedTurn(store, 'd3', 'd3')
288
+
289
+ expect(store.getSnapshot('d2', 'main').messages).toEqual([])
290
+ expect(store.getSnapshot('d1', 'main').messages.length).toBeGreaterThan(0)
291
+ expect(store.getSnapshot('d3', 'main').messages.length).toBeGreaterThan(0)
292
+ })
293
+ })
294
+
295
+ /** One complete (idle-at-the-end) turn — the only shape the LRU may evict. */
296
+ function finishedTurn(
297
+ store: ReturnType<typeof createChatDialogStore>,
298
+ dialogId: string,
299
+ text: string,
300
+ ): void {
301
+ store.apply(dialogId, 'main', { type: 'turn-start', seq: 1 })
302
+ store.apply(dialogId, 'main', { type: 'text-delta', text, seq: 2 })
303
+ store.apply(dialogId, 'main', { type: 'turn-end', seq: 3 })
304
+ }
305
+
306
+ describe('createChatDialogStore — eviction safety', () => {
307
+ it('NEVER evicts a reducer that is mid-stream (streamingPhase !== idle)', () => {
308
+ const store = createChatDialogStore({ maxReducers: 1 })
309
+
310
+ // A live turn on `live`: no turn-end, so the phase is still streaming.
311
+ store.apply('live', 'main', { type: 'turn-start', seq: 1 })
312
+ store.apply('live', 'main', { type: 'text-delta', text: 'half a sen', seq: 2 })
313
+ expect(store.getSnapshot('live', 'main').streamingPhase).not.toBe('idle')
314
+
315
+ // Ten other dialogs churn through the cap of 1.
316
+ for (let i = 0; i < 10; i += 1) finishedTurn(store, `other-${i}`, 'x')
317
+
318
+ // The live thread survives — dropping it would blank a visible thread
319
+ // that the host's seq cursor believes is already caught up.
320
+ const live = store.getSnapshot('live', 'main')
321
+ expect(live.messages.length).toBeGreaterThan(0)
322
+ expect(live.streamingPhase).not.toBe('idle')
323
+
324
+ // …and once the turn finishes it becomes evictable again.
325
+ store.apply('live', 'main', { type: 'turn-end', seq: 3 })
326
+ for (let i = 0; i < 10; i += 1) finishedTurn(store, `later-${i}`, 'x')
327
+ expect(store.getSnapshot('live', 'main').messages).toEqual([])
328
+ })
329
+
330
+ it('NEVER evicts a RETAINED key, and evicts it again after release', () => {
331
+ const store = createChatDialogStore({ maxReducers: 1 })
332
+ finishedTurn(store, 'pinned', 'pinned-text')
333
+ const release = store.retain('pinned', 'main')
334
+
335
+ for (let i = 0; i < 10; i += 1) finishedTurn(store, `noise-${i}`, 'x')
336
+ expect(last(last(store.getSnapshot('pinned', 'main').messages)?.segments)).toEqual({
337
+ type: 'text',
338
+ text: 'pinned-text',
339
+ })
340
+
341
+ release()
342
+ for (let i = 0; i < 10; i += 1) finishedTurn(store, `more-${i}`, 'x')
343
+ expect(store.getSnapshot('pinned', 'main').messages).toEqual([])
344
+ })
345
+
346
+ /**
347
+ * REGRESSION (round 4): eviction used to be UNPUBLISHED, so hosts inferred
348
+ * it by comparing reducer OBJECT IDENTITY across `getReducer` calls and then
349
+ * suppressing the pristine-empty snapshot a silently-recreated reducer
350
+ * returns. The store knows the fact exactly — it states it.
351
+ */
352
+ it('onEvict publishes the (dialogId, side) an LRU eviction dropped', () => {
353
+ const evicted: Array<[string, string]> = []
354
+ const store = createChatDialogStore({
355
+ maxReducers: 1,
356
+ onEvict: (dialogId, side) => evicted.push([dialogId, side]),
357
+ })
358
+ finishedTurn(store, 'first', 'a')
359
+ // Round-trip through a side whose name contains no separator ambiguity.
360
+ store.getReducer('second', 'observer')
361
+ expect(evicted).toEqual([['first', 'main']])
362
+
363
+ // Host-initiated removal is NOT an eviction — the caller already knows.
364
+ store.remove('second', 'observer')
365
+ expect(evicted).toEqual([['first', 'main']])
366
+ })
367
+
368
+ /**
369
+ * REGRESSION (round 5): the eviction handoff used to carry MESSAGES only, so
370
+ * a host re-seeding the recreated reducer got back a pristine
371
+ * `approvalStatuses = {}` and `lastAppliedSeq = -Infinity` — a resolved
372
+ * approval whose APPROVAL_RESULT row is not in the refetched history page
373
+ * re-renders as ACTIONABLE (exactly what `resetForDialogSwitch` preserves
374
+ * that map to prevent), and a replay from the host's cursor re-applies
375
+ * events the dropped instance had consumed.
376
+ */
377
+ it('onEvict parks approvalStatuses + lastAppliedSeq, and the state round-trips', () => {
378
+ const parked: Array<{
379
+ approvalStatuses: Record<string, string>
380
+ lastAppliedSeq: number
381
+ messages: unknown[]
382
+ }> = []
383
+ const store = createChatDialogStore({
384
+ maxReducers: 1,
385
+ onEvict: (_dialogId, _side, state) =>
386
+ parked.push({
387
+ approvalStatuses: state.approvalStatuses,
388
+ lastAppliedSeq: state.lastAppliedSeq,
389
+ messages: state.messages,
390
+ }),
391
+ })
392
+ finishedTurn(store, 'first', 'a')
393
+ store.mutate('first', 'main', (r) => r.setApprovalStatus('req-1', 'approved'))
394
+
395
+ // Churn the cap so `first` is evicted.
396
+ store.getReducer('second', 'main')
397
+ expect(parked).toHaveLength(1)
398
+ expect(parked[0].approvalStatuses).toEqual({ 'req-1': 'approved' })
399
+ expect(parked[0].lastAppliedSeq).toBe(3)
400
+ expect(parked[0].messages.length).toBeGreaterThan(0)
401
+
402
+ // The recreated instance is pristine…
403
+ const recreated = store.getReducer('first', 'main')
404
+ expect(recreated.state.approvalStatuses).toEqual({})
405
+ expect(recreated.getLastAppliedSeq()).toBe(Number.NEGATIVE_INFINITY)
406
+
407
+ // …until the host restores the parked payload alongside the refetched
408
+ // messages, which is what the callback exists to make possible.
409
+ recreated.initializeWithState([], {
410
+ approvalStatuses: parked[0].approvalStatuses as never,
411
+ lastAppliedSeq: parked[0].lastAppliedSeq,
412
+ })
413
+ expect(recreated.state.approvalStatuses).toEqual({ 'req-1': 'approved' })
414
+ // The restored seq gate drops an already-applied replay.
415
+ recreated.apply({ type: 'text-delta', text: 'replayed', seq: 2 })
416
+ expect(recreated.state.messages).toEqual([])
417
+ })
418
+
419
+ /**
420
+ * A key dropped between `pushOptimisticSend` and its `MESSAGE_REQUEST` echo
421
+ * leaves the replacement reducer with NOTHING armed, so the echo renders a
422
+ * duplicate user bubble. The armed entries ride along in the parked payload.
423
+ */
424
+ it('onEvict parks armed pendingEchoes, and echo dedup round-trips', () => {
425
+ const realNow = Date.now
426
+ try {
427
+ let now = 2_000_000
428
+ Date.now = () => now
429
+ const parked: EvictedReducerState[] = []
430
+ const store = createChatDialogStore({
431
+ maxReducers: 1,
432
+ defaultCreateOptions: () => ({
433
+ transport: 'nats',
434
+ ownEchoIncludesAdmin: true,
435
+ selfUserId: 'me',
436
+ }),
437
+ onEvict: (_dialogId, _side, state) => parked.push(state),
438
+ })
439
+ // A send made DURING a turn: the turn ends (phase → idle, so the key is
440
+ // evictable) while the send's echo is still in flight.
441
+ store.apply('first', 'main', { type: 'turn-start', seq: 1 })
442
+ now += 1
443
+ store.mutate('first', 'main', (r) => r.pushOptimisticSend('hello world'))
444
+ store.apply('first', 'main', { type: 'turn-end', seq: 2 })
445
+
446
+ store.getReducer('second', 'main')
447
+ expect(parked).toHaveLength(1)
448
+ expect(parked[0].pendingEchoes).toEqual([{ text: 'hello world', at: now }])
449
+
450
+ const recreated = store.getReducer('first', 'main')
451
+ recreated.initializeWithState(parked[0].messages as never, {
452
+ pendingEchoes: parked[0].pendingEchoes,
453
+ lastAppliedSeq: parked[0].lastAppliedSeq,
454
+ })
455
+ recreated.apply({
456
+ type: 'participant',
457
+ kind: 'message-request',
458
+ text: 'hello world',
459
+ ownerType: 'ADMIN',
460
+ userId: 'me',
461
+ seq: 3,
462
+ })
463
+ expect(recreated.state.messages.filter((m) => m.role === 'user')).toHaveLength(1)
464
+ } finally {
465
+ Date.now = realNow
466
+ }
467
+ })
468
+
469
+ it('setRetained pins exactly the given set and releases the rest', () => {
470
+ const store = createChatDialogStore({ maxReducers: 2 })
471
+ finishedTurn(store, 'a', 'a-text')
472
+ finishedTurn(store, 'b', 'b-text')
473
+ store.setRetained([
474
+ { dialogId: 'a' },
475
+ { dialogId: 'b', side: 'main' },
476
+ ])
477
+ for (let i = 0; i < 10; i += 1) finishedTurn(store, `noise-${i}`, 'x')
478
+ expect(store.getSnapshot('a', 'main').messages.length).toBeGreaterThan(0)
479
+ expect(store.getSnapshot('b', 'main').messages.length).toBeGreaterThan(0)
480
+
481
+ // Swap the set: `b` stays pinned (never dips to zero), `a` is released.
482
+ store.setRetained([{ dialogId: 'b' }])
483
+ for (let i = 0; i < 10; i += 1) finishedTurn(store, `more-${i}`, 'x')
484
+ expect(store.getSnapshot('a', 'main').messages).toEqual([])
485
+ expect(store.getSnapshot('b', 'main').messages.length).toBeGreaterThan(0)
486
+
487
+ // Empty set releases everything.
488
+ store.setRetained([])
489
+ for (let i = 0; i < 10; i += 1) finishedTurn(store, `last-${i}`, 'x')
490
+ expect(store.getSnapshot('b', 'main').messages).toEqual([])
491
+ })
492
+
493
+ it('setRetained composes with (and never clobbers) a component retain()', () => {
494
+ const store = createChatDialogStore({ maxReducers: 1 })
495
+ finishedTurn(store, 'shared', 'shared-text')
496
+ const release = store.retain('shared')
497
+ store.setRetained([{ dialogId: 'shared' }])
498
+
499
+ // Dropping the policy retain leaves the component's hold standing.
500
+ store.setRetained([])
501
+ for (let i = 0; i < 5; i += 1) finishedTurn(store, `noise-${i}`, 'x')
502
+ expect(store.getSnapshot('shared', 'main').messages.length).toBeGreaterThan(0)
503
+
504
+ release()
505
+ for (let i = 0; i < 5; i += 1) finishedTurn(store, `more-${i}`, 'x')
506
+ expect(store.getSnapshot('shared', 'main').messages).toEqual([])
507
+ })
508
+
509
+ it('retain() is refcounted and its release is idempotent', () => {
510
+ const store = createChatDialogStore({ maxReducers: 1 })
511
+ finishedTurn(store, 'pinned', 'pinned-text')
512
+ const releaseA = store.retain('pinned')
513
+ const releaseB = store.retain('pinned')
514
+
515
+ releaseA()
516
+ releaseA() // idempotent — must NOT decrement B's hold
517
+ for (let i = 0; i < 5; i += 1) finishedTurn(store, `noise-${i}`, 'x')
518
+ expect(store.getSnapshot('pinned', 'main').messages.length).toBeGreaterThan(0)
519
+
520
+ releaseB()
521
+ for (let i = 0; i < 5; i += 1) finishedTurn(store, `more-${i}`, 'x')
522
+ expect(store.getSnapshot('pinned', 'main').messages).toEqual([])
523
+ })
524
+
525
+ it('eviction never notifies SYNCHRONOUSLY (getReducer runs in the render phase)', async () => {
526
+ const store = createChatDialogStore({ maxReducers: 1 })
527
+ finishedTurn(store, 'd1', 'd1')
528
+
529
+ let notifications = 0
530
+ store.subscribe(() => {
531
+ notifications += 1
532
+ })
533
+
534
+ // A render-phase `getReducer` for a NEW key evicts d1 — synchronously
535
+ // notifying here is React's "cannot update a component while rendering".
536
+ store.getReducer('d2', 'main')
537
+ expect(notifications).toBe(0)
538
+ await Promise.resolve()
539
+ await Promise.resolve()
540
+ expect(notifications).toBeGreaterThan(0)
541
+ })
542
+
543
+ /**
544
+ * REGRESSION (round 3): `getReducer` inserted THEN evicted from the LRU
545
+ * head. A brand-new key is unretained (the hook retains in an effect, i.e.
546
+ * after render) and idle, so when every older key was protected the loop
547
+ * walked to the tail and dropped the key it had just created — `getReducer`
548
+ * handed back a DETACHED reducer whose mutations `getSnapshot` could never
549
+ * see.
550
+ */
551
+ it('NEVER evicts the key getReducer just created (all older keys protected)', () => {
552
+ const store = createChatDialogStore({ maxReducers: 1 })
553
+ finishedTurn(store, 'pinned', 'pinned-text')
554
+ store.retain('pinned', 'main')
555
+
556
+ const reducer = store.getReducer('fresh', 'main')
557
+ reducer.setMessages([
558
+ { id: 'm1', role: 'assistant', content: 'hello', segments: [{ type: 'text', text: 'hello' }] },
559
+ ])
560
+
561
+ // The just-created reducer must still be the store's — not a detached
562
+ // instance whose key resolves to EMPTY_STATE.
563
+ expect(store.getReducer('fresh', 'main')).toBe(reducer)
564
+ expect(store.getSnapshot('fresh', 'main').messages).toHaveLength(1)
565
+ // The retained key survived too.
566
+ expect(store.getSnapshot('pinned', 'main').messages.length).toBeGreaterThan(0)
567
+ })
568
+
569
+ /**
570
+ * REGRESSION (round 10): the round-3 guard protected only the ONE key
571
+ * `getReducer` was inserting. `useChatStreamReducer` calls `getReducer`
572
+ * during RENDER and `retain()` in an EFFECT, so React renders every panel of
573
+ * a commit before ANY retain lands — mounting `maxReducers + 1` panels with
574
+ * distinct dialogIds in one commit let render #(cap+1) evict the key render
575
+ * #1 had just created. Panel #1 kept its reducer object, but `getSnapshot`
576
+ * is a pure map read, so its thread rendered as EMPTY_STATE forever.
577
+ */
578
+ it('protects EVERY key created in one commit, not just the newest (>MAX panels mounted together)', () => {
579
+ const max = 4
580
+ const evicted: string[] = []
581
+ const store = createChatDialogStore({
582
+ maxReducers: max,
583
+ onEvict: (dialogId) => evicted.push(dialogId),
584
+ })
585
+
586
+ // One React commit: N renders, each a `getReducer`, ZERO retains yet.
587
+ const reducers = Array.from({ length: max + 3 }, (_, i) =>
588
+ store.getReducer(`panel-${i}`, 'main'),
589
+ )
590
+ reducers.forEach((r, i) =>
591
+ r.setMessages([
592
+ { id: `m-${i}`, role: 'assistant', content: `thread ${i}`, segments: [{ type: 'text', text: `thread ${i}` }] },
593
+ ]),
594
+ )
595
+
596
+ // Nothing was dropped, and every panel still sees its OWN thread.
597
+ expect(evicted).toEqual([])
598
+ reducers.forEach((r, i) => {
599
+ expect(store.getReducer(`panel-${i}`, 'main')).toBe(r)
600
+ expect(last(store.getSnapshot(`panel-${i}`, 'main').messages)?.content).toBe(`thread ${i}`)
601
+ })
602
+
603
+ // The effects that follow the commit pin what is still mounted.
604
+ reducers.forEach((_, i) => store.retain(`panel-${i}`, 'main'))
605
+ reducers.forEach((_, i) =>
606
+ expect(store.getSnapshot(`panel-${i}`, 'main').messages).toHaveLength(1),
607
+ )
608
+ })
609
+
610
+ /** The group protection is a stand-in for the retain that has not landed
611
+ * yet, not a licence to grow: once the retains land (and the panels later
612
+ * unmount), the LRU catches all the way back down to the cap. */
613
+ it('resumes evicting to the cap once the pending retains land', () => {
614
+ const store = createChatDialogStore({ maxReducers: 2 })
615
+ const ids = ['p-0', 'p-1', 'p-2', 'p-3', 'p-4']
616
+ const reducers = ids.map((id) => store.getReducer(id, 'main'))
617
+ expect(reducers).toHaveLength(5)
618
+
619
+ // The effects run (retain), then the panels unmount (release).
620
+ ids.map((id) => store.retain(id, 'main')).forEach((release) => release())
621
+
622
+ // A later, unrelated commit creates one more key — the now-unprotected
623
+ // ones are evictable again.
624
+ store.getReducer('p-late', 'main')
625
+ expect(store.getSnapshot('p-0', 'main')).toBe(store.getSnapshot('nope', 'main'))
626
+ })
627
+
628
+ /**
629
+ * REGRESSION (round 11): the round-10 group protection expired on a
630
+ * MICROTASK, on the assumption that a render/commit pass never yields to the
631
+ * microtask queue. Under CONCURRENT rendering React time-slices — it suspends
632
+ * the render and resumes in a later macrotask, flushing microtasks in between
633
+ * — so keys created before the yield lost their protection while their
634
+ * `retain()` was still pending, and the resumed render evicted them: the
635
+ * exact blank-thread defect round 10 closed. Protection now ends at the first
636
+ * `retain()`, an event that cannot be re-ordered by the scheduler.
637
+ */
638
+ it('keeps pending-retain keys protected across a mid-render microtask yield', async () => {
639
+ const max = 2
640
+ const evicted: string[] = []
641
+ const store = createChatDialogStore({
642
+ maxReducers: max,
643
+ onEvict: (dialogId) => evicted.push(dialogId),
644
+ })
645
+
646
+ const early = Array.from({ length: max }, (_, i) => store.getReducer(`panel-${i}`, 'main'))
647
+ early.forEach((r, i) =>
648
+ r.setMessages([
649
+ {
650
+ id: `m-${i}`,
651
+ role: 'assistant',
652
+ content: `thread ${i}`,
653
+ segments: [{ type: 'text', text: `thread ${i}` }],
654
+ },
655
+ ]),
656
+ )
657
+
658
+ // React yields mid-render; the microtask queue drains before it resumes.
659
+ await Promise.resolve()
660
+ await Promise.resolve()
661
+
662
+ // …the SAME render resumes and mounts the remaining panels.
663
+ const late = [store.getReducer('panel-2', 'main'), store.getReducer('panel-3', 'main')]
664
+ expect(late).toHaveLength(2)
665
+
666
+ // No retain has landed yet, so nothing may have been dropped.
667
+ expect(evicted).toEqual([])
668
+ early.forEach((r, i) => {
669
+ expect(store.getReducer(`panel-${i}`, 'main')).toBe(r)
670
+ expect(last(store.getSnapshot(`panel-${i}`, 'main').messages)?.content).toBe(`thread ${i}`)
671
+ })
672
+
673
+ // The effects that follow the commit pin what is still mounted.
674
+ early.forEach((_, i) => store.retain(`panel-${i}`, 'main'))
675
+ early.forEach((_, i) =>
676
+ expect(store.getSnapshot(`panel-${i}`, 'main').messages).toHaveLength(1),
677
+ )
678
+ })
679
+
680
+ /** Protection stands in for a retain that has not landed. The moment it
681
+ * lands the key is protected by the RETAIN COUNT instead, so a release must
682
+ * make the key evictable again inside the same task — no timer involved. */
683
+ it('drops the pending-retain protection at the first retain(), not on a timer', () => {
684
+ const store = createChatDialogStore({ maxReducers: 1 })
685
+ store.getReducer('a', 'main').setMessages([
686
+ { id: 'm1', role: 'assistant', content: 'a-text', segments: [{ type: 'text', text: 'a-text' }] },
687
+ ])
688
+
689
+ // The effect lands, then the panel unmounts — all in the same task.
690
+ store.retain('a', 'main')()
691
+
692
+ store.getReducer('b', 'main')
693
+ expect(store.getSnapshot('a', 'main').messages).toEqual([])
694
+ })
695
+
696
+ /**
697
+ * The protected set is itself bounded (`MAX_PENDING_RETAIN_KEYS`), so a
698
+ * caller that reads a reducer once and drops it — never retaining — cannot
699
+ * grow the map without limit. Beyond the bound the oldest pending key falls
700
+ * through to ordinary LRU.
701
+ */
702
+ it('bounds the map when thousands of keys are created without ever retaining', () => {
703
+ const total = MAX_PENDING_RETAIN_KEYS * 4
704
+ const evicted = new Set<string>()
705
+ const store = createChatDialogStore({
706
+ maxReducers: 10,
707
+ onEvict: (dialogId) => evicted.add(dialogId),
708
+ })
709
+
710
+ for (let i = 0; i < total; i += 1) store.getReducer(`d-${i}`, 'main')
711
+
712
+ const survivors = Array.from({ length: total }, (_, i) => `d-${i}`).filter(
713
+ (id) => !evicted.has(id),
714
+ )
715
+ expect(survivors).toHaveLength(MAX_PENDING_RETAIN_KEYS)
716
+ expect(survivors).toEqual(
717
+ Array.from(
718
+ { length: MAX_PENDING_RETAIN_KEYS },
719
+ (_, i) => `d-${total - MAX_PENDING_RETAIN_KEYS + i}`,
720
+ ),
721
+ )
722
+ })
723
+
724
+ /** Event-phase creations are NOT group-protected: nobody holds those
725
+ * instances past the call, so a burst of events across many dialogs must
726
+ * still evict down to the cap inside the same task. */
727
+ it('apply()-created keys still evict synchronously within one task', () => {
728
+ const store = createChatDialogStore({ maxReducers: 1 })
729
+ finishedTurn(store, 'd1', 'd1-text')
730
+ finishedTurn(store, 'd2', 'd2-text')
731
+ finishedTurn(store, 'd3', 'd3-text')
732
+ expect(store.getSnapshot('d1', 'main').messages).toEqual([])
733
+ expect(store.getSnapshot('d3', 'main').messages.length).toBeGreaterThan(0)
734
+ })
735
+
736
+ /** `apply()`/`mutate()` can be a key's FIRST toucher; the reducer they
737
+ * create must carry the host's semantics, since create-options are
738
+ * consulted once and a later `getReducer(..., options)` gets that same
739
+ * instance. */
740
+ it('reducers first created by apply() use the store-level defaultCreateOptions', () => {
741
+ const store = createChatDialogStore({
742
+ defaultCreateOptions: () => ({ transport: 'nats', ownEchoIncludesAdmin: true }),
743
+ })
744
+ // First touch is an EVENT, not a render.
745
+ store.apply('d1', 'main', { type: 'participant', kind: 'system', text: 'joined', seq: 1 })
746
+ store.mutate('d1', 'main', (r) => r.pushOptimisticSend('ok'))
747
+ store.apply('d1', 'main', {
748
+ type: 'participant',
749
+ kind: 'message-request',
750
+ text: 'ok',
751
+ ownerType: 'ADMIN',
752
+ seq: 2,
753
+ })
754
+ // ownEchoIncludesAdmin was honoured → the ADMIN echo was consumed.
755
+ expect(store.getSnapshot('d1', 'main').messages.filter((m) => m.content === 'ok')).toHaveLength(1)
756
+ })
757
+
758
+ it('remove() notifies only when something was actually deleted', () => {
759
+ const store = createChatDialogStore()
760
+ finishedTurn(store, 'd1', 'd1')
761
+
762
+ let notifications = 0
763
+ store.subscribe(() => {
764
+ notifications += 1
765
+ })
766
+
767
+ store.remove('never-existed')
768
+ store.remove('never-existed', 'main')
769
+ expect(notifications).toBe(0)
770
+
771
+ store.remove('d1', 'main')
772
+ expect(notifications).toBe(1)
773
+ })
774
+
775
+ /**
776
+ * A retain is a claim on the KEY, not on the reducer instance — so it
777
+ * deliberately SURVIVES `remove()` (and the recreate that usually follows).
778
+ * Clearing the retain counter in `remove()` would unpin a key its consumer is
779
+ * still holding; the counter is bounded by live retainers and drains when the
780
+ * last release runs.
781
+ */
782
+ it('retain() survives remove() and still pins the recreated key', () => {
783
+ const store = createChatDialogStore({ maxReducers: 1 })
784
+ finishedTurn(store, 'd1', 'd1-text')
785
+ const releaseA = store.retain('d1')
786
+ const releaseB = store.retain('d1')
787
+
788
+ store.remove('d1', 'main')
789
+ expect(store.getSnapshot('d1', 'main').messages).toEqual([])
790
+
791
+ // Recreated key: the retains taken before the removal still protect it.
792
+ finishedTurn(store, 'd1', 'd1-again')
793
+ for (let i = 0; i < 5; i += 1) finishedTurn(store, `noise-${i}`, 'x')
794
+ expect(store.getSnapshot('d1', 'main').messages.length).toBeGreaterThan(0)
795
+
796
+ // Refcount was NOT reset by remove(): one release still leaves B's hold.
797
+ releaseA()
798
+ for (let i = 0; i < 5; i += 1) finishedTurn(store, `more-${i}`, 'x')
799
+ expect(store.getSnapshot('d1', 'main').messages.length).toBeGreaterThan(0)
800
+
801
+ // Last release drains the entry — the key becomes evictable again.
802
+ releaseB()
803
+ for (let i = 0; i < 5; i += 1) finishedTurn(store, `last-${i}`, 'x')
804
+ expect(store.getSnapshot('d1', 'main').messages).toEqual([])
805
+ })
806
+ })