@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.
- package/dist/{chunk-MKVKVJQQ.js → chunk-APGMNHPY.js} +19 -32
- package/dist/chunk-APGMNHPY.js.map +1 -0
- package/dist/{chunk-3TDLFCCS.cjs → chunk-DV3YZWGN.cjs} +20 -33
- package/dist/chunk-DV3YZWGN.cjs.map +1 -0
- package/dist/{chunk-BKPS6M2F.cjs → chunk-EJ7J4QAD.cjs} +12 -12
- package/dist/{chunk-BKPS6M2F.cjs.map → chunk-EJ7J4QAD.cjs.map} +1 -1
- package/dist/{chunk-CZITXP77.cjs → chunk-EPXROXYC.cjs} +713 -645
- package/dist/chunk-EPXROXYC.cjs.map +1 -0
- package/dist/{chunk-SGJBUL2Y.cjs → chunk-FSS2MIEA.cjs} +3 -20
- package/dist/chunk-FSS2MIEA.cjs.map +1 -0
- package/dist/{chunk-VAUI3CND.js → chunk-LJGBFD52.js} +1 -18
- package/dist/chunk-LJGBFD52.js.map +1 -0
- package/dist/{chunk-FRRMXLU5.js → chunk-M7DPUWAL.js} +188 -120
- package/dist/chunk-M7DPUWAL.js.map +1 -0
- package/dist/{chunk-SEVJT2HP.js → chunk-NKGNRFSS.js} +8 -8
- package/dist/{chunk-VFKQMAUF.cjs → chunk-OB45JHDY.cjs} +3 -3
- package/dist/{chunk-VFKQMAUF.cjs.map → chunk-OB45JHDY.cjs.map} +1 -1
- package/dist/{chunk-4XLJWX2N.js → chunk-ORJREQ2W.js} +4 -4
- package/dist/{chunk-7ZHFCEJH.js → chunk-P5C732SC.js} +6 -6
- package/dist/{chunk-JJV3XGCY.js → chunk-PTSAF7T2.js} +2 -2
- package/dist/{chunk-VXP553WT.cjs → chunk-RJC3L5DT.cjs} +7 -7
- package/dist/{chunk-VXP553WT.cjs.map → chunk-RJC3L5DT.cjs.map} +1 -1
- package/dist/{chunk-MP5HOP7N.cjs → chunk-STKMIMLP.cjs} +12 -12
- package/dist/{chunk-MP5HOP7N.cjs.map → chunk-STKMIMLP.cjs.map} +1 -1
- package/dist/{chunk-V35PZSUA.js → chunk-WD2MZVJI.js} +5 -5
- package/dist/{chunk-DUZBTUXF.cjs → chunk-Z6GGYZDR.cjs} +27 -27
- package/dist/{chunk-DUZBTUXF.cjs.map → chunk-Z6GGYZDR.cjs.map} +1 -1
- package/dist/components/chat/chat-message-list.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-chat-card-item.d.ts.map +1 -1
- package/dist/components/chat/index.cjs +16 -6
- package/dist/components/chat/index.cjs.map +1 -1
- package/dist/components/chat/index.js +15 -5
- package/dist/components/chat/types/entities/content-ref.d.ts +20 -5
- package/dist/components/chat/types/entities/content-ref.d.ts.map +1 -1
- package/dist/components/chat/utils/history-merge.d.ts +92 -0
- package/dist/components/chat/utils/history-merge.d.ts.map +1 -0
- package/dist/components/chat/utils/index.d.ts +1 -0
- package/dist/components/chat/utils/index.d.ts.map +1 -1
- package/dist/components/contact/index.cjs +7 -7
- package/dist/components/contact/index.js +6 -6
- package/dist/components/faq/faq-section.d.ts +1 -7
- package/dist/components/faq/faq-section.d.ts.map +1 -1
- package/dist/components/faq/index.cjs +3 -3
- package/dist/components/faq/index.js +2 -2
- package/dist/components/features/index.cjs +6 -6
- package/dist/components/features/index.js +5 -5
- package/dist/components/features/policy-configuration-panel.d.ts.map +1 -1
- package/dist/components/index.cjs +93 -87
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.d.ts +0 -1
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +37 -31
- package/dist/components/index.js.map +1 -1
- package/dist/components/navigation/index.cjs +6 -6
- package/dist/components/navigation/index.js +5 -5
- package/dist/components/onboarding-guides/index.cjs +30 -30
- package/dist/components/onboarding-guides/index.cjs.map +1 -1
- package/dist/components/onboarding-guides/index.js +11 -11
- package/dist/components/tickets/index.cjs +73 -73
- package/dist/components/tickets/index.cjs.map +1 -1
- package/dist/components/tickets/index.js +15 -15
- package/dist/components/ui/index.cjs +19 -9
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.js +23 -13
- package/dist/embed-shims/index.cjs +3 -3
- package/dist/embed-shims/index.cjs.map +1 -1
- package/dist/embed-shims/index.js +5 -5
- package/dist/index.cjs +11 -17
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +49 -55
- package/dist/types/index.d.ts +0 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utils/index.cjs +0 -81
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.d.ts +1 -4
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +1 -74
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/list-url.d.ts +0 -6
- package/dist/utils/list-url.d.ts.map +1 -1
- package/package.json +1 -7
- package/src/components/chat/__tests__/chat-message-list.test.tsx +92 -0
- package/src/components/chat/chat-message-list.tsx +74 -49
- package/src/components/chat/hooks/use-chat-card-item.ts +38 -3
- package/src/components/chat/types/entities/content-ref.ts +21 -5
- package/src/components/chat/utils/__tests__/history-merge.test.ts +339 -0
- package/src/components/chat/utils/history-merge.ts +255 -0
- package/src/components/chat/utils/index.ts +12 -0
- package/src/components/faq/faq-section.tsx +24 -69
- package/src/components/features/policy-configuration-panel.tsx +1 -0
- package/src/components/index.ts +0 -1
- package/src/types/index.ts +0 -1
- package/src/utils/index.ts +1 -16
- package/src/utils/list-url.ts +0 -9
- package/dist/chunk-3TDLFCCS.cjs.map +0 -1
- package/dist/chunk-CZITXP77.cjs.map +0 -1
- package/dist/chunk-F3GMWNAT.js +0 -285
- package/dist/chunk-F3GMWNAT.js.map +0 -1
- package/dist/chunk-FRRMXLU5.js.map +0 -1
- package/dist/chunk-HU252PYJ.cjs +0 -285
- package/dist/chunk-HU252PYJ.cjs.map +0 -1
- package/dist/chunk-MKVKVJQQ.js.map +0 -1
- package/dist/chunk-SGJBUL2Y.cjs.map +0 -1
- package/dist/chunk-VAUI3CND.js.map +0 -1
- package/dist/components/related-content/index.cjs +0 -26
- package/dist/components/related-content/index.cjs.map +0 -1
- package/dist/components/related-content/index.d.ts +0 -2
- package/dist/components/related-content/index.d.ts.map +0 -1
- package/dist/components/related-content/index.js +0 -26
- package/dist/components/related-content/index.js.map +0 -1
- package/dist/components/related-content/related-content-section.d.ts +0 -141
- package/dist/components/related-content/related-content-section.d.ts.map +0 -1
- package/dist/types/content-ref.d.ts +0 -35
- package/dist/types/content-ref.d.ts.map +0 -1
- package/dist/utils/content-ref-groups.d.ts +0 -58
- package/dist/utils/content-ref-groups.d.ts.map +0 -1
- package/dist/utils/extract-items.d.ts +0 -18
- package/dist/utils/extract-items.d.ts.map +0 -1
- package/dist/utils/suggestion-url.d.ts +0 -26
- package/dist/utils/suggestion-url.d.ts.map +0 -1
- package/src/components/related-content/index.ts +0 -9
- package/src/components/related-content/related-content-section.tsx +0 -612
- package/src/types/content-ref.ts +0 -34
- package/src/utils/content-ref-groups.ts +0 -89
- package/src/utils/extract-items.ts +0 -48
- package/src/utils/suggestion-url.ts +0 -39
- /package/dist/{chunk-SEVJT2HP.js.map → chunk-NKGNRFSS.js.map} +0 -0
- /package/dist/{chunk-4XLJWX2N.js.map → chunk-ORJREQ2W.js.map} +0 -0
- /package/dist/{chunk-7ZHFCEJH.js.map → chunk-P5C732SC.js.map} +0 -0
- /package/dist/{chunk-JJV3XGCY.js.map → chunk-PTSAF7T2.js.map} +0 -0
- /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
|
|