@flamingo-stack/openframe-frontend-core 0.0.244 → 0.0.246

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 (133) hide show
  1. package/dist/{chunk-STKMIMLP.cjs → chunk-3JRA55FZ.cjs} +12 -12
  2. package/dist/{chunk-STKMIMLP.cjs.map → chunk-3JRA55FZ.cjs.map} +1 -1
  3. package/dist/{chunk-DV3YZWGN.cjs → chunk-3TDLFCCS.cjs} +33 -20
  4. package/dist/chunk-3TDLFCCS.cjs.map +1 -0
  5. package/dist/{chunk-ORJREQ2W.js → chunk-4XLJWX2N.js} +4 -4
  6. package/dist/{chunk-P5C732SC.js → chunk-DB4HW42V.js} +6 -6
  7. package/dist/{chunk-EPXROXYC.cjs → chunk-ESADULCN.cjs} +651 -716
  8. package/dist/chunk-ESADULCN.cjs.map +1 -0
  9. package/dist/{chunk-EJ7J4QAD.cjs → chunk-GMFDA36U.cjs} +12 -12
  10. package/dist/{chunk-EJ7J4QAD.cjs.map → chunk-GMFDA36U.cjs.map} +1 -1
  11. package/dist/{chunk-PTSAF7T2.js → chunk-LXDA67PW.js} +2 -2
  12. package/dist/{chunk-APGMNHPY.js → chunk-MKVKVJQQ.js} +32 -19
  13. package/dist/chunk-MKVKVJQQ.js.map +1 -0
  14. package/dist/{chunk-M7DPUWAL.js → chunk-NZST65RV.js} +126 -191
  15. package/dist/chunk-NZST65RV.js.map +1 -0
  16. package/dist/{chunk-Z6GGYZDR.cjs → chunk-P2G5X6AW.cjs} +27 -27
  17. package/dist/{chunk-Z6GGYZDR.cjs.map → chunk-P2G5X6AW.cjs.map} +1 -1
  18. package/dist/chunk-PINAV6VJ.cjs +287 -0
  19. package/dist/chunk-PINAV6VJ.cjs.map +1 -0
  20. package/dist/{chunk-NKGNRFSS.js → chunk-QRGODKEG.js} +8 -8
  21. package/dist/{chunk-FSS2MIEA.cjs → chunk-SGJBUL2Y.cjs} +20 -3
  22. package/dist/chunk-SGJBUL2Y.cjs.map +1 -0
  23. package/dist/{chunk-LJGBFD52.js → chunk-VAUI3CND.js} +18 -1
  24. package/dist/chunk-VAUI3CND.js.map +1 -0
  25. package/dist/{chunk-OB45JHDY.cjs → chunk-VFKQMAUF.cjs} +3 -3
  26. package/dist/{chunk-OB45JHDY.cjs.map → chunk-VFKQMAUF.cjs.map} +1 -1
  27. package/dist/chunk-VI7GW7PE.js +287 -0
  28. package/dist/chunk-VI7GW7PE.js.map +1 -0
  29. package/dist/{chunk-RJC3L5DT.cjs → chunk-YJVDQZ5Y.cjs} +7 -7
  30. package/dist/{chunk-RJC3L5DT.cjs.map → chunk-YJVDQZ5Y.cjs.map} +1 -1
  31. package/dist/{chunk-WD2MZVJI.js → chunk-ZXECERJ4.js} +5 -5
  32. package/dist/components/chat/chat-message-list.d.ts.map +1 -1
  33. package/dist/components/chat/entity-cards/onboarding-guide-card.d.ts.map +1 -1
  34. package/dist/components/chat/hooks/use-chat-card-item.d.ts.map +1 -1
  35. package/dist/components/chat/index.cjs +6 -16
  36. package/dist/components/chat/index.cjs.map +1 -1
  37. package/dist/components/chat/index.js +5 -15
  38. package/dist/components/chat/types/entities/content-ref.d.ts +5 -20
  39. package/dist/components/chat/types/entities/content-ref.d.ts.map +1 -1
  40. package/dist/components/chat/utils/index.d.ts +0 -1
  41. package/dist/components/chat/utils/index.d.ts.map +1 -1
  42. package/dist/components/contact/index.cjs +7 -7
  43. package/dist/components/contact/index.js +6 -6
  44. package/dist/components/faq/faq-section.d.ts +7 -1
  45. package/dist/components/faq/faq-section.d.ts.map +1 -1
  46. package/dist/components/faq/index.cjs +3 -3
  47. package/dist/components/faq/index.js +2 -2
  48. package/dist/components/features/index.cjs +6 -6
  49. package/dist/components/features/index.js +5 -5
  50. package/dist/components/features/policy-configuration-panel.d.ts.map +1 -1
  51. package/dist/components/index.cjs +87 -93
  52. package/dist/components/index.cjs.map +1 -1
  53. package/dist/components/index.d.ts +1 -0
  54. package/dist/components/index.d.ts.map +1 -1
  55. package/dist/components/index.js +31 -37
  56. package/dist/components/index.js.map +1 -1
  57. package/dist/components/navigation/index.cjs +6 -6
  58. package/dist/components/navigation/index.js +5 -5
  59. package/dist/components/onboarding-guides/index.cjs +30 -30
  60. package/dist/components/onboarding-guides/index.cjs.map +1 -1
  61. package/dist/components/onboarding-guides/index.js +11 -11
  62. package/dist/components/related-content/index.cjs +26 -0
  63. package/dist/components/related-content/index.cjs.map +1 -0
  64. package/dist/components/related-content/index.d.ts +2 -0
  65. package/dist/components/related-content/index.d.ts.map +1 -0
  66. package/dist/components/related-content/index.js +26 -0
  67. package/dist/components/related-content/index.js.map +1 -0
  68. package/dist/components/related-content/related-content-section.d.ts +149 -0
  69. package/dist/components/related-content/related-content-section.d.ts.map +1 -0
  70. package/dist/components/tickets/index.cjs +73 -73
  71. package/dist/components/tickets/index.cjs.map +1 -1
  72. package/dist/components/tickets/index.js +15 -15
  73. package/dist/components/ui/index.cjs +9 -19
  74. package/dist/components/ui/index.cjs.map +1 -1
  75. package/dist/components/ui/index.js +13 -23
  76. package/dist/embed-shims/index.cjs +3 -3
  77. package/dist/embed-shims/index.cjs.map +1 -1
  78. package/dist/embed-shims/index.js +5 -5
  79. package/dist/index.cjs +17 -11
  80. package/dist/index.cjs.map +1 -1
  81. package/dist/index.js +55 -49
  82. package/dist/types/content-ref.d.ts +35 -0
  83. package/dist/types/content-ref.d.ts.map +1 -0
  84. package/dist/types/index.d.ts +1 -0
  85. package/dist/types/index.d.ts.map +1 -1
  86. package/dist/utils/content-ref-groups.d.ts +58 -0
  87. package/dist/utils/content-ref-groups.d.ts.map +1 -0
  88. package/dist/utils/extract-items.d.ts +18 -0
  89. package/dist/utils/extract-items.d.ts.map +1 -0
  90. package/dist/utils/index.cjs +81 -0
  91. package/dist/utils/index.cjs.map +1 -1
  92. package/dist/utils/index.d.ts +4 -1
  93. package/dist/utils/index.d.ts.map +1 -1
  94. package/dist/utils/index.js +74 -1
  95. package/dist/utils/index.js.map +1 -1
  96. package/dist/utils/list-url.d.ts +6 -0
  97. package/dist/utils/list-url.d.ts.map +1 -1
  98. package/dist/utils/suggestion-url.d.ts +26 -0
  99. package/dist/utils/suggestion-url.d.ts.map +1 -0
  100. package/package.json +7 -1
  101. package/src/components/chat/chat-message-list.tsx +49 -74
  102. package/src/components/chat/entity-cards/onboarding-guide-card.tsx +18 -3
  103. package/src/components/chat/hooks/use-chat-card-item.ts +3 -38
  104. package/src/components/chat/types/entities/content-ref.ts +5 -21
  105. package/src/components/chat/utils/index.ts +0 -12
  106. package/src/components/faq/faq-section.tsx +69 -24
  107. package/src/components/features/policy-configuration-panel.tsx +0 -1
  108. package/src/components/index.ts +1 -0
  109. package/src/components/related-content/index.ts +9 -0
  110. package/src/components/related-content/related-content-section.tsx +630 -0
  111. package/src/types/content-ref.ts +34 -0
  112. package/src/types/index.ts +1 -0
  113. package/src/utils/content-ref-groups.ts +89 -0
  114. package/src/utils/extract-items.ts +48 -0
  115. package/src/utils/index.ts +16 -1
  116. package/src/utils/list-url.ts +9 -0
  117. package/src/utils/suggestion-url.ts +39 -0
  118. package/dist/chunk-APGMNHPY.js.map +0 -1
  119. package/dist/chunk-DV3YZWGN.cjs.map +0 -1
  120. package/dist/chunk-EPXROXYC.cjs.map +0 -1
  121. package/dist/chunk-FSS2MIEA.cjs.map +0 -1
  122. package/dist/chunk-LJGBFD52.js.map +0 -1
  123. package/dist/chunk-M7DPUWAL.js.map +0 -1
  124. package/dist/components/chat/utils/history-merge.d.ts +0 -92
  125. package/dist/components/chat/utils/history-merge.d.ts.map +0 -1
  126. package/src/components/chat/__tests__/chat-message-list.test.tsx +0 -92
  127. package/src/components/chat/utils/__tests__/history-merge.test.ts +0 -339
  128. package/src/components/chat/utils/history-merge.ts +0 -255
  129. /package/dist/{chunk-ORJREQ2W.js.map → chunk-4XLJWX2N.js.map} +0 -0
  130. /package/dist/{chunk-P5C732SC.js.map → chunk-DB4HW42V.js.map} +0 -0
  131. /package/dist/{chunk-PTSAF7T2.js.map → chunk-LXDA67PW.js.map} +0 -0
  132. /package/dist/{chunk-NKGNRFSS.js.map → chunk-QRGODKEG.js.map} +0 -0
  133. /package/dist/{chunk-WD2MZVJI.js.map → chunk-ZXECERJ4.js.map} +0 -0
