@tanstack/ai 0.18.0 → 0.19.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/ai",
3
- "version": "0.18.0",
3
+ "version": "0.19.1",
4
4
  "description": "Core TanStack AI library - Open source AI SDK",
5
5
  "author": "Tanner Linsley",
6
6
  "license": "MIT",
@@ -55,7 +55,7 @@
55
55
  "dependencies": {
56
56
  "@ag-ui/core": "^0.0.52",
57
57
  "partial-json": "^0.1.7",
58
- "@tanstack/ai-event-client": "0.3.2"
58
+ "@tanstack/ai-event-client": "0.3.4"
59
59
  },
60
60
  "peerDependencies": {
61
61
  "@opentelemetry/api": ">=1.9.0"
@@ -1,23 +1,30 @@
1
1
  ---
2
2
  name: ai-core/structured-outputs
3
3
  description: >
4
- Type-safe JSON schema responses from LLMs using outputSchema on chat().
5
- Supports Zod, ArkType, and Valibot schemas. The adapter handles
6
- provider-specific strategies transparently — never configure structured
7
- output at the provider level. Pass stream:true alongside outputSchema for
8
- incremental JSON deltas + a terminal validated object via the
9
- `structured-output.complete` event. convertSchemaToJsonSchema() for manual
10
- schema conversion.
4
+ Type-safe JSON schema responses from LLMs using outputSchema on chat()
5
+ and useChat(). Supports Zod, ArkType, and Valibot schemas. The adapter
6
+ handles provider-specific strategies transparently — never configure
7
+ structured output at the provider level. Pass stream:true alongside
8
+ outputSchema for incremental JSON deltas + a terminal validated object
9
+ via the `structured-output.complete` event. Every assistant turn in
10
+ useChat carries its own typed `StructuredOutputPart` on
11
+ `messages[i].parts`, so multi-turn structured chats preserve history
12
+ automatically — partial/final derive from the latest assistant turn's
13
+ part. convertSchemaToJsonSchema() for manual schema conversion.
11
14
  type: sub-skill
12
15
  library: tanstack-ai
13
16
  library_version: '0.10.0'
14
17
  sources:
15
- - 'TanStack/ai:docs/chat/structured-outputs.md'
18
+ - 'TanStack/ai:docs/structured-outputs/overview.md'
19
+ - 'TanStack/ai:docs/structured-outputs/one-shot.md'
20
+ - 'TanStack/ai:docs/structured-outputs/streaming.md'
21
+ - 'TanStack/ai:docs/structured-outputs/multi-turn.md'
22
+ - 'TanStack/ai:docs/structured-outputs/with-tools.md'
16
23
  ---
17
24
 
18
25
  # Structured Outputs
19
26
 
20
- > **Dependency note:** This skill builds on ai-core. Read it first for critical rules.
27
+ > **Dependency note:** This skill builds on ai-core. Read it first for critical rules. The `useChat` patterns below build on ai-core/chat-experience — read that for the base hook surface, then come back here for the structured-output specifics.
21
28
 
22
29
  ## Setup
23
30
 
@@ -26,29 +33,32 @@ import { chat } from '@tanstack/ai'
26
33
  import { openaiText } from '@tanstack/ai-openai'
27
34
  import { z } from 'zod'
28
35
 
29
- const stream = chat({
36
+ const person = await chat({
30
37
  adapter: openaiText('gpt-5.2'),
31
- messages: [
32
- {
33
- role: 'user',
34
- content: [
35
- {
36
- type: 'text',
37
- content: 'Extract the person info from: John is 30 years old',
38
- },
39
- ],
40
- },
41
- ],
38
+ messages: [{ role: 'user', content: 'John Doe, 30' }],
42
39
  outputSchema: z.object({
43
40
  name: z.string(),
44
41
  age: z.number(),
45
42
  }),
46
43
  })
44
+
45
+ person.name // string — fully typed, no cast
46
+ person.age // number
47
47
  ```
48
48
 
49
- When `outputSchema` is provided, `chat()` returns `Promise<InferSchemaType<TSchema>>` instead of `AsyncIterable<StreamChunk>`. The result is fully typed based on the schema.
49
+ When `outputSchema` is provided, `chat()` returns `Promise<InferSchemaType<TSchema>>` instead of `AsyncIterable<StreamChunk>`. The result is fully typed.
50
+
51
+ Adding `stream: true` switches the return to `StructuredOutputStream<InferSchemaType<TSchema>>` — incremental JSON deltas plus a terminal validated object. See **Pattern 3** below for direct iteration, **Pattern 4** for the `useChat` shape on the client, and **Pattern 5** for multi-turn structured chats.
50
52
 
51
- Adding `stream: true` switches the return to `StructuredOutputStream<InferSchemaType<TSchema>>` — incremental JSON deltas plus a terminal validated object. See **Pattern 3** below.
53
+ ## Decision: which pattern fits
54
+
55
+ | Building this | Use |
56
+ | ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
57
+ | One prompt in → one typed object out (script, server endpoint, CLI) | Pattern 1 (basic) or 2 (nested) |
58
+ | A UI that fills in field by field as the model streams (progressive form, live card) | Pattern 4 — `useChat({ outputSchema })` |
59
+ | Direct iteration of the stream in Node or tests | Pattern 3 — async iterable |
60
+ | Users iterate on a structured object across multiple turns (recipe builder, ticket refinement) | Pattern 5 — multi-turn structured chat |
61
+ | Tools that gather info, then return a typed object | Combine any of the above with `tools` — see ai-core/tool-calling |
52
62
 
53
63
  ## Core Patterns
54
64
 
@@ -132,9 +142,9 @@ console.log(company.employees[0].role)
132
142
  console.log(company.financials?.revenue)
133
143
  ```
134
144
 
135
- ### Pattern 3: Streaming structured output
145
+ ### Pattern 3: Direct stream iteration
136
146
 
137
- Pass `stream: true` alongside `outputSchema` to receive incremental JSON deltas while the model generates, plus a final validated typed object. Useful for streaming partial UI (progress views, typewriter previews, partially-filled forms).
147
+ Pass `stream: true` alongside `outputSchema` to get an async iterable of standard streaming chunks plus a terminal validated object. Use this when you're a single process end-to-end — Node script, CLI, test, or a server endpoint that responds with one JSON blob. For the in-browser progressive-UI case, jump to Pattern 4 instead.
138
148
 
139
149
  ```typescript
140
150
  import { chat } from '@tanstack/ai'
@@ -156,15 +166,8 @@ const stream = chat({
156
166
  stream: true,
157
167
  })
158
168
 
159
- let raw = ''
160
169
  for await (const chunk of stream) {
161
- if (chunk.type === 'TEXT_MESSAGE_CONTENT') {
162
- // Partial JSON text — drive progress UI only. Do NOT JSON.parse.
163
- raw += chunk.delta
164
- } else if (
165
- chunk.type === 'CUSTOM' &&
166
- chunk.name === 'structured-output.complete'
167
- ) {
170
+ if (chunk.type === 'CUSTOM' && chunk.name === 'structured-output.complete') {
168
171
  // Terminal event. `chunk.value.object` is fully validated and typed
169
172
  // against the schema you passed in — no helper or cast required.
170
173
  chunk.value.object.name // string
@@ -174,7 +177,7 @@ for await (const chunk of stream) {
174
177
  }
175
178
  ```
176
179
 
177
- The terminal event is a `CUSTOM` chunk: `{ type: 'CUSTOM', name: 'structured-output.complete', value: { object: T, raw: string, reasoning?: string } }`. The return type of `chat({ outputSchema, stream: true })` carries `T` through to the terminal event, so a plain discriminated narrow (`chunk.type === 'CUSTOM' && chunk.name === 'structured-output.complete'`) is enough — no type guard helper needed.
180
+ The terminal event is a `CUSTOM` chunk: `{ type: 'CUSTOM', name: 'structured-output.complete', value: { object: T, raw: string, reasoning?: string } }`. The return type of `chat({ outputSchema, stream: true })` carries `T` through, so a plain discriminated narrow (`chunk.type === 'CUSTOM' && chunk.name === 'structured-output.complete'`) is enough — no type guard helper.
178
181
 
179
182
  **Adapter coverage for streaming:**
180
183
 
@@ -186,13 +189,208 @@ The terminal event is a `CUSTOM` chunk: `{ type: 'CUSTOM', name: 'structured-out
186
189
  | `@tanstack/ai-groq` | Native single-request stream (Chat Completions) |
187
190
  | All other adapters (anthropic, gemini, ollama, …) | Fallback: runs non-streaming `structuredOutput`, emits one `structured-output.complete` event |
188
191
 
189
- The consumer code is identical across providers — always read the final object off `structured-output.complete`. You only see incremental deltas when the adapter implements `structuredOutputStream` natively.
192
+ Consumer code is identical across providers — always read the final object off `structured-output.complete`. You only see incremental `TEXT_MESSAGE_CONTENT` deltas when the adapter implements `structuredOutputStream` natively.
193
+
194
+ ### Pattern 4: useChat with outputSchema (progressive UI)
195
+
196
+ Pass `outputSchema` to `useChat` and you get a `partial` field that fills in as JSON streams in, plus a `final` field that snaps to the validated object on the terminal event. No `onChunk` ceremony, no manual JSON accumulation, no `parsePartialJSON` calls.
197
+
198
+ **Server** (same as Pattern 3, just behind an SSE endpoint):
199
+
200
+ ```typescript
201
+ // app/api/extract-person/route.ts (or your framework's equivalent)
202
+ import { chat, toServerSentEventsResponse } from '@tanstack/ai'
203
+ import { openaiText } from '@tanstack/ai-openai'
204
+ import { z } from 'zod'
205
+
206
+ const PersonSchema = z.object({
207
+ name: z.string(),
208
+ age: z.number(),
209
+ email: z.string().email(),
210
+ })
211
+
212
+ export async function POST(request: Request) {
213
+ const { messages } = await request.json()
214
+ const stream = chat({
215
+ adapter: openaiText('gpt-5.2'),
216
+ messages,
217
+ outputSchema: PersonSchema,
218
+ stream: true,
219
+ })
220
+ return toServerSentEventsResponse(stream)
221
+ }
222
+ ```
223
+
224
+ **Client:**
225
+
226
+ ```tsx
227
+ import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'
228
+ import { z } from 'zod'
229
+
230
+ const PersonSchema = z.object({
231
+ name: z.string(),
232
+ age: z.number(),
233
+ email: z.string().email(),
234
+ })
235
+
236
+ function PersonExtractor() {
237
+ const { sendMessage, isLoading, partial, final } = useChat({
238
+ connection: fetchServerSentEvents('/api/extract-person'),
239
+ outputSchema: PersonSchema,
240
+ })
241
+
242
+ return (
243
+ <div>
244
+ <button
245
+ disabled={isLoading}
246
+ onClick={() => sendMessage('Extract: John Doe, 30, john@example.com')}
247
+ >
248
+ Extract
249
+ </button>
250
+ {/* `partial` fills in field by field while streaming. */}
251
+ <p>Name: {partial.name ?? '…'}</p>
252
+ <p>Age: {partial.age ?? '…'}</p>
253
+ <p>Email: {partial.email ?? '…'}</p>
254
+ {final && <pre>Validated: {JSON.stringify(final, null, 2)}</pre>}
255
+ </div>
256
+ )
257
+ }
258
+ ```
259
+
260
+ - `partial` is `DeepPartial<z.infer<typeof PersonSchema>>` — every property optional, every nested array element optional. Updated from `TEXT_MESSAGE_CONTENT` deltas.
261
+ - `final` is `z.infer<typeof PersonSchema> | null` — populated when `structured-output.complete` arrives.
262
+ - `outputSchema` is for client-side type inference only. **Validation runs on the server** against the schema you pass to `chat({ outputSchema })` there.
263
+ - Same shape works for non-streaming adapters: the fallback path emits one whole-JSON `TEXT_MESSAGE_CONTENT` then the terminal event, so `partial` populates and `final` snaps in the same render tick — same consumer code as the native-streaming providers, just without an intermediate field-by-field reveal.
264
+
265
+ ### Pattern 5: Multi-turn structured chat
266
+
267
+ Every assistant turn produced by `useChat({ outputSchema })` carries its own typed `StructuredOutputPart` on `messages[i].parts`. Old turns stay renderable; new turns produce new parts; history is preserved without manual state plumbing. This is what makes the recipe-builder shape ("now make it vegan") work.
268
+
269
+ ```tsx
270
+ import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'
271
+ import type { StructuredOutputPart } from '@tanstack/ai-client'
272
+ import { z } from 'zod'
273
+
274
+ const RecipeSchema = z.object({
275
+ title: z.string(),
276
+ cuisine: z.string(),
277
+ servings: z.number(),
278
+ ingredients: z.array(z.object({ item: z.string(), amount: z.string() })),
279
+ steps: z.array(z.string()),
280
+ })
281
+ type Recipe = z.infer<typeof RecipeSchema>
282
+ type RecipePart = StructuredOutputPart<Recipe>
283
+
284
+ function RecipeBuilder() {
285
+ const { messages, sendMessage } = useChat({
286
+ outputSchema: RecipeSchema,
287
+ connection: fetchServerSentEvents('/api/recipes'),
288
+ })
289
+
290
+ return (
291
+ <div>
292
+ {messages.map((m) => {
293
+ if (m.role === 'user') {
294
+ const text = m.parts
295
+ .filter((p) => p.type === 'text')
296
+ .map((p) => p.content)
297
+ .join('')
298
+ return <UserBubble key={m.id} text={text} />
299
+ }
300
+ if (m.role === 'assistant') {
301
+ // `data` is `Recipe` because the schema generic flows from
302
+ // `useChat({ outputSchema })` through `messages` to the part.
303
+ const part = m.parts.find(
304
+ (p): p is RecipePart => p.type === 'structured-output',
305
+ )
306
+ if (!part) return null
307
+ return <RecipeCard key={m.id} part={part} />
308
+ }
309
+ return null
310
+ })}
311
+ <button onClick={() => sendMessage('pasta for two')}>Cook</button>
312
+ <button onClick={() => sendMessage('now make it vegan')}>Modify</button>
313
+ </div>
314
+ )
315
+ }
316
+
317
+ function RecipeCard({ part }: { part: RecipePart }) {
318
+ // `data` lands on complete, `partial` fills in while streaming.
319
+ // Both are typed against the schema. No casts.
320
+ const recipe = part.data ?? part.partial ?? ({} as Partial<Recipe>)
321
+ return <h3>{recipe.title ?? 'Plating up…'}</h3>
322
+ }
323
+ ```
324
+
325
+ Key behaviors:
326
+
327
+ - **Per-turn parts.** Each `sendMessage()` produces a new assistant message with its own `StructuredOutputPart`. The previous turn's part is untouched — `messages.map(...)` renders the whole history.
328
+ - **Typed by schema.** `messages[i].parts.find(p => p.type === 'structured-output').data` is typed as `Recipe` (no cast, no `unknown`). Works because `useChat<TSchema>` threads `InferSchemaType<TSchema>` down through `UIMessage<TTools, TData>` → `MessagePart<TTools, TData>` → `StructuredOutputPart<TData>`. **In `@tanstack/ai` core** the message types are single-generic (`UIMessage<TData>`); the tools generic lives in `@tanstack/ai-client` and the framework hook packages — import from your framework package or `ai-client`, not from `@tanstack/ai`.
329
+ - **`partial` / `final` are derived.** The hook-level `partial` and `final` are NOT singleton state — they're derived from the latest assistant message's part (the one after the most recent user message). Between `sendMessage()` and the first chunk, `partial` reads `{}` and `final` reads `null` because no new assistant turn exists yet.
330
+ - **Round-trip preserves history.** When the client sends turn N+1, each prior assistant turn's `structured-output` part is serialized back as `{ role: 'assistant', content: <part.raw> }` so the model sees its own prior structured response. Streaming / errored parts are dropped from the round-trip.
190
331
 
191
332
  ## Common Mistakes
192
333
 
334
+ ### HIGH: Filtering `TextPart`s out of `useChat` renderers when using `outputSchema`
335
+
336
+ Earlier versions of the library routed structured-output JSON deltas through `TextPart`, so renderers had to filter them out:
337
+
338
+ ```tsx
339
+ // OBSOLETE — this guard was needed only because JSON used to land in a TextPart
340
+ const last = messages.at(-1)
341
+ last?.parts.map((part) => {
342
+ if (part.type === 'text') return null // ❌ hides the structured JSON
343
+ // ...
344
+ })
345
+ ```
346
+
347
+ That hack is **gone**. With `outputSchema` set, `TEXT_MESSAGE_CONTENT` deltas now route into a dedicated `StructuredOutputPart` (with `raw`, `partial`, `data`, `status`, optional `errorMessage`). Render the structured part directly; let real `TextPart`s through.
348
+
349
+ ```tsx
350
+ // CORRECT — find the structured-output part directly; let actual TextParts render
351
+ last?.parts.map((part, i) => {
352
+ if (part.type === 'thinking')
353
+ return <ReasoningView key={i} text={part.content} />
354
+ if (part.type === 'tool-call') return <ToolCallView key={i} part={part} />
355
+ if (part.type === 'structured-output')
356
+ return <RecipeCard key={i} part={part} />
357
+ if (part.type === 'text') return <p key={i}>{part.content}</p> // ← real text, not JSON
358
+ return null
359
+ })
360
+ ```
361
+
362
+ If you still have an `if (part.type === 'text') return null` line in a structured-output renderer specifically for "hiding the JSON," delete it.
363
+
364
+ Source: PR #577 — structured-output became a typed UIMessage part.
365
+
366
+ ### HIGH: Treating `partial` / `final` as sticky state across turns
367
+
368
+ `partial` and `final` are **derived from the latest assistant message's `structured-output` part**, not a sticky hook-level slot. In a multi-turn chat:
369
+
370
+ - Between `sendMessage()` and the first chunk, `partial` reads `{}` and `final` reads `null` (no assistant message after the latest user yet).
371
+ - Once the latest turn completes, `partial === final`. Earlier turns' data is NOT in `partial` / `final` — it lives on the prior assistant messages' parts.
372
+
373
+ To render history, walk `messages` directly (see Pattern 5). Use `partial` / `final` for a sticky summary of the **most recent** turn only.
374
+
375
+ ```tsx
376
+ // WRONG — `final` only reflects the latest turn; earlier recipes vanish from this view
377
+ {final && <RecipeCard recipe={final} />}
378
+
379
+ // CORRECT for history — walk messages, render every assistant's structured-output part
380
+ {messages.map((m) =>
381
+ m.role === 'assistant'
382
+ ? m.parts.find((p) => p.type === 'structured-output')
383
+ ? <RecipeCard key={m.id} part={...} />
384
+ : null
385
+ : null
386
+ )}
387
+ ```
388
+
389
+ Source: PR #577 — partial/final derive from the latest assistant turn's part.
390
+
193
391
  ### HIGH: Parsing streaming JSON deltas yourself
194
392
 
195
- When using `chat({ outputSchema, stream: true })`, the `TEXT_MESSAGE_CONTENT` chunks contain _partial_ JSON fragments — they are not valid JSON until the stream completes. Always read the validated object from the terminal `structured-output.complete` event. Validation runs once, on the complete payload.
393
+ When iterating `chat({ outputSchema, stream: true })` directly (Pattern 3), the `TEXT_MESSAGE_CONTENT` chunks contain _partial_ JSON fragments — they are not valid JSON until the stream completes. Always read the validated object from the terminal `structured-output.complete` event. Validation runs once, on the complete payload.
196
394
 
197
395
  ```typescript
198
396
  // WRONG -- partial JSON, throws SyntaxError mid-stream, no schema validation
@@ -202,21 +400,15 @@ for await (const chunk of stream) {
202
400
  }
203
401
  }
204
402
 
205
- // CORRECT -- accumulate deltas only for UX progress; trust the terminal event
206
- let raw = ''
403
+ // CORRECT -- trust the terminal event
207
404
  for await (const chunk of stream) {
208
- if (chunk.type === 'TEXT_MESSAGE_CONTENT') {
209
- raw += chunk.delta // optional: render a "streaming JSON" preview
210
- } else if (
211
- chunk.type === 'CUSTOM' &&
212
- chunk.name === 'structured-output.complete'
213
- ) {
405
+ if (chunk.type === 'CUSTOM' && chunk.name === 'structured-output.complete') {
214
406
  const result = chunk.value.object // ✅ typed and validated
215
407
  }
216
408
  }
217
409
  ```
218
410
 
219
- If you need progressive _parsed_ state (e.g. show fields as they arrive), use a partial-JSON parser on the accumulated `raw` string at render time — but do NOT treat the result as schema-validated; only the terminal event is.
411
+ If you need progressive parsed state in a non-React environment, use a partial-JSON parser on the accumulated raw string at render time — but do NOT treat the result as schema-validated; only the terminal event is. In `useChat`, this is already done for you (`partial` field on Pattern 4).
220
412
 
221
413
  Source: maintainer interview
222
414
 
@@ -290,5 +482,6 @@ Source: maintainer interview
290
482
 
291
483
  ## Cross-References
292
484
 
293
- - See also: ai-core/adapter-configuration/SKILL.md -- Adapter handles structured output strategy transparently
294
- - See also: ai-core/chat-experience/SKILL.md -- Consuming `StreamChunk` events on the client (the streaming variant uses the same chunk model plus the terminal `structured-output.complete` custom event)
485
+ - See also: **ai-core/chat-experience/SKILL.md** — Base `useChat` surface; the structured-output additions documented here layer on top.
486
+ - See also: **ai-core/adapter-configuration/SKILL.md** — Adapter handles structured-output strategy transparently.
487
+ - See also: **ai-core/tool-calling/SKILL.md** — Combine `tools` with `outputSchema` for an agent loop that runs tools first and returns a typed object. Tool-approval and client-tool flows compose with structured runs without extra wiring; see [docs/structured-outputs/with-tools.md](https://github.com/TanStack/ai/blob/main/docs/structured-outputs/with-tools.md).
@@ -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,
@@ -2045,7 +2045,100 @@ async function* runStreamingStructuredOutputImpl<TSchema extends SchemaInput>(
2045
2045
  outputSchema: jsonSchema,
2046
2046
  })
2047
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
+
2048
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
+
2049
2142
  if (
2050
2143
  chunk.type === EventType.CUSTOM &&
2051
2144
  chunk.name === 'structured-output.complete'
@@ -2065,10 +2158,15 @@ async function* runStreamingStructuredOutputImpl<TSchema extends SchemaInput>(
2065
2158
  ...chunk,
2066
2159
  // Forward `reasoning` through schema validation so consumers that
2067
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.
2068
2163
  value: {
2069
2164
  object: validated,
2070
2165
  raw: value.raw,
2071
2166
  ...(value.reasoning ? { reasoning: value.reasoning } : {}),
2167
+ ...(structuredMessageId
2168
+ ? { messageId: structuredMessageId }
2169
+ : {}),
2072
2170
  },
2073
2171
  }
2074
2172
  continue
@@ -2100,6 +2198,18 @@ async function* runStreamingStructuredOutputImpl<TSchema extends SchemaInput>(
2100
2198
  return
2101
2199
  }
2102
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
+ }
2103
2213
  yield chunk
2104
2214
  continue
2105
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
@@ -284,6 +292,24 @@ function buildAssistantMessages(uiMessage: UIMessage): Array<ModelMessage> {
284
292
  }
285
293
  break
286
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
+
287
313
  default:
288
314
  break
289
315
  }