promise.test.ts 15 KB

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