promise.test.ts 41 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240
  1. import { describe, expect, test } from "bun:test"
  2. import { Effect, Schema } from "effect"
  3. import { CodeMode, Tool, toolError } from "../src/index.js"
  4. // Wave 5 acceptance suite: first-class promise values. Un-awaited tool calls start eagerly on
  5. // supervised fibers, `await` settles them, Promise.all/allSettled/race/resolve/reject are
  6. // ordinary functions over arbitrary arrays mixing promises and plain values, and
  7. // .then/.catch/.finally chain reactions onto any promise.
  8. type Trace = {
  9. starts: Array<number>
  10. active: number
  11. maxActive: number
  12. completed: number
  13. interrupted: number
  14. }
  15. const makeTrace = (): Trace => ({ starts: [], active: 0, maxActive: 0, completed: 0, interrupted: 0 })
  16. /** Echoes `id` after `ms` milliseconds, recording start order, live concurrency, and interruption. */
  17. const sleepyTool = (trace: Trace) =>
  18. Tool.make({
  19. description: "Echo an id after a delay",
  20. input: Schema.Struct({ id: Schema.Number, ms: Schema.optionalKey(Schema.Number) }),
  21. output: Schema.Number,
  22. run: ({ id, ms }) =>
  23. Effect.gen(function* () {
  24. trace.starts.push(id)
  25. trace.active += 1
  26. trace.maxActive = Math.max(trace.maxActive, trace.active)
  27. yield* Effect.sleep(ms ?? 20)
  28. trace.active -= 1
  29. trace.completed += 1
  30. return id
  31. }).pipe(
  32. Effect.onInterrupt(() =>
  33. Effect.sync(() => {
  34. trace.active -= 1
  35. trace.interrupted += 1
  36. }),
  37. ),
  38. ),
  39. })
  40. const failingTool = Tool.make({
  41. description: "Always refuse",
  42. input: Schema.Struct({}),
  43. output: Schema.String,
  44. run: () => Effect.fail(toolError("Lookup refused")),
  45. })
  46. const interruptedTool = Tool.make({
  47. description: "Interrupt this call",
  48. input: Schema.Struct({}),
  49. output: Schema.String,
  50. run: () => Effect.interrupt,
  51. })
  52. const completedTool = (trace: Trace) =>
  53. Tool.make({
  54. description: "Return the number of completed sleepy calls",
  55. input: Schema.Struct({}),
  56. output: Schema.Number,
  57. run: () => Effect.succeed(trace.completed),
  58. })
  59. /** Never settles, and holds interruption cleanup for `cleanupMs` so completion cleanup can outlast a timeout. */
  60. const stubbornTool = (trace: Trace) =>
  61. Tool.make({
  62. description: "Never settle; clean up slowly when interrupted",
  63. input: Schema.Struct({ cleanupMs: Schema.Number }),
  64. output: Schema.Number,
  65. run: ({ cleanupMs }) =>
  66. Effect.never.pipe(
  67. Effect.onInterrupt(() =>
  68. Effect.andThen(
  69. Effect.sleep(cleanupMs),
  70. Effect.sync(() => {
  71. trace.interrupted += 1
  72. }),
  73. ),
  74. ),
  75. ),
  76. })
  77. const run = (
  78. code: string,
  79. options: { trace?: Trace; limits?: CodeMode.ExecutionLimits } = {},
  80. ): Promise<CodeMode.Result> => {
  81. const trace = options.trace ?? makeTrace()
  82. return Effect.runPromise(
  83. CodeMode.execute({
  84. tools: {
  85. host: {
  86. sleepy: sleepyTool(trace),
  87. fail: failingTool,
  88. interrupt: interruptedTool,
  89. completed: completedTool(trace),
  90. stubborn: stubbornTool(trace),
  91. },
  92. },
  93. code,
  94. ...(options.limits ? { limits: options.limits } : {}),
  95. }),
  96. )
  97. }
  98. const value = async (code: string, options: { trace?: Trace; limits?: CodeMode.ExecutionLimits } = {}) => {
  99. const result = await run(code, options)
  100. if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
  101. return result.value
  102. }
  103. const error = async (code: string, options: { trace?: Trace; limits?: CodeMode.ExecutionLimits } = {}) => {
  104. const result = await run(code, options)
  105. if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`)
  106. return result.error
  107. }
  108. describe("first-class promise values", () => {
  109. test("async functions return promises with isolated concurrent invocations", async () => {
  110. expect(
  111. await value(`
  112. const load = async (id) => {
  113. const result = await tools.host.sleepy({ id, ms: 20 })
  114. return [id, result]
  115. }
  116. const first = load(1)
  117. const second = load(2)
  118. return [first instanceof Promise, second instanceof Promise, await Promise.all([first, second])]
  119. `),
  120. ).toEqual([
  121. true,
  122. true,
  123. [
  124. [1, 1],
  125. [2, 2],
  126. ],
  127. ])
  128. })
  129. test("async function errors reject instead of throwing at the call site", async () => {
  130. expect(
  131. await value(`
  132. const fail = async () => { throw new Error("boom") }
  133. const promise = fail()
  134. try {
  135. await promise
  136. return "no"
  137. } catch (error) {
  138. return error.message
  139. }
  140. `),
  141. ).toBe("boom")
  142. })
  143. test("an un-awaited tool call starts eagerly, in call order, before any await", async () => {
  144. const trace = makeTrace()
  145. const result = await value(
  146. `
  147. const a = tools.host.sleepy({ id: 1, ms: 40 })
  148. const b = tools.host.sleepy({ id: 2, ms: 40 })
  149. const rb = await b
  150. const ra = await a
  151. return [ra, rb]
  152. `,
  153. { trace },
  154. )
  155. expect(result).toEqual([1, 2])
  156. expect(trace.starts).toEqual([1, 2])
  157. // Both calls overlapped even though they were awaited sequentially.
  158. expect(trace.maxActive).toBeGreaterThan(1)
  159. })
  160. test("awaiting the same promise twice settles once and never re-runs the call", async () => {
  161. const result = await run(`
  162. const p = tools.host.sleepy({ id: 7 })
  163. const x = await p
  164. const y = await p
  165. return [x, y]
  166. `)
  167. expect(result.ok).toBe(true)
  168. if (!result.ok) return
  169. expect(result.value).toEqual([7, 7])
  170. expect(result.toolCalls).toStrictEqual([{ name: "host.sleepy" }])
  171. })
  172. test("await of a non-promise value passes it through unchanged", async () => {
  173. expect(await value(`return await 42`)).toBe(42)
  174. expect(await value(`const x = await "s"; return x`)).toBe("s")
  175. expect(await value(`return await null`)).toBeNull()
  176. expect(await value(`return (await [1, 2]).length`)).toBe(2)
  177. })
  178. test("returning an un-awaited tool call resolves it (async-function return semantics)", async () => {
  179. expect(await value(`return tools.host.sleepy({ id: 9 })`)).toBe(9)
  180. })
  181. test("typeof a promise is 'object', and console.log renders it sensibly", async () => {
  182. const result = await run(`
  183. const p = Promise.resolve(1)
  184. console.log(p)
  185. return typeof p
  186. `)
  187. expect(result.ok).toBe(true)
  188. if (!result.ok) return
  189. expect(result.value).toBe("object")
  190. expect(result.logs).toStrictEqual(["[Promise (await it to get its value)]"])
  191. })
  192. test("an awaited failure is catchable exactly like a synchronous throw", async () => {
  193. const result = await run(`
  194. const p = tools.host.fail({})
  195. try {
  196. await p
  197. return "no"
  198. } catch (e) {
  199. return e.message
  200. }
  201. `)
  202. expect(result.ok).toBe(true)
  203. if (!result.ok) return
  204. expect(result.value).toBe("Lookup refused")
  205. expect(result.warnings).toBeUndefined()
  206. })
  207. test("a fire-and-forget call is interrupted when the program returns", async () => {
  208. const trace = makeTrace()
  209. const result = await run(
  210. `
  211. tools.host.sleepy({ id: 1, ms: 30 })
  212. return "done"
  213. `,
  214. { trace },
  215. )
  216. expect(result.ok).toBe(true)
  217. if (!result.ok) return
  218. expect(result.value).toBe("done")
  219. expect(result.warnings).toBeUndefined()
  220. expect(trace.completed).toBe(0)
  221. expect(trace.interrupted).toBe(1)
  222. })
  223. test("a never-awaited failing call preserves the result and reports the rejection", async () => {
  224. const result = await run(`
  225. tools.host.fail({})
  226. return "done"
  227. `)
  228. expect(result.ok).toBe(true)
  229. if (!result.ok) return
  230. expect(result.value).toBe("done")
  231. expect(result.warnings).toStrictEqual([
  232. { kind: "ToolFailure", message: "Unhandled rejection from an un-awaited promise: Lookup refused" },
  233. ])
  234. expect(Schema.decodeUnknownSync(CodeMode.Result)(JSON.parse(JSON.stringify(result)))).toStrictEqual(result)
  235. })
  236. test("a never-awaited failing async function is reported with a successful result", async () => {
  237. const result = await run(`
  238. const fail = async () => { throw new Error("boom") }
  239. fail()
  240. return "done"
  241. `)
  242. expect(result.ok).toBe(true)
  243. if (!result.ok) return
  244. expect(result.value).toBe("done")
  245. expect(result.warnings).toStrictEqual([
  246. { kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: boom" },
  247. ])
  248. })
  249. test("output truncation bounds warning diagnostics with an in-band marker", async () => {
  250. const result = await run(
  251. `
  252. for (let i = 0; i < 100; i += 1) Promise.reject(new Error("x".repeat(1_000)))
  253. return "done"
  254. `,
  255. { limits: { maxOutputBytes: 64 } },
  256. )
  257. expect(result.ok).toBe(true)
  258. if (!result.ok) return
  259. expect(result.truncated).toBe(true)
  260. expect(result.warnings).toStrictEqual([
  261. { kind: "Truncated", message: "100 additional warnings omitted by the output limit." },
  262. ])
  263. })
  264. test("a budget-consuming value does not starve warnings", async () => {
  265. const result = await run(
  266. `
  267. Promise.reject(new Error("boom"))
  268. return "x".repeat(500)
  269. `,
  270. { limits: { maxOutputBytes: 128 } },
  271. )
  272. expect(result.ok).toBe(true)
  273. if (!result.ok) return
  274. expect(result.truncated).toBe(true)
  275. expect(typeof result.value).toBe("string")
  276. expect(result.warnings).toStrictEqual([
  277. { kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: boom" },
  278. ])
  279. })
  280. test("an un-awaited async function's pending chain is interrupted at the return", async () => {
  281. const trace = makeTrace()
  282. const result = await run(
  283. `
  284. const run = async () => {
  285. await tools.host.sleepy({ id: 1, ms: 60000 })
  286. tools.host.fail({})
  287. }
  288. run()
  289. return "done"
  290. `,
  291. { trace },
  292. )
  293. expect(result.ok).toBe(true)
  294. if (!result.ok) return
  295. expect(result.value).toBe("done")
  296. expect(result.warnings).toBeUndefined()
  297. expect(trace.starts).toEqual([1])
  298. expect(trace.completed).toBe(0)
  299. expect(trace.interrupted).toBe(1)
  300. })
  301. test("reports every unhandled rejection in promise creation order", async () => {
  302. const result = await run(`
  303. Promise.reject(new Error("first"))
  304. tools.host.fail({})
  305. Promise.reject(new Error("third"))
  306. return "done"
  307. `)
  308. expect(result.ok).toBe(true)
  309. if (!result.ok) return
  310. expect(result.warnings).toStrictEqual([
  311. { kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: first" },
  312. { kind: "ToolFailure", message: "Unhandled rejection from an un-awaited promise: Lookup refused" },
  313. { kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: third" },
  314. ])
  315. })
  316. test("orders an async function rejection before promises created inside its body", async () => {
  317. const result = await run(`
  318. const outer = async () => {
  319. Promise.reject(new Error("inner"))
  320. throw new Error("outer")
  321. }
  322. outer()
  323. return "done"
  324. `)
  325. expect(result.ok).toBe(true)
  326. if (!result.ok) return
  327. expect(result.warnings).toStrictEqual([
  328. { kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: outer" },
  329. { kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: inner" },
  330. ])
  331. })
  332. test("un-awaited interruptions settle without becoming rejections", async () => {
  333. const result = await run(`
  334. tools.host.interrupt({})
  335. Promise.all([tools.host.interrupt({})])
  336. return "done"
  337. `)
  338. expect(result.ok).toBe(true)
  339. if (!result.ok) return
  340. expect(result.value).toBe("done")
  341. expect(result.warnings).toBeUndefined()
  342. })
  343. test("a fatal program error cancels outstanding work without reporting unhandled rejections", async () => {
  344. const trace = makeTrace()
  345. const result = await run(
  346. `
  347. tools.host.sleepy({ id: 1, ms: 1_000 })
  348. throw new Error("boom")
  349. `,
  350. { trace },
  351. )
  352. expect(result.ok).toBe(false)
  353. if (result.ok) return
  354. expect(result.error.message).toBe("Uncaught: boom")
  355. expect("warnings" in result).toBe(false)
  356. expect(trace.completed).toBe(0)
  357. expect(trace.interrupted).toBe(1)
  358. })
  359. test("async-function promises remain owned by the execution after the function returns", async () => {
  360. const trace = makeTrace()
  361. expect(
  362. await value(
  363. `
  364. const launch = async () => {
  365. tools.host.sleepy({ id: 1, ms: 60000 })
  366. Promise.all([tools.host.sleepy({ id: 2, ms: 60000 })])
  367. return "returned"
  368. }
  369. return await launch()
  370. `,
  371. { trace },
  372. ),
  373. ).toBe("returned")
  374. // Both calls outlive launch() itself - they belong to the execution, not the function -
  375. // and are interrupted only when the whole program returns.
  376. expect(trace.starts).toEqual([1, 2])
  377. expect(trace.completed).toBe(0)
  378. expect(trace.interrupted).toBe(2)
  379. })
  380. })
  381. describe("promises at data boundaries", () => {
  382. test("returning an un-awaited promise inside data is a clear await-hinting diagnostic", async () => {
  383. const diagnostic = await error(`return { result: tools.host.sleepy({ id: 1 }) }`)
  384. expect(diagnostic.kind).toBe("InvalidDataValue")
  385. expect(diagnostic.message).toContain("un-awaited Promise")
  386. expect(diagnostic.message).toContain("await tools.ns.tool(...)")
  387. })
  388. test("collection helpers do not let un-awaited promises cross the result boundary", async () => {
  389. const diagnostic = await error(`return Array.from([Promise.resolve(1)])`)
  390. expect(diagnostic.kind).toBe("InvalidDataValue")
  391. expect(diagnostic.message).toContain("un-awaited Promise")
  392. })
  393. test("invalid returned data cancels pending work", async () => {
  394. const trace = makeTrace()
  395. const result = await run(
  396. `
  397. const pending = tools.host.sleepy({ id: 1, ms: 60_000 })
  398. return { pending }
  399. `,
  400. { trace, limits: { timeoutMs: 100 } },
  401. )
  402. expect(result.ok).toBe(false)
  403. if (result.ok) return
  404. expect(result.error.kind).toBe("InvalidDataValue")
  405. expect(trace.completed).toBe(0)
  406. expect(trace.interrupted).toBe(1)
  407. })
  408. test("passing an un-awaited promise as a tool argument is a clear diagnostic", async () => {
  409. const diagnostic = await error(`return await tools.host.sleepy({ id: tools.host.sleepy({ id: 1 }) })`)
  410. expect(diagnostic.kind).toBe("InvalidDataValue")
  411. expect(diagnostic.message).toContain("un-awaited Promise")
  412. })
  413. test("JSON.stringify of a promise is a diagnostic, not '{}'", async () => {
  414. const diagnostic = await error(`return JSON.stringify(Promise.resolve(1))`)
  415. expect(diagnostic.kind).toBe("InvalidDataValue")
  416. expect(diagnostic.message).toContain("un-awaited Promise")
  417. })
  418. test("operators reject promise operands", async () => {
  419. const diagnostic = await error(`return Promise.resolve(1) + 1`)
  420. expect(diagnostic.kind).toBe("InvalidDataValue")
  421. })
  422. })
  423. describe("Promise.all over arbitrary arrays", () => {
  424. test("combinators return promises that can be assigned and awaited later", async () => {
  425. expect(
  426. await value(`
  427. const all = Promise.all([Promise.resolve(1)])
  428. const settled = Promise.allSettled([Promise.reject("no")])
  429. const race = Promise.race([Promise.resolve(2)])
  430. const promises = [all instanceof Promise, settled instanceof Promise, race instanceof Promise]
  431. return [promises, await all, await settled, await race]
  432. `),
  433. ).toEqual([[true, true, true], [1], [{ status: "rejected", reason: "no" }], 2])
  434. })
  435. test("separately-created aggregate batches overlap before either is awaited", async () => {
  436. const trace = makeTrace()
  437. expect(
  438. await value(
  439. `
  440. const first = Promise.all([tools.host.sleepy({ id: 1, ms: 40 })])
  441. const second = Promise.all([tools.host.sleepy({ id: 2, ms: 40 })])
  442. return [await first, await second]
  443. `,
  444. { trace },
  445. ),
  446. ).toEqual([[1], [2]])
  447. expect(trace.starts).toEqual([1, 2])
  448. expect(trace.maxActive).toBeGreaterThan(1)
  449. })
  450. test("an aggregate created before a try block rejects at its later await", async () => {
  451. expect(
  452. await value(`
  453. const aggregate = Promise.all([tools.host.fail({})])
  454. try {
  455. await aggregate
  456. return "no"
  457. } catch (error) {
  458. return error.message
  459. }
  460. `),
  461. ).toBe("Lookup refused")
  462. })
  463. test("awaiting an aggregate repeatedly does not rerun its members", async () => {
  464. const result = await run(`
  465. const aggregate = Promise.all([tools.host.sleepy({ id: 7 })])
  466. return [await aggregate, await aggregate]
  467. `)
  468. expect(result.ok).toBe(true)
  469. if (!result.ok) return
  470. expect(result.value).toEqual([[7], [7]])
  471. expect(result.toolCalls).toStrictEqual([{ name: "host.sleepy" }])
  472. })
  473. test("mixes promises and plain values, preserving order", async () => {
  474. expect(
  475. await value(`
  476. return await Promise.all([tools.host.sleepy({ id: 1 }), "plain", tools.host.sleepy({ id: 2 }), 42])
  477. `),
  478. ).toEqual([1, "plain", 2, 42])
  479. })
  480. test("accepts arrays built beforehand, passed as identifiers, and spread elements", async () => {
  481. expect(
  482. await value(`
  483. const calls = []
  484. calls.push(tools.host.sleepy({ id: 1 }))
  485. calls.push(7)
  486. const more = [tools.host.sleepy({ id: 2 })]
  487. const batch = [...calls, ...more, "x"]
  488. return await Promise.all(batch)
  489. `),
  490. ).toEqual([1, 7, 2, "x"])
  491. })
  492. test("runs items.map tool calls in parallel", async () => {
  493. const trace = makeTrace()
  494. const result = await value(
  495. `
  496. const ids = [1, 2, 3, 4]
  497. return await Promise.all(ids.map((id) => tools.host.sleepy({ id, ms: 40 })))
  498. `,
  499. { trace },
  500. )
  501. expect(result).toEqual([1, 2, 3, 4])
  502. // maxActive counts truly-overlapping live executions, so > 1 proves real
  503. // parallelism deterministically - no wall-clock assertion needed.
  504. expect(trace.maxActive).toBeGreaterThan(1)
  505. })
  506. test("runs async map callbacks concurrently", async () => {
  507. const trace = makeTrace()
  508. const result = await value(
  509. `
  510. const ids = [1, 2, 3, 4]
  511. return await Promise.all(ids.map(async (id) => await tools.host.sleepy({ id, ms: 40 })))
  512. `,
  513. { trace },
  514. )
  515. expect(result).toEqual([1, 2, 3, 4])
  516. expect(trace.maxActive).toBeGreaterThan(1)
  517. })
  518. test("caps live tool-call concurrency at the fixed internal constant (8)", async () => {
  519. const trace = makeTrace()
  520. const result = await value(
  521. `
  522. const ids = []
  523. for (let i = 0; i < 20; i += 1) ids.push(i)
  524. const results = await Promise.all(ids.map((id) => tools.host.sleepy({ id, ms: 10 })))
  525. return results.length
  526. `,
  527. { trace },
  528. )
  529. expect(result).toBe(20)
  530. expect(trace.maxActive).toBeGreaterThan(1)
  531. expect(trace.maxActive).toBeLessThanOrEqual(8)
  532. })
  533. test("resolves the empty array", async () => {
  534. expect(await value(`return await Promise.all([])`)).toEqual([])
  535. })
  536. test("rejects with the first failure, catchable in-program", async () => {
  537. const result = await run(`
  538. try {
  539. await Promise.all([tools.host.sleepy({ id: 1 }), tools.host.fail({})])
  540. return "no"
  541. } catch (e) {
  542. return e.message
  543. }
  544. `)
  545. expect(result.ok).toBe(true)
  546. if (!result.ok) return
  547. expect(result.value).toBe("Lookup refused")
  548. expect(result.warnings).toBeUndefined()
  549. })
  550. test("rejects before an earlier slow promise fulfills", async () => {
  551. const trace = makeTrace()
  552. expect(
  553. await value(
  554. `
  555. try {
  556. await Promise.all([
  557. tools.host.sleepy({ id: 1, ms: 100 }),
  558. tools.host.fail({}),
  559. ])
  560. return -1
  561. } catch {
  562. return await tools.host.completed({})
  563. }
  564. `,
  565. { trace },
  566. ),
  567. ).toBe(0)
  568. // The surviving member is observed (Promise.all handled it), so completion interrupts
  569. // it instead of waiting for it.
  570. expect(trace.completed).toBe(0)
  571. expect(trace.interrupted).toBe(1)
  572. })
  573. test("fail-fast does not cancel a sibling the program still holds and awaits", async () => {
  574. const trace = makeTrace()
  575. expect(
  576. await value(
  577. `
  578. const slow = tools.host.sleepy({ id: 1, ms: 40 })
  579. try {
  580. await Promise.all([slow, tools.host.fail({})])
  581. return "no"
  582. } catch {}
  583. return await slow
  584. `,
  585. { trace },
  586. ),
  587. ).toBe(1)
  588. expect(trace.completed).toBe(1)
  589. expect(trace.interrupted).toBe(0)
  590. })
  591. test("a slower observed sibling is interrupted at completion after failing fast", async () => {
  592. const trace = makeTrace()
  593. expect(
  594. await value(
  595. `
  596. const failLater = async () => {
  597. await tools.host.sleepy({ id: 1, ms: 40 })
  598. throw new Error("later")
  599. }
  600. const aggregate = Promise.all([Promise.reject(new Error("first")), failLater()])
  601. try {
  602. await aggregate
  603. return "no"
  604. } catch (error) {
  605. return error.message
  606. }
  607. `,
  608. { trace },
  609. ),
  610. ).toBe("first")
  611. expect(trace.completed).toBe(0)
  612. expect(trace.interrupted).toBe(1)
  613. })
  614. test("a non-collection argument is a clear error", async () => {
  615. const diagnostic = await error(`return await Promise.all(42)`)
  616. expect(diagnostic.message).toContain("Promise.all expects an array")
  617. })
  618. test("exceeding maxToolCalls inside Promise.all is a ToolCallLimitExceeded diagnostic", async () => {
  619. const diagnostic = await error(
  620. `return await Promise.all([tools.host.sleepy({ id: 1 }), tools.host.sleepy({ id: 2 }), tools.host.sleepy({ id: 3 })])`,
  621. { limits: { maxToolCalls: 2 } },
  622. )
  623. expect(diagnostic.kind).toBe("ToolCallLimitExceeded")
  624. })
  625. })
  626. describe("Promise.allSettled", () => {
  627. test("reports fulfilled and rejected outcomes with catch-normalized reasons", async () => {
  628. expect(
  629. await value(`
  630. return await Promise.allSettled([
  631. tools.host.sleepy({ id: 5 }),
  632. tools.host.fail({}),
  633. "plain",
  634. Promise.reject(new Error("boom")),
  635. ])
  636. `),
  637. ).toEqual([
  638. { status: "fulfilled", value: 5 },
  639. { status: "rejected", reason: { name: "Error", message: "Lookup refused" } },
  640. { status: "fulfilled", value: "plain" },
  641. { status: "rejected", reason: { name: "Error", message: "boom" } },
  642. ])
  643. })
  644. test("never rejects for program-level failures", async () => {
  645. const result = await run(`
  646. const settled = await Promise.allSettled([tools.host.fail({}), tools.host.fail({})])
  647. return settled.filter((s) => s.status === "rejected").length
  648. `)
  649. expect(result.ok).toBe(true)
  650. if (!result.ok) return
  651. expect(result.value).toBe(2)
  652. expect(result.warnings).toBeUndefined()
  653. })
  654. })
  655. describe("Promise.race", () => {
  656. test("first settlement wins and a direct loser is interrupted at completion", async () => {
  657. const trace = makeTrace()
  658. const result = await value(
  659. `
  660. const fast = tools.host.sleepy({ id: 1, ms: 10 })
  661. const slow = tools.host.sleepy({ id: 2, ms: 40 })
  662. return await Promise.race([fast, slow])
  663. `,
  664. { trace },
  665. )
  666. expect(result).toBe(1)
  667. // The loser is observed (the race handled it), so the execution does not wait for it.
  668. expect(trace.completed).toBe(1)
  669. expect(trace.interrupted).toBe(1)
  670. })
  671. test("a direct loser remains awaitable after the race settles", async () => {
  672. expect(
  673. await value(`
  674. const fast = tools.host.sleepy({ id: 1, ms: 10 })
  675. const slow = tools.host.sleepy({ id: 2, ms: 40 })
  676. const winner = await Promise.race([fast, slow])
  677. return { winner, loser: await slow }
  678. `),
  679. ).toEqual({ winner: 1, loser: 2 })
  680. })
  681. test("a nested aggregate loser and its members are interrupted at completion", async () => {
  682. const trace = makeTrace()
  683. expect(
  684. await value(
  685. `
  686. const nested = Promise.all([
  687. tools.host.sleepy({ id: 1, ms: 40 }),
  688. tools.host.sleepy({ id: 2, ms: 40 }),
  689. ])
  690. return await Promise.race(["immediate", nested])
  691. `,
  692. { trace },
  693. ),
  694. ).toBe("immediate")
  695. // The nested aggregate and its members are all observed, so nothing waits for them.
  696. expect(trace.completed).toBe(0)
  697. expect(trace.interrupted).toBe(2)
  698. })
  699. test("a rejection can win the race", async () => {
  700. expect(
  701. await value(`
  702. try {
  703. await Promise.race([tools.host.fail({}), tools.host.sleepy({ id: 1, ms: 40 })])
  704. return "no"
  705. } catch (e) {
  706. return e.message
  707. }
  708. `),
  709. ).toBe("Lookup refused")
  710. })
  711. test("a plain value wins over pending promises", async () => {
  712. const trace = makeTrace()
  713. expect(
  714. await value(`return await Promise.race([tools.host.sleepy({ id: 1, ms: 40 }), "immediate"])`, { trace }),
  715. ).toBe("immediate")
  716. expect(trace.completed).toBe(0)
  717. expect(trace.interrupted).toBe(1)
  718. })
  719. test("a rejected race loser is observed by the aggregate", async () => {
  720. const result = await run(`return await Promise.race(["winner", tools.host.fail({})])`)
  721. expect(result.ok).toBe(true)
  722. if (!result.ok) return
  723. expect(result.value).toBe("winner")
  724. expect(result.warnings).toBeUndefined()
  725. })
  726. test("an empty race is a clear error instead of hanging", async () => {
  727. const diagnostic = await error(`return await Promise.race([])`)
  728. expect(diagnostic.message).toContain("never settle")
  729. })
  730. })
  731. describe("Promise.resolve / Promise.reject", () => {
  732. test("resolve wraps plain values and passes promises through", async () => {
  733. expect(await value(`return await Promise.resolve(42)`)).toBe(42)
  734. expect(await value(`return await Promise.resolve(Promise.resolve("nested"))`)).toBe("nested")
  735. expect(await value(`return await Promise.resolve(tools.host.sleepy({ id: 3 }))`)).toBe(3)
  736. expect(await value(`const promise = Promise.resolve(1); return [promise].includes(Promise.resolve(promise))`)).toBe(
  737. true,
  738. )
  739. })
  740. test("reject produces a promise whose await throws the reason", async () => {
  741. expect(
  742. await value(`
  743. try {
  744. await Promise.reject("nope")
  745. return "no"
  746. } catch (e) {
  747. return e
  748. }
  749. `),
  750. ).toBe("nope")
  751. })
  752. test("a rejection observed after settlement is handled", async () => {
  753. expect(
  754. await value(`
  755. const rejected = Promise.reject(new Error("handled"))
  756. await tools.host.sleepy({ id: 1 })
  757. try {
  758. await rejected
  759. return "no"
  760. } catch (error) {
  761. return error.message
  762. }
  763. `),
  764. ).toBe("handled")
  765. })
  766. test("an abandoned rejected promise is reported as unhandled", async () => {
  767. const result = await run(`
  768. Promise.reject(new Error("abandoned"))
  769. return "done"
  770. `)
  771. expect(result.ok).toBe(true)
  772. if (!result.ok) return
  773. expect(result.value).toBe("done")
  774. expect(result.warnings).toStrictEqual([
  775. { kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: abandoned" },
  776. ])
  777. })
  778. })
  779. describe("timeout interruption of forked calls", () => {
  780. test("the execution timeout interrupts in-flight forked fibers", async () => {
  781. const trace = makeTrace()
  782. const result = await run(
  783. `
  784. const a = tools.host.sleepy({ id: 1, ms: 60000 })
  785. const b = tools.host.sleepy({ id: 2, ms: 60000 })
  786. return await a
  787. `,
  788. { trace, limits: { timeoutMs: 100 } },
  789. )
  790. expect(result.ok).toBe(false)
  791. if (result.ok) return
  792. expect(result.error.kind).toBe("TimeoutExceeded")
  793. // Both calls started; neither escaped the timeout - the awaited one AND the abandoned one.
  794. expect(trace.starts).toEqual([1, 2])
  795. expect(trace.interrupted).toBe(2)
  796. expect(trace.completed).toBe(0)
  797. })
  798. test("the timeout also interrupts calls inside Promise.all", async () => {
  799. const trace = makeTrace()
  800. const result = await run(
  801. `return await Promise.all([tools.host.sleepy({ id: 1, ms: 60000 }), tools.host.sleepy({ id: 2, ms: 60000 })])`,
  802. { trace, limits: { timeoutMs: 100 } },
  803. )
  804. expect(result.ok).toBe(false)
  805. if (result.ok) return
  806. expect(result.error.kind).toBe("TimeoutExceeded")
  807. expect(trace.interrupted).toBe(2)
  808. })
  809. test("a non-settling race loser cannot hold the execution to the timeout", async () => {
  810. const trace = makeTrace()
  811. const result = await run(`return await Promise.race(["winner", tools.host.sleepy({ id: 1, ms: 60000 })])`, {
  812. trace,
  813. limits: { timeoutMs: 100 },
  814. })
  815. // Completion interrupts the observed loser immediately; the race result survives.
  816. expect(result.ok).toBe(true)
  817. if (!result.ok) return
  818. expect(result.value).toBe("winner")
  819. expect(result.warnings).toBeUndefined()
  820. expect(trace.starts).toEqual([1])
  821. expect(trace.completed).toBe(0)
  822. expect(trace.interrupted).toBe(1)
  823. })
  824. test("a timeout during completion cleanup keeps the computed value and warns", async () => {
  825. const trace = makeTrace()
  826. const result = await run(
  827. `
  828. tools.host.stubborn({ cleanupMs: 400 })
  829. return "done"
  830. `,
  831. { trace, limits: { timeoutMs: 100 } },
  832. )
  833. expect(result.ok).toBe(true)
  834. if (!result.ok) return
  835. expect(result.value).toBe("done")
  836. expect(result.warnings).toStrictEqual([
  837. {
  838. kind: "TimeoutExceeded",
  839. message:
  840. "The program returned, but background work was still running at the 100ms timeout and was interrupted. Await all started promises.",
  841. },
  842. ])
  843. expect(trace.interrupted).toBe(1)
  844. expect(trace.completed).toBe(0)
  845. })
  846. test("a timeout during completion cleanup reports the timeout warning before settled rejections", async () => {
  847. const result = await run(
  848. `
  849. tools.host.fail({})
  850. tools.host.stubborn({ cleanupMs: 400 })
  851. return "done"
  852. `,
  853. { limits: { timeoutMs: 100 } },
  854. )
  855. expect(result.ok).toBe(true)
  856. if (!result.ok) return
  857. expect(result.value).toBe("done")
  858. expect(result.warnings).toStrictEqual([
  859. {
  860. kind: "TimeoutExceeded",
  861. message:
  862. "The program returned, but background work was still running at the 100ms timeout and was interrupted. Await all started promises.",
  863. },
  864. { kind: "ToolFailure", message: "Unhandled rejection from an un-awaited promise: Lookup refused" },
  865. ])
  866. })
  867. })
  868. describe("promise chaining", () => {
  869. test("then transforms tool results and adopts returned promises across a chain", async () => {
  870. expect(
  871. await value(`
  872. return await tools.host
  873. .sleepy({ id: 2 })
  874. .then((id) => tools.host.sleepy({ id: id + 1 }))
  875. .then((id) => id * 10)
  876. `),
  877. ).toBe(30)
  878. })
  879. test("handlers are deferred and run in attach order", async () => {
  880. expect(
  881. await value(`
  882. const order = []
  883. const promise = Promise.resolve(1)
  884. promise.then(() => order.push("h1"))
  885. promise.then(() => order.push("h2"))
  886. order.push("sync")
  887. await promise
  888. return order
  889. `),
  890. ).toEqual(["sync", "h1", "h2"])
  891. })
  892. test("catch recovers a tool failure and preserves fulfillment", async () => {
  893. expect(
  894. await value(`
  895. return [
  896. await tools.host.fail({}).catch((error) => error.message),
  897. await tools.host.sleepy({ id: 4 }).catch(() => "unused"),
  898. ]
  899. `),
  900. ).toEqual(["Lookup refused", 4])
  901. })
  902. test("finally observes settlement without changing the value", async () => {
  903. expect(
  904. await value(`
  905. const events = []
  906. const result = await tools.host.sleepy({ id: 5 }).finally(() => events.push("cleanup"))
  907. return [result, events]
  908. `),
  909. ).toEqual([5, ["cleanup"]])
  910. })
  911. test("a settled, un-awaited rejected chain tail warns exactly once", async () => {
  912. const result = await run(`
  913. Promise.reject(new Error("boom")).then((value) => value)
  914. await Promise.resolve()
  915. return "done"
  916. `)
  917. expect(result.ok).toBe(true)
  918. if (!result.ok) return
  919. expect(result.value).toBe("done")
  920. // The source rejection belongs to the chain (no warning); only the derived tail warns.
  921. expect(result.warnings).toStrictEqual([
  922. { kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: boom" },
  923. ])
  924. })
  925. test("a catch handler silences the chain's rejection warning", async () => {
  926. const result = await run(`
  927. Promise.reject(new Error("boom")).catch(() => "handled")
  928. await Promise.resolve()
  929. return "done"
  930. `)
  931. expect(result.ok).toBe(true)
  932. if (!result.ok) return
  933. expect(result.warnings).toBeUndefined()
  934. })
  935. test("non-plain-function handlers fail loudly instead of being ignored", async () => {
  936. const diagnostic = await error(`return await tools.host.sleepy({ id: 1 }).then(tools.host.completed)`)
  937. expect(diagnostic.message).toContain("Promise.prototype.then handlers must be plain functions")
  938. })
  939. test("chaining methods are opaque references until called", async () => {
  940. expect(await value(`return typeof tools.host.sleepy({ id: 1 }).then`)).toBe("function")
  941. })
  942. })
  943. describe("combinator settlement timing", () => {
  944. test("a combinator settling one reaction turn after the program returns is interrupted silently", async () => {
  945. // The aggregate's one-turn settlement delay (V8 parity) means an immediately-returning
  946. // program abandons it while still pending: interrupted like any pending work, so no
  947. // rejection warning survives - the member itself was observed by the combinator.
  948. const result = await run(`
  949. Promise.all([Promise.reject(new Error("boom"))])
  950. return "done"
  951. `)
  952. expect(result.ok).toBe(true)
  953. if (!result.ok) return
  954. expect(result.value).toBe("done")
  955. expect(result.warnings).toBeUndefined()
  956. })
  957. test("a combinator settles one reaction turn after its members, as in V8", async () => {
  958. // Regression for the race winner flip: Promise.all's settlement burns a reaction turn,
  959. // so a plain resolved value entered in the same race wins, and a fail-fast aggregate
  960. // cannot beat it into rejection.
  961. expect(
  962. await value(`
  963. const pending = tools.host.sleepy({ id: 9, ms: 60000 })
  964. const winner = await Promise.race([Promise.all([Promise.resolve(1)]), Promise.resolve(2)])
  965. try {
  966. const raced = await Promise.race([Promise.all([Promise.reject("x"), pending]), Promise.resolve("ok")])
  967. return [winner, "fulfilled", raced]
  968. } catch (reason) {
  969. return [winner, "rejected", reason]
  970. }
  971. `),
  972. ).toEqual([2, "fulfilled", "ok"])
  973. })
  974. })
  975. describe("unsupported promise surface", () => {
  976. test("other property reads on a promise hint at the missing await", async () => {
  977. const diagnostic = await error(`return tools.host.sleepy({ id: 1 }).value`)
  978. expect(diagnostic.kind).toBe("InvalidDataValue")
  979. expect(diagnostic.message).toContain("un-awaited Promise")
  980. expect(diagnostic.message).toContain("await it first")
  981. })
  982. test("unknown Promise statics list what is available", async () => {
  983. const diagnostic = await error(`return await Promise.withResolvers()`)
  984. expect(diagnostic.message).toContain("Promise.withResolvers is not available")
  985. expect(diagnostic.message).toContain("Promise.any")
  986. })
  987. })
  988. describe("Promise.any", () => {
  989. test("first tool success wins; failing and losing calls are handled silently", async () => {
  990. const trace = makeTrace()
  991. const result = await run(
  992. `
  993. const winner = await Promise.any([
  994. tools.host.fail({}),
  995. tools.host.sleepy({ id: 1, ms: 5 }),
  996. tools.host.sleepy({ id: 2, ms: 60000 }),
  997. ])
  998. return winner
  999. `,
  1000. { trace },
  1001. )
  1002. expect(result.ok).toBe(true)
  1003. if (!result.ok) return
  1004. expect(result.value).toBe(1)
  1005. // The slow loser stays execution-owned and is interrupted at completion; the tool
  1006. // failure was observed by the aggregate, so no rejection warning survives.
  1007. expect(result.warnings).toBeUndefined()
  1008. expect(trace.interrupted).toBe(1)
  1009. })
  1010. test("all members failing rejects with catch-normalized reasons in input order", async () => {
  1011. expect(
  1012. await value(`
  1013. try {
  1014. await Promise.any([tools.host.fail({}), Promise.reject("plain")])
  1015. return "fulfilled"
  1016. } catch (error) {
  1017. return [error.name, error.errors.map((reason) => reason.message ?? reason)]
  1018. }
  1019. `),
  1020. ).toEqual(["AggregateError", ["Lookup refused", "plain"]])
  1021. })
  1022. test("settles one reaction turn after its deciding member, as in V8", async () => {
  1023. expect(await value(`return await Promise.race([Promise.any([Promise.resolve(1)]), Promise.resolve(2)])`)).toBe(2)
  1024. })
  1025. test("a tie is decided by settlement order, not input order", async () => {
  1026. // Handlers run in attach order, so `first` settles before `second` and wins
  1027. // despite its later input position - as in real JS.
  1028. expect(
  1029. await value(`
  1030. const first = Promise.resolve().then(() => "one")
  1031. const second = Promise.resolve().then(() => "two")
  1032. return await Promise.any([second, first])
  1033. `),
  1034. ).toBe("one")
  1035. })
  1036. test("an abandoned rejecting aggregate is interrupted silently at the return", async () => {
  1037. const result = await run(`
  1038. Promise.any([Promise.reject(new Error("boom"))])
  1039. return "done"
  1040. `)
  1041. expect(result.ok).toBe(true)
  1042. if (!result.ok) return
  1043. expect(result.value).toBe("done")
  1044. expect(result.warnings).toBeUndefined()
  1045. })
  1046. })
  1047. describe("promise construction", () => {
  1048. test("a deferred gate coordinates tool results across async functions", async () => {
  1049. expect(
  1050. await value(`
  1051. let openGate
  1052. const gate = new Promise((resolve) => { openGate = resolve })
  1053. const worker = (async () => {
  1054. const id = await gate
  1055. return id * 2
  1056. })()
  1057. openGate(await tools.host.sleepy({ id: 21, ms: 5 }))
  1058. return await worker
  1059. `),
  1060. ).toBe(42)
  1061. })
  1062. test("the .then(resolve) bridge settles a constructed promise", async () => {
  1063. expect(
  1064. await value(`
  1065. const bridged = new Promise((resolve, reject) => {
  1066. tools.host.sleepy({ id: 7, ms: 5 }).then(resolve, reject)
  1067. })
  1068. return await bridged
  1069. `),
  1070. ).toBe(7)
  1071. })
  1072. test("constructed promises participate in combinators", async () => {
  1073. expect(
  1074. await value(`
  1075. let settle
  1076. const manual = new Promise((resolve) => { settle = resolve })
  1077. const race = Promise.race([manual, tools.host.sleepy({ id: 3, ms: 60000 })])
  1078. const all = Promise.all([manual, "plain"])
  1079. const any = Promise.any([manual, new Promise(() => {})])
  1080. settle("manual")
  1081. return [await race, await all, await any]
  1082. `),
  1083. ).toEqual(["manual", ["manual", "plain"], "manual"])
  1084. })
  1085. test("resolving with a pending promise adopts its later settlement", async () => {
  1086. expect(
  1087. await value(`
  1088. let innerResolve, innerReject
  1089. const adopted = new Promise((resolve) => resolve(new Promise((resolve) => { innerResolve = resolve })))
  1090. const adoptedRejection = new Promise((resolve) => resolve(new Promise((_, reject) => { innerReject = reject })))
  1091. innerResolve("later")
  1092. innerReject("bad")
  1093. try {
  1094. return [await adopted, await adoptedRejection]
  1095. } catch (reason) {
  1096. return [await adopted, reason]
  1097. }
  1098. `),
  1099. ).toEqual(["later", "bad"])
  1100. })
  1101. test("an async executor's post-await resolve settles the promise", async () => {
  1102. expect(
  1103. await value(`
  1104. const result = new Promise(async (resolve) => {
  1105. const id = await tools.host.sleepy({ id: 5, ms: 5 })
  1106. resolve(id * 2)
  1107. })
  1108. return await result
  1109. `),
  1110. ).toBe(10)
  1111. })
  1112. test("a never-settled promise is abandoned silently at the return", async () => {
  1113. const result = await run(`
  1114. const forever = new Promise(() => {})
  1115. forever.then(() => {})
  1116. return "done"
  1117. `)
  1118. expect(result.ok).toBe(true)
  1119. if (!result.ok) return
  1120. expect(result.value).toBe("done")
  1121. expect(result.warnings).toBeUndefined()
  1122. })
  1123. test("an un-awaited constructed rejection is reported like any unhandled rejection", async () => {
  1124. const result = await run(`
  1125. new Promise((_, reject) => reject(new Error("dropped")))
  1126. await Promise.resolve()
  1127. await Promise.resolve()
  1128. return "done"
  1129. `)
  1130. expect(result.ok).toBe(true)
  1131. if (!result.ok) return
  1132. expect(result.value).toBe("done")
  1133. expect(result.warnings).toHaveLength(1)
  1134. expect(result.warnings?.[0].message).toContain("Unhandled rejection")
  1135. expect(result.warnings?.[0].message).toContain("dropped")
  1136. })
  1137. test("resolver functions cannot cross the data boundary", async () => {
  1138. const diagnostic = await error(`
  1139. let escaped
  1140. new Promise((resolve) => { escaped = resolve })
  1141. return { escaped }
  1142. `)
  1143. expect(diagnostic.kind).toBe("InvalidDataValue")
  1144. })
  1145. })