@trigger.dev/sdk 4.5.12 → 4.5.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commonjs/v3/ai.d.ts +154 -20
- package/dist/commonjs/v3/ai.js +1241 -387
- package/dist/commonjs/v3/ai.js.map +1 -1
- package/dist/commonjs/v3/chat.d.ts +7 -2
- package/dist/commonjs/v3/chat.js +22 -7
- package/dist/commonjs/v3/chat.js.map +1 -1
- package/dist/commonjs/v3/chat.test.js +13 -4
- package/dist/commonjs/v3/chat.test.js.map +1 -1
- package/dist/commonjs/v3/envvars.js.map +1 -1
- package/dist/commonjs/v3/sessions.d.ts +4 -10
- package/dist/commonjs/v3/sessions.js +73 -47
- package/dist/commonjs/v3/sessions.js.map +1 -1
- package/dist/commonjs/v3/streams.js +1 -0
- package/dist/commonjs/v3/streams.js.map +1 -1
- package/dist/commonjs/v3/test/mock-chat-agent.js +1 -0
- package/dist/commonjs/v3/test/mock-chat-agent.js.map +1 -1
- package/dist/commonjs/v3/test/test-session-handle.js +22 -23
- package/dist/commonjs/v3/test/test-session-handle.js.map +1 -1
- package/dist/commonjs/version.js +1 -1
- package/dist/esm/v3/ai.d.ts +154 -20
- package/dist/esm/v3/ai.js +1239 -387
- package/dist/esm/v3/ai.js.map +1 -1
- package/dist/esm/v3/chat.d.ts +7 -2
- package/dist/esm/v3/chat.js +22 -7
- package/dist/esm/v3/chat.js.map +1 -1
- package/dist/esm/v3/chat.test.js +13 -4
- package/dist/esm/v3/chat.test.js.map +1 -1
- package/dist/esm/v3/envvars.js.map +1 -1
- package/dist/esm/v3/sessions.d.ts +4 -10
- package/dist/esm/v3/sessions.js +73 -47
- package/dist/esm/v3/sessions.js.map +1 -1
- package/dist/esm/v3/streams.js +1 -0
- package/dist/esm/v3/streams.js.map +1 -1
- package/dist/esm/v3/test/mock-chat-agent.js +2 -1
- package/dist/esm/v3/test/mock-chat-agent.js.map +1 -1
- package/dist/esm/v3/test/test-session-handle.js +23 -24
- package/dist/esm/v3/test/test-session-handle.js.map +1 -1
- package/dist/esm/version.js +1 -1
- package/docs/ai-chat/client-protocol.mdx +8 -3
- package/docs/ai-chat/custom-agents.mdx +181 -46
- package/docs/ai-chat/patterns/recovery-boot.mdx +9 -2
- package/docs/ai-chat/patterns/version-upgrades.mdx +26 -6
- package/docs/ai-chat/pending-messages.mdx +5 -3
- package/docs/ai-chat/reference.mdx +26 -10
- package/docs/ai-chat/types.mdx +5 -1
- package/docs/deployment/atomic-deployment.mdx +12 -0
- package/docs/deployment/overview.mdx +7 -1
- package/docs/deployment/version-skew-protection.mdx +430 -0
- package/docs/github-actions.mdx +33 -5
- package/docs/github-integration.mdx +12 -0
- package/docs/realtime/auth.mdx +18 -0
- package/docs/realtime/react-hooks/session-stream.mdx +109 -0
- package/docs/realtime/react-hooks/streams.mdx +71 -3
- package/docs/self-hosting/env/webapp.mdx +1 -0
- package/docs/self-hosting/security.mdx +1 -1
- package/docs/tasks/streams.mdx +3 -0
- package/docs/vercel-integration.mdx +43 -9
- package/docs/versioning.mdx +2 -0
- package/package.json +2 -2
|
@@ -19,61 +19,113 @@ Inside the wrapper, pick one of two loop styles:
|
|
|
19
19
|
- **[Managed loop](#managed-loop-chatcreatesession)** — `chat.createSession()` yields turns; the SDK handles stop signals, accumulation, idle suspend/resume, and turn-complete signaling. You write the turn body.
|
|
20
20
|
- **[Hand-rolled loop](#hand-rolled-loop-with-primitives)** — you write the loop itself with `chat.messages`, `MessageAccumulator`, `pipeAndCapture`, and `writeTurnComplete`. The right choice when you need complete control over `.toUIMessageStream()` (e.g. `onFinish`, `originalMessages`) beyond what `chat.setUIMessageStreamOptions()` provides, or you're implementing a custom protocol.
|
|
21
21
|
|
|
22
|
+
### Validating client data
|
|
23
|
+
|
|
24
|
+
Use `chat.withClientData({ schema })` to validate `payload.metadata`. Custom agents parse the metadata on the initial payload and every later non-close input frame before passing it to `run`, `chat.messages`, or `chat.createSession`. Schema defaults and transforms are included in the value your code receives.
|
|
25
|
+
|
|
26
|
+
This only validates `metadata`. A raw custom agent does not expose an action schema, so `payload.action` remains `unknown`. Validate the full frame or action payload in your own loop when you need that boundary.
|
|
27
|
+
|
|
28
|
+
If validation fails for a submitted turn or an async read such as `wait()`, the SDK consumes and skips the invalid frame, writes an `Invalid client data` error followed by `turn-complete`, then waits for the next valid frame. The invalid value is not returned to the raw caller. The detailed validator error is available in the task log and `onClientDataValidationError`, but it is not sent to the client.
|
|
29
|
+
|
|
30
|
+
This convenience path settles the invalid input before the read returns. If your raw loop needs to coordinate validation with persistence or settlement, omit `withClientData({ schema })` and validate the full wire frame in the loop instead. A messageless preload or continuation boot has no submitted turn to complete, so the SDK reports the error through the task log and callback while it waits.
|
|
31
|
+
|
|
32
|
+
An invalid [head-start handover](/ai-chat/fast-starts#handover-with-custom-agents) boot fails closed. The SDK waits for the warm handler to finish so stream ordering stays intact. A handover skip ends the run. A real handover writes the validation error and `turn-complete` after the warm output, then ends the run. Without a schema, metadata is passed through unchanged.
|
|
33
|
+
|
|
34
|
+
`chat.messages.on()` is different because a subscribed frame can arrive while the current response is still streaming. Ending the turn at that point would cut off the response. While the subscription is active, the SDK skips an invalid frame, logs the validation error, and calls `onClientDataValidationError` if you set it. A raw subscription has no turn boundary the SDK can key a later write to, so it reports through the callback and the task log only and never writes to the stream.
|
|
35
|
+
|
|
36
|
+
The steering subscription created by `chat.createSession({ pendingMessages })` skips an invalid frame the same way, but the session does own the turn boundary, so it can write the client-visible error once the turn has closed. `reportErrorAt` governs that write and applies to steering frames only, not to `chat.messages.on()`.
|
|
37
|
+
|
|
38
|
+
By default the `Invalid client data` error for a steering frame is held until the turn ends, so a bad send cannot truncate an answer the user is already reading. Pass `reportErrorAt: "arrival"` to `withClientData` if you would rather surface it as soon as validation fails, accepting that it ends the response in progress:
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
chat.withClientData({
|
|
42
|
+
schema: z.object({ userId: z.string() }),
|
|
43
|
+
reportErrorAt: "arrival",
|
|
44
|
+
onValidationError: ({ error, payload }) => logger.warn("bad client data", { error }),
|
|
45
|
+
});
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The validation callback and the task log fire on arrival in both modes, and the frame is never delivered as a turn either way.
|
|
49
|
+
|
|
50
|
+
Calling `off()` stops the subscription from accepting new frames. A valid frame accepted before `off()` still finishes validation and is delivered to the handler. An invalid frame that finishes validation after `off()` is logged without calling the handler or error callback.
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
import { chat } from "@trigger.dev/sdk/ai";
|
|
54
|
+
import { z } from "zod";
|
|
55
|
+
|
|
56
|
+
export const myChat = chat
|
|
57
|
+
.withClientData({ schema: z.object({ userId: z.string() }) })
|
|
58
|
+
.customAgent({
|
|
59
|
+
id: "my-chat",
|
|
60
|
+
onClientDataValidationError: ({ error, payload }) => {
|
|
61
|
+
console.warn("Invalid client data", { error, trigger: payload.trigger });
|
|
62
|
+
},
|
|
63
|
+
run: async (payload) => {
|
|
64
|
+
// ...
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`chat.messages.peek()` validates synchronously and throws validation errors to the caller. If your schema only supports asynchronous parsing, use `once()`, `wait()`, or `waitWithIdleTimeout()` instead.
|
|
70
|
+
|
|
22
71
|
## Managed loop: chat.createSession()
|
|
23
72
|
|
|
24
73
|
`chat.createSession()` gives you an async iterator of `ChatTurn` objects. Each turn arrives with the accumulated history, a combined stop+cancel signal, and helpers to finish the turn:
|
|
25
74
|
|
|
26
75
|
```ts trigger/my-chat.ts
|
|
27
|
-
import { chat
|
|
76
|
+
import { chat } from "@trigger.dev/sdk/ai";
|
|
28
77
|
import { streamText, stepCountIs } from "ai";
|
|
29
78
|
import { anthropic } from "@ai-sdk/anthropic";
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
signal,
|
|
45
|
-
idleTimeoutInSeconds: 60,
|
|
46
|
-
timeout: "1h",
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
for await (const turn of session) {
|
|
50
|
-
// Persist the incoming user message BEFORE streaming — this is your
|
|
51
|
-
// onTurnStart equivalent. Without it, a page reload mid-stream
|
|
52
|
-
// restores the assistant text (replayed from the session) but loses
|
|
53
|
-
// the user message that prompted it.
|
|
54
|
-
await db.chat.update({
|
|
55
|
-
where: { id: turn.chatId },
|
|
56
|
-
data: { messages: turn.uiMessages },
|
|
79
|
+
import { z } from "zod";
|
|
80
|
+
|
|
81
|
+
export const myChat = chat
|
|
82
|
+
.withClientData({ schema: z.object({ userId: z.string() }) })
|
|
83
|
+
.customAgent({
|
|
84
|
+
id: "my-chat",
|
|
85
|
+
run: async (payload, { signal }) => {
|
|
86
|
+
// One-time initialization — plain code, no hooks. Upsert, not create:
|
|
87
|
+
// continuation runs boot with the row already in place.
|
|
88
|
+
const clientData = payload.metadata!;
|
|
89
|
+
await db.chat.upsert({
|
|
90
|
+
where: { id: payload.chatId },
|
|
91
|
+
create: { id: payload.chatId, userId: clientData.userId },
|
|
92
|
+
update: {},
|
|
57
93
|
});
|
|
58
94
|
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
stopWhen: stepCountIs(15),
|
|
95
|
+
const session = chat.createSession(payload, {
|
|
96
|
+
signal,
|
|
97
|
+
idleTimeoutInSeconds: 60,
|
|
98
|
+
timeout: "1h",
|
|
64
99
|
});
|
|
65
100
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
101
|
+
for await (const turn of session) {
|
|
102
|
+
// Persist the incoming user message BEFORE streaming — this is your
|
|
103
|
+
// onTurnStart equivalent. Without it, a page reload mid-stream
|
|
104
|
+
// restores the assistant text (replayed from the session) but loses
|
|
105
|
+
// the user message that prompted it.
|
|
106
|
+
await db.chat.update({
|
|
107
|
+
where: { id: turn.chatId },
|
|
108
|
+
data: { messages: turn.uiMessages },
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const result = streamText({
|
|
112
|
+
model: anthropic("claude-sonnet-4-5"),
|
|
113
|
+
messages: turn.messages,
|
|
114
|
+
abortSignal: turn.signal,
|
|
115
|
+
stopWhen: stepCountIs(15),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Pipe, capture, accumulate, and signal turn-complete — all in one call
|
|
119
|
+
await turn.complete(result);
|
|
120
|
+
|
|
121
|
+
// Persist the full exchange after the turn — your onTurnComplete equivalent
|
|
122
|
+
await db.chat.update({
|
|
123
|
+
where: { id: turn.chatId },
|
|
124
|
+
data: { messages: turn.uiMessages },
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
});
|
|
77
129
|
```
|
|
78
130
|
|
|
79
131
|
<Warning>
|
|
@@ -102,7 +154,7 @@ Each turn yielded by the iterator provides:
|
|
|
102
154
|
| `number` | `number` | Turn number (0-indexed) |
|
|
103
155
|
| `chatId` | `string` | Chat session ID |
|
|
104
156
|
| `trigger` | `string` | What triggered this turn |
|
|
105
|
-
| `clientData` | `unknown`
|
|
157
|
+
| `clientData` | Schema output or `unknown` | Parsed client data when `withClientData` is configured |
|
|
106
158
|
| `messages` | `ModelMessage[]` | Full accumulated model messages — pass to `streamText` |
|
|
107
159
|
| `uiMessages` | `UIMessage[]` | Full accumulated UI messages — use for persistence |
|
|
108
160
|
| `signal` | `AbortSignal` | Combined stop+cancel signal (fresh each turn) |
|
|
@@ -144,6 +196,31 @@ for await (const turn of session) {
|
|
|
144
196
|
|
|
145
197
|
Without this, a resumed chat silently loses its history: the model sees only the message that triggered the continuation. In a hand-rolled loop, seed by passing the stored history into the turn-0 `addIncoming` call — shown in the example below.
|
|
146
198
|
|
|
199
|
+
### Rotating to a new deployment
|
|
200
|
+
|
|
201
|
+
With `chat.createSession()`, use `chat.requestUpgrade()` and let the iterator exit normally. For an immediate handoff, close the iterator before calling `chat.endAndContinue()`; the method rejects until the iterator and any active `next()` call have settled. In a fully hand-rolled custom agent, call it directly to hand the Session to a fresh run.
|
|
202
|
+
|
|
203
|
+
Close the iterator between reads. If `return()` races a `next()` that is already waiting for input, it waits for that read to settle before releasing the handoff guard. Input dispatched while the iterator is closing is not yielded as a turn and remains available to the continuation unless you write another turn-complete boundary.
|
|
204
|
+
|
|
205
|
+
Call it between turns, after detaching the old run's input listeners. If the old run completed its current turn, persist its state and write the turn-complete boundary before the handoff:
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
// Detach any chat.messages.on() subscriptions you created.
|
|
209
|
+
stop.cleanup();
|
|
210
|
+
await persistMessages(conversation.uiMessages);
|
|
211
|
+
await chat.writeTurnComplete();
|
|
212
|
+
await chat.endAndContinue();
|
|
213
|
+
return;
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
The server starts a continuation run using the Session's existing trigger configuration and atomically makes it the current run. The Session and its streams stay open, so input that the old run has not consumed remains on `.in` for the continuation run. The new run uses the latest deployed task version unless the Session's trigger configuration sets `lockToVersion`.
|
|
217
|
+
|
|
218
|
+
If input has been dispatched to the old run but should be processed by the continuation, detach the listeners and do not write another turn-complete boundary before handing off. `chat.writeTurnComplete()` acknowledges the latest input dispatched to the old run; writing it after that dispatch would make the continuation resume after the input.
|
|
219
|
+
|
|
220
|
+
<Warning>
|
|
221
|
+
`chat.endAndContinue()` starts the new run but does not stop the caller. Await it and return from `run()` immediately; continuing to read or write can race the new run on the same Session. If the handoff fails, the promise rejects.
|
|
222
|
+
</Warning>
|
|
223
|
+
|
|
147
224
|
### turn.complete() vs manual control
|
|
148
225
|
|
|
149
226
|
`turn.complete(result)` is the one-call path — it handles piping, capturing the response, accumulating messages, cleaning up aborted parts on a stop, and writing the turn-complete chunk.
|
|
@@ -179,6 +256,12 @@ for await (const turn of session) {
|
|
|
179
256
|
|
|
180
257
|
The frontend stops a turn with [`transport.stopGeneration(chatId)`](/ai-chat/frontend#stop-generation), which writes a stop signal to the session's input stream. It aborts the current turn's generation but keeps the run alive, so the next message continues on the same session.
|
|
181
258
|
|
|
259
|
+
A stop only applies to the turn that was live when it arrived. If the run crashes
|
|
260
|
+
and a later run recovers a message that had not been answered yet, a stop that
|
|
261
|
+
was already applied before the crash is not applied again, so the turn answering
|
|
262
|
+
the recovered message runs to completion. A stop sent after the recovery is live
|
|
263
|
+
and aborts that turn as normal.
|
|
264
|
+
|
|
182
265
|
`turn.signal` is a combined stop-and-cancel `AbortSignal`, fresh each turn. Pass it to `streamText` so the stop reaches the model, then let `turn.complete()` finish the turn:
|
|
183
266
|
|
|
184
267
|
```ts trigger/my-chat.ts
|
|
@@ -213,14 +296,66 @@ For full control, skip `createSession` and compose the primitives directly:
|
|
|
213
296
|
|
|
214
297
|
| Primitive | Description |
|
|
215
298
|
| ------------------------------- | -------------------------------------------------------------------------------------------- |
|
|
216
|
-
| `chat.messages` |
|
|
299
|
+
| `chat.messages` | Mailbox for incoming messages — inspect buffered input, consume one record, or suspend until the next turn |
|
|
217
300
|
| `chat.createStopSignal()` | Create a managed stop signal wired to the stop input stream |
|
|
218
301
|
| `chat.pipeAndCapture(result)` | Pipe a stream and capture the response; returns `{ message, status, error }` |
|
|
219
302
|
| `chat.writeTurnComplete()` | Signal turn complete; returns `{ lastEventId, sessionInEventId }` resume cursors |
|
|
303
|
+
| `chat.endAndContinue()` | Hand off the Session to a continuation run; call between turns, then return |
|
|
220
304
|
| `chat.MessageAccumulator` | Accumulates conversation messages across turns |
|
|
221
305
|
| `chat.pipe(stream)` | Pipe a stream to the frontend (no response capture) |
|
|
222
306
|
| `chat.cleanupAbortedParts(msg)` | Clean up incomplete parts from a stopped response |
|
|
223
307
|
|
|
308
|
+
### `chat.messages` mailbox
|
|
309
|
+
|
|
310
|
+
`chat.messages` exposes the incoming message mailbox for hand-rolled loops:
|
|
311
|
+
|
|
312
|
+
| Method | Behavior |
|
|
313
|
+
| --- | --- |
|
|
314
|
+
| `peek()` | Return the next queued message without consuming it, or `undefined` when none is queued |
|
|
315
|
+
| `hasPending()` | Resolve `true` when a message is queued; does not consume it |
|
|
316
|
+
| `next({ timeoutInSeconds? })` | Consume exactly one message record in channel order, or resolve `undefined` when the optional timeout elapses |
|
|
317
|
+
| `on(handler)` | Consume messages as they arrive and invoke the handler |
|
|
318
|
+
| `waitWithIdleTimeout(options)` | Wait warm, then suspend the run until the next message arrives |
|
|
319
|
+
|
|
320
|
+
`hasPending()` checks whether a message has already been delivered locally and is
|
|
321
|
+
waiting for `next()` to take it. It does not query the remote Session channel or
|
|
322
|
+
start a subscription. Use `waitWithIdleTimeout()` when the loop needs to idle
|
|
323
|
+
until future input arrives.
|
|
324
|
+
|
|
325
|
+
`next({ timeoutInSeconds: 0 })` is also a local, non-blocking read. Call
|
|
326
|
+
`next()` without a timeout, or with a positive timeout, to subscribe for future
|
|
327
|
+
input.
|
|
328
|
+
|
|
329
|
+
`next()` returns a readonly record envelope:
|
|
330
|
+
|
|
331
|
+
```ts
|
|
332
|
+
const record = await chat.messages.next({ timeoutInSeconds: 5 });
|
|
333
|
+
if (record) {
|
|
334
|
+
console.log(record.id, record.seqNum);
|
|
335
|
+
currentPayload = record.payload;
|
|
336
|
+
}
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
- `id` is the append's stable idempotency key.
|
|
340
|
+
- `seqNum` is the monotonic sequence on this Session's `.in` channel.
|
|
341
|
+
- `payload` is the existing `ChatTaskWirePayload` delivered by the other mailbox methods.
|
|
342
|
+
|
|
343
|
+
Both identifiers remain the same if the record is delivered again after a
|
|
344
|
+
reconnect. Each `next()` call commits only the record it returns, so a loop that
|
|
345
|
+
owns its own turn sequencing never advances past input it has not taken. By
|
|
346
|
+
contrast, `on()` commits a record as soon as it dispatches the handler; avoid
|
|
347
|
+
mixing `on()` and `next()` when a single loop owns mailbox consumption.
|
|
348
|
+
|
|
349
|
+
The Session `.in` channel also carries control records such as stops and
|
|
350
|
+
handovers. Those are routed to their own consumers and never block messages: a
|
|
351
|
+
message that arrived behind one is still reported by `hasPending()` and still
|
|
352
|
+
returned by `next()`, in channel order. The same holds for a record kind this
|
|
353
|
+
version of the SDK does not recognise, which is discarded rather than left where
|
|
354
|
+
it would make every message behind it undeliverable.
|
|
355
|
+
|
|
356
|
+
`next()` returns `undefined` when no message became consumable before the
|
|
357
|
+
timeout.
|
|
358
|
+
|
|
224
359
|
A complete loop:
|
|
225
360
|
|
|
226
361
|
```ts trigger/my-chat-raw.ts
|
|
@@ -32,7 +32,7 @@ On a continuation boot, the runtime reads:
|
|
|
32
32
|
- **`session.out` tail past the snapshot cursor** — closed assistant turns plus, optionally, a `partialAssistant` (the trailing message whose stream never received a `finish` chunk). `cleanupAbortedParts` has already stripped streaming-in-progress fragments.
|
|
33
33
|
- **`session.in` tail past the last `turn-complete` cursor** — user messages the dead run hadn't acknowledged.
|
|
34
34
|
|
|
35
|
-
If
|
|
35
|
+
If there's a `partialAssistant` and two or more `inFlightUsers`, the runtime splices `[firstInFlightUser, partialAssistant]` onto the chain. The remaining in-flight users dispatch as fresh turns. The model sees:
|
|
36
36
|
|
|
37
37
|
```
|
|
38
38
|
[ ...settledMessages, // chain through the last completed turn
|
|
@@ -138,10 +138,17 @@ type RecoveryBootResult<TUIM extends UIMessage = UIMessage> = {
|
|
|
138
138
|
};
|
|
139
139
|
```
|
|
140
140
|
|
|
141
|
-
- **`chain`** — replaces the seed chain. Defaults to `[...settledMessages, firstInFlightUser, partialAssistant]` when
|
|
141
|
+
- **`chain`** — replaces the seed chain. Defaults to `[...settledMessages, firstInFlightUser, partialAssistant]` when there's a partial **and two or more** in-flight users, otherwise `settledMessages` alone.
|
|
142
142
|
- **`recoveredTurns`** — user messages to dispatch as fresh turns after the chain is restored. Defaults to `inFlightUsers.slice(1)` when the smart default consumed the first user, otherwise `inFlightUsers`.
|
|
143
143
|
- **`beforeBoot`** — runs after the writer flushes and before the first recovered turn fires. Use for blocking persistence (write the partial to your DB so a later turn can reference it). Errors bubble — wrap your own try/catch if you want to soft-fail.
|
|
144
144
|
|
|
145
|
+
<Note>
|
|
146
|
+
The splice needs a follow-up user to answer, so it only applies with two or
|
|
147
|
+
more in-flight users. With exactly one — the plain OOM or crash-mid-answer
|
|
148
|
+
case — the orphan partial is dropped and that single user is re-dispatched as
|
|
149
|
+
a fresh turn, so the interrupted question still gets answered.
|
|
150
|
+
</Note>
|
|
151
|
+
|
|
145
152
|
## Examples
|
|
146
153
|
|
|
147
154
|
### Drop the partial — strict "cancel means discard"
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: "Version upgrades"
|
|
3
3
|
sidebarTitle: "Version upgrades"
|
|
4
|
-
description: "Gracefully migrate
|
|
4
|
+
description: "Gracefully migrate chat agents to a new deployment using chat.requestUpgrade(), chat.endAndContinue(), and the continuation mechanism."
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
Chat agent runs are pinned to the worker version they started on. When you deploy a new version, suspended runs resume on the **old** code. If your deploy includes breaking changes (new tools, changed schemas, updated API contracts), this can cause issues.
|
|
8
8
|
|
|
9
|
-
`chat.requestUpgrade()`
|
|
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
|
## How it works
|
|
12
12
|
|
|
@@ -151,14 +151,34 @@ export const myChat = chat
|
|
|
151
151
|
|
|
152
152
|
This upgrades on **every** deploy, not just breaking changes. Good for fast-moving projects where you always want the latest code.
|
|
153
153
|
|
|
154
|
-
##
|
|
154
|
+
## Custom agents
|
|
155
155
|
|
|
156
|
-
|
|
157
|
-
|
|
156
|
+
Use `chat.requestUpgrade()` with `chat.agent()`. With `chat.createSession()`, call `chat.requestUpgrade()`, then advance the iterator once more so it can exit normally. For an immediate handoff, close the iterator before calling `chat.endAndContinue()`. In a fully hand-rolled `chat.customAgent()` task, detach input listeners, persist the completed turn, write its boundary, then call `chat.endAndContinue()` and return immediately:
|
|
157
|
+
|
|
158
|
+
Close a `chat.createSession()` iterator between reads. If `return()` races a `next()` that is already waiting for input, it waits for that read to settle before the handoff can continue. Input dispatched while the iterator is closing is not yielded as a turn and remains available to the continuation unless you write another turn-complete boundary.
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
// Detach any chat.messages.on() subscriptions you created.
|
|
162
|
+
stop.cleanup();
|
|
163
|
+
await persistMessages(conversation.uiMessages);
|
|
164
|
+
await chat.writeTurnComplete();
|
|
165
|
+
await chat.endAndContinue();
|
|
166
|
+
return;
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
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`.
|
|
170
|
+
|
|
171
|
+
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.
|
|
172
|
+
|
|
173
|
+
<Warning>
|
|
174
|
+
`chat.endAndContinue()` starts the successor but does not stop the calling run. Perform no more Session reads or writes after calling it, and return from the task. If the handoff fails, the promise rejects.
|
|
175
|
+
</Warning>
|
|
158
176
|
|
|
159
177
|
## Interaction with recovery boot
|
|
160
178
|
|
|
161
|
-
`chat.requestUpgrade()` is a
|
|
179
|
+
When `chat.requestUpgrade()` is handled before a turn starts, the SDK immediately hands the Session to a new run, which processes the same input on the latest version. When it is requested during a turn, including through `chat.createSession()`, the current turn finishes and the old run exits; the next input starts the continuation run.
|
|
180
|
+
|
|
181
|
+
Both are graceful exits. [`onRecoveryBoot`](/ai-chat/patterns/recovery-boot) does not fire — the hook is reserved for mid-stream interruptions (cancel, crash, or OOM) where a partial assistant exists on the tail.
|
|
162
182
|
|
|
163
183
|
## See also
|
|
164
184
|
|
|
@@ -10,7 +10,9 @@ When an AI agent is executing tool calls, users may want to send a message that
|
|
|
10
10
|
|
|
11
11
|
By default (without `pendingMessages`), a message sent while the agent is responding never interrupts the in-flight response: it's buffered and processed as its own turn once the current turn completes, with multiple messages running sequentially in arrival order.
|
|
12
12
|
|
|
13
|
-
The `pendingMessages` option enables steering instead, injecting user messages between tool-call steps via the AI SDK's `prepareStep`. Messages that arrive during streaming are queued and injected at the next step boundary.
|
|
13
|
+
The `pendingMessages` option enables steering instead, injecting user messages between tool-call steps via the AI SDK's `prepareStep`. Messages that arrive during streaming are queued and injected at the next step boundary. A message that is not injected becomes the next turn instead, whether that is because `shouldInject` returned `false` or because there were no more step boundaries (single-step response or final text generation). The backend handles that, so no client-side re-send is involved.
|
|
14
|
+
|
|
15
|
+
Injection is what needs wiring: the `pendingMessages` options only reach `streamText` if you spread `chat.toStreamTextOptions()` (or pass `prepareStep`). Without that, nothing injects, so every mid-turn message is answered as the next turn. Deferral does not depend on it.
|
|
14
16
|
|
|
15
17
|
## How it works
|
|
16
18
|
|
|
@@ -20,7 +22,7 @@ The `pendingMessages` option enables steering instead, injecting user messages b
|
|
|
20
22
|
4. At the next `prepareStep` boundary (between tool-call steps), `shouldInject` is called
|
|
21
23
|
5. If it returns `true`, the message is injected into the LLM's context
|
|
22
24
|
6. A `data-pending-message-injected` stream chunk confirms injection to the frontend
|
|
23
|
-
7. If `prepareStep` never fires (no tool calls), the message
|
|
25
|
+
7. If `shouldInject` returns `false`, or `prepareStep` never fires (no tool calls), the message stays queued on the backend and is answered as the next turn
|
|
24
26
|
|
|
25
27
|
## Backend: chat.agent
|
|
26
28
|
|
|
@@ -310,7 +312,7 @@ function Chat({ chatId }: { chatId: string }) {
|
|
|
310
312
|
|
|
311
313
|
### Message lifecycle
|
|
312
314
|
|
|
313
|
-
- **Steering messages** are sent via `transport.sendPendingMessage()` immediately. They appear as purple pending bubbles. If injected, they disappear from the overlay and render inline at the injection point. If not injected
|
|
315
|
+
- **Steering messages** are sent via `transport.sendPendingMessage()` immediately. They appear as purple pending bubbles. If injected, they disappear from the overlay and render inline at the injection point. If not injected, the backend answers them as the next turn once the response finishes; the client does not need to re-send them.
|
|
314
316
|
|
|
315
317
|
- **Queued messages** stay client-side until the turn completes, then auto-send as the next turn via `sendMessage()`. They can be promoted to steering mid-stream by clicking "Steer instead".
|
|
316
318
|
|
|
@@ -396,7 +396,7 @@ Options for the `pendingMessages` field. See [Pending Messages](/ai-chat/pending
|
|
|
396
396
|
|
|
397
397
|
| Option | Type | Required | Description |
|
|
398
398
|
| -------------- | --------------------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------- |
|
|
399
|
-
| `shouldInject` | `(event: PendingMessagesBatchEvent) => boolean \| Promise<boolean>` | No | Decide whether to inject the batch between tool-call steps. If absent,
|
|
399
|
+
| `shouldInject` | `(event: PendingMessagesBatchEvent) => boolean \| Promise<boolean>` | No | Decide whether to inject the batch between tool-call steps. If absent, nothing is injected and the messages are answered as the next turn. Only consulted when `chat.toStreamTextOptions()` (or `prepareStep`) reaches `streamText`; without that nothing is injected and every mid-turn message becomes the next turn. |
|
|
400
400
|
| `prepare` | `(event: PendingMessagesBatchEvent) => ModelMessage[] \| Promise<ModelMessage[]>` | No | Transform the batch before injection. Default: convert each via `convertToModelMessages`. |
|
|
401
401
|
| `onReceived` | `(event: PendingMessageReceivedEvent) => void \| Promise<void>` | No | Called when a message arrives during streaming (per-message). |
|
|
402
402
|
| `onInjected` | `(event: PendingMessagesInjectedEvent) => void \| Promise<void>` | No | Called after a batch is injected via prepareStep. |
|
|
@@ -504,13 +504,14 @@ All methods available on the `chat` object from `@trigger.dev/sdk/ai`.
|
|
|
504
504
|
| `chat.createSession(payload, options)` | Create an async iterator for chat turns |
|
|
505
505
|
| `chat.pipe(source, options?)` | Pipe a stream to the frontend (from anywhere inside a task) |
|
|
506
506
|
| `chat.pipeAndCapture(source, options?)` | Pipe and capture the response; returns `{ message, status, error }` |
|
|
507
|
-
| `chat.writeTurnComplete(options?)` | Signal turn complete; returns `{ lastEventId, sessionInEventId }` resume cursors
|
|
507
|
+
| `chat.writeTurnComplete(options?)` | Signal turn complete; returns `{ lastEventId, sessionInEventId }` resume cursors. `sessionInEventId` is a lower bound, not the sequence of the record the turn answered |
|
|
508
508
|
| `chat.createStopSignal()` | Create a managed stop signal wired to the stop input stream |
|
|
509
|
-
| `chat.messages` |
|
|
509
|
+
| `chat.messages` | Incoming message mailbox; supports non-consuming `.peek()` / `.hasPending()`, single-record `.next()`, `.on()`, and suspend-aware `.waitWithIdleTimeout()` |
|
|
510
510
|
| `chat.local<T>({ id })` | Create a per-run typed local (see [`chat.local`](/ai-chat/chat-local)) |
|
|
511
511
|
| `chat.createStartSessionAction(taskId, options?)` | Returns a server action that creates a chat Session + triggers the first run + returns a session-scoped PAT. Idempotent on `(env, externalId)`. |
|
|
512
512
|
| `chat.waitForHandover(options)` | Wait for a [`chat.headStart`](/ai-chat/fast-starts#handover-with-custom-agents) handover signal in a custom loop. Returns the signal or `null`. `chat.MessageAccumulator` wraps this as `consumeHandover()` / `applyHandover()` |
|
|
513
513
|
| `chat.requestUpgrade()` | End the current run after this turn so the next message starts on the latest agent version. Server-orchestrated handoff. |
|
|
514
|
+
| `chat.endAndContinue()` | In a hand-rolled custom agent, hand off the Session to a fresh continuation run. Call between turns after detaching input listeners, then return immediately. The promise rejects if the handoff fails. |
|
|
514
515
|
| `chat.setTurnTimeout(duration)` | Override turn timeout at runtime (e.g. `"2h"`) |
|
|
515
516
|
| `chat.setTurnTimeoutInSeconds(seconds)` | Override turn timeout at runtime (in seconds) |
|
|
516
517
|
| `chat.setIdleTimeoutInSeconds(seconds)` | Override idle timeout at runtime |
|
|
@@ -546,15 +547,30 @@ Use this when you need [`InferChatUIMessage`](#inferchatuimessage) / typed `data
|
|
|
546
547
|
|
|
547
548
|
## `chat.withClientData`
|
|
548
549
|
|
|
549
|
-
Returns a [`ChatBuilder`](/ai-chat/types#chatbuilder) with a fixed client data schema.
|
|
550
|
+
Returns a [`ChatBuilder`](/ai-chat/types#chatbuilder) with a fixed client data schema. Managed-agent hooks and `run` get typed `clientData` without passing `clientDataSchema` in `.agent()` options. Custom agents parse `payload.metadata` on the initial payload and later input frames before passing it to user code.
|
|
550
551
|
|
|
551
552
|
```ts
|
|
552
|
-
chat.withClientData<TSchema>(
|
|
553
|
+
chat.withClientData<TSchema extends TaskSchema>(config: {
|
|
554
|
+
schema: TSchema;
|
|
555
|
+
reportErrorAt?: "turn-end" | "arrival";
|
|
556
|
+
onValidationError?: (event: {
|
|
557
|
+
error: unknown;
|
|
558
|
+
payload: ChatTaskWirePayload;
|
|
559
|
+
}) => Promise<void> | void;
|
|
560
|
+
}): ChatBuilder<UIMessage, TSchema>;
|
|
553
561
|
```
|
|
554
562
|
|
|
555
|
-
| Parameter
|
|
556
|
-
|
|
|
557
|
-
| `schema`
|
|
563
|
+
| Parameter | Type | Default | Description |
|
|
564
|
+
| ------------------- | --------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------ |
|
|
565
|
+
| `schema` | `TaskSchema` | required | Zod, ArkType, Valibot, or any supported schema lib |
|
|
566
|
+
| `reportErrorAt` | `"turn-end" \| "arrival"` | `"turn-end"` | When the client-visible `Invalid client data` error is written for a steering frame that failed validation mid-turn |
|
|
567
|
+
| `onValidationError` | `(event) => void` | — | Called when an input fails validation. Composes with the task-level `onClientDataValidationError` rather than replacing it |
|
|
568
|
+
|
|
569
|
+
`reportErrorAt` governs only the stream-visible error, and only for frames arriving on the steering subscription created by `chat.createSession({ pendingMessages })`. `"turn-end"` holds it until the turn closes, so a bad send cannot truncate an answer the user is already reading; `"arrival"` writes it as soon as validation fails, ending the response in progress. `onValidationError` and the task log fire on arrival in both modes, and the frame is never delivered as a turn either way. A raw `chat.messages.on()` subscription has no turn boundary to defer to, so it always reports through the callback and the task log without writing to the stream.
|
|
570
|
+
|
|
571
|
+
For `chat.customAgent()`, invalid client data is skipped. Async reads emit an error chunk followed by `turn-complete`. A `chat.messages.on()` subscription uses the task's `onClientDataValidationError` callback and task log instead, so an active response is not ended early. Without a schema, metadata is passed through unchanged.
|
|
572
|
+
|
|
573
|
+
Passing options directly to `chat.customAgent()` instead of through the builder uses the flat equivalents: `clientDataSchema`, `clientDataReportErrorAt`, and `onClientDataValidationError`.
|
|
558
574
|
|
|
559
575
|
Full guide: [Typed client data](/ai-chat/types#typed-client-data-with-chatwithclientdata).
|
|
560
576
|
|
|
@@ -645,7 +661,7 @@ The `onEvent` callback receives a `ChatTransportEvent` (exported from `@trigger.
|
|
|
645
661
|
| `message-send-failed` | `messageId?`, `source`, `error`, `status?`, `durationMs`, `partId?`, `bodyBytes?` | A send definitively failed after internal retries. Fires in addition to `useChat`'s `onError`. |
|
|
646
662
|
| `stream-connected` | `resumed`, `lastEventId?`, `messageId?` | The SSE subscription to the session's output stream started delivering. `resumed: true` when reconnecting from a stored cursor (page reload) rather than following a fresh send. `lastEventId` is the cursor it connected from. |
|
|
647
663
|
| `first-chunk` | `chunkType?`, `lastEventId?`, `messageId?`, `sinceSendMs?` | The first response chunk of a turn arrived. `sinceSendMs` is the delta from the last turn-producing send — time to first token without any bookkeeping. |
|
|
648
|
-
| `turn-completed` | `lastEventId?`, `sessionInEventId?`, `messageId?`, `sinceSendMs?` | The agent's turn-complete control record arrived — the "finished answering" signal. `sinceSendMs` is the full turn latency; `sessionInEventId` is the agent
|
|
664
|
+
| `turn-completed` | `lastEventId?`, `sessionInEventId?`, `messageId?`, `sinceSendMs?` | The agent's turn-complete control record arrived — the "finished answering" signal. `sinceSendMs` is the full turn latency; `sessionInEventId` is the cursor the agent can safely resume its input stream from. Treat it as a lower bound: it is held back behind any message still waiting to be handled, so it can be below the sequence of the record this turn answered. Do not use it to decide whether a turn boundary belongs to your own send. |
|
|
649
665
|
| `stream-error` | `error`, `status?` | The output stream failed unrecoverably. |
|
|
650
666
|
|
|
651
667
|
`source` identifies the send path: `"submit-message"`, `"regenerate-message"`, `"steer"` (`sendPendingMessage`), `"action"` (`sendAction`), `"stop"` (`stopGeneration`), or `"head-start"`.
|
|
@@ -784,7 +800,7 @@ Send a custom action to the agent. Actions wake the agent from suspension and fi
|
|
|
784
800
|
transport.sendAction(chatId: string, action: unknown): Promise<ReadableStream<UIMessageChunk>>
|
|
785
801
|
```
|
|
786
802
|
|
|
787
|
-
|
|
803
|
+
For managed `chat.agent()` tasks, the action payload is validated against the agent's `actionSchema` on the backend. Raw `chat.customAgent()` tasks receive it as `unknown` and must validate it themselves.
|
|
788
804
|
|
|
789
805
|
```tsx
|
|
790
806
|
// Undo button
|
package/docs/ai-chat/types.mdx
CHANGED
|
@@ -140,7 +140,7 @@ You can also import `InferChatUIMessage` from `@trigger.dev/sdk/ai` in non-React
|
|
|
140
140
|
|
|
141
141
|
## Typed client data with `chat.withClientData`
|
|
142
142
|
|
|
143
|
-
`chat.withClientData({ schema })` returns a [ChatBuilder](#chatbuilder) that fixes the client data schema.
|
|
143
|
+
`chat.withClientData({ schema })` returns a [ChatBuilder](#chatbuilder) that fixes the client data schema. Managed-agent hooks and `run` receive typed `clientData` without needing `clientDataSchema` in `.agent()` options. A `.customAgent()` run receives the parsed schema output in `payload.metadata`, and `chat.createSession()` yields it as `turn.clientData`.
|
|
144
144
|
|
|
145
145
|
```ts
|
|
146
146
|
import { chat } from "@trigger.dev/sdk/ai";
|
|
@@ -167,6 +167,10 @@ export const myChat = chat
|
|
|
167
167
|
});
|
|
168
168
|
```
|
|
169
169
|
|
|
170
|
+
The schema runs at runtime for both `.agent()` and `.customAgent()`. Custom agents validate the initial payload and later `chat.messages` frames. Invalid frames are not passed to user code. Async reads emit an error chunk followed by `turn-complete`; `chat.messages.on()` reports through `onClientDataValidationError` and the task log so it does not end an active response. Without a schema, metadata is passed through unchanged.
|
|
171
|
+
|
|
172
|
+
`withClientData` also takes `reportErrorAt` and `onValidationError` alongside `schema`. See [chat.withClientData](/ai-chat/reference#chatwithclientdata) for both, and [Validating client data](/ai-chat/custom-agents#validating-client-data) for the custom-agent walkthrough.
|
|
173
|
+
|
|
170
174
|
## ChatBuilder
|
|
171
175
|
|
|
172
176
|
Both `chat.withUIMessage()` and `chat.withClientData()` return a **ChatBuilder** — a chainable object that accumulates configuration before creating the agent with `.agent()`.
|
|
@@ -4,6 +4,18 @@ sidebarTitle: "Atomic deploys"
|
|
|
4
4
|
description: "Use atomic deploys to coordinate changes to your tasks and your application."
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
+
<Warning>
|
|
8
|
+
**There's now a simpler way to do this.** [Version skew
|
|
9
|
+
protection](/deployment/version-skew-protection) solves the same problem without a second
|
|
10
|
+
deployment, without gating your app's deploy, and without setting `TRIGGER_VERSION` — and it covers
|
|
11
|
+
staging and preview as well as production. If you use the [Vercel
|
|
12
|
+
integration](/vercel-integration), its **automatic atomic deployments** setting is now deprecated
|
|
13
|
+
in favour of skew protection.
|
|
14
|
+
|
|
15
|
+
The manual workflows on this page still work, and remain the right answer if you specifically want
|
|
16
|
+
your application's deployment held back until your tasks have finished building.
|
|
17
|
+
</Warning>
|
|
18
|
+
|
|
7
19
|
Atomic deploys in Trigger.dev allow you to synchronize the deployment of your application with a specific version of your tasks. This ensures that your application always uses the correct version of its associated tasks, preventing inconsistencies or errors due to version mismatches.
|
|
8
20
|
|
|
9
21
|
## How it works
|
|
@@ -122,6 +122,12 @@ If you want to set a global version to run all tasks against, you can use the `T
|
|
|
122
122
|
TRIGGER_VERSION=20250228.1
|
|
123
123
|
```
|
|
124
124
|
|
|
125
|
+
<Tip>
|
|
126
|
+
If what you actually want is for each release of your app to run against the tasks built from the
|
|
127
|
+
same commit, you don't need to plumb version numbers around by hand. See [version skew
|
|
128
|
+
protection](/deployment/version-skew-protection).
|
|
129
|
+
</Tip>
|
|
130
|
+
|
|
125
131
|
### Child tasks and auto-version locking
|
|
126
132
|
|
|
127
133
|
Trigger and wait functions version lock child task runs to the parent task run version. This ensures the results from child runs match what the parent task is expecting. If you don't wait then version locking doesn't apply.
|
|
@@ -155,7 +161,7 @@ Or from the dashboard:
|
|
|
155
161
|
|
|
156
162
|

|
|
157
163
|
|
|
158
|
-
To learn more about skipping promotion and how this enables atomic deployments, see our [Atomic deployment](/deployment/atomic-deployment) guide.
|
|
164
|
+
To learn more about skipping promotion and how this enables atomic deployments, see our [Atomic deployment](/deployment/atomic-deployment) guide. To keep your app and tasks in sync without coordinating promotion at all, see [version skew protection](/deployment/version-skew-protection).
|
|
159
165
|
|
|
160
166
|
## Staging deploys
|
|
161
167
|
|