Skip to content

Commit 3343ef5

Browse files
committed
feat(sdk): return lastEventId from writeTurnComplete and typed capture result
chat.writeTurnComplete() now resolves to { lastEventId }, the resume cursor for the next turn, so a custom-agent loop can persist it straight from the task instead of round-tripping it back from the client. chat.pipeAndCapture() no longer throws when a stream is stopped or fails. It 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. The internal turn.complete() path keeps its existing contract (returns UIMessage | undefined, still throws on a genuine stream failure and discards output on full cancel).
1 parent 6e943f2 commit 3343ef5

8 files changed

Lines changed: 144 additions & 58 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": minor
3+
---
4+
5+
Custom chat agent loops get two ergonomic wins for owning the turn loop.
6+
7+
`chat.writeTurnComplete()` now returns the `lastEventId` of the turn-complete record, so you can persist the next turn's resume cursor straight from the task instead of round-tripping it back from the client.
8+
9+
```ts
10+
const { lastEventId } = await chat.writeTurnComplete();
11+
await db.chats.update(chatId, { lastEventId });
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 the frontend that the current turn is complete; returns `{ lastEventId }` |
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: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,10 +179,10 @@ for (let turn = 0; turn < 100; turn++) {
179179
stopWhen: stepCountIs(15),
180180
});
181181

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

185-
if (response) await conversation.addResponse(response);
185+
if (message) await conversation.addResponse(message);
186186
await chat.writeTurnComplete();
187187
}
188188
```

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 the frontend that the current turn is complete; returns `{ lastEventId }` |
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)) |

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

