@flamingo-stack/openframe-frontend-core 0.0.489 → 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 (280) 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-K7MNWYYI.js → chunk-4NWARV5I.js} +2 -2
  22. package/dist/{chunk-2RNJYJWO.cjs → chunk-5CJR2GZ4.cjs} +31 -31
  23. package/dist/{chunk-2RNJYJWO.cjs.map → chunk-5CJR2GZ4.cjs.map} +1 -1
  24. package/dist/{chunk-H3YRJ3LZ.cjs → chunk-5EYRXSY5.cjs} +9 -9
  25. package/dist/{chunk-H3YRJ3LZ.cjs.map → chunk-5EYRXSY5.cjs.map} +1 -1
  26. package/dist/{chunk-IN4IO7BL.cjs → chunk-5LMGFI4J.cjs} +6930 -4755
  27. package/dist/chunk-5LMGFI4J.cjs.map +1 -0
  28. package/dist/{chunk-YMJZOANX.js → chunk-BE6DK7QY.js} +12228 -10053
  29. package/dist/chunk-BE6DK7QY.js.map +1 -0
  30. package/dist/{chunk-TWGYBXYV.js → chunk-CORIBKHG.js} +2 -2
  31. package/dist/{chunk-RO7Q56ZK.cjs → chunk-CSYSYJCF.cjs} +37 -37
  32. package/dist/{chunk-RO7Q56ZK.cjs.map → chunk-CSYSYJCF.cjs.map} +1 -1
  33. package/dist/{chunk-Z7Y63IFU.cjs → chunk-DSDZ5T4P.cjs} +5 -5
  34. package/dist/{chunk-Z7Y63IFU.cjs.map → chunk-DSDZ5T4P.cjs.map} +1 -1
  35. package/dist/{chunk-EU6QNO4S.js → chunk-GTWO5NAY.js} +2 -2
  36. package/dist/{chunk-BGWDDB7Y.js → chunk-HSACATCD.js} +2 -2
  37. package/dist/{chunk-3OIB763B.cjs → chunk-NWX7MMF5.cjs} +15 -15
  38. package/dist/{chunk-3OIB763B.cjs.map → chunk-NWX7MMF5.cjs.map} +1 -1
  39. package/dist/{chunk-J7H32E3R.cjs → chunk-P4WTZU6L.cjs} +11 -11
  40. package/dist/{chunk-J7H32E3R.cjs.map → chunk-P4WTZU6L.cjs.map} +1 -1
  41. package/dist/{chunk-VLOE7MI2.cjs → chunk-Q6E7VJ4W.cjs} +28 -27
  42. package/dist/chunk-Q6E7VJ4W.cjs.map +1 -0
  43. package/dist/{chunk-ZIAS5NNL.cjs → chunk-QSCHCZL3.cjs} +7 -7
  44. package/dist/{chunk-ZIAS5NNL.cjs.map → chunk-QSCHCZL3.cjs.map} +1 -1
  45. package/dist/{chunk-VL4LUO32.js → chunk-RQLBHKXN.js} +2 -2
  46. package/dist/{chunk-5YMY4BAW.cjs → chunk-S2NHJDKQ.cjs} +62 -62
  47. package/dist/{chunk-5YMY4BAW.cjs.map → chunk-S2NHJDKQ.cjs.map} +1 -1
  48. package/dist/{chunk-N4MTNTSX.js → chunk-T2EGSZFN.js} +6 -5
  49. package/dist/chunk-T2EGSZFN.js.map +1 -0
  50. package/dist/{chunk-W6ZUOWSB.js → chunk-TKN5667N.js} +10 -10
  51. package/dist/chunk-TKN5667N.js.map +1 -0
  52. package/dist/{chunk-B4YR342L.js → chunk-UUYRJPGJ.js} +4 -4
  53. package/dist/{chunk-FKPXFTZT.js → chunk-VHG6AX5V.js} +2 -2
  54. package/dist/{chunk-2IHN7NNB.cjs → chunk-VQ523ORK.cjs} +26 -26
  55. package/dist/{chunk-2IHN7NNB.cjs.map → chunk-VQ523ORK.cjs.map} +1 -1
  56. package/dist/{chunk-X2YF5TJS.js → chunk-XQ2B74SI.js} +2 -2
  57. package/dist/{chunk-LLEDO4P7.cjs → chunk-YHOIAA2E.cjs} +60 -60
  58. package/dist/chunk-YHOIAA2E.cjs.map +1 -0
  59. package/dist/{chunk-5VSEMWWE.js → chunk-YZ2OIYQ3.js} +4 -4
  60. package/dist/components/case-studies/index.cjs +8 -8
  61. package/dist/components/case-studies/index.js +2 -2
  62. package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
  63. package/dist/components/chat/chat-message-list.d.ts.map +1 -1
  64. package/dist/components/chat/hooks/index.d.ts +0 -1
  65. package/dist/components/chat/hooks/index.d.ts.map +1 -1
  66. package/dist/components/chat/hooks/use-chat.d.ts.map +1 -1
  67. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts +15 -48
  68. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
  69. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts +10 -57
  70. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
  71. package/dist/components/chat/index.cjs +10 -2
  72. package/dist/components/chat/index.cjs.map +1 -1
  73. package/dist/components/chat/index.d.ts +1 -0
  74. package/dist/components/chat/index.d.ts.map +1 -1
  75. package/dist/components/chat/index.js +21 -13
  76. package/dist/components/chat/stream/chat-dialog-store.d.ts +184 -0
  77. package/dist/components/chat/stream/chat-dialog-store.d.ts.map +1 -0
  78. package/dist/components/chat/stream/chat-stream-reducer.d.ts +367 -0
  79. package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +1 -0
  80. package/dist/components/chat/stream/delta-batcher.d.ts +51 -0
  81. package/dist/components/chat/stream/delta-batcher.d.ts.map +1 -0
  82. package/dist/components/chat/stream/index.d.ts +17 -0
  83. package/dist/components/chat/stream/index.d.ts.map +1 -0
  84. package/dist/components/chat/stream/message-mutations.d.ts +77 -0
  85. package/dist/components/chat/stream/message-mutations.d.ts.map +1 -0
  86. package/dist/components/chat/stream/use-chat-stream-reducer.d.ts +22 -0
  87. package/dist/components/chat/stream/use-chat-stream-reducer.d.ts.map +1 -0
  88. package/dist/components/chat/types/api.types.d.ts +1 -81
  89. package/dist/components/chat/types/api.types.d.ts.map +1 -1
  90. package/dist/components/chat/types/processing.types.d.ts +2 -90
  91. package/dist/components/chat/types/processing.types.d.ts.map +1 -1
  92. package/dist/components/chat/utils/extract-incomplete-message-state.d.ts +34 -6
  93. package/dist/components/chat/utils/extract-incomplete-message-state.d.ts.map +1 -1
  94. package/dist/components/chat/utils/index.d.ts +2 -3
  95. package/dist/components/chat/utils/index.d.ts.map +1 -1
  96. package/dist/components/chat/utils/process-historical-messages.d.ts +59 -11
  97. package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
  98. package/dist/components/contact/index.cjs +3 -3
  99. package/dist/components/contact/index.js +2 -2
  100. package/dist/components/docs/index.cjs +5 -5
  101. package/dist/components/docs/index.js +4 -4
  102. package/dist/components/embeds/index.cjs +3 -3
  103. package/dist/components/embeds/index.js +2 -2
  104. package/dist/components/faq/index.cjs +3 -3
  105. package/dist/components/faq/index.js +2 -2
  106. package/dist/components/features/index.cjs +2 -2
  107. package/dist/components/features/index.js +1 -1
  108. package/dist/components/help-center-pages/index.cjs +20 -20
  109. package/dist/components/help-center-pages/index.js +11 -11
  110. package/dist/components/index.cjs +172 -130
  111. package/dist/components/index.cjs.map +1 -1
  112. package/dist/components/index.js +63 -21
  113. package/dist/components/index.js.map +1 -1
  114. package/dist/components/navigation/index.cjs +2 -2
  115. package/dist/components/navigation/index.js +1 -1
  116. package/dist/components/onboarding-guides/index.cjs +5 -5
  117. package/dist/components/onboarding-guides/index.js +4 -4
  118. package/dist/components/onboarding-guides/onboarding-guide-detail-view.d.ts.map +1 -1
  119. package/dist/components/related-content/index.cjs +3 -3
  120. package/dist/components/related-content/index.js +2 -2
  121. package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
  122. package/dist/components/tickets/index.cjs +5 -5
  123. package/dist/components/tickets/index.js +4 -4
  124. package/dist/components/ui/index.cjs +44 -2
  125. package/dist/components/ui/index.cjs.map +1 -1
  126. package/dist/components/ui/index.d.ts +1 -2
  127. package/dist/components/ui/index.d.ts.map +1 -1
  128. package/dist/components/ui/index.js +55 -13
  129. package/dist/components/ui/markdown/base-components.d.ts +51 -0
  130. package/dist/components/ui/markdown/base-components.d.ts.map +1 -0
  131. package/dist/components/ui/markdown/engine.d.ts +99 -0
  132. package/dist/components/ui/markdown/engine.d.ts.map +1 -0
  133. package/dist/components/ui/markdown/heading-ids.d.ts +65 -0
  134. package/dist/components/ui/markdown/heading-ids.d.ts.map +1 -0
  135. package/dist/components/ui/markdown/index.d.ts +18 -0
  136. package/dist/components/ui/markdown/index.d.ts.map +1 -0
  137. package/dist/components/ui/markdown/mermaid-diagram.d.ts +79 -0
  138. package/dist/components/ui/markdown/mermaid-diagram.d.ts.map +1 -0
  139. package/dist/components/ui/markdown/rich/embed-overrides.d.ts +14 -0
  140. package/dist/components/ui/markdown/rich/embed-overrides.d.ts.map +1 -0
  141. package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts +40 -0
  142. package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts.map +1 -0
  143. package/dist/components/ui/markdown/rich/shortcodes.d.ts +8 -0
  144. package/dist/components/ui/markdown/rich/shortcodes.d.ts.map +1 -0
  145. package/dist/components/ui/markdown/sanitize.d.ts +153 -0
  146. package/dist/components/ui/markdown/sanitize.d.ts.map +1 -0
  147. package/dist/components/ui/markdown/simple-markdown-renderer.d.ts +23 -0
  148. package/dist/components/ui/markdown/simple-markdown-renderer.d.ts.map +1 -0
  149. package/dist/components/ui/markdown/streaming.d.ts +78 -0
  150. package/dist/components/ui/markdown/streaming.d.ts.map +1 -0
  151. package/dist/components/ui/markdown/text-size.d.ts +34 -0
  152. package/dist/components/ui/markdown/text-size.d.ts.map +1 -0
  153. package/dist/index.cjs +60 -2
  154. package/dist/index.cjs.map +1 -1
  155. package/dist/index.js +71 -13
  156. package/dist/utils/index.cjs +169 -40
  157. package/dist/utils/index.cjs.map +1 -1
  158. package/dist/utils/index.d.ts +1 -0
  159. package/dist/utils/index.d.ts.map +1 -1
  160. package/dist/utils/index.js +162 -41
  161. package/dist/utils/index.js.map +1 -1
  162. package/dist/utils/markdown-fences.d.ts +42 -0
  163. package/dist/utils/markdown-fences.d.ts.map +1 -0
  164. package/dist/utils/markdown-heading-id.d.ts +85 -0
  165. package/dist/utils/markdown-heading-id.d.ts.map +1 -0
  166. package/dist/utils/markdown-section-extractor.d.ts.map +1 -1
  167. package/package.json +7 -1
  168. package/src/chat-protocol/__tests__/__snapshots__/nats-decoder-golden.test.ts.snap +307 -0
  169. package/src/chat-protocol/__tests__/chat-protocol.test.ts +516 -0
  170. package/src/chat-protocol/__tests__/env-flag.test.ts +52 -0
  171. package/src/chat-protocol/__tests__/ip-normalize.test.ts +136 -0
  172. package/src/chat-protocol/__tests__/nats-decoder-golden.test.ts +228 -0
  173. package/src/chat-protocol/decode.ts +278 -0
  174. package/src/chat-protocol/encode.ts +71 -0
  175. package/src/chat-protocol/env-flag.ts +39 -0
  176. package/src/chat-protocol/events.ts +232 -0
  177. package/src/chat-protocol/frames.ts +245 -0
  178. package/src/chat-protocol/index.ts +21 -0
  179. package/src/chat-protocol/ip-normalize.ts +146 -0
  180. package/src/chat-protocol/nats-decoder.ts +252 -0
  181. package/src/components/chat/__tests__/chat-message-list.test.tsx +293 -5
  182. package/src/components/chat/__tests__/chat-message-streaming-memo.test.tsx +207 -0
  183. package/src/components/chat/__tests__/chat-pending-turn.test.tsx +209 -0
  184. package/src/components/chat/chat-message-enhanced.tsx +111 -17
  185. package/src/components/chat/chat-message-list.tsx +358 -109
  186. package/src/components/chat/embeddable-chat.tsx +6 -0
  187. package/src/components/chat/hooks/.index.md +30 -33
  188. package/src/components/chat/hooks/.use-nats-chat-adapter.md +36 -55
  189. package/src/components/chat/hooks/__tests__/__snapshots__/conversation-id-persistence-golden.test.ts.snap +37 -0
  190. package/src/components/chat/hooks/__tests__/__snapshots__/sse-stream-golden.test.ts.snap +0 -0
  191. package/src/components/chat/hooks/__tests__/conversation-id-persistence-golden.test.ts +438 -0
  192. package/src/components/chat/hooks/__tests__/sse-stream-golden.test.ts +318 -0
  193. package/src/components/chat/hooks/index.ts +0 -1
  194. package/src/components/chat/hooks/use-chat.ts +8 -0
  195. package/src/components/chat/hooks/use-nats-chat-adapter.ts +243 -823
  196. package/src/components/chat/hooks/use-sse-chat-adapter.ts +380 -684
  197. package/src/components/chat/index.ts +5 -0
  198. package/src/components/chat/stream/__tests__/__snapshots__/chat-stream-reducer-golden.test.ts.snap +940 -0
  199. package/src/components/chat/stream/__tests__/chat-dialog-store.test.ts +756 -0
  200. package/src/components/chat/stream/__tests__/chat-stream-reducer-golden.test.ts +460 -0
  201. package/src/components/chat/stream/__tests__/chat-stream-reducer.test.ts +894 -0
  202. package/src/components/chat/stream/__tests__/delta-batcher.test.ts +255 -0
  203. package/src/components/chat/stream/__tests__/use-chat-stream-reducer.test.ts +98 -0
  204. package/src/components/chat/stream/chat-dialog-store.ts +536 -0
  205. package/src/components/chat/stream/chat-stream-reducer.ts +1796 -0
  206. package/src/components/chat/stream/delta-batcher.ts +159 -0
  207. package/src/components/chat/stream/index.ts +55 -0
  208. package/src/components/chat/stream/message-mutations.ts +338 -0
  209. package/src/components/chat/stream/use-chat-stream-reducer.ts +126 -0
  210. package/src/components/chat/thinking-display.tsx +1 -1
  211. package/src/components/chat/types/.api.types.md +39 -49
  212. package/src/components/chat/types/.processing.types.md +29 -58
  213. package/src/components/chat/types/api.types.ts +6 -73
  214. package/src/components/chat/types/processing.types.ts +11 -52
  215. package/src/components/chat/utils/.index.md +31 -40
  216. package/src/components/chat/utils/__tests__/__snapshots__/process-historical-messages-golden.test.ts.snap +420 -0
  217. package/src/components/chat/utils/__tests__/__snapshots__/segment-accumulator-golden.test.ts.snap +605 -0
  218. package/src/components/chat/utils/__tests__/process-historical-messages-golden.test.ts +317 -0
  219. package/src/components/chat/utils/__tests__/segment-accumulator-golden.test.ts +270 -0
  220. package/src/components/chat/utils/extract-incomplete-message-state.ts +63 -5
  221. package/src/components/chat/utils/index.ts +5 -8
  222. package/src/components/chat/utils/process-historical-messages.ts +352 -384
  223. package/src/components/onboarding-guides/onboarding-guide-detail-view.tsx +22 -4
  224. package/src/components/shared/legal-document/legal-document-page.tsx +1 -1
  225. package/src/components/shared/product-release/release-detail-page.tsx +19 -4
  226. package/src/components/ui/__tests__/__snapshots__/markdown-parity.test.tsx.snap +2420 -0
  227. package/src/components/ui/__tests__/markdown-parity.test.tsx +2528 -0
  228. package/src/components/ui/index.ts +1 -2
  229. package/src/components/ui/markdown/__tests__/mermaid-security.test.ts +115 -0
  230. package/src/components/ui/markdown/__tests__/mermaid-stale-render.test.tsx +95 -0
  231. package/src/components/ui/markdown/__tests__/sanitize-invariant.test.ts +309 -0
  232. package/src/components/ui/markdown/__tests__/streaming.test.tsx +388 -0
  233. package/src/components/ui/markdown/base-components.tsx +360 -0
  234. package/src/components/ui/markdown/engine.tsx +315 -0
  235. package/src/components/ui/markdown/heading-ids.ts +239 -0
  236. package/src/components/ui/markdown/index.ts +49 -0
  237. package/src/components/ui/markdown/mermaid-diagram.tsx +291 -0
  238. package/src/components/ui/markdown/rich/embed-overrides.tsx +199 -0
  239. package/src/components/ui/markdown/rich/rich-markdown-renderer.tsx +184 -0
  240. package/src/components/ui/markdown/rich/shortcodes.ts +170 -0
  241. package/src/components/ui/markdown/sanitize.ts +3499 -0
  242. package/src/components/ui/markdown/simple-markdown-renderer.tsx +28 -0
  243. package/src/components/ui/markdown/streaming.ts +362 -0
  244. package/src/components/ui/markdown/text-size.ts +106 -0
  245. package/src/components/ui/release-changelog-section.tsx +1 -1
  246. package/src/components/ui/ticket-info-section.tsx +1 -1
  247. package/src/utils/index.ts +1 -0
  248. package/src/utils/markdown-fences.ts +127 -0
  249. package/src/utils/markdown-heading-id.ts +348 -0
  250. package/src/utils/markdown-section-extractor.ts +41 -56
  251. package/dist/chunk-IN4IO7BL.cjs.map +0 -1
  252. package/dist/chunk-LLEDO4P7.cjs.map +0 -1
  253. package/dist/chunk-N4MTNTSX.js.map +0 -1
  254. package/dist/chunk-VLOE7MI2.cjs.map +0 -1
  255. package/dist/chunk-W6ZUOWSB.js.map +0 -1
  256. package/dist/chunk-YMJZOANX.js.map +0 -1
  257. package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts +0 -6
  258. package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts.map +0 -1
  259. package/dist/components/chat/utils/chunk-parser.d.ts +0 -25
  260. package/dist/components/chat/utils/chunk-parser.d.ts.map +0 -1
  261. package/dist/components/ui/rich-markdown-renderer.d.ts +0 -34
  262. package/dist/components/ui/rich-markdown-renderer.d.ts.map +0 -1
  263. package/dist/components/ui/simple-markdown-renderer.d.ts +0 -73
  264. package/dist/components/ui/simple-markdown-renderer.d.ts.map +0 -1
  265. package/src/components/chat/hooks/.use-realtime-chunk-processor.md +0 -77
  266. package/src/components/chat/hooks/use-realtime-chunk-processor.ts +0 -479
  267. package/src/components/chat/utils/.chunk-parser.md +0 -62
  268. package/src/components/chat/utils/chunk-parser.ts +0 -256
  269. package/src/components/ui/.simple-markdown-renderer.md +0 -52
  270. package/src/components/ui/rich-markdown-renderer.tsx +0 -1223
  271. package/src/components/ui/simple-markdown-renderer.tsx +0 -964
  272. /package/dist/{chunk-K7MNWYYI.js.map → chunk-4NWARV5I.js.map} +0 -0
  273. /package/dist/{chunk-TWGYBXYV.js.map → chunk-CORIBKHG.js.map} +0 -0
  274. /package/dist/{chunk-EU6QNO4S.js.map → chunk-GTWO5NAY.js.map} +0 -0
  275. /package/dist/{chunk-BGWDDB7Y.js.map → chunk-HSACATCD.js.map} +0 -0
  276. /package/dist/{chunk-VL4LUO32.js.map → chunk-RQLBHKXN.js.map} +0 -0
  277. /package/dist/{chunk-B4YR342L.js.map → chunk-UUYRJPGJ.js.map} +0 -0
  278. /package/dist/{chunk-FKPXFTZT.js.map → chunk-VHG6AX5V.js.map} +0 -0
  279. /package/dist/{chunk-X2YF5TJS.js.map → chunk-XQ2B74SI.js.map} +0 -0
  280. /package/dist/{chunk-5VSEMWWE.js.map → chunk-YZ2OIYQ3.js.map} +0 -0
