You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix(sdk): persist the message being answered at the start of a turn
Reloading a chat while the agent is still answering now shows the
message being answered. The incoming message was previously persisted
only once the turn finished, so a refresh mid-answer rendered the reply
with no question above it.
The runtime now writes the message at the start of the turn, carrying
the previous turn's stream cursors so a mid-answer reload still resumes
from the last completed turn. That write is not awaited before the model
runs. The output stream is held on it instead, so no part of the answer
reaches the frontend before the message is durable, and time to first
token is unchanged.
The same ordering is available to your own writes as
`chat.deferBeforeOutput()`:
```ts
onTurnStart: async ({ chatId, uiMessages }) => {
chat.deferBeforeOutput(
db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } })
);
},
```
It runs alongside the model like `chat.defer()`, but the answer waits
for it, so the next page load always sees the write. It orders the write
against what the frontend can see and not against the model, so a write
that a tool reads back during the same turn still needs to be awaited.
Mono-RevId: a84c08af51b376f5a49091d001ed1ec59149881f
Reloading a chat while the agent is still answering now shows the message being answered. Previously the incoming message was only persisted once the turn finished, so a refresh mid-answer showed the reply arriving with no question above it.
6
+
7
+
Adds `chat.deferBeforeOutput()` for app-owned writes that the next page load has to see. Like `chat.defer()` the work is not awaited by the hook that registers it, so it runs alongside the model and costs no time to first token, but the answer is held until it lands. Use it for the conversation or message write you previously had to `await` in `onTurnStart`, as long as nothing else in the turn reads that write back: it orders the write against what the frontend can see, not against the model, so a tool that reads the same row still needs an awaited write.
`chat.defer()` can be called from anywhere during a turn: hooks, `run()`, or nested helpers. All deferred promises are collected and awaited together before `onTurnComplete`.
180
180
181
181
<Warning>
182
-
**Don't use `chat.defer()` for the message-history write in `onTurnStart`.**That write must land *before* the model starts streaming, otherwise a mid-stream page refresh will read `[]` from your DB and lose the user's message from the rendered conversation. See [Database persistence: `onTurnStart`](/ai-chat/patterns/database-persistence#onturnstart). Reserve`chat.defer` for writes whose timing has no resume implication.
182
+
**Don't use `chat.defer()` for the message-history write in `onTurnStart`.**`chat.defer` only promises the work finished before`onTurnComplete`, which is long after the answer started streaming. A page refresh in between reads `[]` from your DB and loses the user's message from the rendered conversation. Use [`chat.deferBeforeOutput()`](#chat-deferbeforeoutput) for that write instead, and reserve`chat.defer` for writes whose timing has no resume implication.
183
183
</Warning>
184
184
185
+
## `chat.deferBeforeOutput`
186
+
187
+
`chat.deferBeforeOutput()` is `chat.defer()` for work the next page load has to see. The work starts immediately and the hook does not await it, so it runs alongside the model and costs no time to first token. The difference is that the output stream waits for it: no part of the answer is written to the session until it settles.
188
+
189
+
That ordering is the point. A reader that can see the answer can also see whatever the work persisted, so a refresh mid-answer never renders a reply to a question that is missing.
190
+
191
+
```ts
192
+
exportconst myChat =chat.agent({
193
+
id: "my-chat",
194
+
onTurnStart: async ({ chatId, uiMessages }) => {
195
+
// The next page load has to see this, so the answer waits for it.
returnstreamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
202
+
},
203
+
});
204
+
```
205
+
206
+
Use it for the conversation write, a message insert, or anything else the frontend reads back on reload. Keep analytics, audit logs and search-index updates on `chat.defer`.
207
+
208
+
<Warning>
209
+
**This is not a consistency barrier for the turn.** It orders the write against what the frontend can see, and nothing else. The work is still in flight while the model runs, so a tool, a `prepareStep`, or another service reading the same row during the turn can still see the state as it was before the write.
210
+
211
+
If the turn's own code reads the write back, `await` it instead:
212
+
213
+
```ts
214
+
onTurnStart: async ({ chatId, uiMessages }) => {
215
+
// A tool later in this turn reads this row, so the turn has to wait for it.
That costs time to first token, which is the price of the stronger guarantee. `chat.deferBeforeOutput` is for writes whose only reader is the next page load.
221
+
</Warning>
222
+
223
+
A registered promise that rejects, or that runs longer than the internal timeout, lets the answer through rather than stalling the conversation. It is a best-effort ordering guarantee, not a lock.
224
+
225
+
||`chat.defer()`|`chat.deferBeforeOutput()`|
226
+
|---|---|---|
227
+
|**Awaited by the hook**| No | No |
228
+
|**Costs time to first token**| No | No |
229
+
|**Answer waits for it**| No | Yes |
230
+
|**Settled by**| Before `onTurnComplete`| Before the first chunk reaches the session |
231
+
|**Read back by tools in the same turn**| No | No |
232
+
|**Use for**| Analytics, audit logs, index writes | Conversation and message writes the frontend reads back |
233
+
234
+
<Note>
235
+
If your agent persists through a [transcript storage](/ai-chat/transcript-storage), the runtime already does this for the incoming message on your behalf. `chat.deferBeforeOutput` is for writes your app owns on top of that.
Copy file name to clipboardExpand all lines: docs/ai-chat/patterns/database-persistence.mdx
+18-7Lines changed: 18 additions & 7 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -56,24 +56,34 @@ If you skip preload, do the equivalent in **`onChatStart`** when **`preloaded`**
56
56
57
57
### `onTurnStart`
58
58
59
-
-**`await`** persist **`uiMessages`** (full accumulated history including the new user turn) **before** the hook returns — `chat.agent` does not begin streaming until `onTurnStart` resolves, so this is what bounds "user message is durable before the stream".
59
+
- Persist **`uiMessages`** (full accumulated history including the new user turn). Two ways, and which one you want depends on who reads the write:
60
+
-**Nothing in the turn reads it back** (the usual case: the row exists for the next page load). Use [**`chat.deferBeforeOutput()`**](/ai-chat/background-injection#chat-deferbeforeoutput). The write runs alongside the model, so it adds nothing to time to first token, and no part of the answer reaches the frontend until it lands. That bounds "user message is durable before anything can render the answer".
61
+
-**A tool, a `prepareStep`, or another service reads it back during the turn.****`await`** it. `chat.deferBeforeOutput` orders the write against the frontend, not against the model, so the turn's own code can still see pre-write state. Awaiting costs time to first token and is the only thing that bounds "durable before the model starts".
60
62
61
63
<Warning>
62
-
**Don't use [`chat.defer()`](/ai-chat/background-injection#chat-defer-standalone) for the message write here.**`chat.defer` is fire-and-forget — the hook resolves before the write lands and the stream starts immediately. If the user refreshes mid-stream, the next page load reads `[]` from your DB, the resumed SSE stream pushes the assistant into an empty array, and the user's message disappears from the rendered conversation forever.
64
+
**Don't use [`chat.defer()`](/ai-chat/background-injection#chat-defer-standalone) for the message write here.**`chat.defer` is fire-and-forget: the hook resolves, the stream starts immediately, and the write is only guaranteed before `onTurnComplete`. If the user refreshes in between, the next page load reads `[]` from your DB, the resumed SSE stream pushes the assistant into an empty array, and the user's message disappears from the rendered conversation.
63
65
64
66
```ts
65
-
// ❌ Bad — non-blocking write, mid-stream refresh drops the user message.
67
+
// ❌ Bad: the answer can stream before the write lands.
`chat.defer` is for writes whose timing doesn't matter for resume — analytics, audit logs, search-index updates, etc. Anything the next page load reads needs to land before the stream begins.
86
+
`chat.defer` is for writes whose timing doesn't matter for resume: analytics, audit logs, search-index updates. Anything the next page load reads belongs on `chat.deferBeforeOutput`.
77
87
</Warning>
78
88
79
89
### `onTurnComplete`
@@ -152,8 +162,9 @@ chat.agent({
152
162
},
153
163
154
164
onTurnStart: async ({ chatId, uiMessages }) => {
155
-
// Awaited, not chat.defer — see the warning in `onTurnStart` above.
@@ -131,11 +131,16 @@ A changeset carries the same save two ways, and a storage uses whichever suits i
131
131
132
132
`transcript` is the whole conversation as it stands after those changes, `entries` plus `state`. A store that keeps the conversation as one document (object storage, a key-value store, a JSON column) writes it as-is and keeps no state of its own between saves. The default storage is exactly that: it serialises `transcript` and rewrites the blob.
133
133
134
-
The changes are the intent, spelled out. A normal turn is two `put`s, the user's message and the assistant's answer. A steering message the user sent mid-turn is another `put` in the same changeset. An undo through `chat.history.slice(0, -2)` is one `truncateAfter`. A regenerate is a `truncateAfter` and a `put`. A tool approval that updates the assistant message in place is one `put` for that id. Messages are addressed by id; how you order rows is your concern.
134
+
The changes are the intent, spelled out. A normal turn is two `put`s, the user's message and the assistant's answer, split across the turn's two saves. A steering message the user sent mid-turn is another `put` in the same changeset. An undo through `chat.history.slice(0, -2)` is one `truncateAfter`. A regenerate is a `truncateAfter` and a `put`. A tool approval that updates the assistant message in place is one `put` for that id. Messages are addressed by id; how you order rows is your concern.
135
+
136
+
Every turn saves twice. The `turn-start` save carries the message being answered, before the model runs. The `turn-complete` save carries the answer. Both `put` the same user message id, and a `put` upserts, so a storage that applies changes in order needs no special handling for the repeat.
137
+
138
+
The `turn-start` save is what makes a reload during an answer show the question that is being answered. It leaves `cursors` on the previous turn's position, because the answer's own cursor does not exist yet, so a reload mid-answer still resumes from the last completed turn rather than skipping chunks it never received.
135
139
136
140
A few properties worth knowing:
137
141
138
-
- Saves happen after the turn's answer has reached the browser, so they never delay the response. The runtime awaits each `save` before the run suspends.
142
+
- The `turn-start` save runs alongside the model rather than before it, so it costs no time to first token. Nothing from the turn reaches the browser until it settles, which is what makes the question durable before the answer can render. A save that fails or runs long lets the answer through rather than stalling the conversation.
143
+
- The `turn-complete` save happens after the turn's answer has reached the browser, so it never delays the response. The runtime awaits each `save` before the run suspends.
139
144
- A `save` that throws is logged and the turn continues. The changes fold into the next changeset, and every change is idempotent, so a retried changeset converges on the same result.
140
145
- A `load` that throws boots the run from the durable stream's recent tail rather than failing.
0 commit comments