@tanstack/ai-client 0.7.1 → 0.7.4

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.
@@ -4,17 +4,22 @@ import {
4
4
  normalizeToUIMessage,
5
5
  } from '@tanstack/ai'
6
6
  import { DefaultChatClientEventEmitter } from './events'
7
+ import { normalizeConnectionAdapter } from './connection-adapters'
7
8
  import type {
8
9
  AnyClientTool,
9
10
  ContentPart,
10
11
  ModelMessage,
11
12
  StreamChunk,
12
13
  } from '@tanstack/ai'
13
- import type { ConnectionAdapter } from './connection-adapters'
14
+ import type {
15
+ ConnectionAdapter,
16
+ SubscribeConnectionAdapter,
17
+ } from './connection-adapters'
14
18
  import type { ChatClientEventEmitter } from './events'
15
19
  import type {
16
20
  ChatClientOptions,
17
21
  ChatClientState,
22
+ ConnectionStatus,
18
23
  MessagePart,
19
24
  MultimodalContent,
20
25
  ToolCallPart,
@@ -23,13 +28,15 @@ import type {
23
28
 
24
29
  export class ChatClient {
25
30
  private processor: StreamProcessor
26
- private connection: ConnectionAdapter
31
+ private connection: SubscribeConnectionAdapter
27
32
  private uniqueId: string
28
33
  private body: Record<string, any> = {}
29
34
  private pendingMessageBody: Record<string, any> | undefined = undefined
30
35
  private isLoading = false
36
+ private isSubscribed = false
31
37
  private error: Error | undefined = undefined
32
38
  private status: ChatClientState = 'ready'
39
+ private connectionStatus: ConnectionStatus = 'disconnected'
33
40
  private abortController: AbortController | null = null
34
41
  private events: ChatClientEventEmitter
35
42
  private clientToolsRef: { current: Map<string, AnyClientTool> }
@@ -40,9 +47,15 @@ export class ChatClient {
40
47
  private pendingToolExecutions: Map<string, Promise<void>> = new Map()
41
48
  // Flag to deduplicate continuation checks during action draining
42
49
  private continuationPending = false
50
+ private subscriptionAbortController: AbortController | null = null
51
+ private processingResolve: (() => void) | null = null
52
+ private errorReportedGeneration: number | null = null
53
+ private streamGeneration = 0
43
54
  // Tracks whether a queued checkForContinuation was skipped because
44
55
  // continuationPending was true (chained approval scenario)
45
56
  private continuationSkipped = false
57
+ private sessionGenerating = false
58
+ private activeRunIds = new Set<string>()
46
59
 
47
60
  private callbacksRef: {
48
61
  current: {
@@ -54,6 +67,9 @@ export class ChatClient {
54
67
  onLoadingChange: (isLoading: boolean) => void
55
68
  onErrorChange: (error: Error | undefined) => void
56
69
  onStatusChange: (status: ChatClientState) => void
70
+ onSubscriptionChange: (isSubscribed: boolean) => void
71
+ onConnectionStatusChange: (status: ConnectionStatus) => void
72
+ onSessionGeneratingChange: (isGenerating: boolean) => void
57
73
  onCustomEvent: (
58
74
  eventType: string,
59
75
  data: unknown,
@@ -65,7 +81,7 @@ export class ChatClient {
65
81
  constructor(options: ChatClientOptions) {
66
82
  this.uniqueId = options.id || this.generateUniqueId('chat')
67
83
  this.body = options.body || {}
68
- this.connection = options.connection
84
+ this.connection = normalizeConnectionAdapter(options.connection)
69
85
  this.events = new DefaultChatClientEventEmitter(this.uniqueId)
70
86
 
71
87
  // Build client tools map
@@ -86,6 +102,11 @@ export class ChatClient {
86
102
  onLoadingChange: options.onLoadingChange || (() => {}),
87
103
  onErrorChange: options.onErrorChange || (() => {}),
88
104
  onStatusChange: options.onStatusChange || (() => {}),
105
+ onSubscriptionChange: options.onSubscriptionChange || (() => {}),
106
+ onConnectionStatusChange:
107
+ options.onConnectionStatusChange || (() => {}),
108
+ onSessionGeneratingChange:
109
+ options.onSessionGeneratingChange || (() => {}),
89
110
  onCustomEvent: options.onCustomEvent || (() => {}),
90
111
  },
91
112
  }
@@ -100,15 +121,31 @@ export class ChatClient {
100
121
  },
101
122
  onStreamStart: () => {
102
123
  this.setStatus('streaming')
124
+ const assistantMessageId =
125
+ this.processor.getCurrentAssistantMessageId()
126
+ if (!assistantMessageId) {
127
+ return
128
+ }
129
+ const messages = this.processor.getMessages()
130
+ const assistantMessage = messages.find(
131
+ (m: UIMessage) => m.id === assistantMessageId,
132
+ )
133
+ if (assistantMessage) {
134
+ this.currentMessageId = assistantMessage.id
135
+ this.events.messageAppended(
136
+ assistantMessage,
137
+ this.currentStreamId || undefined,
138
+ )
139
+ }
103
140
  },
104
141
  onStreamEnd: (message: UIMessage) => {
105
142
  this.callbacksRef.current.onFinish(message)
106
143
  this.setStatus('ready')
144
+ // Resolve the processing-complete promise so streamResponse can continue
145
+ this.resolveProcessing()
107
146
  },
108
147
  onError: (error: Error) => {
109
- this.setError(error)
110
- this.setStatus('error')
111
- this.callbacksRef.current.onError(error)
148
+ this.reportStreamError(error)
112
149
  },
113
150
  onTextUpdate: (messageId: string, content: string) => {
114
151
  // Emit text update to devtools
@@ -235,75 +272,170 @@ export class ChatClient {
235
272
  this.callbacksRef.current.onStatusChange(status)
236
273
  }
237
274
 
275
+ private setIsSubscribed(isSubscribed: boolean): void {
276
+ this.isSubscribed = isSubscribed
277
+ this.callbacksRef.current.onSubscriptionChange(isSubscribed)
278
+ }
279
+
280
+ private setConnectionStatus(status: ConnectionStatus): void {
281
+ this.connectionStatus = status
282
+ this.callbacksRef.current.onConnectionStatusChange(status)
283
+ }
284
+
285
+ private setSessionGenerating(isGenerating: boolean): void {
286
+ if (this.sessionGenerating === isGenerating) return
287
+ this.sessionGenerating = isGenerating
288
+ this.callbacksRef.current.onSessionGeneratingChange(isGenerating)
289
+ }
290
+
291
+ private resetSessionGenerating(): void {
292
+ this.activeRunIds.clear()
293
+ this.setSessionGenerating(false)
294
+ }
295
+
238
296
  private setError(error: Error | undefined): void {
239
297
  this.error = error
240
298
  this.callbacksRef.current.onErrorChange(error)
241
299
  this.events.errorChanged(error?.message || null)
242
300
  }
243
301
 
302
+ private abortSubscriptionLoop(): void {
303
+ this.subscriptionAbortController?.abort()
304
+ this.subscriptionAbortController = null
305
+ }
306
+
307
+ private resolveProcessing(): void {
308
+ this.processingResolve?.()
309
+ this.processingResolve = null
310
+ }
311
+
312
+ private cancelInFlightStream(options?: {
313
+ setReadyStatus?: boolean
314
+ abortSubscription?: boolean
315
+ }): void {
316
+ this.abortController?.abort()
317
+ this.abortController = null
318
+ if (options?.abortSubscription) {
319
+ this.abortSubscriptionLoop()
320
+ }
321
+ this.resolveProcessing()
322
+ this.setIsLoading(false)
323
+ if (options?.setReadyStatus) {
324
+ this.setStatus('ready')
325
+ }
326
+ }
327
+
328
+ private reportStreamError(error: Error): void {
329
+ const alreadyReported =
330
+ this.errorReportedGeneration === this.streamGeneration
331
+ this.setError(error)
332
+ // Preserve request-level error semantics even if a RUN_ERROR arrives
333
+ // slightly after loading flips false during stream teardown.
334
+ if (
335
+ this.isLoading ||
336
+ this.status === 'submitted' ||
337
+ this.status === 'streaming'
338
+ ) {
339
+ this.setStatus('error')
340
+ }
341
+ if (!alreadyReported) {
342
+ this.errorReportedGeneration = this.streamGeneration
343
+ this.callbacksRef.current.onError(error)
344
+ }
345
+ }
346
+
244
347
  /**
245
- * Process a stream through the StreamProcessor
348
+ * Start the background subscription loop.
246
349
  */
247
- private async processStream(
248
- source: AsyncIterable<StreamChunk>,
249
- ): Promise<UIMessage | null> {
250
- // Generate a stream ID for this streaming operation
251
- this.currentStreamId = this.generateUniqueId('stream')
252
-
253
- // Prepare for a new assistant message (created lazily on first content)
254
- this.processor.prepareAssistantMessage()
350
+ private startSubscription(): void {
351
+ this.subscriptionAbortController = new AbortController()
352
+ const signal = this.subscriptionAbortController.signal
353
+
354
+ this.consumeSubscription(signal)
355
+ .catch((err) => {
356
+ if (err instanceof Error && err.name !== 'AbortError') {
357
+ this.setConnectionStatus('error')
358
+ this.resetSessionGenerating()
359
+ this.setIsSubscribed(false)
360
+ this.reportStreamError(err)
361
+ }
362
+ // Resolve pending processing so streamResponse doesn't hang
363
+ this.resolveProcessing()
364
+ })
365
+ .finally(() => {
366
+ // Ignore stale loops that were superseded by a restart.
367
+ if (this.subscriptionAbortController?.signal !== signal) {
368
+ return
369
+ }
370
+ this.subscriptionAbortController = null
371
+ if (!signal.aborted && this.isSubscribed) {
372
+ this.setIsSubscribed(false)
373
+ if (this.connectionStatus !== 'error') {
374
+ this.setConnectionStatus('disconnected')
375
+ }
376
+ }
377
+ })
378
+ }
255
379
 
256
- // Process each chunk
257
- for await (const chunk of source) {
380
+ /**
381
+ * Consume chunks from the connection subscription.
382
+ */
383
+ private async consumeSubscription(signal: AbortSignal): Promise<void> {
384
+ const stream = this.connection.subscribe(signal)
385
+ for await (const chunk of stream) {
386
+ if (signal.aborted) break
387
+ if (this.connectionStatus === 'connecting') {
388
+ this.setConnectionStatus('connected')
389
+ }
258
390
  this.callbacksRef.current.onChunk(chunk)
259
391
  this.processor.processChunk(chunk)
260
-
261
- // Track the message ID once the processor lazily creates it
262
- if (!this.currentMessageId) {
263
- const newMessageId =
264
- this.processor.getCurrentAssistantMessageId() ?? null
265
- if (newMessageId) {
266
- this.currentMessageId = newMessageId
267
- // Emit message appended event now that the assistant message exists
268
- const assistantMessage = this.processor
269
- .getMessages()
270
- .find((m: UIMessage) => m.id === newMessageId)
271
- if (assistantMessage) {
272
- this.events.messageAppended(
273
- assistantMessage,
274
- this.currentStreamId || undefined,
275
- )
276
- }
392
+ if (chunk.type === 'RUN_STARTED') {
393
+ this.activeRunIds.add(chunk.runId)
394
+ this.setSessionGenerating(true)
395
+ }
396
+ // RUN_FINISHED / RUN_ERROR signal run completion — resolve processing
397
+ // (redundant if onStreamEnd already resolved it, harmless)
398
+ if (chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR') {
399
+ if (chunk.runId) {
400
+ this.activeRunIds.delete(chunk.runId)
401
+ } else if (chunk.type === 'RUN_ERROR') {
402
+ // RUN_ERROR without runId is a session-level error; clear all runs
403
+ this.activeRunIds.clear()
277
404
  }
405
+ this.setSessionGenerating(this.activeRunIds.size > 0)
406
+ this.resolveProcessing()
278
407
  }
279
-
280
- // Yield control back to event loop to allow UI updates
408
+ // Yield control back to event loop for UI updates
281
409
  await new Promise((resolve) => setTimeout(resolve, 0))
282
410
  }
411
+ }
283
412
 
284
- // Wait for all pending tool executions to complete before finalizing
285
- // This ensures client tools finish before we check for continuation
286
- if (this.pendingToolExecutions.size > 0) {
287
- await Promise.all(this.pendingToolExecutions.values())
413
+ /**
414
+ * Ensure subscription loop is running, starting it if needed.
415
+ */
416
+ private ensureSubscription(): void {
417
+ if (!this.isSubscribed) {
418
+ this.subscribe()
419
+ return
288
420
  }
289
-
290
- // Finalize the stream
291
- this.processor.finalizeStream()
292
-
293
- // Get the message ID (may be null if no content arrived)
294
- const messageId = this.processor.getCurrentAssistantMessageId()
295
-
296
- // Clear the current stream and message IDs
297
- this.currentStreamId = null
298
- this.currentMessageId = null
299
-
300
- // Return the assistant message if one was created
301
- if (messageId) {
302
- const messages = this.processor.getMessages()
303
- return messages.find((m: UIMessage) => m.id === messageId) || null
421
+ if (
422
+ !this.subscriptionAbortController ||
423
+ this.subscriptionAbortController.signal.aborted
424
+ ) {
425
+ this.subscribe({ restart: true })
304
426
  }
427
+ }
305
428
 
306
- return null
429
+ /**
430
+ * Create a promise that resolves when onStreamEnd fires.
431
+ * Used by streamResponse to await processing completion.
432
+ */
433
+ private waitForProcessing(): Promise<void> {
434
+ // Resolve any stale promise (e.g., from a previous aborted request)
435
+ this.resolveProcessing()
436
+ return new Promise<void>((resolve) => {
437
+ this.processingResolve = resolve
438
+ })
307
439
  }
308
440
 
309
441
  /**
@@ -426,9 +558,13 @@ export class ChatClient {
426
558
  return false
427
559
  }
428
560
 
561
+ // Track generation so a superseded stream's cleanup doesn't clobber the new one
562
+ const generation = ++this.streamGeneration
563
+
429
564
  this.setIsLoading(true)
430
565
  this.setStatus('submitted')
431
566
  this.setError(undefined)
567
+ this.errorReportedGeneration = null
432
568
  this.abortController = new AbortController()
433
569
  // Reset pending tool executions for the new stream
434
570
  this.pendingToolExecutions.clear()
@@ -452,42 +588,85 @@ export class ChatClient {
452
588
  // Clear the pending message body after use
453
589
  this.pendingMessageBody = undefined
454
590
 
455
- // Connect and stream
456
- const stream = this.connection.connect(
591
+ // Generate stream ID — assistant message will be created by stream events
592
+ this.currentStreamId = this.generateUniqueId('stream')
593
+ this.currentMessageId = null
594
+
595
+ // Reset processor stream state for new response — prevents stale
596
+ // messageStates entries (from a previous stream) from blocking
597
+ // creation of a new assistant message (e.g. after reload).
598
+ this.processor.prepareAssistantMessage()
599
+
600
+ // Ensure subscription loop is running
601
+ this.ensureSubscription()
602
+
603
+ // Set up promise that resolves when onStreamEnd fires
604
+ const processingComplete = this.waitForProcessing()
605
+
606
+ // Send through normalized connection (pushes chunks to subscription queue)
607
+ await this.connection.send(
457
608
  messages,
458
609
  mergedBody,
459
610
  this.abortController.signal,
460
611
  )
461
612
 
462
- await this.processStream(stream)
613
+ // Wait for subscription loop to finish processing all chunks
614
+ await processingComplete
615
+
616
+ // If this stream was superseded (e.g. by reload()), bail out —
617
+ // the new stream owns the processor and processingResolve now.
618
+ if (generation !== this.streamGeneration) {
619
+ return false
620
+ }
621
+
622
+ // A RUN_ERROR from the stream transitions status to error.
623
+ // Do not treat this stream as a successful completion.
624
+ if (this.status === 'error') {
625
+ return false
626
+ }
627
+
628
+ // Wait for pending client tool executions
629
+ if (this.pendingToolExecutions.size > 0) {
630
+ await Promise.all(this.pendingToolExecutions.values())
631
+ }
632
+
633
+ // Finalize (idempotent — may already be done by RUN_FINISHED handler)
634
+ this.processor.finalizeStream()
463
635
  streamCompletedSuccessfully = true
464
636
  } catch (err) {
465
637
  if (err instanceof Error) {
466
638
  if (err.name === 'AbortError') {
467
639
  return false
468
640
  }
469
- this.setError(err)
470
- this.setStatus('error')
471
- this.callbacksRef.current.onError(err)
641
+ if (generation === this.streamGeneration) {
642
+ this.reportStreamError(err)
643
+ }
472
644
  }
473
645
  } finally {
474
- this.abortController = null
475
- this.setIsLoading(false)
476
- this.pendingMessageBody = undefined // Ensure it's cleared even on error
477
-
478
- // Drain any actions that were queued while the stream was in progress
479
- await this.drainPostStreamActions()
480
-
481
- // Continue conversation if the stream ended with a tool result (server tool completed)
482
- if (streamCompletedSuccessfully) {
483
- const messages = this.processor.getMessages()
484
- const lastPart = messages.at(-1)?.parts.at(-1)
485
-
486
- if (lastPart?.type === 'tool-result' && this.shouldAutoSend()) {
487
- try {
488
- await this.checkForContinuation()
489
- } catch (error) {
490
- console.error('Failed to continue flow after tool result:', error)
646
+ // Only clean up if this is still the active stream.
647
+ // A superseded stream (e.g. reload() started a new one) must not
648
+ // clobber the new stream's abortController or isLoading state.
649
+ if (generation === this.streamGeneration) {
650
+ this.currentStreamId = null
651
+ this.currentMessageId = null
652
+ this.abortController = null
653
+ this.setIsLoading(false)
654
+ this.pendingMessageBody = undefined // Ensure it's cleared even on error
655
+
656
+ // Drain any actions that were queued while the stream was in progress
657
+ await this.drainPostStreamActions()
658
+
659
+ // Continue conversation if the stream ended with a tool result (server tool completed)
660
+ if (streamCompletedSuccessfully) {
661
+ const messages = this.processor.getMessages()
662
+ const lastPart = messages.at(-1)?.parts.at(-1)
663
+
664
+ if (lastPart?.type === 'tool-result' && this.shouldAutoSend()) {
665
+ try {
666
+ await this.checkForContinuation()
667
+ } catch (error) {
668
+ console.error('Failed to continue flow after tool result:', error)
669
+ }
491
670
  }
492
671
  }
493
672
  }
@@ -496,6 +675,39 @@ export class ChatClient {
496
675
  return streamCompletedSuccessfully
497
676
  }
498
677
 
678
+ /**
679
+ * Start the client subscription loop.
680
+ * This controls the connection lifecycle independently from request lifecycle.
681
+ */
682
+ subscribe(options?: { restart?: boolean }): void {
683
+ const restart = options?.restart === true
684
+ if (this.isSubscribed && !restart) {
685
+ return
686
+ }
687
+
688
+ if (this.isSubscribed && restart) {
689
+ this.abortSubscriptionLoop()
690
+ }
691
+
692
+ this.setIsSubscribed(true)
693
+ this.setConnectionStatus('connecting')
694
+ this.startSubscription()
695
+ }
696
+
697
+ /**
698
+ * Unsubscribe and fully tear down live behavior.
699
+ * This aborts an in-flight request and the subscription loop.
700
+ */
701
+ unsubscribe(): void {
702
+ this.cancelInFlightStream({
703
+ setReadyStatus: true,
704
+ abortSubscription: true,
705
+ })
706
+ this.resetSessionGenerating()
707
+ this.setIsSubscribed(false)
708
+ this.setConnectionStatus('disconnected')
709
+ }
710
+
499
711
  /**
500
712
  * Reload the last assistant message
501
713
  */
@@ -510,6 +722,11 @@ export class ChatClient {
510
722
 
511
723
  if (lastUserMessageIndex === -1) return
512
724
 
725
+ // Cancel any active stream before reloading
726
+ if (this.isLoading) {
727
+ this.cancelInFlightStream()
728
+ }
729
+
513
730
  this.events.reloaded(lastUserMessageIndex)
514
731
 
515
732
  // Remove all messages after the last user message
@@ -523,12 +740,7 @@ export class ChatClient {
523
740
  * Stop the current stream
524
741
  */
525
742
  stop(): void {
526
- if (this.abortController) {
527
- this.abortController.abort()
528
- this.abortController = null
529
- }
530
- this.setIsLoading(false)
531
- this.setStatus('ready')
743
+ this.cancelInFlightStream({ setReadyStatus: true })
532
744
  this.events.stopped()
533
745
  }
534
746
 
@@ -692,6 +904,30 @@ export class ChatClient {
692
904
  return this.status
693
905
  }
694
906
 
907
+ /**
908
+ * Get whether the subscription loop is active
909
+ */
910
+ getIsSubscribed(): boolean {
911
+ return this.isSubscribed
912
+ }
913
+
914
+ /**
915
+ * Get current connection lifecycle status
916
+ */
917
+ getConnectionStatus(): ConnectionStatus {
918
+ return this.connectionStatus
919
+ }
920
+
921
+ /**
922
+ * Whether the shared session is actively generating.
923
+ * Derived from stream run events (RUN_STARTED / RUN_FINISHED / RUN_ERROR).
924
+ * Unlike `isLoading` (request-local), this reflects shared generation
925
+ * activity visible to all subscribers (e.g. across tabs/devices).
926
+ */
927
+ getSessionGenerating(): boolean {
928
+ return this.sessionGenerating
929
+ }
930
+
695
931
  /**
696
932
  * Get current error
697
933
  */
@@ -717,6 +953,9 @@ export class ChatClient {
717
953
  onChunk?: (chunk: StreamChunk) => void
718
954
  onFinish?: (message: UIMessage) => void
719
955
  onError?: (error: Error) => void
956
+ onSubscriptionChange?: (isSubscribed: boolean) => void
957
+ onConnectionStatusChange?: (status: ConnectionStatus) => void
958
+ onSessionGeneratingChange?: (isGenerating: boolean) => void
720
959
  onCustomEvent?: (
721
960
  eventType: string,
722
961
  data: unknown,
@@ -724,7 +963,25 @@ export class ChatClient {
724
963
  ) => void
725
964
  }): void {
726
965
  if (options.connection !== undefined) {
727
- this.connection = options.connection
966
+ const wasSubscribed = this.isSubscribed
967
+
968
+ if (this.isLoading) {
969
+ this.cancelInFlightStream({
970
+ setReadyStatus: true,
971
+ abortSubscription: true,
972
+ })
973
+ } else if (wasSubscribed) {
974
+ this.abortSubscriptionLoop()
975
+ }
976
+
977
+ this.resetSessionGenerating()
978
+ this.setIsSubscribed(false)
979
+ this.setConnectionStatus('disconnected')
980
+ this.connection = normalizeConnectionAdapter(options.connection)
981
+
982
+ if (wasSubscribed) {
983
+ this.subscribe()
984
+ }
728
985
  }
729
986
  if (options.body !== undefined) {
730
987
  this.body = options.body
@@ -747,6 +1004,18 @@ export class ChatClient {
747
1004
  if (options.onError !== undefined) {
748
1005
  this.callbacksRef.current.onError = options.onError
749
1006
  }
1007
+ if (options.onSubscriptionChange !== undefined) {
1008
+ this.callbacksRef.current.onSubscriptionChange =
1009
+ options.onSubscriptionChange
1010
+ }
1011
+ if (options.onConnectionStatusChange !== undefined) {
1012
+ this.callbacksRef.current.onConnectionStatusChange =
1013
+ options.onConnectionStatusChange
1014
+ }
1015
+ if (options.onSessionGeneratingChange !== undefined) {
1016
+ this.callbacksRef.current.onSessionGeneratingChange =
1017
+ options.onSessionGeneratingChange
1018
+ }
750
1019
  if (options.onCustomEvent !== undefined) {
751
1020
  this.callbacksRef.current.onCustomEvent = options.onCustomEvent
752
1021
  }