Skip to content

Commit be45cf9

Browse files
authored
fix(sdk): preserve partial assistant message on chat stream failure (#4348)
## Summary When a `chat.agent` (or `chat.createSession`) turn's model stream fails mid-response (e.g. a transport timeout like `UND_ERR_BODY_TIMEOUT`), the assistant output that already streamed was dropped: `onTurnComplete` fired with `responseMessage: undefined`, and the manual loop's `turn.complete()` rethrew without keeping the partial. Apps that register `hydrateMessages` are hit hardest, since boot-time tail-replay recovery is off by design. This preserves the streamed-so-far assistant output while still reporting the turn as errored, so persistence and recovery keep it. ## Scope of behavior change Only the **error path** changes. Successful turns are unaffected: the same chunks stream to the client in the same order, and backpressure/cancel behave as before. Everything here is a correctness improvement on a turn that hit a source-stream failure. ## What it does Follow-up to #4304 (`chat.pipeAndCapture`), extending the same partial-recovery to the two loops that lacked it: - **`chat.agent`**: taps the response stream (via a `TransformStream`, so pass-through backpressure and cancel are preserved) to buffer chunks, and on a source-stream failure reconstructs the partial (preferring the `onFinish` message). It's surfaced on the error-path `onTurnComplete` (`responseMessage`, `rawResponseMessage`, `uiMessages`, `newUIMessages`, `newMessages`) and committed to the accumulator so the next turn and the reboot snapshot keep it. - **`chat.createSession` / `turn.complete()`**: the reconstructed partial is accumulated (so `turn.uiMessages` reflects it and the caller can persist after catching) before `turn.complete()` rethrows. `onBeforeTurnComplete` stays skipped on the error path (it hands out a writer for a stream that has already broken). ## Correctness properties (each covered by a regression test) Each test below was confirmed to fail without its fix: - The recovered partial reaches `onTurnComplete` and the next turn's accumulated messages. - An already-committed (possibly enriched) response is not overwritten if a post-response hook then throws. - Incomplete tool parts are cleaned from the recovered partial (text kept), so the UI and model views agree and the next turn isn't poisoned. - A prior turn's model-only compaction survives an errored turn (append only the new tail, don't reconvert the full history). - A reconstructed fragment that reuses an existing message id does not clobber the complete message. - Queued `chat.response` data parts are folded into the recovered partial, matching the success path. - `newMessages` (model delta) stays symmetric with `newUIMessages`. ## Tests New `chat-agent-source-stream-error.test.ts` covers the cases above. The full `@trigger.dev/sdk` unit suite passes and the package build is green across all supported runtimes (Node 20 to 26, Bun, Deno, Cloudflare Workers).
1 parent 109e245 commit be45cf9

3 files changed

Lines changed: 551 additions & 26 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
---
4+
5+
Preserve the partial assistant message when a chat turn's model stream fails mid-response. `chat.agent` now passes the recovered partial to `onTurnComplete`, and `chat.createSession`'s `turn.complete()` keeps it before rethrowing, instead of dropping the streamed-so-far output.

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

Lines changed: 111 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6389,6 +6389,9 @@ function chatAgent<
63896389
// Declared here so the finally can detach it — a handler leaked past
63906390
// its turn duplicates every mid-stream message into the shared buffer.
63916391
let turnMsgSub: { off: () => void } | undefined;
6392+
let capturedPartialResponse: TUIMessage | undefined;
6393+
let responseCommitted = false;
6394+
const turnBufferedChunks: UIMessageChunk[] = [];
63926395
try {
63936396
// Extract turn-level context before entering the span. Slim
63946397
// wire: at most one delta message per record. `headStartMessages`
@@ -7175,11 +7178,12 @@ function chatAgent<
71757178
finishReason?: FinishReason;
71767179
}) => {
71777180
capturedResponseMessage = responseMessage as TUIMessage;
7181+
capturedPartialResponse = responseMessage as TUIMessage;
71787182
capturedFinishReason = finishReason;
71797183
resolveOnFinish!();
71807184
},
71817185
});
7182-
await pipeChat(uiStream, {
7186+
await pipeChat(tapUIMessageChunks(uiStream, turnBufferedChunks), {
71837187
signal: combinedSignal,
71847188
spanName: "stream response",
71857189
});
@@ -7390,6 +7394,12 @@ function chatAgent<
73907394
}
73917395
}
73927396

