Skip to content

Commit e348d61

Browse files
committed
fix(chat): move the snapshot cursor on a failed turn
The error path wrote its snapshot with the failed turn's completion cursor but did not update the shared cursor, so a later history-changing action, whose snapshot is cursor-neutral, wrote the cursor from before the failed turn and a continuation would replay superseded output.
1 parent 920c76d commit e348d61

2 files changed

Lines changed: 112 additions & 0 deletions

File tree

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9186,6 +9186,10 @@ function chatAgent<
91869186
});
91879187
// Signal turn complete so the client knows this turn is done
91889188
errorTurnCompleteResult = await writeTurnCompleteChunk(currentWirePayload.chatId);
9189+
// A later action's snapshot reuses this cursor, so it has to move
9190+
// here too or that snapshot resumes from before the failed turn.
9191+
lastSnapshotOutEventId =
9192+
errorTurnCompleteResult?.lastEventId ?? lastSnapshotOutEventId;
91899193
} catch {
91909194
// Best-effort — if stream write fails, let the run continue anyway
91919195
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
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+
* The snapshot cursor after a failed turn.
12+
*
13+
* The error path writes its snapshot with the failed turn's completion cursor
14+
* but does not update the shared cursor holder, so a later action's snapshot,
15+
* which is cursor-neutral and reuses the holder, writes the cursor from
16+
* BEFORE the failed turn. A continuation then resumes from there and replays
17+
* output the failed turn's snapshot had already superseded.
18+
*/
19+
20+
const USAGE = {
21+
inputTokens: { total: 1, noCache: 1, cacheRead: undefined, cacheWrite: undefined },
22+
outputTokens: { total: 1, text: 1, reasoning: undefined },
23+
};
24+
const userMessage = (text: string, id: string) => ({
25+
id,
26+
role: "user" as const,
27+
parts: [{ type: "text" as const, text }],
28+
});
29+
async function waitFor(check: () => boolean, label = "condition", timeoutMs = 8_000) {
30+
const start = Date.now();
31+
while (Date.now() - start < timeoutMs) {
32+
if (check()) return;
33+
await new Promise((r) => setTimeout(r, 10));
34+
}
35+
throw new Error(`waitFor timed out: ${label}`);
36+
}
37+
const textChunks = (text: string): LanguageModelV3StreamPart[] => [
38+
{ type: "text-start", id: "t1" },
39+
{ type: "text-delta", id: "t1", delta: text },
40+
{ type: "text-end", id: "t1" },
41+
{ type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage: USAGE },
42+
];
43+
function erroringStream(): ReadableStream<LanguageModelV3StreamPart> {
44+
const chunks: LanguageModelV3StreamPart[] = [
45+
{ type: "text-start", id: "t1" },
46+
{ type: "text-delta", id: "t1", delta: "partial" },
47+
];
48+
let i = 0;
49+
return new ReadableStream({
50+
pull(c) {
51+
if (i < chunks.length) return void c.enqueue(chunks[i++]!);
52+
c.error(new Error("UND_ERR_BODY_TIMEOUT"));
53+
},
54+
});
55+
}
56+
57+
describe("the snapshot an action writes after a failed turn", () => {
58+
it("carries the failed turn's cursor, not the one before it", { timeout: 30_000 }, async () => {
59+
const completes: { finishReason?: string }[] = [];
60+
let step = 0;
61+
const model = new MockLanguageModelV3({
62+
doStream: async () =>
63+
step++ === 0
64+
? { stream: simulateReadableStream({ chunks: textChunks("first"), initialDelayInMs: 5 }) }
65+
: { stream: erroringStream() },
66+
});
67+
68+
const agent = chat.agent({
69+
id: "error-snapshot-cursor",
70+
actionSchema: z.discriminatedUnion("type", [z.object({ type: z.literal("undo") })]),
71+
onTurnComplete: async ({ finishReason }) => {
72+
completes.push({ finishReason });
73+
},
74+
onAction: async ({ action }) => {
75+
if (action.type === "undo") chat.history.slice(0, -2);
76+
},
77+
run: async ({ messages, signal }) => streamText({ model, messages, abortSignal: signal }),
78+
});
79+
80+
const harness = mockChatAgent(agent, { chatId: "error-snapshot-cursor" });
81+
try {
82+
await harness.sendMessage(userMessage("m1", "u-1"));
83+
await waitFor(() => harness.getSnapshot()?.lastOutEventId !== undefined, "turn 0 snapshot");
84+
const afterTurn0 = harness.getSnapshot()?.lastOutEventId;
85+
86+
await harness.sendMessage(userMessage("m2", "u-2"));
87+
await waitFor(() => completes.length >= 2, "turn 1 (failed)");
88+
expect(completes[1]!.finishReason).toBe("error");
89+
await waitFor(
90+
() => harness.getSnapshot()?.lastOutEventId !== afterTurn0,
91+
"failed turn snapshot"
92+
);
93+
const afterFailedTurn = harness.getSnapshot()?.lastOutEventId;
94+
expect(afterFailedTurn).toBeDefined();
95+
// The failed turn moved the cursor: it wrote an error and a completion.
96+
expect(afterFailedTurn).not.toBe(afterTurn0);
97+
98+
await harness.sendAction({ type: "undo" });
99+
await new Promise((r) => setTimeout(r, 60));
100+
101+
// An action's write is cursor-neutral, so it has to keep the CURRENT
102+
// cursor, which is the failed turn's, not the one from before it.
103+
expect(harness.getSnapshot()?.lastOutEventId).toBe(afterFailedTurn);
104+
} finally {
105+
await harness.close();
106+
}
107+
});
108+
});

0 commit comments

Comments
 (0)