Skip to content

Commit 64c3ba1

Browse files
0skiTrigger.dev RepoOps
authored andcommitted
fix(sdk): stop a chat.agent turn without an error chunk
Stopping a `chat.agent` turn whose `run()` returns a `streamText` result no longer sends an `error` chunk saying "An unexpected error occurred". This was a regression in 4.7.x. The turn ends quietly again and the run stays alive for the next message. Mono-RevId: 63cdf4e7ac05e7f2019b878c9bfeaf5e992f09cb
1 parent 3ae7e35 commit 64c3ba1

3 files changed

Lines changed: 118 additions & 2 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+
Stopping a `chat.agent` turn whose `run()` returns a `streamText` result no longer sends an `error` chunk with "An unexpected error occurred". The turn ends quietly again and the run stays alive for the next message.

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9308,8 +9308,13 @@ function chatAgent<
93089308
);
93099309
}
93109310
} catch (error) {
9311-
// Handle AbortError from streamText gracefully
9312-
if (error instanceof Error && error.name === "AbortError") {
9311+
// A stop aborts with a string reason, which the managed pipe
9312+
// rethrows as-is. Match the reason itself so a real failure
9313+
// after a stop is still reported.
9314+
if (
9315+
(error instanceof Error && error.name === "AbortError") ||
9316+
(combinedSignal.aborted && error === combinedSignal.reason)
9317+
) {
93139318
if (runSignal.aborted) {
93149319
return "exit"; // Full run cancellation — exit
93159320
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
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 { simulateReadableStream, streamText } from "ai";
8+
import { MockLanguageModelV3 } from "ai/test";
9+
import type { LanguageModelV3StreamPart } from "@ai-sdk/provider";
10+
import { chat } from "../src/v3/ai.js";
11+
12+
function userMessage(text: string, id: string): UIMessage {
13+
return { id, role: "user", parts: [{ type: "text", text }] };
14+
}
15+
16+
function slowModel() {
17+
const chunks: LanguageModelV3StreamPart[] = [
18+
{ type: "text-start", id: "t1" },
19+
...["one", " two", " three", " four"].map((delta) => ({
20+
type: "text-delta" as const,
21+
id: "t1",
22+
delta,
23+
})),
24+
{ type: "text-end", id: "t1" },
25+
{
26+
type: "finish",
27+
finishReason: { unified: "stop", raw: "stop" },
28+
usage: {
29+
inputTokens: { total: 5, noCache: 5, cacheRead: undefined, cacheWrite: undefined },
30+
outputTokens: { total: 5, text: 5, reasoning: undefined },
31+
},
32+
},
33+
];
34+
return new MockLanguageModelV3({
35+
doStream: async () => ({
36+
stream: simulateReadableStream({ chunks, initialDelayInMs: 0, chunkDelayInMs: 200 }),
37+
}),
38+
});
39+
}
40+
41+
async function waitFor(check: () => boolean, timeoutMs = 5_000) {
42+
const deadline = Date.now() + timeoutMs;
43+
while (!check()) {
44+
if (Date.now() > deadline) throw new Error("waitFor timed out");
45+
await new Promise((resolve) => setTimeout(resolve, 10));
46+
}
47+
}
48+
49+
describe("chat.agent stop", () => {
50+
it("ends a returned streamText turn without an error chunk and keeps the run alive", async () => {
51+
const agent = chat.agent({
52+
id: "stop.returned-stream-text",
53+
run: async ({ messages, signal }) =>
54+
streamText({
55+
...chat.toStreamTextOptions(),
56+
model: slowModel(),
57+
messages,
58+
abortSignal: signal,
59+
}),
60+
});
61+
62+
const harness = mockChatAgent(agent, { chatId: "stop-returned" });
63+
try {
64+
const stopped = harness.sendMessage(userMessage("hi", "u-1"));
65+
await waitFor(() => harness.allChunks.some((c) => c.type === "text-delta"));
66+
await harness.sendStop();
67+
const { chunks } = await stopped;
68+
69+
expect(chunks.filter((c) => c.type === "error")).toEqual([]);
70+
71+
const next = await harness.sendMessage(userMessage("again", "u-2"));
72+
expect(next.chunks.filter((c) => c.type === "error")).toEqual([]);
73+
expect(next.chunks.some((c) => c.type === "finish")).toBe(true);
74+
} finally {
75+
await harness.close();
76+
}
77+
});
78+
79+
it("still reports a real error that run() throws after a stop", async () => {
80+
let runStarted = false;
81+
const agent = chat.agent({
82+
id: "stop.error-after-stop",
83+
run: async ({ signal }) => {
84+
runStarted = true;
85+
if (!signal.aborted) {
86+
await new Promise((resolve) => signal.addEventListener("abort", resolve, { once: true }));
87+
}
88+
throw new Error("db write failed");
89+
},
90+
});
91+
92+
const harness = mockChatAgent(agent, { chatId: "stop-error-after" });
93+
try {
94+
const turn = harness.sendMessage(userMessage("hi", "u-1"));
95+
await waitFor(() => runStarted);
96+
await harness.sendStop();
97+
const { chunks } = await turn;
98+
99+
expect(chunks.filter((c) => c.type === "error")).toEqual([
100+
{ type: "error", errorText: "db write failed" },
101+
]);
102+
} finally {
103+
await harness.close();
104+
}
105+
});
106+
});

0 commit comments

Comments
 (0)