@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
@@ -22,7 +22,7 @@ import {
22
22
  parseWithStandardSchema,
23
23
  } from './tools/schema-converter'
24
24
  import { maxIterations as maxIterationsStrategy } from './agent-loop-strategies'
25
- import { convertMessagesToModelMessages } from './messages'
25
+ import { convertMessagesToModelMessages, generateMessageId } from './messages'
26
26
  import { MiddlewareRunner } from './middleware/compose'
27
27
  import type {
28
28
  ApprovalRequest,
@@ -48,6 +48,7 @@ import type {
48
48
  ToolCallArgsEvent,
49
49
  ToolCallEndEvent,
50
50
  ToolCallStartEvent,
51
+ UIMessage,
51
52
  } from '../../types'
52
53
  import type {
53
54
  ChatMiddleware,
@@ -85,12 +86,21 @@ export interface TextActivityOptions<
85
86
  > {
86
87
  /** The text adapter to use (created by a provider function like openaiText('gpt-4o')) */
87
88
  adapter: TAdapter
88
- /** Conversation messages - content types are constrained by the adapter's input modalities and metadata */
89
+ /**
90
+ * Conversation messages. Accepts:
91
+ * - `ConstrainedModelMessage` — content types constrained by the adapter's input modalities.
92
+ * - `ModelMessage` — unconstrained model message (e.g., forwarded from an AG-UI wire payload).
93
+ * - `UIMessage` — parts-based UI representation; converted internally via `convertMessagesToModelMessages`.
94
+ *
95
+ * The three shapes can be mixed in a single array (e.g., when forwarding a wire payload that includes both anchor UIMessages and AG-UI fan-out ModelMessages).
96
+ */
89
97
  messages?: Array<
90
- ConstrainedModelMessage<{
91
- inputModalities: TAdapter['~types']['inputModalities']
92
- messageMetadataByModality: TAdapter['~types']['messageMetadataByModality']
93
- }>
98
+ | UIMessage
99
+ | ModelMessage
100
+ | ConstrainedModelMessage<{
101
+ inputModalities: TAdapter['~types']['inputModalities']
102
+ messageMetadataByModality: TAdapter['~types']['messageMetadataByModality']
103
+ }>
94
104
  >
95
105
  /** System prompts to prepend to the conversation */
96
106
  systemPrompts?: TextOptions['systemPrompts']
@@ -128,6 +138,8 @@ export interface TextActivityOptions<
128
138
  threadId?: TextOptions['threadId']
129
139
  /** Run ID override for AG-UI protocol. Auto-generated by adapter if not provided. */
130
140
  runId?: TextOptions['runId']
141
+ /** Parent run ID for AG-UI protocol nested run correlation. */
142
+ parentRunId?: TextOptions['parentRunId']
131
143
  /**
132
144
  * Optional Standard Schema for structured output.
133
145
  * When provided, the activity will:
@@ -313,6 +325,7 @@ class TextEngine<
313
325
  // AG-UI protocol IDs
314
326
  private threadId: string
315
327
  private runIdOverride?: string
328
+ private parentRunIdOverride?: string
316
329
 
317
330
  // Middleware support
318
331
  private readonly middlewareRunner: MiddlewareRunner
@@ -364,8 +377,15 @@ class TextEngine<
364
377
  ? { signal: config.params.abortController.signal }
365
378
  : undefined
366
379
  this.effectiveSignal = config.params.abortController?.signal
367
- this.threadId = config.params.threadId || this.createId('thread')
380
+ // `conversationId` is the legacy alias of `threadId` — accept it
381
+ // as a fallback so `chat({ conversationId })` keeps working, with
382
+ // explicit `threadId` winning when both are set.
383
+ this.threadId =
384
+ config.params.threadId ||
385
+ config.params.conversationId ||
386
+ this.createId('thread')
368
387
  this.runIdOverride = config.params.runId
388
+ this.parentRunIdOverride = config.params.parentRunId
369
389
 
370
390
  // Initialize middleware — devtools first, strip-to-spec always last.
371
391
  // handleStreamChunk processes raw chunks BEFORE middleware, so internal
@@ -381,7 +401,10 @@ class TextEngine<
381
401
  this.middlewareCtx = {
382
402
  requestId: this.requestId,
383
403
  streamId: this.streamId,
384
- conversationId: config.params.conversationId,
404
+ threadId: this.threadId,
405
+ // Legacy alias kept on the ctx so middleware that reads
406
+ // `ctx.conversationId` keeps working. Always equals `threadId`.
407
+ conversationId: this.threadId,
385
408
  phase: 'init' as ChatMiddlewarePhase,
386
409
  iteration: 0,
387
410
  chunkIndex: 0,
@@ -429,7 +452,7 @@ class TextEngine<
429
452
  async *run(): AsyncGenerator<StreamChunk> {
430
453
  this.beforeRun()
431
454
  this.logger.agentLoop('run started', {
432
- conversationId: this.middlewareCtx.conversationId,
455
+ threadId: this.middlewareCtx.threadId,
433
456
  })
434
457
 
435
458
  try {
@@ -508,7 +531,7 @@ class TextEngine<
508
531
  // Genuine error — call onError
509
532
  this.logger.errors('chat run failed', {
510
533
  error,
511
- conversationId: this.middlewareCtx.conversationId,
534
+ threadId: this.middlewareCtx.threadId,
512
535
  })
513
536
  await this.middlewareRunner.runOnError(this.middlewareCtx, {
514
537
  error,
@@ -634,6 +657,7 @@ class TextEngine<
634
657
  logger: this.logger,
635
658
  threadId: this.threadId,
636
659
  runId: this.runIdOverride,
660
+ parentRunId: this.parentRunIdOverride,
637
661
  })) {
638
662
  if (this.isCancelled()) {
639
663
  break
@@ -2021,7 +2045,100 @@ async function* runStreamingStructuredOutputImpl<TSchema extends SchemaInput>(
2021
2045
  outputSchema: jsonSchema,
2022
2046
  })
2023
2047
 
2048
+ // Tag the start/complete events with the assistant messageId so the
2049
+ // client-side processor can route JSON deltas to (and snap) the right
2050
+ // StructuredOutputPart. Missing messageId is treated as a hard error
2051
+ // below to avoid silently rendering JSON as plain text.
2052
+ let structuredMessageId: string | null = null
2053
+ let startEmitted = false
2054
+
2055
+ const extractMessageId = (c: StreamChunk): string | null => {
2056
+ const id = (c as { messageId?: unknown }).messageId
2057
+ return typeof id === 'string' && id !== '' ? id : null
2058
+ }
2059
+
2060
+ // Emit a `structured-output.start` (synthesizing a messageId if the
2061
+ // adapter hasn't picked one yet) so that the client processor can route
2062
+ // the forthcoming error chunk into a `structured-output` part on the
2063
+ // placeholder assistant message. Without this, a RUN_ERROR that fires
2064
+ // before the adapter has yielded any TEXT_MESSAGE_START leaves the
2065
+ // assistant message with zero parts — the structured-output UI surface
2066
+ // never sees the error.
2067
+ const emitStartIfNeeded = function* (
2068
+ referenceChunk: StreamChunk,
2069
+ ): Generator<StreamChunk, void, void> {
2070
+ if (startEmitted) return
2071
+ const idForStart = structuredMessageId ?? generateMessageId()
2072
+ structuredMessageId = idForStart
2073
+ startEmitted = true
2074
+ yield {
2075
+ type: EventType.CUSTOM,
2076
+ name: 'structured-output.start',
2077
+ value: { messageId: idForStart },
2078
+ model:
2079
+ 'model' in referenceChunk ? (referenceChunk.model ?? model) : model,
2080
+ timestamp:
2081
+ 'timestamp' in referenceChunk
2082
+ ? (referenceChunk.timestamp ?? Date.now())
2083
+ : Date.now(),
2084
+ runId,
2085
+ }
2086
+ }
2087
+
2024
2088
  for await (const chunk of stream) {
2089
+ if (!structuredMessageId) {
2090
+ if (
2091
+ chunk.type === EventType.TEXT_MESSAGE_START ||
2092
+ chunk.type === EventType.TEXT_MESSAGE_CONTENT
2093
+ ) {
2094
+ structuredMessageId = extractMessageId(chunk)
2095
+ }
2096
+ }
2097
+
2098
+ // RUN_ERROR before any text deltas: synthesize the structured-output.start
2099
+ // so the client snaps an errored part instead of a silent UI. The
2100
+ // synthesized messageId becomes the assistant message id the client
2101
+ // creates on its side (handleRunErrorEvent calls ensureAssistantMessage()
2102
+ // which picks up the same id from the structured-output.start above).
2103
+ if (chunk.type === EventType.RUN_ERROR && !startEmitted) {
2104
+ yield* emitStartIfNeeded(chunk)
2105
+ }
2106
+
2107
+ // Adapter emitted content with no usable messageId. Routing JSON deltas
2108
+ // into a TextPart would silently render raw JSON in the user's chat, so
2109
+ // fail loudly here instead.
2110
+ if (!structuredMessageId && chunk.type === EventType.TEXT_MESSAGE_CONTENT) {
2111
+ yield {
2112
+ type: EventType.RUN_ERROR,
2113
+ runId,
2114
+ model,
2115
+ timestamp: Date.now(),
2116
+ message:
2117
+ 'Structured-output stream produced text content without a messageId; ' +
2118
+ 'adapter is not honoring the AG-UI contract.',
2119
+ code: 'structured-output-missing-message-id',
2120
+ }
2121
+ return
2122
+ }
2123
+
2124
+ if (
2125
+ !startEmitted &&
2126
+ structuredMessageId &&
2127
+ (chunk.type === EventType.TEXT_MESSAGE_START ||
2128
+ chunk.type === EventType.TEXT_MESSAGE_CONTENT)
2129
+ ) {
2130
+ startEmitted = true
2131
+ yield {
2132
+ type: EventType.CUSTOM,
2133
+ name: 'structured-output.start',
2134
+ value: { messageId: structuredMessageId },
2135
+ model: 'model' in chunk ? (chunk.model ?? model) : model,
2136
+ timestamp:
2137
+ 'timestamp' in chunk ? (chunk.timestamp ?? Date.now()) : Date.now(),
2138
+ runId,
2139
+ }
2140
+ }
2141
+
2025
2142
  if (
2026
2143
  chunk.type === EventType.CUSTOM &&
2027
2144
  chunk.name === 'structured-output.complete'
@@ -2041,10 +2158,15 @@ async function* runStreamingStructuredOutputImpl<TSchema extends SchemaInput>(
2041
2158
  ...chunk,
2042
2159
  // Forward `reasoning` through schema validation so consumers that
2043
2160
  // only listen for the terminal event don't lose chain-of-thought.
2161
+ // Tag with messageId so the client processor can snap the right
2162
+ // assistant message's structured-output part.
2044
2163
  value: {
2045
2164
  object: validated,
2046
2165
  raw: value.raw,
2047
2166
  ...(value.reasoning ? { reasoning: value.reasoning } : {}),
2167
+ ...(structuredMessageId
2168
+ ? { messageId: structuredMessageId }
2169
+ : {}),
2048
2170
  },
2049
2171
  }
2050
2172
  continue
@@ -2076,6 +2198,18 @@ async function* runStreamingStructuredOutputImpl<TSchema extends SchemaInput>(
2076
2198
  return
2077
2199
  }
2078
2200
  }
2201
+ // No Standard schema (raw JSONSchema). Still tag the terminal event
2202
+ // with messageId so the client processor can snap the right part.
2203
+ if (structuredMessageId) {
2204
+ yield {
2205
+ ...chunk,
2206
+ value: {
2207
+ ...(chunk.value as Record<string, unknown>),
2208
+ messageId: structuredMessageId,
2209
+ },
2210
+ }
2211
+ continue
2212
+ }
2079
2213
  yield chunk
2080
2214
  continue
2081
2215
  }
@@ -24,6 +24,14 @@ function isContentPart(part: MessagePart): part is ContentPart {
24
24
  )
25
25
  }
26
26
 
27
+ function safeJsonStringify(value: unknown): string {
28
+ try {
29
+ return JSON.stringify(value)
30
+ } catch {
31
+ return ''
32
+ }
33
+ }
34
+
27
35
  /**
28
36
  * Collapse an array of ContentParts into the most compact ModelMessage content:
29
37
  * - Empty array → null
@@ -63,15 +71,55 @@ function getTextContent(content: string | null | Array<ContentPart>): string {
63
71
  export function convertMessagesToModelMessages(
64
72
  messages: Array<UIMessage | ModelMessage>,
65
73
  ): Array<ModelMessage> {
74
+ // Pre-pass: collect toolCallIds already represented in anchor UIMessage parts.
75
+ // Fan-out tool messages whose toolCallId matches an anchored ToolResultPart
76
+ // are AG-UI duplicates and must be dropped to avoid double-feeding the LLM.
77
+ const anchoredToolCallIds = new Set<string>()
78
+ for (const msg of messages) {
79
+ if ('parts' in msg) {
80
+ for (const part of msg.parts) {
81
+ if (part.type === 'tool-result') {
82
+ anchoredToolCallIds.add(part.toolCallId)
83
+ }
84
+ }
85
+ }
86
+ }
87
+
66
88
  const modelMessages: Array<ModelMessage> = []
67
89
  for (const msg of messages) {
68
90
  if ('parts' in msg) {
69
- // UIMessage - convert to ModelMessages
91
+ // UIMessage anchor — existing fan-out path
70
92
  modelMessages.push(...uiMessageToModelMessages(msg))
71
- } else {
72
- // Already ModelMessage
73
- modelMessages.push(msg)
93
+ continue
94
+ }
95
+
96
+ const role = (msg as { role: string }).role
97
+
98
+ // AG-UI tool fan-out duplicate — drop if anchor already covers it
99
+ if (
100
+ role === 'tool' &&
101
+ msg.toolCallId &&
102
+ anchoredToolCallIds.has(msg.toolCallId)
103
+ ) {
104
+ continue
105
+ }
106
+
107
+ // AG-UI reasoning and activity — no ModelMessage equivalent today
108
+ if (role === 'reasoning' || role === 'activity') {
109
+ continue
74
110
  }
111
+
112
+ // AG-UI developer — collapse to system
113
+ if (role === 'developer') {
114
+ modelMessages.push({
115
+ role: 'system' as ModelMessage['role'],
116
+ content: (msg as { content: string }).content,
117
+ } as ModelMessage)
118
+ continue
119
+ }
120
+
121
+ // Already a ModelMessage (user, assistant, system, tool with no anchor) — pass through
122
+ modelMessages.push(msg)
75
123
  }
76
124
  return modelMessages
77
125
  }
@@ -244,6 +292,24 @@ function buildAssistantMessages(uiMessage: UIMessage): Array<ModelMessage> {
244
292
  }
245
293
  break
246
294
 
295
+ case 'structured-output':
296
+ // Only emit completed structured responses into history. Streaming or
297
+ // errored buffers would push malformed JSON into the next LLM turn's
298
+ // assistant content. `raw` is the source of truth; `data` is the
299
+ // defensive fallback for terminal-only completes that didn't ship raw.
300
+ if (part.status === 'complete') {
301
+ const serialized =
302
+ part.raw !== ''
303
+ ? part.raw
304
+ : part.data !== undefined
305
+ ? safeJsonStringify(part.data)
306
+ : ''
307
+ if (serialized !== '') {
308
+ current.contentParts.push({ type: 'text', content: serialized })
309
+ }
310
+ }
311
+ break
312
+
247
313
  default:
248
314
  break
249
315
  }
@@ -26,7 +26,7 @@ function instrumentCtx(ctx: ChatMiddlewareContext) {
26
26
  return {
27
27
  requestId: ctx.requestId,
28
28
  streamId: ctx.streamId,
29
- clientId: ctx.conversationId,
29
+ clientId: ctx.threadId,
30
30
  timestamp: Date.now(),
31
31
  }
32
32
  }
@@ -28,7 +28,18 @@ export interface ChatMiddlewareContext {
28
28
  requestId: string
29
29
  /** Unique identifier for this stream */
30
30
  streamId: string
31
- /** Conversation identifier, if provided by the caller */
31
+ /**
32
+ * AG-UI thread identifier — a stable per-conversation ID used to
33
+ * correlate client and server devtools events. Resolves to the
34
+ * caller-provided `threadId` (or legacy `conversationId`), or an
35
+ * auto-generated value when neither is supplied.
36
+ */
37
+ threadId: string
38
+ /**
39
+ * @deprecated Use `threadId` instead. Retained as an alias of
40
+ * `threadId` so middleware written before the AG-UI rename keeps
41
+ * working unchanged. Will be removed in a future major release.
42
+ */
32
43
  conversationId?: string
33
44
  /** Current lifecycle phase */
34
45
  phase: ChatMiddlewarePhase
@@ -5,7 +5,9 @@
5
5
  * These are used by StreamProcessor to manage the message array.
6
6
  */
7
7
 
8
+ import { parsePartialJSON } from './json-parser'
8
9
  import type {
10
+ StructuredOutputPart,
9
11
  ThinkingPart,
10
12
  ToolCallPart,
11
13
  ToolResultPart,
@@ -251,6 +253,175 @@ export function updateToolCallApprovalResponse(
251
253
  })
252
254
  }
253
255
 
256
+ /**
257
+ * Append a delta to the structured-output part on `messageId`, or create one
258
+ * if absent. Progressive parse of the accumulated buffer fills `partial`.
259
+ *
260
+ * Callers must only invoke this while the part is still in flight — the
261
+ * helper unconditionally writes `status: 'streaming'`, so feeding it a delta
262
+ * after a `complete`/`error` terminal would regress the part. In practice the
263
+ * processor gates calls via `structuredMessageIds`, which is dropped on
264
+ * terminal events.
265
+ *
266
+ * If the progressive parse returns null/undefined (the buffer is not yet a
267
+ * parseable JSON prefix), the previously-good `partial` is preserved so the
268
+ * UI doesn't flicker back to empty for a single render.
269
+ */
270
+ export function appendStructuredOutputDelta(
271
+ messages: Array<UIMessage>,
272
+ messageId: string,
273
+ delta: string,
274
+ ): Array<UIMessage> {
275
+ return messages.map((msg) => {
276
+ if (msg.id !== messageId) {
277
+ return msg
278
+ }
279
+
280
+ const parts = [...msg.parts]
281
+ const existingIndex = parts.findIndex(
282
+ (p): p is StructuredOutputPart => p.type === 'structured-output',
283
+ )
284
+ const existing =
285
+ existingIndex >= 0 ? (parts[existingIndex] as StructuredOutputPart) : null
286
+
287
+ const nextRaw = (existing?.raw ?? '') + delta
288
+ const progressive = parsePartialJSON(nextRaw)
289
+ const nextPartial =
290
+ progressive !== undefined && progressive !== null
291
+ ? progressive
292
+ : existing?.partial
293
+
294
+ const nextPart: StructuredOutputPart = {
295
+ type: 'structured-output',
296
+ status: 'streaming',
297
+ raw: nextRaw,
298
+ ...(nextPartial !== undefined ? { partial: nextPartial } : {}),
299
+ ...(existing?.reasoning !== undefined
300
+ ? { reasoning: existing.reasoning }
301
+ : {}),
302
+ }
303
+
304
+ if (existingIndex >= 0) {
305
+ parts[existingIndex] = nextPart
306
+ } else {
307
+ parts.push(nextPart)
308
+ }
309
+
310
+ return { ...msg, parts }
311
+ })
312
+ }
313
+
314
+ /**
315
+ * Snap the structured-output part on `messageId` to `complete` with the
316
+ * validated `data`. Picks the freshest available `raw` so the wire
317
+ * round-trip stays internally consistent:
318
+ *
319
+ * 1. Caller-supplied `raw` (the original streamed bytes from the model).
320
+ * 2. The existing part's `raw` (deltas accumulated before this terminal).
321
+ * 3. `JSON.stringify(data)` as a defensive fallback for terminal-only
322
+ * completes that never shipped raw — keeps the part self-consistent
323
+ * so downstream consumers never see a complete part with empty raw.
324
+ */
325
+ export function completeStructuredOutputPart(
326
+ messages: Array<UIMessage>,
327
+ messageId: string,
328
+ data: unknown,
329
+ raw: string,
330
+ reasoning?: string,
331
+ ): Array<UIMessage> {
332
+ return messages.map((msg) => {
333
+ if (msg.id !== messageId) {
334
+ return msg
335
+ }
336
+
337
+ const parts = [...msg.parts]
338
+ const existingIndex = parts.findIndex(
339
+ (p): p is StructuredOutputPart => p.type === 'structured-output',
340
+ )
341
+
342
+ const existingRaw =
343
+ existingIndex >= 0
344
+ ? (parts[existingIndex] as StructuredOutputPart).raw
345
+ : ''
346
+ let resolvedRaw = raw || existingRaw
347
+ if (resolvedRaw === '' && data !== undefined) {
348
+ try {
349
+ resolvedRaw = JSON.stringify(data)
350
+ } catch {
351
+ // Unserializable (circular, BigInt, throwing toJSON). Leave raw
352
+ // empty. Both downstream paths handle this: `ag-ui-wire.ts`
353
+ // `collectText` skips complete parts with empty raw entirely, and
354
+ // `uiMessageToModelMessages` falls back to a defensive
355
+ // `safeJsonStringify(data)` which itself returns `''` for the same
356
+ // unserializable inputs — so the turn is silently dropped from the
357
+ // next request rather than shipping garbage or crashing the stream.
358
+ }
359
+ }
360
+
361
+ const nextPart: StructuredOutputPart = {
362
+ type: 'structured-output',
363
+ status: 'complete',
364
+ data,
365
+ partial: data,
366
+ raw: resolvedRaw,
367
+ ...(reasoning !== undefined ? { reasoning } : {}),
368
+ }
369
+
370
+ if (existingIndex >= 0) {
371
+ parts[existingIndex] = nextPart
372
+ } else {
373
+ parts.push(nextPart)
374
+ }
375
+
376
+ return { ...msg, parts }
377
+ })
378
+ }
379
+
380
+ /**
381
+ * Mark the structured-output part on `messageId` as errored. If no part
382
+ * exists yet — RUN_ERROR fired after `structured-output.start` but before
383
+ * any delta — create an empty errored placeholder so consumers have
384
+ * something renderable. Existing complete parts are left alone (an error
385
+ * after a successful complete should not retroactively un-complete it).
386
+ */
387
+ export function errorStructuredOutputPart(
388
+ messages: Array<UIMessage>,
389
+ messageId: string,
390
+ errorMessage: string,
391
+ ): Array<UIMessage> {
392
+ return messages.map((msg) => {
393
+ if (msg.id !== messageId) {
394
+ return msg
395
+ }
396
+
397
+ const parts = [...msg.parts]
398
+ const existingIndex = parts.findIndex(
399
+ (p): p is StructuredOutputPart => p.type === 'structured-output',
400
+ )
401
+
402
+ if (existingIndex < 0) {
403
+ parts.push({
404
+ type: 'structured-output',
405
+ status: 'error',
406
+ raw: '',
407
+ errorMessage,
408
+ })
409
+ return { ...msg, parts }
410
+ }
411
+
412
+ const existing = parts[existingIndex] as StructuredOutputPart
413
+ if (existing.status === 'complete') {
414
+ return msg
415
+ }
416
+ parts[existingIndex] = {
417
+ ...existing,
418
+ status: 'error',
419
+ errorMessage,
420
+ }
421
+ return { ...msg, parts }
422
+ })
423
+ }
424
+
254
425
  /**
255
426
  * Update or add a thinking part to a message, keyed by stepId.
256
427
  * Each distinct stepId produces its own ThinkingPart.