@flamingo-stack/openframe-frontend-core 0.0.249 → 0.0.250

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 (58) hide show
  1. package/dist/{chunk-PCXI7ZB2.js → chunk-D2BC3NZ2.js} +135 -30
  2. package/dist/chunk-D2BC3NZ2.js.map +1 -0
  3. package/dist/{chunk-5ZVPKTXM.js → chunk-DZJ53RX6.js} +2 -2
  4. package/dist/{chunk-4EED3YEI.cjs → chunk-NCXHTQH2.cjs} +663 -558
  5. package/dist/chunk-NCXHTQH2.cjs.map +1 -0
  6. package/dist/{chunk-3DZ7QYNA.cjs → chunk-PDKBCCCD.cjs} +11 -11
  7. package/dist/{chunk-3DZ7QYNA.cjs.map → chunk-PDKBCCCD.cjs.map} +1 -1
  8. package/dist/{chunk-LHZYI23D.cjs → chunk-PDYT7T6K.cjs} +32 -29
  9. package/dist/chunk-PDYT7T6K.cjs.map +1 -0
  10. package/dist/{chunk-PVAYXHYF.js → chunk-PSG7H4KG.js} +2 -2
  11. package/dist/{chunk-VAKBNCV6.js → chunk-SGEO3W3F.js} +6 -3
  12. package/dist/chunk-SGEO3W3F.js.map +1 -0
  13. package/dist/{chunk-3VTHSACO.cjs → chunk-UMH4RR2F.cjs} +25 -25
  14. package/dist/{chunk-3VTHSACO.cjs.map → chunk-UMH4RR2F.cjs.map} +1 -1
  15. package/dist/components/chat/chat-message-list.d.ts.map +1 -1
  16. package/dist/components/chat/index.cjs +12 -2
  17. package/dist/components/chat/index.cjs.map +1 -1
  18. package/dist/components/chat/index.js +11 -1
  19. package/dist/components/chat/utils/history-merge.d.ts +92 -0
  20. package/dist/components/chat/utils/history-merge.d.ts.map +1 -0
  21. package/dist/components/chat/utils/index.d.ts +1 -0
  22. package/dist/components/chat/utils/index.d.ts.map +1 -1
  23. package/dist/components/contact/index.cjs +3 -3
  24. package/dist/components/contact/index.js +2 -2
  25. package/dist/components/features/index.cjs +2 -2
  26. package/dist/components/features/index.js +1 -1
  27. package/dist/components/features/policy-configuration-panel.d.ts.map +1 -1
  28. package/dist/components/index.cjs +59 -49
  29. package/dist/components/index.cjs.map +1 -1
  30. package/dist/components/index.js +13 -3
  31. package/dist/components/index.js.map +1 -1
  32. package/dist/components/navigation/index.cjs +2 -2
  33. package/dist/components/navigation/index.js +1 -1
  34. package/dist/components/related-content/index.cjs +3 -3
  35. package/dist/components/related-content/index.js +2 -2
  36. package/dist/components/related-content/related-content-section.d.ts.map +1 -1
  37. package/dist/components/tickets/index.cjs +55 -55
  38. package/dist/components/tickets/index.js +3 -3
  39. package/dist/components/ui/index.cjs +12 -2
  40. package/dist/components/ui/index.cjs.map +1 -1
  41. package/dist/components/ui/index.js +11 -1
  42. package/dist/index.cjs +12 -2
  43. package/dist/index.cjs.map +1 -1
  44. package/dist/index.js +11 -1
  45. package/package.json +1 -1
  46. package/src/components/chat/__tests__/chat-message-list.test.tsx +92 -0
  47. package/src/components/chat/chat-message-list.tsx +74 -49
  48. package/src/components/chat/utils/__tests__/history-merge.test.ts +339 -0
  49. package/src/components/chat/utils/history-merge.ts +255 -0
  50. package/src/components/chat/utils/index.ts +12 -0
  51. package/src/components/features/policy-configuration-panel.tsx +1 -0
  52. package/src/components/related-content/related-content-section.tsx +6 -2
  53. package/dist/chunk-4EED3YEI.cjs.map +0 -1
  54. package/dist/chunk-LHZYI23D.cjs.map +0 -1
  55. package/dist/chunk-PCXI7ZB2.js.map +0 -1
  56. package/dist/chunk-VAKBNCV6.js.map +0 -1
  57. /package/dist/{chunk-5ZVPKTXM.js.map → chunk-DZJ53RX6.js.map} +0 -0
  58. /package/dist/{chunk-PVAYXHYF.js.map → chunk-PSG7H4KG.js.map} +0 -0
@@ -130,6 +130,11 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
130
130
  onLoadMoreRef.current = onLoadMore
131
131
  const isFetchingRef = useRef(isFetchingNextPage)
132
132
  isFetchingRef.current = isFetchingNextPage
