@flamingo-stack/openframe-frontend-core 0.0.488 → 0.0.490-1506.3944.1

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