Skip to content

Commit a4987e3

Browse files
committed
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.
1 parent c02f1ea commit a4987e3

13 files changed

Lines changed: 397 additions & 17 deletions

File tree

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

apps/webapp/app/routes/api.v1.sessions.$session.end-and-continue.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ const { action, loader } = createActionApiRoute(
134134
const responseBody: EndAndContinueSessionResponseBody = {
135135
runId: run?.friendlyId ?? result.runId,
136136
swapped: result.swapped,
137+
pendingVersion: result.pendingVersion,
137138
};
138139
return json<EndAndContinueSessionResponseBody>(responseBody);
139140
} catch (error) {

apps/webapp/test/sessionRunManagerExternalDeploymentId.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,50 @@ describe("session runs — external deployment id", () => {
263263
}
264264
);
265265

266+
postgresTest(
267+
"a changed pin does not disturb a live run — the SDK moves it, not the server",
268+
async ({ prisma }) => {
269+
const suffix = `pin_live_${seq++}`;
270+
const { seed, writerStore } = await setup(prisma as unknown as PrismaClient, suffix);
271+
272+
const liveRunId = cuidRunId(`l${seq}`);
273+
await writerStore.createRun(
274+
buildCreateRunInput({
275+
runId: liveRunId,
276+
friendlyId: `run_${suffix}_live`,
277+
organizationId: seed.organization.id,
278+
projectId: seed.project.id,
279+
runtimeEnvironmentId: seed.environment.id,
280+
status: "EXECUTING",
281+
})
282+
);
283+
284+
const session = await prisma.session.create({
285+
data: {
286+
friendlyId: `session_${suffix}`,
287+
type: "chat.agent",
288+
projectId: seed.project.id,
289+
runtimeEnvironmentId: seed.environment.id,
290+
environmentType: "PRODUCTION",
291+
organizationId: seed.organization.id,
292+
taskIdentifier: "my-chat",
293+
triggerConfig: { basePayload: {}, externalDeploymentId: "commit-new" },
294+
currentRunId: liveRunId,
295+
currentRunVersion: 0,
296+
},
297+
});
298+
299+
const result = await ensureRunForSession({
300+
session,
301+
environment: environmentFor(seed),
302+
reason: "continuation",
303+
});
304+
305+
expect(result.triggered).toBe(false);
306+
expect(result.runId).toBe(liveRunId);
307+
}
308+
);
309+
266310
postgresTest("reports pendingVersion when the triggered run parks", async ({ prisma }) => {
267311
const suffix = `pin_parked_${seq++}`;
268312
const { seed } = await setup(prisma as unknown as PrismaClient, suffix);

docs/ai-chat/patterns/version-upgrades.mdx

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,14 @@ Chat agent runs are pinned to the worker version they started on. When you deplo
99
`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.
1010

1111
<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.
1720
</Note>
1821

1922
## How it works
@@ -130,6 +133,13 @@ This pattern is useful when:
130133

131134
## Auto-detect from build ID (Next.js / Vercel)
132135

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+
133143
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:
134144

135145
```tsx title="app/components/Chat.tsx"
@@ -178,11 +188,37 @@ export const myChat = chat
178188

179189
This upgrades on **every** deploy, not just breaking changes. Good for fast-moving projects where you always want the latest code.
180190

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+
export const 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
220+
chats](/deployment/version-skew-protection#chat-sessions).
221+
</Note>
186222

187223
## Custom agents
188224

@@ -199,7 +235,7 @@ await chat.endAndContinue();
199235
return;
200236
```
201237

202-
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.
203239

204240
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.
205241

docs/deployment/version-skew-protection.mdx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,7 @@ export const startChatSession = chat.createStartSessionAction<typeof myChat>("my
296296
297297
Three things follow from the pin living on the session:
298298
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.
300300
- **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.
301301
- **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:
302302
@@ -312,7 +312,7 @@ const transport = useTriggerChatTransport({
312312
});
313313
```
314314
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.
316316
317317
[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.
318318
@@ -336,7 +336,7 @@ Use this for a conversation that should always run on the current version — a
336336
chat.requestUpgrade({ externalDeploymentId: clientData.commitSha });
337337
```
338338
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.
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.
340340
341341
## Waiting and expiry
342342

packages/core/src/v3/schemas/api.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1993,6 +1993,12 @@ export const EndAndContinueSessionResponseBody = z.object({
19931993
* to drive the next run.
19941994
*/
19951995
swapped: z.boolean(),
1996+
/**
1997+
* The run that took over is parked waiting for its deployment. Surface this so a
1998+
* handoff onto a version that has not landed reads as a deploy in progress rather
1999+
* than a chat that went quiet.
2000+
*/
2001+
pendingVersion: z.boolean().optional(),
19962002
});
19972003
export type EndAndContinueSessionResponseBody = z.infer<typeof EndAndContinueSessionResponseBody>;
19982004

packages/core/src/v3/sessionStreams/wireProtocol.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export const SESSION_IN_CONSUMED_ID_HEADER = "session-in-consumed-id" as const;
5555
export const TRIGGER_CONTROL_SUBTYPE = {
5656
TURN_COMPLETE: "turn-complete",
5757
UPGRADE_REQUIRED: "upgrade-required",
58+
PENDING_VERSION: "pending-version",
5859
} as const;
5960

6061
export type TriggerControlSubtype =

packages/trigger-sdk/src/v3/ai.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
type TaskSchema,
3737
type TaskWithSchema,
3838
TRIGGER_CONTROL_SUBTYPE,
39+
tryCatch,
3940
type StreamWriteResult,
4041
type RouterCheckpoint,
4142
type SessionRouteTable,
@@ -109,6 +110,7 @@ type ToolCallOptions = {
109110
import { readFileInSkill, runBashInSkill } from "./agentSkillsRuntime.js";
110111
import { ensureAiSdkTelemetry } from "./aiAutoTelemetry.js";
111112
import { withResolvedExternalDeploymentId } from "./externalDeploymentId.js";
113+
import { type ChatVersionSkewPolicy, resolvePinToFollow } from "./chatVersionSkew.js";
112114
import {
113115
type SessionChannelHandleFor,
114116
type SessionHandle,
@@ -5515,6 +5517,18 @@ export type ChatAgentOptions<
55155517
*/
55165518
oomMachine?: MachinePresetName;
55175519

5520+
/**
5521+
* What to do when the session's `externalDeploymentId` no longer names the deployment
5522+
* this run is on — after a redeploy re-pins the session, say.
5523+
*
5524+
* - `"follow"` (default) hands the conversation to the pinned deployment at the next
5525+
* turn boundary, so no `chat.requestUpgrade()` of your own is needed.
5526+
* - `"hold"` stays put until you ask to move. Manual `chat.requestUpgrade()` works either way.
5527+
*
5528+
* Ignored for sessions with no pin, and for sessions using `lockToVersion`.
5529+
*/
5530+
versionSkew?: ChatVersionSkewPolicy;
5531+
55185532
/**
55195533
* Schema for validating `clientData` from the frontend.
55205534
* Accepts Zod, ArkType, Valibot, or any supported schema library.
@@ -6394,6 +6408,7 @@ function chatAgent<
63946408
onChatResume,
63956409
exitAfterPreloadIdle = false,
63966410
oomMachine,
6411+
versionSkew,
63976412
...restOptions
63986413
} = options;
63996414

@@ -8010,6 +8025,8 @@ function chatAgent<
80108025
);
80118026
}
80128027

8028+
await followSessionPin(currentWirePayload.chatId, versionSkew);
8029+
80138030
// chat.requestUpgrade() called in onTurnStart (or onValidateMessages) —
80148031
// skip run() and signal the transport to re-trigger the same message
80158032
// on the new version.
@@ -9677,6 +9694,30 @@ function requestUpgrade(options?: { externalDeploymentId?: string }): void {
96779694
if (target) locals.set(chatUpgradeExternalDeploymentIdKey, target);
96789695
}
96799696

9697+
/** @internal Requests a handoff when the session's pin no longer names this deployment. */
9698+
async function followSessionPin(
9699+
chatId: string | undefined,
9700+
policy: ChatVersionSkewPolicy | undefined
9701+
): Promise<void> {
9702+
if (!chatId) {
9703+
return;
9704+
}
9705+
9706+
const target = await resolvePinToFollow({
9707+
policy,
9708+
deployedExternalId: locals.get(chatAgentRunContextKey)?.deployment?.externalId,
9709+
upgradeAlreadyRequested: locals.get(chatUpgradeRequestedKey) === true,
9710+
readPin: async () => (await sessions.retrieve(chatId)).triggerConfig,
9711+
});
9712+
9713+
if (!target) {
9714+
return;
9715+
}
9716+
9717+
logger.info("chat.versionSkew: following the session pin", { chatId, target });
9718+
requestUpgrade({ externalDeploymentId: target });
9719+
}
9720+
96809721
/**
96819722
* Hand off the current custom agent Session to a fresh run.
96829723
*
@@ -9733,11 +9774,25 @@ async function performEndAndContinue(options?: { externalDeploymentId?: string }
97339774

97349775
const externalDeploymentId = options?.externalDeploymentId;
97359776
const apiClient = apiClientManager.clientOrThrow();
9736-
await apiClient.endAndContinueSession(chatId, {
9777+
const result = await apiClient.endAndContinueSession(chatId, {
97379778
callingRunId,
97389779
reason: "upgrade",
97399780
...(externalDeploymentId ? { externalDeploymentId } : {}),
97409781
});
9782+
9783+
if (result?.pendingVersion !== true) {
9784+
return;
9785+
}
9786+
9787+
// The successor parked. Say so on `.out` while this run still can — the transport's
9788+
// subscription survives the swap, so the client learns without waiting for its next send.
9789+
const [error] = await tryCatch(
9790+
getChatSession().out.writeControl(TRIGGER_CONTROL_SUBTYPE.PENDING_VERSION)
9791+
);
9792+
9793+
if (error) {
9794+
logger.warn("could not signal a parked handoff", { chatId, error });
9795+
}
97419796
}
97429797

97439798
/**
@@ -10494,6 +10549,11 @@ export type ChatSessionOptions = {
1049410549
timeout?: string;
1049510550
/** Max turns before ending. @default 100 */
1049610551
maxTurns?: number;
10552+
/**
10553+
* What to do when the session's `externalDeploymentId` no longer names this deployment.
10554+
* `"follow"` (default) hands over at the next turn boundary; `"hold"` stays put.
10555+
*/
10556+
versionSkew?: ChatVersionSkewPolicy;
1049710557
/** Automatic context compaction — same options as `chat.agent({ compaction })`. */
1049810558
compaction?: ChatAgentCompactionOptions;
1049910559
/** Configure mid-execution message injection — same options as `chat.agent({ pendingMessages })`. */
@@ -10710,6 +10770,7 @@ function createChatSession<TClientData = unknown>(
1071010770
maxTurns = 100,
1071110771
compaction: sessionCompaction,
1071210772
pendingMessages: sessionPendingMessages,
10773+
versionSkew: sessionVersionSkew,
1071310774
} = options;
1071410775

1071510776
const idleTimeoutInSeconds = sessionIdleTimeoutOpt ?? 30;
@@ -10934,6 +10995,8 @@ function createChatSession<TClientData = unknown>(
1093410995
accumulator.applyHandover(pendingHandoverSignal);
1093510996
}
1093610997

10998+
await followSessionPin(currentPayload.chatId, sessionVersionSkew);
10999+
1093711000
// chat.requestUpgrade() called before this turn — signal transport and exit
1093811001
if (locals.get(chatUpgradeRequestedKey)) {
1093911002
await writeUpgradeRequiredChunk();

packages/trigger-sdk/src/v3/chat-client.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -835,6 +835,10 @@ export class AgentChat<TAgent = unknown> {
835835
continue;
836836
}
837837

838+
if (controlValue === TRIGGER_CONTROL_SUBTYPE.PENDING_VERSION) {
839+
continue;
840+
}
841+
838842
if (controlValue === TRIGGER_CONTROL_SUBTYPE.TURN_COMPLETE) {
839843
// Customer's callback may be async (e.g. persisting
840844
// lastEventId to a DB). Wrap so a rejected Promise

0 commit comments

Comments
 (0)