@tanstack/ai 0.17.0 → 0.19.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.
Files changed (43) hide show
  1. package/README.md +0 -4
  2. package/dist/esm/activities/chat/index.d.ts +12 -3
  3. package/dist/esm/activities/chat/index.js +75 -7
  4. package/dist/esm/activities/chat/index.js.map +1 -1
  5. package/dist/esm/activities/chat/messages.js +41 -2
  6. package/dist/esm/activities/chat/messages.js.map +1 -1
  7. package/dist/esm/activities/chat/middleware/compose.js +1 -1
  8. package/dist/esm/activities/chat/middleware/compose.js.map +1 -1
  9. package/dist/esm/activities/chat/middleware/types.d.ts +12 -1
  10. package/dist/esm/activities/chat/stream/message-updaters.d.ts +35 -0
  11. package/dist/esm/activities/chat/stream/message-updaters.js +95 -0
  12. package/dist/esm/activities/chat/stream/message-updaters.js.map +1 -1
  13. package/dist/esm/activities/chat/stream/processor.d.ts +1 -0
  14. package/dist/esm/activities/chat/stream/processor.js +90 -2
  15. package/dist/esm/activities/chat/stream/processor.js.map +1 -1
  16. package/dist/esm/activities/chat/tools/schema-converter.js +5 -0
  17. package/dist/esm/activities/chat/tools/schema-converter.js.map +1 -1
  18. package/dist/esm/adapter-internals.d.ts +1 -0
  19. package/dist/esm/index.d.ts +3 -0
  20. package/dist/esm/index.js +8 -2
  21. package/dist/esm/index.js.map +1 -1
  22. package/dist/esm/types.d.ts +86 -16
  23. package/dist/esm/utilities/ag-ui-wire.d.ts +44 -0
  24. package/dist/esm/utilities/ag-ui-wire.js +104 -0
  25. package/dist/esm/utilities/ag-ui-wire.js.map +1 -0
  26. package/dist/esm/utilities/chat-params.d.ts +80 -0
  27. package/dist/esm/utilities/chat-params.js +96 -0
  28. package/dist/esm/utilities/chat-params.js.map +1 -0
  29. package/package.json +3 -3
  30. package/skills/ai-core/ag-ui-protocol/SKILL.md +46 -3
  31. package/skills/ai-core/structured-outputs/SKILL.md +240 -47
  32. package/src/activities/chat/index.ts +144 -10
  33. package/src/activities/chat/messages.ts +70 -4
  34. package/src/activities/chat/middleware/compose.ts +1 -1
  35. package/src/activities/chat/middleware/types.ts +12 -1
  36. package/src/activities/chat/stream/message-updaters.ts +171 -0
  37. package/src/activities/chat/stream/processor.ts +137 -2
  38. package/src/activities/chat/tools/schema-converter.ts +14 -0
  39. package/src/adapter-internals.ts +1 -0
  40. package/src/index.ts +11 -0
  41. package/src/types.ts +104 -15
  42. package/src/utilities/ag-ui-wire.ts +201 -0
  43. package/src/utilities/chat-params.ts +199 -0
@@ -20,6 +20,9 @@
20
20
  import { generateMessageId, uiMessageToModelMessages } from '../messages.js'
21
21
  import { defaultJSONParser } from './json-parser'
