@hexabot-ai/agentic 3.0.1-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. package/LICENSE.md +120 -0
  2. package/README.md +231 -0
  3. package/dist/cjs/action/abstract-action.js +141 -0
  4. package/dist/cjs/action/action.js +47 -0
  5. package/dist/cjs/action/action.types.js +7 -0
  6. package/dist/cjs/bindings/base-binding.js +278 -0
  7. package/dist/cjs/context.js +79 -0
  8. package/dist/cjs/dsl.types.js +318 -0
  9. package/dist/cjs/index.js +52 -0
  10. package/dist/cjs/runner-runtime-control.js +343 -0
  11. package/dist/cjs/step-executors/conditional-executor.js +63 -0
  12. package/dist/cjs/step-executors/loop-executor.js +187 -0
  13. package/dist/cjs/step-executors/parallel-executor.js +51 -0
  14. package/dist/cjs/step-executors/skip-helpers.js +29 -0
  15. package/dist/cjs/step-executors/task-executor.js +155 -0
  16. package/dist/cjs/step-executors/types.js +7 -0
  17. package/dist/cjs/suspension-rebuilder.js +301 -0
  18. package/dist/cjs/utils/deferred.js +17 -0
  19. package/dist/cjs/utils/naming.js +48 -0
  20. package/dist/cjs/utils/timeout.js +42 -0
  21. package/dist/cjs/utils/workflow-definition.js +102 -0
  22. package/dist/cjs/workflow-compiler.js +212 -0
  23. package/dist/cjs/workflow-event-emitter.js +40 -0
  24. package/dist/cjs/workflow-runner.js +448 -0
  25. package/dist/cjs/workflow-types.js +7 -0
  26. package/dist/cjs/workflow-values.js +99 -0
  27. package/dist/cjs/workflow.js +243 -0
  28. package/dist/esm/action/abstract-action.js +137 -0
  29. package/dist/esm/action/action.js +44 -0
  30. package/dist/esm/action/action.types.js +6 -0
  31. package/dist/esm/bindings/base-binding.js +273 -0
  32. package/dist/esm/context.js +75 -0
  33. package/dist/esm/dsl.types.js +309 -0
  34. package/dist/esm/index.js +16 -0
  35. package/dist/esm/runner-runtime-control.js +338 -0
  36. package/dist/esm/step-executors/conditional-executor.js +60 -0
  37. package/dist/esm/step-executors/loop-executor.js +182 -0
  38. package/dist/esm/step-executors/parallel-executor.js +48 -0
  39. package/dist/esm/step-executors/skip-helpers.js +25 -0
  40. package/dist/esm/step-executors/task-executor.js +152 -0
  41. package/dist/esm/step-executors/types.js +6 -0
  42. package/dist/esm/suspension-rebuilder.js +296 -0
  43. package/dist/esm/utils/deferred.js +14 -0
  44. package/dist/esm/utils/naming.js +42 -0
  45. package/dist/esm/utils/timeout.js +37 -0
  46. package/dist/esm/utils/workflow-definition.js +98 -0
  47. package/dist/esm/workflow-compiler.js +208 -0
  48. package/dist/esm/workflow-event-emitter.js +36 -0
  49. package/dist/esm/workflow-runner.js +444 -0
  50. package/dist/esm/workflow-types.js +6 -0
  51. package/dist/esm/workflow-values.js +89 -0
  52. package/dist/esm/workflow.js +236 -0
  53. package/dist/types/action/abstract-action.d.ts +54 -0
  54. package/dist/types/action/abstract-action.d.ts.map +1 -0
  55. package/dist/types/action/action.d.ts +23 -0
  56. package/dist/types/action/action.d.ts.map +1 -0
  57. package/dist/types/action/action.types.d.ts +55 -0
  58. package/dist/types/action/action.types.d.ts.map +1 -0
  59. package/dist/types/bindings/base-binding.d.ts +52 -0
  60. package/dist/types/bindings/base-binding.d.ts.map +1 -0
  61. package/dist/types/context.d.ts +105 -0
  62. package/dist/types/context.d.ts.map +1 -0
  63. package/dist/types/dsl.types.d.ts +220 -0
  64. package/dist/types/dsl.types.d.ts.map +1 -0
  65. package/dist/types/index.d.ts +18 -0
  66. package/dist/types/index.d.ts.map +1 -0
  67. package/dist/types/runner-runtime-control.d.ts +64 -0
  68. package/dist/types/runner-runtime-control.d.ts.map +1 -0
  69. package/dist/types/step-executors/conditional-executor.d.ts +13 -0
  70. package/dist/types/step-executors/conditional-executor.d.ts.map +1 -0
  71. package/dist/types/step-executors/loop-executor.d.ts +33 -0
  72. package/dist/types/step-executors/loop-executor.d.ts.map +1 -0
  73. package/dist/types/step-executors/parallel-executor.d.ts +14 -0
  74. package/dist/types/step-executors/parallel-executor.d.ts.map +1 -0
  75. package/dist/types/step-executors/skip-helpers.d.ts +12 -0
  76. package/dist/types/step-executors/skip-helpers.d.ts.map +1 -0
  77. package/dist/types/step-executors/task-executor.d.ts +13 -0
  78. package/dist/types/step-executors/task-executor.d.ts.map +1 -0
  79. package/dist/types/step-executors/types.d.ts +24 -0
  80. package/dist/types/step-executors/types.d.ts.map +1 -0
  81. package/dist/types/suspension-rebuilder.d.ts +67 -0
  82. package/dist/types/suspension-rebuilder.d.ts.map +1 -0
  83. package/dist/types/utils/deferred.d.ts +7 -0
  84. package/dist/types/utils/deferred.d.ts.map +1 -0
  85. package/dist/types/utils/naming.d.ts +24 -0
  86. package/dist/types/utils/naming.d.ts.map +1 -0
  87. package/dist/types/utils/timeout.d.ts +17 -0
  88. package/dist/types/utils/timeout.d.ts.map +1 -0
  89. package/dist/types/utils/workflow-definition.d.ts +3 -0
  90. package/dist/types/utils/workflow-definition.d.ts.map +1 -0
  91. package/dist/types/workflow-compiler.d.ts +12 -0
  92. package/dist/types/workflow-compiler.d.ts.map +1 -0
  93. package/dist/types/workflow-event-emitter.d.ts +70 -0
  94. package/dist/types/workflow-event-emitter.d.ts.map +1 -0
  95. package/dist/types/workflow-runner.d.ts +179 -0
  96. package/dist/types/workflow-runner.d.ts.map +1 -0
  97. package/dist/types/workflow-types.d.ts +195 -0
  98. package/dist/types/workflow-types.d.ts.map +1 -0
  99. package/dist/types/workflow-values.d.ts +34 -0
  100. package/dist/types/workflow-values.d.ts.map +1 -0
  101. package/dist/types/workflow.d.ts +75 -0
  102. package/dist/types/workflow.d.ts.map +1 -0
  103. package/examples/defs-bindings-agent/actions/ai-agent.ts +110 -0
  104. package/examples/defs-bindings-agent/actions/caculate-score.ts +49 -0
  105. package/examples/defs-bindings-agent/actions/index.ts +15 -0
  106. package/examples/defs-bindings-agent/bindings.ts +20 -0
  107. package/examples/defs-bindings-agent/context.ts +32 -0
  108. package/examples/defs-bindings-agent/workflow.ts +67 -0
  109. package/examples/defs-bindings-agent/workflow.yml +28 -0
  110. package/examples/full/actions/await-user-input.ts +46 -0
  111. package/examples/full/actions/call-llm.ts +133 -0
  112. package/examples/full/actions/create-ticket.ts +46 -0
  113. package/examples/full/actions/decision-router.ts +83 -0
  114. package/examples/full/actions/get-calendar-events.ts +55 -0
  115. package/examples/full/actions/get-user-profile.ts +72 -0
  116. package/examples/full/actions/index.ts +33 -0
  117. package/examples/full/actions/query-memory.ts +45 -0
  118. package/examples/full/actions/search-web.ts +53 -0
  119. package/examples/full/actions/send-email.ts +50 -0
  120. package/examples/full/context.ts +41 -0
  121. package/examples/full/workflow.ts +75 -0
  122. package/examples/full/workflow.yml +359 -0
  123. package/examples/loop/actions/await-reply.ts +47 -0
  124. package/examples/loop/actions/index.ts +19 -0
  125. package/examples/loop/actions/send-text-message.ts +40 -0
  126. package/examples/loop/context.ts +32 -0
  127. package/examples/loop/workflow.ts +66 -0
  128. package/examples/loop/workflow.yml +66 -0
  129. package/examples/suspend-resume/actions/format-reply.ts +43 -0
  130. package/examples/suspend-resume/actions/index.ts +13 -0
  131. package/examples/suspend-resume/actions/wait-for-user.ts +51 -0
  132. package/examples/suspend-resume/context.ts +32 -0
  133. package/examples/suspend-resume/workflow.ts +79 -0
  134. package/examples/suspend-resume/workflow.yml +29 -0
  135. package/package.json +60 -0
  136. package/src/__tests__/parser.test.ts +95 -0
  137. package/src/__tests__/suspension-rebuilder.test.ts +361 -0
  138. package/src/__tests__/test-helpers.ts +36 -0
  139. package/src/__tests__/validation.test.ts +526 -0
  140. package/src/__tests__/workflow-compiler.test.ts +715 -0
  141. package/src/__tests__/workflow-definition-path.test.ts +381 -0
  142. package/src/__tests__/workflow-event-emitter.test.ts +50 -0
  143. package/src/__tests__/workflow-runner.test.ts +1397 -0
  144. package/src/__tests__/workflow-values.test.ts +132 -0
  145. package/src/__tests__/workflow.test.ts +320 -0
  146. package/src/action/__tests__/abstract-action-timing.test.ts +183 -0
  147. package/src/action/__tests__/action.test.ts +473 -0
  148. package/src/action/abstract-action.ts +215 -0
  149. package/src/action/action.ts +83 -0
  150. package/src/action/action.types.ts +93 -0
  151. package/src/bindings/base-binding.ts +508 -0
  152. package/src/context.ts +179 -0
  153. package/src/dsl.types.ts +473 -0
  154. package/src/index.ts +114 -0
  155. package/src/runner-runtime-control.ts +544 -0
  156. package/src/step-executors/conditional-executor.test.ts +192 -0
  157. package/src/step-executors/conditional-executor.ts +83 -0
  158. package/src/step-executors/loop-executor.test.ts +303 -0
  159. package/src/step-executors/loop-executor.ts +299 -0
  160. package/src/step-executors/parallel-executor.test.ts +182 -0
  161. package/src/step-executors/parallel-executor.ts +77 -0
  162. package/src/step-executors/skip-helpers.ts +68 -0
  163. package/src/step-executors/task-executor.test.ts +257 -0
  164. package/src/step-executors/task-executor.ts +248 -0
  165. package/src/step-executors/types.ts +70 -0
  166. package/src/suspension-rebuilder.ts +504 -0
  167. package/src/utils/deferred.ts +23 -0
  168. package/src/utils/naming.ts +55 -0
  169. package/src/utils/timeout.ts +48 -0
  170. package/src/utils/workflow-definition.ts +173 -0
  171. package/src/workflow-compiler.ts +317 -0
  172. package/src/workflow-event-emitter.ts +86 -0
  173. package/src/workflow-runner.ts +603 -0
  174. package/src/workflow-types.ts +207 -0
  175. package/src/workflow-values.ts +159 -0
  176. package/src/workflow.ts +387 -0
