Compare commits

...

15 Commits

Author SHA1 Message Date
Aiden Cline 314388f46e fix(codemode): align promise combinators with Test262 2026-07-08 17:51:41 -05:00
Aiden Cline 6273304f2a Merge remote-tracking branch 'origin/v2' into promise-chaining
# Conflicts:
#	packages/codemode/src/interpreter/runtime.ts
#	packages/codemode/src/tool-runtime.ts
2026-07-08 17:33:50 -05:00
Aiden Cline 34b4fa9543 chore: merge v2 2026-07-06 23:04:26 -05:00
Aiden Cline 99c4e201d5 chore: retry ci 2026-07-07 00:41:35 +00:00
Aiden Cline 61391353c9 fix(app): add session deletion event id 2026-07-06 23:52:20 +00:00
Aiden Cline 0a0d7d69cd fix(codemode): preserve promise reaction order 2026-07-06 18:29:59 -05:00
Aiden Cline d7d0113591 docs(codemode): narrow chaining guidance 2026-07-06 18:20:32 -05:00
Aiden Cline c9b4eddf25 docs(codemode): clarify promise status 2026-07-06 18:18:19 -05:00
Aiden Cline 78520af164 fix(codemode): supervise promise lifecycles 2026-07-06 18:11:16 -05:00
Aiden Cline 9445497fc5 docs(codemode): track promise status 2026-07-06 17:50:21 -05:00
Aiden Cline cf5e278447 fix(codemode): return promises from combinators 2026-07-06 17:32:58 -05:00
Aiden Cline c3b9d03bdb fix(codemode): preserve promise race losers 2026-07-06 17:24:00 -05:00
Aiden Cline 0800770b0d docs(codemode): track thenable assimilation 2026-07-06 17:04:03 -05:00
Aiden Cline 8b53f362e8 docs(codemode): describe promise chaining 2026-07-06 16:58:55 -05:00
Aiden Cline 1289e00778 feat(codemode): support promise chaining 2026-07-06 16:52:00 -05:00
11 changed files with 1020 additions and 212 deletions
+2 -2
View File
@@ -246,12 +246,12 @@ CodeMode executes a deliberately bounded JavaScript subset. It supports:
- Regular expressions - `/literals/` and `new RegExp(...)` with `test`/`exec` (stateful `lastIndex` for `g`), plus string `match`/`matchAll`/`replace`/`replaceAll`/`split`/`search` with patterns. Match results are arrays carrying `index` and named `groups` as own properties (`input` is omitted). `replace` and `replaceAll` accept function replacers with captures, offset, input, and named groups; callbacks run sequentially, may await tool calls, and have their results coerced to strings. Invalid patterns, invalid flags, and missing-`g` calls fail with catchable errors that say what was wrong and how to fix it (escaping hints, the exact `/pattern/g` to write). Patterns run on the host engine, so pathological backtracking is bounded only by the execution timeout.
- `Map` and `Set` - construction from entries/arrays/strings, `get`/`set`/`add`/`has`/`delete`/`clear`/`size`/`forEach`, and `keys`/`values`/`entries` returning **arrays** (not iterators).
- URL helpers - `URL` resolution and mutation, linked `URLSearchParams`, `URL.canParse`/`URL.parse`, URI and URI-component encoding/decoding, and query parameter construction, lookup, mutation, sorting, callbacks, and materialization. URLSearchParams iteration methods return arrays, matching the Map/Set convention.
- First-class promises - an un-awaited `tools.ns.tool(...)` is a promise value whose call starts immediately on a supervised fiber; `await` resolves it (awaiting a non-promise value is a no-op, and `return tools.ns.tool(...)` resolves like an async-function return). `Promise.all`, `Promise.allSettled`, and `Promise.race` accept any array mixing promises and plain values (built inline, beforehand, or via spread); `Promise.resolve`/`Promise.reject` construct settled promises. `Promise.allSettled` rejection reasons are the same plain `{ name?, message }` data a `catch` binding sees, and `Promise.race` interrupts its losing in-flight calls. At most 8 tool calls run concurrently. When a program completes, still-running un-awaited calls are awaited before the execution ends; a failure from a call that was never awaited surfaces as an unhandled-rejection diagnostic.
- First-class promises - an un-awaited `tools.ns.tool(...)` is a promise value whose call starts immediately on a supervised fiber; `await` resolves it (awaiting a non-promise value is a no-op, and `return tools.ns.tool(...)` resolves like an async-function return). Promises support `then`/`catch`/`finally`, including returned-promise flattening and rejection recovery. `Promise.all`, `Promise.allSettled`, and `Promise.race` accept any array mixing promises and plain values (built inline, beforehand, or via spread); `Promise.resolve` preserves an existing promise or wraps a plain value, and `Promise.reject` creates a rejected promise. `Promise.allSettled` rejection reasons use the same values a `catch` binding sees, and `Promise.race` lets losing promises continue. At most 8 tool calls run concurrently. When a program completes, still-running un-awaited calls are awaited before the execution ends; a failure from a call that was never awaited surfaces as an unhandled-rejection diagnostic.
- `throw value` and `throw new Error(message)` for explicit program failure. `Error` (and `TypeError`/`RangeError`/`SyntaxError`/`ReferenceError`/`EvalError`/`URIError`) are real constructors, callable with or without `new`; error values are plain `{ name, message }` data that additionally satisfy `instanceof Error` (a specific type matches itself and `Error`, as in JS). Every caught failure - thrown errors, interpreter runtime errors, and tool failures - is `instanceof Error` in a `catch` block; a thrown non-error value (`throw "text"`) is not, matching JS. Caught failures carry the `name` the equivalent real-JS failure would have - `JSON.parse` and invalid regex patterns produce a `SyntaxError` (satisfying `instanceof SyntaxError`), an unknown identifier a `ReferenceError`, assigning to a constant a `TypeError`, a bad `normalize` form a `RangeError`; failures with no specific analogue (including tool failures) are named `"Error"`. `instanceof` also recognizes `Date`, `RegExp`, `Map`, `Set`, `URL`, `URLSearchParams`, `Array`, `Object`, and `Promise`; any other right-hand side is a catchable error.
Inside a program, standard-library values stay live everywhere: the internal data checkpoints (`Object.*` helpers, spread, coercion inputs) preserve the instances, so `Object.values({ d: date })[0].getTime()` and a spread copy of an object holding a Map keep working. Only at the host boundary (final result, tool arguments, `JSON.stringify`) do they serialize exactly as `JSON.stringify` would: Date and URL become strings (an invalid Date becomes `null`), while RegExp, Map, Set, and URLSearchParams become `{}`. Promise values never cross a data boundary: an un-awaited promise in a result or tool argument produces a diagnostic that says to await it, instead of serializing to `{}`.
It does not expose `eval`, dynamic imports, modules, classes, generators, timers, host globals, prototype mutation, custom promise constructors (`new Promise`), promise chaining (`.then`/`.catch`/`.finally` - `await` with `try`/`catch` is the supported style), or arbitrary method calls. Unsupported syntax returns an `UnsupportedSyntax` diagnostic with a source location when available.
It does not expose `eval`, dynamic imports, modules, classes, generators, timers, host globals, prototype mutation, custom promise constructors (`new Promise`), or arbitrary method calls. Unsupported syntax returns an `UnsupportedSyntax` diagnostic with a source location when available.
CodeMode is an orchestration language, not a general JavaScript runtime.
+61 -6
View File
@@ -63,8 +63,10 @@ path lookup, namespace browsing, deterministic ranking, and pagination.
### Tool execution
Calling a tool starts its Effect eagerly on a supervised fiber. The returned sandbox promise is run-once and can be
awaited directly or through the supported `Promise` combinators. At most eight tool calls execute concurrently.
Unfinished calls are drained before successful program completion, and an unhandled call failure becomes a diagnostic.
awaited directly, chained with `then`/`catch`/`finally`, or passed through the supported `Promise` combinators. At most
eight tool calls execute concurrently.
Unfinished tracked promises are drained before successful program completion, and an unhandled rejection becomes a
diagnostic.
The public execution-policy knobs are `timeoutMs`, `maxToolCalls`, and `maxOutputBytes`. The package supplies no
defaults because budgets are host policy. The interpreter also enforces fixed internal boundaries for tool-call
@@ -109,6 +111,62 @@ MCP tools use this canonical path: they register as grouped tools and are deferr
output schemas are preserved in generated signatures. Direct Core tools remain direct and are not ambient globals
inside CodeMode.
## Promise Status
The runtime currently provides eager, run-once promises for tool calls and async functions; `await`;
`then`/`catch`/`finally`; and chainable `all`/`allSettled`/`race`/`resolve`/`reject`. `Promise.all` rejects promptly while
siblings continue, and `Promise.race` leaves losers running as JavaScript does. Tracked work remains supervised in one
execution scope, at most eight tool calls run concurrently, and ordinary success or failure drains unfinished work before
closing. Timeout and external interruption cancel immediately instead.
### Confirmed defects
- [ ] Align handler callability with the values CodeMode reports as functions, or document the narrower callback
allowlist. For example, unsupported constructor-like callables are currently treated as absent handlers.
- [ ] Run synchronous Promise reactions to completion. Reactions currently start in FIFO order but can interleave when
Effect auto-yields a long synchronous handler.
- [ ] Give `Promise.race` settlement the same reaction turn as native promises. Its result currently settles before an
independently queued reaction in several Test262 ordering cases.
- [ ] Drain queued reactions before classifying rejected sources as unhandled. A handler attached by an already-queued
reaction can currently lose to execution-boundary rejection reporting.
### Deliberate deviations and open decisions
- CodeMode drains unfinished work before ordinary success or failure closes. This keeps tool effects supervised, but a
race loser or fail-fast `Promise.all` sibling that never settles can hold execution open indefinitely when the host
supplies no timeout.
- Promise resolution unwraps only `SandboxPromise`; arbitrary `{ then(resolve, reject) }` values remain data. Full
thenable assimilation requires internal callable resolver values, first-settlement arbitration, recursive adoption,
and cycle detection. Decide whether that machinery belongs in the bounded runtime.
- `new Promise`, `Promise.any`, resolver APIs, subclasses/species, and the broader prototype surface are unavailable.
Consider `Promise.any` independently; custom constructors and subclassing are not current goals.
- Combinators currently accept arrays plus CodeMode's spreadable strings, Maps, and Sets, while documentation and
diagnostics describe array inputs. Choose and document one contract.
- `Promise.race([])` raises a clear error instead of creating a permanently pending promise.
- Rejection tracking is execution-scoped and checked at drain time, not an ECMAScript microtask-level unhandled
rejection model.
### Covered regressions
- Nested unreturned tool calls remain alive after an async function or `then` handler settles.
- Abandoned failing tool calls, async functions, and immediate `Promise.reject` values report unhandled rejections.
- Ordinary program failure drains pending work and preserves the original error; a rejecting race winner drains its slow
loser.
- Timeouts interrupt all in-flight promise fibers with parallel teardown, while host interruption propagates instead of
becoming a diagnostic.
- Promise reactions and plain, pending, or settled `await` continuations start in deterministic FIFO order. Nested
reactions preserve enqueue order, while an async reaction can suspend without blocking the next queued reaction.
- Awaiting the same promise twice settles it once.
- Sparse combinator inputs consume holes as explicit `undefined` values.
### Missing coverage
- Nested unreturned work from `catch` and `finally` handlers.
- Abandoned chained and combinator rejections.
- External interruption while handled pending work remains.
- Never-settling race losers and fail-fast `Promise.all` siblings under an explicit timeout.
- Shared or duplicate promises across combinators, discarded inner chains, and detailed reaction ordering.
## Intentionally Unsupported
These are product boundaries rather than DSL backlog:
@@ -130,7 +188,7 @@ represent accurately rather than guessing semantics.
| Keep an owned tree-walking interpreter. | The product need is bounded tool orchestration, not arbitrary JavaScript. Owning the language surface keeps authority and behavior explicit. |
| Treat schemas as the model-facing interface. | Signatures drive correct calls; Effect Schema also provides the runtime validation boundary, while JSON Schema supports adapter interoperability. |
| Keep authority host-owned. | CodeMode can only confine programs to supplied tools. The host chooses those tools, and each tool enforces its own authorization and side-effect policy. |
| Use progressive catalog disclosure plus search. | Large tool sets should not consume the prompt, but every namespace must remain discoverable and speculative search calls should remain valid. |
| Use progressive catalog disclosure plus search. | Large tool sets should not consume the prompt, but every namespace must remain discoverable and speculative search calls must remain valid. |
| Start tool promises eagerly and supervise them. | This preserves normal call-time parallelism while giving each call run-once settlement and interruption safety. |
| Keep files outside the sandbox value space. | Models should compose structured data without routing binary payloads through generated code or context. |
| Treat `execute` as the model-facing invocation boundary. | Nested calls are implementation details of one orchestration program. Reusing the outer context and bounding only the final result preserves complete intermediate data without inventing durable child-call identities. |
@@ -148,9 +206,6 @@ the adapter TODO. Delete entries when completed.
The supported JavaScript subset should grow when common model-generated code improves tool orchestration. These are
current omissions to implement, not intentional product boundaries.
- [ ] Design proper multi-stage promise pipelines. Supporting `.then`, `.catch`, and `.finally` should preserve promise
assimilation, cancellation, failure handling, and concurrent per-item pipelines rather than adding syntax-only
shims. Consider `Promise.any` in the same pass.
- [ ] Support async iteration and `for await...of`. Define behavior first for the runtime's supported promise and
collection values, then extend it to bounded host streams when a stream boundary exists.
- [ ] Support callback-bearing standard-library variants that models commonly generate: the mapper argument to
+1 -1
View File
@@ -122,7 +122,7 @@ export type DiagnosticKind =
export const OptionalShortCircuit: unique symbol = Symbol("codemode.optional-short-circuit")
export const supportedSyntaxMessage =
"Supported orchestration syntax: tools.* calls (they return promises - resolve them with await), data literals, destructuring, optional chaining, template literals, conditionals, switch, loops (incl. for...of and for...in over object/array/tools keys), arrow functions, spread, try/catch, array methods (map/filter/find/findIndex/some/every/reduce/flatMap/forEach/sort/slice/concat/indexOf/lastIndexOf/at/flat/reverse/includes/join), string methods (incl. match/matchAll/replace/split with regular expressions), Date/RegExp/Map/Set/URL/URLSearchParams, URI encoding helpers, Object/Math/JSON helpers, captured console.log/warn/error/dir/table, and Promise.all/allSettled/race/resolve/reject over arrays mixing promises and plain values for parallel tool calls (promise chaining with .then/.catch is not supported - use await with try/catch)."
"Supported orchestration syntax: tools.* calls (they return promises - resolve them with await or .then/.catch/.finally), data literals, destructuring, optional chaining, template literals, conditionals, switch, loops (incl. for...of and for...in over object/array/tools keys), arrow functions, spread, try/catch, array methods (map/filter/find/findIndex/some/every/reduce/flatMap/forEach/sort/slice/concat/indexOf/lastIndexOf/at/flat/reverse/includes/join), string methods (incl. match/matchAll/replace/split with regular expressions), Date/RegExp/Map/Set/URL/URLSearchParams, URI encoding helpers, Object/Math/JSON helpers, captured console.log/warn/error/dir/table, and Promise.all/allSettled/race/resolve/reject over arrays mixing promises and plain values for parallel tool calls."
export class InterpreterRuntimeError extends Error {
readonly node?: AstNode
+267 -132
View File
@@ -1,5 +1,5 @@
import { parse } from "acorn"
import { Cause, Effect, Exit, Fiber, Semaphore } from "effect"
import { Cause, Deferred, Effect, Exit, Fiber, Queue, Scope, Semaphore } from "effect"
import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript"
import {
copyIn,
@@ -148,6 +148,11 @@ const parseProgram = (code: string): ProgramNode => {
return parsed as ProgramNode
}
type PromiseReaction<R> = {
readonly effect: Effect.Effect<unknown, unknown, R>
readonly settlement: Deferred.Deferred<unknown, unknown>
}
const publicErrorMessage = (message: string): string =>
message.replace(/\/(?:Users|home|private|tmp|var\/folders)\/[^\s"'`]+/g, "<redacted-path>")
@@ -219,7 +224,7 @@ const normalizeError = (error: unknown): Diagnostic => {
}
}
// Shared by catch bindings, Promise.allSettled rejection reasons, and Promise.race losers.
// Shared by catch bindings and Promise.allSettled rejection reasons.
const caughtErrorValue = (thrown: unknown): unknown => {
if (thrown instanceof ProgramThrow) return thrown.value
if (thrown instanceof InterpreterRuntimeError) return createErrorValue(thrown.errorName, thrown.message)
@@ -618,9 +623,14 @@ class Interpreter<R> {
// ToolRuntime.make like invokeTool: the interpreter never holds the tree itself.
private readonly toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>
private readonly logs: Array<string>
private lastValue: unknown
// Every promise fiber belongs to the execution rather than the async function or handler
// that happened to create it. The execution scope still interrupts all work on teardown.
private readonly promiseScope: Scope.Scope
private readonly reactionQueue: Queue.Queue<PromiseReaction<R>>
// Caps how many eagerly forked tool calls run at once (the parallel-call concurrency cap).
private readonly callPermits: Semaphore.Semaphore
// Fiber-backed promises whose settlement no program construct has observed yet. Successful
// Fiber-backed promises whose settlement no program construct has observed yet. Ordinary
// program completion drains these (like a runtime waiting on in-flight work at exit) and
// surfaces a never-awaited failure as an unhandled-rejection diagnostic.
private readonly pendingSettlements: Set<SandboxPromise>
@@ -628,6 +638,8 @@ class Interpreter<R> {
constructor(
invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>,
toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>,
promiseScope: Scope.Scope,
reactionQueue: Queue.Queue<PromiseReaction<R>>,
logs: Array<string> = [],
shared?: { callPermits: Semaphore.Semaphore; pendingSettlements: Set<SandboxPromise> },
) {
@@ -636,6 +648,9 @@ class Interpreter<R> {
this.invokeTool = invokeTool
this.toolKeys = toolKeys
this.logs = logs
this.lastValue = undefined
this.promiseScope = promiseScope
this.reactionQueue = reactionQueue
this.callPermits = shared?.callPermits ?? Semaphore.makeUnsafe(TOOL_CALL_CONCURRENCY)
this.pendingSettlements = shared?.pendingSettlements ?? new Set<SandboxPromise>()
globalScope.set("tools", { mutable: false, value: new ToolReference([]) })
@@ -678,7 +693,7 @@ class Interpreter<R> {
// top-level declarations (`let undefined = 5`, `const Object = ...`) shadow builtins like
// JS module scope, instead of colliding with the seeded globals.
this.pushScope()
return Effect.gen(function* () {
const evaluate = Effect.gen(function* () {
self.hoistFunctions(program.body)
let value: unknown = undefined
for (const [index, statement] of program.body.entries()) {
@@ -703,31 +718,44 @@ class Interpreter<R> {
// resolves before crossing the data boundary - `return tools.ns.tool(...)` works
// without an explicit await, exactly as in JS.
if (value instanceof SandboxPromise) value = yield* self.settlePromise(value)
yield* self.drainPendingSettlements()
return value
})
return Effect.gen(function* () {
const result = yield* Effect.exit(evaluate)
if (Exit.isFailure(result) && Cause.hasInterruptsOnly(result.cause)) {
return yield* Effect.failCause(result.cause)
}
const drained = yield* Effect.exit(self.drainPendingSettlements())
if (Exit.isFailure(result)) return yield* Effect.failCause(result.cause)
if (Exit.isFailure(drained)) return yield* Effect.failCause(drained.cause)
return result.value
}).pipe(Effect.ensuring(Effect.sync(() => self.popScope())))
}
// Awaits every fiber-backed promise the program abandoned (fire-and-forget tool calls), so
// their work completes before the execution ends - mirroring a JS runtime waiting on
// in-flight I/O at exit. A failure nobody could have handled becomes an unhandled-rejection
// diagnostic (interrupted calls, e.g. Promise.race losers, are ignored).
// diagnostic.
private drainPendingSettlements(): Effect.Effect<void, unknown, never> {
const self = this
return Effect.gen(function* () {
let unhandled: InterpreterRuntimeError | undefined
while (self.pendingSettlements.size > 0) {
const promise = self.pendingSettlements.values().next().value
if (promise === undefined) break
const exit = yield* self.observePromise(promise)
if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause)) continue
if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause) || promise.handled) continue
if (unhandled !== undefined) continue
const failure = normalizeError(Cause.squash(exit.cause))
throw new InterpreterRuntimeError(
unhandled = new InterpreterRuntimeError(
`Unhandled rejection from an un-awaited promise: ${failure.message}`,
undefined,
failure.kind,
["Await promises so failures can be caught and handled."],
)
}
if (unhandled !== undefined) throw unhandled
})
}
@@ -743,7 +771,7 @@ class Interpreter<R> {
}
private createPromise(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<SandboxPromise, never, R> {
return Effect.map(Effect.forkChild(effect, { startImmediately: true }), (fiber) => {
return Effect.map(Effect.forkIn(effect, this.promiseScope, { startImmediately: true }), (fiber) => {
const promise = new SandboxPromise(fiber)
this.pendingSettlements.add(promise)
return promise
@@ -760,28 +788,49 @@ class Interpreter<R> {
// `await promise`: succeed with the fulfilled value or re-raise the failure so try/catch
// observes it exactly like a synchronous throw at the await site.
private settlePromise(promise: SandboxPromise, node?: AstNode): Effect.Effect<unknown, unknown, never> {
private settlePromise(promise: SandboxPromise): Effect.Effect<unknown, unknown, never> {
const self = this
return Effect.flatMap(this.observePromise(promise), (exit) => self.unwrapPromiseExit(promise, exit, node))
return Effect.flatMap(this.observePromise(promise), (exit) => self.unwrapPromiseExit(exit))
}
private unwrapPromiseExit(
promise: SandboxPromise | undefined,
exit: Exit.Exit<unknown, unknown>,
node?: AstNode,
): Effect.Effect<unknown, unknown> {
if (Exit.isSuccess(exit)) return Effect.succeed(exit.value)
// A call Promise.race interrupted after losing settles as a catchable program failure;
// any other interruption is execution teardown (timeout/host) and must keep propagating
// as interruption rather than becoming program-visible data.
if (promise?.interrupted === true && Cause.hasInterruptsOnly(exit.cause)) {
return Effect.fail(
new InterpreterRuntimeError(
"This tool call was interrupted because another value settled a Promise.race first.",
node,
private createReaction(
source: Effect.Effect<Exit.Exit<unknown, unknown>, never, R>,
reaction: (exit: Exit.Exit<unknown, unknown>, chained: SandboxPromise) => Effect.Effect<unknown, unknown, R>,
): Effect.Effect<SandboxPromise, never, R> {
const settlement = Deferred.makeUnsafe<unknown, unknown>()
const chained = new SandboxPromise(undefined, Deferred.await(settlement))
this.pendingSettlements.add(chained)
return Effect.as(
Effect.forkIn(
Effect.flatMap(source, (exit) =>
Effect.sync(() =>
Queue.offerUnsafe(this.reactionQueue, {
settlement,
effect: Effect.suspend(() => reaction(exit, chained)),
}),
),
),
)
}
this.promiseScope,
{ startImmediately: true },
),
chained,
)
}
private awaitValue(value: unknown): Effect.Effect<unknown, unknown, R> {
return Effect.flatMap(
this.createReaction(
value instanceof SandboxPromise
? Effect.exit(this.settlePromise(value))
: Effect.succeed(Exit.succeed(value)),
(exit) => this.unwrapPromiseExit(exit),
),
(continuation) => this.settlePromise(continuation),
)
}
private unwrapPromiseExit(exit: Exit.Exit<unknown, unknown>): Effect.Effect<unknown, unknown> {
if (Exit.isSuccess(exit)) return Effect.succeed(exit.value)
return Effect.failCause(exit.cause)
}
@@ -1528,12 +1577,9 @@ class Interpreter<R> {
case "UpdateExpression":
return this.evaluateUpdateExpression(node)
case "AwaitExpression": {
// `await` resolves a promise value; awaiting anything else is a passthrough no-op,
// matching real JS semantics for non-thenables.
const self = this
return Effect.flatMap(this.evaluateExpression(getNode(node, "argument")), (value) =>
value instanceof SandboxPromise ? self.settlePromise(value, node) : Effect.succeed(value),
)
// Even an already-settled value resumes in a later reaction turn, after work that was
// already queued, matching JavaScript's await continuation ordering.
return Effect.flatMap(this.evaluateExpression(getNode(node, "argument")), (value) => this.awaitValue(value))
}
case "NewExpression":
return this.evaluateNewExpression(node)
@@ -2217,14 +2263,24 @@ class Interpreter<R> {
)
}
if (ref.name === "reject") {
return Effect.sync(() => new SandboxPromise(undefined, Effect.fail(new ProgramThrow(args[0]))))
return Effect.sync(() => {
const promise = new SandboxPromise(undefined, Effect.fail(new ProgramThrow(args[0])))
this.pendingSettlements.add(promise)
return promise
})
}
const items = Array.isArray(args[0]) ? args[0] : spreadItems(args[0])
// Promise combinators consume arrays through their iterator, which visits holes as
// explicit undefined values instead of preserving sparse positions like Array.map.
const items = Array.isArray(args[0]) ? [...args[0]] : spreadItems(args[0])
if (items === undefined) {
throw new InterpreterRuntimeError(
`Promise.${ref.name} expects an array of promises or plain values (e.g. Promise.${ref.name}(items.map((item) => tools.ns.tool(item)))).`,
node,
return this.createPromise(
Effect.fail(
new InterpreterRuntimeError(
`Promise.${ref.name} expects an array of promises or plain values (e.g. Promise.${ref.name}(items.map((item) => tools.ns.tool(item)))).`,
node,
).as("TypeError"),
),
)
}
@@ -2238,68 +2294,59 @@ class Interpreter<R> {
? Effect.map(this.observePromise(item), (exit) => ({ index, item, exit }))
: Effect.succeed({ index, item: undefined, exit: Exit.succeed(item) }),
)
return Effect.gen(function* () {
const remaining = [...observations]
const values: Array<unknown> = []
values.length = items.length
while (remaining.length > 0) {
const winner = yield* Effect.raceAll(remaining)
const position = remaining.indexOf(observations[winner.index])
if (position >= 0) remaining.splice(position, 1)
if (Exit.isSuccess(winner.exit)) {
values[winner.index] = winner.exit.value
continue
return this.createPromise(
Effect.gen(function* () {
const remaining = [...observations]
const values: Array<unknown> = []
values.length = items.length
while (remaining.length > 0) {
const winner = yield* Effect.raceAll(remaining)
const position = remaining.indexOf(observations[winner.index])
if (position >= 0) remaining.splice(position, 1)
if (Exit.isSuccess(winner.exit)) {
values[winner.index] = winner.exit.value
continue
}
for (const item of items) {
if (!(item instanceof SandboxPromise) || item === winner.item) continue
item.handled = true
self.pendingSettlements.add(item)
}
return yield* self.unwrapPromiseExit(winner.exit)
}
yield* self.createPromise(
Effect.asVoid(
Effect.forEach(
items,
(item) => (item instanceof SandboxPromise ? self.observePromise(item) : Effect.void),
{ concurrency: "unbounded" },
),
),
)
return yield* self.unwrapPromiseExit(winner.item, winner.exit, node)
}
return values
})
return values
}),
)
}
case "allSettled": {
const observations = items.map((item) =>
item instanceof SandboxPromise
? Effect.map(this.observePromise(item), (exit) => ({ promise: item as SandboxPromise | undefined, exit }))
: Effect.succeed({ promise: undefined as SandboxPromise | undefined, exit: Exit.succeed(item as unknown) }),
item instanceof SandboxPromise ? this.observePromise(item) : Effect.succeed(Exit.succeed(item as unknown)),
)
return Effect.gen(function* () {
const outcomes: Array<unknown> = []
for (const observation of observations) {
const { exit, promise } = yield* observation
if (Exit.isSuccess(exit)) {
outcomes.push(
Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value }),
)
continue
}
const raceInterrupted = promise?.interrupted === true && Cause.hasInterruptsOnly(exit.cause)
if (Cause.hasInterruptsOnly(exit.cause) && !raceInterrupted) {
// Execution teardown (timeout/host interruption), not a program-level rejection.
return yield* Effect.failCause(exit.cause)
}
const thrown = raceInterrupted
? new InterpreterRuntimeError(
"This tool call was interrupted because another value settled a Promise.race first.",
node,
return this.createPromise(
Effect.gen(function* () {
const outcomes: Array<unknown> = []
for (const observation of observations) {
const exit = yield* observation
if (Exit.isSuccess(exit)) {
outcomes.push(
Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value }),
)
: Cause.squash(exit.cause)
outcomes.push(
Object.assign(Object.create(null) as SafeObject, {
status: "rejected",
reason: caughtErrorValue(thrown),
}),
)
}
return outcomes
})
continue
}
if (Cause.hasInterruptsOnly(exit.cause)) {
// Execution teardown (timeout/host interruption), not a program-level rejection.
return yield* Effect.failCause(exit.cause)
}
outcomes.push(
Object.assign(Object.create(null) as SafeObject, {
status: "rejected",
reason: caughtErrorValue(Cause.squash(exit.cause)),
}),
)
}
return outcomes
}),
)
}
case "race": {
if (items.length === 0) {
@@ -2313,28 +2360,26 @@ class Interpreter<R> {
? Effect.map(this.observePromise(item), (exit) => ({ index, exit }))
: Effect.succeed({ index, exit: Exit.succeed(item as unknown) }),
)
return Effect.gen(function* () {
// First settlement (fulfilled OR rejected) wins; the observations never fail, so
// racing them yields exactly that. Losing in-flight calls are then interrupted.
const winner = yield* Effect.raceAll(observations)
for (const [index, item] of items.entries()) {
if (index === winner.index || !(item instanceof SandboxPromise) || item.fiber === undefined) continue
item.interrupted = true
yield* Fiber.interrupt(item.fiber)
}
const winningItem = items[winner.index]
return yield* self.unwrapPromiseExit(
winningItem instanceof SandboxPromise ? winningItem : undefined,
winner.exit,
node,
)
})
return this.createPromise(
Effect.gen(function* () {
// First settlement (fulfilled OR rejected) wins. Losers continue like native
// promises, while a supervised drain keeps their failures handled and their work
// inside the execution lifetime.
const winner = yield* Effect.raceAll(observations)
for (const [index, item] of items.entries()) {
if (index === winner.index || !(item instanceof SandboxPromise)) continue
item.handled = true
self.pendingSettlements.add(item)
}
return yield* self.unwrapPromiseExit(winner.exit)
}),
)
}
}
}
private invokeFunction(fn: CodeModeFunction, args: Array<unknown>): Effect.Effect<unknown, unknown, R> {
const invocation = new Interpreter(this.invokeTool, this.toolKeys, this.logs, {
const invocation = new Interpreter(this.invokeTool, this.toolKeys, this.promiseScope, this.reactionQueue, this.logs, {
callPermits: this.callPermits,
pendingSettlements: this.pendingSettlements,
})
@@ -2377,6 +2422,9 @@ class Interpreter<R> {
args: Array<unknown>,
node: AstNode,
): Effect.Effect<unknown, unknown, R> {
if (ref.receiver instanceof SandboxPromise) {
return this.invokePromisePrototypeMethod(ref.receiver, ref.name, args, node)
}
if (typeof ref.receiver === "string") {
if (
(ref.name === "replace" || ref.name === "replaceAll") &&
@@ -2413,6 +2461,86 @@ class Interpreter<R> {
throw new InterpreterRuntimeError(`Method '${ref.name}' is not available in CodeMode.`, node)
}
private invokePromisePrototypeMethod(
promise: SandboxPromise,
name: string,
args: Array<unknown>,
node: AstNode,
): Effect.Effect<SandboxPromise, never, R> {
const callback = (value: unknown) => {
if (
!(value instanceof ToolReference && value.path.length > 0) &&
!(value instanceof PromiseMethodReference) &&
!(value instanceof CodeModeFunction) &&
!(value instanceof IntrinsicReference) &&
!(value instanceof GlobalMethodReference) &&
!(value instanceof CoercionFunction) &&
!(value instanceof UriFunction) &&
!(value instanceof ErrorConstructorReference)
)
return undefined
return (callbackArgs: Array<unknown>, chained: SandboxPromise) =>
Effect.flatMap(
value instanceof ToolReference
? this.createToolCallPromise(value.path, callbackArgs)
: value instanceof PromiseMethodReference
? this.invokePromiseMethod(value, callbackArgs, node)
: value instanceof CodeModeFunction
? this.invokeFunction(value, callbackArgs)
: value instanceof IntrinsicReference
? this.invokeIntrinsic(value, callbackArgs, node)
: value instanceof GlobalMethodReference
? Effect.sync(() => {
if (value.namespace === "console") return this.invokeConsole(value.name, callbackArgs, node)
if (
value.namespace === "Object" &&
callbackArgs[0] instanceof ToolReference &&
!objectMethodsPreservingIdentity.has(value.name)
)
return this.invokeObjectMethodOnTools(value.name, callbackArgs[0], node)
return invokeGlobalMethod(value, callbackArgs, node)
})
: value instanceof CoercionFunction
? Effect.succeed(invokeCoercion(value, callbackArgs, node))
: value instanceof UriFunction
? Effect.succeed(invokeUriFunction(value, callbackArgs, node))
: Effect.succeed(
createErrorValue(
value.name,
callbackArgs[0] === undefined ? "" : coerceToString(callbackArgs[0]),
),
),
(result) => {
if (result === chained) {
return Effect.fail(
new InterpreterRuntimeError("Chaining cycle detected for promise.", node).as("TypeError"),
)
}
return result instanceof SandboxPromise ? this.settlePromise(result) : Effect.succeed(result)
},
)
}
const onFulfilled = name === "then" ? callback(args[0]) : undefined
const onRejected = name === "then" ? callback(args[1]) : name === "catch" ? callback(args[0]) : undefined
const onFinally = name === "finally" ? callback(args[0]) : undefined
const settlement = Effect.exit(this.settlePromise(promise))
return this.createReaction(
settlement,
(exit, chained) =>
Effect.gen(function* () {
if (onFinally !== undefined) yield* onFinally([], chained)
if (Exit.isSuccess(exit)) {
if (onFulfilled === undefined) return exit.value
return yield* onFulfilled([exit.value], chained)
}
if (onRejected !== undefined)
return yield* onRejected([caughtErrorValue(Cause.squash(exit.cause))], chained)
return yield* Effect.failCause(exit.cause)
}),
)
}
private invokeStringReplacer(
value: string,
name: "replace" | "replaceAll",
@@ -3206,17 +3334,9 @@ class Interpreter<R> {
return new ComputedValue(undefined)
}
// Any property access on a promise is a confused program (`p.then(...)`, `p.value`);
// reading `undefined` here would hide the missing await, so both paths get an explicit,
// await-hinting error instead of the forgiving unknown-property fallthrough.
if (objectValue instanceof SandboxPromise) {
if (key === "then" || key === "catch" || key === "finally") {
throw new InterpreterRuntimeError(
`Promise.prototype.${String(key)} is not supported in CodeMode; use await instead (with try/catch to handle failures) - e.g. \`const result = await tools.ns.tool(...)\`.`,
propertyNode,
"UnsupportedSyntax",
[supportedSyntaxMessage],
)
return new IntrinsicReference(objectValue, key)
}
throw new InterpreterRuntimeError(
"This value is an un-awaited Promise and has no readable properties; await it first - e.g. `const result = await tools.ns.tool(...)`.",
@@ -3505,18 +3625,33 @@ export const executeWithLimits = <const Tools extends Record<string, unknown>>(
})
}
const operation = Effect.gen(function* () {
const program = parseProgram(options.code)
const interpreter = new Interpreter<Services<Tools>>(tools.invoke, tools.keys, logs)
const value = yield* interpreter.run(program)
const result = copyOut(copyIn(value, "Execution result"), true) as DataValue
return {
ok: true,
value: result,
...logged(),
toolCalls: tools.calls,
} satisfies Result
}).pipe((program) => {
const operation = Effect.scoped(
Effect.gen(function* () {
const scope = yield* Effect.acquireRelease(Scope.make("parallel"), (scope, exit) => Scope.close(scope, exit))
const reactionQueue = yield* Queue.unbounded<PromiseReaction<Services<Tools>>>()
yield* Effect.forever(
Effect.gen(function* () {
const reaction = yield* Queue.take(reactionQueue)
yield* Effect.yieldNow
yield* Effect.forkIn(
Effect.flatMap(Effect.exit(reaction.effect), (exit) => Deferred.done(reaction.settlement, exit)),
scope,
{ startImmediately: true },
)
}),
).pipe(Effect.forkIn(scope, { startImmediately: true }))
const program = parseProgram(options.code)
const interpreter = new Interpreter<Services<Tools>>(tools.invoke, tools.keys, scope, reactionQueue, logs)
const value = yield* interpreter.run(program)
const result = copyOut(copyIn(value, "Execution result"), true) as DataValue
return {
ok: true,
value: result,
...logged(),
toolCalls: tools.calls,
} satisfies Result
}),
).pipe((program) => {
const timeoutMs = limits.timeoutMs
if (timeoutMs === undefined) return program
return program.pipe(
+1 -1
View File
@@ -616,7 +616,7 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
"## Language",
"",
"Use common JavaScript data operations, functions, control flow, selected standard-library methods, and awaited tool calls. Built-ins include Date, RegExp, Map, Set, URL, URLSearchParams, and URI encoding helpers.",
"Modules/imports, classes, generators, timers, fetch, eval, prototype access, unlisted methods, and promise chaining are unavailable. Use Code Mode tools for external operations. Use await with try/catch.",
"Modules/imports, classes, generators, timers, fetch, eval, prototype access, and unlisted methods are unavailable. Use Code Mode tools for external operations. Use await with try/catch.",
"Prefer explicit `return`; otherwise only the final top-level expression becomes the result.",
"Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.",
]
+1 -1
View File
@@ -1,7 +1,7 @@
import type { Effect, Fiber } from "effect"
export class SandboxPromise {
interrupted = false
handled = false
constructor(
readonly fiber: Fiber.Fiber<unknown, unknown> | undefined,
readonly immediate?: Effect.Effect<unknown, unknown>,
+4 -1
View File
@@ -652,9 +652,12 @@ 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"]) {
expect(instructions).toContain(missing)
}
expect(instructions).toContain("selected standard-library methods, and awaited tool calls")
expect(instructions).toContain("Use await with try/catch")
expect(instructions).not.toContain("promise chaining are unavailable")
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")
@@ -0,0 +1,334 @@
/*
* Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75:
* - test/built-ins/Promise/all/S25.4.4.1_A1.1_T1.js
* - test/built-ins/Promise/all/S25.4.4.1_A2.1_T1.js
* - test/built-ins/Promise/all/S25.4.4.1_A2.3_T1.js
* - test/built-ins/Promise/all/S25.4.4.1_A2.3_T2.js
* - test/built-ins/Promise/all/S25.4.4.1_A2.3_T3.js
* - test/built-ins/Promise/all/S25.4.4.1_A7.1_T1.js
* - test/built-ins/Promise/all/S25.4.4.1_A8.2_T1.js
* - test/built-ins/Promise/all/S25.4.4.1_A8.2_T2.js
* - test/built-ins/Promise/all/iter-arg-is-number-reject.js
* - test/built-ins/Promise/all/resolve-non-thenable.js
* - test/built-ins/Promise/allSettled/is-function.js
* - test/built-ins/Promise/allSettled/returns-promise.js
* - test/built-ins/Promise/allSettled/resolves-empty-array.js
* - test/built-ins/Promise/allSettled/resolves-to-array.js
* - test/built-ins/Promise/allSettled/resolved-all-fulfilled.js
* - test/built-ins/Promise/allSettled/resolved-all-mixed.js
* - test/built-ins/Promise/allSettled/resolved-all-rejected.js
* - test/built-ins/Promise/allSettled/iter-arg-is-number-reject.js
* - test/built-ins/Promise/allSettled/resolve-non-thenable.js
* - test/built-ins/Promise/race/S25.4.4.3_A1.1_T1.js
* - test/built-ins/Promise/race/S25.4.4.3_A7.1_T1.js
* - test/built-ins/Promise/race/S25.4.4.3_A7.1_T2.js
* - test/built-ins/Promise/race/S25.4.4.3_A7.1_T3.js
* - test/built-ins/Promise/race/S25.4.4.3_A7.2_T1.js
* - test/built-ins/Promise/race/S25.4.4.3_A7.3_T1.js
* - test/built-ins/Promise/race/S25.4.4.3_A7.3_T2.js
* - test/built-ins/Promise/race/iter-arg-is-number-reject.js
* - test/built-ins/Promise/resolve/S25.4.4.5_A1.1_T1.js
* - test/built-ins/Promise/resolve/S25.4.4.5_A2.1_T1.js
* - test/built-ins/Promise/resolve/resolve-non-obj.js
* - test/built-ins/Promise/resolve/resolve-non-thenable.js
* - test/built-ins/Promise/reject/S25.4.4.4_A1.1_T1.js
* - test/built-ins/Promise/reject/S25.4.4.4_A2.1_T1.js
* - test/built-ins/Array/from/from-array.js
* - test/language/statements/async-function/declaration-returns-promise.js
* - test/language/statements/async-function/evaluation-body-that-returns.js
* - test/language/statements/async-function/evaluation-body-that-returns-after-await.js
* - test/language/statements/async-function/evaluation-body-that-throws.js
* - test/language/statements/async-function/evaluation-body-that-throws-after-await.js
*
* Copyright 2014 Cubane Canada, Inc. All rights reserved.
* Copyright (C) 2016 the V8 project authors. All rights reserved.
* Copyright (C) 2019 Leo Balter. All rights reserved.
* Copyright (C) 2018 Rick Waldron. All rights reserved.
* Copyright 2015 Microsoft Corporation. All rights reserved.
* Copyright 2016 Microsoft, Inc. All rights reserved.
* Test262 portions are governed by the BSD license in LICENSE.test262.
*/
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { CodeMode } from "../src/index.js"
const value = async (code: string) => {
const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} }))
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
return result.value
}
describe("Test262 Promise static adaptations", () => {
test("Promise statics are callable", async () => {
expect(
await value(
`return [typeof Promise.all, typeof Promise.allSettled, typeof Promise.race, typeof Promise.resolve, typeof Promise.reject]`,
),
).toEqual(["function", "function", "function", "function", "function"])
})
test("invalid combinator inputs return rejected TypeError promises", async () => {
expect(
await value(`
const observe = async (run) => {
let returned = false
try {
const promise = run()
returned = promise instanceof Promise
await promise
return [returned, "fulfilled"]
} catch (error) {
return [returned, error.name]
}
}
return await Promise.all([
observe(() => Promise.all(42)),
observe(() => Promise.allSettled(42)),
observe(() => Promise.race(42)),
])
`),
).toEqual([
[true, "TypeError"],
[true, "TypeError"],
[true, "TypeError"],
])
})
test("Promise.all returns a promise and creates a fresh empty array", async () => {
expect(
await value(`
const input = []
const promise = Promise.all(input)
const result = await promise
return [promise instanceof Promise, result instanceof Array, result.length, result !== input]
`),
).toEqual([true, true, 0, true])
})
test("Promise.all preserves values and input order", async () => {
expect(
await value(`
const first = { id: 1 }
const second = { id: 2 }
const result = await Promise.all([Promise.resolve(3), first, Promise.resolve(second)])
return [result.length, result[0], result[1] === first, result[2] === second]
`),
).toEqual([3, 3, true, true])
})
test("Promise.all adopts rejection from either input position", async () => {
expect(
await value(`
const observe = async (promise) => {
try { await promise; return "fulfilled" } catch (reason) { return reason }
}
return await Promise.all([
observe(Promise.all([Promise.reject(1), Promise.resolve(2)])),
observe(Promise.all([Promise.resolve(1), Promise.reject(2)])),
])
`),
).toEqual([1, 2])
})
test("Promise.all treats sparse positions as undefined values", async () => {
expect(
await value(`
const input = []
input[1] = 1
const result = await Promise.all(input)
return [result.length, result[0] === undefined, result[1]]
`),
).toEqual([2, true, 1])
})
test("Promise.allSettled returns a promise and a fresh array", async () => {
expect(
await value(`
const input = []
const promise = Promise.allSettled(input)
const result = await promise
return [promise instanceof Promise, result instanceof Array, result.length, result !== input]
`),
).toEqual([true, true, 0, true])
})
test("Promise.allSettled preserves order and settlement shapes", async () => {
expect(
await value(`
const reason = { id: 4 }
const result = await Promise.allSettled([
Promise.resolve(1),
Promise.reject(2),
3,
Promise.reject(reason),
])
return [result, result.map((item) => Object.keys(item))]
`),
).toEqual([
[
{ status: "fulfilled", value: 1 },
{ status: "rejected", reason: 2 },
{ status: "fulfilled", value: 3 },
{ status: "rejected", reason: { id: 4 } },
],
[
["status", "value"],
["status", "reason"],
["status", "value"],
["status", "reason"],
],
])
})
test("Promise.allSettled preserves input order across settlement timing", async () => {
expect(
await value(`
const slow = async () => { await Promise.resolve(); return "slow" }
return await Promise.allSettled([slow(), Promise.resolve("fast")])
`),
).toEqual([
{ status: "fulfilled", value: "slow" },
{ status: "fulfilled", value: "fast" },
])
})
test("Promise.allSettled adopts a non-thenable object", async () => {
expect(
await value(`
const object = { id: 1 }
const result = await Promise.allSettled([object])
return [result, result[0].value === object]
`),
).toEqual([[{ status: "fulfilled", value: { id: 1 } }], true])
})
test("Promise.allSettled treats sparse positions as undefined values", async () => {
expect(
await value(`
const input = []
input[1] = 1
const result = await Promise.allSettled(input)
return [result.length, result[0].status, result[0].value === undefined, result[1]]
`),
).toEqual([2, "fulfilled", true, { status: "fulfilled", value: 1 }])
})
test("Promise.race returns a promise and settles from its first input", async () => {
expect(
await value(`
const rejected = Promise.reject(2)
const fulfilled = Promise.resolve(1)
const promise = Promise.race([fulfilled, rejected])
return [promise instanceof Promise, await promise]
`),
).toEqual([true, 1])
})
test("Promise.race preserves primitive fulfillment and rejection", async () => {
expect(
await value(`
const fulfilled = await Promise.race([23])
let rejected
try { await Promise.race([Promise.reject(7)]) } catch (reason) { rejected = reason }
return [fulfilled, rejected]
`),
).toEqual([23, 7])
})
test("Promise.race observes later fulfillment and rejection", async () => {
expect(
await value(`
const slow = async () => { await Promise.resolve(); await Promise.resolve(); return 1 }
const rejectSoon = async () => { await Promise.resolve(); throw 2 }
try { await Promise.race([slow(), rejectSoon()]); return "fulfilled" } catch (reason) { return reason }
`),
).toBe(2)
})
test("Promise.race selects fulfilled and rejected inputs by settlement order", async () => {
expect(
await value(`
const delayed = async (value) => { await Promise.resolve(); return value }
const first = await Promise.race([Promise.resolve(1), Promise.resolve(2)])
const second = await Promise.race([Promise.resolve(1), delayed(9)])
const third = await Promise.race([delayed(9), Promise.resolve(2)])
let fourth
try { await Promise.race([Promise.reject(1), Promise.resolve(2)]) } catch (reason) { fourth = reason }
return [first, second, third, fourth]
`),
).toEqual([1, 1, 2, 1])
})
test("Promise.race treats a sparse first position as undefined", async () => {
expect(
await value(`
const input = []
input[1] = 1
return (await Promise.race(input)) === undefined
`),
).toBe(true)
})
test("Promise.resolve adopts values and nested promises", async () => {
expect(
await value(`
const object = { id: 1 }
return [
await Promise.resolve(42),
await Promise.resolve(Promise.resolve("nested")),
(await Promise.resolve(object)) === object,
]
`),
).toEqual([42, "nested", true])
})
test("Promise.resolve preserves promise identity", async () => {
expect(
await value(`
const promise = Promise.resolve(1)
const identities = new Map([[promise, "same"]])
return identities.get(Promise.resolve(promise))
`),
).toBe("same")
})
test("Promise.reject preserves primitive and object reasons", async () => {
expect(
await value(`
const object = { reason: true }
const reasons = [undefined, null, false, true, 0, "", 42, object]
const observe = async (reason) => {
try { await Promise.reject(reason); return false } catch (caught) { return caught === reason }
}
return await Promise.all(reasons.map(observe))
`),
).toEqual([true, true, true, true, true, true, true, true])
})
})
describe("Test262 async function adaptations", () => {
test("async functions return promises and adopt returned values", async () => {
expect(
await value(`
const plain = async () => 42
const afterAwait = async () => { await Promise.resolve(); return 43 }
const first = plain()
const second = afterAwait()
return [first instanceof Promise, second instanceof Promise, await first, await second]
`),
).toEqual([true, true, 42, 43])
})
test("async functions reject for throws before and after await", async () => {
expect(
await value(`
const throwsBefore = async () => { throw 1 }
const throwsAfter = async () => { await Promise.resolve(); throw 2 }
const observe = async (promise) => {
try { await promise; return "fulfilled" } catch (reason) { return reason }
}
return await Promise.all([observe(throwsBefore()), observe(throwsAfter())])
`),
).toEqual([1, 2])
})
})
+308 -68
View File
@@ -92,33 +92,14 @@ describe("first-class promise values", () => {
}
const first = load(1)
const second = load(2)
return [first instanceof Promise, second instanceof Promise, await Promise.all([first, second])]
return await Promise.all([first, second])
`),
).toEqual([
true,
true,
[
[1, 1],
[2, 2],
],
[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(
@@ -212,6 +193,22 @@ describe("first-class promise values", () => {
expect(diagnostic.suggestions?.join(" ")).toContain("Await promises")
})
test("an unhandled rejection does not stop later work from draining", async () => {
const trace = makeTrace()
const diagnostic = await error(
`
tools.host.fail({})
tools.host.sleepy({ id: 1, ms: 30 })
return "done"
`,
{ trace },
)
expect(diagnostic.kind).toBe("ToolFailure")
expect(diagnostic.message).toContain("Unhandled rejection from an un-awaited promise")
expect(trace.completed).toBe(1)
expect(trace.interrupted).toBe(0)
})
test("a never-awaited failing async function surfaces as an unhandled promise rejection", async () => {
const diagnostic = await error(`
const fail = async () => { throw new Error("boom") }
@@ -235,6 +232,58 @@ describe("first-class promise values", () => {
expect(diagnostic.kind).toBe("ToolFailure")
expect(diagnostic.message).toContain("Lookup refused")
})
test("keeps unreturned calls alive after their async function settles", async () => {
const trace = makeTrace()
expect(
await value(
`
const start = async () => {
tools.host.sleepy({ id: 1, ms: 30 })
return "started"
}
await start()
return "done"
`,
{ trace },
),
).toBe("done")
expect(trace.completed).toBe(1)
expect(trace.interrupted).toBe(0)
})
test("keeps unreturned calls alive after their promise handler settles", async () => {
const trace = makeTrace()
expect(
await value(
`
await Promise.resolve().then(() => {
tools.host.sleepy({ id: 1, ms: 30 })
})
return "done"
`,
{ trace },
),
).toBe("done")
expect(trace.completed).toBe(1)
expect(trace.interrupted).toBe(0)
})
test("drains pending work after program failure and preserves the original failure", async () => {
const trace = makeTrace()
const diagnostic = await error(
`
tools.host.fail({})
tools.host.sleepy({ id: 1, ms: 30 })
throw new Error("original")
`,
{ trace },
)
expect(diagnostic.kind).toBe("ExecutionFailure")
expect(diagnostic.message).toBe("Uncaught: original")
expect(trace.completed).toBe(1)
expect(trace.interrupted).toBe(0)
})
})
describe("promises at data boundaries", () => {
@@ -335,10 +384,6 @@ describe("Promise.all over arbitrary arrays", () => {
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 () => {
expect(
await value(`
@@ -418,45 +463,36 @@ describe("Promise.allSettled", () => {
})
describe("Promise.race", () => {
test("first settlement wins and losers are interrupted", async () => {
test("first settlement wins and losers continue", 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: 5000 })
const slow = tools.host.sleepy({ id: 2, ms: 30 })
return await Promise.race([fast, slow])
`,
{ trace },
)
expect(result).toBe(1)
expect(trace.interrupted).toBe(1)
expect(trace.completed).toBe(1)
expect(trace.interrupted).toBe(0)
expect(trace.completed).toBe(2)
})
test("awaiting an interrupted loser afterwards is a catchable program failure", async () => {
test("a losing chain continues and remains awaitable", async () => {
expect(
await value(`
const fast = tools.host.sleepy({ id: 1, ms: 10 })
const slow = tools.host.sleepy({ id: 2, ms: 5000 })
const winner = await Promise.race([fast, slow])
try {
await slow
return "no"
} catch (e) {
return { winner, caught: e.message }
}
const slow = tools.host.sleepy({ id: 2, ms: 30 }).then((id) => id * 2)
const winner = await Promise.race([slow, "fast"])
return [winner, await slow]
`),
).toEqual({
winner: 1,
caught: "This tool call was interrupted because another value settled a Promise.race first.",
})
).toEqual(["fast", 4])
})
test("a rejection can win the race", async () => {
expect(
await value(`
try {
await Promise.race([tools.host.fail({}), tools.host.sleepy({ id: 1, ms: 5000 })])
await Promise.race([tools.host.fail({}), tools.host.sleepy({ id: 1, ms: 30 })])
return "no"
} catch (e) {
return e.message
@@ -465,12 +501,37 @@ describe("Promise.race", () => {
).toBe("Lookup refused")
})
test("a rejecting winner still drains its slow loser", async () => {
const trace = makeTrace()
const diagnostic = await error(
`return await Promise.race([tools.host.fail({}), tools.host.sleepy({ id: 1, ms: 30 })])`,
{ trace },
)
expect(diagnostic.kind).toBe("ToolFailure")
expect(diagnostic.message).toBe("Lookup refused")
expect(trace.completed).toBe(1)
expect(trace.interrupted).toBe(0)
})
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: 5000 }), "immediate"])`, { trace }),
await value(`return await Promise.race([tools.host.sleepy({ id: 1, ms: 30 }), "immediate"])`, { trace }),
).toBe("immediate")
expect(trace.interrupted).toBe(1)
expect(trace.interrupted).toBe(0)
expect(trace.completed).toBe(1)
})
test("a losing rejection remains handled", async () => {
expect(
await value(`
const rejectLater = async () => {
await tools.host.sleepy({ id: 1, ms: 20 })
throw new Error("late")
}
return await Promise.race([rejectLater(), "winner"])
`),
).toBe("winner")
})
test("an empty race is a clear error instead of hanging", async () => {
@@ -480,23 +541,35 @@ describe("Promise.race", () => {
})
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")
test("resolve adopts a tool-call promise", async () => {
expect(await value(`return await Promise.resolve(tools.host.sleepy({ id: 3 }))`)).toBe(3)
})
test("reject produces a promise whose await throws the reason", async () => {
test("an abandoned rejected promise surfaces as an unhandled rejection", async () => {
const diagnostic = await error(`
Promise.reject(new Error("boom"))
return "done"
`)
expect(diagnostic.kind).toBe("ExecutionFailure")
expect(diagnostic.message).toContain("Unhandled rejection from an un-awaited promise")
expect(diagnostic.message).toContain("boom")
})
})
describe("Promise combinator values", () => {
test("all, allSettled, and race return chainable promises", async () => {
expect(
await value(`
try {
await Promise.reject("nope")
return "no"
} catch (e) {
return e
}
`),
).toBe("nope")
const all = Promise.all([Promise.resolve(1), 2])
const settled = Promise.allSettled([Promise.resolve(3)])
const race = Promise.race([Promise.resolve(4)])
return [
await all.then((values) => values.join(",")),
await settled.then((values) => values[0].value),
await race.then((value) => value + 1),
]
`),
).toEqual(["1,2", 3, 5])
})
})
@@ -531,18 +604,185 @@ describe("timeout interruption of forked calls", () => {
expect(result.error.kind).toBe("TimeoutExceeded")
expect(trace.interrupted).toBe(2)
})
test("interrupts promise fibers concurrently during scope teardown", async () => {
const cleanup = { active: 0, overlapped: false }
const tool = Tool.make({
description: "Wait until interrupted",
input: Schema.Struct({}),
output: Schema.Never,
run: () =>
Effect.never.pipe(
Effect.onInterrupt(() =>
Effect.gen(function* () {
cleanup.active += 1
yield* Effect.sleep(20)
cleanup.overlapped ||= cleanup.active > 1
cleanup.active -= 1
}),
),
),
})
const result = await Effect.runPromise(
CodeMode.execute({
tools: { host: { first: tool, second: tool } },
code: `
tools.host.first({})
tools.host.second({})
return await Promise.resolve("waiting")
`,
limits: { timeoutMs: 50 },
}),
)
expect(result.ok).toBe(false)
if (result.ok) return
expect(result.error.kind).toBe("TimeoutExceeded")
expect(cleanup.overlapped).toBe(true)
})
})
describe("promise chaining", () => {
test("then transforms values and flattens returned promises", async () => {
expect(
await value(`
return tools.host.sleepy({ id: 2 })
.then((id) => tools.host.sleepy({ id: id + 1 }))
.then((id) => id * 2)
`),
).toBe(6)
})
test("handlers run after synchronous statements", async () => {
expect(
await value(`
const order = []
const chained = Promise.resolve().then(() => order.push("then"))
order.push("sync")
await chained
return order
`),
).toEqual(["sync", "then"])
})
test("nested reactions run before downstream reactions queued later", async () => {
expect(
await value(`
const order = []
await Promise.resolve()
.then(() => {
order.push(1)
Promise.resolve().then(() => order.push(2))
})
.then(() => order.push(3))
return order
`),
).toEqual([1, 2, 3])
})
test("plain and settled await resume after reactions that are already queued", async () => {
expect(
await value(`
const order = []
Promise.resolve().then(() => order.push(1))
await 0
order.push(2)
Promise.resolve().then(() => order.push(3))
await Promise.resolve()
order.push(4)
return order
`),
).toEqual([1, 2, 3, 4])
})
test("reactions registered on the same pending promise preserve order", async () => {
expect(
await value(`
const order = []
const pending = tools.host.sleepy({ id: 1 })
pending.then(() => order.push(1))
pending.then(() => order.push(2))
await pending
return order
`),
).toEqual([1, 2])
})
test("an async reaction does not block the next queued reaction", async () => {
expect(
await value(`
const order = []
const first = Promise.resolve().then(async () => {
order.push(1)
await tools.host.sleepy({ id: 1 })
order.push(3)
})
Promise.resolve().then(() => order.push(2))
await first
return order
`),
).toEqual([1, 2, 3])
})
test("catch receives normalized errors and recovers the chain", async () => {
expect(await value(`return tools.host.fail({}).catch((error) => error.message)`)).toBe("Lookup refused")
expect(
await value(`
return Promise.resolve(1)
.then(() => { throw new Error("boom") })
.catch((error) => error.message)
`),
).toBe("boom")
})
test("then rejection handlers and omitted handlers pass through settlement", async () => {
expect(await value(`return Promise.resolve(4).then(undefined).catch(undefined)`)).toBe(4)
expect(await value(`return Promise.reject("nope").then(undefined, (reason) => reason + "!")`)).toBe("nope!")
})
test("supported builtin callables can be handlers", async () => {
expect(await value(`return Promise.resolve({ a: 1 }).then(JSON.stringify)`)).toBe('{"a":1}')
expect(await value(`return Promise.resolve(4).then(Promise.resolve)`)).toBe(4)
})
test("finally awaits its callback and preserves the original settlement", async () => {
expect(
await value(`
let cleanup = 0
const result = await Promise.resolve(7).finally(async () => {
await tools.host.sleepy({ id: 1 })
cleanup = 1
return 99
})
return [result, cleanup]
`),
).toEqual([7, 1])
expect(
await value(`return Promise.reject(new Error("original")).finally(() => 99).catch((error) => error.message)`),
).toBe("original")
})
test("a rejected finally callback replaces the original settlement", async () => {
expect(
await value(`
return Promise.resolve(1)
.finally(() => Promise.reject(new Error("cleanup")))
.catch((error) => error.message)
`),
).toBe("cleanup")
})
test("a self-resolving chain rejects instead of deadlocking", async () => {
expect(
await value(`
let chained
chained = Promise.resolve().then(() => chained)
return chained.catch((error) => [error.name, error.message])
`),
).toEqual(["TypeError", "Chaining cycle detected for promise."])
})
})
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")
}
})
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")
+40
View File
@@ -0,0 +1,40 @@
# Test262 Promise Coverage
The Promise tests adapt Test262 at revision `250f204f23a9249ff204be2baec29600faae7b75`. The upstream Promise tree
contains 729 files; 459 are under the eight API directories corresponding to CodeMode's five Promise statics and three
chaining methods. The executable suite currently cites 39 distinct Promise, Array, and async-function sources.
This is coverage of CodeMode's confined Promise surface, not a claim of ECMAScript or Test262 conformance. One upstream
file may contain both adapted and inapplicable assertions, so a cited source means only that the represented assertions
were adapted. `LICENSE.test262` contains the upstream BSD terms.
## Covered Surface
- `Promise.all`, `Promise.allSettled`, and `Promise.race` result types, ordering, sparse positions, mixed values, and
rejection propagation.
- `Promise.resolve` value adoption and promise identity.
- `Promise.reject` reason preservation.
- Async-function promise creation, returned-value adoption, and throws before and after `await`.
The Promise PR's handwritten tests remain authoritative for tool-call concurrency, execution-scoped lifetime,
cancellation, output boundaries, model-safe tool failures, unhandled-rejection diagnostics, and chaining behavior.
## Exclusions
- `new Promise`, externally captured resolving functions, and executor semantics.
- Promise subclasses, constructors, species, realms, proxies, property descriptors, and function metadata.
- Custom thenables, poisoned `then` accessors, and arbitrary iterable or iterator-closing behavior.
- `Promise.any`, `Promise.try`, `Promise.withResolvers`, `Promise.allKeyed`, and `Promise.allSettledKeyed`.
- Exact native microtask behavior for `.then`, `.catch`, and `.finally`; those are exercised separately by the Promise
PR's chaining tests and audit.
- Exact native unhandled-rejection timing. CodeMode drains execution-owned work and reports safe diagnostics at its
execution boundary.
## Observed Differences
- Promise combinators accept arrays plus CodeMode's supported spreadable collections. The model guidance currently
describes arrays, so this remains a contract decision rather than conformance coverage.
- `Promise.race([])` returns an actionable error instead of a permanently pending promise, which cannot usefully finish
a bounded CodeMode execution.
- Promise identity is preserved, but CodeMode intentionally rejects general binary operators over Promise references;
identity is tested through `Map` keys instead of `===`.
+1
View File
@@ -544,6 +544,7 @@ it.effect("advertises MCP output schemas to Code Mode", () =>
const execute = (yield* toolDefinitions(registry)).find((tool) => tool.name === "execute")
expect(execute?.description).toContain("tools.demo.search(input: {}): Promise<{\n ok: boolean,\n}>")
expect(execute?.description).not.toContain("promise chaining are unavailable")
}),
)