@tanstack/ai-client 0.29.0 → 0.29.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/ai-client",
3
- "version": "0.29.0",
3
+ "version": "0.29.1",
4
4
  "description": "Framework-agnostic headless client for TanStack AI chat, realtime sessions, streaming transports, and media generations.",
5
5
  "author": "Tanner Linsley",
6
6
  "license": "MIT",
@@ -53,8 +53,8 @@
53
53
  "src"
54
54
  ],
55
55
  "dependencies": {
56
- "@tanstack/ai": "^0.50.0",
57
- "@tanstack/ai-event-client": "^0.11.0",
56
+ "@tanstack/ai": "^0.51.0",
57
+ "@tanstack/ai-event-client": "^0.11.1",
58
58
  "@tanstack/ai-utils": "^0.4.0"
59
59
  },
60
60
  "devDependencies": {
@@ -164,6 +164,20 @@ function resolveTransport(transport: {
164
164
  throw new Error('ChatClient: either `connection` or `fetcher` is required.')
165
165
  }
166
166
 
167
+ function connectionDrainsOnSend(connection: ConnectionAdapter): boolean {
168
+ return 'connect' in connection
169
+ }
170
+
171
+ function isIntermediateToolTurn(chunk: StreamChunk): boolean {
172
+ if (chunk.type !== 'RUN_FINISHED') return false
173
+ if (chunk.outcome?.type === 'interrupt') return false
174
+ const extra = chunk as StreamChunk & { finishReason?: unknown }
175
+ if (extra.finishReason !== undefined) {
176
+ return extra.finishReason === 'tool_calls'
177
+ }
178
+ return tanstackMetadata(chunk)?.finishReason === 'tool_calls'
179
+ }
180
+
167
181
  export interface NormalizedQueueConfig {
168
182
  whenBusy: WhenBusy
169
183
  drain: 'fifo' | 'batch'
@@ -416,6 +430,12 @@ export class ChatClient<
416
430
  private continuationPending = false
417
431
  private subscriptionAbortController: AbortController | null = null
418
432
  private processingResolve: (() => void) | null = null
433
+ /**
434
+ * `connect()` adapters push the full HTTP body into the subscribe queue, then
435
+ * wait until that queue is idle. After `send()` returns, every chunk from this
436
+ * request has been processed. Subscribe/send sockets do not drain that way.
437
+ */
438
+ private connectionDrainsOnSend = false
419
439
  private errorReportedGeneration: number | null = null
420
440
  private streamGeneration = 0
421
441
  private continuationGeneration = 0
@@ -518,7 +538,9 @@ export class ChatClient<
518
538
  this.byokProvider = options.byokProvider
519
539
  this.context = options.context
520
540
  this.queueConfig = normalizeQueueOption(options.queue)
521
- this.connection = normalizeConnectionAdapter(resolveTransport(options))
541
+ const transport = resolveTransport(options)
542
+ this.connectionDrainsOnSend = connectionDrainsOnSend(transport)
543
+ this.connection = normalizeConnectionAdapter(transport)
522
544
 
523
545
  // Build client tools map
524
546
  this.clientToolsRef = { current: new Map() }
@@ -829,6 +851,9 @@ export class ChatClient<
829
851
  if (eventType === 'memory:state') {
830
852
  this.devtoolsBridge.recordMemoryState(data)
831
853
  }
854
+ if (eventType === 'skills:state') {
855
+ this.devtoolsBridge.recordSkillsState(data)
856
+ }
832
857
  this.callbacksRef.current.onCustomEvent(eventType, data, context)
833
858
  },
834
859
  },
@@ -1140,7 +1165,9 @@ export class ChatClient<
1140
1165
  this.clearedStreamTracker.onSessionRunError()
1141
1166
  }
1142
1167
  this.setSessionGenerating(this.activeRunIds.size > 0)
1143
- if (options?.resolveProcessing !== false) {
1168
+ const skipProcessingResolve =
1169
+ chunk.type === 'RUN_FINISHED' && isIntermediateToolTurn(chunk)
1170
+ if (options?.resolveProcessing !== false && !skipProcessingResolve) {
1144
1171
  this.resolveProcessing()
1145
1172
  }
1146
1173
  }
