guest.spec.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353
  1. import { execFile } from 'node:child_process'
  2. import { setImmediate } from 'node:timers/promises'
  3. import { promisify } from 'node:util'
  4. import { describe, expect, it, onTestFinished } from 'vitest'
  5. import { runWorkflowGuest } from '../src/guest.ts'
  6. import { WORKFLOW_GUEST_SOURCE } from '../src/guest-source.ts'
  7. import type { WorkflowGuestHost, WorkflowProgress } from '../src/guest-types.ts'
  8. import type { ChildResult, ChildStartRequest, WorkerInit, WorkerLimits } from '../src/types.ts'
  9. function fixture(body: string, overrides: Partial<WorkflowGuestHost> = {}, limits: Partial<WorkerLimits> = {}) {
  10. const requests: ChildStartRequest[] = []
  11. const events: WorkflowProgress[] = []
  12. const disposed: number[] = []
  13. const init: WorkerInit = {
  14. meta: { name: 'guest-test', description: 'exercise workflow guest callbacks' },
  15. body,
  16. args: { prompt: 'answer this' },
  17. limits: { maxConcurrentAgents: 4, maxTotalAgents: 8, maxItemsPerCall: 8, syncTimeoutMs: 5000, ...limits },
  18. }
  19. const host: WorkflowGuestHost = {
  20. begin: async () => init,
  21. async startChild(request) {
  22. requests.push(request)
  23. return { callId: requests.length, childId: `child-${requests.length}` }
  24. },
  25. childResult: async ({ callId }) => ({ output: [{ type: 'text', text: requests[callId - 1]!.prompt }], stopReason: 'completed' }),
  26. async disposeChild({ callId }) { disposed.push(callId); return null },
  27. async progress(batch) { events.push(...batch); return null },
  28. ...overrides,
  29. }
  30. return { host, init, requests, events, disposed }
  31. }
  32. describe('workflow guest callbacks', () => {
  33. it('runs the six-global script API and forwards structured child options and progress', async () => {
  34. const test = fixture(`
  35. phase('Read')
  36. log('starting')
  37. return await agent(args.prompt, { label: 'Answer', provider: 'openai', model: 'small', schema: { type: 'object' } })
  38. `, { childResult: async () => ({ output: [], structured: { answer: 42 }, stopReason: 'completed' }) })
  39. await expect(runWorkflowGuest(test.host)).resolves.toEqual({ value: { answer: 42 }, stopReason: 'completed', agentsStarted: 1 })
  40. expect(test.requests).toEqual([{ prompt: 'answer this', provider: 'openai', model: 'small', schema: { type: 'object' } }])
  41. expect(test.events).toEqual([
  42. { type: 'phase', title: 'Read' },
  43. { type: 'log', message: 'starting' },
  44. { type: 'agent-start', info: { seq: 1, label: 'Answer', phase: 'Read', childId: 'child-1' } },
  45. { type: 'agent-end', info: { seq: 1, label: 'Answer', phase: 'Read', childId: 'child-1', outcome: 'completed' } },
  46. ])
  47. expect(test.disposed).toEqual([1])
  48. })
  49. it('keeps combinator functions inside the VM and maps ordinary stage failures to null', async () => {
  50. const { host } = fixture(`
  51. const first = await parallel([() => agent('a'), () => { throw new Error('ordinary') }, () => { throw { fatal: true } }])
  52. const second = await pipeline([1, 2, 3], (prev, item, index) => prev + item + index, value => { if (value === 5) throw 'skip'; return value * 2 })
  53. return { first, second }
  54. `)
  55. await expect(runWorkflowGuest(host)).resolves.toMatchObject({ value: { first: ['a', null, null], second: [4, null, 16] }, stopReason: 'completed' })
  56. })
  57. it('does not expose ambient Node globals and maps a missing return to null', async () => {
  58. const test = fixture('log([typeof process, typeof setTimeout, typeof fetch].join(","))')
  59. await expect(runWorkflowGuest(test.host)).resolves.toEqual({ value: null, stopReason: 'completed', agentsStarted: 0 })
  60. expect(test.events).toEqual([{ type: 'log', message: 'undefined,undefined,undefined' }])
  61. })
  62. it.each([
  63. ['return 42', 'completed'],
  64. ['throw new Error("body failed")', 'error'],
  65. ])('waits for progress before publishing script settlement: %s', async (settlement, stopReason) => {
  66. const entered = Promise.withResolvers<undefined>()
  67. const delivered = Promise.withResolvers<null>()
  68. onTestFinished(() => { delivered.resolve(null) })
  69. const test = fixture(`log("last"); ${settlement}`, { progress: () => { entered.resolve(undefined); return delivered.promise } })
  70. let finished = false
  71. const active = runWorkflowGuest(test.host).then((result) => { finished = true; return result })
  72. await entered.promise
  73. expect(finished).toBe(false)
  74. delivered.resolve(null)
  75. await expect(active).resolves.toMatchObject({ stopReason })
  76. })
  77. it('retains a progress failure that arrives before script completion', async () => {
  78. const { host } = fixture('log("progress"); await agent("work"); return 42', {
  79. progress: async () => { throw new Error('progress delivery failed') },
  80. })
  81. const result = await runWorkflowGuest(host)
  82. expect(result).toMatchObject({ value: null, stopReason: 'error', agentsStarted: 1 })
  83. expect(result.error).toContain('progress delivery failed')
  84. })
  85. it('coalesces a synchronous progress burst while one acknowledgement is pending', async () => {
  86. const first = Promise.withResolvers<undefined>()
  87. const second = Promise.withResolvers<undefined>()
  88. const firstAck = Promise.withResolvers<null>()
  89. const secondAck = Promise.withResolvers<null>()
  90. const batches: WorkflowProgress[][] = []
  91. let pending = 0
  92. let peak = 0
  93. const test = fixture('for (let index = 0; index < 200; index++) log(String(index)); return "done"', {
  94. async progress(events) {
  95. batches.push(events)
  96. peak = Math.max(peak, ++pending)
  97. const firstBatch = batches.length === 1
  98. if (firstBatch) first.resolve(undefined)
  99. else second.resolve(undefined)
  100. await (firstBatch ? firstAck.promise : secondAck.promise)
  101. pending -= 1
  102. return null
  103. },
  104. })
  105. let settled = false
  106. const active = runWorkflowGuest(test.host).then((result) => { settled = true; return result })
  107. onTestFinished(async () => { firstAck.resolve(null); secondAck.resolve(null); await active })
  108. await first.promise
  109. expect(batches).toHaveLength(1)
  110. expect(batches[0]).toEqual([{ type: 'log', message: '0' }])
  111. firstAck.resolve(null)
  112. await second.promise
  113. expect(batches).toHaveLength(2)
  114. expect(batches[1]).toEqual(Array.from({ length: 199 }, (_, index) => ({ type: 'log', message: String(index + 1) })))
  115. expect(peak).toBe(1)
  116. await setImmediate()
  117. expect(settled).toBe(false)
  118. secondAck.resolve(null)
  119. await expect(active).resolves.toMatchObject({ value: 'done', stopReason: 'completed' })
  120. })
  121. it('reports a rejected batch and stops dispatching its queued progress', async () => {
  122. const entered = Promise.withResolvers<undefined>()
  123. const acknowledgement = Promise.withResolvers<null>()
  124. const batches: WorkflowProgress[][] = []
  125. const test = fixture('for (let index = 0; index < 200; index++) log(String(index)); return "done"', {
  126. progress(events) {
  127. batches.push(events)
  128. entered.resolve(undefined)
  129. return acknowledgement.promise
  130. },
  131. })
  132. const active = runWorkflowGuest(test.host)
  133. onTestFinished(async () => { acknowledgement.resolve(null); await active })
  134. await entered.promise
  135. acknowledgement.reject(new Error('progress batch rejected'))
  136. const result = await active
  137. expect(result).toMatchObject({ value: null, stopReason: 'error' })
  138. expect(result.error).toContain('progress batch rejected')
  139. expect(batches).toEqual([[{ type: 'log', message: '0' }]])
  140. })
  141. it('delivers queued child lifecycle events before disposing the published child', async () => {
  142. const firstAck = Promise.withResolvers<null>()
  143. const events: WorkflowProgress[] = []
  144. let batches = 0
  145. const test = fixture('log("hold"); return await agent("work")', {
  146. async progress(batch) {
  147. events.push(...batch)
  148. if (++batches === 1) await firstAck.promise
  149. return null
  150. },
  151. })
  152. const active = runWorkflowGuest(test.host)
  153. onTestFinished(async () => { firstAck.resolve(null); await active })
  154. // All child callbacks are fulfilled promises; the next turn drains their microtasks.
  155. await setImmediate()
  156. expect(test.requests).toHaveLength(1)
  157. expect(test.disposed).toEqual([])
  158. expect(events).toEqual([{ type: 'log', message: 'hold' }])
  159. firstAck.resolve(null)
  160. await expect(active).resolves.toMatchObject({ value: 'work', stopReason: 'completed' })
  161. expect(events.map(event => event.type)).toEqual(['log', 'agent-start', 'agent-end'])
  162. expect(test.disposed).toEqual([1])
  163. })
  164. it('reports initialization failure before any child or progress call', async () => {
  165. const test = fixture('return 42', { begin: async () => { throw new Error('run already cancelled') } })
  166. await expect(runWorkflowGuest(test.host)).rejects.toThrow('run already cancelled')
  167. expect(test.requests).toEqual([])
  168. expect(test.events).toEqual([])
  169. })
  170. it('rejects a body that does not parse, including TypeScript-only syntax', async () => {
  171. for (const body of ['return (((', 'const value: number = 1; return value']) {
  172. await expect(runWorkflowGuest(fixture(body).host)).rejects.toThrow('workflow script does not parse')
  173. }
  174. })
  175. it('ends an initial synchronous loop at the VM timeout after emitting its progress', async () => {
  176. const test = fixture('log("before loop"); while (true) {}', {}, { syncTimeoutMs: 20 })
  177. const result = await runWorkflowGuest(test.host)
  178. expect(result.stopReason).toBe('error')
  179. expect(result.error).toContain('Script execution timed out')
  180. expect(test.events).toEqual([{ type: 'log', message: 'before loop' }])
  181. })
  182. it('rejects non-JSON completion values with the workflow diagnostic', async () => {
  183. const result = await runWorkflowGuest(fixture('return { date: new Date(0) }').host)
  184. expect(result.stopReason).toBe('error')
  185. expect(result.error).toContain("the workflow's return value is not plain JSON data")
  186. })
  187. it('loads the shipped generated module from a data URL and retains fresh VM state per run', async () => {
  188. const guest = await import(`data:text/javascript,${encodeURIComponent(WORKFLOW_GUEST_SOURCE)}`) as { runWorkflowGuest: typeof runWorkflowGuest }
  189. const test = fixture('globalThis.count = (globalThis.count ?? 0) + 1; return await agent(String(globalThis.count))')
  190. await expect(guest.runWorkflowGuest(test.host)).resolves.toMatchObject({ value: '1', stopReason: 'completed' })
  191. await expect(guest.runWorkflowGuest(test.host)).resolves.toMatchObject({ value: '1', stopReason: 'completed' })
  192. })
  193. it('names generated helper frames without exposing the encoded guest module in errors', async () => {
  194. // Vitest replaces stack formatting; plain Node must honor the guest's sourceURL.
  195. const active = promisify(execFile)(process.execPath, ['--input-type=module', '--eval', `
  196. let source = ''
  197. for await (const chunk of process.stdin) source += chunk
  198. const { runWorkflowGuest } = await import('data:text/javascript,' + encodeURIComponent(source))
  199. const result = await runWorkflowGuest({
  200. begin: async () => ({
  201. meta: { name: 'helper-error', description: 'helper error stack' },
  202. body: 'return await parallel([1])',
  203. limits: { maxConcurrentAgents: 1, maxTotalAgents: 1, maxItemsPerCall: 1, syncTimeoutMs: 5000 },
  204. }),
  205. })
  206. process.stdout.write(JSON.stringify(result))
  207. `], { encoding: 'utf8', timeout: 30_000 })
  208. onTestFinished(async () => { active.child.kill(); await active.catch(() => {}) })
  209. active.child.stdin!.end(WORKFLOW_GUEST_SOURCE)
  210. const { stdout } = await active
  211. const result = JSON.parse(stdout) as { stopReason: string; error: string }
  212. expect(result.stopReason).toBe('error')
  213. expect(result.error.includes('dsh-workflow-guest.js:')).toBe(true)
  214. expect(result.error.includes('data:text/javascript')).toBe(false)
  215. })
  216. })
  217. describe('workflow child results', () => {
  218. it.each([
  219. ['failed child', { output: [], stopReason: 'error' }],
  220. ['missing structured value', { output: [], stopReason: 'completed' }],
  221. ] as const)('returns null for a %s and disposes its child', async (_name, result) => {
  222. const test = fixture('return await agent("work", {schema: {type: "object"}})', { childResult: async () => ({ ...result, output: [] }) })
  223. await expect(runWorkflowGuest(test.host)).resolves.toMatchObject({ value: null, stopReason: 'completed', agentsStarted: 1 })
  224. expect(test.events.at(-1)).toMatchObject({ type: 'agent-end', info: { outcome: 'failed' } })
  225. expect(test.disposed).toEqual([1])
  226. })
  227. it('returns text blocks and derives a short first-line label unless options override it', async () => {
  228. const long = 'x'.repeat(70)
  229. const test = fixture(`phase('default'); await agent(${JSON.stringify(`${long}\nsecond line`)}); return await agent('short', {phase: 'override'})`, {
  230. childResult: async () => ({ output: [{ type: 'text', text: 'a' }, { type: 'reasoning', text: 'private reasoning' }, { type: 'text', text: 'b' }], stopReason: 'completed' }),
  231. })
  232. await expect(runWorkflowGuest(test.host)).resolves.toMatchObject({ value: 'ab', stopReason: 'completed' })
  233. expect(test.events.filter(event => event.type === 'agent-start')).toMatchObject([
  234. { info: { label: `${'x'.repeat(47)}…`, phase: 'default' } },
  235. { info: { label: 'short', phase: 'override' } },
  236. ])
  237. })
  238. it('propagates child-start infrastructure failure through a combinator', async () => {
  239. const test = fixture('return await parallel([() => agent("work")])', {
  240. startChild: async () => { throw new Error('provider unavailable') },
  241. })
  242. const result = await runWorkflowGuest(test.host)
  243. expect(result.stopReason).toBe('error')
  244. expect(result.error).toContain('agent() could not start a child')
  245. expect(test.events).toEqual([])
  246. })
  247. it('pairs and disposes a child whose result rejects, preserving a fatal pipeline failure', async () => {
  248. const test = fixture('return await pipeline([1], () => agent("work"))', {
  249. childResult: async () => { throw new Error('provider result failed') },
  250. })
  251. const result = await runWorkflowGuest(test.host)
  252. expect(result.stopReason).toBe('error')
  253. expect(result.error).toContain('child agent run failed')
  254. expect(test.events.at(-1)).toMatchObject({ type: 'agent-end', info: { outcome: 'failed' } })
  255. expect(test.disposed).toEqual([1])
  256. })
  257. it('contains the rejection of an agent() promise the script dropped', async () => {
  258. const droppedDisposed = Promise.withResolvers<undefined>()
  259. onTestFinished(() => { droppedDisposed.resolve(undefined) })
  260. const test = fixture('agent("dropped"); return await agent("kept")', {
  261. async childResult({ callId }) {
  262. if (callId === 1) throw new Error('dropped child failed')
  263. await droppedDisposed.promise
  264. return { output: [{ type: 'text', text: 'kept result' }], stopReason: 'completed' }
  265. },
  266. async disposeChild({ callId }) {
  267. if (callId === 1) droppedDisposed.resolve(undefined)
  268. return null
  269. },
  270. })
  271. await expect(runWorkflowGuest(test.host)).resolves.toMatchObject({ value: 'kept result', stopReason: 'completed' })
  272. expect(test.events).toContainEqual({
  273. type: 'agent-end', info: { seq: 1, label: 'dropped', childId: 'child-1', outcome: 'failed' },
  274. })
  275. })
  276. it('admits queued child calls in FIFO order after each previous child finishes', async () => {
  277. const starts = [Promise.withResolvers<undefined>(), Promise.withResolvers<undefined>(), Promise.withResolvers<undefined>()]
  278. const results = [Promise.withResolvers<ChildResult>(), Promise.withResolvers<ChildResult>(), Promise.withResolvers<ChildResult>()]
  279. onTestFinished(() => { for (const result of results) result.resolve({ output: [], stopReason: 'cancelled' }) })
  280. const test = fixture('return await parallel([() => agent("one"), () => agent("two"), () => agent("three")])', {}, { maxConcurrentAgents: 1 })
  281. const startChild = test.host.startChild.bind(test.host)
  282. test.host.startChild = async (request) => {
  283. const child = await startChild(request)
  284. starts[child.callId - 1]!.resolve(undefined)
  285. return child
  286. }
  287. test.host.childResult = ({ callId }) => results[callId - 1]!.promise
  288. const active = runWorkflowGuest(test.host)
  289. for (let index = 0; index < results.length; index++) {
  290. await starts[index]!.promise
  291. expect(test.requests).toHaveLength(index + 1)
  292. results[index]!.resolve({ output: [{ type: 'text', text: String(index) }], stopReason: 'completed' })
  293. }
  294. await expect(active).resolves.toMatchObject({ value: ['0', '1', '2'], stopReason: 'completed' })
  295. expect(test.requests.map(request => request.prompt)).toEqual(['one', 'two', 'three'])
  296. })
  297. })
  298. describe('workflow script validation', () => {
  299. it.each([
  300. ['return await agent(1)', 'non-empty prompt'],
  301. ['return await agent("x", new Date())', 'options must be plain JSON'],
  302. ['return await agent("x", [])', 'options must be an object'],
  303. ['return await agent("x", {effort: "high"})', 'deferred and not supported'],
  304. ['return await agent("x", {unknown: true})', 'not recognized'],
  305. ['return await agent("x", {label: 1})', 'must be a string'],
  306. ['return await agent("x", {schema: {type: "object", pattern: "x"}})', 'outside the supported subset'],
  307. ['return await parallel(1)', 'requires an array'],
  308. ['return await parallel([1])', 'not a function'],
  309. ['return await parallel([() => agent("x", {unknown: true})])', 'not recognized'],
  310. ['return await pipeline(1, () => 1)', 'requires an items array'],
  311. ['return await pipeline([])', 'requires at least one stage'],
  312. ['return await pipeline([], 1)', 'not a function'],
  313. ['phase(1)', 'non-empty title'],
  314. ['log(1)', 'message string'],
  315. ])('rejects invalid script calls: %s', async (body, message) => {
  316. const result = await runWorkflowGuest(fixture(body).host)
  317. expect(result.stopReason).toBe('error')
  318. expect(result.error).toContain(message)
  319. })
  320. it('applies the ordinary total-child and per-combinator item limits', async () => {
  321. const total = await runWorkflowGuest(fixture('await agent("one"); return await agent("two")', {}, { maxTotalAgents: 1 }).host)
  322. expect(total.stopReason).toBe('error')
  323. expect(total.error).toContain('total agent cap (1)')
  324. for (const body of ['return await parallel([() => 1, () => 2])', 'return await pipeline([1, 2], value => value)']) {
  325. const result = await runWorkflowGuest(fixture(body, {}, { maxItemsPerCall: 1 }).host)
  326. expect(result.stopReason).toBe('error')
  327. expect(result.error).toContain('per-call cap (1)')
  328. }
  329. })
  330. })