Skip to content

Commit b6ea02f

Browse files
committed
fix(sdk): don't overwrite a committed response when a post-response step throws
The chat.agent try block spans the whole turn, so a throw from a post-response step (a customer onBeforeTurnComplete/onTurnComplete hook, a late conversion) lands in the error handler after the response was already committed. Track a per-turn responseCommitted flag and keep capturedPartialResponse pointed at the enriched committed message, so the error path reports it without re-recovering a raw partial and clobbering the committed message, its queued data parts, or a prior turn's compaction. Also use truthy id checks to match the success path.
1 parent 1fe685c commit b6ea02f

2 files changed

Lines changed: 72 additions & 6 deletions

File tree

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

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6396,6 +6396,7 @@ function chatAgent<
63966396
// onFinish message if it fired; otherwise the buffered chunks are
63976397
// reconstructed as the fallback (mirrors chat.pipeAndCapture).
63986398
let capturedPartialResponse: TUIMessage | undefined;
6399+
let responseCommitted = false;
63996400
const turnBufferedChunks: UIMessageChunk[] = [];
64006401
try {
64016402
// Extract turn-level context before entering the span. Slim
@@ -7404,6 +7405,11 @@ function chatAgent<
74047405
}
74057406
}
74067407

7408+
if (capturedResponseMessage) {
7409+
responseCommitted = true;
7410+
capturedPartialResponse = capturedResponseMessage;
7411+
}
7412+
74077413
if (runSignal.aborted) return "exit";
74087414

74097415
// Await deferred background work (e.g. DB writes from onTurnStart)
@@ -7625,6 +7631,7 @@ function chatAgent<
76257631
parts: [...(msg.parts ?? []), ...lateParts],
76267632
} as TUIMessage;
76277633
capturedResponseMessage = accumulatedUIMessages[idx] as TUIMessage;
7634+
capturedPartialResponse = capturedResponseMessage;
76287635
turnCompleteEvent.responseMessage = capturedResponseMessage;
76297636
turnCompleteEvent.uiMessages = accumulatedUIMessages;
76307637
}
@@ -7935,10 +7942,9 @@ function chatAgent<
79357942
// success path) instead of dropping it as a dup; otherwise append.
79367943
// `erroredWireMessage` was already folded into `erroredUIMessages`
79377944
// above when the pre-run merge hadn't happened.
7938-
const partialIdx =
7939-
partialResponse?.id != null
7940-
? erroredUIMessages.findIndex((m) => m.id === partialResponse!.id)
7941-
: -1;
7945+
const partialIdx = partialResponse?.id
7946+
? erroredUIMessages.findIndex((m) => m.id === partialResponse!.id)
7947+
: -1;
79427948
const erroredUIMessagesWithPartial: TUIMessage[] = !partialResponse
79437949
? erroredUIMessages
79447950
: partialIdx === -1
@@ -7950,7 +7956,7 @@ function chatAgent<
79507956
const erroredNewUIMessages: TUIMessage[] = erroredWireMessage
79517957
? [erroredWireMessage]
79527958
: [];
7953-
if (partialResponse) {
7959+
if (partialResponse && !responseCommitted) {
79547960
erroredNewUIMessages.push(partialResponse);
79557961
}
79567962

@@ -7964,7 +7970,7 @@ function chatAgent<
79647970
// its model messages to preserve a prior turn's compaction (mirrors
79657971
// the success path's append branch); otherwise reconvert from UI.
79667972
// Guard the conversion so a secondary failure can't crash the run.
7967-
if (erroredUIMessagesWithPartial !== accumulatedUIMessages) {
7973+
if (!responseCommitted && erroredUIMessagesWithPartial !== accumulatedUIMessages) {
79687974
const onlyAppendedPartial =
79697975
partialResponse != null &&
79707976
partialIdx === -1 &&

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

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,66 @@ describe("chat.agent managed loop — source-stream failure", () => {
160160
await harness.close();
161161
}
162162
});
163+
164+
it("does not overwrite an already-committed enriched response when a post-response hook throws", async () => {
165+
const events: TurnCompleteEvent<unknown, UIMessage>[] = [];
166+
167+
const okModel = (text: string) =>
168+
new MockLanguageModelV3({
169+
doStream: async () => ({
170+
stream: simulateReadableStream({
171+
chunks: [
172+
{ type: "text-start", id: "t1" },
173+
{ type: "text-delta", id: "t1", delta: text },
174+
{ type: "text-end", id: "t1" },
175+
{
176+
type: "finish",
177+
finishReason: { unified: "stop", raw: "stop" },
178+
usage: {
179+
inputTokens: {
180+
total: 5,
181+
noCache: 5,
182+
cacheRead: undefined,
183+
cacheWrite: undefined,
184+
},
185+
outputTokens: { total: 5, text: 5, reasoning: undefined },
186+
},
187+
},
188+
] as LanguageModelV3StreamPart[],
189+
}),
190+
}),
191+
});
192+
193+
const agent = chat.agent({
194+
id: "chatAgent.post-commit-hook-throw",
195+
run: async ({ messages }) => {
196+
chat.response.write({ type: "data-marker", data: { kept: true } } as never);
197+
return streamText({ model: okModel("full response"), messages });
198+
},
199+
onTurnComplete: async (event) => {
200+
events.push(event);
201+
if (event.error == null) {
202+
throw new Error("hook boom after commit");
203+
}
204+
},
205+
});
206+
207+
const harness = mockChatAgent(agent, { chatId: "cae-post-commit-throw" });
208+
try {
209+
await harness.sendMessage(userMessage("hi", "u-1"));
210+
await waitFor(() => events.some((e) => e.error != null));
211+
212+
const errorEvent = events.find((e) => e.error != null)!;
213+
const assistant = (errorEvent.uiMessages as UIMessage[]).find((m) => m.role === "assistant");
214+
expect(assistant).toBeDefined();
215+
expect(
216+
(assistant!.parts as Array<{ type: string }>).some((p) => p.type === "data-marker")
217+
).toBe(true);
218+
expect(extractText(assistant)).toBe("full response");
219+
} finally {
220+
await harness.close();
221+
}
222+
});
163223
});
164224

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

0 commit comments

Comments
 (0)