22
22
  import {
23
+ appendStructuredOutputDelta,
24
+ completeStructuredOutputPart,
25
+ errorStructuredOutputPart,
23
26
  updateTextPart,
24
27
  updateThinkingPart,
25
28
  updateToolCallApproval,
@@ -145,6 +148,8 @@ export class StreamProcessor {
145
148
  private pendingManualMessageId: string | null = null
146
149
  private pendingThinkingStepId: string | null = null
147
150
 
151
+ private structuredMessageIds: Set<string> = new Set()
152
+
148
153
  // Run tracking (for concurrent run safety)
149
154
  private activeRuns = new Set<string>()
150
155
 
@@ -383,6 +388,27 @@ export class StreamProcessor {
383
388
  * Remove messages after a certain index (for reload/retry)
384
389
  */
385
390
  removeMessagesAfter(index: number): void {
391
+ const keptIds = new Set(this.messages.slice(0, index + 1).map((m) => m.id))
392
+ // Drop routing state for messages that no longer exist; otherwise a
393
+ // resumed stream (`reload()` or a server that reuses messageIds across
394
+ // runs) could land deltas / tool args on stale map entries and corrupt
395
+ // the new assistant message's parts. Mirror the four routing maps that
396
+ // key on messageId: structuredMessageIds (custom-event routing),
397
+ // messageStates (per-message stream state), toolCallToMessage (tool
398
+ // args → message), and activeMessageIds (finalize / completeAllToolCalls
399
+ // iteration targets — must not include phantoms).
400
+ for (const id of this.structuredMessageIds) {
401
+ if (!keptIds.has(id)) this.structuredMessageIds.delete(id)
402
+ }
403
+ for (const id of this.messageStates.keys()) {
404
+ if (!keptIds.has(id)) this.messageStates.delete(id)
405
+ }
406
+ for (const [toolCallId, msgId] of this.toolCallToMessage) {
407
+ if (!keptIds.has(msgId)) this.toolCallToMessage.delete(toolCallId)
408
+ }
409
+ for (const id of this.activeMessageIds) {
410
+ if (!keptIds.has(id)) this.activeMessageIds.delete(id)
411
+ }
386
412
  this.messages = this.messages.slice(0, index + 1)
387
413
  this.emitMessagesChange()
388
414
  }
@@ -395,6 +421,7 @@ export class StreamProcessor {
395
421
  this.messageStates.clear()
396
422
  this.activeMessageIds.clear()
397
423
  this.toolCallToMessage.clear()
424
+ this.structuredMessageIds.clear()
398
425
  this.pendingManualMessageId = null
399
426
  this.emitMessagesChange()
400
427
  }
@@ -841,6 +868,41 @@ export class StreamProcessor {
841
868
  // Content arriving means all current tool calls for this message are complete
842
869
  this.completeAllToolCallsForMessage(messageId)
843
870
 
871
+ if (this.structuredMessageIds.has(messageId)) {
872
+ // `chunk.delta` is incremental; `chunk.content` is sometimes cumulative
873
+ // (mirrors what the plain-text branch handles below). Reconcile against
874
+ // the existing raw buffer so adapters that emit cumulative content
875
+ // don't duplicate the JSON.
876
+ let delta = chunk.delta || ''
877
+ if (delta === '' && chunk.content !== undefined && chunk.content !== '') {
878
+ const existingRaw = (
879
+ this.messages
880
+ .find((m) => m.id === messageId)
881
+ ?.parts.find(
882
+ (p): p is Extract<MessagePart, { type: 'structured-output' }> =>
883
+ p.type === 'structured-output',
884
+ ) ?? { raw: '' }
885
+ ).raw
886
+ if (chunk.content.startsWith(existingRaw)) {
887
+ delta = chunk.content.slice(existingRaw.length)
888
+ } else if (existingRaw.startsWith(chunk.content)) {
889
+ delta = ''
890
+ } else {
891
+ delta = chunk.content
892
+ }
893
+ }
894
+ if (delta !== '') {
895
+ this.messages = appendStructuredOutputDelta(
896
+ this.messages,
897
+ messageId,
898
+ delta,
899
+ )
900
+ state.totalTextContent += delta
901
+ this.emitMessagesChange()
902
+ }
903
+ return
904
+ }
905
+
844
906
  const previousSegment = state.currentSegmentText
845
907
 
846
908
  // Detect if this is a NEW text segment (after tool calls) vs continuation
@@ -1192,10 +1254,29 @@ export class StreamProcessor {
1192
1254
  } else {
1193
1255
  this.activeRuns.clear()
1194
1256
  }
1195
- this.ensureAssistantMessage()
1196
- // Prefer spec field `message`; fall back to deprecated `error.message`
1257
+ const { messageId } = this.ensureAssistantMessage()
1258
+ // Prefer spec field `message`; fall back to deprecated `error.message`.
1259
+ // If neither is set, the chunk still carries debug context (provider
1260
+ // error codes, request ids, etc.) — log it so the failure isn't silent.
1197
1261
  const errorMessage =
1198
1262
  chunk.message || chunk.error?.message || 'An error occurred'
1263
+ if (!chunk.message && !chunk.error?.message) {
1264
+ console.error(
1265
+ '[StreamProcessor] RUN_ERROR with no message; original chunk:',
1266
+ chunk,
1267
+ )
1268
+ }
1269
+
1270
+ if (this.structuredMessageIds.has(messageId)) {
1271
+ this.messages = errorStructuredOutputPart(
1272
+ this.messages,
1273
+ messageId,
1274
+ errorMessage,
1275
+ )
1276
+ this.structuredMessageIds.delete(messageId)
1277
+ this.emitMessagesChange()
1278
+ }
1279
+
1199
1280
  this.events.onError?.(new Error(errorMessage))
1200
1281
  }
1201
1282
 
@@ -1375,6 +1456,38 @@ export class StreamProcessor {
1375
1456
  ): void {
1376
1457
  const messageId = this.getActiveAssistantMessageId()
1377
1458
 
1459
+ if (chunk.name === 'structured-output.start' && chunk.value) {
1460
+ const v = chunk.value as { messageId?: string }
1461
+ const targetId = v.messageId ?? messageId
1462
+ if (targetId) {
1463
+ this.ensureAssistantMessage(targetId)
1464
+ this.structuredMessageIds.add(targetId)
1465
+ }
1466
+ return
1467
+ }
1468
+
1469
+ if (chunk.name === 'structured-output.complete' && chunk.value) {
1470
+ const v = chunk.value as {
1471
+ object: unknown
1472
+ raw?: string
1473
+ reasoning?: string
1474
+ messageId?: string
1475
+ }
1476
+ const targetId = v.messageId ?? messageId
1477
+ if (targetId) {
1478
+ this.messages = completeStructuredOutputPart(
1479
+ this.messages,
1480
+ targetId,
1481
+ v.object,
1482
+ v.raw ?? '',
1483
+ v.reasoning,
1484
+ )
1485
+ this.structuredMessageIds.delete(targetId)
1486
+ this.emitMessagesChange()
1487
+ }
1488
+ // Fall through so user `onCustomEvent` callbacks still observe the event.
1489
+ }
1490
+
1378
1491
  // Handle client tool input availability - trigger client-side execution
1379
1492
  if (chunk.name === 'tool-input-available' && chunk.value) {
1380
1493
  const { toolCallId, toolName, input } = chunk.value as {
@@ -1591,6 +1704,27 @@ export class StreamProcessor {
1591
1704
  }
1592
1705
  }
1593
1706
 
1707
+ // The stream closed but one or more structured-output runs never sent
1708
+ // their terminal `structured-output.complete`. Snap each lingering
1709
+ // streaming part to error so the UI doesn't appear to stream forever,
1710
+ // and drop the routing entries so a subsequent run on the same
1711
+ // processor instance (long-lived `subscribe()` mode) doesn't reuse
1712
+ // the stale ids.
1713
+ //
1714
+ // The iteration is unconditional w.r.t. `this.hasError` — RUN_ERROR
1715
+ // already removed its target messageId from `structuredMessageIds`
1716
+ // before reaching finalize, so anything still in the set is by
1717
+ // definition a non-errored, never-completed run (the multi-run case:
1718
+ // run-A errors, run-B is still streaming when finalize fires).
1719
+ for (const messageId of this.structuredMessageIds) {
1720
+ this.messages = errorStructuredOutputPart(
1721
+ this.messages,
1722
+ messageId,
1723
+ 'Stream ended without structured-output.complete',
1724
+ )
1725
+ }
1726
+ this.structuredMessageIds.clear()
1727
+
1594
1728
  this.activeMessageIds.clear()
1595
1729
 
1596
1730
  // Remove whitespace-only assistant messages (handles models like Gemini
@@ -1719,6 +1853,7 @@ export class StreamProcessor {
1719
1853
  this.activeMessageIds.clear()
1720
1854
  this.activeRuns.clear()
1721
1855
  this.toolCallToMessage.clear()
1856
+ this.structuredMessageIds.clear()
1722
1857
  this.pendingManualMessageId = null
1723
1858
  this.pendingThinkingStepId = null
1724
1859
  this.finishReason = null
@@ -254,6 +254,20 @@ export function convertSchemaToJsonSchema(
254
254
  return result as JSONSchema
255
255
  }
256
256
 
257
+ // Detect Standard Schema validators (Zod, ArkType, Valibot, …) that don't
258
+ // expose a `~standard.jsonSchema` converter. These would otherwise fall
259
+ // through to the JSONSchema pass-through below and ship `{ '~standard': … }`
260
+ // straight to the LLM provider, producing an opaque downstream error. Fail
261
+ // fast with actionable guidance instead.
262
+ if (isStandardSchema(schema)) {
263
+ throw new Error(
264
+ 'Schema is a Standard Schema validator but does not expose a JSON Schema ' +
265
+ 'converter on `~standard.jsonSchema`. Use Zod v4.2+, ArkType v2.1.28+, ' +
266
+ 'or wrap a Valibot schema with `toStandardJsonSchema()` from ' +
267
+ '`@valibot/to-json-schema` before passing it as `outputSchema`.',
268
+ )
269
+ }
270
+
257
271
  // If it's not a Standard JSON Schema, assume it's already a JSONSchema and pass through
258
272
  // Still apply structured output transformation if requested
259
273
 
@@ -4,5 +4,6 @@
4
4
 
5
5
  export type { ResolvedCategories } from './logger/internal-logger'
6
6
  export { InternalLogger } from './logger/internal-logger'
7
+ export type { Logger } from './logger/types'
7
8
  export { resolveDebugOption } from './logger/resolve'
8
9
  export { toRunErrorPayload } from './activities/error-payload'
package/src/index.ts CHANGED
@@ -168,6 +168,17 @@ export type {
168
168
  JSONParser,
169
169
  } from './activities/chat/stream/index'
170
170
 
171
+ // Chat utilities
172
+ export {
173
+ chatParamsFromRequest,
174
+ chatParamsFromRequestBody,
175
+ mergeAgentTools,
176
+ } from './utilities/chat-params'
177
+
178
+ // AG-UI wire serialization (used internally by @tanstack/ai-client)
179
+ export { uiMessagesToWire } from './utilities/ag-ui-wire'
180
+ export type { WireMessage } from './utilities/ag-ui-wire'
181
+
171
182
  // Adapter extension utilities
172
183
  export { createModel, extendAdapter } from './extend-adapter'
173
184
  export type { ExtendedModelDef } from './extend-adapter'
package/src/types.ts CHANGED
@@ -1,4 +1,7 @@
1
- import type { StandardJSONSchemaV1 } from '@standard-schema/spec'
1
+ import type {
2
+ StandardJSONSchemaV1,
3
+ StandardSchemaV1,
4
+ } from '@standard-schema/spec'
2
5
  import type { InternalLogger } from './logger/internal-logger'
3
6
  import type {
4
7
  BaseEvent as AGUIBaseEvent,
@@ -91,25 +94,42 @@ export interface JSONSchema {
91
94
  }
92
95
 
93
96
  /**
94
- * Union type for schema input - can be any Standard JSON Schema compliant schema or a plain JSONSchema object.
97
+ * Union type for schema input - can be any Standard Schema compliant validator,
98
+ * any Standard JSON Schema compliant schema, or a plain JSONSchema object.
95
99
  *
96
- * Standard JSON Schema compliant libraries include:
100
+ * Standard JSON Schema compliant libraries (carry the JSON-schema converter):
97
101
  * - Zod v4.2+ (natively supports StandardJSONSchemaV1)
98
102
  * - ArkType v2.1.28+ (natively supports StandardJSONSchemaV1)
99
103
  * - Valibot v1.2+ (via `toStandardJsonSchema()` from `@valibot/to-json-schema`)
100
104
  *
105
+ * StandardSchemaV1 covers libraries whose published types only expose the
106
+ * validator surface — Zod's core `$ZodType['~standard']` is currently typed
107
+ * as `StandardSchemaV1.Props` even though the runtime attaches the
108
+ * `jsonSchema` converter, so this branch is what makes `InferSchemaType`
109
+ * recover the inferred type for callers using `z.ZodType<T>`.
110
+ *
101
111
  * @see https://standardschema.dev/json-schema
102
112
  */
103
113
 
104
- export type SchemaInput = StandardJSONSchemaV1<any, any> | JSONSchema
114
+ export type SchemaInput =
115
+ | StandardJSONSchemaV1<any, any>
116
+ | StandardSchemaV1<any, any>
117
+ | JSONSchema
105
118
 
106
119
  /**
107
120
  * Infer the TypeScript type from a schema.
108
121
  * For Standard JSON Schema compliant schemas, extracts the input type.
109
- * For plain JSONSchema, returns `any` since we can't infer types from JSON Schema at compile time.
122
+ * For Standard Schema validators (e.g. Zod's `~standard` surface), extracts
123
+ * the input type from the `StandardSchemaV1` shape.
124
+ * For plain JSONSchema, returns `unknown` since we can't infer types from
125
+ * JSON Schema at compile time.
110
126
  */
111
127
  export type InferSchemaType<T> =
112
- T extends StandardJSONSchemaV1<infer TInput, unknown> ? TInput : unknown
128
+ T extends StandardJSONSchemaV1<infer TInput, unknown>
129
+ ? TInput
130
+ : T extends StandardSchemaV1<infer TInput, unknown>
131
+ ? TInput
132
+ : unknown
113
133
 
114
134
  export interface ToolCall<TMetadata = unknown> {
115
135
  id: string
@@ -345,7 +365,43 @@ export interface ThinkingPart {
345
365
  signature?: string
346
366
  }
347
367
 
348
- export type MessagePart =
368
+ /**
369
+ * Recursive `Partial` — every nested field becomes optional. Used as the
370
+ * `partial` type on a streaming structured-output part since the progressive
371
+ * JSON parse hands back objects whose fields are only filled in as bytes
372
+ * arrive. Defaulted in `DeepPartial<unknown>` → `unknown` so untyped parts
373
+ * keep their existing shape.
374
+ */
375
+ export type DeepPartial<T> =
376
+ T extends ReadonlyArray<infer U>
377
+ ? Array<DeepPartial<U>>
378
+ : T extends object
379
+ ? { [K in keyof T]?: DeepPartial<T[K]> }
380
+ : T
381
+
382
+ /**
383
+ * StructuredOutputPart — a typed structured response attached to the assistant
384
+ * message that produced it. Generic over the schema-inferred data type so
385
+ * consumers can thread `useChat({ outputSchema })`'s schema all the way down
386
+ * to `messages[i].parts[j].data`. Defaults to `unknown` so untyped consumers
387
+ * (e.g. internal codepaths that don't know about TSchema) keep working.
388
+ */
389
+ export interface StructuredOutputPart<TData = unknown> {
390
+ type: 'structured-output'
391
+ status: 'streaming' | 'complete' | 'error'
392
+ /** Progressive parse of `raw` via parsePartialJSON — populated while streaming and after complete. */
393
+ partial?: DeepPartial<TData>
394
+ /** Validated final object — only set when `status === 'complete'`. */
395
+ data?: TData
396
+ /** Accumulating JSON buffer. Source of truth for wire round-trip. */
397
+ raw: string
398
+ /** Optional chain-of-thought surfaced by reasoning models alongside the structured output. */
399
+ reasoning?: string
400
+ /** Populated when `status === 'error'`. */
401
+ errorMessage?: string
402
+ }
403
+
404
+ export type MessagePart<TData = unknown> =
349
405
  | TextPart
350
406
  | ImagePart
351
407
  | AudioPart
@@ -354,15 +410,19 @@ export type MessagePart =
354
410
  | ToolCallPart
355
411
  | ToolResultPart
356
412
  | ThinkingPart
413
+ | StructuredOutputPart<TData>
357
414
 
358
415
  /**
359
416
  * UIMessage - Domain-specific message format optimized for building chat UIs
360
- * Contains parts that can be text, tool calls, or tool results
417
+ * Contains parts that can be text, tool calls, or tool results. Generic over
418
+ * the structured-output data type so `useChat({ outputSchema })`'s schema
419
+ * narrows `parts.find(p => p.type === 'structured-output').data` on the
420
+ * consumer side without manual casts.
361
421
  */
362
- export interface UIMessage {
422
+ export interface UIMessage<TData = unknown> {
363
423
  id: string
364
424
  role: 'system' | 'user' | 'assistant'
365
- parts: Array<MessagePart>
425
+ parts: Array<MessagePart<TData>>
366
426
  createdAt?: Date
367
427
  }
368
428
 
@@ -729,8 +789,14 @@ export interface TextOptions<
729
789
  */
730
790
  outputSchema?: SchemaInput
731
791
  /**
732
- * Conversation ID for correlating client and server-side devtools events.
733
- * When provided, server-side events will be linked to the client conversation in devtools.
792
+ * @deprecated Use `threadId` instead. `conversationId` is the legacy
793
+ * pre-AG-UI name for the same concept (a stable per-conversation
794
+ * identifier used to correlate client/server devtools events). When
795
+ * `conversationId` is omitted, the runtime falls back to `threadId`
796
+ * automatically, so most callers can simply pass `threadId` (or rely
797
+ * on `chatParamsFromRequest`, which surfaces it on `params`).
798
+ *
799
+ * Will be removed in a future major release.
734
800
  */
735
801
  conversationId?: string
736
802
  /**
@@ -766,6 +832,11 @@ export interface TextOptions<
766
832
  * If not provided, a unique ID will be generated.
767
833
  */
768
834
  runId?: string
835
+ /**
836
+ * Parent run ID for AG-UI protocol nested run correlation.
837
+ * Surfaced for observability/middleware; not consumed by the LLM call.
838
+ */
839
+ parentRunId?: string
769
840
  }
770
841
 
771
842
  // ============================================================================
@@ -1083,11 +1154,28 @@ export interface StructuredOutputCompleteEvent<T = unknown> extends Omit<
1083
1154
  value: { object: T; raw: string; reasoning?: string }
1084
1155
  }
1085
1156
 
1157
+ /**
1158
+ * Emitted at the start of a streaming structured-output run, before the JSON
1159
+ * deltas. Tells consumers that the upcoming `TEXT_MESSAGE_CONTENT` deltas
1160
+ * belong to a structured response so they can route those bytes into a
1161
+ * `StructuredOutputPart` instead of building a `TextPart`. Carries the
1162
+ * `messageId` the deltas will be tagged with so the routing decision can be
1163
+ * made per-message rather than globally.
1164
+ */
1165
+ export interface StructuredOutputStartEvent extends Omit<
1166
+ CustomEvent,
1167
+ 'name' | 'value'
1168
+ > {
1169
+ name: 'structured-output.start'
1170
+ value: { messageId: string }
1171
+ }
1172
+
1086
1173
  /**
1087
1174
  * Emitted when a server tool requires approval before execution. The agent
1088
1175
  * loop yields this and pauses — `structured-output.complete` will not fire
1089
1176
  * for that run. The shape is fixed by the orchestrator's tool-approval flow
1090
- * (see `buildApprovalChunks` in `activities/chat/index.ts`).
1177
+ * (the agent-loop branch of `runStreamingStructuredOutputImpl` in
1178
+ * `activities/chat/index.ts` forwards CUSTOM events from `TextEngine.run()`).
1091
1179
  */
1092
1180
  export interface ApprovalRequestedEvent extends Omit<
1093
1181
  CustomEvent,
@@ -1105,8 +1193,8 @@ export interface ApprovalRequestedEvent extends Omit<
1105
1193
  /**
1106
1194
  * Emitted when a client tool is invoked. The agent loop yields this and
1107
1195
  * pauses to let the caller run the tool client-side — `structured-output.complete`
1108
- * will not fire for that run. Shape fixed by `buildClientToolChunks` in
1109
- * `activities/chat/index.ts`.
1196
+ * will not fire for that run. Shape fixed by the agent-loop forwarding in
1197
+ * `runStreamingStructuredOutputImpl` in `activities/chat/index.ts`.
1110
1198
  */
1111
1199
  export interface ToolInputAvailableEvent extends Omit<
1112
1200
  CustomEvent,
@@ -1152,6 +1240,7 @@ export interface ToolInputAvailableEvent extends Omit<
1152
1240
  */
1153
1241
  export type StructuredOutputStream<T = unknown> = AsyncIterable<
1154
1242
  | Exclude<StreamChunk, CustomEvent>
1243
+ | StructuredOutputStartEvent
1155
1244
  | StructuredOutputCompleteEvent<T>
1156
1245
  | ApprovalRequestedEvent
1157
1246
  | ToolInputAvailableEvent
@@ -0,0 +1,201 @@
1
+ import type { ContentPart, MessagePart, UIMessage } from '../types'
2
+
3
+ type AGUITextInputContent = { type: 'text'; text: string }
4
+ type AGUIInputContent =
5
+ | AGUITextInputContent
6
+ | (ContentPart & { type: 'image' | 'audio' | 'video' | 'document' })
7
+
8
+ type AGUIToolCallMirror = {
9
+ id: string
10
+ type: 'function'
11
+ function: { name: string; arguments: string }
12
+ }
13
+
14
+ type AGUIToolMessage = {
15
+ role: 'tool'
16
+ id: string
17
+ toolCallId: string
18
+ content: string
19
+ error?: string
20
+ }
21
+
22
+ type AGUIReasoningMessage = {
23
+ role: 'reasoning'
24
+ id: string
25
+ content: string
26
+ }
27
+
28
+ type WireAnchorMessage = UIMessage & {
29
+ content?: string | Array<AGUIInputContent>
30
+ toolCalls?: Array<AGUIToolCallMirror>
31
+ }
32
+
33
+ export type WireMessage =
34
+ | WireAnchorMessage
35
+ | AGUIToolMessage
36
+ | AGUIReasoningMessage
37
+
38
+ /**
39
+ * Serialize TanStack `UIMessage`s into the AG-UI `RunAgentInput.messages`
40
+ * wire shape. Each anchor (system/user/assistant) carries the canonical
41
+ * `parts` array verbatim plus AG-UI mirror fields (`content`, `toolCalls`)
42
+ * so AG-UI Zod parsing succeeds. Tool results and thinking parts on
43
+ * assistant messages are additionally emitted as fan-out
44
+ * `{role:'tool',...}` and `{role:'reasoning',...}` entries for strict
45
+ * AG-UI server consumers.
46
+ */
47
+ export function uiMessagesToWire(
48
+ messages: Array<UIMessage>,
49
+ ): Array<WireMessage> {
50
+ const wire: Array<WireMessage> = []
51
+
52
+ for (const msg of messages) {
53
+ // Defensive: if parts is missing (ModelMessage-shaped input), pass through as-is.
54
+ // UIMessage always has parts; ModelMessage uses content directly.
55
+ const parts: ReadonlyArray<MessagePart> =
56
+ (msg.parts as ReadonlyArray<MessagePart> | undefined) ?? []
57
+
58
+ if (msg.role === 'system') {
59
+ wire.push({
60
+ ...msg,
61
+ content:
62
+ parts.length > 0
63
+ ? collectText(parts)
64
+ : ((msg as unknown as { content?: string }).content ?? ''),
65
+ })
66
+ continue
67
+ }
68
+
69
+ if (msg.role === 'user') {
70
+ wire.push({
71
+ ...msg,
72
+ content:
73
+ parts.length > 0
74
+ ? collectUserContent(parts)
75
+ : ((msg as unknown as { content?: string }).content ?? ''),
76
+ })
77
+ continue
78
+ }
79
+
80
+ // assistant: emit reasoning fan-outs first, then anchor, then tool fan-outs
81
+ for (const part of parts) {
82
+ if (part.type === 'thinking') {
83
+ wire.push({
84
+ role: 'reasoning',
85
+ id: deriveReasoningId(msg.id, part),
86
+ content: part.content,
87
+ })
88
+ }
89
+ }
90
+
91
+ const text = collectText(parts)
92
+ const toolCalls = collectToolCalls(parts)
93
+ wire.push({
94
+ ...msg,
95
+ ...(text !== '' && { content: text }),
96
+ ...(toolCalls && { toolCalls }),
97
+ })
98
+
99
+ for (const part of parts) {
100
+ if (part.type === 'tool-result') {
101
+ wire.push({
102
+ role: 'tool',
103
+ id: deriveToolMessageId(part.toolCallId),
104
+ toolCallId: part.toolCallId,
105
+ content: part.content,
106
+ ...(part.error !== undefined && { error: part.error }),
107
+ })
108
+ }
109
+ }
110
+ }
111
+
112
+ return wire
113
+ }
114
+
115
+ function collectText(parts: ReadonlyArray<MessagePart>): string {
116
+ // The streamed JSON of a completed structured-output part is the source of
117
+ // truth for multi-turn coherence — emitting it back as assistant content
118
+ // lets the LLM see its own prior structured response. Streaming/errored
119
+ // parts are skipped: they'd ship malformed JSON fragments and confuse the
120
+ // model. `completeStructuredOutputPart` tries hard to populate `raw`
121
+ // (caller → existing buffer → `JSON.stringify(data)`), but the stringify
122
+ // fallback can leave it empty when `data` is unserializable (BigInt,
123
+ // circular). The `p.raw !== ''` guard below is what enforces "no malformed
124
+ // round-trip" in that case — without it we'd ship `''` and the model would
125
+ // see an empty assistant turn.
126
+ const out: Array<string> = []
127
+ for (const p of parts) {
128
+ if (p.type === 'text') {
129
+ out.push(p.content)
130
+ } else if (
131
+ p.type === 'structured-output' &&
132
+ p.status === 'complete' &&
133
+ p.raw !== ''
134
+ ) {
135
+ out.push(p.raw)
136
+ }
137
+ }
138
+ return out.join('')
139
+ }
140
+
141
+ function collectUserContent(
142
+ parts: ReadonlyArray<MessagePart>,
143
+ ): string | Array<AGUIInputContent> {
144
+ const hasMultimodal = parts.some(
145
+ (p) =>
146
+ p.type === 'image' ||
147
+ p.type === 'audio' ||
148
+ p.type === 'video' ||
149
+ p.type === 'document',
150
+ )
151
+ if (!hasMultimodal) {
152
+ return collectText(parts)
153
+ }
154
+ const out: Array<AGUIInputContent> = []
155
+ for (const p of parts) {
156
+ if (p.type === 'text') {
157
+ out.push({ type: 'text', text: p.content })
158
+ } else if (
159
+ p.type === 'image' ||
160
+ p.type === 'audio' ||
161
+ p.type === 'video' ||
162
+ p.type === 'document'
163
+ ) {
164
+ out.push(p as AGUIInputContent)
165
+ }
166
+ }
167
+ return out
168
+ }
169
+
170
+ function collectToolCalls(
171
+ parts: ReadonlyArray<MessagePart>,
172
+ ): Array<AGUIToolCallMirror> | undefined {
173
+ const calls: Array<AGUIToolCallMirror> = []
174
+ for (const p of parts) {
175
+ if (p.type === 'tool-call') {
176
+ calls.push({
177
+ id: p.id,
178
+ type: 'function',
179
+ function: { name: p.name, arguments: p.arguments },
180
+ })
181
+ }
182
+ }
183
+ return calls.length > 0 ? calls : undefined
184
+ }
185
+
186
+ function deriveReasoningId(messageId: string, part: MessagePart): string {
187
+ return `${messageId}-reasoning-${(part as { id?: string }).id ?? hashContent((part as { content: string }).content)}`
188
+ }
189
+
190
+ function deriveToolMessageId(toolCallId: string): string {
191
+ return `tool-${toolCallId}`
192
+ }
193
+
194
+ function hashContent(s: string): string {
195
+ // Cheap deterministic id suffix; collisions are tolerable since
196
+ // reasoning ids only matter for AG-UI server consumers, not for our
197
+ // own server's dedup logic (which keys on toolCallId, not reasoning id).
198
+ let h = 0
199
+ for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0
200
+ return Math.abs(h).toString(36)
201
+ }