@@ -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