@@ -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 */
88558891async 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