Skip to content

Commit 9868110

Browse files
ericallamTrigger.dev RepoOps
authored andcommitted
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
1 parent 94cb791 commit 9868110

11 files changed

Lines changed: 691 additions & 64 deletions

.changeset/tidy-donkeys-shave.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
---
4+
5+
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.

docs/ai-chat/background-injection.mdx

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,9 +179,62 @@ export const myChat = chat.agent({
179179
`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`.
180180

181181
<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.
183183
</Warning>
184184

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+
export const 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.
196+
chat.deferBeforeOutput(
197+
db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } })
198+
);
199+
},
200+
run: async ({ messages, signal, streamText }) => {
201+
return streamText({ 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.
216+
await db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } });
217+
},
218+
```
219+
220+
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.
236+
</Note>
237+
185238
## How it differs from pending messages
186239

187240
| | `chat.inject()` | [Pending messages](/ai-chat/pending-messages) |

docs/ai-chat/patterns/database-persistence.mdx

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,24 +56,34 @@ If you skip preload, do the equivalent in **`onChatStart`** when **`preloaded`**
5656

5757
### `onTurnStart`
5858

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".
6062

6163
<Warning>
62-
**Don't use [`chat.defer()`](/ai-chat/background-injection#chat-defer-standalone) for the message write here.** `chat.defer` is fire-and-forgetthe 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.
6365

6466
```ts
65-
// ❌ Bad — non-blocking write, mid-stream refresh drops the user message.
67+
// ❌ Bad: the answer can stream before the write lands.
6668
onTurnStart: async ({ chatId, uiMessages }) => {
6769
chat.defer(db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } }));
6870
},
6971

70-
// ✅ Good — awaited, durable before the model starts.
72+
// ✅ Good: runs alongside the model, and the answer waits for it.
73+
onTurnStart: async ({ chatId, uiMessages }) => {
74+
chat.deferBeforeOutput(
75+
db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } })
76+
);
77+
},
78+
79+
// ✅ Required when a tool or prepareStep in this turn reads the row back.
80+
// Slower: awaiting blocks the model call itself.
7181
onTurnStart: async ({ chatId, uiMessages }) => {
7282
await db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } });
7383
},
7484
```
7585

76-
`chat.defer` is for writes whose timing doesn't matter for resumeanalytics, 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`.
7787
</Warning>
7888

7989
### `onTurnComplete`
@@ -152,8 +162,9 @@ chat.agent({
152162
},
153163

154164
onTurnStart: async ({ chatId, uiMessages }) => {
155-
// Awaited, not chat.defer — see the warning in `onTurnStart` above.
156-
await saveConversationMessages(chatId, uiMessages);
165+
// Held before output, not chat.defer. See the warning in `onTurnStart` above.
166+
// Nothing in this turn reads the row back; await it if yours does.
167+
chat.deferBeforeOutput(saveConversationMessages(chatId, uiMessages));
157168
},
158169

159170
onTurnComplete: async ({ chatId, uiMessages, chatAccessToken, lastEventId }) => {

docs/ai-chat/transcript-storage.mdx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ type TranscriptStorage<TClientData = unknown> = {
9292
ctx: TaskRunContext;
9393
},
9494
changeset: {
95-
reason: "turn-complete" | "turn-error" | "action" | "compaction" | "recovery";
95+
reason: "turn-start" | "turn-complete" | "turn-error" | "action" | "compaction" | "recovery";
9696
changes: TranscriptChange[];
9797
transcript: { entries: Array<{ id: string; final: boolean; message: UIMessage }>; state: unknown | null };
9898
cursors?: { lastOutEventId?: string; lastInEventId?: string };
@@ -131,11 +131,16 @@ A changeset carries the same save two ways, and a storage uses whichever suits i
131131

132132
`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.
133133

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.
135139

136140
A few properties worth knowing:
137141

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.
139144
- 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.
140145
- A `load` that throws boots the run from the durable stream's recent tail rather than failing.
141146

0 commit comments

Comments
 (0)