promise.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456
  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, and Promise.all/allSettled/race/resolve/reject are
  6. // ordinary functions over arbitrary arrays mixing promises and plain values.
  7. type Trace = {
  8. starts: Array<number>
  9. active: number
  10. maxActive: number
  11. completed: number
  12. interrupted: number
  13. }
  14. const makeTrace = (): Trace => ({ starts: [], active: 0, maxActive: 0, completed: 0, interrupted: 0 })
  15. /** Echoes `id` after `ms` milliseconds, recording start order, live concurrency, and interruption. */
  16. const sleepyTool = (trace: Trace) =>
  17. Tool.make({
  18. description: "Echo an id after a delay",
  19. input: Schema.Struct({ id: Schema.Number, ms: Schema.optionalKey(Schema.Number) }),
  20. output: Schema.Number,
  21. run: ({ id, ms }) =>
  22. Effect.gen(function* () {
  23. trace.starts.push(id)
  24. trace.active += 1
  25. trace.maxActive = Math.max(trace.maxActive, trace.active)
  26. yield* Effect.sleep(ms ?? 20)
  27. trace.active -= 1
  28. trace.completed += 1
  29. return id
  30. }).pipe(
  31. Effect.onInterrupt(() =>
  32. Effect.sync(() => {
  33. trace.active -= 1
  34. trace.interrupted += 1
  35. }),
  36. ),
  37. ),
  38. })
  39. const failingTool = Tool.make({
  40. description: "Always refuse",
  41. input: Schema.Struct({}),
  42. output: Schema.String,
  43. run: () => Effect.fail(toolError("Lookup refused")),
  44. })
  45. const run = (
  46. code: string,
  47. options: { trace?: Trace; limits?: CodeMode.ExecutionLimits } = {},
  48. ): Promise<CodeMode.Result> => {
  49. const trace = options.trace ?? makeTrace()
  50. return Effect.runPromise(
  51. CodeMode.execute({
  52. tools: { host: { sleepy: sleepyTool(trace), fail: failingTool } },
  53. code,
  54. ...(options.limits ? { limits: options.limits } : {}),
  55. }),
  56. )
  57. }
  58. const value = async (code: string, options: { trace?: Trace; limits?: CodeMode.ExecutionLimits } = {}) => {
  59. const result = await run(code, options)
  60. if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
  61. return result.value
  62. }
  63. const error = async (code: string, options: { trace?: Trace; limits?: CodeMode.ExecutionLimits } = {}) => {
  64. const result = await run(code, options)
  65. if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`)
  66. return result.error
  67. }
  68. describe("first-class promise values", () => {
  69. test("an un-awaited tool call starts eagerly, in call order, before any await", async () => {
  70. const trace = makeTrace()
  71. const result = await value(
  72. `
  73. const a = tools.host.sleepy({ id: 1, ms: 40 })
  74. const b = tools.host.sleepy({ id: 2, ms: 40 })
  75. const rb = await b
  76. const ra = await a
  77. return [ra, rb]
  78. `,
  79. { trace },
  80. )
  81. expect(result).toEqual([1, 2])
  82. expect(trace.starts).toEqual([1, 2])
  83. // Both calls overlapped even though they were awaited sequentially.
  84. expect(trace.maxActive).toBeGreaterThan(1)
  85. })
  86. test("awaiting the same promise twice settles once and never re-runs the call", async () => {
  87. const result = await run(`
  88. const p = tools.host.sleepy({ id: 7 })
  89. const x = await p
  90. const y = await p
  91. return [x, y]
  92. `)
  93. expect(result.ok).toBe(true)
  94. if (!result.ok) return
  95. expect(result.value).toEqual([7, 7])
  96. expect(result.toolCalls).toStrictEqual([{ name: "host.sleepy" }])
  97. })
  98. test("await of a non-promise value is a passthrough no-op", async () => {
  99. expect(await value(`return await 42`)).toBe(42)
  100. expect(await value(`const x = await "s"; return x`)).toBe("s")
  101. expect(await value(`return await null`)).toBeNull()
  102. expect(await value(`return (await [1, 2]).length`)).toBe(2)
  103. })
  104. test("returning an un-awaited tool call resolves it (async-function return semantics)", async () => {
  105. expect(await value(`return tools.host.sleepy({ id: 9 })`)).toBe(9)
  106. })
  107. test("typeof a promise is 'object', and console.log renders it sensibly", async () => {
  108. const result = await run(`
  109. const p = Promise.resolve(1)
  110. console.log(p)
  111. return typeof p
  112. `)
  113. expect(result.ok).toBe(true)
  114. if (!result.ok) return
  115. expect(result.value).toBe("object")
  116. expect(result.logs).toStrictEqual(["[Promise (await it to get its value)]"])
  117. })
  118. test("an awaited failure is catchable exactly like a synchronous throw", async () => {
  119. expect(
  120. await value(`
  121. const p = tools.host.fail({})
  122. try {
  123. await p
  124. return "no"
  125. } catch (e) {
  126. return e.message
  127. }
  128. `),
  129. ).toBe("Lookup refused")
  130. })
  131. test("a fire-and-forget call completes before the execution ends", async () => {
  132. const trace = makeTrace()
  133. const result = await value(
  134. `
  135. tools.host.sleepy({ id: 1, ms: 30 })
  136. return "done"
  137. `,
  138. { trace },
  139. )
  140. expect(result).toBe("done")
  141. expect(trace.completed).toBe(1)
  142. expect(trace.interrupted).toBe(0)
  143. })
  144. test("a never-awaited failing call surfaces as an unhandled-rejection diagnostic", async () => {
  145. const diagnostic = await error(`
  146. tools.host.fail({})
  147. return "done"
  148. `)
  149. expect(diagnostic.kind).toBe("ToolFailure")
  150. expect(diagnostic.message).toContain("Unhandled rejection from an un-awaited tool call")
  151. expect(diagnostic.message).toContain("Lookup refused")
  152. expect(diagnostic.suggestions?.join(" ")).toContain("await tools.ns.tool(...)")
  153. })
  154. })
  155. describe("promises at data boundaries", () => {
  156. test("returning an un-awaited promise inside data is a clear await-hinting diagnostic", async () => {
  157. const diagnostic = await error(`return { result: tools.host.sleepy({ id: 1 }) }`)
  158. expect(diagnostic.kind).toBe("InvalidDataValue")
  159. expect(diagnostic.message).toContain("un-awaited Promise")
  160. expect(diagnostic.message).toContain("await tools.ns.tool(...)")
  161. })
  162. test("passing an un-awaited promise as a tool argument is a clear diagnostic", async () => {
  163. const diagnostic = await error(`return await tools.host.sleepy({ id: tools.host.sleepy({ id: 1 }) })`)
  164. expect(diagnostic.kind).toBe("InvalidDataValue")
  165. expect(diagnostic.message).toContain("un-awaited Promise")
  166. })
  167. test("JSON.stringify of a promise is a diagnostic, not '{}'", async () => {
  168. const diagnostic = await error(`return JSON.stringify(Promise.resolve(1))`)
  169. expect(diagnostic.kind).toBe("InvalidDataValue")
  170. expect(diagnostic.message).toContain("un-awaited Promise")
  171. })
  172. test("operators reject promise operands", async () => {
  173. const diagnostic = await error(`return Promise.resolve(1) + 1`)
  174. expect(diagnostic.kind).toBe("InvalidDataValue")
  175. })
  176. })
  177. describe("Promise.all over arbitrary arrays", () => {
  178. test("mixes promises and plain values, preserving order", async () => {
  179. expect(
  180. await value(`
  181. return await Promise.all([tools.host.sleepy({ id: 1 }), "plain", tools.host.sleepy({ id: 2 }), 42])
  182. `),
  183. ).toEqual([1, "plain", 2, 42])
  184. })
  185. test("accepts arrays built beforehand, passed as identifiers, and spread elements", async () => {
  186. expect(
  187. await value(`
  188. const calls = []
  189. calls.push(tools.host.sleepy({ id: 1 }))
  190. calls.push(7)
  191. const more = [tools.host.sleepy({ id: 2 })]
  192. const batch = [...calls, ...more, "x"]
  193. return await Promise.all(batch)
  194. `),
  195. ).toEqual([1, 7, 2, "x"])
  196. })
  197. test("runs items.map tool calls in parallel", async () => {
  198. const trace = makeTrace()
  199. const result = await value(
  200. `
  201. const ids = [1, 2, 3, 4]
  202. return await Promise.all(ids.map((id) => tools.host.sleepy({ id, ms: 40 })))
  203. `,
  204. { trace },
  205. )
  206. expect(result).toEqual([1, 2, 3, 4])
  207. // maxActive counts truly-overlapping live executions, so > 1 proves real
  208. // parallelism deterministically - no wall-clock assertion needed.
  209. expect(trace.maxActive).toBeGreaterThan(1)
  210. })
  211. test("caps live tool-call concurrency at the fixed internal constant (8)", async () => {
  212. const trace = makeTrace()
  213. const result = await value(
  214. `
  215. const ids = []
  216. for (let i = 0; i < 20; i += 1) ids.push(i)
  217. const results = await Promise.all(ids.map((id) => tools.host.sleepy({ id, ms: 10 })))
  218. return results.length
  219. `,
  220. { trace },
  221. )
  222. expect(result).toBe(20)
  223. expect(trace.maxActive).toBeGreaterThan(1)
  224. expect(trace.maxActive).toBeLessThanOrEqual(8)
  225. })
  226. test("resolves the empty array", async () => {
  227. expect(await value(`return await Promise.all([])`)).toEqual([])
  228. })
  229. test("rejects with the first failure, catchable in-program", async () => {
  230. expect(
  231. await value(`
  232. try {
  233. await Promise.all([tools.host.sleepy({ id: 1 }), tools.host.fail({})])
  234. return "no"
  235. } catch (e) {
  236. return e.message
  237. }
  238. `),
  239. ).toBe("Lookup refused")
  240. })
  241. test("a non-collection argument is a clear error", async () => {
  242. const diagnostic = await error(`return await Promise.all(42)`)
  243. expect(diagnostic.message).toContain("Promise.all expects an array")
  244. })
  245. test("exceeding maxToolCalls inside Promise.all is a ToolCallLimitExceeded diagnostic", async () => {
  246. const diagnostic = await error(
  247. `return await Promise.all([tools.host.sleepy({ id: 1 }), tools.host.sleepy({ id: 2 }), tools.host.sleepy({ id: 3 })])`,
  248. { limits: { maxToolCalls: 2 } },
  249. )
  250. expect(diagnostic.kind).toBe("ToolCallLimitExceeded")
  251. })
  252. })
  253. describe("Promise.allSettled", () => {
  254. test("reports fulfilled and rejected outcomes with catch-normalized reasons", async () => {
  255. expect(
  256. await value(`
  257. return await Promise.allSettled([
  258. tools.host.sleepy({ id: 5 }),
  259. tools.host.fail({}),
  260. "plain",
  261. Promise.reject(new Error("boom")),
  262. ])
  263. `),
  264. ).toEqual([
  265. { status: "fulfilled", value: 5 },
  266. { status: "rejected", reason: { name: "Error", message: "Lookup refused" } },
  267. { status: "fulfilled", value: "plain" },
  268. { status: "rejected", reason: { name: "Error", message: "boom" } },
  269. ])
  270. })
  271. test("never rejects for program-level failures", async () => {
  272. const result = await run(`
  273. const settled = await Promise.allSettled([tools.host.fail({}), tools.host.fail({})])
  274. return settled.filter((s) => s.status === "rejected").length
  275. `)
  276. expect(result.ok).toBe(true)
  277. if (result.ok) expect(result.value).toBe(2)
  278. })
  279. })
  280. describe("Promise.race", () => {
  281. test("first settlement wins and losers are interrupted", async () => {
  282. const trace = makeTrace()
  283. const result = await value(
  284. `
  285. const fast = tools.host.sleepy({ id: 1, ms: 10 })
  286. const slow = tools.host.sleepy({ id: 2, ms: 5000 })
  287. return await Promise.race([fast, slow])
  288. `,
  289. { trace },
  290. )
  291. expect(result).toBe(1)
  292. expect(trace.interrupted).toBe(1)
  293. expect(trace.completed).toBe(1)
  294. })
  295. test("awaiting an interrupted loser afterwards is a catchable program failure", async () => {
  296. expect(
  297. await value(`
  298. const fast = tools.host.sleepy({ id: 1, ms: 10 })
  299. const slow = tools.host.sleepy({ id: 2, ms: 5000 })
  300. const winner = await Promise.race([fast, slow])
  301. try {
  302. await slow
  303. return "no"
  304. } catch (e) {
  305. return { winner, caught: e.message }
  306. }
  307. `),
  308. ).toEqual({
  309. winner: 1,
  310. caught: "This tool call was interrupted because another value settled a Promise.race first.",
  311. })
  312. })
  313. test("a rejection can win the race", async () => {
  314. expect(
  315. await value(`
  316. try {
  317. await Promise.race([tools.host.fail({}), tools.host.sleepy({ id: 1, ms: 5000 })])
  318. return "no"
  319. } catch (e) {
  320. return e.message
  321. }
  322. `),
  323. ).toBe("Lookup refused")
  324. })
  325. test("a plain value wins over pending promises", async () => {
  326. const trace = makeTrace()
  327. expect(
  328. await value(`return await Promise.race([tools.host.sleepy({ id: 1, ms: 5000 }), "immediate"])`, { trace }),
  329. ).toBe("immediate")
  330. expect(trace.interrupted).toBe(1)
  331. })
  332. test("an empty race is a clear error instead of hanging", async () => {
  333. const diagnostic = await error(`return await Promise.race([])`)
  334. expect(diagnostic.message).toContain("never settle")
  335. })
  336. })
  337. describe("Promise.resolve / Promise.reject", () => {
  338. test("resolve wraps plain values and passes promises through", async () => {
  339. expect(await value(`return await Promise.resolve(42)`)).toBe(42)
  340. expect(await value(`return await Promise.resolve(Promise.resolve("nested"))`)).toBe("nested")
  341. expect(await value(`return await Promise.resolve(tools.host.sleepy({ id: 3 }))`)).toBe(3)
  342. })
  343. test("reject produces a promise whose await throws the reason", async () => {
  344. expect(
  345. await value(`
  346. try {
  347. await Promise.reject("nope")
  348. return "no"
  349. } catch (e) {
  350. return e
  351. }
  352. `),
  353. ).toBe("nope")
  354. })
  355. })
  356. describe("timeout interruption of forked calls", () => {
  357. test("the execution timeout interrupts in-flight forked fibers", async () => {
  358. const trace = makeTrace()
  359. const result = await run(
  360. `
  361. const a = tools.host.sleepy({ id: 1, ms: 60000 })
  362. const b = tools.host.sleepy({ id: 2, ms: 60000 })
  363. return await a
  364. `,
  365. { trace, limits: { timeoutMs: 100 } },
  366. )
  367. expect(result.ok).toBe(false)
  368. if (result.ok) return
  369. expect(result.error.kind).toBe("TimeoutExceeded")
  370. // Both calls started; neither escaped the timeout - the awaited one AND the abandoned one.
  371. expect(trace.starts).toEqual([1, 2])
  372. expect(trace.interrupted).toBe(2)
  373. expect(trace.completed).toBe(0)
  374. })
  375. test("the timeout also interrupts calls inside Promise.all", async () => {
  376. const trace = makeTrace()
  377. const result = await run(
  378. `return await Promise.all([tools.host.sleepy({ id: 1, ms: 60000 }), tools.host.sleepy({ id: 2, ms: 60000 })])`,
  379. { trace, limits: { timeoutMs: 100 } },
  380. )
  381. expect(result.ok).toBe(false)
  382. if (result.ok) return
  383. expect(result.error.kind).toBe("TimeoutExceeded")
  384. expect(trace.interrupted).toBe(2)
  385. })
  386. })
  387. describe("unsupported promise surface", () => {
  388. test(".then/.catch/.finally give a clear await-instead error", async () => {
  389. for (const method of ["then", "catch", "finally"]) {
  390. const diagnostic = await error(`return tools.host.sleepy({ id: 1 }).${method}((x) => x)`)
  391. expect(diagnostic.kind).toBe("UnsupportedSyntax")
  392. expect(diagnostic.message).toContain(`Promise.prototype.${method} is not supported`)
  393. expect(diagnostic.message).toContain("await")
  394. }
  395. })
  396. test("other property reads on a promise hint at the missing await", async () => {
  397. const diagnostic = await error(`return tools.host.sleepy({ id: 1 }).value`)
  398. expect(diagnostic.kind).toBe("InvalidDataValue")
  399. expect(diagnostic.message).toContain("un-awaited Promise")
  400. expect(diagnostic.message).toContain("await it first")
  401. })
  402. test("unknown Promise statics list what is available", async () => {
  403. const diagnostic = await error(`return await Promise.any([tools.host.sleepy({ id: 1 })])`)
  404. expect(diagnostic.message).toContain("Promise.any is not available")
  405. expect(diagnostic.message).toContain("Promise.allSettled")
  406. })
  407. test("new Promise(...) points at tool calls instead", async () => {
  408. const diagnostic = await error(`return new Promise((resolve) => resolve(1))`)
  409. expect(diagnostic.kind).toBe("UnsupportedSyntax")
  410. expect(diagnostic.message).toContain("new Promise(...) is not supported")
  411. expect(diagnostic.message).toContain("already return promises")
  412. })
  413. })