session.spec.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510
  1. import { describe, expect, it, vi } from 'vitest'
  2. import { MessageChannel } from 'node:worker_threads'
  3. import type { MessagePort } from 'node:worker_threads'
  4. import { HostToWorkerType, WorkerToHostType } from '../src/protocol.ts'
  5. import type { HostToWorkerMessage, WorkerToHostMessage } from '../src/protocol.ts'
  6. import { requireParentPort, runWorkerSession } from '../src/session.ts'
  7. import type { ChildResult, WorkerInit } from '../src/types.ts'
  8. /** Default limits for in-process sessions (concurrency pinned; auto is machine-derived). */
  9. function limits(overrides?: Partial<WorkerInit['limits']>): WorkerInit['limits'] {
  10. return { maxConcurrentAgents: 8, maxTotalAgents: 1000, maxItemsPerCall: 4096, syncTimeoutMs: 5000, ...overrides }
  11. }
  12. /** Wrap a body in the minimal valid meta header (the session receives it pre-extracted). */
  13. function init(body: string, args?: unknown, limitOverrides?: Partial<WorkerInit['limits']>): WorkerInit {
  14. return {
  15. meta: { name: 'test-flow', description: 'a test workflow' },
  16. body,
  17. ...args !== undefined ? { args } : {},
  18. limits: limits(limitOverrides),
  19. }
  20. }
  21. /** One scripted host over the other end of a MessageChannel. */
  22. interface FakeHost {
  23. port: MessagePort
  24. messages: WorkerToHostMessage[]
  25. /** Messages of one type, as they arrive. */
  26. ofType<T extends WorkerToHostMessage['type']>(type: T): Extract<WorkerToHostMessage, { type: T }>[]
  27. send(message: HostToWorkerMessage): void
  28. /** Resolves with the terminal result message. */
  29. result(): Promise<Extract<WorkerToHostMessage, { type: 'result' }>['result']>
  30. close(): void
  31. }
  32. interface FakeHostOptions {
  33. /** Auto-respond to child-start: reply started + settled per child index. Omit a reply to leave the child pending. */
  34. reply?: (request: { prompt: string; schema?: unknown; provider?: string; model?: string }, index: number) => ChildResult | undefined
  35. /** Reject the start instead (child-start-error) when returning a string. */
  36. refuse?: (index: number) => string | undefined
  37. /** Auto-send `go` on `ready` (default true). */
  38. go?: boolean
  39. /** Manual mode: do NOT auto-answer child-start at all (the test scripts the replies). */
  40. manual?: boolean
  41. }
  42. /**
  43. * Drive runWorkerSession IN-PROCESS over a MessageChannel: this is where the
  44. * worker-side files earn their coverage — code inside a real Worker is
  45. * invisible to main-process coverage. The fake host mirrors the real host's
  46. * protocol discipline (one started/start-error per start; settled/disposed
  47. * follow).
  48. */
  49. function fakeHost(options?: FakeHostOptions): FakeHost {
  50. const channel = new MessageChannel()
  51. const messages: WorkerToHostMessage[] = []
  52. const resultGate = Promise.withResolvers<Extract<WorkerToHostMessage, { type: 'result' }>['result']>()
  53. let childIndex = 0
  54. channel.port1.on('message', (message: WorkerToHostMessage) => {
  55. messages.push(message)
  56. switch (message.type) {
  57. case WorkerToHostType.Ready:
  58. if (options?.go !== false) channel.port1.postMessage({ type: HostToWorkerType.Go } satisfies HostToWorkerMessage)
  59. break
  60. case WorkerToHostType.ChildStart: {
  61. if (options?.manual) break
  62. const index = childIndex
  63. childIndex += 1
  64. const refusal = options?.refuse?.(index)
  65. if (refusal !== undefined) {
  66. channel.port1.postMessage(
  67. { type: HostToWorkerType.ChildStartError, callId: message.callId, rendered: refusal } satisfies HostToWorkerMessage,
  68. )
  69. break
  70. }
  71. channel.port1.postMessage({ type: HostToWorkerType.ChildStarted, callId: message.callId, childId: `child-${index}` } satisfies HostToWorkerMessage)
  72. const reply = options?.reply?.(message.request, index)
  73. if (reply !== undefined) {
  74. channel.port1.postMessage(
  75. { type: HostToWorkerType.ChildSettled, callId: message.callId, result: reply } satisfies HostToWorkerMessage,
  76. )
  77. }
  78. break
  79. }
  80. case WorkerToHostType.ChildDispose:
  81. channel.port1.postMessage({ type: HostToWorkerType.ChildDisposed, callId: message.callId } satisfies HostToWorkerMessage)
  82. break
  83. case WorkerToHostType.Result:
  84. resultGate.resolve(message.result)
  85. break
  86. default:
  87. break
  88. }
  89. })
  90. return {
  91. port: channel.port2,
  92. messages,
  93. ofType: type => messages.filter((message): message is never => message.type === type),
  94. send: (message) => { channel.port1.postMessage(message) },
  95. result: () => resultGate.promise,
  96. close: () => { channel.port1.close() },
  97. }
  98. }
  99. /** A completed text child result. */
  100. function text(reply: string): ChildResult {
  101. return { output: [{ type: 'text', text: reply }], stopReason: 'completed' }
  102. }
  103. describe('runWorkerSession over an in-process MessageChannel', () => {
  104. it('runs a script end to end: ready/go handshake, phases, log, agents, result', async () => {
  105. const host = fakeHost({ reply: (_request, index) => text(`answer-${index}`) })
  106. const session = runWorkerSession(host.port, init(`
  107. phase('Scan')
  108. log('starting with ' + args.files.length + ' files')
  109. const answers = await pipeline(args.files, (prev, item) => agent('read ' + item))
  110. return { answers }
  111. `, { files: ['a.ts', 'b.ts'] }))
  112. const result = await host.result()
  113. await session
  114. expect(result.stopReason).toBe('completed')
  115. expect(result.agentsStarted).toBe(2)
  116. expect(result.value).toEqual({ answers: ['answer-0', 'answer-1'] })
  117. expect(host.messages[0]!.type).toBe('ready')
  118. expect(host.ofType(WorkerToHostType.Phase).map(m => m.title)).toEqual(['Scan'])
  119. expect(host.ofType(WorkerToHostType.Log).map(m => m.message)).toEqual(['starting with 2 files'])
  120. expect(host.ofType(WorkerToHostType.AgentStart).map(m => m.info.childId)).toEqual(['child-0', 'child-1'])
  121. expect(host.ofType(WorkerToHostType.AgentEnd).every(m => m.info.outcome === 'completed')).toBe(true)
  122. host.close()
  123. })
  124. it('agent({schema}) forwards the schema on the start request and returns the structured value', async () => {
  125. const host = fakeHost({ reply: () => ({ output: [], structured: { files: ['x.ts'] }, stopReason: 'completed' }) })
  126. void runWorkerSession(host.port, init(`
  127. const found = await agent('list files', { schema: { type: 'object', properties: { files: { type: 'array', items: { type: 'string' } } } }, model: 'deepseek-v4-pro' })
  128. return { first: found.files[0] }
  129. `))
  130. const result = await host.result()
  131. expect(result.value).toEqual({ first: 'x.ts' })
  132. const start = host.ofType(WorkerToHostType.ChildStart)[0]!
  133. expect(start.request.schema).toEqual({ type: 'object', properties: { files: { type: 'array', items: { type: 'string' } } } })
  134. expect(start.request.model).toBe('deepseek-v4-pro')
  135. host.close()
  136. })
  137. it('agent({provider}) forwards a provider without inventing a model', async () => {
  138. const host = fakeHost({ reply: () => text('ok') })
  139. void runWorkerSession(host.port, init("return await agent('route me', { provider: 'openai' })"))
  140. const result = await host.result()
  141. expect(result.value).toBe('ok')
  142. const start = host.ofType(WorkerToHostType.ChildStart)[0]!
  143. expect(start.request.provider).toBe('openai')
  144. expect(start.request.model).toBeUndefined()
  145. host.close()
  146. })
  147. it('a schema child completing WITHOUT a structured value resolves null with a failed outcome', async () => {
  148. const host = fakeHost({ reply: () => text('prose, no structure') })
  149. void runWorkerSession(host.port, init("return await agent('p', { schema: { type: 'object' } })"))
  150. const result = await host.result()
  151. expect(result.value).toBeNull()
  152. expect(host.ofType(WorkerToHostType.AgentEnd)[0]!.info.outcome).toBe('failed')
  153. host.close()
  154. })
  155. it('a child settling non-completed resolves null (scripts filter), never throwing into the script', async () => {
  156. const host = fakeHost({ reply: (_request, index) => index === 0 ? { output: [], stopReason: 'error' } : text('ok') })
  157. void runWorkerSession(host.port, init("return await parallel([() => agent('one'), () => agent('two')])"))
  158. const result = await host.result()
  159. expect(result.value).toEqual([null, 'ok'])
  160. expect(host.ofType(WorkerToHostType.AgentEnd).map(m => m.info.outcome)).toEqual(expect.arrayContaining(['failed', 'completed']))
  161. host.close()
  162. })
  163. it('a start refusal (child-start-error) is a fatal AGENT_START that kills the script through a combinator', async () => {
  164. const host = fakeHost({ refuse: () => 'no provider here' })
  165. void runWorkerSession(host.port, init("return await pipeline([1], () => agent('p'))"))
  166. const result = await host.result()
  167. expect(result.stopReason).toBe('error')
  168. expect(result.error).toContain('agent() could not start a child')
  169. expect(result.error).toContain('no provider here')
  170. host.close()
  171. })
  172. it('a child-failed message (infrastructure rejection) is fatal AGENT_RESULT with the paired failed outcome', async () => {
  173. const host = fakeHost()
  174. void runWorkerSession(host.port, init(`
  175. try { await agent('p'); return 'unreachable' } catch (e) { return { name: e.name, code: e.code, fatal: e.fatal } }
  176. `))
  177. await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildStart).length).toBe(1) })
  178. const callId = host.ofType(WorkerToHostType.ChildStart)[0]!.callId
  179. host.send({ type: HostToWorkerType.ChildStarted, callId, childId: 'child-0' })
  180. host.send({ type: HostToWorkerType.ChildFailed, callId, rendered: 'backend exploded' })
  181. const result = await host.result()
  182. expect(result.value).toMatchObject({ name: 'WorkflowError', code: 'AGENT_RESULT', fatal: true })
  183. expect(host.ofType(WorkerToHostType.AgentEnd)[0]!.info.outcome).toBe('failed')
  184. host.close()
  185. })
  186. it('cancel before go: the body never runs at all and the result is cancelled (a second cancel is a no-op)', async () => {
  187. const host = fakeHost({ go: false })
  188. const session = runWorkerSession(host.port, init("log('ran')\nreturn 123"))
  189. await vi.waitFor(() => { expect(host.messages.some(m => m.type === WorkerToHostType.Ready)).toBe(true) })
  190. host.send({ type: HostToWorkerType.Cancel, reason: 'aborted before start' })
  191. // Idempotence: the first reason wins; a duplicate cancel changes nothing.
  192. host.send({ type: HostToWorkerType.Cancel, reason: 'a later reason that must lose' })
  193. const result = await host.result()
  194. await session
  195. expect(result.stopReason).toBe('cancelled')
  196. expect(result.error).toContain('aborted before start')
  197. expect(result.error).not.toContain('must lose')
  198. expect(result.value).toBeNull()
  199. expect(host.ofType(WorkerToHostType.Log)).toEqual([])
  200. host.close()
  201. })
  202. it('a script with no return value resolves value: null', async () => {
  203. const host = fakeHost({ reply: () => text('ok') })
  204. void runWorkerSession(host.port, init("await agent('p')"))
  205. const result = await host.result()
  206. expect(result.stopReason).toBe('completed')
  207. expect(result.value).toBeNull()
  208. host.close()
  209. })
  210. it('cancel mid-run: hooks throw at entry and the run reports cancelled', async () => {
  211. const host = fakeHost()
  212. void runWorkerSession(host.port, init(`
  213. phase('before')
  214. try { await agent('x') } catch (e) {}
  215. try { phase('after') } catch (e) {}
  216. try { log('after') } catch (e) {}
  217. try { await parallel([() => 'ran']) } catch (e) {}
  218. try { await pipeline(['item'], p => p) } catch (e) {}
  219. return 'survived by catching'
  220. `))
  221. await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildStart).length).toBe(1) })
  222. const callId = host.ofType(WorkerToHostType.ChildStart)[0]!.callId
  223. host.send({ type: HostToWorkerType.ChildStarted, callId, childId: 'child-0' })
  224. host.send({ type: HostToWorkerType.Cancel, reason: 'stop everything' })
  225. // The real host settles the aborted child; mirror it.
  226. host.send({ type: HostToWorkerType.ChildSettled, callId, result: { output: [], stopReason: 'aborted' } })
  227. const result = await host.result()
  228. expect(result.stopReason).toBe('cancelled')
  229. expect(result.error).toContain('stop everything')
  230. expect(host.ofType(WorkerToHostType.AgentEnd)[0]!.info.outcome).toBe('cancelled')
  231. // No post-cancel narration left the runtime (the hooks threw at entry).
  232. expect(host.ofType(WorkerToHostType.Phase).map(m => m.title)).toEqual(['before'])
  233. expect(host.ofType(WorkerToHostType.Log)).toEqual([])
  234. host.close()
  235. })
  236. it('cancellation between a queued waiter and its slot: the waiter rejects without a child-start', async () => {
  237. const host = fakeHost({ go: true })
  238. void runWorkerSession(host.port, init(
  239. "return await parallel([() => agent('a'), () => agent('b')])",
  240. undefined,
  241. { maxConcurrentAgents: 1 },
  242. ))
  243. await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildStart).length).toBe(1) })
  244. host.send({ type: HostToWorkerType.Cancel, reason: 'raced' })
  245. const result = await host.result()
  246. expect(result.stopReason).toBe('cancelled')
  247. // Only the first agent ever reached the host.
  248. expect(host.ofType(WorkerToHostType.ChildStart).length).toBe(1)
  249. host.close()
  250. })
  251. it('a stray (never-awaited) agent is reaped after settlement: cancel + dispose RPCs flow, no unhandled rejection', async () => {
  252. const unhandled: unknown[] = []
  253. const onUnhandled = (reason: unknown): void => { unhandled.push(reason) }
  254. process.on('unhandledRejection', onUnhandled)
  255. try {
  256. const host = fakeHost()
  257. void runWorkerSession(host.port, init(`
  258. agent('stray, never awaited')
  259. return 'done without awaiting'
  260. `))
  261. const result = await host.result()
  262. expect(result.stopReason).toBe('completed')
  263. await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildStart).length).toBe(1) })
  264. const callId = host.ofType(WorkerToHostType.ChildStart)[0]!.callId
  265. host.send({ type: HostToWorkerType.ChildStarted, callId, childId: 'child-0' })
  266. host.send({ type: HostToWorkerType.ChildSettled, callId, result: { output: [], stopReason: 'aborted' } })
  267. await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildDispose).map(m => m.callId)).toContain(callId) })
  268. await new Promise(resolve => setTimeout(resolve, 20))
  269. expect(unhandled).toEqual([])
  270. host.close()
  271. } finally {
  272. process.off('unhandledRejection', onUnhandled)
  273. }
  274. })
  275. it('an unparseable body settles an error result instead of dying without one (host pre-parse skew guard)', async () => {
  276. const host = fakeHost()
  277. await runWorkerSession(host.port, init('return ((('))
  278. const result = await host.result()
  279. expect(result.stopReason).toBe('error')
  280. expect(result.error).toContain('does not parse')
  281. expect(result.agentsStarted).toBe(0)
  282. host.close()
  283. })
  284. it('a synchronous spin in the initial slice dies by the in-worker vm timeout', async () => {
  285. const host = fakeHost()
  286. void runWorkerSession(host.port, init('while (true) {}', undefined, { syncTimeoutMs: 50 }))
  287. const result = await host.result()
  288. expect(result.stopReason).toBe('error')
  289. expect(result.error?.toLowerCase()).toContain('timed out')
  290. host.close()
  291. })
  292. it('a non-JSON return value fails loud as RESULT_UNSERIALIZABLE', async () => {
  293. const host = fakeHost()
  294. void runWorkerSession(host.port, init('return { when: new Date(0) }'))
  295. const result = await host.result()
  296. expect(result.stopReason).toBe('error')
  297. expect(result.error).toContain('not plain JSON data')
  298. host.close()
  299. })
  300. it('tolerates replies for unknown callIds (a teardown race): nothing crashes, the run completes', async () => {
  301. const host = fakeHost({ reply: () => text('fine') })
  302. void runWorkerSession(host.port, init("return await agent('p')"))
  303. host.send({ type: HostToWorkerType.ChildStarted, callId: 999, childId: 'ghost' })
  304. host.send({ type: HostToWorkerType.ChildStartError, callId: 999, rendered: 'ghost' })
  305. host.send({ type: HostToWorkerType.ChildSettled, callId: 999, result: text('ghost') })
  306. host.send({ type: HostToWorkerType.ChildFailed, callId: 999, rendered: 'ghost' })
  307. host.send({ type: HostToWorkerType.ChildDisposed, callId: 999 })
  308. const result = await host.result()
  309. expect(result.stopReason).toBe('completed')
  310. expect(result.value).toBe('fine')
  311. host.close()
  312. })
  313. it('caps and malformed hook arguments reject loud (the runtime runs unchanged inside the session)', async () => {
  314. const cases: [string, string][] = [
  315. ['return await agent(42)', 'non-empty prompt string'],
  316. ["return await agent('')", 'non-empty prompt string'],
  317. ["return await agent('p', 'opts')", 'options must be an object'],
  318. ["return await agent('p', { label: 3 })", '"label" must be a string'],
  319. ["return await agent('p', { get label() { throw new Error('read failed') } })", 'options must be plain JSON data'],
  320. ["return await agent('p', { bogus: true })", '"bogus" is not recognized'],
  321. ["return await agent('p', { effort: 'high' })", '"effort" is deferred and not supported by this engine (supported: label, phase, schema, provider, model)'],
  322. ["return await agent('p', { schema: { type: 'object', oneOf: [] } })", 'outside the supported subset'],
  323. ['return await parallel([() => 1, () => 2, () => 3])', 'over the per-call cap (2)'],
  324. ['return await pipeline([1, 2, 3], (x) => x)', 'maxItemsPerCall'],
  325. ["return await parallel('no')", 'parallel() requires an array'],
  326. ['return await parallel([3])', 'item 0 is not a function'],
  327. ["return await pipeline('no', () => 1)", 'pipeline() requires an items array'],
  328. ['return await pipeline([1])', 'at least one stage'],
  329. ["return await pipeline([1], 'x')", 'stage 0 is not a function'],
  330. ["phase('')", 'phase() requires a non-empty title string'],
  331. ['log(3)', 'log() requires a message string'],
  332. ]
  333. for (const [body, expected] of cases) {
  334. const host = fakeHost({ reply: () => text('ok') })
  335. void runWorkerSession(host.port, init(body, undefined, { maxItemsPerCall: 2 }))
  336. const result = await host.result()
  337. expect(result.stopReason).toBe('error')
  338. expect(result.error).toContain(expected)
  339. host.close()
  340. }
  341. })
  342. it('combinator semantics: thunk/stage throws null the item; a forged fatal-shaped object stays null; real fatals propagate', async () => {
  343. const host = fakeHost({ reply: () => text('fine') })
  344. void runWorkerSession(host.port, init(`
  345. const viaParallel = await parallel([
  346. () => { throw new Error('boom') },
  347. () => agent('fine'),
  348. () => 'plain value',
  349. () => { throw { name: 'WorkflowError', fatal: true, message: 'forged fatal' } },
  350. ])
  351. const viaPipeline = await pipeline([10, 20],
  352. (prev, item, index) => { if (item === 10) throw new Error('ordinary failure'); return 'kept-' + item + '-' + index },
  353. )
  354. return { viaParallel, viaPipeline }
  355. `))
  356. const result = await host.result()
  357. expect(result.stopReason).toBe('completed')
  358. expect(result.value).toEqual({
  359. viaParallel: [null, 'fine', 'plain value', null],
  360. viaPipeline: [null, 'kept-20-1'],
  361. })
  362. host.close()
  363. })
  364. it('trips the total-agent cap with a message naming the config knob', async () => {
  365. const host = fakeHost({ reply: () => text('ok') })
  366. void runWorkerSession(host.port, init("await agent('1'); await agent('2'); await agent('3')", undefined, { maxTotalAgents: 2 }))
  367. const result = await host.result()
  368. expect(result.stopReason).toBe('error')
  369. expect(result.error).toContain('total agent cap (2)')
  370. expect(result.agentsStarted).toBe(2)
  371. host.close()
  372. })
  373. it('queued agents proceed through the concurrency semaphore in FIFO order', async () => {
  374. const host = fakeHost({ reply: request => text(`ok:${request.prompt}`) })
  375. void runWorkerSession(host.port, init(
  376. "return await parallel([1, 2, 3].map((n) => () => agent('job ' + n)))",
  377. undefined,
  378. { maxConcurrentAgents: 1 },
  379. ))
  380. const result = await host.result()
  381. expect(result.value).toEqual(['ok:job 1', 'ok:job 2', 'ok:job 3'])
  382. host.close()
  383. })
  384. it('labels default from the prompt first line, truncated; explicit label/phase options win', async () => {
  385. const host = fakeHost({ reply: () => text('ok') })
  386. void runWorkerSession(host.port, init(`
  387. phase('Find')
  388. await agent('a prompt that is quite long and will surely get truncated down to a display label\\n'
  389. + 'with a second line the label must not include')
  390. await agent('short', { label: 'named', phase: 'Custom' })
  391. return null
  392. `))
  393. await host.result()
  394. const starts = host.ofType(WorkerToHostType.AgentStart).map(m => m.info)
  395. expect(starts[0]).toMatchObject({ seq: 1, phase: 'Find' })
  396. expect(starts[0]!.label.length).toBeLessThanOrEqual(48)
  397. expect(starts[0]!.label).not.toContain('second line')
  398. expect(starts[1]).toMatchObject({ seq: 2, label: 'named', phase: 'Custom' })
  399. host.close()
  400. })
  401. it('non-text output blocks are filtered out of the text result', async () => {
  402. const host = fakeHost({
  403. reply: () => ({
  404. output: [
  405. { type: 'text', text: 'first ' },
  406. { type: 'tool_call', id: 'c1', name: 'x', arguments: {} } as never,
  407. { type: 'text', text: 'second' },
  408. ],
  409. stopReason: 'completed',
  410. }),
  411. })
  412. void runWorkerSession(host.port, init("return await agent('p')"))
  413. const result = await host.result()
  414. expect(result.value).toBe('first second')
  415. host.close()
  416. })
  417. it('a cancel landing DURING the start round-trip disposes the fresh child and dies cancelled', async () => {
  418. const host = fakeHost({ manual: true })
  419. void runWorkerSession(host.port, init("return await agent('p')"))
  420. await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildStart).length).toBe(1) })
  421. const callId = host.ofType(WorkerToHostType.ChildStart)[0]!.callId
  422. // Simulate a teardown race by delivering cancellation before a stale start reply.
  423. host.send({ type: HostToWorkerType.Cancel, reason: 'raced the start' })
  424. host.send({ type: HostToWorkerType.ChildStarted, callId, childId: 'child-0' })
  425. const result = await host.result()
  426. expect(result.stopReason).toBe('cancelled')
  427. await vi.waitFor(() => {
  428. expect(host.ofType(WorkerToHostType.ChildDispose).map(m => m.callId)).toContain(callId)
  429. })
  430. // The unpublished child is disposed without a lifecycle announcement.
  431. expect(host.ofType(WorkerToHostType.AgentStart)).toEqual([])
  432. host.close()
  433. })
  434. it('a start refusal arriving after a cancel reads as the cancellation, not a broken seam', async () => {
  435. const host = fakeHost({ manual: true })
  436. void runWorkerSession(host.port, init(`
  437. try { await agent('p'); return 'unreachable' } catch (e) { return { code: e.code } }
  438. `))
  439. await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildStart).length).toBe(1) })
  440. const callId = host.ofType(WorkerToHostType.ChildStart)[0]!.callId
  441. host.send({ type: HostToWorkerType.Cancel, reason: 'stopping' })
  442. host.send({ type: HostToWorkerType.ChildStartError, callId, rendered: 'workflow run cancelled: stopping' })
  443. const result = await host.result()
  444. // The run reports cancelled (the script died of CANCELLED, not AGENT_START).
  445. expect(result.stopReason).toBe('cancelled')
  446. host.close()
  447. })
  448. it('a child result rejection while cancelled pairs a cancelled agent-end, and the run reports cancelled', async () => {
  449. const host = fakeHost({ manual: true })
  450. void runWorkerSession(host.port, init("return await agent('doomed')"))
  451. await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildStart).length).toBe(1) })
  452. const callId = host.ofType(WorkerToHostType.ChildStart)[0]!.callId
  453. host.send({ type: HostToWorkerType.ChildStarted, callId, childId: 'child-0' })
  454. await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.AgentStart).length).toBe(1) })
  455. host.send({ type: HostToWorkerType.Cancel, reason: 'user aborted' })
  456. host.send({ type: HostToWorkerType.ChildFailed, callId, rendered: 'backend crashed on abort' })
  457. const result = await host.result()
  458. expect(result.stopReason).toBe('cancelled')
  459. expect(host.ofType(WorkerToHostType.AgentEnd)[0]!.info.outcome).toBe('cancelled')
  460. host.close()
  461. })
  462. })
  463. describe('the worker bootstrap', () => {
  464. it('requireParentPort narrows a real port and throws on the main thread', () => {
  465. const channel = new MessageChannel()
  466. expect(requireParentPort(channel.port1)).toBe(channel.port1)
  467. channel.port1.close()
  468. expect(() => requireParentPort(null)).toThrow(/inside a worker thread/)
  469. })
  470. it('the entry module itself throws when loaded on the main thread (no parentPort)', async () => {
  471. // This import EXECUTES ../src/worker.ts on the main thread, which is what
  472. // covers the bootstrap file: requireParentPort throws before
  473. // runWorkerSession is reached.
  474. await expect(import('../src/worker.ts')).rejects.toThrow(/inside a worker thread/)
  475. })
  476. })