@tanstack/ai-client 0.25.1 → 0.26.0

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.
@@ -3,8 +3,11 @@ import {
3
3
  convertSchemaToJsonSchema,
4
4
  generateMessageId,
5
5
  isStandardSchema,
6
+ mergeMetadata,
6
7
  normalizeToUIMessage,
7
8
  parseWithStandardSchema,
9
+ restoreInboundChunk,
10
+ tanstackMetadata,
8
11
  } from '@tanstack/ai/client'
9
12
  import { createNoOpChatDevtoolsBridge } from './devtools-noop'
10
13
  import {
@@ -189,6 +192,7 @@ export function normalizeQueueOption(
189
192
  * Merge a run of queued messages into a single send for `drain: 'batch'`.
190
193
  * All-string content is joined with newlines; mixed/multimodal content is
191
194
  * flattened into a single `ContentPart` array. The last item's `body` wins.
195
+ * Object-form metadata is merged last-write-wins per key.
192
196
  */
193
197
  function mergeQueuedMessages(items: Array<InternalQueuedMessage>): {
194
198
  content: string | MultimodalContent
@@ -209,17 +213,24 @@ function mergeQueuedMessages(items: Array<InternalQueuedMessage>): {
209
213
  }
210
214
  }
211
215
  const parts: Array<ContentPart> = []
216
+ let metadata: Record<string, any> | undefined
212
217
  for (const item of items) {
213
218
  if (typeof item.content === 'string') {
214
219
  parts.push({ type: 'text', content: item.content })
215
- } else if (typeof item.content.content === 'string') {
220
+ continue
221
+ }
222
+ if (typeof item.content.content === 'string') {
216
223
  parts.push({ type: 'text', content: item.content.content })
217
224
  } else {
218
225
  parts.push(...item.content.content)
219
226
  }
227
+ metadata = mergeMetadata(metadata, item.content.metadata)
220
228
  }
221
229
  return {
222
- content: { content: parts },
230
+ content: {
231
+ content: parts,
232
+ ...(metadata !== undefined ? { metadata } : {}),
233
+ },
223
234
  ...(body !== undefined ? { body } : {}),
224
235
  }
225
236
  }
@@ -1747,12 +1758,14 @@ export class ChatClient<
1747
1758
  chunk: StreamChunk,
1748
1759
  options?: { defer?: boolean },
1749
1760
  ): Promise<void> {
1761
+ chunk = restoreInboundChunk(chunk)
1750
1762
  if (
1751
1763
  chunk.type === 'RUN_ERROR' &&
1752
1764
  this.isActiveInterruptSubmissionFailure(chunk)
1753
1765
  ) {
1766
+ const interruptErrors = tanstackMetadata(chunk)?.interruptErrors
1754
1767
  this.interruptSubmissionFailure = {
1755
- errors: chunk['tanstack:interruptErrors'] ?? [],
1768
+ errors: Array.isArray(interruptErrors) ? interruptErrors : [],
1756
1769
  }
1757
1770
  }
1758
1771
  if (this.connectionStatus === 'connecting') {
@@ -1776,11 +1789,13 @@ export class ChatClient<
1776
1789
  this.processor.processChunk(chunk)
1777
1790
  this.updateRunLifecycle(chunk)
1778
1791
  this.observeInterruptState(chunk)
1779
- // The live path yields a macrotask between chunks so React can paint each
1780
- // delta progressively. A resume replay passes `defer: false` to skip it, so
1781
- // the buffered backlog applies in one batch (instant catch-up) instead of
1782
- // re-typing the whole reply.
1783
- if (options?.defer !== false) {
1792
+ // Live path: yield a macrotask so the UI can paint. Skip when the page is
1793
+ // hidden. Browsers clamp setTimeout there, and that wait paces stream pull.
1794
+ // Replay passes defer: false so a backlog applies in one batch.
1795
+ if (
1796
+ options?.defer !== false &&
1797
+ (typeof document === 'undefined' || !document.hidden)
1798
+ ) {
1784
1799
  await new Promise((resolve) => setTimeout(resolve, 0))
1785
1800
  }
1786
1801
  this.resolveJoinedRun(chunk)
@@ -1790,8 +1805,10 @@ export class ChatClient<
1790
1805
  chunk: Extract<StreamChunk, { type: 'RUN_ERROR' }>,
1791
1806
  ): boolean {
1792
1807
  const submission = this.activeInterruptSubmission
1793
- const errors = chunk['tanstack:interruptErrors']
1794
- if (!submission || !errors || errors.length === 0) return false
1808
+ const errors = tanstackMetadata(chunk)?.interruptErrors
1809
+ if (!submission || !Array.isArray(errors) || errors.length === 0) {
1810
+ return false
1811
+ }
1795
1812
  const runId = getChunkRunId(chunk)
1796
1813
  if (runId !== undefined && runId !== this.currentRunId) return false
1797
1814
  if (
@@ -1800,12 +1817,22 @@ export class ChatClient<
1800
1817
  ) {
1801
1818
  return false
1802
1819
  }
1803
- return errors.every(
1804
- (error) =>
1820
+ return errors.every((error) => {
1821
+ if (
1822
+ error == null ||
1823
+ typeof error !== 'object' ||
1824
+ typeof error.threadId !== 'string' ||
1825
+ typeof error.interruptedRunId !== 'string' ||
1826
+ typeof error.generation !== 'number'
1827
+ ) {
1828
+ return false
1829
+ }
1830
+ return (
1805
1831
  error.threadId === submission.threadId &&
1806
1832
  error.interruptedRunId === submission.interruptedRunId &&
1807
- error.generation === submission.generation,
1808
- )
1833
+ error.generation === submission.generation
1834
+ )
1835
+ })
1809
1836
  }
1810
1837
 
1811
1838
  private resolveJoinedRun(chunk: StreamChunk): void {
@@ -1980,6 +2007,7 @@ export class ChatClient<
1980
2007
  const userMessage = this.processor.addUserMessage(
1981
2008
  normalizedContent.content,
1982
2009
  normalizedContent.id,
2010
+ normalizedContent.metadata,
1983
2011
  )
1984
2012
  this.events.messageSent(userMessage.id, normalizedContent.content)
1985
2013
  return await this.streamResponse()
@@ -2039,17 +2067,22 @@ export class ChatClient<
2039
2067
  }
2040
2068
 
2041
2069
  /**
2042
- * Normalize the message input to extract content and optional id.
2043
- * Trims string content automatically.
2070
+ * Normalize the message input to extract content, optional id, and
2071
+ * optional metadata. String form has no metadata. Trims string content.
2044
2072
  */
2045
2073
  private normalizeMessageInput(input: string | MultimodalContent): {
2046
2074
  content: string | Array<ContentPart>
2047
2075
  id?: string
2076
+ metadata?: Record<string, any>
2048
2077
  } {
2049
2078
  if (typeof input === 'string') {
2050
2079
  return { content: input.trim() }
2051
2080
  }
2052
- return { content: input.content, id: input.id }
2081
+ return {
2082
+ content: input.content,
2083
+ id: input.id,
2084
+ ...(input.metadata !== undefined ? { metadata: input.metadata } : {}),
2085
+ }
2053
2086
  }
2054
2087
 
2055
2088
  /**
@@ -1,4 +1,11 @@
1
- import { EventType, uiMessagesToWire } from '@tanstack/ai/client'
1
+ import {
2
+ EventType,
3
+ getChunkRunId as getNormalizedChunkRunId,
4
+ restoreInboundChunk,
5
+ tanstackMetadata,
6
+ uiMessagesToWire,
7
+ withTanstackMetadata,
8
+ } from '@tanstack/ai/client'
2
9
  import {
3
10
  createResponseStreamTextDecoder,
4
11
  getResponseStreamReader,
@@ -8,7 +15,6 @@ import type {
8
15
  ModelMessage,
9
16
  RunAgentResumeItem,
10
17
  RunErrorEvent,
11
- RunFinishedEvent,
12
18
  StreamChunk,
13
19
  UIMessage,
14
20
  } from '@tanstack/ai/client'
@@ -34,12 +40,7 @@ export function getChunkRunId(chunk: StreamChunk): string | undefined {
34
40
  // the client's run identity to win when a provider stamps its own id; for
35
41
  // resumable reconnect/join the two ids match, so precedence is moot there.
36
42
  const requestRunId = chunkRunIds.get(chunk)
37
- return (
38
- requestRunId ??
39
- ('runId' in chunk && typeof chunk.runId === 'string'
40
- ? chunk.runId
41
- : undefined)
42
- )
43
+ return requestRunId ?? getNormalizedChunkRunId(chunk)
43
44
  }
44
45
 
45
46
  /**
@@ -385,6 +386,22 @@ function isNdjsonEnvelope(
385
386
  )
386
387
  }
387
388
 
389
+ /** Rebuild pre-wire extras after SSE/NDJSON ingest. */
390
+ function restoreInboundUsage(chunk: StreamChunk): StreamChunk {
391
+ return restoreInboundChunk(chunk)
392
+ }
393
+
394
+ function sseChunkModel(chunk: StreamChunk): string | undefined {
395
+ const tanstackModel = tanstackMetadata(chunk)?.model
396
+ if (typeof tanstackModel === 'string') return tanstackModel
397
+ const usage = 'usage' in chunk ? chunk.usage : undefined
398
+ if (Array.isArray(usage)) {
399
+ const model = (usage[0] as { model?: unknown } | undefined)?.model
400
+ if (typeof model === 'string') return model
401
+ }
402
+ return undefined
403
+ }
404
+
388
405
  /**
389
406
  * Parse SSE-format lines into stream events, pairing each chunk with the `id:`
390
407
  * offset of the event it arrived on. Shared by the fetch- and XHR-backed SSE
@@ -431,27 +448,31 @@ async function* linesToSSEEvents(
431
448
  }
432
449
  const data = parseSseDataLine(line)
433
450
  if (data === '[DONE]') {
434
- const synthetic: RunFinishedEvent = {
435
- type: EventType.RUN_FINISHED,
436
- threadId: lastThreadId ?? fallbackIds?.threadId ?? '',
437
- runId: lastRunId ?? fallbackIds?.runId ?? '',
438
- model: lastModel ?? '',
439
- timestamp: Date.now(),
440
- finishReason: 'stop',
451
+ yield {
452
+ chunk: withTanstackMetadata(
453
+ {
454
+ type: EventType.RUN_FINISHED,
455
+ threadId: lastThreadId ?? fallbackIds?.threadId ?? '',
456
+ runId: lastRunId ?? fallbackIds?.runId ?? '',
457
+ timestamp: Date.now(),
458
+ },
459
+ {
460
+ finishReason: 'stop',
461
+ ...(lastModel !== undefined ? { model: lastModel } : {}),
462
+ },
463
+ ) as StreamChunk,
441
464
  }
442
- yield { chunk: synthetic }
443
465
  return
444
466
  }
445
- const chunk = JSON.parse(data) as StreamChunk
467
+ const chunk = restoreInboundUsage(JSON.parse(data) as StreamChunk)
446
468
  if ('threadId' in chunk && typeof chunk.threadId === 'string') {
447
469
  lastThreadId = chunk.threadId
448
470
  }
449
471
  if ('runId' in chunk && typeof chunk.runId === 'string') {
450
472
  lastRunId = chunk.runId
451
473
  }
452
- if ('model' in chunk && typeof chunk.model === 'string') {
453
- lastModel = chunk.model
454
- }
474
+ const model = sseChunkModel(chunk)
475
+ if (model !== undefined) lastModel = model
455
476
  const id = pendingId
456
477
  pendingId = undefined
457
478
  yield { chunk, ...(id !== undefined ? { id } : {}) }
@@ -470,9 +491,9 @@ async function* linesToNdjsonEvents(
470
491
  for await (const line of lines) {
471
492
  const parsed = JSON.parse(line) as unknown
472
493
  if (isNdjsonEnvelope(parsed)) {
473
- yield { chunk: parsed.chunk, id: parsed.id }
494
+ yield { chunk: restoreInboundUsage(parsed.chunk), id: parsed.id }
474
495
  } else {
475
- yield { chunk: parsed as StreamChunk }
496
+ yield { chunk: restoreInboundUsage(parsed as StreamChunk) }
476
497
  }
477
498
  }
478
499
  }
@@ -1064,21 +1085,24 @@ export function normalizeConnectionAdapter(
1064
1085
  // observed, but stamp the caller's request runId so getChunkRunId()
1065
1086
  // correlates to activeRunIds / currentRunId (same as real stream chunks).
1066
1087
  if (!abortSignal?.aborted && !hasTerminalEvent) {
1067
- const synthetic: RunFinishedEvent = {
1068
- type: EventType.RUN_FINISHED,
1069
- threadId: requireSyntheticId(
1070
- upstreamThreadId ?? runContext?.threadId,
1071
- 'threadId',
1072
- ),
1073
- runId: requireSyntheticId(
1074
- upstreamRunId ?? runContext?.runId,
1075
- 'runId',
1076
- ),
1077
- model: 'connect-wrapper',
1078
- timestamp: Date.now(),
1079
- finishReason: 'stop',
1080
- }
1081
- push(synthetic, runContext?.runId)
1088
+ push(
1089
+ withTanstackMetadata(
1090
+ {
1091
+ type: EventType.RUN_FINISHED,
1092
+ threadId: requireSyntheticId(
1093
+ upstreamThreadId ?? runContext?.threadId,
1094
+ 'threadId',
1095
+ ),
1096
+ runId: requireSyntheticId(
1097
+ upstreamRunId ?? runContext?.runId,
1098
+ 'runId',
1099
+ ),
1100
+ timestamp: Date.now(),
1101
+ },
1102
+ { finishReason: 'stop', model: 'connect-wrapper' },
1103
+ ) as StreamChunk,
1104
+ runContext?.runId,
1105
+ )
1082
1106
  }
1083
1107
  } catch (err) {
1084
1108
  if (!abortSignal?.aborted && !hasTerminalEvent) {
@@ -2143,9 +2167,9 @@ export function webSocket(
2143
2167
  }
2144
2168
  if (isPingFrame(parsed)) return
2145
2169
  const envelopeId = isNdjsonEnvelope(parsed) ? parsed.id : undefined
2146
- const chunk = isNdjsonEnvelope(parsed)
2147
- ? parsed.chunk
2148
- : (parsed as StreamChunk)
2170
+ const chunk = restoreInboundUsage(
2171
+ isNdjsonEnvelope(parsed) ? parsed.chunk : (parsed as StreamChunk),
2172
+ )
2149
2173
 
2150
2174
  // Thread durable chunks through the active run session's tracker (if
2151
2175
  // any) so a later reconnect knows the last offset and can skip a
@@ -2325,7 +2349,9 @@ export function webSocket(
2325
2349
  }
2326
2350
  if (isPingFrame(parsed)) return
2327
2351
  pipe.push(
2328
- isNdjsonEnvelope(parsed) ? parsed.chunk : (parsed as StreamChunk),
2352
+ restoreInboundUsage(
2353
+ isNdjsonEnvelope(parsed) ? parsed.chunk : (parsed as StreamChunk),
2354
+ ),
2329
2355
  )
2330
2356
  }
2331
2357
  ws.onclose = (event?: CloseEvent) => {
@@ -10,6 +10,7 @@ import {
10
10
  } from './generation-types'
11
11
  import { createNoOpGenerationDevtoolsBridge } from './devtools-noop'
12
12
  import { parseSSEResponse } from './sse-parser'
13
+ import { restoreInboundChunk } from '@tanstack/ai/client'
13
14
  import type { StreamChunk } from '@tanstack/ai/client'
14
15
  import type {
15
16
  ConnectConnectionAdapter,
@@ -349,9 +350,10 @@ export class GenerationClient<
349
350
  let streamRunId: string | undefined
350
351
  let sawTerminalChunk = false
351
352
 
352
- for await (const chunk of source) {
353
+ for await (const raw of source) {
353
354
  if (signal.aborted) break
354
355
 
356
+ const chunk = restoreInboundChunk(raw)
355
357
  this.callbacksRef.onChunk?.(chunk)
356
358
  this.observeResumeSnapshot(chunk)
357
359
  const chunkRunId =
@@ -390,11 +392,9 @@ export class GenerationClient<
390
392
  this.devtoolsBridge.ensureRunStarted(
391
393
  chunkRunId ?? streamRunId ?? fallbackRunId,
392
394
  )
393
- // Prefer spec `message`; fall back to deprecated `error.message`
395
+ // Spec RUN_ERROR message. Missing message uses this fallback.
394
396
  const msg =
395
- (chunk.message as string | undefined) ||
396
- chunk.error?.message ||
397
- 'An error occurred'
397
+ (chunk.message as string | undefined) || 'An error occurred'
398
398
  throw new Error(msg)
399
399
  }
400
400
  default:
@@ -1,3 +1,4 @@
1
+ import { tanstackMetadata } from '@tanstack/ai/client'
1
2
  import type {
2
3
  MediaPrompt,
3
4
  PersistedArtifactRef,
@@ -461,8 +462,13 @@ export function updateGenerationResumeSnapshot(
461
462
  previous: GenerationResumeSnapshot | null | undefined,
462
463
  chunk: StreamChunk,
463
464
  ): GenerationResumeSnapshot {
464
- const threadId = stringField(chunk, 'threadId')
465
- const runId = stringField(chunk, 'runId')
465
+ const tanstack = tanstackMetadata(chunk)
466
+ const threadId =
467
+ stringField(chunk, 'threadId') ??
468
+ (typeof tanstack?.threadId === 'string' ? tanstack.threadId : undefined)
469
+ const runId =
470
+ stringField(chunk, 'runId') ??
471
+ (typeof tanstack?.runId === 'string' ? tanstack.runId : undefined)
466
472
  const carried = chunk.type === 'RUN_STARTED' ? undefined : previous
467
473
  const previousArtifacts = carried?.pendingArtifacts ?? []
468
474
  const next: GenerationResumeSnapshot = {
package/src/types.ts CHANGED
@@ -397,6 +397,18 @@ export interface MultimodalContent {
397
397
  * If not provided, a unique ID will be generated.
398
398
  */
399
399
  id?: string
400
+ /**
401
+ * Optional AG-UI metadata bag copied onto the resulting UIMessage.
402
+ *
403
+ * @example
404
+ * ```ts
405
+ * await client.sendMessage({
406
+ * content: 'Show me failed logins',
407
+ * metadata: { author: { id: 'user-42', name: 'Dana' } },
408
+ * })
409
+ * ```
410
+ */
411
+ metadata?: Record<string, any>
400
412
  }
401
413
 
402
414
  /**
@@ -616,6 +628,11 @@ export interface UIMessage<
616
628
  role: 'system' | 'user' | 'assistant'
617
629
  parts: Array<MessagePart<TTools, TData>>
618
630
  createdAt?: Date
631
+ /**
632
+ * Optional AG-UI metadata bag. TanStack writes the `tanstack` key.
633
+ * User keys stay at the top.
634
+ */
635
+ metadata?: Record<string, any>
619
636
  }
