Skip to content

Commit efa1751

Browse files
committed
docs(ai-chat): onChatStart once-per-chat contract + continuation run handling
onChatStart now fires only on the chat's very first user message — not on every run boot. Continuation runs (post-endRun, post-waitpoint-timeout, chat.requestUpgrade) and OOM-retry attempts skip the hook, so chat-start setup work is guaranteed to run exactly once per chat. Per-turn work that should fire on continuations belongs in onTurnStart. Also documents the server-side continuation-overrides behavior (sticky `message` / `messages` / `trigger` are stripped from basePayload on continuation runs) and the mock harness's new `continuation` / `mode: "continuation"` options for offline testing. - lifecycle-hooks: rewrite onChatStart section, mark continuation / previousRunId deprecated on ChatStartEvent, clarify onTurnStart still fires on continuation turns - client-protocol: basePayload no longer replays first-turn-only fields on continuations; client doesn't need to send continuation / previousRunId — server detects automatically - upgrade-guide: drop the messages.length === 0 → continuation: false migration; new guidance is to drop the continuation gate entirely - testing: full mockChatAgent options table + Boot modes table + new 'Testing continuation runs' pattern with example - changelog: replace onChatStart.messages bullet with the once-per-chat contract + continuation-wait branch entry on the pending prerelease - database-persistence pattern: drop the continuation branch from the onChatStart walkthrough - backend, reference: one-line updates to match
1 parent 36a58d1 commit efa1751

8 files changed

Lines changed: 149 additions & 42 deletions

File tree

