Skip to content

Commit 432ca69

Browse files
ci: apply automated fixes
1 parent ea82967 commit 432ca69

1 file changed

Lines changed: 9 additions & 11 deletions

File tree

src/blog/multi-turn-structured-output.md

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
---
22
title: 'Structured Output That Remembers Across Turns'
33
published: 2026-05-19
4-
excerpt: "useChat({ outputSchema }) used to keep one slot for partial/final, so multi-turn structured chats lost prior turns the moment a new one streamed in. Every assistant turn now carries its own typed StructuredOutputPart on its UIMessage. History is preserved by default, and the schema generic threads all the way down to messages[i].parts[j].data."
4+
excerpt: 'useChat({ outputSchema }) used to keep one slot for partial/final, so multi-turn structured chats lost prior turns the moment a new one streamed in. Every assistant turn now carries its own typed StructuredOutputPart on its UIMessage. History is preserved by default, and the schema generic threads all the way down to messages[i].parts[j].data.'
55
library: ai
66
authors:
77
- Alem Tuzlak
88
---
99

1010
![Structured Output That Remembers Across Turns](/blog-assets/multi-turn-structured-output/header.png)
1111

12-
You ask the LLM for a recipe. It plates up *Spaghetti Pomodoro* — title, cuisine, servings, ingredients, steps, all typed against your schema. Beautiful. You ask it to make the recipe vegan. A new recipe streams in.
12+
You ask the LLM for a recipe. It plates up _Spaghetti Pomodoro_ — title, cuisine, servings, ingredients, steps, all typed against your schema. Beautiful. You ask it to make the recipe vegan. A new recipe streams in.
1313

1414
Then the first one vanishes.
1515

@@ -21,14 +21,14 @@ This post walks through what changed, why it matters for any multi-turn UI, and
2121

2222
## The old shape
2323

24-
The previous `useChat({ outputSchema })` exposed two values that tracked the *current* run:
24+
The previous `useChat({ outputSchema })` exposed two values that tracked the _current_ run:
2525

2626
- `partial``DeepPartial<T>`, the progressively-parsed object as JSON streamed in
2727
- `final``T | null`, the validated object once `structured-output.complete` fired
2828

2929
On a single-turn extractor (paste a paragraph → get a typed `Person`), this was perfect. Field-by-field reveal as the JSON streamed, validated payload on terminal event, one render to consume both.
3030

31-
On a multi-turn chat it fell apart. `partial` and `final` were a *single slot*, scoped to whichever run was most recent. As soon as you called `sendMessage()` again, the previous turn's `final` was gone. The runtime had no place to keep it — the typed structured payload didn't live on the message itself, only in this transient hook state.
31+
On a multi-turn chat it fell apart. `partial` and `final` were a _single slot_, scoped to whichever run was most recent. As soon as you called `sendMessage()` again, the previous turn's `final` was gone. The runtime had no place to keep it — the typed structured payload didn't live on the message itself, only in this transient hook state.
3232

3333
The workaround we'd see in user code:
3434

@@ -50,7 +50,7 @@ Three problems with that:
5050
2. **The model can't see the history.** Your `recipes[]` array is local to the component, the wire layer never sees it. When the user types "now make the vegan one cheaper," the LLM has no idea what "the vegan one" refers to; it only sees the raw text of each prior turn, with the structured response stripped to whatever it landed on the original `TextPart` as. Multi-turn refinement collapses.
5151
3. **You lose the schema's type safety.** `recipes` is typed, but the moment you try to round-trip a prior recipe back into the conversation, you're stringifying a typed object into the wire payload by hand. The library can't help you because it doesn't know `recipes` exists.
5252

53-
The right place for this state is *on the message it came from*. That's what we shipped.
53+
The right place for this state is _on the message it came from_. That's what we shipped.
5454

5555
## The new shape: typed parts on every assistant message
5656

@@ -71,11 +71,11 @@ type StructuredOutputPart<TData = unknown> = {
7171
}
7272
```
7373
74-
The runtime routes `TEXT_MESSAGE_CONTENT` deltas (the streaming JSON bytes) into this part instead of building a `TextPart`. On the terminal `structured-output.complete` event, `status` flips to `'complete'` and `data` is populated with the validated object. Every assistant turn produces a *new* assistant message, which carries its *own* `structured-output` part. The previous turn's part is untouched.
74+
The runtime routes `TEXT_MESSAGE_CONTENT` deltas (the streaming JSON bytes) into this part instead of building a `TextPart`. On the terminal `structured-output.complete` event, `status` flips to `'complete'` and `data` is populated with the validated object. Every assistant turn produces a _new_ assistant message, which carries its _own_ `structured-output` part. The previous turn's part is untouched.
7575
76-
The hook-level `partial` and `final` still exist, they're derived from the *latest* assistant message's part now, instead of being a sticky slot. That means they read `{}` and `null` between `sendMessage()` and the first chunk (because no new assistant message exists yet), and they snap to the freshest turn's payload as it streams. The migration is zero, the same code that read `partial` / `final` for a single-turn extractor reads identical values in the new shape.
76+
The hook-level `partial` and `final` still exist, they're derived from the _latest_ assistant message's part now, instead of being a sticky slot. That means they read `{}` and `null` between `sendMessage()` and the first chunk (because no new assistant message exists yet), and they snap to the freshest turn's payload as it streams. The migration is zero, the same code that read `partial` / `final` for a single-turn extractor reads identical values in the new shape.
7777
78-
What changed is everything *else*. Walking `messages[]` now exposes the full history of typed objects:
78+
What changed is everything _else_. Walking `messages[]` now exposes the full history of typed objects:
7979
8080
```tsx
8181
type RecipePart = StructuredOutputPart<Recipe>
@@ -110,9 +110,7 @@ export const RecipeSchema = z.object({
110110
cuisine: z.string(),
111111
servings: z.number(),
112112
estimatedCostUsd: z.number(),
113-
ingredients: z.array(
114-
z.object({ item: z.string(), amount: z.string() }),
115-
),
113+
ingredients: z.array(z.object({ item: z.string(), amount: z.string() })),
116114
steps: z.array(z.string()),
117115
tips: z.array(z.string()),
118116
})

0 commit comments

Comments
 (0)