@@ -0,0 +1,192 @@
1
+ /*
2
+ * Hexabot — Fair Core License (FCL-1.0-ALv2)
3
+ * Copyright (c) 2025 Hexastack.
4
+ * Full terms: see LICENSE.md.
5
+ */
6
+
7
+ import { BaseWorkflowContext } from '../context';
8
+ import type { RuntimeSuspensionRequest } from '../runner-runtime-control';
9
+ import {
10
+ StepType,
11
+ type EventEmitterLike,
12
+ type StepInfo,
13
+ } from '../workflow-event-emitter';
14
+ import type {
15
+ CompiledStep,
16
+ ConditionalStep,
17
+ ExecutionState,
18
+ Suspension,
19
+ } from '../workflow-types';
20
+
21
+ import { executeConditional } from './conditional-executor';
22
+ import type { StepExecutorEnv } from './types';
23
+
24
+ class TestContext extends BaseWorkflowContext {
25
+ public eventEmitter: EventEmitterLike = { emit: jest.fn(), on: jest.fn() };
26
+
27
+ constructor() {
28
+ super({});
29
+ }
30
+ }
31
+
32
+ const createState = (): ExecutionState => ({
33
+ input: {},
34
+ output: {},
35
+ iterationStack: [],
36
+ });
37
+ const createTaskStep = (id: string): CompiledStep => ({
38
+ id,
39
+ type: StepType.Task,
40
+ label: id,
41
+ taskName: `task_${id}`,
42
+ });
43
+ const createEnv = (): StepExecutorEnv => {
44
+ const compiled = {
45
+ definition: {} as any,
46
+ tasks: {},
47
+ flow: [],
48
+ outputMapping: {},
49
+ inputParser: { parse: (value: unknown) => value } as any,
50
+ } as any;
51
+
52
+ return {
53
+ compiled,
54
+ context: new TestContext(),
55
+ runId: 'run-1',
56
+ buildInstanceStepInfo: jest.fn(),
57
+ markSnapshot: jest.fn(),
58
+ recordStepExecution: jest.fn(),
59
+ emit: jest.fn(),
60
+ setCurrentStep: jest.fn(),
61
+ waitForStepSuspension: jest
62
+ .fn()
63
+ .mockImplementation(
64
+ () => new Promise<RuntimeSuspensionRequest>(() => undefined),
65
+ ),
66
+ clearStepSuspensions: jest.fn(),
67
+ primeStepResumeData: jest.fn(),
68
+ captureTaskOutput: jest.fn(),
69
+ executeFlow: jest.fn(),
70
+ executeStep: jest.fn(),
71
+ };
72
+ };
73
+
74
+ describe('executeConditional', () => {
75
+ it('executes the first matching branch', async () => {
76
+ const env = createEnv();
77
+ const state = createState();
78
+ const step: ConditionalStep = {
79
+ id: 'conditional',
80
+ type: StepType.Conditional,
81
+ label: 'conditional',
82
+ branches: [
83
+ {
84
+ id: 'branch-0',
85
+ condition: { kind: 'literal', value: false },
86
+ steps: [createTaskStep('a')],
87
+ },
88
+ {
89
+ id: 'branch-1',
90
+ condition: { kind: 'literal', value: true },
91
+ steps: [createTaskStep('b')],
92
+ },
93
+ {
94
+ id: 'branch-2',
95
+ condition: { kind: 'literal', value: true },
96
+ steps: [createTaskStep('c')],
97
+ },
98
+ ],
99
+ };
100
+ env.executeFlow = jest.fn().mockResolvedValue(undefined);
101
+
102
+ const result = await executeConditional(env, step, state, [0]);
103
+
104
+ expect(result).toBeUndefined();
105
+ expect(env.executeFlow).toHaveBeenCalledTimes(1);
106
+ expect(env.executeFlow).toHaveBeenCalledWith(
107
+ step.branches[1].steps,
108
+ state,
109
+ [0, 'branch', 1],
110
+ );
111
+ });
112
+
113
+ it('wraps and resumes from a suspended branch', async () => {
114
+ const env = createEnv();
115
+ const state = createState();
116
+ const innerSuspension: Suspension = {
117
+ step: { id: 'inner', name: 'inner', type: StepType.Task } as StepInfo,
118
+ reason: 'pause',
119
+ continue: jest.fn().mockResolvedValue(undefined),
120
+ };
121
+ env.executeFlow = jest.fn().mockResolvedValue(innerSuspension);
122
+
123
+ const step: ConditionalStep = {
124
+ id: 'conditional',
125
+ type: StepType.Conditional,
126
+ label: 'conditional',
127
+ branches: [{ id: 'branch-0', steps: [createTaskStep('a')] }],
128
+ };
129
+ const suspension = await executeConditional(env, step, state, []);
130
+ expect(suspension).toEqual(
131
+ expect.objectContaining({ step: innerSuspension.step, reason: 'pause' }),
132
+ );
133
+ expect(env.executeFlow).toHaveBeenCalledTimes(1);
134
+
135
+ await suspension?.continue('resume-data');
136
+ expect(innerSuspension.continue).toHaveBeenCalledWith('resume-data');
137
+ expect(env.executeFlow).toHaveBeenCalledTimes(1);
138
+ });
139
+
140
+ it('forwards nested suspensions from branch continuation', async () => {
141
+ const env = createEnv();
142
+ const state = createState();
143
+ const nextSuspension: Suspension = {
144
+ step: { id: 'next', name: 'next', type: StepType.Task } as StepInfo,
145
+ continue: jest.fn(),
146
+ };
147
+ const innerSuspension: Suspension = {
148
+ step: { id: 'inner', name: 'inner', type: StepType.Task } as StepInfo,
149
+ continue: jest.fn().mockResolvedValue(nextSuspension),
150
+ };
151
+ env.executeFlow = jest.fn().mockResolvedValue(innerSuspension);
152
+
153
+ const step: ConditionalStep = {
154
+ id: 'conditional',
155
+ type: StepType.Conditional,
156
+ label: 'conditional',
157
+ branches: [{ id: 'branch-0', steps: [createTaskStep('a')] }],
158
+ };
159
+ const suspension = await executeConditional(env, step, state, []);
160
+ const result = await suspension?.continue('resume-data');
161
+
162
+ expect(result).toBe(nextSuspension);
163
+ expect(innerSuspension.continue).toHaveBeenCalledWith('resume-data');
164
+ expect(env.executeFlow).toHaveBeenCalledTimes(1);
165
+ });
166
+
167
+ it('returns undefined when no branch matches', async () => {
168
+ const env = createEnv();
169
+ const state = createState();
170
+ const step: ConditionalStep = {
171
+ id: 'conditional',
172
+ type: StepType.Conditional,
173
+ label: 'conditional',
174
+ branches: [
175
+ {
176
+ id: 'branch-0',
177
+ condition: { kind: 'literal', value: false },
178
+ steps: [createTaskStep('a')],
179
+ },
180
+ {
181
+ id: 'branch-1',
182
+ condition: { kind: 'literal', value: false },
183
+ steps: [createTaskStep('b')],
184
+ },
185
+ ],
186
+ };
187
+ const result = await executeConditional(env, step, state, []);
188
+
189
+ expect(result).toBeUndefined();
190
+ expect(env.executeFlow).not.toHaveBeenCalled();
191
+ });
192
+ });
@@ -0,0 +1,83 @@
1
+ /*
2
+ * Hexabot — Fair Core License (FCL-1.0-ALv2)
3
+ * Copyright (c) 2025 Hexastack.
4
+ * Full terms: see LICENSE.md.
5
+ */
6
+
7
+ import type {
8
+ ConditionalStep,
9
+ ExecutionState,
10
+ Suspension,
11
+ } from '../workflow-types';
12
+ import { evaluateValue } from '../workflow-values';
13
+
14
+ import { markStepsSkipped } from './skip-helpers';
15
+ import type { StepExecutorEnv } from './types';
16
+
17
+ /**
18
+ * Evaluate a conditional step by checking branches in order and executing the first match.
19
+ *
20
+ * @param env Executor environment providing helpers and workflow context.
21
+ * @param step The conditional step definition.
22
+ * @param state Mutable workflow execution state.
23
+ * @param path Path tokens locating this step within the workflow tree.
24
+ * @returns A suspension if a branch pauses execution, otherwise void.
25
+ */
26
+ export async function executeConditional(
27
+ env: StepExecutorEnv,
28
+ step: ConditionalStep,
29
+ state: ExecutionState,
30
+ path: Array<number | string>,
31
+ ): Promise<Suspension | void> {
32
+ for (let index = 0; index < step.branches.length; index += 1) {
33
+ const branch = step.branches[index];
34
+ const scope = {
35
+ input: state.input,
36
+ context: env.context.state,
37
+ output: state.output,
38
+ iteration: state.iteration,
39
+ accumulator: state.accumulator,
40
+ };
41
+ const conditionResult =
42
+ branch.condition !== undefined
43
+ ? await evaluateValue(branch.condition, scope)
44
+ : true;
45
+
46
+ if (conditionResult) {
47
+ step.branches.forEach((candidate, candidateIndex) => {
48
+ if (candidateIndex !== index) {
49
+ markStepsSkipped(env, candidate.steps, state.iterationStack);
50
+ }
51
+ });
52
+ const suspension = await env.executeFlow(branch.steps, state, [
53
+ ...path,
54
+ 'branch',
55
+ index,
56
+ ]);
57
+
58
+ if (suspension) {
59
+ return {
60
+ ...suspension,
61
+ continue: async (resumeData: unknown) => {
62
+ const next = await suspension.continue(resumeData);
63
+ if (next) {
64
+ return next;
65
+ }
66
+
67
+ return undefined;
68
+ },
69
+ };
70
+ }
71
+
72
+ return undefined;
73
+ }
74
+ }
75
+
76
+ if (step.branches.length > 0) {
77
+ step.branches.forEach((branch) =>
78
+ markStepsSkipped(env, branch.steps, state.iterationStack),
79
+ );
80
+ }
81
+
82
+ return undefined;
83
+ }
@@ -0,0 +1,303 @@
1
+ /*
2
+ * Hexabot — Fair Core License (FCL-1.0-ALv2)
3
+ * Copyright (c) 2025 Hexastack.
4
+ * Full terms: see LICENSE.md.
5
+ */
6
+
7
+ import { BaseWorkflowContext } from '../context';
8
+ import type { RuntimeSuspensionRequest } from '../runner-runtime-control';
9
+ import {
10
+ StepType,
11
+ type EventEmitterLike,
12
+ type StepInfo,
13
+ } from '../workflow-event-emitter';
14
+ import type {
15
+ CompiledStep,
16
+ ExecutionState,
17
+ LoopStep,
18
+ Suspension,
19
+ } from '../workflow-types';
20
+ import { compileValue } from '../workflow-values';
21
+
22
+ import {
23
+ executeLoop,
24
+ shouldStopLoop,
25
+ updateAccumulator,
26
+ } from './loop-executor';
27
+ import type { StepExecutorEnv } from './types';
28
+
29
+ class TestContext extends BaseWorkflowContext {
30
+ public eventEmitter: EventEmitterLike = { emit: jest.fn(), on: jest.fn() };
31
+
32
+ constructor() {
33
+ super({});
34
+ }
35
+ }
36
+
37
+ const createState = (): ExecutionState => ({
38
+ input: {},
39
+ output: {},
40
+ iterationStack: [],
41
+ });
42
+ const createEnv = (executeFlow: jest.Mock): StepExecutorEnv => {
43
+ const compiled = {
44
+ definition: {} as any,
45
+ tasks: {},
46
+ flow: [],
47
+ outputMapping: {},
48
+ inputParser: { parse: (value: unknown) => value } as any,
49
+ } as any;
50
+
51
+ return {
52
+ compiled,
53
+ context: new TestContext(),
54
+ runId: 'run-loop',
55
+ buildInstanceStepInfo: jest.fn(),
56
+ markSnapshot: jest.fn(),
57
+ recordStepExecution: jest.fn(),
58
+ emit: jest.fn(),
59
+ setCurrentStep: jest.fn(),
60
+ waitForStepSuspension: jest
61
+ .fn()
62
+ .mockImplementation(
63
+ () => new Promise<RuntimeSuspensionRequest>(() => undefined),
64
+ ),
65
+ clearStepSuspensions: jest.fn(),
66
+ primeStepResumeData: jest.fn(),
67
+ captureTaskOutput: jest.fn(),
68
+ executeFlow,
69
+ executeStep: jest.fn(),
70
+ };
71
+ };
72
+ const createTaskStep = (id: string): CompiledStep => ({
73
+ id,
74
+ type: StepType.Task,
75
+ label: id,
76
+ taskName: `task_${id}`,
77
+ });
78
+
79
+ describe('executeLoop', () => {
80
+ it('iterates items, accumulates values, and stops when the until condition is met', async () => {
81
+ const flowCalls: Array<{ index: number; item: unknown }> = [];
82
+ const executeFlow = jest.fn(
83
+ async (_steps: CompiledStep[], iterationState: ExecutionState) => {
84
+ flowCalls.push(
85
+ iterationState.iteration as { index: number; item: unknown },
86
+ );
87
+ iterationState.output[`item_${iterationState.iteration?.index}`] =
88
+ iterationState.iteration?.item;
89
+
90
+ return undefined;
91
+ },
92
+ );
93
+ const env = createEnv(executeFlow);
94
+ const state = createState();
95
+ const step: LoopStep = {
96
+ id: 'loop',
97
+ type: StepType.Loop,
98
+ loopType: 'for_each',
99
+ label: 'loop',
100
+ name: 'collector',
101
+ forEach: { item: 'entry', in: { kind: 'literal', value: [2, 4, 6] } },
102
+ until: compileValue('=$accumulator >= 6'),
103
+ accumulate: {
104
+ as: 'sum',
105
+ initial: 0,
106
+ merge: compileValue('=$accumulator + $iteration.item'),
107
+ },
108
+ steps: [createTaskStep('child')],
109
+ };
110
+ const result = await executeLoop(env, step, state, []);
111
+
112
+ expect(result).toBeUndefined();
113
+ expect(flowCalls).toEqual([
114
+ { item: 2, index: 0 },
115
+ { item: 4, index: 1 },
116
+ { item: 6, index: 2 },
117
+ ]);
118
+ expect(state.output.collector).toEqual({ sum: 12 });
119
+ expect(state.accumulator).toBe(12);
120
+ expect(state.output).toMatchObject({ item_0: 2, item_1: 4, item_2: 6 });
121
+ });
122
+
123
+ it('resumes after suspension and continues remaining iterations', async () => {
124
+ const innerSuspension: Suspension = {
125
+ step: { id: 'child', name: 'child', type: StepType.Task } as StepInfo,
126
+ continue: jest.fn().mockResolvedValue(undefined),
127
+ };
128
+ const executeFlow = jest
129
+ .fn()
130
+ .mockResolvedValueOnce(innerSuspension)
131
+ .mockImplementationOnce(
132
+ async (_steps: CompiledStep[], iterationState: ExecutionState) => {
133
+ iterationState.output[`item_${iterationState.iteration?.index}`] =
134
+ iterationState.iteration?.item;
135
+
136
+ return undefined;
137
+ },
138
+ );
139
+ const env = createEnv(executeFlow);
140
+ const state = createState();
141
+ const step: LoopStep = {
142
+ id: 'loop',
143
+ type: StepType.Loop,
144
+ loopType: 'for_each',
145
+ label: 'loop',
146
+ name: 'collector',
147
+ forEach: { item: 'entry', in: { kind: 'literal', value: [1, 2] } },
148
+ until: compileValue('=$iteration.index >= 1'),
149
+ accumulate: {
150
+ as: 'sum',
151
+ initial: 0,
152
+ merge: compileValue('=$accumulator + $iteration.item'),
153
+ },
154
+ steps: [createTaskStep('child')],
155
+ };
156
+ const suspension = await executeLoop(env, step, state, []);
157
+ expect(suspension).toEqual(
158
+ expect.objectContaining({ step: innerSuspension.step }),
159
+ );
160
+
161
+ const result = await suspension?.continue({ resumed: true });
162
+
163
+ expect(result).toBeUndefined();
164
+ expect(innerSuspension.continue).toHaveBeenCalledWith({ resumed: true });
165
+ expect(executeFlow).toHaveBeenCalledTimes(2);
166
+ expect(executeFlow).toHaveBeenNthCalledWith(
167
+ 1,
168
+ step.steps,
169
+ expect.any(Object),
170
+ [0],
171
+ );
172
+ expect(executeFlow).toHaveBeenNthCalledWith(
173
+ 2,
174
+ step.steps,
175
+ expect.any(Object),
176
+ [1],
177
+ );
178
+ expect(state.output.collector).toEqual({ sum: 2 });
179
+ expect(state.accumulator).toBeUndefined();
180
+ });
181
+
182
+ it('evaluates while loops before each iteration and can exit without running', async () => {
183
+ const executeFlow = jest.fn();
184
+ const env = createEnv(executeFlow);
185
+ const state = createState();
186
+ const step: LoopStep = {
187
+ id: 'loop',
188
+ type: StepType.Loop,
189
+ loopType: 'while',
190
+ label: 'loop',
191
+ while: compileValue('=$exists($output.await_phone_reply) = true'),
192
+ steps: [createTaskStep('child')],
193
+ };
194
+ const result = await executeLoop(env, step, state, []);
195
+
196
+ expect(result).toBeUndefined();
197
+ expect(executeFlow).not.toHaveBeenCalled();
198
+ });
199
+
200
+ it('continues while loops after suspension until the condition becomes false', async () => {
201
+ const innerSuspension: Suspension = {
202
+ step: { id: 'child', name: 'child', type: StepType.Task } as StepInfo,
203
+ continue: jest.fn().mockResolvedValue(undefined),
204
+ };
205
+ const executeFlow = jest
206
+ .fn()
207
+ .mockResolvedValueOnce(innerSuspension)
208
+ .mockImplementationOnce(
209
+ async (_steps: CompiledStep[], iterationState: ExecutionState) => {
210
+ iterationState.output.should_continue = false;
211
+ iterationState.output.done = true;
212
+
213
+ return undefined;
214
+ },
215
+ );
216
+ const env = createEnv(executeFlow);
217
+ const state = createState();
218
+ state.output.should_continue = true;
219
+
220
+ const step: LoopStep = {
221
+ id: 'loop',
222
+ type: StepType.Loop,
223
+ loopType: 'while',
224
+ label: 'loop',
225
+ name: 'collector',
226
+ while: compileValue(
227
+ '=$exists($output.should_continue) and $output.should_continue = true',
228
+ ),
229
+ accumulate: {
230
+ as: 'count',
231
+ initial: 0,
232
+ merge: compileValue('=$accumulator + 1'),
233
+ },
234
+ steps: [createTaskStep('child')],
235
+ };
236
+ const suspension = await executeLoop(env, step, state, []);
237
+ expect(suspension).toEqual(
238
+ expect.objectContaining({ step: innerSuspension.step }),
239
+ );
240
+
241
+ const result = await suspension?.continue({ resumed: true });
242
+
243
+ expect(result).toBeUndefined();
244
+ expect(innerSuspension.continue).toHaveBeenCalledWith({ resumed: true });
245
+ expect(executeFlow).toHaveBeenCalledTimes(2);
246
+ expect(executeFlow).toHaveBeenNthCalledWith(
247
+ 1,
248
+ step.steps,
249
+ expect.any(Object),
250
+ [0],
251
+ );
252
+ expect(executeFlow).toHaveBeenNthCalledWith(
253
+ 2,
254
+ step.steps,
255
+ expect.any(Object),
256
+ [1],
257
+ );
258
+ expect(state.output.collector).toEqual({ count: 1 });
259
+ });
260
+ });
261
+
262
+ describe('loop helpers', () => {
263
+ it('updates accumulator and stop conditions based on configuration', async () => {
264
+ const step: LoopStep = {
265
+ id: 'loop',
266
+ type: StepType.Loop,
267
+ loopType: 'for_each',
268
+ label: 'loop',
269
+ forEach: { item: 'entry', in: { kind: 'literal', value: [] } },
270
+ steps: [],
271
+ };
272
+ const scope = {
273
+ input: {},
274
+ context: new TestContext().state,
275
+ output: {},
276
+ iteration: { item: 1, index: 0 },
277
+ accumulator: 5,
278
+ };
279
+
280
+ await expect(updateAccumulator(step, scope, 5)).resolves.toBe(5);
281
+ await expect(shouldStopLoop(step, scope)).resolves.toBe(false);
282
+ });
283
+
284
+ it('does not stop while loops through until semantics', async () => {
285
+ const step: LoopStep = {
286
+ id: 'loop',
287
+ type: StepType.Loop,
288
+ loopType: 'while',
289
+ label: 'loop',
290
+ while: compileValue('=true'),
291
+ steps: [],
292
+ };
293
+ const scope = {
294
+ input: {},
295
+ context: new TestContext().state,
296
+ output: {},
297
+ iteration: { item: undefined, index: 0 },
298
+ accumulator: 0,
299
+ };
300
+
301
+ await expect(shouldStopLoop(step, scope)).resolves.toBe(false);
302
+ });
303
+ });