Skip to content

Commit 6bf731b

Browse files
ericallamTrigger.dev RepoOps
authored andcommitted
docs(ai-chat): clarify setup, steering, recovery, and branching
Clarify chat.agent setup and recovery with examples that check chat ownership before starting sessions or refreshing tokens, use managed streamText for steering, and preserve partial responses after failures. Explain how to load transcripts before resuming the frontend and stop a resumed generation. Update the branching guide to use transcript storage and an explicit active branch, and add checks for transport behavior, error recovery, and branch isolation. Mono-RevId: 071c63091f9605d84806381d97311f2fb1583eea
1 parent 4e83d83 commit 6bf731b

6 files changed

Lines changed: 215 additions & 351 deletions

File tree

‎docs/ai-chat/error-handling.mdx‎

Lines changed: 44 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ When the model API errors mid-response (rate limits, network failures, malformed
2121

2222
```ts
2323
import { chat } from "@trigger.dev/sdk/ai";
24+
import { anthropic } from "@ai-sdk/anthropic";
2425

2526
export const myChat = chat.agent({
2627
id: "my-chat",
@@ -36,7 +37,7 @@ export const myChat = chat.agent({
3637
return "Something went wrong while generating a response. Please try again.";
3738
},
3839
},
39-
run: async ({ messages, signal }) => {
40+
run: async ({ messages, signal, streamText }) => {
4041
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
4142
},
4243
});
@@ -72,7 +73,7 @@ export const myChat = chat.agent({
7273
// and the agent waits for the next message
7374
await db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } });
7475
},
75-
run: async ({ messages, signal }) => {
76+
run: async ({ messages, signal, streamText }) => {
7677
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
7778
},
7879
});
@@ -101,25 +102,7 @@ onValidateMessages: async ({ messages }) => {
101102

102103
### Catching errors inside `run()`
103104

104-
`run()` is your code — wrap it in try/catch for full control. This is the right place to save partial state to your DB before the error chunk goes out:
105-
106-
```ts
107-
run: async ({ messages, chatId, signal }) => {
108-
try {
109-
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
110-
} catch (err) {
111-
// Save the failed turn for debugging / undo
112-
await db.failedTurn.create({
113-
data: {
114-
chatId,
115-
error: err instanceof Error ? err.message : String(err),
116-
messages,
117-
},
118-
});
119-
throw err; // Re-throw to trigger the error chunk
120-
}
121-
},
122-
```
105+
Catch errors from work you await inside `run()`, such as loading application data. Errors emitted after `streamText()` returns reach the response stream instead. Record those in `onTurnComplete`, which receives the partial response and the error.
123106

124107
## Saving error state to your DB
125108

@@ -158,14 +141,14 @@ chat.agent({
158141
error: error.message,
159142
});
160143
},
161-
run: async ({ messages, signal }) => {
162-
return streamText({ ... });
144+
run: async ({ messages, signal, streamText }) => {
145+
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
163146
},
164147
});
165148
```
166149

167150
<Info>
168-
`chat.agent` uses `retry: { maxAttempts: 1 }` internally, so the run never retries on failure. To add run-level retries, wrap the agent in a parent task or implement your own retry logic in the frontend (re-send the message).
151+
`chat.agent` defaults to one run attempt. Setting `oomMachine` enables one additional attempt on the larger machine after an out-of-memory failure. Recover from a failed turn by sending another message or calling `regenerate()`.
169152
</Info>
170153

171154
## Recovery patterns
@@ -186,22 +169,25 @@ chat.agent({
186169
const lastUserIdx = [...uiMessages].reverse().findIndex(m => m.role === "user");
187170
if (lastUserIdx !== -1) {
188171
const targetIdx = uiMessages.length - 1 - lastUserIdx - 1;
189-
const target = uiMessages[targetIdx];
190-
if (target) chat.history.rollbackTo(target.id);
172+
chat.history.slice(0, targetIdx + 1);
191173
}
192174
}
193175
},
194-
run: async ({ messages, signal }) => {
195-
return streamText({ ... });
176+
run: async ({ messages, signal, streamText }) => {
177+
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
196178
},
197179
});
198180
```
199181

200182
On the frontend, show an "Undo" button when an error occurs:
201183

202-
```tsx
184+
```tsx app/chat/UndoButton.tsx
185+
import { useChatActions } from "@trigger.dev/sdk/chat/react";
186+
187+
const { sendAction } = useChatActions({ sendMessage });
188+
203189
{error && (
204-
<button onClick={() => transport.sendAction(chatId, { type: "undo" })}>
190+
<button onClick={() => sendAction({ type: "undo" })}>
205191
Undo and try again
206192
</button>
207193
)}
@@ -219,55 +205,39 @@ const { messages, error, regenerate } = useChat({ transport });
219205
)}
220206
```
221207

