From 9f879a4bf94462b7d64b6b697816afc50bc5ebd7 Mon Sep 17 00:00:00 2001 From: Nickolas Oliveira Date: Thu, 18 Jun 2026 16:04:18 -0300 Subject: [PATCH] fix(evo-flow): route split node to the selected variant edge (EVO-1828) The Split (A/B) node computed the target handle but dropped it: its `.then()` called createSuccessResult without nextNodeHandle, so the executor fell back to the first edge and every contact took variant A regardless of percentages. Carry nextNodeHandle in the result (mirroring ConditionalNode) and declare it on NodeExecutionResult. Adds unit specs for the handle + per-contact selection. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../temporal/activities/nodes/base.node.ts | 1 + .../activities/nodes/split.node.spec.ts | 55 +++++++++++++++++++ .../temporal/activities/nodes/split.node.ts | 16 ++++-- 3 files changed, 67 insertions(+), 5 deletions(-) create mode 100644 src/modules/temporal/activities/nodes/split.node.spec.ts diff --git a/src/modules/temporal/activities/nodes/base.node.ts b/src/modules/temporal/activities/nodes/base.node.ts index 0359f6b..eb8abc7 100644 --- a/src/modules/temporal/activities/nodes/base.node.ts +++ b/src/modules/temporal/activities/nodes/base.node.ts @@ -8,6 +8,7 @@ import { export interface NodeExecutionResult { success: boolean; nextNodeId?: string; + nextNodeHandle?: string; // Branch routing: the edge.sourceHandle the executor should follow (conditional/split nodes) error?: string; variables?: Record; executionTime?: number; diff --git a/src/modules/temporal/activities/nodes/split.node.spec.ts b/src/modules/temporal/activities/nodes/split.node.spec.ts new file mode 100644 index 0000000..d8223bd --- /dev/null +++ b/src/modules/temporal/activities/nodes/split.node.spec.ts @@ -0,0 +1,55 @@ +import { SplitNode } from './split.node'; + +describe('SplitNode (EVO-1828)', () => { + let node: SplitNode; + + const make = ( + variants?: Array<{ id: string; name: string; percentage: number }>, + contactId = 'contact-1', + ) => ({ + nodeId: 'split-1', + contactId, + sessionId: 'session-1', + nodeData: { variants }, + }); + + beforeEach(() => { + node = new SplitNode(); + const logger = (node as any).logger; + for (const m of ['log', 'debug', 'warn', 'error']) { + jest.spyOn(logger, m).mockImplementation(() => undefined); + } + }); + + it('returns a nextNodeHandle pointing at the selected variant (was dropped before the fix)', async () => { + const res = await node.execute(make()); + expect(res.success).toBe(true); + expect(res.nextNodeHandle).toMatch(/^split-variant-(variant-a|variant-b)$/); + }); + + it('routes every contact to variant A when A is 100%', async () => { + const variants = [ + { id: 'variant-a', name: 'A', percentage: 100 }, + { id: 'variant-b', name: 'B', percentage: 0 }, + ]; + for (const c of ['c1', 'c2', 'whatever']) { + const res = await node.execute(make(variants, c)); + expect(res.nextNodeHandle).toBe('split-variant-variant-a'); + } + }); + + it('routes every contact to variant B when B is 100%', async () => { + const variants = [ + { id: 'variant-a', name: 'A', percentage: 0 }, + { id: 'variant-b', name: 'B', percentage: 100 }, + ]; + const res = await node.execute(make(variants, 'c1')); + expect(res.nextNodeHandle).toBe('split-variant-variant-b'); + }); + + it('selects deterministically per contactId', async () => { + const a = await node.execute(make(undefined, 'same-contact')); + const b = await node.execute(make(undefined, 'same-contact')); + expect(a.nextNodeHandle).toBe(b.nextNodeHandle); + }); +}); diff --git a/src/modules/temporal/activities/nodes/split.node.ts b/src/modules/temporal/activities/nodes/split.node.ts index 585fbec..67d162e 100644 --- a/src/modules/temporal/activities/nodes/split.node.ts +++ b/src/modules/temporal/activities/nodes/split.node.ts @@ -79,11 +79,17 @@ export class SplitNode extends BaseNode { }; }) .then(({ result, executionTime }) => { - return this.createSuccessResult(input, executionTime, { - [`node_${input.nodeId}_selected_variant`]: result.selectedVariant, - [`node_${input.nodeId}_variant_id`]: result.variantId, - [`node_${input.nodeId}_random_value`]: result.random, - }); + // EVO-1828: carry the routing decision so the executor branches to the + // selected variant's edge (sourceHandle = split-variant-) instead of + // always falling back to the first edge. + return { + ...this.createSuccessResult(input, executionTime, { + [`node_${input.nodeId}_selected_variant`]: result.selectedVariant, + [`node_${input.nodeId}_variant_id`]: result.variantId, + [`node_${input.nodeId}_random_value`]: result.random, + }), + nextNodeHandle: result.nextNodeHandle, + }; }) .catch((error) => { const executionTime = Date.now();