@@ -814,6 +814,146 @@ function titleGenerationTests(version: 'v1' | 'v2') {
814814 expect ( agentCallCount ) . toBe ( 1 ) ; // But main agent should still be called
815815 } ) ;
816816
817+ it ( 'awaits title persistence before generate resolves (#20682)' , async ( ) => {
818+ // Regression: detached void genTitle() never ran on serverless runtimes that
819+ // freeze after the HTTP response. generate()/generateLegacy() must not
820+ // resolve until the title has been persisted — no post-await setTimeout.
821+ let titlePersisted = false ;
822+
823+ const mockMemory = new MockMemory ( ) ;
824+ mockMemory . getMergedThreadConfig = ( ) => ( {
825+ generateTitle : true ,
826+ } ) ;
827+
828+ const originalCreateThread = mockMemory . createThread . bind ( mockMemory ) ;
829+ mockMemory . createThread = async args => {
830+ // Intentionally slow so a detached fire-and-forget would lose the race.
831+ await new Promise ( resolve => setTimeout ( resolve , 40 ) ) ;
832+ const thread = await originalCreateThread ( args ) ;
833+ if ( args . title ) {
834+ titlePersisted = true ;
835+ }
836+ return thread ;
837+ } ;
838+
839+ let testModel : MockLanguageModelV1 | MockLanguageModelV2 ;
840+
841+ if ( version === 'v1' ) {
842+ testModel = new MockLanguageModelV1 ( {
843+ doGenerate : async options => {
844+ const messages = options . prompt ;
845+ const isForTitle = messages . some ( ( msg : any ) => msg . content ?. includes ?.( 'you will generate a short title' ) ) ;
846+
847+ if ( isForTitle ) {
848+ return {
849+ rawCall : { rawPrompt : null , rawSettings : { } } ,
850+ finishReason : 'stop' ,
851+ usage : { promptTokens : 5 , completionTokens : 10 } ,
852+ text : 'Serverless Safe Title' ,
853+ } ;
854+ }
855+
856+ return {
857+ rawCall : { rawPrompt : null , rawSettings : { } } ,
858+ finishReason : 'stop' ,
859+ usage : { promptTokens : 10 , completionTokens : 20 } ,
860+ text : 'Agent Response' ,
861+ } ;
862+ } ,
863+ } ) ;
864+ } else {
865+ testModel = new MockLanguageModelV2 ( {
866+ doGenerate : async options => {
867+ const messages = options . prompt ;
868+ const isForTitle = messages . some ( ( msg : any ) => msg . content ?. includes ?.( 'you will generate a short title' ) ) ;
869+
870+ if ( isForTitle ) {
871+ return {
872+ rawCall : { rawPrompt : null , rawSettings : { } } ,
873+ finishReason : 'stop' ,
874+ usage : { inputTokens : 5 , outputTokens : 10 , totalTokens : 15 } ,
875+ text : 'Serverless Safe Title' ,
876+ content : [ { type : 'text' , text : 'Serverless Safe Title' } ] ,
877+ warnings : [ ] ,
878+ } ;
879+ }
880+
881+ return {
882+ rawCall : { rawPrompt : null , rawSettings : { } } ,
883+ finishReason : 'stop' ,
884+ usage : { inputTokens : 10 , outputTokens : 20 , totalTokens : 30 } ,
885+ text : 'Agent Response' ,
886+ content : [ { type : 'text' , text : 'Agent Response' } ] ,
887+ warnings : [ ] ,
888+ } ;
889+ } ,
890+ doStream : async options => {
891+ const messages = options . prompt ;
892+ const isForTitle = messages . some ( ( msg : any ) => msg . content ?. includes ?.( 'you will generate a short title' ) ) ;
893+ const text = isForTitle ? 'Serverless Safe Title' : 'Agent Response' ;
894+
895+ return {
896+ rawCall : { rawPrompt : null , rawSettings : { } } ,
897+ warnings : [ ] ,
898+ stream : convertArrayToReadableStream ( [
899+ { type : 'stream-start' , warnings : [ ] } ,
900+ {
901+ type : 'response-metadata' ,
902+ id : 'id-0' ,
903+ modelId : 'mock-model-id' ,
904+ timestamp : new Date ( 0 ) ,
905+ } ,
906+ { type : 'text-start' , id : 'text-1' } ,
907+ { type : 'text-delta' , id : 'text-1' , delta : text } ,
908+ { type : 'text-end' , id : 'text-1' } ,
909+ {
910+ type : 'finish' ,
911+ finishReason : 'stop' ,
912+ usage : { inputTokens : 10 , outputTokens : 20 , totalTokens : 30 } ,
913+ } ,
914+ ] ) ,
915+ } ;
916+ } ,
917+ } ) ;
918+ }
919+
920+ const agent = new Agent ( {
921+ id : 'await-title-agent' ,
922+ name : 'Await Title Agent' ,
923+ instructions : 'test agent' ,
924+ model : testModel ,
925+ memory : mockMemory ,
926+ } ) ;
927+
928+ const threadId = `thread-await-title-${ version } ` ;
929+
930+ if ( version === 'v1' ) {
931+ await agent . generateLegacy ( 'Name this conversation' , {
932+ memory : {
933+ resource : 'user-await' ,
934+ thread : {
935+ id : threadId ,
936+ title : '' ,
937+ } ,
938+ } ,
939+ } ) ;
940+ } else {
941+ await agent . generate ( 'Name this conversation' , {
942+ memory : {
943+ resource : 'user-await' ,
944+ thread : {
945+ id : threadId ,
946+ title : '' ,
947+ } ,
948+ } ,
949+ } ) ;
950+ }
951+
952+ expect ( titlePersisted ) . toBe ( true ) ;
953+ const thread = await mockMemory . getThreadById ( { threadId } ) ;
954+ expect ( thread ?. title ) . toBe ( 'Serverless Safe Title' ) ;
955+ } ) ;
956+
817957 it ( 'should not generate title for pre-created threads (thread already exists)' , async ( ) => {
818958 // Pre-created threads already exist in the DB, so threadExists is true.
819959 // Title generation only fires when the thread is newly created by the agent.
0 commit comments