feat(codemode): support promise chaining

This commit is contained in:
Aiden Cline 2026-07-06 16:52:00 -05:00
parent ba24037e64
commit 1289e00778
4 changed files with 171 additions and 19 deletions

View file

@ -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

View file

@ -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

View file

@ -2375,6 +2375,9 @@ class Interpreter<R> {
args: Array<unknown>,
node: AstNode,
): Effect.Effect<unknown, unknown, R> {
if (ref.receiver instanceof SandboxPromise) {
return this.invokePromisePrototypeMethod(ref.receiver, ref.name, args, node)
}
if (typeof ref.receiver === "string") {
if (
(ref.name === "replace" || ref.name === "replaceAll") &&
@ -2411,6 +2414,89 @@ class Interpreter<R> {
throw new InterpreterRuntimeError(`Method '${ref.name}' is not available in CodeMode.`, node)
}
private invokePromisePrototypeMethod(
promise: SandboxPromise,
name: string,
args: Array<unknown>,
node: AstNode,
): Effect.Effect<SandboxPromise, never, R> {
const callback = (value: unknown) => {
if (
!(value instanceof ToolReference && value.path.length > 0) &&
!(value instanceof PromiseMethodReference) &&
!(value instanceof CodeModeFunction) &&
!(value instanceof IntrinsicReference) &&
!(value instanceof GlobalMethodReference) &&
!(value instanceof CoercionFunction) &&
!(value instanceof UriFunction) &&
!(value instanceof ErrorConstructorReference)
)
return undefined
return (callbackArgs: Array<unknown>) =>
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<R> {
return new ComputedValue(undefined)
}
// Any property access on a promise is a confused program (`p.then(...)`, `p.value`);
// reading `undefined` here would hide the missing await, so both paths get an explicit,
// await-hinting error instead of the forgiving unknown-property fallthrough.
if (objectValue instanceof SandboxPromise) {
if (key === "then" || key === "catch" || key === "finally") {
throw new InterpreterRuntimeError(
`Promise.prototype.${String(key)} is not supported in CodeMode; use await instead (with try/catch to handle failures) - e.g. \`const result = await tools.ns.tool(...)\`.`,
propertyNode,
"UnsupportedSyntax",
[supportedSyntaxMessage],
)
return new IntrinsicReference(objectValue, key)
}
throw new InterpreterRuntimeError(
"This value is an un-awaited Promise and has no readable properties; await it first - e.g. `const result = await tools.ns.tool(...)`.",

View file

@ -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")