mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-26 13:43:35 +00:00
feat(codemode): support promise chaining
This commit is contained in:
parent
ba24037e64
commit
1289e00778
4 changed files with 171 additions and 19 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(...)`.",
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue