Skip to content

Commit 79bea15

Browse files
committed
fix(parallel): add numbered parallel and loop blocks for multiple instances of sub-nodes
1 parent 80a7bf5 commit 79bea15

File tree

8 files changed

+605
-52
lines changed

8 files changed

+605
-52
lines changed

apps/sim/app/w/[id]/workflow.tsx

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,11 @@ function WorkflowContent() {
245245
if (type === 'loop' || type === 'parallel') {
246246
// Create a unique ID and name for the container
247247
const id = crypto.randomUUID()
248-
const name = type === 'loop' ? 'Loop' : 'Parallel'
248+
249+
// Auto-number the blocks based on existing blocks of the same type
250+
const existingBlocksOfType = Object.values(blocks).filter((b) => b.type === type)
251+
const blockNumber = existingBlocksOfType.length + 1
252+
const name = type === 'loop' ? `Loop ${blockNumber}` : `Parallel ${blockNumber}`
249253

250254
// Calculate the center position of the viewport
251255
const centerPosition = project({
@@ -363,7 +367,11 @@ function WorkflowContent() {
363367
if (data.type === 'loop' || data.type === 'parallel') {
364368
// Create a unique ID and name for the container
365369
const id = crypto.randomUUID()
366-
const name = data.type === 'loop' ? 'Loop' : 'Parallel'
370+
371+
// Auto-number the blocks based on existing blocks of the same type
372+
const existingBlocksOfType = Object.values(blocks).filter((b) => b.type === data.type)
373+
const blockNumber = existingBlocksOfType.length + 1
374+
const name = data.type === 'loop' ? `Loop ${blockNumber}` : `Parallel ${blockNumber}`
367375

368376
// Check if we're dropping inside another container
369377
if (containerInfo) {
@@ -467,9 +475,9 @@ function WorkflowContent() {
467475
const id = crypto.randomUUID()
468476
const name =
469477
data.type === 'loop'
470-
? 'Loop'
478+
? `Loop ${Object.values(blocks).filter((b) => b.type === 'loop').length + 1}`
471479
: data.type === 'parallel'
472-
? 'Parallel'
480+
? `Parallel ${Object.values(blocks).filter((b) => b.type === 'parallel').length + 1}`
473481
: `${blockConfig!.name} ${Object.values(blocks).filter((b) => b.type === data.type).length + 1}`
474482

475483
if (containerInfo) {

apps/sim/executor/handlers/parallel/parallel-handler.test.ts

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,4 +277,215 @@ describe('ParallelBlockHandler', () => {
277277
// Should not have items when no distribution
278278
expect(context.loopItems.has('parallel-1_items')).toBe(false)
279279
})
280+
281+
describe('multiple downstream connections', () => {
282+
it('should make results available to all downstream blocks', async () => {
283+
const handler = new ParallelBlockHandler()
284+
const parallelBlock = createMockBlock('parallel-1')
285+
parallelBlock.config.params = {
286+
parallelType: 'collection',
287+
count: 3,
288+
}
289+
290+
const parallel: SerializedParallel = {
291+
id: 'parallel-1',
292+
nodes: ['agent-1'],
293+
distribution: ['item1', 'item2', 'item3'],
294+
}
295+
296+
const context = createMockContext(parallel)
297+
context.workflow!.connections = [
298+
{
299+
source: 'parallel-1',
300+
target: 'agent-1',
301+
sourceHandle: 'parallel-start-source',
302+
},
303+
{
304+
source: 'parallel-1',
305+
target: 'function-1',
306+
sourceHandle: 'parallel-end-source',
307+
},
308+
{
309+
source: 'parallel-1',
310+
target: 'parallel-2',
311+
sourceHandle: 'parallel-end-source',
312+
},
313+
]
314+
315+
// Initialize parallel
316+
const initResult = await handler.execute(parallelBlock, {}, context)
317+
expect((initResult as any).response.started).toBe(true)
318+
expect((initResult as any).response.parallelCount).toBe(3)
319+
320+
// Simulate all virtual blocks being executed
321+
const parallelState = context.parallelExecutions?.get('parallel-1')
322+
expect(parallelState).toBeDefined()
323+
324+
// Mark all virtual blocks as executed and store results
325+
for (let i = 0; i < 3; i++) {
326+
const virtualBlockId = `agent-1_parallel_parallel-1_iteration_${i}`
327+
context.executedBlocks.add(virtualBlockId)
328+
329+
// Store iteration results
330+
parallelState!.executionResults.set(`iteration_${i}`, {
331+
'agent-1': {
332+
response: {
333+
content: `Result from iteration ${i}`,
334+
model: 'test-model',
335+
},
336+
},
337+
})
338+
}
339+
340+
// Re-execute to aggregate results
341+
const aggregatedResult = await handler.execute(parallelBlock, {}, context)
342+
343+
// Verify results are aggregated
344+
expect((aggregatedResult as any).response.completed).toBe(true)
345+
expect((aggregatedResult as any).response.results).toHaveLength(3)
346+
347+
// Verify block state is stored
348+
const blockState = context.blockStates.get('parallel-1')
349+
expect(blockState).toBeDefined()
350+
expect(blockState?.output.response.results).toHaveLength(3)
351+
352+
// Verify both downstream blocks are activated
353+
expect(context.activeExecutionPath.has('function-1')).toBe(true)
354+
expect(context.activeExecutionPath.has('parallel-2')).toBe(true)
355+
356+
// Verify parallel is marked as completed
357+
expect(context.completedLoops.has('parallel-1')).toBe(true)
358+
359+
// Simulate downstream blocks trying to access results
360+
// This should work without errors
361+
const storedResults = context.blockStates.get('parallel-1')?.output.response.results
362+
expect(storedResults).toBeDefined()
363+
expect(storedResults).toHaveLength(3)
364+
})
365+
366+
it('should handle reference resolution when multiple parallel blocks exist', async () => {
367+
const handler = new ParallelBlockHandler()
368+
369+
// Create first parallel block
370+
const parallel1Block = createMockBlock('parallel-1')
371+
parallel1Block.config.params = {
372+
parallelType: 'collection',
373+
count: 2,
374+
}
375+
376+
// Create second parallel block (even if not connected)
377+
const parallel2Block = createMockBlock('parallel-2')
378+
parallel2Block.config.params = {
379+
parallelType: 'collection',
380+
collection: '<parallel.response.results>', // This references the first parallel
381+
}
382+
383+
// Set up context with both parallels
384+
const context: ExecutionContext = {
385+
workflowId: 'test-workflow',
386+
blockStates: new Map(),
387+
blockLogs: [],
388+
metadata: { duration: 0 },
389+
environmentVariables: {},
390+
decisions: { router: new Map(), condition: new Map() },
391+
loopIterations: new Map(),
392+
loopItems: new Map(),
393+
completedLoops: new Set(),
394+
executedBlocks: new Set(),
395+
activeExecutionPath: new Set(),
396+
workflow: {
397+
version: '1.0',
398+
blocks: [
399+
parallel1Block,
400+
parallel2Block,
401+
{
402+
id: 'agent-1',
403+
position: { x: 0, y: 0 },
404+
config: { tool: 'agent', params: {} },
405+
inputs: {},
406+
outputs: {},
407+
metadata: { id: 'agent', name: 'Agent 1' },
408+
enabled: true,
409+
},
410+
{
411+
id: 'function-1',
412+
position: { x: 0, y: 0 },
413+
config: {
414+
tool: 'function',
415+
params: {
416+
code: 'return <parallel.response.results>;',
417+
},
418+
},
419+
inputs: {},
420+
outputs: {},
421+
metadata: { id: 'function', name: 'Function 1' },
422+
enabled: true,
423+
},
424+
],
425+
connections: [
426+
{
427+
source: 'parallel-1',
428+
target: 'agent-1',
429+
sourceHandle: 'parallel-start-source',
430+
},
431+
{
432+
source: 'parallel-1',
433+
target: 'function-1',
434+
sourceHandle: 'parallel-end-source',
435+
},
436+
{
437+
source: 'parallel-1',
438+
target: 'parallel-2',
439+
sourceHandle: 'parallel-end-source',
440+
},
441+
],
442+
loops: {},
443+
parallels: {
444+
'parallel-1': {
445+
id: 'parallel-1',
446+
nodes: ['agent-1'],
447+
distribution: ['item1', 'item2'],
448+
},
449+
'parallel-2': {
450+
id: 'parallel-2',
451+
nodes: [],
452+
distribution: '<parallel.response.results>',
453+
},
454+
},
455+
},
456+
}
457+
458+
// Initialize first parallel
459+
await handler.execute(parallel1Block, {}, context)
460+
461+
// Simulate execution of agent blocks
462+
const parallelState = context.parallelExecutions?.get('parallel-1')
463+
for (let i = 0; i < 2; i++) {
464+
context.executedBlocks.add(`agent-1_parallel_parallel-1_iteration_${i}`)
465+
parallelState!.executionResults.set(`iteration_${i}`, {
466+
'agent-1': { response: { content: `Result ${i}` } },
467+
})
468+
}
469+
470+
// Re-execute first parallel to aggregate results
471+
const result = await handler.execute(parallel1Block, {}, context)
472+
expect((result as any).response.completed).toBe(true)
473+
474+
// Verify the block state is available
475+
const blockState = context.blockStates.get('parallel-1')
476+
expect(blockState).toBeDefined()
477+
expect(blockState?.output.response.results).toHaveLength(2)
478+
479+
// Now when function block tries to resolve <parallel.response.results>, it should work
480+
// even though parallel-2 exists on the canvas
481+
expect(() => {
482+
// This simulates what the resolver would do
483+
const state = context.blockStates.get('parallel-1')
484+
if (!state) throw new Error('No state found for block parallel-1')
485+
const results = state.output?.response?.results
486+
if (!results) throw new Error('No results found')
487+
return results
488+
}).not.toThrow()
489+
})
490+
})
280491
})

0 commit comments

Comments
 (0)