@@ -574,20 +574,20 @@ describe('AgentBlockHandler', () => {
574574 context : 'execution' ,
575575 base64 : 'iVBORw0KGgo=' ,
576576 }
577+ const hydrateWithBytes = ( ) =>
578+ vi . spyOn ( userFileBase64 , 'hydrateUserFilesWithBase64' ) . mockImplementation (
579+ async ( value ) =>
580+ ( value as ( typeof file ) [ ] ) . map ( ( attachment ) => ( {
581+ ...attachment ,
582+ base64 : file . base64 ,
583+ } ) ) as typeof value
584+ )
577585
578586 it . each ( [ 'files' , 'messages' , 'userPrompt' ] as const ) (
579587 'replays a previous turn from %s with a fresh provider attachment' ,
580588 async ( source ) => {
581589 mockGetProviderFromModel . mockReturnValue ( 'openai' )
582- const hydrate = vi
583- . spyOn ( userFileBase64 , 'hydrateUserFilesWithBase64' )
584- . mockImplementation ( async ( value ) => {
585- const files = value as ( typeof file ) [ ]
586- return files . map ( ( attachment ) => ( {
587- ...attachment ,
588- base64 : file . base64 ,
589- } ) ) as typeof value
590- } )
590+ const hydrate = hydrateWithBytes ( )
591591 const inputs : AgentInputs = {
592592 model : 'gpt-4o' ,
593593 memoryType : 'conversation' ,
@@ -682,6 +682,127 @@ describe('AgentBlockHandler', () => {
682682 )
683683 ) . toBe ( false )
684684 } )
685+
686+ const numbered = ( index : number ) => ( {
687+ ...file ,
688+ id : `file-${ index } ` ,
689+ name : `page-${ index } .png` ,
690+ key : `execution/test-workspace/test-workflow/exec-1/page-${ index } .png` ,
691+ } )
692+
693+ it ( 'replays files every turn re-attached once each, where they first appear' , async ( ) => {
694+ mockGetProviderFromModel . mockReturnValue ( 'openai' )
695+ const hydrate = hydrateWithBytes ( )
696+ const attachments = Array . from ( { length : 7 } , ( _ , index ) => numbered ( index ) )
697+ queueTableRows ( schemaMock . memory , [
698+ {
699+ secretProvenanceVersion : null ,
700+ data : [ 1 , 2 , 3 ] . flatMap ( ( turn ) => [
701+ { role : 'user' , content : `Turn ${ turn } ` , files : attachments } ,
702+ { role : 'assistant' , content : `Answer ${ turn } ` } ,
703+ ] ) ,
704+ } ,
705+ ] )
706+ await handler . execute ( { ...mockContext , executionId : 'exec-4' } , mockBlock , {
707+ model : 'gpt-4o' ,
708+ memoryType : 'conversation' ,
709+ conversationId : 'conversation-1' ,
710+ messages : [ { role : 'user' , content : 'Turn 4' } ] ,
711+ files : attachments ,
712+ } )
713+ const sent = mockExecuteProviderRequest . mock . calls . at ( - 1 ) ?. [ 1 ] . messages as Message [ ]
714+ const userTurns = sent . filter ( ( message ) => message . role === 'user' )
715+ expect ( userTurns . map ( ( message ) => message . content ) ) . toEqual ( [
716+ 'Turn 1' ,
717+ 'Turn 2' ,
718+ 'Turn 3' ,
719+ 'Turn 4' ,
720+ ] )
721+ expect ( userTurns [ 0 ] . files ?. map ( ( attachment ) => attachment . key ) ) . toEqual (
722+ attachments . map ( ( attachment ) => attachment . key )
723+ )
724+ expect ( userTurns . slice ( 1 ) . map ( ( message ) => message . files ) ) . toEqual ( [
725+ undefined ,
726+ undefined ,
727+ undefined ,
728+ ] )
729+ expect ( hydrate . mock . calls . flatMap ( ( [ value ] ) => value as unknown [ ] ) ) . toHaveLength ( 7 )
730+ hydrate . mockRestore ( )
731+ } )
732+
733+ it ( 'still refuses a replay of more distinct files than the memory limit' , async ( ) => {
734+ mockGetProviderFromModel . mockReturnValue ( 'openai' )
735+ const hydrate = hydrateWithBytes ( )
736+ queueTableRows ( schemaMock . memory , [
737+ {
738+ secretProvenanceVersion : null ,
739+ data : [ 0 , 1 , 2 ] . map ( ( turn ) => ( {
740+ role : 'user' ,
741+ content : `Turn ${ turn } ` ,
742+ files : Array . from ( { length : 7 } , ( _ , index ) => numbered ( turn * 7 + index ) ) ,
743+ } ) ) ,
744+ } ,
745+ ] )
746+ await expect (
747+ handler . execute ( { ...mockContext , executionId : 'exec-4' } , mockBlock , {
748+ model : 'gpt-4o' ,
749+ memoryType : 'conversation' ,
750+ conversationId : 'conversation-1' ,
751+ messages : [ { role : 'user' , content : 'Turn 3' } ] ,
752+ } )
753+ ) . rejects . toThrow ( 'Conversation memory exceeds 20 file attachments' )
754+ expect ( mockExecuteProviderRequest ) . not . toHaveBeenCalled ( )
755+ hydrate . mockRestore ( )
756+ } )
757+
758+ it ( 'keeps a repeated file on a turn that has no text to stand on without it' , async ( ) => {
759+ mockGetProviderFromModel . mockReturnValue ( 'openai' )
760+ const hydrate = hydrateWithBytes ( )
761+ queueTableRows ( schemaMock . memory , [
762+ {
763+ secretProvenanceVersion : null ,
764+ data : [
765+ { role : 'user' , content : 'Read this' , files : [ file ] } ,
766+ { role : 'assistant' , content : 'Read it' } ,
767+ { role : 'user' , content : '' , files : [ file ] } ,
768+ { role : 'assistant' , content : 'Same file' } ,
769+ ] ,
770+ } ,
771+ ] )
772+ await handler . execute ( { ...mockContext , executionId : 'exec-3' } , mockBlock , {
773+ model : 'gpt-4o' ,
774+ memoryType : 'conversation' ,
775+ conversationId : 'conversation-1' ,
776+ messages : [ { role : 'user' , content : 'And now?' } ] ,
777+ } )
778+ const sent = mockExecuteProviderRequest . mock . calls . at ( - 1 ) ?. [ 1 ] . messages as Message [ ]
779+ const userTurns = sent . filter ( ( message ) => message . role === 'user' )
780+ expect ( userTurns . map ( ( message ) => message . files ?. length ) ) . toEqual ( [ 1 , 1 , undefined ] )
781+ hydrate . mockRestore ( )
782+ } )
783+
784+ it ( 'keeps a user turn’s file when only an earlier assistant turn carried it' , async ( ) => {
785+ mockGetProviderFromModel . mockReturnValue ( 'openai' )
786+ const hydrate = hydrateWithBytes ( )
787+ queueTableRows ( schemaMock . memory , [
788+ {
789+ secretProvenanceVersion : null ,
790+ data : [
791+ { role : 'assistant' , content : 'Here is a file' , files : [ file ] } ,
792+ { role : 'user' , content : 'Read it' , files : [ file ] } ,
793+ ] ,
794+ } ,
795+ ] )
796+ await handler . execute ( { ...mockContext , executionId : 'exec-3' } , mockBlock , {
797+ model : 'gpt-4o' ,
798+ memoryType : 'conversation' ,
799+ conversationId : 'conversation-1' ,
800+ messages : [ { role : 'user' , content : 'And now?' } ] ,
801+ } )
802+ const sent = mockExecuteProviderRequest . mock . calls . at ( - 1 ) ?. [ 1 ] . messages as Message [ ]
803+ expect ( sent . find ( ( message ) => message . content === 'Read it' ) ?. files ) . toHaveLength ( 1 )
804+ hydrate . mockRestore ( )
805+ } )
685806 } )
686807
687808 describe ( 'model fallback' , ( ) => {
0 commit comments