@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.
Files changed (72) hide show
  1. package/dist/{chunk-MVUN35FH.js → chunk-6ZMD3KBP.js} +2 -2
  2. package/dist/{chunk-DO55GWNT.cjs → chunk-776ZURDZ.cjs} +10 -10
  3. package/dist/{chunk-DO55GWNT.cjs.map → chunk-776ZURDZ.cjs.map} +1 -1
  4. package/dist/{chunk-5NL62LIF.js → chunk-7ECI5ZVO.js} +2 -2
  5. package/dist/{chunk-BBDJCXHW.cjs → chunk-7XVVRE53.cjs} +6 -6
  6. package/dist/{chunk-BBDJCXHW.cjs.map → chunk-7XVVRE53.cjs.map} +1 -1
  7. package/dist/{chunk-GO45CXT5.js → chunk-AVLSICZO.js} +49 -33
  8. package/dist/chunk-AVLSICZO.js.map +1 -0
  9. package/dist/{chunk-L4BJU5CE.cjs → chunk-QP4H2JJN.cjs} +34 -34
  10. package/dist/{chunk-L4BJU5CE.cjs.map → chunk-QP4H2JJN.cjs.map} +1 -1
  11. package/dist/{chunk-MDHNLZME.js → chunk-SZG56DA3.js} +2 -2
  12. package/dist/{chunk-WYQ5VFXA.cjs → chunk-XBTQK32O.cjs} +25 -25
  13. package/dist/{chunk-WYQ5VFXA.cjs.map → chunk-XBTQK32O.cjs.map} +1 -1
  14. package/dist/{chunk-737ZLQ2T.js → chunk-YK6FFIVG.js} +2 -2
  15. package/dist/{chunk-7LTWNUHB.cjs → chunk-Z53YSXDD.cjs} +149 -133
  16. package/dist/chunk-Z53YSXDD.cjs.map +1 -0
  17. package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
  18. package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts.map +1 -1
  19. package/dist/components/chat/index.cjs +2 -2
  20. package/dist/components/chat/index.js +1 -1
  21. package/dist/components/chat/types/api.types.d.ts +7 -2
  22. package/dist/components/chat/types/api.types.d.ts.map +1 -1
  23. package/dist/components/chat/types/message.types.d.ts +10 -0
  24. package/dist/components/chat/types/message.types.d.ts.map +1 -1
  25. package/dist/components/chat/types/network.types.d.ts +2 -0
  26. package/dist/components/chat/types/network.types.d.ts.map +1 -1
  27. package/dist/components/chat/types/processing.types.d.ts +1 -0
  28. package/dist/components/chat/types/processing.types.d.ts.map +1 -1
  29. package/dist/components/chat/utils/chunk-parser.d.ts.map +1 -1
  30. package/dist/components/chat/utils/history-merge.d.ts +8 -0
  31. package/dist/components/chat/utils/history-merge.d.ts.map +1 -1
  32. package/dist/components/chat/utils/message-segment-accumulator.d.ts +5 -3
  33. package/dist/components/chat/utils/message-segment-accumulator.d.ts.map +1 -1
  34. package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
  35. package/dist/components/contact/index.cjs +3 -3
  36. package/dist/components/contact/index.js +2 -2
  37. package/dist/components/faq/index.cjs +3 -3
  38. package/dist/components/faq/index.js +2 -2
  39. package/dist/components/features/index.cjs +2 -2
  40. package/dist/components/features/index.js +1 -1
  41. package/dist/components/index.cjs +53 -53
  42. package/dist/components/index.js +4 -4
  43. package/dist/components/navigation/index.cjs +2 -2
  44. package/dist/components/navigation/index.js +1 -1
  45. package/dist/components/related-content/index.cjs +3 -3
  46. package/dist/components/related-content/index.js +2 -2
  47. package/dist/components/tickets/index.cjs +52 -52
  48. package/dist/components/tickets/index.js +3 -3
  49. package/dist/components/ui/index.cjs +2 -2
  50. package/dist/components/ui/index.js +1 -1
  51. package/dist/index.cjs +2 -2
  52. package/dist/index.js +1 -1
  53. package/dist/utils/index.cjs.map +1 -1
  54. package/dist/utils/index.js.map +1 -1
  55. package/package.json +1 -1
  56. package/src/components/chat/chat-message-enhanced.tsx +1 -0
  57. package/src/components/chat/hooks/use-realtime-chunk-processor.ts +34 -21
  58. package/src/components/chat/types/api.types.ts +7 -2
  59. package/src/components/chat/types/message.types.ts +10 -0
  60. package/src/components/chat/types/network.types.ts +2 -0
  61. package/src/components/chat/types/processing.types.ts +1 -1
  62. package/src/components/chat/utils/__tests__/history-merge.test.ts +70 -0
  63. package/src/components/chat/utils/chunk-parser.ts +12 -1
  64. package/src/components/chat/utils/history-merge.ts +22 -2
  65. package/src/components/chat/utils/message-segment-accumulator.ts +10 -4
  66. package/src/components/chat/utils/process-historical-messages.ts +4 -1
  67. package/dist/chunk-7LTWNUHB.cjs.map +0 -1
  68. package/dist/chunk-GO45CXT5.js.map +0 -1
  69. /package/dist/{chunk-MVUN35FH.js.map → chunk-6ZMD3KBP.js.map} +0 -0
  70. /package/dist/{chunk-5NL62LIF.js.map → chunk-7ECI5ZVO.js.map} +0 -0
  71. /package/dist/{chunk-MDHNLZME.js.map → chunk-SZG56DA3.js.map} +0 -0
  72. /package/dist/{chunk-737ZLQ2T.js.map → chunk-YK6FFIVG.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flamingo-stack/openframe-frontend-core",
3
- "version": "0.0.278",
3
+ "version": "0.0.279",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -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
- callbacks.onSegmentsUpdate?.(segments)
160
+ emitSegments(segments)
150
161
  } else {
151
- callbacks.onSegmentsUpdate?.([{ type: 'text', text: action.text }], { append: true })
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
- callbacks.onSegmentsUpdate?.(segments)
170
+ emitSegments(segments)
160
171
  } else {
161
- callbacks.onSegmentsUpdate?.([{ type: 'thinking', text: action.text }], { append: true })
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
- callbacks.onSegmentsUpdate?.(segments)
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
- callbacks.onSegmentsUpdate?.(segments)
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
- callbacks.onSegmentsUpdate?.(segments)
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
- callbacks.onSegmentsUpdate?.(segments)
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
- callbacks.onSegmentsUpdate?.(segments)
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
- callbacks.onSegmentsUpdate?.(segments)
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
- callbacks.onSegmentsUpdate?.(segments)
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
- callbacks.onSegmentsUpdate?.(accumulator.getSegments())
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
- callbacks.onSegmentsUpdate?.(segments)
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
- callbacks.onSegmentsUpdate?.(segments, standalone ? { append: true, isCompacting: true } : undefined)
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
- callbacks.onSegmentsUpdate?.(segments, standalone ? { append: true, isCompacting: true } : undefined)
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 coverage when known, wall-clock otherwise.
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
- historyCoversRealtime !== null ? historyCoversRealtime : (m.timestamp?.getTime() ?? 0) <= historyFetchedAt
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