Lines changed: 97 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -8828,35 +8828,72 @@ function createStopSignal(): {
88288828
* The `TriggerChatTransport` intercepts this to close the ReadableStream
88298829
* for the current turn. Call after piping the response stream.
88308830
*
8831+
* Returns the `lastEventId` of the turn-complete control record on
8832+
* `session.out` — the resume cursor for the start of the next turn. Save it
8833+
* from the task (e.g. to your DB) instead of round-tripping it back from the
8834+
* client after the turn ends. `undefined` if the write returned no ack.
8835+
*
88318836
* @example
88328837
* ```ts
88338838
* await chat.pipe(result);
8834-
* await chat.writeTurnComplete();
8839+
* const { lastEventId } = await chat.writeTurnComplete();
8840+
* await db.chats.update(chatId, { lastEventId });
88358841
* ```
88368842
*/
8837-
async function chatWriteTurnComplete(options?: { publicAccessToken?: string }): Promise<void> {
8838-
await writeTurnCompleteChunk(undefined, options?.publicAccessToken);
8843+
async function chatWriteTurnComplete(options?: {
8844+
publicAccessToken?: string;
8845+
}): Promise<{ lastEventId?: string }> {
8846+
const result = await writeTurnCompleteChunk(undefined, options?.publicAccessToken);
8847+
return { lastEventId: result?.lastEventId };
88398848
}
88408849

8850+
/**
8851+
* The outcome of a turn's stream, reported by {@link pipeChatAndCapture}.
8852+
*
8853+
* - `complete` — the stream finished on its own.
8854+
* - `aborted` — the stream was stopped via the `signal` (user stop / cancel).
8855+
* - `error` — the stream threw; `error` carries what was thrown.
8856+
*
8857+
* `message` holds whatever the assistant produced and is present for every
8858+
* status — including `aborted` and `error` — as long as any output streamed
8859+
* before the stop or failure, so partial responses are never lost.
8860+
*/
8861+
export type PipeAndCaptureResult = {
8862+
/** The captured assistant message, or `undefined` if nothing streamed. */
8863+
message: UIMessage | undefined;
8864+
/** Coarse outcome of the stream. */
8865+
status: "complete" | "aborted" | "error";
8866+
/** The AI SDK finish reason, when the stream reported one. */
8867+
finishReason?: FinishReason;
8868+
/** What the stream threw, present only when `status === "error"`. */
8869+
error?: unknown;
8870+
};
8871+
88418872
/**
88428873
* Pipe a `StreamTextResult` (or similar) to the chat stream and capture
88438874
* the assistant's response message via `onFinish`.
88448875
*
88458876
* Combines `toUIMessageStream()` + `onFinish` callback + `chat.pipe()`.
8846-
* Returns the captured `UIMessage`, or `undefined` if capture failed.
8877+
* Never throws on a stopped or failed stream: it returns a
8878+
* {@link PipeAndCaptureResult} whose `message` holds any partial output
8879+
* captured before the stop/failure, alongside a typed `status` (and `error`
8880+
* on failure). Save the partial after a stop or error without separate
8881+
* capture logic.
88478882
*
88488883
* @example
88498884
* ```ts
88508885
* const result = streamText({ model, messages, abortSignal: signal });
8851-
* const response = await chat.pipeAndCapture(result, { signal });
8852-
* if (response) conversation.addResponse(response);
8886+
* const { message, status, error } = await chat.pipeAndCapture(result, { signal });
8887+
* if (message) conversation.addResponse(message);
8888+
* if (status === "error") logger.error("turn failed", { error });
88538889
* ```
88548890
*/
88558891
async function pipeChatAndCapture(
88568892
source: UIMessageStreamable,
88578893
options?: { signal?: AbortSignal; spanName?: string; originalMessages?: UIMessage[] }
8858-
): Promise<UIMessage | undefined> {
8894+
): Promise<PipeAndCaptureResult> {
88598895
let captured: UIMessage | undefined;
8896+
let capturedFinishReason: FinishReason | undefined;
88608897
let resolveOnFinish: () => void;
88618898
const onFinishPromise = new Promise<void>((r) => {
88628899
resolveOnFinish = r;
@@ -8875,19 +8912,51 @@ async function pipeChatAndCapture(
88758912
// the frontend replaces the partial message — wiping the
88768913
// pre-injection text from the UI and the captured response.
88778914
generateMessageId: resolvedOptions.generateMessageId ?? generateMessageId,
8878-
onFinish: ({ responseMessage }: { responseMessage: UIMessage }) => {
8915+
onFinish: ({
8916+
responseMessage,
8917+
finishReason,
8918+
}: {
8919+
responseMessage: UIMessage;
8920+
finishReason?: FinishReason;
8921+
}) => {
88798922
captured = responseMessage;
8923+
capturedFinishReason = finishReason;
88808924
resolveOnFinish!();
88818925
},
88828926
});
88838927

8884-
await pipeChat(uiStream, {
8885-
signal: options?.signal,
8886-
spanName: options?.spanName ?? "stream response",
8887-
});
8888-
await onFinishPromise;
8928+
let status: PipeAndCaptureResult["status"] = "complete";
8929+
let error: unknown;
8930+
try {
8931+
await pipeChat(uiStream, {
8932+
signal: options?.signal,
8933+
spanName: options?.spanName ?? "stream response",
8934+
});
8935+
// The pipe can drain cleanly on a stop — the source stream just ends
8936+
// early — so classify by the signal rather than relying on a throw.
8937+
if (options?.signal?.aborted) {
8938+
status = "aborted";
8939+
}
8940+
} catch (err) {
8941+
if ((err instanceof Error && err.name === "AbortError") || options?.signal?.aborted) {
8942+
status = "aborted";
8943+
} else {
8944+
status = "error";
8945+
error = err;
8946+
}
8947+
}
88898948

8890-
return captured;
8949+
// `onFinish` fires even on abort, carrying the partial — but a hard stop can
8950+
// prevent it from firing at all, so race it against a timeout to avoid
8951+
// hanging the caller. Mirrors chat.agent's capture path.
8952+
await Promise.race([onFinishPromise, new Promise<void>((r) => setTimeout(r, 2_000))]);
8953+
8954+
return {
8955+
message: captured,
8956+
status,
8957+
finishReason: capturedFinishReason,
8958+
...(error !== undefined ? { error } : {}),
8959+
};
88918960
}
88928961

88938962
/**
@@ -8903,8 +8972,8 @@ async function pipeChatAndCapture(
89038972
* for (let turn = 0; turn < 100; turn++) {
89048973
* const messages = await conversation.addIncoming(payload.messages, payload.trigger, turn);
89058974
* const result = streamText({ model, messages });
8906-
* const response = await chat.pipeAndCapture(result);
8907-
* if (response) await conversation.addResponse(response);
8975+
* const { message } = await chat.pipeAndCapture(result);
8976+
* if (message) await conversation.addResponse(message);
89088977
* }
89098978
* ```
89108979
*/
@@ -9570,7 +9639,7 @@ function createChatSession(
95709639
}
95719640
let response: UIMessage | undefined;
95729641
try {
9573-
response = await pipeChatAndCapture(source, {
9642+
const captured = await pipeChatAndCapture(source, {
95749643
signal: combinedSignal,
95759644
// On a non-final handover turn, thread the spliced partial so a
95769645
// resumed tool round's tool-output chunks merge into the
@@ -9579,18 +9648,18 @@ function createChatSession(
95799648
// fresh response into the prior assistant message).
95809649
...(handoverThisTurn ? { originalMessages: accumulator.uiMessages } : {}),
95819650
});
9582-
} catch (error) {
9583-
if (error instanceof Error && error.name === "AbortError") {
9584-
if (runSignal.aborted) {
9585-
// Full cancel — don't accumulate
9586-
sessionMsgSub.off();
9587-
await chatWriteTurnComplete();
9588-
return undefined;
9589-
}
9590-
// Stop — fall through to accumulate partial response
9591-
} else {
9592-
throw error;
9651+
if (runSignal.aborted) {
9652+
// Full cancel — don't accumulate
9653+
sessionMsgSub.off();
9654+
await chatWriteTurnComplete();
9655+
return undefined;
9656+
}
9657+
// Surface a genuine stream failure to the caller. A user stop
9658+
// (status "aborted") falls through so the partial is accumulated.
9659+
if (captured.status === "error") {
9660+
throw captured.error;
95939661
}
9662+
response = captured.message;
95949663
} finally {
95959664
// Detach at stream end (like the agent loop): the steering queue
95969665
// can't inject anymore, so later arrivals must buffer for the next turn.

0 commit comments

Comments
 (0)