Skip to content

Commit 288aa08

Browse files
TheodoreSpeaksTheodore Li
andauthored
fix(copilot) Allow loop-in-loop workflow edits (#3723)
* Allow loop-in-loop workflow edits * Fix lint * Fix orphaned loop-in-loop if parent id not found --------- Co-authored-by: Theodore Li <theo@sim.ai>
1 parent 4c83959 commit 288aa08

File tree

2 files changed

+498
-289
lines changed

2 files changed

+498
-289
lines changed

apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.test.ts

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
137197
describe('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

Comments
 (0)