133
+ // Fresh dialogId for the IntersectionObserver callback (its effect must
134
+ // not depend on `dialogId` or it would re-create on every dialog switch
135
+ // mid-stream; the ref keeps the snapshot's dialog tag current).
136
+ const dialogIdRef = useRef(dialogId)
137
+ dialogIdRef.current = dialogId
133
138
 
134
139
  // Tracks previous render state for explicit "force-stick" decisions
135
140
  // (dialog change, first message, new user message). The library
@@ -140,15 +145,19 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
140
145
  lastMessageId: string | undefined
141
146
  }>({ dialogId: undefined, messageCount: 0, lastMessageId: undefined })
142
147
 
143
- // Snapshot of the first message id + scrollHeight observed BEFORE
144
- // a load-older prepend, so we can preserve the user's viewport
145
- // position when older messages stream in above their current
146
- // reading position.
147
- const prependRef = useRef<{
148
- firstMessageId: string | undefined
149
- firstMessageContent: unknown
148
+ // Geometry snapshot captured the instant the top sentinel triggers
149
+ // onLoadMore (BEFORE the older page lands), applied once when the taller
150
+ // content commits. Keyed on the LOAD LIFECYCLE, not `messages[0].id`: the
151
+ // history processor groups consecutive assistant turns into one bubble
152
+ // keyed by the group's LAST id, so an older page that is all continuation
153
+ // of the first bubble grows that bubble in place WITHOUT changing its id.
154
+ // An id-based anchor misses that growth and leaves the reader pinned at
155
+ // the top (and the still-visible sentinel never re-fires).
156
+ const loadOlderAnchorRef = useRef<{
157
+ dialogId: string | undefined
150
158
  scrollHeight: number
151
- }>({ firstMessageId: undefined, firstMessageContent: undefined, scrollHeight: 0 })
159
+ scrollTop: number
160
+ } | null>(null)
152
161
 
153
162
  // ---- Force-stick: dialog change / first-load / new user message --
154
163
  useEffect(() => {
@@ -156,6 +165,7 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
156
165
 
157
166
  const dialogChanged = dialogId !== prevRenderRef.current.dialogId
158
167
  const prevCount = prevRenderRef.current.messageCount
168
+ const prevLastId = prevRenderRef.current.lastMessageId
159
169
  const newCount = messages.length
160
170
  const currentLastId = messages[messages.length - 1]?.id
161
171
  prevRenderRef.current = { dialogId, messageCount: newCount, lastMessageId: currentLastId }
@@ -177,7 +187,18 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
177
187
  // user message DID arrive). When the user just sent a
178
188
  // message, they expect their bubble pinned to the bottom,
179
189
  // regardless of where they were scrolled.
180
- const newSlice = messages.slice(prevCount)
190
+ //
191
+ // The tail must be located via the previous LAST message id,
192
+ // not `slice(prevCount)`: when load-older PREPENDS a page,
193
+ // count grows but the new messages are at the HEAD —
194
+ // `slice(prevCount)` would return already-rendered tail
195
+ // messages (which contain user bubbles) and force-scroll the
196
+ // reader away from the older page they just loaded. With the
197
+ // id-based slice a pure prepend yields an empty tail → no
198
+ // scroll, and the prepend-anchoring effect below keeps their
199
+ // viewport position.
200
+ const prevLastIdx = prevLastId ? messages.findIndex((m) => m.id === prevLastId) : -1
201
+ const newSlice = prevLastIdx >= 0 ? messages.slice(prevLastIdx + 1) : messages.slice(prevCount)
181
202
  const hasNewUser = newSlice.some((m) => m.role === 'user')
182
203
  if (hasNewUser) {
183
204
  void scrollToBottom({ animation: 'instant', ignoreEscapes: true })
@@ -230,49 +251,35 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
230
251
  }, [autoScroll, messages, dialogId, scrollToBottom, contentRef])
231
252
 
232
253
  // ---- Prepend anchoring (load-older) ------------------------------
233
- // The library doesn't preserve user position when content prepends
234
- // ABOVE the viewport. We do it ourselves: snapshot scrollHeight
235
- // BEFORE the new render, then add the delta after to keep the
236
- // user on the same message they were reading. `useLayoutEffect`
237
- // so the scrollTop write lands before paint (no visible jump).
254
+ // The library doesn't preserve user position when content grows ABOVE
255
+ // the viewport. We do: from the pre-load geometry snapshot (taken in the
256
+ // sentinel callback), add the height the list gained and re-pin scrollTop
257
+ // so the message the user was reading stays put. `useLayoutEffect` so the
258
+ // write lands before paint (no visible jump). Covers both a pure prepend
259
+ // (new ids on top) and a boundary-merge (first bubble grows in place) —
260
+ // it measures total height delta, not message identity.
238
261
  useLayoutEffect(() => {
239
262
  const el = scrollRef.current
240
- if (!el) {
241
- prependRef.current = { firstMessageId: undefined, firstMessageContent: undefined, scrollHeight: 0 }
242
- return
243
- }
244
-
245
- const currentFirstId = messages[0]?.id
246
- const currentFirstContent = messages[0]?.content
247
- const prevFirstId = prependRef.current.firstMessageId
248
- const prevHeight = prependRef.current.scrollHeight
249
-
250
- if (currentFirstId !== prevFirstId) {
251
- // First-id changed → older messages prepended (verified by
252
- // confirming the previous-first is still in the array at
253
- // idx > 0; otherwise this is some other mutation).
254
- if (prevFirstId && prevHeight > 0) {
255
- const prevIdx = messages.findIndex((m) => m.id === prevFirstId)
256
- if (prevIdx > 0) {
257
- const addedHeight = el.scrollHeight - prevHeight
258
- if (addedHeight > 0) el.scrollTop += addedHeight
259
- }
260
- }
261
- prependRef.current = {
262
- firstMessageId: currentFirstId,
263
- firstMessageContent: currentFirstContent,
264
- scrollHeight: el.scrollHeight,
265
- }
263
+ const anchor = loadOlderAnchorRef.current
264
+ if (!el || !anchor) return
265
+ // Dialog switched out from under a pending load — abandon; the
266
+ // force-stick effect owns the new dialog's position.
267
+ if (anchor.dialogId !== dialogId) {
268
+ loadOlderAnchorRef.current = null
266
269
  return
267
270
  }
268
- // Always keep `scrollHeight` snapshot fresh so the next prepend
269
- // computes the right delta. The id/content updates only when
270
- // those actually change to avoid churn.
271
- prependRef.current.scrollHeight = el.scrollHeight
272
- if (currentFirstContent !== prependRef.current.firstMessageContent) {
273
- prependRef.current.firstMessageContent = currentFirstContent
274
- }
275
- }, [messages, scrollRef])
271
+ // Hold the anchor across the intermediate renders between firing
272
+ // onLoadMore and the older page committing (the isFetchingNextPage
273
+ // flip, and in some hosts a two-stage store update) — those don't grow
274
+ // the list. Apply on the first render where it actually grew taller,
275
+ // which is the prepend (new ids on top) or a boundary-merge (first
276
+ // bubble grew in place). Same-id boundary-merge is why this can't key
277
+ // on message count.
278
+ const addedHeight = el.scrollHeight - anchor.scrollHeight
279
+ if (addedHeight <= 0) return
280
+ loadOlderAnchorRef.current = null
281
+ el.scrollTop = anchor.scrollTop + addedHeight
282
+ }, [messages, dialogId, scrollRef])
276
283
 
