@flamingo-stack/openframe-frontend-core 0.0.243 → 0.0.244

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 (131) hide show
  1. package/dist/{chunk-MKVKVJQQ.js → chunk-APGMNHPY.js} +19 -32
  2. package/dist/chunk-APGMNHPY.js.map +1 -0
  3. package/dist/{chunk-3TDLFCCS.cjs → chunk-DV3YZWGN.cjs} +20 -33
  4. package/dist/chunk-DV3YZWGN.cjs.map +1 -0
  5. package/dist/{chunk-BKPS6M2F.cjs → chunk-EJ7J4QAD.cjs} +12 -12
  6. package/dist/{chunk-BKPS6M2F.cjs.map → chunk-EJ7J4QAD.cjs.map} +1 -1
  7. package/dist/{chunk-CZITXP77.cjs → chunk-EPXROXYC.cjs} +713 -645
  8. package/dist/chunk-EPXROXYC.cjs.map +1 -0
  9. package/dist/{chunk-SGJBUL2Y.cjs → chunk-FSS2MIEA.cjs} +3 -20
  10. package/dist/chunk-FSS2MIEA.cjs.map +1 -0
  11. package/dist/{chunk-VAUI3CND.js → chunk-LJGBFD52.js} +1 -18
  12. package/dist/chunk-LJGBFD52.js.map +1 -0
  13. package/dist/{chunk-FRRMXLU5.js → chunk-M7DPUWAL.js} +188 -120
  14. package/dist/chunk-M7DPUWAL.js.map +1 -0
  15. package/dist/{chunk-SEVJT2HP.js → chunk-NKGNRFSS.js} +8 -8
  16. package/dist/{chunk-VFKQMAUF.cjs → chunk-OB45JHDY.cjs} +3 -3
  17. package/dist/{chunk-VFKQMAUF.cjs.map → chunk-OB45JHDY.cjs.map} +1 -1
  18. package/dist/{chunk-4XLJWX2N.js → chunk-ORJREQ2W.js} +4 -4
  19. package/dist/{chunk-7ZHFCEJH.js → chunk-P5C732SC.js} +6 -6
  20. package/dist/{chunk-JJV3XGCY.js → chunk-PTSAF7T2.js} +2 -2
  21. package/dist/{chunk-VXP553WT.cjs → chunk-RJC3L5DT.cjs} +7 -7
  22. package/dist/{chunk-VXP553WT.cjs.map → chunk-RJC3L5DT.cjs.map} +1 -1
  23. package/dist/{chunk-MP5HOP7N.cjs → chunk-STKMIMLP.cjs} +12 -12
  24. package/dist/{chunk-MP5HOP7N.cjs.map → chunk-STKMIMLP.cjs.map} +1 -1
  25. package/dist/{chunk-V35PZSUA.js → chunk-WD2MZVJI.js} +5 -5
  26. package/dist/{chunk-DUZBTUXF.cjs → chunk-Z6GGYZDR.cjs} +27 -27
  27. package/dist/{chunk-DUZBTUXF.cjs.map → chunk-Z6GGYZDR.cjs.map} +1 -1
  28. package/dist/components/chat/chat-message-list.d.ts.map +1 -1
  29. package/dist/components/chat/hooks/use-chat-card-item.d.ts.map +1 -1
  30. package/dist/components/chat/index.cjs +16 -6
  31. package/dist/components/chat/index.cjs.map +1 -1
  32. package/dist/components/chat/index.js +15 -5
  33. package/dist/components/chat/types/entities/content-ref.d.ts +20 -5
  34. package/dist/components/chat/types/entities/content-ref.d.ts.map +1 -1
  35. package/dist/components/chat/utils/history-merge.d.ts +92 -0
  36. package/dist/components/chat/utils/history-merge.d.ts.map +1 -0
  37. package/dist/components/chat/utils/index.d.ts +1 -0
  38. package/dist/components/chat/utils/index.d.ts.map +1 -1
  39. package/dist/components/contact/index.cjs +7 -7
  40. package/dist/components/contact/index.js +6 -6
  41. package/dist/components/faq/faq-section.d.ts +1 -7
  42. package/dist/components/faq/faq-section.d.ts.map +1 -1
  43. package/dist/components/faq/index.cjs +3 -3
  44. package/dist/components/faq/index.js +2 -2
  45. package/dist/components/features/index.cjs +6 -6
  46. package/dist/components/features/index.js +5 -5
  47. package/dist/components/features/policy-configuration-panel.d.ts.map +1 -1
  48. package/dist/components/index.cjs +93 -87
  49. package/dist/components/index.cjs.map +1 -1
  50. package/dist/components/index.d.ts +0 -1
  51. package/dist/components/index.d.ts.map +1 -1
  52. package/dist/components/index.js +37 -31
  53. package/dist/components/index.js.map +1 -1
  54. package/dist/components/navigation/index.cjs +6 -6
  55. package/dist/components/navigation/index.js +5 -5
  56. package/dist/components/onboarding-guides/index.cjs +30 -30
  57. package/dist/components/onboarding-guides/index.cjs.map +1 -1
  58. package/dist/components/onboarding-guides/index.js +11 -11
  59. package/dist/components/tickets/index.cjs +73 -73
  60. package/dist/components/tickets/index.cjs.map +1 -1
  61. package/dist/components/tickets/index.js +15 -15
  62. package/dist/components/ui/index.cjs +19 -9
  63. package/dist/components/ui/index.cjs.map +1 -1
  64. package/dist/components/ui/index.js +23 -13
  65. package/dist/embed-shims/index.cjs +3 -3
  66. package/dist/embed-shims/index.cjs.map +1 -1
  67. package/dist/embed-shims/index.js +5 -5
  68. package/dist/index.cjs +11 -17
  69. package/dist/index.cjs.map +1 -1
  70. package/dist/index.js +49 -55
  71. package/dist/types/index.d.ts +0 -1
  72. package/dist/types/index.d.ts.map +1 -1
  73. package/dist/utils/index.cjs +0 -81
  74. package/dist/utils/index.cjs.map +1 -1
  75. package/dist/utils/index.d.ts +1 -4
  76. package/dist/utils/index.d.ts.map +1 -1
  77. package/dist/utils/index.js +1 -74
  78. package/dist/utils/index.js.map +1 -1
  79. package/dist/utils/list-url.d.ts +0 -6
  80. package/dist/utils/list-url.d.ts.map +1 -1
  81. package/package.json +1 -7
  82. package/src/components/chat/__tests__/chat-message-list.test.tsx +92 -0
  83. package/src/components/chat/chat-message-list.tsx +74 -49
  84. package/src/components/chat/hooks/use-chat-card-item.ts +38 -3
  85. package/src/components/chat/types/entities/content-ref.ts +21 -5
  86. package/src/components/chat/utils/__tests__/history-merge.test.ts +339 -0
  87. package/src/components/chat/utils/history-merge.ts +255 -0
  88. package/src/components/chat/utils/index.ts +12 -0
  89. package/src/components/faq/faq-section.tsx +24 -69
  90. package/src/components/features/policy-configuration-panel.tsx +1 -0
  91. package/src/components/index.ts +0 -1
  92. package/src/types/index.ts +0 -1
  93. package/src/utils/index.ts +1 -16
  94. package/src/utils/list-url.ts +0 -9
  95. package/dist/chunk-3TDLFCCS.cjs.map +0 -1
  96. package/dist/chunk-CZITXP77.cjs.map +0 -1
  97. package/dist/chunk-F3GMWNAT.js +0 -285
  98. package/dist/chunk-F3GMWNAT.js.map +0 -1
  99. package/dist/chunk-FRRMXLU5.js.map +0 -1
  100. package/dist/chunk-HU252PYJ.cjs +0 -285
  101. package/dist/chunk-HU252PYJ.cjs.map +0 -1
  102. package/dist/chunk-MKVKVJQQ.js.map +0 -1
  103. package/dist/chunk-SGJBUL2Y.cjs.map +0 -1
  104. package/dist/chunk-VAUI3CND.js.map +0 -1
  105. package/dist/components/related-content/index.cjs +0 -26
  106. package/dist/components/related-content/index.cjs.map +0 -1
  107. package/dist/components/related-content/index.d.ts +0 -2
  108. package/dist/components/related-content/index.d.ts.map +0 -1
  109. package/dist/components/related-content/index.js +0 -26
  110. package/dist/components/related-content/index.js.map +0 -1
  111. package/dist/components/related-content/related-content-section.d.ts +0 -141
  112. package/dist/components/related-content/related-content-section.d.ts.map +0 -1
  113. package/dist/types/content-ref.d.ts +0 -35
  114. package/dist/types/content-ref.d.ts.map +0 -1
  115. package/dist/utils/content-ref-groups.d.ts +0 -58
  116. package/dist/utils/content-ref-groups.d.ts.map +0 -1
  117. package/dist/utils/extract-items.d.ts +0 -18
  118. package/dist/utils/extract-items.d.ts.map +0 -1
  119. package/dist/utils/suggestion-url.d.ts +0 -26
  120. package/dist/utils/suggestion-url.d.ts.map +0 -1
  121. package/src/components/related-content/index.ts +0 -9
  122. package/src/components/related-content/related-content-section.tsx +0 -612
  123. package/src/types/content-ref.ts +0 -34
  124. package/src/utils/content-ref-groups.ts +0 -89
  125. package/src/utils/extract-items.ts +0 -48
  126. package/src/utils/suggestion-url.ts +0 -39
  127. /package/dist/{chunk-SEVJT2HP.js.map → chunk-NKGNRFSS.js.map} +0 -0
  128. /package/dist/{chunk-4XLJWX2N.js.map → chunk-ORJREQ2W.js.map} +0 -0
  129. /package/dist/{chunk-7ZHFCEJH.js.map → chunk-P5C732SC.js.map} +0 -0
  130. /package/dist/{chunk-JJV3XGCY.js.map → chunk-PTSAF7T2.js.map} +0 -0
  131. /package/dist/{chunk-V35PZSUA.js.map → chunk-WD2MZVJI.js.map} +0 -0
