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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (306) hide show
  1. package/dist/chat-protocol/decode.d.ts +58 -0
  2. package/dist/chat-protocol/decode.d.ts.map +1 -0
  3. package/dist/chat-protocol/encode.d.ts +44 -0
  4. package/dist/chat-protocol/encode.d.ts.map +1 -0
  5. package/dist/chat-protocol/env-flag.d.ts +35 -0
  6. package/dist/chat-protocol/env-flag.d.ts.map +1 -0
  7. package/dist/chat-protocol/events.d.ts +216 -0
  8. package/dist/chat-protocol/events.d.ts.map +1 -0
  9. package/dist/chat-protocol/frames.d.ts +213 -0
  10. package/dist/chat-protocol/frames.d.ts.map +1 -0
  11. package/dist/chat-protocol/index.cjs +506 -0
  12. package/dist/chat-protocol/index.cjs.map +1 -0
  13. package/dist/chat-protocol/index.d.ts +18 -0
  14. package/dist/chat-protocol/index.d.ts.map +1 -0
  15. package/dist/chat-protocol/index.js +490 -0
  16. package/dist/chat-protocol/index.js.map +1 -0
  17. package/dist/chat-protocol/ip-normalize.d.ts +44 -0
  18. package/dist/chat-protocol/ip-normalize.d.ts.map +1 -0
  19. package/dist/chat-protocol/nats-decoder.d.ts +26 -0
  20. package/dist/chat-protocol/nats-decoder.d.ts.map +1 -0
  21. package/dist/{chunk-3SQ5KXHQ.cjs → chunk-2T4GTV25.cjs} +9 -9
  22. package/dist/{chunk-3SQ5KXHQ.cjs.map → chunk-2T4GTV25.cjs.map} +1 -1
  23. package/dist/{chunk-CWAOV2FK.cjs → chunk-3CGZPAGR.cjs} +3 -3
  24. package/dist/{chunk-CWAOV2FK.cjs.map → chunk-3CGZPAGR.cjs.map} +1 -1
  25. package/dist/{chunk-TLMJHMXJ.js → chunk-3QSR22IC.js} +12 -12
  26. package/dist/chunk-3QSR22IC.js.map +1 -0
  27. package/dist/{chunk-TWYNR4TZ.cjs → chunk-4G5TLHJD.cjs} +7141 -4799
  28. package/dist/chunk-4G5TLHJD.cjs.map +1 -0
  29. package/dist/{chunk-ADPMHWOE.js → chunk-72XAID7Y.js} +4 -4
  30. package/dist/{chunk-PAGKRNWK.js → chunk-7WZHBQ4J.js} +2 -2
  31. package/dist/{chunk-OCEKO5CW.js → chunk-AEELJC4N.js} +12106 -9764
  32. package/dist/chunk-AEELJC4N.js.map +1 -0
  33. package/dist/{chunk-Z42BGM6Q.cjs → chunk-BT2WWDFP.cjs} +5 -5
  34. package/dist/{chunk-Z42BGM6Q.cjs.map → chunk-BT2WWDFP.cjs.map} +1 -1
  35. package/dist/{chunk-7LEL3RIX.cjs → chunk-C7OY3PQP.cjs} +11 -11
  36. package/dist/{chunk-7LEL3RIX.cjs.map → chunk-C7OY3PQP.cjs.map} +1 -1
  37. package/dist/{chunk-RHGGEKPQ.cjs → chunk-CIOLOQKQ.cjs} +4 -4
  38. package/dist/{chunk-RHGGEKPQ.cjs.map → chunk-CIOLOQKQ.cjs.map} +1 -1
  39. package/dist/{chunk-TSZHM74B.js → chunk-DC2TKS7C.js} +2 -2
  40. package/dist/{chunk-M2GOR3XQ.cjs → chunk-DYPYR7FR.cjs} +87 -87
  41. package/dist/{chunk-M2GOR3XQ.cjs.map → chunk-DYPYR7FR.cjs.map} +1 -1
  42. package/dist/{chunk-I64ABCDX.js → chunk-I2B6X77L.js} +2 -2
  43. package/dist/{chunk-NIZDKTGL.cjs → chunk-K5MSCMDK.cjs} +37 -37
  44. package/dist/{chunk-NIZDKTGL.cjs.map → chunk-K5MSCMDK.cjs.map} +1 -1
  45. package/dist/{chunk-DTYRYB2N.js → chunk-K6QTZ7CZ.js} +2 -2
  46. package/dist/{chunk-4LTMXDUS.js → chunk-KBN7PMFT.js} +2 -2
  47. package/dist/{chunk-TBMV7I5N.js → chunk-KXZO2WZX.js} +2 -2
  48. package/dist/{chunk-QTYZMP6D.cjs → chunk-LBAKLT6T.cjs} +62 -62
  49. package/dist/chunk-LBAKLT6T.cjs.map +1 -0
  50. package/dist/{chunk-KEBYLU3U.js → chunk-LFUPI7VX.js} +2 -2
  51. package/dist/{chunk-LDTR4IVZ.cjs → chunk-M37PBG4N.cjs} +31 -31
  52. package/dist/{chunk-LDTR4IVZ.cjs.map → chunk-M37PBG4N.cjs.map} +1 -1
  53. package/dist/{chunk-WA7RR64F.js → chunk-PO5O654H.js} +6 -5
  54. package/dist/chunk-PO5O654H.js.map +1 -0
  55. package/dist/{chunk-WK4N5VBX.cjs → chunk-QJJISLPA.cjs} +28 -27
  56. package/dist/chunk-QJJISLPA.cjs.map +1 -0
  57. package/dist/{chunk-2F44PLTV.cjs → chunk-T7MCSA55.cjs} +26 -26
  58. package/dist/{chunk-2F44PLTV.cjs.map → chunk-T7MCSA55.cjs.map} +1 -1
  59. package/dist/{chunk-WJQHJD7J.js → chunk-TWSKTJHW.js} +2 -2
  60. package/dist/{chunk-ZSQHZYCO.cjs → chunk-UQDMJ6DH.cjs} +13 -13
  61. package/dist/{chunk-ZSQHZYCO.cjs.map → chunk-UQDMJ6DH.cjs.map} +1 -1
  62. package/dist/{chunk-TZRUCD56.js → chunk-V3ZAAFE3.js} +5 -5
  63. package/dist/{chunk-LRGHJPET.js → chunk-XGIFOHKE.js} +2 -2
  64. package/dist/{chunk-POOMO3PA.cjs → chunk-YQEPRYT5.cjs} +7 -7
  65. package/dist/{chunk-POOMO3PA.cjs.map → chunk-YQEPRYT5.cjs.map} +1 -1
  66. package/dist/components/case-studies/index.cjs +8 -8
  67. package/dist/components/case-studies/index.js +2 -2
  68. package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
  69. package/dist/components/chat/chat-message-list.d.ts.map +1 -1
  70. package/dist/components/chat/hooks/index.d.ts +0 -1
  71. package/dist/components/chat/hooks/index.d.ts.map +1 -1
  72. package/dist/components/chat/hooks/use-chat.d.ts.map +1 -1
  73. package/dist/components/chat/hooks/use-chunk-catchup.d.ts.map +1 -1
  74. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts +15 -48
  75. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
  76. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts +10 -57
  77. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
  78. package/dist/components/chat/index.cjs +10 -2
  79. package/dist/components/chat/index.cjs.map +1 -1
  80. package/dist/components/chat/index.d.ts +1 -0
  81. package/dist/components/chat/index.d.ts.map +1 -1
  82. package/dist/components/chat/index.js +21 -13
  83. package/dist/components/chat/stream/chat-dialog-store.d.ts +184 -0
  84. package/dist/components/chat/stream/chat-dialog-store.d.ts.map +1 -0
  85. package/dist/components/chat/stream/chat-stream-reducer.d.ts +380 -0
  86. package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +1 -0
  87. package/dist/components/chat/stream/delta-batcher.d.ts +51 -0
  88. package/dist/components/chat/stream/delta-batcher.d.ts.map +1 -0
  89. package/dist/components/chat/stream/index.d.ts +17 -0
  90. package/dist/components/chat/stream/index.d.ts.map +1 -0
  91. package/dist/components/chat/stream/message-mutations.d.ts +77 -0
  92. package/dist/components/chat/stream/message-mutations.d.ts.map +1 -0
  93. package/dist/components/chat/stream/use-chat-stream-reducer.d.ts +22 -0
  94. package/dist/components/chat/stream/use-chat-stream-reducer.d.ts.map +1 -0
  95. package/dist/components/chat/types/api.types.d.ts +1 -81
  96. package/dist/components/chat/types/api.types.d.ts.map +1 -1
  97. package/dist/components/chat/types/processing.types.d.ts +2 -93
  98. package/dist/components/chat/types/processing.types.d.ts.map +1 -1
  99. package/dist/components/chat/types/unified-chat-state.types.d.ts +15 -0
  100. package/dist/components/chat/types/unified-chat-state.types.d.ts.map +1 -1
  101. package/dist/components/chat/utils/extract-incomplete-message-state.d.ts +34 -6
  102. package/dist/components/chat/utils/extract-incomplete-message-state.d.ts.map +1 -1
  103. package/dist/components/chat/utils/history-merge.d.ts.map +1 -1
  104. package/dist/components/chat/utils/index.d.ts +2 -3
  105. package/dist/components/chat/utils/index.d.ts.map +1 -1
  106. package/dist/components/chat/utils/process-historical-messages.d.ts +59 -11
  107. package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
  108. package/dist/components/contact/index.cjs +3 -3
  109. package/dist/components/contact/index.js +2 -2
  110. package/dist/components/docs/index.cjs +5 -5
  111. package/dist/components/docs/index.js +4 -4
  112. package/dist/components/embeds/index.cjs +3 -3
  113. package/dist/components/embeds/index.js +2 -2
  114. package/dist/components/faq/index.cjs +3 -3
  115. package/dist/components/faq/index.js +2 -2
  116. package/dist/components/features/index.cjs +2 -2
  117. package/dist/components/features/index.js +1 -1
  118. package/dist/components/help-center-pages/index.cjs +22 -22
  119. package/dist/components/help-center-pages/index.js +13 -13
  120. package/dist/components/index.cjs +174 -132
  121. package/dist/components/index.cjs.map +1 -1
  122. package/dist/components/index.js +65 -23
  123. package/dist/components/index.js.map +1 -1
  124. package/dist/components/meeting-scheduler/index.cjs +34 -34
  125. package/dist/components/meeting-scheduler/index.js +3 -3
  126. package/dist/components/navigation/index.cjs +2 -2
  127. package/dist/components/navigation/index.js +1 -1
  128. package/dist/components/onboarding-guides/index.cjs +5 -5
  129. package/dist/components/onboarding-guides/index.js +4 -4
  130. package/dist/components/onboarding-guides/onboarding-guide-detail-view.d.ts.map +1 -1
  131. package/dist/components/related-content/index.cjs +3 -3
  132. package/dist/components/related-content/index.js +2 -2
  133. package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
  134. package/dist/components/tickets/index.cjs +6 -6
  135. package/dist/components/tickets/index.js +5 -5
  136. package/dist/components/ui/index.cjs +44 -2
  137. package/dist/components/ui/index.cjs.map +1 -1
  138. package/dist/components/ui/index.d.ts +1 -2
  139. package/dist/components/ui/index.d.ts.map +1 -1
  140. package/dist/components/ui/index.js +55 -13
  141. package/dist/components/ui/markdown/base-components.d.ts +51 -0
  142. package/dist/components/ui/markdown/base-components.d.ts.map +1 -0
  143. package/dist/components/ui/markdown/engine.d.ts +99 -0
  144. package/dist/components/ui/markdown/engine.d.ts.map +1 -0
  145. package/dist/components/ui/markdown/heading-ids.d.ts +65 -0
  146. package/dist/components/ui/markdown/heading-ids.d.ts.map +1 -0
  147. package/dist/components/ui/markdown/index.d.ts +18 -0
  148. package/dist/components/ui/markdown/index.d.ts.map +1 -0
  149. package/dist/components/ui/markdown/mermaid-diagram.d.ts +79 -0
  150. package/dist/components/ui/markdown/mermaid-diagram.d.ts.map +1 -0
  151. package/dist/components/ui/markdown/rich/embed-overrides.d.ts +14 -0
  152. package/dist/components/ui/markdown/rich/embed-overrides.d.ts.map +1 -0
  153. package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts +40 -0
  154. package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts.map +1 -0
  155. package/dist/components/ui/markdown/rich/shortcodes.d.ts +8 -0
  156. package/dist/components/ui/markdown/rich/shortcodes.d.ts.map +1 -0
  157. package/dist/components/ui/markdown/sanitize.d.ts +153 -0
  158. package/dist/components/ui/markdown/sanitize.d.ts.map +1 -0
  159. package/dist/components/ui/markdown/simple-markdown-renderer.d.ts +23 -0
  160. package/dist/components/ui/markdown/simple-markdown-renderer.d.ts.map +1 -0
  161. package/dist/components/ui/markdown/streaming.d.ts +78 -0
  162. package/dist/components/ui/markdown/streaming.d.ts.map +1 -0
  163. package/dist/components/ui/markdown/text-size.d.ts +34 -0
  164. package/dist/components/ui/markdown/text-size.d.ts.map +1 -0
  165. package/dist/index.cjs +60 -2
  166. package/dist/index.cjs.map +1 -1
  167. package/dist/index.js +71 -13
  168. package/dist/utils/index.cjs +169 -40
  169. package/dist/utils/index.cjs.map +1 -1
  170. package/dist/utils/index.d.ts +1 -0
  171. package/dist/utils/index.d.ts.map +1 -1
  172. package/dist/utils/index.js +162 -41
  173. package/dist/utils/index.js.map +1 -1
  174. package/dist/utils/markdown-fences.d.ts +42 -0
  175. package/dist/utils/markdown-fences.d.ts.map +1 -0
  176. package/dist/utils/markdown-heading-id.d.ts +85 -0
  177. package/dist/utils/markdown-heading-id.d.ts.map +1 -0
  178. package/dist/utils/markdown-section-extractor.d.ts.map +1 -1
  179. package/package.json +7 -1
  180. package/src/chat-protocol/__tests__/__snapshots__/nats-decoder-golden.test.ts.snap +319 -0
  181. package/src/chat-protocol/__tests__/chat-protocol.test.ts +516 -0
  182. package/src/chat-protocol/__tests__/env-flag.test.ts +52 -0
  183. package/src/chat-protocol/__tests__/ip-normalize.test.ts +136 -0
  184. package/src/chat-protocol/__tests__/nats-decoder-golden.test.ts +237 -0
  185. package/src/chat-protocol/decode.ts +278 -0
  186. package/src/chat-protocol/encode.ts +71 -0
  187. package/src/chat-protocol/env-flag.ts +39 -0
  188. package/src/chat-protocol/events.ts +254 -0
  189. package/src/chat-protocol/frames.ts +245 -0
  190. package/src/chat-protocol/index.ts +21 -0
  191. package/src/chat-protocol/ip-normalize.ts +146 -0
  192. package/src/chat-protocol/nats-decoder.ts +264 -0
  193. package/src/components/chat/.chat-message-list.md +10 -6
  194. package/src/components/chat/__tests__/chat-message-list.test.tsx +450 -9
  195. package/src/components/chat/__tests__/chat-message-streaming-memo.test.tsx +207 -0
  196. package/src/components/chat/__tests__/chat-pending-turn.test.tsx +250 -0
  197. package/src/components/chat/chat-message-enhanced.tsx +122 -20
  198. package/src/components/chat/chat-message-list.tsx +515 -133
  199. package/src/components/chat/embeddable-chat.tsx +6 -0
  200. package/src/components/chat/hooks/.index.md +30 -33
  201. package/src/components/chat/hooks/.use-nats-chat-adapter.md +36 -55
  202. package/src/components/chat/hooks/__tests__/__snapshots__/conversation-id-persistence-golden.test.ts.snap +37 -0
  203. package/src/components/chat/hooks/__tests__/__snapshots__/sse-stream-golden.test.ts.snap +0 -0
  204. package/src/components/chat/hooks/__tests__/chunk-catchup-dialog-staleness.test.ts +92 -0
  205. package/src/components/chat/hooks/__tests__/conversation-id-persistence-golden.test.ts +438 -0
  206. package/src/components/chat/hooks/__tests__/sse-stream-golden.test.ts +318 -0
  207. package/src/components/chat/hooks/index.ts +0 -1
  208. package/src/components/chat/hooks/use-chat.ts +8 -0
  209. package/src/components/chat/hooks/use-chunk-catchup.ts +26 -1
  210. package/src/components/chat/hooks/use-nats-chat-adapter.ts +254 -825
  211. package/src/components/chat/hooks/use-sse-chat-adapter.ts +380 -684
  212. package/src/components/chat/index.ts +5 -0
  213. package/src/components/chat/stream/__tests__/__snapshots__/chat-stream-reducer-golden.test.ts.snap +944 -0
  214. package/src/components/chat/stream/__tests__/chat-dialog-store.test.ts +806 -0
  215. package/src/components/chat/stream/__tests__/chat-stream-reducer-golden.test.ts +467 -0
  216. package/src/components/chat/stream/__tests__/chat-stream-reducer.test.ts +1330 -0
  217. package/src/components/chat/stream/__tests__/delta-batcher.test.ts +255 -0
  218. package/src/components/chat/stream/__tests__/use-chat-stream-reducer.test.ts +105 -0
  219. package/src/components/chat/stream/chat-dialog-store.ts +536 -0
  220. package/src/components/chat/stream/chat-stream-reducer.ts +1928 -0
  221. package/src/components/chat/stream/delta-batcher.ts +168 -0
  222. package/src/components/chat/stream/index.ts +55 -0
  223. package/src/components/chat/stream/message-mutations.ts +340 -0
  224. package/src/components/chat/stream/use-chat-stream-reducer.ts +126 -0
  225. package/src/components/chat/thinking-display.tsx +1 -1
  226. package/src/components/chat/types/.api.types.md +39 -49
  227. package/src/components/chat/types/.processing.types.md +29 -58
  228. package/src/components/chat/types/.unified-chat-state.types.md +2 -2
  229. package/src/components/chat/types/api.types.ts +6 -73
  230. package/src/components/chat/types/processing.types.ts +11 -53
  231. package/src/components/chat/types/unified-chat-state.types.ts +16 -0
  232. package/src/components/chat/utils/.extract-incomplete-message-state.md +21 -3
  233. package/src/components/chat/utils/.history-merge.md +44 -3
  234. package/src/components/chat/utils/.index.md +31 -40
  235. package/src/components/chat/utils/__tests__/__snapshots__/process-historical-messages-golden.test.ts.snap +420 -0
  236. package/src/components/chat/utils/__tests__/__snapshots__/segment-accumulator-golden.test.ts.snap +605 -0
  237. package/src/components/chat/utils/__tests__/extract-incomplete-message-state.test.ts +106 -0
  238. package/src/components/chat/utils/__tests__/history-merge.test.ts +160 -0
  239. package/src/components/chat/utils/__tests__/process-historical-messages-approvals.test.ts +61 -0
  240. package/src/components/chat/utils/__tests__/process-historical-messages-golden.test.ts +394 -0
  241. package/src/components/chat/utils/__tests__/segment-accumulator-golden.test.ts +270 -0
  242. package/src/components/chat/utils/extract-incomplete-message-state.ts +93 -6
  243. package/src/components/chat/utils/history-merge.ts +85 -5
  244. package/src/components/chat/utils/index.ts +5 -8
  245. package/src/components/chat/utils/process-historical-messages.ts +361 -385
  246. package/src/components/onboarding-guides/onboarding-guide-detail-view.tsx +22 -4
  247. package/src/components/shared/legal-document/legal-document-page.tsx +1 -1
  248. package/src/components/shared/product-release/release-detail-page.tsx +19 -4
  249. package/src/components/ui/__tests__/__snapshots__/markdown-parity.test.tsx.snap +2420 -0
  250. package/src/components/ui/__tests__/markdown-parity.test.tsx +2540 -0
  251. package/src/components/ui/index.ts +1 -2
  252. package/src/components/ui/markdown/__tests__/mermaid-security.test.ts +176 -0
  253. package/src/components/ui/markdown/__tests__/mermaid-stale-render.test.tsx +151 -0
  254. package/src/components/ui/markdown/__tests__/sanitize-invariant.test.ts +354 -0
  255. package/src/components/ui/markdown/__tests__/sanitize-render.test.tsx +118 -0
  256. package/src/components/ui/markdown/__tests__/streaming.test.tsx +436 -0
  257. package/src/components/ui/markdown/base-components.tsx +360 -0
  258. package/src/components/ui/markdown/engine.tsx +315 -0
  259. package/src/components/ui/markdown/heading-ids.ts +239 -0
  260. package/src/components/ui/markdown/index.ts +49 -0
  261. package/src/components/ui/markdown/mermaid-diagram.tsx +323 -0
  262. package/src/components/ui/markdown/rich/embed-overrides.tsx +199 -0
  263. package/src/components/ui/markdown/rich/rich-markdown-renderer.tsx +184 -0
  264. package/src/components/ui/markdown/rich/shortcodes.ts +170 -0
  265. package/src/components/ui/markdown/sanitize.ts +3584 -0
  266. package/src/components/ui/markdown/simple-markdown-renderer.tsx +28 -0
  267. package/src/components/ui/markdown/streaming.ts +390 -0
  268. package/src/components/ui/markdown/text-size.ts +106 -0
  269. package/src/components/ui/release-changelog-section.tsx +1 -1
  270. package/src/components/ui/ticket-info-section.tsx +1 -1
  271. package/src/utils/index.ts +1 -0
  272. package/src/utils/markdown-fences.ts +127 -0
  273. package/src/utils/markdown-heading-id.ts +348 -0
  274. package/src/utils/markdown-section-extractor.ts +41 -56
  275. package/dist/chunk-OCEKO5CW.js.map +0 -1
  276. package/dist/chunk-QTYZMP6D.cjs.map +0 -1
  277. package/dist/chunk-TLMJHMXJ.js.map +0 -1
  278. package/dist/chunk-TWYNR4TZ.cjs.map +0 -1
  279. package/dist/chunk-WA7RR64F.js.map +0 -1
  280. package/dist/chunk-WK4N5VBX.cjs.map +0 -1
  281. package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts +0 -6
  282. package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts.map +0 -1
  283. package/dist/components/chat/utils/chunk-parser.d.ts +0 -25
  284. package/dist/components/chat/utils/chunk-parser.d.ts.map +0 -1
  285. package/dist/components/ui/rich-markdown-renderer.d.ts +0 -34
  286. package/dist/components/ui/rich-markdown-renderer.d.ts.map +0 -1
  287. package/dist/components/ui/simple-markdown-renderer.d.ts +0 -73
  288. package/dist/components/ui/simple-markdown-renderer.d.ts.map +0 -1
  289. package/src/components/chat/hooks/.use-realtime-chunk-processor.md +0 -77
  290. package/src/components/chat/hooks/use-realtime-chunk-processor.ts +0 -489
  291. package/src/components/chat/utils/.chunk-parser.md +0 -62
  292. package/src/components/chat/utils/chunk-parser.ts +0 -262
  293. package/src/components/ui/.simple-markdown-renderer.md +0 -52
  294. package/src/components/ui/rich-markdown-renderer.tsx +0 -1223
  295. package/src/components/ui/simple-markdown-renderer.tsx +0 -964
  296. /package/dist/{chunk-ADPMHWOE.js.map → chunk-72XAID7Y.js.map} +0 -0
  297. /package/dist/{chunk-PAGKRNWK.js.map → chunk-7WZHBQ4J.js.map} +0 -0
  298. /package/dist/{chunk-TSZHM74B.js.map → chunk-DC2TKS7C.js.map} +0 -0
  299. /package/dist/{chunk-I64ABCDX.js.map → chunk-I2B6X77L.js.map} +0 -0
  300. /package/dist/{chunk-DTYRYB2N.js.map → chunk-K6QTZ7CZ.js.map} +0 -0
  301. /package/dist/{chunk-4LTMXDUS.js.map → chunk-KBN7PMFT.js.map} +0 -0
  302. /package/dist/{chunk-TBMV7I5N.js.map → chunk-KXZO2WZX.js.map} +0 -0
  303. /package/dist/{chunk-KEBYLU3U.js.map → chunk-LFUPI7VX.js.map} +0 -0
  304. /package/dist/{chunk-WJQHJD7J.js.map → chunk-TWSKTJHW.js.map} +0 -0
  305. /package/dist/{chunk-TZRUCD56.js.map → chunk-V3ZAAFE3.js.map} +0 -0
  306. /package/dist/{chunk-LRGHJPET.js.map → chunk-XGIFOHKE.js.map} +0 -0
