feat(codemode): support promise chaining with .then/.catch/.finally (#36304)

This commit is contained in:
Aiden Cline 2026-07-10 18:22:04 -05:00 committed by GitHub
commit 8e76adb08f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 486 additions and 214 deletions

View file

@ -672,9 +672,10 @@ describe("CodeMode public contract", () => {
expect(instructions).toContain("not a general-purpose runtime")
expect(instructions).not.toContain("Standard modern JavaScript works")
expect(instructions).not.toContain("TypeScript type annotations")
for (const missing of ["Modules/imports", "classes", "generators", "fetch", "promise chaining"]) {
for (const missing of ["Modules/imports", "classes", "generators", "fetch", "new Promise(...)"]) {
expect(instructions).toContain(missing)
}
expect(instructions).not.toContain("promise chaining")
expect(instructions).toContain("URL, URLSearchParams, and URI encoding helpers")
expect(instructions).not.toContain("host globals")
expect(instructions).toContain("Use Code Mode tools for external operations")

View file

@ -16,8 +16,7 @@ import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { CodeMode } from "../src/index.js"
const execute = (code: string) =>
Effect.runPromise(CodeMode.execute({ code, tools: {}, limits: { timeoutMs: 1_000 } }))
const execute = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {}, limits: { timeoutMs: 1_000 } }))
const value = async (code: string) => {
const result = await execute(code)
@ -450,7 +449,10 @@ describe("Test262 async functions and await", () => {
const promises = [declaration(), expression(), arrow()]
return [promises.map((item) => item instanceof Promise), await Promise.all(promises)]
`),
).toEqual([[true, true, true], [1, 2, 3]])
).toEqual([
[true, true, true],
[1, 2, 3],
])
})
test("async bodies adopt returns and reject throws before and after await", async () => {
@ -479,13 +481,7 @@ describe("Test262 async functions and await", () => {
await observe(throwsAfter()),
]
`),
).toEqual([
["body"],
["fulfilled", 42],
["fulfilled", 43],
["rejected", 1],
["rejected", 2],
])
).toEqual([["body"], ["fulfilled", 42], ["fulfilled", 43], ["rejected", 1], ["rejected", 2]])
})
test("default-parameter throws reject instead of escaping the call", async () => {
@ -578,7 +574,7 @@ describe("Test262 async functions and await", () => {
describe("Test262 expected Promise conformance", () => {
for (const name of ["all", "allSettled", "race"] as const) {
test.failing(`Promise.${name} rejects invalid input with TypeError`, async () => {
test(`Promise.${name} rejects invalid input with TypeError`, async () => {
// Sources:
// test/built-ins/Promise/all/S25.4.4.1_A3.1_T1.js
// test/built-ins/Promise/all/S25.4.4.1_A3.1_T2.js
@ -634,7 +630,7 @@ describe("Test262 expected Promise conformance", () => {
).toBe(true)
})
test.failing("Promise.all settles after reactions attached to its inputs", async () => {
test("Promise.all settles after reactions attached to its inputs", async () => {
// Sources:
// test/built-ins/Promise/all/S25.4.4.1_A7.2_T1.js
// test/built-ins/Promise/all/S25.4.4.1_A8.1_T1.js
@ -653,7 +649,7 @@ describe("Test262 expected Promise conformance", () => {
).toEqual([1, 2, 3, 4, 5])
})
test.failing("Promise.allSettled settles after reactions attached to its inputs", async () => {
test("Promise.allSettled settles after reactions attached to its inputs", async () => {
// Sources:
// test/built-ins/Promise/allSettled/resolved-sequence.js
// test/built-ins/Promise/allSettled/resolved-sequence-extra-ticks.js
@ -674,7 +670,7 @@ describe("Test262 expected Promise conformance", () => {
).toEqual([1, 2, 3, 4, 5])
})
test.failing("Promise.race settles in a reaction after its winning input", async () => {
test("Promise.race settles in a reaction after its winning input", async () => {
// Sources:
// test/built-ins/Promise/race/S25.4.4.3_A6.1_T1.js
// test/built-ins/Promise/race/resolved-sequence-extra-ticks.js
@ -692,7 +688,7 @@ describe("Test262 expected Promise conformance", () => {
).toEqual([1, 2, 3, 4, 5])
})
test.failing("then reactions route and propagate fulfillment and rejection", async () => {
test("then reactions route and propagate fulfillment and rejection", async () => {
// Sources:
// test/built-ins/Promise/prototype/then/prfm-fulfilled.js
// test/built-ins/Promise/prototype/then/prfm-rejected.js
@ -726,7 +722,7 @@ describe("Test262 expected Promise conformance", () => {
])
})
test.failing("then reactions preserve breadth-first queue order", async () => {
test("then reactions preserve breadth-first queue order", async () => {
// Source: test/built-ins/Promise/prototype/then/S25.4.4_A1.1_T1.js
expect(
await value(`
@ -741,7 +737,7 @@ describe("Test262 expected Promise conformance", () => {
).toEqual([1, 2, 3, 4, 5, 6, 7, 8])
})
test.failing("then rejects direct self-resolution for fulfilled and rejected sources", async () => {
test("then rejects direct self-resolution for fulfilled and rejected sources", async () => {
// Sources:
// test/built-ins/Promise/prototype/then/resolve-settled-fulfilled-self.js
// test/built-ins/Promise/prototype/then/resolve-settled-rejected-self.js
@ -761,7 +757,7 @@ describe("Test262 expected Promise conformance", () => {
).toEqual(["TypeError", "TypeError"])
})
test.failing("catch delegates rejection handling and preserves fulfillment", async () => {
test("catch delegates rejection handling and preserves fulfillment", async () => {
// Sources:
// test/built-ins/Promise/prototype/catch/S25.4.5.1_A2.1_T1.js
// test/built-ins/Promise/prototype/catch/S25.4.5.1_A3.1_T1.js
@ -776,7 +772,7 @@ describe("Test262 expected Promise conformance", () => {
).toEqual([1, 4])
})
test.failing("finally preserves or replaces the original settlement", async () => {
test("finally preserves or replaces the original settlement", async () => {
// Sources:
// test/built-ins/Promise/prototype/finally/resolution-value-no-override.js
// test/built-ins/Promise/prototype/finally/rejection-reason-no-fulfill.js
@ -799,7 +795,109 @@ describe("Test262 expected Promise conformance", () => {
])
})
test.failing("await always resumes in a later reaction and interleaves async functions", async () => {
test("then ignores non-callable handlers", async () => {
// Sources:
// test/built-ins/Promise/prototype/then/S25.4.5.3_A4.1_T1.js
// test/built-ins/Promise/prototype/then/S25.4.5.3_A4.1_T2.js
// test/built-ins/Promise/prototype/then/S25.4.5.3_A5.1_T1.js
// test/built-ins/Promise/prototype/then/S25.4.5.3_A5.2_T1.js
// (adapted: only non-callable handlers are probed; callables that are not plain
// functions, such as tool references, intentionally throw in CodeMode)
expect(
await value(`
const observe = async (promise) => {
try { return ["fulfilled", await promise] } catch (reason) { return ["rejected", reason] }
}
return await Promise.all([
observe(Promise.resolve(1).then(2)),
observe(Promise.resolve(4).then(null, null)),
observe(Promise.resolve(5).then({}, "x")),
observe(Promise.reject(3).then(null, "x")),
observe(Promise.reject(6).then(7, {})),
])
`),
).toEqual([
["fulfilled", 1],
["fulfilled", 4],
["fulfilled", 5],
["rejected", 3],
["rejected", 6],
])
})
test("finally waits for a returned promise and preserves or replaces settlement", async () => {
// Sources:
// test/built-ins/Promise/prototype/finally/resolved-observable-then-calls.js
// test/built-ins/Promise/prototype/finally/rejected-observable-then-calls.js
// test/built-ins/Promise/prototype/finally/resolution-value-no-override.js
expect(
await value(`
const observe = async (promise) => {
try { return ["fulfilled", await promise] } catch (reason) { return ["rejected", reason] }
}
const order = []
const cleanup = async () => {
await Promise.resolve()
order.push("cleanup")
}
const settled = await Promise.resolve("kept").finally(() => cleanup())
order.push("settled:" + settled)
return [
await observe(Promise.resolve(1).finally(() => Promise.resolve(99))),
order,
await observe(Promise.resolve(2).finally(() => Promise.reject(3))),
await observe(Promise.reject(4).finally(() => Promise.resolve(99))),
]
`),
).toEqual([
["fulfilled", 1],
["cleanup", "settled:kept"],
["rejected", 3],
["rejected", 4],
])
})
test("then adopts a returned rejected promise", async () => {
// Sources:
// test/built-ins/Promise/prototype/then/rxn-handler-fulfilled-return-abrupt.js
// test/built-ins/Promise/resolve/resolve-promise.js
// (adapted: the fulfillment handler returns an already-rejected promise instead of
// throwing, and the rejection handler recovers with a fulfilled promise)
expect(
await value(`
const observe = async (promise) => {
try { return ["fulfilled", await promise] } catch (reason) { return ["rejected", reason] }
}
return await Promise.all([
observe(Promise.resolve(1).then(() => Promise.reject("bad"))),
observe(Promise.reject(2).then(undefined, () => Promise.resolve("ok"))),
])
`),
).toEqual([
["rejected", "bad"],
["fulfilled", "ok"],
])
})
test("independent reactions on one source each observe the same settlement", async () => {
// Source: test/built-ins/Promise/prototype/then/S25.4.4_A2.1_T1.js
// (adapted: the multiple-reactions family is asserted through the values every
// reaction returns instead of a shared completion counter)
expect(
await value(`
const fulfilled = Promise.resolve(7)
const rejected = Promise.reject(8)
return await Promise.all([
fulfilled.then((value) => "first:" + value),
fulfilled.then((value) => "second:" + value),
rejected.catch((reason) => "first:" + reason),
rejected.catch((reason) => "second:" + reason),
])
`),
).toEqual(["first:7", "second:7", "first:8", "second:8"])
})
test("await always resumes in a later reaction and interleaves async functions", async () => {
// Sources:
// test/language/expressions/await/async-await-interleaved.js
// test/language/expressions/await/await-non-promise.js
@ -814,7 +912,7 @@ describe("Test262 expected Promise conformance", () => {
).toEqual(["first:1", "second:1", "first:2", "second:2"])
})
test.failing("an async function rejects when it resolves with its own promise", async () => {
test("an async function rejects when it resolves with its own promise", async () => {
// Adapted from the self-resolution requirement represented by:
// test/built-ins/Promise/resolve-self.js
// test/built-ins/Promise/resolve/S25.4.4.5_A4.1_T1.js

View file

@ -3,8 +3,9 @@ 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, and Promise.all/allSettled/race/resolve/reject are
// ordinary functions over arbitrary arrays mixing promises and plain values.
// 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>
@ -184,7 +185,7 @@ describe("first-class promise values", () => {
expect(result.toolCalls).toStrictEqual([{ name: "host.sleepy" }])
})
test("await of a non-promise value is a passthrough no-op", async () => {
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()
@ -935,16 +936,124 @@ describe("timeout interruption of forked calls", () => {
})
})
describe("unsupported promise surface", () => {
test(".then/.catch/.finally give a clear await-instead error", async () => {
for (const method of ["then", "catch", "finally"]) {
const diagnostic = await error(`return tools.host.sleepy({ id: 1 }).${method}((x) => x)`)
expect(diagnostic.kind).toBe("UnsupportedSyntax")
expect(diagnostic.message).toContain(`Promise.prototype.${method} is not supported`)
expect(diagnostic.message).toContain("await")
}
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")