@flamingo-stack/openframe-frontend-core 0.0.278 → 0.0.279
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-MVUN35FH.js → chunk-6ZMD3KBP.js} +2 -2
- package/dist/{chunk-DO55GWNT.cjs → chunk-776ZURDZ.cjs} +10 -10
- package/dist/{chunk-DO55GWNT.cjs.map → chunk-776ZURDZ.cjs.map} +1 -1
- package/dist/{chunk-5NL62LIF.js → chunk-7ECI5ZVO.js} +2 -2
- package/dist/{chunk-BBDJCXHW.cjs → chunk-7XVVRE53.cjs} +6 -6
- package/dist/{chunk-BBDJCXHW.cjs.map → chunk-7XVVRE53.cjs.map} +1 -1
- package/dist/{chunk-GO45CXT5.js → chunk-AVLSICZO.js} +49 -33
- package/dist/chunk-AVLSICZO.js.map +1 -0
- package/dist/{chunk-L4BJU5CE.cjs → chunk-QP4H2JJN.cjs} +34 -34
- package/dist/{chunk-L4BJU5CE.cjs.map → chunk-QP4H2JJN.cjs.map} +1 -1
- package/dist/{chunk-MDHNLZME.js → chunk-SZG56DA3.js} +2 -2
- package/dist/{chunk-WYQ5VFXA.cjs → chunk-XBTQK32O.cjs} +25 -25
- package/dist/{chunk-WYQ5VFXA.cjs.map → chunk-XBTQK32O.cjs.map} +1 -1
- package/dist/{chunk-737ZLQ2T.js → chunk-YK6FFIVG.js} +2 -2
- package/dist/{chunk-7LTWNUHB.cjs → chunk-Z53YSXDD.cjs} +149 -133
- package/dist/chunk-Z53YSXDD.cjs.map +1 -0
- package/dist/components/chat/chat-message-enhanced.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 +2 -2
- package/dist/components/chat/index.js +1 -1
- package/dist/components/chat/types/api.types.d.ts +7 -2
- package/dist/components/chat/types/api.types.d.ts.map +1 -1
- package/dist/components/chat/types/message.types.d.ts +10 -0
- package/dist/components/chat/types/message.types.d.ts.map +1 -1
- package/dist/components/chat/types/network.types.d.ts +2 -0
- package/dist/components/chat/types/network.types.d.ts.map +1 -1
- package/dist/components/chat/types/processing.types.d.ts +1 -0
- package/dist/components/chat/types/processing.types.d.ts.map +1 -1
- package/dist/components/chat/utils/chunk-parser.d.ts.map +1 -1
- package/dist/components/chat/utils/history-merge.d.ts +8 -0
- package/dist/components/chat/utils/history-merge.d.ts.map +1 -1
- package/dist/components/chat/utils/message-segment-accumulator.d.ts +5 -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 +3 -3
- package/dist/components/contact/index.js +2 -2
- package/dist/components/faq/index.cjs +3 -3
- package/dist/components/faq/index.js +2 -2
- package/dist/components/features/index.cjs +2 -2
- package/dist/components/features/index.js +1 -1
- package/dist/components/index.cjs +53 -53
- package/dist/components/index.js +4 -4
- package/dist/components/navigation/index.cjs +2 -2
- package/dist/components/navigation/index.js +1 -1
- package/dist/components/related-content/index.cjs +3 -3
- package/dist/components/related-content/index.js +2 -2
- package/dist/components/tickets/index.cjs +52 -52
- package/dist/components/tickets/index.js +3 -3
- package/dist/components/ui/index.cjs +2 -2
- package/dist/components/ui/index.js +1 -1
- package/dist/index.cjs +2 -2
- package/dist/index.js +1 -1
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/chat/chat-message-enhanced.tsx +1 -0
- package/src/components/chat/hooks/use-realtime-chunk-processor.ts +34 -21
- package/src/components/chat/types/api.types.ts +7 -2
- package/src/components/chat/types/message.types.ts +10 -0
- package/src/components/chat/types/network.types.ts +2 -0
- package/src/components/chat/types/processing.types.ts +1 -1
- package/src/components/chat/utils/__tests__/history-merge.test.ts +70 -0
- package/src/components/chat/utils/chunk-parser.ts +12 -1
- package/src/components/chat/utils/history-merge.ts +22 -2
- package/src/components/chat/utils/message-segment-accumulator.ts +10 -4
- package/src/components/chat/utils/process-historical-messages.ts +4 -1
- package/dist/chunk-7LTWNUHB.cjs.map +0 -1
- package/dist/chunk-GO45CXT5.js.map +0 -1
- /package/dist/{chunk-MVUN35FH.js.map → chunk-6ZMD3KBP.js.map} +0 -0
- /package/dist/{chunk-5NL62LIF.js.map → chunk-7ECI5ZVO.js.map} +0 -0
- /package/dist/{chunk-MDHNLZME.js.map → chunk-SZG56DA3.js.map} +0 -0
- /package/dist/{chunk-737ZLQ2T.js.map → chunk-YK6FFIVG.js.map} +0 -0
package/package.json
CHANGED
|
@@ -492,6 +492,7 @@ const ChatMessageEnhanced = forwardRef<HTMLDivElement, ChatMessageEnhancedProps>
|
|
|
492
492
|
key={index}
|
|
493
493
|
data={segment.data}
|
|
494
494
|
status={segment.status}
|
|
495
|
+
resolvedByName={segment.resolvedByName}
|
|
495
496
|
onApprove={segment.onApprove}
|
|
496
497
|
onReject={segment.onReject}
|
|
497
498
|
/>
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
createMessageSegmentAccumulator,
|
|
13
13
|
} from '../utils/message-segment-accumulator'
|
|
14
14
|
import { MESSAGE_TYPE } from '../types'
|
|
15
|
-
import type { UseRealtimeChunkProcessorReturn, UseRealtimeChunkProcessorOptions, ChatApprovalStatus, PendingToolCallData } from '../types'
|
|
15
|
+
import type { UseRealtimeChunkProcessorReturn, UseRealtimeChunkProcessorOptions, ChatApprovalStatus, PendingToolCallData, MessageSegment, SegmentsUpdateMetadata } from '../types'
|
|
16
16
|
import { getCommandText } from '../utils/tool-call-helpers'
|
|
17
17
|
|
|
18
18
|
// Actions allowed through once the dialog is in direct mode. Everything else is
|
|
@@ -107,6 +107,17 @@ export function useRealtimeChunkProcessor(
|
|
|
107
107
|
|
|
108
108
|
const accumulator = accumulatorRef.current
|
|
109
109
|
|
|
110
|
+
// streamSeq of the chunk currently being processed (JetStream only).
|
|
111
|
+
// Only content emissions below carry it through to the host, so the
|
|
112
|
+
// streaming bubble is stamped with the highest CONTENT seq it saw — the
|
|
113
|
+
// non-persisted MESSAGE_END / TOKEN_USAGE chunks never reach emitSegments.
|
|
114
|
+
const streamSeq =
|
|
115
|
+
chunk && typeof chunk === 'object' && typeof (chunk as { streamSeq?: unknown }).streamSeq === 'number'
|
|
116
|
+
? (chunk as { streamSeq: number }).streamSeq
|
|
117
|
+
: undefined
|
|
118
|
+
const emitSegments = (s: MessageSegment[], meta?: SegmentsUpdateMetadata) =>
|
|
119
|
+
callbacks.onSegmentsUpdate?.(s, streamSeq != null ? { ...meta, streamSeq } : meta)
|
|
120
|
+
|
|
110
121
|
if (action.action === 'direct_message') sawDirectMessageRef.current = true
|
|
111
122
|
|
|
112
123
|
if ((directModeFlagRef.current || sawDirectMessageRef.current) && !DIRECT_MODE_ALLOWED.has(action.action)) {
|
|
@@ -146,9 +157,9 @@ export function useRealtimeChunkProcessor(
|
|
|
146
157
|
// spawn the first assistant bubble — otherwise appendSegmentsToLastAssistant
|
|
147
158
|
// silently drops the chunk when no last assistant exists.
|
|
148
159
|
if (isInStreamRef.current || !hasEverStreamedRef.current) {
|
|
149
|
-
|
|
160
|
+
emitSegments(segments)
|
|
150
161
|
} else {
|
|
151
|
-
|
|
162
|
+
emitSegments([{ type: 'text', text: action.text }], { append: true })
|
|
152
163
|
}
|
|
153
164
|
break
|
|
154
165
|
}
|
|
@@ -156,9 +167,9 @@ export function useRealtimeChunkProcessor(
|
|
|
156
167
|
case 'thinking': {
|
|
157
168
|
const segments = accumulator.appendThinking(action.text)
|
|
158
169
|
if (isInStreamRef.current || !hasEverStreamedRef.current) {
|
|
159
|
-
|
|
170
|
+
emitSegments(segments)
|
|
160
171
|
} else {
|
|
161
|
-
|
|
172
|
+
emitSegments([{ type: 'thinking', text: action.text }], { append: true })
|
|
162
173
|
}
|
|
163
174
|
break
|
|
164
175
|
}
|
|
@@ -178,7 +189,7 @@ export function useRealtimeChunkProcessor(
|
|
|
178
189
|
// cross-message scan is first-match-wins and could touch a
|
|
179
190
|
// same-execId segment in a prior bubble (agent retry case).
|
|
180
191
|
const segments = accumulator.addToolExecution(action.segment)
|
|
181
|
-
|
|
192
|
+
emitSegments(segments)
|
|
182
193
|
break
|
|
183
194
|
}
|
|
184
195
|
|
|
@@ -195,7 +206,7 @@ export function useRealtimeChunkProcessor(
|
|
|
195
206
|
approvalType,
|
|
196
207
|
status as ChatApprovalStatus
|
|
197
208
|
)
|
|
198
|
-
|
|
209
|
+
emitSegments(segments)
|
|
199
210
|
} else {
|
|
200
211
|
// Track as escalated
|
|
201
212
|
pendingEscalatedRef.current.set(requestId, { command, explanation, approvalType })
|
|
@@ -229,7 +240,7 @@ export function useRealtimeChunkProcessor(
|
|
|
229
240
|
|
|
230
241
|
if (batchApprovalsEnabled) {
|
|
231
242
|
const segments = accumulator.addApprovalBatch(requestId, approvalType, toolCalls, status)
|
|
232
|
-
|
|
243
|
+
emitSegments(segments)
|
|
233
244
|
break
|
|
234
245
|
}
|
|
235
246
|
|
|
@@ -248,12 +259,12 @@ export function useRealtimeChunkProcessor(
|
|
|
248
259
|
status,
|
|
249
260
|
)
|
|
250
261
|
}
|
|
251
|
-
|
|
262
|
+
emitSegments(segments)
|
|
252
263
|
break
|
|
253
264
|
}
|
|
254
265
|
|
|
255
266
|
case 'approval_result': {
|
|
256
|
-
const { requestId, approved, approvalType } = action
|
|
267
|
+
const { requestId, approved, approvalType, resolvedByName } = action
|
|
257
268
|
const escalatedData = pendingEscalatedRef.current.get(requestId)
|
|
258
269
|
const status: ChatApprovalStatus = approved ? 'approved' : 'rejected'
|
|
259
270
|
|
|
@@ -272,8 +283,10 @@ export function useRealtimeChunkProcessor(
|
|
|
272
283
|
escalatedData.approvalType,
|
|
273
284
|
escalatedData.toolCalls,
|
|
274
285
|
status,
|
|
286
|
+
undefined,
|
|
287
|
+
resolvedByName,
|
|
275
288
|
)
|
|
276
|
-
|
|
289
|
+
emitSegments(segments)
|
|
277
290
|
} else {
|
|
278
291
|
let segments = accumulator.getSegments()
|
|
279
292
|
for (const call of escalatedData.toolCalls) {
|
|
@@ -286,7 +299,7 @@ export function useRealtimeChunkProcessor(
|
|
|
286
299
|
status,
|
|
287
300
|
)
|
|
288
301
|
}
|
|
289
|
-
|
|
302
|
+
emitSegments(segments)
|
|
290
303
|
}
|
|
291
304
|
} else {
|
|
292
305
|
const segments = accumulator.addApprovalRequest(
|
|
@@ -296,12 +309,12 @@ export function useRealtimeChunkProcessor(
|
|
|
296
309
|
escalatedData.approvalType,
|
|
297
310
|
status,
|
|
298
311
|
)
|
|
299
|
-
|
|
312
|
+
emitSegments(segments)
|
|
300
313
|
}
|
|
301
314
|
} else {
|
|
302
315
|
// Always keep the in-memory accumulator in sync so a following
|
|
303
316
|
// text/tool chunk replays the resolved status into the message.
|
|
304
|
-
accumulator.updateApprovalStatus(requestId, status)
|
|
317
|
+
accumulator.updateApprovalStatus(requestId, status, resolvedByName)
|
|
305
318
|
// When the consumer wires cross-message resolution via
|
|
306
319
|
// `onApprovalResolved`, skip `onSegmentsUpdate` here: this path
|
|
307
320
|
// routes through `ensureAssistantMessage` + `updateStreamingMessageSegments`,
|
|
@@ -309,10 +322,10 @@ export function useRealtimeChunkProcessor(
|
|
|
309
322
|
// accumulator's segments into it — turning a status flip into a
|
|
310
323
|
// bubble overwrite that wipes the original card.
|
|
311
324
|
if (!callbacks.onApprovalResolved) {
|
|
312
|
-
|
|
325
|
+
emitSegments(accumulator.getSegments())
|
|
313
326
|
}
|
|
314
327
|
}
|
|
315
|
-
callbacks.onApprovalResolved?.(requestId, status, approvalType)
|
|
328
|
+
callbacks.onApprovalResolved?.(requestId, status, approvalType, resolvedByName)
|
|
316
329
|
break
|
|
317
330
|
}
|
|
318
331
|
|
|
@@ -326,7 +339,7 @@ export function useRealtimeChunkProcessor(
|
|
|
326
339
|
}
|
|
327
340
|
}
|
|
328
341
|
const segments = accumulator.addError(action.error, message)
|
|
329
|
-
|
|
342
|
+
emitSegments(segments)
|
|
330
343
|
callbacks.onError?.(action.error, message)
|
|
331
344
|
break
|
|
332
345
|
}
|
|
@@ -360,14 +373,14 @@ export function useRealtimeChunkProcessor(
|
|
|
360
373
|
case 'context_compaction_start': {
|
|
361
374
|
const standalone = !isInStreamRef.current
|
|
362
375
|
const segments = accumulator.addContextCompaction()
|
|
363
|
-
|
|
376
|
+
emitSegments(segments, standalone ? { append: true, isCompacting: true } : undefined)
|
|
364
377
|
break
|
|
365
378
|
}
|
|
366
379
|
|
|
367
380
|
case 'context_compaction_end': {
|
|
368
381
|
const standalone = !isInStreamRef.current
|
|
369
382
|
const segments = accumulator.completeContextCompaction(action.summary)
|
|
370
|
-
|
|
383
|
+
emitSegments(segments, standalone ? { append: true, isCompacting: true } : undefined)
|
|
371
384
|
break
|
|
372
385
|
}
|
|
373
386
|
|
|
@@ -395,8 +408,8 @@ export function useRealtimeChunkProcessor(
|
|
|
395
408
|
}, [])
|
|
396
409
|
|
|
397
410
|
const updateApprovalStatus = useCallback(
|
|
398
|
-
(requestId: string, status: ChatApprovalStatus) => {
|
|
399
|
-
return accumulatorRef.current.updateApprovalStatus(requestId, status)
|
|
411
|
+
(requestId: string, status: ChatApprovalStatus, resolvedByName?: string | null) => {
|
|
412
|
+
return accumulatorRef.current.updateApprovalStatus(requestId, status, resolvedByName)
|
|
400
413
|
},
|
|
401
414
|
[]
|
|
402
415
|
)
|
|
@@ -146,6 +146,11 @@ export interface SegmentsUpdateMetadata {
|
|
|
146
146
|
append?: boolean
|
|
147
147
|
/** The update was triggered by context compaction */
|
|
148
148
|
isCompacting?: boolean
|
|
149
|
+
/** streamSeq of the content chunk that produced this update, when the
|
|
150
|
+
* transport carries one (JetStream). Hosts stamp it onto the streaming
|
|
151
|
+
* message so `mergeHistoryWithRealtime` can dedup per-message against
|
|
152
|
+
* persisted history. Undefined for legacy NATS chunks. */
|
|
153
|
+
streamSeq?: number
|
|
149
154
|
}
|
|
150
155
|
|
|
151
156
|
export interface RealtimeChunkCallbacks {
|
|
@@ -183,7 +188,7 @@ export interface RealtimeChunkCallbacks {
|
|
|
183
188
|
* a new assistant message is streaming). Idempotent — safe to no-op if no
|
|
184
189
|
* matching segment is found dialog-wide.
|
|
185
190
|
*/
|
|
186
|
-
onApprovalResolved?: (requestId: string, status: ChatApprovalStatus, approvalType: string) => void
|
|
191
|
+
onApprovalResolved?: (requestId: string, status: ChatApprovalStatus, approvalType: string, resolvedByName?: string | null) => void
|
|
187
192
|
/**
|
|
188
193
|
* Called whenever an `EXECUTED_TOOL` chunk is processed. Lets consumers
|
|
189
194
|
* merge the result into the originating `EXECUTING_TOOL` (or batch
|
|
@@ -267,7 +272,7 @@ export interface UseRealtimeChunkProcessorReturn {
|
|
|
267
272
|
/** Reset the accumulator */
|
|
268
273
|
reset: () => void
|
|
269
274
|
/** Update approval status for a request */
|
|
270
|
-
updateApprovalStatus: (requestId: string, status: ChatApprovalStatus) => MessageSegment[]
|
|
275
|
+
updateApprovalStatus: (requestId: string, status: ChatApprovalStatus, resolvedByName?: string | null) => MessageSegment[]
|
|
271
276
|
/** Get pending approval requests */
|
|
272
277
|
getPendingApprovals: () => Map<
|
|
273
278
|
string,
|
|
@@ -107,6 +107,8 @@ export interface ApprovalResultData {
|
|
|
107
107
|
approvalRequestId: string
|
|
108
108
|
approved: boolean
|
|
109
109
|
approvalType?: string
|
|
110
|
+
/** Display name of the user who resolved the request; null/absent for system actions. */
|
|
111
|
+
resolvedByName?: string | null
|
|
110
112
|
}
|
|
111
113
|
|
|
112
114
|
/**
|
|
@@ -177,6 +179,8 @@ export type ApprovalBatchSegment = {
|
|
|
177
179
|
type: 'approval_batch'
|
|
178
180
|
data: ApprovalBatchData
|
|
179
181
|
status?: ChatApprovalStatus
|
|
182
|
+
/** Display name of the user who resolved the request; set when the batch is resolved (null/absent for system actions). */
|
|
183
|
+
resolvedByName?: string | null
|
|
180
184
|
onApprove?: (requestId?: string) => void | Promise<void>
|
|
181
185
|
onReject?: (requestId?: string) => void | Promise<void>
|
|
182
186
|
}
|
|
@@ -250,6 +254,8 @@ export interface ApprovalResultMessageData extends MessageDataBase {
|
|
|
250
254
|
approvalRequestId?: string
|
|
251
255
|
approved?: boolean
|
|
252
256
|
approvalType?: string
|
|
257
|
+
/** Display name of the user who resolved the request; null/absent for system actions. */
|
|
258
|
+
resolvedByName?: string | null
|
|
253
259
|
}
|
|
254
260
|
|
|
255
261
|
export interface ErrorMessageData extends MessageDataBase {
|
|
@@ -337,6 +343,10 @@ export interface Message {
|
|
|
337
343
|
authorType?: AuthorType
|
|
338
344
|
timestamp?: Date
|
|
339
345
|
avatar?: string | null
|
|
346
|
+
/** Highest CONTENT chunk streamSeq that composed this message. Stamped on
|
|
347
|
+
* realtime synthetics so `mergeHistoryWithRealtime` can decide history
|
|
348
|
+
* coverage per-message (see `MergeableChatMessage.streamSeq`). */
|
|
349
|
+
streamSeq?: number
|
|
340
350
|
/** Per-row metadata for inline entity-card rendering on this message
|
|
341
351
|
* (v6.1 §B.2.6). Keyed by `<documentType>:<primaryKey>`. Optional —
|
|
342
352
|
* user messages and legacy turns omit this field. The host's
|
|
@@ -56,6 +56,8 @@ export interface ChunkData {
|
|
|
56
56
|
command?: string
|
|
57
57
|
explanation?: string
|
|
58
58
|
approved?: boolean
|
|
59
|
+
/** On APPROVAL_RESULT chunks: display name of the user who resolved the request. */
|
|
60
|
+
resolvedByName?: string
|
|
59
61
|
toolCalls?: any[]
|
|
60
62
|
modelName?: string
|
|
61
63
|
providerName?: string
|
|
@@ -19,7 +19,7 @@ export type ParsedChunkAction =
|
|
|
19
19
|
| { action: 'tool_execution'; segment: ToolExecutionSegment }
|
|
20
20
|
| { action: 'approval_request'; requestId: string; command: string; explanation?: string; approvalType: string }
|
|
21
21
|
| { action: 'approval_batch'; requestId: string; approvalType: string; toolCalls: PendingToolCallData[] }
|
|
22
|
-
| { action: 'approval_result'; requestId: string; approved: boolean; approvalType: string }
|
|
22
|
+
| { action: 'approval_result'; requestId: string; approved: boolean; approvalType: string; resolvedByName?: string | null }
|
|
23
23
|
| { action: 'message_request'; text: string; ownerType?: string; displayName?: string; userId?: string }
|
|
24
24
|
| { action: 'token_usage'; data: TokenUsageData }
|
|
25
25
|
| { action: 'direct_message'; text: string; ownerType?: string; displayName?: string; userId?: string }
|
|
@@ -294,6 +294,76 @@ describe('mergeHistoryWithRealtime', () => {
|
|
|
294
294
|
})
|
|
295
295
|
expect(ids(merged)).toContain(welcome.id)
|
|
296
296
|
})
|
|
297
|
+
|
|
298
|
+
// Production reality: the client consumes the non-persisted MESSAGE_END /
|
|
299
|
+
// final TOKEN_USAGE chunks, so `realtimeSeenStreamSeq` always ends up ABOVE
|
|
300
|
+
// `historyMaxStreamSeq` (no persisted message can carry the tail's seq).
|
|
301
|
+
// The single global coverage signal therefore reads "not covered" forever
|
|
302
|
+
// and a non-trailing synthetic survives next to its persisted twin (the
|
|
303
|
+
// reported duplication). The per-message `streamSeq` makes the decision
|
|
304
|
+
// exact and turn-local.
|
|
305
|
+
describe('per-message streamSeq coverage', () => {
|
|
306
|
+
const U2b: TestMessage = { id: 'aaaa0006', role: 'user', content: 'third question', timestamp: t(3000) }
|
|
307
|
+
const A2b: TestMessage = { id: 'aaaa0007', role: 'assistant', content: txt('history three'), timestamp: t(3100) }
|
|
308
|
+
|
|
309
|
+
it('drops an earlier finished synthetic by its own seq even though the global seq reads not-covered', () => {
|
|
310
|
+
const synEarly: TestMessage = { id: 'assistant-2100-x', role: 'assistant', content: txt('second answer'), timestamp: t(2100), streamSeq: 40 }
|
|
311
|
+
const synLate: TestMessage = { id: 'assistant-3100-x', role: 'assistant', content: txt('realtime three'), timestamp: t(3100), streamSeq: 80 }
|
|
312
|
+
const merged = mergeHistoryWithRealtime<TestMessage>({
|
|
313
|
+
processedHistory: [U0, A0, U1, A1, U2b, A2b],
|
|
314
|
+
rawHistoryIds: new Set([U0.id, A0.id, U1.id, A1.id, U2b.id, A2b.id]),
|
|
315
|
+
existingMessages: [U0, A0, U1, A1, U2b, A2b, synEarly, synLate],
|
|
316
|
+
streamingMessageId: null,
|
|
317
|
+
historyFetchedAt: 9000,
|
|
318
|
+
historyMaxStreamSeq: 80, // both turns fully persisted
|
|
319
|
+
realtimeSeenStreamSeq: 82, // biased past 80 by the consumed MESSAGE_END
|
|
320
|
+
})
|
|
321
|
+
// Global seq (80 >= 82) is false, but per-message seqs (40, 80) are both <= 80.
|
|
322
|
+
expect(ids(merged)).toEqual([U0.id, A0.id, U1.id, A1.id, U2b.id, A2b.id])
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
it('drops a partial mid-stream synthetic in favour of the full persisted turn', () => {
|
|
326
|
+
const partial: TestMessage = { id: 'assistant-2100-x', role: 'assistant', content: txt('second ans'), timestamp: t(2100), streamSeq: 50 }
|
|
327
|
+
const merged = mergeHistoryWithRealtime<TestMessage>({
|
|
328
|
+
processedHistory: [U0, A0, U1, A1],
|
|
329
|
+
rawHistoryIds: new Set([U0.id, A0.id, U1.id, A1.id]),
|
|
330
|
+
existingMessages: [U0, A0, U1, A1, partial],
|
|
331
|
+
streamingMessageId: null,
|
|
332
|
+
historyFetchedAt: 9000,
|
|
333
|
+
historyMaxStreamSeq: 80,
|
|
334
|
+
realtimeSeenStreamSeq: 82,
|
|
335
|
+
})
|
|
336
|
+
expect(ids(merged)).toEqual([U0.id, A0.id, U1.id, A1.id])
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
it('keeps a still-streaming later turn while dropping an earlier finished one', () => {
|
|
340
|
+
const synEarly: TestMessage = { id: 'assistant-2100-x', role: 'assistant', content: txt('second answer'), timestamp: t(2100), streamSeq: 40 }
|
|
341
|
+
const synLive: TestMessage = { id: 'assistant-3100-x', role: 'assistant', content: txt('third in progress'), timestamp: t(3100), streamSeq: 70 }
|
|
342
|
+
const merged = mergeHistoryWithRealtime<TestMessage>({
|
|
343
|
+
processedHistory: [U0, A0, U1, A1], // only turn 1 persisted so far
|
|
344
|
+
rawHistoryIds: new Set([U0.id, A0.id, U1.id, A1.id]),
|
|
345
|
+
existingMessages: [U0, A0, U1, A1, synEarly, synLive],
|
|
346
|
+
streamingMessageId: null,
|
|
347
|
+
historyFetchedAt: 9000,
|
|
348
|
+
historyMaxStreamSeq: 40, // turn 2 not persisted yet
|
|
349
|
+
realtimeSeenStreamSeq: 70,
|
|
350
|
+
})
|
|
351
|
+
// synEarly (40 <= 40) is covered and dropped; synLive (70 > 40) survives.
|
|
352
|
+
expect(ids(merged)).toEqual([U0.id, A0.id, U1.id, A1.id, synLive.id])
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
it('falls back to the global seq for an unstamped synthetic (legacy NATS)', () => {
|
|
356
|
+
const merged = mergeHistoryWithRealtime<TestMessage>({
|
|
357
|
+
processedHistory: [U0, A0, U1, A1],
|
|
358
|
+
existingMessages: [U0, A0, OPT1, SYN1], // SYN1 has no streamSeq
|
|
359
|
+
streamingMessageId: null,
|
|
360
|
+
historyFetchedAt: 5000,
|
|
361
|
+
historyMaxStreamSeq: 80,
|
|
362
|
+
realtimeSeenStreamSeq: 80,
|
|
363
|
+
})
|
|
364
|
+
expect(ids(merged)).toEqual([U0.id, A0.id, U1.id, A1.id])
|
|
365
|
+
})
|
|
366
|
+
})
|
|
297
367
|
})
|
|
298
368
|
|
|
299
369
|
describe('computeHistoryPrepend', () => {
|
|
@@ -122,13 +122,24 @@ export function parseChunkToAction(chunk: unknown): ParsedChunkAction | null {
|
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
case MESSAGE_TYPE.APPROVAL_RESULT:
|
|
125
|
+
case MESSAGE_TYPE.APPROVAL_RESULT: {
|
|
126
|
+
// The realtime NATS chunk carries the resolver's name as `displayName`; the
|
|
127
|
+
// persisted GraphQL message exposes the same value as `resolvedByName`. Accept
|
|
128
|
+
// either so realtime and history-replay render "by {name}" identically.
|
|
129
|
+
const resolvedByName =
|
|
130
|
+
typeof data.resolvedByName === 'string'
|
|
131
|
+
? data.resolvedByName
|
|
132
|
+
: typeof data.displayName === 'string'
|
|
133
|
+
? data.displayName
|
|
134
|
+
: undefined
|
|
126
135
|
return {
|
|
127
136
|
action: 'approval_result',
|
|
128
137
|
requestId: data.approvalRequestId || data.approval_request_id || '',
|
|
129
138
|
approved: data.approved === true,
|
|
130
139
|
approvalType: data.approvalType || 'CLIENT',
|
|
140
|
+
resolvedByName,
|
|
131
141
|
}
|
|
142
|
+
}
|
|
132
143
|
|
|
133
144
|
case MESSAGE_TYPE.ERROR:
|
|
134
145
|
return {
|
|
@@ -24,6 +24,14 @@ export interface MergeableChatMessage {
|
|
|
24
24
|
role: string
|
|
25
25
|
content: MessageContent
|
|
26
26
|
timestamp?: Date
|
|
27
|
+
/** Highest CONTENT chunk streamSeq that composed this message (text / tool /
|
|
28
|
+
* approval / error / compaction — never the non-persisted MESSAGE_END /
|
|
29
|
+
* TOKEN_USAGE control chunks). Hosts stamp it on realtime synthetics so the
|
|
30
|
+
* merge can decide coverage per-message: a synthetic is in history once
|
|
31
|
+
* `historyMaxStreamSeq >= streamSeq`. Optional — absent on history messages
|
|
32
|
+
* and on hosts that don't stamp it (those fall back to the global seq /
|
|
33
|
+
* wall-clock rule). */
|
|
34
|
+
streamSeq?: number
|
|
27
35
|
}
|
|
28
36
|
|
|
29
37
|
/** Ids minted client-side by realtime chunk processors
|
|
@@ -216,15 +224,27 @@ export function mergeHistoryWithRealtime<M extends MergeableChatMessage>(input:
|
|
|
216
224
|
// twice; one the snapshot cannot contain yet must be kept or a message
|
|
217
225
|
// the user already saw is lost, with no realtime replay to restore it
|
|
218
226
|
// (JetStream resumes after the highest seq this client has consumed).
|
|
219
|
-
// Decided by seq
|
|
227
|
+
// Decided PER-MESSAGE by its own content seq when stamped, else the global
|
|
228
|
+
// seq coverage, else wall-clock.
|
|
220
229
|
if (isSyntheticRealtimeId(m.id) && m.id !== streamingMessageId) {
|
|
221
230
|
// A non-streaming synthetic that re-renders the trailing persisted assistant verbatim is its
|
|
222
231
|
// twin no matter what the seq signal says — drop it (covers the persistence-lag gap).
|
|
223
232
|
if (m.role === 'assistant' && trailingAssistantText && assistantAnswerText(m.content) === trailingAssistantText) {
|
|
224
233
|
return false
|
|
225
234
|
}
|
|
235
|
+
// Per-message coverage: the synthetic carries the highest CONTENT seq
|
|
236
|
+
// that built it (never the MESSAGE_END/TOKEN_USAGE tail), so history has
|
|
237
|
+
// it once its max persisted seq reaches that. This is exact per-turn —
|
|
238
|
+
// it drops earlier finished turns while keeping a later still-streaming
|
|
239
|
+
// one, which the single global `realtimeSeenStreamSeq` (biased upward by
|
|
240
|
+
// the tail the client consumed) cannot distinguish. Falls back to the
|
|
241
|
+
// global coverage / wall-clock for unstamped synthetics (legacy NATS).
|
|
226
242
|
const covered =
|
|
227
|
-
|
|
243
|
+
typeof m.streamSeq === 'number' && historyMaxStreamSeq > 0
|
|
244
|
+
? historyMaxStreamSeq >= m.streamSeq
|
|
245
|
+
: historyCoversRealtime !== null
|
|
246
|
+
? historyCoversRealtime
|
|
247
|
+
: (m.timestamp?.getTime() ?? 0) <= historyFetchedAt
|
|
228
248
|
if (covered) return false
|
|
229
249
|
}
|
|
230
250
|
return true
|
|
@@ -320,6 +320,7 @@ export class MessageSegmentAccumulator {
|
|
|
320
320
|
toolCalls: PendingToolCallData[],
|
|
321
321
|
status: ChatApprovalStatus = 'pending',
|
|
322
322
|
executions?: Record<string, ApprovalBatchExecutionState>,
|
|
323
|
+
resolvedByName?: string | null,
|
|
323
324
|
): MessageSegment[] {
|
|
324
325
|
const existingIndex = this.segments.findIndex(
|
|
325
326
|
(s): s is ApprovalBatchSegment =>
|
|
@@ -338,6 +339,7 @@ export class MessageSegmentAccumulator {
|
|
|
338
339
|
...(mergedExecutions ? { executions: mergedExecutions } : {}),
|
|
339
340
|
},
|
|
340
341
|
status,
|
|
342
|
+
resolvedByName: resolvedByName ?? existing.resolvedByName,
|
|
341
343
|
onApprove: this.callbacks.onApprove,
|
|
342
344
|
onReject: this.callbacks.onReject,
|
|
343
345
|
}
|
|
@@ -353,6 +355,7 @@ export class MessageSegmentAccumulator {
|
|
|
353
355
|
...(executions ? { executions } : {}),
|
|
354
356
|
},
|
|
355
357
|
status,
|
|
358
|
+
resolvedByName,
|
|
356
359
|
onApprove: this.callbacks.onApprove,
|
|
357
360
|
onReject: this.callbacks.onReject,
|
|
358
361
|
}
|
|
@@ -396,15 +399,17 @@ export class MessageSegmentAccumulator {
|
|
|
396
399
|
}
|
|
397
400
|
|
|
398
401
|
/**
|
|
399
|
-
* Update status of an existing approval segment (single or batch)
|
|
402
|
+
* Update status of an existing approval segment (single or batch).
|
|
403
|
+
* `resolvedByName` (when provided) is stamped onto the matching batch segment so the
|
|
404
|
+
* resolved card shows "by {name}"; omit it to leave any existing value untouched.
|
|
400
405
|
*/
|
|
401
|
-
updateApprovalStatus(requestId: string, status: ChatApprovalStatus): MessageSegment[] {
|
|
406
|
+
updateApprovalStatus(requestId: string, status: ChatApprovalStatus, resolvedByName?: string | null): MessageSegment[] {
|
|
402
407
|
this.segments = this.segments.map(segment => {
|
|
403
408
|
if (segment.type === 'approval_request' && segment.data.requestId === requestId) {
|
|
404
409
|
return { ...segment, status }
|
|
405
410
|
}
|
|
406
411
|
if (segment.type === 'approval_batch' && segment.data.approvalRequestId === requestId) {
|
|
407
|
-
return { ...segment, status }
|
|
412
|
+
return { ...segment, status, resolvedByName: resolvedByName ?? segment.resolvedByName }
|
|
408
413
|
}
|
|
409
414
|
return segment
|
|
410
415
|
})
|
|
@@ -517,13 +522,14 @@ export class MessageSegmentAccumulator {
|
|
|
517
522
|
break
|
|
518
523
|
}
|
|
519
524
|
case 'approval_batch': {
|
|
520
|
-
const { data, status } = segment
|
|
525
|
+
const { data, status, resolvedByName } = segment
|
|
521
526
|
this.addApprovalBatch(
|
|
522
527
|
data.approvalRequestId,
|
|
523
528
|
data.approvalType,
|
|
524
529
|
data.toolCalls,
|
|
525
530
|
status,
|
|
526
531
|
data.executions,
|
|
532
|
+
resolvedByName,
|
|
527
533
|
)
|
|
528
534
|
break
|
|
529
535
|
}
|
|
@@ -330,6 +330,7 @@ function processMessageData(
|
|
|
330
330
|
if ('approvalRequestId' in data && data.approvalRequestId) {
|
|
331
331
|
const existingStatus = approvalStatuses[data.approvalRequestId] as ChatApprovalStatus | undefined
|
|
332
332
|
const status: ChatApprovalStatus = existingStatus || (data.approved ? 'approved' : 'rejected')
|
|
333
|
+
const resolvedByName = 'resolvedByName' in data ? data.resolvedByName : undefined
|
|
333
334
|
const escalatedData = escalatedApprovals?.get(data.approvalRequestId)
|
|
334
335
|
|
|
335
336
|
if (escalatedData?.toolCalls && escalatedData.toolCalls.length > 0) {
|
|
@@ -339,6 +340,8 @@ function processMessageData(
|
|
|
339
340
|
escalatedData.approvalType,
|
|
340
341
|
escalatedData.toolCalls,
|
|
341
342
|
status,
|
|
343
|
+
undefined,
|
|
344
|
+
resolvedByName,
|
|
342
345
|
)
|
|
343
346
|
} else {
|
|
344
347
|
for (const call of escalatedData.toolCalls) {
|
|
@@ -368,7 +371,7 @@ function processMessageData(
|
|
|
368
371
|
// If a segment with this id is already present (batch or legacy), just flip its status.
|
|
369
372
|
// updateApprovalStatus matches both `approval_batch` and `approval_request` segments.
|
|
370
373
|
const before = accumulator.getSegments()
|
|
371
|
-
const after = accumulator.updateApprovalStatus(data.approvalRequestId, status)
|
|
374
|
+
const after = accumulator.updateApprovalStatus(data.approvalRequestId, status, resolvedByName)
|
|
372
375
|
const updatedExisting = before.some((s, i) => after[i] !== s)
|
|
373
376
|
if (updatedExisting) break
|
|
374
377
|
|