executor.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472
  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. // Shell escaping wraps interpolated values in single quotes for safety
  126. expect(result.completedSteps[0].output).toContain("Hello 'World'")
  127. })
  128. it('should skip steps when condition is not met', async () => {
  129. const ability: Ability = {
  130. name: 'conditional',
  131. description: 'Conditional test',
  132. inputs: {
  133. env: {
  134. type: 'string',
  135. required: true,
  136. },
  137. },
  138. steps: [
  139. {
  140. id: 'always',
  141. type: 'script',
  142. run: 'echo always',
  143. },
  144. {
  145. id: 'prod-only',
  146. type: 'script',
  147. run: 'echo production',
  148. when: 'inputs.env == "production"',
  149. },
  150. ],
  151. }
  152. const result = await executeAbility(
  153. ability,
  154. { env: 'staging' },
  155. createMockContext()
  156. )
  157. expect(result.status).toBe('completed')
  158. expect(result.completedSteps[0].status).toBe('completed')
  159. expect(result.completedSteps[1].status).toBe('skipped')
  160. })
  161. it('should continue on failure when configured', async () => {
  162. const ability: Ability = {
  163. name: 'continue',
  164. description: 'Continue test',
  165. steps: [
  166. {
  167. id: 'fail',
  168. type: 'script',
  169. run: 'exit 1',
  170. validation: {
  171. exit_code: 0,
  172. },
  173. on_failure: 'continue',
  174. },
  175. {
  176. id: 'after',
  177. type: 'script',
  178. run: 'echo after',
  179. },
  180. ],
  181. }
  182. const result = await executeAbility(ability, {}, createMockContext())
  183. expect(result.status).toBe('completed')
  184. expect(result.completedSteps[0].status).toBe('failed')
  185. expect(result.completedSteps[1].status).toBe('completed')
  186. })
  187. })
  188. describe('executeAgentStep', () => {
  189. it('should execute agent step with context', async () => {
  190. const ability: Ability = {
  191. name: 'agent-test',
  192. description: 'Agent test',
  193. steps: [
  194. {
  195. id: 'review',
  196. type: 'agent',
  197. agent: 'reviewer',
  198. prompt: 'Review this code',
  199. },
  200. ],
  201. }
  202. const ctx = createMockContext()
  203. ctx.agents = {
  204. async call(options) {
  205. return `Reviewed by ${options.agent}: ${options.prompt}`
  206. },
  207. async background(options) {
  208. return this.call(options)
  209. }
  210. }
  211. const result = await executeAbility(ability, {}, ctx)
  212. expect(result.status).toBe('completed')
  213. expect(result.completedSteps[0].status).toBe('completed')
  214. expect(result.completedSteps[0].output).toContain('Reviewed by reviewer')
  215. })
  216. it('should fail agent step without agent context', async () => {
  217. const ability: Ability = {
  218. name: 'agent-fail',
  219. description: 'Agent fail test',
  220. steps: [
  221. {
  222. id: 'review',
  223. type: 'agent',
  224. agent: 'reviewer',
  225. prompt: 'Review this',
  226. },
  227. ],
  228. }
  229. const result = await executeAbility(ability, {}, createMockContext())
  230. expect(result.status).toBe('failed')
  231. expect(result.completedSteps[0].error).toContain('Agent execution not available')
  232. })
  233. it('should pass prior step outputs to agent step', async () => {
  234. const ability: Ability = {
  235. name: 'context-pass',
  236. description: 'Context passing test',
  237. steps: [
  238. {
  239. id: 'generate',
  240. type: 'script',
  241. run: 'echo "GENERATED_DATA_123"',
  242. },
  243. {
  244. id: 'review',
  245. type: 'agent',
  246. agent: 'reviewer',
  247. prompt: 'Review the generated data',
  248. needs: ['generate'],
  249. },
  250. ],
  251. }
  252. let receivedPrompt = ''
  253. const ctx = createMockContext()
  254. ctx.agents = {
  255. async call(options) {
  256. receivedPrompt = options.prompt
  257. return 'Reviewed'
  258. },
  259. async background(options) {
  260. return this.call(options)
  261. }
  262. }
  263. await executeAbility(ability, {}, ctx)
  264. expect(receivedPrompt).toContain('Context from prior steps')
  265. expect(receivedPrompt).toContain('generate')
  266. expect(receivedPrompt).toContain('GENERATED_DATA_123')
  267. })
  268. })
  269. describe('executeSkillStep', () => {
  270. it('should execute skill step with context', async () => {
  271. const ability: Ability = {
  272. name: 'skill-test',
  273. description: 'Skill test',
  274. steps: [
  275. {
  276. id: 'docs',
  277. type: 'skill',
  278. skill: 'generate-docs',
  279. },
  280. ],
  281. }
  282. const ctx = createMockContext()
  283. ctx.skills = {
  284. async load(name) {
  285. return `Loaded skill: ${name}`
  286. }
  287. }
  288. const result = await executeAbility(ability, {}, ctx)
  289. expect(result.status).toBe('completed')
  290. expect(result.completedSteps[0].output).toContain('generate-docs')
  291. })
  292. it('should fail skill step without skill context', async () => {
  293. const ability: Ability = {
  294. name: 'skill-fail',
  295. description: 'Skill fail test',
  296. steps: [
  297. {
  298. id: 'docs',
  299. type: 'skill',
  300. skill: 'generate-docs',
  301. },
  302. ],
  303. }
  304. const result = await executeAbility(ability, {}, createMockContext())
  305. expect(result.status).toBe('failed')
  306. expect(result.completedSteps[0].error).toContain('Skill execution not available')
  307. })
  308. })
  309. describe('executeApprovalStep', () => {
  310. it('should execute approval step when approved', async () => {
  311. const ability: Ability = {
  312. name: 'approval-test',
  313. description: 'Approval test',
  314. steps: [
  315. {
  316. id: 'approve',
  317. type: 'approval',
  318. prompt: 'Deploy to production?',
  319. },
  320. ],
  321. }
  322. const ctx = createMockContext()
  323. ctx.approval = {
  324. async request() {
  325. return true
  326. }
  327. }
  328. const result = await executeAbility(ability, {}, ctx)
  329. expect(result.status).toBe('completed')
  330. expect(result.completedSteps[0].output).toBe('Approved')
  331. })
  332. it('should fail approval step when rejected', async () => {
  333. const ability: Ability = {
  334. name: 'approval-reject',
  335. description: 'Approval reject test',
  336. steps: [
  337. {
  338. id: 'approve',
  339. type: 'approval',
  340. prompt: 'Deploy to production?',
  341. },
  342. ],
  343. }
  344. const ctx = createMockContext()
  345. ctx.approval = {
  346. async request() {
  347. return false
  348. }
  349. }
  350. const result = await executeAbility(ability, {}, ctx)
  351. expect(result.status).toBe('failed')
  352. expect(result.completedSteps[0].output).toBe('Rejected')
  353. })
  354. it('should fail approval step without approval context', async () => {
  355. const ability: Ability = {
  356. name: 'approval-fail',
  357. description: 'Approval fail test',
  358. steps: [
  359. {
  360. id: 'approve',
  361. type: 'approval',
  362. prompt: 'Deploy?',
  363. },
  364. ],
  365. }
  366. const result = await executeAbility(ability, {}, createMockContext())
  367. expect(result.status).toBe('failed')
  368. expect(result.completedSteps[0].error).toContain('Approval not available')
  369. })
  370. it('should interpolate variables in approval prompt', async () => {
  371. const ability: Ability = {
  372. name: 'approval-vars',
  373. description: 'Approval vars test',
  374. inputs: {
  375. version: { type: 'string', required: true }
  376. },
  377. steps: [
  378. {
  379. id: 'approve',
  380. type: 'approval',
  381. prompt: 'Deploy {{inputs.version}} to production?',
  382. },
  383. ],
  384. }
  385. let receivedPrompt = ''
  386. const ctx = createMockContext()
  387. ctx.approval = {
  388. async request(options) {
  389. receivedPrompt = options.prompt
  390. return true
  391. }
  392. }
  393. await executeAbility(ability, { version: 'v1.2.3' }, ctx)
  394. expect(receivedPrompt).toBe('Deploy v1.2.3 to production?')
  395. })
  396. })
  397. describe('formatExecutionResult', () => {
  398. it('should format completed execution', async () => {
  399. const ability: Ability = {
  400. name: 'test',
  401. description: 'Test',
  402. steps: [
  403. { id: 'step1', type: 'script', run: 'echo hello' },
  404. ],
  405. }
  406. const execution = await executeAbility(ability, {}, createMockContext())
  407. const formatted = formatExecutionResult(execution)
  408. expect(formatted).toContain('Ability: test')
  409. expect(formatted).toContain('✅ Complete')
  410. expect(formatted).toContain('✅ step1')
  411. })
  412. })