Skip to content

Commit 8152444

Browse files
committed
fix(sdk): preserve partial assistant message on chat stream failure
When a chat turn's model stream fails mid-response (e.g. a transport timeout), the streamed-so-far output is no longer dropped. chat.agent passes the recovered partial to onTurnComplete, and chat.createSession accumulates it before turn.complete() rethrows, so it survives for persistence even when hydrateMessages disables boot-time replay recovery. The turn is still reported as errored.
1 parent aafc333 commit 8152444

3 files changed

Lines changed: 202 additions & 7 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 (e.g. a transport timeout). Previously the streamed-so-far output was dropped: `chat.agent`'s `onTurnComplete` fired with `responseMessage: undefined`, and `chat.createSession`'s `turn.complete()` rethrew without keeping the partial. Now the recovered partial is passed to `onTurnComplete` (on `responseMessage`, `uiMessages`, and `newUIMessages`) and accumulated before `turn.complete()` rethrows, so it survives for persistence even when `hydrateMessages` disables boot-time replay recovery. The turn is still reported as errored.

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

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6389,6 +6389,14 @@ 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+
// Declared at turn scope (not inside the span callback) so the error
6393+
// handler below can recover the partial assistant output when a
6394+
// source-stream failure abandons the turn before onTurnComplete's
6395+
// success-path capture runs. `capturedPartialResponse` holds the
6396+
// onFinish message if it fired; otherwise the buffered chunks are
6397+
// reconstructed as the fallback (mirrors chat.pipeAndCapture).
6398+
let capturedPartialResponse: TUIMessage | undefined;
6399+
const turnBufferedChunks: UIMessageChunk[] = [];
63926400
try {
63936401
// Extract turn-level context before entering the span. Slim
63946402
// wire: at most one delta message per record. `headStartMessages`
@@ -7175,11 +7183,17 @@ function chatAgent<
71757183
finishReason?: FinishReason;
71767184
}) => {
71777185
capturedResponseMessage = responseMessage as TUIMessage;
7186+
capturedPartialResponse = responseMessage as TUIMessage;
71787187
capturedFinishReason = finishReason;
71797188
resolveOnFinish!();
71807189
},
71817190
});
7182-
await pipeChat(uiStream, {
7191+
// Buffer chunks as they flow to the pipe so a source-stream
7192+
// transport failure — which abandons the UI stream before
7193+
// onFinish fires — can still reconstruct the partial in the
7194+
// error handler instead of dropping it. The happy path pays
7195+
// nothing extra; reassembly only runs in the error fallback.
7196+
await pipeChat(tapUIMessageChunks(uiStream, turnBufferedChunks), {
71837197
signal: combinedSignal,
71847198
spanName: "stream response",
71857199
});
@@ -7904,10 +7918,36 @@ function chatAgent<
79047918
? [...accumulatedUIMessages, erroredWireMessage]
79057919
: accumulatedUIMessages;
79067920

7921+
// Recover the partial assistant output the model streamed before the
7922+
// failure. A source-stream transport error (e.g. UND_ERR_BODY_TIMEOUT)
7923+
// abandons the UI stream before onFinish fires, so fall back to
7924+
// reconstructing the partial from the buffered chunks. Surfacing it on
7925+
// the error-path event lets persistence keep the partial instead of
7926+
// losing it — critical when hydrateMessages disables boot-time tail
7927+
// replay, so the recovery path can't reclaim it later. Empty for
7928+
// non-stream failures (nothing buffered), preserving prior behavior.
7929+
const partialResponse: TUIMessage | undefined =
7930+
capturedPartialResponse ??
7931+
((await assemblePartialFromChunks(turnBufferedChunks)) as TUIMessage | undefined);
7932+
7933+
// Include the partial in the UI-message views too, so customers who
7934+
// persist from uiMessages / newUIMessages (not responseMessage) keep
7935+
// it as well. Dedup by id against the accumulator to be safe.
7936+
const erroredNewUIMessages: TUIMessage[] = erroredWireMessage
7937+
? [erroredWireMessage]
7938+
: [];
7939+
if (partialResponse) {
7940+
erroredNewUIMessages.push(partialResponse);
7941+
}
7942+
const erroredUIMessagesWithPartial =
7943+
partialResponse && !erroredUIMessages.some((m) => m.id === partialResponse.id)
7944+
? [...erroredUIMessages, partialResponse]
7945+
: erroredUIMessages;
7946+
79077947
// Fire onTurnComplete on the error path too — the docs promise it
79087948
// 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.
7949+
// mark the turn failed. `responseMessage` carries any partial
7950+
// recovered above and `error` carries the thrown value.
79117951
if (onTurnComplete) {
79127952
try {
79137953
await tracer.startActiveSpan(
@@ -7917,11 +7957,11 @@ function chatAgent<
79177957
ctx,
79187958
chatId: currentWirePayload.chatId,
79197959
messages: accumulatedMessages,
7920-
uiMessages: erroredUIMessages,
7960+
uiMessages: erroredUIMessagesWithPartial,
79217961
newMessages: [],
7922-
newUIMessages: erroredWireMessage ? [erroredWireMessage] : [],
7923-
responseMessage: undefined,
7924-
rawResponseMessage: undefined,
7962+
newUIMessages: erroredNewUIMessages,
7963+
responseMessage: partialResponse,
7964+
rawResponseMessage: partialResponse,
79257965
turn,
79267966
runId: ctx.run.id,
79277967
chatAccessToken: "",
@@ -9744,6 +9784,14 @@ function createChatSession(
97449784
// Surface a genuine stream failure to the caller. A user stop
97459785
// (status "aborted") falls through so the partial is accumulated.
97469786
if (captured.status === "error") {
9787+
// Preserve the partial the model streamed before the failure:
9788+
// accumulate it (mirroring the stop path) so `turn.uiMessages`
9789+
// reflects it and the caller can persist it after catching,
9790+
// then rethrow. Without this the partial pipeAndCapture
9791+
// reconstructed is silently dropped on rethrow.
9792+
if (captured.message) {
9793+
await accumulator.addResponse(captured.message);
9794+
}
97479795
throw captured.error;
97489796
}
97499797
response = captured.message;
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
// Import the test harness FIRST — this installs the resource catalog so
2+
// `chat.agent()` calls below register their task functions correctly.
3+
import { mockChatAgent } from "../src/v3/test/index.js";
4+
5+
import { describe, expect, it } from "vitest";
6+
import type { UIMessage } from "ai";
7+
import { chat } from "../src/v3/ai.js";
8+
import type { TurnCompleteEvent } from "../src/v3/ai.js";
9+
10+
// ── Helpers ────────────────────────────────────────────────────────────
11+
12+
function userMessage(text: string, id: string): UIMessage {
13+
return { id, role: "user", parts: [{ type: "text", text }] };
14+
}
15+
16+
function extractText(message: UIMessage | undefined): string {
17+
if (!message) return "";
18+
return (message.parts as Array<{ type: string; text?: string }>)
19+
.filter((p) => p.type === "text")
20+
.map((p) => p.text ?? "")
21+
.join("");
22+
}
23+
24+
async function waitFor(check: () => boolean, timeoutMs = 5_000) {
25+
const start = Date.now();
26+
while (Date.now() - start < timeoutMs) {
27+
if (check()) return;
28+
await new Promise((r) => setTimeout(r, 20));
29+
}
30+
throw new Error("waitFor timed out");
31+
}
32+
33+
/**
34+
* A `run()` return value that looks like a `StreamTextResult` (has
35+
* `toUIMessageStream()`) but whose UI stream emits a partial assistant
36+
* message and then errors — reproducing a source-stream transport failure
37+
* (e.g. `UND_ERR_BODY_TIMEOUT`) mid-turn. `onFinish` is never invoked, which
38+
* is exactly what happens on a hard transport error. Chunks are delivered
39+
* one-per-pull before the error so they aren't discarded (calling
40+
* `controller.error()` in the same tick as `enqueue()` resets the queue).
41+
*/
42+
function erroringSource(errorMessage: string) {
43+
const partialChunks = [
44+
{ type: "start", messageId: "a-err" },
45+
{ type: "text-start", id: "t1" },
46+
{ type: "text-delta", id: "t1", delta: "partial answer" },
47+
];
48+
return {
49+
toUIMessageStream() {
50+
let i = 0;
51+
return new ReadableStream({
52+
pull(controller) {
53+
if (i < partialChunks.length) {
54+
controller.enqueue(partialChunks[i++]);
55+
} else {
56+
controller.error(new Error(errorMessage));
57+
}
58+
},
59+
});
60+
},
61+
};
62+
}
63+
64+
// ── Tests ──────────────────────────────────────────────────────────────
65+
66+
describe("chat.agent managed loop — source-stream failure", () => {
67+
it("preserves the partial assistant message on onTurnComplete when the source stream fails", async () => {
68+
const turnCompletes: TurnCompleteEvent<unknown, UIMessage>[] = [];
69+
70+
const agent = chat.agent({
71+
id: "chatAgent.source-stream-error",
72+
run: async () => erroringSource("UND_ERR_BODY_TIMEOUT") as never,
73+
onTurnComplete: async (event) => {
74+
turnCompletes.push(event);
75+
},
76+
});
77+
78+
const harness = mockChatAgent(agent, { chatId: "cae-source-error" });
79+
try {
80+
await harness.sendMessage(userMessage("hi", "u-1"));
81+
await waitFor(() => turnCompletes.length >= 1);
82+
83+
const evt = turnCompletes[0]!;
84+
85+
// The turn is reported as errored, carrying the thrown transport error.
86+
expect(evt.finishReason).toBe("error");
87+
expect(evt.error).toBeInstanceOf(Error);
88+
expect((evt.error as Error).message).toBe("UND_ERR_BODY_TIMEOUT");
89+
90+
// The partial assistant output that streamed before the failure must be
91+
// preserved so persistence / recovery can keep it, instead of being
92+
// dropped (responseMessage: undefined).
93+
expect(evt.responseMessage).toBeDefined();
94+
expect(extractText(evt.responseMessage)).toBe("partial answer");
95+
} finally {
96+
await harness.close();
97+
}
98+
});
99+
});
100+
101+
describe("chat.createSession turn.complete() — source-stream failure", () => {
102+
it("accumulates the partial before rethrowing so the caller can persist it", async () => {
103+
let caughtError: unknown;
104+
let uiMessagesAfterError: UIMessage[] = [];
105+
106+
const agent = chat.customAgent({
107+
id: "createSession.source-stream-error",
108+
run: async (payload) => {
109+
const session = chat.createSession(payload, {
110+
signal: new AbortController().signal,
111+
idleTimeoutInSeconds: 2,
112+
});
113+
for await (const turn of session) {
114+
try {
115+
await turn.complete(erroringSource("UND_ERR_BODY_TIMEOUT") as never);
116+
} catch (err) {
117+
caughtError = err;
118+
// The partial must be accumulated so persistence from the session
119+
// state keeps it, rather than being lost on the rethrow.
120+
uiMessagesAfterError = [...turn.uiMessages];
121+
await turn.done();
122+
}
123+
}
124+
},
125+
});
126+
127+
const harness = mockChatAgent(agent, { chatId: "cs-source-error" });
128+
try {
129+
await harness.sendMessage(userMessage("hi", "u-1"));
130+
await waitFor(() => caughtError !== undefined);
131+
132+
expect(caughtError).toBeInstanceOf(Error);
133+
expect((caughtError as Error).message).toBe("UND_ERR_BODY_TIMEOUT");
134+
135+
const partial = uiMessagesAfterError.find((m) => m.role === "assistant");
136+
expect(partial).toBeDefined();
137+
expect(extractText(partial)).toBe("partial answer");
138+
} finally {
139+
await harness.close();
140+
}
141+
});
142+
});

0 commit comments

Comments
 (0)