@tanstack/ai-client 0.14.1 → 0.15.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.
@@ -2,7 +2,9 @@ import {
2
2
  StreamProcessor,
3
3
  convertSchemaToJsonSchema,
4
4
  generateMessageId,
5
+ isStandardSchema,
5
6
  normalizeToUIMessage,
7
+ parseWithStandardSchema,
6
8
  } from '@tanstack/ai/client'
7
9
  import { createNoOpChatDevtoolsBridge } from './devtools-noop'
8
10
  import {
@@ -39,6 +41,37 @@ import type {
39
41
  UIMessage,
40
42
  } from './types'
41
43
 
44
+ type ChatClientUpdateOptionsWithoutContext<
45
+ TTools extends ReadonlyArray<AnyClientTool>,
46
+ > = {
47
+ connection?: ConnectionAdapter
48
+ fetcher?: ChatFetcher
49
+ /** @deprecated Use `forwardedProps` instead. */
50
+ body?: Record<string, any>
51
+ forwardedProps?: Record<string, any>
52
+ tools?: TTools
53
+ onResponse?: (response?: Response) => void | Promise<void>
54
+ onChunk?: (chunk: StreamChunk) => void
55
+ onFinish?: (message: UIMessage) => void
56
+ onError?: (error: Error) => void
57
+ onSubscriptionChange?: (isSubscribed: boolean) => void
58
+ onConnectionStatusChange?: (status: ConnectionStatus) => void
59
+ onSessionGeneratingChange?: (isGenerating: boolean) => void
60
+ onCustomEvent?: (
61
+ eventType: string,
62
+ data: unknown,
63
+ context: { toolCallId?: string },
64
+ ) => void
65
+ }
66
+
67
+ type ClientToolResult = {
68
+ toolCallId: string
69
+ tool: string
70
+ output: any
71
+ state?: 'output-available' | 'output-error'
72
+ errorText?: string
73
+ }
74
+
42
75
  function resolveTransport(transport: {
43
76
  connection?: ConnectionAdapter
44
77
  fetcher?: ChatFetcher
@@ -54,7 +87,10 @@ function resolveTransport(transport: {
54
87
  throw new Error('ChatClient: either `connection` or `fetcher` is required.')
55
88
  }
56
89
 
57
- export class ChatClient {
90
+ export class ChatClient<
91
+ TTools extends ReadonlyArray<AnyClientTool> = any,
92
+ TContext = unknown,
93
+ > {
58
94
  private readonly processor: StreamProcessor
59
95
  private connection: SubscribeConnectionAdapter
60
96
  private readonly uniqueId: string
@@ -65,6 +101,7 @@ export class ChatClient {
65
101
  // merged on every send, with `forwardedProps` winning on key collision.
66
102
  private bodyOption: Record<string, any> = {}
67
103
  private forwardedPropsOption: Record<string, any> = {}
104
+ private context: TContext | undefined = undefined
68
105
  private pendingMessageBody: Record<string, any> | undefined = undefined
69
106
  private isLoading = false
70
107
  private isSubscribed = false
@@ -86,6 +123,8 @@ export class ChatClient {
86
123
  private readonly postStreamActions: Array<() => Promise<void>> = []
87
124
  // Track pending client tool executions to await them before stream finalization
88
125
  private readonly pendingToolExecutions: Map<string, Promise<void>> = new Map()
126
+ private activeClientTools: Map<string, AnyClientTool> | null = null
127
+ private activeContext: TContext | undefined = undefined
89
128
  // Flag to deduplicate continuation checks during action draining
90
129
  private continuationPending = false
91
130
  private subscriptionAbortController: AbortController | null = null
@@ -121,7 +160,7 @@ export class ChatClient {
121
160
  }
122
161
  }
123
162
 
124
- constructor(options: ChatClientOptions) {
163
+ constructor(options: ChatClientOptions<TTools, TContext>) {
125
164
  this.uniqueId = options.id || this.generateUniqueId('chat')
126
165
  this.threadId = options.threadId || this.generateUniqueId('thread')
127
166
  // Both `body` (deprecated) and `forwardedProps` populate the AG-UI
@@ -131,6 +170,7 @@ export class ChatClient {
131
170
  // winning on key collision.
132
171
  this.bodyOption = options.body || {}
133
172
  this.forwardedPropsOption = options.forwardedProps || {}
173
+ this.context = options.context
134
174
  this.connection = normalizeConnectionAdapter(resolveTransport(options))
135
175
 
136
176
  // Build client tools map
@@ -288,7 +328,9 @@ export class ChatClient {
288
328
  input: any
289
329
  }) => {
290
330
  // Handle client-side tool execution automatically
291
- const clientTool = this.clientToolsRef.current.get(args.toolName)
331
+ const clientTools =
332
+ this.activeClientTools ?? this.clientToolsRef.current
333
+ const clientTool = clientTools.get(args.toolName)
292
334
  const executeFunc = clientTool?.execute
293
335
  if (executeFunc) {
294
336
  // Capture the run context at execution-start so a tool whose
@@ -300,18 +342,27 @@ export class ChatClient {
300
342
  // Create and track the execution promise
301
343
  const executionPromise = (async () => {
302
344
  try {
303
- const output = await executeFunc(args.input)
304
- await this.addToolResultInternal(
345
+ const context =
346
+ this.activeClientTools === null
347
+ ? this.context
348
+ : this.activeContext
349
+ const output = await executeFunc(args.input, {
350
+ toolCallId: args.toolCallId,
351
+ context: context as TContext,
352
+ emitCustomEvent: () => {},
353
+ })
354
+ await this.addToolResultForClientTool(
305
355
  {
306
356
  toolCallId: args.toolCallId,
307
357
  tool: args.toolName,
308
358
  output,
309
359
  state: 'output-available',
310
360
  },
361
+ clientTool,
311
362
  runEventContext,
312
363
  )
313
364
  } catch (error: any) {
314
- await this.addToolResultInternal(
365
+ await this.addToolResultForClientTool(
315
366
  {
316
367
  toolCallId: args.toolCallId,
317
368
  tool: args.toolName,
@@ -319,6 +370,7 @@ export class ChatClient {
319
370
  state: 'output-error',
320
371
  errorText: error.message,
321
372
  },
373
+ clientTool,
322
374
  runEventContext,
323
375
  )
324
376
  } finally {
@@ -761,6 +813,8 @@ export class ChatClient {
761
813
  try {
762
814
  // Get UIMessages with parts (preserves approval state and client tool results)
763
815
  const messages = this.processor.getMessages()
816
+ const clientTools = new Map(this.clientToolsRef.current)
817
+ const runtimeContext = this.context
764
818
 
765
819
  // Call onResponse callback
766
820
  await this.callbacksRef.current.onResponse()
@@ -796,6 +850,8 @@ export class ChatClient {
796
850
  this.currentStreamId = this.generateUniqueId('stream')
797
851
  this.devtoolsBridge.setCurrentStreamId(this.currentStreamId)
798
852
  this.currentMessageId = null
853
+ this.activeClientTools = clientTools
854
+ this.activeContext = runtimeContext
799
855
 
800
856
  // Reset processor stream state for new response — prevents stale
801
857
  // messageStates entries (from a previous stream) from blocking
@@ -819,15 +875,13 @@ export class ChatClient {
819
875
  const runContext = {
820
876
  threadId: this.threadId,
821
877
  runId: `run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
822
- clientTools: Array.from(this.clientToolsRef.current.values()).map(
823
- (t) => ({
824
- name: t.name,
825
- description: t.description,
826
- parameters: t.inputSchema
827
- ? convertSchemaToJsonSchema(t.inputSchema)
828
- : { type: 'object' },
829
- }),
830
- ),
878
+ clientTools: Array.from(clientTools.values()).map((t) => ({
879
+ name: t.name,
880
+ description: t.description,
881
+ parameters: t.inputSchema
882
+ ? convertSchemaToJsonSchema(t.inputSchema)
883
+ : { type: 'object' },
884
+ })),
831
885
  forwardedProps: { ...mergedBody },
832
886
  }
833
887
  this.devtoolsBridge.beginRun(runContext.runId, this.threadId)
@@ -913,6 +967,8 @@ export class ChatClient {
913
967
  this.currentStreamId = null
914
968
  this.devtoolsBridge.setCurrentStreamId(null)
915
969
  this.currentMessageId = null
970
+ this.activeClientTools = null
971
+ this.activeContext = undefined
916
972
  this.abortController = null
917
973
  this.setIsLoading(false)
918
974
  this.pendingMessageBody = undefined // Ensure it's cleared even on error
@@ -1044,26 +1100,32 @@ export class ChatClient {
1044
1100
  /**
1045
1101
  * Add the result of a client-side tool execution
1046
1102
  */
1047
- async addToolResult(result: {
1048
- toolCallId: string
1049
- tool: string
1050
- output: any
1051
- state?: 'output-available' | 'output-error'
1052
- errorText?: string
1053
- }): Promise<void> {
1054
- await this.addToolResultInternal(result)
1103
+ async addToolResult(result: ClientToolResult): Promise<void> {
1104
+ const clientTool = this.clientToolsRef.current.get(result.tool)
1105
+ await this.addToolResultForClientTool(result, clientTool)
1055
1106
  }
1056
1107
 
1057
- private async addToolResultInternal(
1058
- result: {
1059
- toolCallId: string
1060
- tool: string
1061
- output: any
1062
- state?: 'output-available' | 'output-error'
1063
- errorText?: string
1064
- },
1108
+ private async addToolResultForClientTool(
1109
+ result: ClientToolResult,
1110
+ clientTool: AnyClientTool | undefined,
1065
1111
  context?: ChatClientRunEventContext,
1066
1112
  ): Promise<void> {
1113
+ if (clientTool && result.state !== 'output-error') {
1114
+ try {
1115
+ result = {
1116
+ ...result,
1117
+ output: this.validateClientToolOutput(clientTool, result.output),
1118
+ }
1119
+ } catch (error: any) {
1120
+ result = {
1121
+ ...result,
1122
+ output: null,
1123
+ state: 'output-error',
1124
+ errorText: error.message,
1125
+ }
1126
+ }
1127
+ }
1128
+
1067
1129
  this.events.toolResultAdded(
1068
1130
  result.toolCallId,
1069
1131
  result.tool,
@@ -1088,6 +1150,17 @@ export class ChatClient {
1088
1150
  await this.checkForContinuation()
1089
1151
  }
1090
1152
 
1153
+ private validateClientToolOutput(
1154
+ clientTool: AnyClientTool,
1155
+ output: any,
1156
+ ): any {
1157
+ if (clientTool.outputSchema && isStandardSchema(clientTool.outputSchema)) {
1158
+ return parseWithStandardSchema(clientTool.outputSchema, output)
1159
+ }
1160
+
1161
+ return output
1162
+ }
1163
+
1091
1164
  /**
1092
1165
  * Respond to a tool approval request
1093
1166
  */
@@ -1206,8 +1279,8 @@ export class ChatClient {
1206
1279
  /**
1207
1280
  * Get current messages
1208
1281
  */
1209
- getMessages(): Array<UIMessage> {
1210
- return this.processor.getMessages()
1282
+ getMessages(): Array<UIMessage<TTools>> {
1283
+ return this.processor.getMessages() as Array<UIMessage<TTools>>
1211
1284
  }
1212
1285
 
1213
1286
  /**
@@ -1258,7 +1331,7 @@ export class ChatClient {
1258
1331
  /**
1259
1332
  * Manually set messages
1260
1333
  */
1261
- setMessagesManually(messages: Array<UIMessage>): void {
1334
+ setMessagesManually(messages: Array<UIMessage<TTools>>): void {
1262
1335
  this.processor.setMessages(messages)
1263
1336
  this.devtoolsBridge.emitSnapshot()
1264
1337
  }
@@ -1266,26 +1339,16 @@ export class ChatClient {
1266
1339
  /**
1267
1340
  * Update options refs (for use in React hooks to avoid recreating client)
1268
1341
  */
1269
- updateOptions(options: {
1270
- connection?: ConnectionAdapter
1271
- fetcher?: ChatFetcher
1272
- /** @deprecated Use `forwardedProps` instead. */
1273
- body?: Record<string, any>
1274
- forwardedProps?: Record<string, any>
1275
- tools?: ReadonlyArray<AnyClientTool>
1276
- onResponse?: (response?: Response) => void | Promise<void>
1277
- onChunk?: (chunk: StreamChunk) => void
1278
- onFinish?: (message: UIMessage) => void
1279
- onError?: (error: Error) => void
1280
- onSubscriptionChange?: (isSubscribed: boolean) => void
1281
- onConnectionStatusChange?: (status: ConnectionStatus) => void
1282
- onSessionGeneratingChange?: (isGenerating: boolean) => void
1283
- onCustomEvent?: (
1284
- eventType: string,
1285
- data: unknown,
1286
- context: { toolCallId?: string },
1287
- ) => void
1288
- }): void {
1342
+ updateOptions(options: ChatClientUpdateOptionsWithoutContext<TTools>): void
1343
+ updateOptions(
1344
+ options: ChatClientUpdateOptionsWithoutContext<TTools> &
1345
+ Pick<ChatClientOptions<TTools, TContext>, 'context'>,
1346
+ ): void
1347
+ updateOptions(
1348
+ options: ChatClientUpdateOptionsWithoutContext<TTools> & {
1349
+ context?: TContext | undefined
1350
+ },
1351
+ ): void {
1289
1352
  if (options.connection !== undefined || options.fetcher !== undefined) {
1290
1353
  const wasSubscribed = this.isSubscribed
1291
1354
 
@@ -1312,15 +1375,19 @@ export class ChatClient {
1312
1375
  this.subscribe()
1313
1376
  }
1314
1377
  }
1315
- // Replace each slot independently so callers can update one without
1316
- // wiping the other. (Passing `undefined` for either field is a "leave
1317
- // unchanged" signal - to clear a slot, pass an empty object `{}`.)
1378
+ // Replace each wire-payload slot independently so callers can update one
1379
+ // without wiping the other. Passing `undefined` for `body` or
1380
+ // `forwardedProps` leaves that slot unchanged; context is cleared when the
1381
+ // key is present with an `undefined` value.
1318
1382
  if (options.body !== undefined) {
1319
1383
  this.bodyOption = options.body
1320
1384
  }
1321
1385
  if (options.forwardedProps !== undefined) {
1322
1386
  this.forwardedPropsOption = options.forwardedProps
1323
1387
  }
1388
+ if ('context' in options) {
1389
+ this.context = options.context
1390
+ }
1324
1391
  if (options.tools !== undefined) {
1325
1392
  this.clientToolsRef.current = new Map()
1326
1393
  for (const tool of options.tools) {
package/src/devtools.ts CHANGED
@@ -1219,7 +1219,9 @@ function hydrateToolCallOutputs(
1219
1219
  candidate.output === undefined,
1220
1220
  )
1221
1221
  if (toolCall) {
1222
- toolCall.output = parseFixtureResultContent(part.content)
1222
+ toolCall.output = Array.isArray(part.content)
1223
+ ? part.content
1224
+ : parseFixtureResultContent(part.content)
1223
1225
  }
1224
1226
  }
1225
1227
 
package/src/index.ts CHANGED
@@ -13,8 +13,10 @@ export type {
13
13
  StructuredOutputPart,
14
14
  // Client configuration types
15
15
  ChatClientOptions,
16
+ ClientContextOptionFromTools,
16
17
  ChatRequestBody,
17
18
  InferChatMessages,
19
+ InferredClientContext,
18
20
  ChatClientState,
19
21
  ConnectionStatus,
20
22
  ChatFetcher,
package/src/types.ts CHANGED
@@ -220,7 +220,7 @@ export type ToolCallPart<TTools extends ReadonlyArray<AnyClientTool> = any> =
220
220
  export interface ToolResultPart {
221
221
  type: 'tool-result'
222
222
  toolCallId: string
223
- content: string
223
+ content: string | Array<ContentPart>
224
224
  state: ToolResultState
225
225
  error?: string // Error message if state is "error"
226
226
  }
@@ -266,14 +266,91 @@ export interface UIMessage<
266
266
  createdAt?: Date
267
267
  }
268
268
 
269
+ type IsUnknown<T> = unknown extends T
270
+ ? [T] extends [unknown]
271
+ ? true
272
+ : false
273
+ : false
274
+
275
+ type KnownContext<T> = IsUnknown<T> extends true ? never : T
276
+
277
+ type MergeContext<TLeft, TRight> = [TLeft] extends [never]
278
+ ? TRight
279
+ : [TRight] extends [never]
280
+ ? TLeft
281
+ : TLeft & TRight
282
+
283
+ type UnionToIntersection<T> = [T] extends [never]
284
+ ? never
285
+ : (T extends unknown ? (value: T) => void : never) extends (
286
+ value: infer TIntersection,
287
+ ) => void
288
+ ? TIntersection
289
+ : never
290
+
291
+ type DefinedContext<T> = Exclude<T, undefined>
292
+
293
+ type ContextFromExecute<T> = T extends (...args: any) => any
294
+ ? NonNullable<Parameters<T>[1]> extends { context: infer TContext }
295
+ ? KnownContext<TContext>
296
+ : never
297
+ : never
298
+
299
+ type ContextFromClientTool<T> = T extends AnyClientTool
300
+ ? T extends { execute?: infer TExecute }
301
+ ? ContextFromExecute<TExecute>
302
+ : never
303
+ : never
304
+
305
+ type RequiredContextFromClientToolUnion<T> = T extends unknown
306
+ ? undefined extends ContextFromClientTool<T>
307
+ ? never
308
+ : ContextFromClientTool<T>
309
+ : never
310
+
311
+ type ContextFromClientToolUnion<T> = [
312
+ UnionToIntersection<DefinedContext<ContextFromClientTool<T>>>,
313
+ ] extends [never]
314
+ ? never
315
+ : [RequiredContextFromClientToolUnion<T>] extends [never]
316
+ ? UnionToIntersection<DefinedContext<ContextFromClientTool<T>>> | undefined
317
+ : UnionToIntersection<DefinedContext<ContextFromClientTool<T>>>
318
+
319
+ type ContextFromClientTools<TTools> =
320
+ IsUnknown<TTools> extends true
321
+ ? never
322
+ : TTools extends readonly [infer THead, ...infer TTail]
323
+ ? MergeContext<
324
+ ContextFromClientTool<THead>,
325
+ ContextFromClientTools<TTail>
326
+ >
327
+ : TTools extends ReadonlyArray<infer TItem>
328
+ ? ContextFromClientToolUnion<TItem>
329
+ : never
330
+
331
+ export type InferredClientContext<TTools> = [
332
+ ContextFromClientTools<TTools>,
333
+ ] extends [never]
334
+ ? unknown
335
+ : ContextFromClientTools<TTools>
336
+
337
+ export type ClientContextOptionFromTools<TTools, TContext> = [
338
+ ContextFromClientTools<TTools>,
339
+ ] extends [never]
340
+ ? { context?: TContext }
341
+ : undefined extends ContextFromClientTools<TTools>
342
+ ? { context?: TContext & ContextFromClientTools<TTools> }
343
+ : { context: TContext & ContextFromClientTools<TTools> }
344
+
269
345
  /**
270
- * Options for `ChatClient`. Exactly one of `connection` or `fetcher` must be
271
- * provided — the type-level XOR is enforced via `ChatTransport`.
346
+ * Base options for `ChatClient`, excluding the transport (`connection` or
347
+ * `fetcher`) which is supplied separately via `ChatTransport` so the XOR
348
+ * is preserved when composing the final `ChatClientOptions` type.
272
349
  */
273
- export type ChatClientOptions<
350
+ export interface ChatClientBaseOptions<
274
351
  TTools extends ReadonlyArray<AnyClientTool> = any,
275
352
  TContext = unknown,
276
- > = {
353
+ > {
277
354
  /**
278
355
  * Initial messages to populate the chat
279
356
  */
@@ -311,6 +388,14 @@ export type ChatClientOptions<
311
388
  */
312
389
  body?: Record<string, any>
313
390
 
391
+ /**
392
+ * Client-local runtime context passed to client tool implementations.
393
+ *
394
+ * This value is not serialized to the server. Use `forwardedProps` for
395
+ * explicit client-to-server handoff of serializable values.
396
+ */
397
+ context?: TContext
398
+
314
399
  /**
315
400
  * Callback when a response is received
316
401
  */
@@ -390,11 +475,6 @@ export type ChatClientOptions<
390
475
  */
391
476
  tools?: TTools
392
477
 
393
- /**
394
- * Client-local context passed to client-side tool execution.
395
- */
396
- context?: TContext
397
-
398
478
  /**
399
479
  * Devtools hook metadata for this client instance.
400
480
  */
@@ -420,7 +500,18 @@ export type ChatClientOptions<
420
500
  */
421
501
  chunkStrategy?: ChunkStrategy
422
502
  }
423
- } & ChatTransport
503
+ }
504
+
505
+ /**
506
+ * Options for `ChatClient`. Exactly one of `connection` or `fetcher` must be
507
+ * provided — the type-level XOR is enforced via `ChatTransport`.
508
+ */
509
+ export type ChatClientOptions<
510
+ TTools extends ReadonlyArray<AnyClientTool> = any,
511
+ TContext = InferredClientContext<TTools>,
512
+ > = DistributedOmit<ChatClientBaseOptions<TTools, TContext>, 'context'> &
513
+ ClientContextOptionFromTools<TTools, TContext> &
514
+ ChatTransport
424
515
 
425
516
  export interface ChatRequestBody {
426
517
  messages: Array<ModelMessage>
@@ -466,7 +557,7 @@ export function clientTools<const T extends Array<AnyClientTool>>(
466
557
  */
467
558
  export function createChatClientOptions<
468
559
  const TTools extends ReadonlyArray<AnyClientTool>,
469
- TContext = unknown,
560
+ TContext = InferredClientContext<TTools>,
470
561
  >(
471
562
  options: ChatClientOptions<TTools, TContext>,
472
563
  ): ChatClientOptions<TTools, TContext> {