Skip to content

Commit 920c76d

Browse files
committed
fix(chat): keep a failed action from counting as a turn
Reporting a failed action stream by throwing landed in the shared turn-error path, which fired onTurnComplete, kept the turn number and consumed the one-shot instruction lane. The action branch now reports the failure itself and falls through to its own snapshot, completion and turn--, so the next real turn is still next and still gets an instruction injected before the action.
1 parent 39ea541 commit 920c76d

2 files changed

Lines changed: 130 additions & 1 deletion

File tree

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8203,7 +8203,19 @@ function chatAgent<
82038203
) {
82048204
return "exit";
82058205
}
8206-
throw error;
8206+
// Reported here rather than rethrown: the shared catch
8207+
// below is the turn-error path, and it would fire
8208+
// onTurnComplete, keep the turn number and consume the
8209+
// one-shot instruction lane, none of which an action does.
8210+
try {
8211+
await withChatWriter(async (writer) => {
8212+
const errorText =
8213+
error instanceof Error ? error.message : "An unexpected error occurred";
8214+
writer.write({ type: "error", errorText } as any);
8215+
});
8216+
} catch {
8217+
// best effort
8218+
}
82078219
}
82088220
}
82098221

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { mockChatAgent } from "../src/v3/test/index.js";
2+
3+
import type { LanguageModelV3StreamPart } from "@ai-sdk/provider";
4+
import { simulateReadableStream, streamText } from "ai";
5+
import { MockLanguageModelV3 } from "ai/test";
6+
import { describe, expect, it } from "vitest";
7+
import { z } from "zod";
8+
import { chat } from "../src/v3/ai.js";
9+
10+
/**
11+
* An action whose stream fails is still an action, not a turn.
12+
*
13+
* Reporting the failure by throwing lands in the shared turn-error path,
14+
* which fires `onTurnComplete`, advances the turn counter and consumes the
15+
* one-shot instruction lane, none of which an action is supposed to do. The
16+
* failure still has to be reported to the client and the partial kept.
17+
*/
18+
19+
const USAGE = {
20+
inputTokens: { total: 1, noCache: 1, cacheRead: undefined, cacheWrite: undefined },
21+
outputTokens: { total: 1, text: 1, reasoning: undefined },
22+
};
23+
const userMessage = (text: string, id: string) => ({
24+
id,
25+
role: "user" as const,
26+
parts: [{ type: "text" as const, text }],
27+
});
28+
async function waitFor(check: () => boolean, label = "condition", timeoutMs = 8_000) {
29+
const start = Date.now();
30+
while (Date.now() - start < timeoutMs) {
31+
if (check()) return;
32+
await new Promise((r) => setTimeout(r, 10));
33+
}
34+
throw new Error(`waitFor timed out: ${label}`);
35+
}
36+
const textChunks = (text: string): LanguageModelV3StreamPart[] => [
37+
{ type: "text-start", id: "t1" },
38+
{ type: "text-delta", id: "t1", delta: text },
39+
{ type: "text-end", id: "t1" },
40+
{ type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage: USAGE },
41+
];
42+
43+
describe("an action whose stream fails", () => {
44+
it("is reported without being counted as a turn", { timeout: 30_000 }, async () => {
45+
const turnCompletes: { turn: number; finishReason?: string }[] = [];
46+
const turnPrompts: string[] = [];
47+
48+
const turnModel = new MockLanguageModelV3({
49+
doStream: async ({ prompt }) => {
50+
turnPrompts.push(JSON.stringify(prompt));
51+
return {
52+
stream: simulateReadableStream({ chunks: textChunks("answer"), initialDelayInMs: 5 }),
53+
};
54+
},
55+
});
56+
const failingActionModel = new MockLanguageModelV3({
57+
doStream: async () => ({
58+
stream: new ReadableStream<LanguageModelV3StreamPart>({
59+
pull(c) {
60+
c.error(new Error("provider exploded mid-stream"));
61+
},
62+
}),
63+
}),
64+
});
65+
66+
const agent = chat.agent({
67+
id: "action-failure-not-a-turn",
68+
actionSchema: z.discriminatedUnion("type", [z.object({ type: z.literal("regenerate") })]),
69+
onTurnComplete: async ({ turn, finishReason }) => {
70+
turnCompletes.push({ turn, finishReason });
71+
// Injected after turn 0, meant for the next real turn.
72+
if (turn === 0)
73+
chat.inject([{ role: "system", content: "INSTRUCTION-FOR-NEXT-TURN" }] as never);
74+
},
75+
onAction: async ({ action, messages }) => {
76+
if (action.type !== "regenerate") return;
77+
chat.history.slice(0, -1);
78+
return streamText({ model: failingActionModel, messages, ...chat.toStreamTextOptions() });
79+
},
80+
run: async ({ messages, signal }) =>
81+
streamText({
82+
model: turnModel,
83+
messages,
84+
abortSignal: signal,
85+
...chat.toStreamTextOptions(),
86+
}),
87+
});
88+
89+
const harness = mockChatAgent(agent, { chatId: "action-failure-not-a-turn" });
90+
try {
91+
await harness.sendMessage(userMessage("m1", "u-1"));
92+
await waitFor(() => turnCompletes.length >= 1, "turn 0");
93+
94+
await harness.sendAction({ type: "regenerate" }).catch(() => {});
95+
await new Promise((r) => setTimeout(r, 200));
96+
97+
// The failure reached the client.
98+
const errors = (harness.allRawChunks as { type?: string }[]).filter(
99+
(c) => c.type === "error"
100+
);
101+
expect(errors.length).toBeGreaterThan(0);
102+
103+
// But it was not a turn: no turn lifecycle for it.
104+
expect(turnCompletes).toHaveLength(1);
105+
106+
await harness.sendMessage(userMessage("m2", "u-2"));
107+
await waitFor(() => turnCompletes.length >= 2, "turn 1");
108+
109+
// The next real turn is turn 1, not turn 2, and it still gets the
110+
// instruction the failed action must not have consumed.
111+
expect(turnCompletes[1]!.turn).toBe(1);
112+
expect(turnPrompts.at(-1)!).toContain("INSTRUCTION-FOR-NEXT-TURN");
113+
} finally {
114+
await harness.close();
115+
}
116+
});
117+
});

0 commit comments

Comments
 (0)