mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-18 13:49:25 -04:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 314388f46e | |||
| 6273304f2a | |||
| 34b4fa9543 | |||
| 99c4e201d5 | |||
| 61391353c9 | |||
| 0a0d7d69cd | |||
| d7d0113591 | |||
| c9b4eddf25 | |||
| 78520af164 | |||
| 9445497fc5 | |||
| cf5e278447 | |||
| c3b9d03bdb | |||
| 0800770b0d | |||
| 8b53f362e8 | |||
| 1289e00778 |
@@ -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.
|
- 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).
|
- `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.
|
- 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.
|
- `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 `{}`.
|
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.
|
CodeMode is an orchestration language, not a general JavaScript runtime.
|
||||||
|
|
||||||
|
|||||||
@@ -63,8 +63,10 @@ path lookup, namespace browsing, deterministic ranking, and pagination.
|
|||||||
### Tool execution
|
### Tool execution
|
||||||
|
|
||||||
Calling a tool starts its Effect eagerly on a supervised fiber. The returned sandbox promise is run-once and can be
|
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.
|
awaited directly, chained with `then`/`catch`/`finally`, or passed through the supported `Promise` combinators. At most
|
||||||
Unfinished calls are drained before successful program completion, and an unhandled call failure becomes a diagnostic.
|
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
|
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
|
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
|
output schemas are preserved in generated signatures. Direct Core tools remain direct and are not ambient globals
|
||||||
inside CodeMode.
|
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
|
## Intentionally Unsupported
|
||||||
|
|
||||||
These are product boundaries rather than DSL backlog:
|
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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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
|
The supported JavaScript subset should grow when common model-generated code improves tool orchestration. These are
|
||||||
current omissions to implement, not intentional product boundaries.
|
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
|
- [ ] 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.
|
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
|
- [ ] Support callback-bearing standard-library variants that models commonly generate: the mapper argument to
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export type DiagnosticKind =
|
|||||||
export const OptionalShortCircuit: unique symbol = Symbol("codemode.optional-short-circuit")
|
export const OptionalShortCircuit: unique symbol = Symbol("codemode.optional-short-circuit")
|
||||||
|
|
||||||
export const supportedSyntaxMessage =
|
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 {
|
export class InterpreterRuntimeError extends Error {
|
||||||
readonly node?: AstNode
|
readonly node?: AstNode
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { parse } from "acorn"
|
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 { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript"
|
||||||
import {
|
import {
|
||||||
copyIn,
|
copyIn,
|
||||||
@@ -148,6 +148,11 @@ const parseProgram = (code: string): ProgramNode => {
|
|||||||
return parsed as 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 =>
|
const publicErrorMessage = (message: string): string =>
|
||||||
message.replace(/\/(?:Users|home|private|tmp|var\/folders)\/[^\s"'`]+/g, "<redacted-path>")
|
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 => {
|
const caughtErrorValue = (thrown: unknown): unknown => {
|
||||||
if (thrown instanceof ProgramThrow) return thrown.value
|
if (thrown instanceof ProgramThrow) return thrown.value
|
||||||
if (thrown instanceof InterpreterRuntimeError) return createErrorValue(thrown.errorName, thrown.message)
|
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.
|
// ToolRuntime.make like invokeTool: the interpreter never holds the tree itself.
|
||||||
private readonly toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>
|
private readonly toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>
|
||||||
private readonly logs: Array<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).
|
// Caps how many eagerly forked tool calls run at once (the parallel-call concurrency cap).
|
||||||
private readonly callPermits: Semaphore.Semaphore
|
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
|
// 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.
|
// surfaces a never-awaited failure as an unhandled-rejection diagnostic.
|
||||||
private readonly pendingSettlements: Set<SandboxPromise>
|
private readonly pendingSettlements: Set<SandboxPromise>
|
||||||
@@ -628,6 +638,8 @@ class Interpreter<R> {
|
|||||||
constructor(
|
constructor(
|
||||||
invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>,
|
invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>,
|
||||||
toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>,
|
toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>,
|
||||||
|
promiseScope: Scope.Scope,
|
||||||
|
reactionQueue: Queue.Queue<PromiseReaction<R>>,
|
||||||
logs: Array<string> = [],
|
logs: Array<string> = [],
|
||||||
shared?: { callPermits: Semaphore.Semaphore; pendingSettlements: Set<SandboxPromise> },
|
shared?: { callPermits: Semaphore.Semaphore; pendingSettlements: Set<SandboxPromise> },
|
||||||
) {
|
) {
|
||||||
@@ -636,6 +648,9 @@ class Interpreter<R> {
|
|||||||
this.invokeTool = invokeTool
|
this.invokeTool = invokeTool
|
||||||
this.toolKeys = toolKeys
|
this.toolKeys = toolKeys
|
||||||
this.logs = logs
|
this.logs = logs
|
||||||
|
this.lastValue = undefined
|
||||||
|
this.promiseScope = promiseScope
|
||||||
|
this.reactionQueue = reactionQueue
|
||||||
this.callPermits = shared?.callPermits ?? Semaphore.makeUnsafe(TOOL_CALL_CONCURRENCY)
|
this.callPermits = shared?.callPermits ?? Semaphore.makeUnsafe(TOOL_CALL_CONCURRENCY)
|
||||||
this.pendingSettlements = shared?.pendingSettlements ?? new Set<SandboxPromise>()
|
this.pendingSettlements = shared?.pendingSettlements ?? new Set<SandboxPromise>()
|
||||||
globalScope.set("tools", { mutable: false, value: new ToolReference([]) })
|
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
|
// top-level declarations (`let undefined = 5`, `const Object = ...`) shadow builtins like
|
||||||
// JS module scope, instead of colliding with the seeded globals.
|
// JS module scope, instead of colliding with the seeded globals.
|
||||||
this.pushScope()
|
this.pushScope()
|
||||||
return Effect.gen(function* () {
|
const evaluate = Effect.gen(function* () {
|
||||||
self.hoistFunctions(program.body)
|
self.hoistFunctions(program.body)
|
||||||
let value: unknown = undefined
|
let value: unknown = undefined
|
||||||
for (const [index, statement] of program.body.entries()) {
|
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
|
// resolves before crossing the data boundary - `return tools.ns.tool(...)` works
|
||||||
// without an explicit await, exactly as in JS.
|
// without an explicit await, exactly as in JS.
|
||||||
if (value instanceof SandboxPromise) value = yield* self.settlePromise(value)
|
if (value instanceof SandboxPromise) value = yield* self.settlePromise(value)
|
||||||
yield* self.drainPendingSettlements()
|
|
||||||
return value
|
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())))
|
}).pipe(Effect.ensuring(Effect.sync(() => self.popScope())))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Awaits every fiber-backed promise the program abandoned (fire-and-forget tool calls), so
|
// 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
|
// 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
|
// 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> {
|
private drainPendingSettlements(): Effect.Effect<void, unknown, never> {
|
||||||
const self = this
|
const self = this
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
|
let unhandled: InterpreterRuntimeError | undefined
|
||||||
while (self.pendingSettlements.size > 0) {
|
while (self.pendingSettlements.size > 0) {
|
||||||
const promise = self.pendingSettlements.values().next().value
|
const promise = self.pendingSettlements.values().next().value
|
||||||
if (promise === undefined) break
|
if (promise === undefined) break
|
||||||
const exit = yield* self.observePromise(promise)
|
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))
|
const failure = normalizeError(Cause.squash(exit.cause))
|
||||||
throw new InterpreterRuntimeError(
|
unhandled = new InterpreterRuntimeError(
|
||||||
`Unhandled rejection from an un-awaited promise: ${failure.message}`,
|
`Unhandled rejection from an un-awaited promise: ${failure.message}`,
|
||||||
undefined,
|
undefined,
|
||||||
failure.kind,
|
failure.kind,
|
||||||
["Await promises so failures can be caught and handled."],
|
["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> {
|
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)
|
const promise = new SandboxPromise(fiber)
|
||||||
this.pendingSettlements.add(promise)
|
this.pendingSettlements.add(promise)
|
||||||
return 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
|
// `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.
|
// 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
|
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(
|
private createReaction(
|
||||||
promise: SandboxPromise | undefined,
|
source: Effect.Effect<Exit.Exit<unknown, unknown>, never, R>,
|
||||||
exit: Exit.Exit<unknown, unknown>,
|
reaction: (exit: Exit.Exit<unknown, unknown>, chained: SandboxPromise) => Effect.Effect<unknown, unknown, R>,
|
||||||
node?: AstNode,
|
): Effect.Effect<SandboxPromise, never, R> {
|
||||||
): Effect.Effect<unknown, unknown> {
|
const settlement = Deferred.makeUnsafe<unknown, unknown>()
|
||||||
if (Exit.isSuccess(exit)) return Effect.succeed(exit.value)
|
const chained = new SandboxPromise(undefined, Deferred.await(settlement))
|
||||||
// A call Promise.race interrupted after losing settles as a catchable program failure;
|
this.pendingSettlements.add(chained)
|
||||||
// any other interruption is execution teardown (timeout/host) and must keep propagating
|
return Effect.as(
|
||||||
// as interruption rather than becoming program-visible data.
|
Effect.forkIn(
|
||||||
if (promise?.interrupted === true && Cause.hasInterruptsOnly(exit.cause)) {
|
Effect.flatMap(source, (exit) =>
|
||||||
return Effect.fail(
|
Effect.sync(() =>
|
||||||
new InterpreterRuntimeError(
|
Queue.offerUnsafe(this.reactionQueue, {
|
||||||
"This tool call was interrupted because another value settled a Promise.race first.",
|
settlement,
|
||||||
node,
|
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)
|
return Effect.failCause(exit.cause)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1528,12 +1577,9 @@ class Interpreter<R> {
|
|||||||
case "UpdateExpression":
|
case "UpdateExpression":
|
||||||
return this.evaluateUpdateExpression(node)
|
return this.evaluateUpdateExpression(node)
|
||||||
case "AwaitExpression": {
|
case "AwaitExpression": {
|
||||||
// `await` resolves a promise value; awaiting anything else is a passthrough no-op,
|
// Even an already-settled value resumes in a later reaction turn, after work that was
|
||||||
// matching real JS semantics for non-thenables.
|
// already queued, matching JavaScript's await continuation ordering.
|
||||||
const self = this
|
return Effect.flatMap(this.evaluateExpression(getNode(node, "argument")), (value) => this.awaitValue(value))
|
||||||
return Effect.flatMap(this.evaluateExpression(getNode(node, "argument")), (value) =>
|
|
||||||
value instanceof SandboxPromise ? self.settlePromise(value, node) : Effect.succeed(value),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
case "NewExpression":
|
case "NewExpression":
|
||||||
return this.evaluateNewExpression(node)
|
return this.evaluateNewExpression(node)
|
||||||
@@ -2217,14 +2263,24 @@ class Interpreter<R> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (ref.name === "reject") {
|
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) {
|
if (items === undefined) {
|
||||||
throw new InterpreterRuntimeError(
|
return this.createPromise(
|
||||||
`Promise.${ref.name} expects an array of promises or plain values (e.g. Promise.${ref.name}(items.map((item) => tools.ns.tool(item)))).`,
|
Effect.fail(
|
||||||
node,
|
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.map(this.observePromise(item), (exit) => ({ index, item, exit }))
|
||||||
: Effect.succeed({ index, item: undefined, exit: Exit.succeed(item) }),
|
: Effect.succeed({ index, item: undefined, exit: Exit.succeed(item) }),
|
||||||
)
|
)
|
||||||
return Effect.gen(function* () {
|
return this.createPromise(
|
||||||
const remaining = [...observations]
|
Effect.gen(function* () {
|
||||||
const values: Array<unknown> = []
|
const remaining = [...observations]
|
||||||
values.length = items.length
|
const values: Array<unknown> = []
|
||||||
while (remaining.length > 0) {
|
values.length = items.length
|
||||||
const winner = yield* Effect.raceAll(remaining)
|
while (remaining.length > 0) {
|
||||||
const position = remaining.indexOf(observations[winner.index])
|
const winner = yield* Effect.raceAll(remaining)
|
||||||
if (position >= 0) remaining.splice(position, 1)
|
const position = remaining.indexOf(observations[winner.index])
|
||||||
if (Exit.isSuccess(winner.exit)) {
|
if (position >= 0) remaining.splice(position, 1)
|
||||||
values[winner.index] = winner.exit.value
|
if (Exit.isSuccess(winner.exit)) {
|
||||||
continue
|
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(
|
return values
|
||||||
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
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
case "allSettled": {
|
case "allSettled": {
|
||||||
const observations = items.map((item) =>
|
const observations = items.map((item) =>
|
||||||
item instanceof SandboxPromise
|
item instanceof SandboxPromise ? this.observePromise(item) : Effect.succeed(Exit.succeed(item as unknown)),
|
||||||
? 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) }),
|
|
||||||
)
|
)
|
||||||
return Effect.gen(function* () {
|
return this.createPromise(
|
||||||
const outcomes: Array<unknown> = []
|
Effect.gen(function* () {
|
||||||
for (const observation of observations) {
|
const outcomes: Array<unknown> = []
|
||||||
const { exit, promise } = yield* observation
|
for (const observation of observations) {
|
||||||
if (Exit.isSuccess(exit)) {
|
const exit = yield* observation
|
||||||
outcomes.push(
|
if (Exit.isSuccess(exit)) {
|
||||||
Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value }),
|
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,
|
|
||||||
)
|
)
|
||||||
: Cause.squash(exit.cause)
|
continue
|
||||||
outcomes.push(
|
}
|
||||||
Object.assign(Object.create(null) as SafeObject, {
|
if (Cause.hasInterruptsOnly(exit.cause)) {
|
||||||
status: "rejected",
|
// Execution teardown (timeout/host interruption), not a program-level rejection.
|
||||||
reason: caughtErrorValue(thrown),
|
return yield* Effect.failCause(exit.cause)
|
||||||
}),
|
}
|
||||||
)
|
outcomes.push(
|
||||||
}
|
Object.assign(Object.create(null) as SafeObject, {
|
||||||
return outcomes
|
status: "rejected",
|
||||||
})
|
reason: caughtErrorValue(Cause.squash(exit.cause)),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return outcomes
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
case "race": {
|
case "race": {
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
@@ -2313,28 +2360,26 @@ class Interpreter<R> {
|
|||||||
? Effect.map(this.observePromise(item), (exit) => ({ index, exit }))
|
? Effect.map(this.observePromise(item), (exit) => ({ index, exit }))
|
||||||
: Effect.succeed({ index, exit: Exit.succeed(item as unknown) }),
|
: Effect.succeed({ index, exit: Exit.succeed(item as unknown) }),
|
||||||
)
|
)
|
||||||
return Effect.gen(function* () {
|
return this.createPromise(
|
||||||
// First settlement (fulfilled OR rejected) wins; the observations never fail, so
|
Effect.gen(function* () {
|
||||||
// racing them yields exactly that. Losing in-flight calls are then interrupted.
|
// First settlement (fulfilled OR rejected) wins. Losers continue like native
|
||||||
const winner = yield* Effect.raceAll(observations)
|
// promises, while a supervised drain keeps their failures handled and their work
|
||||||
for (const [index, item] of items.entries()) {
|
// inside the execution lifetime.
|
||||||
if (index === winner.index || !(item instanceof SandboxPromise) || item.fiber === undefined) continue
|
const winner = yield* Effect.raceAll(observations)
|
||||||
item.interrupted = true
|
for (const [index, item] of items.entries()) {
|
||||||
yield* Fiber.interrupt(item.fiber)
|
if (index === winner.index || !(item instanceof SandboxPromise)) continue
|
||||||
}
|
item.handled = true
|
||||||
const winningItem = items[winner.index]
|
self.pendingSettlements.add(item)
|
||||||
return yield* self.unwrapPromiseExit(
|
}
|
||||||
winningItem instanceof SandboxPromise ? winningItem : undefined,
|
return yield* self.unwrapPromiseExit(winner.exit)
|
||||||
winner.exit,
|
}),
|
||||||
node,
|
)
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private invokeFunction(fn: CodeModeFunction, args: Array<unknown>): Effect.Effect<unknown, unknown, R> {
|
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,
|
callPermits: this.callPermits,
|
||||||
pendingSettlements: this.pendingSettlements,
|
pendingSettlements: this.pendingSettlements,
|
||||||
})
|
})
|
||||||
@@ -2377,6 +2422,9 @@ class Interpreter<R> {
|
|||||||
args: Array<unknown>,
|
args: Array<unknown>,
|
||||||
node: AstNode,
|
node: AstNode,
|
||||||
): Effect.Effect<unknown, unknown, R> {
|
): 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 (typeof ref.receiver === "string") {
|
||||||
if (
|
if (
|
||||||
(ref.name === "replace" || ref.name === "replaceAll") &&
|
(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)
|
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(
|
private invokeStringReplacer(
|
||||||
value: string,
|
value: string,
|
||||||
name: "replace" | "replaceAll",
|
name: "replace" | "replaceAll",
|
||||||
@@ -3206,17 +3334,9 @@ class Interpreter<R> {
|
|||||||
return new ComputedValue(undefined)
|
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 (objectValue instanceof SandboxPromise) {
|
||||||
if (key === "then" || key === "catch" || key === "finally") {
|
if (key === "then" || key === "catch" || key === "finally") {
|
||||||
throw new InterpreterRuntimeError(
|
return new IntrinsicReference(objectValue, key)
|
||||||
`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],
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
throw new InterpreterRuntimeError(
|
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(...)`.",
|
"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 operation = Effect.scoped(
|
||||||
const program = parseProgram(options.code)
|
Effect.gen(function* () {
|
||||||
const interpreter = new Interpreter<Services<Tools>>(tools.invoke, tools.keys, logs)
|
const scope = yield* Effect.acquireRelease(Scope.make("parallel"), (scope, exit) => Scope.close(scope, exit))
|
||||||
const value = yield* interpreter.run(program)
|
const reactionQueue = yield* Queue.unbounded<PromiseReaction<Services<Tools>>>()
|
||||||
const result = copyOut(copyIn(value, "Execution result"), true) as DataValue
|
yield* Effect.forever(
|
||||||
return {
|
Effect.gen(function* () {
|
||||||
ok: true,
|
const reaction = yield* Queue.take(reactionQueue)
|
||||||
value: result,
|
yield* Effect.yieldNow
|
||||||
...logged(),
|
yield* Effect.forkIn(
|
||||||
toolCalls: tools.calls,
|
Effect.flatMap(Effect.exit(reaction.effect), (exit) => Deferred.done(reaction.settlement, exit)),
|
||||||
} satisfies Result
|
scope,
|
||||||
}).pipe((program) => {
|
{ 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
|
const timeoutMs = limits.timeoutMs
|
||||||
if (timeoutMs === undefined) return program
|
if (timeoutMs === undefined) return program
|
||||||
return program.pipe(
|
return program.pipe(
|
||||||
|
|||||||
@@ -616,7 +616,7 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
|
|||||||
"## Language",
|
"## 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.",
|
"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.",
|
"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 `{}`.",
|
"Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Effect, Fiber } from "effect"
|
import type { Effect, Fiber } from "effect"
|
||||||
|
|
||||||
export class SandboxPromise {
|
export class SandboxPromise {
|
||||||
interrupted = false
|
handled = false
|
||||||
constructor(
|
constructor(
|
||||||
readonly fiber: Fiber.Fiber<unknown, unknown> | undefined,
|
readonly fiber: Fiber.Fiber<unknown, unknown> | undefined,
|
||||||
readonly immediate?: Effect.Effect<unknown, unknown>,
|
readonly immediate?: Effect.Effect<unknown, unknown>,
|
||||||
|
|||||||
@@ -652,9 +652,12 @@ describe("CodeMode public contract", () => {
|
|||||||
expect(instructions).toContain("not a general-purpose runtime")
|
expect(instructions).toContain("not a general-purpose runtime")
|
||||||
expect(instructions).not.toContain("Standard modern JavaScript works")
|
expect(instructions).not.toContain("Standard modern JavaScript works")
|
||||||
expect(instructions).not.toContain("TypeScript type annotations")
|
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(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).toContain("URL, URLSearchParams, and URI encoding helpers")
|
||||||
expect(instructions).not.toContain("host globals")
|
expect(instructions).not.toContain("host globals")
|
||||||
expect(instructions).toContain("Use Code Mode tools for external operations")
|
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])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -92,33 +92,14 @@ describe("first-class promise values", () => {
|
|||||||
}
|
}
|
||||||
const first = load(1)
|
const first = load(1)
|
||||||
const second = load(2)
|
const second = load(2)
|
||||||
return [first instanceof Promise, second instanceof Promise, await Promise.all([first, second])]
|
return await Promise.all([first, second])
|
||||||
`),
|
`),
|
||||||
).toEqual([
|
).toEqual([
|
||||||
true,
|
[1, 1],
|
||||||
true,
|
[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 () => {
|
test("an un-awaited tool call starts eagerly, in call order, before any await", async () => {
|
||||||
const trace = makeTrace()
|
const trace = makeTrace()
|
||||||
const result = await value(
|
const result = await value(
|
||||||
@@ -212,6 +193,22 @@ describe("first-class promise values", () => {
|
|||||||
expect(diagnostic.suggestions?.join(" ")).toContain("Await promises")
|
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 () => {
|
test("a never-awaited failing async function surfaces as an unhandled promise rejection", async () => {
|
||||||
const diagnostic = await error(`
|
const diagnostic = await error(`
|
||||||
const fail = async () => { throw new Error("boom") }
|
const fail = async () => { throw new Error("boom") }
|
||||||
@@ -235,6 +232,58 @@ describe("first-class promise values", () => {
|
|||||||
expect(diagnostic.kind).toBe("ToolFailure")
|
expect(diagnostic.kind).toBe("ToolFailure")
|
||||||
expect(diagnostic.message).toContain("Lookup refused")
|
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", () => {
|
describe("promises at data boundaries", () => {
|
||||||
@@ -335,10 +384,6 @@ describe("Promise.all over arbitrary arrays", () => {
|
|||||||
expect(trace.maxActive).toBeLessThanOrEqual(8)
|
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 () => {
|
test("rejects with the first failure, catchable in-program", async () => {
|
||||||
expect(
|
expect(
|
||||||
await value(`
|
await value(`
|
||||||
@@ -418,45 +463,36 @@ describe("Promise.allSettled", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("Promise.race", () => {
|
describe("Promise.race", () => {
|
||||||
test("first settlement wins and losers are interrupted", async () => {
|
test("first settlement wins and losers continue", async () => {
|
||||||
const trace = makeTrace()
|
const trace = makeTrace()
|
||||||
const result = await value(
|
const result = await value(
|
||||||
`
|
`
|
||||||
const fast = tools.host.sleepy({ id: 1, ms: 10 })
|
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])
|
return await Promise.race([fast, slow])
|
||||||
`,
|
`,
|
||||||
{ trace },
|
{ trace },
|
||||||
)
|
)
|
||||||
expect(result).toBe(1)
|
expect(result).toBe(1)
|
||||||
expect(trace.interrupted).toBe(1)
|
expect(trace.interrupted).toBe(0)
|
||||||
expect(trace.completed).toBe(1)
|
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(
|
expect(
|
||||||
await value(`
|
await value(`
|
||||||
const fast = tools.host.sleepy({ id: 1, ms: 10 })
|
const slow = tools.host.sleepy({ id: 2, ms: 30 }).then((id) => id * 2)
|
||||||
const slow = tools.host.sleepy({ id: 2, ms: 5000 })
|
const winner = await Promise.race([slow, "fast"])
|
||||||
const winner = await Promise.race([fast, slow])
|
return [winner, await slow]
|
||||||
try {
|
|
||||||
await slow
|
|
||||||
return "no"
|
|
||||||
} catch (e) {
|
|
||||||
return { winner, caught: e.message }
|
|
||||||
}
|
|
||||||
`),
|
`),
|
||||||
).toEqual({
|
).toEqual(["fast", 4])
|
||||||
winner: 1,
|
|
||||||
caught: "This tool call was interrupted because another value settled a Promise.race first.",
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("a rejection can win the race", async () => {
|
test("a rejection can win the race", async () => {
|
||||||
expect(
|
expect(
|
||||||
await value(`
|
await value(`
|
||||||
try {
|
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"
|
return "no"
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return e.message
|
return e.message
|
||||||
@@ -465,12 +501,37 @@ describe("Promise.race", () => {
|
|||||||
).toBe("Lookup refused")
|
).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 () => {
|
test("a plain value wins over pending promises", async () => {
|
||||||
const trace = makeTrace()
|
const trace = makeTrace()
|
||||||
expect(
|
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")
|
).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 () => {
|
test("an empty race is a clear error instead of hanging", async () => {
|
||||||
@@ -480,23 +541,35 @@ describe("Promise.race", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("Promise.resolve / Promise.reject", () => {
|
describe("Promise.resolve / Promise.reject", () => {
|
||||||
test("resolve wraps plain values and passes promises through", async () => {
|
test("resolve adopts a tool-call promise", async () => {
|
||||||
expect(await value(`return await Promise.resolve(42)`)).toBe(42)
|
|
||||||
expect(await value(`return await Promise.resolve(Promise.resolve("nested"))`)).toBe("nested")
|
|
||||||
expect(await value(`return await Promise.resolve(tools.host.sleepy({ id: 3 }))`)).toBe(3)
|
expect(await value(`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(
|
expect(
|
||||||
await value(`
|
await value(`
|
||||||
try {
|
const all = Promise.all([Promise.resolve(1), 2])
|
||||||
await Promise.reject("nope")
|
const settled = Promise.allSettled([Promise.resolve(3)])
|
||||||
return "no"
|
const race = Promise.race([Promise.resolve(4)])
|
||||||
} catch (e) {
|
return [
|
||||||
return e
|
await all.then((values) => values.join(",")),
|
||||||
}
|
await settled.then((values) => values[0].value),
|
||||||
`),
|
await race.then((value) => value + 1),
|
||||||
).toBe("nope")
|
]
|
||||||
|
`),
|
||||||
|
).toEqual(["1,2", 3, 5])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -531,18 +604,185 @@ describe("timeout interruption of forked calls", () => {
|
|||||||
expect(result.error.kind).toBe("TimeoutExceeded")
|
expect(result.error.kind).toBe("TimeoutExceeded")
|
||||||
expect(trace.interrupted).toBe(2)
|
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", () => {
|
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 () => {
|
test("other property reads on a promise hint at the missing await", async () => {
|
||||||
const diagnostic = await error(`return tools.host.sleepy({ id: 1 }).value`)
|
const diagnostic = await error(`return tools.host.sleepy({ id: 1 }).value`)
|
||||||
expect(diagnostic.kind).toBe("InvalidDataValue")
|
expect(diagnostic.kind).toBe("InvalidDataValue")
|
||||||
|
|||||||
@@ -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 `===`.
|
||||||
@@ -544,6 +544,7 @@ it.effect("advertises MCP output schemas to Code Mode", () =>
|
|||||||
const execute = (yield* toolDefinitions(registry)).find((tool) => tool.name === "execute")
|
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).toContain("tools.demo.search(input: {}): Promise<{\n ok: boolean,\n}>")
|
||||||
|
expect(execute?.description).not.toContain("promise chaining are unavailable")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user