@flamingo-stack/openframe-frontend-core 0.0.444 → 0.0.445

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 (123) hide show
  1. package/dist/{chunk-DTMJWBDQ.js → chunk-22NSWMI6.js} +3 -3
  2. package/dist/{chunk-NDWIFCLS.js → chunk-3BHV2QAH.js} +555 -135
  3. package/dist/{chunk-NDWIFCLS.js.map → chunk-3BHV2QAH.js.map} +1 -1
  4. package/dist/{chunk-PMTR5W5G.js → chunk-3DQ5BFPM.js} +5 -5
  5. package/dist/{chunk-RALJEQWM.cjs → chunk-3HYSD7NR.cjs} +15 -15
  6. package/dist/{chunk-RALJEQWM.cjs.map → chunk-3HYSD7NR.cjs.map} +1 -1
  7. package/dist/{chunk-YP7VJPCJ.cjs → chunk-5DWPXK37.cjs} +40 -40
  8. package/dist/{chunk-YP7VJPCJ.cjs.map → chunk-5DWPXK37.cjs.map} +1 -1
  9. package/dist/{chunk-OXZLBIR7.cjs → chunk-5FUQTSTT.cjs} +18 -18
  10. package/dist/{chunk-OXZLBIR7.cjs.map → chunk-5FUQTSTT.cjs.map} +1 -1
  11. package/dist/{chunk-MG4AMIFX.js → chunk-5NYXGWBN.js} +2 -2
  12. package/dist/{chunk-LCJGOV6P.js → chunk-7R256DKK.js} +2 -2
  13. package/dist/{chunk-2PLNDTRG.cjs → chunk-DTLNCOSG.cjs} +32 -32
  14. package/dist/{chunk-2PLNDTRG.cjs.map → chunk-DTLNCOSG.cjs.map} +1 -1
  15. package/dist/{chunk-3SCNEGL2.js → chunk-FIAXFNAA.js} +5 -5
  16. package/dist/{chunk-FQASXKBK.js → chunk-G2KXNMFQ.js} +2 -2
  17. package/dist/{chunk-H7H3AFLF.js → chunk-H2DYCT6M.js} +2 -2
  18. package/dist/{chunk-CE3AITV2.js → chunk-H352AZJN.js} +6 -6
  19. package/dist/{chunk-DCDB3SM4.js → chunk-K4RASNQM.js} +3 -3
  20. package/dist/{chunk-WXU6A6CA.cjs → chunk-KISQUH33.cjs} +73 -73
  21. package/dist/{chunk-WXU6A6CA.cjs.map → chunk-KISQUH33.cjs.map} +1 -1
  22. package/dist/{chunk-BYTJNRJW.cjs → chunk-MPSA6YAM.cjs} +40 -40
  23. package/dist/{chunk-BYTJNRJW.cjs.map → chunk-MPSA6YAM.cjs.map} +1 -1
  24. package/dist/{chunk-ABIRMYG5.js → chunk-MQUS6BFR.js} +6 -6
  25. package/dist/{chunk-DSW6FIEF.cjs → chunk-N64VTHZ5.cjs} +7 -7
  26. package/dist/{chunk-DSW6FIEF.cjs.map → chunk-N64VTHZ5.cjs.map} +1 -1
  27. package/dist/{chunk-F6NMMPDL.cjs → chunk-P3FN4NAE.cjs} +29 -29
  28. package/dist/{chunk-F6NMMPDL.cjs.map → chunk-P3FN4NAE.cjs.map} +1 -1
  29. package/dist/{chunk-PHWQLKVE.js → chunk-P47NN7RY.js} +29 -31
  30. package/dist/chunk-P47NN7RY.js.map +1 -0
  31. package/dist/{chunk-2CJ734N2.cjs → chunk-PTT4J5GF.cjs} +3 -3
  32. package/dist/{chunk-2CJ734N2.cjs.map → chunk-PTT4J5GF.cjs.map} +1 -1
  33. package/dist/{chunk-4R3SVOKH.cjs → chunk-QL2EI56X.cjs} +797 -377
  34. package/dist/chunk-QL2EI56X.cjs.map +1 -0
  35. package/dist/{chunk-VI2QQIGR.cjs → chunk-R6WGCJBB.cjs} +73 -73
  36. package/dist/{chunk-VI2QQIGR.cjs.map → chunk-R6WGCJBB.cjs.map} +1 -1
  37. package/dist/{chunk-Q6XOEVPH.cjs → chunk-RXLMN6B5.cjs} +12 -12
  38. package/dist/{chunk-Q6XOEVPH.cjs.map → chunk-RXLMN6B5.cjs.map} +1 -1
  39. package/dist/{chunk-F2XHBAHT.js → chunk-TCEBFMLQ.js} +2 -2
  40. package/dist/{chunk-7CZ4MJSI.cjs → chunk-VIJARDBM.cjs} +3 -3
  41. package/dist/{chunk-7CZ4MJSI.cjs.map → chunk-VIJARDBM.cjs.map} +1 -1
  42. package/dist/{chunk-PXXS27EE.cjs → chunk-W24U7ZUV.cjs} +40 -42
  43. package/dist/chunk-W24U7ZUV.cjs.map +1 -0
  44. package/dist/{chunk-37ZZQXHK.js → chunk-XPZLIONZ.js} +4 -4
  45. package/dist/{chunk-FO4CI4R5.cjs → chunk-YCLSV2H6.cjs} +5 -5
  46. package/dist/{chunk-FO4CI4R5.cjs.map → chunk-YCLSV2H6.cjs.map} +1 -1
  47. package/dist/{chunk-U7U2CZBZ.js → chunk-Z4N7XLFG.js} +3 -3
  48. package/dist/components/case-studies/index.cjs +10 -10
  49. package/dist/components/case-studies/index.js +4 -4
  50. package/dist/components/chat/hooks/use-chunk-catchup.d.ts.map +1 -1
  51. package/dist/components/chat/hooks/use-jetstream-dialog-subscription.d.ts.map +1 -1
  52. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts +44 -1
  53. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
  54. package/dist/components/chat/hooks/use-nats-dialog-subscription.d.ts.map +1 -1
  55. package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts.map +1 -1
  56. package/dist/components/chat/index.cjs +10 -4
  57. package/dist/components/chat/index.cjs.map +1 -1
  58. package/dist/components/chat/index.js +9 -3
  59. package/dist/components/chat/utils/message-segment-accumulator.d.ts.map +1 -1
  60. package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
  61. package/dist/components/contact/index.cjs +5 -5
  62. package/dist/components/contact/index.js +4 -4
  63. package/dist/components/docs/index.cjs +7 -7
  64. package/dist/components/docs/index.js +6 -6
  65. package/dist/components/embeds/index.cjs +5 -5
  66. package/dist/components/embeds/index.js +4 -4
  67. package/dist/components/faq/index.cjs +6 -6
  68. package/dist/components/faq/index.js +5 -5
  69. package/dist/components/features/index.cjs +4 -4
  70. package/dist/components/features/index.js +3 -3
  71. package/dist/components/help-center-pages/index.cjs +23 -23
  72. package/dist/components/help-center-pages/index.js +14 -14
  73. package/dist/components/index.cjs +142 -136
  74. package/dist/components/index.cjs.map +1 -1
  75. package/dist/components/index.js +18 -12
  76. package/dist/components/index.js.map +1 -1
  77. package/dist/components/navigation/index.cjs +4 -4
  78. package/dist/components/navigation/index.js +3 -3
  79. package/dist/components/onboarding-guides/index.cjs +8 -8
  80. package/dist/components/onboarding-guides/index.js +7 -7
  81. package/dist/components/related-content/index.cjs +6 -6
  82. package/dist/components/related-content/index.js +5 -5
  83. package/dist/components/tickets/index.cjs +7 -7
  84. package/dist/components/tickets/index.js +6 -6
  85. package/dist/components/ui/index.cjs +10 -4
  86. package/dist/components/ui/index.cjs.map +1 -1
  87. package/dist/components/ui/index.js +9 -3
  88. package/dist/hooks/index.cjs +3 -3
  89. package/dist/hooks/index.js +2 -2
  90. package/dist/index.cjs +10 -4
  91. package/dist/index.cjs.map +1 -1
  92. package/dist/index.js +9 -3
  93. package/dist/nats/index.cjs +2 -2
  94. package/dist/nats/index.js +1 -1
  95. package/dist/nats/shared-connection.d.ts +6 -0
  96. package/dist/nats/shared-connection.d.ts.map +1 -1
  97. package/package.json +1 -1
  98. package/src/components/chat/hooks/__tests__/nats-adapter-message-updaters.test.ts +270 -0
  99. package/src/components/chat/hooks/use-chunk-catchup.ts +212 -57
  100. package/src/components/chat/hooks/use-jetstream-dialog-subscription.ts +12 -8
  101. package/src/components/chat/hooks/use-nats-chat-adapter.ts +574 -9
  102. package/src/components/chat/hooks/use-nats-dialog-subscription.ts +12 -6
  103. package/src/components/chat/hooks/use-realtime-chunk-processor.ts +40 -3
  104. package/src/components/chat/utils/__tests__/process-historical-messages-approvals.test.ts +62 -0
  105. package/src/components/chat/utils/message-segment-accumulator.ts +6 -0
  106. package/src/components/chat/utils/process-historical-messages.ts +25 -3
  107. package/src/nats/shared-connection.ts +42 -29
  108. package/dist/chunk-4R3SVOKH.cjs.map +0 -1
  109. package/dist/chunk-PHWQLKVE.js.map +0 -1
  110. package/dist/chunk-PXXS27EE.cjs.map +0 -1
  111. /package/dist/{chunk-DTMJWBDQ.js.map → chunk-22NSWMI6.js.map} +0 -0
  112. /package/dist/{chunk-PMTR5W5G.js.map → chunk-3DQ5BFPM.js.map} +0 -0
  113. /package/dist/{chunk-MG4AMIFX.js.map → chunk-5NYXGWBN.js.map} +0 -0
  114. /package/dist/{chunk-LCJGOV6P.js.map → chunk-7R256DKK.js.map} +0 -0
  115. /package/dist/{chunk-3SCNEGL2.js.map → chunk-FIAXFNAA.js.map} +0 -0
  116. /package/dist/{chunk-FQASXKBK.js.map → chunk-G2KXNMFQ.js.map} +0 -0
  117. /package/dist/{chunk-H7H3AFLF.js.map → chunk-H2DYCT6M.js.map} +0 -0
  118. /package/dist/{chunk-CE3AITV2.js.map → chunk-H352AZJN.js.map} +0 -0
  119. /package/dist/{chunk-DCDB3SM4.js.map → chunk-K4RASNQM.js.map} +0 -0
  120. /package/dist/{chunk-ABIRMYG5.js.map → chunk-MQUS6BFR.js.map} +0 -0
  121. /package/dist/{chunk-F2XHBAHT.js.map → chunk-TCEBFMLQ.js.map} +0 -0
  122. /package/dist/{chunk-37ZZQXHK.js.map → chunk-XPZLIONZ.js.map} +0 -0
  123. /package/dist/{chunk-U7U2CZBZ.js.map → chunk-Z4N7XLFG.js.map} +0 -0
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Pure message-updater helpers behind `useNatsChatAdapter`'s realtime
3
+ * callbacks. These guard the post-MESSAGE_END paths that used to corrupt the
4
+ * thread: continuation fragments replacing the whole trailing bubble, tool
5
+ * chunks wiping completed replies, and standalone compaction duplication.
6
+ */
7
+
8
+ import { describe, it, expect } from 'vitest'
9
+ import {
10
+ appendToTrailingAssistant,
11
+ applyToolExecutionToMessages,
12
+ upsertTrailingCompaction,
13
+ } from '../use-nats-chat-adapter'
14
+ import type { UnifiedChatMessage } from '../../types/unified-chat-state.types'
15
+ import type { MessageSegment, ToolExecutionSegment } from '../../types'
16
+
17
+ const assistant = (id: string, segments: MessageSegment[]): UnifiedChatMessage => ({
18
+ id,
19
+ role: 'assistant',
20
+ content: '',
21
+ segments,
22
+ })
23
+
24
+ const user = (id: string, content: string): UnifiedChatMessage => ({
25
+ id,
26
+ role: 'user',
27
+ content,
28
+ })
29
+
30
+ const executing = (execId?: string, toolFunction = 'run_command'): ToolExecutionSegment => ({
31
+ type: 'tool_execution',
32
+ data: {
33
+ type: 'EXECUTING_TOOL',
34
+ integratedToolType: 'SHELL',
35
+ toolFunction,
36
+ toolTitle: 'Run command',
37
+ ...(execId ? { toolExecutionRequestId: execId } : {}),
38
+ },
39
+ })
40
+
41
+ const executed = (execId?: string, toolFunction = 'run_command'): ToolExecutionSegment => ({
42
+ type: 'tool_execution',
43
+ data: {
44
+ type: 'EXECUTED_TOOL',
45
+ integratedToolType: 'SHELL',
46
+ toolFunction,
47
+ result: 'ok',
48
+ success: true,
49
+ ...(execId ? { toolExecutionRequestId: execId } : {}),
50
+ },
51
+ })
52
+
53
+ describe('appendToTrailingAssistant', () => {
54
+ it('appends into the trailing assistant bubble without dropping prior segments', () => {
55
+ const prev = [
56
+ user('u1', 'hi'),
57
+ assistant('a1', [{ type: 'text', text: 'done. ' }]),
58
+ ]
59
+ const next = appendToTrailingAssistant(prev, [{ type: 'text', text: 'continuing' }])
60
+ expect(next).toHaveLength(2)
61
+ expect(next[1].segments).toEqual([{ type: 'text', text: 'done. continuing' }])
62
+ })
63
+
64
+ it('pushes a new segment when types differ instead of merging', () => {
65
+ const prev = [assistant('a1', [{ type: 'text', text: 'answer' }])]
66
+ const next = appendToTrailingAssistant(prev, [{ type: 'thinking', text: 'hmm' }])
67
+ expect(next[0].segments).toEqual([
68
+ { type: 'text', text: 'answer' },
69
+ { type: 'thinking', text: 'hmm' },
70
+ ])
71
+ })
72
+
73
+ it('opens a fresh assistant bubble when the thread ends with a user message', () => {
74
+ const prev = [user('u1', 'hi')]
75
+ const next = appendToTrailingAssistant(prev, [{ type: 'text', text: 'reply' }])
76
+ expect(next).toHaveLength(2)
77
+ expect(next[1].role).toBe('assistant')
78
+ expect(next[1].segments).toEqual([{ type: 'text', text: 'reply' }])
79
+ })
80
+
81
+ it('returns the input untouched for an empty fragment list', () => {
82
+ const prev = [assistant('a1', [{ type: 'text', text: 'answer' }])]
83
+ expect(appendToTrailingAssistant(prev, [])).toBe(prev)
84
+ })
85
+
86
+ it('upserts approval deltas by request id instead of duplicating (replayed emit)', () => {
87
+ const card = (status: 'pending' | 'approved'): MessageSegment => ({
88
+ type: 'approval_batch',
89
+ data: {
90
+ approvalRequestId: 'req-1',
91
+ approvalType: 'USER',
92
+ toolCalls: [
93
+ {
94
+ toolExecutionRequestId: 'exec-1',
95
+ toolName: 'run_command',
96
+ requiresApproval: true,
97
+ approvalType: 'USER',
98
+ toolCallArguments: null,
99
+ },
100
+ ],
101
+ },
102
+ status,
103
+ })
104
+ const prev = [assistant('a1', [{ type: 'text', text: 'reply' }, card('pending')])]
105
+ const next = appendToTrailingAssistant(prev, [card('approved')])
106
+ const segs = next[0].segments!
107
+ expect(segs).toHaveLength(2)
108
+ expect(segs[1].type).toBe('approval_batch')
109
+ expect((segs[1] as Extract<MessageSegment, { type: 'approval_batch' }>).status).toBe('approved')
110
+ })
111
+
112
+ it('upserts legacy approval cards by (requestId, command)', () => {
113
+ const legacy = (command: string, status: 'pending' | 'approved'): MessageSegment => ({
114
+ type: 'approval_request',
115
+ data: { requestId: 'req-1', command, approvalType: 'USER' },
116
+ status,
117
+ })
118
+ const prev = [assistant('a1', [legacy('ls', 'pending'), legacy('rm x', 'pending')])]
119
+ const next = appendToTrailingAssistant(prev, [legacy('rm x', 'approved')])
120
+ const segs = next[0].segments!
121
+ expect(segs).toHaveLength(2)
122
+ expect((segs[0] as Extract<MessageSegment, { type: 'approval_request' }>).status).toBe('pending')
123
+ expect((segs[1] as Extract<MessageSegment, { type: 'approval_request' }>).status).toBe('approved')
124
+ })
125
+ })
126
+
127
+ describe('applyToolExecutionToMessages', () => {
128
+ it('replaces a matching EXECUTING segment in place (same bubble intact)', () => {
129
+ const prev = [
130
+ user('u1', 'run it'),
131
+ assistant('a1', [{ type: 'text', text: 'running…' }, executing('exec-1')]),
132
+ ]
133
+ const next = applyToolExecutionToMessages(prev, executed('exec-1'))
134
+ expect(next).toHaveLength(2)
135
+ const segs = next[1].segments!
136
+ expect(segs[0]).toEqual({ type: 'text', text: 'running…' })
137
+ expect(segs[1].type).toBe('tool_execution')
138
+ expect((segs[1] as ToolExecutionSegment).data.type).toBe('EXECUTED_TOOL')
139
+ // toolTitle restored from the EXECUTING twin (backend omits it on EXECUTED)
140
+ expect((segs[1] as ToolExecutionSegment).data.toolTitle).toBe('Run command')
141
+ })
142
+
143
+ it('updates a tool in an EARLIER bubble, not just the trailing one', () => {
144
+ const prev = [
145
+ assistant('a1', [executing('exec-1')]),
146
+ assistant('a2', [{ type: 'text', text: 'meanwhile' }]),
147
+ ]
148
+ const next = applyToolExecutionToMessages(prev, executed('exec-1'))
149
+ expect((next[0].segments![0] as ToolExecutionSegment).data.type).toBe('EXECUTED_TOOL')
150
+ expect(next[1].segments).toEqual([{ type: 'text', text: 'meanwhile' }])
151
+ })
152
+
153
+ it('merges execution state into an approval_batch containing the exec id', () => {
154
+ const prev: UnifiedChatMessage[] = [
155
+ assistant('a1', [
156
+ {
157
+ type: 'approval_batch',
158
+ data: {
159
+ approvalRequestId: 'req-1',
160
+ approvalType: 'CLIENT',
161
+ toolCalls: [
162
+ {
163
+ toolExecutionRequestId: 'exec-1',
164
+ toolName: 'run_command',
165
+ requiresApproval: true,
166
+ approvalType: 'CLIENT',
167
+ toolCallArguments: null,
168
+ },
169
+ ],
170
+ },
171
+ status: 'approved',
172
+ },
173
+ ]),
174
+ ]
175
+ const next = applyToolExecutionToMessages(prev, executed('exec-1'))
176
+ const batch = next[0].segments![0]
177
+ expect(batch.type).toBe('approval_batch')
178
+ expect((batch as Extract<MessageSegment, { type: 'approval_batch' }>).data.executions).toEqual({
179
+ 'exec-1': { status: 'done', result: 'ok', success: true },
180
+ })
181
+ })
182
+
183
+ it('appends to the trailing bubble when nothing matches (never replaces content)', () => {
184
+ const prev = [assistant('a1', [{ type: 'text', text: 'full reply' }])]
185
+ const next = applyToolExecutionToMessages(prev, executing('exec-9'))
186
+ const segs = next[0].segments!
187
+ expect(segs).toHaveLength(2)
188
+ expect(segs[0]).toEqual({ type: 'text', text: 'full reply' })
189
+ expect((segs[1] as ToolExecutionSegment).data.toolExecutionRequestId).toBe('exec-9')
190
+ })
191
+
192
+ it('never downgrades an EXECUTED segment back to EXECUTING (replayed chunk)', () => {
193
+ const prev = [assistant('a1', [executed('exec-1')])]
194
+ const next = applyToolExecutionToMessages(prev, executing('exec-1'))
195
+ expect(next).toBe(prev)
196
+ })
197
+
198
+ it('never downgrades a done batch slot back to executing (replayed chunk)', () => {
199
+ const prev: UnifiedChatMessage[] = [
200
+ assistant('a1', [
201
+ {
202
+ type: 'approval_batch',
203
+ data: {
204
+ approvalRequestId: 'req-1',
205
+ approvalType: 'CLIENT',
206
+ toolCalls: [
207
+ {
208
+ toolExecutionRequestId: 'exec-1',
209
+ toolName: 'run_command',
210
+ requiresApproval: true,
211
+ approvalType: 'CLIENT',
212
+ toolCallArguments: null,
213
+ },
214
+ ],
215
+ executions: { 'exec-1': { status: 'done', result: 'ok', success: true } },
216
+ },
217
+ status: 'approved',
218
+ },
219
+ ]),
220
+ ]
221
+ const next = applyToolExecutionToMessages(prev, executing('exec-1'))
222
+ expect(next).toBe(prev)
223
+ })
224
+
225
+ it('falls back to (toolType, toolFunction) pairing when no exec id is present', () => {
226
+ const prev = [assistant('a1', [executing(undefined, 'get_devices')])]
227
+ const next = applyToolExecutionToMessages(prev, executed(undefined, 'get_devices'))
228
+ expect((next[0].segments![0] as ToolExecutionSegment).data.type).toBe('EXECUTED_TOOL')
229
+ })
230
+ })
231
+
232
+ describe('upsertTrailingCompaction', () => {
233
+ it('appends a started compaction into the trailing bubble', () => {
234
+ const prev = [assistant('a1', [{ type: 'text', text: 'reply' }])]
235
+ const next = upsertTrailingCompaction(prev, [{ type: 'context_compaction', status: 'started' }])
236
+ expect(next[0].segments).toEqual([
237
+ { type: 'text', text: 'reply' },
238
+ { type: 'context_compaction', status: 'started' },
239
+ ])
240
+ })
241
+
242
+ it('replaces the started segment on completion instead of duplicating', () => {
243
+ const prev = [
244
+ assistant('a1', [
245
+ { type: 'text', text: 'reply' },
246
+ { type: 'context_compaction', status: 'started' },
247
+ ]),
248
+ ]
249
+ const next = upsertTrailingCompaction(prev, [
250
+ { type: 'context_compaction', status: 'completed', summary: 'compacted' },
251
+ ])
252
+ expect(next[0].segments).toEqual([
253
+ { type: 'text', text: 'reply' },
254
+ { type: 'context_compaction', status: 'completed', summary: 'compacted' },
255
+ ])
256
+ })
257
+
258
+ it('applies ONLY the compaction segment from a cumulative emission', () => {
259
+ const prev = [assistant('a1', [{ type: 'text', text: 'reply' }])]
260
+ // Accumulator emits its whole array — interleaved text must not duplicate.
261
+ const next = upsertTrailingCompaction(prev, [
262
+ { type: 'context_compaction', status: 'completed' },
263
+ { type: 'text', text: 'reply' },
264
+ ])
265
+ expect(next[0].segments).toEqual([
266
+ { type: 'text', text: 'reply' },
267
+ { type: 'context_compaction', status: 'completed' },
268
+ ])
269
+ })
270
+ })
@@ -62,9 +62,22 @@ export function useChunkCatchup({
62
62
  }: UseChunkCatchupOptions): UseChunkCatchupReturn {
63
63
  const processedSequenceKeys = useRef<Set<string>>(new Set())
64
64
  const lastSequenceId = useRef<number | null>(null)
65
+ // Per-messageType resume checkpoints. The legacy Redis transport keeps an
66
+ // INDEPENDENT sequence counter per (dialog, chatType), so resuming every
67
+ // stream from the single global `lastSequenceId` would permanently skip
68
+ // the slower stream's newer chunks (one stream at seq 100, the other at
69
+ // 20 → resuming both from 100 loses the second stream's 21+).
70
+ const lastSequenceIdByType = useRef<Map<NatsMessageType, number>>(new Map())
65
71
 
66
72
  const fetchingInProgress = useRef(false)
67
73
  const lastFetchParams = useRef<{ dialogId: string; fromSequenceId?: number | null } | null>(null)
74
+ // A catch-up requested while another fetch is in flight (double reconnect,
75
+ // reconnect during the initial catchup). Queued instead of dropped: the old
76
+ // behaviour returned early and the stale fetch's completion marked catchup
77
+ // done, so the new gap was never fetched — a permanent transcript hole.
78
+ // Scoped to its originating dialog — a rerun must never apply one dialog's
79
+ // offset to another after a switch.
80
+ const pendingCatchupRef = useRef<{ dialogId: string; fromSequenceId?: number | null } | null>(null)
68
81
 
69
82
  // Buffer for NATS chunks that arrive during catchup
70
83
  const chunkBuffer = useRef<BufferedChunk[]>([])
@@ -92,8 +105,18 @@ export function useChunkCatchup({
92
105
 
93
106
  if (chunk.sequenceId !== undefined && chunk.sequenceId !== null) {
94
107
  const chunkType = typeof chunk.type === 'string' ? chunk.type : ''
95
- processedSequenceKeys.current.add(makeSeqKey(messageType, chunkType, chunk.sequenceId))
108
+ const key = makeSeqKey(messageType, chunkType, chunk.sequenceId)
109
+ // A live chunk can also be in the catchup fetch result (published just
110
+ // before the fetch resolved, delivered just after the flush). Without
111
+ // this check the live path only RECORDED keys and never consulted
112
+ // them, so the overlap rendered twice (duplicated text / tool cards).
113
+ if (processedSequenceKeys.current.has(key)) return true
114
+ processedSequenceKeys.current.add(key)
96
115
  lastSequenceId.current = chunk.sequenceId
116
+ const prevTypeSeq = lastSequenceIdByType.current.get(messageType)
117
+ if (prevTypeSeq === undefined || chunk.sequenceId > prevTypeSeq) {
118
+ lastSequenceIdByType.current.set(messageType, chunk.sequenceId)
119
+ }
97
120
  }
98
121
 
99
122
  onChunkReceivedRef.current(chunk, messageType)
@@ -108,9 +131,11 @@ export function useChunkCatchup({
108
131
  const buffered = [...chunkBuffer.current]
109
132
  chunkBuffer.current = []
110
133
 
134
+ // Chunks WITHOUT a sequence id are live deliveries — the newest events.
135
+ // Sorting them as seq 0 (the old behaviour) pushed them BEFORE history.
111
136
  buffered.sort((a, b) => {
112
- const seqA = a.chunk.sequenceId ?? 0
113
- const seqB = b.chunk.sequenceId ?? 0
137
+ const seqA = a.chunk.sequenceId ?? Number.MAX_SAFE_INTEGER
138
+ const seqB = b.chunk.sequenceId ?? Number.MAX_SAFE_INTEGER
114
139
  return seqA - seqB
115
140
  })
116
141
 
@@ -132,10 +157,23 @@ export function useChunkCatchup({
132
157
  }
133
158
 
134
159
  if (hasCompletedInitialCatchup.current) {
160
+ // Safety valve: never leave the buffering flag stuck on a completed
161
+ // catchup — buffered live chunks would otherwise queue forever and the
162
+ // dialog would look frozen.
163
+ if (bufferUntilInitialCatchupComplete.current) {
164
+ bufferUntilInitialCatchupComplete.current = false
165
+ flushBufferedRealtimeChunks()
166
+ }
135
167
  return
136
168
  }
137
169
 
138
170
  if (fetchingInProgress.current) {
171
+ // Queue instead of dropping — the in-flight fetch's finally block
172
+ // re-runs catch-up with these params once it settles. An explicit
173
+ // fromSequenceId wins; a queued INITIAL call (undefined) falls back to
174
+ // the last seq seen so far (null on a fresh dialog → backend returns
175
+ // everything, same as the original undefined semantics).
176
+ pendingCatchupRef.current = { dialogId, fromSequenceId: fromSequenceId ?? lastSequenceId.current }
139
177
  return
140
178
  }
141
179
 
@@ -159,7 +197,17 @@ export function useChunkCatchup({
159
197
  // Fetch chunks for all configured chat types
160
198
  const chunkPromises = chatTypes.map(async (chatType) => {
161
199
  try {
162
- const chunks = await fetchChunks(dialogId, chatType, fromSequenceId)
200
+ // Resume each stream from ITS OWN checkpoint: the legacy Redis
201
+ // transport numbers each (dialog, chatType) stream independently,
202
+ // so passing one global offset to every type would skip the slower
203
+ // stream's newer chunks. `undefined` (initial load) still fetches
204
+ // everything; a type we've never seen resumes from null (= all
205
+ // unsaved chunks) rather than borrowing another stream's offset.
206
+ const typeFromSequenceId =
207
+ fromSequenceId === undefined
208
+ ? undefined
209
+ : (lastSequenceIdByType.current.get(getChatTypeMessageType(chatType)) ?? null)
210
+ const chunks = await fetchChunks(dialogId, chatType, typeFromSequenceId)
163
211
  const messageType = getChatTypeMessageType(chatType)
164
212
  return chunks.map(chunk => ({ chunk, messageType }))
165
213
  } catch (error) {
@@ -183,64 +231,98 @@ export function useChunkCatchup({
183
231
  chunkBuffer.current = []
184
232
  const allChunks = [...allCatchupChunks, ...bufferedNatsChunks]
185
233
 
186
- // Sort by sequence ID
234
+ // Sort by sequence ID. Chunks without one are buffered live deliveries
235
+ // (the newest events) — sort them AFTER history, not before it.
187
236
  allChunks.sort((a, b) => {
188
- const seqA = a.chunk.sequenceId ?? 0
189
- const seqB = b.chunk.sequenceId ?? 0
237
+ const seqA = a.chunk.sequenceId ?? Number.MAX_SAFE_INTEGER
238
+ const seqB = b.chunk.sequenceId ?? Number.MAX_SAFE_INTEGER
190
239
  return seqA - seqB
191
240
  })
192
-
193
- // Deduplicate
241
+
242
+ // Deduplicate — only chunks that carry a sequence id. Id-less live
243
+ // chunks get no key: two identical streaming deltas ("the ", "yes")
244
+ // are distinct content, and collapsing them (the old behaviour, where
245
+ // the key degraded to type+text) silently dropped text.
194
246
  const uniqueAllChunks: BufferedChunk[] = []
195
247
  const seenInBatch = new Set<string>()
196
248
  for (const item of allChunks) {
197
- const k = makeBatchDedupKey(item)
198
- if (seenInBatch.has(k)) continue
199
- seenInBatch.add(k)
249
+ const hasSeq = item.chunk.sequenceId !== undefined && item.chunk.sequenceId !== null
250
+ if (hasSeq) {
251
+ const k = makeBatchDedupKey(item)
252
+ if (seenInBatch.has(k)) continue
253
+ seenInBatch.add(k)
254
+ }
200
255
  uniqueAllChunks.push(item)
201
256
  }
202
257
 
203
- // Find last complete message boundary
204
- let lastMessageStartSeqId: number | null = null
205
- let lastMessageEndSeqId: number | null = null
206
-
207
- for (let i = uniqueAllChunks.length - 1; i >= 0; i--) {
208
- const seq = uniqueAllChunks[i].chunk.sequenceId
209
- if (uniqueAllChunks[i].chunk.type === MESSAGE_TYPE.MESSAGE_END && seq !== undefined && seq !== null) {
210
- lastMessageEndSeqId = seq
211
- break
212
- }
258
+ // Id-less chunks are buffered live deliveries (newer than anything the
259
+ // fetch returned) the boundary filters must KEEP them, not drop them.
260
+ const hasNoSeq = (item: BufferedChunk) =>
261
+ item.chunk.sequenceId === undefined || item.chunk.sequenceId === null
262
+
263
+ // Boundary detection runs PER messageType. In the legacy Redis
264
+ // transport each (dialog, chatType) stream has its OWN independent
265
+ // sequence counter, so a MESSAGE_END boundary found in one stream must
266
+ // never truncate the other stream's chunks (tool/approval chunks of an
267
+ // admin mirror used to vanish this way). With JetStream's global seqs
268
+ // the per-type grouping is still correct — it just partitions the same
269
+ // ordered list.
270
+ const byType = new Map<NatsMessageType, BufferedChunk[]>()
271
+ for (const item of uniqueAllChunks) {
272
+ const list = byType.get(item.messageType)
273
+ if (list) list.push(item)
274
+ else byType.set(item.messageType, [item])
213
275
  }
214
-
215
- for (let i = uniqueAllChunks.length - 1; i >= 0; i--) {
216
- const chunk = uniqueAllChunks[i].chunk
217
- const seq = chunk.sequenceId
218
- if (chunk.type === MESSAGE_TYPE.MESSAGE_START && seq !== undefined && seq !== null) {
219
- if (lastMessageEndSeqId === null || seq > lastMessageEndSeqId) {
220
- lastMessageStartSeqId = seq
276
+
277
+ const chunksToProcess: BufferedChunk[] = []
278
+ for (const items of byType.values()) {
279
+ // Find the last complete message boundary within this stream.
280
+ let lastMessageStartSeqId: number | null = null
281
+ let lastMessageEndSeqId: number | null = null
282
+
283
+ for (let i = items.length - 1; i >= 0; i--) {
284
+ const seq = items[i].chunk.sequenceId
285
+ if (items[i].chunk.type === MESSAGE_TYPE.MESSAGE_END && seq !== undefined && seq !== null) {
286
+ lastMessageEndSeqId = seq
221
287
  break
222
288
  }
223
289
  }
290
+
291
+ for (let i = items.length - 1; i >= 0; i--) {
292
+ const chunk = items[i].chunk
293
+ const seq = chunk.sequenceId
294
+ if (chunk.type === MESSAGE_TYPE.MESSAGE_START && seq !== undefined && seq !== null) {
295
+ if (lastMessageEndSeqId === null || seq > lastMessageEndSeqId) {
296
+ lastMessageStartSeqId = seq
297
+ break
298
+ }
299
+ }
300
+ }
301
+
302
+ if (lastMessageStartSeqId !== null) {
303
+ // Process from the last incomplete message
304
+ chunksToProcess.push(
305
+ ...items.filter(item => hasNoSeq(item) || item.chunk.sequenceId! >= lastMessageStartSeqId!),
306
+ )
307
+ } else if (lastMessageEndSeqId !== null) {
308
+ // Process only after the last complete message
309
+ chunksToProcess.push(
310
+ ...items.filter(item => hasNoSeq(item) || item.chunk.sequenceId! > lastMessageEndSeqId!),
311
+ )
312
+ } else {
313
+ // Process all
314
+ chunksToProcess.push(...items)
315
+ }
224
316
  }
225
-
226
- let chunksToProcess: BufferedChunk[]
227
-
228
- if (lastMessageStartSeqId !== null) {
229
- // Process from the last incomplete message
230
- chunksToProcess = uniqueAllChunks.filter(item =>
231
- item.chunk.sequenceId !== undefined &&
232
- item.chunk.sequenceId >= lastMessageStartSeqId!
233
- )
234
- } else if (lastMessageEndSeqId !== null) {
235
- // Process only after the last complete message
236
- chunksToProcess = uniqueAllChunks.filter(item =>
237
- item.chunk.sequenceId !== undefined &&
238
- item.chunk.sequenceId > lastMessageEndSeqId!
239
- )
240
- } else {
241
- // Process all
242
- chunksToProcess = uniqueAllChunks
243
- }
317
+
318
+ // Restore cross-stream order (concatenation above grouped by type).
319
+ // Stable sort keeps per-type order; JetStream seqs interleave globally,
320
+ // Redis-mode cross-type order is inherently undefined either way.
321
+ chunksToProcess.sort((a, b) => {
322
+ const seqA = a.chunk.sequenceId ?? Number.MAX_SAFE_INTEGER
323
+ const seqB = b.chunk.sequenceId ?? Number.MAX_SAFE_INTEGER
324
+ return seqA - seqB
325
+ })
244
326
 
245
327
  // Process the chunks
246
328
  chunksToProcess.forEach(({ chunk, messageType }) => {
@@ -250,6 +332,10 @@ export function useChunkCatchup({
250
332
  if (processedSequenceKeys.current.has(key)) return
251
333
  processedSequenceKeys.current.add(key)
252
334
  lastSequenceId.current = chunk.sequenceId
335
+ const prevTypeSeq = lastSequenceIdByType.current.get(messageType)
336
+ if (prevTypeSeq === undefined || chunk.sequenceId > prevTypeSeq) {
337
+ lastSequenceIdByType.current.set(messageType, chunk.sequenceId)
338
+ }
253
339
  }
254
340
  onChunkReceivedRef.current(chunk, messageType)
255
341
  })
@@ -259,24 +345,81 @@ export function useChunkCatchup({
259
345
  } catch (error) {
260
346
  console.error('Error during chunk catchup:', error)
261
347
  } finally {
262
- fetchingInProgress.current = false
348
+ // A fetch whose dialog is no longer active must not touch the NEW
349
+ // dialog's cycle state — `resetChunkTracking` already re-armed the
350
+ // flags for it, and clearing `fetchingInProgress` / finalizing the
351
+ // buffer here would corrupt the new dialog's in-flight catch-up.
352
+ // That includes the pending queue: only discard an entry that belongs
353
+ // to THIS stale dialog — a reconnect during the new dialog's initial
354
+ // fetch queues an entry for the new dialog, and wiping it here would
355
+ // silently drop that gap's re-fetch (permanent transcript hole).
356
+ if (dialogId !== dialogIdRef.current) {
357
+ if (pendingCatchupRef.current?.dialogId === dialogId) {
358
+ pendingCatchupRef.current = null
359
+ }
360
+ } else {
361
+ fetchingInProgress.current = false
263
362
 
264
- if (bufferUntilInitialCatchupComplete.current) {
265
- bufferUntilInitialCatchupComplete.current = false
266
- hasCompletedInitialCatchup.current = true
267
- flushBufferedRealtimeChunks()
363
+ // A reset was requested mid-fetch: re-arm and re-run with the queued
364
+ // params instead of finalizing on this (stale) fetch's results. The
365
+ // success path above may have already flipped the completion flags —
366
+ // reset them so the re-run isn't rejected by its own guards. A
367
+ // pending entry from a DIFFERENT dialog is discarded, never replayed
368
+ // against the current one.
369
+ //
370
+ // KNOWN LIMIT: the stale fetch may already have processed (and
371
+ // flushed) chunks NEWER than the queued gap, so on an extreme
372
+ // double-flap the gap's chunks can render after later ones — order
373
+ // skew inside the bubble, but no content loss (strictly better than
374
+ // the pre-fix permanent hole). Perfect ordering would require holding
375
+ // the stale fetch's flush until the re-run completes, which isn't
376
+ // worth the complexity for this corner.
377
+ const pending = pendingCatchupRef.current
378
+ if (pending) {
379
+ pendingCatchupRef.current = null
380
+ if (pending.dialogId === dialogId) {
381
+ hasCompletedInitialCatchup.current = false
382
+ lastFetchParams.current = null
383
+ bufferUntilInitialCatchupComplete.current = true
384
+ // AWAIT the re-run (not fire-and-forget): callers chain their own
385
+ // finally on this promise — the adapter lowers its onAgentBusy
386
+ // suppression there, and a detached re-run would replay a dead
387
+ // tail's EXECUTING chunk with suppression already off, locking
388
+ // the composer with no MESSAGE_END ever coming.
389
+ try {
390
+ await catchUpChunksRef.current?.(pending.fromSequenceId)
391
+ } catch (rerunError) {
392
+ console.error('Queued catch-up re-run failed:', rerunError)
393
+ }
394
+ } else if (bufferUntilInitialCatchupComplete.current) {
395
+ bufferUntilInitialCatchupComplete.current = false
396
+ hasCompletedInitialCatchup.current = true
397
+ flushBufferedRealtimeChunks()
398
+ }
399
+ } else if (bufferUntilInitialCatchupComplete.current) {
400
+ bufferUntilInitialCatchupComplete.current = false
401
+ hasCompletedInitialCatchup.current = true
402
+ flushBufferedRealtimeChunks()
403
+ }
268
404
  }
269
405
  }
270
406
  }, [flushBufferedRealtimeChunks])
271
407
 
408
+ // Self-reference for the queued re-run above (the callback can't name
409
+ // itself inside its own useCallback initializer).
410
+ const catchUpChunksRef = useRef<typeof catchUpChunks | null>(null)
411
+ catchUpChunksRef.current = catchUpChunks
412
+
272
413
  /**
273
414
  * Reset all tracking state
274
415
  */
275
416
  const resetChunkTracking = useCallback(() => {
276
417
  processedSequenceKeys.current.clear()
277
418
  lastSequenceId.current = null
419
+ lastSequenceIdByType.current.clear()
278
420
  fetchingInProgress.current = false
279
421
  lastFetchParams.current = null
422
+ pendingCatchupRef.current = null
280
423
  chunkBuffer.current = []
281
424
  bufferUntilInitialCatchupComplete.current = false
282
425
  hasCompletedInitialCatchup.current = false
@@ -286,8 +429,13 @@ export function useChunkCatchup({
286
429
  * Start buffering NATS chunks for initial catchup
287
430
  */
288
431
  const startInitialBuffering = useCallback(() => {
289
- chunkBuffer.current = []
290
- bufferUntilInitialCatchupComplete.current = true
432
+ // Idempotent: a second reconnect while a back-fill is already buffering
433
+ // (its history refetch still awaiting) must NOT clear the live chunks
434
+ // collected so far — only a fresh start owns the buffer.
435
+ if (!bufferUntilInitialCatchupComplete.current) {
436
+ chunkBuffer.current = []
437
+ bufferUntilInitialCatchupComplete.current = true
438
+ }
291
439
  hasCompletedInitialCatchup.current = false
292
440
  }, [])
293
441
 
@@ -305,8 +453,15 @@ export function useChunkCatchup({
305
453
  const fromSeq = lastSequenceId.current
306
454
  hasCompletedInitialCatchup.current = false
307
455
  lastFetchParams.current = null
308
- bufferUntilInitialCatchupComplete.current = true
309
- chunkBuffer.current = []
456
+ if (!bufferUntilInitialCatchupComplete.current) {
457
+ // Starting buffering fresh — drop stale leftovers. When the CALLER
458
+ // already armed buffering (e.g. `startInitialBuffering()` before an
459
+ // async history refetch on reconnect), KEEP the chunks it collected:
460
+ // clearing here would silently discard everything delivered during
461
+ // that await.
462
+ chunkBuffer.current = []
463
+ bufferUntilInitialCatchupComplete.current = true
464
+ }
310
465
  await catchUpChunks(fromSeq)
311
466
  }, [catchUpChunks])
312
467
 
@@ -213,6 +213,18 @@ export function useJetStreamDialogSubscription({
213
213
  }
214
214
  }, [enabled, wsUrl])
215
215
 
216
+ // Reset the highest-seen sequence whenever the dialog changes so a new dialog
217
+ // starts from optStartSeq (or DeliverPolicy.New) rather than the previous
218
+ // dialog's offset. MUST be declared BEFORE the subscription effect below:
219
+ // React runs effects in declaration order, and when this ran after it, the
220
+ // new dialog's consumer was created with the PREVIOUS dialog's stale
221
+ // `highestStreamSeq + 1` as its start sequence (skipping messages /
222
+ // ignoring optStartSeq).
223
+ useEffect(() => {
224
+ highestStreamSeqRef.current = null
225
+ setCurrentStreamSeq(null)
226
+ }, [dialogId])
227
+
216
228
  // Subscription lifecycle: (re)create the ephemeral JetStream consumer whenever
217
229
  // we transition into a connected state for a dialog, and whenever the dialog
218
230
  // changes. On reconnect we resume from highestStreamSeq + 1.
@@ -315,13 +327,5 @@ export function useJetStreamDialogSubscription({
315
327
  }
316
328
  }, [enabled, dialogId, isConnected, streamName, topic, reconnectionCount])
317
329
 
318
- // Reset the highest-seen sequence whenever the dialog changes so a new dialog
319
- // starts from optStartSeq (or DeliverPolicy.New) rather than the previous
320
- // dialog's offset.
321
- useEffect(() => {
322
- highestStreamSeqRef.current = null
323
- setCurrentStreamSeq(null)
324
- }, [dialogId])
325
-
326
330
  return { isConnected, isSubscribed, reconnectionCount, currentStreamSeq }
327
331
  }