docs/ai-chat/backend.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ async function runAgentLoop(messages: ModelMessage[]) {
9393

9494
- [Lifecycle hooks](/ai-chat/lifecycle-hooks)`onPreload`, `onChatStart`, `onValidateMessages`, `hydrateMessages`, `onTurnStart`, `onBeforeTurnComplete`, `onTurnComplete`, `onChatSuspend` / `onChatResume`, `exitAfterPreloadIdle`, plus how `ctx` plumbs through every callback.
9595

96-
**Per-turn order:** `onValidateMessages``hydrateMessages``onChatStart` (turn 0 only) → `onTurnStart``run()``onBeforeTurnComplete``onTurnComplete`.
96+
**Per-turn order:** `onValidateMessages``hydrateMessages``onChatStart` (chat's first message only) → `onTurnStart``run()``onBeforeTurnComplete``onTurnComplete`.
9797

9898
### Using prompts
9999

docs/ai-chat/changelog.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ The wire is now **delta-only**: each `.in/append` carries at most one new `UIMes
2626
- **Run boot**: when `hydrateMessages` is not registered, the runtime reads `packets/{projectRef}/{envSlug}/sessions/{sessionId}/snapshot.json` from object storage and replays any `session.out` chunks landed since the snapshot's cursor. Snapshot writes happen after every `onTurnComplete`, awaited so they survive an idle suspend.
2727
- **`hydrateMessages` short-circuit**: registering the hook skips snapshot read/write and replay entirely. Customer is the source of truth for history, same as today.
2828
- **`hydrateMessages.incomingMessages`**: now consistently 0-or-1-length across every trigger type. Previously `regenerate-message` and continuations occasionally shipped full history; they now ship none.
29-
- **`onChatStart.messages`**: on a continuation, reflects the **full prior conversation** loaded from snapshot+replay (was empty-or-tiny on a fresh run before).
29+
- **`onChatStart` is now once-per-chat**: fires only on the chat's very first user message; does NOT fire on continuation runs (post-`endRun`, post-waitpoint-timeout, post-`chat.requestUpgrade`) or on OOM-retry attempts. The `continuation` and `previousRunId` fields on `ChatStartEvent` are now `@deprecated` (always `false` / `undefined` when the hook fires). Drop any `if (continuation) return;` gates from `onChatStart` — they're now unreachable. For per-turn setup that runs on continuations too, move to `onTurnStart`.
30+
- **Continuation boot payload**: the server now strips `message` / `messages` / `trigger` from the cached `basePayload` on continuation runs, and the SDK enters a new continuation-wait branch that waits silently on `session.in` for the next user message. Fixes a phantom-turn bug where stale boot-payload fields were replayed on every resume.
3031
- **OOM-retry boot**: uses the snapshot's `lastOutTimestamp` as the `session.in` cutoff, saving one stream subscription per retry.
3132
- **Built-in transports**: `TriggerChatTransport`, `AgentChat`, mid-stream pending-message handling, and `chat.headStart` route handler all updated to the slim shape. Existing customer code calling `transport.sendMessage(...)` / `agentChat.sendMessage(...)` is unaffected — the change is below those surfaces.
3233

docs/ai-chat/client-protocol.mdx

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ Pick `"preload"` when the UI has rendered but the user hasn't typed (warms the a
189189
| --- | --- | --- |
190190
| `type` | `string` | Discriminator. Use `"chat.agent"`. |
191191
| `taskIdentifier` | `string` | The `id` you passed to `chat.agent({ id: ... })` — e.g. `"ai-chat"`. |
192-
| `triggerConfig.basePayload` | `object` | The wire payload sent to **every run** triggered by this session — both the first run (created by this call) and any continuation runs. Same shape as [`ChatTaskWirePayload`](#chattaskwirepayload) in Step 3 (the type used by `.in/append`). See [What goes in `basePayload`](#what-goes-in-basepayload) below. |
192+
| `triggerConfig.basePayload` | `object` | The wire payload sent to the **first run** created by this call. Same shape as [`ChatTaskWirePayload`](#chattaskwirepayload) in Step 3. Durable fields (`chatId`, `metadata`, `idleTimeoutInSeconds`, `sessionId`) flow through to continuation runs too; first-turn-only fields (`message`, `trigger`) are stripped on continuations — those are session-create concerns and don't replay. See [What goes in `basePayload`](#what-goes-in-basepayload) below. |
193193
194194
### Optional fields
195195
@@ -515,7 +515,7 @@ Signals that the agent cannot handle this message on its current version and a n
515515
516516
When you receive this chunk:
517517
1. Close the stream reader.
518-
2. Re-send the user's message on `.in/append` with `continuation: true` and `previousRunId` in the payload (see [Continuations](#continuations)). The append handler triggers a fresh run on the same session — `sessionId` and `publicAccessToken` are reused, only `runId` changes.
518+
2. Re-send the user's message on `.in/append` as a normal `submit-message` (no extra fields needed — see [Continuations](#continuations)). The append handler triggers a fresh run on the same session — `sessionId` and `publicAccessToken` are reused, only `runId` changes.
519519
3. Resubscribe to `/realtime/v1/sessions/{sessionId}/out`.
520520
521521
The user's message is not lost — the agent processes it on the new version. The built-in clients handle this transparently.
@@ -642,7 +642,18 @@ type ChatTaskWirePayload<TMessage extends UIMessage = UIMessage, TMetadata = unk
642642
*/
643643
metadata?: TMetadata;
644644
action?: unknown;
645+
/**
646+
* Informational — the server sets this automatically on continuation
647+
* runs (when the prior run is dead). Clients don't need to send it.
648+
* Read by the agent's boot gate to skip `onChatStart` and trigger
649+
* snapshot read + replay.
650+
*/
645651
continuation?: boolean;
652+
/**
653+
* Informational — paired with `continuation: true`, set by the server
654+
* from the prior run's friendly ID. Surfaced to the agent in
655+
* `ctx.previousRunId`. Clients don't need to send it.
656+
*/
646657
previousRunId?: string;
647658
idleTimeoutInSeconds?: number;
648659
sessionId?: string;
@@ -843,7 +854,7 @@ See [Pending Messages](/ai-chat/pending-messages) for how to configure the agent
843854
844855
A run can end for several reasons: idle timeout, max turns reached, `chat.requestUpgrade()`, crash, or cancellation. When this happens, the session row stays alive — only the run is gone. The next message you append to `.in` automatically triggers a fresh run on the same session.
845856
846-
The wire shape is identical to a normal `submit-message`, plus a `continuation: true` flag so the agent's `onChatStart` hook can distinguish from a brand-new conversation:
857+
**Clients send the wire shape exactly as a normal `submit-message`** — the server detects the absent run and handles the continuation itself:
847858
848859
```json
849860
{
@@ -856,15 +867,21 @@ The wire shape is identical to a normal `submit-message`, plus a `continuation:
856867
},
857868
"chatId": "conversation-123",
858869
"trigger": "submit-message",
859-
"metadata": { "userId": "user-456" },
860-
"continuation": true,
861-
"previousRunId": "run_abc123"
870+
"metadata": { "userId": "user-456" }
862871
}
863872
}
864873
```
865874
866875
POST to the same `/realtime/v1/sessions/{sessionId}/in/append` URL with the same `publicAccessToken` you've been using — both stay valid across runs. The server detects the absent run, triggers a new one on the session's `triggerConfig`, and the agent boots, reads the snapshot from the prior run's last turn, replays any tail, and continues. Only `runId` (which you can read from later `trigger:turn-complete` chunks if you need it) changes.
867876
877+
<Note>
878+
**You don't need to track `runId` or set `continuation: true` / `previousRunId` yourself.** The server detects continuation when the prior run is in a terminal state and sets those fields on the new run's boot payload automatically. The `continuation` and `previousRunId` fields on `ChatTaskWirePayload` are informational — used internally by the agent's boot path, never required from the client.
879+
</Note>
880+
881+
<Note>
882+
**`onChatStart` does NOT fire on continuation runs.** The hook is once-per-chat — it fires only on the chat's very first user message. Customers who want per-turn setup that also runs on continuation turns should use `onTurnStart` instead.
883+
</Note>
884+
868885
<Tip>
869886
This is how [version upgrades](/ai-chat/patterns/version-upgrades) work transparently — the agent calls `chat.requestUpgrade()`, the run exits, and the client's next message triggers a continuation on the new version. Same session, new run, same snapshot.
870887
</Tip>

docs/ai-chat/lifecycle-hooks.mdx

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ description: "Hook into every stage of a chat agent's run: preload, turn start,
66

77
`chat.agent({ ... })` accepts a set of lifecycle hooks for persisting state, validating input, transforming messages, and reacting to suspension and resumption. They fire in a fixed order around each turn.
88

9-
**Per-turn order:** `onValidateMessages``hydrateMessages``onChatStart` (turn 0 only) → `onTurnStart``run()``onBeforeTurnComplete``onTurnComplete`.
9+
**Per-turn order:** `onValidateMessages``hydrateMessages``onChatStart` (chat's first message only) → `onTurnStart``run()``onBeforeTurnComplete``onTurnComplete`.
1010

1111
**Suspend / resume:** `onChatSuspend` fires when the run transitions from idle to suspended (waiting on the next message); `onChatResume` fires on wake.
1212

@@ -63,20 +63,26 @@ Every lifecycle callback receives a `writer`, a lazy stream writer that lets you
6363

6464
## onChatStart
6565

66-
Fires once on the first turn (turn 0) before `run()` executes. Use it to create a chat record in your database.
66+
Fires **exactly once per chat**, on the very first user message of the chat's lifetime, before `run()` executes. Use it for one-time chat setup — create the Chat DB row, initialize per-chat in-memory state, mint resources tied to the chat's lifetime.
6767

68-
The `continuation` field tells you whether this is a brand new chat or a continuation of an existing one (where the previous run timed out or was cancelled). The `preloaded` field tells you whether `onPreload` already ran.
68+
`onChatStart` does **not** fire on:
69+
70+
- **Continuation runs** — a new run picking up an existing session after the prior run ended (`chat.endRun`, waitpoint timeout, `chat.requestUpgrade`). The chat already started.
71+
- **OOM-retry attempts** — same chat, same conversation, just on a larger machine.
72+
73+
If you need per-turn setup that runs on every turn including continuations, use [`onTurnStart`](#onturnstart) instead.
74+
75+
The `preloaded` field tells you whether [`onPreload`](#onpreload) already ran for this chat — useful for skipping setup work that's already done.
6976

7077
<Note>
71-
On a continuation (`continuation: true`), the `messages` field reflects the **full prior conversation** — the runtime rebuilds it from a durable snapshot plus a `session.out` replay before `onChatStart` runs. On a brand-new chat or a preloaded run that hasn't received its first turn yet, `messages` is empty. See [Persistence and replay](/ai-chat/patterns/persistence-and-replay) for the underlying mechanism.
78+
Because `onChatStart` fires only on the chat's first ever message, `messages` is either empty (when no message exists yet — e.g. a preloaded run that hasn't received its first turn) or contains just the first user message. There's no prior history to load here.
7279
</Note>
7380

7481
```ts
7582
export const myChat = chat.agent({
7683
id: "my-chat",
77-
onChatStart: async ({ chatId, clientData, continuation, preloaded }) => {
84+
onChatStart: async ({ chatId, clientData, preloaded }) => {
7885
if (preloaded) return; // Already set up in onPreload
79-
if (continuation) return; // Chat record already exists
8086

8187
const { userId } = clientData as { userId: string };
8288
await db.chat.create({
@@ -170,7 +176,7 @@ export const myChat = chat.agent({
170176
});
171177
```
172178

173-
**Lifecycle position:** `onValidateMessages`**`hydrateMessages`**`onChatStart` (turn 0) → `onTurnStart``run()`
179+
**Lifecycle position:** `onValidateMessages`**`hydrateMessages`**`onChatStart` (chat's first message only) → `onTurnStart``run()`
174180

175181
After the hook returns, any incoming wire message whose ID matches a hydrated message is auto-merged. This makes [tool approvals](/ai-chat/frontend#tool-approvals) work transparently with hydration.
176182

@@ -188,7 +194,7 @@ After the hook returns, any incoming wire message whose ID matches a hydrated me
188194

189195
## onTurnStart
190196

191-
Fires at the start of every turn, after message accumulation and `onChatStart` (turn 0), but **before** `run()` executes. Use it to persist messages before streaming begins so a mid-stream page refresh still shows the user's message.
197+
Fires at the start of **every turn** — including the first turn of a continuation run, where `onChatStart` doesn't fire. Runs after message accumulation and (when applicable) `onChatStart`, but **before** `run()` executes. Use it to persist messages before streaming begins so a mid-stream page refresh still shows the user's message.
192198

193199
| Field | Type | Description |
194200
| ----------------- | --------------------------------------------- | ----------------------------------------------- |

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

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,13 @@ When the user triggers [preload](/ai-chat/fast-starts#preload), the run starts *
4040

4141
If you skip preload, do the equivalent in **`onChatStart`** when **`preloaded`** is false.
4242

43-
### `onChatStart` (turn 0, non-preloaded path)
43+
### `onChatStart` (chat's first message, non-preloaded path)
4444

45+
- Fires **once per chat**, on the very first user message. Does NOT fire on continuation runs (post-`endRun`, post-waitpoint-timeout, post-`chat.requestUpgrade`) or on OOM-retry attempts.
4546
- If **`preloaded`** is true, return early — **`onPreload`** already ran.
4647
- Otherwise mirror preload: user/context, conversation create, session upsert.
47-
- If **`continuation`** is true, the conversation row usually **already exists** (previous run ended or timed out); only update **session** fields so the **new** PAT and `lastEventId` are stored.
48+
- No need to gate the conversation create on `continuation` — it's always a brand-new chat at this point.
49+
- For continuation runs that need to refresh per-run state (new PAT, new `lastEventId`), do it in **`onTurnStart`** / **`onTurnComplete`** — both fire on every turn including the first turn of a continuation run.
4850

4951
### `onTurnStart`
5052

@@ -135,12 +137,11 @@ chat.agent({
135137
await upsertSession({ chatId, publicAccessToken: chatAccessToken });
136138
},
137139

138-
onChatStart: async ({ chatId, chatAccessToken, clientData, continuation, preloaded }) => {
140+
onChatStart: async ({ chatId, chatAccessToken, clientData, preloaded }) => {
139141
if (preloaded) return;
142+
// Fires once per chat — no continuation gate needed.
140143
await ensureUser(clientData.userId);
141-
if (!continuation) {
142-
await upsertConversation({ id: chatId, userId: clientData.userId /* ... */ });
143-
}
144+
await upsertConversation({ id: chatId, userId: clientData.userId /* ... */ });
144145
await upsertSession({ chatId, publicAccessToken: chatAccessToken });
145146
},
146147

docs/ai-chat/reference.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ Options for `chat.agent()`.
1414
| `run` | `(payload: ChatTaskRunPayload) => Promise<unknown>` | required | Handler for each turn |
1515
| `clientDataSchema` | `TaskSchema` || Schema for validating and typing `clientData` |
1616
| `onPreload` | `(event: PreloadEvent) => Promise<void> \| void` || Fires on preloaded runs before the first message |
17-
| `onChatStart` | `(event: ChatStartEvent) => Promise<void> \| void` || Fires on turn 0 before `run()` |
17+
| `onChatStart` | `(event: ChatStartEvent) => Promise<void> \| void` || Fires once per chat, on the very first user message. Does NOT fire on continuation runs or OOM-retries — see [onChatStart](/ai-chat/lifecycle-hooks#onchatstart). |
1818
| `onValidateMessages` | `(event: ValidateMessagesEvent) => UIMessage[] \| Promise<UIMessage[]>` || Validate/transform UIMessages before model conversion. See [onValidateMessages](/ai-chat/lifecycle-hooks#onvalidatemessages) |
1919
| `hydrateMessages` | `(event: HydrateMessagesEvent) => UIMessage[] \| Promise<UIMessage[]>` || Load message history from backend, replacing the linear accumulator. See [hydrateMessages](/ai-chat/lifecycle-hooks#hydratemessages) |
2020
| `actionSchema` | `TaskSchema` || Schema for validating custom actions sent via `transport.sendAction()`. See [Actions](/ai-chat/actions) |

0 commit comments

Comments
 (0)