Skip to content

Commit 84809b0

Browse files
authored
docs(ai-chat): head-start persistence contract and a clearer sessions page (#3908)
## Summary Two documentation improvements for the AI chat docs. **Head-start persistence contract.** The fast starts page now documents what your hooks can rely on across a head-start handover: one stable assistant `messageId` for the whole turn, `onTurnComplete` as the canonical persistence point, reasoning parts flowing into durable history, and how Head Start composes with `hydrateMessages` (the first-turn history arrives as `incomingMessages`, and the runtime splices the warm partial onto the hydrated chain, deduplicated by id). The hydrate examples on the lifecycle hooks and database persistence pages now upsert their conversation row, since head-start first turns run without a preload to create it. **Sessions page.** The page opened with "a durable, task-bound, bi-directional I/O channel pair", which reads as jargon and omitted run orchestration entirely. It now leads with the plain mental model (a pair of durable streams: input carries user messages, output carries everything the agent produces) plus the Session's role orchestrating runs, a diagram, a minimal runnable example, and a section on the one-session-many-runs lifecycle. Documents behavior shipping in [#3907](#3907).
1 parent 51af9ae commit 84809b0

5 files changed

Lines changed: 108 additions & 8 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: 7 additions & 4 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 incoming messages for this turn. Usually 0-or-1 (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, this can contain 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

@@ -320,7 +323,7 @@ After the hook returns, the runtime overlays the wire's tool-state advances (`ou
320323
</Tip>
321324

322325
<Note>
323-
`incomingMessages` is **0-or-1-length** consistently. `submit-message` and tool-approval responses ship a single message; `regenerate-message`, continuations, and actions ship none. Patterns like [tool-result auditing](/ai-chat/patterns/tool-result-auditing) work the same regardless — iterate the array and the loop runs zero or one times.
326+
`incomingMessages` is **usually 0-or-1-length**. `submit-message` and tool-approval responses ship a single message; `regenerate-message`, continuations, and actions ship none. The exception is a [Head Start](/ai-chat/fast-starts#with-hydratemessages) first turn, where it carries the route handler's first-turn history. Patterns like [tool-result auditing](/ai-chat/patterns/tool-result-auditing) work the same regardless — iterate the array rather than assuming a single element.
324327
</Note>
325328

326329
## onTurnStart

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.

docs/ai-chat/patterns/persistence-and-replay.mdx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,13 @@ export const myChat = chat.agent({
142142
// See lifecycle-hooks for the full upsert pattern + rationale:
143143
// /ai-chat/lifecycle-hooks#hydratemessages
144144
if (upsertIncomingMessage(stored, { trigger, incomingMessages })) {
145-
await db.chat.update({ where: { id: chatId }, data: { messages: stored } });
145+
// Upsert, not update: head-start first turns run without a preload
146+
// to create the row.
147+
await db.chat.upsert({
148+
where: { id: chatId },
149+
create: { id: chatId, messages: stored },
150+
update: { messages: stored },
151+
});
146152
}
147153

148154
return stored;

docs/ai-chat/sessions.mdx

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,81 @@
11
---
22
title: "Sessions"
33
sidebarTitle: "Sessions"
4-
description: "The durable, task-bound, bi-directional I/O primitive that backs chat.agent — sessions.list / open / start / close plus the SessionHandle (in/out) API."
4+
description: "A Session is a pair of durable streams — input carries your users' messages to the agent, output carries everything the agent produces back — plus orchestration of the runs that process them."
55
---
66

77
import RcBanner from "/snippets/ai-chat-rc-banner.mdx";
88

99
<RcBanner />
1010

11-
A **Session** is a durable, task-bound, bi-directional I/O channel pair. It outlives any single run: a Session row is keyed on a stable `externalId` (e.g. `chatId`), holds the conversation's identity across run boundaries, and exposes two realtime streams — `.in` (clients → task) and `.out` (task → clients).
11+
**A Session is a pair of durable streams.** The input stream (`.in`) carries incoming user messages to your task. The output stream (`.out`) carries everything the agent produces back to your clients: AI generation parts (text, reasoning, tool calls) and any custom data parts you write.
12+
13+
Sessions also **orchestrate the runs that process those streams**. A Session is keyed on your stable id (`externalId` — for chat, the `chatId`) and owns its current run: when a run suspends, idles out, or hands off to a new version, the Session starts or swaps to a fresh run and the streams carry on. Clients keep sending and reading against the same id; they never know a run changed underneath.
14+
15+
```mermaid
16+
flowchart LR
17+
C[Browser / backend clients] -- "user messages" --> IN([Session .in])
18+
IN --> R["current run<br/>(runs come and go)"]
19+
R -- "text, reasoning, tool calls,<br/>data parts" --> OUT([Session .out])
20+
OUT --> C
21+
```
1222

1323
`chat.agent` is built on Sessions. You can also use them directly for any pattern that needs durable bi-directional streaming across runs: long-lived agent inboxes, multi-step approval flows, server-to-server pipelines that survive worker restarts.
1424

25+
## A minimal example
26+
27+
A task that echoes whatever lands on its input stream, and a backend that starts the session, sends a message, and reads the reply:
28+
29+
```ts trigger/inbox.ts
30+
import { task, sessions } from "@trigger.dev/sdk";
31+
32+
export const inboxAgent = task({
33+
id: "inbox-agent",
34+
run: async (payload: { sessionId: string }) => {
35+
const session = sessions.open(payload.sessionId);
36+
37+
while (true) {
38+
// Suspends the run (no compute billed) until a record arrives.
39+
const next = await session.in.wait<{ text: string }>({ timeout: "1h" });
40+
if (!next.ok) return;
41+
await session.out.append({ type: "reply", text: `echo: ${next.output.text}` });
42+
}
43+
},
44+
});
45+
```
46+
47+
```ts Your backend
48+
import { sessions } from "@trigger.dev/sdk";
49+
50+
// Atomically create the session AND trigger its first run.
51+
await sessions.start({
52+
type: "inbox",
53+
externalId: userId,
54+
taskIdentifier: "inbox-agent",
55+
triggerConfig: { basePayload: { sessionId: userId } },
56+
});
57+
58+
const session = sessions.open(userId);
59+
await session.in.send({ text: "hello" });
60+
61+
const stream = await session.out.read({ signal: AbortSignal.timeout(30_000) });
62+
for await (const chunk of stream) {
63+
console.log(chunk); // { type: "reply", text: "echo: hello" }
64+
}
65+
```
66+
67+
The run can suspend, crash, or be replaced between the `send` and the `read` — the streams are durable, so nothing is lost and the client code doesn't change.
68+
69+
## Sessions and runs
70+
71+
One Session spans many runs over its lifetime. The Session row tracks `currentRunId`; the runs do the work:
72+
73+
- **First run**: created atomically by `sessions.start` (no gap where the session exists but nothing is listening).
74+
- **Idle suspend**: a run blocked on `in.wait` suspends and frees compute. A new record on `.in` wakes it.
75+
- **Continuation**: when a run ends (idle timeout, `chat.endRun`, a crash, a version upgrade), the next incoming record triggers a fresh run against the same Session. The new run picks up the streams where the old one left off.
76+
77+
This is what makes a Session the durable identity for a conversation: runs are an execution detail, the Session (and its `externalId`) is what your clients address. See [How it works](/ai-chat/how-it-works) for how `chat.agent` drives this loop.
78+
1579
## When to reach for Sessions directly
1680

1781
`chat.agent` handles 90% of chat-shaped workloads — message accumulation, the turn loop, stop signals, lifecycle hooks. Use the raw `sessions` API when you need any of:

0 commit comments

Comments
 (0)