From 1289e0077826aa221d5ec9ebf5ca9ade9351b300 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 6 Jul 2026 16:52:00 -0500 Subject: [PATCH 01/12] feat(codemode): support promise chaining --- packages/codemode/codemode.md | 3 +- packages/codemode/src/interpreter/model.ts | 2 +- packages/codemode/src/interpreter/runtime.ts | 96 ++++++++++++++++++-- packages/codemode/test/promise.test.ts | 89 ++++++++++++++++-- 4 files changed, 171 insertions(+), 19 deletions(-) diff --git a/packages/codemode/codemode.md b/packages/codemode/codemode.md index f7f798f2e1b5..dfaedb85e04d 100644 --- a/packages/codemode/codemode.md +++ b/packages/codemode/codemode.md @@ -63,7 +63,8 @@ path lookup, namespace browsing, deterministic ranking, and pagination. ### Tool execution Calling a tool starts its Effect eagerly on a supervised fiber. The returned sandbox promise is run-once and can be -awaited directly or through the supported `Promise` combinators. At most eight tool calls execute concurrently. +awaited directly, chained with `then`/`catch`/`finally`, or passed through the supported `Promise` combinators. At most +eight tool calls execute concurrently. Unfinished calls are drained before successful program completion, and an unhandled call failure becomes a diagnostic. The public execution-policy knobs are `timeoutMs`, `maxToolCalls`, and `maxOutputBytes`. The package supplies no diff --git a/packages/codemode/src/interpreter/model.ts b/packages/codemode/src/interpreter/model.ts index 08d47cd37e8c..3ee4a7d5ffe1 100644 --- a/packages/codemode/src/interpreter/model.ts +++ b/packages/codemode/src/interpreter/model.ts @@ -123,7 +123,7 @@ export type DiagnosticKind = export const OptionalShortCircuit: unique symbol = Symbol("codemode.optional-short-circuit") export const supportedSyntaxMessage = - "Supported orchestration syntax: tools.* calls (they return promises - resolve them with await), data literals, destructuring, optional chaining, template literals, conditionals, switch, loops (incl. for...of and for...in over object/array/tools keys), arrow functions, spread, try/catch, array methods (map/filter/find/findIndex/some/every/reduce/flatMap/forEach/sort/slice/concat/indexOf/lastIndexOf/at/flat/reverse/includes/join), string methods (incl. match/matchAll/replace/split with regular expressions), Date/RegExp/Map/Set/URL/URLSearchParams, URI encoding helpers, Object/Math/JSON helpers, captured console.log/warn/error/dir/table, and Promise.all/allSettled/race/resolve/reject over arrays mixing promises and plain values for parallel tool calls (promise chaining with .then/.catch is not supported - use await with try/catch)." + "Supported orchestration syntax: tools.* calls (they return promises - resolve them with await or .then/.catch/.finally), data literals, destructuring, optional chaining, template literals, conditionals, switch, loops (incl. for...of and for...in over object/array/tools keys), arrow functions, spread, try/catch, array methods (map/filter/find/findIndex/some/every/reduce/flatMap/forEach/sort/slice/concat/indexOf/lastIndexOf/at/flat/reverse/includes/join), string methods (incl. match/matchAll/replace/split with regular expressions), Date/RegExp/Map/Set/URL/URLSearchParams, URI encoding helpers, Object/Math/JSON helpers, captured console.log/warn/error/dir/table, and Promise.all/allSettled/race/resolve/reject over arrays mixing promises and plain values for parallel tool calls." export class InterpreterRuntimeError extends Error { readonly node?: AstNode diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index 84f4d7c53f7c..9a29df1cc18e 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -2375,6 +2375,9 @@ class Interpreter { args: Array, node: AstNode, ): Effect.Effect { + if (ref.receiver instanceof SandboxPromise) { + return this.invokePromisePrototypeMethod(ref.receiver, ref.name, args, node) + } if (typeof ref.receiver === "string") { if ( (ref.name === "replace" || ref.name === "replaceAll") && @@ -2411,6 +2414,89 @@ class Interpreter { throw new InterpreterRuntimeError(`Method '${ref.name}' is not available in CodeMode.`, node) } + private invokePromisePrototypeMethod( + promise: SandboxPromise, + name: string, + args: Array, + node: AstNode, + ): Effect.Effect { + 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) => + 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, node) : Effect.succeed(result) + }, + ) + } + let chained: SandboxPromise | undefined + 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, node)) + + return Effect.map( + this.createPromise( + Effect.gen(function* () { + yield* Effect.yieldNow + const exit = yield* settlement + if (onFinally !== undefined) yield* onFinally([]) + if (Exit.isSuccess(exit)) { + if (onFulfilled === undefined) return exit.value + return yield* onFulfilled([exit.value]) + } + if (onRejected !== undefined) return yield* onRejected([caughtErrorValue(Cause.squash(exit.cause))]) + return yield* Effect.failCause(exit.cause) + }), + ), + (promise) => (chained = promise), + ) + } + private invokeStringReplacer( value: string, name: "replace" | "replaceAll", @@ -3204,17 +3290,9 @@ 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. if (objectValue instanceof SandboxPromise) { if (key === "then" || key === "catch" || key === "finally") { - throw new InterpreterRuntimeError( - `Promise.prototype.${String(key)} is not supported in CodeMode; use await instead (with try/catch to handle failures) - e.g. \`const result = await tools.ns.tool(...)\`.`, - propertyNode, - "UnsupportedSyntax", - [supportedSyntaxMessage], - ) + return new IntrinsicReference(objectValue, key) } throw new InterpreterRuntimeError( "This value is an un-awaited Promise and has no readable properties; await it first - e.g. `const result = await tools.ns.tool(...)`.", diff --git a/packages/codemode/test/promise.test.ts b/packages/codemode/test/promise.test.ts index 313b9c22cc0c..931e246ea9fb 100644 --- a/packages/codemode/test/promise.test.ts +++ b/packages/codemode/test/promise.test.ts @@ -527,16 +527,89 @@ 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 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("catch receives normalized errors and recovers the chain", async () => { + expect(await value(`return tools.host.fail({}).catch((error) => error.message)`)).toBe("Lookup refused") + expect( + await value(` + return Promise.resolve(1) + .then(() => { throw new Error("boom") }) + .catch((error) => error.message) + `), + ).toBe("boom") + }) + + test("then rejection handlers and omitted handlers pass through settlement", async () => { + expect(await value(`return Promise.resolve(4).then(undefined).catch(undefined)`)).toBe(4) + expect(await value(`return Promise.reject("nope").then(undefined, (reason) => reason + "!")`)).toBe("nope!") + }) + + test("supported builtin callables can be handlers", async () => { + expect(await value(`return Promise.resolve({ a: 1 }).then(JSON.stringify)`)).toBe('{"a":1}') + expect(await value(`return Promise.resolve(4).then(Promise.resolve)`)).toBe(4) }) + test("finally awaits its callback and preserves the original settlement", async () => { + expect( + await value(` + let cleanup = 0 + const result = await Promise.resolve(7).finally(async () => { + await tools.host.sleepy({ id: 1 }) + cleanup = 1 + return 99 + }) + return [result, cleanup] + `), + ).toEqual([7, 1]) + expect( + await value(`return Promise.reject(new Error("original")).finally(() => 99).catch((error) => error.message)`), + ).toBe("original") + }) + + test("a rejected finally callback replaces the original settlement", async () => { + expect( + await value(` + return Promise.resolve(1) + .finally(() => Promise.reject(new Error("cleanup"))) + .catch((error) => error.message) + `), + ).toBe("cleanup") + }) + + test("a self-resolving chain rejects instead of deadlocking", async () => { + expect( + await value(` + let chained + chained = Promise.resolve().then(() => chained) + return chained.catch((error) => [error.name, error.message]) + `), + ).toEqual(["TypeError", "Chaining cycle detected for promise."]) + }) +}) + +describe("unsupported promise surface", () => { test("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") From 8b53f362e896f987f620e7be522e32e51b01752c Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 6 Jul 2026 16:58:55 -0500 Subject: [PATCH 02/12] docs(codemode): describe promise chaining --- packages/codemode/README.md | 4 ++-- packages/codemode/codemode.md | 5 ++--- packages/codemode/src/tool-runtime.ts | 4 ++-- packages/codemode/test/codemode.test.ts | 3 ++- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/codemode/README.md b/packages/codemode/README.md index 37bde2d86966..17715b9a2f9c 100644 --- a/packages/codemode/README.md +++ b/packages/codemode/README.md @@ -246,12 +246,12 @@ CodeMode executes a deliberately bounded JavaScript subset. It supports: - Regular expressions - `/literals/` and `new RegExp(...)` with `test`/`exec` (stateful `lastIndex` for `g`), plus string `match`/`matchAll`/`replace`/`replaceAll`/`split`/`search` with patterns. Match results are arrays carrying `index` and named `groups` as own properties (`input` is omitted). `replace` and `replaceAll` accept function replacers with captures, offset, input, and named groups; callbacks run sequentially, may await tool calls, and have their results coerced to strings. Invalid patterns, invalid flags, and missing-`g` calls fail with catchable errors that say what was wrong and how to fix it (escaping hints, the exact `/pattern/g` to write). Patterns run on the host engine, so pathological backtracking is bounded only by the execution timeout. - `Map` and `Set` - construction from entries/arrays/strings, `get`/`set`/`add`/`has`/`delete`/`clear`/`size`/`forEach`, and `keys`/`values`/`entries` returning **arrays** (not iterators). - URL helpers - `URL` resolution and mutation, linked `URLSearchParams`, `URL.canParse`/`URL.parse`, URI and URI-component encoding/decoding, and query parameter construction, lookup, mutation, sorting, callbacks, and materialization. URLSearchParams iteration methods return arrays, matching the Map/Set convention. -- First-class promises - an un-awaited `tools.ns.tool(...)` is a promise value whose call starts immediately on a supervised fiber; `await` resolves it (awaiting a non-promise value is a no-op, and `return tools.ns.tool(...)` resolves like an async-function return). `Promise.all`, `Promise.allSettled`, and `Promise.race` accept any array mixing promises and plain values (built inline, beforehand, or via spread); `Promise.resolve`/`Promise.reject` construct settled promises. `Promise.allSettled` rejection reasons are the same plain `{ name?, message }` data a `catch` binding sees, and `Promise.race` interrupts its losing in-flight calls. At most 8 tool calls run concurrently. When a program completes, still-running un-awaited calls are awaited before the execution ends; a failure from a call that was never awaited surfaces as an unhandled-rejection diagnostic. +- First-class promises - an un-awaited `tools.ns.tool(...)` is a promise value whose call starts immediately on a supervised fiber; `await` resolves it (awaiting a non-promise value is a no-op, and `return tools.ns.tool(...)` resolves like an async-function return). Promises support `then`/`catch`/`finally`, including returned-promise flattening and rejection recovery. `Promise.all`, `Promise.allSettled`, and `Promise.race` accept any array mixing promises and plain values (built inline, beforehand, or via spread); `Promise.resolve`/`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. - `throw value` and `throw new Error(message)` for explicit program failure. `Error` (and `TypeError`/`RangeError`/`SyntaxError`/`ReferenceError`/`EvalError`/`URIError`) are real constructors, callable with or without `new`; error values are plain `{ name, message }` data that additionally satisfy `instanceof Error` (a specific type matches itself and `Error`, as in JS). Every caught failure - thrown errors, interpreter runtime errors, and tool failures - is `instanceof Error` in a `catch` block; a thrown non-error value (`throw "text"`) is not, matching JS. Caught failures carry the `name` the equivalent real-JS failure would have - `JSON.parse` and invalid regex patterns produce a `SyntaxError` (satisfying `instanceof SyntaxError`), an unknown identifier a `ReferenceError`, assigning to a constant a `TypeError`, a bad `normalize` form a `RangeError`; failures with no specific analogue (including tool failures) are named `"Error"`. `instanceof` also recognizes `Date`, `RegExp`, `Map`, `Set`, `URL`, `URLSearchParams`, `Array`, `Object`, and `Promise`; any other right-hand side is a catchable error. Inside a program, standard-library values stay live everywhere: the internal data checkpoints (`Object.*` helpers, spread, coercion inputs) preserve the instances, so `Object.values({ d: date })[0].getTime()` and a spread copy of an object holding a Map keep working. Only at the host boundary (final result, tool arguments, `JSON.stringify`) do they serialize exactly as `JSON.stringify` would: Date and URL become strings (an invalid Date becomes `null`), while RegExp, Map, Set, and URLSearchParams become `{}`. Promise values never cross a data boundary: an un-awaited promise in a result or tool argument produces a diagnostic that says to await it, instead of serializing to `{}`. -It does not expose `eval`, dynamic imports, modules, classes, generators, timers, host globals, prototype mutation, custom promise constructors (`new Promise`), promise chaining (`.then`/`.catch`/`.finally` - `await` with `try`/`catch` is the supported style), or arbitrary method calls. Unsupported syntax returns an `UnsupportedSyntax` diagnostic with a source location when available. +It does not expose `eval`, dynamic imports, modules, classes, generators, timers, host globals, prototype mutation, custom promise constructors (`new Promise`), or arbitrary method calls. Unsupported syntax returns an `UnsupportedSyntax` diagnostic with a source location when available. CodeMode is an orchestration language, not a general JavaScript runtime. diff --git a/packages/codemode/codemode.md b/packages/codemode/codemode.md index dfaedb85e04d..c6253b80a3d5 100644 --- a/packages/codemode/codemode.md +++ b/packages/codemode/codemode.md @@ -149,9 +149,8 @@ the adapter TODO. Delete entries when completed. The supported JavaScript subset should grow when common model-generated code improves tool orchestration. These are current omissions to implement, not intentional product boundaries. -- [ ] Design proper multi-stage promise pipelines. Supporting `.then`, `.catch`, and `.finally` should preserve promise - assimilation, cancellation, failure handling, and concurrent per-item pipelines rather than adding syntax-only - shims. Consider `Promise.any` in the same pass. +- [ ] Complete promise-pipeline parity: assimilate supported thenables, propagate cancellation through chained promises, + and consider `Promise.any`. - [ ] Support async iteration and `for await...of`. Define behavior first for the runtime's supported promise and collection values, then extend it to bounded host streams when a stream boundary exists. - [ ] Support callback-bearing standard-library variants that models commonly generate: the mapper argument to diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts index 7d67ba6c79f8..4e7ef5fce318 100644 --- a/packages/codemode/src/tool-runtime.ts +++ b/packages/codemode/src/tool-runtime.ts @@ -615,8 +615,8 @@ 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.", + "Use common JavaScript data operations, functions, control flow, selected standard-library methods, and tool calls. Promises support await, then/catch/finally, and the listed Promise combinators. Built-ins include Date, RegExp, Map, Set, URL, URLSearchParams, and URI encoding helpers.", + "Modules/imports, classes, generators, timers, fetch, eval, prototype access, and unlisted methods are unavailable. Use Code Mode tools for external operations.", "Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.", ] diff --git a/packages/codemode/test/codemode.test.ts b/packages/codemode/test/codemode.test.ts index 221b5e07dfe1..92677f34f41e 100644 --- a/packages/codemode/test/codemode.test.ts +++ b/packages/codemode/test/codemode.test.ts @@ -652,9 +652,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"]) { expect(instructions).toContain(missing) } + expect(instructions).toContain("Promises support await, then/catch/finally") 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") From 0800770b0d7f93d3845f1cac1f752cd49c02fafa Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 6 Jul 2026 17:04:03 -0500 Subject: [PATCH 03/12] docs(codemode): track thenable assimilation --- packages/codemode/codemode.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/codemode/codemode.md b/packages/codemode/codemode.md index c6253b80a3d5..45ebbfa5aa3d 100644 --- a/packages/codemode/codemode.md +++ b/packages/codemode/codemode.md @@ -149,8 +149,10 @@ the adapter TODO. Delete entries when completed. The supported JavaScript subset should grow when common model-generated code improves tool orchestration. These are current omissions to implement, not intentional product boundaries. -- [ ] Complete promise-pipeline parity: assimilate supported thenables, propagate cancellation through chained promises, - and consider `Promise.any`. +- [ ] Decide whether thenable assimilation belongs in the bounded runtime. Promise resolution currently unwraps only + `SandboxPromise`; arbitrary `{ then(resolve, reject) }` values remain data. Full assimilation requires internal + callable resolver values, first-settlement arbitration, recursive adoption, and cycle detection. +- [ ] Propagate cancellation through chained promises, and consider `Promise.any`. - [ ] Support async iteration and `for await...of`. Define behavior first for the runtime's supported promise and collection values, then extend it to bounded host streams when a stream boundary exists. - [ ] Support callback-bearing standard-library variants that models commonly generate: the mapper argument to From c3b9d03bdb2c54d54af49d1f1583c42bc59e952d Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 6 Jul 2026 17:24:00 -0500 Subject: [PATCH 04/12] fix(codemode): preserve promise race losers --- packages/codemode/README.md | 2 +- packages/codemode/codemode.md | 2 +- packages/codemode/src/interpreter/runtime.ts | 75 +++++++------------- packages/codemode/src/values.ts | 1 - packages/codemode/test/promise.test.ts | 46 ++++++------ 5 files changed, 53 insertions(+), 73 deletions(-) diff --git a/packages/codemode/README.md b/packages/codemode/README.md index 17715b9a2f9c..27358e7807bc 100644 --- a/packages/codemode/README.md +++ b/packages/codemode/README.md @@ -246,7 +246,7 @@ CodeMode executes a deliberately bounded JavaScript subset. It supports: - Regular expressions - `/literals/` and `new RegExp(...)` with `test`/`exec` (stateful `lastIndex` for `g`), plus string `match`/`matchAll`/`replace`/`replaceAll`/`split`/`search` with patterns. Match results are arrays carrying `index` and named `groups` as own properties (`input` is omitted). `replace` and `replaceAll` accept function replacers with captures, offset, input, and named groups; callbacks run sequentially, may await tool calls, and have their results coerced to strings. Invalid patterns, invalid flags, and missing-`g` calls fail with catchable errors that say what was wrong and how to fix it (escaping hints, the exact `/pattern/g` to write). Patterns run on the host engine, so pathological backtracking is bounded only by the execution timeout. - `Map` and `Set` - construction from entries/arrays/strings, `get`/`set`/`add`/`has`/`delete`/`clear`/`size`/`forEach`, and `keys`/`values`/`entries` returning **arrays** (not iterators). - URL helpers - `URL` resolution and mutation, linked `URLSearchParams`, `URL.canParse`/`URL.parse`, URI and URI-component encoding/decoding, and query parameter construction, lookup, mutation, sorting, callbacks, and materialization. URLSearchParams iteration methods return arrays, matching the Map/Set convention. -- First-class promises - an un-awaited `tools.ns.tool(...)` is a promise value whose call starts immediately on a supervised fiber; `await` resolves it (awaiting a non-promise value is a no-op, and `return tools.ns.tool(...)` resolves like an async-function return). 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`/`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`/`Promise.reject` construct settled promises. `Promise.allSettled` rejection reasons are the same plain `{ name?, message }` data a `catch` binding sees, and `Promise.race` lets losing promises continue. At most 8 tool calls run concurrently. When a program completes, still-running un-awaited calls are awaited before the execution ends; a failure from a call that was never awaited surfaces as an unhandled-rejection diagnostic. - `throw value` and `throw new Error(message)` for explicit program failure. `Error` (and `TypeError`/`RangeError`/`SyntaxError`/`ReferenceError`/`EvalError`/`URIError`) are real constructors, callable with or without `new`; error values are plain `{ name, message }` data that additionally satisfy `instanceof Error` (a specific type matches itself and `Error`, as in JS). Every caught failure - thrown errors, interpreter runtime errors, and tool failures - is `instanceof Error` in a `catch` block; a thrown non-error value (`throw "text"`) is not, matching JS. Caught failures carry the `name` the equivalent real-JS failure would have - `JSON.parse` and invalid regex patterns produce a `SyntaxError` (satisfying `instanceof SyntaxError`), an unknown identifier a `ReferenceError`, assigning to a constant a `TypeError`, a bad `normalize` form a `RangeError`; failures with no specific analogue (including tool failures) are named `"Error"`. `instanceof` also recognizes `Date`, `RegExp`, `Map`, `Set`, `URL`, `URLSearchParams`, `Array`, `Object`, and `Promise`; any other right-hand side is a catchable error. Inside a program, standard-library values stay live everywhere: the internal data checkpoints (`Object.*` helpers, spread, coercion inputs) preserve the instances, so `Object.values({ d: date })[0].getTime()` and a spread copy of an object holding a Map keep working. Only at the host boundary (final result, tool arguments, `JSON.stringify`) do they serialize exactly as `JSON.stringify` would: Date and URL become strings (an invalid Date becomes `null`), while RegExp, Map, Set, and URLSearchParams become `{}`. Promise values never cross a data boundary: an un-awaited promise in a result or tool argument produces a diagnostic that says to await it, instead of serializing to `{}`. diff --git a/packages/codemode/codemode.md b/packages/codemode/codemode.md index 45ebbfa5aa3d..973d933e930c 100644 --- a/packages/codemode/codemode.md +++ b/packages/codemode/codemode.md @@ -152,7 +152,7 @@ current omissions to implement, not intentional product boundaries. - [ ] Decide whether thenable assimilation belongs in the bounded runtime. Promise resolution currently unwraps only `SandboxPromise`; arbitrary `{ then(resolve, reject) }` values remain data. Full assimilation requires internal callable resolver values, first-settlement arbitration, recursive adoption, and cycle detection. -- [ ] Propagate cancellation through chained promises, and consider `Promise.any`. +- [ ] Consider `Promise.any`. - [ ] Support async iteration and `for await...of`. Define behavior first for the runtime's supported promise and collection values, then extend it to bounded host streams when a stream boundary exists. - [ ] Support callback-bearing standard-library variants that models commonly generate: the mapper argument to diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index 9a29df1cc18e..3b5833b90651 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -219,7 +219,7 @@ const normalizeError = (error: unknown): Diagnostic => { } } -// Shared by catch bindings, Promise.allSettled rejection reasons, and Promise.race losers. +// Shared by catch bindings and Promise.allSettled rejection reasons. const caughtErrorValue = (thrown: unknown): unknown => { if (thrown instanceof ProgramThrow) return thrown.value if (thrown instanceof InterpreterRuntimeError) return createErrorValue(thrown.errorName, thrown.message) @@ -703,7 +703,7 @@ class Interpreter { // Awaits every fiber-backed promise the program abandoned (fire-and-forget tool calls), so // their work completes before the execution ends - mirroring a JS runtime waiting on // in-flight I/O at exit. A failure nobody could have handled becomes an unhandled-rejection - // diagnostic (interrupted calls, e.g. Promise.race losers, are ignored). + // diagnostic. private drainPendingSettlements(): Effect.Effect { const self = this return Effect.gen(function* () { @@ -752,28 +752,13 @@ class Interpreter { // `await promise`: succeed with the fulfilled value or re-raise the failure so try/catch // observes it exactly like a synchronous throw at the await site. - private settlePromise(promise: SandboxPromise, node?: AstNode): Effect.Effect { + private settlePromise(promise: SandboxPromise): Effect.Effect { const self = this - return Effect.flatMap(this.observePromise(promise), (exit) => self.unwrapPromiseExit(promise, exit, node)) + return Effect.flatMap(this.observePromise(promise), (exit) => self.unwrapPromiseExit(exit)) } - private unwrapPromiseExit( - promise: SandboxPromise | undefined, - exit: Exit.Exit, - node?: AstNode, - ): Effect.Effect { + private unwrapPromiseExit(exit: Exit.Exit): Effect.Effect { if (Exit.isSuccess(exit)) return Effect.succeed(exit.value) - // A call Promise.race interrupted after losing settles as a catchable program failure; - // any other interruption is execution teardown (timeout/host) and must keep propagating - // as interruption rather than becoming program-visible data. - if (promise?.interrupted === true && Cause.hasInterruptsOnly(exit.cause)) { - return Effect.fail( - new InterpreterRuntimeError( - "This tool call was interrupted because another value settled a Promise.race first.", - node, - ), - ) - } return Effect.failCause(exit.cause) } @@ -1533,7 +1518,7 @@ class Interpreter { // matching real JS semantics for non-thenables. const self = this return Effect.flatMap(this.evaluateExpression(getNode(node, "argument")), (value) => - value instanceof SandboxPromise ? self.settlePromise(value, node) : Effect.succeed(value), + value instanceof SandboxPromise ? self.settlePromise(value) : Effect.succeed(value), ) } case "NewExpression": @@ -2257,42 +2242,33 @@ class Interpreter { ), ), ) - return yield* self.unwrapPromiseExit(winner.item, winner.exit, node) + return yield* self.unwrapPromiseExit(winner.exit) } return values }) } case "allSettled": { const observations = items.map((item) => - item instanceof SandboxPromise - ? Effect.map(this.observePromise(item), (exit) => ({ promise: item as SandboxPromise | undefined, exit })) - : Effect.succeed({ promise: undefined as SandboxPromise | undefined, exit: Exit.succeed(item as unknown) }), + item instanceof SandboxPromise ? this.observePromise(item) : Effect.succeed(Exit.succeed(item as unknown)), ) return Effect.gen(function* () { const outcomes: Array = [] for (const observation of observations) { - const { exit, promise } = yield* observation + const exit = yield* observation if (Exit.isSuccess(exit)) { outcomes.push( Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value }), ) continue } - const raceInterrupted = promise?.interrupted === true && Cause.hasInterruptsOnly(exit.cause) - if (Cause.hasInterruptsOnly(exit.cause) && !raceInterrupted) { + if (Cause.hasInterruptsOnly(exit.cause)) { // 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) outcomes.push( Object.assign(Object.create(null) as SafeObject, { status: "rejected", - reason: caughtErrorValue(thrown), + reason: caughtErrorValue(Cause.squash(exit.cause)), }), ) } @@ -2312,20 +2288,21 @@ class Interpreter { : Effect.succeed({ index, exit: Exit.succeed(item as unknown) }), ) return Effect.gen(function* () { - // First settlement (fulfilled OR rejected) wins; the observations never fail, so - // racing them yields exactly that. Losing in-flight calls are then interrupted. + // First settlement (fulfilled OR rejected) wins. Losers continue like native + // promises, while a supervised drain keeps their failures handled and their work + // inside the execution lifetime. const winner = yield* Effect.raceAll(observations) - for (const [index, item] of items.entries()) { - if (index === winner.index || !(item instanceof SandboxPromise) || item.fiber === undefined) continue - item.interrupted = true - yield* Fiber.interrupt(item.fiber) - } - const winningItem = items[winner.index] - return yield* self.unwrapPromiseExit( - winningItem instanceof SandboxPromise ? winningItem : undefined, - winner.exit, - node, + yield* self.createPromise( + Effect.asVoid( + Effect.forEach( + items, + (item, index) => + index !== winner.index && item instanceof SandboxPromise ? self.observePromise(item) : Effect.void, + { concurrency: "unbounded" }, + ), + ), ) + return yield* self.unwrapPromiseExit(winner.exit) }) } } @@ -2469,7 +2446,7 @@ class Interpreter { new InterpreterRuntimeError("Chaining cycle detected for promise.", node).as("TypeError"), ) } - return result instanceof SandboxPromise ? this.settlePromise(result, node) : Effect.succeed(result) + return result instanceof SandboxPromise ? this.settlePromise(result) : Effect.succeed(result) }, ) } @@ -2477,7 +2454,7 @@ class Interpreter { 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, node)) + const settlement = Effect.exit(this.settlePromise(promise)) return Effect.map( this.createPromise( diff --git a/packages/codemode/src/values.ts b/packages/codemode/src/values.ts index 4ca305d815eb..101ccd83779c 100644 --- a/packages/codemode/src/values.ts +++ b/packages/codemode/src/values.ts @@ -1,7 +1,6 @@ import type { Effect, Fiber } from "effect" export class SandboxPromise { - interrupted = false constructor( readonly fiber: Fiber.Fiber | undefined, readonly immediate?: Effect.Effect, diff --git a/packages/codemode/test/promise.test.ts b/packages/codemode/test/promise.test.ts index 931e246ea9fb..e364f3907629 100644 --- a/packages/codemode/test/promise.test.ts +++ b/packages/codemode/test/promise.test.ts @@ -412,45 +412,36 @@ describe("Promise.allSettled", () => { }) describe("Promise.race", () => { - test("first settlement wins and losers are interrupted", async () => { + test("first settlement wins and losers continue", async () => { const trace = makeTrace() const result = await value( ` const fast = tools.host.sleepy({ id: 1, ms: 10 }) - const slow = tools.host.sleepy({ id: 2, ms: 5000 }) + const slow = tools.host.sleepy({ id: 2, ms: 30 }) return await Promise.race([fast, slow]) `, { trace }, ) expect(result).toBe(1) - expect(trace.interrupted).toBe(1) - expect(trace.completed).toBe(1) + expect(trace.interrupted).toBe(0) + expect(trace.completed).toBe(2) }) - test("awaiting an interrupted loser afterwards is a catchable program failure", async () => { + test("a losing chain continues and remains awaitable", async () => { expect( await value(` - const fast = tools.host.sleepy({ id: 1, ms: 10 }) - const slow = tools.host.sleepy({ id: 2, ms: 5000 }) - const winner = await Promise.race([fast, slow]) - try { - await slow - return "no" - } catch (e) { - return { winner, caught: e.message } - } + const slow = tools.host.sleepy({ id: 2, ms: 30 }).then((id) => id * 2) + const winner = await Promise.race([slow, "fast"]) + return [winner, await slow] `), - ).toEqual({ - winner: 1, - caught: "This tool call was interrupted because another value settled a Promise.race first.", - }) + ).toEqual(["fast", 4]) }) test("a rejection can win the race", async () => { expect( await value(` try { - await Promise.race([tools.host.fail({}), tools.host.sleepy({ id: 1, ms: 5000 })]) + await Promise.race([tools.host.fail({}), tools.host.sleepy({ id: 1, ms: 30 })]) return "no" } catch (e) { return e.message @@ -462,9 +453,22 @@ describe("Promise.race", () => { test("a plain value wins over pending promises", async () => { const trace = makeTrace() expect( - await value(`return await Promise.race([tools.host.sleepy({ id: 1, ms: 5000 }), "immediate"])`, { trace }), + await value(`return await Promise.race([tools.host.sleepy({ id: 1, ms: 30 }), "immediate"])`, { trace }), ).toBe("immediate") - expect(trace.interrupted).toBe(1) + expect(trace.interrupted).toBe(0) + expect(trace.completed).toBe(1) + }) + + test("a losing rejection remains handled", async () => { + expect( + await value(` + const rejectLater = async () => { + await tools.host.sleepy({ id: 1, ms: 20 }) + throw new Error("late") + } + return await Promise.race([rejectLater(), "winner"]) + `), + ).toBe("winner") }) test("an empty race is a clear error instead of hanging", async () => { From cf5e27844773bc163209bdeb2a91a229b00538f5 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 6 Jul 2026 17:32:58 -0500 Subject: [PATCH 05/12] fix(codemode): return promises from combinators --- packages/codemode/src/interpreter/runtime.ts | 121 +++++++++---------- packages/codemode/src/values.ts | 1 + packages/codemode/test/promise.test.ts | 20 +++ 3 files changed, 80 insertions(+), 62 deletions(-) diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index 3b5833b90651..55d62130c5df 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -711,7 +711,7 @@ class Interpreter { const promise = self.pendingSettlements.values().next().value if (promise === undefined) break const exit = yield* self.observePromise(promise) - if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause)) continue + if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause) || promise.handled) continue const failure = normalizeError(Cause.squash(exit.cause)) throw new InterpreterRuntimeError( `Unhandled rejection from an un-awaited promise: ${failure.message}`, @@ -2221,59 +2221,59 @@ class Interpreter { ? Effect.map(this.observePromise(item), (exit) => ({ index, item, exit })) : Effect.succeed({ index, item: undefined, exit: Exit.succeed(item) }), ) - return Effect.gen(function* () { - const remaining = [...observations] - const values: Array = [] - values.length = items.length - while (remaining.length > 0) { - const winner = yield* Effect.raceAll(remaining) - const position = remaining.indexOf(observations[winner.index]) - if (position >= 0) remaining.splice(position, 1) - if (Exit.isSuccess(winner.exit)) { - values[winner.index] = winner.exit.value - continue + return this.createPromise( + Effect.gen(function* () { + const remaining = [...observations] + const values: Array = [] + values.length = items.length + while (remaining.length > 0) { + const winner = yield* Effect.raceAll(remaining) + const position = remaining.indexOf(observations[winner.index]) + if (position >= 0) remaining.splice(position, 1) + if (Exit.isSuccess(winner.exit)) { + values[winner.index] = winner.exit.value + continue + } + for (const item of items) { + if (!(item instanceof SandboxPromise) || item === winner.item) continue + item.handled = true + self.pendingSettlements.add(item) + } + return yield* self.unwrapPromiseExit(winner.exit) } - yield* self.createPromise( - Effect.asVoid( - Effect.forEach( - items, - (item) => (item instanceof SandboxPromise ? self.observePromise(item) : Effect.void), - { concurrency: "unbounded" }, - ), - ), - ) - return yield* self.unwrapPromiseExit(winner.exit) - } - return values - }) + return values + }), + ) } case "allSettled": { const observations = items.map((item) => item instanceof SandboxPromise ? this.observePromise(item) : Effect.succeed(Exit.succeed(item as unknown)), ) - return Effect.gen(function* () { - const outcomes: Array = [] - for (const observation of observations) { - const exit = yield* observation - if (Exit.isSuccess(exit)) { + return this.createPromise( + 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": { if (items.length === 0) { @@ -2287,23 +2287,20 @@ class Interpreter { ? Effect.map(this.observePromise(item), (exit) => ({ index, exit })) : Effect.succeed({ index, exit: Exit.succeed(item as unknown) }), ) - return Effect.gen(function* () { - // First settlement (fulfilled OR rejected) wins. Losers continue like native - // promises, while a supervised drain keeps their failures handled and their work - // inside the execution lifetime. - const winner = yield* Effect.raceAll(observations) - yield* self.createPromise( - Effect.asVoid( - Effect.forEach( - items, - (item, index) => - index !== winner.index && item instanceof SandboxPromise ? self.observePromise(item) : Effect.void, - { concurrency: "unbounded" }, - ), - ), - ) - return yield* self.unwrapPromiseExit(winner.exit) - }) + return this.createPromise( + Effect.gen(function* () { + // First settlement (fulfilled OR rejected) wins. Losers continue like native + // promises, while a supervised drain keeps their failures handled and their work + // inside the execution lifetime. + const winner = yield* Effect.raceAll(observations) + for (const [index, item] of items.entries()) { + if (index === winner.index || !(item instanceof SandboxPromise)) continue + item.handled = true + self.pendingSettlements.add(item) + } + return yield* self.unwrapPromiseExit(winner.exit) + }), + ) } } } diff --git a/packages/codemode/src/values.ts b/packages/codemode/src/values.ts index 101ccd83779c..b1a3e947c54a 100644 --- a/packages/codemode/src/values.ts +++ b/packages/codemode/src/values.ts @@ -1,6 +1,7 @@ import type { Effect, Fiber } from "effect" export class SandboxPromise { + handled = false constructor( readonly fiber: Fiber.Fiber | undefined, readonly immediate?: Effect.Effect, diff --git a/packages/codemode/test/promise.test.ts b/packages/codemode/test/promise.test.ts index e364f3907629..dbc6b28bf873 100644 --- a/packages/codemode/test/promise.test.ts +++ b/packages/codemode/test/promise.test.ts @@ -498,6 +498,26 @@ describe("Promise.resolve / Promise.reject", () => { }) }) +describe("Promise combinator values", () => { + test("all, allSettled, and race return chainable promises", async () => { + expect( + await value(` + const all = Promise.all([Promise.resolve(1), 2]) + const settled = Promise.allSettled([Promise.resolve(3)]) + const race = Promise.race([Promise.resolve(4)]) + return [ + all instanceof Promise, + settled instanceof Promise, + race instanceof Promise, + await all.then((values) => values.join(",")), + await settled.then((values) => values[0].value), + await race.then((value) => value + 1), + ] + `), + ).toEqual([true, true, true, "1,2", 3, 5]) + }) +}) + describe("timeout interruption of forked calls", () => { test("the execution timeout interrupts in-flight forked fibers", async () => { const trace = makeTrace() From 9445497fc5362d5fca34b32d622aa8f3c0ccebd4 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 6 Jul 2026 17:50:21 -0500 Subject: [PATCH 06/12] docs(codemode): track promise status --- packages/codemode/README.md | 2 +- packages/codemode/codemode.md | 55 ++++++++++++++++++++++++--- packages/codemode/src/tool-runtime.ts | 2 +- 3 files changed, 52 insertions(+), 7 deletions(-) diff --git a/packages/codemode/README.md b/packages/codemode/README.md index 27358e7807bc..b80247e88978 100644 --- a/packages/codemode/README.md +++ b/packages/codemode/README.md @@ -246,7 +246,7 @@ CodeMode executes a deliberately bounded JavaScript subset. It supports: - Regular expressions - `/literals/` and `new RegExp(...)` with `test`/`exec` (stateful `lastIndex` for `g`), plus string `match`/`matchAll`/`replace`/`replaceAll`/`split`/`search` with patterns. Match results are arrays carrying `index` and named `groups` as own properties (`input` is omitted). `replace` and `replaceAll` accept function replacers with captures, offset, input, and named groups; callbacks run sequentially, may await tool calls, and have their results coerced to strings. Invalid patterns, invalid flags, and missing-`g` calls fail with catchable errors that say what was wrong and how to fix it (escaping hints, the exact `/pattern/g` to write). Patterns run on the host engine, so pathological backtracking is bounded only by the execution timeout. - `Map` and `Set` - construction from entries/arrays/strings, `get`/`set`/`add`/`has`/`delete`/`clear`/`size`/`forEach`, and `keys`/`values`/`entries` returning **arrays** (not iterators). - URL helpers - `URL` resolution and mutation, linked `URLSearchParams`, `URL.canParse`/`URL.parse`, URI and URI-component encoding/decoding, and query parameter construction, lookup, mutation, sorting, callbacks, and materialization. URLSearchParams iteration methods return arrays, matching the Map/Set convention. -- First-class promises - an un-awaited `tools.ns.tool(...)` is a promise value whose call starts immediately on a supervised fiber; `await` resolves it (awaiting a non-promise value is a no-op, and `return tools.ns.tool(...)` resolves like an async-function return). 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`/`Promise.reject` construct settled promises. `Promise.allSettled` rejection reasons are the same plain `{ name?, message }` data 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. +- First-class promises - an un-awaited `tools.ns.tool(...)` is a promise value whose call starts immediately on a supervised fiber; `await` resolves it (awaiting a non-promise value is a no-op, and `return tools.ns.tool(...)` resolves like an async-function return). Promises support `then`/`catch`/`finally`, including returned-promise flattening and rejection recovery. `Promise.all`, `Promise.allSettled`, and `Promise.race` accept any array mixing promises and plain values (built inline, beforehand, or via spread); `Promise.resolve` preserves an existing promise or wraps a plain value, and `Promise.reject` creates a rejected promise. `Promise.allSettled` rejection reasons use the same values a `catch` binding sees, and `Promise.race` lets losing promises continue. At most 8 tool calls run concurrently. When a program completes, still-running un-awaited calls are awaited before the execution ends; a failure from a call that was never awaited surfaces as an unhandled-rejection diagnostic. - `throw value` and `throw new Error(message)` for explicit program failure. `Error` (and `TypeError`/`RangeError`/`SyntaxError`/`ReferenceError`/`EvalError`/`URIError`) are real constructors, callable with or without `new`; error values are plain `{ name, message }` data that additionally satisfy `instanceof Error` (a specific type matches itself and `Error`, as in JS). Every caught failure - thrown errors, interpreter runtime errors, and tool failures - is `instanceof Error` in a `catch` block; a thrown non-error value (`throw "text"`) is not, matching JS. Caught failures carry the `name` the equivalent real-JS failure would have - `JSON.parse` and invalid regex patterns produce a `SyntaxError` (satisfying `instanceof SyntaxError`), an unknown identifier a `ReferenceError`, assigning to a constant a `TypeError`, a bad `normalize` form a `RangeError`; failures with no specific analogue (including tool failures) are named `"Error"`. `instanceof` also recognizes `Date`, `RegExp`, `Map`, `Set`, `URL`, `URLSearchParams`, `Array`, `Object`, and `Promise`; any other right-hand side is a catchable error. Inside a program, standard-library values stay live everywhere: the internal data checkpoints (`Object.*` helpers, spread, coercion inputs) preserve the instances, so `Object.values({ d: date })[0].getTime()` and a spread copy of an object holding a Map keep working. Only at the host boundary (final result, tool arguments, `JSON.stringify`) do they serialize exactly as `JSON.stringify` would: Date and URL become strings (an invalid Date becomes `null`), while RegExp, Map, Set, and URLSearchParams become `{}`. Promise values never cross a data boundary: an un-awaited promise in a result or tool argument produces a diagnostic that says to await it, instead of serializing to `{}`. diff --git a/packages/codemode/codemode.md b/packages/codemode/codemode.md index 973d933e930c..a8a03292cb8f 100644 --- a/packages/codemode/codemode.md +++ b/packages/codemode/codemode.md @@ -65,7 +65,8 @@ path lookup, namespace browsing, deterministic ranking, and pagination. Calling a tool starts its Effect eagerly on a supervised fiber. The returned sandbox promise is run-once and can be awaited directly, chained with `then`/`catch`/`finally`, or passed through the supported `Promise` combinators. At most eight tool calls execute concurrently. -Unfinished calls are drained before successful program completion, and an unhandled call failure becomes a diagnostic. +Unfinished tracked promises are drained before successful program completion, and an unhandled rejection becomes a +diagnostic. The public execution-policy knobs are `timeoutMs`, `maxToolCalls`, and `maxOutputBytes`. The package supplies no defaults because budgets are host policy. The interpreter also enforces fixed internal boundaries for tool-call @@ -110,6 +111,54 @@ MCP tools use this canonical path: they register as grouped tools and are deferr output schemas are preserved in generated signatures. Direct Core tools remain direct and are not ambient globals inside CodeMode. +## Promise Status + +The runtime currently provides eager, run-once promises for tool calls and async functions; `await`; +`then`/`catch`/`finally`; and chainable `all`/`allSettled`/`race`/`resolve`/`reject`. `Promise.all` rejects promptly while +siblings continue, and `Promise.race` leaves losers running as JavaScript does. Tracked work remains supervised, at most +eight tool calls run concurrently, and successful execution drains unfinished work before closing. + +### Confirmed defects + +- [ ] Keep nested fire-and-forget work alive until the execution drain. A tool call started but not returned inside an + async function or promise handler is currently a child of that short-lived fiber and may be interrupted when the + parent settles even though the promise remains globally tracked. +- [ ] Track immediate rejected promises. `Promise.reject(value)` does not enter `pendingSettlements`, so abandoning it + produces no unhandled-rejection diagnostic. +- [ ] Drain handled work after ordinary program failure, then preserve the original failure. Today pending work drains + only after success, so a rejecting race winner or a later program throw interrupts race losers and `Promise.all` + siblings. Timeout and external interruption should still cancel immediately rather than drain. +- [ ] Make every `await` continuation asynchronous. Awaiting a plain or already-settled value currently resumes in the + same scheduling turn and can reorder state mutation relative to JavaScript. +- [ ] Return rejected promises for invalid `Promise.all`/`allSettled`/`race` inputs instead of throwing during the call. +- [ ] 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. + +### Deliberate deviations and open decisions + +- CodeMode drains unfinished work before successful execution 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. + +### Required coverage + +- Nested unreturned work from async functions and `then`/`catch`/`finally` handlers. +- Abandoned immediate, chained, and combinator rejections. +- Plain-value and already-settled `await` ordering. +- Ordinary program failure versus timeout/external interruption while handled 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 reaction ordering. + ## Intentionally Unsupported These are product boundaries rather than DSL backlog: @@ -149,10 +198,6 @@ the adapter TODO. Delete entries when completed. The supported JavaScript subset should grow when common model-generated code improves tool orchestration. These are current omissions to implement, not intentional product boundaries. -- [ ] Decide whether thenable assimilation belongs in the bounded runtime. Promise resolution currently unwraps only - `SandboxPromise`; arbitrary `{ then(resolve, reject) }` values remain data. Full assimilation requires internal - callable resolver values, first-settlement arbitration, recursive adoption, and cycle detection. -- [ ] Consider `Promise.any`. - [ ] Support async iteration and `for await...of`. Define behavior first for the runtime's supported promise and collection values, then extend it to bounded host streams when a stream boundary exists. - [ ] Support callback-bearing standard-library variants that models commonly generate: the mapper argument to diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts index 4e7ef5fce318..8f76f6c0d7ef 100644 --- a/packages/codemode/src/tool-runtime.ts +++ b/packages/codemode/src/tool-runtime.ts @@ -615,7 +615,7 @@ export const prepare = (tools: HostTools, catalogBudget = defaultCatalogBu "", "## Language", "", - "Use common JavaScript data operations, functions, control flow, selected standard-library methods, and tool calls. Promises support await, then/catch/finally, and the listed Promise combinators. 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 tool calls. Promises support await, then/catch/finally, and Promise.all/allSettled/race/resolve/reject. Built-ins include Date, RegExp, Map, Set, URL, URLSearchParams, and URI encoding helpers.", "Modules/imports, classes, generators, timers, fetch, eval, prototype access, and unlisted methods are unavailable. Use Code Mode tools for external operations.", "Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.", ] From 78520af16488947d1ceedd4eb54e69f64c8bd037 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 6 Jul 2026 18:11:16 -0500 Subject: [PATCH 07/12] fix(codemode): supervise promise lifecycles --- packages/codemode/codemode.md | 13 +- packages/codemode/src/interpreter/runtime.ts | 65 +++++++--- packages/codemode/test/promise.test.ts | 125 +++++++++++++++++++ 3 files changed, 173 insertions(+), 30 deletions(-) diff --git a/packages/codemode/codemode.md b/packages/codemode/codemode.md index a8a03292cb8f..6eb7abd030b9 100644 --- a/packages/codemode/codemode.md +++ b/packages/codemode/codemode.md @@ -115,19 +115,12 @@ inside CodeMode. 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, at most -eight tool calls run concurrently, and successful execution drains unfinished work before closing. +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 -- [ ] Keep nested fire-and-forget work alive until the execution drain. A tool call started but not returned inside an - async function or promise handler is currently a child of that short-lived fiber and may be interrupted when the - parent settles even though the promise remains globally tracked. -- [ ] Track immediate rejected promises. `Promise.reject(value)` does not enter `pendingSettlements`, so abandoning it - produces no unhandled-rejection diagnostic. -- [ ] Drain handled work after ordinary program failure, then preserve the original failure. Today pending work drains - only after success, so a rejecting race winner or a later program throw interrupts race losers and `Promise.all` - siblings. Timeout and external interruption should still cancel immediately rather than drain. - [ ] Make every `await` continuation asynchronous. Awaiting a plain or already-settled value currently resumes in the same scheduling turn and can reorder state mutation relative to JavaScript. - [ ] Return rejected promises for invalid `Promise.all`/`allSettled`/`race` inputs instead of throwing during the call. diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index 55d62130c5df..fd3c79c61cfc 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -1,5 +1,5 @@ import { parse } from "acorn" -import { Cause, Effect, Exit, Fiber, Semaphore } from "effect" +import { Cause, Effect, Exit, Fiber, Scope, Semaphore } from "effect" import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript" import { copyIn, @@ -607,9 +607,12 @@ class Interpreter { private readonly toolKeys: (path: ReadonlyArray) => ReadonlyArray private readonly logs: Array 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 // Caps how many eagerly forked tool calls run at once (the parallel-call concurrency cap). private readonly callPermits: Semaphore.Semaphore - // Fiber-backed promises whose settlement no program construct has observed yet. Successful + // Fiber-backed promises whose settlement no program construct has observed yet. Ordinary // program completion drains these (like a runtime waiting on in-flight work at exit) and // surfaces a never-awaited failure as an unhandled-rejection diagnostic. private readonly pendingSettlements: Set @@ -617,6 +620,7 @@ class Interpreter { constructor( invokeTool: (path: ReadonlyArray, args: Array) => Effect.Effect, toolKeys: (path: ReadonlyArray) => ReadonlyArray, + promiseScope: Scope.Scope, logs: Array = [], shared?: { callPermits: Semaphore.Semaphore; pendingSettlements: Set }, ) { @@ -626,6 +630,7 @@ class Interpreter { this.toolKeys = toolKeys this.logs = logs this.lastValue = undefined + this.promiseScope = promiseScope this.callPermits = shared?.callPermits ?? Semaphore.makeUnsafe(TOOL_CALL_CONCURRENCY) this.pendingSettlements = shared?.pendingSettlements ?? new Set() globalScope.set("tools", { mutable: false, value: new ToolReference([]) }) @@ -668,7 +673,7 @@ class Interpreter { // top-level declarations (`let undefined = 5`, `const Object = ...`) shadow builtins like // JS module scope, instead of colliding with the seeded globals. this.pushScope() - return Effect.gen(function* () { + const evaluate = Effect.gen(function* () { self.hoistFunctions(program.body) let value: unknown = undefined let returned = false @@ -695,8 +700,18 @@ class Interpreter { // resolves before crossing the data boundary - `return tools.ns.tool(...)` works // without an explicit await, exactly as in JS. if (value instanceof SandboxPromise) value = yield* self.settlePromise(value) - yield* self.drainPendingSettlements() return value + }) + return Effect.gen(function* () { + const result = yield* Effect.exit(evaluate) + if (Exit.isFailure(result) && Cause.hasInterruptsOnly(result.cause)) { + return yield* Effect.failCause(result.cause) + } + + const drained = yield* Effect.exit(self.drainPendingSettlements()) + if (Exit.isFailure(result)) return yield* Effect.failCause(result.cause) + if (Exit.isFailure(drained)) return yield* Effect.failCause(drained.cause) + return result.value }).pipe(Effect.ensuring(Effect.sync(() => self.popScope()))) } @@ -707,19 +722,22 @@ class Interpreter { private drainPendingSettlements(): Effect.Effect { const self = this return Effect.gen(function* () { + let unhandled: InterpreterRuntimeError | undefined while (self.pendingSettlements.size > 0) { const promise = self.pendingSettlements.values().next().value if (promise === undefined) break const exit = yield* self.observePromise(promise) if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause) || promise.handled) continue + if (unhandled !== undefined) continue const failure = normalizeError(Cause.squash(exit.cause)) - throw new InterpreterRuntimeError( + unhandled = new InterpreterRuntimeError( `Unhandled rejection from an un-awaited promise: ${failure.message}`, undefined, failure.kind, ["Await promises so failures can be caught and handled."], ) } + if (unhandled !== undefined) throw unhandled }) } @@ -735,7 +753,7 @@ class Interpreter { } private createPromise(effect: Effect.Effect): Effect.Effect { - return Effect.map(Effect.forkChild(effect, { startImmediately: true }), (fiber) => { + return Effect.map(Effect.forkIn(effect, this.promiseScope, { startImmediately: true }), (fiber) => { const promise = new SandboxPromise(fiber) this.pendingSettlements.add(promise) return promise @@ -2200,7 +2218,11 @@ class Interpreter { ) } 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]) @@ -2306,7 +2328,7 @@ class Interpreter { } private invokeFunction(fn: CodeModeFunction, args: Array): Effect.Effect { - const invocation = new Interpreter(this.invokeTool, this.toolKeys, this.logs, { + const invocation = new Interpreter(this.invokeTool, this.toolKeys, this.promiseScope, this.logs, { callPermits: this.callPermits, pendingSettlements: this.pendingSettlements, }) @@ -3555,18 +3577,21 @@ export const executeWithLimits = >( }) } - const operation = Effect.gen(function* () { - const program = parseProgram(options.code) - const interpreter = new Interpreter>(tools.invoke, tools.keys, logs) - const value = yield* interpreter.run(program) - const result = copyOut(copyIn(value, "Execution result"), true) as DataValue - return { - ok: true, - value: result, - ...logged(), - toolCalls: tools.calls, - } satisfies Result - }).pipe((program) => { + const operation = Effect.scoped( + Effect.gen(function* () { + const scope = yield* Effect.acquireRelease(Scope.make("parallel"), (scope, exit) => Scope.close(scope, exit)) + const program = parseProgram(options.code) + const interpreter = new Interpreter>(tools.invoke, tools.keys, scope, logs) + const value = yield* interpreter.run(program) + const result = copyOut(copyIn(value, "Execution result"), true) as DataValue + return { + ok: true, + value: result, + ...logged(), + toolCalls: tools.calls, + } satisfies Result + }), + ).pipe((program) => { const timeoutMs = limits.timeoutMs if (timeoutMs === undefined) return program return program.pipe( diff --git a/packages/codemode/test/promise.test.ts b/packages/codemode/test/promise.test.ts index dbc6b28bf873..522ada193c8d 100644 --- a/packages/codemode/test/promise.test.ts +++ b/packages/codemode/test/promise.test.ts @@ -212,6 +212,22 @@ describe("first-class promise values", () => { expect(diagnostic.suggestions?.join(" ")).toContain("Await promises") }) + test("an unhandled rejection does not stop later work from draining", async () => { + const trace = makeTrace() + const diagnostic = await error( + ` + tools.host.fail({}) + tools.host.sleepy({ id: 1, ms: 30 }) + return "done" + `, + { trace }, + ) + expect(diagnostic.kind).toBe("ToolFailure") + expect(diagnostic.message).toContain("Unhandled rejection from an un-awaited promise") + expect(trace.completed).toBe(1) + expect(trace.interrupted).toBe(0) + }) + test("a never-awaited failing async function surfaces as an unhandled promise rejection", async () => { const diagnostic = await error(` const fail = async () => { throw new Error("boom") } @@ -235,6 +251,58 @@ describe("first-class promise values", () => { expect(diagnostic.kind).toBe("ToolFailure") expect(diagnostic.message).toContain("Lookup refused") }) + + test("keeps unreturned calls alive after their async function settles", async () => { + const trace = makeTrace() + expect( + await value( + ` + const start = async () => { + tools.host.sleepy({ id: 1, ms: 30 }) + return "started" + } + await start() + return "done" + `, + { trace }, + ), + ).toBe("done") + expect(trace.completed).toBe(1) + expect(trace.interrupted).toBe(0) + }) + + test("keeps unreturned calls alive after their promise handler settles", async () => { + const trace = makeTrace() + expect( + await value( + ` + await Promise.resolve().then(() => { + tools.host.sleepy({ id: 1, ms: 30 }) + }) + return "done" + `, + { trace }, + ), + ).toBe("done") + expect(trace.completed).toBe(1) + expect(trace.interrupted).toBe(0) + }) + + test("drains pending work after program failure and preserves the original failure", async () => { + const trace = makeTrace() + const diagnostic = await error( + ` + tools.host.fail({}) + tools.host.sleepy({ id: 1, ms: 30 }) + throw new Error("original") + `, + { trace }, + ) + expect(diagnostic.kind).toBe("ExecutionFailure") + expect(diagnostic.message).toBe("Uncaught: original") + expect(trace.completed).toBe(1) + expect(trace.interrupted).toBe(0) + }) }) describe("promises at data boundaries", () => { @@ -450,6 +518,18 @@ describe("Promise.race", () => { ).toBe("Lookup refused") }) + test("a rejecting winner still drains its slow loser", async () => { + const trace = makeTrace() + const diagnostic = await error( + `return await Promise.race([tools.host.fail({}), tools.host.sleepy({ id: 1, ms: 30 })])`, + { trace }, + ) + expect(diagnostic.kind).toBe("ToolFailure") + expect(diagnostic.message).toBe("Lookup refused") + expect(trace.completed).toBe(1) + expect(trace.interrupted).toBe(0) + }) + test("a plain value wins over pending promises", async () => { const trace = makeTrace() expect( @@ -496,6 +576,16 @@ describe("Promise.resolve / Promise.reject", () => { `), ).toBe("nope") }) + + 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", () => { @@ -549,6 +639,41 @@ describe("timeout interruption of forked calls", () => { expect(result.error.kind).toBe("TimeoutExceeded") expect(trace.interrupted).toBe(2) }) + + test("interrupts promise fibers concurrently during scope teardown", async () => { + const cleanup = { active: 0, overlapped: false } + const tool = Tool.make({ + description: "Wait until interrupted", + input: Schema.Struct({}), + output: Schema.Never, + run: () => + Effect.never.pipe( + Effect.onInterrupt(() => + Effect.gen(function* () { + cleanup.active += 1 + yield* Effect.sleep(20) + cleanup.overlapped ||= cleanup.active > 1 + cleanup.active -= 1 + }), + ), + ), + }) + const result = await Effect.runPromise( + CodeMode.execute({ + tools: { host: { first: tool, second: tool } }, + code: ` + tools.host.first({}) + tools.host.second({}) + return await Promise.resolve("waiting") + `, + limits: { timeoutMs: 50 }, + }), + ) + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.error.kind).toBe("TimeoutExceeded") + expect(cleanup.overlapped).toBe(true) + }) }) describe("promise chaining", () => { From c9b4eddf250358685f42bd70bfd8e3cd44691ece Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 6 Jul 2026 18:18:19 -0500 Subject: [PATCH 08/12] docs(codemode): clarify promise status --- packages/codemode/codemode.md | 26 ++++++++++++++++++-------- packages/core/test/mcp.test.ts | 2 ++ 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/codemode/codemode.md b/packages/codemode/codemode.md index 6eb7abd030b9..d75e64e14c3f 100644 --- a/packages/codemode/codemode.md +++ b/packages/codemode/codemode.md @@ -129,9 +129,9 @@ closing. Timeout and external interruption cancel immediately instead. ### Deliberate deviations and open decisions -- CodeMode drains unfinished work before successful execution 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. +- 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. @@ -143,14 +143,24 @@ closing. Timeout and external interruption cancel immediately instead. - Rejection tracking is execution-scoped and checked at drain time, not an ECMAScript microtask-level unhandled rejection model. -### Required coverage +### Covered regressions -- Nested unreturned work from async functions and `then`/`catch`/`finally` handlers. -- Abandoned immediate, chained, and combinator rejections. +- 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. +- Awaiting the same promise twice settles it once, and basic `then` handlers run after synchronous statements. + +### Missing coverage + +- Nested unreturned work from `catch` and `finally` handlers. +- Abandoned chained and combinator rejections. - Plain-value and already-settled `await` ordering. -- Ordinary program failure versus timeout/external interruption while handled work remains. +- 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 reaction ordering. +- Shared or duplicate promises across combinators, discarded inner chains, and detailed reaction ordering. ## Intentionally Unsupported diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index 29ac8a96fb1b..c6146e4c9f5e 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -184,6 +184,8 @@ it.effect("advertises MCP output schemas to Code Mode", () => const execute = (yield* toolDefinitions(registry)).find((tool) => tool.name === "execute") expect(execute?.description).toContain("tools.demo.search(input: {}): Promise<{\n ok: boolean,\n}>") + expect(execute?.description).toContain("Promises support await, then/catch/finally") + expect(execute?.description).not.toContain("promise chaining are unavailable") }), ) From d7d0113591d3b060bbf8706c70964ad42f2ac420 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 6 Jul 2026 18:20:32 -0500 Subject: [PATCH 09/12] docs(codemode): narrow chaining guidance --- packages/codemode/src/tool-runtime.ts | 4 ++-- packages/codemode/test/codemode.test.ts | 4 +++- packages/core/test/mcp.test.ts | 1 - 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts index 8f76f6c0d7ef..8a80493fec0b 100644 --- a/packages/codemode/src/tool-runtime.ts +++ b/packages/codemode/src/tool-runtime.ts @@ -615,8 +615,8 @@ export const prepare = (tools: HostTools, catalogBudget = defaultCatalogBu "", "## Language", "", - "Use common JavaScript data operations, functions, control flow, selected standard-library methods, and tool calls. Promises support await, then/catch/finally, and Promise.all/allSettled/race/resolve/reject. Built-ins include Date, RegExp, Map, Set, URL, URLSearchParams, and URI encoding helpers.", - "Modules/imports, classes, generators, timers, fetch, eval, prototype access, and unlisted methods are unavailable. Use Code Mode tools for external operations.", + "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, and unlisted methods are unavailable. Use Code Mode tools for external operations. Use await with try/catch.", "Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.", ] diff --git a/packages/codemode/test/codemode.test.ts b/packages/codemode/test/codemode.test.ts index 92677f34f41e..d69fcb11dee3 100644 --- a/packages/codemode/test/codemode.test.ts +++ b/packages/codemode/test/codemode.test.ts @@ -655,7 +655,9 @@ describe("CodeMode public contract", () => { for (const missing of ["Modules/imports", "classes", "generators", "fetch"]) { expect(instructions).toContain(missing) } - expect(instructions).toContain("Promises support await, then/catch/finally") + expect(instructions).toContain("selected standard-library methods, and awaited tool calls") + expect(instructions).toContain("Use await with try/catch") + expect(instructions).not.toContain("promise chaining are unavailable") expect(instructions).toContain("URL, URLSearchParams, and URI encoding helpers") expect(instructions).not.toContain("host globals") expect(instructions).toContain("Use Code Mode tools for external operations") diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index c6146e4c9f5e..0a63188a9645 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -184,7 +184,6 @@ it.effect("advertises MCP output schemas to Code Mode", () => const execute = (yield* toolDefinitions(registry)).find((tool) => tool.name === "execute") expect(execute?.description).toContain("tools.demo.search(input: {}): Promise<{\n ok: boolean,\n}>") - expect(execute?.description).toContain("Promises support await, then/catch/finally") expect(execute?.description).not.toContain("promise chaining are unavailable") }), ) From 0a0d7d69cdf8b580a2772e75c48e2c88b234f0c3 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 6 Jul 2026 18:29:59 -0500 Subject: [PATCH 10/12] fix(codemode): preserve promise reaction order --- packages/codemode/codemode.md | 7 +- packages/codemode/src/interpreter/runtime.ts | 90 +++++++++++++++----- packages/codemode/test/promise.test.ts | 59 +++++++++++++ 3 files changed, 132 insertions(+), 24 deletions(-) diff --git a/packages/codemode/codemode.md b/packages/codemode/codemode.md index d75e64e14c3f..ce08add6e8f9 100644 --- a/packages/codemode/codemode.md +++ b/packages/codemode/codemode.md @@ -121,8 +121,6 @@ closing. Timeout and external interruption cancel immediately instead. ### Confirmed defects -- [ ] Make every `await` continuation asynchronous. Awaiting a plain or already-settled value currently resumes in the - same scheduling turn and can reorder state mutation relative to JavaScript. - [ ] Return rejected promises for invalid `Promise.all`/`allSettled`/`race` inputs instead of throwing during the call. - [ ] 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. @@ -151,13 +149,14 @@ closing. Timeout and external interruption cancel immediately instead. loser. - Timeouts interrupt all in-flight promise fibers with parallel teardown, while host interruption propagates instead of becoming a diagnostic. -- Awaiting the same promise twice settles it once, and basic `then` handlers run after synchronous statements. +- 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. ### Missing coverage - Nested unreturned work from `catch` and `finally` handlers. - Abandoned chained and combinator rejections. -- Plain-value and already-settled `await` ordering. - 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. diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index fd3c79c61cfc..369df4e2fc00 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -1,5 +1,5 @@ import { parse } from "acorn" -import { Cause, Effect, Exit, Fiber, Scope, Semaphore } from "effect" +import { Cause, Deferred, Effect, Exit, Fiber, Queue, Scope, Semaphore } from "effect" import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript" import { copyIn, @@ -148,6 +148,11 @@ const parseProgram = (code: string): ProgramNode => { return parsed as ProgramNode } +type PromiseReaction = { + readonly effect: Effect.Effect + readonly settlement: Deferred.Deferred +} + const publicErrorMessage = (message: string): string => message.replace(/\/(?:Users|home|private|tmp|var\/folders)\/[^\s"'`]+/g, "") @@ -610,6 +615,7 @@ class Interpreter { // 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> // Caps how many eagerly forked tool calls run at once (the parallel-call concurrency cap). private readonly callPermits: Semaphore.Semaphore // Fiber-backed promises whose settlement no program construct has observed yet. Ordinary @@ -621,6 +627,7 @@ class Interpreter { invokeTool: (path: ReadonlyArray, args: Array) => Effect.Effect, toolKeys: (path: ReadonlyArray) => ReadonlyArray, promiseScope: Scope.Scope, + reactionQueue: Queue.Queue>, logs: Array = [], shared?: { callPermits: Semaphore.Semaphore; pendingSettlements: Set }, ) { @@ -631,6 +638,7 @@ class Interpreter { this.logs = logs this.lastValue = undefined this.promiseScope = promiseScope + this.reactionQueue = reactionQueue this.callPermits = shared?.callPermits ?? Semaphore.makeUnsafe(TOOL_CALL_CONCURRENCY) this.pendingSettlements = shared?.pendingSettlements ?? new Set() globalScope.set("tools", { mutable: false, value: new ToolReference([]) }) @@ -775,6 +783,42 @@ class Interpreter { return Effect.flatMap(this.observePromise(promise), (exit) => self.unwrapPromiseExit(exit)) } + private createReaction( + source: Effect.Effect, never, R>, + reaction: (exit: Exit.Exit, chained: SandboxPromise) => Effect.Effect, + ): Effect.Effect { + const settlement = Deferred.makeUnsafe() + const chained = new SandboxPromise(undefined, Deferred.await(settlement)) + this.pendingSettlements.add(chained) + return Effect.as( + Effect.forkIn( + Effect.flatMap(source, (exit) => + Effect.sync(() => + Queue.offerUnsafe(this.reactionQueue, { + settlement, + effect: Effect.suspend(() => reaction(exit, chained)), + }), + ), + ), + this.promiseScope, + { startImmediately: true }, + ), + chained, + ) + } + + private awaitValue(value: unknown): Effect.Effect { + 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): Effect.Effect { if (Exit.isSuccess(exit)) return Effect.succeed(exit.value) return Effect.failCause(exit.cause) @@ -1532,12 +1576,9 @@ 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. - const self = this - return Effect.flatMap(this.evaluateExpression(getNode(node, "argument")), (value) => - value instanceof SandboxPromise ? self.settlePromise(value) : Effect.succeed(value), - ) + // Even an already-settled value resumes in a later reaction turn, after work that was + // already queued, matching JavaScript's await continuation ordering. + return Effect.flatMap(this.evaluateExpression(getNode(node, "argument")), (value) => this.awaitValue(value)) } case "NewExpression": return this.evaluateNewExpression(node) @@ -2328,7 +2369,7 @@ class Interpreter { } private invokeFunction(fn: CodeModeFunction, args: Array): Effect.Effect { - const invocation = new Interpreter(this.invokeTool, this.toolKeys, this.promiseScope, this.logs, { + const invocation = new Interpreter(this.invokeTool, this.toolKeys, this.promiseScope, this.reactionQueue, this.logs, { callPermits: this.callPermits, pendingSettlements: this.pendingSettlements, }) @@ -2428,7 +2469,7 @@ class Interpreter { !(value instanceof ErrorConstructorReference) ) return undefined - return (callbackArgs: Array) => + return (callbackArgs: Array, chained: SandboxPromise) => Effect.flatMap( value instanceof ToolReference ? this.createToolCallPromise(value.path, callbackArgs) @@ -2469,27 +2510,24 @@ class Interpreter { }, ) } - let chained: SandboxPromise | undefined 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 Effect.map( - this.createPromise( + return this.createReaction( + settlement, + (exit, chained) => Effect.gen(function* () { - yield* Effect.yieldNow - const exit = yield* settlement - if (onFinally !== undefined) yield* onFinally([]) + if (onFinally !== undefined) yield* onFinally([], chained) if (Exit.isSuccess(exit)) { if (onFulfilled === undefined) return exit.value - return yield* onFulfilled([exit.value]) + return yield* onFulfilled([exit.value], chained) } - if (onRejected !== undefined) return yield* onRejected([caughtErrorValue(Cause.squash(exit.cause))]) + if (onRejected !== undefined) + return yield* onRejected([caughtErrorValue(Cause.squash(exit.cause))], chained) return yield* Effect.failCause(exit.cause) }), - ), - (promise) => (chained = promise), ) } @@ -3580,8 +3618,20 @@ export const executeWithLimits = >( const operation = Effect.scoped( Effect.gen(function* () { const scope = yield* Effect.acquireRelease(Scope.make("parallel"), (scope, exit) => Scope.close(scope, exit)) + const reactionQueue = yield* Queue.unbounded>>() + yield* Effect.forever( + Effect.gen(function* () { + const reaction = yield* Queue.take(reactionQueue) + yield* Effect.yieldNow + yield* Effect.forkIn( + Effect.flatMap(Effect.exit(reaction.effect), (exit) => Deferred.done(reaction.settlement, exit)), + scope, + { startImmediately: true }, + ) + }), + ).pipe(Effect.forkIn(scope, { startImmediately: true })) const program = parseProgram(options.code) - const interpreter = new Interpreter>(tools.invoke, tools.keys, scope, logs) + const interpreter = new Interpreter>(tools.invoke, tools.keys, scope, reactionQueue, logs) const value = yield* interpreter.run(program) const result = copyOut(copyIn(value, "Execution result"), true) as DataValue return { diff --git a/packages/codemode/test/promise.test.ts b/packages/codemode/test/promise.test.ts index 522ada193c8d..6e5a5e07e8b1 100644 --- a/packages/codemode/test/promise.test.ts +++ b/packages/codemode/test/promise.test.ts @@ -699,6 +699,65 @@ describe("promise chaining", () => { ).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( From 61391353c9a40ce60f83fc866000d5cb6def1c3d Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 6 Jul 2026 23:52:20 +0000 Subject: [PATCH 11/12] fix(app): add session deletion event id --- packages/app/src/context/server-sdk.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/app/src/context/server-sdk.test.ts b/packages/app/src/context/server-sdk.test.ts index 7b592178fa24..5c26815425ff 100644 --- a/packages/app/src/context/server-sdk.test.ts +++ b/packages/app/src/context/server-sdk.test.ts @@ -126,6 +126,7 @@ describe("enqueueServerEvent", () => { enqueue(partUpdated("old")) enqueue({ + id: "event", type: "session.deleted", properties: { sessionID: "session", info: { id: "session" } }, } as Event) From 99c4e201d5dd4a3639d71cf26746bb79ada8a7dc Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 7 Jul 2026 00:41:35 +0000 Subject: [PATCH 12/12] chore: retry ci