@@ -2344,6 +2371,14 @@ export class ChatClient<
2344
2371
  return false
2345
2372
  }
2346
2373
 
2374
+ // connect() send() already waited until the subscribe queue was idle.
2375
+ // Kick the processing wait so a stream that ends on tool_calls (no
2376
+ // interrupt / stop) cannot hang. Subscribe/send sockets still wait for
2377
+ // a request-ending terminal below.
2378
+ if (this.connectionDrainsOnSend) {
2379
+ this.resolveProcessing()
2380
+ }
2381
+
2347
2382
  // Wait for subscription loop to finish processing all chunks
2348
2383
  await processingComplete
2349
2384
 
@@ -3045,12 +3080,12 @@ export class ChatClient<
3045
3080
  this.resetSessionGenerating()
3046
3081
  this.setIsSubscribed(false)
3047
3082
  this.setConnectionStatus('disconnected')
3048
- this.connection = normalizeConnectionAdapter(
3049
- resolveTransport({
3050
- connection: options.connection,
3051
- fetcher: options.fetcher,
3052
- }),
3053
- )
3083
+ const transport = resolveTransport({
3084
+ connection: options.connection,
3085
+ fetcher: options.fetcher,
3086
+ })
3087
+ this.connectionDrainsOnSend = connectionDrainsOnSend(transport)
3088
+ this.connection = normalizeConnectionAdapter(transport)
3054
3089
 
3055
3090
  if (wasSubscribed) {
3056
3091
  this.subscribe()
@@ -1055,6 +1055,28 @@ export function normalizeConnectionAdapter(
1055
1055
  }
1056
1056
  }
1057
1057
 
1058
+ async function waitUntilSubscriberIdle(
1059
+ abortSignal?: AbortSignal,
1060
+ ): Promise<void> {
1061
+ // Idle means the subscriber is waiting for the next chunk, so the
1062
+ // previous chunk has left processIncomingChunk. Empty waiters with an
1063
+ // empty buffer is in-flight delivery, not idle.
1064
+ const idle = () =>
1065
+ activeBuffer.length === 0 &&
1066
+ (activeWaiters.length > 0 || abortSignal?.aborted)
1067
+ for (let i = 0; i < 16 && !abortSignal?.aborted; i++) {
1068
+ if (idle()) return
1069
+ await Promise.resolve()
1070
+ }
1071
+ let macrotaskWaits = 0
1072
+ while (!abortSignal?.aborted) {
1073
+ if (idle()) return
1074
+ await new Promise<void>((resolve) => setTimeout(resolve, 0))
1075
+ macrotaskWaits++
1076
+ if (activeWaiters.length === 0 && macrotaskWaits >= 32) return
1077
+ }
1078
+ }
1079
+
1058
1080
  return {
1059
1081
  subscribe(abortSignal?: AbortSignal): AsyncIterable<StreamChunk> {
1060
1082
  // Transfer ownership to the latest subscriber so only one active
@@ -1162,6 +1184,7 @@ export function normalizeConnectionAdapter(
1162
1184
  }
1163
1185
  throw err
1164
1186
  }
1187
+ await waitUntilSubscriberIdle(abortSignal)
1165
1188
  },
1166
1189
  // Expose joinRun only when the underlying connection is resumable. Require
1167
1190
  // a real function — `'joinRun' in connection` is true for
@@ -87,6 +87,7 @@ export class NoOpChatDevtoolsBridge {
87
87
  }
88
88
  observeChunk(_chunk: StreamChunk): void {}
89
89
  recordMemoryState(_value: unknown): void {}
90
+ recordSkillsState(_value: unknown): void {}
90
91
  beginRun(_runId: string, _threadId: string): void {}
