From 1289e0077826aa221d5ec9ebf5ca9ade9351b300 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 6 Jul 2026 16:52:00 -0500 Subject: [PATCH] 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 f7f798f2e1b..dfaedb85e04 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 08d47cd37e8..3ee4a7d5ffe 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 84f4d7c53f7..9a29df1cc18 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 313b9c22cc0..931e246ea9f 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")