diff --git a/packages/codemode/codemode.md b/packages/codemode/codemode.md index 0428ff02517..8fd1bc5af94 100644 --- a/packages/codemode/codemode.md +++ b/packages/codemode/codemode.md @@ -64,10 +64,13 @@ path lookup, namespace browsing, deterministic ranking, and pagination. ### Tool execution Every sandbox promise starts eagerly on a run-once fiber owned by the whole CodeMode execution, including tool calls, -async functions, `Promise.all`, `Promise.allSettled`, `Promise.race`, `Promise.resolve`, and `Promise.reject`. Nested -functions therefore cannot end the lifetime of work they started. Independent aggregate batches overlap, and rejection -is observed at the eventual `await`. `Promise.race` uses native non-cancelling settlement semantics: its first result -wins while losers continue running. At normal completion CodeMode interrupts everything still running - race losers, +async functions, chained `.then`/`.catch`/`.finally` reactions, `Promise.all`, `Promise.allSettled`, `Promise.race`, +`Promise.resolve`, and `Promise.reject`. Nested functions therefore cannot end the lifetime of work they started. +Independent aggregate batches overlap, and rejection is observed at the eventual `await` or chained rejection handler. +`Promise.race` uses native non-cancelling settlement semantics: its first result wins while losers continue running. +Reaction ordering matches what V8 makes observable - handlers and await continuations are deferred and run in attach +order, and a combinator settles one reaction turn after its deciding member - without promising exact microtask-count +parity beyond that. At normal completion CodeMode interrupts everything still running - race losers, fail-fast `Promise.all` stragglers, and fire-and-forget calls alike: the program has returned, so no future await can exist, and work whose completion matters must be awaited by the program. Waiting for any class of leftover instead would let it hold the execution open, or deadlock it when queued work needs tool-call permits the leftovers occupy. diff --git a/packages/codemode/interpreter-support.md b/packages/codemode/interpreter-support.md index 6887fdaed94..40d0ab6eeef 100644 --- a/packages/codemode/interpreter-support.md +++ b/packages/codemode/interpreter-support.md @@ -92,7 +92,8 @@ ultimate source of truth. - [x] Optional property access and optional calls. - [x] Function/tool calls and spread arguments. - [x] Sequence expressions (the comma operator). -- [x] `await` for sandbox promises; awaiting a plain value is a no-op. +- [x] `await` for sandbox promises; a plain value passes through unchanged, though every `await` still defers its + continuation one reaction turn. - [x] `new` for Error types, Date, RegExp, Map, Set, URL, and URLSearchParams. - [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`. - [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`. @@ -116,12 +117,21 @@ ultimate source of truth. - [x] `Promise.race` settles from the first result without cancelling losers at settlement time. - [x] Real promise values from `Promise.all`, `Promise.allSettled`, and `Promise.race`; separately constructed combinator batches overlap as in normal JavaScript. +- [x] Promise chaining with `.then`, `.catch`, and `.finally`: handlers run deferred in attach order, returned + promises are adopted, handler throws reject the derived promise, `.finally` preserves the original settlement + unless its cleanup fails, and direct self-resolution rejects with a `TypeError`. +- [x] Every `await` (including of plain values and already-settled promises) defers its continuation one reaction + turn, so concurrent async functions interleave at await points as in JavaScript. +- [x] Combinators settle one reaction turn after their deciding member (V8-observable ordering): reactions already + attached to members run first, and an aggregate cannot beat a plain value settling in the same turn into a + `Promise.race`. Exact microtask-count parity beyond this observable ordering is not a documented guarantee. - [x] All still-pending work (race losers, fail-fast `Promise.all` stragglers, and un-awaited calls alike) is interrupted when the program returns; rejections that settled un-awaited become `Success.warnings` - diagnostics. + diagnostics. A combinator abandoned inside its final settlement turn counts as pending and is interrupted + without a warning. - [x] `try`/`catch` can handle awaited tool and promise failures. - [ ] `Promise.any`. -- [ ] Promise chaining with `.then`, `.catch`, and `.finally`. +- [ ] Thenable assimilation (objects with a `then` method are plain data, not promises). - [ ] Custom promise construction with `new Promise(...)`. - [ ] Async iterables, host streams, and stream consumption. diff --git a/packages/codemode/src/codemode.ts b/packages/codemode/src/codemode.ts index f35294a2274..88b07883bf5 100644 --- a/packages/codemode/src/codemode.ts +++ b/packages/codemode/src/codemode.ts @@ -1,11 +1,6 @@ import { Effect, Schema } from "effect" import { executeWithLimits } from "./interpreter/runtime.js" -import { - type HostTools, - type Services, - type ToolDescription, - ToolRuntime, -} from "./tool-runtime.js" +import { type HostTools, type Services, type ToolDescription, ToolRuntime } from "./tool-runtime.js" import type { Definition } from "./tool.js" /** A tool call admitted during an execution. */ @@ -130,11 +125,7 @@ export type Runtime = { readonly execute: (code: string) => Effect.Effect } -const validateLimit = ( - name: keyof ExecutionLimits, - value: Value, - minimum: number, -): Value => { +const validateLimit = (name: keyof ExecutionLimits, value: number | undefined, minimum: number): number | undefined => { if (value !== undefined && (!Number.isSafeInteger(value) || value < minimum)) { throw new RangeError(`${name} must be a safe integer greater than or equal to ${minimum}.`) } diff --git a/packages/codemode/src/interpreter/model.ts b/packages/codemode/src/interpreter/model.ts index e26538550cc..212e271e8dc 100644 --- a/packages/codemode/src/interpreter/model.ts +++ b/packages/codemode/src/interpreter/model.ts @@ -1,5 +1,5 @@ import type { SafeObject } from "../tool-runtime.js" -import type { SandboxURL } from "../values.js" +import type { SandboxPromise, SandboxURL } from "../values.js" export type SourcePosition = { line: number @@ -67,6 +67,15 @@ export class PromiseMethodReference { constructor(readonly name: PromiseMethodName) {} } +export type PromiseInstanceMethodName = "then" | "catch" | "finally" + +export class PromiseInstanceMethodReference { + constructor( + readonly promise: SandboxPromise, + readonly name: PromiseInstanceMethodName, + ) {} +} + export type GlobalNamespaceName = | "Object" | "Math" @@ -122,11 +131,11 @@ export type DiagnosticKind = export const OptionalShortCircuit: unique symbol = Symbol("codemode.optional-short-circuit") export const supportedSyntaxMessage = - "Supported orchestration syntax: tools.* calls (they return promises - resolve them with await), data literals, destructuring, optional chaining, template literals, conditionals, switch, loops (incl. for...of and for...in over object/array/tools keys), arrow functions, spread, try/catch, array methods (map/filter/find/findIndex/some/every/reduce/flatMap/forEach/sort/slice/concat/indexOf/lastIndexOf/at/flat/reverse/includes/join), string methods (incl. match/matchAll/replace/split with regular expressions), Date/RegExp/Map/Set/URL/URLSearchParams, URI encoding helpers, Object/Math/JSON helpers, captured console.log/warn/error/dir/table, and Promise.all/allSettled/race/resolve/reject over arrays mixing promises and plain values for parallel tool calls (promise chaining with .then/.catch is not supported - use await with try/catch)." + "Supported orchestration syntax: tools.* calls (they return promises - resolve them with await), 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, Promise.all/allSettled/race/resolve/reject over arrays mixing promises and plain values for parallel tool calls, and promise chaining with .then/.catch/.finally." export class InterpreterRuntimeError extends Error { readonly node?: AstNode - errorName: string = "Error" + errorName = "Error" constructor( message: string, diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index ba095e74d52..8e56849c5c8 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -31,7 +31,6 @@ import { ErrorConstructorReference, GlobalMethodReference, GlobalNamespace, - type GlobalNamespaceName, formatLocation, getArray, getBoolean, @@ -43,6 +42,7 @@ import { isRecord, type MemberReference, OptionalShortCircuit, + PromiseInstanceMethodReference, PromiseMethodReference, type PromiseMethodName, PromiseNamespace, @@ -56,7 +56,7 @@ import { } from "./model.js" import { arrayMethods, mapMethods, setMethods, spreadItems } from "../stdlib/collections.js" import { consoleMethods, MAX_CONSOLE_DEPTH } from "../stdlib/console.js" -import { dateMethods, dateStatics, invokeDateMethod, invokeDateStatic } from "../stdlib/date.js" +import { dateMethods, invokeDateMethod, invokeDateStatic } from "../stdlib/date.js" import { invokeJsonMethod } from "../stdlib/json.js" import { invokeMathMethod, mathConstants } from "../stdlib/math.js" import { @@ -82,7 +82,6 @@ import { urlMethods, urlProperties, urlSearchParamsMethods, - urlStatics, urlWritableProperties, invokeUriFunction, invokeURLMethod, @@ -219,6 +218,30 @@ const normalizeError = (error: unknown): Diagnostic => { } } +// V8 parity: a combinator settles one reaction turn after the deciding member, never +// before reactions already attached to it. +const settleAfterTurn = (body: Effect.Effect): Effect.Effect => + Effect.flatMap(Effect.exit(body), (exit) => Effect.andThen(Effect.yieldNow, exit)) + +const selfResolutionError = (node?: AstNode): InterpreterRuntimeError => + new InterpreterRuntimeError("Chaining cycle detected: a promise cannot resolve with itself.", node).as("TypeError") + +type ReactionHandler = CodeModeFunction | CoercionFunction | UriFunction + +// Non-callables are ignored as in JS: `.then(undefined, f)` relies on the passthrough. +const reactionHandler = (value: unknown, method: string, node: AstNode): ReactionHandler | undefined => { + if (value instanceof CodeModeFunction || value instanceof CoercionFunction || value instanceof UriFunction) { + return value + } + if (typeofValue(value) === "function") { + throw new InterpreterRuntimeError( + `${method} handlers must be plain functions; wrap other callables in an arrow function, e.g. (value) => tools.ns.tool(value).`, + node, + ) + } + return undefined +} + // Shared by catch bindings and Promise.allSettled rejection reasons. const caughtErrorValue = (thrown: unknown): unknown => { if (thrown instanceof ProgramThrow) return thrown.value @@ -235,6 +258,7 @@ const isRuntimeReference = (value: unknown): boolean => value instanceof GlobalMethodReference || value instanceof PromiseNamespace || value instanceof PromiseMethodReference || + value instanceof PromiseInstanceMethodReference || value instanceof SandboxPromise || value instanceof CoercionFunction || value instanceof UriFunction || @@ -279,6 +303,7 @@ const typeofValue = (value: unknown): string => { value instanceof IntrinsicReference || value instanceof GlobalMethodReference || value instanceof PromiseMethodReference || + value instanceof PromiseInstanceMethodReference || value instanceof PromiseNamespace || value instanceof ErrorConstructorReference ) @@ -391,7 +416,7 @@ const invokeStringMethod = (value: string, name: string, args: Array, n break } if (args[0] instanceof SandboxRegExp) { - result = value.split((args[0] as SandboxRegExp).regex, optNum(1)) + result = value.split(args[0].regex, optNum(1)) break } const requestedLimit = optNum(1) @@ -419,7 +444,7 @@ const invokeStringMethod = (value: string, name: string, args: Array, n case "replace": case "replaceAll": { if (args[0] instanceof SandboxRegExp) { - const pattern = (args[0] as SandboxRegExp).regex + const pattern = args[0].regex const replacement = str(1) if (name === "replaceAll" && !pattern.global) { throw new InterpreterRuntimeError( @@ -524,9 +549,8 @@ const invokeArrayStatic = (name: string, args: Array, node: AstNode): u ) } // Map/Set materialize directly (the data checkpoint would serialize them to {}). - if (args[0] instanceof SandboxMap) - return Array.from((args[0] as SandboxMap).map.entries(), ([key, item]) => [key, item]) - if (args[0] instanceof SandboxSet) return Array.from((args[0] as SandboxSet).set.values()) + if (args[0] instanceof SandboxMap) return Array.from(args[0].map.entries(), ([key, item]) => [key, item]) + if (args[0] instanceof SandboxSet) return Array.from(args[0].set.values()) if (args[0] instanceof SandboxURLSearchParams) { return Array.from(args[0].params.entries(), ([key, value]) => [key, value]) } @@ -568,11 +592,7 @@ const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array, no if (ref.namespace === "Number") return invokeNumberStatic(ref.name, args, node) if (ref.namespace === "String") return invokeStringStatic(ref.name, args, node) if (ref.namespace === "URL") return invokeURLStatic(ref.name, args, node) - if (ref.namespace === "Date") { - if (!dateStatics.has(ref.name)) - throw new InterpreterRuntimeError(`Date.${ref.name} is not available in CodeMode.`, node) - return invokeDateStatic(ref.name, args, node) - } + if (ref.namespace === "Date") return invokeDateStatic(ref.name, args, node) if ( ref.namespace === "RegExp" || ref.namespace === "Map" || @@ -768,7 +788,6 @@ class Interpreter { if (result.kind === "break" || result.kind === "continue") { throw new InterpreterRuntimeError(`Unexpected '${result.kind}' outside of a loop.`, statement) } - } // The program body runs inside an implicit async function, so a returned promise @@ -794,16 +813,14 @@ class Interpreter { return this.promises.create(effect) } - // `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. Settlement is idempotent - // (fiber exits replay), so awaiting the same promise repeatedly never re-runs the call. + // Settlement is idempotent (fiber exits replay), so awaiting the same promise repeatedly + // never re-runs the call. The post-settlement yield defers the continuation one reaction + // turn, as in JS: awaiters never resume inline, so they interleave in attach order. private settlePromise(promise: SandboxPromise): Effect.Effect { const promises = this.promises return Effect.suspend(() => { promises.markObserved(promise) - return Effect.flatMap(promises.await(promise), (exit) => - Exit.isSuccess(exit) ? Effect.succeed(exit.value) : Effect.failCause(exit.cause), - ) + return Effect.flatMap(promises.await(promise), (exit) => Effect.andThen(Effect.yieldNow, exit)) }) } @@ -980,7 +997,6 @@ class Interpreter { if (result.kind === "return") { return result } - } return { kind: "none" } satisfies StatementResult @@ -1007,7 +1023,6 @@ class Interpreter { if (result.kind === "return") { return result } - } while (yield* self.evaluateExpression(testNode)) return { kind: "none" } satisfies StatementResult @@ -1037,16 +1052,13 @@ class Interpreter { : [] while (testNode ? yield* self.evaluateExpression(testNode) : true) { - let iterationScope: Map | undefined - if (perIterationBindings.length > 0) { - iterationScope = new Map( - perIterationBindings.map((name) => { - const binding = self.currentScope().get(name)! - return [name, { ...binding }] - }), - ) - self.scopes.push(iterationScope) - } + const iterationScope = + perIterationBindings.length > 0 + ? new Map( + perIterationBindings.map((name): [string, Binding] => [name, { ...self.currentScope().get(name)! }]), + ) + : undefined + if (iterationScope) self.scopes.push(iterationScope) const result = yield* self.evaluateStatement(bodyNode).pipe( Effect.ensuring( Effect.sync(() => { @@ -1096,7 +1108,7 @@ class Interpreter { // Arrays iterate in place; strings iterate code points; Maps iterate [key, value] // pairs and Sets iterate values over a snapshot (mutation during iteration is safe). - const iterable = Array.isArray(right) ? right : spreadItems(right) + const iterable = spreadItems(right) if (iterable === undefined) { throw new InterpreterRuntimeError("for...of requires an array, string, Map, or Set value in CodeMode.", node) } @@ -1550,11 +1562,10 @@ class Interpreter { case "UpdateExpression": return this.evaluateUpdateExpression(node) case "AwaitExpression": { - // `await` resolves a promise value; awaiting anything else is a passthrough no-op, - // matching real JS semantics for non-thenables. + // In JS every await suspends, even on a non-promise. const self = this return Effect.flatMap(this.evaluateExpression(getNode(node, "argument")), (value) => - value instanceof SandboxPromise ? self.settlePromise(value) : Effect.succeed(value), + value instanceof SandboxPromise ? self.settlePromise(value) : Effect.as(Effect.yieldNow, value), ) } case "NewExpression": @@ -1923,7 +1934,7 @@ class Interpreter { return self.setIdentifierValue(name, next, left) } const rightValue = yield* self.evaluateExpression(getNode(node, "right")) - if (operator === "=") return self.setIdentifierValue(name, rightValue, left) + return self.setIdentifierValue(name, rightValue, left) } if (left.type === "MemberExpression") { return yield* self.modifyMember(left, (current) => @@ -2025,6 +2036,9 @@ class Interpreter { if (callable instanceof PromiseMethodReference) { return yield* self.invokePromiseMethod(callable, args, node) } + if (callable instanceof PromiseInstanceMethodReference) { + return yield* self.invokePromiseInstanceMethod(callable, args, node) + } if (callable instanceof CodeModeFunction) { return yield* self.invokeFunction(callable, args) } @@ -2241,14 +2255,14 @@ class Interpreter { return this.createPromise(Effect.fail(new ProgramThrow(args[0]))) } - const items = Array.isArray(args[0]) ? args[0] : spreadItems(args[0]) + const items = spreadItems(args[0]) if (items === undefined) { return this.createPromise( Effect.fail( new InterpreterRuntimeError( `Promise.${ref.name} expects an array of promises or plain values (e.g. Promise.${ref.name}(items.map((item) => tools.ns.tool(item)))).`, node, - ), + ).as("TypeError"), ), ) } @@ -2261,47 +2275,42 @@ class Interpreter { switch (ref.name) { case "all": { - // Each observation re-raises its member's failure, so Effect.all rejects on the first - // failure without waiting for the rest and preserves input order when all fulfill. - // Its failure-time interruption only unsubscribes the sibling waiters: the underlying - // fibers stay execution-owned and keep running, as in JS. + // Rejects on the first failure; sibling fibers stay execution-owned and keep running, as in JS. const observations = items.map((item) => - item instanceof SandboxPromise - ? Effect.flatMap(this.promises.await(item), (exit) => - Exit.isSuccess(exit) ? Effect.succeed(exit.value) : Effect.failCause(exit.cause), - ) - : Effect.succeed(item), + item instanceof SandboxPromise ? Effect.flatten(this.promises.await(item)) : Effect.succeed(item), ) - return this.createPromise(Effect.all(observations, { concurrency: "unbounded" })) + return this.createPromise(settleAfterTurn(Effect.all(observations, { concurrency: "unbounded" }))) } case "allSettled": { const observations = items.map((item) => - item instanceof SandboxPromise ? this.promises.await(item) : Effect.succeed(Exit.succeed(item as unknown)), + item instanceof SandboxPromise ? this.promises.await(item) : Effect.succeed(Exit.succeed(item)), ) return this.createPromise( - Effect.gen(function* () { - const outcomes: Array = [] - for (const observation of observations) { - const exit = yield* observation - if (Exit.isSuccess(exit)) { + settleAfterTurn( + Effect.gen(function* () { + const outcomes: Array = [] + for (const observation of observations) { + const exit = yield* observation + if (Exit.isSuccess(exit)) { + outcomes.push( + Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value }), + ) + continue + } + if (Cause.hasInterruptsOnly(exit.cause)) { + // Execution teardown (timeout/host interruption), not a program-level rejection. + return yield* Effect.failCause(exit.cause) + } outcomes.push( - Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value }), + Object.assign(Object.create(null) as SafeObject, { + status: "rejected", + reason: caughtErrorValue(Cause.squash(exit.cause)), + }), ) - continue } - if (Cause.hasInterruptsOnly(exit.cause)) { - // Execution teardown (timeout/host interruption), not a program-level rejection. - return yield* Effect.failCause(exit.cause) - } - outcomes.push( - Object.assign(Object.create(null) as SafeObject, { - status: "rejected", - reason: caughtErrorValue(Cause.squash(exit.cause)), - }), - ) - } - return outcomes - }), + return outcomes + }), + ), ) } case "race": { @@ -2316,19 +2325,86 @@ class Interpreter { ) } const observations = items.map((item) => - item instanceof SandboxPromise ? this.promises.await(item) : Effect.succeed(Exit.succeed(item as unknown)), + item instanceof SandboxPromise ? this.promises.await(item) : Effect.succeed(Exit.succeed(item)), ) // First settlement (fulfilled OR rejected) wins; losing work stays execution-owned // and is interrupted at normal completion (already observed) or by teardown. - return this.createPromise( - Effect.flatMap(Effect.raceAll(observations), (exit) => - Exit.isSuccess(exit) ? Effect.succeed(exit.value) : Effect.failCause(exit.cause), - ), - ) + return this.createPromise(settleAfterTurn(Effect.flatten(Effect.raceAll(observations)))) } } } + // Teardown interruption propagates without running handlers; a real settlement defers + // one reaction turn so handlers never run inline. + private reactionExit(source: SandboxPromise): Effect.Effect, unknown, R> { + const promises = this.promises + return Effect.gen(function* () { + const exit = yield* promises.await(source) + if (!Exit.isSuccess(exit) && Cause.hasInterruptsOnly(exit.cause)) return yield* Effect.failCause(exit.cause) + yield* Effect.yieldNow + return exit + }) + } + + private invokePromiseInstanceMethod( + ref: PromiseInstanceMethodReference, + args: Array, + node: AstNode, + ): Effect.Effect { + const method = `Promise.prototype.${ref.name}` + this.promises.markObserved(ref.promise) + if (ref.name === "finally") { + return this.chainFinally(ref.promise, reactionHandler(args[0], method, node), method, node) + } + const onFulfilled = ref.name === "then" ? reactionHandler(args[0], method, node) : undefined + const onRejected = reactionHandler(ref.name === "then" ? args[1] : args[0], method, node) + return this.chainReaction(ref.promise, onFulfilled, onRejected, method, node) + } + + private chainReaction( + source: SandboxPromise, + onFulfilled: ReactionHandler | undefined, + onRejected: ReactionHandler | undefined, + method: string, + node: AstNode, + ): Effect.Effect { + const self = this + const box: { derived?: SandboxPromise } = {} + const body = Effect.gen(function* () { + const exit = yield* self.reactionExit(source) + const handler = Exit.isSuccess(exit) ? onFulfilled : onRejected + if (handler === undefined) return yield* exit + const input = Exit.isSuccess(exit) ? exit.value : caughtErrorValue(Cause.squash(exit.cause)) + const result = yield* self.applyCollectionCallback(handler, method, node)([input]) + if (result === box.derived) return yield* Effect.fail(selfResolutionError(node)) + if (result instanceof SandboxPromise) return yield* self.settlePromise(result) + return result + }) + return Effect.map(this.createPromise(body), (derived) => { + box.derived = derived + return derived + }) + } + + private chainFinally( + source: SandboxPromise, + cleanup: ReactionHandler | undefined, + method: string, + node: AstNode, + ): Effect.Effect { + const self = this + return this.createPromise( + Effect.gen(function* () { + const exit = yield* self.reactionExit(source) + if (cleanup !== undefined) { + const result = yield* self.applyCollectionCallback(cleanup, method, node)([]) + if (result instanceof SandboxPromise) yield* self.settlePromise(result) + } + return yield* exit + }), + ) + } + private invokeFunction(fn: CodeModeFunction, args: Array): Effect.Effect { const invocation = new Interpreter(this.invokeTool, this.toolKeys, this.promises, this.logs, this.callPermits) invocation.scopes = [...fn.capturedScopes, new Map()] @@ -2358,10 +2434,20 @@ class Interpreter { return yield* invocation.evaluateExpression(fn.body) }) if (!fn.async) return run - return this.createPromise( - Effect.flatMap(run, (value) => - value instanceof SandboxPromise ? invocation.settlePromise(value) : Effect.succeed(value), + // Every await yields, so `box.own` is assigned before the body can return a reference to it. + const box: { own?: SandboxPromise } = {} + return Effect.map( + this.createPromise( + Effect.flatMap(run, (value) => { + if (!(value instanceof SandboxPromise)) return Effect.succeed(value) + if (value === box.own) return Effect.fail(selfResolutionError()) + return invocation.settlePromise(value) + }), ), + (promise) => { + box.own = promise + return promise + }, ) } @@ -2472,8 +2558,8 @@ class Interpreter { }) } - // Runs a collection callback accepting a user function or supported builtin callable, - // mirroring the array-method callback contract. + // Accepts a user function or supported builtin callable, so idioms such as + // `filter(Boolean)`, `map(String)`, and `map(encodeURIComponent)` work as in JS. private applyCollectionCallback( callback: unknown, name: string, @@ -2761,24 +2847,7 @@ class Interpreter { return Effect.succeed(Array.from(target.entries(), ([index, item]): Array => [index, item])) } - const callback = args[0] - if ( - !(callback instanceof CodeModeFunction) && - !(callback instanceof CoercionFunction) && - !(callback instanceof UriFunction) - ) { - throw new InterpreterRuntimeError(`Array.${name} expects a function callback.`, node) - } - const self = this - // Accept a user function or supported builtin callable, so idioms such as - // `filter(Boolean)`, `map(String)`, and `map(encodeURIComponent)` work as in JS. Builtins - // are synchronous; only CodeModeFunctions can await tool calls. - const apply = (callbackArgs: Array): Effect.Effect => - callback instanceof CoercionFunction - ? Effect.succeed(invokeCoercion(callback, callbackArgs, node)) - : callback instanceof UriFunction - ? Effect.succeed(invokeUriFunction(callback, callbackArgs, node)) - : self.invokeFunction(callback, callbackArgs) + const apply = this.applyCollectionCallback(args[0], `Array.${name}`, node) return Effect.gen(function* () { // Capture the initial length, but read the receiver live so callbacks observe mutations // without visiting elements appended after iteration begins. @@ -3079,6 +3148,7 @@ class Interpreter { | MemberReference | ToolReference | PromiseMethodReference + | PromiseInstanceMethodReference | IntrinsicReference | GlobalMethodReference | ComputedValue @@ -3199,20 +3269,13 @@ class Interpreter { 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. + // Error instead of `undefined` so a missing await never hides. if (objectValue instanceof SandboxPromise) { if (key === "then" || key === "catch" || key === "finally") { - throw new InterpreterRuntimeError( - `Promise.prototype.${String(key)} is not supported in CodeMode; use await instead (with try/catch to handle failures) - e.g. \`const result = await tools.ns.tool(...)\`.`, - propertyNode, - "UnsupportedSyntax", - [supportedSyntaxMessage], - ) + return new PromiseInstanceMethodReference(objectValue, key) } throw new InterpreterRuntimeError( - "This value is an un-awaited Promise and has no readable properties; await it first - e.g. `const result = await tools.ns.tool(...)`.", + "This value is an un-awaited Promise; await it first - e.g. `const result = await tools.ns.tool(...)`.", objectNode, "InvalidDataValue", ) @@ -3265,6 +3328,7 @@ class Interpreter { reference === undefined || reference instanceof ToolReference || reference instanceof PromiseMethodReference || + reference instanceof PromiseInstanceMethodReference || reference instanceof IntrinsicReference || reference instanceof GlobalMethodReference ) @@ -3302,6 +3366,7 @@ class Interpreter { reference === undefined || reference instanceof ToolReference || reference instanceof PromiseMethodReference || + reference instanceof PromiseInstanceMethodReference || reference instanceof IntrinsicReference || reference instanceof GlobalMethodReference ) { @@ -3489,15 +3554,14 @@ export const executeWithLimits = >( // and the timeout path's completed value - binds at run time: a reused Effect must start // from a clean slate instead of observing a previous run's state. return Effect.suspend(() => { - const hooks = { - ...(options.onToolCallStart === undefined ? {} : { onToolCallStart: options.onToolCallStart }), - ...(options.onToolCallEnd === undefined ? {} : { onToolCallEnd: options.onToolCallEnd }), - } const tools = ToolRuntime.make( (options.tools ?? {}) as HostTools>, limits.maxToolCalls, searchIndex, - hooks, + { + onToolCallStart: options.onToolCallStart, + onToolCallEnd: options.onToolCallEnd, + }, ) const logs: Array = [] const logged = () => (logs.length > 0 ? { logs: [...logs] } : {}) diff --git a/packages/codemode/src/openapi/runtime.ts b/packages/codemode/src/openapi/runtime.ts index 47312ae1623..2515621792b 100644 --- a/packages/codemode/src/openapi/runtime.ts +++ b/packages/codemode/src/openapi/runtime.ts @@ -46,9 +46,7 @@ export const invoke = (plan: Plan, input: unknown): Effect.Effect>): string return toolError(`Missing required path parameter '${field.inputName}'.`) } const fieldValue = serializeSimple(field, item, (value) => - encodeURIComponent(value).replace(/[!'()*]/g, (character) => - `%${character.charCodeAt(0).toString(16).toUpperCase()}`, + encodeURIComponent(value).replace( + /[!'()*]/g, + (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`, ), ) if (fieldValue instanceof ToolError) return fieldValue @@ -271,10 +270,7 @@ const serializeQuery = ( if (value.some((item) => item === undefined || (item !== null && typeof item === "object"))) { return toolError(`Query parameter '${field.inputName}' contains an unsupported nested value.`) } - return value.reduce( - (current, item) => HttpClientRequest.appendUrlParam(current, field.name, String(item)), - request, - ) + return value.reduce((current, item) => HttpClientRequest.appendUrlParam(current, field.name, String(item)), request) } if (isRecord(value) && field.explode) { return Object.entries(value).reduce((current, [name, item]) => { @@ -289,11 +285,15 @@ const serializeQuery = ( return rendered instanceof ToolError ? rendered : HttpClientRequest.appendUrlParam(request, field.name, rendered) } -const readResponseBody = (response: HttpClientResponse.HttpClientResponse, plan: Plan): Effect.Effect => +const readResponseBody = ( + response: HttpClientResponse.HttpClientResponse, + plan: Plan, +): Effect.Effect => Effect.gen(function* () { const contentLength = response.headers["content-length"] const parsedSize = contentLength === undefined ? undefined : Number.parseInt(contentLength, 10) - const declaredSize = parsedSize !== undefined && Number.isSafeInteger(parsedSize) && parsedSize >= 0 ? parsedSize : undefined + const declaredSize = + parsedSize !== undefined && Number.isSafeInteger(parsedSize) && parsedSize >= 0 ? parsedSize : undefined if (declaredSize !== undefined && declaredSize > maxResponseBodyBytes) { return yield* Effect.fail(toolError(`${plan.operation.method} ${plan.operation.path} response exceeds 50 MiB.`)) } @@ -304,7 +304,9 @@ const readResponseBody = (response: HttpClientResponse.HttpClientResponse, plan: return Effect.fail(toolError(`${plan.operation.method} ${plan.operation.path} response exceeds 50 MiB.`)) } if (size + chunk.byteLength > body.byteLength) { - const grown = Buffer.allocUnsafe(Math.min(maxResponseBodyBytes, Math.max(size + chunk.byteLength, body.byteLength * 2))) + const grown = Buffer.allocUnsafe( + Math.min(maxResponseBodyBytes, Math.max(size + chunk.byteLength, body.byteLength * 2)), + ) body.copy(grown, 0, 0, size) body = grown } diff --git a/packages/codemode/src/openapi/spec.ts b/packages/codemode/src/openapi/spec.ts index 22cf1535a83..bd4dc5aed63 100644 --- a/packages/codemode/src/openapi/spec.ts +++ b/packages/codemode/src/openapi/spec.ts @@ -78,7 +78,9 @@ const isBinaryMediaType = (document: Document, mediaType: string, value: unknown return isRecord(schema) && schema.format === "binary" } -const jsonContent = (content: Record): { readonly mediaType: string; readonly schema: unknown } | undefined => { +const jsonContent = ( + content: Record, +): { readonly mediaType: string; readonly schema: unknown } | undefined => { const entry = Object.entries(content).find(([mediaType]) => isJsonMediaType(mediaType)) return entry !== undefined && isRecord(entry[1]) ? { mediaType: entry[0], schema: entry[1].schema } : undefined } @@ -344,7 +346,7 @@ export const operationOutput = ( if (outcomes.length === 0) return { ok: true, value: undefined } return { ok: true, - value: withDefinitions(outcomes.length === 1 ? outcomes[0] ?? {} : { anyOf: outcomes }, definitions), + value: withDefinitions(outcomes.length === 1 ? (outcomes[0] ?? {}) : { anyOf: outcomes }, definitions), } } @@ -380,7 +382,9 @@ export const operationPath = ( namespaces: ReadonlySet, ): ReadonlyArray => { const raw = nonEmptyString(operation.operationId) - const segments = (raw === undefined ? [fallbackOperationId(method, path)] : raw.split(".")).map(sanitizeOperationSegment) + const segments = (raw === undefined ? [fallbackOperationId(method, path)] : raw.split(".")).map( + sanitizeOperationSegment, + ) if (isOperationPathAvailable(segments, used, namespaces)) return segments const conflict = segments.slice(0, -1).findIndex((_, index) => used.has(segments.slice(0, index + 1).join("."))) if (conflict >= 0 && conflict + 1 < segments.length) { diff --git a/packages/codemode/src/stdlib/date.ts b/packages/codemode/src/stdlib/date.ts index c492f58f94b..11566e2d7c5 100644 --- a/packages/codemode/src/stdlib/date.ts +++ b/packages/codemode/src/stdlib/date.ts @@ -23,8 +23,6 @@ export const dateMethods = new Set([ "getTimezoneOffset", ]) -export const dateStatics = new Set(["now", "parse", "UTC"]) - export const invokeDateStatic = (name: string, args: Array, node: AstNode): number => { switch (name) { case "now": diff --git a/packages/codemode/src/stdlib/json.ts b/packages/codemode/src/stdlib/json.ts index 8a479d2c8c7..a7cc13629e7 100644 --- a/packages/codemode/src/stdlib/json.ts +++ b/packages/codemode/src/stdlib/json.ts @@ -6,10 +6,7 @@ import { } from "../interpreter/model.js" import { copyIn, copyOut } from "../tool-runtime.js" -export const jsonStatics = new Set(["stringify", "parse"]) - export const invokeJsonMethod = (name: string, args: Array, node: AstNode): unknown => { - if (!jsonStatics.has(name)) throw new InterpreterRuntimeError(`JSON.${name} is not available in CodeMode.`, node) switch (name) { case "stringify": { const replacer = args[1] diff --git a/packages/codemode/src/stdlib/object.ts b/packages/codemode/src/stdlib/object.ts index 49a61110dca..f25077857f1 100644 --- a/packages/codemode/src/stdlib/object.ts +++ b/packages/codemode/src/stdlib/object.ts @@ -3,11 +3,9 @@ import { isBlockedMember } from "../tool-runtime.js" import { isSandboxValue, SandboxMap, SandboxPromise, SandboxSet, SandboxURLSearchParams } from "../values.js" import { boundedData, coerceToString } from "./value.js" -export const objectStatics = new Set(["keys", "values", "entries", "hasOwn", "assign", "fromEntries"]) export const objectMethodsPreservingIdentity = new Set(["assign", "values", "entries", "fromEntries"]) export const invokeObjectMethod = (name: string, args: Array, node: AstNode): unknown => { - if (!objectStatics.has(name)) throw new InterpreterRuntimeError(`Object.${name} is not available in CodeMode.`, node) const requireObject = (): Record => { const input = args[0] if (Array.isArray(input)) return input as unknown as Record @@ -53,13 +51,11 @@ export const invokeObjectMethod = (name: string, args: Array, node: Ast } const out = target as Record for (const source of args.slice(1)) { - if (source === null || source === undefined) continue - const value = source - if (isSandboxValue(value)) continue - if (value === null || typeof value !== "object" || Array.isArray(value)) { + if (source === null || source === undefined || isSandboxValue(source)) continue + if (typeof source !== "object" || Array.isArray(source)) { throw new InterpreterRuntimeError("Object.assign expects data objects.", node) } - for (const [key, item] of Object.entries(value)) guardedSet(out, key, item) + for (const [key, item] of Object.entries(source)) guardedSet(out, key, item) } return out } diff --git a/packages/codemode/src/stdlib/value.ts b/packages/codemode/src/stdlib/value.ts index 17ca8b1c486..d9b85ea52de 100644 --- a/packages/codemode/src/stdlib/value.ts +++ b/packages/codemode/src/stdlib/value.ts @@ -60,7 +60,7 @@ export const invokeCoercion = (ref: CoercionFunction, args: Array, node if (ref.name === "parseInt") return parseInt(coerceToString(raw)) return parseFloat(coerceToString(raw)) } - const value = boundedData(args[0], `${ref.name} input`) + const value = boundedData(raw, `${ref.name} input`) if (ref.name === "Number") return coerceToNumber(value) if (ref.name === "Boolean") return Boolean(value) if (ref.name === "parseInt") { @@ -73,11 +73,7 @@ export const invokeCoercion = (ref: CoercionFunction, args: Array, node if (ref.name === "parseFloat") return parseFloat(coerceToString(value)) return coerceToString(value) } -import { - type AstNode, - CoercionFunction, - InterpreterRuntimeError, -} from "../interpreter/model.js" +import { type AstNode, CoercionFunction, InterpreterRuntimeError } from "../interpreter/model.js" import { copyIn, type SafeObject } from "../tool-runtime.js" import { isSandboxValue, diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts index 3cdf49cfe53..7ff0c76b792 100644 --- a/packages/codemode/src/tool-runtime.ts +++ b/packages/codemode/src/tool-runtime.ts @@ -326,15 +326,12 @@ export const copyOut = (value: unknown, undefinedAsNull = false): unknown => { const definitions = ( tools: HostTools, path: ReadonlyArray = [], -): Array<{ path: string; definition: Definition }> => { - const entries: Array<{ path: string; definition: Definition }> = [] - for (const [name, value] of Object.entries(tools)) { +): Array<{ path: string; definition: Definition }> => + Object.entries(tools).flatMap(([name, value]) => { const next = [...path, name] - if (isDefinition(value)) entries.push({ path: next.join("."), definition: value }) - else if (typeof value !== "function") entries.push(...definitions(value, next)) - } - return entries -} + if (isDefinition(value)) return [{ path: next.join("."), definition: value }] + return typeof value === "function" ? [] : definitions(value, next) + }) const describeDefinition = (path: string, definition: Definition): ToolDescription => ({ path, @@ -349,9 +346,6 @@ const visibleDefinitions = (tools: HostTools) => description: describeDefinition(path, definition), })) -export const catalog = (tools: HostTools): ReadonlyArray => - visibleDefinitions(tools).map(({ description }) => description) - export type DiscoveryPlan = { readonly catalog: ReadonlyArray readonly instructions: string @@ -617,7 +611,7 @@ export const prepare = (tools: HostTools, catalogBudget = defaultCatalogBu "## Language", "", "Use common JavaScript data operations, functions, control flow, selected standard-library methods, and awaited tool calls. Built-ins include Date, RegExp, Map, Set, URL, URLSearchParams, and URI encoding helpers.", - "Modules/imports, classes, generators, timers, fetch, eval, prototype access, unlisted methods, and promise chaining are unavailable. Use Code Mode tools for external operations. Use await with try/catch.", + "Modules/imports, classes, generators, timers, fetch, eval, prototype access, unlisted methods, and new Promise(...) are unavailable. Use Code Mode tools for external operations. Use await with try/catch.", "Prefer explicit `return`; otherwise only the final top-level expression becomes the result.", "Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.", ] diff --git a/packages/codemode/src/tool-schema.ts b/packages/codemode/src/tool-schema.ts index 16213fa8eeb..36fc7511b8a 100644 --- a/packages/codemode/src/tool-schema.ts +++ b/packages/codemode/src/tool-schema.ts @@ -23,7 +23,7 @@ const effectNumberSentinel = (schema: JsonSchema) => const intersection = (members: ReadonlyArray): string => { const concrete = members.filter((member) => member !== "unknown") if (concrete.length === 0) return "unknown" - if (concrete.length === 1) return concrete[0] ?? "unknown" + if (concrete.length === 1) return concrete[0] return concrete.map((member) => (member.includes(" | ") ? `(${member})` : member)).join(" & ") } diff --git a/packages/codemode/test/codemode.test.ts b/packages/codemode/test/codemode.test.ts index 2e09e9d3c9e..b4c06b587c5 100644 --- a/packages/codemode/test/codemode.test.ts +++ b/packages/codemode/test/codemode.test.ts @@ -672,9 +672,10 @@ describe("CodeMode public contract", () => { expect(instructions).toContain("not a general-purpose runtime") expect(instructions).not.toContain("Standard modern JavaScript works") expect(instructions).not.toContain("TypeScript type annotations") - for (const missing of ["Modules/imports", "classes", "generators", "fetch", "promise chaining"]) { + for (const missing of ["Modules/imports", "classes", "generators", "fetch", "new Promise(...)"]) { expect(instructions).toContain(missing) } + expect(instructions).not.toContain("promise chaining") expect(instructions).toContain("URL, URLSearchParams, and URI encoding helpers") expect(instructions).not.toContain("host globals") expect(instructions).toContain("Use Code Mode tools for external operations") diff --git a/packages/codemode/test/promise-test262.test.ts b/packages/codemode/test/promise-test262.test.ts index 500b7ae45df..fa0eb246c1a 100644 --- a/packages/codemode/test/promise-test262.test.ts +++ b/packages/codemode/test/promise-test262.test.ts @@ -16,8 +16,7 @@ import { describe, expect, test } from "bun:test" import { Effect } from "effect" import { CodeMode } from "../src/index.js" -const execute = (code: string) => - Effect.runPromise(CodeMode.execute({ code, tools: {}, limits: { timeoutMs: 1_000 } })) +const execute = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {}, limits: { timeoutMs: 1_000 } })) const value = async (code: string) => { const result = await execute(code) @@ -450,7 +449,10 @@ describe("Test262 async functions and await", () => { const promises = [declaration(), expression(), arrow()] return [promises.map((item) => item instanceof Promise), await Promise.all(promises)] `), - ).toEqual([[true, true, true], [1, 2, 3]]) + ).toEqual([ + [true, true, true], + [1, 2, 3], + ]) }) test("async bodies adopt returns and reject throws before and after await", async () => { @@ -479,13 +481,7 @@ describe("Test262 async functions and await", () => { await observe(throwsAfter()), ] `), - ).toEqual([ - ["body"], - ["fulfilled", 42], - ["fulfilled", 43], - ["rejected", 1], - ["rejected", 2], - ]) + ).toEqual([["body"], ["fulfilled", 42], ["fulfilled", 43], ["rejected", 1], ["rejected", 2]]) }) test("default-parameter throws reject instead of escaping the call", async () => { @@ -578,7 +574,7 @@ describe("Test262 async functions and await", () => { describe("Test262 expected Promise conformance", () => { for (const name of ["all", "allSettled", "race"] as const) { - test.failing(`Promise.${name} rejects invalid input with TypeError`, async () => { + test(`Promise.${name} rejects invalid input with TypeError`, async () => { // Sources: // test/built-ins/Promise/all/S25.4.4.1_A3.1_T1.js // test/built-ins/Promise/all/S25.4.4.1_A3.1_T2.js @@ -634,7 +630,7 @@ describe("Test262 expected Promise conformance", () => { ).toBe(true) }) - test.failing("Promise.all settles after reactions attached to its inputs", async () => { + test("Promise.all settles after reactions attached to its inputs", async () => { // Sources: // test/built-ins/Promise/all/S25.4.4.1_A7.2_T1.js // test/built-ins/Promise/all/S25.4.4.1_A8.1_T1.js @@ -653,7 +649,7 @@ describe("Test262 expected Promise conformance", () => { ).toEqual([1, 2, 3, 4, 5]) }) - test.failing("Promise.allSettled settles after reactions attached to its inputs", async () => { + test("Promise.allSettled settles after reactions attached to its inputs", async () => { // Sources: // test/built-ins/Promise/allSettled/resolved-sequence.js // test/built-ins/Promise/allSettled/resolved-sequence-extra-ticks.js @@ -674,7 +670,7 @@ describe("Test262 expected Promise conformance", () => { ).toEqual([1, 2, 3, 4, 5]) }) - test.failing("Promise.race settles in a reaction after its winning input", async () => { + test("Promise.race settles in a reaction after its winning input", async () => { // Sources: // test/built-ins/Promise/race/S25.4.4.3_A6.1_T1.js // test/built-ins/Promise/race/resolved-sequence-extra-ticks.js @@ -692,7 +688,7 @@ describe("Test262 expected Promise conformance", () => { ).toEqual([1, 2, 3, 4, 5]) }) - test.failing("then reactions route and propagate fulfillment and rejection", async () => { + test("then reactions route and propagate fulfillment and rejection", async () => { // Sources: // test/built-ins/Promise/prototype/then/prfm-fulfilled.js // test/built-ins/Promise/prototype/then/prfm-rejected.js @@ -726,7 +722,7 @@ describe("Test262 expected Promise conformance", () => { ]) }) - test.failing("then reactions preserve breadth-first queue order", async () => { + test("then reactions preserve breadth-first queue order", async () => { // Source: test/built-ins/Promise/prototype/then/S25.4.4_A1.1_T1.js expect( await value(` @@ -741,7 +737,7 @@ describe("Test262 expected Promise conformance", () => { ).toEqual([1, 2, 3, 4, 5, 6, 7, 8]) }) - test.failing("then rejects direct self-resolution for fulfilled and rejected sources", async () => { + test("then rejects direct self-resolution for fulfilled and rejected sources", async () => { // Sources: // test/built-ins/Promise/prototype/then/resolve-settled-fulfilled-self.js // test/built-ins/Promise/prototype/then/resolve-settled-rejected-self.js @@ -761,7 +757,7 @@ describe("Test262 expected Promise conformance", () => { ).toEqual(["TypeError", "TypeError"]) }) - test.failing("catch delegates rejection handling and preserves fulfillment", async () => { + test("catch delegates rejection handling and preserves fulfillment", async () => { // Sources: // test/built-ins/Promise/prototype/catch/S25.4.5.1_A2.1_T1.js // test/built-ins/Promise/prototype/catch/S25.4.5.1_A3.1_T1.js @@ -776,7 +772,7 @@ describe("Test262 expected Promise conformance", () => { ).toEqual([1, 4]) }) - test.failing("finally preserves or replaces the original settlement", async () => { + test("finally preserves or replaces the original settlement", async () => { // Sources: // test/built-ins/Promise/prototype/finally/resolution-value-no-override.js // test/built-ins/Promise/prototype/finally/rejection-reason-no-fulfill.js @@ -799,7 +795,109 @@ describe("Test262 expected Promise conformance", () => { ]) }) - test.failing("await always resumes in a later reaction and interleaves async functions", async () => { + test("then ignores non-callable handlers", async () => { + // Sources: + // test/built-ins/Promise/prototype/then/S25.4.5.3_A4.1_T1.js + // test/built-ins/Promise/prototype/then/S25.4.5.3_A4.1_T2.js + // test/built-ins/Promise/prototype/then/S25.4.5.3_A5.1_T1.js + // test/built-ins/Promise/prototype/then/S25.4.5.3_A5.2_T1.js + // (adapted: only non-callable handlers are probed; callables that are not plain + // functions, such as tool references, intentionally throw in CodeMode) + expect( + await value(` + const observe = async (promise) => { + try { return ["fulfilled", await promise] } catch (reason) { return ["rejected", reason] } + } + return await Promise.all([ + observe(Promise.resolve(1).then(2)), + observe(Promise.resolve(4).then(null, null)), + observe(Promise.resolve(5).then({}, "x")), + observe(Promise.reject(3).then(null, "x")), + observe(Promise.reject(6).then(7, {})), + ]) + `), + ).toEqual([ + ["fulfilled", 1], + ["fulfilled", 4], + ["fulfilled", 5], + ["rejected", 3], + ["rejected", 6], + ]) + }) + + test("finally waits for a returned promise and preserves or replaces settlement", async () => { + // Sources: + // test/built-ins/Promise/prototype/finally/resolved-observable-then-calls.js + // test/built-ins/Promise/prototype/finally/rejected-observable-then-calls.js + // test/built-ins/Promise/prototype/finally/resolution-value-no-override.js + expect( + await value(` + const observe = async (promise) => { + try { return ["fulfilled", await promise] } catch (reason) { return ["rejected", reason] } + } + const order = [] + const cleanup = async () => { + await Promise.resolve() + order.push("cleanup") + } + const settled = await Promise.resolve("kept").finally(() => cleanup()) + order.push("settled:" + settled) + return [ + await observe(Promise.resolve(1).finally(() => Promise.resolve(99))), + order, + await observe(Promise.resolve(2).finally(() => Promise.reject(3))), + await observe(Promise.reject(4).finally(() => Promise.resolve(99))), + ] + `), + ).toEqual([ + ["fulfilled", 1], + ["cleanup", "settled:kept"], + ["rejected", 3], + ["rejected", 4], + ]) + }) + + test("then adopts a returned rejected promise", async () => { + // Sources: + // test/built-ins/Promise/prototype/then/rxn-handler-fulfilled-return-abrupt.js + // test/built-ins/Promise/resolve/resolve-promise.js + // (adapted: the fulfillment handler returns an already-rejected promise instead of + // throwing, and the rejection handler recovers with a fulfilled promise) + expect( + await value(` + const observe = async (promise) => { + try { return ["fulfilled", await promise] } catch (reason) { return ["rejected", reason] } + } + return await Promise.all([ + observe(Promise.resolve(1).then(() => Promise.reject("bad"))), + observe(Promise.reject(2).then(undefined, () => Promise.resolve("ok"))), + ]) + `), + ).toEqual([ + ["rejected", "bad"], + ["fulfilled", "ok"], + ]) + }) + + test("independent reactions on one source each observe the same settlement", async () => { + // Source: test/built-ins/Promise/prototype/then/S25.4.4_A2.1_T1.js + // (adapted: the multiple-reactions family is asserted through the values every + // reaction returns instead of a shared completion counter) + expect( + await value(` + const fulfilled = Promise.resolve(7) + const rejected = Promise.reject(8) + return await Promise.all([ + fulfilled.then((value) => "first:" + value), + fulfilled.then((value) => "second:" + value), + rejected.catch((reason) => "first:" + reason), + rejected.catch((reason) => "second:" + reason), + ]) + `), + ).toEqual(["first:7", "second:7", "first:8", "second:8"]) + }) + + test("await always resumes in a later reaction and interleaves async functions", async () => { // Sources: // test/language/expressions/await/async-await-interleaved.js // test/language/expressions/await/await-non-promise.js @@ -814,7 +912,7 @@ describe("Test262 expected Promise conformance", () => { ).toEqual(["first:1", "second:1", "first:2", "second:2"]) }) - test.failing("an async function rejects when it resolves with its own promise", async () => { + test("an async function rejects when it resolves with its own promise", async () => { // Adapted from the self-resolution requirement represented by: // test/built-ins/Promise/resolve-self.js // test/built-ins/Promise/resolve/S25.4.4.5_A4.1_T1.js diff --git a/packages/codemode/test/promise.test.ts b/packages/codemode/test/promise.test.ts index 847cd6fb155..c74d6fd7df9 100644 --- a/packages/codemode/test/promise.test.ts +++ b/packages/codemode/test/promise.test.ts @@ -3,8 +3,9 @@ import { Effect, Schema } from "effect" import { CodeMode, Tool, toolError } from "../src/index.js" // Wave 5 acceptance suite: first-class promise values. Un-awaited tool calls start eagerly on -// supervised fibers, `await` settles them, and Promise.all/allSettled/race/resolve/reject are -// ordinary functions over arbitrary arrays mixing promises and plain values. +// supervised fibers, `await` settles them, Promise.all/allSettled/race/resolve/reject are +// ordinary functions over arbitrary arrays mixing promises and plain values, and +// .then/.catch/.finally chain reactions onto any promise. type Trace = { starts: Array @@ -184,7 +185,7 @@ describe("first-class promise values", () => { expect(result.toolCalls).toStrictEqual([{ name: "host.sleepy" }]) }) - test("await of a non-promise value is a passthrough no-op", async () => { + test("await of a non-promise value passes it through unchanged", async () => { expect(await value(`return await 42`)).toBe(42) expect(await value(`const x = await "s"; return x`)).toBe("s") expect(await value(`return await null`)).toBeNull() @@ -935,16 +936,124 @@ describe("timeout interruption of forked calls", () => { }) }) -describe("unsupported promise surface", () => { - test(".then/.catch/.finally give a clear await-instead error", async () => { - for (const method of ["then", "catch", "finally"]) { - const diagnostic = await error(`return tools.host.sleepy({ id: 1 }).${method}((x) => x)`) - expect(diagnostic.kind).toBe("UnsupportedSyntax") - expect(diagnostic.message).toContain(`Promise.prototype.${method} is not supported`) - expect(diagnostic.message).toContain("await") - } +describe("promise chaining", () => { + test("then transforms tool results and adopts returned promises across a chain", async () => { + expect( + await value(` + return await tools.host + .sleepy({ id: 2 }) + .then((id) => tools.host.sleepy({ id: id + 1 })) + .then((id) => id * 10) + `), + ).toBe(30) }) + test("handlers are deferred and run in attach order", async () => { + expect( + await value(` + const order = [] + const promise = Promise.resolve(1) + promise.then(() => order.push("h1")) + promise.then(() => order.push("h2")) + order.push("sync") + await promise + return order + `), + ).toEqual(["sync", "h1", "h2"]) + }) + + test("catch recovers a tool failure and preserves fulfillment", async () => { + expect( + await value(` + return [ + await tools.host.fail({}).catch((error) => error.message), + await tools.host.sleepy({ id: 4 }).catch(() => "unused"), + ] + `), + ).toEqual(["Lookup refused", 4]) + }) + + test("finally observes settlement without changing the value", async () => { + expect( + await value(` + const events = [] + const result = await tools.host.sleepy({ id: 5 }).finally(() => events.push("cleanup")) + return [result, events] + `), + ).toEqual([5, ["cleanup"]]) + }) + + test("a settled, un-awaited rejected chain tail warns exactly once", async () => { + const result = await run(` + Promise.reject(new Error("boom")).then((value) => value) + await Promise.resolve() + return "done" + `) + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.value).toBe("done") + // The source rejection belongs to the chain (no warning); only the derived tail warns. + expect(result.warnings).toStrictEqual([ + { kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: boom" }, + ]) + }) + + test("a catch handler silences the chain's rejection warning", async () => { + const result = await run(` + Promise.reject(new Error("boom")).catch(() => "handled") + await Promise.resolve() + return "done" + `) + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.warnings).toBeUndefined() + }) + + test("non-plain-function handlers fail loudly instead of being ignored", async () => { + const diagnostic = await error(`return await tools.host.sleepy({ id: 1 }).then(tools.host.completed)`) + expect(diagnostic.message).toContain("Promise.prototype.then handlers must be plain functions") + }) + + test("chaining methods are opaque references until called", async () => { + expect(await value(`return typeof tools.host.sleepy({ id: 1 }).then`)).toBe("function") + }) +}) + +describe("combinator settlement timing", () => { + test("a combinator settling one reaction turn after the program returns is interrupted silently", async () => { + // The aggregate's one-turn settlement delay (V8 parity) means an immediately-returning + // program abandons it while still pending: interrupted like any pending work, so no + // rejection warning survives - the member itself was observed by the combinator. + const result = await run(` + Promise.all([Promise.reject(new Error("boom"))]) + return "done" + `) + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.value).toBe("done") + expect(result.warnings).toBeUndefined() + }) + + test("a combinator settles one reaction turn after its members, as in V8", async () => { + // Regression for the race winner flip: Promise.all's settlement burns a reaction turn, + // so a plain resolved value entered in the same race wins, and a fail-fast aggregate + // cannot beat it into rejection. + expect( + await value(` + const pending = tools.host.sleepy({ id: 9, ms: 60000 }) + const winner = await Promise.race([Promise.all([Promise.resolve(1)]), Promise.resolve(2)]) + try { + const raced = await Promise.race([Promise.all([Promise.reject("x"), pending]), Promise.resolve("ok")]) + return [winner, "fulfilled", raced] + } catch (reason) { + return [winner, "rejected", reason] + } + `), + ).toEqual([2, "fulfilled", "ok"]) + }) +}) + +describe("unsupported promise surface", () => { test("other property reads on a promise hint at the missing await", async () => { const diagnostic = await error(`return tools.host.sleepy({ id: 1 }).value`) expect(diagnostic.kind).toBe("InvalidDataValue")