@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.
Files changed (92) hide show
  1. package/dist/{chunk-2PNWAAU4.cjs → chunk-3RM5Z2SH.cjs} +59 -59
  2. package/dist/{chunk-2PNWAAU4.cjs.map → chunk-3RM5Z2SH.cjs.map} +1 -1
  3. package/dist/{chunk-ITJMUH4Q.cjs → chunk-5Z3YOBRE.cjs} +31 -31
  4. package/dist/{chunk-ITJMUH4Q.cjs.map → chunk-5Z3YOBRE.cjs.map} +1 -1
  5. package/dist/{chunk-PF6CKZIH.cjs → chunk-7YSVW2JG.cjs} +5 -5
  6. package/dist/{chunk-PF6CKZIH.cjs.map → chunk-7YSVW2JG.cjs.map} +1 -1
  7. package/dist/{chunk-SHC5NFOH.js → chunk-AEPR6AXO.js} +2 -2
  8. package/dist/{chunk-4ONE3HQP.js → chunk-AMS55EES.js} +2 -2
  9. package/dist/{chunk-3GBLPPSW.js → chunk-ANJ56NCM.js} +4 -4
  10. package/dist/{chunk-ZQVEB6KT.js → chunk-CAEQ5BQO.js} +4 -4
  11. package/dist/{chunk-KYLOPUIH.cjs → chunk-CCSCZSXS.cjs} +26 -26
  12. package/dist/{chunk-KYLOPUIH.cjs.map → chunk-CCSCZSXS.cjs.map} +1 -1
  13. package/dist/{chunk-FR6NVZVM.cjs → chunk-HKMQ3LHY.cjs} +11 -11
  14. package/dist/{chunk-FR6NVZVM.cjs.map → chunk-HKMQ3LHY.cjs.map} +1 -1
  15. package/dist/{chunk-XUS75SQW.cjs → chunk-JM5VSYQB.cjs} +15 -15
  16. package/dist/{chunk-XUS75SQW.cjs.map → chunk-JM5VSYQB.cjs.map} +1 -1
  17. package/dist/{chunk-TMZYFYSS.js → chunk-KP2E22VP.js} +4 -4
  18. package/dist/{chunk-RNXMP7GB.cjs → chunk-M2U5KN4Q.cjs} +73 -13
  19. package/dist/chunk-M2U5KN4Q.cjs.map +1 -0
  20. package/dist/{chunk-S7VMUDUB.cjs → chunk-NQTASSKI.cjs} +9 -9
  21. package/dist/{chunk-S7VMUDUB.cjs.map → chunk-NQTASSKI.cjs.map} +1 -1
  22. package/dist/{chunk-E6HKLQ3Q.cjs → chunk-P62PIQSY.cjs} +7 -7
  23. package/dist/{chunk-E6HKLQ3Q.cjs.map → chunk-P62PIQSY.cjs.map} +1 -1
  24. package/dist/{chunk-G7QSLGMI.js → chunk-PFRUS5M4.js} +2 -2
  25. package/dist/{chunk-ZYB3BYBA.js → chunk-PHH7M3CF.js} +2 -2
  26. package/dist/{chunk-6TAIXSF5.js → chunk-PJ27XJRQ.js} +2 -2
  27. package/dist/{chunk-YCSNDUDE.js → chunk-QJN4XSP4.js} +73 -13
  28. package/dist/{chunk-YCSNDUDE.js.map → chunk-QJN4XSP4.js.map} +1 -1
  29. package/dist/{chunk-5BJJRPDH.js → chunk-T245XRIS.js} +2 -2
  30. package/dist/{chunk-X4XHW3OV.cjs → chunk-TGEAT3G4.cjs} +37 -37
  31. package/dist/{chunk-X4XHW3OV.cjs.map → chunk-TGEAT3G4.cjs.map} +1 -1
  32. package/dist/{chunk-WMOF3WQT.js → chunk-UAOR2SRY.js} +4 -4
  33. package/dist/{chunk-WLU37UZO.cjs → chunk-X62WEO5Z.cjs} +62 -62
  34. package/dist/{chunk-WLU37UZO.cjs.map → chunk-X62WEO5Z.cjs.map} +1 -1
  35. package/dist/{chunk-EZMM2OMP.cjs → chunk-ZGERD4OP.cjs} +26 -26
  36. package/dist/{chunk-EZMM2OMP.cjs.map → chunk-ZGERD4OP.cjs.map} +1 -1
  37. package/dist/{chunk-D2NLNBLH.js → chunk-ZRYCV52Q.js} +2 -2
  38. package/dist/components/case-studies/index.cjs +8 -8
  39. package/dist/components/case-studies/index.js +2 -2
  40. package/dist/components/chat/index.cjs +2 -2
  41. package/dist/components/chat/index.js +1 -1
  42. package/dist/components/chat/types/message.types.d.ts +13 -0
  43. package/dist/components/chat/types/message.types.d.ts.map +1 -1
  44. package/dist/components/chat/utils/history-merge.d.ts +14 -4
  45. package/dist/components/chat/utils/history-merge.d.ts.map +1 -1
  46. package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
  47. package/dist/components/contact/index.cjs +3 -3
  48. package/dist/components/contact/index.js +2 -2
  49. package/dist/components/docs/index.cjs +5 -5
  50. package/dist/components/docs/index.js +4 -4
  51. package/dist/components/embeds/index.cjs +3 -3
  52. package/dist/components/embeds/index.js +2 -2
  53. package/dist/components/faq/index.cjs +3 -3
  54. package/dist/components/faq/index.js +2 -2
  55. package/dist/components/features/index.cjs +2 -2
  56. package/dist/components/features/index.js +1 -1
  57. package/dist/components/help-center-pages/index.cjs +20 -20
  58. package/dist/components/help-center-pages/index.js +11 -11
  59. package/dist/components/index.cjs +129 -129
  60. package/dist/components/index.js +9 -9
  61. package/dist/components/navigation/index.cjs +2 -2
  62. package/dist/components/navigation/index.js +1 -1
  63. package/dist/components/onboarding-guides/index.cjs +5 -5
  64. package/dist/components/onboarding-guides/index.js +4 -4
  65. package/dist/components/related-content/index.cjs +3 -3
  66. package/dist/components/related-content/index.js +2 -2
  67. package/dist/components/tickets/index.cjs +5 -5
  68. package/dist/components/tickets/index.js +4 -4
  69. package/dist/components/ui/index.cjs +2 -2
  70. package/dist/components/ui/index.js +1 -1
  71. package/dist/index.cjs +2 -2
  72. package/dist/index.js +1 -1
  73. package/dist/utils/index.cjs.map +1 -1
  74. package/dist/utils/index.js.map +1 -1
  75. package/package.json +1 -1
  76. package/src/components/chat/types/message.types.ts +13 -0
  77. package/src/components/chat/utils/__tests__/history-merge.test.ts +301 -0
  78. package/src/components/chat/utils/__tests__/process-historical-messages-approvals.test.ts +35 -0
  79. package/src/components/chat/utils/history-merge.ts +157 -14
  80. package/src/components/chat/utils/process-historical-messages.ts +45 -6
  81. package/dist/chunk-RNXMP7GB.cjs.map +0 -1
  82. /package/dist/{chunk-SHC5NFOH.js.map → chunk-AEPR6AXO.js.map} +0 -0
  83. /package/dist/{chunk-4ONE3HQP.js.map → chunk-AMS55EES.js.map} +0 -0
  84. /package/dist/{chunk-3GBLPPSW.js.map → chunk-ANJ56NCM.js.map} +0 -0
  85. /package/dist/{chunk-ZQVEB6KT.js.map → chunk-CAEQ5BQO.js.map} +0 -0
  86. /package/dist/{chunk-TMZYFYSS.js.map → chunk-KP2E22VP.js.map} +0 -0
  87. /package/dist/{chunk-G7QSLGMI.js.map → chunk-PFRUS5M4.js.map} +0 -0
  88. /package/dist/{chunk-ZYB3BYBA.js.map → chunk-PHH7M3CF.js.map} +0 -0
  89. /package/dist/{chunk-6TAIXSF5.js.map → chunk-PJ27XJRQ.js.map} +0 -0
  90. /package/dist/{chunk-5BJJRPDH.js.map → chunk-T245XRIS.js.map} +0 -0
  91. /package/dist/{chunk-WMOF3WQT.js.map → chunk-UAOR2SRY.js.map} +0 -0
  92. /package/dist/{chunk-D2NLNBLH.js.map → chunk-ZRYCV52Q.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flamingo-stack/openframe-frontend-core",
3
- "version": "0.0.447",
3
+ "version": "0.0.448",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -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
- * `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). */
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
- // Per-message coverage: the synthetic carries the highest CONTENT seq
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 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
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