@flamingo-stack/openframe-frontend-core 0.0.579-1952.5335.1 → 0.0.579

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 (236) hide show
  1. package/dist/chat-protocol/decode.d.ts.map +1 -1
  2. package/dist/chat-protocol/events.d.ts +47 -1
  3. package/dist/chat-protocol/events.d.ts.map +1 -1
  4. package/dist/chat-protocol/index.cjs +51 -0
  5. package/dist/chat-protocol/index.cjs.map +1 -1
  6. package/dist/chat-protocol/index.js +48 -1
  7. package/dist/chat-protocol/index.js.map +1 -1
  8. package/dist/chat-protocol/leading-frames.d.ts +9 -3
  9. package/dist/chat-protocol/leading-frames.d.ts.map +1 -1
  10. package/dist/chat-protocol/nats-decoder.d.ts +41 -0
  11. package/dist/chat-protocol/nats-decoder.d.ts.map +1 -1
  12. package/dist/{chunk-3B42TFEE.cjs → chunk-36TJYCF3.cjs} +27 -27
  13. package/dist/{chunk-3B42TFEE.cjs.map → chunk-36TJYCF3.cjs.map} +1 -1
  14. package/dist/{chunk-YYEVFC3K.cjs → chunk-37XJYOAY.cjs} +17 -17
  15. package/dist/{chunk-YYEVFC3K.cjs.map → chunk-37XJYOAY.cjs.map} +1 -1
  16. package/dist/{chunk-DBP7S75Q.js → chunk-3AX3ZEY3.js} +1481 -1318
  17. package/dist/chunk-3AX3ZEY3.js.map +1 -0
  18. package/dist/{chunk-DGB2FLFH.cjs → chunk-4FJSN2XW.cjs} +10 -10
  19. package/dist/{chunk-DGB2FLFH.cjs.map → chunk-4FJSN2XW.cjs.map} +1 -1
  20. package/dist/{chunk-WHSSI3S7.cjs → chunk-4HGRXFQP.cjs} +3 -3
  21. package/dist/{chunk-WHSSI3S7.cjs.map → chunk-4HGRXFQP.cjs.map} +1 -1
  22. package/dist/{chunk-SY5HCZZO.cjs → chunk-4LZ7B5H5.cjs} +5 -5
  23. package/dist/{chunk-SY5HCZZO.cjs.map → chunk-4LZ7B5H5.cjs.map} +1 -1
  24. package/dist/{chunk-XJ45MHWI.js → chunk-4WSKSNXL.js} +9 -9
  25. package/dist/{chunk-ZCEQLHU3.js → chunk-5Y6TPFW7.js} +5 -5
  26. package/dist/{chunk-EVTRRDIH.cjs → chunk-A6C5HOD7.cjs} +23 -23
  27. package/dist/{chunk-EVTRRDIH.cjs.map → chunk-A6C5HOD7.cjs.map} +1 -1
  28. package/dist/{chunk-RXZWUJEB.js → chunk-AKPLEWMH.js} +2 -2
  29. package/dist/{chunk-7WKFLOC7.cjs → chunk-BCJQ7CJE.cjs} +11 -11
  30. package/dist/{chunk-7WKFLOC7.cjs.map → chunk-BCJQ7CJE.cjs.map} +1 -1
  31. package/dist/{chunk-C4KL7OQI.js → chunk-BW4WDUSI.js} +3 -3
  32. package/dist/{chunk-L66XBQM6.js → chunk-CP5EYMNL.js} +276 -160
  33. package/dist/chunk-CP5EYMNL.js.map +1 -0
  34. package/dist/{chunk-QOPQ6BCA.js → chunk-D44S2QLI.js} +3 -3
  35. package/dist/{chunk-CGVPUA4X.js → chunk-DB3TV4CW.js} +6 -6
  36. package/dist/{chunk-PITZMTGE.cjs → chunk-FEVLXRTC.cjs} +506 -390
  37. package/dist/chunk-FEVLXRTC.cjs.map +1 -0
  38. package/dist/{chunk-TYJITV3H.js → chunk-H4BPHGU5.js} +3 -3
  39. package/dist/{chunk-C7Q2PVHD.cjs → chunk-H54EPP3P.cjs} +7 -7
  40. package/dist/{chunk-C7Q2PVHD.cjs.map → chunk-H54EPP3P.cjs.map} +1 -1
  41. package/dist/{chunk-NWAUHBYH.js → chunk-HGA74X35.js} +4 -4
  42. package/dist/{chunk-4C3MS2G6.cjs → chunk-HHJVIHVR.cjs} +81 -81
  43. package/dist/{chunk-4C3MS2G6.cjs.map → chunk-HHJVIHVR.cjs.map} +1 -1
  44. package/dist/{chunk-3XFMM34I.cjs → chunk-IFYQ7DHV.cjs} +48 -48
  45. package/dist/{chunk-3XFMM34I.cjs.map → chunk-IFYQ7DHV.cjs.map} +1 -1
  46. package/dist/{chunk-QYQXAXVR.cjs → chunk-IKDC3R7V.cjs} +4 -3
  47. package/dist/chunk-IKDC3R7V.cjs.map +1 -0
  48. package/dist/{chunk-W6JA6SFD.js → chunk-JRYS4VMV.js} +45 -45
  49. package/dist/chunk-JRYS4VMV.js.map +1 -0
  50. package/dist/{chunk-AT4WAFJO.js → chunk-L2CF2QZW.js} +7 -7
  51. package/dist/{chunk-2BFZHTSD.cjs → chunk-LEISUAHF.cjs} +18 -8
  52. package/dist/chunk-LEISUAHF.cjs.map +1 -0
  53. package/dist/{chunk-AAHKPPGQ.cjs → chunk-M5GWCMYE.cjs} +42 -42
  54. package/dist/{chunk-AAHKPPGQ.cjs.map → chunk-M5GWCMYE.cjs.map} +1 -1
  55. package/dist/{chunk-PQETCTJ4.js → chunk-MXOSTJAG.js} +4 -3
  56. package/dist/chunk-MXOSTJAG.js.map +1 -0
  57. package/dist/{chunk-653A7FPP.cjs → chunk-NPVDKOGD.cjs} +22 -22
  58. package/dist/{chunk-653A7FPP.cjs.map → chunk-NPVDKOGD.cjs.map} +1 -1
  59. package/dist/{chunk-2ML5QEDH.js → chunk-O2FTVZOD.js} +5 -5
  60. package/dist/{chunk-OCSAB6NT.cjs → chunk-OGZAFM6M.cjs} +5 -5
  61. package/dist/{chunk-OCSAB6NT.cjs.map → chunk-OGZAFM6M.cjs.map} +1 -1
  62. package/dist/{chunk-CSBNQJSI.js → chunk-PMENIZXI.js} +6 -6
  63. package/dist/{chunk-PFTEG6L7.js → chunk-PWOYEUIQ.js} +7 -7
  64. package/dist/{chunk-KXLJT5U5.js → chunk-QCP2KV7P.js} +5 -5
  65. package/dist/{chunk-6QDW4CDG.js → chunk-QGJYWLF4.js} +2 -2
  66. package/dist/{chunk-VLRXLTR4.cjs → chunk-RKGOVTXZ.cjs} +377 -214
  67. package/dist/chunk-RKGOVTXZ.cjs.map +1 -0
  68. package/dist/{chunk-HM2R5MDR.js → chunk-T5HDPHN6.js} +13 -3
  69. package/dist/chunk-T5HDPHN6.js.map +1 -0
  70. package/dist/{chunk-GP2QLZIN.cjs → chunk-YW4WBNYV.cjs} +37 -37
  71. package/dist/{chunk-GP2QLZIN.cjs.map → chunk-YW4WBNYV.cjs.map} +1 -1
  72. package/dist/{chunk-36NFJULV.cjs → chunk-ZI27ALYX.cjs} +41 -41
  73. package/dist/chunk-ZI27ALYX.cjs.map +1 -0
  74. package/dist/components/case-studies/index.cjs +12 -12
  75. package/dist/components/case-studies/index.js +6 -6
  76. package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
  77. package/dist/components/chat/guide-display.d.ts +14 -0
  78. package/dist/components/chat/guide-display.d.ts.map +1 -0
  79. package/dist/components/chat/index.cjs +11 -3
  80. package/dist/components/chat/index.cjs.map +1 -1
  81. package/dist/components/chat/index.d.ts +1 -0
  82. package/dist/components/chat/index.d.ts.map +1 -1
  83. package/dist/components/chat/index.js +10 -2
  84. package/dist/components/chat/stream/chat-stream-reducer.d.ts +9 -0
  85. package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +1 -1
  86. package/dist/components/chat/stream/delta-batcher.d.ts +3 -3
  87. package/dist/components/chat/stream/delta-batcher.d.ts.map +1 -1
  88. package/dist/components/chat/stream/message-mutations.d.ts.map +1 -1
  89. package/dist/components/chat/types/component.types.d.ts +6 -0
  90. package/dist/components/chat/types/component.types.d.ts.map +1 -1
  91. package/dist/components/chat/types/message.types.d.ts +30 -2
  92. package/dist/components/chat/types/message.types.d.ts.map +1 -1
  93. package/dist/components/chat/types/network.types.d.ts +3 -1
  94. package/dist/components/chat/types/network.types.d.ts.map +1 -1
  95. package/dist/components/chat/types/processing.types.d.ts +4 -1
  96. package/dist/components/chat/types/processing.types.d.ts.map +1 -1
  97. package/dist/components/chat/utils/approval-display.d.ts +36 -8
  98. package/dist/components/chat/utils/approval-display.d.ts.map +1 -1
  99. package/dist/components/chat/utils/extract-incomplete-message-state.d.ts.map +1 -1
  100. package/dist/components/chat/utils/index.d.ts +1 -1
  101. package/dist/components/chat/utils/index.d.ts.map +1 -1
  102. package/dist/components/chat/utils/message-segment-accumulator.d.ts +16 -4
  103. package/dist/components/chat/utils/message-segment-accumulator.d.ts.map +1 -1
  104. package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
  105. package/dist/components/chat/utils/resolve-fetched-card-href.d.ts +1 -1
  106. package/dist/components/contact/index.cjs +7 -7
  107. package/dist/components/contact/index.js +6 -6
  108. package/dist/components/docs/index.cjs +10 -10
  109. package/dist/components/docs/index.js +9 -9
  110. package/dist/components/embeds/index.cjs +7 -7
  111. package/dist/components/embeds/index.js +6 -6
  112. package/dist/components/faq/index.cjs +7 -7
  113. package/dist/components/faq/index.js +6 -6
  114. package/dist/components/features/board/board.d.ts.map +1 -1
  115. package/dist/components/features/index.cjs +8 -6
  116. package/dist/components/features/index.cjs.map +1 -1
  117. package/dist/components/features/index.js +7 -5
  118. package/dist/components/features/sortable-list/index.d.ts +2 -1
  119. package/dist/components/features/sortable-list/index.d.ts.map +1 -1
  120. package/dist/components/features/sortable-list/sortable-list.d.ts +8 -1
  121. package/dist/components/features/sortable-list/sortable-list.d.ts.map +1 -1
  122. package/dist/components/features/sortable-list/sortable-move-buttons.d.ts +24 -0
  123. package/dist/components/features/sortable-list/sortable-move-buttons.d.ts.map +1 -0
  124. package/dist/components/features/sortable-list/use-sortable-item.d.ts +12 -4
  125. package/dist/components/features/sortable-list/use-sortable-item.d.ts.map +1 -1
  126. package/dist/components/features/ticket-status-config-list/ticket-status-config-list.d.ts +21 -5
  127. package/dist/components/features/ticket-status-config-list/ticket-status-config-list.d.ts.map +1 -1
  128. package/dist/components/help-center-pages/index.cjs +26 -26
  129. package/dist/components/help-center-pages/index.js +17 -17
  130. package/dist/components/index.cjs +139 -129
  131. package/dist/components/index.cjs.map +1 -1
  132. package/dist/components/index.js +26 -16
  133. package/dist/components/index.js.map +1 -1
  134. package/dist/components/meeting-scheduler/index.cjs +37 -37
  135. package/dist/components/meeting-scheduler/index.js +5 -5
  136. package/dist/components/navigation/index.cjs +9 -9
  137. package/dist/components/navigation/index.js +8 -8
  138. package/dist/components/onboarding-guides/index.cjs +9 -9
  139. package/dist/components/onboarding-guides/index.js +8 -8
  140. package/dist/components/tickets/index.cjs +10 -10
  141. package/dist/components/tickets/index.js +9 -9
  142. package/dist/components/ui/file-manager/index.cjs +4 -4
  143. package/dist/components/ui/file-manager/index.js +1 -1
  144. package/dist/components/ui/index.cjs +14 -6
  145. package/dist/components/ui/index.cjs.map +1 -1
  146. package/dist/components/ui/index.js +13 -5
  147. package/dist/components/ui/input.d.ts +2 -0
  148. package/dist/components/ui/input.d.ts.map +1 -1
  149. package/dist/components/ui/textarea.d.ts +2 -0
  150. package/dist/components/ui/textarea.d.ts.map +1 -1
  151. package/dist/components/ui/ticket-status-config-row.d.ts +10 -4
  152. package/dist/components/ui/ticket-status-config-row.d.ts.map +1 -1
  153. package/dist/hooks/index.cjs +7 -3
  154. package/dist/hooks/index.cjs.map +1 -1
  155. package/dist/hooks/index.js +6 -2
  156. package/dist/hooks/ui/index.d.ts +1 -0
  157. package/dist/hooks/ui/index.d.ts.map +1 -1
  158. package/dist/hooks/ui/use-drag-and-drop-enabled.d.ts +31 -0
  159. package/dist/hooks/ui/use-drag-and-drop-enabled.d.ts.map +1 -0
  160. package/dist/hooks/ui/use-media-query.d.ts.map +1 -1
  161. package/dist/index.cjs +25 -11
  162. package/dist/index.cjs.map +1 -1
  163. package/dist/index.js +23 -9
  164. package/dist/index.js.map +1 -1
  165. package/dist/utils/index.cjs.map +1 -1
  166. package/dist/utils/index.js.map +1 -1
  167. package/package.json +1 -4
  168. package/src/chat-protocol/__tests__/__snapshots__/nats-decoder-golden.test.ts.snap +80 -0
  169. package/src/chat-protocol/__tests__/nats-decoder-golden.test.ts +118 -1
  170. package/src/chat-protocol/decode.ts +4 -3
  171. package/src/chat-protocol/events.ts +51 -0
  172. package/src/chat-protocol/leading-frames.ts +9 -3
  173. package/src/chat-protocol/nats-decoder.ts +110 -1
  174. package/src/components/chat/__tests__/chat-pending-turn.test.tsx +19 -0
  175. package/src/components/chat/chat-message-enhanced.tsx +13 -1
  176. package/src/components/chat/guide-display.tsx +42 -0
  177. package/src/components/chat/index.ts +1 -0
  178. package/src/components/chat/stream/__tests__/chat-stream-reducer.test.ts +90 -0
  179. package/src/components/chat/stream/chat-dialog-store.ts +1 -1
  180. package/src/components/chat/stream/chat-stream-reducer.ts +49 -11
  181. package/src/components/chat/stream/delta-batcher.ts +9 -4
  182. package/src/components/chat/stream/message-mutations.ts +2 -0
  183. package/src/components/chat/types/component.types.ts +9 -0
  184. package/src/components/chat/types/message.types.ts +32 -0
  185. package/src/components/chat/types/network.types.ts +3 -1
  186. package/src/components/chat/types/processing.types.ts +4 -1
  187. package/src/components/chat/utils/__tests__/process-historical-messages-approvals.test.ts +93 -0
  188. package/src/components/chat/utils/__tests__/process-historical-messages-golden.test.ts +78 -1
  189. package/src/components/chat/utils/approval-display.ts +53 -8
  190. package/src/components/chat/utils/extract-incomplete-message-state.ts +1 -0
  191. package/src/components/chat/utils/index.ts +6 -1
  192. package/src/components/chat/utils/message-segment-accumulator.ts +37 -3
  193. package/src/components/chat/utils/process-historical-messages.ts +44 -7
  194. package/src/components/chat/utils/resolve-fetched-card-href.ts +1 -1
  195. package/src/components/features/board/.board.md +1 -0
  196. package/src/components/features/board/board.tsx +11 -1
  197. package/src/components/features/sortable-list/.sortable-list.md +23 -12
  198. package/src/components/features/sortable-list/index.ts +2 -1
  199. package/src/components/features/sortable-list/sortable-list.tsx +16 -5
  200. package/src/components/features/sortable-list/sortable-move-buttons.tsx +81 -0
  201. package/src/components/features/sortable-list/use-sortable-item.ts +25 -13
  202. package/src/components/features/ticket-status-config-list/ticket-status-config-list.tsx +55 -62
  203. package/src/components/ui/.ticket-status-config-row.md +11 -9
  204. package/src/components/ui/input.tsx +5 -2
  205. package/src/components/ui/textarea.tsx +5 -2
  206. package/src/components/ui/ticket-status-config-row.tsx +162 -70
  207. package/src/hooks/ui/.index.md +1 -0
  208. package/src/hooks/ui/.use-drag-and-drop-enabled.md +32 -0
  209. package/src/hooks/ui/index.ts +1 -0
  210. package/src/hooks/ui/use-drag-and-drop-enabled.ts +37 -0
  211. package/src/hooks/ui/use-media-query.ts +3 -2
  212. package/src/stories/ChatMessageEnhanced.stories.tsx +7 -3
  213. package/dist/chunk-2BFZHTSD.cjs.map +0 -1
  214. package/dist/chunk-36NFJULV.cjs.map +0 -1
  215. package/dist/chunk-DBP7S75Q.js.map +0 -1
  216. package/dist/chunk-HM2R5MDR.js.map +0 -1
  217. package/dist/chunk-L66XBQM6.js.map +0 -1
  218. package/dist/chunk-PITZMTGE.cjs.map +0 -1
  219. package/dist/chunk-PQETCTJ4.js.map +0 -1
  220. package/dist/chunk-QYQXAXVR.cjs.map +0 -1
  221. package/dist/chunk-VLRXLTR4.cjs.map +0 -1
  222. package/dist/chunk-W6JA6SFD.js.map +0 -1
  223. /package/dist/{chunk-XJ45MHWI.js.map → chunk-4WSKSNXL.js.map} +0 -0
  224. /package/dist/{chunk-ZCEQLHU3.js.map → chunk-5Y6TPFW7.js.map} +0 -0
  225. /package/dist/{chunk-RXZWUJEB.js.map → chunk-AKPLEWMH.js.map} +0 -0
  226. /package/dist/{chunk-C4KL7OQI.js.map → chunk-BW4WDUSI.js.map} +0 -0
  227. /package/dist/{chunk-QOPQ6BCA.js.map → chunk-D44S2QLI.js.map} +0 -0
  228. /package/dist/{chunk-CGVPUA4X.js.map → chunk-DB3TV4CW.js.map} +0 -0
  229. /package/dist/{chunk-TYJITV3H.js.map → chunk-H4BPHGU5.js.map} +0 -0
  230. /package/dist/{chunk-NWAUHBYH.js.map → chunk-HGA74X35.js.map} +0 -0
  231. /package/dist/{chunk-AT4WAFJO.js.map → chunk-L2CF2QZW.js.map} +0 -0
  232. /package/dist/{chunk-2ML5QEDH.js.map → chunk-O2FTVZOD.js.map} +0 -0
  233. /package/dist/{chunk-CSBNQJSI.js.map → chunk-PMENIZXI.js.map} +0 -0
  234. /package/dist/{chunk-PFTEG6L7.js.map → chunk-PWOYEUIQ.js.map} +0 -0
  235. /package/dist/{chunk-KXLJT5U5.js.map → chunk-QCP2KV7P.js.map} +0 -0
  236. /package/dist/{chunk-6QDW4CDG.js.map → chunk-QGJYWLF4.js.map} +0 -0
