@flamingo-stack/openframe-frontend-core 0.0.194 → 0.0.195-snapshot.20260519151313

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 (51) hide show
  1. package/dist/{chunk-PJ5KFD2V.js → chunk-4ML3NA2L.js} +75 -1
  2. package/dist/{chunk-PJ5KFD2V.js.map → chunk-4ML3NA2L.js.map} +1 -1
  3. package/dist/{chunk-C5VTN2SB.js → chunk-BXPH5SOL.js} +748 -359
  4. package/dist/chunk-BXPH5SOL.js.map +1 -0
  5. package/dist/{chunk-56JS47IQ.cjs → chunk-E6AWU7EI.cjs} +794 -405
  6. package/dist/chunk-E6AWU7EI.cjs.map +1 -0
  7. package/dist/{chunk-CVMSC7M4.cjs → chunk-OII2IERE.cjs} +77 -3
  8. package/dist/chunk-OII2IERE.cjs.map +1 -0
  9. package/dist/components/chat/hooks/index.d.ts +1 -0
  10. package/dist/components/chat/hooks/index.d.ts.map +1 -1
  11. package/dist/components/chat/hooks/use-jetstream-dialog-subscription.d.ts +15 -0
  12. package/dist/components/chat/hooks/use-jetstream-dialog-subscription.d.ts.map +1 -0
  13. package/dist/components/chat/types/api.types.d.ts +43 -0
  14. package/dist/components/chat/types/api.types.d.ts.map +1 -1
  15. package/dist/components/chat/types/network.types.d.ts +4 -0
  16. package/dist/components/chat/types/network.types.d.ts.map +1 -1
  17. package/dist/components/chat/utils/message-segment-accumulator.d.ts +16 -0
  18. package/dist/components/chat/utils/message-segment-accumulator.d.ts.map +1 -1
  19. package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
  20. package/dist/components/features/index.cjs +3 -3
  21. package/dist/components/features/index.js +2 -2
  22. package/dist/components/index.cjs +5 -3
  23. package/dist/components/index.cjs.map +1 -1
  24. package/dist/components/index.js +4 -2
  25. package/dist/components/navigation/index.cjs +3 -3
  26. package/dist/components/navigation/index.js +2 -2
  27. package/dist/components/ui/index.cjs +5 -3
  28. package/dist/components/ui/index.cjs.map +1 -1
  29. package/dist/components/ui/index.js +4 -2
  30. package/dist/hooks/index.cjs +2 -2
  31. package/dist/hooks/index.js +1 -1
  32. package/dist/index.cjs +5 -3
  33. package/dist/index.cjs.map +1 -1
  34. package/dist/index.js +4 -2
  35. package/dist/nats/index.cjs +73 -0
  36. package/dist/nats/index.cjs.map +1 -1
  37. package/dist/nats/index.js +73 -0
  38. package/dist/nats/index.js.map +1 -1
  39. package/dist/nats/nats.d.ts +22 -1
  40. package/dist/nats/nats.d.ts.map +1 -1
  41. package/package.json +1 -1
  42. package/src/components/chat/hooks/index.ts +1 -0
  43. package/src/components/chat/hooks/use-jetstream-dialog-subscription.ts +474 -0
  44. package/src/components/chat/types/api.types.ts +45 -0
  45. package/src/components/chat/types/network.types.ts +4 -0
  46. package/src/components/chat/utils/message-segment-accumulator.ts +28 -0
  47. package/src/components/chat/utils/process-historical-messages.ts +23 -5
  48. package/src/nats/nats.ts +117 -0
  49. package/dist/chunk-56JS47IQ.cjs.map +0 -1
  50. package/dist/chunk-C5VTN2SB.js.map +0 -1
  51. package/dist/chunk-CVMSC7M4.cjs.map +0 -1
