@@ -260,6 +260,202 @@ describe("chat.handover", () => {
260260 }
261261 } ) ;
262262
263+ it ( "pure-text head-start (isFinal: true) with hydrateMessages persists the partial (TRI-10715)" , async ( ) => {
264+ // Same as the pure-text case above, but the customer registers
265+ // `hydrateMessages` (the documented DB-as-source-of-truth pattern).
266+ // The head-start user message must reach the hydrate hook as
267+ // `incomingMessages`, and the warm route's partial must land in the
268+ // accumulator so `onTurnComplete` carries the full first turn.
269+ const runFn = vi . fn ( ) ;
270+ const stored : { id : string ; role : string ; parts : unknown [ ] } [ ] = [ ] ;
271+ const hydrateIncomingRoles : string [ ] = [ ] ;
272+ let captured :
273+ | { responseId ?: string ; responseText ?: string ; roles ?: string [ ] }
274+ | undefined ;
275+
276+ const agent = chat . agent ( {
277+ id : "chat.handover.hydrate-pure-text" ,
278+ hydrateMessages : async ( { incomingMessages } ) => {
279+ hydrateIncomingRoles . push ( ...incomingMessages . map ( ( m ) => m . role ) ) ;
280+ for ( const m of incomingMessages ) {
281+ if ( ! stored . some ( ( s ) => s . id === m . id ) ) stored . push ( m as ( typeof stored ) [ number ] ) ;
282+ }
283+ return [ ...stored ] as never ;
284+ } ,
285+ onTurnComplete : ( { responseMessage, uiMessages } ) => {
286+ captured = {
287+ responseId : responseMessage ?. id ,
288+ responseText : ( responseMessage ?. parts ?? [ ] )
289+ . filter ( ( p ) => p . type === "text" )
290+ . map ( ( p ) => ( p as { text ?: string } ) . text || "" )
291+ . join ( "" ) ,
292+ roles : uiMessages . map ( ( m ) => m . role ) ,
293+ } ;
294+ } ,
295+ run : async ( { messages, signal } ) => {
296+ runFn ( ) ;
297+ return streamText ( {
298+ model : new MockLanguageModelV3 ( {
299+ doStream : async ( ) => ( { stream : textStream ( "should-not-run" ) } ) ,
300+ } ) ,
301+ messages,
302+ abortSignal : signal ,
303+ } ) ;
304+ } ,
305+ } ) ;
306+
307+ const harness = mockChatAgent ( agent , {
308+ chatId : "test-handover-hydrate-final" ,
309+ mode : "handover-prepare" ,
310+ headStartMessages : [
311+ { id : "hs-user-1" , role : "user" , parts : [ { type : "text" , text : "say hi" } ] } ,
312+ ] ,
313+ } ) ;
314+
315+ try {
316+ await harness . sendHandover ( {
317+ partialAssistantMessage : [
318+ {
319+ role : "assistant" ,
320+ content : [ { type : "text" , text : "Hi there, hope you're well." } ] ,
321+ } ,
322+ ] ,
323+ messageId : "asst-hydrate-1" ,
324+ isFinal : true ,
325+ } ) ;
326+ await new Promise ( ( r ) => setTimeout ( r , 30 ) ) ;
327+
328+ // isFinal — the agent never calls the user's run().
329+ expect ( runFn ) . not . toHaveBeenCalled ( ) ;
330+
331+ // The head-start user message reached the hydrate hook as incoming.
332+ expect ( hydrateIncomingRoles ) . toContain ( "user" ) ;
333+
334+ // onTurnComplete carries the full first turn: user + the warm
335+ // route's assistant, under the handover messageId.
336+ expect ( captured ) . toBeDefined ( ) ;
337+ expect ( captured ! . roles ) . toEqual ( [ "user" , "assistant" ] ) ;
338+ expect ( captured ! . responseId ) . toBe ( "asst-hydrate-1" ) ;
339+ expect ( captured ! . responseText ) . toBe ( "Hi there, hope you're well." ) ;
340+ } finally {
341+ await harness . close ( ) ;
342+ }
343+ } ) ;
344+
345+ it ( "tool-call handover (isFinal: false) with hydrateMessages resumes from step 2 (TRI-10715)" , async ( ) => {
346+ // Hydrate variant of the schema-only tool-call case: the spliced
347+ // partial (assistant + approval round) must reach the agent's
348+ // streamText so AI SDK executes the pending tool instead of
349+ // re-running step 1 from scratch against an empty/short prompt.
350+ const toolExecute = vi . fn ( async ( { city } : { city : string } ) => ( { city, temp : 22 } ) ) ;
351+ const weatherTool = tool ( {
352+ description : "Look up weather" ,
353+ inputSchema : z . object ( { city : z . string ( ) } ) ,
354+ execute : toolExecute ,
355+ } ) ;
356+
357+ const stored : { id : string ; role : string ; parts : unknown [ ] } [ ] = [ ] ;
358+ let runMessageRoles : string [ ] | undefined ;
359+ let captured : { roles ?: string [ ] ; assistantIds ?: ( string | undefined ) [ ] } | undefined ;
360+
361+ const agent = chat . agent ( {
362+ id : "chat.handover.hydrate-schema-only-tool" ,
363+ hydrateMessages : async ( { incomingMessages } ) => {
364+ for ( const m of incomingMessages ) {
365+ if ( ! stored . some ( ( s ) => s . id === m . id ) ) stored . push ( m as ( typeof stored ) [ number ] ) ;
366+ }
367+ return [ ...stored ] as never ;
368+ } ,
369+ onTurnComplete : ( { uiMessages } ) => {
370+ captured = {
371+ roles : uiMessages . map ( ( m ) => m . role ) ,
372+ assistantIds : uiMessages . filter ( ( m ) => m . role === "assistant" ) . map ( ( m ) => m . id ) ,
373+ } ;
374+ } ,
375+ run : async ( { messages, signal } ) => {
376+ runMessageRoles = messages . map ( ( m ) => m . role ) ;
377+ return streamText ( {
378+ model : new MockLanguageModelV3 ( {
379+ doStream : async ( ) => ( { stream : textStream ( "the weather in tokyo is 22°C" ) } ) ,
380+ } ) ,
381+ messages,
382+ tools : { weather : weatherTool } ,
383+ abortSignal : signal ,
384+ } ) ;
385+ } ,
386+ } ) ;
387+
388+ const harness = mockChatAgent ( agent , {
389+ chatId : "test-handover-hydrate-tool" ,
390+ mode : "handover-prepare" ,
391+ headStartMessages : [
392+ { id : "hs-user-2" , role : "user" , parts : [ { type : "text" , text : "weather in tokyo?" } ] } ,
393+ ] ,
394+ } ) ;
395+
396+ try {
397+ const turn = await harness . sendHandover ( {
398+ isFinal : false ,
399+ messageId : "asst-hydrate-2" ,
400+ partialAssistantMessage : [
401+ {
402+ role : "assistant" ,
403+ content : [
404+ { type : "text" , text : "let me check the weather" } ,
405+ {
406+ type : "tool-call" ,
407+ toolCallId : "tc-h1" ,
408+ toolName : "weather" ,
409+ input : { city : "tokyo" } ,
410+ } ,
411+ {
412+ type : "tool-approval-request" ,
413+ approvalId : "handover-approval-h1" ,
414+ toolCallId : "tc-h1" ,
415+ } ,
416+ ] ,
417+ } ,
418+ {
419+ role : "tool" ,
420+ content : [
421+ {
422+ type : "tool-approval-response" ,
423+ approvalId : "handover-approval-h1" ,
424+ approved : true ,
425+ } ,
426+ ] ,
427+ } ,
428+ ] ,
429+ } ) ;
430+ await new Promise ( ( r ) => setTimeout ( r , 30 ) ) ;
431+
432+ // The resume prompt contained the full splice: user + partial
433+ // assistant + approval round — NOT an empty/user-only prompt.
434+ expect ( runMessageRoles ) . toEqual ( [ "user" , "assistant" , "tool" ] ) ;
435+
436+ // AI SDK's initial-tool-execution branch ran the agent-side
437+ // execute (no step-1 re-run).
438+ expect ( toolExecute ) . toHaveBeenCalledWith (
439+ expect . objectContaining ( { city : "tokyo" } ) ,
440+ expect . anything ( )
441+ ) ;
442+
443+ // Step-2 text streamed through session.out.
444+ const text = turn . chunks
445+ . filter ( ( c ) => c . type === "text-delta" )
446+ . map ( ( c ) => ( c as { delta : string } ) . delta )
447+ . join ( "" ) ;
448+ expect ( text ) . toContain ( "tokyo" ) ;
449+
450+ // One assistant in the final chain, under the handover messageId.
451+ expect ( captured ) . toBeDefined ( ) ;
452+ expect ( captured ! . roles ) . toEqual ( [ "user" , "assistant" ] ) ;
453+ expect ( captured ! . assistantIds ) . toEqual ( [ "asst-hydrate-2" ] ) ;
454+ } finally {
455+ await harness . close ( ) ;
456+ }
457+ } ) ;
458+
263459 it ( "onTurnStart fires after the handover signal arrives (lazy)" , async ( ) => {
264460 // Hooks should not fire during the wait — only once handover lands
265461 // and a real turn begins. Verifies the order so customers can
0 commit comments