mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-24 16:53:47 +00:00
feat(codemode): assimilate promise thenables (#38237)
This commit is contained in:
parent
59ad593d9c
commit
69c05ae3fc
4 changed files with 189 additions and 66 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue