@tanstack/ai-client 0.28.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.
@@ -26,6 +26,7 @@ import {
26
26
  } from './connection-adapters'
27
27
  import { ChatPersistor } from './client-persistor'
28
28
  import { ClearedStreamTracker } from './cleared-stream-tracker'
29
+ import { normalizeMessagesDates } from './message-date-normalizer'
29
30
  import { InterruptManager } from './interrupt-manager'
30
31
  import type {
31
32
  AnyClientTool,
@@ -163,6 +164,20 @@ function resolveTransport(transport: {
163
164
  throw new Error('ChatClient: either `connection` or `fetcher` is required.')
164
165
  }
165
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
+
166
181
  export interface NormalizedQueueConfig {
167
182
  whenBusy: WhenBusy
168
183
  drain: 'fifo' | 'batch'
@@ -415,8 +430,19 @@ export class ChatClient<
415
430
  private continuationPending = false
416
431
  private subscriptionAbortController: AbortController | null = null
417
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
418
439
  private errorReportedGeneration: number | null = null
419
440
  private streamGeneration = 0
441
+ private continuationGeneration = 0
442
+ // Generation of the run that opened the current stream. Public
443
+ // `addToolResult` must use this, not the live counter: `stop()` increments
444
+ // the live counter, so a post-stop call would otherwise look current.
445
+ private streamContinuationGeneration = 0
420
446
  // Tracks whether a queued checkForContinuation was skipped because
421
447
  // continuationPending was true (chained approval scenario)
422
448
  private continuationSkipped = false
@@ -512,7 +538,9 @@ export class ChatClient<
512
538
  this.byokProvider = options.byokProvider
513
539
  this.context = options.context
514
540
  this.queueConfig = normalizeQueueOption(options.queue)
515
- this.connection = normalizeConnectionAdapter(resolveTransport(options))
541
+ const transport = resolveTransport(options)
542
+ this.connectionDrainsOnSend = connectionDrainsOnSend(transport)
543
+ this.connection = normalizeConnectionAdapter(transport)
516
544
 
517
545
  // Build client tools map
518
546
  this.clientToolsRef = { current: new Map() }
@@ -737,6 +765,7 @@ export class ChatClient<
737
765
  const clientTool = clientTools.get(args.toolName)
738
766
  const executeFunc = clientTool?.execute
739
767
  if (executeFunc) {
768
+ const continuationGeneration = this.continuationGeneration
740
769
  // Capture the run context at execution-start so a tool whose
741
770
  // result lands AFTER the originating run finishes still reports
742
771
  // back against the originating run, not whatever run is
@@ -763,6 +792,7 @@ export class ChatClient<
763
792
  state: 'output-available',
764
793
  },
765
794
  clientTool,
795
+ continuationGeneration,
766
796
  runEventContext,
767
797
  )
768
798
  } catch (error: any) {
@@ -775,6 +805,7 @@ export class ChatClient<
775
805
  errorText: error.message,
776
806
  },
777
807
  clientTool,
808
+ continuationGeneration,
778
809
  runEventContext,
779
810
  )
780
811
  } finally {
@@ -820,6 +851,9 @@ export class ChatClient<
820
851
  if (eventType === 'memory:state') {
821
852
  this.devtoolsBridge.recordMemoryState(data)
822
853
  }
854
+ if (eventType === 'skills:state') {
855
+ this.devtoolsBridge.recordSkillsState(data)
856
+ }
823
857
  this.callbacksRef.current.onCustomEvent(eventType, data, context)
824
858
  },
825
859
  },
@@ -1017,7 +1051,7 @@ export class ChatClient<
1017
1051
  // A send may have started while the fetch was in flight — don't stomp it.
1018
1052
  if (this.isLoading || this.abortController) return
1019
1053
  if (result.messages.length > 0) {
1020
- this.processor.setMessages(result.messages)
1054
+ this.processor.setMessages(normalizeMessagesDates(result.messages))
1021
1055
  }
