@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,1928 @@
1
+ /**
2
+ * createChatStreamReducer — THE master chat-stream reader (Phase 3 of the
3
+ * chat unification). One accumulation path for every event source:
4
+ *
5
+ * SSE bytes → createSseFrameDecoder ─┐
6
+ * NATS chunk → decodeNatsChunk ─┼→ ChatStreamEvent → reducer.apply()
7
+ * history → decodeHistoricalMessageData (per-item vocabulary) →
8
+ * envelope grouping → reducer.initializeWithState()
9
+ *
10
+ * The reducer absorbs, verbatim, the semantics that used to be spread over
11
+ * three layers:
12
+ * - `MessageSegmentAccumulator` stays as the internal per-turn segment
13
+ * kernel (instantiated here — its goldens remain valid);
14
+ * - the deleted `useRealtimeChunkProcessor`'s switch (in-stream vs post-END
15
+ * routing, agent-busy outside a message window, escalated approvals,
16
+ * compaction upsert, direct-mode barrier) — surfaced as
17
+ * `ChatReducerEffect`s for consumers that want the callback contract;
18
+ * - `useNatsChatAdapter`'s message-mutation callbacks (trailing-assistant
19
+ * replace/append, compaction upsert, cross-message tool merge, approval
20
+ * flip, participant dedup) — now the reducer's own state transitions via
21
+ * `./message-mutations`;
22
+ * - `useSseChatAdapter`/`useChat`'s SSE turn kernel (cumulative text
23
+ * replace, front-inserted thinking, approval card, `decision_resolved`
24
+ * receipt + cardRef stamping, sendIdx-keyed sources/refs/meta maps).
25
+ *
26
+ * Idempotency:
27
+ * - events whose `seq` ≤ the last applied seq are dropped (per instance);
28
+ * - participant rows additionally dedup via a one-shot optimistic-echo
29
+ * list and a short same-author content window (seq-less transports);
30
+ * - value-level no-op merges return prior references (see
31
+ * `message-mutations.ts`).
32
+ *
33
+ * TRANSPORT ORDERING ASSUMPTION (the seq gate, see `apply()`): every
34
+ * transport feeding this reducer is AT-LEAST-ONCE and IN-ORDER. JetStream
35
+ * redelivers (hence the gate) but never reorders within a stream, and the
36
+ * catchup back-fill replays a monotonically increasing range. So `seq <=
37
+ * lastAppliedSeq` means "already applied", and dropping is correct.
38
+ * A genuinely out-of-order arrival (seq 5 then 4) would be discarded
39
+ * PERMANENTLY — accepted deliberately: no transport here reorders, and an
40
+ * out-of-order buffer would add latency + unbounded state to guard against
41
+ * a case that cannot occur. If a reordering transport is ever added, this
42
+ * gate is the place that must change (do not paper over it downstream).
43
+ *
44
+ * `resolvePendingApprovalForExecution` (the accumulator's implicit
45
+ * approve-on-execution) is reachable ONLY on `transport: 'nats'` — the SSE
46
+ * kernel never routes tool executions through the accumulator.
47
+ *
48
+ * PURE + framework-free: no React, no timers, no network. Side effects the
49
+ * UI needs (approve/reject POSTs) enter as opaque callbacks stamped onto
50
+ * approval segments.
51
+ */
52
+
53
+ import {
54
+ MessageSegmentAccumulator,
55
+ createMessageSegmentAccumulator,
56
+ type AccumulatorCallbacks,
57
+ } from '../utils/message-segment-accumulator'
58
+ import { getCommandText } from '../utils/tool-call-helpers'
59
+ import { parseScrollAnchor, type ScrollAnchor } from '../utils/scroll-anchor'
60
+ import { escapeThinkingTags } from '../../../chat-protocol/decode'
61
+ import { buildChatRefKey } from '../types/chat.types'
62
+ import type {
63
+ ApprovalRequestSegment,
64
+ ChatApprovalStatus,
65
+ MessageSegment,
66
+ PendingToolCallData,
67
+ SegmentsUpdateMetadata,
68
+ ToolExecutionSegment,
69
+ ExecutingToolState,
70
+ } from '../types'
71
+ import type { PendingApproval } from '../types/processing.types'
72
+ import type {
73
+ DialogTokenUsage,
74
+ StreamingPhase,
75
+ UnifiedChatMessage,
76
+ UnifiedUsageBreakdown,
77
+ } from '../types/unified-chat-state.types'
78
+ import {
79
+ CHAT_OWNER_ADMIN,
80
+ type ChatStreamEvent,
81
+ type ApprovalResolvedEvent,
82
+ type ChatMetadataEvent,
83
+ type ParticipantEvent,
84
+ type UsageEvent,
85
+ } from '../../../chat-protocol/events'
86
+ import type { ChatRef } from '../chat-ref.types'
87
+ import {
88
+ CONTENT_DEDUP_WINDOW,
89
+ SYSTEM_DEDUP_WINDOW,
90
+ appendToTrailingAssistant,
91
+ applyToolExecutionToMessages,
92
+ hasRecentMessage,
93
+ mergeToolExecutionIfPresent,
94
+ nextId,
95
+ projectApprovalResolutionToMessages,
96
+ updateTrailingAssistant,
97
+ upsertTrailingCompaction,
98
+ } from './message-mutations'
99
+
100
+ // =============================================================================
101
+ // Public types
102
+ // =============================================================================
103
+
104
+ /** Per-turn metadata extracted from the streamed metadata/usage frames
105
+ * (SSE transport). Canonical home — `use-sse-chat-adapter` re-exports it. */
106
+ export interface ChatTurnMeta {
107
+ provider: string | null
108
+ modelLabel: string | null
109
+ contextWindowMaxTokens: number | null
110
+ /** Input tokens (from usage:start). Includes cached tokens. */
111
+ inputTokens: number | null
112
+ /** Output tokens (from the trailing usage frame). */
113
+ outputTokens: number | null
114
+ /** Cache hit % (read / total-input × 100). Only known after stream end. */
115
+ cacheHitRatePct: number | null
116
+ /** Cross-call usage breakdown extracted from the trailing usage frame. */
117
+ breakdown: UnifiedUsageBreakdown | null
118
+ /** Per-message viewport-positioning hint. */
119
+ scrollAnchor: ScrollAnchor | null
120
+ routedComplexity: string | null
121
+ routedThinkingBudget: number | null
122
+ }
123
+
124
+ /** Single source of truth for a fresh `ChatTurnMeta` row. */
125
+ export function createEmptyTurnMeta(): ChatTurnMeta {
126
+ return {
127
+ provider: null,
128
+ modelLabel: null,
129
+ contextWindowMaxTokens: null,
130
+ inputTokens: null,
131
+ outputTokens: null,
132
+ cacheHitRatePct: null,
133
+ breakdown: null,
134
+ scrollAnchor: null,
135
+ routedComplexity: null,
136
+ routedThinkingBudget: null,
137
+ }
138
+ }
139
+
140
+ /** SSE per-send maps, keyed by the send counter (`sendIdx`). Each user send
141
+ * produces ONE server-side refs/sources entry but can fan out to MULTIPLE
142
+ * assistant messages client-side — the adapter maps every following
143
+ * assistant message back to its send's entry. */
144
+ export interface ChatTurnMetaState {
145
+ meta: Map<number, ChatTurnMeta>
146
+ sources: Map<number, unknown[]>
147
+ refs: Map<number, Record<string, ChatRef>>
148
+ sendCount: number
149
+ }
150
+
151
+ export interface ChatReducerState {
152
+ messages: UnifiedChatMessage[]
153
+ streamingPhase: StreamingPhase
154
+ turnMeta: ChatTurnMetaState
155
+ dialogTokenUsage?: DialogTokenUsage | null
156
+ liveModel?: {
157
+ provider: string | null
158
+ modelLabel: string | null
159
+ contextWindowMaxTokens: number | null
160
+ } | null
161
+ approvalStatuses: Record<string, ChatApprovalStatus>
162
+ }
163
+
164
+ /** Escalated-approval bookkeeping entry (mirrors the legacy processor). */
165
+ export interface EscalatedApprovalData {
166
+ command: string
167
+ explanation?: string
168
+ approvalType: string
169
+ toolCalls?: PendingToolCallData[]
170
+ }
171
+
172
+ /**
173
+ * Callback-visible effect produced by `apply()`. Names + args mirror the
174
+ * legacy `RealtimeChunkCallbacks` contract 1:1 — the contract the deleted
175
+ * `useRealtimeChunkProcessor` wrapper exposed, now pinned directly by
176
+ * `__tests__/chat-stream-reducer-golden.test.ts`.
177
+ *
178
+ * `segments-after-approval-result` is the one conditional emission: the
179
+ * legacy processor emitted a cumulative `onSegmentsUpdate` for a
180
+ * non-escalated APPROVAL_RESULT only when the consumer had NOT wired
181
+ * `onApprovalResolved` — the effect sink resolves that at dispatch time.
182
+ */
183
+ export interface ChatReducerEffect {
184
+ name:
185
+ | 'onStreamStart'
186
+ | 'onStreamEnd'
187
+ | 'onMetadata'
188
+ | 'onSegmentsUpdate'
189
+ | 'onError'
190
+ | 'onUserMessage'
191
+ | 'onTokenUsage'
192
+ | 'onDirectMessage'
193
+ | 'onSystemMessage'
194
+ | 'onEscalatedApproval'
195
+ | 'onEscalatedApprovalResult'
196
+ | 'onApprovalResolved'
197
+ | 'onToolExecuted'
198
+ | 'onAgentBusy'
199
+ | 'onDialogClosed'
200
+ | 'segments-after-approval-result'
201
+ args: unknown[]
202
+ }
203
+
204
+ export interface ChatStreamReducerOptions {
205
+ /** Which transport's turn kernel drives `apply()`. Default `'nats'`. */
206
+ transport?: 'sse' | 'nats'
207
+ /** Seed for the approval-status map (request-id → status). */
208
+ approvalStatuses?: Record<string, ChatApprovalStatus>
209
+ /** Batch APPROVAL_REQUESTs render as one card (default true) or unfold. */
210
+ batchApprovalsEnabled?: boolean
211
+ /** Approval types displayed inline; others escalate. Default ['CLIENT']. */
212
+ displayApprovalTypes?: string[]
213
+ /** Opaque approve/reject handlers stamped onto approval segments. */
214
+ callbacks?: AccumulatorCallbacks
215
+ /** Engage the direct-mode barrier optimistically (host-known takeover). */
216
+ isDirectMode?: boolean
217
+ /**
218
+ * Legacy-processor parity knob: post-MESSAGE_END tool chunks route
219
+ * cross-message (`onToolExecuted` effect + `applyToolExecutionToMessages`)
220
+ * only when the consumer wired the cross-message updater; otherwise they
221
+ * fall through the accumulator like the pre-callback code path. Default
222
+ * true (both first-party adapters wire it).
223
+ */
224
+ crossMessageToolRouting?: boolean
225
+ /** Effect sink for callback-contract consumers (compat wrapper). */
226
+ onEffect?: (effect: ChatReducerEffect) => void
227
+ /** Notified after every state mutation (the dialog store wires this). */
228
+ onChange?: () => void
229
+ /**
230
+ * Whether an ADMIN-authored `MESSAGE_REQUEST` may be consumed as OUR OWN
231
+ * optimistic echo (see `pushOptimisticSend`). Default `false`.
232
+ *
233
+ * Who the local operator is decides this, and it differs per host:
234
+ * - Hub website chat / ticket CLIENT side: the local user is NEVER the
235
+ * admin, so an ADMIN-authored inbound row is a technician's reply and
236
+ * must ALWAYS render. Consuming it as an echo would silently delete a
237
+ * real message whenever its text happened to match ours. → `false`.
238
+ * - OpenFrame product app (Mingo + ticket ADMIN side): the operator IS
239
+ * the admin, so their own sends echo back as ADMIN and MUST be
240
+ * deduped, or every message renders twice. → `true`.
241
+ *
242
+ * Only ever applies to text the host itself registered via
243
+ * `pushOptimisticSend`, so enabling it cannot drop a message the host
244
+ * did not just send.
245
+ */
246
+ ownEchoIncludesAdmin?: boolean
247
+ /**
248
+ * The LOCAL user's id, either as a value or as a GETTER resolved at event
249
+ * time. When it resolves to a value, an inbound `MESSAGE_REQUEST` that
250
+ * DECLARES a different author (`event.userId !== selfUserId`) may not be
251
+ * consumed as our own optimistic echo.
252
+ *
253
+ * Without it the echo list matches on RAW TEXT alone, which on a shared
254
+ * ADMIN side (two technicians in one ticket, `ownEchoIncludesAdmin: true`)
255
+ * can silently delete a colleague's message: if OUR send never echoes back,
256
+ * its entry stays armed and the next identical text from anyone — canned
257
+ * replies like "ok" / "done" / "on it" make this routine — is swallowed.
258
+ *
259
+ * The guard FAILS OPEN, deliberately: a row that carries NO `userId` is not
260
+ * "someone else's", it is UNATTRIBUTED, and rejecting it would disable
261
+ * dedup entirely (→ every send rendered twice) on any transport whose
262
+ * decoder does not surface the author id. Such a row falls back to the
263
+ * text + `OWN_ECHO_TTL_MS` behaviour instead, and a one-shot `console.warn`
264
+ * makes the missing id observable.
265
+ *
266
+ * Pass a FUNCTION whenever the id can change or arrive late (auth
267
+ * rehydration, logout / login-as-another-user without a reload): reducers
268
+ * are retained for the store's lifetime, so a value captured at creation
269
+ * time can go stale and silently disable the guard.
270
+ */
271
+ selfUserId?: string | (() => string | undefined)
272
+ }
273
+
274
+ /**
275
+ * How long an un-consumed optimistic-echo entry stays armed **on the
276
+ * UNATTRIBUTED path**. Long enough to cover any realistic send → server-echo
277
+ * round trip, short enough that a send whose echo never lands cannot swallow
278
+ * an unrelated identical message later in the conversation.
279
+ *
280
+ * A row that is DECLARED ours (`selfUserId` set and `event.userId ===
281
+ * selfUserId`) gets the LONGER `OWN_ECHO_AUTHOR_TTL_MS` instead — see there.
282
+ */
283
+ export const OWN_ECHO_TTL_MS = 30_000
284
+
285
+ /**
286
+ * How long an un-consumed entry stays armed on the AUTHOR-MATCHED path
287
+ * (`selfUserId` set, inbound row declares the same author).
288
+ *
289
+ * The author check rules out CROSS-technician theft, but NOT the same user on
290
+ * a second tab or device: a row THIS tab did not originate looks exactly like
291
+ * an author-matched echo. So the entry still needs an upper bound — without
292
+ * one, a send whose echo never lands (dropped frame, backend text
293
+ * normalization, reconnect gap) leaves the entry armed for the reducer's
294
+ * whole lifetime, and the next identical text from the same user on ANOTHER
295
+ * tab is silently swallowed. With `MAX_PENDING_ECHOES` armed slots and canned
296
+ * replies ("ok", "done", "on it") being exactly the recurring strings, that is
297
+ * a routine message-LOSS bug, strictly worse than the duplicate row it trades
298
+ * against.
299
+ *
300
+ * Ten minutes is the bound: generously longer than the slow echo the author
301
+ * path exists to protect (a JetStream catch-up replay after a network gap
302
+ * arrives long after the send, and its rows carry `seq`, so the seq-less
303
+ * content-dedup fallback cannot rescue it), and far shorter than the
304
+ * "same text again an hour later" window in which a stale entry does damage.
305
+ *
306
+ * It is a BACKSTOP, not the usual bound: `turn-end` disarms entries much
307
+ * sooner (see `purgeEchoesAtTurnEnd`). The ten minutes only ever apply to a
308
+ * dialog that never reports a turn boundary at all.
309
+ */
310
+ export const OWN_ECHO_AUTHOR_TTL_MS = 10 * 60_000
311
+
312
+ /** Cap on simultaneously-armed echo entries (a backend that never echoes must
313
+ * not grow the list without bound). */
314
+ const MAX_PENDING_ECHOES = 5
315
+
316
+ /** One armed optimistic-echo entry: the sent text and the wall-clock ms it was
317
+ * armed at. Exported because the LRU-eviction round trip PARKS these (see
318
+ * `getPendingEchoes` / `InitializeExtras.pendingEchoes`). */
319
+ export interface PendingEcho {
320
+ text: string
321
+ at: number
322
+ }
323
+
324
+ export interface InitializeExtras {
325
+ existingSegments?: MessageSegment[]
326
+ pendingApprovals?: Map<string, PendingApproval>
327
+ executingTools?: Map<string, ExecutingToolState>
328
+ escalatedApprovals?: Map<string, EscalatedApprovalData>
329
+ /**
330
+ * Approval statuses PARKED from a previous instance of this key (LRU
331
+ * eviction hands them to `onEvict`). Merged with the same state-monotonic
332
+ * precedence as `mergeApprovalStatuses` — a resolved approval must not come
333
+ * back as actionable, which is exactly why `resetForDialogSwitch` preserves
334
+ * this map rather than clearing it.
335
+ */
336
+ approvalStatuses?: Record<string, ChatApprovalStatus>
337
+ /**
338
+ * Seq cursor PARKED from a previous instance of this key. A recreated
339
+ * reducer starts at `-Infinity`, so a host that replays from its own cursor
340
+ * would re-apply already-applied events; restoring the parked value keeps
341
+ * `apply()`'s idempotency gate intact across an eviction. Only ever moves
342
+ * the gate FORWARD (a lower value is ignored).
343
+ */
344
+ lastAppliedSeq?: number
345
+ /**
346
+ * Armed optimistic-echo entries PARKED from a previous instance of this key.
347
+ * Without them, a key LRU-evicted between `pushOptimisticSend` and its
348
+ * `MESSAGE_REQUEST` echo leaves the replacement reducer with nothing armed,
349
+ * so the echo renders a DUPLICATE user bubble. Entries already past
350
+ * `OWN_ECHO_AUTHOR_TTL_MS` are dropped on restore (an expired entry could
351
+ * only swallow an unrelated identical message), and the list is capped at
352
+ * `MAX_PENDING_ECHOES` exactly as the live path is.
353
+ */
354
+ pendingEchoes?: readonly PendingEcho[]
355
+ /**
356
+ * Whether the restored thread is a RESUMED dialog (a `MESSAGE_START` already
357
+ * fired server-side), which makes post-stream continuation chunks append
358
+ * into the existing bubble instead of taking the cold-start cumulative path.
359
+ * Defaults to "the restored thread is non-empty" — an eviction restore of a
360
+ * key that never streamed must NOT claim it did, or a cold text-delta with
361
+ * no preceding `turn-start` appends into a bubble that was never spawned.
362
+ * Pass explicitly only to override that derivation.
363
+ */
364
+ resumed?: boolean
365
+ /**
366
+ * Whether the restored tail means the AGENT IS STILL WORKING — as opposed to
367
+ * blocked on the user. Raises an `idle` reducer to `thinking` on restore
368
+ * (same rule as a live `onAgentBusy`: an open stream keeps the phase), which
369
+ * is what puts the activity indicator back.
370
+ *
371
+ * Nothing on the wire restores this: the phase machine is driven by events,
372
+ * and a run's EXECUTING chunk is long past by the time a reload happens while
373
+ * its EXECUTED one may be minutes away. Derived once by
374
+ * `extractIncompleteMessageState` so every host agrees on the distinction —
375
+ * a PENDING approval is the opposite state and must NOT spin.
376
+ */
377
+ agentBusy?: boolean
378
+ }
379
+
380
+ export interface BeginSseSendOptions {
381
+ text: string
382
+ hidden?: boolean
383
+ userName?: string
384
+ assistantName?: string
385
+ assistantAvatar?: string
386
+ }
387
+
388
+ // =============================================================================
389
+ // Reducer
390
+ // =============================================================================
391
+
392
+ export interface ChatStreamReducer {
393
+ /** Apply one normalized stream event. */
394
+ apply(event: ChatStreamEvent): void
395
+ /** Current immutable state snapshot (stable identity between mutations). */
396
+ readonly state: ChatReducerState
397
+ /** Full reset — thread, per-turn kernel, dedup sets, seq gate, maps. */
398
+ reset(): void
399
+ /**
400
+ * Seed the thread (history hydration / persisted-state rehydration) and
401
+ * optionally the per-turn kernel + escalated approvals (resume of an
402
+ * incomplete turn). `messages: null` keeps the current thread. Escalated
403
+ * entries are re-surfaced via `onEscalatedApproval` effects. Marks the
404
+ * instance as having streamed so continuation chunks append.
405
+ */
406
+ initializeWithState(messages: UnifiedChatMessage[] | null, extras?: InitializeExtras): void
407
+
408
+ // ── Thread commands (adapter-driven, not wire events) ──────────────────
409
+ setMessages(messages: UnifiedChatMessage[]): void
410
+ prependMessages(messages: UnifiedChatMessage[]): void
411
+ /** Optimistic local send: user bubble (+ echo record) + assistant placeholder. */
412
+ pushOptimisticSend(text: string, hidden?: boolean): void
413
+ /** Wipe thread + per-turn kernel, keep approval statuses (legacy clear). */
414
+ clearThread(): void
415
+ /** Per-dialog reset (keeps approval statuses — request-ids are global). */
416
+ resetForDialogSwitch(): void
417
+
418
+ // ── SSE turn commands ───────────────────────────────────────────────────
419
+ beginSseSend(options: BeginSseSendOptions): void
420
+ endSseTurn(): void
421
+ failSseTurn(errorMessage: string): void
422
+ seedSseMaps(seed: {
423
+ sources?: Array<[number, unknown[]]>
424
+ refs?: Array<[number, Record<string, ChatRef>]>
425
+ sendCount?: number
426
+ }): void
427
+
428
+ // ── Phase / status commands ─────────────────────────────────────────────
429
+ setPhase(phase: StreamingPhase): void
430
+ setApprovalStatus(requestId: string, status: ChatApprovalStatus | null): void
431
+ /**
432
+ * CANONICAL merge of a PERSISTED status map (host-side store, history
433
+ * hydration, dialog-switch top-up) into this reducer's map.
434
+ *
435
+ * Precedence is STATE-MONOTONIC, not source-based, because either side can
436
+ * be the stale one:
437
+ * - persisted lags (a snapshot taken before the stream resolved the
438
+ * request) → the stream's resolved status must win, or a just-approved
439
+ * card would be downgraded back to `pending` and re-arm its buttons;
440
+ * - the STREAM lags (this tab saw the request, the operator resolved it in
441
+ * a SECOND TAB, and the persisted map is the one carrying the truth) →
442
+ * the persisted resolution must win, or the card stays actionable and
443
+ * invites a second approve on an already-resolved request.
444
+ *
445
+ * With statuses being exactly `pending | approved | rejected | cancelled`,
446
+ * "resolved beats pending" is a total order that gets both directions
447
+ * right: take the persisted value when ours is `pending` and theirs isn't;
448
+ * keep ours otherwise. Persisted entries still fill in every request-id the
449
+ * stream hasn't seen (other dialogs, pre-session history).
450
+ */
451
+ mergeApprovalStatuses(persisted: Record<string, ChatApprovalStatus>): void
452
+ setDirectMode(isDirectMode: boolean): void
453
+ setDialogTokenUsage(usage: DialogTokenUsage | null): void
454
+ /** +1/-1 windows during catchup replay (suppresses agent-busy locks). */
455
+ adjustAgentBusySuppression(delta: number): void
456
+ armAdoptTrailingAssistant(value: boolean): void
457
+
458
+ // ── Cross-side projections (dialog-store fan-out) ───────────────────────
459
+ projectApprovalResolution(
460
+ requestId: string,
461
+ status: ChatApprovalStatus,
462
+ resolvedByName?: string | null,
463
+ ): void
464
+ projectToolExecution(segment: ToolExecutionSegment): void
465
+
466
+ /**
467
+ * The seq gate's current value (`-Infinity` before anything seq-carrying was
468
+ * applied). Not part of `state` — it is bookkeeping, not render input — but
469
+ * a host that PARKS a reducer (LRU eviction, dialog swap) needs it to
470
+ * restore idempotency on the recreated instance via
471
+ * `initializeWithState(messages, { lastAppliedSeq })`.
472
+ */
473
+ getLastAppliedSeq(): number
474
+
475
+ /**
476
+ * Currently-armed optimistic-echo entries. Same rationale as
477
+ * `getLastAppliedSeq`: not render input, but a host that PARKS a reducer
478
+ * needs them to restore echo dedup on the recreated instance via
479
+ * `initializeWithState(messages, { pendingEchoes })` — otherwise an echo
480
+ * in flight across the eviction renders a duplicate user bubble.
481
+ */
482
+ getPendingEchoes(): readonly PendingEcho[]
483
+
484
+ // ── Legacy processor surface (compat wrapper) ───────────────────────────
485
+ getSegments(): MessageSegment[]
486
+ updateApprovalStatus(
487
+ requestId: string,
488
+ status: ChatApprovalStatus,
489
+ resolvedByName?: string | null,
490
+ ): MessageSegment[]
491
+ getPendingEscalated(): Map<string, EscalatedApprovalData>
492
+ }
493
+
494
+ // Actions allowed through once the dialog is in direct mode. Everything else
495
+ // is an AI-assistant event and gets dropped — an allowlist so any future
496
+ // assistant event is blocked by default.
497
+ function isDirectModeAllowed(event: ChatStreamEvent): boolean {
498
+ return event.type === 'participant' || event.type === 'dialog-closed'
499
+ }
500
+
501
+ export function createChatStreamReducer(
502
+ options: ChatStreamReducerOptions = {},
503
+ ): ChatStreamReducer {
504
+ const {
505
+ transport = 'nats',
506
+ batchApprovalsEnabled = true,
507
+ displayApprovalTypes = ['CLIENT'],
508
+ callbacks = {},
509
+ crossMessageToolRouting = true,
510
+ onEffect,
511
+ onChange,
512
+ } = options
513
+
514
+ // ── Mutable internals ──────────────────────────────────────────────────
515
+ let messages: UnifiedChatMessage[] = []
516
+ let streamingPhase: StreamingPhase = 'idle'
517
+ let dialogTokenUsage: DialogTokenUsage | null = null
518
+ let liveModel: ChatReducerState['liveModel'] = null
519
+ let approvalStatuses: Record<string, ChatApprovalStatus> = {
520
+ ...(options.approvalStatuses ?? {}),
521
+ }
522
+
523
+ const accumulator: MessageSegmentAccumulator = createMessageSegmentAccumulator(callbacks)
524
+
525
+ // Stream-window flags (chunk-processor parity).
526
+ let isInStream = false
527
+ let hasEverStreamed = false
528
+
529
+ // Direct-mode barrier.
530
+ let directModeFlag = options.isDirectMode ?? false
531
+ let sawDirectMessage = false
532
+ let directTeardownFired = false
533
+
534
+ // Escalated approvals (single or batch).
535
+ const pendingEscalated = new Map<string, EscalatedApprovalData>()
536
+
537
+ // Idempotency. The per-participant seen-seq set that used to live here was
538
+ // REMOVED as redundant: `apply()`'s global gate already drops any event
539
+ // whose seq was applied before, so a seq-carrying participant row can never
540
+ // reach the handlers twice.
541
+ let lastAppliedSeq = Number.NEGATIVE_INFINITY
542
+ let pendingEchoTexts: PendingEcho[] = []
543
+ /** `Date.now()` of the last observed `turn-start`; 0 = none seen yet.
544
+ * Bounds `purgeEchoesAtTurnEnd` — see there. */
545
+ let turnStartedAt = 0
546
+
547
+ /** Consume a one-shot optimistic-echo entry for `text`. Returns true when
548
+ * this inbound row IS our own echo and must not render.
549
+ *
550
+ * `authorMatched` widens the expiry window (`OWN_ECHO_AUTHOR_TTL_MS` vs
551
+ * `OWN_ECHO_TTL_MS`) — it does NOT remove it. The author check rules out a
552
+ * colleague's message, not the same user's second tab, so an entry whose
553
+ * echo never landed must still age out or it eventually swallows a real
554
+ * message. `MAX_PENDING_ECHOES` bounds growth on both paths. */
555
+ function consumeOwnEcho(text: string, authorMatched = false): boolean {
556
+ if (pendingEchoTexts.length === 0) return false
557
+ const now = Date.now()
558
+ // Purge at the WIDEST bound only: a short-TTL (unattributed) lookup must
559
+ // not evict an entry that a later author-matched row is still entitled to
560
+ // consume. The per-path window is applied at match time instead.
561
+ pendingEchoTexts = pendingEchoTexts.filter((e) => now - e.at < OWN_ECHO_AUTHOR_TTL_MS)
562
+ const ttl = authorMatched ? OWN_ECHO_AUTHOR_TTL_MS : OWN_ECHO_TTL_MS
563
+ const idx = pendingEchoTexts.findIndex((e) => e.text === text && now - e.at < ttl)
564
+ if (idx === -1) return false
565
+ pendingEchoTexts.splice(idx, 1)
566
+ return true
567
+ }
568
+
569
+ /** Disarm every echo entry that was armed BEFORE the turn this `turn-end`
570
+ * closes (i.e. before the last observed `turn-start`).
571
+ *
572
+ * A `turn-end` is proof the server finished processing everything it had
573
+ * accepted up to that point, so an echo for a send made BEFORE the turn
574
+ * opened that has not landed by now never will (dropped frame, backend
575
+ * text normalization, reconnect gap). Keeping the entry armed for the full
576
+ * `OWN_ECHO_AUTHOR_TTL_MS` preserves up to ten minutes in which the same
577
+ * user's identical send from a SECOND TAB is silently swallowed — the
578
+ * message-LOSS failure this module ranks strictly worse than the duplicate
579
+ * row it trades against. Draining at the turn boundary cuts that window
580
+ * from ten minutes to one turn.
581
+ *
582
+ * The purge is SELECTIVE (`at > turnStartedAt`) rather than unconditional:
583
+ * an entry armed DURING the turn being ended is not stale by the argument
584
+ * above — its echo is still in flight — and dropping it duplicates the
585
+ * user's bubble. Such entries survive to their normal author TTL; entries
586
+ * from earlier turns still drain here.
587
+ *
588
+ * WHAT THIS COVERS, EXACTLY. `turnStartedAt` is the ARRIVAL wall-clock of
589
+ * the last `turn-start` this reducer saw, NOT the event's own time: the
590
+ * normalized event union carries no timestamp. `ChatStreamEventBase` has a
591
+ * single optional field, `seq` (JetStream `streamSeq`, lifted by
592
+ * `decodeNatsChunk`); the SSE decoder emits a bare `{ type: 'turn-start' }`
593
+ * with no envelope at all. The boundary is therefore an ARRIVAL order, and
594
+ * only these cases are actually handled:
595
+ *
596
+ * - COVERED — a `turn-end` with NO observed `turn-start` purges nothing
597
+ * (`turnStartedAt` starts at 0; there is no boundary to be stale
598
+ * relative to). This is the approval-interrupt shape as the adapter
599
+ * drives it — sending while an approval pends cancels the in-flight
600
+ * turn, so the interrupted turn's `turn-end` lands after the new send
601
+ * armed its entry — and any stale `turn-end` on a fresh reducer.
602
+ * - COVERED — `turn-start` observed BEFORE the send, `turn-end` after: the
603
+ * entry was armed past the boundary and survives.
604
+ * - NOT COVERED — a reconnect catch-up replay that delivers BOTH a
605
+ * pre-disconnect `turn-start` and its `turn-end` after the user has
606
+ * already sent. The replayed `turn-start` restamps `turnStartedAt` to
607
+ * now, so the following `turn-end` purges a still-live entry and the
608
+ * echo renders a SECOND bubble. This is not fixable at this layer: with
609
+ * no event-own timestamp, that interleaving is indistinguishable from
610
+ * "the send was queued mid-turn, and this reducer's own turn opened and
611
+ * closed before the echo landed" — a case the module deliberately
612
+ * resolves toward the duplicate row rather than risk swallowing a real
613
+ * message. `seq` does not disambiguate it either: a replay whose seqs
614
+ * were already applied is dropped by `apply()`'s idempotency gate before
615
+ * reaching here, so any replay that DOES reach here carries
616
+ * strictly-increasing unseen seqs — exactly what a live turn carries.
617
+ * Pinned by the "a replayed turn boundary after a send duplicates the
618
+ * bubble" test. Fix it by giving the transport an event-own time, not by
619
+ * widening the purge here.
620
+ *
621
+ * `OWN_ECHO_AUTHOR_TTL_MS` remains the backstop for dialogs that never
622
+ * report a turn boundary at all.
623
+ *
624
+ * Same-millisecond ties break toward PURGING (`>`, not `>=`): a send and
625
+ * the turn it opened share a timestamp, and that entry IS the pre-turn
626
+ * one. A send made DURING a turn is a human action a turn-start later, so
627
+ * it never ties in practice. */
628
+ function purgeEchoesAtTurnEnd(): void {
629
+ if (pendingEchoTexts.length === 0) return
630
+ if (turnStartedAt === 0) return
631
+ pendingEchoTexts = pendingEchoTexts.filter((e) => e.at > turnStartedAt)
632
+ }
633
+
634
+ /** Resolve `selfUserId` AT EVENT TIME — a getter lets the host track auth
635
+ * rehydration / user switches on a reducer that outlives both. */
636
+ function resolveSelfUserId(): string | undefined {
637
+ const self = options.selfUserId
638
+ return typeof self === 'function' ? self() : self
639
+ }
640
+
641
+ /** One-shot: the host configured `selfUserId` but inbound rows carry none,
642
+ * so the author guard is inert and dedup silently degrades to text+TTL. */
643
+ let warnedIdLessAuthor = false
644
+ function warnIdLessAuthor(): void {
645
+ if (warnedIdLessAuthor) return
646
+ warnedIdLessAuthor = true
647
+ console.warn(
648
+ '[chat] selfUserId is configured but an inbound MESSAGE_REQUEST carries no userId — ' +
649
+ 'the author echo guard is inert and dedup falls back to text + TTL matching. ' +
650
+ 'Check the transport decoder surfaces the author id (the ticket wire model ' +
651
+ 'declares it at `owner.userId`) and that it is in the SAME id space as selfUserId.',
652
+ )
653
+ }
654
+
655
+ // Adapter-armed flags.
656
+ let suppressAgentBusy = 0
657
+ let adoptTrailingAssistant = false
658
+
659
+ // SSE per-send maps + per-turn kernel.
660
+ const metaMap = new Map<number, ChatTurnMeta>()
661
+ const sourcesMap = new Map<number, unknown[]>()
662
+ const refsMap = new Map<number, Record<string, ChatRef>>()
663
+ let sendCount = 0
664
+ let sseCurrentText = ''
665
+
666
+ // ── Snapshot cache ─────────────────────────────────────────────────────
667
+ let stateCache: ChatReducerState | null = null
668
+ function invalidate(): void {
669
+ stateCache = null
670
+ onChange?.()
671
+ }
672
+ function getState(): ChatReducerState {
673
+ if (!stateCache) {
674
+ stateCache = {
675
+ messages,
676
+ streamingPhase,
677
+ turnMeta: { meta: metaMap, sources: sourcesMap, refs: refsMap, sendCount },
678
+ dialogTokenUsage,
679
+ liveModel,
680
+ approvalStatuses,
681
+ }
682
+ }
683
+ return stateCache
684
+ }
685
+
686
+ function emit(name: ChatReducerEffect['name'], ...args: unknown[]): void {
687
+ onEffect?.({ name, args })
688
+ }
689
+
690
+ function setMessagesInternal(next: UnifiedChatMessage[]): void {
691
+ if (next !== messages) {
692
+ messages = next
693
+ invalidate()
694
+ }
695
+ }
696
+
697
+ function setPhaseInternal(phase: StreamingPhase): void {
698
+ if (streamingPhase !== phase) {
699
+ streamingPhase = phase
700
+ invalidate()
701
+ }
702
+ }
703
+
704
+ /** onAgentBusy state semantics: only 'idle' upgrades to 'thinking'; an
705
+ * open stream keeps ownership. Suppressed during catchup replay. */
706
+ function agentBusyState(): void {
707
+ if (suppressAgentBusy > 0) return
708
+ if (streamingPhase === 'idle') setPhaseInternal('thinking')
709
+ }
710
+
711
+ function mirrorApprovalStatus(requestId: string, status: ChatApprovalStatus): void {
712
+ if (approvalStatuses[requestId] === status) return
713
+ approvalStatuses = { ...approvalStatuses, [requestId]: status }
714
+ invalidate()
715
+ }
716
+
717
+ // ===========================================================================
718
+ // NATS kernel — the absorbed chunk-processor switch + adapter callbacks body
719
+ // ===========================================================================
720
+
721
+ /**
722
+ * Stamp the highest consumed chunk seq onto the trailing assistant row.
723
+ *
724
+ * This is what `mergeHistoryWithRealtime` decides coverage with, per message.
725
+ * Leave it off and the merge has no per-row signal, so it falls through to
726
+ * the wall-clock fallback — "this synthetic predates the fetch instant, so
727
+ * history must contain it" — which is false for any turn the backend has not
728
+ * finished persisting. A history refetch mid-stream (the one a reconnect
729
+ * fires) then drops every live assistant bubble except the one that happens
730
+ * to be `streamingMessageId`, leaving the thread as just the persisted user
731
+ * prompt. The host store used to stamp this from the `onSegmentsUpdate`
732
+ * metadata; now the reducer owns the thread, so it owns the stamp.
733
+ *
734
+ * Monotonic: replay can re-apply an older seq, and coverage must never go
735
+ * backwards.
736
+ */
737
+ function stampTrailingAssistantSeq(
738
+ next: UnifiedChatMessage[],
739
+ seq: number | undefined,
740
+ ): UnifiedChatMessage[] {
741
+ if (typeof seq !== 'number') return next
742
+ const last = next[next.length - 1]
743
+ if (!last || last.role !== 'assistant') return next
744
+ if (typeof last.streamSeq === 'number' && last.streamSeq >= seq) return next
745
+ return [...next.slice(0, -1), { ...last, streamSeq: seq }]
746
+ }
747
+
748
+ function applySegmentsToState(segments: MessageSegment[], meta?: SegmentsUpdateMetadata): void {
749
+ const seq = meta?.streamSeq
750
+ // Standalone compaction updates carry the accumulator's cumulative
751
+ // array — apply only the compaction segment (upsert) or interleaved
752
+ // continuation text would duplicate.
753
+ if (meta?.append && meta.isCompacting) {
754
+ setMessagesInternal(stampTrailingAssistantSeq(upsertTrailingCompaction(messages, segments), seq))
755
+ return
756
+ }
757
+ // Post-MESSAGE_END continuation fragments append into the existing
758
+ // bubble; replacing would wipe the completed reply.
759
+ if (meta?.append) {
760
+ setMessagesInternal(stampTrailingAssistantSeq(appendToTrailingAssistant(messages, segments), seq))
761
+ return
762
+ }
763
+ setMessagesInternal(stampTrailingAssistantSeq(updateTrailingAssistant(messages, segments), seq))
764
+ }
765
+
766
+ function applyStreamStartToState(): void {
767
+ setPhaseInternal('streaming')
768
+ // A new stream must never overwrite a COMPLETED trailing assistant
769
+ // bubble (observer tab / second device / post-approval continuation
770
+ // turn). Open a fresh bubble unless the trailing assistant is empty
771
+ // (our own optimistic placeholder) or is the incomplete history tail
772
+ // the catchup replay is legitimately re-streaming (adopt-once flag).
773
+ const adoptTail = adoptTrailingAssistant
774
+ adoptTrailingAssistant = false
775
+ const last = messages[messages.length - 1]
776
+ if (!last || last.role !== 'assistant' || adoptTail) return
777
+ const hasContent = (last.segments?.length ?? 0) > 0 || last.content !== ''
778
+ if (!hasContent) return
779
+ setMessagesInternal([
780
+ ...messages,
781
+ { id: nextId('assistant'), role: 'assistant', content: '', segments: [] },
782
+ ])
783
+ }
784
+
785
+ /** MESSAGE_REQUEST echo — a user message from THIS or another session.
786
+ * Dedup layers in priority order: seq identity → one-shot optimistic-echo
787
+ * consumption → same-author content window (seq-less transports only). */
788
+ function applyUserMessageToState(ev: ParticipantEvent): void {
789
+ const text = ev.text
790
+ if (!text) return
791
+ const seq = ev.seq
792
+ // ADMIN rows are only echo-consumable when the host declares that its
793
+ // operator IS the admin (see `ownEchoIncludesAdmin`). Otherwise an
794
+ // ADMIN row is someone else's message and must always render.
795
+ const adminEchoAllowed =
796
+ ev.ownerType !== CHAT_OWNER_ADMIN || options.ownEchoIncludesAdmin === true
797
+ // AUTHOR guard: with `selfUserId` configured, a row that DECLARES a
798
+ // DIFFERENT author may not consume an echo entry. Two technicians on the
799
+ // same ADMIN side both author ADMIN rows, so text alone cannot tell "my
800
+ // echo" from "their message".
801
+ //
802
+ // It fails OPEN on an id-less row: the decoder may not surface the author
803
+ // (the ticket wire model declares it at `owner.userId`, and the app's id
804
+ // may live in another id space entirely), and treating "unattributed" as
805
+ // "not mine" would disable dedup outright — every send rendered TWICE,
806
+ // strictly worse than the bug the guard fixes. Unattributed rows fall back
807
+ // to the text + TTL behaviour, with a one-shot warning so the
808
+ // misconfiguration is observable.
809
+ const selfUserId = resolveSelfUserId()
810
+ const authorDeclared = typeof ev.userId === 'string' && ev.userId !== ''
811
+ if (selfUserId !== undefined && !authorDeclared) warnIdLessAuthor()
812
+ const authorMatched = selfUserId !== undefined && authorDeclared && ev.userId === selfUserId
813
+ const authorEchoAllowed = selfUserId === undefined || !authorDeclared || authorMatched
814
+ if (adminEchoAllowed && authorEchoAllowed && consumeOwnEcho(text, authorMatched)) return
815
+ const authorType = ev.ownerType === CHAT_OWNER_ADMIN ? ('admin' as const) : ('user' as const)
816
+ if (
817
+ typeof seq !== 'number' &&
818
+ hasRecentMessage(
819
+ messages,
820
+ (m) => m.role === 'user' && (m.authorType ?? 'user') === authorType && m.content === text,
821
+ CONTENT_DEDUP_WINDOW,
822
+ )
823
+ ) {
824
+ return
825
+ }
826
+ setMessagesInternal([
827
+ ...messages,
828
+ {
829
+ id: nextId('user'),
830
+ role: 'user',
831
+ content: text,
832
+ // Coverage signal for the history merge — see `streamSeq` on
833
+ // `UnifiedChatMessage`. For `user-` rows it is also what tells a
834
+ // REPLAYED echo (history already has its row) apart from a genuine new
835
+ // send that merely repeats an earlier text.
836
+ ...(typeof seq === 'number' ? { streamSeq: seq } : {}),
837
+ ...(ev.displayName ? { name: ev.displayName } : {}),
838
+ authorType,
839
+ ...(ev.contextItems && ev.contextItems.length > 0
840
+ ? { contextItems: ev.contextItems }
841
+ : {}),
842
+ },
843
+ ])
844
+ }
845
+
846
+ /** Technician / admin direct message into the dialog. */
847
+ function applyDirectMessageToState(ev: ParticipantEvent): void {
848
+ const text = ev.text
849
+ if (!text) return
850
+ const seq = ev.seq
851
+ // Seq-less fallback matches only prior ADMIN-authored twins — a client's
852
+ // identical text must never suppress the technician's.
853
+ if (
854
+ typeof seq !== 'number' &&
855
+ hasRecentMessage(
856
+ messages,
857
+ (m) => m.role === 'user' && m.authorType === 'admin' && m.content === text,
858
+ CONTENT_DEDUP_WINDOW,
859
+ )
860
+ ) {
861
+ return
862
+ }
863
+ setMessagesInternal([
864
+ ...messages,
865
+ {
866
+ id: `direct-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
867
+ role: 'user',
868
+ content: text,
869
+ ...(typeof seq === 'number' ? { streamSeq: seq } : {}),
870
+ name: ev.displayName ?? 'Admin',
871
+ authorType: 'admin',
872
+ },
873
+ ])
874
+ }
875
+
876
+ /** System notice — rendered as a name-only row (same shape the history
877
+ * processor produces via its standalone-SYSTEM path). */
878
+ function applySystemMessageToState(ev: ParticipantEvent): void {
879
+ const text = ev.text
880
+ if (!text) return
881
+ const seq = ev.seq
882
+ if (
883
+ typeof seq !== 'number' &&
884
+ hasRecentMessage(
885
+ messages,
886
+ (m) => m.authorType === 'system' && m.name === text,
887
+ SYSTEM_DEDUP_WINDOW,
888
+ )
889
+ ) {
890
+ return
891
+ }
892
+ setMessagesInternal([
893
+ ...messages,
894
+ {
895
+ id: `system-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
896
+ role: 'user',
897
+ content: '',
898
+ ...(typeof seq === 'number' ? { streamSeq: seq } : {}),
899
+ name: text,
900
+ authorType: 'system',
901
+ },
902
+ ])
903
+ }
904
+
905
+ function applyNats(event: ChatStreamEvent): void {
906
+ const seq = event.seq
907
+ const withSeqMeta = (meta?: SegmentsUpdateMetadata): SegmentsUpdateMetadata | undefined =>
908
+ seq != null ? { ...meta, streamSeq: seq } : meta
909
+ const emitSegments = (segments: MessageSegment[], meta?: SegmentsUpdateMetadata) =>
910
+ emit('onSegmentsUpdate', segments, withSeqMeta(meta))
911
+
912
+ /**
913
+ * Emit + apply an accumulator result, choosing REPLACE vs APPEND the same
914
+ * way `text-delta` does — and it must be chosen, not defaulted.
915
+ *
916
+ * `turn-end` calls `accumulator.resetSegments()`, so after a turn closes the
917
+ * accumulator's cumulative array holds ONLY what was added since. Applying
918
+ * that array in replace mode overwrites the finished answer: a
919
+ * post-MESSAGE_END approval card or error used to reduce a completed bubble
920
+ * to `[approval_request]` / `[error]` and the reply was gone. Post-END
921
+ * arrivals are routine (an approved command executes between the approval
922
+ * bubble and the continuation stream, and catchup replays everything after
923
+ * the last MESSAGE_END with no preceding `turn-start`).
924
+ *
925
+ * `before` is the accumulator's segment count taken BEFORE the add, so the
926
+ * appended slice is exactly the new segments — never a hand-rebuilt copy
927
+ * that could drift from the accumulator's shape (which stamps
928
+ * `onApprove`/`onReject` handlers), and never a positional guess. An add
929
+ * that UPSERTS in place (a redelivered card) yields an empty slice and is
930
+ * correctly a no-op here: status flips travel through `approval-resolved`.
931
+ *
932
+ * Cold start (`!hasEverStreamed`) stays cumulative: with no bubble to append
933
+ * into, the first card of a session would otherwise render nothing.
934
+ */
935
+ const applyAccumulated = (before: number, segments: MessageSegment[]): void => {
936
+ if (isInStream || !hasEverStreamed) {
937
+ emitSegments(segments)
938
+ applySegmentsToState(segments, withSeqMeta(undefined))
939
+ return
940
+ }
941
+ const added = segments.slice(before)
942
+ if (added.length === 0) return
943
+ emitSegments(added, { append: true })
944
+ applySegmentsToState(added, withSeqMeta({ append: true }))
945
+ }
946
+
947
+ if (event.type === 'participant' && event.kind === 'direct-message') {
948
+ sawDirectMessage = true
949
+ }
950
+
951
+ if ((directModeFlag || sawDirectMessage) && !isDirectModeAllowed(event)) {
952
+ // Hard stop: tear down AI activity exactly once so the typing
953
+ // indicator, composer lock, and half-rendered bubble clear, then drop
954
+ // the event. Fires even when no stream is open — an agent-busy lock
955
+ // (approved commands executing post-MESSAGE_END) has no other release
956
+ // once the barrier starts dropping the continuation chunks.
957
+ if (isInStream || !directTeardownFired) {
958
+ isInStream = false
959
+ directTeardownFired = true
960
+ emit('onStreamEnd')
961
+ setPhaseInternal('idle')
962
+ accumulator.resetSegments()
963
+ }
964
+ return
965
+ }
966
+
967
+ switch (event.type) {
968
+ case 'turn-start': {
969
+ isInStream = true
970
+ hasEverStreamed = true
971
+ turnStartedAt = Date.now()
972
+ emit('onStreamStart')
973
+ applyStreamStartToState()
974
+ accumulator.resetSegments()
975
+ break
976
+ }
977
+
978
+ case 'turn-end': {
979
+ isInStream = false
980
+ purgeEchoesAtTurnEnd()
981
+ emit('onStreamEnd')
982
+ setPhaseInternal('idle')
983
+ accumulator.resetSegments()
984
+ break
985
+ }
986
+
987
+ case 'metadata': {
988
+ // Legacy `parseChunkToAction` action shape, reconstructed for the
989
+ // callback contract.
990
+ emit('onMetadata', {
991
+ action: 'metadata',
992
+ modelDisplayName: event.modelLabel ?? undefined,
993
+ modelName: event.modelName,
994
+ providerName: event.provider,
995
+ contextWindow: event.contextWindowMaxTokens,
996
+ })
997
+ liveModel = {
998
+ provider: event.provider || null,
999
+ modelLabel: event.modelLabel || event.modelName || null,
1000
+ contextWindowMaxTokens: event.contextWindowMaxTokens || null,
1001
+ }
1002
+ invalidate()
1003
+ break
1004
+ }
1005
+
1006
+ // The three APPEND-ONLY body streams share one shape: accumulate the
1007
+ // verbatim slice into the trailing segment of the matching kind (the
1008
+ // accumulator coalesces), then emit. `guide-delta` is NATS-only today
1009
+ // but rides the identical path so a guide body streams, coalesces and
1010
+ // continues post-MESSAGE_END exactly like answer text.
1011
+ case 'text-delta':
1012
+ case 'thinking-delta':
1013
+ case 'guide-delta': {
1014
+ const kind =
1015
+ event.type === 'text-delta'
1016
+ ? 'text'
1017
+ : event.type === 'thinking-delta'
1018
+ ? 'thinking'
1019
+ : 'guide'
1020
+ const segments =
1021
+ kind === 'text'
1022
+ ? accumulator.appendText(event.text)
1023
+ : kind === 'thinking'
1024
+ ? accumulator.appendThinking(event.text)
1025
+ : accumulator.appendGuide(event.text)
1026
+ // Append-mode only for *true* post-stream continuation (after a
1027
+ // MESSAGE_END we actually saw). Cold-start chunks (no prior
1028
+ // MESSAGE_START) emit cumulative segments so the consumer can spawn
1029
+ // the first assistant bubble.
1030
+ if (isInStream || !hasEverStreamed) {
1031
+ emitSegments(segments)
1032
+ applySegmentsToState(segments, withSeqMeta(undefined))
1033
+ } else {
1034
+ const delta: MessageSegment[] = [{ type: kind, text: event.text }]
1035
+ emitSegments(delta, { append: true })
1036
+ applySegmentsToState(delta, withSeqMeta({ append: true }))
1037
+ }
1038
+ break
1039
+ }
1040
+
1041
+ case 'tool-execution': {
1042
+ const segment: ToolExecutionSegment = { type: 'tool_execution', data: event.data }
1043
+ // A starting tool run means the agent's turn is in progress even
1044
+ // when this lands after MESSAGE_END (approved commands execute
1045
+ // between the approval bubble and the continuation stream).
1046
+ if (event.data.type === 'EXECUTING_TOOL') {
1047
+ emit('onAgentBusy')
1048
+ agentBusyState()
1049
+ }
1050
+ // Post-MESSAGE_END tool chunks (cancellations / async batch results
1051
+ // for a batch in a prior bubble) flow only through the cross-message
1052
+ // updater. Skipping the accumulator avoids pushing a standalone
1053
+ // segment that the next text chunk would replay into a new bubble.
1054
+ if (!isInStream && crossMessageToolRouting) {
1055
+ emit('onToolExecuted', segment)
1056
+ setMessagesInternal(applyToolExecutionToMessages(messages, segment))
1057
+ break
1058
+ }
1059
+ // In-stream: accumulator-driven update of the streaming bubble is
1060
+ // the source of truth. The cross-message scan is first-match-wins
1061
+ // and could touch a same-execId segment in a prior bubble (agent
1062
+ // retry case), so it is NOT run here.
1063
+ const segments = accumulator.addToolExecution(segment)
1064
+ emitSegments(segments)
1065
+ applySegmentsToState(segments, withSeqMeta(undefined))
1066
+ break
1067
+ }
1068
+
1069
+ case 'approval-request': {
1070
+ const requestId = event.requestId
1071
+ const approvalType = event.approvalType ?? 'USER'
1072
+ const toolCalls = event.toolCalls
1073
+
1074
+ if (toolCalls && toolCalls.length > 0) {
1075
+ // ── Batch form ──
1076
+ const status = (approvalStatuses[requestId] || 'pending') as ChatApprovalStatus
1077
+ if (!displayApprovalTypes.includes(approvalType)) {
1078
+ // Escalated: keep batch context locally for replay on result;
1079
+ // surface a summary command via the legacy escalation callback.
1080
+ const required = toolCalls.find((c) => c.requiresApproval) ?? toolCalls[0]
1081
+ const summary = required
1082
+ ? getCommandText(required)
1083
+ : `Batch of ${toolCalls.length} tool calls`
1084
+ pendingEscalated.set(requestId, {
1085
+ command: summary,
1086
+ explanation: required?.toolExplanation,
1087
+ approvalType,
1088
+ toolCalls,
1089
+ })
1090
+ emit('onEscalatedApproval', requestId, {
1091
+ command: summary,
1092
+ explanation: required?.toolExplanation,
1093
+ approvalType,
1094
+ })
1095
+ break
1096
+ }
1097
+ if (batchApprovalsEnabled) {
1098
+ const before = accumulator.getSegments().length
1099
+ const segments = accumulator.addApprovalBatch(requestId, approvalType, toolCalls, status)
1100
+ applyAccumulated(before, segments)
1101
+ break
1102
+ }
1103
+ // Flag OFF — unfold batch into N legacy approval cards. They share
1104
+ // `requestId`, so a click on any will approve the whole batch via a
1105
+ // single backend call, and the resulting APPROVAL_RESULT event will
1106
+ // flip status on every matching segment.
1107
+ let segments = accumulator.getSegments()
1108
+ const before = segments.length
1109
+ for (const call of toolCalls) {
1110
+ if (!call.requiresApproval) continue
1111
+ segments = accumulator.addApprovalRequest(
1112
+ requestId,
1113
+ getCommandText(call),
1114
+ call.toolExplanation,
1115
+ approvalType,
1116
+ status,
1117
+ )
1118
+ }
1119
+ applyAccumulated(before, segments)
1120
+ break
1121
+ }
1122
+
1123
+ // ── Single form ──
1124
+ const command = event.command ?? ''
1125
+ const explanation = event.explanation
1126
+ if (displayApprovalTypes.includes(approvalType)) {
1127
+ const status = (approvalStatuses[requestId] || 'pending') as ChatApprovalStatus
1128
+ const before = accumulator.getSegments().length
1129
+ const segments = accumulator.addApprovalRequest(
1130
+ requestId,
1131
+ command,
1132
+ explanation,
1133
+ approvalType,
1134
+ status,
1135
+ )
1136
+ applyAccumulated(before, segments)
1137
+ } else {
1138
+ pendingEscalated.set(requestId, { command, explanation, approvalType })
1139
+ emit('onEscalatedApproval', requestId, { command, explanation, approvalType })
1140
+ }
1141
+ break
1142
+ }
1143
+
1144
+ case 'approval-resolved': {
1145
+ const requestId = event.requestId ?? ''
1146
+ const status = event.status
1147
+ const approved = status === 'approved'
1148
+ const approvalType = event.approvalType ?? 'CLIENT'
1149
+ const resolvedByName = event.resolvedByName
1150
+ // Approved → the agent resumes to execute the command(s); surface
1151
+ // busy immediately so the composer locks before EXECUTING_TOOL
1152
+ // lands. Rejection keeps the input free — the user may want to type
1153
+ // a correction right away.
1154
+ if (approved) {
1155
+ emit('onAgentBusy')
1156
+ agentBusyState()
1157
+ }
1158
+ const escalatedData = pendingEscalated.get(requestId)
1159
+
1160
+ if (escalatedData) {
1161
+ pendingEscalated.delete(requestId)
1162
+ emit('onEscalatedApprovalResult', requestId, approved, {
1163
+ command: escalatedData.command,
1164
+ explanation: escalatedData.explanation,
1165
+ approvalType: escalatedData.approvalType,
1166
+ })
1167
+
1168
+ // The escalated card was never displayed inline, so this emit is
1169
+ // what surfaces it after resolution. In-stream the cumulative
1170
+ // array is correct; post-MESSAGE_END the accumulator was RESET, so
1171
+ // a cumulative (replace-mode) emit would wipe the trailing bubble
1172
+ // down to the lone card — emit only the resolved card(s) as an
1173
+ // append instead. Appends upsert by request id, so a replayed
1174
+ // append stays idempotent.
1175
+ const emitResolved = (segments: MessageSegment[]) => {
1176
+ if (isInStream) {
1177
+ emitSegments(segments)
1178
+ applySegmentsToState(segments, withSeqMeta(undefined))
1179
+ return
1180
+ }
1181
+ const delta = segments.filter(
1182
+ (s) =>
1183
+ (s.type === 'approval_request' && s.data.requestId === requestId) ||
1184
+ (s.type === 'approval_batch' && s.data.approvalRequestId === requestId),
1185
+ )
1186
+ emitSegments(delta, { append: true })
1187
+ applySegmentsToState(delta, withSeqMeta({ append: true }))
1188
+ }
1189
+
1190
+ if (escalatedData.toolCalls && escalatedData.toolCalls.length > 0) {
1191
+ if (batchApprovalsEnabled) {
1192
+ emitResolved(
1193
+ accumulator.addApprovalBatch(
1194
+ requestId,
1195
+ escalatedData.approvalType,
1196
+ escalatedData.toolCalls,
1197
+ status,
1198
+ undefined,
1199
+ resolvedByName,
1200
+ ),
1201
+ )
1202
+ } else {
1203
+ let segments = accumulator.getSegments()
1204
+ for (const call of escalatedData.toolCalls) {
1205
+ if (!call.requiresApproval) continue
1206
+ segments = accumulator.addApprovalRequest(
1207
+ requestId,
1208
+ getCommandText(call),
1209
+ call.toolExplanation,
1210
+ escalatedData.approvalType,
1211
+ status,
1212
+ )
1213
+ }
1214
+ emitResolved(segments)
1215
+ }
1216
+ } else {
1217
+ emitResolved(
1218
+ accumulator.addApprovalRequest(
1219
+ requestId,
1220
+ escalatedData.command,
1221
+ escalatedData.explanation,
1222
+ escalatedData.approvalType,
1223
+ status,
1224
+ ),
1225
+ )
1226
+ }
1227
+ } else {
1228
+ // Always keep the in-memory accumulator in sync so a following
1229
+ // text/tool event replays the resolved status into the message.
1230
+ accumulator.updateApprovalStatus(requestId, status, resolvedByName)
1231
+ // Conditional legacy emission — forwarded as onSegmentsUpdate only
1232
+ // when the consumer did NOT wire onApprovalResolved (the wrapper
1233
+ // resolves the condition at dispatch time).
1234
+ emit('segments-after-approval-result', accumulator.getSegments(), withSeqMeta(undefined))
1235
+ }
1236
+
1237
+ // Cross-message status flip + status-map mirror (absorbed adapter
1238
+ // handler) — runs for both branches, matching the legacy adapter
1239
+ // whose onApprovalResolved callback fired unconditionally.
1240
+ setMessagesInternal(
1241
+ projectApprovalResolutionToMessages(messages, requestId, status, resolvedByName),
1242
+ )
1243
+ mirrorApprovalStatus(requestId, status)
1244
+ emit('onApprovalResolved', requestId, status, approvalType, resolvedByName)
1245
+ break
1246
+ }
1247
+
1248
+ case 'error': {
1249
+ let message: string | undefined
1250
+ if (event.details) {
1251
+ try {
1252
+ message = JSON.parse(event.details)?.error?.message
1253
+ } catch {
1254
+ message = event.details
1255
+ }
1256
+ }
1257
+ const before = accumulator.getSegments().length
1258
+ const segments = accumulator.addError(event.title, message)
1259
+ applyAccumulated(before, segments)
1260
+ emit('onError', event.title, message)
1261
+ // Terminal turn failures can arrive without a MESSAGE_END; unlock
1262
+ // the composer unless an open stream still owns the phase.
1263
+ if (streamingPhase !== 'streaming') setPhaseInternal('idle')
1264
+ break
1265
+ }
1266
+
1267
+ case 'participant': {
1268
+ if (event.kind === 'message-request') {
1269
+ emit('onUserMessage', event.text, {
1270
+ ownerType: event.ownerType,
1271
+ displayName: event.displayName,
1272
+ userId: event.userId,
1273
+ streamSeq: seq,
1274
+ contextItems: event.contextItems,
1275
+ })
1276
+ applyUserMessageToState(event)
1277
+ } else if (event.kind === 'direct-message') {
1278
+ emit('onDirectMessage', event.text, {
1279
+ ownerType: event.ownerType,
1280
+ displayName: event.displayName,
1281
+ userId: event.userId,
1282
+ streamSeq: seq,
1283
+ })
1284
+ applyDirectMessageToState(event)
1285
+ } else {
1286
+ emit('onSystemMessage', event.text, { streamSeq: seq })
1287
+ applySystemMessageToState(event)
1288
+ }
1289
+ break
1290
+ }
1291
+
1292
+ case 'token-usage': {
1293
+ const data = {
1294
+ inputTokensSize: event.inputTokensSize,
1295
+ outputTokensSize: event.outputTokensSize,
1296
+ totalTokensSize: event.totalTokensSize,
1297
+ contextSize: event.contextSize,
1298
+ }
1299
+ emit('onTokenUsage', data)
1300
+ dialogTokenUsage = data
1301
+ invalidate()
1302
+ break
1303
+ }
1304
+
1305
+ case 'compaction': {
1306
+ const standalone = !isInStream
1307
+ const segments =
1308
+ event.phase === 'start'
1309
+ ? accumulator.addContextCompaction()
1310
+ : accumulator.completeContextCompaction(event.summary)
1311
+ const meta = standalone ? ({ append: true, isCompacting: true } as const) : undefined
1312
+ emitSegments(segments, meta)
1313
+ applySegmentsToState(segments, withSeqMeta(meta))
1314
+ break
1315
+ }
1316
+
1317
+ case 'dialog-closed': {
1318
+ emit('onDialogClosed')
1319
+ break
1320
+ }
1321
+
1322
+ default:
1323
+ // Unknown / SSE-only event on the NATS kernel — ignore.
1324
+ break
1325
+ }
1326
+ }
1327
+
1328
+ // ===========================================================================
1329
+ // SSE kernel — the absorbed useChat merge + decision_resolved receipt path
1330
+ // ===========================================================================
1331
+
1332
+ /** Replace the trailing text segment of the LAST assistant message with
1333
+ * the cumulative turn text (or push one). Mirrors `useChat`'s
1334
+ * currentTextSegment handling byte-for-byte. */
1335
+ function sseWriteTrailingText(text: string): void {
1336
+ const last = messages[messages.length - 1]
1337
+ if (!last || last.role !== 'assistant') return
1338
+ const segments = [...(last.segments ?? [])]
1339
+ const tail = segments[segments.length - 1]
1340
+ if (tail && tail.type === 'text') {
1341
+ segments[segments.length - 1] = { type: 'text', text }
1342
+ } else {
1343
+ segments.push({ type: 'text', text })
1344
+ }
1345
+ setMessagesInternal([...messages.slice(0, -1), { ...last, segments }])
1346
+ }
1347
+
1348
+ function sseAppendText(text: string): void {
1349
+ sseCurrentText += text
1350
+ sseWriteTrailingText(sseCurrentText)
1351
+ }
1352
+
1353
+ /** Single thinking segment per message, ALWAYS before the answer text:
1354
+ * replaced in place when present, inserted at the FRONT otherwise. */
1355
+ function sseAppendThinking(escapedDelta: string): void {
1356
+ const last = messages[messages.length - 1]
1357
+ if (!last || last.role !== 'assistant') return
1358
+ const segments = [...(last.segments ?? [])]
1359
+ const idx = segments.findIndex((s) => s.type === 'thinking')
1360
+ if (idx !== -1) {
1361
+ const existing = segments[idx] as { type: 'thinking'; text: string }
1362
+ segments[idx] = { type: 'thinking', text: existing.text + escapedDelta }
1363
+ } else {
1364
+ segments.unshift({ type: 'thinking', text: escapedDelta })
1365
+ }
1366
+ setMessagesInternal([...messages.slice(0, -1), { ...last, segments }])
1367
+ }
1368
+
1369
+ function mergeTurnMeta(sendIdx: number, partial: Partial<ChatTurnMeta>): void {
1370
+ const prev = metaMap.get(sendIdx) ?? createEmptyTurnMeta()
1371
+ const filtered = Object.fromEntries(
1372
+ Object.entries(partial).filter(([, v]) => v !== undefined && v !== null),
1373
+ ) as Partial<ChatTurnMeta>
1374
+ metaMap.set(sendIdx, { ...prev, ...filtered })
1375
+ invalidate()
1376
+ }
1377
+
1378
+ function applySseApprovalResolved(event: ApprovalResolvedEvent, sendIdx: number): void {
1379
+ // Inline post-approve card ref → per-send refs map (the marker in the
1380
+ // receipt resolves through it on rehydration).
1381
+ const cardRef = event.cardRef as ChatRef | undefined
1382
+ if (cardRef?.id && event.cardType) {
1383
+ const existing = refsMap.get(sendIdx) ?? {}
1384
+ const key = buildChatRefKey(event.cardType, cardRef.id)
1385
+ refsMap.set(sendIdx, { ...existing, [key]: cardRef })
1386
+ invalidate()
1387
+ }
1388
+ const proposalId = event.requestId
1389
+ if (!proposalId) return
1390
+
1391
+ // Step 1 — flip the SOURCE card's status (the approval card lives in an
1392
+ // EARLIER assistant message than the one being streamed into).
1393
+ setMessagesInternal(
1394
+ projectApprovalResolutionToMessages(messages, proposalId, event.status),
1395
+ )
1396
+
1397
+ // Step 2 — server-rendered receipt into the CURRENT message. No
1398
+ // server-provided copy → don't fabricate a fallback.
1399
+ const receipt = typeof event.receiptText === 'string' ? event.receiptText : null
1400
+ if (receipt === null) return
1401
+ sseCurrentText = receipt + '\n\n'
1402
+ sseWriteTrailingText(sseCurrentText)
1403
+
1404
+ // Step 3 — stamp the ref onto THIS assistant message so the
1405
+ // `[card://<type>:<id>]` marker resolves via `message.chatRefs`
1406
+ // independent of per-turn refsMap indexing.
1407
+ const refForMessage =
1408
+ cardRef && typeof (cardRef as { type?: unknown }).type === 'string' && typeof cardRef.id === 'string'
1409
+ ? cardRef
1410
+ : null
1411
+ if (refForMessage) {
1412
+ const last = messages[messages.length - 1]
1413
+ if (last && last.role === 'assistant') {
1414
+ const refKey = buildChatRefKey((refForMessage as { type: string }).type, refForMessage.id)
1415
+ setMessagesInternal([
1416
+ ...messages.slice(0, -1),
1417
+ { ...last, chatRefs: { ...(last.chatRefs ?? {}), [refKey]: refForMessage } },
1418
+ ])
1419
+ }
1420
+ }
1421
+ }
1422
+
1423
+ function applySseMetadata(event: ChatMetadataEvent, sendIdx: number): void {
1424
+ if (event.routing) {
1425
+ mergeTurnMeta(sendIdx, {
1426
+ routedComplexity: event.routing.routedComplexity,
1427
+ routedThinkingBudget: event.routing.routedThinkingBudget,
1428
+ })
1429
+ return
1430
+ }
1431
+ if (event.sources) {
1432
+ sourcesMap.set(sendIdx, event.sources as unknown[])
1433
+ invalidate()
1434
+ }
1435
+ if (event.refs && typeof event.refs === 'object') {
1436
+ refsMap.set(sendIdx, event.refs as Record<string, ChatRef>)
1437
+ invalidate()
1438
+ }
1439
+ if (event.modelLabel || event.contextWindowMaxTokens || event.provider || event.modelName) {
1440
+ mergeTurnMeta(sendIdx, {
1441
+ provider: event.provider ?? null,
1442
+ modelLabel: event.modelLabel ?? null,
1443
+ contextWindowMaxTokens: event.contextWindowMaxTokens ?? null,
1444
+ })
1445
+ }
1446
+ const parsedAnchor = parseScrollAnchor(event.scrollAnchor)
1447
+ if (parsedAnchor !== null) {
1448
+ mergeTurnMeta(sendIdx, { scrollAnchor: parsedAnchor })
1449
+ }
1450
+ }
1451
+
1452
+ function applySseUsage(event: UsageEvent, sendIdx: number): void {
1453
+ if (event.stage === 'start') {
1454
+ mergeTurnMeta(sendIdx, { inputTokens: event.input_tokens ?? null })
1455
+ return
1456
+ }
1457
+ // stage === 'end' — the trailing usage frame. Raw wire values pass the
1458
+ // legacy truthiness/typeof gates so a malformed frame degrades
1459
+ // identically to the pre-SSOT parser.
1460
+ const rawBreakdown = event.breakdown as Record<string, any> | null | undefined
1461
+ const breakdown: UnifiedUsageBreakdown | null =
1462
+ rawBreakdown && typeof rawBreakdown === 'object'
1463
+ ? {
1464
+ haikuRewriter:
1465
+ rawBreakdown.haikuRewriter && typeof rawBreakdown.haikuRewriter.input === 'number'
1466
+ ? rawBreakdown.haikuRewriter
1467
+ : undefined,
1468
+ haikuClassifier:
1469
+ rawBreakdown.haikuClassifier && typeof rawBreakdown.haikuClassifier.input === 'number'
1470
+ ? rawBreakdown.haikuClassifier
1471
+ : undefined,
1472
+ haikuSummarizer:
1473
+ rawBreakdown.haikuSummarizer && typeof rawBreakdown.haikuSummarizer.input === 'number'
1474
+ ? rawBreakdown.haikuSummarizer
1475
+ : undefined,
1476
+ routedAnswer:
1477
+ rawBreakdown.routedAnswer && typeof rawBreakdown.routedAnswer.model === 'string'
1478
+ ? rawBreakdown.routedAnswer
1479
+ : undefined,
1480
+ }
1481
+ : null
1482
+ mergeTurnMeta(sendIdx, {
1483
+ inputTokens: event.input_tokens ?? null,
1484
+ outputTokens: event.output_tokens ?? null,
1485
+ cacheHitRatePct: typeof event.hit_rate_pct === 'number' ? event.hit_rate_pct : null,
1486
+ ...(breakdown ? { breakdown } : {}),
1487
+ })
1488
+ }
1489
+
1490
+ function applySse(event: ChatStreamEvent): void {
1491
+ const sendIdx = sendCount - 1
1492
+ switch (event.type) {
1493
+ case 'status':
1494
+ setPhaseInternal('thinking')
1495
+ break
1496
+ case 'thinking-delta':
1497
+ sseAppendThinking(escapeThinkingTags(event.text))
1498
+ break
1499
+ case 'turn-start':
1500
+ setPhaseInternal('streaming')
1501
+ break
1502
+ case 'text-delta':
1503
+ if (!event.leading) setPhaseInternal('streaming')
1504
+ sseAppendText(event.text)
1505
+ break
1506
+ case 'error':
1507
+ // Legacy parity: SSE tool errors surface as answer text.
1508
+ sseAppendText(`⚠️ ${event.title}`)
1509
+ break
1510
+ case 'approval-request': {
1511
+ // Flush any accumulated preamble text so the card lands after it.
1512
+ if (sseCurrentText) {
1513
+ sseWriteTrailingText(sseCurrentText)
1514
+ sseCurrentText = ''
1515
+ }
1516
+ const segment: ApprovalRequestSegment = {
1517
+ type: 'approval_request',
1518
+ data: {
1519
+ command: event.command ?? '',
1520
+ fields: event.fields,
1521
+ requestId: event.requestId,
1522
+ approvalType: event.approvalType,
1523
+ },
1524
+ status: 'pending',
1525
+ onApprove: callbacks.onApprove,
1526
+ onReject: callbacks.onReject,
1527
+ }
1528
+ const last = messages[messages.length - 1]
1529
+ if (last && last.role === 'assistant') {
1530
+ setMessagesInternal([
1531
+ ...messages.slice(0, -1),
1532
+ { ...last, segments: [...(last.segments ?? []), segment] },
1533
+ ])
1534
+ }
1535
+ break
1536
+ }
1537
+ case 'approval-resolved':
1538
+ applySseApprovalResolved(event, sendIdx)
1539
+ break
1540
+ case 'metadata':
1541
+ applySseMetadata(event, sendIdx)
1542
+ break
1543
+ case 'usage':
1544
+ applySseUsage(event, sendIdx)
1545
+ break
1546
+ // NOTE: 'tool-execution' is intentionally NOT handled on the SSE
1547
+ // kernel — the SSE wire never carries it, and routing it through the
1548
+ // accumulator would invoke resolvePendingApprovalForExecution, which
1549
+ // is a NATS-only semantic (observer streams without APPROVAL_RESULT).
1550
+ default:
1551
+ break
1552
+ }
1553
+ }
1554
+
1555
+ // ===========================================================================
1556
+ // Public surface
1557
+ // ===========================================================================
1558
+
1559
+ function apply(event: ChatStreamEvent): void {
1560
+ // seq-based idempotency: drop redelivered / out-of-order events.
1561
+ if (typeof event.seq === 'number') {
1562
+ if (event.seq <= lastAppliedSeq) return
1563
+ lastAppliedSeq = event.seq
1564
+ }
1565
+ if (transport === 'sse') applySse(event)
1566
+ else applyNats(event)
1567
+ }
1568
+
1569
+ function reset(): void {
1570
+ messages = []
1571
+ streamingPhase = 'idle'
1572
+ dialogTokenUsage = null
1573
+ liveModel = null
1574
+ approvalStatuses = {}
1575
+ accumulator.reset()
1576
+ pendingEscalated.clear()
1577
+ isInStream = false
1578
+ hasEverStreamed = false
1579
+ sawDirectMessage = false
1580
+ directTeardownFired = false
1581
+ lastAppliedSeq = Number.NEGATIVE_INFINITY
1582
+ pendingEchoTexts = []
1583
+ turnStartedAt = 0
1584
+ adoptTrailingAssistant = false
1585
+ metaMap.clear()
1586
+ sourcesMap.clear()
1587
+ refsMap.clear()
1588
+ sendCount = 0
1589
+ sseCurrentText = ''
1590
+ invalidate()
1591
+ }
1592
+
1593
+ /**
1594
+ * Per-dialog reset. EVERYTHING scoped to one dialog is dropped; the two
1595
+ * deliberate survivors are called out below, because "the full reset minus
1596
+ * an explicit, argued list" is the only form of this function that does not
1597
+ * rot — a field added to `reset()` and forgotten here is a cross-dialog leak,
1598
+ * and that is exactly how `sseCurrentText` came to bleed (see the SSE block).
1599
+ *
1600
+ * SURVIVOR 1 — `approvalStatuses`: request ids are globally unique, and a
1601
+ * resolved approval must not re-render as actionable when its
1602
+ * APPROVAL_RESULT row is missing from the refetched history page.
1603
+ *
1604
+ * SURVIVOR 2 — `suppressAgentBusy`: NOT dialog state, but a refcount for an
1605
+ * IN-FLIGHT operation, owned by the caller's `+1` / `-1` pairing around a
1606
+ * catchup window. Zeroing it here would break that pairing in exactly the
1607
+ * race the suppression exists for: on an A → B → A re-entry, A's first
1608
+ * catchup can still be replaying its dead tail (`EXECUTING_TOOL` chunks whose
1609
+ * releasing `MESSAGE_END` never replays) when the switch back resets, so
1610
+ * clearing the count would let that replay lock the composer — the bug
1611
+ * `adjustAgentBusySuppression` was introduced to prevent. The pairing is
1612
+ * already safe across a switch (`Math.max(0, …)` absorbs an over-decrement
1613
+ * and each `finally` is scoped to its own captured dialogId), so leaving the
1614
+ * count alone is both correct and sufficient. Pinned by the
1615
+ * "agent-busy suppression survives a dialog switch" test.
1616
+ */
1617
+ function resetForDialogSwitch(): void {
1618
+ messages = []
1619
+ streamingPhase = 'idle'
1620
+ dialogTokenUsage = null
1621
+ liveModel = null
1622
+ accumulator.reset()
1623
+ pendingEscalated.clear()
1624
+ isInStream = false
1625
+ hasEverStreamed = false
1626
+ sawDirectMessage = false
1627
+ directTeardownFired = false
1628
+ lastAppliedSeq = Number.NEGATIVE_INFINITY
1629
+ pendingEchoTexts = []
1630
+ turnStartedAt = 0
1631
+ adoptTrailingAssistant = false
1632
+ // SSE per-turn kernel. `sseCurrentText` is the CUMULATIVE answer text of
1633
+ // the turn being streamed, and `sseAppendText` only ever appends to it —
1634
+ // so leaving it behind prepends the previous dialog's whole answer to the
1635
+ // next `text-delta` that lands without an intervening `beginSseSend`
1636
+ // (the resumed-mid-turn shape: restore history, then live deltas flow).
1637
+ // The per-send maps are keyed by `sendCount`, which is likewise per-dialog;
1638
+ // a host that needs them restored has `seedSseMaps`.
1639
+ sseCurrentText = ''
1640
+ metaMap.clear()
1641
+ sourcesMap.clear()
1642
+ refsMap.clear()
1643
+ sendCount = 0
1644
+ invalidate()
1645
+ }
1646
+
1647
+ /** Canonical state-monotonic merge of a persisted / parked status map.
1648
+ * "resolved beats pending" in BOTH directions — see the interface doc on
1649
+ * `mergeApprovalStatuses`, which is this function. */
1650
+ function mergeApprovalStatusesInternal(
1651
+ persisted: Record<string, ChatApprovalStatus>,
1652
+ ): void {
1653
+ const keys = Object.keys(persisted)
1654
+ if (keys.length === 0) return
1655
+ let next: Record<string, ChatApprovalStatus> | null = null
1656
+ for (const key of keys) {
1657
+ const mine = approvalStatuses[key]
1658
+ const theirs = persisted[key]
1659
+ // Unknown here → adopt. Known → adopt only to upgrade pending →
1660
+ // resolved; never downgrade a resolution back to pending.
1661
+ const adopt = mine === undefined || (mine === 'pending' && theirs !== 'pending')
1662
+ if (!adopt || mine === theirs) continue
1663
+ next ??= { ...approvalStatuses }
1664
+ next[key] = theirs
1665
+ }
1666
+ if (next === null) return
1667
+ approvalStatuses = next
1668
+ invalidate()
1669
+ }
1670
+
1671
+ function initializeWithState(
1672
+ nextMessages: UnifiedChatMessage[] | null,
1673
+ extras?: InitializeExtras,
1674
+ ): void {
1675
+ if (nextMessages !== null) {
1676
+ messages = nextMessages
1677
+ }
1678
+ if (extras) {
1679
+ accumulator.initializeWithState({
1680
+ existingSegments: extras.existingSegments,
1681
+ pendingApprovals: extras.pendingApprovals,
1682
+ executingTools: extras.executingTools,
1683
+ })
1684
+ if (extras.escalatedApprovals) {
1685
+ for (const [requestId, data] of extras.escalatedApprovals) {
1686
+ pendingEscalated.set(requestId, data)
1687
+ emit('onEscalatedApproval', requestId, data)
1688
+ }
1689
+ }
1690
+ // Parked state from a previous instance of this key (see the fields'
1691
+ // docs). Both are restore-only: statuses merge monotonically, the seq
1692
+ // gate only moves forward.
1693
+ if (extras.approvalStatuses) mergeApprovalStatusesInternal(extras.approvalStatuses)
1694
+ if (typeof extras.lastAppliedSeq === 'number' && extras.lastAppliedSeq > lastAppliedSeq) {
1695
+ lastAppliedSeq = extras.lastAppliedSeq
1696
+ }
1697
+ if (extras.pendingEchoes && extras.pendingEchoes.length > 0) {
1698
+ const now = Date.now()
1699
+ const restored = extras.pendingEchoes.filter(
1700
+ (e) => now - e.at < OWN_ECHO_AUTHOR_TTL_MS,
1701
+ )
1702
+ if (restored.length > 0) {
1703
+ pendingEchoTexts = [...pendingEchoTexts, ...restored.map((e) => ({ ...e }))]
1704
+ if (pendingEchoTexts.length > MAX_PENDING_ECHOES) {
1705
+ pendingEchoTexts = pendingEchoTexts.slice(-MAX_PENDING_ECHOES)
1706
+ }
1707
+ }
1708
+ }
1709
+ }
1710
+ // Resumed dialog: a MESSAGE_START already fired server-side. Treat
1711
+ // subsequent continuation chunks (after the next MESSAGE_END) as
1712
+ // post-stream so they append into the existing bubble instead of
1713
+ // replacing its content via the cold-start cumulative path.
1714
+ //
1715
+ // Gated on the restored thread being non-empty (overridable via
1716
+ // `extras.resumed`): the eviction round trip calls this for EVERY dropped
1717
+ // key, including one that never streamed and parked an empty thread.
1718
+ // Claiming "resumed" there would send a later cold text-delta with no
1719
+ // preceding `turn-start` down the append branch, so the first assistant
1720
+ // bubble never spawns.
1721
+ // Never DOWNGRADES an instance that already streamed — this only decides
1722
+ // whether the restore itself counts as evidence of a prior stream.
1723
+ hasEverStreamed = hasEverStreamed || (extras?.resumed ?? messages.length > 0)
1724
+ // A tail the extractor judged agent-busy restores the activity indicator.
1725
+ // Routed through `agentBusyState` so the restore obeys the SAME rule a live
1726
+ // busy signal does — only 'idle' upgrades, an open stream keeps ownership.
1727
+ if (extras?.agentBusy) agentBusyState()
1728
+ invalidate()
1729
+ }
1730
+
1731
+ return {
1732
+ apply,
1733
+ get state() {
1734
+ return getState()
1735
+ },
1736
+ reset,
1737
+ initializeWithState,
1738
+
1739
+ setMessages(next) {
1740
+ messages = next
1741
+ invalidate()
1742
+ },
1743
+ prependMessages(older) {
1744
+ messages = [...older, ...messages]
1745
+ invalidate()
1746
+ },
1747
+ pushOptimisticSend(text, hidden = false) {
1748
+ // Record the text so the backend's MESSAGE_REQUEST echo consumes this
1749
+ // send instead of rendering a duplicate row (cap keeps the list from
1750
+ // growing if a backend never echoes).
1751
+ pendingEchoTexts.push({ text, at: Date.now() })
1752
+ if (pendingEchoTexts.length > MAX_PENDING_ECHOES) pendingEchoTexts.shift()
1753
+ messages = [
1754
+ ...messages,
1755
+ // An EMPTY-text send is out-of-band metadata (an approval decision),
1756
+ // not something the user said — see `beginSseSend` for the full
1757
+ // rationale. Never materialize a row for it.
1758
+ ...(text.length > 0
1759
+ ? ([
1760
+ {
1761
+ id: nextId('user'),
1762
+ role: 'user' as const,
1763
+ content: text,
1764
+ ...(hidden ? { hidden: true } : {}),
1765
+ },
1766
+ ] satisfies UnifiedChatMessage[])
1767
+ : []),
1768
+ { id: nextId('assistant'), role: 'assistant', content: '', segments: [] },
1769
+ ]
1770
+ streamingPhase = 'thinking'
1771
+ invalidate()
1772
+ },
1773
+ clearThread() {
1774
+ messages = []
1775
+ accumulator.reset()
1776
+ streamingPhase = 'idle'
1777
+ invalidate()
1778
+ },
1779
+ resetForDialogSwitch,
1780
+
1781
+ beginSseSend({ text, hidden, userName = 'You', assistantName, assistantAvatar }) {
1782
+ const now = Date.now()
1783
+ messages = [
1784
+ ...messages,
1785
+ // Approve / Reject send `('', { hidden: true, approvalAction })`: the
1786
+ // decision is OUT-OF-BAND metadata (it rides `proposal_id`/`action` in
1787
+ // the request body), not a user utterance. The history builder already
1788
+ // filters `!m.hidden`, so such a row contributes NOTHING to the wire —
1789
+ // it is dead state whose only effect is a render hazard (an author
1790
+ // label with no body). So we never mint it. Hidden sends that DO carry
1791
+ // text (e.g. an auto-continuation directive) still become real state:
1792
+ // the LLM must see them, `Message.hidden` keeps them off screen.
1793
+ ...(text.length > 0
1794
+ ? ([
1795
+ {
1796
+ id: `user-${now}`,
1797
+ role: 'user' as const,
1798
+ name: userName,
1799
+ content: text,
1800
+ timestamp: new Date(now),
1801
+ ...(hidden ? { hidden: true } : {}),
1802
+ },
1803
+ ] satisfies UnifiedChatMessage[])
1804
+ : []),
1805
+ {
1806
+ id: `assistant-${now}`,
1807
+ role: 'assistant',
1808
+ name: assistantName,
1809
+ content: '',
1810
+ segments: [],
1811
+ timestamp: new Date(now),
1812
+ ...(assistantAvatar !== undefined ? { avatar: assistantAvatar } : {}),
1813
+ },
1814
+ ]
1815
+ sendCount += 1
1816
+ sseCurrentText = ''
1817
+ streamingPhase = 'thinking'
1818
+ invalidate()
1819
+ },
1820
+ endSseTurn() {
1821
+ // Reject-path on confirm-tool emits ONLY a `decision_resolved` leading
1822
+ // frame and closes — no text segments stream in, so the placeholder
1823
+ // assistant message would render as a blank bubble. Remove the
1824
+ // trailing assistant message if it still has no content.
1825
+ const last = messages[messages.length - 1]
1826
+ if (
1827
+ last &&
1828
+ last.role === 'assistant' &&
1829
+ (last.segments?.length ?? 0) === 0 &&
1830
+ last.content === ''
1831
+ ) {
1832
+ messages = messages.slice(0, -1)
1833
+ }
1834
+ sseCurrentText = ''
1835
+ streamingPhase = 'idle'
1836
+ invalidate()
1837
+ },
1838
+ failSseTurn(errorMessage) {
1839
+ // Legacy `useChat` catch parity: the trailing (placeholder) message is
1840
+ // replaced by an error-role row.
1841
+ messages = [
1842
+ ...messages.slice(0, -1),
1843
+ {
1844
+ id: `error-${Date.now()}`,
1845
+ role: 'error',
1846
+ content: errorMessage,
1847
+ timestamp: new Date(),
1848
+ } as unknown as UnifiedChatMessage,
1849
+ ]
1850
+ sseCurrentText = ''
1851
+ streamingPhase = 'idle'
1852
+ invalidate()
1853
+ },
1854
+ seedSseMaps({ sources, refs, sendCount: seedSendCount }) {
1855
+ if (sources) for (const [k, v] of sources) sourcesMap.set(k, v)
1856
+ if (refs) for (const [k, v] of refs) refsMap.set(k, v)
1857
+ if (typeof seedSendCount === 'number') sendCount = seedSendCount
1858
+ invalidate()
1859
+ },
1860
+
1861
+ setPhase(phase) {
1862
+ setPhaseInternal(phase)
1863
+ },
1864
+ setApprovalStatus(requestId, status) {
1865
+ if (status === null) {
1866
+ if (!(requestId in approvalStatuses)) return
1867
+ const next = { ...approvalStatuses }
1868
+ delete next[requestId]
1869
+ approvalStatuses = next
1870
+ } else {
1871
+ if (approvalStatuses[requestId] === status) return
1872
+ approvalStatuses = { ...approvalStatuses, [requestId]: status }
1873
+ }
1874
+ invalidate()
1875
+ },
1876
+ mergeApprovalStatuses: mergeApprovalStatusesInternal,
1877
+ setDirectMode(isDirectMode) {
1878
+ directModeFlag = isDirectMode
1879
+ },
1880
+ setDialogTokenUsage(usage) {
1881
+ dialogTokenUsage = usage
1882
+ invalidate()
1883
+ },
1884
+ adjustAgentBusySuppression(delta) {
1885
+ suppressAgentBusy = Math.max(0, suppressAgentBusy + delta)
1886
+ },
1887
+ armAdoptTrailingAssistant(value) {
1888
+ adoptTrailingAssistant = value
1889
+ },
1890
+
1891
+ projectApprovalResolution(requestId, status, resolvedByName) {
1892
+ setMessagesInternal(
1893
+ projectApprovalResolutionToMessages(messages, requestId, status, resolvedByName),
1894
+ )
1895
+ mirrorApprovalStatus(requestId, status)
1896
+ },
1897
+ projectToolExecution(segment) {
1898
+ // Merge-only: a projection must never grow a card on a side the tool
1899
+ // doesn't belong to, and must never re-enter the seq stream.
1900
+ const merged = mergeToolExecutionIfPresent(messages, segment)
1901
+ if (merged !== null) setMessagesInternal(merged)
1902
+ },
1903
+
1904
+ getLastAppliedSeq() {
1905
+ return lastAppliedSeq
1906
+ },
1907
+ getPendingEchoes() {
1908
+ // SNAPSHOT, not the live array: `pushOptimisticSend` pushes into and
1909
+ // `consumeOwnEcho` splices the same object, so handing it out would make
1910
+ // the `readonly` return type a lie and let a parked copy mutate under
1911
+ // its holder.
1912
+ return pendingEchoTexts.slice()
1913
+ },
1914
+ getSegments() {
1915
+ return accumulator.getSegments()
1916
+ },
1917
+ updateApprovalStatus(requestId, status, resolvedByName) {
1918
+ const segments = accumulator.updateApprovalStatus(requestId, status, resolvedByName)
1919
+ setMessagesInternal(
1920
+ projectApprovalResolutionToMessages(messages, requestId, status, resolvedByName),
1921
+ )
1922
+ return segments
1923
+ },
1924
+ getPendingEscalated() {
1925
+ return new Map(pendingEscalated)
1926
+ },
1927
+ }
1928
+ }