222-
`regenerate()` removes the last assistant response and re-sends. Combined with `onValidateMessages` or `hydrateMessages`, you can reload the canonical state from your DB before retrying.
208+
`regenerate()` removes the last assistant response and re-sends. If your storage implements `loadContext`, handle `trigger === "regenerate-message"` there: remove trailing assistant messages from the active branch so the model receives the last user message as its prompt.
223209

224210
### Pattern 3: Save partial responses
225211

226-
When a stream errors mid-response, the `responseMessage` in `onBeforeTurnComplete` and `onTurnComplete` contains the partial output. Save it as a "draft" so the user can see what was generated before the error:
212+
When a stream fails or you stop a response, transcript storage preserves the partial assistant message with `final: false`. Keep that flag in your storage adapter so a reload doesn't present an incomplete answer as finished.
227213

228-
```ts
229-
onBeforeTurnComplete: async ({ chatId, responseMessage, stopped }) => {
230-
if (responseMessage && responseMessage.parts.length > 0) {
231-
// Save partial response — user can manually accept or discard
232-
await db.partialResponse.create({
233-
data: {
234-
chatId,
235-
message: responseMessage,
236-
reason: stopped ? "stopped" : "errored",
237-
},
238-
});
239-
}
240-
},
241-
```
214+
Use `onTurnComplete` to record the outcome. Check `error` or `stopped` before classifying a response as partial:
242215