@@ -886,6 +886,96 @@ describe('createChatStreamReducer — mergeApprovalStatuses precedence', () => {
886
886
  });
887
887
  });
888
888
 
889
+ // ─── GUIDE segments (#1583 threaded through the unified reader) ─────────────
890
+ //
891
+ // A `GUIDE` chunk is an APPEND-ONLY body stream like TEXT/THINKING, but it
892
+ // renders as a titled "OpenFrame Guide" card. It must therefore coalesce the
893
+ // same way (extend the trailing `guide` segment, never push a new one per
894
+ // chunk) and satisfy the same reducer invariants — seq idempotency,
895
+ // referential stability, and post-MESSAGE_END append mode.
896
+
897
+ describe('createChatStreamReducer — guide segments', () => {
898
+ it('coalesces consecutive guide deltas into ONE trailing guide segment', () => {
899
+ const r = createChatStreamReducer({ transport: 'nats' });
900
+ r.apply({ type: 'turn-start', seq: 1 });
901
+ r.apply({ type: 'guide-delta', text: '## Enroll a device\n', seq: 2 });
902
+ r.apply({ type: 'guide-delta', text: '1. Open Settings', seq: 3 });
903
+ const last = r.state.messages[r.state.messages.length - 1];
904
+ expect(last.segments).toEqual([{ type: 'guide', text: '## Enroll a device\n1. Open Settings' }]);
905
+ });
906
+
907
+ it('starts a NEW guide segment when a non-guide segment interrupts', () => {
908
+ const r = createChatStreamReducer({ transport: 'nats' });
909
+ r.apply({ type: 'turn-start', seq: 1 });
910
+ r.apply({ type: 'guide-delta', text: 'step one', seq: 2 });
911
+ r.apply({ type: 'text-delta', text: 'aside', seq: 3 });
912
+ r.apply({ type: 'guide-delta', text: 'step two', seq: 4 });
913
+ const last = r.state.messages[r.state.messages.length - 1];
914
+ expect(last.segments).toEqual([
915
+ { type: 'guide', text: 'step one' },
916
+ { type: 'text', text: 'aside' },
917
+ { type: 'guide', text: 'step two' },
918
+ ]);
919
+ });
920
+
921
+ it('drops a redelivered guide delta via the shared seq gate (no double text)', () => {
922
+ const r = createChatStreamReducer({ transport: 'nats' });
923
+ r.apply({ type: 'turn-start', seq: 1 });
924
+ r.apply({ type: 'guide-delta', text: 'a', seq: 2 });
925
+ r.apply({ type: 'guide-delta', text: 'b', seq: 3 });
926
+ r.apply({ type: 'guide-delta', text: 'b', seq: 3 });
927
+ r.apply({ type: 'guide-delta', text: 'a', seq: 2 });
928
+ const last = r.state.messages[r.state.messages.length - 1];
929
+ expect(last.segments).toEqual([{ type: 'guide', text: 'ab' }]);
930
+ });
931
+
932
+ it('reclones ONLY the touched message — earlier rows keep identity', () => {
933
+ const r = createChatStreamReducer({ transport: 'nats' });
934
+ r.apply({ type: 'participant', kind: 'message-request', text: 'how do I enroll?', seq: 1 });
935
+ r.apply({ type: 'turn-start', seq: 2 });
936
+ r.apply({ type: 'guide-delta', text: 'first', seq: 3 });
937
+ const userRow = r.state.messages[0];
938
+ r.apply({ type: 'guide-delta', text: ' more', seq: 4 });
939
+ expect(r.state.messages[0]).toBe(userRow);
940
+ });
941
+
942
+ it('post-MESSAGE_END continuation emits a DELTA guide segment with append:true', () => {
943
+ const effects: Array<{ name: string; args: unknown[] }> = [];
944
+ const r = createChatStreamReducer({
945
+ transport: 'nats',
946
+ onEffect: e => effects.push(e),
947
+ });
948
+ r.apply({ type: 'turn-start', seq: 1 });
949
+ r.apply({ type: 'guide-delta', text: 'in-stream ', seq: 2 });
950
+ r.apply({ type: 'turn-end', seq: 3 });
951
+ r.apply({ type: 'guide-delta', text: 'post-end', seq: 4 });
952
+
953
+ const appendEmits = effects.filter(
954
+ e => e.name === 'onSegmentsUpdate' && (e.args[1] as { append?: boolean } | undefined)?.append === true,
955
+ );
956
+ expect(appendEmits).toHaveLength(1);
957
+ expect(appendEmits[0].args[0]).toEqual([{ type: 'guide', text: 'post-end' }]);
958
+ // …and the bubble carries ONE coalesced guide segment, not two.
959
+ const last = r.state.messages[r.state.messages.length - 1];
960
+ expect(last.segments).toEqual([{ type: 'guide', text: 'in-stream post-end' }]);
961
+ });
962
+ });
963
+
964
+ /**
965
+ * `setMessages` is a RAW setter and must not be used to hydrate history.
966
+ *
967
+ * It assigns the thread and nothing else — in particular it does not mark the
968
+ * reducer resumed, which is what makes a later chunk with no preceding
969
+ * `turn-start` take the APPEND branch. Hydrating with it leaves the reducer
970
+ * looking cold, so the first such chunk goes down the cumulative path and
971
+ * REPLACES the restored bubble's segments. When that bubble holds a pending
972
+ * approval card, the control the agent is blocked on disappears with it and
973
+ * there is no way back.
974
+ *
975
+ * `initializeWithState` derives `resumed` from the restored thread, which is
976
+ * why every hydration path must go through it (the SSE adapter always did; the
977
+ * NATS adapter used `setMessages` and hit exactly this).
978
+ */
889
979
  describe('createChatStreamReducer — history hydration must declare resumed', () => {
890
980
  const hydrated = () => [
891
981
  {
@@ -207,7 +207,7 @@ const EMPTY_STATE: ChatReducerState = Object.freeze({
207
207
  dialogTokenUsage: null,
208
208
  liveModel: null,
209
209
  approvalStatuses: Object.freeze({}) as ChatReducerState['approvalStatuses'],
210
- });
210
+ }) as ChatReducerState;
211
211
 
212
212
  /** Shared frozen empty list for a key with no send in flight. */
213
213
  const EMPTY_PENDING_ECHOES: readonly PendingEcho[] = Object.freeze([]);
@@ -51,7 +51,7 @@
51
51
  */
52
52
 
53
53
  import { escapeThinkingTags } from '../../../chat-protocol/decode';
54
- import { errorDetailsMessage } from '../../../chat-protocol/events';
54
+ import { errorDetailsMessage, isGuideOrigin } from '../../../chat-protocol/events';
55
55
  import {
56
56
  CHAT_OWNER_ADMIN,
57
57
  type ChatStreamEvent,
@@ -77,7 +77,7 @@ import type {
77
77
  UnifiedChatMessage,
78
78
  UnifiedUsageBreakdown,
79
79
  } from '../types/unified-chat-state.types';
80
- import { approvalDisplaysInline } from '../utils/approval-display';
80
+ import { approvalDisplaysInline, guideApprovalOrigin } from '../utils/approval-display';
81
81
  import {
82
82
  type MessageSegmentAccumulator,
83
83
  createMessageSegmentAccumulator,
@@ -161,6 +161,15 @@ export interface ChatReducerState {
161
161
  contextWindowMaxTokens: number | null;
162
162
  } | null;
163
163
  approvalStatuses: Record<string, ChatApprovalStatus>;
164
+ /**
165
+ * Hub conversation id for the Product Guide half of this dialog, learned from
166
+ * a guide metadata frame. The hub mints it and requires it back on every
167
+ * confirm-tool call, so a host resolving a guide approval card reads it from
168
+ * here. Null until a guide turn has streamed — an approval that arrives from
169
+ * history (after a reload) has no live frame to learn it from, which is why
170
+ * the agent should also expose it per dialog.
171
+ */
172
+ guideConversationId: string | null;
164
173
  }
165
174
 
166
175
  /** Escalated-approval bookkeeping entry (mirrors the legacy processor). */
@@ -530,6 +539,7 @@ export function createChatStreamReducer(options: ChatStreamReducerOptions = {}):
530
539
  let streamingPhase: StreamingPhase = 'idle';
531
540
  let dialogTokenUsage: DialogTokenUsage | null = null;
532
541
  let liveModel: ChatReducerState['liveModel'] = null;
542
+ let guideConversationId: string | null = null;
533
543
  let approvalStatuses: Record<string, ChatApprovalStatus> = {
534
544
  ...(options.approvalStatuses ?? {}),
535
545
  };
@@ -691,6 +701,7 @@ export function createChatStreamReducer(options: ChatStreamReducerOptions = {}):
691
701
  dialogTokenUsage,
692
702
  liveModel,
693
703
  approvalStatuses,
704
+ guideConversationId,
694
705
  };
695
706
  }
696
707
  return stateCache;
@@ -985,6 +996,17 @@ export function createChatStreamReducer(options: ChatStreamReducerOptions = {}):
985
996
  }
