Skip to content

Commit d2924d2

Browse files
committed
docs(ai-chat): document the head-start persistence and handover contract
Adds a persistence section to the fast starts page covering the stable assistant messageId across the handover, onTurnComplete as the canonical persistence point, reasoning parts surviving into durable history, and how Head Start composes with hydrateMessages. Switches the hydrate hook examples to upsert their conversation row, since head-start first turns run without a preload to create it.
1 parent 7b4443a commit d2924d2

3 files changed

Lines changed: 34 additions & 4 deletions

File tree

docs/ai-chat/fast-starts.mdx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,8 +524,27 @@ The handler keeps the SSE response open until the agent run signals turn-complet
524524
| Tool execution runs in | Trigger.dev agent run | Trigger.dev agent run |
525525
| Step 2+ LLM call runs in | Trigger.dev agent run | Trigger.dev agent run |
526526
| `onChatStart` / `onTurnStart` fire | After handover signal arrives | Normally |
527+
| `hydrateMessages` fires (if registered) | After handover, with the first-turn history as `incomingMessages` | Normally |
527528
| `onTurnComplete` fires | After turn finishes (handover) or skipped (handover-skip) | Normally |
528529

530+
### Persistence and the handover contract
531+
532+
A head-start turn persists exactly like a normal turn — the handover machinery is invisible to your hooks. The guarantees:
533+
534+
- **One stable assistant `messageId` across the whole turn.** The route handler generates the id, the handover signal carries it to the agent, and the agent's step 2+ stream reuses it — so the browser merges step 1 and step 2+ into a single assistant message, and you can merge-by-id when persisting.
535+
- **`onTurnComplete` is the canonical persistence point**, same as any turn. It carries the full assistant message under that one id: step-1 text, reasoning, and tool calls plus step-2+ tool results and text. The [database persistence](/ai-chat/patterns/database-persistence) patterns apply unchanged.
536+
- **Reasoning parts survive the handover.** When step 1 runs on an extended-thinking model, the reasoning streamed by your route handler lands in the durable session history (and `onTurnComplete`) under the same `messageId`, with provider metadata intact — Anthropic thinking signatures survive a replay back to the model. Step-2 reasoning appends to the same message rather than replacing it.
537+
538+
#### With `hydrateMessages`
539+
540+
Head Start composes with [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemessages). On the first turn, the hook receives the route handler's first-turn history as `incomingMessages` — the canonical upsert-and-return pattern persists the user message exactly as it would on a direct-trigger turn. The runtime splices the warm handler's partial assistant onto your hydrated chain after the hook returns, deduplicated by the assistant `messageId`, so your hook never needs to include the in-flight partial.
541+
542+
<Warning>
543+
**Hydrate hooks must upsert their conversation row, not update it.** Head-start turns skip preload entirely, so row-creating hooks (`onPreload`, or an `onChatStart` create) have not run when `hydrateMessages` first fires. A bare `UPDATE` against a missing row throws and errors the turn.
544+
</Warning>
545+
546+
Your hydrate hook shapes **model context**, not the transcript — dropping reasoning-only entries or unresolved tool rows from the returned chain is fine and does not affect what `onTurnComplete` persists or what the UI renders.
547+
529548
### The `chat.headStart` API
530549

531550
```ts

docs/ai-chat/lifecycle-hooks.mdx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@ Use this when the backend should be the source of truth for message history: abu
273273
| `chatId` | `string` | Chat session ID |
274274
| `turn` | `number` | Turn number (0-indexed) |
275275
| `trigger` | `"submit-message" \| "regenerate-message" \| "action"` | The trigger type for this turn |
276-
| `incomingMessages` | `UIMessage[]` | Validated wire messages from the frontend — 0-or-1-length (empty for actions, regenerates, and continuations; one element for normal `submit-message` and tool-approval responses) |
276+
| `incomingMessages` | `UIMessage[]` | Validated wire messages from the frontend — 0-or-1-length (empty for actions, regenerates, and continuations; one element for normal `submit-message` and tool-approval responses). On a [Head Start](/ai-chat/fast-starts#with-hydratemessages) first turn, carries the route handler's first-turn history |
277277
| `previousMessages` | `UIMessage[]` | Accumulated UI messages before this turn (`[]` on turn 0) |
278278
| `clientData` | Typed by `clientDataSchema` | Custom data from the frontend |
279279
| `continuation` | `boolean` | Whether this run is continuing an existing chat |
@@ -289,9 +289,12 @@ export const myChat = chat.agent({
289289
const stored = record?.messages ?? [];
290290

291291
if (upsertIncomingMessage(stored, { trigger, incomingMessages })) {
292-
await db.chat.update({
292+
// Upsert, not update: on a head-start first turn no preload ran,
293+
// so the row may not exist yet when this hook fires.
294+
await db.chat.upsert({
293295
where: { id: chatId },
294-
data: { messages: stored },
296+
create: { id: chatId, messages: stored },
297+
update: { messages: stored },
295298
});
296299
}
297300

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,13 @@ export const myChat = chat.agent({
191191
// advance onto the existing entry). See lifecycle hooks for the
192192
// full pattern: /ai-chat/lifecycle-hooks#hydratemessages
193193
if (upsertIncomingMessage(stored, { trigger, incomingMessages })) {
194-
await db.chat.update({ where: { id: chatId }, data: { messages: stored } });
194+
// Upsert, not update: on a head-start first turn no preload ran,
195+
// so the row may not exist yet when this hook fires.
196+
await db.chat.upsert({
197+
where: { id: chatId },
198+
create: { id: chatId, messages: stored },
199+
update: { messages: stored },
200+
});
195201
}
196202

197203
return stored;
@@ -217,6 +223,8 @@ export const myChat = chat.agent({
217223

218224
This replaces the `onTurnStart` persistence pattern — the hook handles both loading and persisting the new message in one place.
219225

226+
Hydration composes with [Head Start](/ai-chat/fast-starts#with-hydratemessages): on a head-start first turn the route handler's history arrives as `incomingMessages`, and the write path must be an upsert because no preload ran to create the row.
227+
220228
## Design notes
221229

222230
- **`chatId`** is stable for the life of a thread and is the only identifier the transport persists. Runs come and go (idle continuation, upgrade, cancel/restart) but the chat keeps its identity.

0 commit comments

Comments
 (0)