@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,1330 @@
1
+ /**
2
+ * Phase-3 tests — `createChatStreamReducer`, the master chat-stream reader.
3
+ *
4
+ * The golden characterization suites (chunk-processor / SSE-stream /
5
+ * accumulator / history / persisted-v1) pin transport parity through the
6
+ * public hooks; THIS file pins the reducer-specific contracts the plan
7
+ * added on top:
8
+ *
9
+ * - seq-based idempotency (drop `seq` ≤ last applied, per instance);
10
+ * - REFERENTIAL STABILITY: untouched messages keep Object.is identity
11
+ * across applies AND across replayed duplicate events (value-level
12
+ * no-op merges return prior references);
13
+ * - `resolvePendingApprovalForExecution` (implicit approve-on-execution)
14
+ * is a NATS-only semantic — the SSE kernel never triggers it.
15
+ */
16
+
17
+ import { describe, it, expect, vi } from 'vitest'
18
+ import {
19
+ createChatStreamReducer,
20
+ OWN_ECHO_AUTHOR_TTL_MS,
21
+ OWN_ECHO_TTL_MS,
22
+ } from '../chat-stream-reducer'
23
+ import type { ChatStreamEvent } from '../../../../chat-protocol/events'
24
+
25
+ const executing = (execId = 'exec-1', seq?: number): ChatStreamEvent => ({
26
+ type: 'tool-execution',
27
+ data: {
28
+ type: 'EXECUTING_TOOL',
29
+ integratedToolType: 'SHELL',
30
+ toolFunction: 'run_command',
31
+ toolTitle: 'Run command',
32
+ parameters: { cmd: 'ls' },
33
+ toolExecutionRequestId: execId,
34
+ },
35
+ ...(seq != null ? { seq } : {}),
36
+ })
37
+
38
+ const executed = (execId = 'exec-1', seq?: number): ChatStreamEvent => ({
39
+ type: 'tool-execution',
40
+ data: {
41
+ type: 'EXECUTED_TOOL',
42
+ integratedToolType: 'SHELL',
43
+ toolFunction: 'run_command',
44
+ result: 'ok',
45
+ success: true,
46
+ toolExecutionRequestId: execId,
47
+ },
48
+ ...(seq != null ? { seq } : {}),
49
+ })
50
+
51
+ describe('createChatStreamReducer — seq idempotency', () => {
52
+ it('drops events whose seq is ≤ the last applied seq', () => {
53
+ const r = createChatStreamReducer({ transport: 'nats' })
54
+ r.apply({ type: 'turn-start', seq: 1 })
55
+ r.apply({ type: 'text-delta', text: 'a', seq: 2 })
56
+ r.apply({ type: 'text-delta', text: 'b', seq: 3 })
57
+ // Redelivery of seq 3 and an out-of-order seq 2 must both be dropped.
58
+ r.apply({ type: 'text-delta', text: 'b', seq: 3 })
59
+ r.apply({ type: 'text-delta', text: 'a', seq: 2 })
60
+ const last = r.state.messages[r.state.messages.length - 1]
61
+ expect(last.segments).toEqual([{ type: 'text', text: 'ab' }])
62
+ })
63
+
64
+ it('seq-less events are never gated', () => {
65
+ const r = createChatStreamReducer({ transport: 'nats' })
66
+ r.apply({ type: 'turn-start', seq: 5 })
67
+ r.apply({ type: 'text-delta', text: 'x' })
68
+ r.apply({ type: 'text-delta', text: 'x' })
69
+ const last = r.state.messages[r.state.messages.length - 1]
70
+ expect(last.segments).toEqual([{ type: 'text', text: 'xx' }])
71
+ })
72
+ })
73
+
74
+ describe('createChatStreamReducer — referential stability', () => {
75
+ it('applies reclone ONLY the touched message; earlier messages keep identity', () => {
76
+ const r = createChatStreamReducer({ transport: 'nats' })
77
+ r.apply({ type: 'participant', kind: 'message-request', text: 'hello', seq: 1 })
78
+ r.apply({ type: 'turn-start', seq: 2 })
79
+ r.apply({ type: 'text-delta', text: 'first reply', seq: 3 })
80
+ r.apply({ type: 'turn-end', seq: 4 })
81
+ const [userBefore, assistantBefore] = r.state.messages
82
+
83
+ // A post-END continuation appends into the trailing assistant only.
84
+ r.apply({ type: 'text-delta', text: ' …more', seq: 5 })
85
+ const [userAfter, assistantAfter] = r.state.messages
86
+ expect(userAfter).toBe(userBefore)
87
+ expect(assistantAfter).not.toBe(assistantBefore)
88
+ expect(assistantAfter.segments).toEqual([{ type: 'text', text: 'first reply …more' }])
89
+ })
90
+
91
+ it('a replayed duplicate EXECUTED event (same seq) is dropped — full state identity holds', () => {
92
+ const r = createChatStreamReducer({ transport: 'nats' })
93
+ r.apply({ type: 'turn-start', seq: 1 })
94
+ r.apply({ type: 'text-delta', text: 'running', seq: 2 })
95
+ r.apply({ type: 'turn-end', seq: 3 })
96
+ r.apply(executing('exec-1', 4))
97
+ r.apply(executed('exec-1', 5))
98
+ const messagesBefore = r.state.messages
99
+
100
+ r.apply(executed('exec-1', 5)) // JetStream redelivery
101
+ expect(r.state.messages).toBe(messagesBefore)
102
+ })
103
+
104
+ it('a replayed seq-less duplicate EXECUTED merge is a value-level no-op — prior references returned', () => {
105
+ const r = createChatStreamReducer({ transport: 'nats' })
106
+ r.apply({ type: 'turn-start' })
107
+ r.apply({ type: 'text-delta', text: 'running' })
108
+ r.apply({ type: 'turn-end' })
109
+ r.apply(executing())
110
+ r.apply(executed())
111
+ const messagesBefore = r.state.messages
112
+ const trailingBefore = messagesBefore[messagesBefore.length - 1]
113
+
114
+ // Same values, new event object, no seq (plain-NATS replay).
115
+ r.apply(executed())
116
+ const messagesAfter = r.state.messages
117
+ expect(messagesAfter).toBe(messagesBefore)
118
+ expect(messagesAfter[messagesAfter.length - 1]).toBe(trailingBefore)
119
+ })
120
+
121
+ it('a redelivered EXECUTING after EXECUTED never downgrades (prior references returned)', () => {
122
+ const r = createChatStreamReducer({ transport: 'nats' })
123
+ r.apply(executing())
124
+ r.apply(executed())
125
+ const before = r.state.messages
126
+ r.apply(executing())
127
+ expect(r.state.messages).toBe(before)
128
+ })
129
+ })
130
+
131
+ describe('createChatStreamReducer — implicit approve-on-execution transport gate', () => {
132
+ const approvalRequest: ChatStreamEvent = {
133
+ type: 'approval-request',
134
+ requestId: 'req-1',
135
+ approvalType: 'CLIENT',
136
+ command: 'systemctl restart nats',
137
+ explanation: 'Restart the broker',
138
+ }
139
+
140
+ it("transport 'nats': an in-stream tool execution implicitly approves the pending gate", () => {
141
+ const r = createChatStreamReducer({ transport: 'nats' })
142
+ r.apply({ type: 'turn-start' })
143
+ r.apply(approvalRequest)
144
+ // Observer stream: EXECUTING arrives with NO APPROVAL_RESULT chunk.
145
+ r.apply(executing())
146
+ const trailing = r.state.messages[r.state.messages.length - 1]
147
+ const card = trailing.segments?.find((s) => s.type === 'approval_request')
148
+ expect(card).toBeDefined()
149
+ expect((card as { status?: string }).status).toBe('approved')
150
+ })
151
+
152
+ it("transport 'sse': tool-execution events are ignored — a pending card stays pending", () => {
153
+ const r = createChatStreamReducer({ transport: 'sse' })
154
+ r.beginSseSend({ text: 'open a ticket', assistantName: 'Mingo AI' })
155
+ r.apply({
156
+ type: 'approval-request',
157
+ requestId: 'prop-1',
158
+ approvalType: 'create_ticket',
159
+ command: 'Create ticket',
160
+ fields: [{ label: 'Subject', value: 'Printer down' }],
161
+ })
162
+ const before = r.state.messages
163
+ r.apply(executing())
164
+ // Ignored entirely: same references, card untouched.
165
+ expect(r.state.messages).toBe(before)
166
+ const trailing = r.state.messages[r.state.messages.length - 1]
167
+ const card = trailing.segments?.find((s) => s.type === 'approval_request')
168
+ expect((card as { status?: string }).status).toBe('pending')
169
+ })
170
+ })
171
+
172
+ describe('createChatStreamReducer — SSE kernel', () => {
173
+ it('accumulates text cumulatively, keeps one thinking segment at the front, escapes tags', () => {
174
+ const r = createChatStreamReducer({ transport: 'sse' })
175
+ r.beginSseSend({ text: 'q', assistantName: 'Mingo AI' })
176
+ r.apply({ type: 'thinking-delta', text: 'checking <docs>… ' })
177
+ r.apply({ type: 'turn-start' })
178
+ r.apply({ type: 'text-delta', text: 'Answer ' })
179
+ r.apply({ type: 'thinking-delta', text: 'found it.' })
180
+ r.apply({ type: 'text-delta', text: 'body.' })
181
+ const trailing = r.state.messages[r.state.messages.length - 1]
182
+ expect(trailing.segments).toEqual([
183
+ { type: 'thinking', text: 'checking &lt;docs>… found it.' },
184
+ { type: 'text', text: 'Answer body.' },
185
+ ])
186
+ expect(r.state.streamingPhase).toBe('streaming')
187
+ })
188
+
189
+ it('decision_resolved flips the SOURCE card, writes the receipt, and stamps chatRefs', () => {
190
+ const r = createChatStreamReducer({ transport: 'sse' })
191
+ // Turn 1: approval card.
192
+ r.beginSseSend({ text: 'open a ticket', assistantName: 'Mingo AI' })
193
+ r.apply({
194
+ type: 'approval-request',
195
+ requestId: 'prop-1',
196
+ approvalType: 'create_ticket',
197
+ command: 'Create ticket',
198
+ fields: [],
199
+ })
200
+ r.endSseTurn()
201
+ // Turn 2: hidden approval-action send → decision_resolved frame.
202
+ r.beginSseSend({ text: '', hidden: true, assistantName: 'Mingo AI' })
203
+ r.apply({
204
+ type: 'approval-resolved',
205
+ requestId: 'prop-1',
206
+ status: 'approved',
207
+ ok: true,
208
+ receiptText: '✅ Approved — ticket created: [card://ticket:77]',
209
+ cardRef: { type: 'ticket', id: '77', title: 'Broken printer' },
210
+ cardType: 'ticket',
211
+ })
212
+ r.endSseTurn()
213
+
214
+ // Three rows, not four: the hidden approval-action send is out-of-band
215
+ // metadata and mints NO user row (it would render as a bare author label).
216
+ expect(r.state.messages).toHaveLength(3)
217
+ const [, sourceMsg, receiptMsg] = r.state.messages
218
+ const card = sourceMsg.segments?.find((s) => s.type === 'approval_request')
219
+ expect((card as { status?: string }).status).toBe('approved')
220
+ expect(receiptMsg.segments).toEqual([
221
+ { type: 'text', text: '✅ Approved — ticket created: [card://ticket:77]\n\n' },
222
+ ])
223
+ expect(receiptMsg.chatRefs?.['ticket:77']?.id).toBe('77')
224
+ // Per-send refs map got the ref too (sendIdx 1 — the hidden send).
225
+ expect(r.state.turnMeta.refs.get(1)?.['ticket:77']?.id).toBe('77')
226
+ expect(r.state.streamingPhase).toBe('idle')
227
+ })
228
+
229
+ it('endSseTurn drops an empty trailing placeholder (reject path)', () => {
230
+ const r = createChatStreamReducer({ transport: 'sse' })
231
+ r.beginSseSend({ text: 'q', assistantName: 'Mingo AI' })
232
+ r.apply({ type: 'text-delta', text: 'answer' })
233
+ r.endSseTurn()
234
+ // Reject: an empty-text (approval-action) send adds ONLY the assistant
235
+ // placeholder…
236
+ r.beginSseSend({ text: '', hidden: true, assistantName: 'Mingo AI' })
237
+ expect(r.state.messages).toHaveLength(3)
238
+ expect(r.state.messages[2].role).toBe('assistant')
239
+ // …and the reject frame streams nothing, so the turn leaves the thread
240
+ // exactly as it was — no bare user label, no blank bubble.
241
+ r.endSseTurn()
242
+ expect(r.state.messages).toHaveLength(2)
243
+ expect(r.state.messages.map((m) => m.role)).toEqual(['user', 'assistant'])
244
+ })
245
+ })
246
+
247
+ describe('createChatStreamReducer — participant dedup', () => {
248
+ it('optimistic echo consumes exactly one MESSAGE_REQUEST twin', () => {
249
+ const r = createChatStreamReducer({ transport: 'nats' })
250
+ r.pushOptimisticSend('yes')
251
+ expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(1)
252
+ // Echo of our own send → consumed, no duplicate row.
253
+ r.apply({ type: 'participant', kind: 'message-request', text: 'yes' })
254
+ expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(1)
255
+ // A genuinely repeated send from another session still renders.
256
+ r.apply({ type: 'participant', kind: 'message-request', text: 'yes', seq: 10 })
257
+ expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(2)
258
+ })
259
+
260
+ it('ADMIN echo is NOT consumed by default (it is a technician reply)', () => {
261
+ const r = createChatStreamReducer({ transport: 'nats' })
262
+ r.pushOptimisticSend('same words')
263
+ // An ADMIN-authored row that happens to match our text is somebody
264
+ // else's message on hosts where the operator is not the admin.
265
+ r.apply({ type: 'participant', kind: 'message-request', text: 'same words', ownerType: 'ADMIN' })
266
+ expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(2)
267
+ })
268
+
269
+ it('ADMIN echo IS consumed when the host declares ownEchoIncludesAdmin', () => {
270
+ const r = createChatStreamReducer({ transport: 'nats', ownEchoIncludesAdmin: true })
271
+ r.pushOptimisticSend('same words')
272
+ r.apply({ type: 'participant', kind: 'message-request', text: 'same words', ownerType: 'ADMIN' })
273
+ expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(1)
274
+ // Still one-shot: a second ADMIN row with the same text renders.
275
+ r.apply({ type: 'participant', kind: 'message-request', text: 'same words', ownerType: 'ADMIN', seq: 11 })
276
+ expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(2)
277
+ })
278
+
279
+ it('participant seen-seq set dedups redelivered rows', () => {
280
+ const r = createChatStreamReducer({ transport: 'nats' })
281
+ r.apply({ type: 'participant', kind: 'direct-message', text: 'human here', seq: 7 })
282
+ const before = r.state.messages
283
+ r.apply({ type: 'participant', kind: 'direct-message', text: 'human here', seq: 7 })
284
+ expect(r.state.messages).toBe(before)
285
+ expect(r.state.messages).toHaveLength(1)
286
+ })
287
+
288
+ /**
289
+ * REGRESSION (round 3): on a shared ADMIN side, raw-text echo matching
290
+ * could delete a SECOND technician's message. Tech A sends "ok", A's echo
291
+ * never lands; Tech B sends "ok" → ADMIN-authored, matched the stale entry,
292
+ * consumed, never rendered for A.
293
+ */
294
+ it('selfUserId: another author\'s identical text never consumes our echo', () => {
295
+ const r = createChatStreamReducer({
296
+ transport: 'nats',
297
+ ownEchoIncludesAdmin: true,
298
+ selfUserId: 'tech-a',
299
+ })
300
+ r.pushOptimisticSend('ok')
301
+ // Tech B's message — same text, same ADMIN owner type, different author.
302
+ r.apply({
303
+ type: 'participant',
304
+ kind: 'message-request',
305
+ text: 'ok',
306
+ ownerType: 'ADMIN',
307
+ userId: 'tech-b',
308
+ seq: 1,
309
+ })
310
+ expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(2)
311
+ // Our OWN echo still dedups.
312
+ r.apply({
313
+ type: 'participant',
314
+ kind: 'message-request',
315
+ text: 'ok',
316
+ ownerType: 'ADMIN',
317
+ userId: 'tech-a',
318
+ seq: 2,
319
+ })
320
+ expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(2)
321
+ })
322
+
323
+ /**
324
+ * REGRESSION (round 4): the author guard must fail OPEN. A transport whose
325
+ * decoder does not surface the author id (the ticket wire model declares it
326
+ * at `owner.userId`; the app's id may live in another id space entirely)
327
+ * would otherwise never dedup — every send rendered TWICE, strictly worse
328
+ * than the message-theft bug the guard fixes.
329
+ */
330
+ it('selfUserId: an id-LESS row still dedups via the text+TTL fallback (fails open)', () => {
331
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
332
+ try {
333
+ const r = createChatStreamReducer({
334
+ transport: 'nats',
335
+ ownEchoIncludesAdmin: true,
336
+ selfUserId: 'tech-a',
337
+ })
338
+ r.pushOptimisticSend('on it')
339
+ r.apply({ type: 'participant', kind: 'message-request', text: 'on it', ownerType: 'ADMIN', seq: 1 })
340
+ expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(1)
341
+ // The misconfiguration is surfaced — once, not per row.
342
+ expect(warn).toHaveBeenCalledTimes(1)
343
+ expect(String(warn.mock.calls[0]?.[0])).toContain('selfUserId')
344
+ r.pushOptimisticSend('again')
345
+ r.apply({ type: 'participant', kind: 'message-request', text: 'again', ownerType: 'ADMIN', seq: 2 })
346
+ expect(warn).toHaveBeenCalledTimes(1)
347
+ } finally {
348
+ warn.mockRestore()
349
+ }
350
+ })
351
+
352
+ it('selfUserId accepts a GETTER resolved at event time (late auth / user switch)', () => {
353
+ let self: string | undefined
354
+ const r = createChatStreamReducer({
355
+ transport: 'nats',
356
+ ownEchoIncludesAdmin: true,
357
+ selfUserId: () => self,
358
+ })
359
+ // Auth rehydrates AFTER the reducer was created.
360
+ self = 'tech-a'
361
+ r.pushOptimisticSend('ok')
362
+ r.apply({
363
+ type: 'participant',
364
+ kind: 'message-request',
365
+ text: 'ok',
366
+ ownerType: 'ADMIN',
367
+ userId: 'tech-b',
368
+ seq: 1,
369
+ })
370
+ expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(2)
371
+ // Log out, log in as somebody else — the guard tracks it without a reload.
372
+ self = 'tech-b'
373
+ r.pushOptimisticSend('ok')
374
+ r.apply({
375
+ type: 'participant',
376
+ kind: 'message-request',
377
+ text: 'ok',
378
+ ownerType: 'ADMIN',
379
+ userId: 'tech-b',
380
+ seq: 2,
381
+ })
382
+ expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(3)
383
+ })
384
+
385
+ /**
386
+ * REGRESSION (round 4): a JetStream catch-up replay after a >30s network gap
387
+ * delivers our own echo long after the send. Its rows carry `seq`, so the
388
+ * seq-less content-dedup fallback cannot rescue it — expiring an
389
+ * AUTHOR-MATCHED entry at the short TTL means a guaranteed duplicate row.
390
+ */
391
+ it('an AUTHOR-MATCHED echo is consumed well past the unattributed TTL', () => {
392
+ const realNow = Date.now
393
+ try {
394
+ let now = 1_000_000
395
+ Date.now = () => now
396
+ const r = createChatStreamReducer({
397
+ transport: 'nats',
398
+ ownEchoIncludesAdmin: true,
399
+ selfUserId: 'tech-a',
400
+ })
401
+ r.pushOptimisticSend('on it')
402
+ now += OWN_ECHO_TTL_MS + 1_000
403
+ r.apply({
404
+ type: 'participant',
405
+ kind: 'message-request',
406
+ text: 'on it',
407
+ ownerType: 'ADMIN',
408
+ userId: 'tech-a',
409
+ seq: 9,
410
+ })
411
+ expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(1)
412
+ // Still one-shot — a second author-matched row with the same text renders.
413
+ r.apply({
414
+ type: 'participant',
415
+ kind: 'message-request',
416
+ text: 'on it',
417
+ ownerType: 'ADMIN',
418
+ userId: 'tech-a',
419
+ seq: 10,
420
+ })
421
+ expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(2)
422
+ } finally {
423
+ Date.now = realNow
424
+ }
425
+ })
426
+
427
+ /**
428
+ * REGRESSION (round 5): the author check rules out a COLLEAGUE's message, not
429
+ * the SAME user on a second tab. An entry whose echo never landed used to
430
+ * stay armed forever on the author-matched path, so the same user's identical
431
+ * send from another tab an hour later was silently DROPPED — message loss,
432
+ * strictly worse than the duplicate row the bypass was avoiding.
433
+ */
434
+ it('an AUTHOR-MATCHED entry expires, so an identical send an HOUR later renders', () => {
435
+ const realNow = Date.now
436
+ try {
437
+ let now = 1_000_000
438
+ Date.now = () => now
439
+ const r = createChatStreamReducer({
440
+ transport: 'nats',
441
+ ownEchoIncludesAdmin: true,
442
+ selfUserId: 'tech-a',
443
+ })
444
+ // Tab A sends "ok" — the echo never lands (dropped frame / reconnect gap).
445
+ r.pushOptimisticSend('ok')
446
+ expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(1)
447
+ // An HOUR later the same user sends "ok" from tab B. The row is
448
+ // author-matched, but the stale entry must NOT eat it.
449
+ now += 60 * 60_000
450
+ r.apply({
451
+ type: 'participant',
452
+ kind: 'message-request',
453
+ text: 'ok',
454
+ ownerType: 'ADMIN',
455
+ userId: 'tech-a',
456
+ seq: 7,
457
+ })
458
+ expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(2)
459
+ } finally {
460
+ Date.now = realNow
461
+ }
462
+ })
463
+
464
+ /**
465
+ * REGRESSION (round 6): `OWN_ECHO_AUTHOR_TTL_MS` alone preserved TEN MINUTES
466
+ * of cross-tab message loss. A `turn-end` proves the server finished the
467
+ * send, so an echo that has not landed by then never will — the entry must
468
+ * be disarmed at that boundary, cutting the window to one turn.
469
+ */
470
+ it('a turn-end disarms a stale echo, so a second-tab send minutes later RENDERS', () => {
471
+ const realNow = Date.now
472
+ try {
473
+ let now = 1_000_000
474
+ Date.now = () => now
475
+ const r = createChatStreamReducer({
476
+ transport: 'nats',
477
+ ownEchoIncludesAdmin: true,
478
+ selfUserId: 'tech-a',
479
+ })
480
+ // Tab A sends "ok" — the echo never lands.
481
+ r.pushOptimisticSend('ok')
482
+ expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(1)
483
+ // The turn runs and ends: the server is demonstrably done with that send.
484
+ r.apply({ type: 'turn-start', seq: 2 })
485
+ r.apply({ type: 'turn-end', seq: 3 })
486
+ // Five minutes later — well INSIDE the 10-minute backstop — the same
487
+ // user sends "ok" from tab B. It must render, not be swallowed.
488
+ now += 5 * 60_000
489
+ r.apply({
490
+ type: 'participant',
491
+ kind: 'message-request',
492
+ text: 'ok',
493
+ ownerType: 'ADMIN',
494
+ userId: 'tech-a',
495
+ seq: 7,
496
+ })
497
+ expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(2)
498
+ } finally {
499
+ Date.now = realNow
500
+ }
501
+ })
502
+
503
+ /** The echo entry is still consumed on the NORMAL path: the server's echo
504
+ * arrives before the turn ends, so `turn-end` must not break dedup. */
505
+ it('an echo landing BEFORE turn-end is still deduped', () => {
506
+ const r = createChatStreamReducer({
507
+ transport: 'nats',
508
+ ownEchoIncludesAdmin: true,
509
+ selfUserId: 'tech-a',
510
+ })
511
+ r.pushOptimisticSend('ok')
512
+ r.apply({
513
+ type: 'participant',
514
+ kind: 'message-request',
515
+ text: 'ok',
516
+ ownerType: 'ADMIN',
517
+ userId: 'tech-a',
518
+ seq: 2,
519
+ })
520
+ r.apply({ type: 'turn-start', seq: 3 })
521
+ r.apply({ type: 'turn-end', seq: 4 })
522
+ expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(1)
523
+ })
524
+
525
+ /**
526
+ * REGRESSION (round 7): the round-6 purge was UNCONDITIONAL, so a `turn-end`
527
+ * arriving between a send and its echo disarmed an entry that was never
528
+ * stale — the echo then rendered a SECOND user bubble. Reachable via a
529
+ * reconnect catch-up replay (a pre-disconnect MESSAGE_END delivered after
530
+ * the user has already sent) and via the approval-interrupt path (sending
531
+ * while an approval pends cancels the in-flight turn, so the interrupted
532
+ * turn's MESSAGE_END lands after the new send armed its entry). Only
533
+ * entries armed BEFORE the turn being ended may be purged.
534
+ */
535
+ it('a turn-end arriving between a send and its echo does NOT duplicate the bubble', () => {
536
+ const r = createChatStreamReducer({
537
+ transport: 'nats',
538
+ ownEchoIncludesAdmin: true,
539
+ selfUserId: 'me',
540
+ })
541
+ r.pushOptimisticSend('hello world')
542
+ // A stale MESSAGE_END for a turn that started before this send (catch-up
543
+ // replay / approval interrupt) — no turn-start was ever observed here.
544
+ r.apply({ type: 'turn-end', seq: 10 })
545
+ r.apply({
546
+ type: 'participant',
547
+ kind: 'message-request',
548
+ text: 'hello world',
549
+ ownerType: 'ADMIN',
550
+ userId: 'me',
551
+ seq: 11,
552
+ })
553
+ expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(1)
554
+ })
555
+
556
+ /**
557
+ * DOCUMENTED LIMIT, not an aspiration: when a reconnect catch-up replay
558
+ * delivers BOTH a pre-disconnect `turn-start` and its `turn-end` after the
559
+ * user has sent, the replayed `turn-start` restamps the arrival-time
560
+ * boundary and the `turn-end` purges the still-live entry — the echo then
561
+ * renders a SECOND bubble. The normalized event union carries no event-own
562
+ * timestamp (only `seq`), so at this layer the interleaving is identical to
563
+ * "send queued mid-turn, own turn opened and closed before the echo", which
564
+ * the module deliberately resolves toward duplicating rather than risk
565
+ * swallowing a real message. Pins the behavior so a future transport-level
566
+ * fix (event-own time) has to update this test consciously.
567
+ */
568
+ it('a replayed turn boundary after a send duplicates the bubble (known limit)', () => {
569
+ const realNow = Date.now
570
+ try {
571
+ let now = 1_000_000
572
+ Date.now = () => now
573
+ const r = createChatStreamReducer({
574
+ transport: 'nats',
575
+ ownEchoIncludesAdmin: true,
576
+ selfUserId: 'me',
577
+ })
578
+ r.pushOptimisticSend('hello')
579
+ now += 5 // the replay lands a few ms after the send
580
+ r.apply({ type: 'turn-start', seq: 1 })
581
+ r.apply({ type: 'turn-end', seq: 2 })
582
+ r.apply({
583
+ type: 'participant',
584
+ kind: 'message-request',
585
+ text: 'hello',
586
+ ownerType: 'ADMIN',
587
+ userId: 'me',
588
+ seq: 3,
589
+ })
590
+ // Two bubbles: the optimistic one and the un-deduped echo.
591
+ expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(2)
592
+ } finally {
593
+ Date.now = realNow
594
+ }
595
+ })
596
+
597
+ /** Same shape, but the interrupted turn's `turn-start` WAS observed and the
598
+ * send happened after it — the entry belongs to the current turn and must
599
+ * survive its `turn-end`. */
600
+ it('an echo armed DURING the turn survives that turn-end', () => {
601
+ const realNow = Date.now
602
+ try {
603
+ let now = 1_000_000
604
+ Date.now = () => now
605
+ const r = createChatStreamReducer({
606
+ transport: 'nats',
607
+ ownEchoIncludesAdmin: true,
608
+ selfUserId: 'tech-a',
609
+ })
610
+ r.apply({ type: 'turn-start', seq: 2 })
611
+ now += 1 // the user types and sends while the turn streams
612
+ r.pushOptimisticSend('hello world')
613
+ r.apply({ type: 'turn-end', seq: 3 })
614
+ r.apply({
615
+ type: 'participant',
616
+ kind: 'message-request',
617
+ text: 'hello world',
618
+ ownerType: 'ADMIN',
619
+ userId: 'tech-a',
620
+ seq: 4,
621
+ })
622
+ expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(1)
623
+ } finally {
624
+ Date.now = realNow
625
+ }
626
+ })
627
+
628
+ it('an author-matched entry is still consumed just INSIDE the author TTL', () => {
629
+ const realNow = Date.now
630
+ try {
631
+ let now = 1_000_000
632
+ Date.now = () => now
633
+ const r = createChatStreamReducer({
634
+ transport: 'nats',
635
+ ownEchoIncludesAdmin: true,
636
+ selfUserId: 'tech-a',
637
+ })
638
+ r.pushOptimisticSend('ok')
639
+ now += OWN_ECHO_AUTHOR_TTL_MS - 1
640
+ r.apply({
641
+ type: 'participant',
642
+ kind: 'message-request',
643
+ text: 'ok',
644
+ ownerType: 'ADMIN',
645
+ userId: 'tech-a',
646
+ seq: 7,
647
+ })
648
+ expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(1)
649
+ } finally {
650
+ Date.now = realNow
651
+ }
652
+ })
653
+
654
+ /** A short-TTL (unattributed) lookup must not EVICT an entry a later
655
+ * author-matched row is still entitled to consume. */
656
+ it('an unattributed miss does not evict an entry the author path still owns', () => {
657
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
658
+ const realNow = Date.now
659
+ try {
660
+ let now = 1_000_000
661
+ Date.now = () => now
662
+ const r = createChatStreamReducer({
663
+ transport: 'nats',
664
+ ownEchoIncludesAdmin: true,
665
+ selfUserId: 'tech-a',
666
+ })
667
+ r.pushOptimisticSend('ok')
668
+ now += OWN_ECHO_TTL_MS + 1
669
+ // Unattributed row: outside the short TTL, so it renders (2 user rows)…
670
+ r.apply({ type: 'participant', kind: 'message-request', text: 'ok', ownerType: 'ADMIN', seq: 5 })
671
+ expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(2)
672
+ // …and our own late echo, declared ours, is still consumed.
673
+ r.apply({
674
+ type: 'participant',
675
+ kind: 'message-request',
676
+ text: 'ok',
677
+ ownerType: 'ADMIN',
678
+ userId: 'tech-a',
679
+ seq: 6,
680
+ })
681
+ expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(2)
682
+ } finally {
683
+ Date.now = realNow
684
+ warn.mockRestore()
685
+ }
686
+ })
687
+
688
+ it('an aged entry still expires on the UNATTRIBUTED path (no author to trust)', () => {
689
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
690
+ const realNow = Date.now
691
+ try {
692
+ let now = 1_000_000
693
+ Date.now = () => now
694
+ const r = createChatStreamReducer({
695
+ transport: 'nats',
696
+ ownEchoIncludesAdmin: true,
697
+ selfUserId: 'tech-a',
698
+ })
699
+ r.pushOptimisticSend('done')
700
+ now += OWN_ECHO_TTL_MS + 1
701
+ r.apply({ type: 'participant', kind: 'message-request', text: 'done', ownerType: 'ADMIN', seq: 5 })
702
+ expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(2)
703
+ } finally {
704
+ Date.now = realNow
705
+ warn.mockRestore()
706
+ }
707
+ })
708
+
709
+ it('an echo entry expires, so an un-echoed send cannot arm a trap', () => {
710
+ const realNow = Date.now
711
+ try {
712
+ let now = 1_000_000
713
+ Date.now = () => now
714
+ const r = createChatStreamReducer({ transport: 'nats', ownEchoIncludesAdmin: true })
715
+ r.pushOptimisticSend('done')
716
+ // Our echo never lands. Much later, somebody else says the same thing.
717
+ now += OWN_ECHO_TTL_MS + 1
718
+ r.apply({ type: 'participant', kind: 'message-request', text: 'done', ownerType: 'ADMIN', seq: 5 })
719
+ expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(2)
720
+ } finally {
721
+ Date.now = realNow
722
+ }
723
+ })
724
+
725
+ it('an echo INSIDE the TTL is still consumed', () => {
726
+ const realNow = Date.now
727
+ try {
728
+ let now = 1_000_000
729
+ Date.now = () => now
730
+ const r = createChatStreamReducer({ transport: 'nats', ownEchoIncludesAdmin: true })
731
+ r.pushOptimisticSend('done')
732
+ now += OWN_ECHO_TTL_MS - 1
733
+ r.apply({ type: 'participant', kind: 'message-request', text: 'done', ownerType: 'ADMIN', seq: 5 })
734
+ expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(1)
735
+ } finally {
736
+ Date.now = realNow
737
+ }
738
+ })
739
+ })
740
+
741
+ /**
742
+ * Parked-state round trip across an LRU eviction. The store captures these
743
+ * (`EvictedReducerState`); these tests pin the reducer half of the contract.
744
+ */
745
+ describe('createChatStreamReducer — parked echo restore', () => {
746
+ const echoOptions = {
747
+ transport: 'nats',
748
+ ownEchoIncludesAdmin: true,
749
+ selfUserId: 'me',
750
+ } as const
751
+
752
+ it('restored pendingEchoes still consume the echo (no duplicate bubble)', () => {
753
+ const dropped = createChatStreamReducer(echoOptions)
754
+ dropped.pushOptimisticSend('hello world')
755
+ const parked = dropped.getPendingEchoes()
756
+ expect(parked).toHaveLength(1)
757
+
758
+ // The key was evicted between the send and its echo; the replacement
759
+ // reducer restores the armed entry alongside the refetched thread.
760
+ const recreated = createChatStreamReducer(echoOptions)
761
+ recreated.initializeWithState(
762
+ [{ id: 'u1', role: 'user', content: 'hello world' }],
763
+ { pendingEchoes: parked },
764
+ )
765
+ recreated.apply({
766
+ type: 'participant',
767
+ kind: 'message-request',
768
+ text: 'hello world',
769
+ ownerType: 'ADMIN',
770
+ userId: 'me',
771
+ seq: 11,
772
+ })
773
+ expect(recreated.state.messages.filter((m) => m.role === 'user')).toHaveLength(1)
774
+ })
775
+
776
+ it('getPendingEchoes returns a SNAPSHOT, not the live internal array', () => {
777
+ const r = createChatStreamReducer(echoOptions)
778
+ r.pushOptimisticSend('hello world')
779
+ const parked = r.getPendingEchoes()
780
+ expect(parked).toHaveLength(1)
781
+
782
+ // Mutating the reducer after the snapshot must not change what the holder
783
+ // observes — the `readonly PendingEcho[]` return type has to be honest.
784
+ r.pushOptimisticSend('second')
785
+ expect(parked).toHaveLength(1)
786
+ expect(r.getPendingEchoes()).toHaveLength(2)
787
+
788
+ // ...and the consume path (which splices) must not empty it either.
789
+ r.apply({
790
+ type: 'participant',
791
+ kind: 'message-request',
792
+ text: 'hello world',
793
+ ownerType: 'ADMIN',
794
+ userId: 'me',
795
+ seq: 11,
796
+ })
797
+ expect(parked).toHaveLength(1)
798
+ expect(parked[0]?.text).toBe('hello world')
799
+ })
800
+
801
+ it('drops parked entries already past the author TTL', () => {
802
+ const r = createChatStreamReducer(echoOptions)
803
+ r.initializeWithState(null, {
804
+ pendingEchoes: [{ text: 'ok', at: Date.now() - OWN_ECHO_AUTHOR_TTL_MS - 1 }],
805
+ })
806
+ expect(r.getPendingEchoes()).toHaveLength(0)
807
+ r.apply({
808
+ type: 'participant',
809
+ kind: 'message-request',
810
+ text: 'ok',
811
+ ownerType: 'ADMIN',
812
+ userId: 'me',
813
+ seq: 3,
814
+ })
815
+ expect(r.state.messages.filter((m) => m.role === 'user')).toHaveLength(1)
816
+ })
817
+ })
818
+
819
+ describe('createChatStreamReducer — initializeWithState resumed gate', () => {
820
+ /** An eviction restore of a key that NEVER streamed (empty parked thread)
821
+ * must not claim "resumed": a later cold delta with no `turn-start` would
822
+ * take the append branch and never spawn the first assistant bubble. */
823
+ it('an EMPTY restore leaves the cold-start cumulative path armed', () => {
824
+ const effects: Array<{ name: string; args: unknown[] }> = []
825
+ const r = createChatStreamReducer({
826
+ transport: 'nats',
827
+ onEffect: (e) => effects.push(e),
828
+ })
829
+ r.initializeWithState([], { lastAppliedSeq: 1 })
830
+ r.apply({ type: 'text-delta', text: 'hello', seq: 2 })
831
+ const updates = effects.filter((e) => e.name === 'onSegmentsUpdate')
832
+ const update = updates[updates.length - 1]
833
+ expect((update?.args[1] as { append?: boolean } | undefined)?.append).toBeUndefined()
834
+ expect(r.state.messages.filter((m) => m.role === 'assistant')).toHaveLength(1)
835
+ })
836
+
837
+ it('a NON-EMPTY restore is treated as resumed (post-stream deltas append)', () => {
838
+ const effects: Array<{ name: string; args: unknown[] }> = []
839
+ const r = createChatStreamReducer({
840
+ transport: 'nats',
841
+ onEffect: (e) => effects.push(e),
842
+ })
843
+ r.initializeWithState([{ id: 'a1', role: 'assistant', content: 'prior', segments: [] }])
844
+ r.apply({ type: 'text-delta', text: 'more', seq: 2 })
845
+ const updates = effects.filter((e) => e.name === 'onSegmentsUpdate')
846
+ const update = updates[updates.length - 1]
847
+ expect((update?.args[1] as { append?: boolean } | undefined)?.append).toBe(true)
848
+ })
849
+
850
+ it('an explicit `resumed` overrides the derivation', () => {
851
+ const effects: Array<{ name: string; args: unknown[] }> = []
852
+ const r = createChatStreamReducer({
853
+ transport: 'nats',
854
+ onEffect: (e) => effects.push(e),
855
+ })
856
+ r.initializeWithState([], { resumed: true })
857
+ r.apply({ type: 'text-delta', text: 'hello', seq: 2 })
858
+ const updates = effects.filter((e) => e.name === 'onSegmentsUpdate')
859
+ const update = updates[updates.length - 1]
860
+ expect((update?.args[1] as { append?: boolean } | undefined)?.append).toBe(true)
861
+ })
862
+ })
863
+
864
+ describe('createChatStreamReducer — mergeApprovalStatuses precedence', () => {
865
+ it('stream-learned resolution beats a LAGGING persisted pending', () => {
866
+ const r = createChatStreamReducer({ transport: 'nats' })
867
+ r.setApprovalStatus('req-1', 'approved')
868
+ r.mergeApprovalStatuses({ 'req-1': 'pending' })
869
+ expect(r.state.approvalStatuses['req-1']).toBe('approved')
870
+ })
871
+
872
+ it('persisted resolution beats a STALE stream-learned pending (second tab)', () => {
873
+ const r = createChatStreamReducer({ transport: 'nats' })
874
+ // This tab saw the request; the operator approved it in another tab.
875
+ r.apply({
876
+ type: 'approval-request',
877
+ requestId: 'req-2',
878
+ command: 'rm -rf /tmp/x',
879
+ approvalType: 'CLIENT',
880
+ seq: 1,
881
+ })
882
+ expect(r.state.approvalStatuses['req-2']).toBeUndefined()
883
+ r.setApprovalStatus('req-2', 'pending')
884
+ r.mergeApprovalStatuses({ 'req-2': 'approved' })
885
+ expect(r.state.approvalStatuses['req-2']).toBe('approved')
886
+ })
887
+
888
+ it('fills unknown ids and is a no-op when nothing changes', () => {
889
+ const r = createChatStreamReducer({ transport: 'nats' })
890
+ r.setApprovalStatus('req-3', 'rejected')
891
+ r.mergeApprovalStatuses({ 'req-3': 'rejected', 'req-4': 'cancelled' })
892
+ expect(r.state.approvalStatuses).toEqual({ 'req-3': 'rejected', 'req-4': 'cancelled' })
893
+ const before = r.state.approvalStatuses
894
+ r.mergeApprovalStatuses({ 'req-3': 'pending', 'req-4': 'cancelled' })
895
+ expect(r.state.approvalStatuses).toBe(before)
896
+ })
897
+ })
898
+
899
+ // ─── GUIDE segments (#1583 threaded through the unified reader) ─────────────
900
+ //
901
+ // A `GUIDE` chunk is an APPEND-ONLY body stream like TEXT/THINKING, but it
902
+ // renders as a titled "OpenFrame Guide" card. It must therefore coalesce the
903
+ // same way (extend the trailing `guide` segment, never push a new one per
904
+ // chunk) and satisfy the same reducer invariants — seq idempotency,
905
+ // referential stability, and post-MESSAGE_END append mode.
906
+
907
+ describe('createChatStreamReducer — guide segments', () => {
908
+ it('coalesces consecutive guide deltas into ONE trailing guide segment', () => {
909
+ const r = createChatStreamReducer({ transport: 'nats' })
910
+ r.apply({ type: 'turn-start', seq: 1 })
911
+ r.apply({ type: 'guide-delta', text: '## Enroll a device\n', seq: 2 })
912
+ r.apply({ type: 'guide-delta', text: '1. Open Settings', seq: 3 })
913
+ const last = r.state.messages[r.state.messages.length - 1]
914
+ expect(last.segments).toEqual([
915
+ { type: 'guide', text: '## Enroll a device\n1. Open Settings' },
916
+ ])
917
+ })
918
+
919
+ it('starts a NEW guide segment when a non-guide segment interrupts', () => {
920
+ const r = createChatStreamReducer({ transport: 'nats' })
921
+ r.apply({ type: 'turn-start', seq: 1 })
922
+ r.apply({ type: 'guide-delta', text: 'step one', seq: 2 })
923
+ r.apply({ type: 'text-delta', text: 'aside', seq: 3 })
924
+ r.apply({ type: 'guide-delta', text: 'step two', seq: 4 })
925
+ const last = r.state.messages[r.state.messages.length - 1]
926
+ expect(last.segments).toEqual([
927
+ { type: 'guide', text: 'step one' },
928
+ { type: 'text', text: 'aside' },
929
+ { type: 'guide', text: 'step two' },
930
+ ])
931
+ })
932
+
933
+ it('drops a redelivered guide delta via the shared seq gate (no double text)', () => {
934
+ const r = createChatStreamReducer({ transport: 'nats' })
935
+ r.apply({ type: 'turn-start', seq: 1 })
936
+ r.apply({ type: 'guide-delta', text: 'a', seq: 2 })
937
+ r.apply({ type: 'guide-delta', text: 'b', seq: 3 })
938
+ r.apply({ type: 'guide-delta', text: 'b', seq: 3 })
939
+ r.apply({ type: 'guide-delta', text: 'a', seq: 2 })
940
+ const last = r.state.messages[r.state.messages.length - 1]
941
+ expect(last.segments).toEqual([{ type: 'guide', text: 'ab' }])
942
+ })
943
+
944
+ it('reclones ONLY the touched message — earlier rows keep identity', () => {
945
+ const r = createChatStreamReducer({ transport: 'nats' })
946
+ r.apply({ type: 'participant', kind: 'message-request', text: 'how do I enroll?', seq: 1 })
947
+ r.apply({ type: 'turn-start', seq: 2 })
948
+ r.apply({ type: 'guide-delta', text: 'first', seq: 3 })
949
+ const userRow = r.state.messages[0]
950
+ r.apply({ type: 'guide-delta', text: ' more', seq: 4 })
951
+ expect(r.state.messages[0]).toBe(userRow)
952
+ })
953
+
954
+ it('post-MESSAGE_END continuation emits a DELTA guide segment with append:true', () => {
955
+ const effects: Array<{ name: string; args: unknown[] }> = []
956
+ const r = createChatStreamReducer({
957
+ transport: 'nats',
958
+ onEffect: (e) => effects.push(e),
959
+ })
960
+ r.apply({ type: 'turn-start', seq: 1 })
961
+ r.apply({ type: 'guide-delta', text: 'in-stream ', seq: 2 })
962
+ r.apply({ type: 'turn-end', seq: 3 })
963
+ r.apply({ type: 'guide-delta', text: 'post-end', seq: 4 })
964
+
965
+ const appendEmits = effects.filter(
966
+ (e) =>
967
+ e.name === 'onSegmentsUpdate' &&
968
+ (e.args[1] as { append?: boolean } | undefined)?.append === true,
969
+ )
970
+ expect(appendEmits).toHaveLength(1)
971
+ expect(appendEmits[0].args[0]).toEqual([{ type: 'guide', text: 'post-end' }])
972
+ // …and the bubble carries ONE coalesced guide segment, not two.
973
+ const last = r.state.messages[r.state.messages.length - 1]
974
+ expect(last.segments).toEqual([{ type: 'guide', text: 'in-stream post-end' }])
975
+ })
976
+ })
977
+
978
+ /**
979
+ * `setMessages` is a RAW setter and must not be used to hydrate history.
980
+ *
981
+ * It assigns the thread and nothing else — in particular it does not mark the
982
+ * reducer resumed, which is what makes a later chunk with no preceding
983
+ * `turn-start` take the APPEND branch. Hydrating with it leaves the reducer
984
+ * looking cold, so the first such chunk goes down the cumulative path and
985
+ * REPLACES the restored bubble's segments. When that bubble holds a pending
986
+ * approval card, the control the agent is blocked on disappears with it and
987
+ * there is no way back.
988
+ *
989
+ * `initializeWithState` derives `resumed` from the restored thread, which is
990
+ * why every hydration path must go through it (the SSE adapter always did; the
991
+ * NATS adapter used `setMessages` and hit exactly this).
992
+ */
993
+ describe('createChatStreamReducer — history hydration must declare resumed', () => {
994
+ const hydrated = () => [
995
+ {
996
+ id: 'a1',
997
+ role: 'assistant' as const,
998
+ content: '',
999
+ segments: [
1000
+ { type: 'text' as const, text: 'I need approval first.' },
1001
+ {
1002
+ type: 'approval_request' as const,
1003
+ data: { requestId: 'r1', command: 'systemctl restart nats' },
1004
+ status: 'pending' as const,
1005
+ },
1006
+ ],
1007
+ },
1008
+ ]
1009
+
1010
+ it('initializeWithState keeps a hydrated pending approval when a cold delta lands', () => {
1011
+ const r = createChatStreamReducer({ transport: 'nats' })
1012
+ r.initializeWithState(hydrated())
1013
+ // No `turn-start`: the turn opened server-side before we subscribed.
1014
+ r.apply({ type: 'text-delta', text: 'Approved, restarting…', seq: 99 })
1015
+
1016
+ const segments = r.state.messages[r.state.messages.length - 1].segments ?? []
1017
+ expect(segments.filter((s) => s.type === 'approval_request')).toHaveLength(1)
1018
+ expect(segments.map((s) => s.type)).toEqual(['text', 'approval_request', 'text'])
1019
+ })
1020
+
1021
+ it('CONTRAST: raw setMessages does NOT declare resumed, so the card is lost', () => {
1022
+ // Pinned deliberately. This is the footgun, not a desired behaviour: it
1023
+ // documents WHY hydration paths must use `initializeWithState`, and it
1024
+ // fails loudly if `setMessages` ever starts deriving `resumed` itself
1025
+ // (at which point the contrast above is redundant and can go).
1026
+ const r = createChatStreamReducer({ transport: 'nats' })
1027
+ r.setMessages(hydrated())
1028
+ r.apply({ type: 'text-delta', text: 'Approved, restarting…', seq: 99 })
1029
+
1030
+ const segments = r.state.messages[r.state.messages.length - 1].segments ?? []
1031
+ expect(segments.filter((s) => s.type === 'approval_request')).toHaveLength(0)
1032
+ })
1033
+ })
1034
+
1035
+ /**
1036
+ * POST-`MESSAGE_END` ARRIVALS MUST NOT DESTROY THE COMPLETED BUBBLE.
1037
+ *
1038
+ * `turn-end` calls `accumulator.resetSegments()`, so once a turn has closed the
1039
+ * accumulator's "cumulative" array holds ONLY the segment just added. Any case
1040
+ * that emits that array in REPLACE mode therefore overwrites the finished
1041
+ * answer instead of extending it. `text-delta` and `tool-execution` both branch
1042
+ * on `isInStream` for exactly this reason; `approval-request` and `error` did
1043
+ * not, and post-END arrivals of both are routine — an approved command executes
1044
+ * between the approval bubble and the continuation stream, and the catchup
1045
+ * replay re-emits chunks that follow the last `MESSAGE_END` with no preceding
1046
+ * `turn-start`.
1047
+ */
1048
+ describe('createChatStreamReducer — post-MESSAGE_END arrivals extend the bubble', () => {
1049
+ const trailingSegments = (r: ReturnType<typeof createChatStreamReducer>) =>
1050
+ r.state.messages[r.state.messages.length - 1]?.segments ?? []
1051
+
1052
+ const finishedTurn = () => {
1053
+ const r = createChatStreamReducer({ transport: 'nats' })
1054
+ r.apply({ type: 'turn-start', seq: 1 })
1055
+ r.apply({ type: 'text-delta', text: 'Here is your answer.', seq: 2 })
1056
+ r.apply({ type: 'turn-end', seq: 3 })
1057
+ return r
1058
+ }
1059
+
1060
+ it('an approval-request after turn-end APPENDS instead of replacing', () => {
1061
+ const r = finishedTurn()
1062
+ r.apply({
1063
+ type: 'approval-request',
1064
+ requestId: 'r1',
1065
+ approvalType: 'CLIENT',
1066
+ command: 'systemctl restart nats',
1067
+ seq: 4,
1068
+ })
1069
+
1070
+ const segments = trailingSegments(r)
1071
+ expect(segments.map((s) => s.type)).toEqual(['text', 'approval_request'])
1072
+ expect(segments[0]).toEqual({ type: 'text', text: 'Here is your answer.' })
1073
+ })
1074
+
1075
+ it('an error after turn-end APPENDS instead of replacing', () => {
1076
+ const r = finishedTurn()
1077
+ r.apply({ type: 'error', title: 'Tool failed', details: 'boom', seq: 4 })
1078
+
1079
+ const segments = trailingSegments(r)
1080
+ expect(segments.map((s) => s.type)).toEqual(['text', 'error'])
1081
+ expect(segments[0]).toEqual({ type: 'text', text: 'Here is your answer.' })
1082
+ })
1083
+
1084
+ it('a COLD approval-request (no turn ever seen) still spawns the first bubble', () => {
1085
+ // The cold-start path must stay cumulative — otherwise the very first
1086
+ // approval of a session has no bubble to append into and renders nothing.
1087
+ const r = createChatStreamReducer({ transport: 'nats' })
1088
+ r.apply({
1089
+ type: 'approval-request',
1090
+ requestId: 'r1',
1091
+ approvalType: 'CLIENT',
1092
+ command: 'ls',
1093
+ seq: 1,
1094
+ })
1095
+ expect(trailingSegments(r).map((s) => s.type)).toEqual(['approval_request'])
1096
+ })
1097
+
1098
+ it('an IN-STREAM approval-request stays cumulative (accumulator owns the bubble)', () => {
1099
+ const r = createChatStreamReducer({ transport: 'nats' })
1100
+ r.apply({ type: 'turn-start', seq: 1 })
1101
+ r.apply({ type: 'text-delta', text: 'Checking… ', seq: 2 })
1102
+ r.apply({
1103
+ type: 'approval-request',
1104
+ requestId: 'r1',
1105
+ approvalType: 'CLIENT',
1106
+ command: 'ls',
1107
+ seq: 3,
1108
+ })
1109
+ expect(trailingSegments(r).map((s) => s.type)).toEqual(['text', 'approval_request'])
1110
+ })
1111
+
1112
+ it('a hydrated thread survives a post-END approval-request with no turn-start', () => {
1113
+ // The catchup-replay shape: history restored the finished turn, and the
1114
+ // replay begins AFTER the last MESSAGE_END — so no `turn-start` precedes the
1115
+ // card at all. `initializeWithState` with a non-empty thread is what marks
1116
+ // the reducer resumed, which is the signal that picks the append branch.
1117
+ const r = createChatStreamReducer({ transport: 'nats' })
1118
+ r.initializeWithState([
1119
+ {
1120
+ id: 'a1',
1121
+ role: 'assistant',
1122
+ content: '',
1123
+ segments: [{ type: 'text', text: 'Long hydrated answer body.' }],
1124
+ },
1125
+ ])
1126
+ r.apply({
1127
+ type: 'approval-request',
1128
+ requestId: 'r1',
1129
+ approvalType: 'CLIENT',
1130
+ command: 'ls',
1131
+ seq: 3,
1132
+ })
1133
+
1134
+ // The card joins the hydrated bubble; the persisted answer is untouched.
1135
+ expect(trailingSegments(r).map((s) => s.type)).toEqual(['text', 'approval_request'])
1136
+ expect(trailingSegments(r)[0]).toEqual({
1137
+ type: 'text',
1138
+ text: 'Long hydrated answer body.',
1139
+ })
1140
+ })
1141
+
1142
+ it('a turn-start after a hydrated thread opens a NEW bubble (no overwrite)', () => {
1143
+ // The complementary guarantee, so the fix above cannot be "improved" into
1144
+ // appending a fresh turn onto a completed one.
1145
+ const r = createChatStreamReducer({ transport: 'nats' })
1146
+ r.initializeWithState([
1147
+ { id: 'a1', role: 'assistant', content: '', segments: [{ type: 'text', text: 'Done.' }] },
1148
+ ])
1149
+ r.apply({ type: 'turn-start', seq: 1 })
1150
+ r.apply({ type: 'text-delta', text: 'Next turn.', seq: 2 })
1151
+
1152
+ expect(r.state.messages).toHaveLength(2)
1153
+ expect(r.state.messages[0].segments).toEqual([{ type: 'text', text: 'Done.' }])
1154
+ expect(trailingSegments(r)).toEqual([{ type: 'text', text: 'Next turn.' }])
1155
+ })
1156
+ })
1157
+
1158
+ /**
1159
+ * `resetForDialogSwitch()` had NO direct coverage, while `reset()` is pinned
1160
+ * through the SSE adapter's goldens. That asymmetry matters because the two
1161
+ * functions clear overlapping-but-different field sets by hand: anything added
1162
+ * to the reducer and wired into only one of them becomes a silent cross-dialog
1163
+ * leak. These fixtures pin the per-dialog boundary field by field, INCLUDING
1164
+ * the two survivors that are deliberate — so a future "make them identical"
1165
+ * cleanup fails loudly instead of resurrecting a resolved approval as
1166
+ * actionable or un-suppressing a replaying catchup.
1167
+ */
1168
+ describe('createChatStreamReducer — resetForDialogSwitch boundary', () => {
1169
+ it('clears the cumulative SSE turn text (no cross-dialog answer bleed)', () => {
1170
+ const r = createChatStreamReducer({ transport: 'sse' })
1171
+ r.beginSseSend({ text: 'question about dialog A' })
1172
+ r.apply({ type: 'text-delta', text: 'Answer for A.' })
1173
+ expect(r.state.messages[r.state.messages.length - 1].segments).toEqual([
1174
+ { type: 'text', text: 'Answer for A.' },
1175
+ ])
1176
+
1177
+ r.resetForDialogSwitch()
1178
+ // Dialog B is RESUMED mid-turn: history restores the open assistant bubble
1179
+ // and live deltas flow straight in, with no `beginSseSend` to zero the
1180
+ // cumulative buffer. `sseAppendText` only ever appends, so a surviving
1181
+ // buffer prepends the whole previous answer to B's first delta.
1182
+ r.initializeWithState([{ id: 'b1', role: 'assistant', content: '', segments: [] }])
1183
+ r.apply({ type: 'text-delta', text: 'Answer for B.' })
1184
+
1185
+ expect(r.state.messages[r.state.messages.length - 1].segments).toEqual([
1186
+ { type: 'text', text: 'Answer for B.' },
1187
+ ])
1188
+ })
1189
+
1190
+ it('clears the per-send SSE maps and the send counter', () => {
1191
+ const r = createChatStreamReducer({ transport: 'sse' })
1192
+ r.beginSseSend({ text: 'q' })
1193
+ r.apply({ type: 'metadata', provider: 'anthropic', modelLabel: 'Opus' })
1194
+ expect(r.state.turnMeta.sendCount).toBe(1)
1195
+ expect(r.state.turnMeta.meta.get(0)?.modelLabel).toBe('Opus')
1196
+
1197
+ r.resetForDialogSwitch()
1198
+
1199
+ expect(r.state.turnMeta.sendCount).toBe(0)
1200
+ expect(r.state.turnMeta.meta.size).toBe(0)
1201
+ expect(r.state.turnMeta.sources.size).toBe(0)
1202
+ expect(r.state.turnMeta.refs.size).toBe(0)
1203
+ })
1204
+
1205
+ it('SURVIVOR: approvalStatuses outlive the switch (request ids are global)', () => {
1206
+ const r = createChatStreamReducer({ transport: 'nats' })
1207
+ r.setApprovalStatus('req-1', 'approved')
1208
+ r.resetForDialogSwitch()
1209
+ // A resolved approval whose APPROVAL_RESULT row is absent from the
1210
+ // refetched history page must NOT re-render as actionable.
1211
+ expect(r.state.approvalStatuses).toEqual({ 'req-1': 'approved' })
1212
+ })
1213
+
1214
+ it('SURVIVOR: agent-busy suppression outlives the switch (in-flight refcount)', () => {
1215
+ const r = createChatStreamReducer({ transport: 'nats' })
1216
+ // A catchup window opened before the switch; its `-1` is still pending.
1217
+ r.adjustAgentBusySuppression(1)
1218
+ r.resetForDialogSwitch()
1219
+
1220
+ // The dead tail replays EXECUTING_TOOL with no releasing MESSAGE_END. While
1221
+ // the window is open that must NOT engage the busy phase — zeroing the
1222
+ // count on a dialog switch is what would let an A → B → A re-entry lock the
1223
+ // composer on a still-replaying catchup.
1224
+ r.apply(executing('exec-a'))
1225
+ expect(r.state.streamingPhase).toBe('idle')
1226
+
1227
+ // Once the window's own `finally` releases it, busy engages again.
1228
+ r.adjustAgentBusySuppression(-1)
1229
+ r.apply(executing('exec-b'))
1230
+ expect(r.state.streamingPhase).toBe('thinking')
1231
+ })
1232
+
1233
+ it('clears the seq gate so the new dialog’s own seq space starts fresh', () => {
1234
+ const r = createChatStreamReducer({ transport: 'nats' })
1235
+ r.apply({ type: 'turn-start', seq: 500 })
1236
+ expect(r.getLastAppliedSeq()).toBe(500)
1237
+
1238
+ r.resetForDialogSwitch()
1239
+
1240
+ // Sequence spaces are per (dialogId, chatType): the next dialog's seq 1 is
1241
+ // NOT a redelivery and must be applied, not dropped by a carried-over gate.
1242
+ expect(r.getLastAppliedSeq()).toBe(Number.NEGATIVE_INFINITY)
1243
+ r.apply({ type: 'turn-start', seq: 1 })
1244
+ r.apply({ type: 'text-delta', text: 'fresh', seq: 2 })
1245
+ const last = r.state.messages[r.state.messages.length - 1]
1246
+ expect(last.segments).toEqual([{ type: 'text', text: 'fresh' }])
1247
+ })
1248
+ })
1249
+
1250
+ /**
1251
+ * The reducer stamps the consumed chunk seq onto the rows it owns. This is not
1252
+ * bookkeeping — `mergeHistoryWithRealtime` decides per-message coverage with
1253
+ * it, and with the field missing it falls through to a wall-clock fallback that
1254
+ * drops live bubbles the backend has not finished persisting.
1255
+ */
1256
+ describe('streamSeq stamping (history-merge coverage signal)', () => {
1257
+ it('stamps the trailing assistant with the seq that built it', () => {
1258
+ const r = createChatStreamReducer({ transport: 'nats' })
1259
+ r.apply({ type: 'turn-start', seq: 10 })
1260
+ r.apply({ type: 'text-delta', text: 'hello', seq: 11 })
1261
+
1262
+ const last = r.state.messages[r.state.messages.length - 1]
1263
+ expect(last.role).toBe('assistant')
1264
+ expect(last.streamSeq).toBe(11)
1265
+ })
1266
+
1267
+ it('keeps the stamp MONOTONIC across a replay of older chunks', () => {
1268
+ // JetStream redelivers; coverage must never move backwards, or a replayed
1269
+ // early chunk would make an already-covered bubble look uncovered.
1270
+ const r = createChatStreamReducer({ transport: 'nats' })
1271
+ r.apply({ type: 'turn-start', seq: 10 })
1272
+ r.apply({ type: 'text-delta', text: 'a', seq: 40 })
1273
+ r.apply({ type: 'text-delta', text: 'b', seq: 41 })
1274
+
1275
+ const before = r.state.messages[r.state.messages.length - 1].streamSeq
1276
+ // The gate drops it, but assert the stamp directly too.
1277
+ r.apply({ type: 'text-delta', text: 'stale', seq: 20 })
1278
+
1279
+ expect(before).toBe(41)
1280
+ expect(r.state.messages[r.state.messages.length - 1].streamSeq).toBe(41)
1281
+ })
1282
+
1283
+ it('stamps participant rows too', () => {
1284
+ // `user-` synthetics need it to tell a REPLAYED echo (its row is already in
1285
+ // history) from a genuine new send that merely repeats an earlier text.
1286
+ const r = createChatStreamReducer({ transport: 'nats' })
1287
+ r.apply({
1288
+ type: 'participant',
1289
+ kind: 'message-request',
1290
+ text: 'run it',
1291
+ ownerType: 'USER',
1292
+ seq: 7,
1293
+ })
1294
+
1295
+ const row = r.state.messages[r.state.messages.length - 1]
1296
+ expect(row.role).toBe('user')
1297
+ expect(row.streamSeq).toBe(7)
1298
+ })
1299
+ })
1300
+
1301
+ /**
1302
+ * `agentBusy` — the restore-time counterpart of a live `onAgentBusy`. The
1303
+ * activity indicator is driven off the phase, and nothing on the wire brings it
1304
+ * back after a reload.
1305
+ */
1306
+ describe('agentBusy on initializeWithState', () => {
1307
+ it('raises an idle reducer to thinking', () => {
1308
+ const r = createChatStreamReducer({ transport: 'nats' })
1309
+ r.initializeWithState(null, { agentBusy: true })
1310
+ expect(r.state.streamingPhase).toBe('thinking')
1311
+ })
1312
+
1313
+ it('leaves an open stream owning the phase', () => {
1314
+ const r = createChatStreamReducer({ transport: 'nats' })
1315
+ r.apply({ type: 'turn-start', seq: 1 })
1316
+ expect(r.state.streamingPhase).toBe('streaming')
1317
+
1318
+ r.initializeWithState(null, { agentBusy: true })
1319
+
1320
+ // Only 'idle' upgrades — a downgrade to 'thinking' would report a live
1321
+ // stream as merely pending.
1322
+ expect(r.state.streamingPhase).toBe('streaming')
1323
+ })
1324
+
1325
+ it('stays idle without the flag', () => {
1326
+ const r = createChatStreamReducer({ transport: 'nats' })
1327
+ r.initializeWithState(null, { existingSegments: [{ type: 'text', text: 'done' }] })
1328
+ expect(r.state.streamingPhase).toBe('idle')
1329
+ })
1330
+ })