@@ -0,0 +1,136 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { normalizeIpForBucketKey, IP_BUCKET_KEY_MAX_LENGTH } from '../ip-normalize'
3
+
4
+ describe('normalizeIpForBucketKey', () => {
5
+ it('passes plain IPv4 through', () => {
6
+ expect(normalizeIpForBucketKey('203.0.113.4')).toBe('203.0.113.4')
7
+ expect(normalizeIpForBucketKey(' 203.0.113.4 ')).toBe('203.0.113.4')
8
+ })
9
+
10
+ it('range-checks IPv4 octets', () => {
11
+ expect(normalizeIpForBucketKey('203.0.113.256')).toBeNull()
12
+ expect(normalizeIpForBucketKey('203.0.113')).toBeNull()
13
+ expect(normalizeIpForBucketKey('203.0.113.4.5')).toBeNull()
14
+ })
15
+
16
+ it('unwraps IPv4-mapped IPv6 to the bare IPv4 (one peer, one bucket)', () => {
17
+ expect(normalizeIpForBucketKey('::ffff:203.0.113.4')).toBe('203.0.113.4')
18
+ // Written-out spelling of the SAME mapped address must land in the same
19
+ // bucket — a hop that formats without zero-compression would otherwise
20
+ // split one peer in two.
21
+ expect(normalizeIpForBucketKey('0:0:0:0:0:ffff:203.0.113.4')).toBe('203.0.113.4')
22
+ expect(normalizeIpForBucketKey('0:0:0:0:0:FFFF:203.0.113.4')).toBe('203.0.113.4')
23
+ expect(normalizeIpForBucketKey('0:0:0:0:0:ffff:203.0.113.999')).toBeNull()
24
+ expect(normalizeIpForBucketKey('::FFFF:203.0.113.4')).toBe('203.0.113.4')
25
+ expect(normalizeIpForBucketKey('[::ffff:203.0.113.4]:443')).toBe('203.0.113.4')
26
+ expect(normalizeIpForBucketKey('::ffff:203.0.113.999')).toBeNull()
27
+ // ZERO-PADDED groups are the fixed-width spelling of the same mapped
28
+ // address. Leading zeros inside an IPv6 group are cosmetic, so this must
29
+ // reach the same bucket as `::ffff:` — not fall through to the IPv6 path.
30
+ expect(normalizeIpForBucketKey('0000:0000:0000:0000:0000:ffff:203.0.113.4')).toBe(
31
+ '203.0.113.4',
32
+ )
33
+ expect(normalizeIpForBucketKey('0000:0000:0000:0000:0000:FFFF:203.0.113.4')).toBe(
34
+ '203.0.113.4',
35
+ )
36
+ // Mixed padding widths (a formatter that pads only some groups).
37
+ expect(normalizeIpForBucketKey('00:000:0:0000:0:ffff:203.0.113.4')).toBe('203.0.113.4')
38
+ // The embedded IPv4 is still range-checked in the padded spelling.
39
+ expect(normalizeIpForBucketKey('0000:0000:0000:0000:0000:ffff:203.0.113.999')).toBeNull()
40
+ // …and its octets still may not be zero-padded (two spellings of one host).
41
+ expect(normalizeIpForBucketKey('0000:0000:0000:0000:0000:ffff:203.0.113.04')).toBeNull()
42
+ })
43
+
44
+ it('strips brackets and a trailing port', () => {
45
+ expect(normalizeIpForBucketKey('[::1]:443')).toBe('::1')
46
+ expect(normalizeIpForBucketKey('[2001:db8::1]')).toBe('2001:db8::1')
47
+ // Unbalanced / junk trailer is not an address.
48
+ expect(normalizeIpForBucketKey('[::1')).toBeNull()
49
+ expect(normalizeIpForBucketKey('[::1]junk')).toBeNull()
50
+ })
51
+
52
+ it('strips the %zone scope id', () => {
53
+ expect(normalizeIpForBucketKey('fe80::1%eth0')).toBe('fe80::1')
54
+ expect(normalizeIpForBucketKey('[fe80::1%25eth0]:9000')).toBe('fe80::1')
55
+ expect(normalizeIpForBucketKey('%eth0')).toBeNull()
56
+ })
57
+
58
+ it('lower-cases IPv6 so casing cannot split a bucket', () => {
59
+ expect(normalizeIpForBucketKey('2001:DB8::AB')).toBe('2001:db8::ab')
60
+ expect(normalizeIpForBucketKey('2001:db8::ab')).toBe('2001:db8::ab')
61
+ })
62
+
63
+ it('accepts full and compressed IPv6 shapes', () => {
64
+ expect(normalizeIpForBucketKey('2001:0db8:0000:0000:0000:0000:0000:0001')).toBe(
65
+ '2001:0db8:0000:0000:0000:0000:0000:0001',
66
+ )
67
+ expect(normalizeIpForBucketKey('::')).toBe('::')
68
+ expect(normalizeIpForBucketKey('64:ff9b::203.0.113.4')).toBe('64:ff9b::203.0.113.4')
69
+ })
70
+
71
+ it('rejects malformed IPv6', () => {
72
+ expect(normalizeIpForBucketKey('2001:db8::1::2')).toBeNull()
73
+ expect(normalizeIpForBucketKey('2001:db8:::1')).toBeNull()
74
+ expect(normalizeIpForBucketKey('1:2:3:4:5:6:7:8:9')).toBeNull()
75
+ expect(normalizeIpForBucketKey('2001:db8:0:0:0:0:1')).toBeNull()
76
+ expect(normalizeIpForBucketKey('2001:zzzz::1')).toBeNull()
77
+ })
78
+
79
+ /**
80
+ * REGRESSION (round 4): Azure App Service / Front Door and several CDNs put
81
+ * `ip:port` in the XFF hop. Returning null there left the caller with NO
82
+ * candidate, collapsing every visitor into the single egress bucket.
83
+ */
84
+ it('strips a bare :port from an IPv4 hop, but not from an ambiguous IPv6', () => {
85
+ expect(normalizeIpForBucketKey('203.0.113.4:8080')).toBe('203.0.113.4')
86
+ expect(normalizeIpForBucketKey('203.0.113.4:0')).toBe('203.0.113.4')
87
+ expect(normalizeIpForBucketKey(' 203.0.113.4:65535 ')).toBe('203.0.113.4')
88
+ // Not a port (6 digits) → not stripped, and the remainder is not an address.
89
+ expect(normalizeIpForBucketKey('203.0.113.4:123456')).toBeNull()
90
+ // Out-of-range head is still rejected once the port comes off.
91
+ expect(normalizeIpForBucketKey('203.0.113.256:8080')).toBeNull()
92
+ // Unbracketed IPv6 + bare port is genuinely ambiguous (`:1` is equally a
93
+ // final group), so the IPv6 reading wins and nothing is stripped.
94
+ expect(normalizeIpForBucketKey('2001:db8::1')).toBe('2001:db8::1')
95
+ expect(normalizeIpForBucketKey('::1:443')).toBe('::1:443')
96
+ })
97
+
98
+ it('rejects zero-padded IPv4 octets (one peer must not split two buckets)', () => {
99
+ expect(normalizeIpForBucketKey('203.0.113.04')).toBeNull()
100
+ expect(normalizeIpForBucketKey('203.0.113.004')).toBeNull()
101
+ expect(normalizeIpForBucketKey('203.00.113.4')).toBeNull()
102
+ expect(normalizeIpForBucketKey('203.0.113.04:8080')).toBeNull()
103
+ expect(normalizeIpForBucketKey('::ffff:203.0.113.04')).toBeNull()
104
+ // A lone `0` octet is not padding.
105
+ expect(normalizeIpForBucketKey('203.0.113.0')).toBe('203.0.113.0')
106
+ })
107
+
108
+ it('rejects a dangling separator and a misplaced embedded IPv4', () => {
109
+ expect(normalizeIpForBucketKey('2001:db8::1:')).toBeNull()
110
+ expect(normalizeIpForBucketKey(':2001:db8::1')).toBeNull()
111
+ expect(normalizeIpForBucketKey('1.2.3.4::1')).toBeNull()
112
+ expect(normalizeIpForBucketKey('::1.2.3.4:5')).toBeNull()
113
+ // The legitimate shapes still pass.
114
+ expect(normalizeIpForBucketKey('::')).toBe('::')
115
+ expect(normalizeIpForBucketKey('2001:db8::')).toBe('2001:db8::')
116
+ expect(normalizeIpForBucketKey('::1.2.3.4')).toBe('::1.2.3.4')
117
+ })
118
+
119
+ it('rejects oversized input rather than making an unbounded key', () => {
120
+ const oversized = 'a'.repeat(IP_BUCKET_KEY_MAX_LENGTH + 1)
121
+ expect(oversized.length).toBeGreaterThan(IP_BUCKET_KEY_MAX_LENGTH)
122
+ expect(normalizeIpForBucketKey(oversized)).toBeNull()
123
+ // A valid address padded past the cap is still refused.
124
+ expect(normalizeIpForBucketKey(`203.0.113.4${' '.repeat(200)}`)).toBe('203.0.113.4')
125
+ expect(normalizeIpForBucketKey(`${'0'.repeat(70)}.1.1.1`)).toBeNull()
126
+ })
127
+
128
+ it('rejects junk', () => {
129
+ expect(normalizeIpForBucketKey('')).toBeNull()
130
+ expect(normalizeIpForBucketKey(' ')).toBeNull()
131
+ expect(normalizeIpForBucketKey('unknown')).toBeNull()
132
+ expect(normalizeIpForBucketKey('example.com')).toBeNull()
133
+ expect(normalizeIpForBucketKey('203.0.113.4, 198.51.100.7')).toBeNull()
134
+ expect(normalizeIpForBucketKey(undefined as unknown as string)).toBeNull()
135
+ })
136
+ })
@@ -0,0 +1,237 @@
1
+ /**
2
+ * GOLDEN CHARACTERIZATION TESTS — `decodeNatsChunk`.
3
+ *
4
+ * Pins the NATS chunk → `ChatStreamEvent` mapping for the whole recorded
5
+ * corpus of wire shapes (every MESSAGE_TYPE the decoder handles, plus the
6
+ * malformed/unknown tolerance cases). Retargeted from the deleted
7
+ * `parseChunkToAction` golden suite when the superseded legacy parser was
8
+ * removed — same corpus, now snapshotted against the ONE live decoder.
9
+ *
10
+ * Do NOT "fix" behaviors captured here — they are the recorded baseline.
11
+ */
12
+
13
+ import { describe, it, expect } from 'vitest'
14
+ import { decodeNatsChunk } from '../nats-decoder'
15
+
16
+ /** Recorded corpus of realistic NATS chunk shapes, keyed by scenario name. */
17
+ const CORPUS: Record<string, unknown> = {
18
+ message_start: { type: 'MESSAGE_START' },
19
+ message_end: { type: 'MESSAGE_END' },
20
+
21
+ text: { type: 'TEXT', text: 'Hello, ' },
22
+ text_empty_string: { type: 'TEXT', text: '' },
23
+ text_missing_text_field: { type: 'TEXT' },
24
+
25
+ thinking: { type: 'THINKING', text: 'Considering options…' },
26
+ thinking_missing_text: { type: 'THINKING' },
27
+
28
+ guide: { type: 'GUIDE', text: '## Enroll a device\n1. Open Settings' },
29
+ guide_empty_string: { type: 'GUIDE', text: '' },
30
+ guide_missing_text: { type: 'GUIDE' },
31
+
32
+ ai_metadata_full: {
33
+ type: 'AI_METADATA',
34
+ modelDisplayName: 'Claude Sonnet',
35
+ modelName: 'claude-sonnet-x',
36
+ providerName: 'anthropic',
37
+ contextWindow: 200000,
38
+ },
39
+ // Legacy field name `provider` (not `providerName`) is accepted.
40
+ ai_metadata_legacy_provider_field: {
41
+ type: 'AI_METADATA',
42
+ modelName: 'gpt-x',
43
+ provider: 'openai',
44
+ },
45
+ ai_metadata_missing_model: { type: 'AI_METADATA', providerName: 'anthropic' },
46
+ ai_metadata_non_numeric_context_window: {
47
+ type: 'AI_METADATA',
48
+ modelName: 'claude-sonnet-x',
49
+ providerName: 'anthropic',
50
+ contextWindow: '200000',
51
+ },
52
+
53
+ executing_tool: {
54
+ type: 'EXECUTING_TOOL',
55
+ integratedToolType: 'TACTICAL_RMM',
56
+ toolFunction: 'run_script',
57
+ title: 'Run cleanup script',
58
+ // The card's body line. Carried by EXECUTING only — the EXECUTED chunk
59
+ // never repeats it, so losing it here blanks the tool card for the whole
60
+ // run. Every chunk in this corpus omitted it once, which is how a decoder
61
+ // that silently dropped the field shipped with the goldens still green.
62
+ toolExplanation: 'Discovering columns available in the system_info table for accurate querying.',
63
+ parameters: { script: 'cleanup.sh', timeout: 30 },
64
+ toolExecutionRequestId: 'exec-1',
65
+ },
66
+ executing_tool_minimal: { type: 'EXECUTING_TOOL' },
67
+
68
+ executed_tool: {
69
+ type: 'EXECUTED_TOOL',
70
+ integratedToolType: 'TACTICAL_RMM',
71
+ toolFunction: 'run_script',
72
+ parameters: { script: 'cleanup.sh' },
73
+ result: 'Freed 2.3 GB',
74
+ success: true,
75
+ toolExecutionRequestId: 'exec-1',
76
+ },
77
+ executed_tool_failure_no_exec_id: {
78
+ type: 'EXECUTED_TOOL',
79
+ integratedToolType: 'FLEET_MDM',
80
+ toolFunction: 'lock_device',
81
+ result: 'Device unreachable',
82
+ success: false,
83
+ },
84
+
85
+ approval_request_single: {
86
+ type: 'APPROVAL_REQUEST',
87
+ approvalRequestId: 'req-1',
88
+ approvalType: 'CLIENT',
89
+ command: 'systemctl restart nats',
90
+ explanation: 'Restart the message broker',
91
+ },
92
+ // snake_case id field (approval_request_id) is accepted as a fallback.
93
+ approval_request_snake_case_id: {
94
+ type: 'APPROVAL_REQUEST',
95
+ approval_request_id: 'req-2',
96
+ command: 'rm -rf /tmp/cache',
97
+ },
98
+ approval_request_batch: {
99
+ type: 'APPROVAL_REQUEST',
100
+ approvalRequestId: 'batch-1',
101
+ approvalType: 'ADMIN',
102
+ toolCalls: [
103
+ {
104
+ toolExecutionRequestId: 'exec-a',
105
+ toolName: 'create_ticket',
106
+ toolTitle: 'Create ticket',
107
+ toolExplanation: 'Opens a new support ticket',
108
+ toolType: 'WRITE',
109
+ requiresApproval: true,
110
+ approvalType: 'ADMIN',
111
+ toolCallArguments: { subject: 'Printer down' },
112
+ },
113
+ {
114
+ toolExecutionRequestId: 'exec-b',
115
+ toolName: 'read_kb',
116
+ requiresApproval: false,
117
+ approvalType: null,
118
+ toolCallArguments: null,
119
+ },
120
+ ],
121
+ },
122
+ // Malformed entries inside toolCalls are coerced/filtered by normalizeToolCalls.
123
+ approval_request_batch_malformed_entries: {
124
+ type: 'APPROVAL_REQUEST',
125
+ approvalRequestId: 'batch-2',
126
+ toolCalls: [
127
+ null,
128
+ 'not-an-object',
129
+ { toolName: 42, requiresApproval: 'yes', toolCallArguments: 'nope' },
130
+ ],
131
+ },
132
+ // CHARACTERIZATION: an empty toolCalls array falls back to the SINGLE
133
+ // approval-request shape (batch requires length > 0).
134
+ approval_request_empty_tool_calls: {
135
+ type: 'APPROVAL_REQUEST',
136
+ approvalRequestId: 'req-3',
137
+ toolCalls: [],
138
+ command: 'echo hi',
139
+ },
140
+
141
+ approval_result_approved: {
142
+ type: 'APPROVAL_RESULT',
143
+ approvalRequestId: 'req-1',
144
+ approved: true,
145
+ approvalType: 'CLIENT',
146
+ displayName: 'Jane Admin',
147
+ },
148
+ approval_result_rejected_resolved_by_name: {
149
+ type: 'APPROVAL_RESULT',
150
+ approvalRequestId: 'req-1',
151
+ approved: false,
152
+ resolvedByName: 'Bob Tech',
153
+ },
154
+ // CHARACTERIZATION: approved must be === true; any other value → rejected.
155
+ approval_result_truthy_string_approved: {
156
+ type: 'APPROVAL_RESULT',
157
+ approvalRequestId: 'req-9',
158
+ approved: 'true',
159
+ },
160
+
161
+ error_full: { type: 'ERROR', error: 'Agent crashed', details: '{"error":{"message":"boom"}}' },
162
+ error_bare: { type: 'ERROR' },
163
+
164
+ message_request: {
165
+ type: 'MESSAGE_REQUEST',
166
+ text: 'open a ticket',
167
+ ownerType: 'CLIENT',
168
+ displayName: 'Alice',
169
+ userId: 'user-42',
170
+ },
171
+ message_request_with_context_items: {
172
+ type: 'MESSAGE_REQUEST',
173
+ text: 'diagnose this device',
174
+ contextItems: [
175
+ { type: 'device', id: 'dev-1' },
176
+ { type: 'ticket' }, // missing id → filtered
177
+ { id: 'orphan' }, // missing type → filtered
178
+ ],
179
+ },
180
+ message_request_no_text: { type: 'MESSAGE_REQUEST' },
181
+
182
+ token_usage: {
183
+ type: 'TOKEN_USAGE',
184
+ inputTokensSize: 1200,
185
+ outputTokensSize: 340,
186
+ totalTokensSize: 1540,
187
+ contextSize: 180000,
188
+ },
189
+ token_usage_empty_defaults: { type: 'TOKEN_USAGE' },
190
+
191
+ context_compaction_start: { type: 'CONTEXT_COMPACTION_START' },
192
+ context_compaction_end: { type: 'CONTEXT_COMPACTION_END', text: 'Compacted 34 messages' },
193
+ context_compaction_end_no_summary: { type: 'CONTEXT_COMPACTION_END' },
194
+
195
+ system: { type: 'SYSTEM', text: 'Technician joined the chat' },
196
+ system_no_text: { type: 'SYSTEM' },
197
+
198
+ direct_message: {
199
+ type: 'DIRECT_MESSAGE',
200
+ text: 'Hi, human here taking over',
201
+ ownerType: 'ADMIN',
202
+ displayName: 'Jane Admin',
203
+ userId: 'admin-7',
204
+ },
205
+ direct_message_no_text: { type: 'DIRECT_MESSAGE' },
206
+
207
+ dialog_closed: { type: 'DIALOG_CLOSED' },
208
+
209
+ // ---- Malformed / unknown chunks ------------------------------------
210
+ unknown_type: { type: 'SOMETHING_NEW' },
211
+ missing_type: { text: 'hello' },
212
+ null_chunk: null,
213
+ undefined_chunk: undefined,
214
+ string_chunk: 'TEXT',
215
+ number_chunk: 42,
216
+ empty_object: {},
217
+ }
218
+
219
+ describe('decodeNatsChunk — golden corpus', () => {
220
+ it('maps the full recorded corpus to normalized events (snapshot)', () => {
221
+ const results = Object.fromEntries(
222
+ Object.entries(CORPUS).map(([name, chunk]) => [name, decodeNatsChunk(chunk)]),
223
+ )
224
+ expect(results).toMatchSnapshot()
225
+ })
226
+ })
227
+
228
+ describe('decodeNatsChunk — seq envelope', () => {
229
+ it('lifts a numeric JetStream `streamSeq` into `seq`, and omits it otherwise (snapshot)', () => {
230
+ expect({
231
+ numeric: decodeNatsChunk({ type: 'TEXT', text: 'hi', streamSeq: 42 }),
232
+ absent: decodeNatsChunk({ type: 'TEXT', text: 'hi' }),
233
+ non_numeric: decodeNatsChunk({ type: 'TEXT', text: 'hi', streamSeq: '42' }),
234
+ zero: decodeNatsChunk({ type: 'MESSAGE_START', streamSeq: 0 }),
235
+ }).toMatchSnapshot()
236
+ })
237
+ })
@@ -0,0 +1,278 @@
1
+ /**
2
+ * SSE wire-protocol decoder — a timer-free synchronous state machine,
3
+ * mechanically extracted from `useSseChatAdapter`'s inline
4
+ * `createDocStreamFn` parser. Byte-for-byte behavior parity with the
5
+ * legacy parser is the contract (the golden fixtures in
6
+ * `src/components/chat/hooks/__tests__/sse-stream-golden.test.ts` pin it
7
+ * through the full hook path), including its quirks:
8
+ *
9
+ * - A leading `\0`-terminated block that fails JSON.parse flips the
10
+ * stream into text mode and the WHOLE buffer (including the `\0`)
11
+ * is emitted as answer text (legacy no-frame stream fallback). This
12
+ * transition is marked `turn-start { implicit: true }`.
13
+ * - In text mode only `\x1F` is scanned — literal `\0` / `\x1E` bytes
14
+ * pass through into the answer; the FIRST literal `\x1F` flips into
15
+ * trailer mode and everything after is captured as the trailer
16
+ * (golden fixture (d) characterizes this mis-framing; the fix lives
17
+ * in `encode.ts`'s sentinel stripping, NOT here).
18
+ * - The `\x1E` sentinel and the `\x1F` trailer can arrive in ONE TCP
19
+ * chunk (fixed-answer responses) — the post-sentinel slice is
20
+ * re-scanned for the trailer sentinel.
21
+ * - Multi-byte UTF-8 across chunk boundaries survives via a single
22
+ * streaming TextDecoder (`{ stream: true }` on every push; no final
23
+ * flush — a trailing partial code point is dropped, as legacy did).
24
+ * - `end()` parses the accumulated trailer (malformed → silently
25
+ * ignored) and drops any un-terminated leading buffer, as legacy did.
26
+ * It is IDEMPOTENT (a deliberate deviation from legacy): repeat calls
27
+ * emit nothing, so an adapter that ends in both its completion path
28
+ * and its `finally` cannot double-count the usage frame.
29
+ *
30
+ * State flow: leading → (sentinel | parse-failure) → text → (\x1F) →
31
+ * trailer-accumulate → end().
32
+ *
33
+ * Server-safe: no React, no timers, TextDecoder only.
34
+ */
35
+
36
+ import { FRAME_TERMINATOR, END_OF_LEADING, TRAILER_SENTINEL } from './frames'
37
+ import type { ChatStreamEvent } from './events'
38
+
39
+ export interface SseFrameDecoder {
40
+ /** Feed raw response bytes; returns the events they produced. */
41
+ push(bytes: Uint8Array): ChatStreamEvent[]
42
+ /**
43
+ * Signal end-of-stream; parses the trailing usage frame if present.
44
+ * IDEMPOTENT — every call after the first returns `[]`. Adapters
45
+ * routinely call this from BOTH their completion path and a `finally`,
46
+ * and a re-emitted `usage`/`stage:'end'` event would double-count token
47
+ * usage (displayed cost doubles).
48
+ */
49
+ end(): ChatStreamEvent[]
50
+ }
51
+
52
+ /**
53
+ * Escape `<` so markdown renderers that pass HTML through (rehypeRaw)
54
+ * don't treat XML-like tokens in Claude's thinking output as elements.
55
+ * `<` → `&lt;` preserves the visible character without breaking
56
+ * blockquote `>` markers. Per-character, so it distributes over
57
+ * concatenation: escape(a + b) === escape(a) + escape(b) — callers may
58
+ * apply it per-delta or on the accumulated string interchangeably.
59
+ */
60
+ export function escapeThinkingTags(text: string): string {
61
+ return text.replace(/</g, '&lt;')
62
+ }
63
+
64
+ /**
65
+ * Map one parsed leading frame to normalized events, replicating the
66
+ * legacy parser's else-if chain ORDER and its exact truthiness/typeof
67
+ * gates. Frames that matched a branch but failed its inner validation
68
+ * (e.g. `routing` without a string `routedComplexity`) produce NO event,
69
+ * exactly like the legacy no-op.
70
+ */
71
+ function mapLeadingFrame(meta: any, out: ChatStreamEvent[]): void {
72
+ if (meta.status === 'thinking') {
73
+ out.push({ type: 'status', phase: 'thinking' })
74
+ } else if (meta.kind === 'thinking-delta' && typeof meta.text === 'string') {
75
+ // Wire is ALREADY delta — emit verbatim, append-only contract.
76
+ out.push({ type: 'thinking-delta', text: meta.text })
77
+ } else if (meta.kind === 'usage' && meta.stage === 'start') {
78
+ out.push({
79
+ type: 'usage',
80
+ stage: 'start',
81
+ input_tokens: meta.input_tokens,
82
+ cache_read_input_tokens: meta.cache_read_input_tokens,
83
+ cache_creation_input_tokens: meta.cache_creation_input_tokens,
84
+ })
85
+ } else if (meta.kind === 'decision_resolved' && typeof meta.action === 'string') {
86
+ const status = meta.action === 'rejected' ? 'rejected' : 'approved'
87
+ const toolName = typeof meta.tool_name === 'string' ? meta.tool_name : undefined
88
+ const result = meta.result ?? null
89
+ const card = meta.card ?? null
90
+ out.push({
91
+ type: 'approval-resolved',
92
+ status,
93
+ ok: meta.ok === true,
94
+ willAutoContinue: meta.willAutoContinue === true,
95
+ ...(toolName ? { toolName } : {}),
96
+ ...(result ? { result } : {}),
97
+ ...(card?.marker ? { marker: card.marker } : {}),
98
+ ...(card?.ref ? { cardRef: card.ref } : {}),
99
+ ...(card?.type ? { cardType: card.type } : {}),
100
+ ...(typeof meta.receiptText === 'string' ? { receiptText: meta.receiptText } : {}),
101
+ requestId: typeof meta.proposalId === 'string' ? meta.proposalId : undefined,
102
+ })
103
+ } else if (meta.kind === 'approval_request' && meta.proposalId) {
104
+ const proposalId = String(meta.proposalId)
105
+ const toolName = String(meta.toolName ?? 'tool')
106
+ const headline =
107
+ typeof meta.title === 'string' && meta.title.length > 0 ? meta.title : toolName
108
+ const rawFields = Array.isArray(meta.fields)
109
+ ? (meta.fields as Array<{ label?: string; value?: string }>)
110
+ : []
111
+ const fields: Array<{ label: string; value: string }> = []
112
+ for (const f of rawFields) {
113
+ if (!f || !f.label || !f.value) continue
114
+ fields.push({ label: f.label, value: f.value })
115
+ }
116
+ out.push({
117
+ type: 'approval-request',
118
+ requestId: proposalId,
119
+ approvalType: toolName,
120
+ command: headline,
121
+ fields,
122
+ status: 'pending',
123
+ })
124
+ } else if (meta.kind === 'text-leading' && typeof meta.text === 'string') {
125
+ out.push({ type: 'text-delta', text: meta.text, leading: true })
126
+ } else if (meta.kind === 'tool_error') {
127
+ const msg =
128
+ typeof meta.message === 'string' && meta.message.length > 0
129
+ ? meta.message
130
+ : 'Could not complete the requested action right now.'
131
+ out.push({ type: 'error', title: msg })
132
+ } else if (meta.kind === 'routing') {
133
+ if (typeof meta.routedComplexity === 'string') {
134
+ out.push({
135
+ type: 'metadata',
136
+ routing: {
137
+ routedComplexity: meta.routedComplexity,
138
+ ...(typeof meta.routedModel === 'string' ? { routedModel: meta.routedModel } : {}),
139
+ routedThinkingBudget:
140
+ typeof meta.routedThinkingBudget === 'number' ? meta.routedThinkingBudget : null,
141
+ },
142
+ })
143
+ }
144
+ } else {
145
+ // Catch-all metadata-ish frame. Raw values pass through UNVALIDATED
146
+ // (possibly undefined) so the consumer can replicate the legacy
147
+ // presence/truthiness gates exactly — including the `model`-presence
148
+ // trigger whose value is never stored.
149
+ out.push({
150
+ type: 'metadata',
151
+ sources: meta.sources,
152
+ refs: meta.refs,
153
+ provider: meta.provider,
154
+ modelLabel: meta.modelLabel,
155
+ modelName: meta.model,
156
+ contextWindowMaxTokens: meta.contextWindowMaxTokens,
157
+ scrollAnchor: meta.scrollAnchor,
158
+ conversationId: meta.conversationId,
159
+ })
160
+ }
161
+ }
162
+
163
+ export function createSseFrameDecoder(): SseFrameDecoder {
164
+ const textDecoder = new TextDecoder()
165
+ let buffer = ''
166
+ let inText = false
167
+ let inTrailer = false
168
+ let trailerBuffer = ''
169
+ let ended = false
170
+
171
+ function push(bytes: Uint8Array): ChatStreamEvent[] {
172
+ const out: ChatStreamEvent[] = []
173
+ const chunk = textDecoder.decode(bytes, { stream: true })
174
+
175
+ if (!inText) {
176
+ buffer += chunk
177
+ while (!inText) {
178
+ const recIdx = buffer.indexOf(END_OF_LEADING)
179
+ const nullIdx = buffer.indexOf(FRAME_TERMINATOR)
180
+ if (recIdx !== -1 && (nullIdx === -1 || recIdx < nullIdx)) {
181
+ inText = true
182
+ out.push({ type: 'turn-start' })
183
+ const after = buffer.slice(recIdx + 1)
184
+ buffer = ''
185
+ if (after) {
186
+ // The `after` slice may ALSO contain the `\x1F` trailing-
187
+ // usage sentinel — common for fixed-answer responses where
188
+ // the whole frame sequence arrives in ONE TCP chunk.
189
+ const unitIdx = after.indexOf(TRAILER_SENTINEL)
190
+ if (unitIdx === -1) {
191
+ out.push({ type: 'text-delta', text: after })
192
+ } else {
193
+ const textBefore = after.slice(0, unitIdx)
194
+ const trailerAfter = after.slice(unitIdx + 1)
195
+ if (textBefore) {
196
+ out.push({ type: 'text-delta', text: textBefore })
197
+ }
198
+ inTrailer = true
199
+ trailerBuffer = trailerAfter
200
+ }
201
+ }
202
+ break
203
+ }
204
+ if (nullIdx === -1) break // need more bytes
205
+ const metaStr = buffer.slice(0, nullIdx)
206
+ const remaining = buffer.slice(nullIdx + 1)
207
+ let meta: any
208
+ try {
209
+ meta = JSON.parse(metaStr)
210
+ } catch {
211
+ // Not JSON — start of answer body. The WHOLE buffer (including
212
+ // the `\0`) becomes answer text; `implicit` tells consumers
213
+ // this was the fallback path, not the `\x1E` sentinel.
214
+ inText = true
215
+ out.push({ type: 'turn-start', implicit: true })
216
+ if (buffer.length > 0) {
217
+ out.push({ type: 'text-delta', text: buffer })
218
+ buffer = ''
219
+ }
220
+ break
221
+ }
222
+ mapLeadingFrame(meta, out)
223
+ buffer = remaining
224
+ }
225
+ } else if (inTrailer) {
226
+ trailerBuffer += chunk
227
+ } else {
228
+ // Text mode: only the `\x1F` trailer sentinel is scanned. The
229
+ // unconditional emit (even for an empty decode of a partial
230
+ // multi-byte code point) mirrors the legacy per-chunk yield.
231
+ const sepIdx = chunk.indexOf(TRAILER_SENTINEL)
232
+ if (sepIdx === -1) {
233
+ out.push({ type: 'text-delta', text: chunk })
234
+ } else {
235
+ const before = chunk.slice(0, sepIdx)
236
+ const after = chunk.slice(sepIdx + 1)
237
+ if (before) out.push({ type: 'text-delta', text: before })
238
+ inTrailer = true
239
+ trailerBuffer = after
240
+ }
241
+ }
242
+ return out
243
+ }
244
+
245
+ function end(): ChatStreamEvent[] {
246
+ // Idempotency guard: a second end() must emit NOTHING. The trailer is
247
+ // also cleared so no later push()/end() pair can replay it.
248
+ if (ended) return []
249
+ ended = true
250
+ const out: ChatStreamEvent[] = []
251
+ if (trailerBuffer.length > 0) {
252
+ const raw = trailerBuffer
253
+ trailerBuffer = ''
254
+ try {
255
+ const meta = JSON.parse(raw)
256
+ if (meta.kind === 'usage' && (meta.stage === 'end' || meta.stage === 'display')) {
257
+ out.push({
258
+ type: 'usage',
259
+ stage: 'end',
260
+ input_tokens: meta.input_tokens,
261
+ output_tokens: meta.output_tokens,
262
+ cache_read_input_tokens: meta.cache_read_input_tokens,
263
+ cache_creation_input_tokens: meta.cache_creation_input_tokens,
264
+ hit_rate_pct: meta.hit_rate_pct,
265
+ telemetry: meta.telemetry,
266
+ breakdown: meta.breakdown,
267
+ debug: meta.debug,
268
+ })
269
+ }
270
+ } catch {
271
+ // Malformed trailer — silently ignore (legacy parity).
272
+ }
273
+ }
274
+ return out
275
+ }
276
+
277
+ return { push, end }
278
+ }