mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-24 19:56:14 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1faad5d62 |
@@ -153,8 +153,7 @@ current omissions to implement, not intentional product boundaries.
|
||||
shims. Consider `Promise.any` in the same pass.
|
||||
- [ ] 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
|
||||
`Array.from(...)` and replacers for `JSON.stringify(...)`, including Effect-aware callbacks where needed.
|
||||
- [ ] Support the mapper argument to `Array.from(...)`, including Effect-aware callbacks.
|
||||
- [ ] Add `Object.is` after runtime method and tool references have stable identity semantics.
|
||||
- [ ] Add deterministic modern collection conveniences where they improve orchestration: `Object.groupBy`, Set
|
||||
composition methods, and `Array.prototype.toSpliced`.
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
copyIn,
|
||||
copyOut,
|
||||
isBlockedMember,
|
||||
MAX_VALUE_DEPTH,
|
||||
ToolReference,
|
||||
ToolRuntime,
|
||||
ToolRuntimeError,
|
||||
@@ -2027,6 +2028,7 @@ class Interpreter<R> {
|
||||
}
|
||||
if (callable instanceof GlobalMethodReference) {
|
||||
if (callable.namespace === "console") return self.invokeConsole(callable.name, args, node)
|
||||
if (callable.namespace === "JSON") return yield* self.invokeJsonMethod(callable.name, args, node)
|
||||
if (callable.namespace === "Object" && args[0] instanceof ToolReference) {
|
||||
return self.invokeObjectMethodOnTools(callable.name, args[0], node)
|
||||
}
|
||||
@@ -2492,6 +2494,129 @@ class Interpreter<R> {
|
||||
})
|
||||
}
|
||||
|
||||
private invokeJsonMethod(
|
||||
name: string,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
): Effect.Effect<unknown, unknown, R> {
|
||||
const callback = args[1]
|
||||
const callable =
|
||||
callback instanceof CodeModeFunction ||
|
||||
callback instanceof CoercionFunction ||
|
||||
callback instanceof GlobalMethodReference ||
|
||||
callback instanceof UriFunction
|
||||
if (name === "parse" && callable) return this.parseJsonWithReviver(args, node)
|
||||
if (name === "stringify" && (callable || Array.isArray(callback))) {
|
||||
return this.stringifyJsonWithReplacer(args, node)
|
||||
}
|
||||
return Effect.sync(() => invokeJsonMethod(name, args, node))
|
||||
}
|
||||
|
||||
private parseJsonWithReviver(args: Array<unknown>, node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
const apply = this.applySettledCollectionCallback(args[1], "JSON.parse", node)
|
||||
const visit = (key: string, item: unknown): Effect.Effect<unknown, unknown, R> =>
|
||||
Effect.gen(function* () {
|
||||
if (Array.isArray(item)) {
|
||||
for (let index = 0; index < item.length; index += 1) {
|
||||
if (!(index in item)) continue
|
||||
const revived = yield* visit(String(index), item[index])
|
||||
if (revived === undefined) {
|
||||
delete item[index]
|
||||
continue
|
||||
}
|
||||
item[index] = revived
|
||||
}
|
||||
return yield* apply([key, item])
|
||||
}
|
||||
if (item !== null && typeof item === "object" && !isSandboxValue(item)) {
|
||||
const object = item as SafeObject
|
||||
for (const childKey of Object.keys(object)) {
|
||||
const revived = yield* visit(childKey, object[childKey])
|
||||
if (revived === undefined) {
|
||||
delete object[childKey]
|
||||
continue
|
||||
}
|
||||
object[childKey] = revived
|
||||
}
|
||||
}
|
||||
return yield* apply([key, item])
|
||||
})
|
||||
return visit("", invokeJsonMethod("parse", [args[0]], node))
|
||||
}
|
||||
|
||||
private stringifyJsonWithReplacer(args: Array<unknown>, node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
const callback = args[1]
|
||||
const apply = Array.isArray(callback)
|
||||
? undefined
|
||||
: this.applySettledCollectionCallback(callback, "JSON.stringify", node)
|
||||
const ancestors = new Set<object>()
|
||||
const propertyList = Array.isArray(callback)
|
||||
? Array.from(
|
||||
new Set(
|
||||
callback
|
||||
.filter((item): item is string | number => typeof item === "string" || typeof item === "number")
|
||||
.map(String),
|
||||
),
|
||||
)
|
||||
: undefined
|
||||
const visit = (key: string, item: unknown, depth: number): Effect.Effect<unknown, unknown, R> =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* (() => {
|
||||
if (apply === undefined) return Effect.succeed(item)
|
||||
return apply([
|
||||
key,
|
||||
item instanceof SandboxDate || item instanceof SandboxURL
|
||||
? copyIn(item, "JSON.stringify value")
|
||||
: item,
|
||||
])
|
||||
})()
|
||||
if (typeofValue(resolved) === "function") return undefined
|
||||
if (resolved === null || typeof resolved !== "object") return resolved
|
||||
if (isSandboxValue(resolved)) {
|
||||
return apply === undefined
|
||||
? copyIn(resolved, "JSON.stringify replacer result")
|
||||
: (Object.create(null) as SafeObject)
|
||||
}
|
||||
if (!Array.isArray(resolved)) {
|
||||
const prototype = Object.getPrototypeOf(resolved)
|
||||
if (prototype !== Object.prototype && prototype !== null) {
|
||||
return copyIn(resolved, "JSON.stringify replacer result")
|
||||
}
|
||||
}
|
||||
if (depth > MAX_VALUE_DEPTH) {
|
||||
throw new ToolRuntimeError(
|
||||
"InvalidDataValue",
|
||||
`JSON.stringify replacer result exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`,
|
||||
)
|
||||
}
|
||||
if (ancestors.has(resolved)) {
|
||||
throw new ToolRuntimeError("InvalidDataValue", "JSON.stringify replacer result contains a circular value.")
|
||||
}
|
||||
ancestors.add(resolved)
|
||||
return yield* Effect.gen(function* () {
|
||||
if (Array.isArray(resolved)) {
|
||||
const output: Array<unknown> = []
|
||||
const length = resolved.length
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
output[index] = yield* visit(String(index), resolved[index], depth + 1)
|
||||
}
|
||||
return output
|
||||
}
|
||||
const output: SafeObject = Object.create(null) as SafeObject
|
||||
for (const childKey of propertyList ?? Object.keys(resolved)) {
|
||||
if (isBlockedMember(childKey)) continue
|
||||
if (apply === undefined && !Object.hasOwn(resolved, childKey)) continue
|
||||
const child = yield* visit(childKey, (resolved as SafeObject)[childKey], depth + 1)
|
||||
if (child !== undefined) output[childKey] = child
|
||||
}
|
||||
return output
|
||||
}).pipe(Effect.ensuring(Effect.sync(() => ancestors.delete(resolved))))
|
||||
})
|
||||
return Effect.map(visit("", args[0], 0), (value) =>
|
||||
invokeJsonMethod("stringify", [value, propertyList, args[2]], node),
|
||||
)
|
||||
}
|
||||
|
||||
// Runs a collection callback accepting a user function or supported builtin callable,
|
||||
// mirroring the array-method callback contract.
|
||||
private applyCollectionCallback(
|
||||
@@ -2502,6 +2627,7 @@ class Interpreter<R> {
|
||||
if (
|
||||
!(callback instanceof CodeModeFunction) &&
|
||||
!(callback instanceof CoercionFunction) &&
|
||||
!(callback instanceof GlobalMethodReference) &&
|
||||
!(callback instanceof UriFunction)
|
||||
) {
|
||||
throw new InterpreterRuntimeError(`${name} expects a function callback.`, node)
|
||||
@@ -2509,9 +2635,23 @@ class Interpreter<R> {
|
||||
return (callbackArgs) =>
|
||||
callback instanceof CoercionFunction
|
||||
? Effect.succeed(invokeCoercion(callback, callbackArgs, node))
|
||||
: callback instanceof UriFunction
|
||||
? Effect.succeed(invokeUriFunction(callback, callbackArgs, node))
|
||||
: this.invokeFunction(callback, callbackArgs)
|
||||
: callback instanceof GlobalMethodReference
|
||||
? Effect.succeed(invokeGlobalMethod(callback, callbackArgs, node))
|
||||
: callback instanceof UriFunction
|
||||
? Effect.succeed(invokeUriFunction(callback, callbackArgs, node))
|
||||
: this.invokeFunction(callback, callbackArgs)
|
||||
}
|
||||
|
||||
private applySettledCollectionCallback(
|
||||
callback: unknown,
|
||||
name: string,
|
||||
node: AstNode,
|
||||
): (args: Array<unknown>) => Effect.Effect<unknown, unknown, R> {
|
||||
const apply = this.applyCollectionCallback(callback, name, node)
|
||||
return (args) =>
|
||||
Effect.flatMap(apply(args), (value) =>
|
||||
value instanceof SandboxPromise ? this.settlePromise(value, node) : Effect.succeed(value),
|
||||
)
|
||||
}
|
||||
|
||||
private invokeMapMethod(
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
type AstNode,
|
||||
CodeModeFunction,
|
||||
InterpreterRuntimeError,
|
||||
supportedSyntaxMessage,
|
||||
} from "../interpreter/model.js"
|
||||
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
|
||||
import { copyIn, copyOut } from "../tool-runtime.js"
|
||||
|
||||
export const jsonStatics = new Set(["stringify", "parse"])
|
||||
@@ -12,18 +7,13 @@ export const invokeJsonMethod = (name: string, args: Array<unknown>, node: AstNo
|
||||
if (!jsonStatics.has(name)) throw new InterpreterRuntimeError(`JSON.${name} is not available in CodeMode.`, node)
|
||||
switch (name) {
|
||||
case "stringify": {
|
||||
const replacer = args[1]
|
||||
if (Array.isArray(replacer) || replacer instanceof CodeModeFunction) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"JSON.stringify replacers are not supported in CodeMode.",
|
||||
node,
|
||||
"UnsupportedSyntax",
|
||||
[supportedSyntaxMessage],
|
||||
)
|
||||
}
|
||||
const space = args[2]
|
||||
const indent = typeof space === "number" || typeof space === "string" ? space : undefined
|
||||
return JSON.stringify(copyOut(copyIn(args[0], "JSON.stringify value")), null, indent)
|
||||
return JSON.stringify(
|
||||
copyOut(copyIn(args[0], "JSON.stringify value")),
|
||||
Array.isArray(args[1]) ? args[1] : null,
|
||||
indent,
|
||||
)
|
||||
}
|
||||
case "parse": {
|
||||
const text = args[0]
|
||||
|
||||
@@ -119,7 +119,7 @@ export class ToolReference {
|
||||
* limit) purely because it produces a clearer diagnostic than a native stack-overflow
|
||||
* RangeError would.
|
||||
*/
|
||||
const MAX_VALUE_DEPTH = 32
|
||||
export const MAX_VALUE_DEPTH = 32
|
||||
|
||||
export class ToolRuntimeError extends Error {
|
||||
constructor(
|
||||
|
||||
@@ -19,6 +19,161 @@ const error = async (code: string) => {
|
||||
return result.error
|
||||
}
|
||||
|
||||
describe("JSON", () => {
|
||||
test("parse revivers run post-order including the root", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const order = []
|
||||
const parsed = JSON.parse('{"item":{"count":2}}', (key, item) => {
|
||||
order.push(key)
|
||||
if (key === "count") return item + 1
|
||||
if (key === "") return { parsed: item }
|
||||
return item
|
||||
})
|
||||
return { parsed, order }
|
||||
`),
|
||||
).toEqual({ parsed: { parsed: { item: { count: 3 } } }, order: ["count", "item", ""] })
|
||||
expect(await value(`return JSON.parse("1", (key, value) => undefined) === undefined`)).toBe(true)
|
||||
})
|
||||
|
||||
test("parse reviver deletion removes properties and creates array holes", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const parsed = JSON.parse('{"keep":1,"drop":2,"items":[1,2,3]}', (key, item) =>
|
||||
key === "drop" || key === "1" ? undefined : item
|
||||
)
|
||||
return { json: JSON.stringify(parsed), hasSecond: 1 in parsed.items, length: parsed.items.length }
|
||||
`),
|
||||
).toEqual({ json: '{"keep":1,"items":[1,null,3]}', hasSecond: false, length: 3 })
|
||||
})
|
||||
|
||||
test("stringify function replacers run root-first and apply JSON deletion rules", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const order = []
|
||||
const json = JSON.stringify({ keep: 1, drop: 2, items: [1, 2] }, (key, item) => {
|
||||
order.push(key)
|
||||
if (key === "drop" || key === "1") return undefined
|
||||
return item
|
||||
})
|
||||
return { json, order }
|
||||
`),
|
||||
).toEqual({ json: '{"keep":1,"items":[1,null]}', order: ["", "keep", "drop", "items", "0", "1"] })
|
||||
expect(
|
||||
await value(`
|
||||
return [
|
||||
JSON.stringify(1, (key, item) => key === "" ? new Date(0) : item),
|
||||
JSON.stringify(1, (key, item) => key === "" ? new URL("https://example.test") : item),
|
||||
]
|
||||
`),
|
||||
).toEqual(["{}", "{}"])
|
||||
})
|
||||
|
||||
test("stringify function replacers observe live mutations and sandbox values", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const item = { a: 1, b: 2 }
|
||||
const visited = []
|
||||
const mutated = JSON.stringify(item, (key, value) => {
|
||||
visited.push(key)
|
||||
if (key === "a") item.b = 3
|
||||
return value
|
||||
})
|
||||
const mapped = JSON.stringify(new Map([["x", 1]]), (key, value) =>
|
||||
value instanceof Map ? Object.fromEntries(value) : value
|
||||
)
|
||||
const removed = { a: 1, b: 2 }
|
||||
const deleted = JSON.stringify(removed, (key, value) => {
|
||||
if (key === "a") removed.b = undefined
|
||||
if (key === "b") visited.push(value === undefined ? "missing" : "present")
|
||||
return value
|
||||
})
|
||||
return { mutated, mapped, deleted, visited }
|
||||
`),
|
||||
).toEqual({
|
||||
mutated: '{"a":1,"b":3}',
|
||||
mapped: '{"x":1}',
|
||||
deleted: '{"a":1}',
|
||||
visited: ["", "a", "b", "missing"],
|
||||
})
|
||||
})
|
||||
|
||||
test("stringify replacer arrays coerce, dedupe, and recursively filter object keys", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
return JSON.stringify(
|
||||
{ 1: "one", keep: { keep: 2, drop: 3 }, drop: 4, list: [{ keep: 5, drop: 6 }] },
|
||||
["keep", 1, "keep", null, {}, "list"],
|
||||
)
|
||||
`),
|
||||
).toBe('{"keep":{"keep":2},"1":"one","list":[{"keep":5}]}')
|
||||
})
|
||||
|
||||
test("JSON callbacks settle async tool calls sequentially", async () => {
|
||||
const calls: Array<number> = []
|
||||
const transform = Tool.make({
|
||||
description: "Transform a number",
|
||||
input: Schema.Number,
|
||||
output: Schema.Number,
|
||||
run: (input) =>
|
||||
Effect.sync(() => {
|
||||
calls.push(input)
|
||||
return input * 10
|
||||
}),
|
||||
})
|
||||
const parse = await Effect.runPromise(
|
||||
CodeMode.execute({
|
||||
tools: { host: { transform } },
|
||||
code: `return JSON.parse("[1,2]", async (key, item) => key === "" ? item : await tools.host.transform(item))`,
|
||||
}),
|
||||
)
|
||||
expect(parse.ok && parse.value).toEqual([10, 20])
|
||||
const stringify = await Effect.runPromise(
|
||||
CodeMode.execute({
|
||||
tools: { host: { transform } },
|
||||
code: `return JSON.stringify([1,2], async (key, item) => key === "" ? item : await tools.host.transform(item))`,
|
||||
}),
|
||||
)
|
||||
expect(stringify.ok && stringify.value).toBe("[10,20]")
|
||||
expect(calls).toEqual([1, 2, 1, 2])
|
||||
})
|
||||
|
||||
test("non-callable JSON callback arguments are ignored", async () => {
|
||||
expect(await value(`return [JSON.parse("{\\"a\\":1}", 42), JSON.stringify({ a: 1 }, { nope: true })]`)).toEqual([
|
||||
{ a: 1 },
|
||||
'{"a":1}',
|
||||
])
|
||||
expect(await value(`return [JSON.parse("1", Boolean), JSON.stringify({ a: 1 }, String)]`)).toEqual([false, '""'])
|
||||
})
|
||||
|
||||
test("supported global methods work as JSON callbacks", async () => {
|
||||
expect(
|
||||
await value(`return [JSON.parse("[1,2]", Number.isFinite), JSON.stringify({ a: 1 }, Number.isFinite)]`),
|
||||
).toEqual([false, "false"])
|
||||
})
|
||||
|
||||
test("stringify replacers can prune values before the depth limit", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
let item = 1
|
||||
for (let index = 0; index < 33; index++) item = { x: item }
|
||||
let calls = 0
|
||||
return JSON.stringify(item, (key, value) => ++calls === 34 ? undefined : value)
|
||||
`),
|
||||
).toBe(`${'{"x":'.repeat(32)}{}${"}".repeat(32)}`)
|
||||
})
|
||||
|
||||
test("stringify omits callable replacer results", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
return JSON.stringify({ object: 1, array: [1] }, (key, value) =>
|
||||
key === "object" || key === "0" ? Number.isFinite : value
|
||||
)
|
||||
`),
|
||||
).toBe('{"array":[null]}')
|
||||
})
|
||||
})
|
||||
|
||||
describe("Date", () => {
|
||||
test("Date.now() returns a number", async () => {
|
||||
expect(await value(`return typeof Date.now()`)).toBe("number")
|
||||
|
||||
Reference in New Issue
Block a user