277
284
  // ---- Per-message scrollAnchor (server-driven) --------------------
278
285
  // Server emits `scrollAnchor: 'top'` on the metadata leading frame
@@ -382,6 +389,14 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
382
389
  // ---- Load-more (infinite-scroll UP) ------------------------------
383
390
  // Distinct from stick-to-bottom; uses its own IntersectionObserver
384
391
  // on the top sentinel.
392
+ //
393
+ // `isFetchingNextPage` is in the deps so the observer is RE-CREATED when a
394
+ // fetch completes: IntersectionObserver fires no new callback while an
395
+ // element stays continuously intersecting, so after an older page lands
396
+ // with the sentinel still in view (small prepend, or rootMargin overlap)
397
+ // nothing would re-trigger until the user scrolled it out and back in.
398
+ // Re-observing re-evaluates the current intersection and fires again if
399
+ // the sentinel is still visible — load continues until it's pushed out.
385
400
  useEffect(() => {
386
401
  const scrollContainer = scrollRef.current
387
402
  const sentinelElement = sentinelRef.current
@@ -392,6 +407,16 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
392
407
  const entry = entries[0]
393
408
  if (!entry) return
394
409
  if (entry.isIntersecting && !isFetchingRef.current) {
410
+ // Snapshot pre-load geometry so the prepend-anchoring layout
411
+ // effect can re-pin scrollTop once the taller content commits.
412
+ const el = scrollRef.current
413
+ if (el) {
414
+ loadOlderAnchorRef.current = {
415
+ dialogId: dialogIdRef.current,
416
+ scrollHeight: el.scrollHeight,
417
+ scrollTop: el.scrollTop,
418
+ }
419
+ }
395
420
  onLoadMoreRef.current?.()
396
421
  }
397
422
  },
@@ -399,7 +424,7 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
399
424
  )
400
425
  observer.observe(sentinelElement)
401
426
  return () => observer.disconnect()
402
- }, [hasNextPage, scrollRef])
427
+ }, [hasNextPage, isFetchingNextPage, scrollRef])
403
428
 
404
429
  // Expose the scroll container ref to parents that need it (rare,
405
430
  // but the existing public contract). Library's `scrollRef` is a
@@ -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
+ })