986
997
 
987
998
  case 'metadata': {
999
+ // A guide metadata event carries ONLY the hub's conversation id (the
1000
+ // decoder strips the rest): record it and stop. Falling through would
1001
+ // rebuild `liveModel` from an event with no model in it and blank the
1002
+ // dialog's model badge mid-answer.
1003
+ if (isGuideOrigin(event)) {
1004
+ if (typeof event.conversationId === 'string' && event.conversationId) {
1005
+ guideConversationId = event.conversationId;
1006
+ invalidate();
1007
+ }
1008
+ break;
1009
+ }
988
1010
  // Legacy `parseChunkToAction` action shape, reconstructed for the
989
1011
  // callback contract.
990
1012
  emit('onMetadata', {
@@ -1003,13 +1025,21 @@ export function createChatStreamReducer(options: ChatStreamReducerOptions = {}):
1003
1025
  break;
1004
1026
  }
1005
1027
 
1006
- // Both APPEND-ONLY body streams share one shape: accumulate the verbatim
1007
- // slice into the trailing segment of the matching kind (the accumulator
1008
- // coalesces), then emit.
1028
+ // The three APPEND-ONLY body streams share one shape: accumulate the
1029
+ // verbatim slice into the trailing segment of the matching kind (the
1030
+ // accumulator coalesces), then emit. `guide-delta` is NATS-only today
1031
+ // but rides the identical path so a guide body streams, coalesces and
1032
+ // continues post-MESSAGE_END exactly like answer text.
1009
1033
  case 'text-delta':
1010
- case 'thinking-delta': {
1011
- const kind = event.type === 'text-delta' ? 'text' : 'thinking';
1012
- const segments = kind === 'text' ? accumulator.appendText(event.text) : accumulator.appendThinking(event.text);
1034
+ case 'thinking-delta':
1035
+ case 'guide-delta': {
1036
+ const kind = event.type === 'text-delta' ? 'text' : event.type === 'thinking-delta' ? 'thinking' : 'guide';
1037
+ const segments =
1038
+ kind === 'text'
1039
+ ? accumulator.appendText(event.text)
1040
+ : kind === 'thinking'
1041
+ ? accumulator.appendThinking(event.text)
1042
+ : accumulator.appendGuide(event.text);
1013
1043
  // Append-mode only for *true* post-stream continuation (after a
1014
1044
  // MESSAGE_END we actually saw). Cold-start chunks (no prior
1015
1045
  // MESSAGE_START) emit cumulative segments so the consumer can spawn
@@ -1085,8 +1115,10 @@ export function createChatStreamReducer(options: ChatStreamReducerOptions = {}):
1085
1115
  const approvalType = event.approvalType ?? 'USER';
1086
1116
  const toolCalls = event.toolCalls;
1087
1117
  // Where this card renders is ONE rule, shared with the SSE kernel and
1088
- // the history replay (`approval-display`).
1089
- const displayInline = (type: string) => approvalDisplaysInline(type, displayApprovalTypes);
1118
+ // the history replay (`approval-display`) — a Product Guide proposal
1119
+ // that renders inline live must not move on the next page load.
1120
+ const guideOrigin = guideApprovalOrigin(event);
1121
+ const displayInline = (type: string) => approvalDisplaysInline(event, type, displayApprovalTypes);
1090
1122
 
1091
1123
  if (toolCalls && toolCalls.length > 0) {
1092
1124
  // ── Batch form ──
@@ -1118,6 +1150,7 @@ export function createChatStreamReducer(options: ChatStreamReducerOptions = {}):
1118
1150
  status,
1119
1151
  undefined,
1120
1152
  undefined,
1153
+ guideOrigin,
1121
1154
  );
1122
1155
  applyAccumulated(before, segments);
1123
1156
  break;
@@ -1137,6 +1170,7 @@ export function createChatStreamReducer(options: ChatStreamReducerOptions = {}):
1137
1170
  approvalType,
1138
1171
  status,
1139
1172
  undefined,
1173
+ guideOrigin,
1140
1174
  );
1141
1175
  }
1142
1176
  applyAccumulated(before, segments);
@@ -1155,8 +1189,10 @@ export function createChatStreamReducer(options: ChatStreamReducerOptions = {}):
1155
1189
  explanation,
1156
1190
  approvalType,
1157
1191
  status,
1158
- // SSE-shaped cards carry their body as structured rows rather than prose.
1192
+ // SSE-shaped cards (a re-streamed Product Guide proposal) carry
1193
+ // their body as structured rows rather than prose.
1159
1194
  event.fields,
1195
+ guideOrigin,
1160
1196
  );
1161
1197
  applyAccumulated(before, segments);
1162
1198
  } else {
@@ -1668,6 +1704,7 @@ export function createChatStreamReducer(options: ChatStreamReducerOptions = {}):
1668
1704
  streamingPhase = 'idle';
1669
1705
  dialogTokenUsage = null;
1670
1706
  liveModel = null;
1707
+ guideConversationId = null;
1671
1708
  approvalStatuses = {};
1672
1709
  accumulator.reset();
1673
1710
  pendingEscalated.clear();
@@ -1715,6 +1752,7 @@ export function createChatStreamReducer(options: ChatStreamReducerOptions = {}):
1715
1752
  streamingPhase = 'idle';
1716
1753
  dialogTokenUsage = null;
1717
1754
  liveModel = null;
1755
+ guideConversationId = null;
1718
1756
  accumulator.reset();
1719
1757
  pendingEscalated.clear();
1720
1758
  isInStream = false;
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * createDeltaBatcher — framework-free, append-only batching of the
3
- * `text-delta` / `thinking-delta` events.
3
+ * `text-delta` / `thinking-delta` / `guide-delta` events.
4
4
  *
5
5
  * WHY THIS IS SHARED (and not a hook-private detail): every host that drives a
6
6
  * `ChatStreamReducer` from a raw transport needs the SAME batching, and a
@@ -23,14 +23,19 @@
23
23
  * deltas never land on the wrong dialog/side.
24
24
  */
25
25
 
26
- import type { ChatStreamEvent, TextDeltaEvent, ThinkingDeltaEvent } from '../../../chat-protocol/events';
26
+ import type {
27
+ ChatStreamEvent,
28
+ GuideDeltaEvent,
29
+ TextDeltaEvent,
30
+ ThinkingDeltaEvent,
31
+ } from '../../../chat-protocol/events';
27
32
 
28
33
  export const DELTA_FLUSH_FALLBACK_MS = 50;
29
34
 
30
- export type DeltaEvent = TextDeltaEvent | ThinkingDeltaEvent;
35
+ export type DeltaEvent = TextDeltaEvent | ThinkingDeltaEvent | GuideDeltaEvent;
31
36
 
32
37
  export function isDeltaEvent(event: ChatStreamEvent): event is DeltaEvent {
33
- return event.type === 'text-delta' || event.type === 'thinking-delta';
38
+ return event.type === 'text-delta' || event.type === 'thinking-delta' || event.type === 'guide-delta';
34
39
  }
35
40
 
36
41
  export interface CreateDeltaBatcherOptions<K> {
@@ -126,6 +126,8 @@ export function appendToTrailingAssistant(
126
126
  merged[merged.length - 1] = { type: 'text', text: tail.text + seg.text };
127
127
  } else if (seg.type === 'thinking' && tail?.type === 'thinking') {
128
128
  merged[merged.length - 1] = { type: 'thinking', text: tail.text + seg.text };
129
+ } else if (seg.type === 'guide' && tail?.type === 'guide') {
130
+ merged[merged.length - 1] = { type: 'guide', text: tail.text + seg.text };
129
131
  } else if (key) {
130
132
  // Block deltas must be IDEMPOTENT: an emit can be seen twice (live plus
131
133
  // the JetStream catch-up replay over hydrated history), so upsert on the
@@ -578,6 +578,15 @@ export interface ThinkingDisplayProps extends HTMLAttributes<HTMLDivElement> {
578
578
  isStreaming?: boolean;
579
579
  }
580
580
 
581
+ // ========== Guide Display Props ==========
582
+
583
+ export interface GuideDisplayProps extends HTMLAttributes<HTMLDivElement> {
584
+ /** Rendered guide body. `ChatMessageEnhanced` passes the SAME markdown
585
+ * output a text segment gets, so `[card://]` cards and mention chips
586
+ * behave identically inside a guide. */
587
+ children?: ReactNode;
588
+ }
589
+
581
590
  // ========== Ask Display Props ==========
582
591
 
583
592
  export interface AskDisplayProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onSelect'> {
@@ -10,6 +10,7 @@ import type { AssistantType, AuthorType, ChatApprovalStatus, MessageOwner } from
10
10
  export const MESSAGE_TYPE = {
11
11
  TEXT: 'TEXT',
12
12
  THINKING: 'THINKING',
13
+ GUIDE: 'GUIDE',
13
14
  ASK: 'ASK',
14
15
  EXECUTING_TOOL: 'EXECUTING_TOOL',
15
16
  EXECUTED_TOOL: 'EXECUTED_TOOL',
@@ -113,6 +114,12 @@ export interface ApprovalRequestData {
113
114
  requestId?: string;
114
115
  approvalRequestId?: string;
115
116
  approvalType?: string;
117
+ /** `'guide'` when the card came from a Product Guide frame (see `GuideOrigin`
118
+ * in `chat-protocol/events`). Carried onto the segment because hosts route
119
+ * and filter approvals by where they came from: mingo lifts its OWN pending
120
+ * cards into a sticky footer, and a guide card must stay inline in the turn
121
+ * the way it does in the hub's chat. */
122
+ origin?: 'guide';
116
123
  }
117
124
 
118
125
  export interface ApprovalResultData {
@@ -220,6 +227,11 @@ export interface ApprovalBatchData {
220
227
  * batch itself is approved.
221
228
  */
222
229
  executions?: Record<string, ApprovalBatchExecutionState>;
230
+ /** `'guide'` when the batch came from a Product Guide `approval_batch` frame.
231
+ * Same contract and the same reasons as `ApprovalRequestData.origin` — the
232
+ * hub groups multiple proposals into one card, and that card resolves
233
+ * through the hub exactly like a single one. */
234
+ origin?: 'guide';
223
235
  }
224
236
 
225
237
  /** Approve/reject handler stamped onto approval segments. MAY resolve a
@@ -242,6 +254,15 @@ export type ThinkingSegment = {
242
254
  text: string;
243
255
  };
244
256
 
257
+ /** Guide answer body — the assistant's how-to/documentation reply, rendered as
258
+ * a titled "OpenFrame Guide" card instead of a bare paragraph. `text` is
259
+ * markdown, streamed in fragments like a `text` segment and coalesced by the
260
+ * accumulator. */
261
+ export type GuideSegment = {
262
+ type: 'guide';
263
+ text: string;
264
+ };
265
+
245
266
  /** One reading the assistant offers in an `ask` card. `label` is BOTH the row's
246
267
  * headline and the exact text sent back when the row is picked — the backend's
247
268
  * guide classifier resolves the user's next message against the labels it
@@ -359,6 +380,7 @@ export type ContextCompactionSegment = {
359
380
  export type MessageSegment =
360
381
  | TextSegment
361
382
  | ThinkingSegment
383
+ | GuideSegment
362
384
  | AskSegment
363
385
  | ToolExecutionSegment
364
386
  | ApprovalRequestSegment
@@ -391,6 +413,15 @@ export interface ThinkingMessageData extends MessageDataBase {
391
413
  text?: string;
392
414
  }
393
415
 
416
+ export interface GuideMessageData extends MessageDataBase {
417
+ type: 'GUIDE';
418
+ text?: string;
419
+ /** Persisted Product Guide frame (GraphQL `GuideData.payload`, a JSON scalar)
420
+ * for rows that carry a card instead of answer text — replayed through the
421
+ * live path's `guideFrameEvent`. */
422
+ payload?: Record<string, unknown>;
423
+ }
424
+
394
425
  /** Persisted `ASK` row (GraphQL `AskData`). `text` is the intro sentence, which
395
426
  * history replays as a text segment ahead of the card — same split the live
396
427
  * `ASK` chunk carries. */
@@ -532,6 +563,7 @@ export interface ContextCompactionEndMessageData extends MessageDataBase {
532
563
  export type MessageData =
533
564
  | TextMessageData
534
565
  | ThinkingMessageData
566
+ | GuideMessageData
535
567
  | AskMessageData
536
568
  | ExecutingToolMessageData
537
569
  | ExecutedToolMessageData
@@ -63,7 +63,9 @@ export interface ChunkData {
63
63
  /** Batch-approval rows. Coerced by `normalizeToolCalls` in the NATS decoder
64
64
  * — the wire shape is per-backend, so entries stay opaque here. */
65
65
  toolCalls?: unknown[];
66
- /** Opaque per-chunk payload some backends attach beside `text`. */
66
+ /** On GUIDE chunks: a Product Guide frame the agent re-streamed verbatim from
67
+ * the hub, instead of the answer text `text` carries. Decoded by
68
+ * `decodeNatsChunk` through the shared leading-frame table. */
67
69
  payload?: Record<string, unknown>;
68
70
  modelName?: string;
69
71
  providerName?: string;
@@ -18,9 +18,12 @@ export interface PendingApproval {
18
18
  command: string;
19
19
  explanation?: string;
20
20
  approvalType: string;
21
- /** Structured label/value rows — a card can be almost entirely
21
+ /** Structured label/value rows — a Product Guide card is almost entirely
22
22
  * these, so a path that forgets them renders a bare title. */
23
23
  fields?: ApprovalRequestField[];
24
+ /** Where the card came from; `'guide'` keeps it inline and routes its
25
+ * buttons to the hub (see `ApprovalRequestData.origin`). */
26
+ origin?: 'guide';
24
27
  }
25
28
 
26
29
  export interface AccumulatorState {
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { describe, it, expect } from 'vitest';
12
+ import { decodeNatsChunk } from '../../../../chat-protocol/nats-decoder';
12
13
  import { createChatStreamReducer } from '../../stream/chat-stream-reducer';
13
14
  import type { HistoricalMessage, MessageSegment } from '../../types';
14
15
  import { processHistoricalMessagesWithErrors } from '../process-historical-messages';
@@ -150,3 +151,95 @@ describe('displayApprovalTypes — history vs realtime default divergence', () =
150
151
  expect(live.escalated).toHaveLength(0);
151
152
  });
152
153
  });
154
+
155
+ /**
156
+ * A Product Guide card must survive a reload UNCHANGED.
157
+ *
158
+ * It arrives as a hub frame the agent re-streams into the dialog, so it is
159
+ * typed by TOOL and resolves through the hub's confirm route — the tier gate
160
+ * these tests are about simply does not apply to it. When the rule lived only
161
+ * in the realtime kernel, the same card rendered inline while streaming and
162
+ * then, on the next page load, was tier-gated: lifted out of its turn into the
163
+ * consumer's sticky pending-approvals envelope, stripped of the `fields` that
164
+ * are its entire body, and stripped of the `origin` that routes its buttons —
165
+ * so clicking Approve hit the agent's endpoint and 404'd.
166
+ */
167
+ describe('Product Guide approvals — history and realtime agree', () => {
168
+ const guideProposalMessage = (id: string): HistoricalMessage => ({
169
+ id,
170
+ createdAt: '2026-07-17T12:00:00Z',
171
+ owner: { type: 'ASSISTANT' },
172
+ messageData: [
173
+ {
174
+ type: 'GUIDE',
175
+ payload: {
176
+ kind: 'approval_request',
177
+ proposalId: 'prop-1',
178
+ toolName: 'create_ticket',
179
+ title: 'Open a support ticket',
180
+ fields: [{ label: 'Subject', value: 'Agent will not enroll' }],
181
+ },
182
+ },
183
+ ],
184
+ });
185
+
186
+ // The narrowest possible tier list: it excludes the guide's tool name, so a
187
+ // card that still renders proves the gate was bypassed, not merely satisfied.
188
+ const NARROW_TIERS = ['CLIENT'];
189
+
190
+ it('renders inline on reload despite a tier list that excludes it', () => {
191
+ const { messages, escalatedApprovals } = processHistoricalMessagesWithErrors([guideProposalMessage('m1')], {
192
+ displayApprovalTypes: NARROW_TIERS,
193
+ });
194
+ const rendered = messages.flatMap(m => approvalSegments(m.content));
195
+ expect(rendered).toHaveLength(1);
196
+ expect(escalatedApprovals.size).toBe(0);
197
+ });
198
+
199
+ it('keeps the card in its own turn, not in the sticky pending-approvals envelope', () => {
200
+ const { messages } = processHistoricalMessagesWithErrors([guideProposalMessage('m1')], {
201
+ displayApprovalTypes: NARROW_TIERS,
202
+ });
203
+ expect(messages.some(m => String(m.id).startsWith('pending-approvals-'))).toBe(false);
204
+ const turn = messages.find(m => m.id === 'm1');
205
+ expect(approvalSegments(turn?.content)).toHaveLength(1);
206
+ });
207
+
208
+ it('carries the same fields and origin as the live stream', () => {
209
+ /** The single card's `data`, narrowed off the segment union. */
210
+ const cardData = (segments: MessageSegment[]) => {
211
+ const card = segments.find(s => s.type === 'approval_request');
212
+ return card?.type === 'approval_request' ? card.data : undefined;
213
+ };
214
+
215
+ const { messages } = processHistoricalMessagesWithErrors([guideProposalMessage('m1')], {
216
+ displayApprovalTypes: NARROW_TIERS,
217
+ });
218
+ const replayed = cardData(messages.flatMap(m => approvalSegments(m.content)));
219
+
220
+ const live = createChatStreamReducer({ transport: 'nats', displayApprovalTypes: NARROW_TIERS });
221
+ live.apply({ type: 'turn-start' });
222
+ const guideChunk = decodeNatsChunk({
223
+ type: 'GUIDE',
224
+ payload: {
225
+ kind: 'approval_request',
226
+ proposalId: 'prop-1',
227
+ toolName: 'create_ticket',
228
+ title: 'Open a support ticket',
229
+ fields: [{ label: 'Subject', value: 'Agent will not enroll' }],
230
+ },
231
+ });
232
+ // A decoder that declines this frame is the failure the test exists to
233
+ // catch — say so rather than applying `undefined` to the reducer.
234
+ if (!guideChunk) throw new Error('decodeNatsChunk did not decode the GUIDE approval_request frame');
235
+ live.apply(guideChunk);
236
+ const streamed = cardData(live.state.messages.flatMap(m => approvalSegments(m.segments)));
237
+
238
+ // Same card either way — down to the rows that ARE its body, and the marker
239
+ // that decides which backend its buttons talk to.
240
+ expect(replayed).toEqual(streamed);
241
+ expect(replayed?.origin).toBe('guide');
242
+ expect(replayed?.approvalType).toBe('create_ticket');
243
+ expect(replayed?.fields).toEqual([{ label: 'Subject', value: 'Agent will not enroll' }]);
244
+ });
245
+ });
@@ -318,9 +318,86 @@ describe('extractErrorMessages / processHistoricalMessagesWithErrors — golden'
318
318
  });
319
319
  });
320
320
 
321
+ // ─── GUIDE rows (#1583) ────────────────────────────────────────────────────
322
+ //
323
+ // The realtime path decodes a `GUIDE` chunk into a `guide-delta` event; the
324
+ // history path must decode the persisted `GUIDE` row into the SAME event so a
325
+ // reloaded dialog renders the guide card instead of silently dropping it.
326
+ // Explicit assertions rather than a corpus snapshot — the point is the exact
327
+ // coalescing shape, not the whole-dialog record.
328
+
329
+ describe('processHistoricalMessages — GUIDE rows', () => {
330
+ const guideDialog: HistoricalMessage[] = [
331
+ {
332
+ id: 'u1',
333
+ chatType: 'CLIENT_CHAT',
334
+ createdAt: '2026-07-19T09:00:00Z',
335
+ owner: { type: 'CLIENT', userId: 'user-42' },
336
+ messageData: [{ type: 'TEXT', text: 'how do I enroll a device?' }],
337
+ },
338
+ {
339
+ id: 'a1',
340
+ chatType: 'CLIENT_CHAT',
341
+ createdAt: '2026-07-19T09:00:05Z',
342
+ owner: { type: 'ASSISTANT', model: 'claude-sonnet-x' },
343
+ messageData: [
344
+ { type: 'GUIDE', text: '## Enroll a device\n' },
345
+ { type: 'GUIDE', text: '1. Open Settings' },
346
+ ],
347
+ },
348
+ ];
349
+
350
+ it('replays consecutive GUIDE rows into ONE coalesced guide segment', () => {
351
+ const { messages } = processHistoricalMessages(guideDialog, {
352
+ assistantName: 'Mingo AI',
353
+ assistantType: 'mingo',
354
+ });
355
+ expect(messages).toHaveLength(2);
356
+ expect(messages[1]).toMatchObject({
357
+ role: 'assistant',
358
+ content: [{ type: 'guide', text: '## Enroll a device\n1. Open Settings' }],
359
+ });
360
+ });
361
+
362
+ it('keeps GUIDE and TEXT as separate segments in wire order', () => {
363
+ const mixed: HistoricalMessage[] = [
364
+ {
365
+ id: 'a1',
366
+ chatType: 'CLIENT_CHAT',
367
+ createdAt: '2026-07-19T09:00:05Z',
368
+ owner: { type: 'ASSISTANT' },
369
+ messageData: [
370
+ { type: 'TEXT', text: 'Here is the walkthrough.' },
371
+ { type: 'GUIDE', text: '## Steps' },
372
+ { type: 'TEXT', text: 'Anything else?' },
373
+ ],
374
+ },
375
+ ];
376
+ const { messages } = processHistoricalMessages(mixed);
377
+ expect(messages[0].content).toEqual([
378
+ { type: 'text', text: 'Here is the walkthrough.' },
379
+ { type: 'guide', text: '## Steps' },
380
+ { type: 'text', text: 'Anything else?' },
381
+ ]);
382
+ });
383
+
384
+ it('a GUIDE row with no text decodes to nothing (no empty card)', () => {
385
+ const empty: HistoricalMessage[] = [
386
+ {
387
+ id: 'a1',
388
+ chatType: 'CLIENT_CHAT',
389
+ createdAt: '2026-07-19T09:00:05Z',
390
+ owner: { type: 'ASSISTANT' },
391
+ messageData: [{ type: 'GUIDE' }],
392
+ },
393
+ ];
394
+ expect(processHistoricalMessages(empty).messages).toEqual([]);
395
+ });
396
+ });
397
+
321
398
  // ─── ASK rows ──────────────────────────────────────────────────────────────
322
399
  //
323
- // History must replay a persisted `ASK` row into
400
+ // Same contract as GUIDE above: history must replay a persisted `ASK` row into
324
401
  // the SAME shape the live `ASK` chunk produces — the intro sentence as answer
325
402
  // text, then the card — or a reloaded dialog loses the clarification the user
326
403
  // was about to answer.