Skip to content

Commit dc63a6e

Browse files
committed
fix(sdk): persist recovered partial to accumulator and snapshot on error
Commit the recovered partial to the canonical accumulator on the chat.agent error path so the next turn and the reboot snapshot both carry it, matching the success path. Fold queued response parts into the manual-loop error partial too. Add a continuation regression test.
1 parent 8152444 commit dc63a6e

2 files changed

Lines changed: 95 additions & 4 deletions

File tree

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

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7944,6 +7944,23 @@ function chatAgent<
79447944
? [...erroredUIMessages, partialResponse]
79457945
: erroredUIMessages;
79467946

7947+
// Commit the recovered partial to the canonical accumulator so the
7948+
// partial survives past this hook: the run stays alive after an
7949+
// error, so the next turn sees it, and the error-path snapshot below
7950+
// (the recovery source for non-hydrate apps) persists it for reboot.
7951+
// Matches the success path, which accumulates the response. Guard the
7952+
// model-message conversion so a secondary failure here can't crash
7953+
// the still-alive run.
7954+
if (partialResponse) {
7955+
accumulatedUIMessages = erroredUIMessagesWithPartial as TUIMessage[];
7956+
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
7957+
try {
7958+
accumulatedMessages = await toModelMessages(accumulatedUIMessages);
7959+
} catch {
7960+
// Keep the prior model accumulator if conversion fails.
7961+
}
7962+
}
7963+
79477964
// Fire onTurnComplete on the error path too — the docs promise it
79487965
// runs "after every turn, successful or errored" so customers can
79497966
// mark the turn failed. `responseMessage` carries any partial
@@ -8007,7 +8024,7 @@ function chatAgent<
80078024
await writeChatSnapshot<TUIMessage>(sessionIdForSnapshot, {
80088025
version: 1,
80098026
savedAt: Date.now(),
8010-
messages: erroredUIMessages,
8027+
messages: erroredUIMessagesWithPartial,
80118028
lastOutEventId: errorTurnCompleteResult?.lastEventId,
80128029
lastInEventId:
80138030
errorSnapshotInCursor !== undefined ? String(errorSnapshotInCursor) : undefined,
@@ -9788,9 +9805,19 @@ function createChatSession(
97889805
// accumulate it (mirroring the stop path) so `turn.uiMessages`
97899806
// reflects it and the caller can persist it after catching,
97909807
// then rethrow. Without this the partial pipeAndCapture
9791-
// reconstructed is silently dropped on rethrow.
9808+
// reconstructed is silently dropped on rethrow. Fold in any
9809+
// data parts queued via chat.response / writer.write() this
9810+
// turn, same as the success path below. cleanupAbortedParts is
9811+
// intentionally skipped: it only runs on a user stop, not on a
9812+
// hard transport error (which is not an abort).
97929813
if (captured.message) {
9793-
await accumulator.addResponse(captured.message);
9814+
const partial = captured.message;
9815+
const queuedParts = locals.get(chatResponsePartsKey);
9816+
if (queuedParts && queuedParts.length > 0) {
9817+
(partial as any).parts = [...(partial.parts ?? []), ...queuedParts];
9818+
locals.set(chatResponsePartsKey, []);
9819+
}
9820+
await accumulator.addResponse(partial);
97949821
}
97959822
throw captured.error;
97969823
}

packages/trigger-sdk/test/chat-agent-source-stream-error.test.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
import { mockChatAgent } from "../src/v3/test/index.js";
44

55
import { describe, expect, it } from "vitest";
6-
import type { UIMessage } from "ai";
6+
import type { ModelMessage, UIMessage } from "ai";
7+
import { simulateReadableStream, streamText } from "ai";
8+
import { MockLanguageModelV3 } from "ai/test";
9+
import type { LanguageModelV3StreamPart } from "@ai-sdk/provider";
710
import { chat } from "../src/v3/ai.js";
811
import type { TurnCompleteEvent } from "../src/v3/ai.js";
912

@@ -96,6 +99,67 @@ describe("chat.agent managed loop — source-stream failure", () => {
9699
await harness.close();
97100
}
98101
});
102+
103+
it("carries the recovered partial into the next turn's accumulated messages", async () => {
104+
let turn = 0;
105+
let turn2Messages: ModelMessage[] | undefined;
106+
107+
const okStream = () =>
108+
simulateReadableStream({
109+
chunks: [
110+
{ type: "text-start", id: "t2" },
111+
{ type: "text-delta", id: "t2", delta: "second answer" },
112+
{ type: "text-end", id: "t2" },
113+
{
114+
type: "finish",
115+
finishReason: { unified: "stop", raw: "stop" },
116+
usage: {
117+
inputTokens: { total: 5, noCache: 5, cacheRead: undefined, cacheWrite: undefined },
118+
outputTokens: { total: 5, text: 5, reasoning: undefined },
119+
},
120+
},
121+
] as LanguageModelV3StreamPart[],
122+
});
123+
124+
const agent = chat.agent({
125+
id: "chatAgent.source-stream-error-continuation",
126+
run: async ({ messages }) => {
127+
turn++;
128+
if (turn === 1) {
129+
return erroringSource("UND_ERR_BODY_TIMEOUT") as never;
130+
}
131+
// Second turn: the failed turn's partial assistant output must be in
132+
// the accumulated history the model now sees.
133+
turn2Messages = messages;
134+
return streamText({
135+
model: new MockLanguageModelV3({ doStream: async () => ({ stream: okStream() }) }),
136+
messages,
137+
});
138+
},
139+
});
140+
141+
const harness = mockChatAgent(agent, { chatId: "cae-source-error-cont" });
142+
try {
143+
await harness.sendMessage(userMessage("hi", "u-1"));
144+
await harness.sendMessage(userMessage("still there?", "u-2"));
145+
await waitFor(() => turn2Messages !== undefined);
146+
147+
const assistantText = turn2Messages!
148+
.filter((m) => m.role === "assistant")
149+
.map((m) =>
150+
typeof m.content === "string"
151+
? m.content
152+
: (m.content as Array<{ type: string; text?: string }>)
153+
.filter((p) => p.type === "text")
154+
.map((p) => p.text ?? "")
155+
.join("")
156+
)
157+
.join("");
158+
expect(assistantText).toContain("partial answer");
159+
} finally {
160+
await harness.close();
161+
}
162+
});
99163
});
100164

101165
describe("chat.createSession turn.complete() — source-stream failure", () => {

0 commit comments

Comments
 (0)