7397+
if (capturedResponseMessage) {
7398+
responseCommitted = true;
7399+
capturedPartialResponse = capturedResponseMessage;
7400+
turnBufferedChunks.length = 0;
7401+
}
7402+
73937403
if (runSignal.aborted) return "exit";
73947404

73957405
// Await deferred background work (e.g. DB writes from onTurnStart)
@@ -7611,6 +7621,7 @@ function chatAgent<
76117621
parts: [...(msg.parts ?? []), ...lateParts],
76127622
} as TUIMessage;
76137623
capturedResponseMessage = accumulatedUIMessages[idx] as TUIMessage;
7624+
capturedPartialResponse = capturedResponseMessage;
76147625
turnCompleteEvent.responseMessage = capturedResponseMessage;
76157626
turnCompleteEvent.uiMessages = accumulatedUIMessages;
76167627
}
@@ -7904,10 +7915,77 @@ function chatAgent<
79047915
? [...accumulatedUIMessages, erroredWireMessage]
79057916
: accumulatedUIMessages;
79067917

7907-
// Fire onTurnComplete on the error path too — the docs promise it
7908-
// runs "after every turn, successful or errored" so customers can
7909-
// mark the turn failed. `responseMessage` is undefined/partial and
7910-
// `error` carries the thrown value.
7918+
let partialResponse: TUIMessage | undefined =
7919+
capturedPartialResponse ??
7920+
((await assemblePartialFromChunks(turnBufferedChunks)) as TUIMessage | undefined);
7921+
if (partialResponse) {
7922+
partialResponse = cleanupAbortedParts(partialResponse);
7923+
}
7924+
7925+
let partialIdx = partialResponse?.id
7926+
? erroredUIMessages.findIndex((m) => m.id === partialResponse!.id)
7927+
: -1;
7928+
if (partialResponse && capturedPartialResponse === undefined && partialIdx !== -1) {
7929+
partialResponse = undefined;
7930+
partialIdx = -1;
7931+
}
7932+
if (partialResponse && !partialResponse.id) {
7933+
partialResponse = { ...partialResponse, id: generateMessageId() } as TUIMessage;
7934+
}
7935+
if (partialResponse && !responseCommitted) {
7936+
const queuedParts = locals.get(chatResponsePartsKey);
7937+
if (queuedParts && queuedParts.length > 0) {
7938+
partialResponse = {
7939+
...partialResponse,
7940+
parts: [...partialResponse.parts, ...(queuedParts as UIMessage["parts"])],
7941+
} as TUIMessage;
7942+
locals.set(chatResponsePartsKey, []);
7943+
}
7944+
}
7945+
const includePartial = partialResponse != null && !responseCommitted;
7946+
let erroredUIMessagesWithPartial: TUIMessage[] = !includePartial
7947+
? erroredUIMessages
7948+
: partialIdx === -1
7949+
? [...erroredUIMessages, partialResponse!]
7950+
: (erroredUIMessages.map((m, i) =>
7951+
i === partialIdx ? partialResponse! : m
7952+
) as TUIMessage[]);
7953+
7954+
let erroredNewUIMessages: TUIMessage[] = erroredWireMessage ? [erroredWireMessage] : [];
7955+
if (includePartial) {
7956+
erroredNewUIMessages.push(partialResponse!);
7957+
}
7958+
7959+
let erroredNewModelMessages: ModelMessage[] = [];
7960+
7961+
if (!responseCommitted) {
7962+
try {
7963+
if (erroredNewUIMessages.length > 0) {
7964+
erroredNewModelMessages = await toModelMessages(
7965+
erroredNewUIMessages.map((m) => stripProviderMetadata(m))
7966+
);
7967+
}
7968+
if (erroredUIMessagesWithPartial !== accumulatedUIMessages) {
7969+
if (partialIdx === -1) {
7970+
const appended = erroredUIMessagesWithPartial.slice(
7971+
accumulatedUIMessages.length
7972+
);
7973+
accumulatedMessages.push(
7974+
...(await toModelMessages(appended.map((m) => stripProviderMetadata(m))))
7975+
);
7976+
} else {
7977+
accumulatedMessages = await toModelMessages(erroredUIMessagesWithPartial);
7978+
}
7979+
accumulatedUIMessages = erroredUIMessagesWithPartial;
7980+
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
7981+
}
7982+
} catch {
7983+
erroredNewModelMessages = [];
7984+
erroredUIMessagesWithPartial = erroredUIMessages;
7985+
erroredNewUIMessages = erroredWireMessage ? [erroredWireMessage] : [];
7986+
}
7987+
}
7988+
79117989
if (onTurnComplete) {
79127990
try {
79137991
await tracer.startActiveSpan(
@@ -7917,11 +7995,11 @@ function chatAgent<
79177995
ctx,
79187996
chatId: currentWirePayload.chatId,
79197997
messages: accumulatedMessages,
7920-
uiMessages: erroredUIMessages,
7921-
newMessages: [],
7922-
newUIMessages: erroredWireMessage ? [erroredWireMessage] : [],
7923-
responseMessage: undefined,
7924-
rawResponseMessage: undefined,
7998+
uiMessages: erroredUIMessagesWithPartial,
7999+
newMessages: erroredNewModelMessages,
8000+
newUIMessages: erroredNewUIMessages,
8001+
responseMessage: partialResponse,
8002+
rawResponseMessage: partialResponse,
79258003
turn,
79268004
runId: ctx.run.id,
79278005
chatAccessToken: "",
@@ -7967,7 +8045,7 @@ function chatAgent<
79678045
await writeChatSnapshot<TUIMessage>(sessionIdForSnapshot, {
79688046
version: 1,
79698047
savedAt: Date.now(),
7970-
messages: erroredUIMessages,
8048+
messages: erroredUIMessagesWithPartial,
79718049
lastOutEventId: errorTurnCompleteResult?.lastEventId,
79728050
lastInEventId:
79738051
errorSnapshotInCursor !== undefined ? String(errorSnapshotInCursor) : undefined,
@@ -8887,28 +8965,26 @@ export type PipeAndCaptureResult = {
88878965
* can return, and propagates a source error to the consumer after buffering
88888966
* whatever streamed first. See {@link pipeChatAndCapture} for why.
88898967
*/
8890-
async function* tapUIMessageChunks(
8968+
function tapUIMessageChunks(
88918969
source: AsyncIterable<unknown> | ReadableStream<unknown>,
88928970
buffer: UIMessageChunk[]
8893-
): AsyncGenerator<unknown> {
8971+
): ReadableStream<unknown> | AsyncGenerator<unknown> {
88948972
if (isReadableStream(source)) {
8895-
const reader = source.getReader();
8896-
try {
8897-
while (true) {
8898-
const { done, value } = await reader.read();
8899-
if (done) break;
8900-
buffer.push(value as UIMessageChunk);
8901-
yield value;
8902-
}
8903-
} finally {
8904-
reader.releaseLock();
8905-
}
8906-
} else {
8973+
return source.pipeThrough(
8974+
new TransformStream<unknown, unknown>({
8975+
transform(chunk, controller) {
8976+
buffer.push(chunk as UIMessageChunk);
8977+
controller.enqueue(chunk);
8978+
},
8979+
})
8980+
);
8981+
}
8982+
return (async function* () {
89078983
for await (const chunk of source) {
89088984
buffer.push(chunk as UIMessageChunk);
89098985
yield chunk;
89108986
}
8911-
}
8987+
})();
89128988
}
89138989

89148990
/**
@@ -9744,6 +9820,15 @@ function createChatSession(
97449820
// Surface a genuine stream failure to the caller. A user stop
97459821
// (status "aborted") falls through so the partial is accumulated.
97469822
if (captured.status === "error") {
9823+
if (captured.message) {
9824+
const partial = cleanupAbortedParts(captured.message);
9825+
const queuedParts = locals.get(chatResponsePartsKey);
9826+
if (queuedParts && queuedParts.length > 0) {
9827+
(partial as any).parts = [...(partial.parts ?? []), ...queuedParts];
9828+
locals.set(chatResponsePartsKey, []);
9829+
}
9830+
await accumulator.addResponse(partial);
9831+
}
97479832
throw captured.error;
97489833
}
97499834
response = captured.message;

0 commit comments

Comments
 (0)