@@ -0,0 +1,474 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react'
4
+ import {
5
+ createNatsClient,
6
+ type JetStreamSubscriptionHandle,
7
+ type NatsClient,
8
+ } from '../../../nats'
9
+ import {
10
+ type UseJetStreamDialogSubscriptionOptions,
11
+ type UseJetStreamDialogSubscriptionReturn,
12
+ NETWORK_CONFIG,
13
+ } from '../types'
14
+
15
+ type SharedConnection = {
16
+ wsUrl: string
17
+ client: NatsClient
18
+ connectPromise: Promise<void> | null
19
+ refCount: number
20
+ closeTimer: ReturnType<typeof setTimeout> | null
21
+ retryTimer: ReturnType<typeof setTimeout> | null
22
+ }
23
+
24
+ let shared: SharedConnection | null = null
25
+
26
+ const DEFAULT_INACTIVE_THRESHOLD_MS = 5 * 60_000
27
+ const DEFAULT_STREAM_NAME = 'CHAT_CHUNKS'
28
+
29
+ /**
30
+ * Subscribe to a chat dialog stream via a JetStream **ephemeral OrderedConsumer**.
31
+ *
32
+ * - Subject: `chat.{dialogId}.{topic}`
33
+ * - When `optStartSeq` is a number, the consumer resumes at `optStartSeq + 1`
34
+ * (`DeliverPolicy.ByStartSequence`). When null/undefined, it live-tails
35
+ * (`DeliverPolicy.New`).
36
+ * - On reconnect, the consumer is recreated starting from the highest stream
37
+ * sequence we've already observed + 1, so no chunk is replayed or skipped.
38
+ * - Consumer is ephemeral with `AckPolicy.None` and a 5-minute inactivity
39
+ * threshold (overridable via `inactiveThresholdMs`).
40
+ */
41
+ export function useJetStreamDialogSubscription({
42
+ enabled,
43
+ dialogId,
44
+ streamName = DEFAULT_STREAM_NAME,
45
+ topic,
46
+ optStartSeq,
47
+ onEvent,
48
+ onConnect,
49
+ onDisconnect,
50
+ onSubscribed,
51
+ onBeforeReconnect,
52
+ getNatsWsUrl,
53
+ clientConfig = {},
54
+ reconnectionBackoff,
55
+ inactiveThresholdMs,
56
+ }: UseJetStreamDialogSubscriptionOptions): UseJetStreamDialogSubscriptionReturn {
57
+ const [isConnected, setIsConnected] = useState(false)
58
+ const [isSubscribed, setIsSubscribed] = useState(false)
59
+ const [reconnectionCount, setReconnectionCount] = useState(0)
60
+ const [currentStreamSeq, setCurrentStreamSeq] = useState<number | null>(null)
61
+
62
+ const clientRef = useRef<NatsClient | null>(null)
63
+ const subscriptionRef = useRef<JetStreamSubscriptionHandle | null>(null)
64
+ const highestStreamSeqRef = useRef<number | null>(null)
65
+
66
+ const onEventRef = useRef(onEvent)
67
+ useEffect(() => {
68
+ onEventRef.current = onEvent
69
+ }, [onEvent])
70
+
71
+ const onConnectRef = useRef(onConnect)
72
+ useEffect(() => {
73
+ onConnectRef.current = onConnect
74
+ }, [onConnect])
75
+
76
+ const onDisconnectRef = useRef(onDisconnect)
77
+ useEffect(() => {
78
+ onDisconnectRef.current = onDisconnect
79
+ }, [onDisconnect])
80
+
81
+ const onSubscribedRef = useRef(onSubscribed)
82
+ useEffect(() => {
83
+ onSubscribedRef.current = onSubscribed
84
+ }, [onSubscribed])
85
+
86
+ const onBeforeReconnectRef = useRef(onBeforeReconnect)
87
+ useEffect(() => {
88
+ onBeforeReconnectRef.current = onBeforeReconnect
89
+ }, [onBeforeReconnect])
90
+
91
+ const getNatsWsUrlRef = useRef(getNatsWsUrl)
92
+ useEffect(() => {
93
+ getNatsWsUrlRef.current = getNatsWsUrl
94
+ }, [getNatsWsUrl])
95
+
96
+ const reconnectionBackoffRef = useRef(reconnectionBackoff)
97
+ useEffect(() => {
98
+ reconnectionBackoffRef.current = reconnectionBackoff
99
+ }, [reconnectionBackoff])
100
+
101
+ const optStartSeqRef = useRef<number | null | undefined>(optStartSeq)
102
+ useEffect(() => {
103
+ optStartSeqRef.current = optStartSeq
104
+ }, [optStartSeq])
105
+
106
+ const inactiveThresholdRef = useRef<number | undefined>(inactiveThresholdMs)
107
+ useEffect(() => {
108
+ inactiveThresholdRef.current = inactiveThresholdMs
109
+ }, [inactiveThresholdMs])
110
+
111
+ const hadConnectionBeforeRef = useRef(false)
112
+
113
+ const acquireClient = useCallback(
114
+ (url: string): SharedConnection => {
115
+ if (shared?.wsUrl !== url) {
116
+ if (shared) {
117
+ if (shared.closeTimer) clearTimeout(shared.closeTimer)
118
+ const old = shared
119
+ shared = null
120
+ void old.client.close().catch(() => {})
121
+ }
122
+
123
+ const { name = 'openframe-frontend-jetstream', user = 'machine', pass = '' } = clientConfig
124
+
125
+ const client = createNatsClient({
126
+ servers: url,
127
+ name,
128
+ user,
129
+ pass,
130
+ connectTimeoutMs: NETWORK_CONFIG.CONNECT_TIMEOUT_MS,
131
+ reconnect: false,
132
+ pingIntervalMs: NETWORK_CONFIG.PING_INTERVAL_MS,
133
+ maxPingOut: NETWORK_CONFIG.MAX_PING_OUT,
134
+ })
135
+ shared = {
136
+ wsUrl: url,
137
+ client,
138
+ connectPromise: null,
139
+ refCount: 0,
140
+ closeTimer: null,
141
+ retryTimer: null,
142
+ }
143
+ }
144
+
145
+ shared.refCount += 1
146
+ if (shared.closeTimer) {
147
+ clearTimeout(shared.closeTimer)
148
+ shared.closeTimer = null
149
+ }
150
+ return shared
151
+ },
152
+ [clientConfig],
153
+ )
154
+
155
+ const releaseClient = useCallback((url: string) => {
156
+ if (!shared || shared.wsUrl !== url) return
157
+
158
+ shared.refCount = Math.max(0, shared.refCount - 1)
159
+ if (shared.refCount > 0) return
160
+
161
+ shared.closeTimer = setTimeout(() => {
162
+ const s = shared
163
+ shared = null
164
+ if (s) {
165
+ if (s.retryTimer) {
166
+ clearTimeout(s.retryTimer)
167
+ s.retryTimer = null
168
+ }
169
+ void s.client.close().catch(() => {})
170
+ }
171
+ }, NETWORK_CONFIG.SHARED_CLOSE_DELAY_MS)
172
+ }, [])
173
+
174
+ const currentWsUrlRef = useRef<string>('')
175
+
176
+ // Connection lifecycle: acquire/release the shared client based on enabled + URL.
177
+ useEffect(() => {
178
+ const wsUrl = getNatsWsUrl()
179
+ if (!enabled || !wsUrl) {
180
+ if (currentWsUrlRef.current && clientRef.current) {
181
+ releaseClient(currentWsUrlRef.current)
182
+ clientRef.current = null
183
+ currentWsUrlRef.current = ''
184
+ setIsConnected(false)
185
+ }
186
+ return
187
+ }
188
+
189
+ if (
190
+ wsUrl === currentWsUrlRef.current &&
191
+ clientRef.current &&
192
+ clientRef.current.isConnected()
193
+ ) {
194
+ return
195
+ }
196
+
197
+ if (
198
+ currentWsUrlRef.current &&
199
+ currentWsUrlRef.current !== wsUrl &&
200
+ clientRef.current
201
+ ) {
202
+ releaseClient(currentWsUrlRef.current)
203
+ clientRef.current = null
204
+ setIsConnected(false)
205
+ }
206
+
207
+ currentWsUrlRef.current = wsUrl
208
+ const sharedConn = acquireClient(wsUrl)
209
+ const client = sharedConn.client
210
+
211
+ clientRef.current = client
212
+ setIsConnected(false)
213
+
214
+ let closed = false
215
+ let retryAttempt = 0
216
+
217
+ function scheduleRetry() {
218
+ if (closed) return
219
+ if (shared !== sharedConn) return
220
+
221
+ if (sharedConn.retryTimer) {
222
+ clearTimeout(sharedConn.retryTimer)
223
+ sharedConn.retryTimer = null
224
+ }
225
+
226
+ const cfg = reconnectionBackoffRef.current ?? {}
227
+ const fastRetries = cfg.fastRetries ?? 0
228
+ const fastDelay = cfg.fastRetryDelayMs ?? NETWORK_CONFIG.RETRY_INITIAL_DELAY_MS
229
+ const baseDelay = cfg.initialDelayMs ?? NETWORK_CONFIG.RETRY_INITIAL_DELAY_MS
230
+ const maxDelay = cfg.maxDelayMs ?? NETWORK_CONFIG.RETRY_MAX_DELAY_MS
231
+ const multiplier = cfg.multiplier ?? NETWORK_CONFIG.RETRY_BACKOFF_MULTIPLIER
232
+
233
+ const delay =
234
+ retryAttempt < fastRetries
235
+ ? fastDelay
236
+ : Math.min(baseDelay * multiplier ** (retryAttempt - fastRetries), maxDelay)
237
+ const jitteredDelay = delay * (0.5 + Math.random() * 0.5)
238
+ retryAttempt++
239
+
240
+ sharedConn.retryTimer = setTimeout(async () => {
241
+ sharedConn.retryTimer = null
242
+ if (closed) return
243
+ if (shared !== sharedConn) return
244
+
245
+ try {
246
+ await onBeforeReconnectRef.current?.()
247
+ } catch {
248
+ // Token refresh failed; still try to reconnect.
249
+ }
250
+
251
+ if (closed) return
252
+ if (shared !== sharedConn) return
253
+
254
+ const freshUrl = getNatsWsUrlRef.current()
255
+ if (freshUrl !== wsUrl) return
256
+
257
+ try {
258
+ sharedConn.connectPromise = null
259
+ sharedConn.connectPromise = client.connect()
260
+ await sharedConn.connectPromise
261
+ if (!closed && shared === sharedConn) {
262
+ retryAttempt = 0
263
+ setIsConnected(true)
264
+ }
265
+ } catch {
266
+ sharedConn.connectPromise = null
267
+ if (!closed && shared === sharedConn) {
268
+ scheduleRetry()
269
+ }
270
+ }
271
+ }, jitteredDelay)
272
+ }
273
+
274
+ const unsubscribeStatus = client.onStatus((event) => {
275
+ const connected = event.status === 'connected'
276
+ // `error` is a protocol-level signal (e.g. -ERR Permissions Violation when
277
+ // CONSUMER.CREATE is denied) that does NOT close the WebSocket. Treating
278
+ // it as a disconnect causes scheduleRetry() to fire on every -ERR, which
279
+ // re-runs onBeforeReconnect (auth refresh / `/api/me`) on a loop. Real
280
+ // transport loss arrives separately as `disconnected` or `closed`.
281
+ const disconnected = event.status === 'closed' || event.status === 'disconnected'
282
+ if (connected) {
283
+ setIsConnected(true)
284
+ if (hadConnectionBeforeRef.current) {
285
+ setReconnectionCount((c) => c + 1)
286
+ }
287
+ hadConnectionBeforeRef.current = true
288
+ retryAttempt = 0
289
+ onConnectRef.current?.()
290
+ }
291
+ if (event.status === 'error') {
292
+ // Subscription-level failures (e.g. consumer.get rejected by JetStream
293
+ // ACLs) already surface to the subscribe effect via the rejected
294
+ // promise; log here for diagnostics and let the existing WS connection
295
+ // keep running.
296
+ console.warn('[JetStream] NATS protocol error:', event.data)
297
+ return
298
+ }
299
+ if (disconnected) {
300
+ setIsConnected(false)
301
+ setIsSubscribed(false)
302
+
303
+ if (subscriptionRef.current) {
304
+ try {
305
+ subscriptionRef.current.unsubscribe()
306
+ } catch {
307
+ // ignore
308
+ }
309
+ subscriptionRef.current = null
310
+ }
311
+
312
+ onDisconnectRef.current?.()
313
+ scheduleRetry()
314
+ }
315
+ })
316
+
317
+ ;(async () => {
318
+ try {
319
+ sharedConn.connectPromise ||= client.connect()
320
+ await sharedConn.connectPromise
321
+ if (!closed) {
322
+ setIsConnected(true)
323
+ hadConnectionBeforeRef.current = true
324
+ }
325
+ } catch {
326
+ sharedConn.connectPromise = null
327
+ if (!closed) {
328
+ setIsConnected(false)
329
+ onDisconnectRef.current?.()
330
+ scheduleRetry()
331
+ }
332
+ }
333
+ })()
334
+
335
+ return () => {
336
+ closed = true
337
+ setIsConnected(false)
338
+ setIsSubscribed(false)
339
+ unsubscribeStatus()
340
+
341
+ if (sharedConn.retryTimer) {
342
+ clearTimeout(sharedConn.retryTimer)
343
+ sharedConn.retryTimer = null
344
+ }
345
+
346
+ if (subscriptionRef.current) {
347
+ try {
348
+ subscriptionRef.current.unsubscribe()
349
+ } catch {
350
+ // ignore
351
+ }
352
+ subscriptionRef.current = null
353
+ }
354
+
355
+ if (clientRef.current && currentWsUrlRef.current) {
356
+ releaseClient(currentWsUrlRef.current)
357
+ clientRef.current = null
358
+ currentWsUrlRef.current = ''
359
+ }
360
+ }
361
+ }, [enabled, getNatsWsUrl, acquireClient, releaseClient])
362
+
363
+ // Subscription lifecycle: (re)create the ephemeral JetStream consumer whenever
364
+ // we transition into a connected state for a dialog, and whenever the dialog
365
+ // changes. On reconnect we resume from highestStreamSeq + 1.
366
+ useEffect(() => {
367
+ if (!enabled || !dialogId || !isConnected) {
368
+ if (subscriptionRef.current) {
369
+ try {
370
+ subscriptionRef.current.unsubscribe()
371
+ } catch {
372
+ // ignore
373
+ }
374
+ subscriptionRef.current = null
375
+ }
376
+ setIsSubscribed(false)
377
+ return
378
+ }
379
+
380
+ const client = clientRef.current
381
+ if (!client) return
382
+
383
+ const abortController = new AbortController()
384
+ const decoder = new TextDecoder()
385
+ const filterSubject = `chat.${dialogId}.${topic}`
386
+
387
+ const resumeSeq = highestStreamSeqRef.current
388
+ const initialOptStart = optStartSeqRef.current
389
+ const startSeq =
390
+ resumeSeq != null ? resumeSeq + 1 : initialOptStart != null ? initialOptStart + 1 : undefined
391
+
392
+ let cancelled = false
393
+
394
+ void (async () => {
395
+ try {
396
+ const handle = await client.subscribeJetStreamOrdered(
397
+ (msg) => {
398
+ if (cancelled) return
399
+ const streamSeq = msg.info.streamSequence
400
+ if (typeof streamSeq === 'number') {
401
+ if (
402
+ highestStreamSeqRef.current == null ||
403
+ streamSeq > highestStreamSeqRef.current
404
+ ) {
405
+ highestStreamSeqRef.current = streamSeq
406
+ setCurrentStreamSeq(streamSeq)
407
+ }
408
+ }
409
+ const cb = onEventRef.current
410
+ if (!cb) return
411
+ try {
412
+ const parsed = JSON.parse(decoder.decode(msg.data)) as Record<string, unknown>
413
+ if (typeof streamSeq === 'number') {
414
+ ;(parsed as { streamSeq?: number }).streamSeq = streamSeq
415
+ }
416
+ cb(parsed, topic)
417
+ } catch {
418
+ // Ignore malformed payloads.
419
+ }
420
+ },
421
+ {
422
+ streamName,
423
+ filterSubject,
424
+ deliverPolicy: startSeq != null ? 'byStartSequence' : 'new',
425
+ optStartSeq: startSeq,
426
+ inactiveThresholdMs: inactiveThresholdRef.current ?? DEFAULT_INACTIVE_THRESHOLD_MS,
427
+ signal: abortController.signal,
428
+ },
429
+ )
430
+
431
+ if (cancelled) {
432
+ try {
433
+ handle.unsubscribe()
434
+ } catch {
435
+ // ignore
436
+ }
437
+ return
438
+ }
439
+
440
+ subscriptionRef.current = handle
441
+ setIsSubscribed(true)
442
+ onSubscribedRef.current?.()
443
+ } catch {
444
+ if (!cancelled) {
445
+ setIsSubscribed(false)
446
+ }
447
+ }
448
+ })()
449
+
450
+ return () => {
451
+ cancelled = true
452
+ abortController.abort()
453
+ if (subscriptionRef.current) {
454
+ try {
455
+ subscriptionRef.current.unsubscribe()
456
+ } catch {
457
+ // ignore
458
+ }
459
+ subscriptionRef.current = null
460
+ }
461
+ setIsSubscribed(false)
462
+ }
463
+ }, [enabled, dialogId, isConnected, streamName, topic, reconnectionCount])
464
+
465
+ // Reset the highest-seen sequence whenever the dialog changes so a new dialog
466
+ // starts from optStartSeq (or DeliverPolicy.New) rather than the previous
467
+ // dialog's offset.
468
+ useEffect(() => {
469
+ highestStreamSeqRef.current = null
470
+ setCurrentStreamSeq(null)
471
+ }, [dialogId])
472
+
473
+ return { isConnected, isSubscribed, reconnectionCount, currentStreamSeq }
474
+ }
@@ -90,6 +90,51 @@ export interface UseNatsDialogSubscriptionReturn {
90
90
  reconnectionCount: number
91
91
  }
