executor.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471
  1. import { describe, expect, it } from 'bun:test'
  2. import { executeAbility, formatExecutionResult } from '../src/executor/index.js'
  3. import type { Ability, ExecutorContext } from '../src/types/index.js'
  4. const createMockContext = (): ExecutorContext => ({
  5. cwd: process.cwd(),
  6. env: {},
  7. })
  8. describe('executeAbility', () => {
  9. it('should execute a simple script ability', async () => {
  10. const ability: Ability = {
  11. name: 'simple',
  12. description: 'Simple test',
  13. steps: [
  14. {
  15. id: 'echo',
  16. type: 'script',
  17. run: 'echo "hello world"',
  18. validation: {
  19. exit_code: 0,
  20. },
  21. },
  22. ],
  23. }
  24. const result = await executeAbility(ability, {}, createMockContext())
  25. expect(result.status).toBe('completed')
  26. expect(result.completedSteps).toHaveLength(1)
  27. expect(result.completedSteps[0].status).toBe('completed')
  28. expect(result.completedSteps[0].output).toContain('hello world')
  29. })
  30. it('should execute steps in dependency order', async () => {
  31. const executionOrder: string[] = []
  32. const ability: Ability = {
  33. name: 'ordered',
  34. description: 'Ordered test',
  35. steps: [
  36. {
  37. id: 'third',
  38. type: 'script',
  39. run: 'echo third',
  40. needs: ['second'],
  41. },
  42. {
  43. id: 'first',
  44. type: 'script',
  45. run: 'echo first',
  46. },
  47. {
  48. id: 'second',
  49. type: 'script',
  50. run: 'echo second',
  51. needs: ['first'],
  52. },
  53. ],
  54. }
  55. const ctx = createMockContext()
  56. ctx.onStepStart = (step) => executionOrder.push(step.id)
  57. await executeAbility(ability, {}, ctx)
  58. expect(executionOrder).toEqual(['first', 'second', 'third'])
  59. })
  60. it('should fail on script validation failure', async () => {
  61. const ability: Ability = {
  62. name: 'fail',
  63. description: 'Fail test',
  64. steps: [
  65. {
  66. id: 'fail',
  67. type: 'script',
  68. run: 'exit 1',
  69. validation: {
  70. exit_code: 0,
  71. },
  72. },
  73. ],
  74. }
  75. const result = await executeAbility(ability, {}, createMockContext())
  76. expect(result.status).toBe('failed')
  77. expect(result.completedSteps[0].status).toBe('failed')
  78. })
  79. it('should validate inputs before execution', async () => {
  80. const ability: Ability = {
  81. name: 'with-inputs',
  82. description: 'Input test',
  83. inputs: {
  84. name: {
  85. type: 'string',
  86. required: true,
  87. },
  88. },
  89. steps: [
  90. {
  91. id: 'greet',
  92. type: 'script',
  93. run: 'echo "Hello {{inputs.name}}"',
  94. },
  95. ],
  96. }
  97. const result = await executeAbility(ability, {}, createMockContext())
  98. expect(result.status).toBe('failed')
  99. expect(result.error).toContain('Input validation failed')
  100. })
  101. it('should interpolate variables in script commands', async () => {
  102. const ability: Ability = {
  103. name: 'interpolate',
  104. description: 'Interpolation test',
  105. inputs: {
  106. name: {
  107. type: 'string',
  108. required: true,
  109. },
  110. },
  111. steps: [
  112. {
  113. id: 'greet',
  114. type: 'script',
  115. run: 'echo "Hello {{inputs.name}}"',
  116. },
  117. ],
  118. }
  119. const result = await executeAbility(
  120. ability,
  121. { name: 'World' },
  122. createMockContext()
  123. )
  124. expect(result.status).toBe('completed')
  125. expect(result.completedSteps[0].output).toContain('Hello World')
  126. })
  127. it('should skip steps when condition is not met', async () => {
  128. const ability: Ability = {
  129. name: 'conditional',
  130. description: 'Conditional test',
  131. inputs: {
  132. env: {
  133. type: 'string',
  134. required: true,
  135. },
  136. },
  137. steps: [
  138. {
  139. id: 'always',
  140. type: 'script',
  141. run: 'echo always',
  142. },
  143. {
  144. id: 'prod-only',
  145. type: 'script',
  146. run: 'echo production',
  147. when: 'inputs.env == "production"',
  148. },
  149. ],
  150. }
  151. const result = await executeAbility(
  152. ability,
  153. { env: 'staging' },
  154. createMockContext()
  155. )
  156. expect(result.status).toBe('completed')
  157. expect(result.completedSteps[0].status).toBe('completed')
  158. expect(result.completedSteps[1].status).toBe('skipped')
  159. })
  160. it('should continue on failure when configured', async () => {
  161. const ability: Ability = {
  162. name: 'continue',
  163. description: 'Continue test',
  164. steps: [
  165. {
  166. id: 'fail',
  167. type: 'script',
  168. run: 'exit 1',
  169. validation: {
  170. exit_code: 0,
  171. },
  172. on_failure: 'continue',
  173. },
  174. {
  175. id: 'after',
  176. type: 'script',
  177. run: 'echo after',
  178. },
  179. ],
  180. }
  181. const result = await executeAbility(ability, {}, createMockContext())
  182. expect(result.status).toBe('completed')
  183. expect(result.completedSteps[0].status).toBe('failed')
  184. expect(result.completedSteps[1].status).toBe('completed')
  185. })
  186. })
  187. describe('executeAgentStep', () => {
  188. it('should execute agent step with context', async () => {
  189. const ability: Ability = {
  190. name: 'agent-test',
  191. description: 'Agent test',
  192. steps: [
  193. {
  194. id: 'review',
  195. type: 'agent',
  196. agent: 'reviewer',
  197. prompt: 'Review this code',
  198. },
  199. ],
  200. }
  201. const ctx = createMockContext()
  202. ctx.agents = {
  203. async call(options) {
  204. return `Reviewed by ${options.agent}: ${options.prompt}`
  205. },
  206. async background(options) {
  207. return this.call(options)
  208. }
  209. }
  210. const result = await executeAbility(ability, {}, ctx)
  211. expect(result.status).toBe('completed')
  212. expect(result.completedSteps[0].status).toBe('completed')
  213. expect(result.completedSteps[0].output).toContain('Reviewed by reviewer')
  214. })
  215. it('should fail agent step without agent context', async () => {
  216. const ability: Ability = {
  217. name: 'agent-fail',
  218. description: 'Agent fail test',
  219. steps: [
  220. {
  221. id: 'review',
  222. type: 'agent',
  223. agent: 'reviewer',
  224. prompt: 'Review this',
  225. },
  226. ],
  227. }
  228. const result = await executeAbility(ability, {}, createMockContext())
  229. expect(result.status).toBe('failed')
  230. expect(result.completedSteps[0].error).toContain('Agent execution not available')
  231. })
  232. it('should pass prior step outputs to agent step', async () => {
  233. const ability: Ability = {
  234. name: 'context-pass',
  235. description: 'Context passing test',
  236. steps: [
  237. {
  238. id: 'generate',
  239. type: 'script',
  240. run: 'echo "GENERATED_DATA_123"',
  241. },
  242. {
  243. id: 'review',
  244. type: 'agent',
  245. agent: 'reviewer',
  246. prompt: 'Review the generated data',
  247. needs: ['generate'],
  248. },
  249. ],
  250. }
  251. let receivedPrompt = ''
  252. const ctx = createMockContext()
  253. ctx.agents = {
  254. async call(options) {
  255. receivedPrompt = options.prompt
  256. return 'Reviewed'
  257. },
  258. async background(options) {
  259. return this.call(options)
  260. }
  261. }
  262. await executeAbility(ability, {}, ctx)
  263. expect(receivedPrompt).toContain('Context from prior steps')
  264. expect(receivedPrompt).toContain('generate')
  265. expect(receivedPrompt).toContain('GENERATED_DATA_123')
  266. })
  267. })
  268. describe('executeSkillStep', () => {
  269. it('should execute skill step with context', async () => {
  270. const ability: Ability = {
  271. name: 'skill-test',
  272. description: 'Skill test',
  273. steps: [
  274. {
  275. id: 'docs',
  276. type: 'skill',
  277. skill: 'generate-docs',
  278. },
  279. ],
  280. }
  281. const ctx = createMockContext()
  282. ctx.skills = {
  283. async load(name) {
  284. return `Loaded skill: ${name}`
  285. }
  286. }
  287. const result = await executeAbility(ability, {}, ctx)
  288. expect(result.status).toBe('completed')
  289. expect(result.completedSteps[0].output).toContain('generate-docs')
  290. })
  291. it('should fail skill step without skill context', async () => {
  292. const ability: Ability = {
  293. name: 'skill-fail',
  294. description: 'Skill fail test',
  295. steps: [
  296. {
  297. id: 'docs',
  298. type: 'skill',
  299. skill: 'generate-docs',
  300. },
  301. ],
  302. }
  303. const result = await executeAbility(ability, {}, createMockContext())
  304. expect(result.status).toBe('failed')
  305. expect(result.completedSteps[0].error).toContain('Skill execution not available')
  306. })
  307. })
  308. describe('executeApprovalStep', () => {
  309. it('should execute approval step when approved', async () => {
  310. const ability: Ability = {
  311. name: 'approval-test',
  312. description: 'Approval test',
  313. steps: [
  314. {
  315. id: 'approve',
  316. type: 'approval',
  317. prompt: 'Deploy to production?',
  318. },
  319. ],
  320. }
  321. const ctx = createMockContext()
  322. ctx.approval = {
  323. async request() {
  324. return true
  325. }
  326. }
  327. const result = await executeAbility(ability, {}, ctx)
  328. expect(result.status).toBe('completed')
  329. expect(result.completedSteps[0].output).toBe('Approved')
  330. })
  331. it('should fail approval step when rejected', async () => {
  332. const ability: Ability = {
  333. name: 'approval-reject',
  334. description: 'Approval reject test',
  335. steps: [
  336. {
  337. id: 'approve',
  338. type: 'approval',
  339. prompt: 'Deploy to production?',
  340. },
  341. ],
  342. }
  343. const ctx = createMockContext()
  344. ctx.approval = {
  345. async request() {
  346. return false
  347. }
  348. }
  349. const result = await executeAbility(ability, {}, ctx)
  350. expect(result.status).toBe('failed')
  351. expect(result.completedSteps[0].output).toBe('Rejected')
  352. })
  353. it('should fail approval step without approval context', async () => {
  354. const ability: Ability = {
  355. name: 'approval-fail',
  356. description: 'Approval fail test',
  357. steps: [
  358. {
  359. id: 'approve',
  360. type: 'approval',
  361. prompt: 'Deploy?',
  362. },
  363. ],
  364. }
  365. const result = await executeAbility(ability, {}, createMockContext())
  366. expect(result.status).toBe('failed')
  367. expect(result.completedSteps[0].error).toContain('Approval not available')
  368. })
  369. it('should interpolate variables in approval prompt', async () => {
  370. const ability: Ability = {
  371. name: 'approval-vars',
  372. description: 'Approval vars test',
  373. inputs: {
  374. version: { type: 'string', required: true }
  375. },
  376. steps: [
  377. {
  378. id: 'approve',
  379. type: 'approval',
  380. prompt: 'Deploy {{inputs.version}} to production?',
  381. },
  382. ],
  383. }
  384. let receivedPrompt = ''
  385. const ctx = createMockContext()
  386. ctx.approval = {
  387. async request(options) {
  388. receivedPrompt = options.prompt
  389. return true
  390. }
  391. }
  392. await executeAbility(ability, { version: 'v1.2.3' }, ctx)
  393. expect(receivedPrompt).toBe('Deploy v1.2.3 to production?')
  394. })
  395. })
  396. describe('formatExecutionResult', () => {
  397. it('should format completed execution', async () => {
  398. const ability: Ability = {
  399. name: 'test',
  400. description: 'Test',
  401. steps: [
  402. { id: 'step1', type: 'script', run: 'echo hello' },
  403. ],
  404. }
  405. const execution = await executeAbility(ability, {}, createMockContext())
  406. const formatted = formatExecutionResult(execution)
  407. expect(formatted).toContain('Ability: test')
  408. expect(formatted).toContain('✅ Complete')
  409. expect(formatted).toContain('✅ step1')
  410. })
  411. })