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
feat(core,sdk,webapp): a pinned chat session follows its pin
Deciding when to call chat.requestUpgrade() was entirely hand-written: thread a
build id through clientData, stash it with chat.local in onBoot, compare it in
onTurnStart. Both documented recipes were that dance, and the docs implied version
skew protection already covered it. It did not — re-pinning a session only
affected the NEXT run, so a live agent kept answering on the old build until it
happened to end.
A pinned session now hands over at the next turn boundary when its stored
externalDeploymentId stops naming the deployment the run is on. The comparison is
self-clearing: the successor runs under that id, so it never re-fires. Opt one
agent out with versionSkew: 'hold'.
Two cases never fire, and the second is load-bearing rather than defensive: an
unpinned session has nothing to compare, and a session using lockToVersion would
hand over, land on the same locked version and repeat forever, because
lockToVersion outranks the external id and requestUpgrade cannot escape it.
A handoff onto a deployment that has not landed parks the successor, which used to
leave the chat silent — swapSessionRun computed pendingVersion and the
end-and-continue route discarded it. The run now writes a pending-version control
record before exiting, and the transport surfaces it as the existing
run-pending-version event with source 'upgrade', so the handler the docs already
prescribe covers this case with no new client code. Manual requestUpgrade gets the
same signal.
Also corrects two docs claims: that skew protection made the upgrade recipe
unnecessary, and that a continuation starts on the latest version regardless of
the session's pin.
A pinned chat session now follows its pin on its own: when your app redeploys and re-pins the session, the agent hands the conversation over at the next turn boundary instead of the new pin only applying to the next run. This replaces writing that yourself with `clientData` and `chat.requestUpgrade()`. Set `versionSkew: "hold"` on an agent that should stay put. If a handoff lands on a deployment that hasn't finished building, the transport emits `run-pending-version` with `source: "upgrade"`.
Copy file name to clipboardExpand all lines: docs/ai-chat/patterns/version-upgrades.mdx
+47-11Lines changed: 47 additions & 11 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -9,11 +9,14 @@ Chat agent runs are pinned to the worker version they started on. When you deplo
9
9
`chat.requestUpgrade()` is the managed upgrade signal for `chat.agent()` and the `chat.createSession()` iterator. Fully hand-rolled custom agents use `chat.endAndContinue()` between turns to immediately hand the Session to a new run.
10
10
11
11
<Note>
12
-
If you use [version skew protection](/deployment/version-skew-protection#chat-sessions), most of
13
-
this page is done for you: sessions pin to the deployment matching the app build that started
14
-
them, so the agent and the frontend move together without a hand-maintained version. Read this
15
-
page for the cases skew protection doesn't cover — an agent that wants to leave its pin
16
-
mid-conversation, or a session that was never pinned.
12
+
If your sessions are pinned by [version skew
13
+
protection](/deployment/version-skew-protection#chat-sessions), you do not need this page to move a
14
+
conversation onto a new deployment. A pinned session follows its pin on its own: when the stored
15
+
`externalDeploymentId` stops naming the deployment a run is on, the agent hands over at the next
16
+
turn boundary. Set [`versionSkew: "hold"`](#staying-put) to turn that off for one agent.
17
+
18
+
Read on for the cases that are still yours to decide — leaving a pin for a version nobody named,
19
+
a session that was never pinned, and hand-rolled custom agents.
17
20
</Note>
18
21
19
22
## How it works
@@ -130,6 +133,13 @@ This pattern is useful when:
130
133
131
134
## Auto-detect from build ID (Next.js / Vercel)
132
135
136
+
<Warning>
137
+
You probably don't need this any more. If your sessions are pinned, following the pin is the
138
+
built-in behaviour and it needs no `clientData` and no `chat.local`. Reach for the recipe below
139
+
only when you want to upgrade on a signal the pin doesn't carry — a frontend build id that moves
140
+
independently of the deployment your app names.
141
+
</Warning>
142
+
133
143
For automatic upgrade on every deploy, pass your platform's build ID via `clientData` instead of a manual version. The agent stores the ID from the first message and upgrades when it changes:
134
144
135
145
```tsx title="app/components/Chat.tsx"
@@ -178,11 +188,37 @@ export const myChat = chat
178
188
179
189
This upgrades on **every** deploy, not just breaking changes. Good for fast-moving projects where you always want the latest code.
180
190
181
-
<Tip>
182
-
With [version skew protection](/deployment/version-skew-protection#chat-sessions) on, you don't
183
-
need this recipe: the session is already pinned to the deployment that matches the app build that
184
-
started it, and a client on a new build re-pins the session when it starts.
185
-
</Tip>
191
+
## Staying put
192
+
193
+
A pinned session follows its pin by default. To keep one agent where it is — a long tool chain you
194
+
don't want interrupted, or a conversation you'd rather move on your own terms — set `versionSkew`:
195
+
196
+
```ts
197
+
exportconst myChat =chat.agent({
198
+
id: "my-chat",
199
+
versionSkew: "hold",
200
+
run: async ({ messages, signal }) => { ... },
201
+
});
202
+
```
203
+
204
+
`"hold"` only stops the automatic handoff. `chat.requestUpgrade()` still works, so you can keep the
205
+
decision and still get the seamless swap.
206
+
207
+
Two cases never hand over automatically, whatever `versionSkew` says:
208
+
209
+
-**A session with no pin.** There is nothing to compare against, and an unpinned session already
210
+
lands on the current version every time it starts a run.
211
+
-**A session using `lockToVersion`.** That pin outranks the external deployment id and
212
+
`chat.requestUpgrade()` cannot escape it, so handing over would land on the same version and
213
+
repeat.
214
+
215
+
<Note>
216
+
Following the pin costs one session read per turn on pinned chats, and the handoff happens at a
217
+
turn boundary — never mid-turn. If the pin names a deployment that hasn't landed yet, the successor
218
+
parks: your messages stay durable, and the transport emits `run-pending-version` with
219
+
`source: "upgrade"` so you can say so in the UI. See [parked
The continuation uses the same durable Session and receives `.in` records that the old run has not consumed. It starts on the latest deployed task version unless the Session's trigger configuration sets `lockToVersion`.
238
+
The continuation uses the same durable Session and receives `.in` records that the old run has not consumed. It starts on the latest deployed task version only if the Session is unpinned — a Session carrying `lockToVersion` or an `externalDeploymentId` re-applies that pin, so the continuation lands where the Session says rather than on the newest build.
203
239
204
240
If input has been dispatched to the old run but should be processed by the continuation, detach the old listeners and skip the final `chat.writeTurnComplete()`. A turn-complete boundary acknowledges the latest input dispatched to the old run, so writing one after that dispatch would cause the continuation to resume past the input.
Three things follow from the pin living on the session:
298
298
299
-
- **Starting the session again refreshes it.** `sessions.start()` is idempotent on `chatId` and rewrites the stored config, so when your transport calls `startSession` after a redeploy, the *next* run picks up the new id. The turn already in flight finishes on the code it started on.
299
+
- **Starting the session again refreshes it, and the conversation follows.** `sessions.start()` is idempotent on `chatId` and rewrites the stored config, so when your transport calls `startSession` after a redeploy, the session re-pins. The agent then hands the conversation over at the next turn boundary, so the next message is answered by the deployment you just named — not several turns later when the old run happens to end. The turn already in flight finishes on the code it started on. Set [`versionSkew: "hold"`](/ai-chat/patterns/version-upgrades#staying-put) on an agent that should stay put instead.
300
300
- **There is one pin per `chatId`.** If the same conversation is open in two tabs on two different releases of your app, whichever called `startSession` most recently sets the pin for both.
301
301
- **A parked chat is waiting, not broken.** A run pinned to a deployment that hasn't landed parks, and every message sent meanwhile is stored durably and delivered once the deployment arrives. Nothing is lost — but nothing answers either, so tell the user. Pass `pendingVersion` through your `startSession` callback and the transport emits a `run-pending-version` event:
302
302
@@ -312,7 +312,7 @@ const transport = useTriggerChatTransport({
312
312
});
313
313
```
314
314
315
-
The event repeats on every message sent while the chat is parked, so a notice driven off it stays accurate.
315
+
The event repeats on every message sent while the chat is parked, so a notice driven off it stays accurate. Its `source` says where the park was learned: `start` from creating the session, `send` from an append, `head-start` from the route's response header, and `upgrade` when a session followed its pin onto a deployment that hasn't landed yet — that last one arrives as soon as the handoff happens, without waiting for another message.
316
316
317
317
[Head Start](/ai-chat/fast-starts#head-start) softens this considerably: turn 1 runs in your own warm process, so a parked deployment costs nothing until step 2. The handover signal is durable, so the agent picks the turn up where it left off once the deployment lands. The transport emits `run-pending-version` with `source: "head-start"`for that case, and `chat.startHeadStart` returns `pendingVersion`for the detached flow.
318
318
@@ -336,7 +336,7 @@ Use this for a conversation that should always run on the current version — a
Either way the change is persisted on the session, so the next continuation doesn't fall back to the id the agent just rejected. `lockToVersion` is a separate, explicit pin and is never cleared — `requestUpgrade()` cannot escape it.
339
+
Either way the change is persisted on the session, so the next continuation doesn't fall back to the id the agent just rejected. `lockToVersion` is a separate, explicit pin and is never cleared — `requestUpgrade()` cannot escape it, which is also why a session using it never follows its external deployment id automatically.
0 commit comments