Skip to content

Commit 11e3306

Browse files
committed
feat(chat): add stop handling, abort cleanup, continuation support, and reference project enhancements
- Fix onFinish race condition: await onFinishPromise so capturedResponseMessage is set before accumulation - Add chat.isStopped() helper accessible from anywhere during a turn - Add chat.cleanupAbortedParts() to remove incomplete tool/reasoning/text parts on stop - Auto-cleanup aborted parts before passing to onTurnComplete - Clean incoming messages from frontend to prevent tool_use without tool_result API errors - Add stopped and rawResponseMessage fields to TurnCompleteEvent - Add continuation and previousRunId fields to all lifecycle hooks and run payload - Add span attributes (chat.id, chat.turn, chat.stopped, chat.continuation, chat.previous_run_id, etc.) - Add webFetch tool and reasoning model support to ai-chat reference project - Render reasoning parts in frontend chat component - Document all new fields in ai-chat guide
1 parent 3dab310 commit 11e3306

File tree

6 files changed

+364
-20
lines changed

6 files changed

+364
-20
lines changed

docs/guides/ai-chat.mdx

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,10 +240,17 @@ export const manualChat = task({
240240

241241
Fires once on the first turn (turn 0) before `run()` executes. Use it to create a chat record in your database.
242242

243+
The `continuation` field tells you whether this is a brand new chat or a continuation of an existing one (where the previous run timed out or was cancelled):
244+
243245
```ts
244246
export const myChat = chat.task({
245247
id: "my-chat",
246-
onChatStart: async ({ chatId, clientData }) => {
248+
onChatStart: async ({ chatId, clientData, continuation }) => {
249+
if (continuation) {
250+
// Previous run ended — chat record already exists, just update session
251+
return;
252+
}
253+
// Brand new chat — create the record
247254
const { userId } = clientData as { userId: string };
248255
await db.chat.create({
249256
data: { id: chatId, userId, title: "New chat" },
@@ -271,6 +278,7 @@ Fires at the start of every turn, after message accumulation and `onChatStart` (
271278
| `turn` | `number` | Turn number (0-indexed) |
272279
| `runId` | `string` | The Trigger.dev run ID |
273280
| `chatAccessToken` | `string` | Scoped access token for this run |
281+
| `continuation` | `boolean` | Whether this run is continuing an existing chat |
274282

275283
```ts
276284
export const myChat = chat.task({
@@ -312,6 +320,9 @@ Fires after each turn completes — after the response is captured, before waiti
312320
| `runId` | `string` | The Trigger.dev run ID |
313321
| `chatAccessToken` | `string` | Scoped access token for this run |
314322
| `lastEventId` | `string \| undefined` | Stream position for resumption. Persist this with the session. |
323+
| `stopped` | `boolean` | Whether the user stopped generation during this turn |
324+
| `continuation` | `boolean` | Whether this run is continuing an existing chat |
325+
| `rawResponseMessage` | `UIMessage \| undefined` | The raw assistant response before abort cleanup (same as `responseMessage` when not stopped) |
315326

316327
```ts
317328
export const myChat = chat.task({
@@ -679,6 +690,66 @@ export const myChat = chat.task({
679690
Use `signal` (the combined signal) in most cases. The separate `stopSignal` and `cancelSignal` are only needed if you want different behavior for stop vs cancel.
680691
</Tip>
681692

693+
### Detecting stop in callbacks
694+
695+
The `onTurnComplete` event includes a `stopped` boolean that indicates whether the user stopped generation during that turn:
696+
697+
```ts
698+
export const myChat = chat.task({
699+
id: "my-chat",
700+
onTurnComplete: async ({ chatId, uiMessages, stopped }) => {
701+
await db.chat.update({
702+
where: { id: chatId },
703+
data: { messages: uiMessages, lastStoppedAt: stopped ? new Date() : undefined },
704+
});
705+
},
706+
run: async ({ messages, signal }) => {
707+
return streamText({ model: openai("gpt-4o"), messages, abortSignal: signal });
708+
},
709+
});
710+
```
711+
712+
You can also check stop status from **anywhere** during a turn using `chat.isStopped()`. This is useful inside `streamText`'s `onFinish` callback where the AI SDK's `isAborted` flag can be unreliable (e.g. when using `createUIMessageStream` + `writer.merge()`):
713+
714+
```ts
715+
import { chat } from "@trigger.dev/sdk/ai";
716+
import { streamText } from "ai";
717+
718+
export const myChat = chat.task({
719+
id: "my-chat",
720+
run: async ({ messages, signal }) => {
721+
return streamText({
722+
model: openai("gpt-4o"),
723+
messages,
724+
abortSignal: signal,
725+
onFinish: ({ isAborted }) => {
726+
// isAborted may be false even after stop when using createUIMessageStream
727+
const wasStopped = isAborted || chat.isStopped();
728+
if (wasStopped) {
729+
// handle stop — e.g. log analytics
730+
}
731+
},
732+
});
733+
},
734+
});
735+
```
736+
737+
### Cleaning up aborted messages
738+
739+
When stop happens mid-stream, the captured response message can contain parts in an incomplete state — tool calls stuck in `partial-call`, reasoning blocks still marked as `streaming`, etc. These can cause UI issues like permanent spinners.
740+
741+
`chat.task` automatically cleans up the `responseMessage` when stop is detected before passing it to `onTurnComplete`. If you use `chat.pipe()` manually and capture response messages yourself, use `chat.cleanupAbortedParts()`:
742+
743+
```ts
744+
const cleaned = chat.cleanupAbortedParts(rawResponseMessage);
745+
```
746+
747+
This removes tool invocation parts stuck in `partial-call` state and marks any `streaming` text or reasoning parts as `done`.
748+
749+
<Note>
750+
Stop signal delivery is best-effort. There is a small race window where the model may finish before the stop signal arrives, in which case the turn completes normally with `stopped: false`. This is expected and does not require special handling.
751+
</Note>
752+
682753
## Client data and metadata
683754

684755
### Transport-level client data
@@ -982,6 +1053,7 @@ Plus all standard [TaskOptions](/tasks/overview) — `retry`, `queue`, `machine`
9821053
| `trigger` | `"submit-message" \| "regenerate-message"` | What triggered the request |
9831054
| `messageId` | `string \| undefined` | Message ID (for regenerate) |
9841055
| `clientData` | Typed by `clientDataSchema` | Custom data from the frontend (typed when schema is provided) |
1056+
| `continuation` | `boolean` | Whether this run is continuing an existing chat (previous run ended) |
9851057
| `signal` | `AbortSignal` | Combined stop + cancel signal |
9861058
| `cancelSignal` | `AbortSignal` | Cancel-only signal |
9871059
| `stopSignal` | `AbortSignal` | Stop-only signal (per-turn) |
@@ -1001,6 +1073,8 @@ See [onTurnComplete](#onturncomplete) for the full field reference.
10011073
| `chat.setTurnTimeout(duration)` | Override turn timeout at runtime (e.g. `"2h"`) |
10021074
| `chat.setTurnTimeoutInSeconds(seconds)` | Override turn timeout at runtime (in seconds) |
10031075
| `chat.setWarmTimeoutInSeconds(seconds)` | Override warm timeout at runtime |
1076+
| `chat.isStopped()` | Check if the current turn was stopped by the user (works anywhere during a turn) |
1077+
| `chat.cleanupAbortedParts(message)` | Remove incomplete parts from a stopped response message |
10041078

10051079
## Self-hosting
10061080

0 commit comments

Comments
 (0)