promise.test.ts 15 KB

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