| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240 |
- import { describe, expect, test } from "bun:test"
- import { Effect, Schema } from "effect"
- import { CodeMode, Tool, toolError } from "../src/index.js"
- // Wave 5 acceptance suite: first-class promise values. Un-awaited tool calls start eagerly on
- // supervised fibers, `await` settles them, Promise.all/allSettled/race/resolve/reject are
- // ordinary functions over arbitrary arrays mixing promises and plain values, and
- // .then/.catch/.finally chain reactions onto any promise.
- type Trace = {
- starts: Array<number>
- active: number
- maxActive: number
- completed: number
- interrupted: number
- }
- const makeTrace = (): Trace => ({ starts: [], active: 0, maxActive: 0, completed: 0, interrupted: 0 })
- /** Echoes `id` after `ms` milliseconds, recording start order, live concurrency, and interruption. */
- const sleepyTool = (trace: Trace) =>
- Tool.make({
- description: "Echo an id after a delay",
- input: Schema.Struct({ id: Schema.Number, ms: Schema.optionalKey(Schema.Number) }),
- output: Schema.Number,
- run: ({ id, ms }) =>
- Effect.gen(function* () {
- trace.starts.push(id)
- trace.active += 1
- trace.maxActive = Math.max(trace.maxActive, trace.active)
- yield* Effect.sleep(ms ?? 20)
- trace.active -= 1
- trace.completed += 1
- return id
- }).pipe(
- Effect.onInterrupt(() =>
- Effect.sync(() => {
- trace.active -= 1
- trace.interrupted += 1
- }),
- ),
- ),
- })
- const failingTool = Tool.make({
- description: "Always refuse",
- input: Schema.Struct({}),
- output: Schema.String,
- run: () => Effect.fail(toolError("Lookup refused")),
- })
- const interruptedTool = Tool.make({
- description: "Interrupt this call",
- input: Schema.Struct({}),
- output: Schema.String,
- run: () => Effect.interrupt,
- })
- const completedTool = (trace: Trace) =>
- Tool.make({
- description: "Return the number of completed sleepy calls",
- input: Schema.Struct({}),
- output: Schema.Number,
- run: () => Effect.succeed(trace.completed),
- })
- /** Never settles, and holds interruption cleanup for `cleanupMs` so completion cleanup can outlast a timeout. */
- const stubbornTool = (trace: Trace) =>
- Tool.make({
- description: "Never settle; clean up slowly when interrupted",
- input: Schema.Struct({ cleanupMs: Schema.Number }),
- output: Schema.Number,
- run: ({ cleanupMs }) =>
- Effect.never.pipe(
- Effect.onInterrupt(() =>
- Effect.andThen(
- Effect.sleep(cleanupMs),
- Effect.sync(() => {
- trace.interrupted += 1
- }),
- ),
- ),
- ),
- })
- const run = (
- code: string,
- options: { trace?: Trace; limits?: CodeMode.ExecutionLimits } = {},
- ): Promise<CodeMode.Result> => {
- const trace = options.trace ?? makeTrace()
- return Effect.runPromise(
- CodeMode.execute({
- tools: {
- host: {
- sleepy: sleepyTool(trace),
- fail: failingTool,
- interrupt: interruptedTool,
- completed: completedTool(trace),
- stubborn: stubbornTool(trace),
- },
- },
- code,
- ...(options.limits ? { limits: options.limits } : {}),
- }),
- )
- }
- const value = async (code: string, options: { trace?: Trace; limits?: CodeMode.ExecutionLimits } = {}) => {
- const result = await run(code, options)
- if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
- return result.value
- }
- const error = async (code: string, options: { trace?: Trace; limits?: CodeMode.ExecutionLimits } = {}) => {
- const result = await run(code, options)
- if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`)
- return result.error
- }
- describe("first-class promise values", () => {
- test("async functions return promises with isolated concurrent invocations", async () => {
- expect(
- await value(`
- const load = async (id) => {
- const result = await tools.host.sleepy({ id, ms: 20 })
- return [id, result]
- }
- const first = load(1)
- const second = load(2)
- return [first instanceof Promise, second instanceof Promise, await Promise.all([first, second])]
- `),
- ).toEqual([
- true,
- true,
- [
- [1, 1],
- [2, 2],
- ],
- ])
- })
- test("async function errors reject instead of throwing at the call site", async () => {
- expect(
- await value(`
- const fail = async () => { throw new Error("boom") }
- const promise = fail()
- try {
- await promise
- return "no"
- } catch (error) {
- return error.message
- }
- `),
- ).toBe("boom")
- })
- test("an un-awaited tool call starts eagerly, in call order, before any await", async () => {
- const trace = makeTrace()
- const result = await value(
- `
- const a = tools.host.sleepy({ id: 1, ms: 40 })
- const b = tools.host.sleepy({ id: 2, ms: 40 })
- const rb = await b
- const ra = await a
- return [ra, rb]
- `,
- { trace },
- )
- expect(result).toEqual([1, 2])
- expect(trace.starts).toEqual([1, 2])
- // Both calls overlapped even though they were awaited sequentially.
- expect(trace.maxActive).toBeGreaterThan(1)
- })
- test("awaiting the same promise twice settles once and never re-runs the call", async () => {
- const result = await run(`
- const p = tools.host.sleepy({ id: 7 })
- const x = await p
- const y = await p
- return [x, y]
- `)
- expect(result.ok).toBe(true)
- if (!result.ok) return
- expect(result.value).toEqual([7, 7])
- expect(result.toolCalls).toStrictEqual([{ name: "host.sleepy" }])
- })
- test("await of a non-promise value passes it through unchanged", async () => {
- expect(await value(`return await 42`)).toBe(42)
- expect(await value(`const x = await "s"; return x`)).toBe("s")
- expect(await value(`return await null`)).toBeNull()
- expect(await value(`return (await [1, 2]).length`)).toBe(2)
- })
- test("returning an un-awaited tool call resolves it (async-function return semantics)", async () => {
- expect(await value(`return tools.host.sleepy({ id: 9 })`)).toBe(9)
- })
- test("typeof a promise is 'object', and console.log renders it sensibly", async () => {
- const result = await run(`
- const p = Promise.resolve(1)
- console.log(p)
- return typeof p
- `)
- expect(result.ok).toBe(true)
- if (!result.ok) return
- expect(result.value).toBe("object")
- expect(result.logs).toStrictEqual(["[Promise (await it to get its value)]"])
- })
- test("an awaited failure is catchable exactly like a synchronous throw", async () => {
- const result = await run(`
- const p = tools.host.fail({})
- try {
- await p
- return "no"
- } catch (e) {
- return e.message
- }
- `)
- expect(result.ok).toBe(true)
- if (!result.ok) return
- expect(result.value).toBe("Lookup refused")
- expect(result.warnings).toBeUndefined()
- })
- test("a fire-and-forget call is interrupted when the program returns", async () => {
- const trace = makeTrace()
- const result = await run(
- `
- tools.host.sleepy({ id: 1, ms: 30 })
- return "done"
- `,
- { trace },
- )
- expect(result.ok).toBe(true)
- if (!result.ok) return
- expect(result.value).toBe("done")
- expect(result.warnings).toBeUndefined()
- expect(trace.completed).toBe(0)
- expect(trace.interrupted).toBe(1)
- })
- test("a never-awaited failing call preserves the result and reports the rejection", async () => {
- const result = await run(`
- tools.host.fail({})
- return "done"
- `)
- expect(result.ok).toBe(true)
- if (!result.ok) return
- expect(result.value).toBe("done")
- expect(result.warnings).toStrictEqual([
- { kind: "ToolFailure", message: "Unhandled rejection from an un-awaited promise: Lookup refused" },
- ])
- expect(Schema.decodeUnknownSync(CodeMode.Result)(JSON.parse(JSON.stringify(result)))).toStrictEqual(result)
- })
- test("a never-awaited failing async function is reported with a successful result", async () => {
- const result = await run(`
- const fail = async () => { throw new Error("boom") }
- fail()
- return "done"
- `)
- expect(result.ok).toBe(true)
- if (!result.ok) return
- expect(result.value).toBe("done")
- expect(result.warnings).toStrictEqual([
- { kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: boom" },
- ])
- })
- test("output truncation bounds warning diagnostics with an in-band marker", async () => {
- const result = await run(
- `
- for (let i = 0; i < 100; i += 1) Promise.reject(new Error("x".repeat(1_000)))
- return "done"
- `,
- { limits: { maxOutputBytes: 64 } },
- )
- expect(result.ok).toBe(true)
- if (!result.ok) return
- expect(result.truncated).toBe(true)
- expect(result.warnings).toStrictEqual([
- { kind: "Truncated", message: "100 additional warnings omitted by the output limit." },
- ])
- })
- test("a budget-consuming value does not starve warnings", async () => {
- const result = await run(
- `
- Promise.reject(new Error("boom"))
- return "x".repeat(500)
- `,
- { limits: { maxOutputBytes: 128 } },
- )
- expect(result.ok).toBe(true)
- if (!result.ok) return
- expect(result.truncated).toBe(true)
- expect(typeof result.value).toBe("string")
- expect(result.warnings).toStrictEqual([
- { kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: boom" },
- ])
- })
- test("an un-awaited async function's pending chain is interrupted at the return", async () => {
- const trace = makeTrace()
- const result = await run(
- `
- const run = async () => {
- await tools.host.sleepy({ id: 1, ms: 60000 })
- tools.host.fail({})
- }
- run()
- return "done"
- `,
- { trace },
- )
- expect(result.ok).toBe(true)
- if (!result.ok) return
- expect(result.value).toBe("done")
- expect(result.warnings).toBeUndefined()
- expect(trace.starts).toEqual([1])
- expect(trace.completed).toBe(0)
- expect(trace.interrupted).toBe(1)
- })
- test("reports every unhandled rejection in promise creation order", async () => {
- const result = await run(`
- Promise.reject(new Error("first"))
- tools.host.fail({})
- Promise.reject(new Error("third"))
- return "done"
- `)
- expect(result.ok).toBe(true)
- if (!result.ok) return
- expect(result.warnings).toStrictEqual([
- { kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: first" },
- { kind: "ToolFailure", message: "Unhandled rejection from an un-awaited promise: Lookup refused" },
- { kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: third" },
- ])
- })
- test("orders an async function rejection before promises created inside its body", async () => {
- const result = await run(`
- const outer = async () => {
- Promise.reject(new Error("inner"))
- throw new Error("outer")
- }
- outer()
- return "done"
- `)
- expect(result.ok).toBe(true)
- if (!result.ok) return
- expect(result.warnings).toStrictEqual([
- { kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: outer" },
- { kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: inner" },
- ])
- })
- test("un-awaited interruptions settle without becoming rejections", async () => {
- const result = await run(`
- tools.host.interrupt({})
- Promise.all([tools.host.interrupt({})])
- return "done"
- `)
- expect(result.ok).toBe(true)
- if (!result.ok) return
- expect(result.value).toBe("done")
- expect(result.warnings).toBeUndefined()
- })
- test("a fatal program error cancels outstanding work without reporting unhandled rejections", async () => {
- const trace = makeTrace()
- const result = await run(
- `
- tools.host.sleepy({ id: 1, ms: 1_000 })
- throw new Error("boom")
- `,
- { trace },
- )
- expect(result.ok).toBe(false)
- if (result.ok) return
- expect(result.error.message).toBe("Uncaught: boom")
- expect("warnings" in result).toBe(false)
- expect(trace.completed).toBe(0)
- expect(trace.interrupted).toBe(1)
- })
- test("async-function promises remain owned by the execution after the function returns", async () => {
- const trace = makeTrace()
- expect(
- await value(
- `
- const launch = async () => {
- tools.host.sleepy({ id: 1, ms: 60000 })
- Promise.all([tools.host.sleepy({ id: 2, ms: 60000 })])
- return "returned"
- }
- return await launch()
- `,
- { trace },
- ),
- ).toBe("returned")
- // Both calls outlive launch() itself - they belong to the execution, not the function -
- // and are interrupted only when the whole program returns.
- expect(trace.starts).toEqual([1, 2])
- expect(trace.completed).toBe(0)
- expect(trace.interrupted).toBe(2)
- })
- })
- describe("promises at data boundaries", () => {
- test("returning an un-awaited promise inside data is a clear await-hinting diagnostic", async () => {
- const diagnostic = await error(`return { result: tools.host.sleepy({ id: 1 }) }`)
- expect(diagnostic.kind).toBe("InvalidDataValue")
- expect(diagnostic.message).toContain("un-awaited Promise")
- expect(diagnostic.message).toContain("await tools.ns.tool(...)")
- })
- test("collection helpers do not let un-awaited promises cross the result boundary", async () => {
- const diagnostic = await error(`return Array.from([Promise.resolve(1)])`)
- expect(diagnostic.kind).toBe("InvalidDataValue")
- expect(diagnostic.message).toContain("un-awaited Promise")
- })
- test("invalid returned data cancels pending work", async () => {
- const trace = makeTrace()
- const result = await run(
- `
- const pending = tools.host.sleepy({ id: 1, ms: 60_000 })
- return { pending }
- `,
- { trace, limits: { timeoutMs: 100 } },
- )
- expect(result.ok).toBe(false)
- if (result.ok) return
- expect(result.error.kind).toBe("InvalidDataValue")
- expect(trace.completed).toBe(0)
- expect(trace.interrupted).toBe(1)
- })
- test("passing an un-awaited promise as a tool argument is a clear diagnostic", async () => {
- const diagnostic = await error(`return await tools.host.sleepy({ id: tools.host.sleepy({ id: 1 }) })`)
- expect(diagnostic.kind).toBe("InvalidDataValue")
- expect(diagnostic.message).toContain("un-awaited Promise")
- })
- test("JSON.stringify of a promise is a diagnostic, not '{}'", async () => {
- const diagnostic = await error(`return JSON.stringify(Promise.resolve(1))`)
- expect(diagnostic.kind).toBe("InvalidDataValue")
- expect(diagnostic.message).toContain("un-awaited Promise")
- })
- test("operators reject promise operands", async () => {
- const diagnostic = await error(`return Promise.resolve(1) + 1`)
- expect(diagnostic.kind).toBe("InvalidDataValue")
- })
- })
- describe("Promise.all over arbitrary arrays", () => {
- test("combinators return promises that can be assigned and awaited later", async () => {
- expect(
- await value(`
- const all = Promise.all([Promise.resolve(1)])
- const settled = Promise.allSettled([Promise.reject("no")])
- const race = Promise.race([Promise.resolve(2)])
- const promises = [all instanceof Promise, settled instanceof Promise, race instanceof Promise]
- return [promises, await all, await settled, await race]
- `),
- ).toEqual([[true, true, true], [1], [{ status: "rejected", reason: "no" }], 2])
- })
- test("separately-created aggregate batches overlap before either is awaited", async () => {
- const trace = makeTrace()
- expect(
- await value(
- `
- const first = Promise.all([tools.host.sleepy({ id: 1, ms: 40 })])
- const second = Promise.all([tools.host.sleepy({ id: 2, ms: 40 })])
- return [await first, await second]
- `,
- { trace },
- ),
- ).toEqual([[1], [2]])
- expect(trace.starts).toEqual([1, 2])
- expect(trace.maxActive).toBeGreaterThan(1)
- })
- test("an aggregate created before a try block rejects at its later await", async () => {
- expect(
- await value(`
- const aggregate = Promise.all([tools.host.fail({})])
- try {
- await aggregate
- return "no"
- } catch (error) {
- return error.message
- }
- `),
- ).toBe("Lookup refused")
- })
- test("awaiting an aggregate repeatedly does not rerun its members", async () => {
- const result = await run(`
- const aggregate = Promise.all([tools.host.sleepy({ id: 7 })])
- return [await aggregate, await aggregate]
- `)
- expect(result.ok).toBe(true)
- if (!result.ok) return
- expect(result.value).toEqual([[7], [7]])
- expect(result.toolCalls).toStrictEqual([{ name: "host.sleepy" }])
- })
- test("mixes promises and plain values, preserving order", async () => {
- expect(
- await value(`
- return await Promise.all([tools.host.sleepy({ id: 1 }), "plain", tools.host.sleepy({ id: 2 }), 42])
- `),
- ).toEqual([1, "plain", 2, 42])
- })
- test("accepts arrays built beforehand, passed as identifiers, and spread elements", async () => {
- expect(
- await value(`
- const calls = []
- calls.push(tools.host.sleepy({ id: 1 }))
- calls.push(7)
- const more = [tools.host.sleepy({ id: 2 })]
- const batch = [...calls, ...more, "x"]
- return await Promise.all(batch)
- `),
- ).toEqual([1, 7, 2, "x"])
- })
- test("runs items.map tool calls in parallel", async () => {
- const trace = makeTrace()
- const result = await value(
- `
- const ids = [1, 2, 3, 4]
- return await Promise.all(ids.map((id) => tools.host.sleepy({ id, ms: 40 })))
- `,
- { trace },
- )
- expect(result).toEqual([1, 2, 3, 4])
- // maxActive counts truly-overlapping live executions, so > 1 proves real
- // parallelism deterministically - no wall-clock assertion needed.
- expect(trace.maxActive).toBeGreaterThan(1)
- })
- test("runs async map callbacks concurrently", async () => {
- const trace = makeTrace()
- const result = await value(
- `
- const ids = [1, 2, 3, 4]
- return await Promise.all(ids.map(async (id) => await tools.host.sleepy({ id, ms: 40 })))
- `,
- { trace },
- )
- expect(result).toEqual([1, 2, 3, 4])
- expect(trace.maxActive).toBeGreaterThan(1)
- })
- test("caps live tool-call concurrency at the fixed internal constant (8)", async () => {
- const trace = makeTrace()
- const result = await value(
- `
- const ids = []
- for (let i = 0; i < 20; i += 1) ids.push(i)
- const results = await Promise.all(ids.map((id) => tools.host.sleepy({ id, ms: 10 })))
- return results.length
- `,
- { trace },
- )
- expect(result).toBe(20)
- expect(trace.maxActive).toBeGreaterThan(1)
- expect(trace.maxActive).toBeLessThanOrEqual(8)
- })
- test("resolves the empty array", async () => {
- expect(await value(`return await Promise.all([])`)).toEqual([])
- })
- test("rejects with the first failure, catchable in-program", async () => {
- const result = await run(`
- try {
- await Promise.all([tools.host.sleepy({ id: 1 }), tools.host.fail({})])
- return "no"
- } catch (e) {
- return e.message
- }
- `)
- expect(result.ok).toBe(true)
- if (!result.ok) return
- expect(result.value).toBe("Lookup refused")
- expect(result.warnings).toBeUndefined()
- })
- test("rejects before an earlier slow promise fulfills", async () => {
- const trace = makeTrace()
- expect(
- await value(
- `
- try {
- await Promise.all([
- tools.host.sleepy({ id: 1, ms: 100 }),
- tools.host.fail({}),
- ])
- return -1
- } catch {
- return await tools.host.completed({})
- }
- `,
- { trace },
- ),
- ).toBe(0)
- // The surviving member is observed (Promise.all handled it), so completion interrupts
- // it instead of waiting for it.
- expect(trace.completed).toBe(0)
- expect(trace.interrupted).toBe(1)
- })
- test("fail-fast does not cancel a sibling the program still holds and awaits", async () => {
- const trace = makeTrace()
- expect(
- await value(
- `
- const slow = tools.host.sleepy({ id: 1, ms: 40 })
- try {
- await Promise.all([slow, tools.host.fail({})])
- return "no"
- } catch {}
- return await slow
- `,
- { trace },
- ),
- ).toBe(1)
- expect(trace.completed).toBe(1)
- expect(trace.interrupted).toBe(0)
- })
- test("a slower observed sibling is interrupted at completion after failing fast", async () => {
- const trace = makeTrace()
- expect(
- await value(
- `
- const failLater = async () => {
- await tools.host.sleepy({ id: 1, ms: 40 })
- throw new Error("later")
- }
- const aggregate = Promise.all([Promise.reject(new Error("first")), failLater()])
- try {
- await aggregate
- return "no"
- } catch (error) {
- return error.message
- }
- `,
- { trace },
- ),
- ).toBe("first")
- expect(trace.completed).toBe(0)
- expect(trace.interrupted).toBe(1)
- })
- test("a non-collection argument is a clear error", async () => {
- const diagnostic = await error(`return await Promise.all(42)`)
- expect(diagnostic.message).toContain("Promise.all expects an array")
- })
- test("exceeding maxToolCalls inside Promise.all is a ToolCallLimitExceeded diagnostic", async () => {
- const diagnostic = await error(
- `return await Promise.all([tools.host.sleepy({ id: 1 }), tools.host.sleepy({ id: 2 }), tools.host.sleepy({ id: 3 })])`,
- { limits: { maxToolCalls: 2 } },
- )
- expect(diagnostic.kind).toBe("ToolCallLimitExceeded")
- })
- })
- describe("Promise.allSettled", () => {
- test("reports fulfilled and rejected outcomes with catch-normalized reasons", async () => {
- expect(
- await value(`
- return await Promise.allSettled([
- tools.host.sleepy({ id: 5 }),
- tools.host.fail({}),
- "plain",
- Promise.reject(new Error("boom")),
- ])
- `),
- ).toEqual([
- { status: "fulfilled", value: 5 },
- { status: "rejected", reason: { name: "Error", message: "Lookup refused" } },
- { status: "fulfilled", value: "plain" },
- { status: "rejected", reason: { name: "Error", message: "boom" } },
- ])
- })
- test("never rejects for program-level failures", async () => {
- const result = await run(`
- const settled = await Promise.allSettled([tools.host.fail({}), tools.host.fail({})])
- return settled.filter((s) => s.status === "rejected").length
- `)
- expect(result.ok).toBe(true)
- if (!result.ok) return
- expect(result.value).toBe(2)
- expect(result.warnings).toBeUndefined()
- })
- })
- describe("Promise.race", () => {
- test("first settlement wins and a direct loser is interrupted at completion", async () => {
- const trace = makeTrace()
- const result = await value(
- `
- const fast = tools.host.sleepy({ id: 1, ms: 10 })
- const slow = tools.host.sleepy({ id: 2, ms: 40 })
- return await Promise.race([fast, slow])
- `,
- { trace },
- )
- expect(result).toBe(1)
- // The loser is observed (the race handled it), so the execution does not wait for it.
- expect(trace.completed).toBe(1)
- expect(trace.interrupted).toBe(1)
- })
- test("a direct loser remains awaitable after the race settles", async () => {
- expect(
- await value(`
- const fast = tools.host.sleepy({ id: 1, ms: 10 })
- const slow = tools.host.sleepy({ id: 2, ms: 40 })
- const winner = await Promise.race([fast, slow])
- return { winner, loser: await slow }
- `),
- ).toEqual({ winner: 1, loser: 2 })
- })
- test("a nested aggregate loser and its members are interrupted at completion", async () => {
- const trace = makeTrace()
- expect(
- await value(
- `
- const nested = Promise.all([
- tools.host.sleepy({ id: 1, ms: 40 }),
- tools.host.sleepy({ id: 2, ms: 40 }),
- ])
- return await Promise.race(["immediate", nested])
- `,
- { trace },
- ),
- ).toBe("immediate")
- // The nested aggregate and its members are all observed, so nothing waits for them.
- expect(trace.completed).toBe(0)
- expect(trace.interrupted).toBe(2)
- })
- test("a rejection can win the race", async () => {
- expect(
- await value(`
- try {
- await Promise.race([tools.host.fail({}), tools.host.sleepy({ id: 1, ms: 40 })])
- return "no"
- } catch (e) {
- return e.message
- }
- `),
- ).toBe("Lookup refused")
- })
- test("a plain value wins over pending promises", async () => {
- const trace = makeTrace()
- expect(
- await value(`return await Promise.race([tools.host.sleepy({ id: 1, ms: 40 }), "immediate"])`, { trace }),
- ).toBe("immediate")
- expect(trace.completed).toBe(0)
- expect(trace.interrupted).toBe(1)
- })
- test("a rejected race loser is observed by the aggregate", async () => {
- const result = await run(`return await Promise.race(["winner", tools.host.fail({})])`)
- expect(result.ok).toBe(true)
- if (!result.ok) return
- expect(result.value).toBe("winner")
- expect(result.warnings).toBeUndefined()
- })
- test("an empty race is a clear error instead of hanging", async () => {
- const diagnostic = await error(`return await Promise.race([])`)
- expect(diagnostic.message).toContain("never settle")
- })
- })
- describe("Promise.resolve / Promise.reject", () => {
- test("resolve wraps plain values and passes promises through", async () => {
- expect(await value(`return await Promise.resolve(42)`)).toBe(42)
- expect(await value(`return await Promise.resolve(Promise.resolve("nested"))`)).toBe("nested")
- expect(await value(`return await Promise.resolve(tools.host.sleepy({ id: 3 }))`)).toBe(3)
- expect(await value(`const promise = Promise.resolve(1); return [promise].includes(Promise.resolve(promise))`)).toBe(
- true,
- )
- })
- test("reject produces a promise whose await throws the reason", async () => {
- expect(
- await value(`
- try {
- await Promise.reject("nope")
- return "no"
- } catch (e) {
- return e
- }
- `),
- ).toBe("nope")
- })
- test("a rejection observed after settlement is handled", async () => {
- expect(
- await value(`
- const rejected = Promise.reject(new Error("handled"))
- await tools.host.sleepy({ id: 1 })
- try {
- await rejected
- return "no"
- } catch (error) {
- return error.message
- }
- `),
- ).toBe("handled")
- })
- test("an abandoned rejected promise is reported as unhandled", async () => {
- const result = await run(`
- Promise.reject(new Error("abandoned"))
- return "done"
- `)
- expect(result.ok).toBe(true)
- if (!result.ok) return
- expect(result.value).toBe("done")
- expect(result.warnings).toStrictEqual([
- { kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: abandoned" },
- ])
- })
- })
- describe("timeout interruption of forked calls", () => {
- test("the execution timeout interrupts in-flight forked fibers", async () => {
- const trace = makeTrace()
- const result = await run(
- `
- const a = tools.host.sleepy({ id: 1, ms: 60000 })
- const b = tools.host.sleepy({ id: 2, ms: 60000 })
- return await a
- `,
- { trace, limits: { timeoutMs: 100 } },
- )
- expect(result.ok).toBe(false)
- if (result.ok) return
- expect(result.error.kind).toBe("TimeoutExceeded")
- // Both calls started; neither escaped the timeout - the awaited one AND the abandoned one.
- expect(trace.starts).toEqual([1, 2])
- expect(trace.interrupted).toBe(2)
- expect(trace.completed).toBe(0)
- })
- test("the timeout also interrupts calls inside Promise.all", async () => {
- const trace = makeTrace()
- const result = await run(
- `return await Promise.all([tools.host.sleepy({ id: 1, ms: 60000 }), tools.host.sleepy({ id: 2, ms: 60000 })])`,
- { trace, limits: { timeoutMs: 100 } },
- )
- expect(result.ok).toBe(false)
- if (result.ok) return
- expect(result.error.kind).toBe("TimeoutExceeded")
- expect(trace.interrupted).toBe(2)
- })
- test("a non-settling race loser cannot hold the execution to the timeout", async () => {
- const trace = makeTrace()
- const result = await run(`return await Promise.race(["winner", tools.host.sleepy({ id: 1, ms: 60000 })])`, {
- trace,
- limits: { timeoutMs: 100 },
- })
- // Completion interrupts the observed loser immediately; the race result survives.
- expect(result.ok).toBe(true)
- if (!result.ok) return
- expect(result.value).toBe("winner")
- expect(result.warnings).toBeUndefined()
- expect(trace.starts).toEqual([1])
- expect(trace.completed).toBe(0)
- expect(trace.interrupted).toBe(1)
- })
- test("a timeout during completion cleanup keeps the computed value and warns", async () => {
- const trace = makeTrace()
- const result = await run(
- `
- tools.host.stubborn({ cleanupMs: 400 })
- return "done"
- `,
- { trace, limits: { timeoutMs: 100 } },
- )
- expect(result.ok).toBe(true)
- if (!result.ok) return
- expect(result.value).toBe("done")
- expect(result.warnings).toStrictEqual([
- {
- kind: "TimeoutExceeded",
- message:
- "The program returned, but background work was still running at the 100ms timeout and was interrupted. Await all started promises.",
- },
- ])
- expect(trace.interrupted).toBe(1)
- expect(trace.completed).toBe(0)
- })
- test("a timeout during completion cleanup reports the timeout warning before settled rejections", async () => {
- const result = await run(
- `
- tools.host.fail({})
- tools.host.stubborn({ cleanupMs: 400 })
- return "done"
- `,
- { limits: { timeoutMs: 100 } },
- )
- expect(result.ok).toBe(true)
- if (!result.ok) return
- expect(result.value).toBe("done")
- expect(result.warnings).toStrictEqual([
- {
- kind: "TimeoutExceeded",
- message:
- "The program returned, but background work was still running at the 100ms timeout and was interrupted. Await all started promises.",
- },
- { kind: "ToolFailure", message: "Unhandled rejection from an un-awaited promise: Lookup refused" },
- ])
- })
- })
- describe("promise chaining", () => {
- test("then transforms tool results and adopts returned promises across a chain", async () => {
- expect(
- await value(`
- return await tools.host
- .sleepy({ id: 2 })
- .then((id) => tools.host.sleepy({ id: id + 1 }))
- .then((id) => id * 10)
- `),
- ).toBe(30)
- })
- test("handlers are deferred and run in attach order", async () => {
- expect(
- await value(`
- const order = []
- const promise = Promise.resolve(1)
- promise.then(() => order.push("h1"))
- promise.then(() => order.push("h2"))
- order.push("sync")
- await promise
- return order
- `),
- ).toEqual(["sync", "h1", "h2"])
- })
- test("catch recovers a tool failure and preserves fulfillment", async () => {
- expect(
- await value(`
- return [
- await tools.host.fail({}).catch((error) => error.message),
- await tools.host.sleepy({ id: 4 }).catch(() => "unused"),
- ]
- `),
- ).toEqual(["Lookup refused", 4])
- })
- test("finally observes settlement without changing the value", async () => {
- expect(
- await value(`
- const events = []
- const result = await tools.host.sleepy({ id: 5 }).finally(() => events.push("cleanup"))
- return [result, events]
- `),
- ).toEqual([5, ["cleanup"]])
- })
- test("a settled, un-awaited rejected chain tail warns exactly once", async () => {
- const result = await run(`
- Promise.reject(new Error("boom")).then((value) => value)
- await Promise.resolve()
- return "done"
- `)
- expect(result.ok).toBe(true)
- if (!result.ok) return
- expect(result.value).toBe("done")
- // The source rejection belongs to the chain (no warning); only the derived tail warns.
- expect(result.warnings).toStrictEqual([
- { kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: boom" },
- ])
- })
- test("a catch handler silences the chain's rejection warning", async () => {
- const result = await run(`
- Promise.reject(new Error("boom")).catch(() => "handled")
- await Promise.resolve()
- return "done"
- `)
- expect(result.ok).toBe(true)
- if (!result.ok) return
- expect(result.warnings).toBeUndefined()
- })
- test("non-plain-function handlers fail loudly instead of being ignored", async () => {
- const diagnostic = await error(`return await tools.host.sleepy({ id: 1 }).then(tools.host.completed)`)
- expect(diagnostic.message).toContain("Promise.prototype.then handlers must be plain functions")
- })
- test("chaining methods are opaque references until called", async () => {
- expect(await value(`return typeof tools.host.sleepy({ id: 1 }).then`)).toBe("function")
- })
- })
- describe("combinator settlement timing", () => {
- test("a combinator settling one reaction turn after the program returns is interrupted silently", async () => {
- // The aggregate's one-turn settlement delay (V8 parity) means an immediately-returning
- // program abandons it while still pending: interrupted like any pending work, so no
- // rejection warning survives - the member itself was observed by the combinator.
- const result = await run(`
- Promise.all([Promise.reject(new Error("boom"))])
- return "done"
- `)
- expect(result.ok).toBe(true)
- if (!result.ok) return
- expect(result.value).toBe("done")
- expect(result.warnings).toBeUndefined()
- })
- test("a combinator settles one reaction turn after its members, as in V8", async () => {
- // Regression for the race winner flip: Promise.all's settlement burns a reaction turn,
- // so a plain resolved value entered in the same race wins, and a fail-fast aggregate
- // cannot beat it into rejection.
- expect(
- await value(`
- const pending = tools.host.sleepy({ id: 9, ms: 60000 })
- const winner = await Promise.race([Promise.all([Promise.resolve(1)]), Promise.resolve(2)])
- try {
- const raced = await Promise.race([Promise.all([Promise.reject("x"), pending]), Promise.resolve("ok")])
- return [winner, "fulfilled", raced]
- } catch (reason) {
- return [winner, "rejected", reason]
- }
- `),
- ).toEqual([2, "fulfilled", "ok"])
- })
- })
- describe("unsupported promise surface", () => {
- test("other property reads on a promise hint at the missing await", async () => {
- const diagnostic = await error(`return tools.host.sleepy({ id: 1 }).value`)
- expect(diagnostic.kind).toBe("InvalidDataValue")
- expect(diagnostic.message).toContain("un-awaited Promise")
- expect(diagnostic.message).toContain("await it first")
- })
- test("unknown Promise statics list what is available", async () => {
- const diagnostic = await error(`return await Promise.withResolvers()`)
- expect(diagnostic.message).toContain("Promise.withResolvers is not available")
- expect(diagnostic.message).toContain("Promise.any")
- })
- })
- describe("Promise.any", () => {
- test("first tool success wins; failing and losing calls are handled silently", async () => {
- const trace = makeTrace()
- const result = await run(
- `
- const winner = await Promise.any([
- tools.host.fail({}),
- tools.host.sleepy({ id: 1, ms: 5 }),
- tools.host.sleepy({ id: 2, ms: 60000 }),
- ])
- return winner
- `,
- { trace },
- )
- expect(result.ok).toBe(true)
- if (!result.ok) return
- expect(result.value).toBe(1)
- // The slow loser stays execution-owned and is interrupted at completion; the tool
- // failure was observed by the aggregate, so no rejection warning survives.
- expect(result.warnings).toBeUndefined()
- expect(trace.interrupted).toBe(1)
- })
- test("all members failing rejects with catch-normalized reasons in input order", async () => {
- expect(
- await value(`
- try {
- await Promise.any([tools.host.fail({}), Promise.reject("plain")])
- return "fulfilled"
- } catch (error) {
- return [error.name, error.errors.map((reason) => reason.message ?? reason)]
- }
- `),
- ).toEqual(["AggregateError", ["Lookup refused", "plain"]])
- })
- test("settles one reaction turn after its deciding member, as in V8", async () => {
- expect(await value(`return await Promise.race([Promise.any([Promise.resolve(1)]), Promise.resolve(2)])`)).toBe(2)
- })
- test("a tie is decided by settlement order, not input order", async () => {
- // Handlers run in attach order, so `first` settles before `second` and wins
- // despite its later input position - as in real JS.
- expect(
- await value(`
- const first = Promise.resolve().then(() => "one")
- const second = Promise.resolve().then(() => "two")
- return await Promise.any([second, first])
- `),
- ).toBe("one")
- })
- test("an abandoned rejecting aggregate is interrupted silently at the return", async () => {
- const result = await run(`
- Promise.any([Promise.reject(new Error("boom"))])
- return "done"
- `)
- expect(result.ok).toBe(true)
- if (!result.ok) return
- expect(result.value).toBe("done")
- expect(result.warnings).toBeUndefined()
- })
- })
- describe("promise construction", () => {
- test("a deferred gate coordinates tool results across async functions", async () => {
- expect(
- await value(`
- let openGate
- const gate = new Promise((resolve) => { openGate = resolve })
- const worker = (async () => {
- const id = await gate
- return id * 2
- })()
- openGate(await tools.host.sleepy({ id: 21, ms: 5 }))
- return await worker
- `),
- ).toBe(42)
- })
- test("the .then(resolve) bridge settles a constructed promise", async () => {
- expect(
- await value(`
- const bridged = new Promise((resolve, reject) => {
- tools.host.sleepy({ id: 7, ms: 5 }).then(resolve, reject)
- })
- return await bridged
- `),
- ).toBe(7)
- })
- test("constructed promises participate in combinators", async () => {
- expect(
- await value(`
- let settle
- const manual = new Promise((resolve) => { settle = resolve })
- const race = Promise.race([manual, tools.host.sleepy({ id: 3, ms: 60000 })])
- const all = Promise.all([manual, "plain"])
- const any = Promise.any([manual, new Promise(() => {})])
- settle("manual")
- return [await race, await all, await any]
- `),
- ).toEqual(["manual", ["manual", "plain"], "manual"])
- })
- test("resolving with a pending promise adopts its later settlement", async () => {
- expect(
- await value(`
- let innerResolve, innerReject
- const adopted = new Promise((resolve) => resolve(new Promise((resolve) => { innerResolve = resolve })))
- const adoptedRejection = new Promise((resolve) => resolve(new Promise((_, reject) => { innerReject = reject })))
- innerResolve("later")
- innerReject("bad")
- try {
- return [await adopted, await adoptedRejection]
- } catch (reason) {
- return [await adopted, reason]
- }
- `),
- ).toEqual(["later", "bad"])
- })
- test("an async executor's post-await resolve settles the promise", async () => {
- expect(
- await value(`
- const result = new Promise(async (resolve) => {
- const id = await tools.host.sleepy({ id: 5, ms: 5 })
- resolve(id * 2)
- })
- return await result
- `),
- ).toBe(10)
- })
- test("a never-settled promise is abandoned silently at the return", async () => {
- const result = await run(`
- const forever = new Promise(() => {})
- forever.then(() => {})
- return "done"
- `)
- expect(result.ok).toBe(true)
- if (!result.ok) return
- expect(result.value).toBe("done")
- expect(result.warnings).toBeUndefined()
- })
- test("an un-awaited constructed rejection is reported like any unhandled rejection", async () => {
- const result = await run(`
- new Promise((_, reject) => reject(new Error("dropped")))
- await Promise.resolve()
- await Promise.resolve()
- return "done"
- `)
- expect(result.ok).toBe(true)
- if (!result.ok) return
- expect(result.value).toBe("done")
- expect(result.warnings).toHaveLength(1)
- expect(result.warnings?.[0].message).toContain("Unhandled rejection")
- expect(result.warnings?.[0].message).toContain("dropped")
- })
- test("resolver functions cannot cross the data boundary", async () => {
- const diagnostic = await error(`
- let escaped
- new Promise((resolve) => { escaped = resolve })
- return { escaped }
- `)
- expect(diagnostic.kind).toBe("InvalidDataValue")
- })
- })
|