@@ -0,0 +1,339 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { MessageSegment } from '../../types'
3
+ import {
4
+ computeHistoryPrepend,
5
+ flattenMessagePagesChronological,
6
+ maxPersistedStreamSeq,
7
+ type MergeableChatMessage,
8
+ mergeHistoryWithRealtime,
9
+ } from '../history-merge'
10
+
11
+ interface TestMessage extends MergeableChatMessage {
12
+ role: 'user' | 'assistant'
13
+ }
14
+
15
+ const txt = (s: string): MessageSegment[] => [{ type: 'text', text: s }]
16
+ const t = (ms: number) => new Date(ms)
17
+ const ids = (msgs: TestMessage[]) => msgs.map((m) => m.id)
18
+ const batchSeg = {
19
+ type: 'approval_batch',
20
+ data: { approvalRequestId: 'req-9', toolCalls: [] },
21
+ } as unknown as MessageSegment
22
+
23
+ // Persisted history (Mongo ids), first fetched at t=1000
24
+ const U0: TestMessage = { id: 'aaaa0001', role: 'user', content: 'first question', timestamp: t(500) }
25
+ const A0: TestMessage = { id: 'aaaa0002', role: 'assistant', content: txt('first answer'), timestamp: t(600) }
26
+
27
+ // A later turn that happened via realtime (optimistic user + streamed reply)
28
+ const OPT1: TestMessage = { id: 'optimistic-2000-x', role: 'user', content: 'second question', timestamp: t(2000) }
29
+ const SYN1: TestMessage = { id: 'assistant-2100-x', role: 'assistant', content: txt('second answer'), timestamp: t(2100) }
30
+
31
+ // Persisted counterparts of that turn (what a FRESH fetch returns)
32
+ const U1: TestMessage = { id: 'aaaa0003', role: 'user', content: 'second question', timestamp: t(2000) }
33
+ const A1: TestMessage = { id: 'aaaa0004', role: 'assistant', content: txt('second answer'), timestamp: t(2100) }
34
+
35
+ describe('mergeHistoryWithRealtime', () => {
36
+ it('keeps realtime messages newer than a STALE history snapshot (no data loss)', () => {
37
+ // Reopen a dialog: first merge runs against the cached snapshot fetched
38
+ // BEFORE the second turn existed. The completed reply must survive — the
39
+ // realtime transport will not redeliver chunks this client already consumed.
40
+ const merged = mergeHistoryWithRealtime({
41
+ processedHistory: [U0, A0],
42
+ existingMessages: [U0, A0, OPT1, SYN1],
43
+ streamingMessageId: null,
44
+ historyFetchedAt: 1000,
45
+ })
46
+ expect(ids(merged)).toEqual([U0.id, A0.id, OPT1.id, SYN1.id])
47
+ })
48
+
49
+ it('replaces optimistic + synthetic messages with their persisted twins on a FRESH snapshot', () => {
50
+ const merged = mergeHistoryWithRealtime({
51
+ processedHistory: [U0, A0, U1, A1],
52
+ existingMessages: [U0, A0, OPT1, SYN1],
53
+ streamingMessageId: null,
54
+ historyFetchedAt: 5000,
55
+ })
56
+ expect(ids(merged)).toEqual([U0.id, A0.id, U1.id, A1.id])
57
+ })
58
+
59
+ it('never drops the in-flight streaming synthetic', () => {
60
+ const merged = mergeHistoryWithRealtime({
61
+ processedHistory: [U0, A0, U1], // assistant turn not persisted yet
62
+ existingMessages: [U0, A0, OPT1, SYN1],
63
+ streamingMessageId: SYN1.id,
64
+ historyFetchedAt: 5000,
65
+ })
66
+ expect(ids(merged)).toContain(SYN1.id)
67
+ })
68
+
69
+ it('dedupes mid-list synthetics against fresh history (no duplicated turns)', () => {
70
+ const OPT2: TestMessage = { id: 'optimistic-3000-x', role: 'user', content: 'third question', timestamp: t(3000) }
71
+ const SYN2: TestMessage = { id: 'assistant-3100-x', role: 'assistant', content: txt('third answer'), timestamp: t(3100) }
72
+ const U2: TestMessage = { id: 'aaaa0006', role: 'user', content: 'third question', timestamp: t(3000) }
73
+ const A2: TestMessage = { id: 'aaaa0007', role: 'assistant', content: txt('third answer'), timestamp: t(3100) }
74
+
75
+ const merged = mergeHistoryWithRealtime({
76
+ processedHistory: [U0, A0, U1, A1, U2, A2],
77
+ existingMessages: [U0, A0, OPT1, SYN1, OPT2, SYN2],
78
+ streamingMessageId: null,
79
+ historyFetchedAt: 5000,
80
+ })
81
+ expect(ids(merged)).toEqual([U0.id, A0.id, U1.id, A1.id, U2.id, A2.id])
82
+ })
83
+
84
+ it('pins the more-complete synthetic over a partial history assistant (approval persistence race)', () => {
85
+ const partialHistA: TestMessage = { id: 'aaaa0005', role: 'assistant', content: [batchSeg], timestamp: t(2100) }
86
+ const fullSyn: TestMessage = {
87
+ id: 'assistant-2100-y',
88
+ role: 'assistant',
89
+ content: [...txt('second answer'), batchSeg],
90
+ timestamp: t(2100), // OLDER than the snapshot — pin must beat the freshness rule
91
+ }
92
+
93
+ const merged = mergeHistoryWithRealtime({
94
+ processedHistory: [U0, A0, U1, partialHistA],
95
+ existingMessages: [U0, A0, OPT1, fullSyn],
96
+ streamingMessageId: null,
97
+ historyFetchedAt: 5000,
98
+ })
99
+ expect(ids(merged)).toContain(fullSyn.id)
100
+ expect(ids(merged)).not.toContain(partialHistA.id)
101
+ })
102
+
103
+ it('drops the synthetic when history is at least as complete for the same approval batch', () => {
104
+ const fullHistA: TestMessage = {
105
+ id: 'aaaa0005',
106
+ role: 'assistant',
107
+ content: [...txt('second answer'), batchSeg],
108
+ timestamp: t(2100),
109
+ }
110
+ const lesserSyn: TestMessage = {
111
+ id: 'assistant-2100-y',
112
+ role: 'assistant',
113
+ content: [batchSeg],
114
+ timestamp: t(9000), // even NEWER than the snapshot — explicit drop wins
115
+ }
116
+
117
+ const merged = mergeHistoryWithRealtime({
118
+ processedHistory: [U0, A0, U1, fullHistA],
119
+ existingMessages: [U0, A0, OPT1, lesserSyn],
120
+ streamingMessageId: null,
121
+ historyFetchedAt: 5000,
122
+ })
123
+ expect(ids(merged)).toContain(fullHistA.id)
124
+ expect(ids(merged)).not.toContain(lesserSyn.id)
125
+ })
126
+
127
+ it('drops a replay-minted synthetic with a FRESH timestamp when seq coverage proves history contains it', () => {
128
+ // Chunk replay re-materializes an old turn into a synthetic stamped with
129
+ // the CURRENT time — newer than the snapshot, so the wall-clock rule
130
+ // would wrongly keep it next to its persisted twin (the "duplicated
131
+ // assistant message after navigating back" bug). Seq coverage is exact:
132
+ // history persisted through seq 80 >= client consumed through seq 80.
133
+ const replayedSyn: TestMessage = {
134
+ id: 'assistant-9999-r',
135
+ role: 'assistant',
136
+ content: txt('second answer'),
137
+ timestamp: t(9999), // newer than historyFetchedAt
138
+ }
139
+ const merged = mergeHistoryWithRealtime({
140
+ processedHistory: [U0, A0, U1, A1],
141
+ existingMessages: [U0, A0, replayedSyn],
142
+ streamingMessageId: null,
143
+ historyFetchedAt: 5000,
144
+ historyMaxStreamSeq: 80,
145
+ realtimeSeenStreamSeq: 80,
146
+ })
147
+ expect(ids(merged)).toEqual([U0.id, A0.id, U1.id, A1.id])
148
+ })
149
+
150
+ it('keeps an old-looking synthetic when seq coverage proves history is BEHIND (persistence lag)', () => {
151
+ // Client consumed through seq 90 but history only persisted through 80 —
152
+ // dropping the synthetic would lose content, no matter how old its
153
+ // wall-clock timestamp looks.
154
+ const merged = mergeHistoryWithRealtime({
155
+ processedHistory: [U0, A0],
156
+ existingMessages: [U0, A0, OPT1, SYN1], // SYN1 timestamp 2100 <= fetchedAt
157
+ streamingMessageId: null,
158
+ historyFetchedAt: 5000,
159
+ historyMaxStreamSeq: 80,
160
+ realtimeSeenStreamSeq: 90,
161
+ })
162
+ expect(ids(merged)).toContain(SYN1.id)
163
+ })
164
+
165
+ it('falls back to the wall-clock rule when seq signals are unavailable', () => {
166
+ const merged = mergeHistoryWithRealtime({
167
+ processedHistory: [U0, A0, U1, A1],
168
+ existingMessages: [U0, A0, OPT1, SYN1],
169
+ streamingMessageId: null,
170
+ historyFetchedAt: 5000,
171
+ historyMaxStreamSeq: 0,
172
+ realtimeSeenStreamSeq: 0,
173
+ })
174
+ expect(ids(merged)).toEqual([U0.id, A0.id, U1.id, A1.id])
175
+ })
176
+
177
+ it('keeps a just-sent optimistic message whose text repeats an older turn (stale snapshot)', () => {
178
+ // The user re-sends "first question" and the merge runs against a STALE
179
+ // snapshot (e.g. triggered by stream start). Content-dedup must not match
180
+ // it against the OLD persisted turn with the same text.
181
+ const repeat: TestMessage = { id: 'optimistic-9000-x', role: 'user', content: 'first question', timestamp: t(9000) }
182
+ const merged = mergeHistoryWithRealtime({
183
+ processedHistory: [U0, A0],
184
+ existingMessages: [U0, A0, repeat],
185
+ streamingMessageId: null,
186
+ historyFetchedAt: 1000,
187
+ })
188
+ expect(ids(merged)).toContain(repeat.id)
189
+ })
190
+
191
+ it('drops an optimistic repeat once the snapshot is fresh enough to contain its twin', () => {
192
+ const repeatPersisted: TestMessage = { id: 'bbbb0001', role: 'user', content: 'first question', timestamp: t(9000) }
193
+ const repeat: TestMessage = { id: 'optimistic-9000-x', role: 'user', content: 'first question', timestamp: t(9000) }
194
+ const merged = mergeHistoryWithRealtime({
195
+ processedHistory: [U0, A0, repeatPersisted],
196
+ existingMessages: [U0, A0, repeat],
197
+ streamingMessageId: null,
198
+ historyFetchedAt: 10_000,
199
+ })
200
+ expect(ids(merged)).toEqual([U0.id, A0.id, repeatPersisted.id])
201
+ })
202
+
203
+ it('returns existing messages untouched when the history snapshot is empty', () => {
204
+ const merged = mergeHistoryWithRealtime({
205
+ processedHistory: [],
206
+ existingMessages: [OPT1, SYN1],
207
+ streamingMessageId: null,
208
+ historyFetchedAt: 10_000,
209
+ })
210
+ expect(merged).toEqual([OPT1, SYN1])
211
+ })
212
+
213
+ it('keeps a just-sent optimistic repeat even when seq coverage reports covered', () => {
214
+ // Optimistic messages are minted on send, not by chunks — seq coverage
215
+ // says nothing about them. Sent AFTER the snapshot → must survive even
216
+ // though its text matches an old persisted turn.
217
+ const repeat: TestMessage = { id: 'optimistic-9000-x', role: 'user', content: 'first question', timestamp: t(9000) }
218
+ const merged = mergeHistoryWithRealtime({
219
+ processedHistory: [U0, A0],
220
+ existingMessages: [U0, A0, repeat],
221
+ streamingMessageId: null,
222
+ historyFetchedAt: 5000,
223
+ historyMaxStreamSeq: 80,
224
+ realtimeSeenStreamSeq: 80,
225
+ })
226
+ expect(ids(merged)).toContain(repeat.id)
227
+ })
228
+
229
+ it('pins the streaming twin even when it carries a persisted history id (adoption path)', () => {
230
+ // Chunk processors ADOPT an in-progress trailing assistant after a prior
231
+ // merge, so the streaming twin can have the SAME Mongo id as history's
232
+ // trailing message. The pin must beat the processed/raw id dedup or the
233
+ // turn vanishes from both lists.
234
+ const adopted: TestMessage = { id: 'aaaa0005', role: 'assistant', content: [batchSeg], timestamp: t(2100) }
235
+ const merged = mergeHistoryWithRealtime({
236
+ processedHistory: [U0, A0, U1, adopted],
237
+ rawHistoryIds: new Set([U0.id, A0.id, U1.id, adopted.id]),
238
+ existingMessages: [U0, A0, adopted],
239
+ streamingMessageId: adopted.id,
240
+ historyFetchedAt: 5000,
241
+ })
242
+ expect(ids(merged)).toEqual([U0.id, A0.id, U1.id, adopted.id])
243
+ })
244
+
245
+ it('never drops the streaming twin in the approval-batch resolution (pins it instead)', () => {
246
+ // History's trailing assistant fully persisted the approval turn, but the
247
+ // same batch id belongs to the LIVE streaming bubble (continuation after
248
+ // approve). Dropping it would orphan the host's streaming pointer and
249
+ // make continuation chunks invisible.
250
+ const fullHistA: TestMessage = {
251
+ id: 'aaaa0005',
252
+ role: 'assistant',
253
+ content: [...txt('second answer'), batchSeg],
254
+ timestamp: t(2100),
255
+ }
256
+ const liveTwin: TestMessage = { id: 'assistant-2100-y', role: 'assistant', content: [batchSeg], timestamp: t(2100) }
257
+ const merged = mergeHistoryWithRealtime({
258
+ processedHistory: [U0, A0, U1, fullHistA],
259
+ existingMessages: [U0, A0, OPT1, liveTwin],
260
+ streamingMessageId: liveTwin.id,
261
+ historyFetchedAt: 5000,
262
+ })
263
+ expect(ids(merged)).toContain(liveTwin.id)
264
+ expect(ids(merged)).not.toContain(fullHistA.id)
265
+ })
266
+
267
+ it('resolves the approval batch against the most recent twin when several share the batch id', () => {
268
+ const partialHistA: TestMessage = { id: 'aaaa0005', role: 'assistant', content: [batchSeg], timestamp: t(2100) }
269
+ const staleTwin: TestMessage = { id: 'assistant-1000-old', role: 'assistant', content: [batchSeg], timestamp: t(1000) }
270
+ const fullTwin: TestMessage = {
271
+ id: 'assistant-2100-y',
272
+ role: 'assistant',
273
+ content: [...txt('second answer'), batchSeg],
274
+ timestamp: t(2100),
275
+ }
276
+ const merged = mergeHistoryWithRealtime({
277
+ processedHistory: [U0, A0, U1, partialHistA],
278
+ existingMessages: [U0, A0, staleTwin, fullTwin],
279
+ streamingMessageId: null,
280
+ historyFetchedAt: 5000,
281
+ })
282
+ expect(ids(merged)).toContain(fullTwin.id)
283
+ expect(ids(merged)).not.toContain(partialHistA.id)
284
+ expect(ids(merged)).not.toContain(staleTwin.id)
285
+ })
286
+
287
+ it('keeps welcome bubbles (never persisted server-side)', () => {
288
+ const welcome: TestMessage = { id: 'welcome-d1', role: 'assistant', content: 'Hi!', timestamp: t(100) }
289
+ const merged = mergeHistoryWithRealtime({
290
+ processedHistory: [U0, A0],
291
+ existingMessages: [welcome],
292
+ streamingMessageId: null,
293
+ historyFetchedAt: 5000,
294
+ })
295
+ expect(ids(merged)).toContain(welcome.id)
296
+ })
297
+ })
298
+
299
+ describe('computeHistoryPrepend', () => {
300
+ it('collects only messages above the first known id', () => {
301
+ const older: TestMessage = { id: 'aaaa0000', role: 'user', content: 'zeroth', timestamp: t(100) }
302
+ const result = computeHistoryPrepend([older, U0, A0], [U0, A0, SYN1])
303
+ expect(result).not.toBeNull()
304
+ expect(ids(result!.newMessages)).toEqual([older.id])
305
+ expect(result!.boundaryUpdates).toBeUndefined()
306
+ })
307
+
308
+ it('reports a boundary content refresh when the known boundary message changed', () => {
309
+ const updatedU0: TestMessage = { ...U0, content: 'first question (edited)' }
310
+ const result = computeHistoryPrepend([updatedU0, A0], [U0, A0])
311
+ expect(result).not.toBeNull()
312
+ expect(result!.newMessages).toEqual([])
313
+ expect(result!.boundaryMessageId).toBe(U0.id)
314
+ expect(result!.boundaryUpdates).toEqual({ content: 'first question (edited)' })
315
+ })
316
+
317
+ it('returns null when there is nothing to apply', () => {
318
+ expect(computeHistoryPrepend([U0, A0], [U0, A0])).toBeNull()
319
+ })
320
+ })
321
+
322
+ describe('page helpers', () => {
323
+ // Pages arrive DESC (newest page first, newest message first within a page).
324
+ const pages: { messages: { id: string; lastChunkStreamSeq?: number | null }[] }[] = [
325
+ { messages: [{ id: 'd', lastChunkStreamSeq: 40 }, { id: 'c', lastChunkStreamSeq: null }] },
326
+ { messages: [{ id: 'b', lastChunkStreamSeq: 20 }, { id: 'a' }] },
327
+ ]
328
+
329
+ it('flattenMessagePagesChronological reverses pages and messages into chronological order', () => {
330
+ expect(flattenMessagePagesChronological(pages).map((m) => m.id)).toEqual(['a', 'b', 'c', 'd'])
331
+ expect(flattenMessagePagesChronological(undefined)).toEqual([])
332
+ })
333
+
334
+ it('maxPersistedStreamSeq returns the highest stamped seq, 0 when absent', () => {
335
+ expect(maxPersistedStreamSeq(pages)).toBe(40)
336
+ expect(maxPersistedStreamSeq([{ messages: [{}] }])).toBe(0)
337
+ expect(maxPersistedStreamSeq(undefined)).toBe(0)
338
+ })
339
+ })
@@ -0,0 +1,255 @@
1
+ import type { ApprovalBatchSegment, MessageContent } from '../types'
2
+
3
+ /**
4
+ * Reconciliation between persisted dialog history (processed GraphQL pages)
5
+ * and the realtime messages a chat client accumulated from streaming chunks.
6
+ *
7
+ * This is the missing middle piece of the chat pipeline this lib already
8
+ * owns: `processHistoricalMessages*` produces one stream, the realtime chunk
9
+ * processor / segment accumulator produces the other, and every host (Mingo,
10
+ * tickets, openframe-chat) needs to merge them whenever history is (re)fetched.
11
+ * Hand-rolled versions of this merge have produced both duplicated turns
12
+ * (synthetic kept alongside its persisted twin) and lost turns (synthetic
13
+ * trimmed against a stale snapshot that didn't contain its persisted twin
14
+ * yet) — the freshness rule below is the invariant that prevents both.
15
+ *
16
+ * Pure on purpose: the host owns WHEN to merge (react-query wiring, store
17
+ * writes), this module owns HOW.
18
+ */
19
+
20
+ /** Minimal structural shape the merge needs — hosts pass their own message
21
+ * type and get it back. */
22
+ export interface MergeableChatMessage {
23
+ id: string
24
+ role: string
25
+ content: MessageContent
26
+ timestamp?: Date
27
+ }
28
+
29
+ /** Ids minted client-side by realtime chunk processors
30
+ * (`assistant-<ts>-…` placeholder bubbles, `user-<ts>-…` peer messages,
31
+ * `error-<ts>` stream errors). They never match the Mongo ObjectIds history
32
+ * returns for the same turns. This is the cross-host contract every minting
33
+ * site (lib `use-chat`, Mingo / tickets chunk processors, openframe-chat)
34
+ * must keep matching — exported so it lives in exactly one place.
35
+ * `welcome-` and `optimistic-` ids are intentionally NOT listed: welcome
36
+ * bubbles are never persisted server-side, and optimistic user messages are
37
+ * deduped by content below. */
38
+ export const SYNTHETIC_REALTIME_ID_PREFIXES = ['assistant-', 'user-', 'error-'] as const
39
+
40
+ function isSyntheticRealtimeId(id: string): boolean {
41
+ return SYNTHETIC_REALTIME_ID_PREFIXES.some((prefix) => id.startsWith(prefix))
42
+ }
43
+
44
+ /** Flattens DESC-sorted message pages (newest page first, newest message
45
+ * first within a page) into one chronological list. */
46
+ export function flattenMessagePagesChronological<T>(pages: readonly { messages: readonly T[] }[] | undefined): T[] {
47
+ if (!pages) return []
48
+ return [...pages].reverse().flatMap((page) => [...page.messages].reverse())
49
+ }
50
+
51
+ /** Max `lastChunkStreamSeq` across history pages — the history half of the
52
+ * merge's seq-coverage signal (`HistoryMergeInput.historyMaxStreamSeq`),
53
+ * also used by hosts as the JetStream replay start offset. 0 = unstamped. */
54
+ export function maxPersistedStreamSeq(
55
+ pages: readonly { messages: readonly { lastChunkStreamSeq?: number | null }[] }[] | undefined,
56
+ ): number {
57
+ let max = 0
58
+ if (!pages) return max
59
+ for (const page of pages) {
60
+ for (const msg of page.messages) {
61
+ const seq = msg.lastChunkStreamSeq
62
+ if (typeof seq === 'number' && seq > max) max = seq
63
+ }
64
+ }
65
+ return max
66
+ }
67
+
68
+ export interface HistoryMergeInput<M extends MergeableChatMessage> {
69
+ /** Processed history in chronological order (all fetched pages). */
70
+ processedHistory: M[]
71
+ /** Raw history message ids across all fetched pages, when they can differ
72
+ * from processed ids (processing may merge/rename). Optional — pass when
73
+ * available so raw-id duplicates are filtered too. */
74
+ rawHistoryIds?: ReadonlySet<string>
75
+ /** Messages currently in the host store for this dialog (realtime + prior merges). */
76
+ existingMessages: M[]
77
+ /** Id of the in-flight streaming synthetic, if any. Never dropped.
78
+ * IMPORTANT: hosts must not pass a STALE id here (e.g. a streaming entry
79
+ * left behind by unmounting mid-stream) — gate it on the server-side
80
+ * stream state when available, or the synthetic will be exempted forever. */
81
+ streamingMessageId: string | null
82
+ /** Epoch ms when the history pages were fetched (react-query `dataUpdatedAt`).
83
+ * Wall-clock freshness fallback used when seq coverage (below) is unknown:
84
+ * a synthetic created AFTER this instant cannot be represented in the
85
+ * snapshot, so it must be kept. NOTE this heuristic is blind to chunk
86
+ * REPLAY, which re-mints synthetics for old turns with fresh timestamps —
87
+ * pass the seq fields whenever the host tracks them. */
88
+ historyFetchedAt: number
89
+ /** Max `lastChunkStreamSeq` across the raw history pages, when the backend
90
+ * stamps it (see `maxPersistedStreamSeq`). Together with
91
+ * `realtimeSeenStreamSeq` this gives an exact coverage signal that
92
+ * replaces the wall-clock heuristic. */
93
+ historyMaxStreamSeq?: number
94
+ /** Highest stream seq this client has consumed for the dialog (live or
95
+ * replayed chunks). */
96
+ realtimeSeenStreamSeq?: number
97
+ }
98
+
99
+ export function mergeHistoryWithRealtime<M extends MergeableChatMessage>(input: HistoryMergeInput<M>): M[] {
100
+ const {
101
+ processedHistory,
102
+ rawHistoryIds,
103
+ existingMessages,
104
+ streamingMessageId,
105
+ historyFetchedAt,
106
+ historyMaxStreamSeq = 0,
107
+ realtimeSeenStreamSeq = 0,
108
+ } = input
109
+
110
+ // An empty snapshot can't dedupe anything — merging would only let the
111
+ // freshness rules below wipe completed realtime messages (e.g. persistence
112
+ // lag on a fresh dialog). The realtime side stays the source of truth.
113
+ if (processedHistory.length === 0) return existingMessages
114
+
115
+ const processedIds = new Set(processedHistory.map((m) => m.id))
116
+
117
+ // Seq-based coverage: every synthetic is derived from consumed chunks, so
118
+ // when history's max persisted seq reaches the highest seq this client has
119
+ // consumed, ALL synthetics are represented in history (drop them); when it
120
+ // hasn't, history is provably behind (keep them ALL — even ones that look
121
+ // old by wall-clock, e.g. re-minted by a chunk replay). `null` = signal
122
+ // unavailable (legacy transport / backend without seq stamps) → fall back
123
+ // to the per-message wall-clock rule.
124
+ const seqCoverageKnown = realtimeSeenStreamSeq > 0 && historyMaxStreamSeq > 0
125
+ const historyCoversRealtime = seqCoverageKnown ? historyMaxStreamSeq >= realtimeSeenStreamSeq : null
126
+
127
+ // Persistence is asynchronous per-chunk: an assistant turn ending in an
128
+ // approval can have its APPROVAL_REQUEST document persisted before the
129
+ // leading THINKING/TEXT documents, so history can return a partial trailing
130
+ // assistant (just `[approval_batch]`) while the realtime store already has
131
+ // the full `[thinking, text, approval_batch]` synthetic. Resolve by
132
+ // `approval_batch.approvalRequestId`:
133
+ // - if the existing twin is the LIVE streaming message, or has more
134
+ // segments — drop the history assistant and pin the twin (realtime is
135
+ // the more complete / still-growing copy).
136
+ // - else (history is at least as complete) — drop the twin.
137
+ const trailing = processedHistory[processedHistory.length - 1]
138
+ const historyTrailingAssistant = trailing?.role === 'assistant' ? trailing : null
139
+ const historyBatchId =
140
+ historyTrailingAssistant && Array.isArray(historyTrailingAssistant.content)
141
+ ? ((historyTrailingAssistant.content.find((s) => s.type === 'approval_batch') as ApprovalBatchSegment | undefined)
142
+ ?.data?.approvalRequestId ?? null)
143
+ : null
144
+
145
+ let processedToUse = processedHistory
146
+ const pinnedSyntheticIds = new Set<string>()
147
+ const droppedSyntheticIds = new Set<string>()
148
+
149
+ if (historyBatchId) {
150
+ const batchTwins = existingMessages.filter(
151
+ (m) =>
152
+ m.role === 'assistant' &&
153
+ Array.isArray(m.content) &&
154
+ m.content.some(
155
+ (s) => s.type === 'approval_batch' && (s as ApprovalBatchSegment).data?.approvalRequestId === historyBatchId,
156
+ ),
157
+ )
158
+ // Prefer the live streaming twin; otherwise the most recent one (a stale
159
+ // replay-minted duplicate may precede it).
160
+ const existingWithSameBatch = batchTwins.find((m) => m.id === streamingMessageId) ?? batchTwins[batchTwins.length - 1]
161
+ if (existingWithSameBatch && Array.isArray(existingWithSameBatch.content)) {
162
+ const histSize = Array.isArray(historyTrailingAssistant?.content) ? historyTrailingAssistant.content.length : 0
163
+ const realtimeSize = existingWithSameBatch.content.length
164
+ if (existingWithSameBatch.id === streamingMessageId || realtimeSize > histSize) {
165
+ processedToUse = processedHistory.slice(0, -1)
166
+ pinnedSyntheticIds.add(existingWithSameBatch.id)
167
+ } else {
168
+ droppedSyntheticIds.add(existingWithSameBatch.id)
169
+ }
170
+ }
171
+ }
172
+
173
+ const realtimeMessages = existingMessages.filter((m) => {
174
+ // Pin wins over everything: the twin may carry a persisted Mongo id (the
175
+ // chunk processors ADOPT an in-progress trailing assistant after a prior
176
+ // merge), in which case `processedIds`/`rawHistoryIds` would drop it even
177
+ // though the pin branch above already removed history's copy — vanishing
178
+ // the whole turn.
179
+ if (pinnedSyntheticIds.has(m.id)) return true
180
+ if (droppedSyntheticIds.has(m.id)) return false
181
+ if (processedIds.has(m.id)) return false
182
+ if (rawHistoryIds?.has(m.id)) return false
183
+ if (m.role === 'user' && m.id.startsWith('optimistic-') && typeof m.content === 'string') {
184
+ // Content-dedup only when the message predates the snapshot — a
185
+ // just-sent message whose text repeats an earlier turn ("yes", "ok")
186
+ // must not vanish against stale history. Wall-clock, not seq coverage:
187
+ // optimistic messages are minted on send, never by chunk replay, so
188
+ // their timestamps are trustworthy.
189
+ const canBeInSnapshot = (m.timestamp?.getTime() ?? 0) <= historyFetchedAt
190
+ return !(canBeInSnapshot && processedToUse.some((pm) => pm.role === 'user' && pm.content === m.content))
191
+ }
192
+ // Freshness rule: a synthetic whose turn is represented in the snapshot
193
+ // (under its persisted Mongo id) must be dropped or the turn renders
194
+ // twice; one the snapshot cannot contain yet must be kept or a message
195
+ // the user already saw is lost, with no realtime replay to restore it
196
+ // (JetStream resumes after the highest seq this client has consumed).
197
+ // Decided by seq coverage when known, wall-clock otherwise.
198
+ if (isSyntheticRealtimeId(m.id) && m.id !== streamingMessageId) {
199
+ const covered =
200
+ historyCoversRealtime !== null ? historyCoversRealtime : (m.timestamp?.getTime() ?? 0) <= historyFetchedAt
201
+ if (covered) return false
202
+ }
203
+ return true
204
+ })
205
+
206
+ return [...processedToUse, ...realtimeMessages]
207
+ }
208
+
209
+ export interface HistoryPrependResult<M extends MergeableChatMessage> {
210
+ newMessages: M[]
211
+ boundaryMessageId?: string
212
+ boundaryUpdates?: { content: MessageContent }
213
+ }
214
+
215
+ /** Pagination path (an older page arrived via fetchNextPage): everything on
216
+ * screen stays; collect only the messages above the first already-known id,
217
+ * plus a content refresh for that boundary message if it changed. Returns
218
+ * null when there is nothing to apply. */
219
+ export function computeHistoryPrepend<M extends MergeableChatMessage>(
220
+ processedHistory: M[],
221
+ existingMessages: M[],
222
+ ): HistoryPrependResult<M> | null {
223
+ const existingIds = new Set(existingMessages.map((m) => m.id))
224
+ const newMessages: M[] = []
225
+ let boundaryMessageIndex = -1
226
+
227
+ for (let i = 0; i < processedHistory.length; i++) {
228
+ if (existingIds.has(processedHistory[i].id)) {
229
+ boundaryMessageIndex = i
230
+ break
231
+ }
232
+ newMessages.push(processedHistory[i])
233
+ }
234
+
235
+ let boundaryMessageId: string | undefined
236
+ let boundaryUpdates: { content: MessageContent } | undefined
237
+
238
+ if (boundaryMessageIndex >= 0) {
239
+ const boundaryMessage = processedHistory[boundaryMessageIndex]
240
+ const existingBoundary = existingMessages.find((m) => m.id === boundaryMessage.id)
241
+
242
+ if (existingBoundary) {
243
+ const existingContent = JSON.stringify(existingBoundary.content)
244
+ const newContent = JSON.stringify(boundaryMessage.content)
245
+
246
+ if (existingContent !== newContent) {
247
+ boundaryMessageId = boundaryMessage.id
248
+ boundaryUpdates = { content: boundaryMessage.content }
249
+ }
250
+ }
251
+ }
252
+
253
+ if (newMessages.length === 0 && !boundaryUpdates) return null
254
+ return { newMessages, boundaryMessageId, boundaryUpdates }
255
+ }
@@ -31,6 +31,18 @@ export {
31
31
  extractIncompleteMessageState,
32
32
  } from './extract-incomplete-message-state'
33
33
 
34
+ // History <-> realtime reconciliation
35
+ export {
36
+ mergeHistoryWithRealtime,
37
+ computeHistoryPrepend,
38
+ flattenMessagePagesChronological,
39
+ maxPersistedStreamSeq,
40
+ SYNTHETIC_REALTIME_ID_PREFIXES,
41
+ type MergeableChatMessage,
42
+ type HistoryMergeInput,
43
+ type HistoryPrependResult,
44
+ } from './history-merge'
45
+
34
46
  // Tool call helpers
35
47
  export { getCommandText } from './tool-call-helpers'
36
48