@@ -0,0 +1,317 @@
1
+ /**
2
+ * Phase-0 GOLDEN CHARACTERIZATION TESTS — `processHistoricalMessages`.
3
+ *
4
+ * Pins the CURRENT GraphQL-history → ProcessedMessage[] transformation
5
+ * before the stream-reader unification. A recorded corpus of messageData
6
+ * rows is processed and the FULL output array is snapshotted.
7
+ *
8
+ * NOTE: `process-historical-messages-approvals.test.ts` already guards
9
+ * displayApprovalTypes semantics — this file deliberately does not
10
+ * duplicate it and instead records the full-output shape.
11
+ */
12
+
13
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
14
+ import { processHistoricalMessages, extractErrorMessages, processHistoricalMessagesWithErrors } from '../process-historical-messages'
15
+ import type { HistoricalMessage } from '../../types'
16
+
17
+ // Deterministic Date.now for the `pending-approvals-${Date.now()}` synthetic
18
+ // message id + its `new Date()` timestamp.
19
+ const FIXED_NOW = new Date('2026-07-20T10:00:00.000Z')
20
+
21
+ beforeEach(() => {
22
+ vi.useFakeTimers()
23
+ vi.setSystemTime(FIXED_NOW)
24
+ })
25
+
26
+ afterEach(() => {
27
+ vi.useRealTimers()
28
+ })
29
+
30
+ /** Recorded corpus — one realistic dialog covering every row shape. */
31
+ const CORPUS: HistoricalMessage[] = [
32
+ // 1. User message with contextItems (CLIENT owner).
33
+ {
34
+ id: 'u1',
35
+ chatType: 'CLIENT_CHAT',
36
+ createdAt: '2026-07-19T09:00:00Z',
37
+ owner: { type: 'CLIENT', userId: 'user-42' },
38
+ lastChunkStreamSeq: 10,
39
+ messageData: [
40
+ {
41
+ type: 'TEXT',
42
+ text: 'diagnose this device please',
43
+ // wire shape: [{ type, id }] — label falls back to id
44
+ contextItems: [
45
+ { type: 'device', id: 'dev-1' },
46
+ { type: 'bad' }, // missing id → filtered
47
+ ],
48
+ } as any,
49
+ ],
50
+ },
51
+ // 2. Assistant: thinking + text (grouped into one bubble).
52
+ {
53
+ id: 'a1',
54
+ chatType: 'CLIENT_CHAT',
55
+ createdAt: '2026-07-19T09:00:05Z',
56
+ owner: { type: 'ASSISTANT', model: 'claude-sonnet-x' },
57
+ lastChunkStreamSeq: 20,
58
+ messageData: [
59
+ { type: 'THINKING', text: 'Checking device state… ' },
60
+ { type: 'THINKING', text: 'looks like disk pressure.' },
61
+ { type: 'TEXT', text: 'The device is low on disk. ' },
62
+ ],
63
+ },
64
+ // 3. Assistant continued: EXECUTING + EXECUTED tool pair merges in place.
65
+ {
66
+ id: 'a2',
67
+ chatType: 'CLIENT_CHAT',
68
+ createdAt: '2026-07-19T09:00:10Z',
69
+ owner: { type: 'ASSISTANT' },
70
+ lastChunkStreamSeq: 15, // lower than a1's — MAX (20) must win on the flushed turn
71
+ messageData: [
72
+ {
73
+ type: 'EXECUTING_TOOL',
74
+ integratedToolType: 'TACTICAL_RMM',
75
+ toolFunction: 'run_script',
76
+ title: 'Run cleanup script',
77
+ parameters: { script: 'cleanup.sh' },
78
+ toolExecutionRequestId: 'exec-1',
79
+ },
80
+ {
81
+ type: 'EXECUTED_TOOL',
82
+ integratedToolType: 'TACTICAL_RMM',
83
+ toolFunction: 'run_script',
84
+ parameters: { script: 'cleanup.sh' },
85
+ result: 'Freed 2.3 GB',
86
+ success: true,
87
+ toolExecutionRequestId: 'exec-1',
88
+ },
89
+ { type: 'TEXT', text: 'Cleanup done.' },
90
+ ],
91
+ },
92
+ // 4. ADMIN-owned user message (owner-type author resolution: display name from user).
93
+ {
94
+ id: 'u2',
95
+ chatType: 'CLIENT_CHAT',
96
+ createdAt: '2026-07-19T09:01:00Z',
97
+ owner: {
98
+ type: 'ADMIN',
99
+ user: {
100
+ id: 'admin-7',
101
+ firstName: 'Jane',
102
+ lastName: 'Admin',
103
+ image: { imageUrl: '/avatars/jane.png' },
104
+ },
105
+ },
106
+ messageData: [{ type: 'TEXT', text: 'please restart the broker' }],
107
+ },
108
+ // 5. Assistant: single approval request + result (resolved in-history).
109
+ {
110
+ id: 'a3',
111
+ chatType: 'CLIENT_CHAT',
112
+ createdAt: '2026-07-19T09:01:05Z',
113
+ owner: { type: 'ASSISTANT' },
114
+ lastChunkStreamSeq: 30,
115
+ messageData: [
116
+ { type: 'TEXT', text: 'I need approval to restart it.' },
117
+ {
118
+ type: 'APPROVAL_REQUEST',
119
+ approvalRequestId: 'req-1',
120
+ approvalType: 'CLIENT',
121
+ command: 'systemctl restart nats',
122
+ explanation: 'Restart the message broker',
123
+ },
124
+ {
125
+ type: 'APPROVAL_RESULT',
126
+ approvalRequestId: 'req-1',
127
+ approved: true,
128
+ approvalType: 'CLIENT',
129
+ resolvedByName: 'Jane Admin',
130
+ },
131
+ ],
132
+ },
133
+ // 6. Assistant: batch approval + per-tool executions merged into the batch.
134
+ {
135
+ id: 'a4',
136
+ chatType: 'CLIENT_CHAT',
137
+ createdAt: '2026-07-19T09:02:00Z',
138
+ owner: { type: 'ASSISTANT' },
139
+ messageData: [
140
+ {
141
+ type: 'APPROVAL_REQUEST',
142
+ approvalRequestId: 'batch-1',
143
+ approvalType: 'CLIENT',
144
+ toolCalls: [
145
+ {
146
+ toolExecutionRequestId: 'exec-a',
147
+ toolName: 'create_ticket',
148
+ toolTitle: 'Create ticket',
149
+ requiresApproval: true,
150
+ approvalType: 'CLIENT',
151
+ toolCallArguments: { subject: 'Printer down' },
152
+ },
153
+ {
154
+ toolExecutionRequestId: 'exec-b',
155
+ toolName: 'read_kb',
156
+ requiresApproval: false,
157
+ },
158
+ ],
159
+ },
160
+ {
161
+ type: 'APPROVAL_RESULT',
162
+ approvalRequestId: 'batch-1',
163
+ approved: true,
164
+ resolvedByName: 'Alice Client',
165
+ },
166
+ {
167
+ type: 'EXECUTING_TOOL',
168
+ integratedToolType: 'PSA',
169
+ toolFunction: 'create_ticket',
170
+ toolExecutionRequestId: 'exec-a',
171
+ },
172
+ {
173
+ type: 'EXECUTED_TOOL',
174
+ integratedToolType: 'PSA',
175
+ toolFunction: 'create_ticket',
176
+ result: 'Ticket #77 created',
177
+ success: true,
178
+ toolExecutionRequestId: 'exec-a',
179
+ },
180
+ ],
181
+ },
182
+ // 7. Standalone SYSTEM message row — flushes the assistant turn and
183
+ // renders as a system-authored row.
184
+ {
185
+ id: 's1',
186
+ chatType: 'CLIENT_CHAT',
187
+ createdAt: '2026-07-19T09:03:00Z',
188
+ owner: { type: 'ASSISTANT' },
189
+ lastChunkStreamSeq: 40,
190
+ messageData: [{ type: 'SYSTEM', text: 'Technician joined the chat' } as any],
191
+ },
192
+ // 8. DIRECT_MESSAGE row. CHARACTERIZATION: DIRECT_MESSAGE is a
193
+ // realtime-only concept — the history processors have NO branch for
194
+ // it. This ADMIN-owned row takes the user-message path, which only
195
+ // renders TEXT data, so it contributes NOTHING to the output (the
196
+ // bubble is lost on reload). Recording current behavior as-is; see
197
+ // unification plan.
198
+ {
199
+ id: 'd1',
200
+ chatType: 'CLIENT_CHAT',
201
+ createdAt: '2026-07-19T09:03:30Z',
202
+ owner: { type: 'ADMIN', user: { id: 'admin-7', firstName: 'Jane' } },
203
+ messageData: [{ type: 'DIRECT_MESSAGE', text: 'Human here, taking over' } as any],
204
+ },
205
+ // 9. Error row inside an assistant turn.
206
+ {
207
+ id: 'a5',
208
+ chatType: 'CLIENT_CHAT',
209
+ createdAt: '2026-07-19T09:04:00Z',
210
+ owner: { type: 'ASSISTANT' },
211
+ messageData: [
212
+ { type: 'TEXT', text: 'Attempting one more thing… ' },
213
+ { type: 'ERROR', error: 'Agent crashed', details: '{"error":{"message":"boom"}}' },
214
+ ],
215
+ },
216
+ ]
217
+
218
+ describe('processHistoricalMessages — golden corpus', () => {
219
+ it('processes the full recorded dialog (snapshot)', () => {
220
+ const result = processHistoricalMessages(CORPUS, {
221
+ assistantName: 'Mingo AI',
222
+ assistantType: 'mingo',
223
+ assistantAvatar: '/avatars/mingo.png',
224
+ })
225
+ expect({
226
+ messages: result.messages,
227
+ escalatedApprovals: Array.from(result.escalatedApprovals.entries()),
228
+ }).toMatchSnapshot()
229
+ })
230
+
231
+ it('unresolved single approval is resurrected as a synthetic pending-approvals bubble', () => {
232
+ const result = processHistoricalMessages([
233
+ {
234
+ id: 'a1',
235
+ createdAt: '2026-07-19T09:00:00Z',
236
+ owner: { type: 'ASSISTANT' },
237
+ messageData: [
238
+ {
239
+ type: 'APPROVAL_REQUEST',
240
+ approvalRequestId: 'req-pending',
241
+ approvalType: 'CLIENT',
242
+ command: 'reboot server',
243
+ },
244
+ ],
245
+ },
246
+ ])
247
+ // Date.now is frozen → deterministic synthetic id.
248
+ expect(result.messages).toMatchSnapshot()
249
+ })
250
+
251
+ it('owner-type author resolution: CLIENT vs ADMIN vs ADMIN-without-name display names/avatars', () => {
252
+ const rows: HistoricalMessage[] = [
253
+ {
254
+ id: 'u-client',
255
+ createdAt: '2026-07-19T09:00:00Z',
256
+ owner: { type: 'CLIENT', userId: 'user-1' },
257
+ messageData: [{ type: 'TEXT', text: 'from client' }],
258
+ },
259
+ {
260
+ id: 'u-admin-named',
261
+ createdAt: '2026-07-19T09:00:01Z',
262
+ owner: {
263
+ type: 'ADMIN',
264
+ user: { id: 'adm', firstName: 'Jane', lastName: 'Admin', image: { imageUrl: '/a.png' } },
265
+ },
266
+ messageData: [{ type: 'TEXT', text: 'from named admin' }],
267
+ },
268
+ {
269
+ id: 'u-admin-anon',
270
+ createdAt: '2026-07-19T09:00:02Z',
271
+ owner: { type: 'ADMIN' },
272
+ messageData: [{ type: 'TEXT', text: 'from anonymous admin' }],
273
+ },
274
+ ]
275
+ expect(processHistoricalMessages(rows).messages).toMatchSnapshot()
276
+ })
277
+
278
+ it('chatTypeFilter drops rows of other chat types', () => {
279
+ const rows: HistoricalMessage[] = [
280
+ {
281
+ id: 'u1',
282
+ chatType: 'CLIENT_CHAT',
283
+ createdAt: '2026-07-19T09:00:00Z',
284
+ owner: { type: 'CLIENT' },
285
+ messageData: [{ type: 'TEXT', text: 'visible' }],
286
+ },
287
+ {
288
+ id: 'u2',
289
+ chatType: 'ADMIN_AI_CHAT',
290
+ createdAt: '2026-07-19T09:00:01Z',
291
+ owner: { type: 'CLIENT' },
292
+ messageData: [{ type: 'TEXT', text: 'filtered out' }],
293
+ },
294
+ ]
295
+ expect(
296
+ processHistoricalMessages(rows, { chatTypeFilter: 'CLIENT_CHAT' }).messages,
297
+ ).toMatchSnapshot()
298
+ })
299
+ })
300
+
301
+ describe('extractErrorMessages / processHistoricalMessagesWithErrors — golden', () => {
302
+ it('extractErrorMessages surfaces ERROR rows as standalone error-role messages', () => {
303
+ expect(extractErrorMessages(CORPUS, { assistantName: 'Mingo AI', assistantType: 'mingo' })).toMatchSnapshot()
304
+ })
305
+
306
+ it('processHistoricalMessagesWithErrors output parity on the same corpus (snapshot)', () => {
307
+ const result = processHistoricalMessagesWithErrors(CORPUS, {
308
+ assistantName: 'Mingo AI',
309
+ assistantType: 'mingo',
310
+ assistantAvatar: '/avatars/mingo.png',
311
+ })
312
+ expect({
313
+ messages: result.messages,
314
+ escalatedApprovals: Array.from(result.escalatedApprovals.entries()),
315
+ }).toMatchSnapshot()
316
+ })
317
+ })
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Phase-0 GOLDEN CHARACTERIZATION TESTS — `MessageSegmentAccumulator`.
3
+ *
4
+ * Pins the CURRENT segment-accumulation semantics before the stream-reader
5
+ * unification. Each test drives the accumulator through a recorded event
6
+ * sequence and snapshots the final segment array. Behaviors captured here
7
+ * (including the implicit approval-flip on tool execution and the batch
8
+ * no-downgrade rule) are the recorded baseline the future reducer must match.
9
+ */
10
+
11
+ import { describe, it, expect } from 'vitest'
12
+ import { createMessageSegmentAccumulator } from '../message-segment-accumulator'
13
+ import type { ToolExecutionSegment, PendingToolCallData } from '../../types'
14
+
15
+ const executing = (
16
+ execId: string | undefined,
17
+ overrides: Partial<ToolExecutionSegment['data']> = {},
18
+ ): ToolExecutionSegment => ({
19
+ type: 'tool_execution',
20
+ data: {
21
+ type: 'EXECUTING_TOOL',
22
+ integratedToolType: 'TACTICAL_RMM',
23
+ toolFunction: 'run_script',
24
+ toolTitle: 'Run cleanup script',
25
+ parameters: { script: 'cleanup.sh' },
26
+ ...(execId ? { toolExecutionRequestId: execId } : {}),
27
+ ...overrides,
28
+ },
29
+ })
30
+
31
+ const executed = (
32
+ execId: string | undefined,
33
+ overrides: Partial<ToolExecutionSegment['data']> = {},
34
+ ): ToolExecutionSegment => ({
35
+ type: 'tool_execution',
36
+ data: {
37
+ type: 'EXECUTED_TOOL',
38
+ integratedToolType: 'TACTICAL_RMM',
39
+ toolFunction: 'run_script',
40
+ // Backend omits toolTitle on EXECUTED_TOOL — the accumulator restores it.
41
+ result: 'Freed 2.3 GB',
42
+ success: true,
43
+ ...(execId ? { toolExecutionRequestId: execId } : {}),
44
+ ...overrides,
45
+ },
46
+ })
47
+
48
+ const batchToolCalls: PendingToolCallData[] = [
49
+ {
50
+ toolExecutionRequestId: 'exec-a',
51
+ toolName: 'create_ticket',
52
+ toolTitle: 'Create ticket',
53
+ toolExplanation: 'Opens a support ticket',
54
+ requiresApproval: true,
55
+ approvalType: 'CLIENT',
56
+ toolCallArguments: { subject: 'Printer down' },
57
+ },
58
+ {
59
+ toolExecutionRequestId: 'exec-b',
60
+ toolName: 'read_kb',
61
+ requiresApproval: false,
62
+ approvalType: null,
63
+ toolCallArguments: null,
64
+ },
65
+ ]
66
+
67
+ describe('MessageSegmentAccumulator — golden sequences', () => {
68
+ it('coalesces consecutive text deltas into one text segment', () => {
69
+ const acc = createMessageSegmentAccumulator()
70
+ acc.appendText('Hello')
71
+ acc.appendText(', ')
72
+ acc.appendText('world')
73
+ expect(acc.getSegments()).toMatchSnapshot()
74
+ })
75
+
76
+ it('coalesces thinking deltas, and text after thinking starts a NEW segment', () => {
77
+ const acc = createMessageSegmentAccumulator()
78
+ acc.appendThinking('Let me think')
79
+ acc.appendThinking('… ok.')
80
+ acc.appendText('Answer: ')
81
+ acc.appendText('42')
82
+ // A late thinking delta after text starts ANOTHER thinking segment
83
+ // (append-to-last only merges into a trailing segment of the same type).
84
+ acc.appendThinking('post-answer thought')
85
+ expect(acc.getSegments()).toMatchSnapshot()
86
+ })
87
+
88
+ it('merges EXECUTING_TOOL → EXECUTED_TOOL in place by toolExecutionRequestId (title restored)', () => {
89
+ const acc = createMessageSegmentAccumulator()
90
+ acc.appendText('Running a cleanup now.')
91
+ acc.addToolExecution(executing('exec-1'))
92
+ acc.addToolExecution(executed('exec-1'))
93
+ expect(acc.getSegments()).toMatchSnapshot()
94
+ })
95
+
96
+ it('pairs by (integratedToolType, toolFunction) when no exec id is present (older backends)', () => {
97
+ const acc = createMessageSegmentAccumulator()
98
+ acc.addToolExecution(executing(undefined))
99
+ acc.addToolExecution(executed(undefined))
100
+ expect(acc.getSegments()).toMatchSnapshot()
101
+ })
102
+
103
+ it('EXECUTED with no prior EXECUTING lands as a standalone completed segment', () => {
104
+ const acc = createMessageSegmentAccumulator()
105
+ acc.addToolExecution(executed('exec-orphan'))
106
+ expect(acc.getSegments()).toMatchSnapshot()
107
+ })
108
+
109
+ it('CHARACTERIZATION: a LATE standalone EXECUTING after its EXECUTED pushes a NEW executing segment (no in-place downgrade, but a duplicate card appears)', () => {
110
+ // The no-downgrade rule is enforced INSIDE approval batches
111
+ // (applyExecutionToBatch); for standalone segments a redelivered
112
+ // EXECUTING after EXECUTED does NOT downgrade the merged segment —
113
+ // it appends a fresh EXECUTING segment. Recording as-is, see
114
+ // unification plan.
115
+ const acc = createMessageSegmentAccumulator()
116
+ acc.addToolExecution(executing('exec-1'))
117
+ acc.addToolExecution(executed('exec-1'))
118
+ acc.addToolExecution(executing('exec-1'))
119
+ expect(acc.getSegments()).toMatchSnapshot()
120
+ })
121
+
122
+ it('single approval request → updateApprovalStatus flips status and stamps resolvedByName only on batches', () => {
123
+ const acc = createMessageSegmentAccumulator({
124
+ onApprove: async () => {},
125
+ onReject: async () => {},
126
+ })
127
+ acc.addApprovalRequest('req-1', 'systemctl restart nats', 'Restart broker', 'CLIENT')
128
+ acc.updateApprovalStatus('req-1', 'approved', 'Jane Admin')
129
+ expect(acc.getSegments()).toMatchSnapshot()
130
+ })
131
+
132
+ it('tracked (pending) approval + processApprovalResult appends a resolved segment', () => {
133
+ const acc = createMessageSegmentAccumulator()
134
+ acc.trackApprovalRequest('req-2', {
135
+ command: 'rm -rf /tmp/cache',
136
+ explanation: 'Clear caches',
137
+ approvalType: 'CLIENT',
138
+ })
139
+ const result = acc.processApprovalResult('req-2', false, 'CLIENT')
140
+ expect({
141
+ segments: acc.getSegments(),
142
+ pendingData: result?.pendingData ?? null,
143
+ hasPendingAfter: acc.hasPendingApprovals(),
144
+ }).toMatchSnapshot()
145
+ })
146
+
147
+ it('batch approval with per-tool executions merged into the batch (no standalone segments)', () => {
148
+ const acc = createMessageSegmentAccumulator()
149
+ acc.addApprovalBatch('batch-1', 'CLIENT', batchToolCalls)
150
+ acc.updateApprovalStatus('batch-1', 'approved', 'Jane Admin')
151
+ // Executions matching the batch's toolExecutionRequestIds merge into
152
+ // `executions` instead of pushing standalone tool_execution segments.
153
+ acc.addToolExecution(executing('exec-a', { toolFunction: 'create_ticket' }))
154
+ acc.addToolExecution(
155
+ executed('exec-a', { toolFunction: 'create_ticket', result: 'Ticket #77 created' }),
156
+ )
157
+ acc.addToolExecution(executing('exec-b', { toolFunction: 'read_kb' }))
158
+ expect(acc.getSegments()).toMatchSnapshot()
159
+ })
160
+
161
+ it('batch no-downgrade rule: a redelivered EXECUTING after EXECUTED keeps the done slot', () => {
162
+ const acc = createMessageSegmentAccumulator()
163
+ acc.addApprovalBatch('batch-1', 'CLIENT', batchToolCalls, 'approved')
164
+ acc.addToolExecution(executed('exec-a', { result: 'done!' }))
165
+ // Late/redelivered EXECUTING for the same exec id — must NOT flip
166
+ // the slot back to 'executing'.
167
+ acc.addToolExecution(executing('exec-a'))
168
+ expect(acc.getSegments()).toMatchSnapshot()
169
+ })
170
+
171
+ it('addApprovalBatch upserts by approvalRequestId (replay path produces ONE batch segment)', () => {
172
+ const acc = createMessageSegmentAccumulator()
173
+ acc.addApprovalBatch('batch-1', 'CLIENT', batchToolCalls, 'pending')
174
+ acc.addApprovalBatch('batch-1', 'CLIENT', batchToolCalls, 'approved', {
175
+ 'exec-a': { status: 'done', result: 'ok', success: true },
176
+ }, 'Jane Admin')
177
+ expect(acc.getSegments()).toMatchSnapshot()
178
+ })
179
+
180
+ it('implicit resolvePendingApprovalForExecution: a tool execution flips the LATEST pending single approval (NATS-observer behavior)', () => {
181
+ // An observer (technician mirroring a client chat) may never receive
182
+ // APPROVAL_RESULT — only the tool's EXECUTING/EXECUTED events. The
183
+ // accumulator treats a tool execution as implicit approval of the most
184
+ // recent still-pending single approval gate. Pinning explicitly.
185
+ const acc = createMessageSegmentAccumulator()
186
+ acc.addApprovalRequest('req-old', 'echo old', undefined, 'CLIENT', 'rejected')
187
+ acc.addApprovalRequest('req-1', 'systemctl restart nats', undefined, 'CLIENT', 'pending')
188
+ acc.addToolExecution(executing('exec-9'))
189
+ expect(acc.getSegments()).toMatchSnapshot()
190
+ })
191
+
192
+ it('implicit approval flips ONLY the latest pending gate, earlier pending gates stay pending', () => {
193
+ const acc = createMessageSegmentAccumulator()
194
+ acc.addApprovalRequest('req-1', 'first', undefined, 'CLIENT', 'pending')
195
+ acc.addApprovalRequest('req-2', 'second', undefined, 'CLIENT', 'pending')
196
+ acc.addToolExecution(executing('exec-1'))
197
+ expect(acc.getSegments()).toMatchSnapshot()
198
+ })
199
+
200
+ it('implicit approval does NOT touch approval_batch segments (handled by applyExecutionToBatch only)', () => {
201
+ const acc = createMessageSegmentAccumulator()
202
+ acc.addApprovalBatch('batch-1', 'CLIENT', batchToolCalls, 'pending')
203
+ // exec id NOT in the batch → standalone segment path; batch stays pending.
204
+ acc.addToolExecution(executing('exec-unrelated'))
205
+ expect(acc.getSegments()).toMatchSnapshot()
206
+ })
207
+
208
+ it('context compaction start → complete replaces the started segment in place', () => {
209
+ const acc = createMessageSegmentAccumulator()
210
+ acc.appendText('Before compaction')
211
+ acc.addContextCompaction()
212
+ acc.completeContextCompaction('Summarized 34 messages')
213
+ acc.appendText('After compaction')
214
+ expect(acc.getSegments()).toMatchSnapshot()
215
+ })
216
+
217
+ it('completeContextCompaction with no started segment pushes a completed one', () => {
218
+ const acc = createMessageSegmentAccumulator()
219
+ acc.completeContextCompaction(undefined)
220
+ expect(acc.getSegments()).toMatchSnapshot()
221
+ })
222
+
223
+ it('error segments append verbatim', () => {
224
+ const acc = createMessageSegmentAccumulator()
225
+ acc.appendText('partial answer')
226
+ acc.addError('Agent crashed', 'boom')
227
+ expect(acc.getSegments()).toMatchSnapshot()
228
+ })
229
+
230
+ it('flushPendingApprovals emits pending cards for every tracked-but-unresolved approval', () => {
231
+ const acc = createMessageSegmentAccumulator()
232
+ acc.trackApprovalRequest('req-1', { command: 'cmd-1', approvalType: 'CLIENT' })
233
+ acc.trackApprovalRequest('req-2', { command: 'cmd-2', explanation: 'why', approvalType: 'ADMIN' })
234
+ expect(acc.flushPendingApprovals()).toMatchSnapshot()
235
+ })
236
+
237
+ it('replaySegments reproduces a mixed recorded segment array (idempotent replay)', () => {
238
+ const acc = createMessageSegmentAccumulator()
239
+ acc.appendThinking('hmm')
240
+ acc.appendText('Sure — ')
241
+ acc.addToolExecution(executing('exec-1'))
242
+ acc.addToolExecution(executed('exec-1'))
243
+ acc.addApprovalBatch('batch-1', 'CLIENT', batchToolCalls, 'approved', {
244
+ 'exec-a': { status: 'done', result: 'ok', success: true },
245
+ })
246
+ acc.appendText('done.')
247
+ const recorded = acc.getSegments()
248
+
249
+ const replayed = createMessageSegmentAccumulator().replaySegments(recorded)
250
+ expect(replayed).toMatchSnapshot()
251
+ })
252
+
253
+ it('initializeWithState continues an incomplete message across reconnect', () => {
254
+ const first = createMessageSegmentAccumulator()
255
+ first.appendText('partial ')
256
+ first.addToolExecution(executing('exec-1'))
257
+ const state = first.getState()
258
+
259
+ const resumed = createMessageSegmentAccumulator()
260
+ resumed.initializeWithState({
261
+ existingSegments: state.segments,
262
+ pendingApprovals: state.pendingApprovals,
263
+ executingTools: state.executingTools,
264
+ })
265
+ // The EXECUTED for the pre-reconnect EXECUTING merges into it.
266
+ resumed.addToolExecution(executed('exec-1'))
267
+ resumed.appendText('and the rest.')
268
+ expect(resumed.getSegments()).toMatchSnapshot()
269
+ })
270
+ })
@@ -9,6 +9,18 @@ import type {
9
9
  PendingApproval,
10
10
  ExecutingToolState,
11
11
  } from '../types'
