mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-15 21:36:21 +00:00
Compare commits
5
Commits
v2
..
subagent-model
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85e51a7bc5 | ||
|
|
68474c23f4 | ||
|
|
b224468b9a | ||
|
|
3cb1097131 | ||
|
|
5785d43167 |
@@ -353,7 +353,6 @@
|
||||
"@ff-labs/fff-node": "0.10.5",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@modelcontextprotocol/client": "2.0.0",
|
||||
"@modelcontextprotocol/core": "2.0.0",
|
||||
"@opencode-ai/pty": "0.1.13",
|
||||
"@opencode/ai": "workspace:*",
|
||||
"@opencode/codemode": "workspace:*",
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-U9IuP/ev6w4urvogOwQyl3rdumY6W4YaY18NkFaOVHU=",
|
||||
"aarch64-linux": "sha256-Wc8OT2DRZpVo56KaoGE0Hsj1NDknakbWXO9w2qy6j+0=",
|
||||
"aarch64-darwin": "sha256-wAea8+jajnMDxZ6XJL+Hsrf0621hwtBtWyD1+dS45dE=",
|
||||
"x86_64-darwin": "sha256-g8PCNBSV6rO+VQjKU9AtYqj+r18o+fhLDXEQq+X2EZ4="
|
||||
"x86_64-linux": "sha256-E5T4o3wNivOg8q4wRV7yE4CUNaUq4QNPxZsnvLaGUcg=",
|
||||
"aarch64-linux": "sha256-xQQi7LgxInZQVCznASfa0Pm+cNGBo5j4Tfp0E/bKnNw=",
|
||||
"aarch64-darwin": "sha256-qxb371dCf7WG09VvQE+HZ/x3EURpFIr11bdnQwGTHhw=",
|
||||
"x86_64-darwin": "sha256-xi1qiYr41hzbgAQl+I3xdtnkiPx2SIcA/0OMpN78qXw="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ for (const custom of [false, true]) {
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
const trigger = page.getByRole("button", { name: "Session details", exact: true })
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
await expect(trigger).toBeEnabled()
|
||||
await trigger.hover()
|
||||
const tooltip = page.getByRole("tooltip")
|
||||
|
||||
@@ -278,11 +278,7 @@ async function installMotionProbe(page: Page) {
|
||||
probe.resetAnchorOnMotion = false
|
||||
}
|
||||
probe.terminalAnchorGaps.push(anchorGap)
|
||||
if (
|
||||
panelGap &&
|
||||
reviewRegion.getBoundingClientRect().height > 1 &&
|
||||
terminalRegion.getBoundingClientRect().height > 1
|
||||
)
|
||||
if (panelGap && terminalRegion.getBoundingClientRect().height > 1)
|
||||
probe.panelGaps.push(panelGap.getBoundingClientRect().height)
|
||||
if (!review) return
|
||||
probe.paintGaps.push({
|
||||
|
||||
@@ -33,7 +33,7 @@ const processes: Array<ReturnType<typeof Bun.spawn>> = []
|
||||
const errors: Array<Promise<string>> = []
|
||||
let failure: unknown
|
||||
try {
|
||||
await fs.mkdir(path.join(root, ".opencode", "plugins"), { recursive: true })
|
||||
await fs.mkdir(path.join(root, ".opencode"))
|
||||
spawnService()
|
||||
spawnService()
|
||||
const registration = await waitForRegistration()
|
||||
@@ -54,6 +54,7 @@ try {
|
||||
if (tokenOpenApi.status !== 200) throw new Error("Compiled application rejected query authentication")
|
||||
if ((await pluginIDs(info.url, headers)).includes("smoke")) throw new Error("Smoke plugin existed before creation")
|
||||
const plugin = path.join(root, ".opencode", "plugins", "smoke.ts")
|
||||
await fs.mkdir(path.dirname(plugin), { recursive: true })
|
||||
await fs.writeFile(plugin, pluginSource())
|
||||
await waitForPlugin(info.url, headers)
|
||||
|
||||
|
||||
@@ -1342,7 +1342,6 @@ export type ModelCompatibility = {
|
||||
maxTokensField?: ModelMaxTokensField
|
||||
requireFinishReason?: boolean
|
||||
requireAssistantAfterTool?: boolean
|
||||
supportsPromptCacheKey?: boolean
|
||||
}
|
||||
|
||||
export type ProviderInfo = {
|
||||
@@ -2012,7 +2011,6 @@ export type ConfigEntry =
|
||||
scope?: string
|
||||
callback_port?: number
|
||||
redirect_uri?: string
|
||||
auth_server_metadata_url?: string
|
||||
}
|
||||
| false
|
||||
disabled?: boolean
|
||||
@@ -4668,7 +4666,6 @@ export type McpAddInput = {
|
||||
readonly scope?: string
|
||||
readonly callback_port?: number
|
||||
readonly redirect_uri?: string
|
||||
readonly auth_server_metadata_url?: string
|
||||
}
|
||||
| false
|
||||
readonly disabled?: boolean
|
||||
|
||||
@@ -22,7 +22,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] The host boundary is `JSON.stringify` plus a short table. The program result and tool arguments cross as
|
||||
what `JSON.stringify` would serialize: `toJSON` is honored, functions and `undefined` properties vanish,
|
||||
`undefined` array elements and non-finite numbers become `null`, a cyclic value throws the same `TypeError`,
|
||||
and Map, RegExp, and generators serialize as `{}`. A bare `undefined` result is `null`.
|
||||
and Map, RegExp, generators, and extension handles serialize as `{}`. A bare `undefined` result is `null`.
|
||||
Tool results come back the way `JSON.parse(JSON.stringify(result))` would. The table, where a value cannot
|
||||
be JSON but what the program meant is clear: a promise is awaited (a rejection fails the program), a Set
|
||||
crosses as an array, a URLSearchParams as its query string, an Error as `{ name, message, ...own }`, a
|
||||
@@ -192,7 +192,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] Prefix and postfix `++` and `--`.
|
||||
- [x] Plain, arithmetic, bitwise, and logical assignment operators.
|
||||
- [x] Property deletion on plain data objects and arrays, including computed and optional forms; deleting an array index
|
||||
creates a hole without changing its length. Deleting a non-configurable property (`length`) or
|
||||
creates a hole without changing its length. Deleting a non-configurable property (`length`, `lastIndex`) or
|
||||
assigning a read-only one (`Math.PI`, `fn.name`) throws a `TypeError`, as in strict mode.
|
||||
- [ ] Operators, `switch` discriminants, template interpolation, and coercion helpers such as `String` and `isNaN`
|
||||
applied to functions and namespaces; JavaScript coerces them, the interpreter rejects non-data operands.
|
||||
@@ -394,9 +394,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
`unicodeSets`, and `dotAll`.
|
||||
- [x] Captures, named groups, match `.index` and `.input`, and stateful global matching.
|
||||
- [x] Integration with supported String methods, including function replacers.
|
||||
- [x] Writable `lastIndex`, shared by `exec`, `test`, and the String methods. It is a prototype accessor that stores
|
||||
a number, so `re.lastIndex = "12"` reads back `12`, `delete` is a no-op, and `hasOwnProperty("lastIndex")` is
|
||||
`false`.
|
||||
- [x] Writable `lastIndex`.
|
||||
- [x] Match `indices` metadata for the `d` flag, including named groups on `exec`, `match`, and `matchAll` results.
|
||||
- [x] `RegExp.escape`.
|
||||
|
||||
@@ -454,25 +452,29 @@ with a hint to encode as text first (`TextDecoder`, `toBase64`, `toHex`).
|
||||
|
||||
## Extensions
|
||||
|
||||
Host functions a host opts in through `Extension.make({ name, globals })` and `CodeMode.make({ extensions })`.
|
||||
Host classes and functions a host opts in through `Extension.make({ name, globals })` and `CodeMode.make({ extensions })`.
|
||||
Nothing is exposed unless a host provides it; extension calls are not tool calls.
|
||||
|
||||
- [x] Each global is a function, callable but not constructible, run with `this` undefined. A global that shadows
|
||||
a built-in or another extension throws at `make`.
|
||||
- [x] Each global is a class or a function, exposed as-is: constructors with `new`, prototype methods, accessors,
|
||||
and statics (including through an exposed subclass, so `new this()` works), plus inheritance
|
||||
up to the nearest exposed ancestor. A global that shadows a built-in or another extension throws at `make`.
|
||||
- [x] Instances of exposed classes stay on the host; the program holds a handle whose only members are the class's.
|
||||
The same host instance is always the same handle within a run, so identity and `instanceof` hold. A handle
|
||||
serializes as `{}` like any object without enumerable properties, so the host object never crosses.
|
||||
- [x] Every value crossing in either direction is converted, never shared: plain objects and arrays are copied,
|
||||
`Date`, `RegExp`, `URL`, `URLSearchParams`, `Map`, `Set`, and `Uint8Array` become fresh copies with their
|
||||
contents converted (a host `ArrayBuffer` comes in as a `Uint8Array`; other typed arrays cannot come out),
|
||||
errors cross as errors with their name and message, and a `__proto__` key is dropped. Functions, generators,
|
||||
un-awaited promises, and symbols cannot be passed in; a class instance, a symbol, or a BigInt cannot come out.
|
||||
- [x] A host function inside a result becomes a program function whose calls cross the same way, so a result can
|
||||
carry methods (`res.json()`) whose host closures keep the host state. Diagnostics name it by its path
|
||||
(`fetch.json`). Like any program function it vanishes at the data boundary.
|
||||
un-awaited promises, and symbols cannot be passed in; an instance of an unexposed class, a symbol, or a BigInt
|
||||
cannot come out.
|
||||
- [x] A host `Promise` becomes a program promise. Whatever host code returns, resolves, throws, or rejects with
|
||||
crosses the same way, so `catch (e)` receives a copy of the thrown value (an `Error` of the matching type, or
|
||||
plain data).
|
||||
plain data). A getter must be synchronous.
|
||||
- [x] A prototype member runs only with a handle of its own class as `this`; a detached call, a plain object, or a
|
||||
handle of another class throws `TypeError: Illegal invocation`. Program edits to an exposed prototype affect
|
||||
that run only. Data properties on a class or prototype are not exposed, since a program write would change the
|
||||
host class itself; expose one through an accessor.
|
||||
- [ ] Program functions as arguments to extension code (callbacks such as `forEach`).
|
||||
- [ ] Host classes. Stateful host objects are expressed as closures; a declared method table would be the next
|
||||
step if `new X()` in a program is ever needed.
|
||||
|
||||
## Errors and diagnostics
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ export type ResolvedExecutionLimits = {
|
||||
export type Options<Provided extends Record<string, unknown> = {}> = ToolRuntime.ToolCallHooks<Services<Provided>> & {
|
||||
/** Explicit tools exposed to the program as `tools`. */
|
||||
tools?: Provided & Tools<Services<Provided>>
|
||||
/** Host functions exposed as globals; see `Extension.make`. */
|
||||
/** Host classes and functions exposed as globals; see `Extension.make`. */
|
||||
extensions?: ReadonlyArray<Extension>
|
||||
/** Resource limits enforced on each execution. */
|
||||
limits?: ExecutionLimits
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
export * as Extension from "./extension.js"
|
||||
|
||||
/**
|
||||
* Host functions a program calls directly as globals. Values crossing in either direction are converted, never
|
||||
* shared: arguments come in as copies, results go out as copies, and a function inside a result is callable the
|
||||
* same way. Extension calls are not tool calls.
|
||||
* Host classes and functions a program uses directly, like JavaScript. Values crossing in either direction are
|
||||
* converted, never shared: plain data is copied, instances of the classes stay on the host behind program-side
|
||||
* handles. Extension calls are not tool calls.
|
||||
*/
|
||||
export type Extension = {
|
||||
readonly name: string
|
||||
/** Each value is a class or a function; everything on a class is exposed, including statics and accessors. */
|
||||
readonly globals: Readonly<Record<string, Function>>
|
||||
}
|
||||
|
||||
export const make = (options: Extension): Extension => {
|
||||
for (const [name, value] of Object.entries(options.globals)) {
|
||||
if (typeof value !== "function") {
|
||||
throw new TypeError(`Extension "${options.name}" global "${name}" must be a function.`)
|
||||
throw new TypeError(`Extension "${options.name}" global "${name}" must be a class or a function.`)
|
||||
}
|
||||
}
|
||||
return { name: options.name, globals: { ...options.globals } }
|
||||
|
||||
@@ -5,18 +5,21 @@ import type { Interpreter } from "./interpreter.js"
|
||||
import { createErrorValue, isErrorType } from "./intrinsics.js"
|
||||
import { MAX_VALUE_DEPTH } from "./limits.js"
|
||||
import { Throw, typeError } from "./model.js"
|
||||
import { fn } from "./native.js"
|
||||
import { constructor, fn } from "./native.js"
|
||||
import {
|
||||
Callable,
|
||||
define,
|
||||
defineAccessor,
|
||||
entries,
|
||||
get,
|
||||
hidden,
|
||||
type Native,
|
||||
Arr,
|
||||
Bytes,
|
||||
DateObj,
|
||||
ErrorObj,
|
||||
GeneratorObj,
|
||||
Handle,
|
||||
MapObj,
|
||||
Obj,
|
||||
PromiseObj,
|
||||
@@ -27,16 +30,31 @@ import {
|
||||
} from "./objects.js"
|
||||
import { describeValue } from "./references.js"
|
||||
|
||||
type Class = Function & { readonly prototype: object }
|
||||
|
||||
const isClass = (value: unknown): value is Class =>
|
||||
typeof value === "function" && typeof value.prototype === "object" && value.prototype !== null
|
||||
|
||||
// Own keys the native function already carries.
|
||||
const ownFunctionKeys = new Set(["length", "name", "prototype"])
|
||||
const ownPrototypeKeys = new Set(["constructor"])
|
||||
|
||||
/**
|
||||
* The global bindings of one run's extensions. Everything crossing the boundary is converted: plain data and
|
||||
* built-in wrappers are copied, a host function becomes a program function whose calls cross the same way, and a
|
||||
* host Promise becomes a program promise.
|
||||
* The global bindings of one run's extensions. Everything crossing the boundary is converted: plain data is
|
||||
* copied, built-in wrappers are copied, instances of exposed classes travel as handles, and a host Promise becomes
|
||||
* a program promise. Prototypes, constructors, and handle identity are all per run.
|
||||
*/
|
||||
export const extensionGlobals = <R>(
|
||||
ctx: Interpreter<R>,
|
||||
extensions: ReadonlyArray<Extension>,
|
||||
): ReadonlyArray<readonly [string, unknown]> => {
|
||||
const builtins = ctx.builtins
|
||||
const classes = new Set(extensions.flatMap((extension) => Object.values(extension.globals)).filter(isClass))
|
||||
// Host prototype object → this run's program prototype, so an instance wraps as its most-derived exposed class.
|
||||
const protoOf = new Map<object, Obj>()
|
||||
const exposed = new Map<Class, { ctor: Native<R>; proto: Obj }>()
|
||||
const classOf = new Map<unknown, Class>()
|
||||
const handles = new WeakMap<object, Handle>()
|
||||
|
||||
const toHost = (value: unknown, label: string, depth = 0, seen = new Set<object>()): unknown => {
|
||||
if (depth > MAX_VALUE_DEPTH) throw typeError(`${label} exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`)
|
||||
@@ -44,6 +62,7 @@ export const extensionGlobals = <R>(
|
||||
if (isPrimitive(value)) return value
|
||||
throw typeError(`${label} contains ${describeValue(value)}, which cannot be passed to an extension.`)
|
||||
}
|
||||
if (value instanceof Handle) return value.instance
|
||||
if (value instanceof Bytes) return new Uint8Array(value.bytes)
|
||||
if (value instanceof DateObj) return new Date(value.time)
|
||||
if (value instanceof RegExpObj) return new RegExp(value.regex.source, value.regex.flags)
|
||||
@@ -64,7 +83,7 @@ export const extensionGlobals = <R>(
|
||||
const name = coerceToString(get(value, "name"))
|
||||
const message = get(value, "message")
|
||||
const text = message === undefined ? "" : coerceToString(message)
|
||||
return name === "AggregateError" ? new AggregateError([], text) : new (hostErrors.get(name) ?? Error)(text)
|
||||
return name === "AggregateError" ? new AggregateError([], text) : new (hostErrors[name] ?? Error)(text)
|
||||
}
|
||||
if (seen.has(value)) throw typeError(`${label} contains a circular value.`)
|
||||
seen.add(value)
|
||||
@@ -83,8 +102,15 @@ export const extensionGlobals = <R>(
|
||||
const fromHost = (value: unknown, label: string, depth = 0, seen = new Set<object>()): unknown => {
|
||||
if (depth > MAX_VALUE_DEPTH) throw typeError(`${label} exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`)
|
||||
if (isPrimitive(value)) return value
|
||||
if (typeof value === "function") return wrap(value, label)
|
||||
if (value !== null && typeof value === "object") {
|
||||
const existing = handles.get(value)
|
||||
if (existing !== undefined) return existing
|
||||
const proto = handlePrototype(value)
|
||||
if (proto !== undefined) {
|
||||
const handle = new Handle(proto, value)
|
||||
handles.set(value, handle)
|
||||
return handle
|
||||
}
|
||||
if (value instanceof Date) return new DateObj(builtins.Date, value.getTime())
|
||||
if (value instanceof RegExp) return new RegExpObj(builtins.RegExp, value.source, value.flags)
|
||||
if (value instanceof Uint8Array) return new Bytes(builtins.Uint8Array, new Uint8Array(value))
|
||||
@@ -96,31 +122,28 @@ export const extensionGlobals = <R>(
|
||||
if (value instanceof URLSearchParams) {
|
||||
return new URLSearchParamsObj(builtins.URLSearchParams, new URLSearchParams(value))
|
||||
}
|
||||
const next = (item: unknown, path: string) => fromHost(item, path, depth + 1, seen)
|
||||
const next = (item: unknown) => fromHost(item, label, depth + 1, seen)
|
||||
if (value instanceof Map) {
|
||||
const wrapped = new MapObj(builtins.Map)
|
||||
for (const [key, item] of value) wrapped.map.set(next(key, label), next(item, label))
|
||||
for (const [key, item] of value) wrapped.map.set(next(key), next(item))
|
||||
return wrapped
|
||||
}
|
||||
if (value instanceof Set) {
|
||||
const wrapped = new SetObj(builtins.Set)
|
||||
for (const item of value) wrapped.set.add(next(item, label))
|
||||
for (const item of value) wrapped.set.add(next(item))
|
||||
return wrapped
|
||||
}
|
||||
if (seen.has(value)) throw typeError(`${label} produced a circular value.`)
|
||||
seen.add(value)
|
||||
if (Array.isArray(value)) {
|
||||
const copied = new Arr(
|
||||
builtins.Array,
|
||||
value.map((item, index) => next(item, `${label}[${index}]`)),
|
||||
)
|
||||
const copied = new Arr(builtins.Array, value.map(next))
|
||||
seen.delete(value)
|
||||
return copied
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(value)
|
||||
if (prototype === Object.prototype || prototype === null) {
|
||||
const copied = new Obj(builtins.Object)
|
||||
for (const [key, item] of Object.entries(value)) define(copied, key, next(item, `${label}.${key}`))
|
||||
for (const [key, item] of Object.entries(value)) define(copied, key, next(item))
|
||||
seen.delete(value)
|
||||
return copied
|
||||
}
|
||||
@@ -128,37 +151,153 @@ export const extensionGlobals = <R>(
|
||||
throw typeError(`${label} produced ${describeHost(value)}, which the program cannot hold.`)
|
||||
}
|
||||
|
||||
// A host function as a program function: arguments cross in, and whatever it returns, resolves, throws, or
|
||||
// rejects with crosses out, so the program catches what the author threw.
|
||||
const wrap = (value: Function, label: string): Native<R> =>
|
||||
fn<R>(builtins, value.name, value.length, (_, values) => {
|
||||
const converted = values.map((item, index) => toHost(item, `Argument ${index + 1} to ${label}`))
|
||||
const thrown = (reason: unknown) => new Throw(fromHost(reason, label))
|
||||
let result: unknown
|
||||
try {
|
||||
result = value.apply(undefined, converted)
|
||||
} catch (reason) {
|
||||
return Effect.fail(thrown(reason))
|
||||
const handlePrototype = (instance: object): Obj | undefined => {
|
||||
for (let level = Object.getPrototypeOf(instance); level !== null; level = Object.getPrototypeOf(level)) {
|
||||
const proto = protoOf.get(level)
|
||||
if (proto !== undefined) return proto
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Runs host code with already-converted inputs. Whatever it returns, resolves, throws, or rejects with crosses
|
||||
// the same way, so the program catches what the author threw.
|
||||
const invoke = (run: () => unknown, label: string): Effect.Effect<unknown, unknown, R> => {
|
||||
const thrown = (reason: unknown) => new Throw(fromHost(reason, label))
|
||||
let result: unknown
|
||||
try {
|
||||
result = run()
|
||||
} catch (reason) {
|
||||
return Effect.fail(thrown(reason))
|
||||
}
|
||||
if (!(result instanceof Promise)) return Effect.succeed(fromHost(result, label))
|
||||
return ctx.pending.create(
|
||||
Effect.map(Effect.tryPromise({ try: () => result, catch: thrown }), (settled) => fromHost(settled, label)),
|
||||
)
|
||||
}
|
||||
|
||||
const args = (values: Array<unknown>, label: string): Array<unknown> =>
|
||||
values.map((value, index) => toHost(value, `Argument ${index + 1} to ${label}`))
|
||||
|
||||
// Own members of each level from `from` up to (excluding) `root`, child first, as JS resolves them.
|
||||
const members = (
|
||||
target: Obj,
|
||||
from: object,
|
||||
root: object,
|
||||
skip: ReadonlySet<string>,
|
||||
label: string,
|
||||
receiver: (thisValue: unknown, member: string) => unknown,
|
||||
): void => {
|
||||
for (let level: object | null = from; level !== null && level !== root; level = Object.getPrototypeOf(level)) {
|
||||
for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(level))) {
|
||||
if (skip.has(key) || target.props.has(key)) continue
|
||||
const name = `${label}.${key}`
|
||||
if (typeof descriptor.value === "function") {
|
||||
const method: Function = descriptor.value
|
||||
const impl = (thisValue: unknown, values: Array<unknown>) => {
|
||||
const self = receiver(thisValue, name)
|
||||
const converted = args(values, name)
|
||||
return invoke(() => method.apply(self, converted), name)
|
||||
}
|
||||
define(target, key, fn<R>(builtins, key, method.length, impl), hidden)
|
||||
continue
|
||||
}
|
||||
// Data properties stay host-side: a program write to one would change the host class itself.
|
||||
if ("value" in descriptor) continue
|
||||
const get = descriptor.get
|
||||
const set = descriptor.set
|
||||
defineAccessor(
|
||||
target,
|
||||
key,
|
||||
get === undefined
|
||||
? undefined
|
||||
: (thisValue) => {
|
||||
const value = get.call(receiver(thisValue, name))
|
||||
if (value instanceof Promise)
|
||||
throw typeError(`${name} returned a Promise; a getter must be synchronous.`)
|
||||
return fromHost(value, name)
|
||||
},
|
||||
set === undefined
|
||||
? undefined
|
||||
: (thisValue, value) => {
|
||||
set.call(receiver(thisValue, name), toHost(value, `${name} value`))
|
||||
},
|
||||
)
|
||||
}
|
||||
if (!(result instanceof Promise)) return fromHost(result, label)
|
||||
return ctx.pending.create(
|
||||
Effect.map(Effect.tryPromise({ try: () => result, catch: thrown }), (settled) => fromHost(settled, label)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const expose = (cls: Class): { ctor: Native<R>; proto: Obj } => {
|
||||
const existing = exposed.get(cls)
|
||||
if (existing !== undefined) return existing
|
||||
const ancestor = exposedAncestor(cls)
|
||||
const base = ancestor === undefined ? undefined : expose(ancestor)
|
||||
const proto = new Obj(base === undefined ? builtins.Object : base.proto)
|
||||
protoOf.set(cls.prototype, proto)
|
||||
const name = cls.name
|
||||
const ctor = constructor<R>(builtins, proto, {
|
||||
name,
|
||||
length: cls.length,
|
||||
call: (_, values) => {
|
||||
const converted = args(values, name)
|
||||
return invoke(() => cls.apply(undefined, converted), name)
|
||||
},
|
||||
construct: (values) => {
|
||||
const label = `new ${name}`
|
||||
const construct = cls as new (...values: Array<unknown>) => object
|
||||
const converted = args(values, label)
|
||||
return invoke(() => new construct(...converted), label)
|
||||
},
|
||||
})
|
||||
if (base !== undefined) ctor.proto = base.ctor
|
||||
const entry = { ctor, proto }
|
||||
exposed.set(cls, entry)
|
||||
classOf.set(ctor, cls)
|
||||
// A static called through an exposed subclass sees that subclass as `this`, like JS.
|
||||
members(ctor, cls, ancestor ?? Function.prototype, ownFunctionKeys, name, (thisValue) => {
|
||||
const called = classOf.get(thisValue)
|
||||
return called !== undefined && (called === cls || called.prototype instanceof cls) ? called : cls
|
||||
})
|
||||
members(
|
||||
proto,
|
||||
cls.prototype,
|
||||
ancestor?.prototype ?? Object.prototype,
|
||||
ownPrototypeKeys,
|
||||
`${name}.prototype`,
|
||||
(thisValue, member) => {
|
||||
if (thisValue instanceof Handle && thisValue.instance instanceof cls) return thisValue.instance
|
||||
throw typeError(`Illegal invocation: ${member} called on ${describeValue(thisValue)}.`)
|
||||
},
|
||||
)
|
||||
return entry
|
||||
}
|
||||
|
||||
const exposedAncestor = (cls: Class): Class | undefined => {
|
||||
for (let level = Object.getPrototypeOf(cls); isClass(level); level = Object.getPrototypeOf(level)) {
|
||||
if (classes.has(level)) return level
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
return extensions.flatMap((extension) =>
|
||||
Object.entries(extension.globals).map(([name, value]) => [name, wrap(value, name)] as const),
|
||||
Object.entries(extension.globals).map(([name, value]) => {
|
||||
if (isClass(value)) return [name, expose(value).ctor] as const
|
||||
const impl = (_: unknown, values: Array<unknown>) => {
|
||||
const converted = args(values, name)
|
||||
return invoke(() => value.apply(undefined, converted), name)
|
||||
}
|
||||
return [name, fn<R>(builtins, name, value.length, impl)] as const
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const hostErrors = new Map<string, ErrorConstructor>([
|
||||
["TypeError", TypeError],
|
||||
["RangeError", RangeError],
|
||||
["SyntaxError", SyntaxError],
|
||||
["ReferenceError", ReferenceError],
|
||||
["EvalError", EvalError],
|
||||
["URIError", URIError],
|
||||
])
|
||||
const hostErrors: Record<string, ErrorConstructor | undefined> = {
|
||||
TypeError,
|
||||
RangeError,
|
||||
SyntaxError,
|
||||
ReferenceError,
|
||||
EvalError,
|
||||
URIError,
|
||||
}
|
||||
|
||||
// The primitives the interpreter operates on; symbols and BigInts are not among them.
|
||||
const isPrimitive = (value: unknown): boolean =>
|
||||
@@ -169,6 +308,7 @@ const isPrimitive = (value: unknown): boolean =>
|
||||
typeof value === "boolean"
|
||||
|
||||
const describeHost = (value: unknown): string => {
|
||||
if (typeof value === "function") return "a function"
|
||||
if (typeof value !== "object" || value === null) return `a ${typeof value}`
|
||||
const name = (value as { constructor?: { name?: string } }).constructor?.name
|
||||
return name === undefined || name === "" ? "an object" : `a ${name}`
|
||||
|
||||
@@ -136,6 +136,7 @@ export class RegExpObj extends Obj {
|
||||
constructor(proto: Obj, pattern: string, flags: string) {
|
||||
super(proto)
|
||||
this.regex = new RegExp(pattern, flags)
|
||||
define(this, "lastIndex", 0, { writable: true, enumerable: false, configurable: false })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +179,16 @@ export class Bytes extends Obj {
|
||||
}
|
||||
}
|
||||
|
||||
/** An instance of an extension class: the host object lives in a field no property path reaches. */
|
||||
export class Handle extends Obj {
|
||||
constructor(
|
||||
proto: Obj,
|
||||
readonly instance: object,
|
||||
) {
|
||||
super(proto)
|
||||
}
|
||||
}
|
||||
|
||||
/** Built-in objects that wrap a host value; data-like, but never plain data. */
|
||||
export const isWrapper = (
|
||||
value: unknown,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Bytes,
|
||||
DateObj,
|
||||
GeneratorObj,
|
||||
Handle,
|
||||
MapObj,
|
||||
Obj,
|
||||
PromiseObj,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
export const isRuntimeReference = (value: unknown): boolean =>
|
||||
value instanceof Callable ||
|
||||
value instanceof GeneratorObj ||
|
||||
value instanceof Handle ||
|
||||
value instanceof ToolReference ||
|
||||
value instanceof PromiseObj ||
|
||||
isWrapper(value)
|
||||
@@ -87,6 +89,7 @@ export const describeValue = (value: unknown): string => {
|
||||
if (value instanceof URLSearchParamsObj) return "a URLSearchParams"
|
||||
if (value instanceof Bytes) return "a Uint8Array"
|
||||
if (value instanceof GeneratorObj) return "a generator"
|
||||
if (value instanceof Handle) return `a ${value.instance.constructor.name}`
|
||||
if (isRuntimeReference(value)) return "a function"
|
||||
if (typeof value === "object") return "a data object"
|
||||
return `a ${typeof value}`
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Effect } from "effect"
|
||||
import type { Builtins } from "../interpreter/intrinsics.js"
|
||||
import { constructor, type Method, methods, prototypeFrom, receiver } from "../interpreter/native.js"
|
||||
import { syntaxError, typeError } from "../interpreter/model.js"
|
||||
import { define, defineAccessor, Arr, Obj, RegExpObj, record } from "../interpreter/objects.js"
|
||||
import { define, defineAccessor, getOwn, Arr, Obj, RegExpObj, record, set } from "../interpreter/objects.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
import { coerceToNumber, coerceToString } from "./value.js"
|
||||
|
||||
@@ -75,6 +75,12 @@ export const constructRegExp = (builtins: Builtins, args: Array<unknown>, proto:
|
||||
}
|
||||
}
|
||||
|
||||
const toLength = (value: unknown): number => {
|
||||
const number = coerceToNumber(value)
|
||||
if (Number.isNaN(number) || number <= 0) return 0
|
||||
return Math.min(Math.floor(number), Number.MAX_SAFE_INTEGER)
|
||||
}
|
||||
|
||||
// RegExp constructs identically with or without new, like JS.
|
||||
export const regexpGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
const builtins = ctx.builtins
|
||||
@@ -99,22 +105,18 @@ export const regexpGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
const self = (thisValue: unknown, name: string) => receiver(RegExpObj, thisValue, `RegExp.prototype.${name}`)
|
||||
defineAccessor(proto, "source", (thisValue) => self(thisValue, "source").regex.source)
|
||||
defineAccessor(proto, "flags", (thisValue) => self(thisValue, "flags").regex.flags)
|
||||
// The host regex holds the only lastIndex, so exec/test and the String methods share one counter.
|
||||
defineAccessor(
|
||||
proto,
|
||||
"lastIndex",
|
||||
(thisValue) => self(thisValue, "lastIndex").regex.lastIndex,
|
||||
(thisValue, value) => {
|
||||
self(thisValue, "lastIndex").regex.lastIndex = coerceToNumber(value)
|
||||
},
|
||||
)
|
||||
for (const name of flagProperties) defineAccessor(proto, name, (thisValue) => self(thisValue, name).regex[name])
|
||||
// exec/test run the host regex from the program-visible lastIndex and write it back only when g or y is set.
|
||||
const run = (name: "exec" | "test"): Method => [
|
||||
name,
|
||||
1,
|
||||
(thisValue, args) => {
|
||||
const value = self(thisValue, name)
|
||||
const matched = value.regex.exec(coerceToString(args[0]))
|
||||
const input = coerceToString(args[0])
|
||||
const stateful = value.regex.global || value.regex.sticky
|
||||
value.regex.lastIndex = toLength(getOwn(value, "lastIndex"))
|
||||
const matched = value.regex.exec(input)
|
||||
if (stateful) set(value, "lastIndex", value.regex.lastIndex)
|
||||
if (name === "test") return matched !== null
|
||||
return matched === null ? null : matchToValue(builtins, matched)
|
||||
},
|
||||
|
||||
@@ -2,12 +2,67 @@ import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { CodeMode, Extension, Tool } from "../src/index.js"
|
||||
|
||||
class Bag {
|
||||
static made = 0
|
||||
static of(...items: Array<string>) {
|
||||
return new this(items)
|
||||
}
|
||||
constructor(readonly items: Array<string> = []) {
|
||||
Bag.made++
|
||||
}
|
||||
get size() {
|
||||
return this.items.length
|
||||
}
|
||||
set size(length: number) {
|
||||
this.items.length = length
|
||||
}
|
||||
add(item: string) {
|
||||
this.items.push(item)
|
||||
return this
|
||||
}
|
||||
toArray() {
|
||||
return [...this.items]
|
||||
}
|
||||
pair() {
|
||||
return { self: this, list: [this, new Bag()] }
|
||||
}
|
||||
async later<T>(value: T) {
|
||||
return value
|
||||
}
|
||||
async reject(reason: unknown) {
|
||||
throw reason
|
||||
}
|
||||
fail() {
|
||||
throw new RangeError("boom")
|
||||
}
|
||||
get lazy() {
|
||||
return Promise.resolve(1)
|
||||
}
|
||||
detached() {
|
||||
return new Other()
|
||||
}
|
||||
}
|
||||
class Other {}
|
||||
class Big extends Bag {
|
||||
double() {
|
||||
return this.items.length * 2
|
||||
}
|
||||
}
|
||||
class Vault {
|
||||
secrets = new Map<string, string>()
|
||||
set(key: string, value: string) {
|
||||
this.secrets.set(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
const held: Array<unknown> = []
|
||||
const config = { retries: 3, nested: { deep: true } }
|
||||
const requests: Array<unknown> = []
|
||||
const extension = Extension.make({
|
||||
name: "web",
|
||||
name: "bag",
|
||||
globals: {
|
||||
Bag,
|
||||
Big,
|
||||
Vault,
|
||||
keep: (value: unknown) => {
|
||||
held.push(value)
|
||||
return value
|
||||
@@ -15,19 +70,6 @@ const extension = Extension.make({
|
||||
settings: () => config,
|
||||
later: async (value: number) => value + 1,
|
||||
first: (map: Map<unknown, unknown>) => map.get("k"),
|
||||
fetch: async (url: string, init?: { method?: string }) => {
|
||||
requests.push([url, init])
|
||||
const bytes = new TextEncoder().encode(`{"url":"${url}"}`)
|
||||
return {
|
||||
status: 200,
|
||||
ok: true,
|
||||
headers: { get: (name: string) => (name === "content-type" ? "application/json" : null) },
|
||||
text: () => new TextDecoder().decode(bytes),
|
||||
json: () => JSON.parse(new TextDecoder().decode(bytes)),
|
||||
bytes: () => bytes,
|
||||
handlers: [(step: number) => step + 1],
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -45,61 +87,66 @@ const failure = async (code: string, target = runtime) => {
|
||||
return result.error
|
||||
}
|
||||
|
||||
describe("extension functions", () => {
|
||||
test("a global is callable, awaitable, and not constructible", async () => {
|
||||
describe("extension classes behave like JS", () => {
|
||||
test("construct, call methods, read and write accessors", async () => {
|
||||
expect(await value(`const b = new Bag(["a"]); b.add("b"); return [b.size, b.toArray()]`)).toEqual([2, ["a", "b"]])
|
||||
expect(await value(`const b = new Bag(["a", "b"]); b.size = 1; return b.toArray()`)).toEqual(["a"])
|
||||
})
|
||||
|
||||
test("instanceof, constructor, typeof, and prototype identity", async () => {
|
||||
expect(
|
||||
await value(
|
||||
`const b = new Bag(); return [b instanceof Bag, b.constructor === Bag, typeof Bag, Bag.prototype.constructor === Bag]`,
|
||||
),
|
||||
).toEqual([true, true, "function", true])
|
||||
})
|
||||
|
||||
test("statics, including `new this()` through an exposed subclass", async () => {
|
||||
expect(await value(`return [Bag.of("x", "y").toArray(), Big.of("q") instanceof Big, Big.of === Bag.of]`)).toEqual([
|
||||
["x", "y"],
|
||||
true,
|
||||
true,
|
||||
])
|
||||
})
|
||||
|
||||
test("data properties are invisible, so a program write never reaches the host class", async () => {
|
||||
Bag.made = 0
|
||||
expect(await value(`Bag.made = 999; return Bag.made`)).toBe(999)
|
||||
expect(Bag.made).toBe(0)
|
||||
expect(await value(`return [Bag.made, new Bag(["a"]).items]`)).toEqual([null, null])
|
||||
})
|
||||
|
||||
test("inheritance chains to the exposed ancestor", async () => {
|
||||
expect(
|
||||
await value(`const b = new Big(["a"]); return [b.double(), b.add("b").size, b instanceof Bag, b instanceof Big]`),
|
||||
).toEqual([2, 2, true, true])
|
||||
})
|
||||
|
||||
test("calling a class without new throws the host TypeError", async () => {
|
||||
const error = await failure(`Bag()`)
|
||||
expect(error.message).toStartWith("TypeError: ")
|
||||
expect(error.message).toContain("new")
|
||||
})
|
||||
|
||||
test("a function global is callable, awaitable, and not constructible", async () => {
|
||||
expect(await value(`return await later(1)`)).toBe(2)
|
||||
expect(await value(`return [typeof later, later.name, later.length]`)).toEqual(["function", "later", 1])
|
||||
expect((await failure(`new later()`)).message).toContain("new later(...) is not supported")
|
||||
})
|
||||
|
||||
test("a function inside a result is callable and crosses the same way", async () => {
|
||||
requests.length = 0
|
||||
expect(
|
||||
await value(
|
||||
`const res = await fetch("https://a.test/", { method: "GET" }); return [res.status, res.ok, res.headers.get("content-type"), res.text(), res.json(), [...res.bytes()].length, typeof res.json, res.json.name, res.handlers[0](1)]`,
|
||||
),
|
||||
).toEqual([
|
||||
200,
|
||||
true,
|
||||
"application/json",
|
||||
'{"url":"https://a.test/"}',
|
||||
{ url: "https://a.test/" },
|
||||
25,
|
||||
"function",
|
||||
"json",
|
||||
2,
|
||||
])
|
||||
expect(requests).toEqual([["https://a.test/", { method: "GET" }]])
|
||||
})
|
||||
|
||||
test("a function inside a result is named by its path in diagnostics", async () => {
|
||||
expect((await failure(`const res = await fetch("https://a.test/"); res.handlers[0](() => 1)`)).message).toContain(
|
||||
"Argument 1 to fetch.handlers[0] contains a function",
|
||||
)
|
||||
const target = CodeMode.make({
|
||||
extensions: [Extension.make({ name: "odd", globals: { make: () => ({ sym: () => Symbol("s") }) } })],
|
||||
})
|
||||
expect((await failure(`make().sym()`, target)).message).toContain("make.sym produced a symbol")
|
||||
})
|
||||
|
||||
test("a function is invisible to the data boundary like any program function", async () => {
|
||||
expect(await value(`return await fetch("https://a.test/")`)).toEqual({
|
||||
status: 200,
|
||||
ok: true,
|
||||
headers: {},
|
||||
handlers: [null],
|
||||
})
|
||||
expect(await value(`return JSON.stringify((await fetch("https://a.test/")).headers)`)).toBe("{}")
|
||||
})
|
||||
|
||||
test("a class global is only a function; calling it throws the host TypeError", async () => {
|
||||
const target = CodeMode.make({ extensions: [Extension.make({ name: "cls", globals: { Bag: class Bag {} } })] })
|
||||
expect((await failure(`Bag()`, target)).message).toContain("without")
|
||||
expect((await failure(`new Bag()`, target)).message).toContain("new Bag(...) is not supported")
|
||||
test("a program can patch a prototype for its own run only", async () => {
|
||||
expect(await value(`Bag.prototype.add = () => "patched"; return new Bag().add("x")`)).toBe("patched")
|
||||
expect(await value(`return new Bag().add("x").toArray()`)).toEqual(["x"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("values are converted at the boundary, never shared", () => {
|
||||
test("the same host instance is the same handle", async () => {
|
||||
expect(
|
||||
await value(`const b = new Bag(); const p = b.pair(); return [p.self === b, p.list[0] === b, keep(b) === b]`),
|
||||
).toEqual([true, true, true])
|
||||
expect(await value(`const p = new Bag().pair(); return p.list[1] instanceof Bag`)).toBe(true)
|
||||
})
|
||||
|
||||
test("plain data passed in is a copy the program cannot change afterwards", async () => {
|
||||
held.length = 0
|
||||
await value(
|
||||
@@ -116,12 +163,8 @@ describe("values are converted at the boundary, never shared", () => {
|
||||
expect(config).toEqual({ retries: 3, nested: { deep: true } })
|
||||
})
|
||||
|
||||
test("the same host value returned twice is two program values", async () => {
|
||||
expect(await value(`return settings() === settings()`)).toBe(false)
|
||||
expect(await value(`return keep(settings()) === settings()`)).toBe(false)
|
||||
})
|
||||
|
||||
test("Map and Set contents are converted element-wise", async () => {
|
||||
test("Map and Set contents are converted element-wise, so handles unwrap inside them", async () => {
|
||||
expect(await value(`const b = new Bag(); return first(new Map([["k", b]])) === b`)).toBe(true)
|
||||
expect(await value(`return first(new Map([["k", { z: 1 }]]))`)).toEqual({ z: 1 })
|
||||
held.length = 0
|
||||
await value(`const inner = { z: 1 }; keep(new Set([inner])); inner.z = 2`)
|
||||
@@ -171,13 +214,6 @@ describe("values are converted at the boundary, never shared", () => {
|
||||
expect(Object.keys(held[0] as object)).toEqual([])
|
||||
})
|
||||
|
||||
test("an Error with an unknown name crosses as a plain Error", async () => {
|
||||
held.length = 0
|
||||
await value(`const e = new Error("x"); e.name = "constructor"; keep(e); e.name = "__proto__"; keep(e)`)
|
||||
expect(held[0]).toBeInstanceOf(Error)
|
||||
expect(held[1]).toBeInstanceOf(Error)
|
||||
})
|
||||
|
||||
test("functions, promises, and symbols cannot be passed in", async () => {
|
||||
expect((await failure(`keep(() => 1)`)).message).toContain("Argument 1 to keep contains a function")
|
||||
expect((await failure(`keep(later(1))`)).message).toContain("un-awaited Promise")
|
||||
@@ -192,45 +228,82 @@ describe("values are converted at the boundary, never shared", () => {
|
||||
expect((await failure(`big()`, target)).message).toContain("big produced a bigint")
|
||||
})
|
||||
|
||||
test("a class instance cannot come out", async () => {
|
||||
class Other {}
|
||||
const target = CodeMode.make({
|
||||
extensions: [Extension.make({ name: "odd", globals: { detached: () => new Other() } })],
|
||||
test("an instance of an unexposed class cannot come out", async () => {
|
||||
expect((await failure(`new Bag().detached()`)).message).toContain("produced a Other, which the program cannot hold")
|
||||
})
|
||||
|
||||
test("a getter must be synchronous", async () => {
|
||||
expect((await failure(`new Bag().lazy`)).message).toContain("Bag.prototype.lazy returned a Promise")
|
||||
})
|
||||
})
|
||||
|
||||
describe("the host object behind a handle is unreachable", () => {
|
||||
test("enumeration, spread, and JSON see no own properties", async () => {
|
||||
expect(
|
||||
await value(`const b = new Bag(["a"]); return [Object.keys(b), Object.entries({ ...b }), String(b)]`),
|
||||
).toEqual([[], [], "[object Object]"])
|
||||
})
|
||||
|
||||
test("a handle serializes as {} when returned, stringified, or handed to a tool", async () => {
|
||||
expect(await value(`return new Bag()`)).toEqual({})
|
||||
expect(await value(`return JSON.stringify(new Bag())`)).toBe("{}")
|
||||
const tools = CodeMode.make({
|
||||
extensions: [extension],
|
||||
tools: {
|
||||
echo: Tool.make({
|
||||
description: "Echo",
|
||||
input: Schema.Struct({ v: Schema.Unknown }),
|
||||
output: Schema.Unknown,
|
||||
execute: (input) => Effect.succeed(input.v),
|
||||
}),
|
||||
},
|
||||
})
|
||||
expect((await failure(`detached()`, target)).message).toContain("produced a Other, which the program cannot hold")
|
||||
expect(await value(`return await tools.echo({ v: new Bag() })`, tools)).toEqual({})
|
||||
})
|
||||
|
||||
test("a method only runs on a handle of its own class", async () => {
|
||||
expect((await failure(`const add = new Bag().add; add("x")`)).message).toContain(
|
||||
"Illegal invocation: Bag.prototype.add called on undefined",
|
||||
)
|
||||
expect((await failure(`const o = { add: Bag.prototype.add }; o.add("x")`)).message).toContain(
|
||||
"called on a data object",
|
||||
)
|
||||
const vault = new Vault()
|
||||
const target = CodeMode.make({
|
||||
extensions: [Extension.make({ name: "vault", globals: { Bag, Vault, vault: () => vault } })],
|
||||
})
|
||||
expect((await failure(`const v = vault(); v.add = Bag.prototype.add; v.add("x")`, target)).message).toContain(
|
||||
"Illegal invocation: Bag.prototype.add called on a Vault",
|
||||
)
|
||||
expect(vault.secrets.size).toBe(0)
|
||||
})
|
||||
|
||||
test("reading an accessor off the prototype itself is an illegal invocation", async () => {
|
||||
expect((await failure(`Bag.prototype.size`)).message).toContain("Illegal invocation")
|
||||
})
|
||||
})
|
||||
|
||||
describe("host errors", () => {
|
||||
test("a synchronous throw becomes the matching program error", async () => {
|
||||
const target = CodeMode.make({
|
||||
extensions: [
|
||||
Extension.make({
|
||||
name: "odd",
|
||||
globals: {
|
||||
fail: () => {
|
||||
throw new RangeError("boom")
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
expect(await value(`try { fail() } catch (e) { return [e instanceof RangeError, e.message] }`, target)).toEqual([
|
||||
expect(await value(`try { new Bag().fail() } catch (e) { return [e instanceof RangeError, e.message] }`)).toEqual([
|
||||
true,
|
||||
"boom",
|
||||
])
|
||||
})
|
||||
|
||||
test("a thrown or rejected value crosses like a return, so the program catches what was thrown", async () => {
|
||||
expect(
|
||||
await value(
|
||||
`try { await new Bag().reject(new TypeError("bad")) } catch (e) { return [e instanceof TypeError, e.message] }`,
|
||||
),
|
||||
).toEqual([true, "bad"])
|
||||
expect(await value(`try { await new Bag().reject("plain") } catch (e) { return e }`)).toBe("plain")
|
||||
const reason = { status: 404, nested: { a: 1 } }
|
||||
const target = CodeMode.make({
|
||||
extensions: [
|
||||
Extension.make({
|
||||
name: "api",
|
||||
globals: {
|
||||
reject: async (reason: unknown) => {
|
||||
throw reason
|
||||
},
|
||||
get: async () => Promise.reject(reason),
|
||||
boom: () => {
|
||||
throw reason
|
||||
@@ -239,13 +312,6 @@ describe("host errors", () => {
|
||||
}),
|
||||
],
|
||||
})
|
||||
expect(
|
||||
await value(
|
||||
`try { await reject(new TypeError("bad")) } catch (e) { return [e instanceof TypeError, e.message] }`,
|
||||
target,
|
||||
),
|
||||
).toEqual([true, "bad"])
|
||||
expect(await value(`try { await reject("plain") } catch (e) { return e }`, target)).toBe("plain")
|
||||
expect(await value(`try { await get() } catch (e) { e.status = 0; return e }`, target)).toEqual({
|
||||
status: 0,
|
||||
nested: { a: 1 },
|
||||
@@ -259,45 +325,23 @@ describe("host errors", () => {
|
||||
describe("configuration", () => {
|
||||
test("extension calls are not tool calls", async () => {
|
||||
const limited = CodeMode.make({ extensions: [extension], limits: { maxToolCalls: 0 } })
|
||||
const result = await Effect.runPromise(
|
||||
limited.execute(`(await fetch("https://a.test/")).json(); return await later(1)`),
|
||||
)
|
||||
const result = await Effect.runPromise(limited.execute(`new Bag().add("x"); return await later(1)`))
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.toolCalls).toEqual([])
|
||||
})
|
||||
|
||||
test("a result handed to a tool is plain data", async () => {
|
||||
const tools = CodeMode.make({
|
||||
extensions: [extension],
|
||||
tools: {
|
||||
echo: Tool.make({
|
||||
description: "Echo",
|
||||
input: Schema.Struct({ v: Schema.Unknown }),
|
||||
output: Schema.Unknown,
|
||||
execute: (input) => Effect.succeed(input.v),
|
||||
}),
|
||||
},
|
||||
})
|
||||
expect(await value(`return await tools.echo({ v: await fetch("https://a.test/") })`, tools)).toEqual({
|
||||
status: 200,
|
||||
ok: true,
|
||||
headers: {},
|
||||
handlers: [null],
|
||||
})
|
||||
})
|
||||
|
||||
test("a global must be a function", () => {
|
||||
test("a global must be a class or a function", () => {
|
||||
expect(() => Extension.make({ name: "bad", globals: { n: 1 as never } })).toThrow(
|
||||
'Extension "bad" global "n" must be a function.',
|
||||
'Extension "bad" global "n" must be a class or a function.',
|
||||
)
|
||||
})
|
||||
|
||||
test("a global may not shadow a built-in or another extension", () => {
|
||||
expect(() => CodeMode.make({ extensions: [Extension.make({ name: "web", globals: { URL: () => 1 } })] })).toThrow(
|
||||
expect(() => CodeMode.make({ extensions: [Extension.make({ name: "web", globals: { URL: class {} } })] })).toThrow(
|
||||
'Extension "web" global "URL" is already defined.',
|
||||
)
|
||||
expect(() =>
|
||||
CodeMode.make({ extensions: [extension, Extension.make({ name: "again", globals: { fetch: () => 1 } })] }),
|
||||
).toThrow('Extension "again" global "fetch" is already defined.')
|
||||
CodeMode.make({ extensions: [extension, Extension.make({ name: "again", globals: { Bag: class {} } })] }),
|
||||
).toThrow('Extension "again" global "Bag" is already defined.')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -237,7 +237,7 @@ describe("RegExp", () => {
|
||||
).toEqual(["1", "22"])
|
||||
})
|
||||
|
||||
test("lastIndex is writable and stores a number", async () => {
|
||||
test("lastIndex is writable and exec coerces its stored value", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const pattern = /(?:ab|cd)\\d?/g
|
||||
@@ -247,112 +247,40 @@ describe("RegExp", () => {
|
||||
pattern.lastIndex = 0
|
||||
return [stored, match[0], match.index, pattern.lastIndex]
|
||||
`),
|
||||
).toEqual([[12, "number"], "ab4", 17, 0])
|
||||
// lastIndex is a prototype accessor, so delete is a no-op rather than a TypeError.
|
||||
expect(await value(`const re = /a/; return [delete re.lastIndex, re.lastIndex]`)).toEqual([true, 0])
|
||||
).toEqual([["12", "string"], "ab4", 17, 0])
|
||||
expect((await error(`delete /a/.lastIndex`)).message).toContain("Cannot delete property 'lastIndex'")
|
||||
})
|
||||
|
||||
test("a non-numeric lastIndex runs from 0; non-global exec and test leave it alone", async () => {
|
||||
test("exec coerces CodeMode data objects assigned to lastIndex", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const pattern = /a/g
|
||||
pattern.lastIndex = {}
|
||||
const stored = pattern.lastIndex
|
||||
const match = pattern.exec("ba")
|
||||
pattern.lastIndex = 10
|
||||
const missed = pattern.exec("a")
|
||||
const plain = /a/
|
||||
plain.lastIndex = 5
|
||||
return [match.index, pattern.lastIndex, missed, plain.exec("ba").index, plain.test("ba"), plain.lastIndex]
|
||||
return [stored, match.index, pattern.lastIndex, missed]
|
||||
`),
|
||||
).toEqual([1, 0, null, 1, true, 5])
|
||||
).toEqual([{}, 1, 0, null])
|
||||
})
|
||||
|
||||
test("String methods read and update lastIndex like exec", async () => {
|
||||
test("non-global exec and test coerce and preserve lastIndex", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const re = /a/y
|
||||
re.exec("aa")
|
||||
re.lastIndex = 0
|
||||
return ["aa".replace(re, "b"), re.lastIndex]
|
||||
`),
|
||||
).toEqual(["ba", 1])
|
||||
expect(
|
||||
await value(`
|
||||
const re = /a/g
|
||||
re.exec("aaa")
|
||||
return ["aaa".match(re), re.lastIndex]
|
||||
`),
|
||||
).toEqual([["a", "a", "a"], 0])
|
||||
expect(
|
||||
await value(`
|
||||
const re = /a/g
|
||||
re.lastIndex = 2
|
||||
return ["aaa".replace(re, () => "b"), re.lastIndex]
|
||||
`),
|
||||
).toEqual(["bbb", 0])
|
||||
expect(
|
||||
await value(`
|
||||
const re = /a/g
|
||||
re.lastIndex = 2
|
||||
return ["aaa".replaceAll(re, "b"), re.lastIndex]
|
||||
`),
|
||||
).toEqual(["bbb", 0])
|
||||
expect(
|
||||
await value(`
|
||||
const re = /a/y
|
||||
re.lastIndex = 1
|
||||
const m = "baa".match(re)
|
||||
return [m.index, re.lastIndex]
|
||||
`),
|
||||
).toEqual([1, 2])
|
||||
})
|
||||
const execPattern = /a/
|
||||
const execIndex = {}
|
||||
execPattern.lastIndex = execIndex
|
||||
const match = execPattern.exec("ba")
|
||||
|
||||
test("split, search, and matchAll leave lastIndex unchanged like JS", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const re = /a/y
|
||||
re.lastIndex = 2
|
||||
return ["banana".split(re), re.lastIndex]
|
||||
`),
|
||||
).toEqual([["b", "n", "n", ""], 2])
|
||||
expect(
|
||||
await value(`
|
||||
const re = /a/g
|
||||
re.lastIndex = 2
|
||||
return ["banana".search(re), re.lastIndex]
|
||||
`),
|
||||
).toEqual([1, 2])
|
||||
expect(
|
||||
await value(`
|
||||
const re = /a/y
|
||||
re.lastIndex = 2
|
||||
return ["banana".search(re), re.lastIndex]
|
||||
`),
|
||||
).toEqual([-1, 2])
|
||||
expect(
|
||||
await value(`
|
||||
const re = /a/g
|
||||
re.lastIndex = 2
|
||||
return ["banana".matchAll(re).map((m) => m.index), re.lastIndex]
|
||||
`),
|
||||
).toEqual([[3, 5], 2])
|
||||
expect(
|
||||
await value(`
|
||||
const re = /a/gy
|
||||
re.lastIndex = 1
|
||||
return ["banana".matchAll(re).map((m) => m.index), re.lastIndex]
|
||||
`),
|
||||
).toEqual([[1], 1])
|
||||
})
|
||||
const testPattern = /a/
|
||||
const testIndex = {}
|
||||
testPattern.lastIndex = testIndex
|
||||
const matched = testPattern.test("ba")
|
||||
|
||||
test("String methods leave lastIndex untouched without g or y", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const re = /a/
|
||||
re.lastIndex = 5
|
||||
return ["aaa".replace(re, "b"), "aaa".match(re).index, "aaa".split(re), "aaa".search(re), re.lastIndex]
|
||||
return [match.index, execPattern.lastIndex === execIndex, matched, testPattern.lastIndex === testIndex]
|
||||
`),
|
||||
).toEqual(["baa", 0, ["", "", "", ""], 0, 5])
|
||||
).toEqual([1, true, true, true])
|
||||
})
|
||||
|
||||
test("an unmatched string pattern returns null", async () => {
|
||||
|
||||
@@ -111,7 +111,6 @@
|
||||
"@ff-labs/fff-node": "0.10.5",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@modelcontextprotocol/client": "2.0.0",
|
||||
"@modelcontextprotocol/core": "2.0.0",
|
||||
"@opencode-ai/pty": "0.1.13",
|
||||
"@opencode/ai": "workspace:*",
|
||||
"@opencode/codemode": "workspace:*",
|
||||
|
||||
@@ -111,13 +111,7 @@ export class CodeRequiredError extends Schema.TaggedError<CodeRequiredError>()("
|
||||
|
||||
export class AuthorizationError extends Schema.TaggedError<AuthorizationError>()("Integration.Authorization", {
|
||||
cause: Schema.Defect(),
|
||||
}) {
|
||||
override get message() {
|
||||
const cause = this.cause
|
||||
if (cause instanceof Error && cause.message) return cause.message
|
||||
return "Authorization failed"
|
||||
}
|
||||
}
|
||||
}) {}
|
||||
|
||||
export class AttemptNotFoundError extends Schema.TaggedError<AttemptNotFoundError>()("Integration.AttemptNotFound", {
|
||||
integrationID: ID,
|
||||
|
||||
@@ -142,14 +142,9 @@ function snapshot(job: Active): Info {
|
||||
}
|
||||
}
|
||||
|
||||
function errorText(cause: Cause.Cause<unknown>) {
|
||||
const render = (error: Error): string => {
|
||||
const message = error.message || error.name || "Unknown error"
|
||||
if (!(error.cause instanceof Error)) return message
|
||||
const detail = render(error.cause)
|
||||
return detail === message || detail.startsWith(`${message}\n`) ? detail : `${message}\nCaused by: ${detail}`
|
||||
}
|
||||
return Cause.prettyErrors(cause).map(render).join("\n") || "Unknown error"
|
||||
function errorText(error: unknown) {
|
||||
if (error instanceof Error) return error.message
|
||||
return String(error)
|
||||
}
|
||||
|
||||
function incrementSession(input: Map<SessionSchema.ID, number>, sessionID: SessionSchema.ID) {
|
||||
@@ -210,7 +205,7 @@ export const make = Effect.gen(function* () {
|
||||
status,
|
||||
completed_at,
|
||||
...(Exit.isSuccess(exit) ? { output: exit.value } : {}),
|
||||
...(Exit.isFailure(exit) ? { error: errorText(exit.cause) } : {}),
|
||||
...(Exit.isFailure(exit) ? { error: errorText(Cause.squash(exit.cause)) } : {}),
|
||||
},
|
||||
}
|
||||
if (status !== "cancelled") yield* persistBackground(next)
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
discoverOAuthServerInfo,
|
||||
extractWWWAuthenticateParams,
|
||||
parseErrorResponse,
|
||||
resourceUrlFromServerUrl,
|
||||
UnauthorizedError,
|
||||
type FetchLike,
|
||||
type OAuthClientProvider,
|
||||
@@ -15,7 +14,6 @@ import {
|
||||
type StoredOAuthClientInformation,
|
||||
type StoredOAuthTokens,
|
||||
} from "@modelcontextprotocol/client"
|
||||
import { OAuthMetadataSchema, OpenIdProviderDiscoveryMetadataSchema } from "@modelcontextprotocol/core"
|
||||
import { Cause, Deferred, Effect } from "effect"
|
||||
import { ConfigMCP } from "@opencode/schema/config/mcp"
|
||||
import { Credential } from "../credential.js"
|
||||
@@ -101,25 +99,6 @@ export const loggedFetch = (fields: { readonly server: string; readonly director
|
||||
return request
|
||||
})
|
||||
|
||||
// A configured authorization server document stands in for RFC 9728 discovery: the SDK reuses this
|
||||
// state instead of probing the resource server, whose well-known path may not exist.
|
||||
export const configuredDiscovery = async (input: {
|
||||
readonly config: typeof ConfigMCP.Remote.Type
|
||||
readonly fetchFn: FetchLike
|
||||
}): Promise<OAuthDiscoveryState | undefined> => {
|
||||
const url = input.config.oauth ? input.config.oauth.auth_server_metadata_url : undefined
|
||||
if (!url) return undefined
|
||||
const response = await input.fetchFn(url, { headers: { accept: "application/json" } })
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status} trying to load OAuth authorization server metadata`)
|
||||
const body = await response.json()
|
||||
const metadata = OAuthMetadataSchema.safeParse(body).data ?? OpenIdProviderDiscoveryMetadataSchema.parse(body)
|
||||
return {
|
||||
authorizationServerUrl: metadata.issuer,
|
||||
authorizationServerMetadata: metadata,
|
||||
resourceMetadata: { resource: resourceUrlFromServerUrl(input.config.url).toString() },
|
||||
}
|
||||
}
|
||||
|
||||
export interface Store {
|
||||
readonly tokens: () => Promise<StoredOAuthTokens | undefined>
|
||||
readonly saveTokens: (tokens: StoredOAuthTokens) => Promise<void>
|
||||
@@ -155,10 +134,7 @@ export const provider = (options: Options): OAuthClientProvider => {
|
||||
let discovery: OAuthDiscoveryState | undefined = options.discovery
|
||||
return {
|
||||
redirectUrl,
|
||||
discoveryState: async () => {
|
||||
discovery ??= await configuredDiscovery({ config: options.config, fetchFn: send })
|
||||
return discovery
|
||||
},
|
||||
discoveryState: () => discovery,
|
||||
saveDiscoveryState: (state) => {
|
||||
discovery = state
|
||||
},
|
||||
@@ -412,9 +388,7 @@ export const authorize = (input: {
|
||||
// CIMD needs the server to advertise it and accept public clients, and our published document only
|
||||
// lists the loopback redirect; a configured client_id always wins.
|
||||
const discovery = yield* Effect.tryPromise({
|
||||
try: async () =>
|
||||
(await configuredDiscovery({ config: input.config, fetchFn })) ??
|
||||
discoverOAuthServerInfo(input.config.url, { resourceMetadataUrl, fetchFn }),
|
||||
try: () => discoverOAuthServerInfo(input.config.url, { resourceMetadataUrl, fetchFn }),
|
||||
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
||||
})
|
||||
const cimd =
|
||||
|
||||
@@ -3,6 +3,7 @@ export * as OpenCodeTools from "./opencode.js"
|
||||
import { SystemPart, ToolFailure } from "@opencode/ai"
|
||||
import type { Context } from "@opencode/plugin/effect/plugin"
|
||||
import type { SessionHooks } from "@opencode/plugin/effect/session"
|
||||
import { Model } from "@opencode/schema/model"
|
||||
import { AbsolutePath } from "@opencode/schema/schema"
|
||||
import { Session } from "@opencode/schema/session"
|
||||
import { Effect, Schema } from "effect"
|
||||
@@ -23,6 +24,14 @@ export const MoveInput = Schema.Struct({
|
||||
|
||||
const MoveOutput = Schema.Struct({ sessionID: Session.ID, directory: AbsolutePath })
|
||||
|
||||
export const ModelsInput = Schema.Struct({
|
||||
provider: Schema.optionalKey(Schema.String).annotate({
|
||||
description: 'Only list models from this provider, for example "anthropic".',
|
||||
}),
|
||||
})
|
||||
|
||||
const ModelsOutput = Schema.Struct({ models: Schema.Array(Model.Info) })
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tools",
|
||||
effect: Effect.fn("OpenCodeTools.Plugin")(function* (ctx: Context) {
|
||||
@@ -85,6 +94,27 @@ export const Plugin = {
|
||||
),
|
||||
),
|
||||
})
|
||||
draft.add({
|
||||
name: "models",
|
||||
description:
|
||||
'List the models available to you. Reference one as "provider/model", or "provider/model#variant" using an entry from its variants. Pass the reference anywhere a model is accepted, such as the subagent tool.',
|
||||
input: ModelsInput,
|
||||
output: ModelsOutput,
|
||||
options: { namespace: "opencode", codemode: true },
|
||||
execute: (input) =>
|
||||
ctx.model.list().pipe(
|
||||
Effect.map((list) => {
|
||||
const models = list.data.filter(
|
||||
(model) => input.provider === undefined || model.providerID === input.provider,
|
||||
)
|
||||
return {
|
||||
output: { models },
|
||||
content: models.map((model) => `${model.providerID}/${model.id}: ${model.name}`).join("\n"),
|
||||
}
|
||||
}),
|
||||
Effect.mapError((error) => new ToolFailure({ message: "Unable to list models", error })),
|
||||
),
|
||||
})
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Effect, Schema } from "effect"
|
||||
import { Agent } from "../../agent.js"
|
||||
import { Config } from "../../config.js"
|
||||
import { Job } from "../../job.js"
|
||||
import { Model } from "../../model.js"
|
||||
import { Permission } from "../../permission.js"
|
||||
import { Session } from "../../session.js"
|
||||
import { SessionSchema } from "../../session/schema.js"
|
||||
@@ -29,6 +30,10 @@ export const Input = Schema.Struct({
|
||||
agent: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }),
|
||||
description: Schema.String.annotate({ description: "A short 3-5 word label for the task, displayed to the user" }),
|
||||
prompt: Schema.String.annotate({ description: "The task for the subagent to perform" }),
|
||||
model: Schema.optionalKey(Schema.String).annotate({
|
||||
description:
|
||||
'Set only when the user explicitly requests a model, and add "#variant" only when they request a variant too. Format "provider/model" or "provider/model#variant". Omitted, the subagent uses the agent\'s configured model, or your own. Use the models tool to find the reference for a requested model.',
|
||||
}),
|
||||
sessionID: Schema.optionalKey(SessionSchema.ID).annotate({
|
||||
description:
|
||||
"Continue a specific previous subagent conversation by passing its sessionID. Calls without a sessionID start a new conversation.",
|
||||
@@ -61,8 +66,29 @@ export const Plugin = {
|
||||
const agents = yield* Agent.Service
|
||||
const config = yield* Config.Service
|
||||
const permission = yield* Permission.Service
|
||||
const models = yield* Model.Service
|
||||
const subagents = yield* SubagentJob.make
|
||||
|
||||
const resolveModel = Effect.fn("SubagentTool.resolveModel")(function* (input: string) {
|
||||
const ref = yield* Effect.try({
|
||||
try: () => Model.Ref.parse(input),
|
||||
catch: () =>
|
||||
new ToolFailure({ message: `Invalid model "${input}". Use "provider/model" or "provider/model#variant".` }),
|
||||
})
|
||||
const model = (yield* models.available()).find(
|
||||
(model) => model.providerID === ref.providerID && model.id === ref.id,
|
||||
)
|
||||
if (model === undefined)
|
||||
return yield* new ToolFailure({
|
||||
message: `Model "${ref.providerID}/${ref.id}" is not available. Use the models tool to see what is available.`,
|
||||
})
|
||||
if (ref.variant !== undefined && !model.variants.some((variant) => variant.id === ref.variant))
|
||||
return yield* new ToolFailure({
|
||||
message: `Variant "${ref.variant}" is not available for "${ref.providerID}/${ref.id}". Available: ${model.variants.map((variant) => variant.id).join(", ") || "none"}.`,
|
||||
})
|
||||
return ref
|
||||
})
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((editor) =>
|
||||
editor.add({
|
||||
@@ -131,24 +157,23 @@ export const Plugin = {
|
||||
return yield* new ToolFailure({
|
||||
message: `Session ${existing.id} is not a child of the current session`,
|
||||
})
|
||||
const override = input.model === undefined ? undefined : yield* resolveModel(input.model)
|
||||
// Continuing with a different agent switches the child, mirroring create semantics
|
||||
// where the agent's configured model wins over the inherited one.
|
||||
if (existing !== undefined && existing.agent !== agent.id) {
|
||||
yield* sessions.switchAgent({ sessionID: existing.id, agent: agent.id }).pipe(
|
||||
Effect.andThen(
|
||||
agent.model === undefined
|
||||
? Effect.void
|
||||
: sessions.switchModel({ sessionID: existing.id, model: agent.model }),
|
||||
),
|
||||
// where an explicit model wins over the agent's configured model, which wins over the inherited one.
|
||||
if (existing !== undefined) {
|
||||
const switched = existing.agent !== agent.id
|
||||
const model = override ?? (switched ? agent.model : undefined)
|
||||
yield* Effect.all([
|
||||
switched ? sessions.switchAgent({ sessionID: existing.id, agent: agent.id }) : Effect.void,
|
||||
model === undefined ? Effect.void : sessions.switchModel({ sessionID: existing.id, model }),
|
||||
]).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({ message: `Failed to switch subagent session agent: ${existing.id}`, error }),
|
||||
(error) => new ToolFailure({ message: `Failed to switch subagent session: ${existing.id}`, error }),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Model selection is policy/config/session state, not an LLM-facing tool argument.
|
||||
const model = agent.model ?? parent.model
|
||||
const model = override ?? agent.model ?? parent.model
|
||||
const child =
|
||||
existing ??
|
||||
(yield* sessions
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Cause, Clock, Duration, Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { Credential } from "@opencode/core/credential"
|
||||
@@ -674,16 +674,3 @@ describe("Integration", () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("AuthorizationError", () => {
|
||||
test("reports the underlying cause message", () => {
|
||||
expect(new Integration.AuthorizationError({ cause: new Error("Request failed: 401") }).message).toBe(
|
||||
"Request failed: 401",
|
||||
)
|
||||
})
|
||||
|
||||
test("falls back when the cause carries no message", () => {
|
||||
expect(new Integration.AuthorizationError({ cause: new Error() }).message).toBe("Authorization failed")
|
||||
expect(new Integration.AuthorizationError({ cause: undefined }).message).toBe("Authorization failed")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,9 +2,8 @@ import { describe, expect } from "bun:test"
|
||||
import { Job } from "@opencode/core/job"
|
||||
import { KV } from "@opencode/core/kv"
|
||||
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
||||
import { Integration } from "@opencode/core/integration"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { Cause, Deferred, Effect, Exit, Fiber, Scope } from "effect"
|
||||
import { Deferred, Effect, Exit, Fiber, Scope } from "effect"
|
||||
import { SessionSchema } from "@opencode/core/session/schema"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
@@ -65,30 +64,6 @@ describe("Job", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("preserves authorization and complete failure details without stacks", () =>
|
||||
Effect.gen(function* () {
|
||||
const jobs = yield* Job.Service
|
||||
const job = yield* jobs.start({
|
||||
type: "test",
|
||||
run: Effect.failCause(
|
||||
Cause.combine(
|
||||
Cause.fail(
|
||||
new Integration.AuthorizationError({
|
||||
cause: new Error("authorization failed", { cause: new Error("token expired") }),
|
||||
}),
|
||||
),
|
||||
Cause.die({ code: "cleanup_failed" }),
|
||||
),
|
||||
),
|
||||
})
|
||||
|
||||
expect((yield* jobs.wait({ id: job.id })).info).toMatchObject({
|
||||
status: "error",
|
||||
error: 'authorization failed\nCaused by: token expired\n{"code":"cleanup_failed"}',
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("reuses running work when started again with the same ID", () =>
|
||||
Effect.gen(function* () {
|
||||
const jobs = yield* Job.Service
|
||||
|
||||
@@ -352,38 +352,6 @@ describe("MCP OAuth", () => {
|
||||
expect(url.pathname).toBe("/as/authorize")
|
||||
})
|
||||
|
||||
test("uses configured authorization server metadata when the resource publishes none", async () => {
|
||||
const { server: issuer } = authorizationServer({})
|
||||
const resource = Bun.serve({ port: 0, fetch: () => new Response(null, { status: 404 }) })
|
||||
const url = `${resource.url.origin}/mcp`
|
||||
const oauth = {
|
||||
client_id: "client",
|
||||
auth_server_metadata_url: `${issuer.url.origin}/.well-known/oauth-authorization-server`,
|
||||
}
|
||||
|
||||
const { url: authorization } = await Effect.runPromise(Effect.scoped(start(url, oauth)))
|
||||
expect(authorization.origin).toBe(issuer.url.origin)
|
||||
expect(authorization.pathname).toBe("/authorize")
|
||||
expect(authorization.searchParams.get("resource")).toBe(url)
|
||||
|
||||
const { server, tokenRequests } = authorizationServer({})
|
||||
const store = memoryCredentials([credential({ access: "expired", refresh: "refresh", url })])
|
||||
const oauthProvider = await connectProvider(
|
||||
new ConfigMCP.Remote({
|
||||
type: "remote",
|
||||
url,
|
||||
oauth: { ...oauth, auth_server_metadata_url: `${server.url.origin}/.well-known/oauth-authorization-server` },
|
||||
}),
|
||||
store,
|
||||
)
|
||||
await auth(oauthProvider, { serverUrl: url }).finally(() => {
|
||||
resource.stop(true)
|
||||
issuer.stop(true)
|
||||
server.stop(true)
|
||||
})
|
||||
expect(tokenRequests[0]?.get("grant_type")).toBe("refresh_token")
|
||||
})
|
||||
|
||||
test("forwards iss from the redirect so issuer-advertising servers can complete", async () => {
|
||||
const { server } = authorizationServer({ authorization_response_iss_parameter_supported: true })
|
||||
const result = await Effect.runPromise(
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Plugin } from "@opencode/core/plugin"
|
||||
import { PluginHost } from "@opencode/core/plugin/host"
|
||||
import { Provider } from "@opencode/core/provider"
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { Tool } from "@opencode/core/tool"
|
||||
import { OpenCodeTools } from "@opencode/core/tool/plugin/opencode"
|
||||
import { Model } from "@opencode/schema/model"
|
||||
import { Effect } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { executeTool, toolIdentity } from "./lib/tool"
|
||||
import { PluginTestLayer } from "./plugin/fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
it.effect("lists available models through the opencode namespace", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Provider.Service
|
||||
const plugins = yield* Plugin.Service
|
||||
const pluginHost = yield* PluginHost.make(plugins)
|
||||
yield* catalog.transform((editor) => {
|
||||
editor.models.update(Provider.ID.make("test"), Model.ID.make("alpha"), (model) => {
|
||||
model.name = "Alpha"
|
||||
model.variants = [{ id: Model.VariantID.make("fast") }]
|
||||
})
|
||||
editor.models.update(Provider.ID.make("other"), Model.ID.make("beta"), (model) => {
|
||||
model.name = "Beta"
|
||||
})
|
||||
editor.models.update(Provider.ID.make("other"), Model.ID.make("disabled"), (model) => {
|
||||
model.enabled = false
|
||||
})
|
||||
})
|
||||
yield* OpenCodeTools.Plugin.effect(pluginHost)
|
||||
const registry = yield* Tool.Service
|
||||
const run = (code: string) =>
|
||||
executeTool(registry, {
|
||||
sessionID: Session.ID.make("ses_tool_opencode"),
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: `call-${code.length}`, name: "execute", input: { code } },
|
||||
})
|
||||
|
||||
const all = yield* run(
|
||||
"const list = await tools.opencode.models({}); return list.models.map((model) => `${model.providerID}/${model.id}: ${model.name} [${model.variants.map((variant) => variant.id)}]`).sort()",
|
||||
)
|
||||
expect(all.content).toEqual([
|
||||
{ type: "text", text: JSON.stringify(["other/beta: Beta []", "test/alpha: Alpha [fast]"], null, 2) },
|
||||
])
|
||||
|
||||
const filtered = yield* run(
|
||||
'const list = await tools.opencode.models({ provider: "other" }); return list.models.map((model) => model.id)',
|
||||
)
|
||||
expect(filtered.content).toEqual([{ type: "text", text: JSON.stringify(["beta"], null, 2) }])
|
||||
}),
|
||||
)
|
||||
@@ -45,6 +45,11 @@ const completedOutput = (sessionID: Session.ID) =>
|
||||
`<subagent sessionID="${sessionID}" state="completed">\n${childText}\n</subagent>`
|
||||
const childModel = Model.Ref.make({ id: Model.ID.make("child"), providerID: Provider.ID.make("test") })
|
||||
const parentModel = Model.Ref.make({ id: Model.ID.make("parent"), providerID: Provider.ID.make("test") })
|
||||
const overrideModel = Model.Ref.make({
|
||||
id: Model.ID.make("override"),
|
||||
providerID: Provider.ID.make("test"),
|
||||
variant: Model.VariantID.make("fast"),
|
||||
})
|
||||
const tokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
|
||||
|
||||
const outputSessionID = (value: unknown) =>
|
||||
@@ -107,7 +112,7 @@ const executionNode = makeGlobalNode({
|
||||
const subagentPluginSupervisor = makeLocationNode({
|
||||
name: "test/subagent-plugins",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(SubagentTool.Plugin)),
|
||||
deps: [Agent.node, Config.node, Permission.node, Session.node, Job.node, Tool.node],
|
||||
deps: [Agent.node, Config.node, Model.node, Permission.node, Session.node, Job.node, Tool.node],
|
||||
})
|
||||
|
||||
const nodes = LayerNode.group([
|
||||
@@ -155,6 +160,13 @@ const withSubagent = (location: Location.Ref) =>
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
yield* Plugin.Service.use((plugins) => plugins.awaitActivation).pipe(Effect.provide(locations.get(location)))
|
||||
yield* Provider.Service.use((providers) =>
|
||||
providers.transform((editor) => {
|
||||
editor.models.update(overrideModel.providerID, overrideModel.id, (model) => {
|
||||
model.variants = [{ id: Model.VariantID.make("fast") }]
|
||||
})
|
||||
}),
|
||||
).pipe(Effect.provide(locations.get(location)))
|
||||
yield* Agent.Service.use((agents) =>
|
||||
agents.transform((editor) => {
|
||||
// The caller identity used by executeTool; subagent permission asserts against it.
|
||||
@@ -615,6 +627,62 @@ describe("SubagentTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("runs the child on an explicitly requested model", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
|
||||
const sessions = yield* Session.Service
|
||||
const parent = yield* sessions.create({ location, model: parentModel })
|
||||
yield* withSubagent(parent.location)
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
const call = (id: string, input: Record<string, unknown>) =>
|
||||
executeTool(registry, {
|
||||
sessionID: parent.id,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call" as const,
|
||||
id,
|
||||
name: SubagentTool.name,
|
||||
input: { agent: "reviewer", description: "review", prompt: "review this", ...input },
|
||||
},
|
||||
})
|
||||
|
||||
// The requested model beats the agent's configured model.
|
||||
const spawned = yield* call("call-override", { model: "test/override#fast" })
|
||||
expect(spawned).toMatchObject({ status: "completed", metadata: { status: "completed" } })
|
||||
const child = yield* sessions.get(outputSessionID(spawned.metadata))
|
||||
expect(child).toMatchObject({ agent: "reviewer", model: overrideModel })
|
||||
|
||||
// Continuing with a model switches the existing child even when the agent is unchanged.
|
||||
const continued = yield* call("call-override-continue", { sessionID: child.id, model: "test/override" })
|
||||
expect(continued).toMatchObject({ status: "completed", metadata: { sessionID: child.id } })
|
||||
expect((yield* sessions.get(child.id)).model).toEqual({
|
||||
id: overrideModel.id,
|
||||
providerID: overrideModel.providerID,
|
||||
variant: Model.VariantID.make("default"),
|
||||
})
|
||||
|
||||
const failures = [
|
||||
["not-a-ref", 'Invalid model "not-a-ref". Use "provider/model" or "provider/model#variant".'],
|
||||
["test/missing", 'Model "test/missing" is not available. Use the models tool to see what is available.'],
|
||||
["test/override#slow", 'Variant "slow" is not available for "test/override". Available: fast.'],
|
||||
] as const
|
||||
for (const [model, message] of failures) {
|
||||
expect(yield* call(`call-${model}`, { model })).toEqual({
|
||||
status: "error",
|
||||
error: { type: "tool.execution", message },
|
||||
})
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("returns child runner failures as tool errors", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
||||
@@ -15250,9 +15250,6 @@
|
||||
},
|
||||
"redirect_uri": {
|
||||
"type": "string"
|
||||
},
|
||||
"auth_server_metadata_url": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -15552,9 +15549,6 @@
|
||||
},
|
||||
"requireAssistantAfterTool": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"supportsPromptCacheKey": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
||||
@@ -44,10 +44,6 @@ export class OAuthConfig extends Schema.Class<OAuthConfig>("Mcp.OAuthConfig")({
|
||||
scope: Schema.String.pipe(optional),
|
||||
callback_port: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })).pipe(optional),
|
||||
redirect_uri: Schema.String.pipe(optional),
|
||||
auth_server_metadata_url: Schema.String.pipe(optional).annotate({
|
||||
description:
|
||||
"URL of the OAuth or OpenID Connect authorization server metadata document. Set when the MCP server does not publish protected resource metadata that names its authorization server.",
|
||||
}),
|
||||
}) {}
|
||||
|
||||
export class RemoteConfig extends Schema.Class<RemoteConfig>("Mcp.RemoteConfig")({
|
||||
|
||||
@@ -42,12 +42,8 @@ function collapseTail(output: string, maxLines: number, maxChars: number) {
|
||||
const lines = output.split("\n")
|
||||
if (lines.length <= maxLines && Array.from(output).length <= maxChars) return output
|
||||
|
||||
const count = Math.max(1, lines.length - Math.max(0, maxLines - 1))
|
||||
const label = `(${count} earlier ${count === 1 ? "line" : "lines"})`
|
||||
if (maxLines <= 1) return label
|
||||
|
||||
const preview = Array.from(lines.slice(-(maxLines - 1)).join("\n"))
|
||||
const available = maxChars - Array.from(label).length - 1
|
||||
if (available <= 0) return label
|
||||
return `${label}\n${preview.slice(-available).join("")}`
|
||||
const preview = lines.slice(-maxLines).join("\n")
|
||||
const visible = Array.from(preview)
|
||||
if (visible.length < maxChars) return `…${preview}`
|
||||
return `…${visible.slice(-Math.max(0, maxChars - 1)).join("")}`
|
||||
}
|
||||
|
||||
@@ -87,9 +87,8 @@ test("custom commands commit the captured agent, model and variant before execut
|
||||
expect(mutations).toEqual([{ type: "agent", body: { agent: "plan" } }])
|
||||
|
||||
// A later local edit must not change the in-flight command's selection.
|
||||
await waitForFrame(setup, (frame) => frame.includes("Plan · second model Demo · low"))
|
||||
setup.mockInput.pressKey("F7")
|
||||
await waitForFrame(setup, (frame) => frame.includes("high"))
|
||||
await setup.waitForFrame((frame) => frame.includes("high"))
|
||||
agent.resolve(new Response(null, { status: 204 }))
|
||||
await setup.waitFor(() => mutations.length === 2)
|
||||
expect(mutations[1]).toEqual({
|
||||
@@ -107,11 +106,3 @@ test("custom commands commit the captured agent, model and variant before execut
|
||||
model.resolve(new Response(null, { status: 204 }))
|
||||
}
|
||||
})
|
||||
|
||||
async function waitForFrame(setup: Awaited<ReturnType<typeof createAppFixture>>, matches: (frame: string) => boolean) {
|
||||
const started = Date.now()
|
||||
while (!matches(setup.captureCharFrame())) {
|
||||
if (Date.now() - started > 2_000) throw new Error("Timed out waiting for command selection frame")
|
||||
await Bun.sleep(10)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ export async function renderLocal(
|
||||
),
|
||||
{ width: 100, height: 30, kittyKeyboard: true },
|
||||
)
|
||||
await waitForModel(() => local !== undefined && local.model.ready)
|
||||
await setup.waitFor(() => local !== undefined && local.model.ready)
|
||||
await data.location.sync()
|
||||
return {
|
||||
...setup,
|
||||
@@ -106,14 +106,6 @@ export async function renderLocal(
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForModel(ready: () => boolean) {
|
||||
const started = Date.now()
|
||||
while (!ready()) {
|
||||
if (Date.now() - started > 2_000) throw new Error("Timed out waiting for local model data")
|
||||
await Bun.sleep(10)
|
||||
}
|
||||
}
|
||||
|
||||
export function model(id: string, variants: string[] = []): ModelInfo {
|
||||
return {
|
||||
id,
|
||||
|
||||
@@ -15250,9 +15250,6 @@
|
||||
},
|
||||
"redirect_uri": {
|
||||
"type": "string"
|
||||
},
|
||||
"auth_server_metadata_url": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -15552,9 +15549,6 @@
|
||||
},
|
||||
"requireAssistantAfterTool": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"supportsPromptCacheKey": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
||||
@@ -15250,9 +15250,6 @@
|
||||
},
|
||||
"redirect_uri": {
|
||||
"type": "string"
|
||||
},
|
||||
"auth_server_metadata_url": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -15552,9 +15549,6 @@
|
||||
},
|
||||
"requireAssistantAfterTool": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"supportsPromptCacheKey": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
|
||||
Google Inc.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
|
||||
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -230,7 +230,6 @@ When a provider gives you client credentials, use V2's snake_case OAuth fields:
|
||||
| `scope` | Space-delimited scopes to request. |
|
||||
| `callback_port` | Local callback port from `1` through `65535`. An available ephemeral port is the default. |
|
||||
| `redirect_uri` | Pre-registered loopback URI whose path and port reach the local callback listener. |
|
||||
| `auth_server_metadata_url` | URL of the authorization server's OAuth or OpenID Connect metadata document. Set it when the MCP server does not publish protected resource metadata that names its authorization server. |
|
||||
|
||||
Remove stored OAuth credentials when you need to sign in again or switch accounts:
|
||||
|
||||
|
||||
@@ -1,23 +1,39 @@
|
||||
@font-face {
|
||||
font-family: "IBM Plex Mono";
|
||||
src: url("../assets/fonts/IBMPlexMonoVariable-Roman.woff2") format("woff2");
|
||||
font-weight: 100 700;
|
||||
src: url("../assets/fonts/ibm-plex-mono-latin-400-normal.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "IBM Plex Mono";
|
||||
src: url("../assets/fonts/IBMPlexMonoVariable-Italic.woff2") format("woff2");
|
||||
font-weight: 100 700;
|
||||
src: url("../assets/fonts/ibm-plex-mono-latin-700-normal.woff2") format("woff2");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "IBM Plex Mono";
|
||||
src: url("../assets/fonts/ibm-plex-mono-latin-400-italic.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "IBM Plex Mono";
|
||||
src: url("../assets/fonts/ibm-plex-mono-latin-700-italic.woff2") format("woff2");
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--background: #000000;
|
||||
--foreground: #efefef;
|
||||
--background: #090909;
|
||||
--foreground: #ededed;
|
||||
--muted: #929292;
|
||||
--border: #303030;
|
||||
--surface: #101010;
|
||||
@@ -62,8 +78,7 @@ body {
|
||||
color: var(--foreground);
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.8em;
|
||||
line-height: 1.5;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user