@tanstack/ai-client 0.22.1 → 0.23.1

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/README.md +15 -1
  2. package/dist/esm/audio-recorder.js +190 -213
  3. package/dist/esm/audio-recorder.js.map +1 -1
  4. package/dist/esm/chat-client.d.ts +172 -3
  5. package/dist/esm/chat-client.js +1656 -1386
  6. package/dist/esm/chat-client.js.map +1 -1
  7. package/dist/esm/cleared-stream-tracker.d.ts +23 -0
  8. package/dist/esm/cleared-stream-tracker.js +97 -0
  9. package/dist/esm/cleared-stream-tracker.js.map +1 -0
  10. package/dist/esm/client-persistor.d.ts +25 -12
  11. package/dist/esm/client-persistor.js +260 -235
  12. package/dist/esm/client-persistor.js.map +1 -1
  13. package/dist/esm/connection-adapters.d.ts +231 -10
  14. package/dist/esm/connection-adapters.js +989 -574
  15. package/dist/esm/connection-adapters.js.map +1 -1
  16. package/dist/esm/devtools-noop.d.ts +1 -0
  17. package/dist/esm/devtools-noop.js +79 -139
  18. package/dist/esm/devtools-noop.js.map +1 -1
  19. package/dist/esm/devtools.d.ts +31 -1
  20. package/dist/esm/devtools.js +977 -1127
  21. package/dist/esm/devtools.js.map +1 -1
  22. package/dist/esm/events.js +224 -226
  23. package/dist/esm/events.js.map +1 -1
  24. package/dist/esm/generation-client.d.ts +145 -2
  25. package/dist/esm/generation-client.js +659 -321
  26. package/dist/esm/generation-client.js.map +1 -1
  27. package/dist/esm/generation-reconstruct.d.ts +21 -0
  28. package/dist/esm/generation-reconstruct.js +85 -0
  29. package/dist/esm/generation-reconstruct.js.map +1 -0
  30. package/dist/esm/generation-types.d.ts +289 -3
  31. package/dist/esm/generation-types.js +356 -13
  32. package/dist/esm/generation-types.js.map +1 -1
  33. package/dist/esm/index.d.ts +9 -4
  34. package/dist/esm/index.js +7 -39
  35. package/dist/esm/interrupt-manager.d.ts +77 -0
  36. package/dist/esm/interrupt-manager.js +787 -0
  37. package/dist/esm/interrupt-manager.js.map +1 -0
  38. package/dist/esm/mcp-app-bridge.js +56 -64
  39. package/dist/esm/mcp-app-bridge.js.map +1 -1
  40. package/dist/esm/realtime-client.js +366 -440
  41. package/dist/esm/realtime-client.js.map +1 -1
  42. package/dist/esm/response-stream.js +19 -26
  43. package/dist/esm/response-stream.js.map +1 -1
  44. package/dist/esm/sse-parser.js +44 -47
  45. package/dist/esm/sse-parser.js.map +1 -1
  46. package/dist/esm/sse-utils.js +8 -9
  47. package/dist/esm/sse-utils.js.map +1 -1
  48. package/dist/esm/storage-adapters.d.ts +62 -0
  49. package/dist/esm/storage-adapters.js +174 -0
  50. package/dist/esm/storage-adapters.js.map +1 -0
  51. package/dist/esm/types.d.ts +212 -10
  52. package/dist/esm/types.js +38 -7
  53. package/dist/esm/types.js.map +1 -1
  54. package/dist/esm/video-generation-client.d.ts +113 -2
  55. package/dist/esm/video-generation-client.js +665 -379
  56. package/dist/esm/video-generation-client.js.map +1 -1
  57. package/package.json +7 -7
  58. package/src/chat-client.ts +1079 -61
  59. package/src/cleared-stream-tracker.ts +151 -0
  60. package/src/client-persistor.ts +102 -33
  61. package/src/connection-adapters.ts +1185 -142
  62. package/src/devtools-noop.ts +4 -3
  63. package/src/devtools.ts +121 -3
  64. package/src/generation-client.ts +563 -13
  65. package/src/generation-reconstruct.ts +121 -0
  66. package/src/generation-types.ts +727 -3
  67. package/src/index.ts +56 -1
  68. package/src/interrupt-manager.ts +1440 -0
  69. package/src/storage-adapters.ts +242 -0
  70. package/src/types.ts +301 -9
  71. package/src/video-generation-client.ts +479 -13
  72. package/dist/esm/index.js.map +0 -1
@@ -13,13 +13,18 @@ import {
13
13
  normalizeConnectionAdapter,
14
14
  } from './connection-adapters'
15
15
  import { ChatPersistor } from './client-persistor'
16
+ import { ClearedStreamTracker } from './cleared-stream-tracker'
17
+ import { InterruptManager } from './interrupt-manager'
16
18
  import type {
17
19
  AnyClientTool,
18
20
  ContentPart,
21
+ InterruptSubmissionError,
19
22
  ModelMessage,
23
+ RunAgentResumeItem,
20
24
  StreamChunk,
21
25
  } from '@tanstack/ai/client'
22
26
  import type {
27
+ ChatHydrationResult,
23
28
  ConnectionAdapter,
24
29
  SubscribeConnectionAdapter,
25
30
  } from './connection-adapters'
@@ -33,9 +38,15 @@ import type {
33
38
  ChatDevtoolsBridgeOptions,
34
39
  } from './devtools'
