11import {
2+ dbChainMockFns ,
23 loggerMock ,
34 queueTableRows ,
45 resetDbChainMock ,
@@ -22,6 +23,7 @@ import * as userFileBase64 from '@/lib/uploads/utils/user-file-base64.server'
2223import { getAllBlocks } from '@/blocks'
2324import { AGENT , BlockType , isMcpTool } from '@/executor/constants'
2425import { AgentBlockHandler } from '@/executor/handlers/agent/agent-handler'
26+ import type { AgentInputs , Message } from '@/executor/handlers/agent/types'
2527import type { ExecutionContext , StreamingExecution } from '@/executor/types'
2628import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
2729import { executeProviderRequest } from '@/providers'
@@ -322,6 +324,170 @@ describe('AgentBlockHandler', () => {
322324 } )
323325 } )
324326
327+ describe ( 'conversation attachment replay' , ( ) => {
328+ beforeEach ( ( ) => {
329+ dbChainMockFns . returning . mockResolvedValue ( [ { id : 'memory-1' } ] )
330+ } )
331+
332+ afterEach ( ( ) => {
333+ vi . restoreAllMocks ( )
334+ } )
335+
336+ const file = {
337+ id : 'file-1' ,
338+ name : 'example.png' ,
339+ key : 'execution/test-workspace/test-workflow/exec-1/example.png' ,
340+ url : 'https://storage.example.com/expired' ,
341+ size : 8 ,
342+ type : 'image/png' ,
343+ context : 'execution' ,
344+ base64 : 'iVBORw0KGgo=' ,
345+ }
346+
347+ it . each ( [ 'files' , 'messages' , 'userPrompt' ] as const ) (
348+ 'replays a previous turn from %s with a fresh provider attachment' ,
349+ async ( source ) => {
350+ mockGetProviderFromModel . mockReturnValue ( 'openai' )
351+ const hydrate = vi
352+ . spyOn ( userFileBase64 , 'hydrateUserFilesWithBase64' )
353+ . mockImplementation ( async ( value ) => {
354+ const files = value as ( typeof file ) [ ]
355+ return files . map ( ( attachment ) => ( {
356+ ...attachment ,
357+ base64 : file . base64 ,
358+ } ) ) as typeof value
359+ } )
360+ const inputs : AgentInputs = {
361+ model : 'gpt-4o' ,
362+ memoryType : 'conversation' ,
363+ conversationId : 'conversation-1' ,
364+ ...( source === 'userPrompt'
365+ ? { userPrompt : 'Analyze this file' , files : [ file ] }
366+ : {
367+ messages : [
368+ {
369+ role : 'user' ,
370+ content : 'Analyze this file' ,
371+ ...( source === 'messages' ? { files : [ file ] } : { } ) ,
372+ } ,
373+ ] ,
374+ ...( source === 'files' ? { files : [ file ] } : { } ) ,
375+ } ) ,
376+ }
377+ const original = structuredClone ( inputs )
378+ await handler . execute ( { ...mockContext , executionId : 'exec-1' } , mockBlock , inputs )
379+ const stored = dbChainMockFns . values . mock . calls
380+ . map ( ( [ row ] ) => row )
381+ . find ( ( row ) => Array . isArray ( row . data ) && row . data [ 0 ] ?. role === 'user' ) ?. data as Message [ ]
382+ expect ( stored ) . toBeDefined ( )
383+ expect ( stored [ 0 ] . files ) . toEqual ( [
384+ {
385+ id : file . id ,
386+ name : file . name ,
387+ key : file . key ,
388+ url : '' ,
389+ size : file . size ,
390+ type : file . type ,
391+ context : file . context ,
392+ } ,
393+ ] )
394+ expect ( inputs ) . toEqual ( original )
395+
396+ queueTableRows ( schemaMock . memory , [
397+ { data : [ ...stored , { role : 'assistant' , content : 'First answer' } ] } ,
398+ ] )
399+ mockGetProviderFromModel . mockReturnValue ( 'anthropic' )
400+ const nextContext = { ...mockContext , executionId : 'exec-2' }
401+ await handler . execute ( nextContext , mockBlock , {
402+ model : 'claude-sonnet-4-5' ,
403+ memoryType : 'conversation' ,
404+ conversationId : 'conversation-1' ,
405+ messages : [ { role : 'user' , content : 'What is in that file?' } ] ,
406+ } )
407+ const request = mockExecuteProviderRequest . mock . calls . at ( - 1 ) ?. [ 1 ]
408+ expect ( request . messages [ 0 ] ) . toMatchObject ( {
409+ role : 'user' ,
410+ content : 'Analyze this file' ,
411+ files : [ { key : file . key , base64 : file . base64 } ] ,
412+ } )
413+ expect ( request . messages . at ( - 1 ) ) . toMatchObject ( {
414+ role : 'user' ,
415+ content : 'What is in that file?' ,
416+ } )
417+ expect ( request . messages . at ( - 1 ) . files ) . toBeUndefined ( )
418+ expect ( hydrate . mock . calls . at ( - 1 ) ?. [ 0 ] ) . toEqual ( stored [ 0 ] . files )
419+ expect ( hydrate . mock . calls . at ( - 1 ) ?. [ 1 ] ) . toMatchObject ( {
420+ executionId : 'exec-2' ,
421+ fileKeys : [ file . key ] ,
422+ } )
423+ hydrate . mockRestore ( )
424+ }
425+ )
426+
427+ it ( 'does not duplicate an attachment when the same execution revisits the agent' , async ( ) => {
428+ mockGetProviderFromModel . mockReturnValue ( 'openai' )
429+ queueTableRows ( schemaMock . memory , [
430+ {
431+ data : [
432+ { role : 'user' , content : 'Analyze this file' , executionId : 'exec-1' , files : [ file ] } ,
433+ ] ,
434+ } ,
435+ ] )
436+ await handler . execute ( { ...mockContext , executionId : 'exec-1' } , mockBlock , {
437+ model : 'gpt-4o' ,
438+ memoryType : 'conversation' ,
439+ conversationId : 'conversation-1' ,
440+ messages : [ { role : 'user' , content : 'Analyze this file' } ] ,
441+ files : [ file ] ,
442+ } )
443+ expect ( mockExecuteProviderRequest . mock . calls [ 0 ] [ 1 ] . messages [ 0 ] . files ) . toHaveLength ( 1 )
444+ expect ( dbChainMockFns . values . mock . calls . some ( ( [ row ] ) => row . data ?. [ 0 ] ?. role === 'user' ) ) . toBe (
445+ false
446+ )
447+ } )
448+
449+ it ( 'saves a new attachment appended to an existing conversation' , async ( ) => {
450+ mockGetProviderFromModel . mockReturnValue ( 'openai' )
451+ queueTableRows ( schemaMock . memory , [ { data : [ { role : 'assistant' , content : 'Hello' } ] } ] )
452+ await handler . execute ( { ...mockContext , executionId : 'exec-2' } , mockBlock , {
453+ model : 'gpt-4o' ,
454+ memoryType : 'conversation' ,
455+ conversationId : 'conversation-1' ,
456+ messages : [ { role : 'user' , content : 'Analyze this file' } ] ,
457+ files : [ file ] ,
458+ } )
459+ const stored = dbChainMockFns . values . mock . calls
460+ . map ( ( [ row ] ) => row )
461+ . find ( ( row ) => row . data ?. [ 0 ] ?. role === 'user' ) ?. data as Message [ ]
462+ expect ( stored [ 0 ] . files ?. [ 0 ] ) . toMatchObject ( { key : file . key , url : '' } )
463+ expect ( stored [ 0 ] . files ?. [ 0 ] . base64 ) . toBeUndefined ( )
464+ } )
465+
466+ it ( 'does not hydrate attachments excluded by the conversation window' , async ( ) => {
467+ mockGetProviderFromModel . mockReturnValue ( 'openai' )
468+ const hydrate = vi . spyOn ( userFileBase64 , 'hydrateUserFilesWithBase64' )
469+ queueTableRows ( schemaMock . memory , [
470+ {
471+ data : [
472+ { role : 'user' , content : 'Old file' , files : [ file ] } ,
473+ { role : 'assistant' , content : 'Recent answer' } ,
474+ ] ,
475+ } ,
476+ ] )
477+ const context = { ...mockContext , executionId : 'exec-2' }
478+ await handler . execute ( context , mockBlock , {
479+ model : 'gpt-4o' ,
480+ memoryType : 'sliding_window' ,
481+ slidingWindowSize : '1' ,
482+ conversationId : 'conversation-1' ,
483+ messages : [ { role : 'user' , content : 'Hello' } ] ,
484+ } )
485+ expect ( hydrate ) . not . toHaveBeenCalled ( )
486+ expect ( context . fileKeys ) . toBeUndefined ( )
487+ hydrate . mockRestore ( )
488+ } )
489+ } )
490+
325491 describe ( 'execute' , ( ) => {
326492 it ( 'should execute a basic agent block request' , async ( ) => {
327493 const inputs = {
0 commit comments