feat(codemode): assimilate promise thenables (#38237)

This commit is contained in:
Aiden Cline 2026-07-21 23:32:02 -05:00 committed by GitHub
parent 59ad593d9c
commit 69c05ae3fc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 189 additions and 66 deletions

View file

@ -136,8 +136,8 @@ ultimate source of truth.
- [x] Optional property access and optional calls.
- [x] Function/tool calls and spread arguments.
- [x] Sequence expressions (the comma operator).
- [x] `await` for CodeMode promises; a plain value passes through unchanged, though every `await` still defers its
continuation one reaction turn.
- [x] `await` for CodeMode promises and callable thenables; a plain value passes through unchanged, though every
`await` still defers its continuation one reaction turn.
- [x] `new` for Array, Object, Error types, Date, RegExp, Map, Set, URL, URLSearchParams, and Promise.
- [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`.
- [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`.
@ -152,7 +152,8 @@ ultimate source of truth.
## Promises and tools
- [x] Tool calls start eagerly and return supervised, run-once CodeMode promises.
- [x] Direct `await`, repeated awaits, and implicit resolution when a promise is returned from a function/program.
- [x] Direct `await`, repeated awaits, and recursive thenable assimilation when a promise or thenable is returned from
a function/program.
- [x] `Promise.resolve` and `Promise.reject`.
- [x] `Promise.all`, `Promise.allSettled`, `Promise.race`, and `Promise.any` over finite collections, custom synchronous
iterators, and synchronous generators containing promises and plain values.
@ -178,11 +179,14 @@ ultimate source of truth.
the catch-normalized reasons in input order, and empty input rejects with an empty `AggregateError`.
- [x] `new Promise((resolve, reject) => ...)`: the executor runs synchronously and receives first-class resolve/reject
callables that settle the promise exactly once (they may escape the executor and settle later); an executor
throw rejects unless the promise already settled, resolving with a promise adopts it, and resolving with the
promise itself rejects with a `TypeError`. Resolver callables work anywhere callbacks are accepted, including
`.then`/`.catch` handlers and collection callbacks, but remain opaque references that cannot cross the data
boundary.
- [ ] Thenable assimilation; objects with a callable `then` field remain plain data.
throw rejects unless the promise already settled, resolving with a promise or callable thenable adopts it, and
resolving with the promise itself rejects with a `TypeError`. Resolver callables work anywhere callbacks are
accepted, including `.then`/`.catch` handlers and collection callbacks, but remain opaque references that cannot
cross the data boundary.
- [x] Recursive assimilation of objects with an own callable `then` field across `Promise.resolve`, combinators,
constructors, reactions, `finally`, `await`, and async returns. Thenable methods run deferred, receive
first-call-wins resolve/reject functions, and ignore throws after settlement. Inherited/accessor `then` fields
and a JavaScript `this` receiver remain outside the supported object/function model.
- [x] Dotted tool names are canonicalized into namespace paths; a path can be both callable and a namespace, and the
last definition supplied for a canonical path wins.
- [x] Tool path segments may be named `constructor`, `prototype`, or `__proto__` because paths use inert Map keys.

View file

@ -87,6 +87,51 @@ export class PromiseRuntime<R> {
export const selfResolutionError = (node?: AstNode): InterpreterRuntimeError =>
new InterpreterRuntimeError("Chaining cycle detected: a promise cannot resolve with itself.", node).as("TypeError")
export const resolvePromiseValue = <R>(
runner: CallbackRunner<R>,
value: unknown,
node: AstNode,
own?: { promise?: CodeModePromise },
): Effect.Effect<unknown, unknown, R> => {
if (own?.promise !== undefined && value === own.promise) return Effect.fail(selfResolutionError(node))
if (value instanceof CodeModePromise) return runner.settlePromise(value)
if (value === null || typeof value !== "object" || !Object.hasOwn(value, "then")) return Effect.succeed(value)
const then = (value as SafeObject).then
if (typeofValue(then) !== "function") return Effect.succeed(value)
return Effect.gen(function* () {
// Promise resolution invokes a thenable's method in a later job.
yield* Effect.yieldNow
const deferred = Deferred.makeUnsafe<unknown, unknown>()
const resolve = new PromiseCapabilityFunction((result) => {
Deferred.doneUnsafe(deferred, Exit.succeed(result))
})
const reject = new PromiseCapabilityFunction((reason) => {
Deferred.doneUnsafe(deferred, Exit.fail(new ProgramThrow(reason)))
})
const executed = yield* Effect.exit(runner.invokeCallable(then, [resolve, reject], node))
if (!Exit.isSuccess(executed)) {
if (Cause.hasInterruptsOnly(executed.cause)) return yield* Effect.failCause(executed.cause)
Deferred.doneUnsafe(deferred, Exit.fail(Cause.squash(executed.cause)))
}
return yield* resolvePromiseValue(runner, yield* Deferred.await(deferred), node, own)
})
}
export const resolvePromise = <R>(
runner: CallbackRunner<R>,
promises: PromiseRuntime<R>,
value: unknown,
node: AstNode,
): Effect.Effect<CodeModePromise, never, R> => {
if (value instanceof CodeModePromise) return Effect.succeed(value)
const box: { promise?: CodeModePromise } = {}
return Effect.map(promises.create(resolvePromiseValue(runner, value, node, box)), (promise) => {
box.promise = promise
return promise
})
}
export const invokePromiseMethod = <R>(
runner: CallbackRunner<R> & SyncIteratorRunner<R>,
promises: PromiseRuntime<R>,
@ -95,8 +140,7 @@ export const invokePromiseMethod = <R>(
node: AstNode,
): Effect.Effect<unknown, unknown, R> => {
if (ref.name === "resolve") {
const value = args[0]
return value instanceof CodeModePromise ? Effect.succeed(value) : promises.create(Effect.succeed(value))
return resolvePromise(runner, promises, args[0], node)
}
if (ref.name === "reject") {
return promises.create(Effect.fail(new ProgramThrow(args[0])))
@ -111,20 +155,19 @@ export const invokePromiseMethod = <R>(
node,
).as("TypeError")
}
const items: Array<unknown> = []
const items: Array<CodeModePromise> = []
while (true) {
const step = yield* cursor.next
if (step.done) break
items.push(step.value)
if (step.value instanceof CodeModePromise) promises.markObserved(step.value)
const item = yield* resolvePromise(runner, promises, step.value, node)
promises.markObserved(item)
items.push(item)
}
if (ref.name === "all") {
return yield* settleAfterTurn(
Effect.all(
items.map((item) =>
item instanceof CodeModePromise ? Effect.flatten(promises.await(item)) : Effect.succeed(item),
),
items.map((item) => Effect.flatten(promises.await(item))),
{ concurrency: "unbounded" },
),
)
@ -132,7 +175,7 @@ export const invokePromiseMethod = <R>(
if (ref.name === "allSettled") {
const outcomes: Array<unknown> = []
for (const item of items) {
const exit = item instanceof CodeModePromise ? yield* promises.await(item) : Exit.succeed(item)
const exit = yield* promises.await(item)
if (Exit.isSuccess(exit)) {
outcomes.push(Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value }))
continue
@ -155,24 +198,14 @@ export const invokePromiseMethod = <R>(
node,
)
}
return yield* settleAfterTurn(
Effect.flatten(
Effect.raceAll(
items.map((item) =>
item instanceof CodeModePromise ? promises.await(item) : Effect.succeed(Exit.succeed(item)),
),
),
),
)
return yield* settleAfterTurn(Effect.flatten(Effect.raceAll(items.map((item) => promises.await(item)))))
}
const flipped = items.map((item) =>
item instanceof CodeModePromise
? Effect.flatMap(promises.await(item), (exit) => {
if (Exit.isSuccess(exit)) return Effect.fail(new PromiseAnyFulfilled(exit.value))
if (Cause.hasInterruptsOnly(exit.cause)) return Effect.failCause(exit.cause)
return Effect.succeed(caughtErrorValue(Cause.squash(exit.cause)))
})
: Effect.fail(new PromiseAnyFulfilled(item)),
Effect.flatMap(promises.await(item), (exit) => {
if (Exit.isSuccess(exit)) return Effect.fail(new PromiseAnyFulfilled(exit.value))
if (Cause.hasInterruptsOnly(exit.cause)) return Effect.failCause(exit.cause)
return Effect.succeed(caughtErrorValue(Cause.squash(exit.cause)))
}),
)
return yield* settleAfterTurn(
Effect.all(flipped, { concurrency: "unbounded" }).pipe(
@ -219,15 +252,11 @@ export const constructPromise = <R>(
}
return Effect.gen(function* () {
const deferred = Deferred.makeUnsafe<unknown, unknown>()
const box: { own?: CodeModePromise } = {}
const box: { promise?: CodeModePromise } = {}
const promise = yield* promises.create(
Effect.flatMap(Deferred.await(deferred), (value) => {
if (!(value instanceof CodeModePromise)) return Effect.succeed(value)
if (value === box.own) return Effect.fail(selfResolutionError(node))
return runner.settlePromise(value)
}),
Effect.flatMap(Deferred.await(deferred), (value) => resolvePromiseValue(runner, value, node, box)),
)
box.own = promise
box.promise = promise
const resolve = new PromiseCapabilityFunction((value) => {
Deferred.doneUnsafe(deferred, Exit.succeed(value))
})
@ -283,19 +312,17 @@ const chainReaction = <R>(
method: string,
node: AstNode,
): Effect.Effect<CodeModePromise, never, R> => {
const box: { derived?: CodeModePromise } = {}
const box: { promise?: CodeModePromise } = {}
const body = Effect.gen(function* () {
const exit = yield* reactionExit(promises, source)
const handler = Exit.isSuccess(exit) ? onFulfilled : onRejected
if (handler === undefined) return yield* exit
const input = Exit.isSuccess(exit) ? exit.value : caughtErrorValue(Cause.squash(exit.cause))
const result = yield* applyCollectionCallback(runner, handler, method, node)([input])
if (result === box.derived) return yield* Effect.fail(selfResolutionError(node))
if (result instanceof CodeModePromise) return yield* runner.settlePromise(result)
return result
return yield* resolvePromiseValue(runner, result, node, box)
})
return Effect.map(promises.create(body), (derived) => {
box.derived = derived
box.promise = derived
return derived
})
}
@ -313,7 +340,13 @@ const chainFinally = <R>(
const exit = yield* reactionExit(promises, source)
if (cleanup !== undefined) {
const result = yield* applyCollectionCallback(runner, cleanup, method, node)([])
if (result instanceof CodeModePromise) yield* runner.settlePromise(result)
const intermediate = yield* promises.create(
Effect.gen(function* () {
yield* runner.settlePromise(yield* resolvePromise(runner, promises, result, node))
return yield* exit
}),
)
return yield* runner.settlePromise(intermediate)
}
return yield* exit
}),

View file

@ -57,7 +57,8 @@ import {
invokePromiseInstanceMethod,
invokePromiseMethod,
PromiseRuntime,
selfResolutionError,
resolvePromise,
resolvePromiseValue,
} from "./promises.js"
import { containsOpaqueReference, isRuntimeReference, rejectCircularInsertion, typeofValue } from "./references.js"
import { ScopeStack } from "./scope.js"
@ -266,6 +267,8 @@ type GeneratorState = {
available?: Deferred.Deferred<void>
}
const promiseResolutionNode: AstNode = { type: "PromiseResolution" }
export class Interpreter<R> {
private scopes: ScopeStack
private readonly invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
@ -356,7 +359,7 @@ export class Interpreter<R> {
}
// The implicit async body adopts returned promises before copy-out.
if (value instanceof CodeModePromise) value = yield* self.settlePromise(value)
value = yield* resolvePromiseValue(self.runner, value, program)
return value
}).pipe(Effect.ensuring(Effect.sync(() => self.scopes.pop())))
}
@ -778,8 +781,10 @@ export class Interpreter<R> {
)
}
private awaitValue(value: unknown): Effect.Effect<unknown, unknown, R> {
return value instanceof CodeModePromise ? this.settlePromise(value) : Effect.as(Effect.yieldNow, value)
private awaitValue(value: unknown, node: AstNode = promiseResolutionNode): Effect.Effect<unknown, unknown, R> {
return Effect.flatMap(resolvePromise(this.runner, this.promises, value, node), (promise) =>
this.settlePromise(promise),
)
}
private awaitAsyncFromSyncValue(
@ -1387,9 +1392,8 @@ export class Interpreter<R> {
return this.evaluateUpdateExpression(node)
case "AwaitExpression": {
// Await always suspends, including for plain values.
const self = this
return Effect.flatMap(this.evaluateExpression(getNode(node, "argument")), (value) =>
value instanceof CodeModePromise ? self.settlePromise(value) : Effect.as(Effect.yieldNow, value),
this.awaitValue(value, node),
)
}
case "YieldExpression":
@ -2102,18 +2106,12 @@ export class Interpreter<R> {
})
if (fn.generator) return Effect.succeed(this.createGenerator(invocation, run, fn.async))
if (!fn.async) return run
// The initial yield assigns `box.own` before the body can self-resolve.
const box: { own?: CodeModePromise } = {}
// The initial yield assigns the promise before the body can self-resolve.
const box: { promise?: CodeModePromise } = {}
return Effect.map(
this.createPromise(
Effect.flatMap(run, (value) => {
if (!(value instanceof CodeModePromise)) return Effect.succeed(value)
if (value === box.own) return Effect.fail(selfResolutionError())
return invocation.settlePromise(value)
}),
),
this.createPromise(Effect.flatMap(run, (value) => resolvePromiseValue(invocation.runner, value, fn.body, box))),
(promise) => {
box.own = promise
box.promise = promise
return promise
},
)

View file

@ -946,7 +946,7 @@ describe("Test262 expected Promise conformance", () => {
).toBe("TypeError")
})
test.failing("Promise.resolve recursively assimilates callable thenables", async () => {
test("Promise.resolve recursively assimilates callable thenables", async () => {
// Source: test/built-ins/Promise/resolve/resolve-thenable.js
expect(
await value(`
@ -958,7 +958,7 @@ describe("Test262 expected Promise conformance", () => {
).toBe(true)
})
test.failing("Promise combinators assimilate callable thenable inputs", async () => {
test("Promise combinators assimilate callable thenable inputs", async () => {
// Sources:
// test/built-ins/Promise/all/reject-immed.js
// test/built-ins/Promise/all/reject-ignored-immed.js
@ -988,7 +988,7 @@ describe("Test262 expected Promise conformance", () => {
])
})
test.failing("await assimilates callable thenables", async () => {
test("await assimilates callable thenables", async () => {
// Source: test/language/expressions/await/await-awaits-thenables.js
expect(
await value(`
@ -998,7 +998,7 @@ describe("Test262 expected Promise conformance", () => {
).toBe(42)
})
test.failing("await rejects when a callable thenable throws", async () => {
test("await rejects when a callable thenable throws", async () => {
// Source: test/language/expressions/await/await-awaits-thenables-that-throw.js
expect(
await value(`
@ -1013,6 +1013,94 @@ describe("Test262 expected Promise conformance", () => {
`),
).toBe(true)
})
test("thenable resolution is deferred and settles only once", async () => {
// Sources:
// test/built-ins/Promise/resolve/S25.Promise_resolve_foreign_thenable_2.js
// test/built-ins/Promise/exception-after-resolve-in-thenable-job.js
expect(
await value(`
const sequence = []
const thenable = {
then: (resolve, reject) => {
sequence.push("then")
resolve(1)
reject(2)
throw 3
}
}
const promise = Promise.resolve(thenable)
sequence.push("after resolve")
const result = await promise
sequence.push("after await")
return [result, sequence]
`),
).toEqual([1, ["after resolve", "then", "after await"]])
})
test("the first thenable rejection wins over later resolution and throws", async () => {
// Source: test/built-ins/Promise/exception-after-resolve-in-thenable-job.js
expect(
await value(`
const thenable = {
then: (resolve, reject) => {
reject("first")
resolve("second")
throw "third"
}
}
try {
await thenable
return "fulfilled"
} catch (reason) {
return reason
}
`),
).toBe("first")
})
test("constructors, reactions, finally, and async returns assimilate thenables", async () => {
// Sources:
// test/built-ins/Promise/resolve-thenable-immed.js
// test/built-ins/Promise/prototype/then/resolve-settled-fulfilled-thenable.js
// test/built-ins/Promise/prototype/finally/resolved-observable-then-calls.js
const thenable = (value: string) => `({ then: (resolve) => resolve(${JSON.stringify(value)}) })`
expect(
await value(`
const fromAsync = async () => ${thenable("async")}
const cleanup = []
return await Promise.all([
new Promise((resolve) => resolve(${thenable("constructor")})),
Promise.resolve().then(() => ${thenable("reaction")}),
Promise.resolve("kept").finally(() => {
cleanup.push("ran")
return ${thenable("ignored")}
}),
fromAsync(),
]).then((values) => [values, cleanup])
`),
).toEqual([["constructor", "reaction", "kept", "async"], ["ran"]])
})
test("finally settles after its cleanup thenable reactions", async () => {
// Source: test/built-ins/Promise/prototype/finally/resolved-observable-then-calls-PromiseResolve.js
expect(
await value(`
const sequence = []
const cleanup = { then: (resolve) => { sequence.push("then"); resolve() } }
const result = Promise.resolve("kept").finally(() => cleanup)
result.then(() => sequence.push("finally"))
Promise.resolve()
.then(() => sequence.push("tick1"))
.then(() => sequence.push("tick2"))
.then(() => sequence.push("tick3"))
.then(() => sequence.push("tick4"))
await result
sequence.push("await")
return sequence
`),
).toEqual(["tick1", "then", "tick2", "tick3", "tick4", "finally", "await"])
})
})
describe("Test262 Promise.any", () => {