12
+ import type { InitializeExtras } from '../stream/chat-stream-reducer'
13
+
14
+ /**
15
+ * What the realtime accumulator needs to RESUME an unfinished turn.
16
+ *
17
+ * DERIVED, not restated: this is exactly the reducer's `InitializeExtras`
18
+ * minus `escalatedApprovals` (which only the live stream can produce — it has
19
+ * no historical representation to extract). Hosts pass the result straight
20
+ * into `reducer.initializeWithState(messages, extras)`, so the two must not
21
+ * drift; a structural coincidence is not a contract.
22
+ */
23
+ export type IncompleteMessageState = Omit<InitializeExtras, 'escalatedApprovals'>
12
24
 
13
25
  /**
14
26
  * Extract incomplete message state from the last historical assistant message
@@ -16,11 +28,7 @@ import type {
16
28
  */
17
29
  export function extractIncompleteMessageState(
18
30
  lastMessage: ProcessedMessage | undefined
19
- ): {
20
- existingSegments?: MessageSegment[]
21
- pendingApprovals?: Map<string, PendingApproval>
22
- executingTools?: Map<string, ExecutingToolState>
23
- } | undefined {
31
+ ): IncompleteMessageState | undefined {
24
32
  if (!lastMessage || lastMessage.role !== 'assistant' || typeof lastMessage.content === 'string') {
25
33
  return undefined
26
34
  }
@@ -92,4 +100,54 @@ export function extractIncompleteMessageState(
92
100
  pendingApprovals: pendingApprovals.size > 0 ? pendingApprovals : undefined,
93
101
  executingTools: executingTools.size > 0 ? executingTools : undefined,
94
102
  }
103
+ }
104
+
105
+ /**
106
+ * THREAD-level generalization of `extractIncompleteMessageState`: given a
107
+ * processed thread, look at the TRAILING RUN of consecutive assistant
108
+ * messages (not just the very last row), flatten it to one segment list, and
109
+ * ask the single-message extractor whether that tail is unfinished.
110
+ *
111
+ * Why the run and not the last row: the backend can split one logical turn
112
+ * across several assistant messages (a preamble bubble, then the approval
113
+ * card, then the continuation). The unfinished artifact — a pending approval,
114
+ * an EXECUTING_TOOL with no result, an open compaction — can therefore sit in
115
+ * an EARLIER bubble of the same trailing run, and a last-row-only check
116
+ * reports "complete" while the turn is very much still in flight.
117
+ *
118
+ * SSOT for every "is the tail incomplete?" question — the NATS adapter (which
119
+ * used to inline the single-row check) and hosts replaying a stored thread
120
+ * both call this instead of re-deriving the walk.
121
+ *
122
+ * A trailing non-assistant row (user / error) means the last turn closed →
123
+ * `undefined`.
124
+ */
125
+ export function extractIncompleteTailState(
126
+ messages: readonly ProcessedMessage[] | undefined,
127
+ ): IncompleteMessageState | undefined {
128
+ if (!messages || messages.length === 0) return undefined
129
+
130
+ // Walk back over the trailing consecutive assistant run.
131
+ let start = messages.length
132
+ while (start > 0 && messages[start - 1]?.role === 'assistant') start -= 1
133
+ if (start === messages.length) return undefined
134
+
135
+ const segments: MessageSegment[] = []
136
+ for (let i = start; i < messages.length; i += 1) {
137
+ const content = messages[i]?.content
138
+ // A string-content assistant row is a plain-text bubble with nothing to
139
+ // resume; skip it rather than aborting — a later row in the same run may
140
+ // still hold the unfinished segment.
141
+ if (Array.isArray(content)) segments.push(...(content as MessageSegment[]))
142
+ }
143
+ if (segments.length === 0) return undefined
144
+
145
+ // Reuse the single-message extractor verbatim by handing it a synthetic
146
+ // assistant row carrying the flattened run — one incompleteness rule set,
147
+ // not two.
148
+ return extractIncompleteMessageState({
149
+ ...messages[messages.length - 1],
150
+ role: 'assistant',
151
+ content: segments,
152
+ } as ProcessedMessage)
95
153
  }