Skip to content

Commit d429a9b

Browse files
committed
fix(sdk): dedup newUIMessages against pre-hydration accumulator
1 parent ae52753 commit d429a9b

2 files changed

Lines changed: 84 additions & 5 deletions

File tree

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

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6158,6 +6158,20 @@ function chatAgent<
61586158
}
61596159

61606160
if (hydrateMessages) {
6161+
// Snapshot the ids the accumulator knew BEFORE this
6162+
// turn ran — used below to decide whether an
6163+
// incoming wire message is genuinely new or just a
6164+
// state advance on an existing entry. We can't use
6165+
// the post-`hydrateMessages` array for this because
6166+
// the canonical hook pattern pushes the incoming
6167+
// user message into the persisted chain and
6168+
// returns it.
6169+
const previouslyKnownMessageIds = new Set(
6170+
accumulatedUIMessages
6171+
.map((m) => m.id)
6172+
.filter((id): id is string => typeof id === "string")
6173+
);
6174+
61616175
// Backend hydration: load the full message history from the user's
61626176
// backend, replacing the built-in accumulator entirely. With slim
61636177
// wire, `incomingMessages` is consistently 0-or-1-length — what
@@ -6217,18 +6231,25 @@ function chatAgent<
62176231

62186232
// Track new messages for onTurnComplete.newUIMessages.
62196233
// Only push for genuinely new ids — HITL continuations
6220-
// whose incoming wire id matches an existing hydrated
6221-
// entry are state advances on an old message, not new
6222-
// messages. The non-hydrate branch below has the same
6223-
// semantic (push only on append, not on merge).
6234+
// whose incoming wire id matches an existing entry are
6235+
// state advances on an old message, not new messages.
6236+
// We compare against `previouslyKnownMessageIds`
6237+
// captured BEFORE hydration, not against `hydrated`:
6238+
// the canonical hydrate pattern pushes the incoming
6239+
// user message into the persisted chain and returns
6240+
// it, so the new id IS in `hydrated`, which would
6241+
// wrongly drop every fresh user turn from
6242+
// `newUIMessages`. The non-hydrate branch below has
6243+
// the same "push only on append" semantic via its
6244+
// own append-vs-replace path.
62246245
if (
62256246
currentWirePayload.trigger === "submit-message" &&
62266247
cleanedUIMessages.length > 0
62276248
) {
62286249
const lastUI = cleanedUIMessages[cleanedUIMessages.length - 1]!;
62296250
const matchedExisting =
62306251
lastUI.id !== undefined &&
6231-
hydrated.some((m) => m.id === lastUI.id);
6252+
previouslyKnownMessageIds.has(lastUI.id);
62326253
if (!matchedExisting) {
62336254
turnNewUIMessages.push(lastUI);
62346255
const lastModel = (await toModelMessages([lastUI]))[0];

packages/trigger-sdk/test/mockChatAgent.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,64 @@ describe("mockChatAgent", () => {
179179
}
180180
});
181181

182+
it("hydrate path: fresh user message lands in onTurnComplete.newUIMessages", async () => {
183+
// The dedup that suppresses HITL-continuation pushes to
184+
// `turnNewUIMessages` must compare against the PRE-hydration
185+
// accumulator, not the post-hydration chain. A `hydrateMessages`
186+
// hook that pushes the incoming user message into its persisted
187+
// chain (the canonical pattern documented in
188+
// /ai-chat/lifecycle-hooks#hydratemessages and the
189+
// `upsertIncomingMessage` helper) would otherwise see the new
190+
// user message in the returned `hydrated` array for every fresh
191+
// turn, causing the dedup to wrongly fire and drop the user
192+
// message from `newUIMessages` / `newMessages`.
193+
const stored: any[] = [];
194+
195+
const model = new MockLanguageModelV3({
196+
doStream: async () => ({ stream: textStream("hi") }),
197+
});
198+
199+
let capturedNewUIMessages: any[] | undefined;
200+
const agent = chat.agent({
201+
id: "mockChatAgent.hydrate-newui-fresh-user",
202+
hydrateMessages: async ({ trigger, incomingMessages }) => {
203+
// Canonical pattern: push the incoming user message into the
204+
// persisted chain and return the chain. Mirrors what
205+
// `upsertIncomingMessage` does.
206+
if (trigger === "submit-message" && incomingMessages.length > 0) {
207+
const newMsg = incomingMessages[incomingMessages.length - 1]!;
208+
const exists = newMsg.id
209+
? stored.some((m) => m.id === newMsg.id)
210+
: false;
211+
if (!exists) stored.push(newMsg);
212+
}
213+
return [...stored];
214+
},
215+
onTurnComplete: async ({ newUIMessages }) => {
216+
capturedNewUIMessages = newUIMessages;
217+
},
218+
run: async ({ messages, signal }) => {
219+
return streamText({ model, messages, abortSignal: signal });
220+
},
221+
});
222+
223+
const harness = mockChatAgent(agent, { chatId: "test-hydrate-newui-fresh-user" });
224+
try {
225+
await harness.sendMessage(userMessage("hello", "u-fresh"));
226+
await new Promise((r) => setTimeout(r, 50));
227+
228+
// `newUIMessages` for a fresh user turn must contain BOTH the
229+
// user message and the assistant response. The bug surfaces as
230+
// length=1 (assistant only — user dropped by the wrong dedup).
231+
expect(capturedNewUIMessages).toBeDefined();
232+
const roles = capturedNewUIMessages!.map((m: any) => m.role);
233+
expect(roles).toEqual(["user", "assistant"]);
234+
expect(capturedNewUIMessages![0]!.id).toBe("u-fresh");
235+
} finally {
236+
await harness.close();
237+
}
238+
});
239+
182240
it("merges HITL tool answer onto head assistant when AI SDK regenerates the id", async () => {
183241
// Regression for TRI-9137: customers (Arena AI) report that the AI SDK
184242
// intermittently mints a fresh id on `addToolOutput` resume, breaking

0 commit comments

Comments
 (0)