@@ -1,92 +0,0 @@
1
- import type { MessageContent } from '../types';
2
- /**
3
- * Reconciliation between persisted dialog history (processed GraphQL pages)
4
- * and the realtime messages a chat client accumulated from streaming chunks.
5
- *
6
- * This is the missing middle piece of the chat pipeline this lib already
7
- * owns: `processHistoricalMessages*` produces one stream, the realtime chunk
8
- * processor / segment accumulator produces the other, and every host (Mingo,
9
- * tickets, openframe-chat) needs to merge them whenever history is (re)fetched.
10
- * Hand-rolled versions of this merge have produced both duplicated turns
11
- * (synthetic kept alongside its persisted twin) and lost turns (synthetic
12
- * trimmed against a stale snapshot that didn't contain its persisted twin
13
- * yet) — the freshness rule below is the invariant that prevents both.
14
- *
15
- * Pure on purpose: the host owns WHEN to merge (react-query wiring, store
16
- * writes), this module owns HOW.
17
- */
18
- /** Minimal structural shape the merge needs — hosts pass their own message
19
- * type and get it back. */
20
- export interface MergeableChatMessage {
21
- id: string;
22
- role: string;
23
- content: MessageContent;
24
- timestamp?: Date;
25
- }
26
- /** Ids minted client-side by realtime chunk processors
27
- * (`assistant-<ts>-…` placeholder bubbles, `user-<ts>-…` peer messages,
28
- * `error-<ts>` stream errors). They never match the Mongo ObjectIds history
29
- * returns for the same turns. This is the cross-host contract every minting
30
- * site (lib `use-chat`, Mingo / tickets chunk processors, openframe-chat)
31
- * must keep matching — exported so it lives in exactly one place.
32
- * `welcome-` and `optimistic-` ids are intentionally NOT listed: welcome
33
- * bubbles are never persisted server-side, and optimistic user messages are
34
- * deduped by content below. */
35
- export declare const SYNTHETIC_REALTIME_ID_PREFIXES: readonly ["assistant-", "user-", "error-"];
36
- /** Flattens DESC-sorted message pages (newest page first, newest message
37
- * first within a page) into one chronological list. */
38
- export declare function flattenMessagePagesChronological<T>(pages: readonly {
39
- messages: readonly T[];
40
- }[] | undefined): T[];
41
- /** Max `lastChunkStreamSeq` across history pages — the history half of the
42
- * merge's seq-coverage signal (`HistoryMergeInput.historyMaxStreamSeq`),
43
- * also used by hosts as the JetStream replay start offset. 0 = unstamped. */
44
- export declare function maxPersistedStreamSeq(pages: readonly {
45
- messages: readonly {
46
- lastChunkStreamSeq?: number | null;
47
- }[];
48
- }[] | undefined): number;
49
- export interface HistoryMergeInput<M extends MergeableChatMessage> {
50
- /** Processed history in chronological order (all fetched pages). */
51
- processedHistory: M[];
52
- /** Raw history message ids across all fetched pages, when they can differ
53
- * from processed ids (processing may merge/rename). Optional — pass when
54
- * available so raw-id duplicates are filtered too. */
55
- rawHistoryIds?: ReadonlySet<string>;
56
- /** Messages currently in the host store for this dialog (realtime + prior merges). */
57
- existingMessages: M[];
58
- /** Id of the in-flight streaming synthetic, if any. Never dropped.
59
- * IMPORTANT: hosts must not pass a STALE id here (e.g. a streaming entry
60
- * left behind by unmounting mid-stream) — gate it on the server-side
61
- * stream state when available, or the synthetic will be exempted forever. */
62
- streamingMessageId: string | null;
63
- /** Epoch ms when the history pages were fetched (react-query `dataUpdatedAt`).
64
- * Wall-clock freshness fallback used when seq coverage (below) is unknown:
65
- * a synthetic created AFTER this instant cannot be represented in the
66
- * snapshot, so it must be kept. NOTE this heuristic is blind to chunk
67
- * REPLAY, which re-mints synthetics for old turns with fresh timestamps —
68
- * pass the seq fields whenever the host tracks them. */
69
- historyFetchedAt: number;
70
- /** Max `lastChunkStreamSeq` across the raw history pages, when the backend
71
- * stamps it (see `maxPersistedStreamSeq`). Together with
72
- * `realtimeSeenStreamSeq` this gives an exact coverage signal that
73
- * replaces the wall-clock heuristic. */
74
- historyMaxStreamSeq?: number;
75
- /** Highest stream seq this client has consumed for the dialog (live or
76
- * replayed chunks). */
77
- realtimeSeenStreamSeq?: number;
78
- }
79
- export declare function mergeHistoryWithRealtime<M extends MergeableChatMessage>(input: HistoryMergeInput<M>): M[];
80
- export interface HistoryPrependResult<M extends MergeableChatMessage> {
81
- newMessages: M[];
82
- boundaryMessageId?: string;
83
- boundaryUpdates?: {
84
- content: MessageContent;
85
- };
86
- }
87
- /** Pagination path (an older page arrived via fetchNextPage): everything on
88
- * screen stays; collect only the messages above the first already-known id,
89
- * plus a content refresh for that boundary message if it changed. Returns
90
- * null when there is nothing to apply. */
91
- export declare function computeHistoryPrepend<M extends MergeableChatMessage>(processedHistory: M[], existingMessages: M[]): HistoryPrependResult<M> | null;
92
- //# sourceMappingURL=history-merge.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"history-merge.d.ts","sourceRoot":"","sources":["../../../../src/components/chat/utils/history-merge.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAwB,cAAc,EAAE,MAAM,UAAU,CAAA;AAEpE;;;;;;;;;;;;;;;GAeG;AAEH;4BAC4B;AAC5B,MAAM,WAAW,oBAAoB;IACnC,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,EAAE,cAAc,CAAA;IACvB,SAAS,CAAC,EAAE,IAAI,CAAA;CACjB;AAED;;;;;;;;gCAQgC;AAChC,eAAO,MAAM,8BAA8B,4CAA6C,CAAA;AAMxF;wDACwD;AACxD,wBAAgB,gCAAgC,CAAC,CAAC,EAAE,KAAK,EAAE,SAAS;IAAE,QAAQ,EAAE,SAAS,CAAC,EAAE,CAAA;CAAE,EAAE,GAAG,SAAS,GAAG,CAAC,EAAE,CAGjH;AAED;;8EAE8E;AAC9E,wBAAgB,qBAAqB,CACnC,KAAK,EAAE,SAAS;IAAE,QAAQ,EAAE,SAAS;QAAE,kBAAkB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,EAAE,CAAA;CAAE,EAAE,GAAG,SAAS,GAC5F,MAAM,CAUR;AAED,MAAM,WAAW,iBAAiB,CAAC,CAAC,SAAS,oBAAoB;IAC/D,oEAAoE;IACpE,gBAAgB,EAAE,CAAC,EAAE,CAAA;IACrB;;2DAEuD;IACvD,aAAa,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,CAAA;IACnC,sFAAsF;IACtF,gBAAgB,EAAE,CAAC,EAAE,CAAA;IACrB;;;kFAG8E;IAC9E,kBAAkB,EAAE,MAAM,GAAG,IAAI,CAAA;IACjC;;;;;6DAKyD;IACzD,gBAAgB,EAAE,MAAM,CAAA;IACxB;;;6CAGyC;IACzC,mBAAmB,CAAC,EAAE,MAAM,CAAA;IAC5B;4BACwB;IACxB,qBAAqB,CAAC,EAAE,MAAM,CAAA;CAC/B;AAED,wBAAgB,wBAAwB,CAAC,CAAC,SAAS,oBAAoB,EAAE,KAAK,EAAE,iBAAiB,CAAC,CAAC,CAAC,GAAG,CAAC,EAAE,CA4GzG;AAED,MAAM,WAAW,oBAAoB,CAAC,CAAC,SAAS,oBAAoB;IAClE,WAAW,EAAE,CAAC,EAAE,CAAA;IAChB,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B,eAAe,CAAC,EAAE;QAAE,OAAO,EAAE,cAAc,CAAA;KAAE,CAAA;CAC9C;AAED;;;2CAG2C;AAC3C,wBAAgB,qBAAqB,CAAC,CAAC,SAAS,oBAAoB,EAClE,gBAAgB,EAAE,CAAC,EAAE,EACrB,gBAAgB,EAAE,CAAC,EAAE,GACpB,oBAAoB,CAAC,CAAC,CAAC,GAAG,IAAI,CAiChC"}
@@ -1,92 +0,0 @@
1
- import { render } from '@testing-library/react'
2
- import { beforeEach, describe, expect, it, vi } from 'vitest'
3
- import type { Message } from '../types/message.types'
4
-
5
- const scrollToBottom = vi.fn()
6
- const stopScroll = vi.fn()
7
-
8
- function makeRef() {
9
- const fn = ((el: HTMLElement | null) => {
10
- ;(fn as { current: HTMLElement | null }).current = el
11
- }) as ((el: HTMLElement | null) => void) & { current: HTMLElement | null }
12
- fn.current = null
13
- return fn
14
- }
15
- const scrollRefMock = makeRef()
16
- const contentRefMock = makeRef()
17
-
18
- vi.mock('use-stick-to-bottom', () => ({
19
- useStickToBottom: () => ({
20
- scrollRef: scrollRefMock,
21
- contentRef: contentRefMock,
22
- scrollToBottom,
23
- stopScroll,
24
- }),
25
- }))
26
-
27
- // Keep the row renderer trivial — these tests exercise ChatMessageList's
28
- // scroll orchestration, not markdown rendering.
29
- vi.mock('../chat-message-enhanced', () => ({
30
- ChatMessageEnhanced: ({ content }: { content: string }) => <div>{content}</div>,
31
- }))
32
-
33
- import { ChatMessageList } from '../chat-message-list'
34
-
35
- // jsdom ships neither observer; the component instantiates both
36
- // (load-more sentinel + top-anchor settle watcher).
37
- class ObserverStub {
38
- observe() {}
39
- unobserve() {}
40
- disconnect() {}
41
- }
42
- vi.stubGlobal('IntersectionObserver', ObserverStub)
43
- vi.stubGlobal('ResizeObserver', ObserverStub)
44
-
45
- const msg = (id: string, role: Message['role']): Message => ({
46
- id,
47
- role,
48
- content: `body of ${id}`,
49
- timestamp: new Date(),
50
- })
51
-
52
- describe('ChatMessageList force-scroll decisions', () => {
53
- beforeEach(() => {
54
- scrollToBottom.mockClear()
55
- })
56
-
57
- it('snaps to bottom when the dialog changes', () => {
58
- const { rerender } = render(<ChatMessageList dialogId="d1" messages={[msg('m1', 'user')]} />)
59
- scrollToBottom.mockClear()
60
- rerender(<ChatMessageList dialogId="d2" messages={[msg('x1', 'user')]} />)
61
- expect(scrollToBottom).toHaveBeenCalled()
62
- })
63
-
64
- it('snaps to bottom when a new user message is appended', () => {
65
- const initial = [msg('m1', 'user'), msg('m2', 'assistant')]
66
- const { rerender } = render(<ChatMessageList dialogId="d1" messages={initial} />)
67
- scrollToBottom.mockClear()
68
- rerender(<ChatMessageList dialogId="d1" messages={[...initial, msg('m3', 'user')]} />)
69
- expect(scrollToBottom).toHaveBeenCalled()
70
- })
71
-
72
- it('does NOT scroll when an older page is prepended (load-more)', () => {
73
- const initial = [msg('m3', 'user'), msg('m4', 'assistant')]
74
- const { rerender } = render(
75
- <ChatMessageList dialogId="d1" messages={initial} hasNextPage onLoadMore={() => {}} />,
76
- )
77
- scrollToBottom.mockClear()
78
- const olderPage = [msg('m1', 'user'), msg('m2', 'assistant')]
79
- rerender(
80
- <ChatMessageList dialogId="d1" messages={[...olderPage, ...initial]} hasNextPage onLoadMore={() => {}} />,
81
- )
82
- expect(scrollToBottom).not.toHaveBeenCalled()
83
- })
84
-
85
- it('does NOT scroll on assistant-only appends (library owns streaming follow)', () => {
86
- const initial = [msg('m1', 'user'), msg('m2', 'assistant')]
87
- const { rerender } = render(<ChatMessageList dialogId="d1" messages={initial} />)
88
- scrollToBottom.mockClear()
89
- rerender(<ChatMessageList dialogId="d1" messages={[...initial, msg('m3', 'assistant')]} />)
90
- expect(scrollToBottom).not.toHaveBeenCalled()
91
- })
92
- })
@@ -1,339 +0,0 @@
1
- import { describe, expect, it } from 'vitest'
2
- import type { MessageSegment } from '../../types'
3
- import {
4
- computeHistoryPrepend,
5
- flattenMessagePagesChronological,
6
- maxPersistedStreamSeq,
7
- type MergeableChatMessage,
8
- mergeHistoryWithRealtime,
9
- } from '../history-merge'
10
-
11
- interface TestMessage extends MergeableChatMessage {
12
- role: 'user' | 'assistant'
13
- }
14
-
15
- const txt = (s: string): MessageSegment[] => [{ type: 'text', text: s }]
16
- const t = (ms: number) => new Date(ms)
17
- const ids = (msgs: TestMessage[]) => msgs.map((m) => m.id)
18
- const batchSeg = {
19
- type: 'approval_batch',
20
- data: { approvalRequestId: 'req-9', toolCalls: [] },
21
- } as unknown as MessageSegment
22
-
23
- // Persisted history (Mongo ids), first fetched at t=1000
24
- const U0: TestMessage = { id: 'aaaa0001', role: 'user', content: 'first question', timestamp: t(500) }
25
- const A0: TestMessage = { id: 'aaaa0002', role: 'assistant', content: txt('first answer'), timestamp: t(600) }
26
-
27
- // A later turn that happened via realtime (optimistic user + streamed reply)
28
- const OPT1: TestMessage = { id: 'optimistic-2000-x', role: 'user', content: 'second question', timestamp: t(2000) }
29
- const SYN1: TestMessage = { id: 'assistant-2100-x', role: 'assistant', content: txt('second answer'), timestamp: t(2100) }
30
-
31
- // Persisted counterparts of that turn (what a FRESH fetch returns)
32
- const U1: TestMessage = { id: 'aaaa0003', role: 'user', content: 'second question', timestamp: t(2000) }
33
- const A1: TestMessage = { id: 'aaaa0004', role: 'assistant', content: txt('second answer'), timestamp: t(2100) }
34
-
35
- describe('mergeHistoryWithRealtime', () => {
36
- it('keeps realtime messages newer than a STALE history snapshot (no data loss)', () => {
37
- // Reopen a dialog: first merge runs against the cached snapshot fetched
38
- // BEFORE the second turn existed. The completed reply must survive — the
39
- // realtime transport will not redeliver chunks this client already consumed.
40
- const merged = mergeHistoryWithRealtime({
41
- processedHistory: [U0, A0],
42
- existingMessages: [U0, A0, OPT1, SYN1],
43
- streamingMessageId: null,
44
- historyFetchedAt: 1000,
45
- })
46
- expect(ids(merged)).toEqual([U0.id, A0.id, OPT1.id, SYN1.id])
47
- })
48
-
49
- it('replaces optimistic + synthetic messages with their persisted twins on a FRESH snapshot', () => {
50
- const merged = mergeHistoryWithRealtime({
51
- processedHistory: [U0, A0, U1, A1],
52
- existingMessages: [U0, A0, OPT1, SYN1],
53
- streamingMessageId: null,
54
- historyFetchedAt: 5000,
55
- })
56
- expect(ids(merged)).toEqual([U0.id, A0.id, U1.id, A1.id])
57
- })
58
-
59
- it('never drops the in-flight streaming synthetic', () => {
60
- const merged = mergeHistoryWithRealtime({
61
- processedHistory: [U0, A0, U1], // assistant turn not persisted yet
62
- existingMessages: [U0, A0, OPT1, SYN1],
63
- streamingMessageId: SYN1.id,
64
- historyFetchedAt: 5000,
65
- })
66
- expect(ids(merged)).toContain(SYN1.id)
67
- })
68
-
69
- it('dedupes mid-list synthetics against fresh history (no duplicated turns)', () => {
70
- const OPT2: TestMessage = { id: 'optimistic-3000-x', role: 'user', content: 'third question', timestamp: t(3000) }
71
- const SYN2: TestMessage = { id: 'assistant-3100-x', role: 'assistant', content: txt('third answer'), timestamp: t(3100) }
72
- const U2: TestMessage = { id: 'aaaa0006', role: 'user', content: 'third question', timestamp: t(3000) }
73
- const A2: TestMessage = { id: 'aaaa0007', role: 'assistant', content: txt('third answer'), timestamp: t(3100) }
74
-
75
- const merged = mergeHistoryWithRealtime({
76
- processedHistory: [U0, A0, U1, A1, U2, A2],
77
- existingMessages: [U0, A0, OPT1, SYN1, OPT2, SYN2],
78
- streamingMessageId: null,
79
- historyFetchedAt: 5000,
80
- })
81
- expect(ids(merged)).toEqual([U0.id, A0.id, U1.id, A1.id, U2.id, A2.id])
82
- })
83
-
84
- it('pins the more-complete synthetic over a partial history assistant (approval persistence race)', () => {
85
- const partialHistA: TestMessage = { id: 'aaaa0005', role: 'assistant', content: [batchSeg], timestamp: t(2100) }
86
- const fullSyn: TestMessage = {
87
- id: 'assistant-2100-y',
88
- role: 'assistant',
89
- content: [...txt('second answer'), batchSeg],
90
- timestamp: t(2100), // OLDER than the snapshot — pin must beat the freshness rule
91
- }
92
-
93
- const merged = mergeHistoryWithRealtime({
94
- processedHistory: [U0, A0, U1, partialHistA],
95
- existingMessages: [U0, A0, OPT1, fullSyn],
96
- streamingMessageId: null,
97
- historyFetchedAt: 5000,
98
- })
99
- expect(ids(merged)).toContain(fullSyn.id)
100
- expect(ids(merged)).not.toContain(partialHistA.id)
101
- })
102
-
103
- it('drops the synthetic when history is at least as complete for the same approval batch', () => {
104
- const fullHistA: TestMessage = {
105
- id: 'aaaa0005',
106
- role: 'assistant',
107
- content: [...txt('second answer'), batchSeg],
108
- timestamp: t(2100),
109
- }
110
- const lesserSyn: TestMessage = {
111
- id: 'assistant-2100-y',
112
- role: 'assistant',
113
- content: [batchSeg],
114
- timestamp: t(9000), // even NEWER than the snapshot — explicit drop wins
115
- }
116
-
117
- const merged = mergeHistoryWithRealtime({
118
- processedHistory: [U0, A0, U1, fullHistA],
119
- existingMessages: [U0, A0, OPT1, lesserSyn],
120
- streamingMessageId: null,
121
- historyFetchedAt: 5000,
122
- })
123
- expect(ids(merged)).toContain(fullHistA.id)
124
- expect(ids(merged)).not.toContain(lesserSyn.id)
125
- })
126
-
127
- it('drops a replay-minted synthetic with a FRESH timestamp when seq coverage proves history contains it', () => {
128
- // Chunk replay re-materializes an old turn into a synthetic stamped with
129
- // the CURRENT time — newer than the snapshot, so the wall-clock rule
130
- // would wrongly keep it next to its persisted twin (the "duplicated
131
- // assistant message after navigating back" bug). Seq coverage is exact:
132
- // history persisted through seq 80 >= client consumed through seq 80.
133
- const replayedSyn: TestMessage = {
134
- id: 'assistant-9999-r',
135
- role: 'assistant',
136
- content: txt('second answer'),
137
- timestamp: t(9999), // newer than historyFetchedAt
138
- }
139
- const merged = mergeHistoryWithRealtime({
140
- processedHistory: [U0, A0, U1, A1],
141
- existingMessages: [U0, A0, replayedSyn],
142
- streamingMessageId: null,
143
- historyFetchedAt: 5000,
144
- historyMaxStreamSeq: 80,
145
- realtimeSeenStreamSeq: 80,
146
- })
147
- expect(ids(merged)).toEqual([U0.id, A0.id, U1.id, A1.id])
148
- })
149
-
150
- it('keeps an old-looking synthetic when seq coverage proves history is BEHIND (persistence lag)', () => {
151
- // Client consumed through seq 90 but history only persisted through 80 —
152
- // dropping the synthetic would lose content, no matter how old its
153
- // wall-clock timestamp looks.
154
- const merged = mergeHistoryWithRealtime({
155
- processedHistory: [U0, A0],
156
- existingMessages: [U0, A0, OPT1, SYN1], // SYN1 timestamp 2100 <= fetchedAt
157
- streamingMessageId: null,
158
- historyFetchedAt: 5000,
159
- historyMaxStreamSeq: 80,
160
- realtimeSeenStreamSeq: 90,
161
- })
162
- expect(ids(merged)).toContain(SYN1.id)
163
- })
164
-
165
- it('falls back to the wall-clock rule when seq signals are unavailable', () => {
166
- const merged = mergeHistoryWithRealtime({
167
- processedHistory: [U0, A0, U1, A1],
168
- existingMessages: [U0, A0, OPT1, SYN1],
169
- streamingMessageId: null,
170
- historyFetchedAt: 5000,
171
- historyMaxStreamSeq: 0,
172
- realtimeSeenStreamSeq: 0,
173
- })
174
- expect(ids(merged)).toEqual([U0.id, A0.id, U1.id, A1.id])
175
- })
176
-
177
- it('keeps a just-sent optimistic message whose text repeats an older turn (stale snapshot)', () => {
178
- // The user re-sends "first question" and the merge runs against a STALE
179
- // snapshot (e.g. triggered by stream start). Content-dedup must not match
180
- // it against the OLD persisted turn with the same text.
181
- const repeat: TestMessage = { id: 'optimistic-9000-x', role: 'user', content: 'first question', timestamp: t(9000) }
182
- const merged = mergeHistoryWithRealtime({
183
- processedHistory: [U0, A0],
184
- existingMessages: [U0, A0, repeat],
185
- streamingMessageId: null,
186
- historyFetchedAt: 1000,
187
- })
188
- expect(ids(merged)).toContain(repeat.id)
189
- })
190
-
191
- it('drops an optimistic repeat once the snapshot is fresh enough to contain its twin', () => {
192
- const repeatPersisted: TestMessage = { id: 'bbbb0001', role: 'user', content: 'first question', timestamp: t(9000) }
193
- const repeat: TestMessage = { id: 'optimistic-9000-x', role: 'user', content: 'first question', timestamp: t(9000) }
194
- const merged = mergeHistoryWithRealtime({
195
- processedHistory: [U0, A0, repeatPersisted],
196
- existingMessages: [U0, A0, repeat],
197
- streamingMessageId: null,
198
- historyFetchedAt: 10_000,
199
- })
200
- expect(ids(merged)).toEqual([U0.id, A0.id, repeatPersisted.id])
201
- })
202
-
203
- it('returns existing messages untouched when the history snapshot is empty', () => {
204
- const merged = mergeHistoryWithRealtime({
205
- processedHistory: [],
206
- existingMessages: [OPT1, SYN1],
207
- streamingMessageId: null,
208
- historyFetchedAt: 10_000,
209
- })
210
- expect(merged).toEqual([OPT1, SYN1])
211
- })
212
-
213
- it('keeps a just-sent optimistic repeat even when seq coverage reports covered', () => {
214
- // Optimistic messages are minted on send, not by chunks — seq coverage
215
- // says nothing about them. Sent AFTER the snapshot → must survive even
216
- // though its text matches an old persisted turn.
217
- const repeat: TestMessage = { id: 'optimistic-9000-x', role: 'user', content: 'first question', timestamp: t(9000) }
218
- const merged = mergeHistoryWithRealtime({
219
- processedHistory: [U0, A0],
220
- existingMessages: [U0, A0, repeat],
221
- streamingMessageId: null,
222
- historyFetchedAt: 5000,
223
- historyMaxStreamSeq: 80,
224
- realtimeSeenStreamSeq: 80,
225
- })
226
- expect(ids(merged)).toContain(repeat.id)
227
- })
228
-
229
- it('pins the streaming twin even when it carries a persisted history id (adoption path)', () => {
230
- // Chunk processors ADOPT an in-progress trailing assistant after a prior
231
- // merge, so the streaming twin can have the SAME Mongo id as history's
232
- // trailing message. The pin must beat the processed/raw id dedup or the
233
- // turn vanishes from both lists.
234
- const adopted: TestMessage = { id: 'aaaa0005', role: 'assistant', content: [batchSeg], timestamp: t(2100) }
235
- const merged = mergeHistoryWithRealtime({
236
- processedHistory: [U0, A0, U1, adopted],
237
- rawHistoryIds: new Set([U0.id, A0.id, U1.id, adopted.id]),
238
- existingMessages: [U0, A0, adopted],
239
- streamingMessageId: adopted.id,
240
- historyFetchedAt: 5000,
241
- })
242
- expect(ids(merged)).toEqual([U0.id, A0.id, U1.id, adopted.id])
243
- })
244
-
245
- it('never drops the streaming twin in the approval-batch resolution (pins it instead)', () => {
246
- // History's trailing assistant fully persisted the approval turn, but the
247
- // same batch id belongs to the LIVE streaming bubble (continuation after
248
- // approve). Dropping it would orphan the host's streaming pointer and
249
- // make continuation chunks invisible.
250
- const fullHistA: TestMessage = {
251
- id: 'aaaa0005',
252
- role: 'assistant',
253
- content: [...txt('second answer'), batchSeg],
254
- timestamp: t(2100),
255
- }
256
- const liveTwin: TestMessage = { id: 'assistant-2100-y', role: 'assistant', content: [batchSeg], timestamp: t(2100) }
257
- const merged = mergeHistoryWithRealtime({
258
- processedHistory: [U0, A0, U1, fullHistA],
259
- existingMessages: [U0, A0, OPT1, liveTwin],
260
- streamingMessageId: liveTwin.id,
261
- historyFetchedAt: 5000,
262
- })
263
- expect(ids(merged)).toContain(liveTwin.id)
264
- expect(ids(merged)).not.toContain(fullHistA.id)
265
- })
266
-
267
- it('resolves the approval batch against the most recent twin when several share the batch id', () => {
268
- const partialHistA: TestMessage = { id: 'aaaa0005', role: 'assistant', content: [batchSeg], timestamp: t(2100) }
269
- const staleTwin: TestMessage = { id: 'assistant-1000-old', role: 'assistant', content: [batchSeg], timestamp: t(1000) }
270
- const fullTwin: TestMessage = {
271
- id: 'assistant-2100-y',
272
- role: 'assistant',
273
- content: [...txt('second answer'), batchSeg],
274
- timestamp: t(2100),
275
- }
276
- const merged = mergeHistoryWithRealtime({
277
- processedHistory: [U0, A0, U1, partialHistA],
278
- existingMessages: [U0, A0, staleTwin, fullTwin],
279
- streamingMessageId: null,
280
- historyFetchedAt: 5000,
281
- })
282
- expect(ids(merged)).toContain(fullTwin.id)
283
- expect(ids(merged)).not.toContain(partialHistA.id)
284
- expect(ids(merged)).not.toContain(staleTwin.id)
285
- })
286
-
287
- it('keeps welcome bubbles (never persisted server-side)', () => {
288
- const welcome: TestMessage = { id: 'welcome-d1', role: 'assistant', content: 'Hi!', timestamp: t(100) }
289
- const merged = mergeHistoryWithRealtime({
290
- processedHistory: [U0, A0],
291
- existingMessages: [welcome],
292
- streamingMessageId: null,
293
- historyFetchedAt: 5000,
294
- })
295
- expect(ids(merged)).toContain(welcome.id)
296
- })
297
- })
298
-
299
- describe('computeHistoryPrepend', () => {
300
- it('collects only messages above the first known id', () => {
301
- const older: TestMessage = { id: 'aaaa0000', role: 'user', content: 'zeroth', timestamp: t(100) }
302
- const result = computeHistoryPrepend([older, U0, A0], [U0, A0, SYN1])
303
- expect(result).not.toBeNull()
304
- expect(ids(result!.newMessages)).toEqual([older.id])
305
- expect(result!.boundaryUpdates).toBeUndefined()
306
- })
307
-
308
- it('reports a boundary content refresh when the known boundary message changed', () => {
309
- const updatedU0: TestMessage = { ...U0, content: 'first question (edited)' }
310
- const result = computeHistoryPrepend([updatedU0, A0], [U0, A0])
311
- expect(result).not.toBeNull()
312
- expect(result!.newMessages).toEqual([])
313
- expect(result!.boundaryMessageId).toBe(U0.id)
314
- expect(result!.boundaryUpdates).toEqual({ content: 'first question (edited)' })
315
- })
316
-
317
- it('returns null when there is nothing to apply', () => {
318
- expect(computeHistoryPrepend([U0, A0], [U0, A0])).toBeNull()
319
- })
320
- })
321
-
322
- describe('page helpers', () => {
323
- // Pages arrive DESC (newest page first, newest message first within a page).
324
- const pages: { messages: { id: string; lastChunkStreamSeq?: number | null }[] }[] = [
325
- { messages: [{ id: 'd', lastChunkStreamSeq: 40 }, { id: 'c', lastChunkStreamSeq: null }] },
326
- { messages: [{ id: 'b', lastChunkStreamSeq: 20 }, { id: 'a' }] },
327
- ]
328
-
329
- it('flattenMessagePagesChronological reverses pages and messages into chronological order', () => {
330
- expect(flattenMessagePagesChronological(pages).map((m) => m.id)).toEqual(['a', 'b', 'c', 'd'])
331
- expect(flattenMessagePagesChronological(undefined)).toEqual([])
332
- })
333
-
334
- it('maxPersistedStreamSeq returns the highest stamped seq, 0 when absent', () => {
335
- expect(maxPersistedStreamSeq(pages)).toBe(40)
336
- expect(maxPersistedStreamSeq([{ messages: [{}] }])).toBe(0)
337
- expect(maxPersistedStreamSeq(undefined)).toBe(0)
338
- })
339
- })