620
637
 
621
638
  /**
@@ -10,6 +10,7 @@ import {
10
10
  } from './generation-types'
11
11
  import { createNoOpVideoDevtoolsBridge } from './devtools-noop'
12
12
  import { parseSSEResponse } from './sse-parser'
13
+ import { restoreInboundChunk } from '@tanstack/ai/client'
13
14
  import type { StreamChunk } from '@tanstack/ai/client'
14
15
  import type {
15
16
  ConnectConnectionAdapter,
@@ -343,9 +344,10 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
343
344
  let streamRunId: string | undefined
344
345
  let sawTerminalChunk = false
345
346
 
346
- for await (const chunk of source) {
347
+ for await (const raw of source) {
347
348
  if (signal.aborted) break
348
349
 
350
+ const chunk = restoreInboundChunk(raw)
349
351
  this.callbacksRef.onChunk?.(chunk)
350
352
  this.observeResumeSnapshot(chunk)
351
353
  const chunkRunId =
@@ -395,11 +397,9 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
395
397
  this.devtoolsBridge.ensureRunStarted(
396
398
  chunkRunId ?? streamRunId ?? fallbackRunId,
397
399
  )
398
- // Prefer spec `message`; fall back to deprecated `error.message`
400
+ // Spec RUN_ERROR message. Missing message uses this fallback.
399
401
  const msg =
400
- (chunk.message as string | undefined) ||
401
- chunk.error?.message ||
402
- 'An error occurred'
402
+ (chunk.message as string | undefined) || 'An error occurred'
403
403
  throw new Error(msg)
404
404
  }
405
405
  default: