@@ -134,6 +134,66 @@ function makeLoopWorkflow() {
134134 }
135135}
136136
137+ function makeNestedLoopWorkflow ( ) {
138+ return {
139+ blocks : {
140+ 'outer-loop' : {
141+ id : 'outer-loop' ,
142+ type : 'loop' ,
143+ name : 'Outer Loop' ,
144+ position : { x : 0 , y : 0 } ,
145+ enabled : true ,
146+ subBlocks : { } ,
147+ outputs : { } ,
148+ data : { loopType : 'for' , count : 2 } ,
149+ } ,
150+ 'inner-loop' : {
151+ id : 'inner-loop' ,
152+ type : 'loop' ,
153+ name : 'Inner Loop' ,
154+ position : { x : 120 , y : 80 } ,
155+ enabled : true ,
156+ subBlocks : { } ,
157+ outputs : { } ,
158+ data : { parentId : 'outer-loop' , extent : 'parent' , loopType : 'for' , count : 3 } ,
159+ } ,
160+ 'inner-agent' : {
161+ id : 'inner-agent' ,
162+ type : 'agent' ,
163+ name : 'Inner Agent' ,
164+ position : { x : 240 , y : 120 } ,
165+ enabled : true ,
166+ subBlocks : {
167+ systemPrompt : { id : 'systemPrompt' , type : 'long-input' , value : 'Original prompt' } ,
168+ model : { id : 'model' , type : 'combobox' , value : 'gpt-4o' } ,
169+ } ,
170+ outputs : { } ,
171+ data : { parentId : 'inner-loop' , extent : 'parent' } ,
172+ } ,
173+ } ,
174+ edges : [
175+ {
176+ id : 'edge-outer-inner' ,
177+ source : 'outer-loop' ,
178+ sourceHandle : 'loop-start-source' ,
179+ target : 'inner-loop' ,
180+ targetHandle : 'target' ,
181+ type : 'default' ,
182+ } ,
183+ {
184+ id : 'edge-inner-agent' ,
185+ source : 'inner-loop' ,
186+ sourceHandle : 'loop-start-source' ,
187+ target : 'inner-agent' ,
188+ targetHandle : 'target' ,
189+ type : 'default' ,
190+ } ,
191+ ] ,
192+ loops : { } ,
193+ parallels : { } ,
194+ }
195+ }
196+
137197describe ( 'handleEditOperation nestedNodes merge' , ( ) => {
138198 it ( 'preserves existing child block IDs when editing a loop with nestedNodes' , ( ) => {
139199 const workflow = makeLoopWorkflow ( )
@@ -261,4 +321,129 @@ describe('handleEditOperation nestedNodes merge', () => {
261321 expect ( agent ) . toBeDefined ( )
262322 expect ( agent . subBlocks . systemPrompt . value ) . toBe ( 'New prompt' )
263323 } )
324+
325+ it ( 'recursively updates an existing nested loop and preserves grandchild IDs' , ( ) => {
326+ const workflow = makeNestedLoopWorkflow ( )
327+
328+ const { state } = applyOperationsToWorkflowState ( workflow , [
329+ {
330+ operation_type : 'edit' ,
331+ block_id : 'outer-loop' ,
332+ params : {
333+ nestedNodes : {
334+ 'new-inner-loop' : {
335+ type : 'loop' ,
336+ name : 'Inner Loop' ,
337+ inputs : {
338+ loopType : 'forEach' ,
339+ collection : '<start.input.items>' ,
340+ } ,
341+ nestedNodes : {
342+ 'new-inner-agent' : {
343+ type : 'agent' ,
344+ name : 'Inner Agent' ,
345+ inputs : { systemPrompt : 'Updated prompt' } ,
346+ } ,
347+ 'new-helper' : {
348+ type : 'function' ,
349+ name : 'Helper' ,
350+ inputs : { code : 'return 1' } ,
351+ } ,
352+ } ,
353+ } ,
354+ } ,
355+ } ,
356+ } ,
357+ ] )
358+
359+ expect ( state . blocks [ 'inner-loop' ] ) . toBeDefined ( )
360+ expect ( state . blocks [ 'new-inner-loop' ] ) . toBeUndefined ( )
361+ expect ( state . blocks [ 'inner-loop' ] . data . loopType ) . toBe ( 'forEach' )
362+ expect ( state . blocks [ 'inner-loop' ] . data . collection ) . toBe ( '<start.input.items>' )
363+
364+ expect ( state . blocks [ 'inner-agent' ] ) . toBeDefined ( )
365+ expect ( state . blocks [ 'new-inner-agent' ] ) . toBeUndefined ( )
366+ expect ( state . blocks [ 'inner-agent' ] . subBlocks . systemPrompt . value ) . toBe ( 'Updated prompt' )
367+
368+ const helperBlock = Object . values ( state . blocks ) . find ( ( block : any ) => block . name === 'Helper' ) as
369+ | any
370+ | undefined
371+ expect ( helperBlock ) . toBeDefined ( )
372+ expect ( helperBlock ?. data ?. parentId ) . toBe ( 'inner-loop' )
373+ } )
374+
375+ it ( 'removes grandchildren omitted from an existing nested loop update' , ( ) => {
376+ const workflow = makeNestedLoopWorkflow ( )
377+
378+ const { state } = applyOperationsToWorkflowState ( workflow , [
379+ {
380+ operation_type : 'edit' ,
381+ block_id : 'outer-loop' ,
382+ params : {
383+ nestedNodes : {
384+ 'new-inner-loop' : {
385+ type : 'loop' ,
386+ name : 'Inner Loop' ,
387+ nestedNodes : {
388+ 'new-helper' : {
389+ type : 'function' ,
390+ name : 'Helper' ,
391+ inputs : { code : 'return 1' } ,
392+ } ,
393+ } ,
394+ } ,
395+ } ,
396+ } ,
397+ } ,
398+ ] )
399+
400+ expect ( state . blocks [ 'inner-loop' ] ) . toBeDefined ( )
401+ expect ( state . blocks [ 'inner-agent' ] ) . toBeUndefined ( )
402+ expect (
403+ state . edges . some (
404+ ( edge : any ) => edge . source === 'inner-agent' || edge . target === 'inner-agent'
405+ )
406+ ) . toBe ( false )
407+
408+ const helperBlock = Object . values ( state . blocks ) . find ( ( block : any ) => block . name === 'Helper' )
409+ expect ( helperBlock ) . toBeDefined ( )
410+ } )
411+
412+ it ( 'removes an unmatched nested container with all descendants and edges' , ( ) => {
413+ const workflow = makeNestedLoopWorkflow ( )
414+
415+ const { state } = applyOperationsToWorkflowState ( workflow , [
416+ {
417+ operation_type : 'edit' ,
418+ block_id : 'outer-loop' ,
419+ params : {
420+ nestedNodes : {
421+ replacement : {
422+ type : 'function' ,
423+ name : 'Replacement' ,
424+ inputs : { code : 'return 2' } ,
425+ } ,
426+ } ,
427+ } ,
428+ } ,
429+ ] )
430+
431+ expect ( state . blocks [ 'inner-loop' ] ) . toBeUndefined ( )
432+ expect ( state . blocks [ 'inner-agent' ] ) . toBeUndefined ( )
433+ expect (
434+ state . edges . some (
435+ ( edge : any ) =>
436+ edge . source === 'inner-loop' ||
437+ edge . target === 'inner-loop' ||
438+ edge . source === 'inner-agent' ||
439+ edge . target === 'inner-agent'
440+ )
441+ ) . toBe ( false )
442+
443+ const replacementBlock = Object . values ( state . blocks ) . find (
444+ ( block : any ) => block . name === 'Replacement'
445+ ) as any
446+ expect ( replacementBlock ) . toBeDefined ( )
447+ expect ( replacementBlock . data ?. parentId ) . toBe ( 'outer-loop' )
448+ } )
264449} )
0 commit comments