@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.
- package/dist/{chunk-DTMJWBDQ.js → chunk-22NSWMI6.js} +3 -3
- package/dist/{chunk-NDWIFCLS.js → chunk-3BHV2QAH.js} +555 -135
- package/dist/{chunk-NDWIFCLS.js.map → chunk-3BHV2QAH.js.map} +1 -1
- package/dist/{chunk-PMTR5W5G.js → chunk-3DQ5BFPM.js} +5 -5
- package/dist/{chunk-RALJEQWM.cjs → chunk-3HYSD7NR.cjs} +15 -15
- package/dist/{chunk-RALJEQWM.cjs.map → chunk-3HYSD7NR.cjs.map} +1 -1
- package/dist/{chunk-YP7VJPCJ.cjs → chunk-5DWPXK37.cjs} +40 -40
- package/dist/{chunk-YP7VJPCJ.cjs.map → chunk-5DWPXK37.cjs.map} +1 -1
- package/dist/{chunk-OXZLBIR7.cjs → chunk-5FUQTSTT.cjs} +18 -18
- package/dist/{chunk-OXZLBIR7.cjs.map → chunk-5FUQTSTT.cjs.map} +1 -1
- package/dist/{chunk-MG4AMIFX.js → chunk-5NYXGWBN.js} +2 -2
- package/dist/{chunk-LCJGOV6P.js → chunk-7R256DKK.js} +2 -2
- package/dist/{chunk-2PLNDTRG.cjs → chunk-DTLNCOSG.cjs} +32 -32
- package/dist/{chunk-2PLNDTRG.cjs.map → chunk-DTLNCOSG.cjs.map} +1 -1
- package/dist/{chunk-3SCNEGL2.js → chunk-FIAXFNAA.js} +5 -5
- package/dist/{chunk-FQASXKBK.js → chunk-G2KXNMFQ.js} +2 -2
- package/dist/{chunk-H7H3AFLF.js → chunk-H2DYCT6M.js} +2 -2
- package/dist/{chunk-CE3AITV2.js → chunk-H352AZJN.js} +6 -6
- package/dist/{chunk-DCDB3SM4.js → chunk-K4RASNQM.js} +3 -3
- package/dist/{chunk-WXU6A6CA.cjs → chunk-KISQUH33.cjs} +73 -73
- package/dist/{chunk-WXU6A6CA.cjs.map → chunk-KISQUH33.cjs.map} +1 -1
- package/dist/{chunk-BYTJNRJW.cjs → chunk-MPSA6YAM.cjs} +40 -40
- package/dist/{chunk-BYTJNRJW.cjs.map → chunk-MPSA6YAM.cjs.map} +1 -1
- package/dist/{chunk-ABIRMYG5.js → chunk-MQUS6BFR.js} +6 -6
- package/dist/{chunk-DSW6FIEF.cjs → chunk-N64VTHZ5.cjs} +7 -7
- package/dist/{chunk-DSW6FIEF.cjs.map → chunk-N64VTHZ5.cjs.map} +1 -1
- package/dist/{chunk-F6NMMPDL.cjs → chunk-P3FN4NAE.cjs} +29 -29
- package/dist/{chunk-F6NMMPDL.cjs.map → chunk-P3FN4NAE.cjs.map} +1 -1
- package/dist/{chunk-PHWQLKVE.js → chunk-P47NN7RY.js} +29 -31
- package/dist/chunk-P47NN7RY.js.map +1 -0
- package/dist/{chunk-2CJ734N2.cjs → chunk-PTT4J5GF.cjs} +3 -3
- package/dist/{chunk-2CJ734N2.cjs.map → chunk-PTT4J5GF.cjs.map} +1 -1
- package/dist/{chunk-4R3SVOKH.cjs → chunk-QL2EI56X.cjs} +797 -377
- package/dist/chunk-QL2EI56X.cjs.map +1 -0
- package/dist/{chunk-VI2QQIGR.cjs → chunk-R6WGCJBB.cjs} +73 -73
- package/dist/{chunk-VI2QQIGR.cjs.map → chunk-R6WGCJBB.cjs.map} +1 -1
- package/dist/{chunk-Q6XOEVPH.cjs → chunk-RXLMN6B5.cjs} +12 -12
- package/dist/{chunk-Q6XOEVPH.cjs.map → chunk-RXLMN6B5.cjs.map} +1 -1
- package/dist/{chunk-F2XHBAHT.js → chunk-TCEBFMLQ.js} +2 -2
- package/dist/{chunk-7CZ4MJSI.cjs → chunk-VIJARDBM.cjs} +3 -3
- package/dist/{chunk-7CZ4MJSI.cjs.map → chunk-VIJARDBM.cjs.map} +1 -1
- package/dist/{chunk-PXXS27EE.cjs → chunk-W24U7ZUV.cjs} +40 -42
- package/dist/chunk-W24U7ZUV.cjs.map +1 -0
- package/dist/{chunk-37ZZQXHK.js → chunk-XPZLIONZ.js} +4 -4
- package/dist/{chunk-FO4CI4R5.cjs → chunk-YCLSV2H6.cjs} +5 -5
- package/dist/{chunk-FO4CI4R5.cjs.map → chunk-YCLSV2H6.cjs.map} +1 -1
- package/dist/{chunk-U7U2CZBZ.js → chunk-Z4N7XLFG.js} +3 -3
- package/dist/components/case-studies/index.cjs +10 -10
- package/dist/components/case-studies/index.js +4 -4
- package/dist/components/chat/hooks/use-chunk-catchup.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-jetstream-dialog-subscription.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts +44 -1
- package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-nats-dialog-subscription.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts.map +1 -1
- package/dist/components/chat/index.cjs +10 -4
- package/dist/components/chat/index.cjs.map +1 -1
- package/dist/components/chat/index.js +9 -3
- package/dist/components/chat/utils/message-segment-accumulator.d.ts.map +1 -1
- package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
- package/dist/components/contact/index.cjs +5 -5
- package/dist/components/contact/index.js +4 -4
- package/dist/components/docs/index.cjs +7 -7
- package/dist/components/docs/index.js +6 -6
- package/dist/components/embeds/index.cjs +5 -5
- package/dist/components/embeds/index.js +4 -4
- package/dist/components/faq/index.cjs +6 -6
- package/dist/components/faq/index.js +5 -5
- package/dist/components/features/index.cjs +4 -4
- package/dist/components/features/index.js +3 -3
- package/dist/components/help-center-pages/index.cjs +23 -23
- package/dist/components/help-center-pages/index.js +14 -14
- package/dist/components/index.cjs +142 -136
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +18 -12
- package/dist/components/index.js.map +1 -1
- package/dist/components/navigation/index.cjs +4 -4
- package/dist/components/navigation/index.js +3 -3
- package/dist/components/onboarding-guides/index.cjs +8 -8
- package/dist/components/onboarding-guides/index.js +7 -7
- package/dist/components/related-content/index.cjs +6 -6
- package/dist/components/related-content/index.js +5 -5
- package/dist/components/tickets/index.cjs +7 -7
- package/dist/components/tickets/index.js +6 -6
- package/dist/components/ui/index.cjs +10 -4
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.js +9 -3
- package/dist/hooks/index.cjs +3 -3
- package/dist/hooks/index.js +2 -2
- package/dist/index.cjs +10 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +9 -3
- package/dist/nats/index.cjs +2 -2
- package/dist/nats/index.js +1 -1
- package/dist/nats/shared-connection.d.ts +6 -0
- package/dist/nats/shared-connection.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/chat/hooks/__tests__/nats-adapter-message-updaters.test.ts +270 -0
- package/src/components/chat/hooks/use-chunk-catchup.ts +212 -57
- package/src/components/chat/hooks/use-jetstream-dialog-subscription.ts +12 -8
- package/src/components/chat/hooks/use-nats-chat-adapter.ts +574 -9
- package/src/components/chat/hooks/use-nats-dialog-subscription.ts +12 -6
- package/src/components/chat/hooks/use-realtime-chunk-processor.ts +40 -3
- package/src/components/chat/utils/__tests__/process-historical-messages-approvals.test.ts +62 -0
- package/src/components/chat/utils/message-segment-accumulator.ts +6 -0
- package/src/components/chat/utils/process-historical-messages.ts +25 -3
- package/src/nats/shared-connection.ts +42 -29
- package/dist/chunk-4R3SVOKH.cjs.map +0 -1
- package/dist/chunk-PHWQLKVE.js.map +0 -1
- package/dist/chunk-PXXS27EE.cjs.map +0 -1
- /package/dist/{chunk-DTMJWBDQ.js.map → chunk-22NSWMI6.js.map} +0 -0
- /package/dist/{chunk-PMTR5W5G.js.map → chunk-3DQ5BFPM.js.map} +0 -0
- /package/dist/{chunk-MG4AMIFX.js.map → chunk-5NYXGWBN.js.map} +0 -0
- /package/dist/{chunk-LCJGOV6P.js.map → chunk-7R256DKK.js.map} +0 -0
- /package/dist/{chunk-3SCNEGL2.js.map → chunk-FIAXFNAA.js.map} +0 -0
- /package/dist/{chunk-FQASXKBK.js.map → chunk-G2KXNMFQ.js.map} +0 -0
- /package/dist/{chunk-H7H3AFLF.js.map → chunk-H2DYCT6M.js.map} +0 -0
- /package/dist/{chunk-CE3AITV2.js.map → chunk-H352AZJN.js.map} +0 -0
- /package/dist/{chunk-DCDB3SM4.js.map → chunk-K4RASNQM.js.map} +0 -0
- /package/dist/{chunk-ABIRMYG5.js.map → chunk-MQUS6BFR.js.map} +0 -0
- /package/dist/{chunk-F2XHBAHT.js.map → chunk-TCEBFMLQ.js.map} +0 -0
- /package/dist/{chunk-37ZZQXHK.js.map → chunk-XPZLIONZ.js.map} +0 -0
- /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
|
-
|
|
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 ??
|
|
113
|
-
const seqB = b.chunk.sequenceId ??
|
|
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
|
-
|
|
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 ??
|
|
189
|
-
const seqB = b.chunk.sequenceId ??
|
|
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
|
|
198
|
-
if (
|
|
199
|
-
|
|
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
|
-
//
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
290
|
-
|
|
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
|
|
309
|
-
|
|
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
|
}
|