92
92
 
93
+ export interface UseJetStreamDialogSubscriptionOptions {
94
+ enabled: boolean
95
+ dialogId: string | null
96
+ /** JetStream stream name. Default: 'CHAT_CHUNKS'. */
97
+ streamName?: string
98
+ /** Single topic to subscribe to. */
99
+ topic: NatsMessageType
100
+ /**
101
+ * Resume from this JetStream sequence + 1. When null/undefined, the consumer starts with
102
+ * DeliverPolicy.New (live tail only).
103
+ */
104
+ optStartSeq?: number | null
105
+ onEvent?: (payload: unknown, messageType: NatsMessageType) => void
106
+ onConnect?: () => void
107
+ onDisconnect?: () => void
108
+ onSubscribed?: () => void
109
+ /** Called on disconnect, before reconnect attempt. Use to refresh auth. */
110
+ onBeforeReconnect?: () => Promise<void> | void
111
+ /** Build the NATS WebSocket URL (or null when not yet available). */
112
+ getNatsWsUrl: () => string | null
113
+ clientConfig?: {
114
+ name?: string
115
+ user?: string
116
+ pass?: string
117
+ }
118
+ reconnectionBackoff?: {
119
+ fastRetries?: number
120
+ fastRetryDelayMs?: number
121
+ initialDelayMs?: number
122
+ maxDelayMs?: number
123
+ multiplier?: number
124
+ }
125
+ /** Consumer inactivity threshold in ms before NATS auto-cleans it. Default: 5 minutes. */
126
+ inactiveThresholdMs?: number
127
+ }
128
+
129
+ export interface UseJetStreamDialogSubscriptionReturn {
130
+ isConnected: boolean
131
+ isSubscribed: boolean
132
+ /** Incremented each time the underlying NATS connection reconnects. Starts at 0. */
133
+ reconnectionCount: number
134
+ /** Highest JetStream stream sequence observed so far (null before first delivery). */
135
+ currentStreamSeq: number | null
136
+ }
137
+
93
138
  export interface SegmentsUpdateMetadata {
94
139
  /** Segments should be appended to the last assistant message */
95
140
  append?: boolean
@@ -34,6 +34,10 @@ export type NetworkConfig = typeof NETWORK_CONFIG
34
34
 
35
35
  export interface ChunkData {
36
36
  sequenceId?: number
37
+ /** JetStream stream sequence, populated from JsMsg.info.streamSequence when delivered via JetStream. */
38
+ streamSeq?: number
39
+ /** Authoritative dialog streaming state at the time this chunk was emitted; consumers should mirror it into their dialog cache. */
40
+ streamState?: 'IDLE' | 'STREAMING'
37
41
  type: string
38
42
  text?: string
39
43
  integratedToolType?: string
@@ -161,6 +161,9 @@ export class MessageSegmentAccumulator {
161
161
 
162
162
  const toolKey = execId || `${toolData.integratedToolType}-${toolData.toolFunction}`
163
163
 
164
+ // A tool only runs after its approval gate was granted. Resolve it here.
165
+ this.resolvePendingApprovalForExecution()
166
+
164
167
  if (toolData.type === 'EXECUTING_TOOL') {
165
168
  this.executingTools.set(toolKey, {
166
169
  integratedToolType: toolData.integratedToolType,
@@ -239,6 +242,31 @@ export class MessageSegmentAccumulator {
239
242
  return matched
240
243
  }
241
244
 
245
+ /**
246
+ * A tool only ever runs after its approval gate was granted. The legacy /
247
+ * single `approval_request` segment carries no `toolExecutionRequestId` to
248
+ * correlate with the execution, and an observer (e.g. a technician
249
+ * mirroring the client chat) may never receive an `APPROVAL_RESULT` chunk —
250
+ * only the tool's `EXECUTING_TOOL` / `EXECUTED_TOOL` events. Treat the
251
+ * arrival of a tool execution as implicit approval of the most recent
252
+ * still-pending gate so the card does not stay stuck `pending` in realtime.
253
+ *
254
+ * The agent stays paused while an approval is outstanding, so there is at
255
+ * most one relevant gate; flipping only the latest pending one is safe and
256
+ * monotonic (never downgrades, can't make a correct state wrong — an
257
+ * unapproved tool cannot execute). `approval_batch` is handled separately by
258
+ * `applyExecutionToBatch` and is intentionally left untouched here.
259
+ */
260
+ private resolvePendingApprovalForExecution(): void {
261
+ for (let i = this.segments.length - 1; i >= 0; i--) {
262
+ const seg = this.segments[i]
263
+ if (seg.type === 'approval_request' && seg.status === 'pending') {
264
+ this.segments[i] = { ...seg, status: 'approved' }
265
+ return
266
+ }
267
+ }
268
+ }
269
+
242
270
  /**
243
271
  * Track a pending approval request
244
272
  */
@@ -284,11 +284,29 @@ function processMessageData(
284
284
  }
285
285
  }
286
286
  } else {
287
- accumulator.trackApprovalRequest(data.approvalRequestId, {
288
- command: data.command || '',
289
- explanation: data.explanation,
290
- approvalType,
291
- })
287
+ // The resolution may already be known to the consumer (realtime
288
+ // flipped it and fed it back via `approvalStatuses`) while the
289
+ // matching APPROVAL_RESULT row is absent from the fetched history
290
+ // pages. Without this, the single-approval path tracks it as
291
+ // pending and `flushPendingApprovals()` resurrects it as a stale
292
+ // sticky card on every history re-process. Mirror the batch path
293
+ // and honor `approvalStatuses`.
294
+ const resolvedStatus = approvalStatuses[data.approvalRequestId] as ChatApprovalStatus | undefined
295
+ if (resolvedStatus === 'approved' || resolvedStatus === 'rejected') {
296
+ accumulator.addApprovalRequest(
297
+ data.approvalRequestId,
298
+ data.command || '',
299
+ data.explanation,
300
+ approvalType,
301
+ resolvedStatus,
302
+ )
303
+ } else {
304
+ accumulator.trackApprovalRequest(data.approvalRequestId, {
305
+ command: data.command || '',
306
+ explanation: data.explanation,
307
+ approvalType,
308
+ })
309
+ }
292
310
  }
293
311
  } else {
294
312
  escalatedApprovals?.set(data.approvalRequestId, {