@flamingo-stack/openframe-frontend-core 0.0.447-snapshot.20260717215742 → 0.0.447
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-3RM5Z2SH.cjs → chunk-2PNWAAU4.cjs} +59 -59
- package/dist/{chunk-3RM5Z2SH.cjs.map → chunk-2PNWAAU4.cjs.map} +1 -1
- package/dist/{chunk-ANJ56NCM.js → chunk-3GBLPPSW.js} +4 -4
- package/dist/{chunk-AMS55EES.js → chunk-4ONE3HQP.js} +2 -2
- package/dist/{chunk-T245XRIS.js → chunk-5BJJRPDH.js} +2 -2
- package/dist/{chunk-PJ27XJRQ.js → chunk-6TAIXSF5.js} +2 -2
- package/dist/{chunk-ZRYCV52Q.js → chunk-D2NLNBLH.js} +2 -2
- package/dist/{chunk-P62PIQSY.cjs → chunk-E6HKLQ3Q.cjs} +7 -7
- package/dist/{chunk-P62PIQSY.cjs.map → chunk-E6HKLQ3Q.cjs.map} +1 -1
- package/dist/{chunk-ZGERD4OP.cjs → chunk-EZMM2OMP.cjs} +26 -26
- package/dist/{chunk-ZGERD4OP.cjs.map → chunk-EZMM2OMP.cjs.map} +1 -1
- package/dist/{chunk-HKMQ3LHY.cjs → chunk-FR6NVZVM.cjs} +11 -11
- package/dist/{chunk-HKMQ3LHY.cjs.map → chunk-FR6NVZVM.cjs.map} +1 -1
- package/dist/{chunk-PFRUS5M4.js → chunk-G7QSLGMI.js} +2 -2
- package/dist/{chunk-5Z3YOBRE.cjs → chunk-ITJMUH4Q.cjs} +31 -31
- package/dist/{chunk-5Z3YOBRE.cjs.map → chunk-ITJMUH4Q.cjs.map} +1 -1
- package/dist/{chunk-CCSCZSXS.cjs → chunk-KYLOPUIH.cjs} +26 -26
- package/dist/{chunk-CCSCZSXS.cjs.map → chunk-KYLOPUIH.cjs.map} +1 -1
- package/dist/{chunk-7YSVW2JG.cjs → chunk-PF6CKZIH.cjs} +5 -5
- package/dist/{chunk-7YSVW2JG.cjs.map → chunk-PF6CKZIH.cjs.map} +1 -1
- package/dist/{chunk-M2U5KN4Q.cjs → chunk-RNXMP7GB.cjs} +13 -73
- package/dist/chunk-RNXMP7GB.cjs.map +1 -0
- package/dist/{chunk-NQTASSKI.cjs → chunk-S7VMUDUB.cjs} +9 -9
- package/dist/{chunk-NQTASSKI.cjs.map → chunk-S7VMUDUB.cjs.map} +1 -1
- package/dist/{chunk-AEPR6AXO.js → chunk-SHC5NFOH.js} +2 -2
- package/dist/{chunk-KP2E22VP.js → chunk-TMZYFYSS.js} +4 -4
- package/dist/{chunk-X62WEO5Z.cjs → chunk-WLU37UZO.cjs} +62 -62
- package/dist/{chunk-X62WEO5Z.cjs.map → chunk-WLU37UZO.cjs.map} +1 -1
- package/dist/{chunk-UAOR2SRY.js → chunk-WMOF3WQT.js} +4 -4
- package/dist/{chunk-TGEAT3G4.cjs → chunk-X4XHW3OV.cjs} +37 -37
- package/dist/{chunk-TGEAT3G4.cjs.map → chunk-X4XHW3OV.cjs.map} +1 -1
- package/dist/{chunk-JM5VSYQB.cjs → chunk-XUS75SQW.cjs} +15 -15
- package/dist/{chunk-JM5VSYQB.cjs.map → chunk-XUS75SQW.cjs.map} +1 -1
- package/dist/{chunk-QJN4XSP4.js → chunk-YCSNDUDE.js} +13 -73
- package/dist/{chunk-QJN4XSP4.js.map → chunk-YCSNDUDE.js.map} +1 -1
- package/dist/{chunk-CAEQ5BQO.js → chunk-ZQVEB6KT.js} +4 -4
- package/dist/{chunk-PHH7M3CF.js → chunk-ZYB3BYBA.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 +0 -13
- package/dist/components/chat/types/message.types.d.ts.map +1 -1
- package/dist/components/chat/utils/history-merge.d.ts +4 -14
- 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 +0 -13
- package/src/components/chat/utils/__tests__/history-merge.test.ts +0 -301
- package/src/components/chat/utils/__tests__/process-historical-messages-approvals.test.ts +0 -35
- package/src/components/chat/utils/history-merge.ts +14 -157
- package/src/components/chat/utils/process-historical-messages.ts +6 -45
- package/dist/chunk-M2U5KN4Q.cjs.map +0 -1
- /package/dist/{chunk-ANJ56NCM.js.map → chunk-3GBLPPSW.js.map} +0 -0
- /package/dist/{chunk-AMS55EES.js.map → chunk-4ONE3HQP.js.map} +0 -0
- /package/dist/{chunk-T245XRIS.js.map → chunk-5BJJRPDH.js.map} +0 -0
- /package/dist/{chunk-PJ27XJRQ.js.map → chunk-6TAIXSF5.js.map} +0 -0
- /package/dist/{chunk-ZRYCV52Q.js.map → chunk-D2NLNBLH.js.map} +0 -0
- /package/dist/{chunk-PFRUS5M4.js.map → chunk-G7QSLGMI.js.map} +0 -0
- /package/dist/{chunk-AEPR6AXO.js.map → chunk-SHC5NFOH.js.map} +0 -0
- /package/dist/{chunk-KP2E22VP.js.map → chunk-TMZYFYSS.js.map} +0 -0
- /package/dist/{chunk-UAOR2SRY.js.map → chunk-WMOF3WQT.js.map} +0 -0
- /package/dist/{chunk-CAEQ5BQO.js.map → chunk-ZQVEB6KT.js.map} +0 -0
- /package/dist/{chunk-PHH7M3CF.js.map → chunk-ZYB3BYBA.js.map} +0 -0
package/package.json
CHANGED
|
@@ -318,13 +318,6 @@ 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
|
|
328
321
|
}
|
|
329
322
|
|
|
330
323
|
// ========== Processed Message Types ==========
|
|
@@ -338,12 +331,6 @@ export interface ProcessedMessage {
|
|
|
338
331
|
authorType?: AuthorType
|
|
339
332
|
timestamp: Date
|
|
340
333
|
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
|
|
347
334
|
}
|
|
348
335
|
|
|
349
336
|
// ========== Base Message Interface ==========
|
|
@@ -19,11 +19,6 @@ 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
|
|
27
22
|
|
|
28
23
|
// Persisted history (Mongo ids), first fetched at t=1000
|
|
29
24
|
const U0: TestMessage = { id: 'aaaa0001', role: 'user', content: 'first question', timestamp: t(500) }
|
|
@@ -231,148 +226,6 @@ describe('mergeHistoryWithRealtime', () => {
|
|
|
231
226
|
expect(ids(merged)).toContain(repeat.id)
|
|
232
227
|
})
|
|
233
228
|
|
|
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
|
-
|
|
376
229
|
it('pins the streaming twin even when it carries a persisted history id (adoption path)', () => {
|
|
377
230
|
// Chunk processors ADOPT an in-progress trailing assistant after a prior
|
|
378
231
|
// merge, so the streaming twin can have the SAME Mongo id as history's
|
|
@@ -573,160 +426,6 @@ describe('mergeHistoryWithRealtime', () => {
|
|
|
573
426
|
expect(ids(merged)).toEqual([U0.id, A0.id, histSystem.id])
|
|
574
427
|
})
|
|
575
428
|
})
|
|
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
|
-
})
|
|
730
429
|
})
|
|
731
430
|
|
|
732
431
|
describe('computeHistoryPrepend', () => {
|
|
@@ -59,39 +59,4 @@ 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
|
-
})
|
|
97
62
|
})
|
|
@@ -27,20 +27,10 @@ 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
|
-
*
|
|
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). */
|
|
30
|
+
* merge can decide coverage per-message: a synthetic is in history once
|
|
31
|
+
* `historyMaxStreamSeq >= streamSeq`. Optional — absent on history messages
|
|
32
|
+
* and on hosts that don't stamp it (those fall back to the global seq /
|
|
33
|
+
* wall-clock rule). */
|
|
44
34
|
streamSeq?: number
|
|
45
35
|
}
|
|
46
36
|
|
|
@@ -160,27 +150,6 @@ export function mergeHistoryWithRealtime<M extends MergeableChatMessage>(input:
|
|
|
160
150
|
const seqCoverageKnown = realtimeSeenStreamSeq > 0 && historyMaxStreamSeq > 0
|
|
161
151
|
const historyCoversRealtime = seqCoverageKnown ? historyMaxStreamSeq >= realtimeSeenStreamSeq : null
|
|
162
152
|
|
|
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
|
-
|
|
184
153
|
// Persistence is asynchronous per-chunk: an assistant turn ending in an
|
|
185
154
|
// approval can have its APPROVAL_REQUEST document persisted before the
|
|
186
155
|
// leading THINKING/TEXT documents, so history can return a partial trailing
|
|
@@ -227,44 +196,6 @@ export function mergeHistoryWithRealtime<M extends MergeableChatMessage>(input:
|
|
|
227
196
|
}
|
|
228
197
|
}
|
|
229
198
|
|
|
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
|
-
|
|
268
199
|
// Content-equality fallback for the trailing turn. The backend stamps `lastChunkStreamSeq`
|
|
269
200
|
// asynchronously, so a just-finished assistant can land in history with a seq BELOW the replay's
|
|
270
201
|
// terminal chunk seq; seq coverage then reads "not covered" and the replayed synthetic survives
|
|
@@ -273,24 +204,6 @@ export function mergeHistoryWithRealtime<M extends MergeableChatMessage>(input:
|
|
|
273
204
|
const trailingAssistantText =
|
|
274
205
|
lastProcessed && lastProcessed.role === 'assistant' ? assistantAnswerText(lastProcessed.content) : ''
|
|
275
206
|
|
|
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
|
-
|
|
294
207
|
const realtimeMessages = existingMessages.filter((m) => {
|
|
295
208
|
// Pin wins over everything: the twin may carry a persisted Mongo id (the
|
|
296
209
|
// chunk processors ADOPT an in-progress trailing assistant after a prior
|
|
@@ -323,75 +236,19 @@ export function mergeHistoryWithRealtime<M extends MergeableChatMessage>(input:
|
|
|
323
236
|
if (m.role === 'assistant' && trailingAssistantText && assistantAnswerText(m.content) === trailingAssistantText) {
|
|
324
237
|
return false
|
|
325
238
|
}
|
|
326
|
-
//
|
|
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
|
|
239
|
+
// Per-message coverage: the synthetic carries the highest CONTENT seq
|
|
369
240
|
// that built it (never the MESSAGE_END/TOKEN_USAGE tail), so history has
|
|
370
|
-
// it once
|
|
371
|
-
// earlier finished
|
|
372
|
-
// the single global `realtimeSeenStreamSeq` (biased upward by
|
|
373
|
-
// tail) cannot distinguish.
|
|
374
|
-
//
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
|
241
|
+
// it once its max persisted seq reaches that. This is exact per-turn —
|
|
242
|
+
// it drops earlier finished turns while keeping a later still-streaming
|
|
243
|
+
// one, which the single global `realtimeSeenStreamSeq` (biased upward by
|
|
244
|
+
// the tail the client consumed) cannot distinguish. Falls back to the
|
|
245
|
+
// global coverage / wall-clock for unstamped synthetics (legacy NATS).
|
|
246
|
+
const covered =
|
|
247
|
+
typeof m.streamSeq === 'number' && historyMaxStreamSeq > 0
|
|
248
|
+
? historyMaxStreamSeq >= m.streamSeq
|
|
249
|
+
: historyCoversRealtime !== null
|
|
392
250
|
? historyCoversRealtime
|
|
393
251
|
: (m.timestamp?.getTime() ?? 0) <= historyFetchedAt
|
|
394
|
-
}
|
|
395
252
|
if (covered) return false
|
|
396
253
|
}
|
|
397
254
|
return true
|