91
92
  getCurrentRunEventContext(): ChatClientRunEventContext | undefined {
92
93
  return undefined
package/src/devtools.ts CHANGED
@@ -33,6 +33,11 @@ export interface AIDevtoolsDisplayOptions {
33
33
  * payload of the `memory:state` CUSTOM chunk. Kept local so `ai-client` doesn't
34
34
  * depend on `ai-memory`; the memory middleware is the producer.
35
35
  */
36
+ interface SkillsStateEventValue {
37
+ catalog?: Array<{ name: string; description: string }>
38
+ activated?: Array<string>
39
+ }
40
+
36
41
  interface MemoryStateEventValue {
37
42
  scope: MemoryScopeLite
38
43
  adapter: string
@@ -448,6 +453,49 @@ function getActiveBridgeRegistry(): Map<string, ActiveDevtoolsBridge> {
448
453
  return registry
449
454
  }
450
455
 
456
+ /**
457
+ * `{...options}` turns `get hookId()` into a data property. Chat/generation
458
+ * clients mint `threadId` after construct (`ensureThreadId` on mount), so a
459
+ * spread would freeze `hookId: ''` and DevTools could never select the hook.
460
+ */
461
+ function withLiveClientIdentity<TSnapshot extends object>(
462
+ identity: Pick<
463
+ AIDevtoolsBridgeOptions<TSnapshot>,
464
+ | 'hookId'
465
+ | 'clientId'
466
+ | 'threadId'
467
+ | 'metadata'
468
+ | 'getTools'
469
+ | 'applyToolFixture'
470
+ >,
471
+ rest: Pick<AIDevtoolsBridgeOptions<TSnapshot>, 'getSnapshot'> &
472
+ Partial<
473
+ Pick<AIDevtoolsBridgeOptions<TSnapshot>, 'getTools' | 'applyToolFixture'>
474
+ >,
475
+ ): AIDevtoolsBridgeOptions<TSnapshot> {
476
+ return {
477
+ get hookId() {
478
+ return identity.hookId
479
+ },
480
+ get clientId() {
481
+ return identity.clientId
482
+ },
483
+ get threadId() {
484
+ return identity.threadId
485
+ },
486
+ metadata: identity.metadata,
487
+ getSnapshot: rest.getSnapshot,
488
+ ...(rest.getTools || identity.getTools
489
+ ? { getTools: rest.getTools ?? identity.getTools }
490
+ : {}),
491
+ ...(rest.applyToolFixture || identity.applyToolFixture
492
+ ? {
493
+ applyToolFixture: rest.applyToolFixture ?? identity.applyToolFixture,
494
+ }
495
+ : {}),
496
+ }
497
+ }
498
+
451
499
  export class ClientDevtoolsBridge<TSnapshot extends object> {
452
500
  protected readonly options: AIDevtoolsBridgeOptions<TSnapshot>
453
501
  private readonly unsubscribers: Array<Unsubscribe> = []
@@ -712,6 +760,7 @@ export class ClientDevtoolsBridge<TSnapshot extends object> {
712
760
  | 'memory:retrieve:started'
713
761
  | 'memory:retrieve:completed'
714
762
  | 'memory:snapshot'
763
+ | 'skills:snapshot'
715
764
  | AIDevtoolsRunEventType,
716
765
  visibility: AIDevtoolsEventVisibility = 'client-state',
717
766
  context: { runId?: string } = {},
@@ -770,17 +819,24 @@ export class ChatDevtoolsBridge extends ClientDevtoolsBridge<AIDevtoolsChatSnaps
770
819
  private lastRunEventContext: ChatClientRunEventContext | undefined
771
820
  /** Last transported `memory:state` value, replayed when a panel opens. */
772
821
  private lastMemoryStateValue: unknown = null
822
+ /** Last transported `skills:state` value, replayed when a panel opens. */
823
+ private lastSkillsStateValue: unknown = null
773
824
 
774
825
  constructor(options: ChatDevtoolsBridgeOptions) {
775
- super({
776
- ...options,
777
- // Thunk defers `this.applyFixture` lookup until after `super` returns.
778
- applyToolFixture: (fixture) => this.applyFixture(fixture),
779
- })
826
+ super(
827
+ withLiveClientIdentity(options, {
828
+ getSnapshot: options.getSnapshot,
829
+ // Thunk defers `this.applyFixture` lookup until after `super` returns.
830
+ applyToolFixture: (fixture) => this.applyFixture(fixture),
831
+ }),
832
+ )
780
833
  this.chatOptions = options
781
834
  // Auto-attaches run/thread context and auto-emits a snapshot after each
782
835
  // event so callers can keep using `this.events.X(...)` with no context arg.
783
- this.events = new ChatDevtoolsAwareEventEmitter(options.clientId, this)
836
+ this.events = new ChatDevtoolsAwareEventEmitter(
837
+ () => options.clientId,
838
+ this,
839
+ )
784
840
  }
785
841
 
786
842
  // --- Stream / run context API -------------------------------------------
@@ -874,6 +930,11 @@ export class ChatDevtoolsBridge extends ClientDevtoolsBridge<AIDevtoolsChatSnaps
874
930
  this.emitMemoryState(rawValue)
875
931
  }
876
932
 
933
+ recordSkillsState(rawValue: unknown): void {
934
+ this.lastSkillsStateValue = rawValue
935
+ this.emitSkillsState(rawValue)
936
+ }
937
+
877
938
  /**
878
939
  * Re-emit the browser-side `memory:*` events from a transported
879
940
  * `memory:state` value. The devtools store consumes these to render the
@@ -926,10 +987,26 @@ export class ChatDevtoolsBridge extends ClientDevtoolsBridge<AIDevtoolsChatSnaps
926
987
  }
927
988
  }
928
989
 
990
+ private emitSkillsState(rawValue: unknown): void {
991
+ if (!rawValue || typeof rawValue !== 'object') return
992
+ const value = rawValue as SkillsStateEventValue
993
+ const catalog = Array.isArray(value.catalog) ? value.catalog : []
994
+ const activated = Array.isArray(value.activated) ? value.activated : []
995
+ const runContext = this.currentRunId ? { runId: this.currentRunId } : {}
996
+ emitAIDevtoolsEvent('skills:snapshot', {
997
+ ...this.createEnvelope('skills:snapshot', 'client-state', runContext),
998
+ catalog,
999
+ activated,
1000
+ })
1001
+ }
1002
+
929
1003
  protected override onReplayState(): void {
930
1004
  if (this.lastMemoryStateValue != null) {
931
1005
  this.emitMemoryState(this.lastMemoryStateValue)
932
1006
  }
1007
+ if (this.lastSkillsStateValue != null) {
1008
+ this.emitSkillsState(this.lastSkillsStateValue)
1009
+ }
933
1010
  }
934
1011
 
935
1012
  getCurrentRunEventContext(): ChatClientRunEventContext | undefined {
@@ -1410,10 +1487,11 @@ export class GenerationDevtoolsBridge<TOutput> extends ClientDevtoolsBridge<
1410
1487
  protected readonly getCoreState: () => GenerationDevtoolsCoreState<TOutput>
1411
1488
 
1412
1489
  constructor(options: GenerationDevtoolsBridgeOptions<TOutput>) {
1413
- super({
1414
- ...options,
1415
- getSnapshot: () => this.buildSnapshot(),
1416
- })
1490
+ super(
1491
+ withLiveClientIdentity(options, {
1492
+ getSnapshot: () => this.buildSnapshot(),
1493
+ }),
1494
+ )
1417
1495
  this.maxRuns = options.maxRuns ?? 20
1418
1496
  this.getCoreState = options.getCoreState
1419
1497
  }
@@ -1759,10 +1837,18 @@ export class VideoDevtoolsBridge<
1759
1837
  // so resolveStreamId() works without the chat client telling it.
1760
1838
  class ChatDevtoolsAwareEventEmitter extends DefaultChatClientEventEmitter {
1761
1839
  constructor(
1762
- clientId: string,
1840
+ private readonly getClientId: () => string,
1763
1841
  private readonly helper: ChatDevtoolsBridge,
1764
1842
  ) {
1765
- super(clientId)
1843
+ super(getClientId())
1844
+ }
1845
+
1846
+ protected override emitEvent(
1847
+ eventName: string,
1848
+ data?: Record<string, any>,
1849
+ ): void {
1850
+ this.clientId = this.getClientId()
1851
+ super.emitEvent(eventName, data)
1766
1852
  }
1767
1853
 
1768
1854
  private afterEmit(streamId?: string): void {