@flamingo-stack/openframe-frontend-core 0.0.447 → 0.0.448
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-2PNWAAU4.cjs → chunk-3RM5Z2SH.cjs} +59 -59
- package/dist/{chunk-2PNWAAU4.cjs.map → chunk-3RM5Z2SH.cjs.map} +1 -1
- package/dist/{chunk-ITJMUH4Q.cjs → chunk-5Z3YOBRE.cjs} +31 -31
- package/dist/{chunk-ITJMUH4Q.cjs.map → chunk-5Z3YOBRE.cjs.map} +1 -1
- package/dist/{chunk-PF6CKZIH.cjs → chunk-7YSVW2JG.cjs} +5 -5
- package/dist/{chunk-PF6CKZIH.cjs.map → chunk-7YSVW2JG.cjs.map} +1 -1
- package/dist/{chunk-SHC5NFOH.js → chunk-AEPR6AXO.js} +2 -2
- package/dist/{chunk-4ONE3HQP.js → chunk-AMS55EES.js} +2 -2
- package/dist/{chunk-3GBLPPSW.js → chunk-ANJ56NCM.js} +4 -4
- package/dist/{chunk-ZQVEB6KT.js → chunk-CAEQ5BQO.js} +4 -4
- package/dist/{chunk-KYLOPUIH.cjs → chunk-CCSCZSXS.cjs} +26 -26
- package/dist/{chunk-KYLOPUIH.cjs.map → chunk-CCSCZSXS.cjs.map} +1 -1
- package/dist/{chunk-FR6NVZVM.cjs → chunk-HKMQ3LHY.cjs} +11 -11
- package/dist/{chunk-FR6NVZVM.cjs.map → chunk-HKMQ3LHY.cjs.map} +1 -1
- package/dist/{chunk-XUS75SQW.cjs → chunk-JM5VSYQB.cjs} +15 -15
- package/dist/{chunk-XUS75SQW.cjs.map → chunk-JM5VSYQB.cjs.map} +1 -1
- package/dist/{chunk-TMZYFYSS.js → chunk-KP2E22VP.js} +4 -4
- package/dist/{chunk-RNXMP7GB.cjs → chunk-M2U5KN4Q.cjs} +73 -13
- package/dist/chunk-M2U5KN4Q.cjs.map +1 -0
- package/dist/{chunk-S7VMUDUB.cjs → chunk-NQTASSKI.cjs} +9 -9
- package/dist/{chunk-S7VMUDUB.cjs.map → chunk-NQTASSKI.cjs.map} +1 -1
- package/dist/{chunk-E6HKLQ3Q.cjs → chunk-P62PIQSY.cjs} +7 -7
- package/dist/{chunk-E6HKLQ3Q.cjs.map → chunk-P62PIQSY.cjs.map} +1 -1
- package/dist/{chunk-G7QSLGMI.js → chunk-PFRUS5M4.js} +2 -2
- package/dist/{chunk-ZYB3BYBA.js → chunk-PHH7M3CF.js} +2 -2
- package/dist/{chunk-6TAIXSF5.js → chunk-PJ27XJRQ.js} +2 -2
- package/dist/{chunk-YCSNDUDE.js → chunk-QJN4XSP4.js} +73 -13
- package/dist/{chunk-YCSNDUDE.js.map → chunk-QJN4XSP4.js.map} +1 -1
- package/dist/{chunk-5BJJRPDH.js → chunk-T245XRIS.js} +2 -2
- package/dist/{chunk-X4XHW3OV.cjs → chunk-TGEAT3G4.cjs} +37 -37
- package/dist/{chunk-X4XHW3OV.cjs.map → chunk-TGEAT3G4.cjs.map} +1 -1
- package/dist/{chunk-WMOF3WQT.js → chunk-UAOR2SRY.js} +4 -4
- package/dist/{chunk-WLU37UZO.cjs → chunk-X62WEO5Z.cjs} +62 -62
- package/dist/{chunk-WLU37UZO.cjs.map → chunk-X62WEO5Z.cjs.map} +1 -1
- package/dist/{chunk-EZMM2OMP.cjs → chunk-ZGERD4OP.cjs} +26 -26
- package/dist/{chunk-EZMM2OMP.cjs.map → chunk-ZGERD4OP.cjs.map} +1 -1
- package/dist/{chunk-D2NLNBLH.js → chunk-ZRYCV52Q.js} +2 -2
- package/dist/components/case-studies/index.cjs +8 -8
- package/dist/components/case-studies/index.js +2 -2
- package/dist/components/chat/index.cjs +2 -2
- package/dist/components/chat/index.js +1 -1
- package/dist/components/chat/types/message.types.d.ts +13 -0
- package/dist/components/chat/types/message.types.d.ts.map +1 -1
- package/dist/components/chat/utils/history-merge.d.ts +14 -4
- package/dist/components/chat/utils/history-merge.d.ts.map +1 -1
- package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
- package/dist/components/contact/index.cjs +3 -3
- package/dist/components/contact/index.js +2 -2
- package/dist/components/docs/index.cjs +5 -5
- package/dist/components/docs/index.js +4 -4
- package/dist/components/embeds/index.cjs +3 -3
- package/dist/components/embeds/index.js +2 -2
- package/dist/components/faq/index.cjs +3 -3
- package/dist/components/faq/index.js +2 -2
- package/dist/components/features/index.cjs +2 -2
- package/dist/components/features/index.js +1 -1
- package/dist/components/help-center-pages/index.cjs +20 -20
- package/dist/components/help-center-pages/index.js +11 -11
- package/dist/components/index.cjs +129 -129
- package/dist/components/index.js +9 -9
- package/dist/components/navigation/index.cjs +2 -2
- package/dist/components/navigation/index.js +1 -1
- package/dist/components/onboarding-guides/index.cjs +5 -5
- package/dist/components/onboarding-guides/index.js +4 -4
- package/dist/components/related-content/index.cjs +3 -3
- package/dist/components/related-content/index.js +2 -2
- package/dist/components/tickets/index.cjs +5 -5
- package/dist/components/tickets/index.js +4 -4
- package/dist/components/ui/index.cjs +2 -2
- package/dist/components/ui/index.js +1 -1
- package/dist/index.cjs +2 -2
- package/dist/index.js +1 -1
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/chat/types/message.types.ts +13 -0
- package/src/components/chat/utils/__tests__/history-merge.test.ts +301 -0
- package/src/components/chat/utils/__tests__/process-historical-messages-approvals.test.ts +35 -0
- package/src/components/chat/utils/history-merge.ts +157 -14
- package/src/components/chat/utils/process-historical-messages.ts +45 -6
- package/dist/chunk-RNXMP7GB.cjs.map +0 -1
- /package/dist/{chunk-SHC5NFOH.js.map → chunk-AEPR6AXO.js.map} +0 -0
- /package/dist/{chunk-4ONE3HQP.js.map → chunk-AMS55EES.js.map} +0 -0
- /package/dist/{chunk-3GBLPPSW.js.map → chunk-ANJ56NCM.js.map} +0 -0
- /package/dist/{chunk-ZQVEB6KT.js.map → chunk-CAEQ5BQO.js.map} +0 -0
- /package/dist/{chunk-TMZYFYSS.js.map → chunk-KP2E22VP.js.map} +0 -0
- /package/dist/{chunk-G7QSLGMI.js.map → chunk-PFRUS5M4.js.map} +0 -0
- /package/dist/{chunk-ZYB3BYBA.js.map → chunk-PHH7M3CF.js.map} +0 -0
- /package/dist/{chunk-6TAIXSF5.js.map → chunk-PJ27XJRQ.js.map} +0 -0
- /package/dist/{chunk-5BJJRPDH.js.map → chunk-T245XRIS.js.map} +0 -0
- /package/dist/{chunk-WMOF3WQT.js.map → chunk-UAOR2SRY.js.map} +0 -0
- /package/dist/{chunk-D2NLNBLH.js.map → chunk-ZRYCV52Q.js.map} +0 -0
package/package.json
CHANGED
|
@@ -318,6 +318,13 @@ export interface HistoricalMessage {
|
|
|
318
318
|
createdAt: string
|
|
319
319
|
owner?: MessageOwner
|
|
320
320
|
messageData?: MessageData | MessageData[]
|
|
321
|
+
/** Persisted stream sequence of this row's last chunk (the backend's
|
|
322
|
+
* `lastChunkStreamSeq`). Passed through to the processed message's
|
|
323
|
+
* `streamSeq` so `mergeHistoryWithRealtime` can decide coverage per-role
|
|
324
|
+
* (a synthetic is covered only by a persisted row of its OWN role reaching
|
|
325
|
+
* its seq). Optional — absent for rows the backend doesn't stamp (e.g. user
|
|
326
|
+
* MESSAGE_REQUEST rows), which then don't participate in seq coverage. */
|
|
327
|
+
lastChunkStreamSeq?: number | null
|
|
321
328
|
}
|
|
322
329
|
|
|
323
330
|
// ========== Processed Message Types ==========
|
|
@@ -331,6 +338,12 @@ export interface ProcessedMessage {
|
|
|
331
338
|
authorType?: AuthorType
|
|
332
339
|
timestamp: Date
|
|
333
340
|
avatar?: string
|
|
341
|
+
/** Persisted last-chunk stream sequence carried through from
|
|
342
|
+
* `HistoricalMessage.lastChunkStreamSeq` (for assistant turns: the MAX
|
|
343
|
+
* across the grouped rows). Hosts stamp it onto the rendered message's
|
|
344
|
+
* `streamSeq` so the history/realtime merge can do per-role seq coverage.
|
|
345
|
+
* Absent when the source row(s) carried no seq. */
|
|
346
|
+
streamSeq?: number
|
|
334
347
|
}
|
|
335
348
|
|
|
336
349
|
// ========== Base Message Interface ==========
|
|
@@ -19,6 +19,11 @@ const batchSeg = {
|
|
|
19
19
|
type: 'approval_batch',
|
|
20
20
|
data: { approvalRequestId: 'req-9', toolCalls: [] },
|
|
21
21
|
} as unknown as MessageSegment
|
|
22
|
+
const tool = (id: string, state: 'EXECUTING_TOOL' | 'EXECUTED_TOOL'): MessageSegment =>
|
|
23
|
+
({
|
|
24
|
+
type: 'tool_execution',
|
|
25
|
+
data: { type: state, integratedToolType: 'sqlite', toolFunction: 'query', toolExecutionRequestId: id },
|
|
26
|
+
}) as unknown as MessageSegment
|
|
22
27
|
|
|
23
28
|
// Persisted history (Mongo ids), first fetched at t=1000
|
|
24
29
|
const U0: TestMessage = { id: 'aaaa0001', role: 'user', content: 'first question', timestamp: t(500) }
|
|
@@ -226,6 +231,148 @@ describe('mergeHistoryWithRealtime', () => {
|
|
|
226
231
|
expect(ids(merged)).toContain(repeat.id)
|
|
227
232
|
})
|
|
228
233
|
|
|
234
|
+
it('drops a replayed `user-` synthetic against a COMPLETED persisted turn (missing user seq)', () => {
|
|
235
|
+
// Repro of the tickets client→viewer duplicate: the backend persists the
|
|
236
|
+
// user MESSAGE_REQUEST row without a lastChunkStreamSeq, so JetStream
|
|
237
|
+
// replays the user chunk and mints a fresh `user-` synthetic that neither
|
|
238
|
+
// seq coverage nor wall-clock can drop. Its persisted twin (U1) is
|
|
239
|
+
// followed by the assistant turn (A1) — a completed turn — so the replay
|
|
240
|
+
// is recognised by content and dropped. The assistant replay is collapsed
|
|
241
|
+
// by the assistant content-fallback, matching "user twice, Fae once".
|
|
242
|
+
const userSynthetic: TestMessage = { id: 'user-9000-x', role: 'user', content: 'second question', timestamp: t(9000) }
|
|
243
|
+
const asstSynthetic: TestMessage = { id: 'assistant-9100-x', role: 'assistant', content: txt('second answer'), timestamp: t(9100) }
|
|
244
|
+
const merged = mergeHistoryWithRealtime({
|
|
245
|
+
processedHistory: [U0, A0, U1, A1],
|
|
246
|
+
existingMessages: [U0, A0, U1, A1, userSynthetic, asstSynthetic],
|
|
247
|
+
streamingMessageId: null,
|
|
248
|
+
historyFetchedAt: 5000,
|
|
249
|
+
})
|
|
250
|
+
expect(ids(merged)).toEqual([U0.id, A0.id, U1.id, A1.id])
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('dedupes a `user-` synthetic against its persisted twin even when that twin is the TRAILING row', () => {
|
|
254
|
+
// Client→viewer duplicate at the START of a conversation: the user row is
|
|
255
|
+
// persisted (as the trailing history row — the assistant reply is not saved
|
|
256
|
+
// yet) and the same MESSAGE_REQUEST is replayed as a `user-` synthetic. The
|
|
257
|
+
// persisted row renders the message, so the synthetic must be dropped —
|
|
258
|
+
// keeping it (the old trailing-twin carve-out) IS the reported duplicate.
|
|
259
|
+
const replay: TestMessage = { id: 'user-9000-x', role: 'user', content: 'third question', timestamp: t(9000) }
|
|
260
|
+
const trailingPersisted: TestMessage = { id: 'aaaa0006', role: 'user', content: 'third question', timestamp: t(3000) }
|
|
261
|
+
const merged = mergeHistoryWithRealtime({
|
|
262
|
+
processedHistory: [U0, A0, U1, A1, trailingPersisted],
|
|
263
|
+
existingMessages: [U0, A0, U1, A1, trailingPersisted, replay],
|
|
264
|
+
streamingMessageId: null,
|
|
265
|
+
historyFetchedAt: 5000,
|
|
266
|
+
})
|
|
267
|
+
expect(ids(merged)).toEqual([U0.id, A0.id, U1.id, A1.id, trailingPersisted.id])
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('keeps a LATER same-text `user-` synthetic whose own row is not persisted yet (positional match)', () => {
|
|
271
|
+
// Two identical "continue" turns: the first is persisted, the second was
|
|
272
|
+
// just sent and its row hasn't landed. Positional content matching covers
|
|
273
|
+
// the first synthetic with the one persisted row and KEEPS the second — the
|
|
274
|
+
// old first-match dedup matched the second against the first's row and
|
|
275
|
+
// dropped it, losing the message (the reported "user message lost" repro).
|
|
276
|
+
const persistedFirst: TestMessage = { id: 'aaaa0006', role: 'user', content: 'continue', timestamp: t(3000) }
|
|
277
|
+
const asstFirst: TestMessage = { id: 'aaaa0007', role: 'assistant', content: txt('ok one'), timestamp: t(3100) }
|
|
278
|
+
const synFirst: TestMessage = { id: 'user-4000-a', role: 'user', content: 'continue', timestamp: t(4000) }
|
|
279
|
+
const synSecond: TestMessage = { id: 'user-5000-b', role: 'user', content: 'continue', timestamp: t(5000) }
|
|
280
|
+
const merged = mergeHistoryWithRealtime({
|
|
281
|
+
processedHistory: [U0, A0, persistedFirst, asstFirst],
|
|
282
|
+
existingMessages: [U0, A0, persistedFirst, asstFirst, synFirst, synSecond],
|
|
283
|
+
streamingMessageId: null,
|
|
284
|
+
historyFetchedAt: 6000,
|
|
285
|
+
})
|
|
286
|
+
// Only one persisted "continue" → synFirst covered/dropped, synSecond kept.
|
|
287
|
+
expect(ids(merged)).toEqual([U0.id, A0.id, persistedFirst.id, asstFirst.id, synSecond.id])
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it('KEEPS a `user-` synthetic when a seq-stamped ASSISTANT row raised the global max but the user turn is not persisted', () => {
|
|
291
|
+
// The reload+stop loss: history has a seq-stamped assistant row (so
|
|
292
|
+
// anyHistoryRowSeq is true and the global max is high) and a live `user-`
|
|
293
|
+
// message arrived whose own row the backend persists WITHOUT a seq and
|
|
294
|
+
// hasn't yet. The assistant's seq must NOT cover it — content dedup finds no
|
|
295
|
+
// twin, so it survives. (On the pre-fix code the `user-` role max was 0 and
|
|
296
|
+
// the merge fell back to the global coverage, dropping it.)
|
|
297
|
+
const histAsst: TestMessage = { id: 'aaaa0501', role: 'assistant', content: txt('older answer'), timestamp: t(2100), streamSeq: 200 }
|
|
298
|
+
const liveUser: TestMessage = { id: 'user-9000-x', role: 'user', content: 'continue', timestamp: t(9000), streamSeq: 150 }
|
|
299
|
+
const merged = mergeHistoryWithRealtime<TestMessage>({
|
|
300
|
+
processedHistory: [U0, histAsst],
|
|
301
|
+
existingMessages: [U0, histAsst, liveUser],
|
|
302
|
+
streamingMessageId: null,
|
|
303
|
+
historyFetchedAt: 5000,
|
|
304
|
+
historyMaxStreamSeq: 200,
|
|
305
|
+
realtimeSeenStreamSeq: 200,
|
|
306
|
+
})
|
|
307
|
+
expect(ids(merged)).toContain(liveUser.id)
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
it('keeps a `user-` synthetic when no persisted twin exists yet (persistence lag)', () => {
|
|
311
|
+
const pending: TestMessage = { id: 'user-9000-x', role: 'user', content: 'brand new question', timestamp: t(9000) }
|
|
312
|
+
const merged = mergeHistoryWithRealtime({
|
|
313
|
+
processedHistory: [U0, A0],
|
|
314
|
+
existingMessages: [U0, A0, pending],
|
|
315
|
+
streamingMessageId: null,
|
|
316
|
+
historyFetchedAt: 5000,
|
|
317
|
+
})
|
|
318
|
+
expect(ids(merged)).toContain(pending.id)
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it('KEEPS a repeated `user-` message whose streamSeq is ahead of history (new send after a long tool turn)', () => {
|
|
322
|
+
// The "many tools + reload, message from the client chat disappears" repro:
|
|
323
|
+
// an earlier "continue" is persisted and answered (a long tool turn pushed
|
|
324
|
+
// historyMaxStreamSeq to 200), then the user sends "continue" AGAIN. The new
|
|
325
|
+
// synthetic's streamSeq (250) is ahead of everything persisted → it is a NEW
|
|
326
|
+
// message, not a replay of the old turn. Positional content match alone
|
|
327
|
+
// would drop it against the old "continue"; the seq watermark keeps it.
|
|
328
|
+
const oldUser: TestMessage = { id: 'aaaa0801', role: 'user', content: 'continue', timestamp: t(2000) }
|
|
329
|
+
const oldAsst: TestMessage = { id: 'aaaa0802', role: 'assistant', content: txt('ok one'), timestamp: t(2100), streamSeq: 200 }
|
|
330
|
+
const newSend: TestMessage = { id: 'user-9000-x', role: 'user', content: 'continue', timestamp: t(9000), streamSeq: 250 }
|
|
331
|
+
const merged = mergeHistoryWithRealtime<TestMessage>({
|
|
332
|
+
processedHistory: [oldUser, oldAsst],
|
|
333
|
+
existingMessages: [oldUser, oldAsst, newSend],
|
|
334
|
+
streamingMessageId: null,
|
|
335
|
+
historyFetchedAt: 5000,
|
|
336
|
+
historyMaxStreamSeq: 200,
|
|
337
|
+
realtimeSeenStreamSeq: 250,
|
|
338
|
+
})
|
|
339
|
+
expect(ids(merged)).toContain(newSend.id)
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
it('DROPS a replayed `user-` message whose streamSeq history already passed', () => {
|
|
343
|
+
// A replay of the OLD "continue" (streamSeq 150) after history persisted the
|
|
344
|
+
// turn past it (200): its own row is in the snapshot → drop the duplicate.
|
|
345
|
+
const oldUser: TestMessage = { id: 'aaaa0811', role: 'user', content: 'continue', timestamp: t(2000) }
|
|
346
|
+
const oldAsst: TestMessage = { id: 'aaaa0812', role: 'assistant', content: txt('ok one'), timestamp: t(2100), streamSeq: 200 }
|
|
347
|
+
const replay: TestMessage = { id: 'user-9000-x', role: 'user', content: 'continue', timestamp: t(9000), streamSeq: 150 }
|
|
348
|
+
const merged = mergeHistoryWithRealtime<TestMessage>({
|
|
349
|
+
processedHistory: [oldUser, oldAsst],
|
|
350
|
+
existingMessages: [oldUser, oldAsst, replay],
|
|
351
|
+
streamingMessageId: null,
|
|
352
|
+
historyFetchedAt: 5000,
|
|
353
|
+
historyMaxStreamSeq: 200,
|
|
354
|
+
realtimeSeenStreamSeq: 200,
|
|
355
|
+
})
|
|
356
|
+
expect(ids(merged)).toEqual([oldUser.id, oldAsst.id])
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
it('DROPS a `user-` echo of a just-sent TRAILING message even when history is behind its seq', () => {
|
|
360
|
+
// Start of a turn: the just-sent "hello" is the trailing history row (no
|
|
361
|
+
// reply yet, so historyMaxStreamSeq is behind its seq). Its own row is right
|
|
362
|
+
// there → drop the echo, don't render "hello" twice.
|
|
363
|
+
const justSent: TestMessage = { id: 'aaaa0821', role: 'user', content: 'hello', timestamp: t(3000) }
|
|
364
|
+
const echo: TestMessage = { id: 'user-9000-x', role: 'user', content: 'hello', timestamp: t(9000), streamSeq: 300 }
|
|
365
|
+
const merged = mergeHistoryWithRealtime<TestMessage>({
|
|
366
|
+
processedHistory: [U0, A0, justSent],
|
|
367
|
+
existingMessages: [U0, A0, justSent, echo],
|
|
368
|
+
streamingMessageId: null,
|
|
369
|
+
historyFetchedAt: 5000,
|
|
370
|
+
historyMaxStreamSeq: 100,
|
|
371
|
+
realtimeSeenStreamSeq: 300,
|
|
372
|
+
})
|
|
373
|
+
expect(ids(merged)).toEqual([U0.id, A0.id, justSent.id])
|
|
374
|
+
})
|
|
375
|
+
|
|
229
376
|
it('pins the streaming twin even when it carries a persisted history id (adoption path)', () => {
|
|
230
377
|
// Chunk processors ADOPT an in-progress trailing assistant after a prior
|
|
231
378
|
// merge, so the streaming twin can have the SAME Mongo id as history's
|
|
@@ -426,6 +573,160 @@ describe('mergeHistoryWithRealtime', () => {
|
|
|
426
573
|
expect(ids(merged)).toEqual([U0.id, A0.id, histSystem.id])
|
|
427
574
|
})
|
|
428
575
|
})
|
|
576
|
+
|
|
577
|
+
// Per-ROLE coverage: when history rows carry their own streamSeq, a synthetic
|
|
578
|
+
// is covered only by a persisted row of the SAME role reaching its seq. This
|
|
579
|
+
// prevents the reload/stop message-loss bug where an unrelated other-role or
|
|
580
|
+
// later row pushed the single global max past a synthetic whose own turn was
|
|
581
|
+
// never persisted (interrupted / async), dropping a message the user saw.
|
|
582
|
+
describe('per-role streamSeq coverage', () => {
|
|
583
|
+
it('KEEPS an assistant synthetic when only an OTHER-role row advances the global max', () => {
|
|
584
|
+
// The repro: after reload, an interrupted assistant turn (seq 100) is on
|
|
585
|
+
// screen as a synthetic. A persisted USER row carries a high seq (90) but
|
|
586
|
+
// the assistant turn itself was never persisted. A stop/new-message
|
|
587
|
+
// recompute must NOT drop the assistant synthetic just because some row's
|
|
588
|
+
// seq >= 100 — only a persisted ASSISTANT row reaching 100 may.
|
|
589
|
+
const histUser: TestMessage = { id: 'aaaa0100', role: 'user', content: 'do a health check', timestamp: t(2000), streamSeq: 90 }
|
|
590
|
+
const histAsst: TestMessage = { id: 'aaaa0101', role: 'assistant', content: txt('older answer'), timestamp: t(2100), streamSeq: 50 }
|
|
591
|
+
const interrupted: TestMessage = { id: 'assistant-9000-x', role: 'assistant', content: txt('running tools…'), timestamp: t(9000), streamSeq: 100 }
|
|
592
|
+
const merged = mergeHistoryWithRealtime<TestMessage>({
|
|
593
|
+
processedHistory: [histUser, histAsst],
|
|
594
|
+
existingMessages: [histUser, histAsst, interrupted],
|
|
595
|
+
streamingMessageId: null, // exemption already lost (stop / new turn)
|
|
596
|
+
historyFetchedAt: 5000,
|
|
597
|
+
historyMaxStreamSeq: 90, // global max — advanced past 100? no, but a bigger user row would
|
|
598
|
+
})
|
|
599
|
+
expect(ids(merged)).toContain(interrupted.id)
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
it('KEEPS an assistant synthetic newer than the same-role persisted max', () => {
|
|
603
|
+
const histAsst: TestMessage = { id: 'aaaa0201', role: 'assistant', content: txt('answer one'), timestamp: t(2100), streamSeq: 50 }
|
|
604
|
+
const laterUser: TestMessage = { id: 'aaaa0202', role: 'user', content: 'and again', timestamp: t(2200), streamSeq: 120 }
|
|
605
|
+
const liveAsst: TestMessage = { id: 'assistant-9000-y', role: 'assistant', content: txt('second answer growing'), timestamp: t(9000), streamSeq: 100 }
|
|
606
|
+
const merged = mergeHistoryWithRealtime<TestMessage>({
|
|
607
|
+
processedHistory: [histAsst, laterUser],
|
|
608
|
+
existingMessages: [histAsst, laterUser, liveAsst],
|
|
609
|
+
streamingMessageId: null,
|
|
610
|
+
historyFetchedAt: 5000,
|
|
611
|
+
historyMaxStreamSeq: 120, // global would cover 100 and wrongly drop it
|
|
612
|
+
})
|
|
613
|
+
// assistant same-role max is 50 < 100 → kept.
|
|
614
|
+
expect(ids(merged)).toContain(liveAsst.id)
|
|
615
|
+
})
|
|
616
|
+
|
|
617
|
+
it('DROPS an assistant synthetic once a same-role persisted row reaches its seq', () => {
|
|
618
|
+
const histAsst: TestMessage = { id: 'aaaa0301', role: 'assistant', content: txt('final answer'), timestamp: t(2100), streamSeq: 100 }
|
|
619
|
+
const synAsst: TestMessage = { id: 'assistant-9000-z', role: 'assistant', content: txt('final answer'), timestamp: t(9000), streamSeq: 100 }
|
|
620
|
+
const merged = mergeHistoryWithRealtime<TestMessage>({
|
|
621
|
+
processedHistory: [U0, histAsst],
|
|
622
|
+
rawHistoryIds: new Set([U0.id, histAsst.id]),
|
|
623
|
+
existingMessages: [U0, histAsst, synAsst],
|
|
624
|
+
streamingMessageId: null,
|
|
625
|
+
historyFetchedAt: 5000,
|
|
626
|
+
historyMaxStreamSeq: 100,
|
|
627
|
+
})
|
|
628
|
+
// same-role assistant max is 100 >= 100 → the replay is deduped.
|
|
629
|
+
expect(ids(merged)).toEqual([U0.id, histAsst.id])
|
|
630
|
+
})
|
|
631
|
+
|
|
632
|
+
it('DROPS a partial assistant synthetic against the same-role full persisted turn', () => {
|
|
633
|
+
// Partial realtime text differs from the full persisted text — coverage
|
|
634
|
+
// is by seq, not content, and the same-role max proves the turn landed.
|
|
635
|
+
const histAsst: TestMessage = { id: 'aaaa0401', role: 'assistant', content: txt('the full answer'), timestamp: t(2100), streamSeq: 80 }
|
|
636
|
+
const partial: TestMessage = { id: 'assistant-9000-p', role: 'assistant', content: txt('the full ans'), timestamp: t(9000), streamSeq: 60 }
|
|
637
|
+
const merged = mergeHistoryWithRealtime<TestMessage>({
|
|
638
|
+
processedHistory: [U0, histAsst],
|
|
639
|
+
rawHistoryIds: new Set([U0.id, histAsst.id]),
|
|
640
|
+
existingMessages: [U0, histAsst, partial],
|
|
641
|
+
streamingMessageId: null,
|
|
642
|
+
historyFetchedAt: 5000,
|
|
643
|
+
historyMaxStreamSeq: 80,
|
|
644
|
+
})
|
|
645
|
+
expect(ids(merged)).toEqual([U0.id, histAsst.id])
|
|
646
|
+
})
|
|
647
|
+
})
|
|
648
|
+
|
|
649
|
+
// Adoption path for plain TOOL turns (no approval batch). After a mid-stream
|
|
650
|
+
// reload the trailing assistant is a persisted Mongo-id row; the chunk
|
|
651
|
+
// processors adopt it and append later chunks IN PLACE, so the store's
|
|
652
|
+
// same-id copy is richer than history's async-lagging persisted version. The
|
|
653
|
+
// merge must keep the richer copy or the bubble reverts to stale segments
|
|
654
|
+
// (the "3 tools collapse to 2, one stuck pending" after reload + stop bug).
|
|
655
|
+
describe('adopted trailing assistant (post-reload tool turns)', () => {
|
|
656
|
+
it('pins the same-id realtime copy when it has MORE tool segments than history', () => {
|
|
657
|
+
const id = 'aaaa0700'
|
|
658
|
+
const persistedTrailing: TestMessage = {
|
|
659
|
+
id, role: 'assistant',
|
|
660
|
+
content: [tool('t1', 'EXECUTED_TOOL'), tool('t2', 'EXECUTING_TOOL')],
|
|
661
|
+
timestamp: t(2100), streamSeq: 60,
|
|
662
|
+
}
|
|
663
|
+
const adoptedRicher: TestMessage = {
|
|
664
|
+
id, role: 'assistant',
|
|
665
|
+
content: [tool('t1', 'EXECUTED_TOOL'), tool('t2', 'EXECUTED_TOOL'), tool('t3', 'EXECUTED_TOOL')],
|
|
666
|
+
timestamp: t(2100), streamSeq: 80,
|
|
667
|
+
}
|
|
668
|
+
const merged = mergeHistoryWithRealtime<TestMessage>({
|
|
669
|
+
processedHistory: [U0, persistedTrailing],
|
|
670
|
+
rawHistoryIds: new Set([U0.id, id]),
|
|
671
|
+
existingMessages: [U0, adoptedRicher], // store holds only the adopted copy
|
|
672
|
+
streamingMessageId: null, // stream is IDLE after stop — no exemption
|
|
673
|
+
historyFetchedAt: 5000,
|
|
674
|
+
historyMaxStreamSeq: 60,
|
|
675
|
+
realtimeSeenStreamSeq: 80,
|
|
676
|
+
})
|
|
677
|
+
const trailing = merged[merged.length - 1]
|
|
678
|
+
expect(trailing).toBe(adoptedRicher)
|
|
679
|
+
expect(Array.isArray(trailing.content) ? trailing.content.length : 0).toBe(3)
|
|
680
|
+
expect(merged.filter((m) => m.id === id)).toHaveLength(1) // no duplicate turn
|
|
681
|
+
})
|
|
682
|
+
|
|
683
|
+
it('pins the same-id realtime copy when counts tie but its consumed seq is higher', () => {
|
|
684
|
+
const id = 'aaaa0701'
|
|
685
|
+
const persistedTrailing: TestMessage = {
|
|
686
|
+
id, role: 'assistant',
|
|
687
|
+
content: [tool('t1', 'EXECUTED_TOOL'), tool('t2', 'EXECUTING_TOOL')],
|
|
688
|
+
timestamp: t(2100), streamSeq: 60,
|
|
689
|
+
}
|
|
690
|
+
const adoptedResolved: TestMessage = {
|
|
691
|
+
id, role: 'assistant',
|
|
692
|
+
content: [tool('t1', 'EXECUTED_TOOL'), tool('t2', 'EXECUTED_TOOL')], // t2 resolved, same count
|
|
693
|
+
timestamp: t(2100), streamSeq: 85,
|
|
694
|
+
}
|
|
695
|
+
const merged = mergeHistoryWithRealtime<TestMessage>({
|
|
696
|
+
processedHistory: [U0, persistedTrailing],
|
|
697
|
+
existingMessages: [U0, adoptedResolved],
|
|
698
|
+
streamingMessageId: null,
|
|
699
|
+
historyFetchedAt: 5000,
|
|
700
|
+
historyMaxStreamSeq: 60,
|
|
701
|
+
realtimeSeenStreamSeq: 85,
|
|
702
|
+
})
|
|
703
|
+
expect(merged[merged.length - 1]).toBe(adoptedResolved)
|
|
704
|
+
})
|
|
705
|
+
|
|
706
|
+
it('keeps the persisted trailing when the same-id realtime copy is NOT richer', () => {
|
|
707
|
+
const id = 'aaaa0702'
|
|
708
|
+
const persistedTrailing: TestMessage = {
|
|
709
|
+
id, role: 'assistant',
|
|
710
|
+
content: [tool('t1', 'EXECUTED_TOOL'), tool('t2', 'EXECUTED_TOOL')],
|
|
711
|
+
timestamp: t(2100), streamSeq: 80,
|
|
712
|
+
}
|
|
713
|
+
const staleSameId: TestMessage = {
|
|
714
|
+
id, role: 'assistant',
|
|
715
|
+
content: [tool('t1', 'EXECUTED_TOOL')], // fewer segments, lower seq
|
|
716
|
+
timestamp: t(2100), streamSeq: 50,
|
|
717
|
+
}
|
|
718
|
+
const merged = mergeHistoryWithRealtime<TestMessage>({
|
|
719
|
+
processedHistory: [U0, persistedTrailing],
|
|
720
|
+
existingMessages: [U0, staleSameId],
|
|
721
|
+
streamingMessageId: null,
|
|
722
|
+
historyFetchedAt: 5000,
|
|
723
|
+
historyMaxStreamSeq: 80,
|
|
724
|
+
realtimeSeenStreamSeq: 80,
|
|
725
|
+
})
|
|
726
|
+
expect(merged[merged.length - 1]).toBe(persistedTrailing)
|
|
727
|
+
expect(merged.filter((m) => m.id === id)).toHaveLength(1)
|
|
728
|
+
})
|
|
729
|
+
})
|
|
429
730
|
})
|
|
430
731
|
|
|
431
732
|
describe('computeHistoryPrepend', () => {
|
|
@@ -59,4 +59,39 @@ describe('processHistoricalMessagesWithErrors — displayApprovalTypes', () => {
|
|
|
59
59
|
const rendered = messages.flatMap((m) => approvalSegments(m.content))
|
|
60
60
|
expect(rendered).toHaveLength(1)
|
|
61
61
|
})
|
|
62
|
+
|
|
63
|
+
it('does not bleed a stale assistant id/streamSeq across an empty (escalated-only) flush', () => {
|
|
64
|
+
// a1 is an assistant turn whose ONLY data is an ADMIN approval that gets
|
|
65
|
+
// escalated (excluded by the explicit list) → it renders nothing, so the
|
|
66
|
+
// flush triggered by the following user row is EMPTY. Its grouping identity
|
|
67
|
+
// and streamSeq must be cleared, or the NEXT assistant turn (a2) inherits
|
|
68
|
+
// a1's id/timestamp and a Math.max-inflated streamSeq (100 instead of 50),
|
|
69
|
+
// which would over-cover synthetics in the history merge.
|
|
70
|
+
const messages: HistoricalMessage[] = [
|
|
71
|
+
{
|
|
72
|
+
id: 'a1',
|
|
73
|
+
createdAt: '2026-07-17T12:00:00Z',
|
|
74
|
+
owner: { type: 'ASSISTANT' },
|
|
75
|
+
lastChunkStreamSeq: 100,
|
|
76
|
+
messageData: [{ type: 'APPROVAL_REQUEST', approvalRequestId: 'req-a1', approvalType: 'ADMIN', command: 'x' }],
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
id: 'u1',
|
|
80
|
+
createdAt: '2026-07-17T12:00:01Z',
|
|
81
|
+
owner: { type: 'CLIENT' },
|
|
82
|
+
messageData: [{ type: 'TEXT', text: 'hello' }],
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
id: 'a2',
|
|
86
|
+
createdAt: '2026-07-17T12:00:02Z',
|
|
87
|
+
owner: { type: 'ASSISTANT' },
|
|
88
|
+
lastChunkStreamSeq: 50,
|
|
89
|
+
messageData: [{ type: 'TEXT', text: 'hi there' }],
|
|
90
|
+
},
|
|
91
|
+
]
|
|
92
|
+
const { messages: out } = processHistoricalMessagesWithErrors(messages, { displayApprovalTypes: ['CLIENT'] })
|
|
93
|
+
const asst = out.find((m) => m.role === 'assistant')
|
|
94
|
+
expect(asst?.id).toBe('a2')
|
|
95
|
+
expect(asst?.streamSeq).toBe(50)
|
|
96
|
+
})
|
|
62
97
|
})
|
|
@@ -27,10 +27,20 @@ export interface MergeableChatMessage {
|
|
|
27
27
|
/** Highest CONTENT chunk streamSeq that composed this message (text / tool /
|
|
28
28
|
* approval / error / compaction — never the non-persisted MESSAGE_END /
|
|
29
29
|
* TOKEN_USAGE control chunks). Hosts stamp it on realtime synthetics so the
|
|
30
|
-
* merge can decide coverage per-message: a synthetic is in history once
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
30
|
+
* merge can decide coverage per-message: a synthetic is in history once a
|
|
31
|
+
* persisted row of the SAME role reaches its seq. (Regular `user-`
|
|
32
|
+
* MESSAGE_REQUEST synthetics are the exception — the backend persists their
|
|
33
|
+
* rows without a seq, so they are deduped by content, not seq; see the merge.)
|
|
34
|
+
*
|
|
35
|
+
* Stamp it on HISTORY rows too (from the persisted `lastChunkStreamSeq`)
|
|
36
|
+
* when available: the merge then computes a PER-ROLE max from history and a
|
|
37
|
+
* synthetic is "covered" only when a persisted row of its own role actually
|
|
38
|
+
* reached its seq. Without per-row history seqs the merge falls back to the
|
|
39
|
+
* single global `historyMaxStreamSeq`, which a later/other-role row can push
|
|
40
|
+
* past a synthetic whose turn is NOT in the snapshot (interrupted /
|
|
41
|
+
* async-persisted) — dropping a message the user saw with no replay to
|
|
42
|
+
* restore it. Optional — absent on hosts that don't stamp it (those keep the
|
|
43
|
+
* global-seq / wall-clock behaviour). */
|
|
34
44
|
streamSeq?: number
|
|
35
45
|
}
|
|
36
46
|
|
|
@@ -150,6 +160,27 @@ export function mergeHistoryWithRealtime<M extends MergeableChatMessage>(input:
|
|
|
150
160
|
const seqCoverageKnown = realtimeSeenStreamSeq > 0 && historyMaxStreamSeq > 0
|
|
151
161
|
const historyCoversRealtime = seqCoverageKnown ? historyMaxStreamSeq >= realtimeSeenStreamSeq : null
|
|
152
162
|
|
|
163
|
+
// Per-role max persisted streamSeq, computed from history rows that carry a
|
|
164
|
+
// per-row `streamSeq` (hosts stamp it from `lastChunkStreamSeq`). The single
|
|
165
|
+
// global `historyMaxStreamSeq` is a MAX over ALL rows, so a later turn or an
|
|
166
|
+
// other-role row (e.g. a user MESSAGE_REQUEST) can push it past a synthetic
|
|
167
|
+
// whose OWN turn is not in the snapshot yet — the merge then "covers" and
|
|
168
|
+
// drops that synthetic with no twin to replace it (permanent loss on stop /
|
|
169
|
+
// reload / any recompute). Deciding coverage against the same-role max
|
|
170
|
+
// instead means a synthetic is dropped only when a persisted row of its role
|
|
171
|
+
// actually reached its seq. `anyHistoryRowSeq` gates the whole scheme: when
|
|
172
|
+
// no history row is seq-stamped (legacy hosts) we keep the previous global
|
|
173
|
+
// behaviour unchanged.
|
|
174
|
+
const historyMaxSeqByRole = new Map<string, number>()
|
|
175
|
+
let anyHistoryRowSeq = false
|
|
176
|
+
for (const pm of processedHistory) {
|
|
177
|
+
if (typeof pm.streamSeq === 'number') {
|
|
178
|
+
anyHistoryRowSeq = true
|
|
179
|
+
const prev = historyMaxSeqByRole.get(pm.role) ?? 0
|
|
180
|
+
if (pm.streamSeq > prev) historyMaxSeqByRole.set(pm.role, pm.streamSeq)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
153
184
|
// Persistence is asynchronous per-chunk: an assistant turn ending in an
|
|
154
185
|
// approval can have its APPROVAL_REQUEST document persisted before the
|
|
155
186
|
// leading THINKING/TEXT documents, so history can return a partial trailing
|
|
@@ -196,6 +227,44 @@ export function mergeHistoryWithRealtime<M extends MergeableChatMessage>(input:
|
|
|
196
227
|
}
|
|
197
228
|
}
|
|
198
229
|
|
|
230
|
+
// Adoption pin, generalised to plain tool turns (no approval batch). After a
|
|
231
|
+
// mid-stream reload the trailing assistant is a PERSISTED row (Mongo id); the
|
|
232
|
+
// chunk processors ADOPT it and append later chunks IN PLACE, so the store's
|
|
233
|
+
// copy keeps that Mongo id but accumulates MORE than history has persisted yet
|
|
234
|
+
// (a later tool, a tool's terminal state). `processedIds` would then drop that
|
|
235
|
+
// richer realtime copy as an id-duplicate and revert the bubble to history's
|
|
236
|
+
// stale segments — the reported "3 tools collapse to 2, one stuck pending
|
|
237
|
+
// instead of failed" after reload + stop. When a same-id realtime twin is
|
|
238
|
+
// strictly richer than the persisted trailing (more segments, or an equal
|
|
239
|
+
// count but a higher consumed `streamSeq`), pin it and drop history's copy —
|
|
240
|
+
// the same rule the approval-batch branch above applies, for any trailing
|
|
241
|
+
// assistant. Skipped when that branch already claimed the trailing turn (its
|
|
242
|
+
// slice moved the last element) or the trailing isn't an assistant. Once the
|
|
243
|
+
// backend finishes persisting the turn, history's length/seq catch up, the
|
|
244
|
+
// twin is no longer richer, and the persisted copy wins again (self-heals).
|
|
245
|
+
if (
|
|
246
|
+
historyTrailingAssistant &&
|
|
247
|
+
Array.isArray(historyTrailingAssistant.content) &&
|
|
248
|
+
processedToUse[processedToUse.length - 1] === historyTrailingAssistant &&
|
|
249
|
+
!pinnedSyntheticIds.has(historyTrailingAssistant.id)
|
|
250
|
+
) {
|
|
251
|
+
const persistedSize = historyTrailingAssistant.content.length
|
|
252
|
+
const persistedSeq = historyTrailingAssistant.streamSeq ?? 0
|
|
253
|
+
const richerTwin = existingMessages.find(
|
|
254
|
+
(m) =>
|
|
255
|
+
m !== historyTrailingAssistant &&
|
|
256
|
+
m.id === historyTrailingAssistant.id &&
|
|
257
|
+
m.role === 'assistant' &&
|
|
258
|
+
Array.isArray(m.content) &&
|
|
259
|
+
(m.content.length > persistedSize ||
|
|
260
|
+
(m.content.length === persistedSize && typeof m.streamSeq === 'number' && m.streamSeq > persistedSeq)),
|
|
261
|
+
)
|
|
262
|
+
if (richerTwin) {
|
|
263
|
+
processedToUse = processedToUse.slice(0, -1)
|
|
264
|
+
pinnedSyntheticIds.add(richerTwin.id)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
199
268
|
// Content-equality fallback for the trailing turn. The backend stamps `lastChunkStreamSeq`
|
|
200
269
|
// asynchronously, so a just-finished assistant can land in history with a seq BELOW the replay's
|
|
201
270
|
// terminal chunk seq; seq coverage then reads "not covered" and the replayed synthetic survives
|
|
@@ -204,6 +273,24 @@ export function mergeHistoryWithRealtime<M extends MergeableChatMessage>(input:
|
|
|
204
273
|
const trailingAssistantText =
|
|
205
274
|
lastProcessed && lastProcessed.role === 'assistant' ? assistantAnswerText(lastProcessed.content) : ''
|
|
206
275
|
|
|
276
|
+
// Positional content multiset for `user-` synthetic dedup. The backend
|
|
277
|
+
// persists user MESSAGE_REQUEST rows WITHOUT a `lastChunkStreamSeq`, so a
|
|
278
|
+
// replayed/echoed `user-` synthetic can be covered by neither seq (its twin
|
|
279
|
+
// adds 0 to every seq max) nor wall-clock (replay re-mints a fresh
|
|
280
|
+
// timestamp). Match it by TEXT against persisted user rows, positionally: the
|
|
281
|
+
// k-th `user-` synthetic with a text is the twin of the k-th persisted user
|
|
282
|
+
// row with that text. This drops a replay/echo once its own row is in the
|
|
283
|
+
// snapshot (kills the client→viewer duplicate, trailing twin or not) while
|
|
284
|
+
// keeping a LATER same-text turn whose row hasn't persisted yet — which the
|
|
285
|
+
// old first-match `findIndex` dropped, losing a repeated message.
|
|
286
|
+
const persistedUserContentCounts = new Map<string, number>()
|
|
287
|
+
for (const pm of processedToUse) {
|
|
288
|
+
if (pm.role === 'user' && typeof pm.content === 'string') {
|
|
289
|
+
persistedUserContentCounts.set(pm.content, (persistedUserContentCounts.get(pm.content) ?? 0) + 1)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
const seenUserSyntheticByContent = new Map<string, number>()
|
|
293
|
+
|
|
207
294
|
const realtimeMessages = existingMessages.filter((m) => {
|
|
208
295
|
// Pin wins over everything: the twin may carry a persisted Mongo id (the
|
|
209
296
|
// chunk processors ADOPT an in-progress trailing assistant after a prior
|
|
@@ -236,19 +323,75 @@ export function mergeHistoryWithRealtime<M extends MergeableChatMessage>(input:
|
|
|
236
323
|
if (m.role === 'assistant' && trailingAssistantText && assistantAnswerText(m.content) === trailingAssistantText) {
|
|
237
324
|
return false
|
|
238
325
|
}
|
|
239
|
-
//
|
|
326
|
+
// Regular user MESSAGE_REQUEST synthetics (`user-`): the backend persists
|
|
327
|
+
// the user row WITHOUT a `lastChunkStreamSeq` (unlike DIRECT/SYSTEM rows,
|
|
328
|
+
// which carry the chunk seq), so seq coverage can NEVER see them (the twin
|
|
329
|
+
// adds 0 to every seq max) and wall-clock can't either (replay re-mints a
|
|
330
|
+
// fresh timestamp). Dedup them purely by TEXT, matched positionally
|
|
331
|
+
// against persisted user rows (see `persistedUserContentCounts`): drop the
|
|
332
|
+
// synthetic once its own row is in the snapshot — trailing twin or not,
|
|
333
|
+
// killing the client→viewer duplicate — while keeping a later same-text
|
|
334
|
+
// turn whose row hasn't persisted yet. `direct-` / `system-` synthetics
|
|
335
|
+
// are ALSO role 'user' but DO carry a persisted seq, so the `user-` prefix
|
|
336
|
+
// gate lets them fall through to seq coverage below.
|
|
337
|
+
// NOTE: content matching is a workaround for the missing user-row
|
|
338
|
+
// `lastChunkStreamSeq`; the durable fix is the backend stamping it (as it
|
|
339
|
+
// already does for DIRECT/SYSTEM), which also stops the replay at the
|
|
340
|
+
// source via a correct `optStartSeq`.
|
|
341
|
+
if (m.role === 'user' && m.id.startsWith('user-') && typeof m.content === 'string') {
|
|
342
|
+
const seen = (seenUserSyntheticByContent.get(m.content) ?? 0) + 1
|
|
343
|
+
seenUserSyntheticByContent.set(m.content, seen)
|
|
344
|
+
// No persisted row for THIS occurrence slot → keep (nothing renders it).
|
|
345
|
+
if ((persistedUserContentCounts.get(m.content) ?? 0) < seen) return true
|
|
346
|
+
// A same-text persisted twin exists for this slot. User rows carry no
|
|
347
|
+
// persisted seq, so that twin may be THIS message's OWN row (drop — it
|
|
348
|
+
// renders the message) OR an OLDER identical-text turn while this is a
|
|
349
|
+
// NEW send repeating the text (keep — dropping it loses a message the
|
|
350
|
+
// user just sent: the reported "message from the client chat disappears"
|
|
351
|
+
// when the text repeats an earlier turn, e.g. sending "continue" again).
|
|
352
|
+
// Disambiguate with signals that DON'T need a user-row seq:
|
|
353
|
+
// - seq watermark: if history persisted PAST this synthetic's
|
|
354
|
+
// streamSeq, the stream reached this delivery point, so its own row
|
|
355
|
+
// is in the snapshot → replay → drop.
|
|
356
|
+
// - trailing twin: if the last history row is a same-text user row (no
|
|
357
|
+
// reply yet), it's the just-sent message's own row → drop its echo.
|
|
358
|
+
// - else history is BEHIND this fresh seq and the twin is an older
|
|
359
|
+
// completed turn → NEW repeat → keep.
|
|
360
|
+
// No seq signal (legacy transport) → positional-only dedup (drop).
|
|
361
|
+
if (typeof m.streamSeq !== 'number' || historyMaxStreamSeq <= 0) return false
|
|
362
|
+
if (historyMaxStreamSeq >= m.streamSeq) return false
|
|
363
|
+
const lastPersisted = processedToUse[processedToUse.length - 1]
|
|
364
|
+
if (lastPersisted && lastPersisted.role === 'user' && lastPersisted.content === m.content) return false
|
|
365
|
+
return true
|
|
366
|
+
}
|
|
367
|
+
// Per-message seq coverage for the remaining synthetics (assistant /
|
|
368
|
+
// direct / system / error). The synthetic carries the highest CONTENT seq
|
|
240
369
|
// that built it (never the MESSAGE_END/TOKEN_USAGE tail), so history has
|
|
241
|
-
// it once
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
370
|
+
// it once a persisted row reaches that seq — exact per-turn: it drops an
|
|
371
|
+
// earlier finished turn while keeping a later still-streaming one, which
|
|
372
|
+
// the single global `realtimeSeenStreamSeq` (biased upward by the consumed
|
|
373
|
+
// tail) cannot distinguish.
|
|
374
|
+
//
|
|
375
|
+
// In the per-role regime (some history row is seq-stamped) decide ONLY
|
|
376
|
+
// against the SAME-ROLE persisted max and NEVER fall back to the global
|
|
377
|
+
// (cross-role) coverage: a role whose persisted rows reached no seq >=
|
|
378
|
+
// this one has no evidence the turn is in the snapshot, so it must be kept
|
|
379
|
+
// — letting an unrelated role's seq (a user MESSAGE_REQUEST that raised the
|
|
380
|
+
// global max, a later turn) cover it is exactly the reload/stop
|
|
381
|
+
// message-loss bug. Only when NO history row is seq-stamped (legacy hosts /
|
|
382
|
+
// unstamped history) do we use the global seq, then wall-clock.
|
|
383
|
+
let covered: boolean
|
|
384
|
+
if (typeof m.streamSeq === 'number' && anyHistoryRowSeq) {
|
|
385
|
+
const roleMax = historyMaxSeqByRole.get(m.role) ?? 0
|
|
386
|
+
covered = roleMax > 0 && roleMax >= m.streamSeq
|
|
387
|
+
} else if (typeof m.streamSeq === 'number' && historyMaxStreamSeq > 0) {
|
|
388
|
+
covered = historyMaxStreamSeq >= m.streamSeq
|
|
389
|
+
} else {
|
|
390
|
+
covered =
|
|
391
|
+
historyCoversRealtime !== null
|
|
250
392
|
? historyCoversRealtime
|
|
251
393
|
: (m.timestamp?.getTime() ?? 0) <= historyFetchedAt
|
|
394
|
+
}
|
|
252
395
|
if (covered) return false
|
|
253
396
|
}
|
|
254
397
|
return true
|