243-
### Pattern 4: Fall back to a different model
216+
```ts trigger/chat.ts
217+
onTurnComplete: async ({ chatId, responseMessage, error, stopped }) => {
218+
if (!responseMessage || (!error && !stopped)) return;
244219

245-
If the primary model errors, try a fallback model in the same turn:
246-
247-
```ts
248-
run: async ({ messages, signal }) => {
249-
try {
250-
return streamText({
251-
model: anthropic("claude-sonnet-4-5"),
252-
messages,
253-
abortSignal: signal,
254-
stopWhen: stepCountIs(15),
255-
});
256-
} catch (err) {
257-
console.warn("Primary model failed, falling back:", err);
258-
return streamText({
259-
model: anthropic("claude-sonnet-4-6"),
260-
messages,
261-
abortSignal: signal,
262-
stopWhen: stepCountIs(15),
263-
});
264-
}
220+
await db.partialResponse.upsert({
221+
where: { id: responseMessage.id },
222+
create: {
223+
id: responseMessage.id,
224+
chatId,
225+
message: responseMessage,
226+
reason: stopped ? "stopped" : "errored",
227+
},
228+
update: {
229+
message: responseMessage,
230+
reason: stopped ? "stopped" : "errored",
231+
},
232+
});
265233
},
266234
```
267235

268-
<Note>
269-
This only catches errors thrown synchronously by `streamText` setup. Errors that happen mid-stream go through `uiMessageStreamOptions.onError`, not your try/catch.
270-
</Note>
236+
### Pattern 4: Retry with another model
237+
238+
`streamText()` returns before the provider finishes streaming. A `try/catch` around that call only catches setup failures; it doesn't catch errors that arrive later in the stream.
239+
240+
For a streamed provider failure, let the failed turn finish, choose a fallback model on your server, then regenerate the response. The partial response remains available until you replace it. Check whether tools already performed side effects before retrying, and make those operations idempotent.
271241

272242
## What gets written to the stream on error
273243

@@ -283,7 +253,7 @@ The AI SDK's `useChat` processes this and:
283253

284254
1. Sets `useChat`'s `error` field to an `Error` with `message = errorText`
285255
2. Calls the user's `onError` callback (if set)
286-
3. Marks the turn as complete (`status` returns to `"ready"`)
256+
3. Sets `status` to `"error"`. Sending another message or regenerating starts a new request.
287257

288258
```tsx
289259
const { messages, error, status } = useChat({
@@ -375,13 +345,11 @@ const transport = useTriggerChatTransport({
375345

376346
## Run-level retries
377347

378-
`chat.agent` uses `retry: { maxAttempts: 1 }` — the run **never retries** on unhandled failure. This is intentional: each turn is conversation-preserving, so a true run failure is severe and shouldn't silently retry (which could send duplicate API calls or mutate state twice).
348+
`chat.agent` defaults to `retry: { maxAttempts: 1 }`. Turn errors are handled within the conversation, so another message can succeed in the same run.
379349

380-
To add retry-like behavior:
350+
If you configure `oomMachine`, the agent uses up to 2 attempts and retries an out-of-memory failure on that machine. This doesn't enable retries for ordinary provider or tool errors.
381351

382-
- **Per-turn retries**: handle inside `run()` with try/catch and a fallback model
383-
- **Per-message retries**: re-send from the frontend (call `sendMessage` or `regenerate` again)
384-
- **Whole-run retries**: wrap `chat.agent` with a parent task that has `retry` configured, and call the agent's task internally
352+
Use the AI SDK's provider retry settings for transient model-call failures. After a turn has failed, let the user send another message or call `regenerate()`. Retrying a conversation can repeat tool side effects, so use idempotency keys for writes.
385353

386354
## Best practices
387355

‎docs/ai-chat/frontend.mdx‎

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,10 @@ Because the underlying Session row outlives individual runs, a chat you were in
139139

140140
If you do not keep your own copy of the conversation, load it from the agent's [transcript storage](/ai-chat/transcript-storage#reading-the-transcript) instead: `chat.createLoadTranscriptAction(storage)` on the server and `useLoadTranscript(chatId, action, { transport })` in the browser return the messages and seed the transport's resume cursor, for the default storage and your own alike.
141141

142+
Mount the component that calls `useChat` after transcript loading finishes. Its initial `messages` and the transport's cursor must describe the same saved transcript. Transcript loading can seed the cursor before session hydration: the transport keeps it until the session is created or hydrated. Reconnecting the live stream requires a known session.
143+
144+
To stop a generation after a reload, stop the local subscription and send the backend stop command together. See [Stop generation](#stop-generation) for the handler.
145+
142146
```tsx app/chat/[chatId]/ChatPage.tsx
143147
"use client";
144148

@@ -250,11 +254,13 @@ const transport = useTriggerChatTransport<typeof myChat>({
250254
accessToken: ({ chatId }) => mintChatAccessToken(chatId),
251255
startSession: ({ chatId, clientData }) =>
252256
startChatSession({ chatId, clientData }),
253-
clientData: { userId: currentUser.id },
257+
clientData: { locale: currentLocale },
254258
});
255259
```
256260

257-
The transport threads `clientData` through three places automatically: into `startSession`'s `params.clientData` for the first run's `payload.metadata`, into per-turn `metadata` on every `.in/append` chunk, and live-updates if the option value changes between renders (so React-driven values like the current user work without reconstructing the transport).
261+
The transport threads `clientData` through three places automatically: into `startSession`'s `params.clientData` for the first run's `payload.metadata`, into per-turn `metadata` on every `.in/append` chunk, and live-updates if the option value changes between renders (so React-driven values like the selected language work without reconstructing the transport).
262+
263+
Browser-supplied `clientData` is input, including after schema validation. Use it for preferences such as language. Derive ownership, permissions, and plan limits from authenticated server state. See [trusted edge signals](/ai-chat/patterns/trusted-edge-signals).
258264

259265
### Per-message metadata
260266

@@ -270,26 +276,19 @@ Instead of manually parsing `clientData` with Zod in every hook, pass a `clientD
270276

271277
```ts
272278
import { chat } from "@trigger.dev/sdk/ai";
273-
import { streamText, stepCountIs } from "ai";
279+
import { stepCountIs } from "ai";
274280
import { anthropic } from "@ai-sdk/anthropic";
275281
import { z } from "zod";
276282

277283
export const myChat = chat.agent({
278284
id: "my-chat",
279285
clientDataSchema: z.object({
280-
model: z.string().optional(),
281-
userId: z.string(),
286+
locale: z.enum(["en", "fr"]).default("en"),
282287
}),
283-
onChatStart: async ({ chatId, clientData }) => {
284-
// clientData is typed as { model?: string; userId: string }
285-
await db.chat.create({
286-
data: { id: chatId, userId: clientData.userId },
287-
});
288-
},
289-
run: async ({ messages, clientData, signal }) => {
290-
// Same typed clientData — no manual parsing needed
288+
run: async ({ messages, clientData, signal, streamText }) => {
291289
return streamText({
292-
model: openai(clientData?.model ?? "gpt-4o"),
290+
model: anthropic("claude-sonnet-4-5"),
291+
system: clientData?.locale === "fr" ? "Reply in French." : "Reply in English.",
293292
messages,
294293
abortSignal: signal,
295294
stopWhen: stepCountIs(15),
@@ -317,15 +316,15 @@ Supports Zod, ArkType, Valibot, and other schema libraries supported by the SDK.
317316

318317
Use `transport.stopGeneration(chatId)` to stop the current generation. This sends a stop signal to the running task via input streams, aborting the current `streamText` call while keeping the run alive for the next message.
319318

320-
`stopGeneration` works in all scenarios — including after a page refresh when the stream was reconnected via `resume`. Call it alongside `useChat`'s `stop()` to also update the frontend state:
319+
`stopGeneration` also works after a page refresh when the stream was reconnected via `resume`. Call `useChat`'s `stop()` immediately, then send the backend stop command without waiting for the local stop to finish:
321320

322321
```tsx
323322
const { messages, sendMessage, stop: aiStop, status } = useChat({ transport });
324323

325324
// Wrap both calls in a single stop handler
326325
const stop = useCallback(() => {
327-
transport.stopGeneration(chatId);
328326
aiStop();
327+
transport.stopGeneration(chatId);
329328
}, [transport, chatId, aiStop]);
330329

331330
{

0 commit comments

Comments
 (0)