Skip to content

Commit e2d3b83

Browse files
authored
feat(sdk): return lastEventId from writeTurnComplete and typed capture result (#4304)
## Summary Two ergonomic additions for custom chat-agent loops that own the turn loop (`chat.customAgent`, `chat.createSession`, and the hand-rolled primitives). `chat.writeTurnComplete()` now resolves to `{ lastEventId }`, the resume cursor for the start of the next turn. A custom loop can persist it straight from the task instead of round-tripping it back from the client after the turn ends. The value was already produced internally by the turn-complete write; the public wrapper simply discarded it. `chat.pipeAndCapture()` no longer throws when a stream is stopped or fails. It now resolves to a `PipeAndCaptureResult` carrying any partial `message` captured before the stop or failure, a typed `status` (`"complete" | "aborted" | "error"`), and the `error` on failure. Previously a failed stream threw and the partial was lost, and an abort was captured only when the AI SDK happened to fire `onFinish` in time. ```ts const { message, status, error } = await chat.pipeAndCapture(result, { signal }); if (message) conversation.addResponse(message); if (status === "error") logger.error("turn failed", { error }); const { lastEventId } = await chat.writeTurnComplete(); await db.chats.update(chatId, { lastEventId }); ``` ## Design `pipeAndCapture` wraps the pipe in a `try/catch` and classifies the outcome from the abort signal (a stop drains the source stream cleanly rather than throwing) versus a thrown error. It also races the `onFinish` capture against a timeout so a hard stop that prevents `onFinish` from firing can't hang the caller. This mirrors the capture path `chat.agent` already uses internally. The `finishReason` from `onFinish` is surfaced too, since it was already captured on the built-in path. The internal `turn.complete()` helper keeps its existing contract: it still returns `UIMessage | undefined`, still throws on a genuine stream failure, and still discards output on a full run cancel. ## Breaking change `chat.pipeAndCapture` previously resolved to `UIMessage | undefined`. Call sites now read `.message` off the result. This is a young, low-level API; the docs examples are updated in this PR.
1 parent 6642c8b commit e2d3b83

10 files changed

Lines changed: 483 additions & 66 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
---
4+
5+
Custom chat agent loops get two ergonomic wins for owning the turn loop.
6+
7+
`chat.writeTurnComplete()` now returns the turn boundary's resume cursors (`lastEventId` for the output stream and `sessionInEventId` for the input stream), so you can persist them straight from the task instead of round-tripping them back from the client.
8+
9+
```ts
10+
const { lastEventId, sessionInEventId } = await chat.writeTurnComplete();
11+
await db.chats.update(chatId, { lastEventId, sessionInEventId });
12+
```
13+
14+
`chat.pipeAndCapture()` no longer throws when a stream is stopped or fails. It now returns a `PipeAndCaptureResult` whose `message` holds any partial output captured before the stop or failure, alongside a typed `status` (`"complete" | "aborted" | "error"`) and, on failure, the `error`. Read the message off the result:
15+
16+
```ts
17+
const { message, status, error } = await chat.pipeAndCapture(result, { signal });
18+
if (message) conversation.addResponse(message);
19+
if (status === "error") logger.error("turn failed", { error });
20+
```
21+
22+
Note: `pipeAndCapture` previously resolved to `UIMessage | undefined`. Update call sites to read `.message` from the returned result.

docs/ai-chat/changelog.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,10 @@ if (isFinal) {
5555
const result = streamText({ model, messages: conversation.modelMessages, tools });
5656
// Pass originalMessages so the handed-over tool round merges into the
5757
// step-1 assistant instead of starting a new message.
58-
const response = await chat.pipeAndCapture(result, {
58+
const { message } = await chat.pipeAndCapture(result, {
5959
originalMessages: conversation.uiMessages,
6060
});
61-
if (response) await conversation.addResponse(response);
61+
if (message) await conversation.addResponse(message);
6262
}
6363
```
6464

docs/ai-chat/compaction.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -365,8 +365,8 @@ for (let turn = 0; turn < 100; turn++) {
365365
stopWhen: stepCountIs(15),
366366
});
367367

368-
const response = await chat.pipeAndCapture(result);
369-
if (response) await conversation.addResponse(response);
368+
const { message } = await chat.pipeAndCapture(result);
369+
if (message) await conversation.addResponse(message);
370370

371371
// Outer-loop compaction
372372
const usage = await result.totalUsage;

docs/ai-chat/custom-agents.mdx

Lines changed: 15 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -160,11 +160,11 @@ for await (const turn of session) {
160160
});
161161

162162
// Manual: pipe and capture separately
163-
const response = await chat.pipeAndCapture(result, { signal: turn.signal });
163+
const { message } = await chat.pipeAndCapture(result, { signal: turn.signal });
164164

165-
if (response) {
165+
if (message) {
166166
// Custom processing before accumulating
167-
await turn.addResponse(response);
167+
await turn.addResponse(message);
168168
}
169169

170170
// Custom persistence, analytics, etc.
@@ -215,8 +215,8 @@ For full control, skip `createSession` and compose the primitives directly:
215215
| ------------------------------- | -------------------------------------------------------------------------------------------- |
216216
| `chat.messages` | Input stream for incoming messages — use `.waitWithIdleTimeout()` to wait for the next turn |
217217
| `chat.createStopSignal()` | Create a managed stop signal wired to the stop input stream |
218-
| `chat.pipeAndCapture(result)` | Pipe a `StreamTextResult` to the chat stream and capture the response |
219-
| `chat.writeTurnComplete()` | Signal the frontend that the current turn is complete |
218+
| `chat.pipeAndCapture(result)` | Pipe a stream and capture the response; returns `{ message, status, error }` |
219+
| `chat.writeTurnComplete()` | Signal turn complete; returns `{ lastEventId, sessionInEventId }` resume cursors |
220220
| `chat.MessageAccumulator` | Accumulates conversation messages across turns |
221221
| `chat.pipe(stream)` | Pipe a stream to the frontend (no response capture) |
222222
| `chat.cleanupAbortedParts(msg)` | Clean up incomplete parts from a stopped response |
@@ -285,21 +285,16 @@ export const myChat = chat.customAgent({
285285
stopWhen: stepCountIs(15),
286286
});
287287

288-
let response;
289-
try {
290-
response = await chat.pipeAndCapture(result, { signal: combinedSignal });
291-
} catch (error) {
292-
if (error instanceof Error && error.name === "AbortError") {
293-
if (runSignal.aborted) break;
294-
// Stop — fall through to accumulate partial
295-
} else {
296-
throw error;
297-
}
298-
}
288+
const { message, status, error } = await chat.pipeAndCapture(result, {
289+
signal: combinedSignal,
290+
});
291+
// pipeAndCapture never throws: a user stop returns status "aborted" with
292+
// the partial message, and a failure returns status "error" with `error`.
293+
if (status === "error") throw error;
299294

300-
if (response) {
295+
if (message) {
301296
const cleaned =
302-
stop.signal.aborted && !runSignal.aborted ? chat.cleanupAbortedParts(response) : response;
297+
stop.signal.aborted && !runSignal.aborted ? chat.cleanupAbortedParts(message) : message;
303298
await conversation.addResponse(cleaned);
304299
}
305300

@@ -346,8 +341,8 @@ const messages = await conversation.addIncoming(
346341
);
347342

348343
// After piping, add the response
349-
const response = await chat.pipeAndCapture(result);
350-
if (response) await conversation.addResponse(response);
344+
const { message } = await chat.pipeAndCapture(result);
345+
if (message) await conversation.addResponse(message);
351346

352347
// Access accumulated messages for persistence
353348
conversation.uiMessages; // UIMessage[]

docs/ai-chat/fast-starts.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -589,10 +589,10 @@ if (turn === 0 && payload.trigger === "handover-prepare") {
589589
messages: conversation.modelMessages,
590590
stopWhen: stepCountIs(10),
591591
});
592-
const response = await chat.pipeAndCapture(result, {
592+
const { message } = await chat.pipeAndCapture(result, {
593593
originalMessages: conversation.uiMessages,
594594
});
595-
if (response) await conversation.addResponse(response);
595+
if (message) await conversation.addResponse(message);
596596
}
597597
await chat.writeTurnComplete(); // on isFinal the warm partial is already the response
598598
return;

docs/ai-chat/pending-messages.mdx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,10 +179,14 @@ for (let turn = 0; turn < 100; turn++) {
179179
stopWhen: stepCountIs(15),
180180
});
181181

182-
const response = await chat.pipeAndCapture(result);
182+
const { message, status, error } = await chat.pipeAndCapture(result);
183183
sub.off();
184184

185-
if (response) await conversation.addResponse(response);
185+
// Keep any partial output, then branch on the outcome: pipeAndCapture no
186+
// longer throws, so rethrow a real failure and don't complete a failed turn.
187+
if (message) await conversation.addResponse(message);
188+
if (status === "error") throw error;
189+
186190
await chat.writeTurnComplete();
187191
}
188192
```

docs/ai-chat/reference.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -503,8 +503,8 @@ All methods available on the `chat` object from `@trigger.dev/sdk/ai`.
503503
| `chat.agent(options)` | Create a chat agent |
504504
| `chat.createSession(payload, options)` | Create an async iterator for chat turns |
505505
| `chat.pipe(source, options?)` | Pipe a stream to the frontend (from anywhere inside a task) |
506-
| `chat.pipeAndCapture(source, options?)` | Pipe and capture the response `UIMessage` |
507-
| `chat.writeTurnComplete(options?)` | Signal the frontend that the current turn is complete |
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 |
508508
| `chat.createStopSignal()` | Create a managed stop signal wired to the stop input stream |
509509
| `chat.messages` | Input stream for incoming messages — use `.waitWithIdleTimeout()` |
510510
| `chat.local<T>({ id })` | Create a per-run typed local (see [`chat.local`](/ai-chat/chat-local)) |

0 commit comments

Comments
 (0)