1022
1056
  if (result.interrupts && result.interrupts.pending.length > 0) {
1023
1057
  // Pending interrupt = the thread is paused awaiting a human decision, so
@@ -1131,7 +1165,9 @@ export class ChatClient<
1131
1165
  this.clearedStreamTracker.onSessionRunError()
1132
1166
  }
1133
1167
  this.setSessionGenerating(this.activeRunIds.size > 0)
1134
- if (options?.resolveProcessing !== false) {
1168
+ const skipProcessingResolve =
1169
+ chunk.type === 'RUN_FINISHED' && isIntermediateToolTurn(chunk)
1170
+ if (options?.resolveProcessing !== false && !skipProcessingResolve) {
1135
1171
  this.resolveProcessing()
1136
1172
  }
1137
1173
  }
@@ -1304,6 +1340,21 @@ export class ChatClient<
1304
1340
  ): Promise<boolean> {
1305
1341
  const target = state ?? this.lastResume
1306
1342
  if (!target) return Promise.resolve(false)
1343
+ return this.resumeInterruptsUnsafeForGeneration(
1344
+ resume,
1345
+ target,
1346
+ this.continuationGeneration,
1347
+ )
1348
+ }
1349
+
1350
+ private resumeInterruptsUnsafeForGeneration(
1351
+ resume: Array<RunAgentResumeItem>,
1352
+ target: ChatResumeState,
1353
+ continuationGeneration: number,
1354
+ ): Promise<boolean> {
1355
+ if (continuationGeneration !== this.continuationGeneration) {
1356
+ return Promise.resolve(false)
1357
+ }
1307
1358
  // Auto-executed client tools resolve during the parent stream's
1308
1359
  // `pendingToolExecutions` wait — while `isLoading` is still true.
1309
1360
  // Defer the child continuation until that stream settles so we do not
@@ -1312,7 +1363,13 @@ export class ChatClient<
1312
1363
  return new Promise<boolean>((resolve, reject) => {
1313
1364
  this.queuePostStreamAction(async () => {
1314
1365
  try {
1315
- resolve(await this.resumeInterruptsUnsafe(resume, target))
1366
+ resolve(
1367
+ await this.resumeInterruptsUnsafeForGeneration(
1368
+ resume,
1369
+ target,
1370
+ continuationGeneration,
1371
+ ),
1372
+ )
1316
1373
  } catch (error) {
1317
1374
  reject(error)
1318
1375
  }
@@ -1336,6 +1393,7 @@ export class ChatClient<
1336
1393
  private async submitInterruptBatch(
1337
1394
  submission: InterruptManagerSubmission,
1338
1395
  ): Promise<void> {
1396
+ const continuationGeneration = this.continuationGeneration
1339
1397
  this.activeInterruptSubmission = submission
1340
1398
  this.interruptSubmissionFailure = undefined
1341
1399
  // Reflect approval decisions in the local message tree immediately so a
@@ -1347,15 +1405,21 @@ export class ChatClient<
1347
1405
  const approvalId = resolution.interruptId
1348
1406
  this.processor.addToolApprovalResponse(approvalId, approved)
1349
1407
  }
1350
- const resumed = await this.resumeInterruptsUnsafe(
1408
+ const resumed = await this.resumeInterruptsUnsafeForGeneration(
1351
1409
  [...submission.resolutions],
1352
1410
  {
1353
1411
  threadId: submission.threadId,
1354
1412
  runId: submission.interruptedRunId,
1355
1413
  },
1414
+ continuationGeneration,
1356
1415
  ).finally(() => {
1357
- this.activeInterruptSubmission = undefined
1416
+ // Only clear if this resume still owns the client: `stop()` may have
1417
+ // invalidated it while the submission was settling.
1418
+ if (this.activeInterruptSubmission === submission) {
1419
+ this.activeInterruptSubmission = undefined
1420
+ }
1358
1421
  })
1422
+ if (continuationGeneration !== this.continuationGeneration) return
1359
1423
  const failure = this.takeInterruptSubmissionFailure()
1360
1424
  if (failure !== undefined) {
1361
1425
  throw { errors: failure.errors }
@@ -1678,6 +1742,7 @@ export class ChatClient<
1678
1742
  // persisted pointer with the provider id — so a SECOND reload would
1679
1743
  // `joinRun` an id the log isn't keyed by and never re-attach.
1680
1744
  this.lastResume = { threadId: this.threadId, runId }
1745
+ this.streamContinuationGeneration = this.continuationGeneration
1681
1746
  this.setIsLoading(true)
1682
1747
  this.setStatus('streaming')
1683
1748
  void (async () => {
@@ -2163,6 +2228,7 @@ export class ChatClient<
2163
2228
 
2164
2229
  // Track generation so a superseded stream's cleanup doesn't clobber the new one
2165
2230
  const generation = ++this.streamGeneration
2231
+ this.streamContinuationGeneration = this.continuationGeneration
2166
2232
  // Native interrupt continuation is a fresh child run. The interrupted run
2167
2233
  // is carried as parentRunId and the complete resolution batch as resume.
2168
2234
  const resumeThreadId = this.pendingResumeThreadId
@@ -2305,6 +2371,14 @@ export class ChatClient<
2305
2371
  return false
2306
2372
  }
2307
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
+
2308
2382
  // Wait for subscription loop to finish processing all chunks
2309
2383
  await processingComplete
2310
2384
 
@@ -2506,9 +2580,14 @@ export class ChatClient<
2506
2580
  * Stop the current stream
2507
2581
  */
2508
2582
  stop(): void {
2583
+ // Invalidate deferred work from the stopped continuation.
2584
+ this.continuationGeneration++
2509
2585
  const hadLocalStream = this.abortController !== null
2510
2586
  this.cancelInFlightStream({ setReadyStatus: true })
2511
2587
  this.discardPendingSends()
2588
+ this.lastResume = null
2589
+ this.activeInterruptSubmission = undefined
2590
+ this.interruptManager.reset()
2512
2591
  if (hadLocalStream) {
2513
2592
  this.resetSessionGenerating()
2514
2593
  }
@@ -2552,12 +2631,17 @@ export class ChatClient<
2552
2631
  */
2553
2632
  async addToolResult(result: ClientToolResult): Promise<void> {
2554
2633
  const clientTool = this.clientToolsRef.current.get(result.tool)
2555
- await this.addToolResultForClientTool(result, clientTool)
2634
+ await this.addToolResultForClientTool(
2635
+ result,
2636
+ clientTool,
2637
+ this.streamContinuationGeneration,
2638
+ )
2556
2639
  }
2557
2640
 
2558
2641
  private async addToolResultForClientTool(
2559
2642
  result: ClientToolResult,
2560
2643
  clientTool: AnyClientTool | undefined,
2644
+ continuationGeneration: number,
2561
2645
  context?: ChatClientRunEventContext,
2562
2646
  ): Promise<void> {
2563
2647
  if (clientTool && result.state !== 'output-error') {
@@ -2584,6 +2668,8 @@ export class ChatClient<
2584
2668
  context,
2585
2669
  )
2586
2670
 
2671
+ if (continuationGeneration !== this.continuationGeneration) return
2672
+
2587
2673
  // Always update local message state so the tool-call part is terminal in
2588
2674
  // the UI even when the AG-UI interrupt path owns server continuation.
2589
2675
  this.processor.addToolResult(
@@ -2609,7 +2695,11 @@ export class ChatClient<
2609
2695
 
2610
2696
  // If stream is in progress, queue continuation check for after it ends
2611
2697
  if (this.isLoading) {
2612
- this.queuePostStreamAction(() => this.checkForContinuation())
2698
+ this.queuePostStreamAction(() =>
2699
+ continuationGeneration === this.continuationGeneration
2700
+ ? this.checkForContinuation()
2701
+ : Promise.resolve(),
2702
+ )
2613
2703
  return
2614
2704
  }
2615
2705
 
@@ -2689,7 +2779,11 @@ export class ChatClient<
2689
2779
  * Queue an action to be executed after the current stream ends
2690
2780
  */
2691
2781
  private queuePostStreamAction(action: () => Promise<void>): void {
2692
- this.postStreamActions.push(action)
2782
+ const continuationGeneration = this.continuationGeneration
2783
+ this.postStreamActions.push(async () => {
2784
+ if (continuationGeneration !== this.continuationGeneration) return
2785
+ await action()
2786
+ })
2693
2787
  }
2694
2788
 
2695
2789
  /**
@@ -2712,6 +2806,10 @@ export class ChatClient<
2712
2806
  * Check if we should continue the flow and do so if needed
2713
2807
  */
2714
2808
  private async checkForContinuation(): Promise<void> {
2809
+ // stop() bumps continuationGeneration without opening a new stream.
2810
+ if (this.streamContinuationGeneration !== this.continuationGeneration) {
2811
+ return
2812
+ }
2715
2813
  if (this.hasPendingInterrupts()) return
2716
2814
 
2717
2815
  // Prevent duplicate continuation attempts
@@ -2982,12 +3080,12 @@ export class ChatClient<
2982
3080
  this.resetSessionGenerating()
2983
3081
  this.setIsSubscribed(false)
2984
3082
  this.setConnectionStatus('disconnected')
2985
- this.connection = normalizeConnectionAdapter(
2986
- resolveTransport({
2987
- connection: options.connection,
2988
- fetcher: options.fetcher,
2989
- }),
2990
- )
3083
+ const transport = resolveTransport({
3084
+ connection: options.connection,
3085
+ fetcher: options.fetcher,
3086
+ })
3087
+ this.connectionDrainsOnSend = connectionDrainsOnSend(transport)
3088
+ this.connection = normalizeConnectionAdapter(transport)
2991
3089
 
2992
3090
  if (wasSubscribed) {
2993
3091
  this.subscribe()
@@ -20,6 +20,7 @@ import type {
20
20
  UIMessage,
21
21
  } from '@tanstack/ai/client'
22
22
  import type { ChatFetcher, ChatPendingInterrupt } from './types'
23
+ import { normalizeMessagesDates } from './message-date-normalizer'
23
24
 
24
25
  /**
25
26
  * Associates connect-wrapped chunks with the run they were produced under.
@@ -573,7 +574,9 @@ async function fetchThreadHydration(
573
574
  }
574
575
  : null
575
576
  return {
576
- messages: Array.isArray(data.messages) ? data.messages : [],
577
+ messages: Array.isArray(data.messages)
578
+ ? normalizeMessagesDates(data.messages)
579
+ : [],
577
580
  activeRun,
578
581
  interrupts,
579
582
  }
@@ -1052,6 +1055,28 @@ export function normalizeConnectionAdapter(
1052
1055
  }
1053
1056
  }
1054
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
+
1055
1080
  return {
1056
1081
  subscribe(abortSignal?: AbortSignal): AsyncIterable<StreamChunk> {
1057
1082
  // Transfer ownership to the latest subscriber so only one active
@@ -1159,6 +1184,7 @@ export function normalizeConnectionAdapter(
1159
1184
  }
1160
1185
  throw err
1161
1186
  }
1187
+ await waitUntilSubscriberIdle(abortSignal)
1162
1188
  },
1163
1189
  // Expose joinRun only when the underlying connection is resumable. Require
1164
1190
  // a real function — `'joinRun' in connection` is true for
@@ -1224,7 +1250,7 @@ function buildRunAgentInputBody(
1224
1250
  ): Record<string, unknown> {
1225
1251
  // Precedence (later spreads win): static adapter `body` is the base,
1226
1252
  // overridden by `runContext.forwardedProps`, overridden by per-message `data`.
1227
- const wireMessages = uiMessagesToWire(messages as Array<UIMessage>)
1253
+ const wireMessages = uiMessagesToWire(messages)
1228
1254
  const forwardedProps = {
1229
1255
  ...options.body,
1230
1256
  ...(runContext?.forwardedProps ?? {}),
@@ -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 {
@@ -0,0 +1,29 @@
1
+ import type { UIMessage } from './types'
2
+
3
+ function validDate(value: unknown): Date | undefined {
4
+ if (value instanceof Date)
5
+ return Number.isNaN(value.getTime()) ? undefined : value
6
+ if (typeof value !== 'string') return undefined
7
+ const date = new Date(value)
8
+ return Number.isNaN(date.getTime()) ? undefined : date
9
+ }
10
+
11
+ export function normalizeMessageDates(message: UIMessage): UIMessage {
12
+ const messageDate = validDate(message.createdAt)
13
+ const parts = message.parts.map((part) => {
14
+ if (part.type !== 'tool-result') return part
15
+ const date = validDate(part.createdAt)
16
+ const { createdAt: _ignored, ...rest } = part
17
+ return date ? { ...rest, createdAt: date } : rest
18
+ })
19
+ const { createdAt: _ignored, ...rest } = message
20
+ return messageDate
21
+ ? { ...rest, parts, createdAt: messageDate }
22
+ : { ...rest, parts }
23
+ }
24
+
25
+ export function normalizeMessagesDates(
26
+ messages: Array<UIMessage>,
27
+ ): Array<UIMessage> {
28
+ return messages.map(normalizeMessageDates)
29
+ }
@@ -1,4 +1,5 @@
1
- import type { ChatPersistedState, ChatStorageAdapter } from './types'
1
+ import type { ChatPersistedState, ChatStorageAdapter, UIMessage } from './types'
2
+ import { normalizeMessagesDates } from './message-date-normalizer'
2
3
 
3
4
  export interface WebStoragePersistenceOptions {
4
5
  keyPrefix?: string
@@ -45,6 +46,16 @@ function stringifyJson(value: ChatPersistedState): string {
45
46
  return serialized
46
47
  }
47
48
 
49
+ function reviveMessageCreatedAt(message: UIMessage): UIMessage {
50
+ return normalizeMessagesDates([message]).at(0) ?? message
51
+ }
52
+
53
+ function revivePersistedState(state: ChatPersistedState): ChatPersistedState {
54
+ if (state == null || typeof state !== 'object') return state
55
+ if (!Array.isArray(state.messages)) return state
56
+ return { ...state, messages: state.messages.map(reviveMessageCreatedAt) }
57
+ }
58
+
48
59
  function createWebStoragePersistence(
49
60
  storageName: 'localStorage' | 'sessionStorage',
50
61
  options: WebStoragePersistenceOptions,
@@ -69,7 +80,7 @@ function createWebStoragePersistence(
69
80
  return {
70
81
  getItem(id) {
71
82
  const item = getStorage().getItem(key(id))
72
- return item === null ? null : deserialize(item)
83
+ return item === null ? null : revivePersistedState(deserialize(item))
73
84
  },
74
85
  setItem(id, value) {
75
86
  getStorage().setItem(key(id), serialize(value))
package/src/types.ts CHANGED
@@ -595,10 +595,14 @@ export type ToolCallPart<TTools extends ReadonlyArray<AnyClientTool> = any> =
595
595
 
596
596
  export interface ToolResultPart {
597
597
  type: 'tool-result'
598
+ id?: string
599
+ name?: string
598
600
  toolCallId: string
599
601
  content: string | Array<ContentPart>
600
602
  state: ToolResultState
601
603
  error?: string // Error message if state is "error"
604
+ metadata?: Record<string, unknown>
605
+ createdAt?: Date
602
606
  }
603
607
 
604
608
  export interface ThinkingPart {
@@ -639,6 +643,7 @@ export interface UIMessage<
639
643
  > {
640
644
  id: string
641
645
  role: 'system' | 'user' | 'assistant'
646
+ name?: string
642
647
  parts: Array<MessagePart<TTools, TData>>
643
648
  createdAt?: Date
644
649
  /**