35
40
  import type {
41
+ BoundInterrupts,
36
42
  ChatClientOptions,
37
43
  ChatClientState,
38
44
  ChatFetcher,
45
+ ChatInterrupt,
46
+ ChatInterruptState,
47
+ ChatPendingInterrupt,
48
+ ChatResumeSnapshot,
49
+ ChatResumeState,
39
50
  ConnectionStatus,
40
51
  MessagePart,
41
52
  MultimodalContent,
@@ -48,6 +59,7 @@ import type {
48
59
  UIMessage,
49
60
  WhenBusy,
50
61
  } from './types'
62
+ import type { InterruptManagerSubmission } from './interrupt-manager'
51
63
 
52
64
  /** Internal queue entry — public {@link QueuedMessage} plus optional per-send body. */
53
65
  interface InternalQueuedMessage extends QueuedMessage {
@@ -72,6 +84,16 @@ type ChatClientUpdateOptionsWithoutContext<
72
84
  onConnectionStatusChange?: (status: ConnectionStatus) => void
73
85
  onSessionGeneratingChange?: (isGenerating: boolean) => void
74
86
  onQueueChange?: (queue: Array<QueuedMessage>) => void
87
+ onResumeStateChange?: (
88
+ resumeState: ChatResumeState | null,
89
+ pendingInterrupts: BoundInterrupts<TTools>,
90
+ ) => void
91
+ /**
92
+ * Fires whenever the id of the run in flight changes: the new id when a run
93
+ * starts (including a rejoin), `null` when it settles.
94
+ */
95
+ onRunIdChange?: (runId: string | null) => void
96
+ onInterruptStateChange?: (state: ChatInterruptState<TTools>) => void
75
97
  onCustomEvent?: (
76
98
  eventType: string,
77
99
  data: unknown,
@@ -178,6 +200,74 @@ function mergeQueuedMessages(items: Array<InternalQueuedMessage>): {
178
200
  }
179
201
  }
180
202
 
203
+ /**
204
+ * Extract a boolean approval decision from an AG-UI resume payload, if present.
205
+ * Tool-approval resolutions carry `{ approved: boolean, ... }`; generic
206
+ * interrupt payloads do not.
207
+ */
208
+ function readApprovalApproved(payload: unknown): boolean | undefined {
209
+ if (
210
+ payload === null ||
211
+ typeof payload !== 'object' ||
212
+ Array.isArray(payload)
213
+ ) {
214
+ return undefined
215
+ }
216
+ if (!('approved' in payload) || typeof payload.approved !== 'boolean') {
217
+ return undefined
218
+ }
219
+ return payload.approved
220
+ }
221
+
222
+ function readResumeState(
223
+ snapshot: ChatResumeSnapshot,
224
+ ): ChatResumeState | undefined {
225
+ const value: unknown = snapshot
226
+ if (
227
+ value === null ||
228
+ typeof value !== 'object' ||
229
+ !('resumeState' in value)
230
+ ) {
231
+ return undefined
232
+ }
233
+ const resumeState = value.resumeState
234
+ if (
235
+ resumeState === null ||
236
+ typeof resumeState !== 'object' ||
237
+ !('threadId' in resumeState) ||
238
+ typeof resumeState.threadId !== 'string' ||
239
+ resumeState.threadId.length === 0 ||
240
+ !('runId' in resumeState) ||
241
+ typeof resumeState.runId !== 'string' ||
242
+ resumeState.runId.length === 0
243
+ ) {
244
+ return undefined
245
+ }
246
+ return { threadId: resumeState.threadId, runId: resumeState.runId }
247
+ }
248
+
249
+ /**
250
+ * How long a reload rejoin waits for its first chunk before giving up. A durable
251
+ * backend keeps a from-start join open waiting for a producer; without this
252
+ * bound a stale pointer to an unknown/evicted run would pin the UI loading for
253
+ * the backend's full first-chunk deadline (tens of seconds). Kept short so the
254
+ * client decides "reachable or not" quickly.
255
+ */
256
+ const REJOIN_CONNECT_DEADLINE_MS = 2000
257
+
258
+ /**
259
+ * Chunk types that (re)build the assistant message on a rejoin. The hydrated
260
+ * in-flight partial is dropped only when one of these arrives — never on
261
+ * `RUN_STARTED` alone — so a rejoin that connects but delivers no content cannot
262
+ * leave an empty assistant bubble.
263
+ */
264
+ const REJOIN_REBUILD_TRIGGERS = new Set<string>([
265
+ 'TEXT_MESSAGE_START',
266
+ 'TEXT_MESSAGE_CONTENT',
267
+ 'TOOL_CALL_START',
268
+ 'MESSAGES_SNAPSHOT',
269
+ ])
270
+
181
271
  export class ChatClient<
182
272
  TTools extends ReadonlyArray<AnyClientTool> = any,
183
273
  TContext = unknown,
@@ -186,11 +276,35 @@ export class ChatClient<
186
276
  private connection: SubscribeConnectionAdapter
187
277
  private readonly uniqueId: string
188
278
  private readonly threadId: string
189
- // All persistence concerns (hydrate / save / clear, plus suppression of late
190
- // chunks after a mid-stream clear) live in ChatPersistor so this class stays
191
- // focused on streaming. Undefined when no `persistence` adapter is configured.
279
+ // Durable chat persistence (optional): messages + resume snapshot as one
280
+ // combined record, so a full page reload restores the transcript, rehydrates
281
+ // pending interrupts, and rejoins an in-flight run. Clear-during-stream
282
+ // suppression is always on via ClearedStreamTracker so `clear()` works
283
+ // without a storage adapter.
192
284
  private readonly persistor?: ChatPersistor
285
+ private readonly clearedStreamTracker = new ClearedStreamTracker()
193
286
  private currentRunId: string | null = null
287
+ // Interrupt-resume tracking: the run/thread of the most recent interrupted
288
+ // run, so approvals/client-tool results can be sent back. Cleared when the
289
+ // run terminates. This is STATE (interrupt) resume, not delivery/cursor.
290
+ private lastResume: ChatResumeState | null = null
291
+ // The in-flight run id already handed to `resumeInFlightRun`, so a persisted
292
+ // run is rejoined at most once even when both the sync read and the async
293
+ // hydrate surface the same resume pointer.
294
+ private rejoinedRunId: string | null = null
295
+ private readonly interruptManager: InterruptManager<TTools>
296
+ private activeInterruptSubmission: InterruptManagerSubmission | undefined
297
+ private interruptSubmissionFailure:
298
+ | { errors: ReadonlyArray<InterruptSubmissionError> }
299
+ | undefined
300
+ private readonly joinedRunWaiters = new Map<string, () => void>()
301
+ // When set, the next streamResponse() continues this interrupted run instead
302
+ // of starting a fresh run (consumed once).
303
+ private pendingResumeParentRunId: string | null = null
304
+ private pendingResumeThreadId: string | null = null
305
+ private pendingResumeItems: Array<RunAgentResumeItem> | null = null
306
+ private activeResumeThreadId: string | null = null
307
+ private activeResumeRunId: string | null = null
194
308
  // Track the legacy `body` option and the canonical `forwardedProps`
195
309
  // option as separate slots so that `updateOptions({ forwardedProps })`
196
310
  // doesn't wipe a previously-set `body` (and vice versa). They are
@@ -258,6 +372,13 @@ export class ChatClient<
258
372
  private draining = false
259
373
  private sessionGenerating = false
260
374
  private readonly activeRunIds = new Set<string>()
375
+ /** Latched by `dispose()`; stops any late async callback starting new work. */
376
+ private disposed = false
377
+ /** Whether a view is currently watching. See `attach` / `detach`. */
378
+ private tailing = false
379
+ /** Constructor inputs `attach()` needs on every re-attach, not just the first. */
380
+ private readonly rejoinRunId: string | null | undefined
381
+ private readonly cachesMessages: boolean
261
382
  private devtoolsMounted = false
262
383
 
263
384
  private readonly callbacksRef: {
@@ -274,6 +395,12 @@ export class ChatClient<
274
395
  onConnectionStatusChange: (status: ConnectionStatus) => void
275
396
  onSessionGeneratingChange: (isGenerating: boolean) => void
276
397
  onQueueChange: (queue: Array<QueuedMessage>) => void
398
+ onResumeStateChange: (
399
+ resumeState: ChatResumeState | null,
400
+ pendingInterrupts: BoundInterrupts<TTools>,
401
+ ) => void
402
+ onRunIdChange: (runId: string | null) => void
403
+ onInterruptStateChange: (state: ChatInterruptState<TTools>) => void
277
404
  onCustomEvent: (
278
405
  eventType: string,
279
406
  data: unknown,
@@ -283,13 +410,33 @@ export class ChatClient<
283
410
  }
284
411
 
285
412
  constructor(options: ChatClientOptions<TTools, TContext>) {
286
- this.uniqueId = options.id || this.generateUniqueId('chat')
287
413
  this.threadId = options.threadId || this.generateUniqueId('thread')
288
- if (options.persistence) {
414
+ // The instance/devtools id defaults to the threadId (the chat's identity),
415
+ // falling back to a generated id only when neither is set. `id` overrides it
416
+ // only for direct ChatClient users who key storage separately from the wire
417
+ // thread; the framework hooks never pass it.
418
+ this.uniqueId = options.id || this.threadId
419
+ // `persistence` is `false`/omitted (ephemeral, in-memory), `true`
420
+ // (server-authoritative: cache nothing client-side, hydrate the thread from
421
+ // the server by `threadId` on mount), or a storage adapter
422
+ // (client-authoritative: cache the transcript plus resume pointer). Only the
423
+ // server-authoritative mode turns transcript caching off; that is what gates
424
+ // the mount hydration and keeps a client record from shadowing server history.
425
+ let cachesMessages = true
426
+ if (options.persistence === true) {
427
+ cachesMessages = false
428
+ } else if (options.persistence) {
429
+ // A storage adapter: keep the combined record (transcript + resume pointer)
430
+ // in the browser. Persistence keys on `threadId` (the conversation
431
+ // identity) so a reload with the same `threadId` finds the same record;
432
+ // `id` overrides it only when set, for apps that key storage separately
433
+ // from the wire thread.
434
+ const persistenceKey = options.id ?? this.threadId
289
435
  this.persistor = new ChatPersistor(
290
436
  options.persistence,
291
- this.uniqueId,
437
+ persistenceKey,
292
438
  (messages) => this.processor.setMessages(messages),
439
+ (snapshot) => this.applyPersistedResume(snapshot),
293
440
  )
294
441
  }
295
442
  // Both `body` (deprecated) and `forwardedProps` populate the AG-UI
@@ -332,17 +479,72 @@ export class ChatClient<
332
479
  onSessionGeneratingChange:
333
480
  options.onSessionGeneratingChange || (() => {}),
334
481
  onQueueChange: options.onQueueChange || (() => {}),
482
+ onResumeStateChange: options.onResumeStateChange || (() => {}),
483
+ onRunIdChange: options.onRunIdChange || (() => {}),
484
+ onInterruptStateChange: options.onInterruptStateChange || (() => {}),
335
485
  onCustomEvent: options.onCustomEvent || (() => {}),
336
486
  },
337
487
  }
338
488
 
489
+ this.interruptManager = new InterruptManager({
490
+ ...(options.tools !== undefined ? { tools: options.tools } : {}),
491
+ submit: (submission) => this.submitInterruptBatch(submission),
492
+ onChange: () => this.notifyResumeStateChange(),
493
+ })
494
+
495
+ // In-memory rehydrate of interrupt descriptors (e.g. after a page reload
496
+ // when the host supplies a snapshot). Durable storage of that snapshot is
497
+ // a persistence-stack concern — not wired here.
498
+ if (options.initialResumeSnapshot) {
499
+ this.applyResumeSnapshot(options.initialResumeSnapshot)
500
+ }
501
+
339
502
  // Create StreamProcessor with event handlers.
340
503
  // Use conditional spreads so we don't pass `undefined` into
341
504
  // `StreamProcessorOptions` fields under `exactOptionalPropertyTypes`.
342
- const persistedMessages = this.persistor?.readInitial()
343
- const initialMessages = Array.isArray(persistedMessages)
344
- ? persistedMessages
505
+ const persistedState = this.persistor?.readInitial()
506
+ const syncPersistedState =
507
+ persistedState instanceof Promise ? undefined : persistedState
508
+ // A persistor exists only in client-authoritative mode, so a synchronously
509
+ // read record's transcript is the conversation; adopt it over host
510
+ // `initialMessages`. (Server-authoritative mode has no persistor and instead
511
+ // hydrates from the server on mount, keyed by threadId.)
512
+ const initialMessages = syncPersistedState
513
+ ? syncPersistedState.messages
345
514
  : options.initialMessages
515
+ // A durable snapshot read synchronously from storage wins over the
516
+ // in-memory `initialResumeSnapshot` fallback applied above. A snapshot with
517
+ // pending interrupts rehydrates the interrupt UI; a bare in-flight run is
518
+ // rejoined after the processor is ready (see `rejoinRunId` below).
519
+ let rejoinRunId: string | null = null
520
+ if (syncPersistedState?.resume) {
521
+ const snapshot = syncPersistedState.resume
522
+ const hasPendingInterrupts =
523
+ Array.isArray(snapshot.pendingInterrupts) &&
524
+ snapshot.pendingInterrupts.length > 0
525
+ if (hasPendingInterrupts) {
526
+ // Interrupts are run-scoped state, restored from the cached snapshot.
527
+ this.applyResumeSnapshot(snapshot)
528
+ } else if (snapshot.resumeState.runId) {
529
+ // A bare in-flight run pointer drives a client-authoritative rejoin.
530
+ rejoinRunId = snapshot.resumeState.runId
531
+ }
532
+ }
533
+ // A host-supplied `initialResumeSnapshot` carrying a bare in-flight run is
534
+ // rejoined too, not just its interrupts (which `applyResumeSnapshot` above
535
+ // already restored). This is how a server-authoritative app hands a FRESH
536
+ // client an in-flight run to tail — e.g. opening the thread on a second
537
+ // device / browser, where hydration reports the active run id but no local
538
+ // resume pointer exists. A run named by the persisted store wins.
539
+ if (!rejoinRunId && options.initialResumeSnapshot) {
540
+ const snapshot = options.initialResumeSnapshot
541
+ const hasPendingInterrupts =
542
+ Array.isArray(snapshot.pendingInterrupts) &&
543
+ snapshot.pendingInterrupts.length > 0
544
+ if (!hasPendingInterrupts && snapshot.resumeState.runId) {
545
+ rejoinRunId = snapshot.resumeState.runId
546
+ }
547
+ }
346
548
 
347
549
  this.processor = new StreamProcessor({
348
550
  ...(options.streamProcessor?.chunkStrategy
@@ -544,12 +746,230 @@ export class ChatClient<
544
746
  data: unknown,
545
747
  context: { toolCallId?: string },
546
748
  ) => {
749
+ // Server-side memory middleware transports its state as a `memory:state`
750
+ // CUSTOM event (its own event bus never reaches this browser runtime).
751
+ // Route it to the devtools bridge here — the designated custom-event
752
+ // path — then still forward to the app's callback.
753
+ if (eventType === 'memory:state') {
754
+ this.devtoolsBridge.recordMemoryState(data)
755
+ }
547
756
  this.callbacksRef.current.onCustomEvent(eventType, data, context)
548
757
  },
549
758
  },
550
759
  })
551
760
 
552
- this.persistor?.hydrateAsync(persistedMessages)
761
+ this.persistor?.hydrateAsync(persistedState)
762
+
763
+ this.rejoinRunId = rejoinRunId
764
+ this.cachesMessages = cachesMessages
765
+ // NO TAILING HERE, deliberately. Constructing a client must not open a
766
+ // connection.
767
+ //
768
+ // A UI framework may build a client and then throw it away — React does it on
769
+ // every double-invoked render, and the discarded instance is never mounted, so
770
+ // nothing ever calls `detach()` or `dispose()` on it. When the constructor
771
+ // opened a stream, that stream became unreachable and held one of the browser's
772
+ // ~6 connections per origin until the page reloaded. Traced with CDP: connection
773
+ // ids 1374/1396/1428/1437 were still held after eight thread switches, and a
774
+ // later request waited 210 SECONDS for a free slot (`stallMs: 210752`).
775
+ //
776
+ // Guarding inside the client cannot fix that, because the leaking instance is
777
+ // the one the framework discarded — every guard runs on the instance it kept.
778
+ // Only "idle until a view attaches" makes a thrown-away client harmless.
779
+ //
780
+ // Callers therefore drive the lifecycle: `attach()` when a view mounts,
781
+ // `detach()` when it unmounts. Every framework wrapper in this repo does.
782
+ }
783
+
784
+ /**
785
+ * START TAILING: re-attach to an in-flight run so its chunks arrive here.
786
+ *
787
+ * Called by the constructor, and again by a UI wrapper every time its view
788
+ * mounts. Idempotent — attaching while already attached does nothing — so the
789
+ * constructor call and a wrapper's first mount cost one attach between them.
790
+ *
791
+ * Pairs with {@link detach}. The pair exists because tailing used to begin ONLY
792
+ * in the constructor, which meant a view could never stop tailing and then
793
+ * resume: unmount had to either keep the connection open or lose it for good.
794
+ * Keeping it open is what starved the page — a browser allows ~6 connections per
795
+ * origin, and one long-lived stream per view reaches that after a handful of
796
+ * views, after which every other request queues (measured: an in-page fetch took
797
+ * over two minutes while the same request from outside the browser took 17ms).
798
+ */
799
+ attach(): void {
800
+ if (this.disposed || this.tailing) return
801
+ this.tailing = true
802
+
803
+ // Full page reload with an in-flight run persisted (synchronous store):
804
+ // re-attach to it off the server's delivery-durability log so the stream
805
+ // finishes here. Async stores rejoin from `applyPersistedResume` once the
806
+ // hydrate resolves. Best-effort and non-blocking.
807
+ if (this.rejoinRunId) {
808
+ this.maybeRejoinInFlight(this.rejoinRunId)
809
+ }
810
+
811
+ // Server-authoritative (`persistence: true`): the client caches no transcript
812
+ // and no run pointer — it re-hydrates from the server on mount, keyed by the
813
+ // stable threadId. `hydrate` returns the stored transcript plus a cursor to
814
+ // any in-flight run, which is tailed via the same joinRun path. This is what
815
+ // makes reload AND a fresh device work with zero app glue (no loader/prop).
816
+ if (!this.cachesMessages && this.connection.hydrate) {
817
+ this.hydrateFromServer()
818
+ }
819
+ }
820
+
821
+ /**
822
+ * STOP TAILING: drop the connection, keep everything else.
823
+ *
824
+ * Called by a UI wrapper when its view unmounts. The transcript, the resume
825
+ * pointer and the run id all stay, so a later {@link attach} repaints instantly
826
+ * and re-tails from the durable log — nothing is lost, because the run keeps
827
+ * going server-side and its log holds every chunk.
828
+ *
829
+ * Deliberately NOT `dispose()`: this client is expected back. And deliberately
830
+ * not `stop()`, which means "the user ended this run" — detaching says only that
831
+ * nobody is watching right now.
832
+ *
833
+ * `rejoinedRunId` is cleared so the next `attach` can re-join the same run;
834
+ * without that reset the guard in {@link maybeRejoinInFlight} would treat the
835
+ * run as already joined and the view would come back silent.
836
+ */
837
+ detach(): void {
838
+ if (!this.tailing) return
839
+ // BEFORE the abort, because `resumeInFlightRun`'s cleanup reads it: a join
840
+ // aborted before its first chunk normally means "this run is unreachable" and
841
+ // clears the resume pointer. A detach is not that — the run is fine and we
842
+ // intend to come back — so the pointer must survive.
843
+ this.tailing = false
844
+ this.cancelInFlightStream({ setReadyStatus: true })
845
+ this.rejoinedRunId = null
846
+ }
847
+
848
+ private applyResumeSnapshot(snapshot: ChatResumeSnapshot): void {
849
+ const resumeState = readResumeState(snapshot)
850
+ if (resumeState === undefined) {
851
+ this.interruptManager.reset()
852
+ return
853
+ }
854
+ this.lastResume = resumeState
855
+ const pendingInterrupts = Array.isArray(snapshot.pendingInterrupts)
856
+ ? snapshot.pendingInterrupts
857
+ : []
858
+ if (pendingInterrupts.length === 0) {
859
+ this.interruptManager.reset()
860
+ return
861
+ }
862
+ const generation = this.interruptGeneration(pendingInterrupts)
863
+ this.interruptManager.hydrate({
864
+ threadId: resumeState.threadId,
865
+ interruptedRunId: resumeState.runId,
866
+ generation,
867
+ interrupts: pendingInterrupts,
868
+ })
869
+ }
870
+
871
+ /**
872
+ * Apply a resume snapshot read from durable storage. Restores interrupt state,
873
+ * and for a bare in-flight run (no pending interrupts) also rejoins it. This is
874
+ * the async-store counterpart to the synchronous rejoin in the constructor:
875
+ * `applyResumeSnapshot` alone only handles interrupts, so an async store
876
+ * (`indexedDBPersistence`) would otherwise never rejoin a mid-stream run.
877
+ */
878
+ private applyPersistedResume(snapshot: ChatResumeSnapshot): void {
879
+ this.applyResumeSnapshot(snapshot)
880
+ const hasInterrupts =
881
+ Array.isArray(snapshot.pendingInterrupts) &&
882
+ snapshot.pendingInterrupts.length > 0
883
+ const runId = snapshot.resumeState?.runId
884
+ // A cached run pointer only reaches here through the persistor, which exists
885
+ // only in client-authoritative mode, so a bare in-flight run rejoins here.
886
+ // (Server-authoritative reconnect is resolved from the server by threadId in
887
+ // `hydrateFromServer`.)
888
+ if (!hasInterrupts && runId) {
889
+ this.maybeRejoinInFlight(runId)
890
+ }
891
+ }
892
+
893
+ /**
894
+ * Rejoin a persisted in-flight run, guarded so it fires at most once and never
895
+ * while another run is already active. Skipped when the connection is not
896
+ * resumable (`joinRun` absent), so a non-durable transport is a no-op.
897
+ */
898
+ private maybeRejoinInFlight(runId: string): void {
899
+ if (!this.connection.joinRun) return
900
+ // A client with no view attached must never open a connection. `tailing` is
901
+ // the load-bearing half: a view switch calls `detach()`, NOT `dispose()`, and
902
+ // an in-flight hydration resolves a moment later and lands right here — so
903
+ // guarding only on `disposed` let every switch open a fresh tail that nothing
904
+ // would ever abort. Measured with CDP: connection ids 1366/1397/1429/1460 were
905
+ // still held after eight switches, and a later request waited 97 SECONDS for a
906
+ // slot (`stallMs: 97691`).
907
+ if (this.disposed || !this.tailing) return
908
+ if (this.rejoinedRunId === runId) return
909
+ // A fresh send (or an already-running rejoin) owns the client; don't stomp it.
910
+ if (this.isLoading || this.abortController) return
911
+ this.rejoinedRunId = runId
912
+ this.resumeInFlightRun(runId)
913
+ }
914
+
915
+ /**
916
+ * Server-authoritative mount hydration (`persistence: true`). The client holds
917
+ * no transcript and no run pointer; on mount it asks the server — keyed by the
918
+ * stable threadId — for the stored transcript and whether a run is still
919
+ * generating. The transcript repaints immediately; an in-flight run is tailed
920
+ * through the same durability rejoin as a reload. Best-effort and
921
+ * non-blocking: a failure leaves the client empty rather than throwing, and a
922
+ * send that starts first owns the client (hydration then backs off).
923
+ */
924
+ private hydrateFromServer(): void {
925
+ const hydrate = this.connection.hydrate
926
+ if (!hydrate) return
927
+ if (this.isLoading || this.abortController) return
928
+ if (this.disposed) return
929
+ void (async () => {
930
+ let result: ChatHydrationResult
931
+ try {
932
+ result = await hydrate(this.threadId)
933
+ } catch {
934
+ return
935
+ }
936
+ // NO VIEW IS WATCHING ANY MORE (it unmounted while this fetch was in
937
+ // flight). Applying anything now is pointless, and one thing is actively
938
+ // harmful: the branch below calls `maybeRejoinInFlight`, which opens a TAIL.
939
+ // A tail started here belongs to a view that has gone, so nothing will ever
940
+ // abort it, and a browser allows only ~6 connections per origin — so a
941
+ // handful of switches starve the page and every later request queues.
942
+ //
943
+ // `!this.tailing` is the case that actually bites: a switch calls `detach()`,
944
+ // not `dispose()`, so a `disposed`-only check let the leak straight through.
945
+ if (this.disposed || !this.tailing) return
946
+ // A send may have started while the fetch was in flight — don't stomp it.
947
+ if (this.isLoading || this.abortController) return
948
+ if (result.messages.length > 0) {
949
+ this.processor.setMessages(result.messages)
950
+ }
951
+ if (result.interrupts && result.interrupts.pending.length > 0) {
952
+ // Pending interrupt = the thread is paused awaiting a human decision, so
953
+ // there is nothing to tail (no chunks stream until it resolves). Restore
954
+ // the approval/wait from the SERVER — identical to reconstructing it from
955
+ // a resume snapshot — so the reload re-prompts the decision and the resume
956
+ // targets the run it paused. This is checked BEFORE `activeRun` on
957
+ // purpose: a run that just paused can momentarily still read as `running`
958
+ // on the server, so a racing hydrate reports both an `activeRun` cursor
959
+ // AND the pending interrupt. Tailing that "active" run would drop the
960
+ // approval card (and hang on a stream that never comes), so the interrupt
961
+ // always wins.
962
+ this.applyResumeSnapshot({
963
+ resumeState: {
964
+ threadId: this.threadId,
965
+ runId: result.interrupts.runId,
966
+ },
967
+ pendingInterrupts: result.interrupts.pending,
968
+ })
969
+ } else if (result.activeRun?.runId) {
970
+ this.maybeRejoinInFlight(result.activeRun.runId)
971
+ }
972
+ })()
553
973
  }
554
974
 
555
975
  mountDevtools(): void {
@@ -568,22 +988,52 @@ export class ChatClient<
568
988
  */
569
989
  private drainIgnoredRunlessChunk(chunk: StreamChunk): void {
570
990
  if (chunk.type !== 'RUN_ERROR') return
571
- const runId = this.persistor?.takeRunlessRunId()
991
+ const runId = this.clearedStreamTracker.takeRunlessRunId()
572
992
  if (!runId) return
573
993
  this.activeRunIds.delete(runId)
574
994
  this.setSessionGenerating(this.activeRunIds.size > 0)
575
995
  this.resolveProcessing()
576
996
  }
577
997
 
998
+ private retireIgnoredClearedTerminalChunk(chunk: StreamChunk): void {
999
+ if (chunk.type !== 'RUN_FINISHED' && chunk.type !== 'RUN_ERROR') return
1000
+ const runId =
1001
+ getChunkRunId(chunk) ?? this.clearedStreamTracker.takeRunlessRunId()
1002
+ if (!runId) return
1003
+ this.activeRunIds.delete(runId)
1004
+ this.setSessionGenerating(this.activeRunIds.size > 0)
1005
+ if (!getChunkRunId(chunk)) {
1006
+ this.resolveProcessing()
1007
+ }
1008
+ }
1009
+
578
1010
  private updateRunLifecycle(
579
1011
  chunk: StreamChunk,
580
1012
  options?: { resolveProcessing?: boolean },
581
1013
  ): void {
582
1014
  if (chunk.type === 'RUN_STARTED') {
583
1015
  const chunkRunId = getChunkRunId(chunk) ?? chunk.runId
1016
+ this.activeResumeThreadId =
1017
+ 'threadId' in chunk && typeof chunk.threadId === 'string'
1018
+ ? chunk.threadId
1019
+ : this.activeResumeThreadId
1020
+ this.activeResumeRunId = chunkRunId
584
1021
  this.activeRunIds.add(chunkRunId)
585
- this.persistor?.onRunStarted(chunkRunId)
1022
+ this.clearedStreamTracker.onRunStarted(chunkRunId)
586
1023
  this.setSessionGenerating(true)
1024
+ // Persist a live-run resume snapshot so a full page reload can rejoin this
1025
+ // in-flight run via joinRun. Only a persistor writes it, and a persistor
1026
+ // exists only in client-authoritative mode; server-authoritative reconnect
1027
+ // is resolved from the server by threadId in `hydrateFromServer`, so no
1028
+ // client-cached run pointer (which goes stale the moment a turn spans a
1029
+ // second run) is ever written. Interrupt/terminal handling overwrites or
1030
+ // clears it in observeInterruptState.
1031
+ if (this.persistor && this.connection.joinRun && !this.lastResume) {
1032
+ this.persistResumeSnapshot({
1033
+ threadId: this.activeResumeThreadId ?? this.threadId,
1034
+ runId: chunkRunId,
1035
+ })
1036
+ }
587
1037
  return
588
1038
  }
589
1039
 
@@ -594,11 +1044,11 @@ export class ChatClient<
594
1044
  const runId = getChunkRunId(chunk)
595
1045
  if (runId) {
596
1046
  this.activeRunIds.delete(runId)
597
- this.persistor?.onRunSettled(runId)
1047
+ this.clearedStreamTracker.onRunSettled(runId)
598
1048
  } else if (chunk.type === 'RUN_ERROR') {
599
1049
  // RUN_ERROR without runId is a session-level error; clear all runs.
600
1050
  this.activeRunIds.clear()
601
- this.persistor?.onSessionRunError()
1051
+ this.clearedStreamTracker.onSessionRunError()
602
1052
  }
603
1053
  this.setSessionGenerating(this.activeRunIds.size > 0)
604
1054
  if (options?.resolveProcessing !== false) {
@@ -606,6 +1056,260 @@ export class ChatClient<
606
1056
  }
607
1057
  }
608
1058
 
1059
+ /**
1060
+ * Track interrupt state off the stream's terminal events. A RUN_FINISHED with
1061
+ * an interrupt outcome records the pending interrupts + the run/thread to
1062
+ * resume; any other terminal event for the tracked/current run clears that
1063
+ * state. This is interrupt (state) resume — there is no delivery cursor.
1064
+ */
1065
+ private observeInterruptState(chunk: StreamChunk): void {
1066
+ if (chunk.type !== 'RUN_FINISHED' && chunk.type !== 'RUN_ERROR') {
1067
+ return
1068
+ }
1069
+
1070
+ if (this.activeInterruptSubmission && chunk.type === 'RUN_ERROR') {
1071
+ return
1072
+ }
1073
+ const runId = getChunkRunId(chunk)
1074
+ const threadId =
1075
+ 'threadId' in chunk && typeof chunk.threadId === 'string'
1076
+ ? chunk.threadId
1077
+ : this.activeResumeThreadId
1078
+
1079
+ if (chunk.type === 'RUN_FINISHED' && chunk.outcome?.type === 'interrupt') {
1080
+ // Track the REQUEST run id (what the client sent) so a resume targets the
1081
+ // same run even when provider events carry their own run id.
1082
+ const interruptedRunId =
1083
+ this.currentRunId ?? runId ?? this.activeResumeRunId ?? ''
1084
+ this.lastResume = {
1085
+ threadId: threadId ?? this.threadId,
1086
+ runId: interruptedRunId,
1087
+ }
1088
+ this.interruptManager.hydrate({
1089
+ threadId: this.lastResume.threadId,
1090
+ interruptedRunId,
1091
+ generation: this.interruptGeneration(chunk.outcome.interrupts),
1092
+ interrupts: chunk.outcome.interrupts,
1093
+ })
1094
+ return
1095
+ }
1096
+
1097
+ const isRunlessSessionError = chunk.type === 'RUN_ERROR' && !runId
1098
+ const isTrackedRunTerminal = Boolean(
1099
+ runId && this.lastResume?.runId === runId,
1100
+ )
1101
+ const isCurrentRunTerminal = Boolean(
1102
+ (runId && this.currentRunId === runId) ||
1103
+ (this.currentRunId && this.lastResume?.runId === this.currentRunId),
1104
+ )
1105
+ // Provider adapters sometimes stamp a different run id on continuation
1106
+ // events than the client-generated request id. RUN_STARTED updates
1107
+ // `activeResumeRunId`, so match that too.
1108
+ const isActiveStreamRunTerminal = Boolean(
1109
+ this.isLoading &&
1110
+ runId &&
1111
+ (runId === this.activeResumeRunId || runId === this.currentRunId),
1112
+ )
1113
+ const isCurrentStreamTerminal =
1114
+ this.isLoading && chunk.type === 'RUN_FINISHED' && !runId
1115
+ // A resume batch that finishes successfully (or with a non-interrupt
1116
+ // terminal) must always clear pending interrupts — even when the provider
1117
+ // run id does not correlate. Otherwise Approve works once but the UI
1118
+ // keeps showing a stale prompt and blocks follow-up turns.
1119
+ const isActiveInterruptSubmissionTerminal = Boolean(
1120
+ this.activeInterruptSubmission &&
1121
+ this.isLoading &&
1122
+ chunk.type === 'RUN_FINISHED' &&
1123
+ chunk.outcome?.type !== 'interrupt',
1124
+ )
1125
+ if (
1126
+ isRunlessSessionError ||
1127
+ isTrackedRunTerminal ||
1128
+ isCurrentRunTerminal ||
1129
+ isActiveStreamRunTerminal ||
1130
+ isCurrentStreamTerminal ||
1131
+ isActiveInterruptSubmissionTerminal
1132
+ ) {
1133
+ this.lastResume = null
1134
+ // Run settled without an interrupt: drop the durable resume snapshot so a
1135
+ // later reload does not try to rejoin a finished run.
1136
+ this.persistor?.persistResumeSnapshot(null)
1137
+ this.interruptManager.reset()
1138
+ return
1139
+ }
1140
+ this.notifyResumeStateChange()
1141
+ }
1142
+
1143
+ /**
1144
+ * The interrupt-resume state for the active/interrupted run (its run/thread
1145
+ * ids), or null when there is nothing to resume. Apps can persist this to
1146
+ * resume interrupts across a full reload.
1147
+ */
1148
+ getResumeState(): ChatResumeState | null {
1149
+ return this.lastResume ? { ...this.lastResume } : null
1150
+ }
1151
+
1152
+ /**
1153
+ * The id of the run this client has in flight — one it started via a send or
1154
+ * rejoined via `joinRun` — or null when there is none. Unlike
1155
+ * {@link getResumeState}, this tracks ordinary runs too, not only one that is
1156
+ * interrupted or being resumed. A run another client started and that arrives
1157
+ * over a live subscription is not this client's run and is not reported here.
1158
+ */
1159
+ getCurrentRunId(): string | null {
1160
+ return this.currentRunId
1161
+ }
1162
+
1163
+ private setCurrentRunId(runId: string | null): void {
1164
+ if (this.currentRunId === runId) return
1165
+ this.currentRunId = runId
1166
+ this.callbacksRef.current.onRunIdChange(runId)
1167
+ }
1168
+
1169
+ getInterruptState(): ChatInterruptState<TTools> {
1170
+ return this.interruptManager.getState()
1171
+ }
1172
+
1173
+ getInterrupts(): BoundInterrupts<TTools> {
1174
+ return this.interruptManager.getInterrupts()
1175
+ }
1176
+
1177
+ /** @deprecated Use getInterrupts(). */
1178
+ getPendingInterrupts(): BoundInterrupts<TTools> {
1179
+ return this.interruptManager.getInterrupts()
1180
+ }
1181
+
1182
+ resolveInterrupts(approved: boolean): void
1183
+ resolveInterrupts(
1184
+ resolver: (interrupt: ChatInterrupt<TTools>) => undefined,
1185
+ ): void
1186
+ resolveInterrupts(
1187
+ resolution: boolean | ((interrupt: ChatInterrupt<TTools>) => undefined),
1188
+ ): void {
1189
+ // Branch so TypeScript can select the InterruptManager.resolve overloads.
1190
+ if (typeof resolution === 'boolean') {
1191
+ this.interruptManager.resolve(resolution)
1192
+ return
1193
+ }
1194
+ this.interruptManager.resolve(resolution)
1195
+ }
1196
+
1197
+ cancelInterrupts(): void {
1198
+ this.interruptManager.cancel()
1199
+ }
1200
+
1201
+ retryInterrupts(): void {
1202
+ this.interruptManager.retry()
1203
+ }
1204
+
1205
+ /** Unsafe low-level resume escape hatch. Prefer bound interrupt methods. */
1206
+ resumeInterruptsUnsafe(
1207
+ resume: Array<RunAgentResumeItem>,
1208
+ state?: ChatResumeState,
1209
+ ): Promise<boolean> {
1210
+ const target = state ?? this.lastResume
1211
+ if (!target) return Promise.resolve(false)
1212
+ // Auto-executed client tools resolve during the parent stream's
1213
+ // `pendingToolExecutions` wait — while `isLoading` is still true.
1214
+ // Defer the child continuation until that stream settles so we do not
1215
+ // race the parent cleanup or return a false "could not start" failure.
1216
+ if (this.isLoading) {
1217
+ return new Promise<boolean>((resolve, reject) => {
1218
+ this.queuePostStreamAction(async () => {
1219
+ try {
1220
+ resolve(await this.resumeInterruptsUnsafe(resume, target))
1221
+ } catch (error) {
1222
+ reject(error)
1223
+ }
1224
+ })
1225
+ })
1226
+ }
1227
+ this.pendingResumeThreadId = target.threadId
1228
+ this.pendingResumeParentRunId = target.runId
1229
+ this.pendingResumeItems = [...resume]
1230
+ return this.streamResponse()
1231
+ }
1232
+
1233
+ /** @deprecated Use bound interrupt methods or resumeInterruptsUnsafe(). */
1234
+ resumeInterrupts(
1235
+ resume: Array<RunAgentResumeItem>,
1236
+ state?: ChatResumeState,
1237
+ ): Promise<boolean> {
1238
+ return this.resumeInterruptsUnsafe(resume, state)
1239
+ }
1240
+
1241
+ private async submitInterruptBatch(
1242
+ submission: InterruptManagerSubmission,
1243
+ ): Promise<void> {
1244
+ this.activeInterruptSubmission = submission
1245
+ this.interruptSubmissionFailure = undefined
1246
+ // Reflect approval decisions in the local message tree immediately so a
1247
+ // follow-up turn does not re-serialize tool-calls still stuck in
1248
+ // `approval-requested` (issue #532).
1249
+ for (const resolution of submission.resolutions) {
1250
+ const approved = readApprovalApproved(resolution.payload)
1251
+ if (approved === undefined) continue
1252
+ const approvalId = resolution.interruptId
1253
+ this.processor.addToolApprovalResponse(approvalId, approved)
1254
+ }
1255
+ const resumed = await this.resumeInterruptsUnsafe(
1256
+ [...submission.resolutions],
1257
+ {
1258
+ threadId: submission.threadId,
1259
+ runId: submission.interruptedRunId,
1260
+ },
1261
+ ).finally(() => {
1262
+ this.activeInterruptSubmission = undefined
1263
+ })
1264
+ const failure = this.takeInterruptSubmissionFailure()
1265
+ if (failure !== undefined) {
1266
+ throw { errors: failure.errors }
1267
+ }
1268
+ if (!resumed) {
1269
+ throw new Error('Interrupt continuation could not be started.')
1270
+ }
1271
+ // Belt-and-suspenders: if the continuation stream finished successfully
1272
+ // but correlation failed to clear resume state, drop it now so the next
1273
+ // user turn is not blocked by a stale interrupt prompt.
1274
+ if (this.lastResume?.runId === submission.interruptedRunId) {
1275
+ this.lastResume = null
1276
+ this.interruptManager.reset()
1277
+ }
1278
+ }
1279
+
1280
+ private takeInterruptSubmissionFailure():
1281
+ | { errors: ReadonlyArray<InterruptSubmissionError> }
1282
+ | undefined {
1283
+ const failure = this.interruptSubmissionFailure
1284
+ this.interruptSubmissionFailure = undefined
1285
+ return failure
1286
+ }
1287
+
1288
+ private interruptGeneration(
1289
+ interrupts: ReadonlyArray<ChatPendingInterrupt>,
1290
+ ): number {
1291
+ let generation: number | undefined
1292
+ for (const interrupt of interrupts) {
1293
+ const candidate: unknown =
1294
+ interrupt.metadata?.['tanstack:interruptBinding']
1295
+ if (
1296
+ candidate === null ||
1297
+ typeof candidate !== 'object' ||
1298
+ !('generation' in candidate) ||
1299
+ typeof candidate.generation !== 'number' ||
1300
+ !Number.isInteger(candidate.generation) ||
1301
+ candidate.generation < 0
1302
+ ) {
1303
+ return 0
1304
+ }
1305
+ if (generation !== undefined && generation !== candidate.generation) {
1306
+ return 0
1307
+ }
1308
+ generation = candidate.generation
1309
+ }
1310
+ return generation ?? 0
1311
+ }
1312
+
609
1313
  private generateUniqueId(prefix: string): string {
610
1314
  return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(7)}`
611
1315
  }
@@ -641,9 +1345,47 @@ export class ChatClient<
641
1345
  this.devtoolsBridge.emitSnapshot()
642
1346
  }
643
1347
 
644
- private resetSessionGenerating(): void {
1348
+ private notifyResumeStateChange(): void {
1349
+ const resumeState = this.getResumeState()
1350
+ // Persist (or clear) the durable resume snapshot so a full page reload can
1351
+ // rehydrate pending interrupts and rejoin the run. Folded into the same
1352
+ // persistence adapter that stores messages (one record per chat).
1353
+ this.persistResumeSnapshot(resumeState)
1354
+ this.callbacksRef.current.onResumeStateChange(
1355
+ resumeState,
1356
+ this.interruptManager.getInterrupts(),
1357
+ )
1358
+ this.callbacksRef.current.onInterruptStateChange(
1359
+ this.interruptManager.getState(),
1360
+ )
1361
+ }
1362
+
1363
+ /**
1364
+ * Build the durable resume snapshot from the current resume state + pending
1365
+ * interrupt descriptors and hand it to the persistor (null clears it).
1366
+ */
1367
+ private persistResumeSnapshot(resumeState: ChatResumeState | null): void {
1368
+ if (!this.persistor) return
1369
+ if (!resumeState) {
1370
+ this.persistor.persistResumeSnapshot(null)
1371
+ return
1372
+ }
1373
+ const descriptors = this.interruptManager.getDescriptors()
1374
+ this.persistor.persistResumeSnapshot({
1375
+ resumeState,
1376
+ ...(descriptors.length > 0
1377
+ ? { pendingInterrupts: [...descriptors] }
1378
+ : {}),
1379
+ })
1380
+ }
1381
+
1382
+ private resetSessionGenerating(options?: {
1383
+ preserveClearedStreamTracking?: boolean
1384
+ }): void {
645
1385
  this.activeRunIds.clear()
646
- this.persistor?.resetIgnored()
1386
+ if (!options?.preserveClearedStreamTracking) {
1387
+ this.clearedStreamTracker.resetActiveRuns()
1388
+ }
647
1389
  this.setSessionGenerating(false)
648
1390
  }
649
1391
 
@@ -793,33 +1535,204 @@ export class ChatClient<
793
1535
  const stream = this.connection.subscribe(signal)
794
1536
  for await (const chunk of stream) {
795
1537
  if (signal.aborted) break
796
- if (this.connectionStatus === 'connecting') {
797
- this.setConnectionStatus('connected')
798
- }
799
- const shouldIgnore = this.persistor?.shouldIgnoreChunk(chunk) ?? false
800
- if (shouldIgnore) {
801
- if (chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR') {
802
- if (getChunkRunId(chunk)) {
803
- this.updateRunLifecycle(chunk, { resolveProcessing: false })
804
- } else {
805
- this.drainIgnoredRunlessChunk(chunk)
1538
+ await this.processIncomingChunk(chunk)
1539
+ }
1540
+ }
1541
+
1542
+ /**
1543
+ * Re-attach to an in-flight run after a full page reload, replaying its stream
1544
+ * from the server's delivery-durability log via `joinRun` (which returns the
1545
+ * whole run so far, then tails live to completion).
1546
+ *
1547
+ * The log is the single source of truth for the run, so we rebuild the
1548
+ * in-flight assistant bubble from it rather than trying to reconcile the
1549
+ * server-hydrated partial with the replay: on the first chunk that actually
1550
+ * (re)builds a message we drop the hydrated in-flight assistant, and the
1551
+ * replay reconstructs one clean bubble. Dropping only on real content (not on
1552
+ * `RUN_STARTED`) means a rejoin that connects but delivers nothing can never
1553
+ * leave an empty bubble behind.
1554
+ *
1555
+ * Bounded connect: a durable backend keeps a from-start join open waiting for
1556
+ * a producer, so a stale pointer to an unknown/evicted run would otherwise pin
1557
+ * the UI in a loading state for the backend's full first-chunk deadline. We
1558
+ * give up after {@link REJOIN_CONNECT_DEADLINE_MS} if no chunk arrives and
1559
+ * clear the dead pointer so it does not retry on the next load.
1560
+ *
1561
+ * Replay chunks are processed WITHOUT the per-chunk yield the live path uses,
1562
+ * so the buffered prefix snaps in and only the genuinely-live tail streams at
1563
+ * network speed — a reload looks like the run continued, not like it re-typed.
1564
+ */
1565
+ private resumeInFlightRun(runId: string): void {
1566
+ const joinRun = this.connection.joinRun
1567
+ if (!joinRun) return
1568
+ const controller = new AbortController()
1569
+ this.abortController = controller
1570
+ this.setCurrentRunId(runId)
1571
+ // Record the resume state in-memory BEFORE replaying. Otherwise the
1572
+ // replayed `RUN_STARTED` (which carries the PROVIDER run id, not the
1573
+ // client/durability-log run id the pointer is keyed by) trips the
1574
+ // `!this.lastResume` guard in `updateRunLifecycle` and rewrites the
1575
+ // persisted pointer with the provider id — so a SECOND reload would
1576
+ // `joinRun` an id the log isn't keyed by and never re-attach.
1577
+ this.lastResume = { threadId: this.threadId, runId }
1578
+ this.setIsLoading(true)
1579
+ this.setStatus('streaming')
1580
+ void (async () => {
1581
+ let rebuilt = false
1582
+ let attached = false
1583
+ // Whether the join FAILED (a thrown non-abort error before any chunk), as
1584
+ // opposed to merely not delivering in time. Only a failure proves the
1585
+ // pointer dead — see the `finally`.
1586
+ let refused = false
1587
+ const connectTimer = setTimeout(() => {
1588
+ if (!attached) controller.abort()
1589
+ }, REJOIN_CONNECT_DEADLINE_MS)
1590
+ try {
1591
+ for await (const chunk of joinRun(runId, controller.signal)) {
1592
+ if (controller.signal.aborted) break
1593
+ if (!attached) {
1594
+ attached = true
1595
+ clearTimeout(connectTimer)
806
1596
  }
1597
+ if (!rebuilt && REJOIN_REBUILD_TRIGGERS.has(chunk.type)) {
1598
+ rebuilt = true
1599
+ this.dropTrailingInFlightAssistant()
1600
+ }
1601
+ await this.processIncomingChunk(chunk, { defer: false })
1602
+ }
1603
+ } catch (error) {
1604
+ // Pre-attach failures (unknown/evicted run, connect deadline abort)
1605
+ // stay soft: keep the restored transcript. Post-attach transport/parser
1606
+ // failures are real stream errors and must surface so the UI is not
1607
+ // left truncated and silent.
1608
+ const isAbort =
1609
+ error instanceof Error &&
1610
+ (error.name === 'AbortError' || error.name === 'TimeoutError')
1611
+ if (!attached && !isAbort) refused = true
1612
+ if (attached && !isAbort) {
1613
+ this.reportStreamError(
1614
+ error instanceof Error ? error : new Error(String(error)),
1615
+ )
1616
+ }
1617
+ } finally {
1618
+ clearTimeout(connectTimer)
1619
+ if (!attached && refused && this.tailing && !this.disposed) {
1620
+ // The server REFUSED the join (unknown / evicted run): the pointer is
1621
+ // dead. Clear it so it does not retry and re-pin the UI on the next
1622
+ // load. The server's persisted transcript is still loaded.
1623
+ //
1624
+ // A connect-deadline abort (or an external abort) deliberately does
1625
+ // NOT clear it: the run may simply not have produced yet — a durable
1626
+ // run whose middleware is still booting a sandbox emits nothing for
1627
+ // a while — and clearing on a timeout would permanently orphan a run
1628
+ // that is still going. The pointer survives for the next load, which
1629
+ // costs that load one more bounded connect attempt.
1630
+ //
1631
+ // `tailing`/`disposed` guard the same pointer from the other side: a
1632
+ // DETACH aborts before the first chunk exactly like an unreachable run
1633
+ // does, and a refusal that lands after the view is gone belongs to
1634
+ // nobody. `refused` already spares the timeout case; these two spare
1635
+ // the "no view is watching any more" case, so the pointer only ever
1636
+ // dies for a client that is still looking at the run.
1637
+ this.lastResume = null
1638
+ this.persistor?.persistResumeSnapshot(null)
1639
+ }
1640
+ if (this.abortController === controller) {
1641
+ this.abortController = null
1642
+ this.setIsLoading(false)
1643
+ if (this.status === 'streaming') this.setStatus('ready')
1644
+ }
1645
+ }
1646
+ })()
1647
+ }
1648
+
1649
+ /**
1650
+ * Drop a hydrated, still-in-flight assistant turn so a resume replay can
1651
+ * rebuild it cleanly. Only touches a trailing assistant message (the shape a
1652
+ * reload-mid-stream leaves); a thread whose last turn is a user message (run
1653
+ * never produced, or already settled) is left untouched.
1654
+ */
1655
+ private dropTrailingInFlightAssistant(): void {
1656
+ const messages = this.processor.getMessages()
1657
+ const last = messages[messages.length - 1]
1658
+ if (last && last.role === 'assistant') {
1659
+ this.processor.setMessages(messages.slice(0, -1))
1660
+ }
1661
+ }
1662
+
1663
+ private async processIncomingChunk(
1664
+ chunk: StreamChunk,
1665
+ options?: { defer?: boolean },
1666
+ ): Promise<void> {
1667
+ if (
1668
+ chunk.type === 'RUN_ERROR' &&
1669
+ this.isActiveInterruptSubmissionFailure(chunk)
1670
+ ) {
1671
+ this.interruptSubmissionFailure = {
1672
+ errors: chunk['tanstack:interruptErrors'] ?? [],
1673
+ }
1674
+ }
1675
+ if (this.connectionStatus === 'connecting') {
1676
+ this.setConnectionStatus('connected')
1677
+ }
1678
+ const shouldIgnore = this.clearedStreamTracker.shouldIgnoreChunk(chunk)
1679
+ if (shouldIgnore) {
1680
+ if (chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR') {
1681
+ if (getChunkRunId(chunk)) {
1682
+ this.updateRunLifecycle(chunk, { resolveProcessing: false })
1683
+ } else {
1684
+ this.drainIgnoredRunlessChunk(chunk)
807
1685
  }
808
- continue
1686
+ this.retireIgnoredClearedTerminalChunk(chunk)
1687
+ this.resolveJoinedRun(chunk)
809
1688
  }
810
- this.callbacksRef.current.onChunk(chunk)
811
- this.devtoolsBridge.observeChunk(chunk)
812
- this.processor.processChunk(chunk)
813
- // Run lifecycle (active-run tracking, session-generating state, and
814
- // processing resolution for RUN_FINISHED / RUN_ERROR) is handled in a
815
- // single place so the ignored-chunk path above and this path can't
816
- // diverge. RUN_ERROR carries its runId via the AG-UI passthrough so a
817
- // per-run error only clears that run, while a runId-less RUN_ERROR is
818
- // treated as a session-level error that clears every active run.
819
- this.updateRunLifecycle(chunk)
820
- // Yield control back to event loop for UI updates
1689
+ return
1690
+ }
1691
+ this.callbacksRef.current.onChunk(chunk)
1692
+ this.devtoolsBridge.observeChunk(chunk)
1693
+ this.processor.processChunk(chunk)
1694
+ this.updateRunLifecycle(chunk)
1695
+ this.observeInterruptState(chunk)
1696
+ // The live path yields a macrotask between chunks so React can paint each
1697
+ // delta progressively. A resume replay passes `defer: false` to skip it, so
1698
+ // the buffered backlog applies in one batch (instant catch-up) instead of
1699
+ // re-typing the whole reply.
1700
+ if (options?.defer !== false) {
821
1701
  await new Promise((resolve) => setTimeout(resolve, 0))
822
1702
  }
1703
+ this.resolveJoinedRun(chunk)
1704
+ }
1705
+
1706
+ private isActiveInterruptSubmissionFailure(
1707
+ chunk: Extract<StreamChunk, { type: 'RUN_ERROR' }>,
1708
+ ): boolean {
1709
+ const submission = this.activeInterruptSubmission
1710
+ const errors = chunk['tanstack:interruptErrors']
1711
+ if (!submission || !errors || errors.length === 0) return false
1712
+ const runId = getChunkRunId(chunk)
1713
+ if (runId !== undefined && runId !== this.currentRunId) return false
1714
+ if (
1715
+ typeof chunk.threadId === 'string' &&
1716
+ chunk.threadId !== submission.threadId
1717
+ ) {
1718
+ return false
1719
+ }
1720
+ return errors.every(
1721
+ (error) =>
1722
+ error.threadId === submission.threadId &&
1723
+ error.interruptedRunId === submission.interruptedRunId &&
1724
+ error.generation === submission.generation,
1725
+ )
1726
+ }
1727
+
1728
+ private resolveJoinedRun(chunk: StreamChunk): void {
1729
+ if (chunk.type !== 'RUN_FINISHED' && chunk.type !== 'RUN_ERROR') return
1730
+ const runId = getChunkRunId(chunk)
1731
+ if (runId === undefined) return
1732
+ const resolve = this.joinedRunWaiters.get(runId)
1733
+ if (resolve === undefined) return
1734
+ this.joinedRunWaiters.delete(runId)
1735
+ resolve()
823
1736
  }
824
1737
 
825
1738
  /**
@@ -890,7 +1803,7 @@ export class ChatClient<
890
1803
  * ],
891
1804
  * id: 'custom-message-id'
892
1805
  * },
893
- * { model: 'gpt-4-audio' }
1806
+ * { model: 'gpt-5.5' }
894
1807
  * )
895
1808
  * ```
896
1809
  */
@@ -904,6 +1817,11 @@ export class ChatClient<
904
1817
  if (emptyMessage) {
905
1818
  return
906
1819
  }
1820
+ if (this.hasBlockingInterrupts()) {
1821
+ throw new Error(
1822
+ 'ChatClient: cannot send normal input while pending interrupts exist. Use resumeInterrupts() instead.',
1823
+ )
1824
+ }
907
1825
 
908
1826
  if (this.isSendBusy()) {
909
1827
  const { action, id } = this.decideWhenBusy(content, sendOptions)
@@ -934,6 +1852,30 @@ export class ChatClient<
934
1852
  }
935
1853
  }
936
1854
 
1855
+ /**
1856
+ * True when the client still has user-actionable interrupts (or is mid
1857
+ * resume submission). Staged/submitting items that are already being
1858
+ * continued do not block a later turn once the resume stream has cleared
1859
+ * resume state.
1860
+ */
1861
+ private hasBlockingInterrupts(): boolean {
1862
+ if (!this.lastResume && !this.activeInterruptSubmission) {
1863
+ return false
1864
+ }
1865
+ if (this.activeInterruptSubmission) {
1866
+ return true
1867
+ }
1868
+ return this.interruptManager
1869
+ .getInterrupts()
1870
+ .some(
1871
+ (item) =>
1872
+ item.status === 'pending' ||
1873
+ item.status === 'validating' ||
1874
+ item.status === 'error' ||
1875
+ item.status === 'staged',
1876
+ )
1877
+ }
1878
+
937
1879
  /** True while a stream is active, a send is claiming the client, or the queue is draining. */
938
1880
  private isSendBusy(): boolean {
939
1881
  return this.isLoading || this.sendInFlight || this.messageQueueDraining
@@ -1043,6 +1985,11 @@ export class ChatClient<
1043
1985
  */
1044
1986
  async append(message: UIMessage | ModelMessage): Promise<void> {
1045
1987
  this.mountDevtools()
1988
+ if (this.hasBlockingInterrupts()) {
1989
+ throw new Error(
1990
+ 'ChatClient: cannot append normal input while pending interrupts exist. Use resumeInterrupts() instead.',
1991
+ )
1992
+ }
1046
1993
  // Normalize the message to ensure it has id and createdAt
1047
1994
  const normalizedMessage = normalizeToUIMessage(message, generateMessageId)
1048
1995
 
@@ -1085,8 +2032,18 @@ export class ChatClient<
1085
2032
 
1086
2033
  // Track generation so a superseded stream's cleanup doesn't clobber the new one
1087
2034
  const generation = ++this.streamGeneration
2035
+ // Native interrupt continuation is a fresh child run. The interrupted run
2036
+ // is carried as parentRunId and the complete resolution batch as resume.
2037
+ const resumeThreadId = this.pendingResumeThreadId
2038
+ const resumeParentRunId = this.pendingResumeParentRunId
2039
+ const resumeItems = this.pendingResumeItems
2040
+ this.pendingResumeThreadId = null
2041
+ this.pendingResumeParentRunId = null
2042
+ this.pendingResumeItems = null
1088
2043
  const runId = `run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
1089
- this.currentRunId = runId
2044
+ this.setCurrentRunId(runId)
2045
+ this.activeResumeThreadId = resumeThreadId ?? this.threadId
2046
+ this.activeResumeRunId = runId
1090
2047
 
1091
2048
  this.setIsLoading(true)
1092
2049
  // Hand off from deliverClaim to isLoading so nested drain can call
@@ -1170,8 +2127,11 @@ export class ChatClient<
1170
2127
  // JSON Schema; sending a Standard Schema instance directly would
1171
2128
  // serialize to an unusable shape.
1172
2129
  const runContext = {
1173
- threadId: this.threadId,
2130
+ threadId: resumeThreadId ?? this.threadId,
1174
2131
  runId,
2132
+ ...(resumeParentRunId !== null
2133
+ ? { parentRunId: resumeParentRunId }
2134
+ : {}),
1175
2135
  clientTools: Array.from(clientTools.values()).map((t) => ({
1176
2136
  name: t.name,
1177
2137
  description: t.description,
@@ -1180,8 +2140,9 @@ export class ChatClient<
1180
2140
  : { type: 'object' },
1181
2141
  })),
1182
2142
  forwardedProps: { ...mergedBody },
2143
+ ...(resumeItems ? { resume: resumeItems } : {}),
1183
2144
  }
1184
- this.devtoolsBridge.beginRun(runContext.runId, this.threadId)
2145
+ this.devtoolsBridge.beginRun(runContext.runId, runContext.threadId)
1185
2146
  activeDevtoolsRunId = runContext.runId
1186
2147
  this.devtoolsBridge.emitRunLifecycle(
1187
2148
  'run:created',
@@ -1198,6 +2159,11 @@ export class ChatClient<
1198
2159
  // Send through normalized connection (pushes chunks to subscription queue)
1199
2160
  await this.connection.send(messages, mergedBody, signal, runContext)
1200
2161
 
2162
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- mutated asynchronously during await
2163
+ if (generation !== this.streamGeneration || signal.aborted) {
2164
+ return false
2165
+ }
2166
+
1201
2167
  // Wait for subscription loop to finish processing all chunks
1202
2168
  await processingComplete
1203
2169
 
@@ -1264,7 +2230,7 @@ export class ChatClient<
1264
2230
  this.currentStreamId = null
1265
2231
  this.devtoolsBridge.setCurrentStreamId(null)
1266
2232
  this.currentMessageId = null
1267
- this.currentRunId = null
2233
+ this.setCurrentRunId(null)
1268
2234
  this.activeClientTools = null
1269
2235
  this.activeContext = undefined
1270
2236
  this.abortController = null
@@ -1424,25 +2390,30 @@ export class ChatClient<
1424
2390
  * Clear all messages
1425
2391
  */
1426
2392
  clear(): void {
1427
- if (this.persistor) {
1428
- this.persistor.snapshotClear({
1429
- messages: this.processor.getMessages(),
1430
- activeRunIds: this.activeRunIds,
1431
- currentRunId: this.currentRunId,
1432
- })
1433
- if (this.isLoading) {
1434
- this.cancelInFlightStream({ setReadyStatus: true })
1435
- this.resetSessionGenerating()
1436
- } else if (this.activeRunIds.size > 0) {
1437
- this.resetSessionGenerating()
1438
- }
1439
- // Suppress persisting the empty snapshot that clearMessages emits, then
1440
- // remove the stored conversation outright.
1441
- this.persistor.beginClear()
2393
+ const hadLocalStream = this.abortController !== null
2394
+ this.clearedStreamTracker.snapshotClear({
2395
+ messages: this.processor.getMessages(),
2396
+ activeRunIds: this.activeRunIds,
2397
+ currentRunId: this.currentRunId,
2398
+ })
2399
+ // Always cancel in-flight work so clear works without message persistence.
2400
+ if (this.isLoading || hadLocalStream) {
2401
+ this.cancelInFlightStream({ setReadyStatus: true })
2402
+ this.resetSessionGenerating({ preserveClearedStreamTracking: true })
2403
+ } else if (this.activeRunIds.size > 0) {
2404
+ this.resetSessionGenerating({ preserveClearedStreamTracking: true })
1442
2405
  }
2406
+ // Suppress persisting the empty snapshot that clearMessages emits, then
2407
+ // remove the stored conversation outright.
2408
+ this.persistor?.beginClear()
1443
2409
  this.processor.clearMessages()
1444
2410
  this.discardPendingSends()
1445
2411
  this.persistor?.remove()
2412
+ this.lastResume = null
2413
+ this.interruptManager.reset()
2414
+ this.pendingResumeThreadId = null
2415
+ this.pendingResumeParentRunId = null
2416
+ this.pendingResumeItems = null
1446
2417
  this.setError(undefined)
1447
2418
  this.events.messagesCleared()
1448
2419
  }
@@ -1484,9 +2455,8 @@ export class ChatClient<
1484
2455
  context,
1485
2456
  )
1486
2457
 
1487
- // Forward an error message only for output-error results (with a fallback so
1488
- // a message-less `throw new Error()` still reaches the terminal 'error'
1489
- // state); a stray errorText on a successful result must not signal an error.
2458
+ // Always update local message state so the tool-call part is terminal in
2459
+ // the UI even when the AG-UI interrupt path owns server continuation.
1490
2460
  this.processor.addToolResult(
1491
2461
  result.toolCallId,
1492
2462
  result.output,
@@ -1494,6 +2464,19 @@ export class ChatClient<
1494
2464
  ? result.errorText || 'Tool execution failed'
1495
2465
  : undefined,
1496
2466
  )
2467
+ this.devtoolsBridge.emitSnapshot()
2468
+
2469
+ const resolvedViaInterrupt = this.interruptManager.resolveClientToolOutput(
2470
+ result.toolCallId,
2471
+ result.state === 'output-error'
2472
+ ? { error: result.errorText || 'Tool execution failed' }
2473
+ : result.output,
2474
+ )
2475
+ if (resolvedViaInterrupt) {
2476
+ // Interrupt manager stages/submits the resume batch (deferred until the
2477
+ // parent stream settles when still loading). Skip legacy continuation.
2478
+ return
2479
+ }
1497
2480
 
1498
2481
  // If stream is in progress, queue continuation check for after it ends
1499
2482
  if (this.isLoading) {
@@ -1522,6 +2505,21 @@ export class ChatClient<
1522
2505
  id: string // approval.id, not toolCallId
1523
2506
  approved: boolean
1524
2507
  }): Promise<void> {
2508
+ // Reflect the decision on the tool-call part so approval UIs that render
2509
+ // from `part.state` (the deprecated pre-interrupt pattern) clear the prompt
2510
+ // and show the response. The bound interrupt resolution below drives the
2511
+ // actual continuation; this keeps the legacy message-state surface in sync.
2512
+ this.processor.addToolApprovalResponse(response.id, response.approved)
2513
+ this.devtoolsBridge.emitSnapshot()
2514
+
2515
+ if (
2516
+ this.interruptManager.resolveToolApprovalDecision(
2517
+ response.id,
2518
+ response.approved,
2519
+ )
2520
+ ) {
2521
+ return
2522
+ }
1525
2523
  // Find the tool call ID from the approval ID
1526
2524
  const messages = this.processor.getMessages()
1527
2525
  let foundToolCallId: string | undefined
@@ -1866,6 +2864,7 @@ export class ChatClient<
1866
2864
  this.context = options.context
1867
2865
  }
1868
2866
  if (options.tools !== undefined) {
2867
+ this.interruptManager.updateTools(options.tools)
1869
2868
  this.clientToolsRef.current = new Map()
1870
2869
  for (const tool of options.tools) {
1871
2870
  this.clientToolsRef.current.set(tool.name, tool)
@@ -1902,12 +2901,31 @@ export class ChatClient<
1902
2901
  if (options.onQueueChange !== undefined) {
1903
2902
  this.callbacksRef.current.onQueueChange = options.onQueueChange
1904
2903
  }
2904
+ if (options.onResumeStateChange !== undefined) {
2905
+ this.callbacksRef.current.onResumeStateChange =
2906
+ options.onResumeStateChange
2907
+ }
2908
+ if (options.onRunIdChange !== undefined) {
2909
+ this.callbacksRef.current.onRunIdChange = options.onRunIdChange
2910
+ }
2911
+ if (options.onInterruptStateChange !== undefined) {
2912
+ this.callbacksRef.current.onInterruptStateChange =
2913
+ options.onInterruptStateChange
2914
+ }
1905
2915
  if (options.onCustomEvent !== undefined) {
1906
2916
  this.callbacksRef.current.onCustomEvent = options.onCustomEvent
1907
2917
  }
1908
2918
  }
1909
2919
 
1910
2920
  dispose(): void {
2921
+ // FIRST, and latched: everything below is teardown, and an async callback that
2922
+ // lands mid-teardown must not start new work. In particular a hydration fetch
2923
+ // that resolves after this point must not open a tail — see `hydrateFromServer`.
2924
+ this.disposed = true
2925
+ // `unsubscribe()` below already aborts the in-flight stream (it calls
2926
+ // `cancelInFlightStream({ abortSubscription: true })`), so disposal does drop
2927
+ // an open tail. Verified by mutation: removing an extra abort here changes
2928
+ // nothing, because unsubscribe covers it.
1911
2929
  this.unsubscribe()
1912
2930
  this.devtoolsBridge.dispose()
1913
2931
  this.devtoolsMounted = false