Files
triggerdotdev--trigger.dev/packages/trigger-sdk/test/chat-agent-source-stream-error.test.ts
Matt Aitken be45cf9e61
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 5s
🚀 Publish Trigger.dev Docker / units (push) Failing after 5s
🚀 Publish Trigger.dev Docker / publish-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker-v4 (push) Has been skipped
🚀 Publish Trigger.dev Docker / scan-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / scan-supervisor (push) Has been skipped
🦋 Changesets PR / Create Release PR (push) Has been cancelled
🚀 Publish Trigger.dev Docker / 📣 Dispatch main image (push) Has been cancelled
⚒️ Publish Worker (v4) / build (supervisor) (push) Has been cancelled
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).
2026-07-24 14:20:20 +01:00

436 lines
15 KiB
TypeScript

// Import the test harness FIRST — this installs the resource catalog so
// `chat.agent()` calls below register their task functions correctly.
import { mockChatAgent } from "../src/v3/test/index.js";
import { describe, expect, it } from "vitest";
import type { ModelMessage, UIMessage } from "ai";
import { simulateReadableStream, streamText } from "ai";
import { MockLanguageModelV3 } from "ai/test";
import type { LanguageModelV3StreamPart } from "@ai-sdk/provider";
import { chat } from "../src/v3/ai.js";
import type { TurnCompleteEvent } from "../src/v3/ai.js";
// ── Helpers ────────────────────────────────────────────────────────────
function userMessage(text: string, id: string): UIMessage {
return { id, role: "user", parts: [{ type: "text", text }] };
}
function extractText(message: UIMessage | undefined): string {
if (!message) return "";
return (message.parts as Array<{ type: string; text?: string }>)
.filter((p) => p.type === "text")
.map((p) => p.text ?? "")
.join("");
}
async function waitFor(check: () => boolean, timeoutMs = 5_000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (check()) return;
await new Promise((r) => setTimeout(r, 20));
}
throw new Error("waitFor timed out");
}
/**
* A `run()` return value that looks like a `StreamTextResult` (has
* `toUIMessageStream()`) but whose UI stream emits a partial assistant
* message and then errors — reproducing a source-stream transport failure
* (e.g. `UND_ERR_BODY_TIMEOUT`) mid-turn. `onFinish` is never invoked, which
* is exactly what happens on a hard transport error. Chunks are delivered
* one-per-pull before the error so they aren't discarded (calling
* `controller.error()` in the same tick as `enqueue()` resets the queue).
*/
function erroringSource(errorMessage: string) {
const partialChunks = [
{ type: "start", messageId: "a-err" },
{ type: "text-start", id: "t1" },
{ type: "text-delta", id: "t1", delta: "partial answer" },
];
return sourceFromChunks(partialChunks, errorMessage);
}
function sourceFromChunks(chunks: unknown[], errorMessage: string) {
return {
toUIMessageStream() {
let i = 0;
return new ReadableStream({
pull(controller) {
if (i < chunks.length) {
controller.enqueue(chunks[i++]);
} else {
controller.error(new Error(errorMessage));
}
},
});
},
};
}
// ── Tests ──────────────────────────────────────────────────────────────
describe("chat.agent managed loop — source-stream failure", () => {
it("preserves the partial assistant message on onTurnComplete when the source stream fails", async () => {
const turnCompletes: TurnCompleteEvent<unknown, UIMessage>[] = [];
const agent = chat.agent({
id: "chatAgent.source-stream-error",
run: async () => erroringSource("UND_ERR_BODY_TIMEOUT") as never,
onTurnComplete: async (event) => {
turnCompletes.push(event);
},
});
const harness = mockChatAgent(agent, { chatId: "cae-source-error" });
try {
await harness.sendMessage(userMessage("hi", "u-1"));
await waitFor(() => turnCompletes.length >= 1);
const evt = turnCompletes[0]!;
expect(evt.finishReason).toBe("error");
expect(evt.error).toBeInstanceOf(Error);
expect((evt.error as Error).message).toBe("UND_ERR_BODY_TIMEOUT");
expect(evt.responseMessage).toBeDefined();
expect(extractText(evt.responseMessage)).toBe("partial answer");
const newAssistantText = (evt.newMessages as ModelMessage[])
.filter((m) => m.role === "assistant")
.map((m) =>
typeof m.content === "string"
? m.content
: (m.content as Array<{ type: string; text?: string }>)
.filter((p) => p.type === "text")
.map((p) => p.text ?? "")
.join("")
)
.join("");
expect(newAssistantText).toBe("partial answer");
expect((evt.newMessages as ModelMessage[]).some((m) => m.role === "user")).toBe(true);
expect((evt.newUIMessages as UIMessage[]).some((m) => m.role === "user")).toBe(true);
} finally {
await harness.close();
}
});
it("carries the recovered partial into the next turn's accumulated messages", async () => {
let turn = 0;
let turn2Messages: ModelMessage[] | undefined;
const okStream = () =>
simulateReadableStream({
chunks: [
{ type: "text-start", id: "t2" },
{ type: "text-delta", id: "t2", delta: "second answer" },
{ type: "text-end", id: "t2" },
{
type: "finish",
finishReason: { unified: "stop", raw: "stop" },
usage: {
inputTokens: { total: 5, noCache: 5, cacheRead: undefined, cacheWrite: undefined },
outputTokens: { total: 5, text: 5, reasoning: undefined },
},
},
] as LanguageModelV3StreamPart[],
});
const agent = chat.agent({
id: "chatAgent.source-stream-error-continuation",
run: async ({ messages }) => {
turn++;
if (turn === 1) {
return erroringSource("UND_ERR_BODY_TIMEOUT") as never;
}
turn2Messages = messages;
return streamText({
model: new MockLanguageModelV3({ doStream: async () => ({ stream: okStream() }) }),
messages,
});
},
});
const harness = mockChatAgent(agent, { chatId: "cae-source-error-cont" });
try {
await harness.sendMessage(userMessage("hi", "u-1"));
await harness.sendMessage(userMessage("still there?", "u-2"));
await waitFor(() => turn2Messages !== undefined);
const assistantText = turn2Messages!
.filter((m) => m.role === "assistant")
.map((m) =>
typeof m.content === "string"
? m.content
: (m.content as Array<{ type: string; text?: string }>)
.filter((p) => p.type === "text")
.map((p) => p.text ?? "")
.join("")
)
.join("");
expect(assistantText).toContain("partial answer");
} finally {
await harness.close();
}
});
it("does not overwrite an already-committed enriched response when a post-response hook throws", async () => {
const events: TurnCompleteEvent<unknown, UIMessage>[] = [];
const okModel = (text: string) =>
new MockLanguageModelV3({
doStream: async () => ({
stream: simulateReadableStream({
chunks: [
{ type: "text-start", id: "t1" },
{ type: "text-delta", id: "t1", delta: text },
{ type: "text-end", id: "t1" },
{
type: "finish",
finishReason: { unified: "stop", raw: "stop" },
usage: {
inputTokens: {
total: 5,
noCache: 5,
cacheRead: undefined,
cacheWrite: undefined,
},
outputTokens: { total: 5, text: 5, reasoning: undefined },
},
},
] as LanguageModelV3StreamPart[],
}),
}),
});
const agent = chat.agent({
id: "chatAgent.post-commit-hook-throw",
run: async ({ messages }) => {
chat.response.write({ type: "data-marker", data: { kept: true } } as never);
return streamText({ model: okModel("full response"), messages });
},
onTurnComplete: async (event) => {
events.push(event);
if (event.error == null) {
throw new Error("hook boom after commit");
}
},
});
const harness = mockChatAgent(agent, { chatId: "cae-post-commit-throw" });
try {
await harness.sendMessage(userMessage("hi", "u-1"));
await waitFor(() => events.some((e) => e.error != null));
const errorEvent = events.find((e) => e.error != null)!;
const assistant = (errorEvent.uiMessages as UIMessage[]).find((m) => m.role === "assistant");
expect(assistant).toBeDefined();
expect(
(assistant!.parts as Array<{ type: string }>).some((p) => p.type === "data-marker")
).toBe(true);
expect(extractText(assistant)).toBe("full response");
} finally {
await harness.close();
}
});
it("does not clobber an existing message when a reconstructed fragment reuses its id", async () => {
let turn = 0;
let firstAssistantId: string | undefined;
const events: TurnCompleteEvent<unknown, UIMessage>[] = [];
const okModel = () =>
new MockLanguageModelV3({
doStream: async () => ({
stream: simulateReadableStream({
chunks: [
{ type: "text-start", id: "t1" },
{ type: "text-delta", id: "t1", delta: "first answer" },
{ type: "text-end", id: "t1" },
{
type: "finish",
finishReason: { unified: "stop", raw: "stop" },
usage: {
inputTokens: {
total: 5,
noCache: 5,
cacheRead: undefined,
cacheWrite: undefined,
},
outputTokens: { total: 5, text: 5, reasoning: undefined },
},
},
] as LanguageModelV3StreamPart[],
}),
}),
});
const collidingErroringSource = (id: string) => ({
toUIMessageStream() {
const chunks = [
{ type: "start", messageId: id },
{ type: "text-start", id: "t2" },
{ type: "text-delta", id: "t2", delta: "clobber" },
];
let i = 0;
return new ReadableStream({
pull(controller) {
if (i < chunks.length) controller.enqueue(chunks[i++]);
else controller.error(new Error("UND_ERR_BODY_TIMEOUT"));
},
});
},
});
const agent = chat.agent({
id: "chatAgent.fragment-id-collision",
run: async ({ messages }) => {
turn++;
if (turn === 1) {
return streamText({ model: okModel(), messages });
}
return collidingErroringSource(firstAssistantId!) as never;
},
onTurnComplete: async (event) => {
events.push(event);
if (event.error == null && event.responseMessage) {
firstAssistantId = event.responseMessage.id;
}
},
});
const harness = mockChatAgent(agent, { chatId: "cae-fragment-collision" });
try {
await harness.sendMessage(userMessage("hi", "u-1"));
await waitFor(() => firstAssistantId !== undefined);
await harness.sendMessage(userMessage("again", "u-2"));
await waitFor(() => events.some((e) => e.error != null));
const errorEvent = events.find((e) => e.error != null)!;
const preserved = (errorEvent.uiMessages as UIMessage[]).find(
(m) => m.id === firstAssistantId
);
expect(preserved).toBeDefined();
expect(extractText(preserved)).toBe("first answer");
expect(
(errorEvent.uiMessages as UIMessage[]).some((m) => extractText(m).includes("clobber"))
).toBe(false);
} finally {
await harness.close();
}
});
it("cleans dangling tool parts from the recovered partial while keeping its text", async () => {
const turnCompletes: TurnCompleteEvent<unknown, UIMessage>[] = [];
const agent = chat.agent({
id: "chatAgent.error-partial-cleanup",
run: async () =>
sourceFromChunks(
[
{ type: "start", messageId: "a-tool" },
{ type: "text-start", id: "t1" },
{ type: "text-delta", id: "t1", delta: "thinking" },
{ type: "text-end", id: "t1" },
{ type: "tool-input-start", toolCallId: "tc1", toolName: "search" },
{
type: "tool-input-available",
toolCallId: "tc1",
toolName: "search",
input: { q: "x" },
},
],
"UND_ERR_BODY_TIMEOUT"
) as never,
onTurnComplete: async (event) => {
turnCompletes.push(event);
},
});
const harness = mockChatAgent(agent, { chatId: "cae-partial-cleanup" });
try {
await harness.sendMessage(userMessage("hi", "u-1"));
await waitFor(() => turnCompletes.length >= 1);
const evt = turnCompletes[0]!;
expect(evt.responseMessage).toBeDefined();
const parts = evt.responseMessage!.parts as Array<{ type: string }>;
expect(extractText(evt.responseMessage)).toBe("thinking");
expect(parts.some((p) => p.type.startsWith("tool-"))).toBe(false);
} finally {
await harness.close();
}
});
it("folds queued response data parts into the recovered partial", async () => {
const turnCompletes: TurnCompleteEvent<unknown, UIMessage>[] = [];
const agent = chat.agent({
id: "chatAgent.error-queued-parts",
run: async () => {
chat.response.write({ type: "data-marker", data: { kept: true } } as never);
return erroringSource("UND_ERR_BODY_TIMEOUT") as never;
},
onTurnComplete: async (event) => {
turnCompletes.push(event);
},
});
const harness = mockChatAgent(agent, { chatId: "cae-error-queued-parts" });
try {
await harness.sendMessage(userMessage("hi", "u-1"));
await waitFor(() => turnCompletes.length >= 1);
const evt = turnCompletes[0]!;
expect(evt.responseMessage).toBeDefined();
const parts = evt.responseMessage!.parts as Array<{ type: string }>;
expect(extractText(evt.responseMessage)).toBe("partial answer");
expect(parts.some((p) => p.type === "data-marker")).toBe(true);
} finally {
await harness.close();
}
});
});
describe("chat.createSession turn.complete() — source-stream failure", () => {
it("accumulates the partial before rethrowing so the caller can persist it", async () => {
let caughtError: unknown;
let uiMessagesAfterError: UIMessage[] = [];
const agent = chat.customAgent({
id: "createSession.source-stream-error",
run: async (payload) => {
const session = chat.createSession(payload, {
signal: new AbortController().signal,
idleTimeoutInSeconds: 2,
});
for await (const turn of session) {
try {
await turn.complete(erroringSource("UND_ERR_BODY_TIMEOUT") as never);
} catch (err) {
caughtError = err;
uiMessagesAfterError = [...turn.uiMessages];
await turn.done();
}
}
},
});
const harness = mockChatAgent(agent, { chatId: "cs-source-error" });
try {
await harness.sendMessage(userMessage("hi", "u-1"));
await waitFor(() => caughtError !== undefined);
expect(caughtError).toBeInstanceOf(Error);
expect((caughtError as Error).message).toBe("UND_ERR_BODY_TIMEOUT");
const partial = uiMessagesAfterError.find((m) => m.role === "assistant");
expect(partial).toBeDefined();
expect(extractText(partial)).toBe("partial answer");
} finally {
await harness.close();
}
});
});