mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-11 20:19:53 -04:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c5ac6ba4f3 | |||
| 20af1f5e79 | |||
| 8daf912fbf | |||
| 3d545f960b | |||
| 58201a32c1 | |||
| 64ca9b8d77 | |||
| a4b91d33d9 | |||
| 397993e898 | |||
| c0ed0106b1 | |||
| f112a73c06 | |||
| 7e9b9cb0fd | |||
| 09a6cecf23 | |||
| 6e55ddd078 | |||
| f8f8cc9546 | |||
| 3c60470269 | |||
| b2bdab24e6 | |||
| 3568dd1b99 | |||
| 1e17202413 | |||
| 49cb73b348 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/client": patch
|
||||
---
|
||||
|
||||
Reuse a same-version background service when a repeated health probe succeeds instead of replacing an endpoint another client may already be using.
|
||||
@@ -115,6 +115,7 @@
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"immer": "11.1.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"open": "10.1.2",
|
||||
"opentui-spinner": "catalog:",
|
||||
|
||||
@@ -65,8 +65,8 @@ export const dict = {
|
||||
"command.message.next.description": "Go to the next user message",
|
||||
"command.model.choose": "Choose model",
|
||||
"command.model.choose.description": "Select a different model",
|
||||
"command.mcp.toggle": "Toggle MCPs",
|
||||
"command.mcp.toggle.description": "Toggle MCPs",
|
||||
"command.mcp.toggle": "Manage MCP servers",
|
||||
"command.mcp.toggle.description": "Enable or disable MCP servers",
|
||||
"command.agent.cycle": "Cycle agent",
|
||||
"command.agent.cycle.description": "Switch to the next agent",
|
||||
"command.agent.cycle.reverse": "Cycle agent backwards",
|
||||
@@ -307,9 +307,9 @@ export const dict = {
|
||||
"prompt.toast.promptSendFailed.title": "Failed to send prompt",
|
||||
"prompt.toast.promptSendFailed.description": "Unable to retrieve session",
|
||||
|
||||
"dialog.mcp.title": "MCPs",
|
||||
"dialog.mcp.title": "MCP servers",
|
||||
"dialog.mcp.description": "{{enabled}} of {{total}} enabled",
|
||||
"dialog.mcp.empty": "No MCPs configured",
|
||||
"dialog.mcp.empty": "No MCP servers configured",
|
||||
|
||||
"dialog.lsp.empty": "LSPs auto-detected from file types",
|
||||
"dialog.plugins.empty": "Plugins configured in opencode.json",
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"immer": "11.1.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"open": "10.1.2",
|
||||
"opentui-spinner": "catalog:",
|
||||
|
||||
@@ -4,7 +4,7 @@ import { run } from "@opencode-ai/tui"
|
||||
import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { TuiConfig } from "../../tui-config"
|
||||
import { Config } from "../../config"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Server } from "../../services/server"
|
||||
import { Updater } from "../../services/updater"
|
||||
@@ -35,13 +35,18 @@ export default Runtime.handler(Commands, (input) =>
|
||||
),
|
||||
)
|
||||
preflight.loading()
|
||||
const config = yield* TuiConfig.load()
|
||||
const config = yield* Config.Service
|
||||
let disposeSlots: (() => void) | undefined
|
||||
const runFork = Effect.runForkWith(yield* Effect.context())
|
||||
const context = yield* Effect.context()
|
||||
const runFork = Effect.runForkWith(context)
|
||||
const runPromise = Effect.runPromiseWith(context)
|
||||
yield* run({
|
||||
server,
|
||||
args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
|
||||
config,
|
||||
config: {
|
||||
get: () => runPromise(config.get()),
|
||||
update: (update) => runPromise(config.update(update)),
|
||||
},
|
||||
terminalHandoff: () => preflight.finish(),
|
||||
log: (level, message, tags) => {
|
||||
const effect =
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
export * as Config from "./config"
|
||||
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Context, Effect, FileSystem, Layer, Option, Schema, Semaphore } from "effect"
|
||||
import { produce, type Draft } from "immer"
|
||||
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
|
||||
import path from "path"
|
||||
import { ConfigMigration } from "./migrate"
|
||||
import { Info } from "./schema"
|
||||
|
||||
export * from "./schema"
|
||||
|
||||
export interface Interface {
|
||||
readonly path: string
|
||||
readonly get: () => Effect.Effect<Info>
|
||||
readonly update: (update: (draft: Draft<Info>) => void) => Effect.Effect<Info, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/config/Config") {}
|
||||
|
||||
const decode = Schema.decodeUnknownOption(Info)
|
||||
const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Any))
|
||||
const empty: Info = {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const global = yield* Global.Service
|
||||
const file = path.join(global.config, "cli.json")
|
||||
const lock = yield* Semaphore.make(1)
|
||||
|
||||
const readJson = Effect.fnUntraced(function* () {
|
||||
const text = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (text === undefined) return undefined
|
||||
const errors: ParseError[] = []
|
||||
const value: any = parse(text, errors, { allowTrailingComma: true })
|
||||
if (errors.length) return undefined
|
||||
return Option.getOrUndefined(decodeRecord(value))
|
||||
})
|
||||
|
||||
const write = Effect.fnUntraced(function* (text: string) {
|
||||
const temp = file + ".tmp"
|
||||
yield* fs.makeDirectory(path.dirname(file), { recursive: true })
|
||||
yield* fs.writeFileString(temp, text, { mode: 0o600 })
|
||||
yield* fs.rename(temp, file)
|
||||
})
|
||||
|
||||
const migrate = ConfigMigration.run({ file, config: global.config, state: global.state }).pipe(
|
||||
Effect.provideService(FileSystem.FileSystem, fs),
|
||||
)
|
||||
|
||||
const get = Effect.fn("cli.config.get")(function* () {
|
||||
yield* migrate.pipe(Effect.catchCause((cause) => Effect.logWarning("failed to migrate cli config", { cause })))
|
||||
return Option.getOrElse(decode(yield* readJson()), () => empty)
|
||||
})
|
||||
|
||||
const update = Effect.fn("cli.config.update")((update: (draft: Draft<Info>) => void) =>
|
||||
lock
|
||||
.withPermits(1)(
|
||||
Effect.gen(function* () {
|
||||
yield* migrate
|
||||
const current = Option.getOrElse(decode(yield* readJson()), () => empty)
|
||||
const next = produce(current, update)
|
||||
const edits = changes(current, next)
|
||||
if (!edits.length) return current
|
||||
const text = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("{}")))
|
||||
const updated = edits.reduce(
|
||||
(text, edit) =>
|
||||
applyEdits(
|
||||
text,
|
||||
modify(text, edit.path, edit.value, { formattingOptions: { tabSize: 2, insertSpaces: true } }),
|
||||
),
|
||||
text,
|
||||
)
|
||||
const errors: ParseError[] = []
|
||||
const config = Option.getOrUndefined(decode(parse(updated, errors, { allowTrailingComma: true })))
|
||||
if (errors.length || config === undefined) return yield* Effect.fail(new Error("Invalid CLI config update"))
|
||||
yield* write(updated.endsWith("\n") ? updated : updated + "\n")
|
||||
return config
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.mapError((cause) => new Error("Failed to update CLI config", { cause }))),
|
||||
)
|
||||
|
||||
return Service.of({ path: file, get, update })
|
||||
}),
|
||||
)
|
||||
|
||||
type Edit = { readonly path: (string | number)[]; readonly value: any }
|
||||
|
||||
function changes(before: any, after: any, path: (string | number)[] = []): Edit[] {
|
||||
if (Object.is(before, after)) return []
|
||||
if (
|
||||
before !== null &&
|
||||
after !== null &&
|
||||
typeof before === "object" &&
|
||||
typeof after === "object" &&
|
||||
!Array.isArray(before) &&
|
||||
!Array.isArray(after)
|
||||
) {
|
||||
return [...new Set([...Object.keys(before), ...Object.keys(after)])].flatMap((key) => {
|
||||
if (!(key in after)) return [{ path: [...path, key], value: undefined }]
|
||||
if (!(key in before)) return [{ path: [...path, key], value: after[key] }]
|
||||
return changes(before[key], after[key], [...path, key])
|
||||
})
|
||||
}
|
||||
return [{ path, value: after }]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * as Config from "./config"
|
||||
@@ -0,0 +1,142 @@
|
||||
export * as ConfigMigration from "./migrate"
|
||||
|
||||
import { TuiConfigV1 } from "@opencode-ai/tui/config/v1"
|
||||
import { Effect, FileSystem, Option, Schema } from "effect"
|
||||
import { parse, type ParseError } from "jsonc-parser"
|
||||
import path from "path"
|
||||
import type { Info } from "./schema"
|
||||
|
||||
const decodeV1 = Schema.decodeUnknownOption(TuiConfigV1.Info)
|
||||
const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Any))
|
||||
|
||||
export const run = Effect.fn("cli.config.migrate")(function* (input: {
|
||||
readonly file: string
|
||||
readonly config: string
|
||||
readonly state: string
|
||||
}) {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
if (yield* fs.exists(input.file).pipe(Effect.orElseSucceed(() => false))) return
|
||||
|
||||
const legacyValue = yield* readJson(path.join(input.config, "tui.json"))
|
||||
const legacy = Option.getOrUndefined(decodeV1(legacyValue))
|
||||
const kv = yield* readJson(path.join(input.state, "kv.json"))
|
||||
const migrated = migrateV1(legacy, kv ?? {})
|
||||
if (!Object.keys(migrated).length) return
|
||||
|
||||
const temp = input.file + ".tmp"
|
||||
yield* fs.makeDirectory(path.dirname(input.file), { recursive: true })
|
||||
yield* fs.writeFileString(temp, JSON.stringify(migrated, null, 2) + "\n", { mode: 0o600 })
|
||||
yield* fs.rename(temp, input.file)
|
||||
yield* Effect.logInfo("migrated cli config", {
|
||||
from: [
|
||||
legacyValue === undefined ? undefined : path.join(input.config, "tui.json"),
|
||||
kv === undefined ? undefined : path.join(input.state, "kv.json"),
|
||||
].filter(Boolean),
|
||||
to: input.file,
|
||||
})
|
||||
})
|
||||
|
||||
export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<string, any>): Info {
|
||||
const plugins = [
|
||||
...(legacy?.plugin?.map((plugin) =>
|
||||
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
|
||||
) ?? []),
|
||||
...Object.entries(legacy?.plugin_enabled ?? {}).map(([id, enabled]) => (enabled ? id : `-${id}`)),
|
||||
]
|
||||
const themeName = legacy?.theme ?? kv.theme
|
||||
const themeMode = kv.theme_mode_lock
|
||||
const attentionSoundPack = kv.attention_sound_pack
|
||||
const diffView = kv.diff_viewer_view ?? (legacy?.diff_style === "stacked" ? "unified" : undefined)
|
||||
const thinking =
|
||||
kv.thinking_mode ??
|
||||
(kv.thinking_visibility === undefined ? undefined : kv.thinking_visibility ? "show" : "hide")
|
||||
|
||||
return {
|
||||
...(themeName !== undefined || themeMode !== undefined
|
||||
? { theme: { ...(themeName === undefined ? {} : { name: themeName }), ...(themeMode === undefined ? {} : { mode: themeMode }) } }
|
||||
: {}),
|
||||
...(legacy?.keybinds === undefined ? {} : { keybinds: legacy.keybinds }),
|
||||
...(plugins.length ? { plugins } : {}),
|
||||
...(legacy?.leader_timeout === undefined ? {} : { leader: { timeout: legacy.leader_timeout } }),
|
||||
...(legacy?.scroll_speed === undefined && legacy?.scroll_acceleration?.enabled === undefined
|
||||
? {}
|
||||
: {
|
||||
scroll: {
|
||||
...(legacy.scroll_speed === undefined ? {} : { speed: legacy.scroll_speed }),
|
||||
...(legacy.scroll_acceleration?.enabled === undefined
|
||||
? {}
|
||||
: { acceleration: legacy.scroll_acceleration.enabled }),
|
||||
},
|
||||
}),
|
||||
...(legacy?.attention === undefined && attentionSoundPack === undefined
|
||||
? {}
|
||||
: {
|
||||
attention: {
|
||||
...legacy?.attention,
|
||||
...(attentionSoundPack === undefined ? {} : { sound_pack: attentionSoundPack }),
|
||||
},
|
||||
}),
|
||||
...(legacy?.diff_style === undefined &&
|
||||
kv.diff_wrap_mode === undefined &&
|
||||
kv.diff_viewer_show_file_tree === undefined &&
|
||||
kv.diff_viewer_single_patch === undefined &&
|
||||
diffView === undefined
|
||||
? {}
|
||||
: {
|
||||
diffs: {
|
||||
...(kv.diff_wrap_mode === undefined ? {} : { wrap: kv.diff_wrap_mode }),
|
||||
...(kv.diff_viewer_show_file_tree === undefined ? {} : { tree: kv.diff_viewer_show_file_tree }),
|
||||
...(kv.diff_viewer_single_patch === undefined ? {} : { single: kv.diff_viewer_single_patch }),
|
||||
...(diffView === undefined ? {} : { view: diffView }),
|
||||
},
|
||||
}),
|
||||
...(kv.terminal_title_enabled === undefined ? {} : { terminal: { title: kv.terminal_title_enabled } }),
|
||||
...(kv.file_context_enabled === undefined && kv.paste_summary_enabled === undefined
|
||||
? {}
|
||||
: {
|
||||
prompt: {
|
||||
...(kv.file_context_enabled === undefined ? {} : { editor: kv.file_context_enabled }),
|
||||
...(kv.paste_summary_enabled === undefined
|
||||
? {}
|
||||
: { paste: kv.paste_summary_enabled ? ("compact" as const) : ("full" as const) }),
|
||||
},
|
||||
}),
|
||||
...(kv.sidebar === undefined &&
|
||||
kv.scrollbar_visible === undefined &&
|
||||
thinking === undefined &&
|
||||
kv.exploration_grouping === undefined
|
||||
? {}
|
||||
: {
|
||||
session: {
|
||||
...(kv.sidebar === undefined ? {} : { sidebar: kv.sidebar }),
|
||||
...(kv.scrollbar_visible === undefined ? {} : { scrollbar: kv.scrollbar_visible }),
|
||||
...(thinking === undefined ? {} : { thinking }),
|
||||
...(kv.exploration_grouping === undefined
|
||||
? {}
|
||||
: { grouping: kv.exploration_grouping ? ("auto" as const) : ("none" as const) }),
|
||||
},
|
||||
}),
|
||||
...(kv.tips_hidden === undefined && kv.dismissed_getting_started === undefined
|
||||
? {}
|
||||
: {
|
||||
hints: {
|
||||
...(kv.tips_hidden === undefined ? {} : { tips: !kv.tips_hidden }),
|
||||
...(kv.dismissed_getting_started === undefined
|
||||
? {}
|
||||
: { onboarding: !kv.dismissed_getting_started }),
|
||||
},
|
||||
}),
|
||||
...(kv.animations_enabled === undefined ? {} : { animations: kv.animations_enabled }),
|
||||
...(legacy?.mouse === undefined ? {} : { mouse: legacy.mouse }),
|
||||
}
|
||||
}
|
||||
|
||||
const readJson = Effect.fnUntraced(function* (target: string) {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const text = yield* fs.readFileString(target).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (text === undefined) return undefined
|
||||
const errors: ParseError[] = []
|
||||
const value: any = parse(text, errors, { allowTrailingComma: true })
|
||||
if (errors.length) return undefined
|
||||
return Option.getOrUndefined(decodeRecord(value))
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Config } from "@opencode-ai/tui/config"
|
||||
import { Schema } from "effect"
|
||||
|
||||
export const Info = Schema.Struct({ ...Config.Info.fields })
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
@@ -3,6 +3,7 @@ import { Command } from "effect/unstable/cli"
|
||||
import { Spec } from "./spec"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Updater } from "../services/updater"
|
||||
import { Config } from "../config"
|
||||
|
||||
export type Input<Value> =
|
||||
Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
|
||||
@@ -13,18 +14,26 @@ export type Input<Value> =
|
||||
|
||||
type RuntimeHandler = (
|
||||
input: unknown,
|
||||
) => Effect.Effect<void, unknown, FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope>
|
||||
) => Effect.Effect<
|
||||
void,
|
||||
unknown,
|
||||
FileSystem.FileSystem | Global.Service | Updater.Service | Config.Service | Scope.Scope
|
||||
>
|
||||
type Loader<Node extends Spec.Any> = () => Promise<{
|
||||
default: (
|
||||
input: Input<Node>,
|
||||
) => Effect.Effect<void, any, FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope>
|
||||
) => Effect.Effect<
|
||||
void,
|
||||
any,
|
||||
FileSystem.FileSystem | Global.Service | Updater.Service | Config.Service | Scope.Scope
|
||||
>
|
||||
}>
|
||||
type ProvidedCommand = Command.Command<
|
||||
string,
|
||||
unknown,
|
||||
unknown,
|
||||
unknown,
|
||||
FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope
|
||||
FileSystem.FileSystem | Global.Service | Updater.Service | Config.Service | Scope.Scope
|
||||
>
|
||||
|
||||
export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never
|
||||
|
||||
@@ -11,6 +11,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
import { Config } from "./config"
|
||||
|
||||
const Handlers = Runtime.handlers(Commands, {
|
||||
$: () => import("./commands/handlers/default"),
|
||||
@@ -51,6 +52,7 @@ Effect.logInfo("cli starting", {
|
||||
}).pipe(
|
||||
Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: InstallationVersion })),
|
||||
Effect.annotateLogs({ role: "cli" }),
|
||||
Effect.provide(Config.layer),
|
||||
Effect.provide(Updater.layer),
|
||||
Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node]))),
|
||||
Effect.provide(Observability.layer),
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
export * as TuiConfig from "./tui-config"
|
||||
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { TuiConfig } from "@opencode-ai/tui/config/v1"
|
||||
import { Effect, FileSystem, Option, Schema } from "effect"
|
||||
import { parse, type ParseError } from "jsonc-parser"
|
||||
import path from "path"
|
||||
|
||||
export const load = Effect.fn("TuiConfig.load")(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const global = yield* Global.Service
|
||||
const filepath = path.join(global.config, "tui.json")
|
||||
const text = yield* fs.readFileString(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!text) return TuiConfig.resolve({}, { terminalSuspend: process.platform !== "win32" })
|
||||
|
||||
const errors: ParseError[] = []
|
||||
const input: unknown = parse(text, errors, { allowTrailingComma: true })
|
||||
if (errors.length) return TuiConfig.resolve({}, { terminalSuspend: process.platform !== "win32" })
|
||||
|
||||
return TuiConfig.resolve(
|
||||
Option.getOrElse(Schema.decodeUnknownOption(TuiConfig.Info)(input), () => ({})),
|
||||
{ terminalSuspend: process.platform !== "win32" },
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Effect } from "effect"
|
||||
import { expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { Config } from "../src/config"
|
||||
|
||||
function run<A, E>(directory: string, effect: Effect.Effect<A, E, Config.Service>) {
|
||||
return Effect.runPromise(
|
||||
effect.pipe(
|
||||
Effect.provide(Config.layer),
|
||||
Effect.provide(Global.layerWith({ config: directory, state: directory })),
|
||||
Effect.provide(NodeFileSystem.layer),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
test("migrates tui and kv config into cli.json", async () => {
|
||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
await Bun.write(
|
||||
path.join(directory, "tui.json"),
|
||||
JSON.stringify({
|
||||
theme: "legacy",
|
||||
keybinds: { leader: "ctrl+o" },
|
||||
plugin: [["example", { mode: "safe" }]],
|
||||
plugin_enabled: { disabled: false },
|
||||
leader_timeout: 500,
|
||||
scroll_speed: 2,
|
||||
scroll_acceleration: { enabled: true },
|
||||
diff_style: "stacked",
|
||||
mouse: false,
|
||||
}),
|
||||
)
|
||||
await Bun.write(
|
||||
path.join(directory, "kv.json"),
|
||||
JSON.stringify({
|
||||
theme_mode_lock: "light",
|
||||
paste_summary_enabled: false,
|
||||
exploration_grouping: false,
|
||||
tips_hidden: true,
|
||||
}),
|
||||
)
|
||||
|
||||
try {
|
||||
const config = await run(
|
||||
directory,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Config.Service
|
||||
return yield* service.get()
|
||||
}),
|
||||
)
|
||||
|
||||
expect(config).toMatchObject({
|
||||
theme: { name: "legacy", mode: "light" },
|
||||
keybinds: { leader: "ctrl+o" },
|
||||
plugins: [{ package: "example", options: { mode: "safe" } }, "-disabled"],
|
||||
leader: { timeout: 500 },
|
||||
scroll: { speed: 2, acceleration: true },
|
||||
diffs: { view: "unified" },
|
||||
prompt: { paste: "full" },
|
||||
session: { grouping: "none" },
|
||||
hints: { tips: false },
|
||||
mouse: false,
|
||||
})
|
||||
expect((await Bun.file(path.join(directory, "cli.json")).json()).keybinds).toEqual({ leader: "ctrl+o" })
|
||||
expect(await Bun.file(path.join(directory, "cli.json")).exists()).toBe(true)
|
||||
expect(await Bun.file(path.join(directory, "tui.json")).exists()).toBe(true)
|
||||
expect(await Bun.file(path.join(directory, "kv.json")).exists()).toBe(true)
|
||||
} finally {
|
||||
await Bun.$`rm -rf ${directory}`
|
||||
}
|
||||
})
|
||||
|
||||
test("migrates before the first update and does not remigrate afterward", async () => {
|
||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
await Bun.write(path.join(directory, "tui.json"), JSON.stringify({ theme: "legacy" }))
|
||||
|
||||
try {
|
||||
const config = await run(
|
||||
directory,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Config.Service
|
||||
yield* service.update((draft) => {
|
||||
draft.animations = false
|
||||
draft.mouse = false
|
||||
})
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(path.join(directory, "tui.json"), JSON.stringify({ theme: "changed" })),
|
||||
)
|
||||
return yield* service.get()
|
||||
}),
|
||||
)
|
||||
|
||||
expect(config).toEqual({ theme: { name: "legacy" }, animations: false, mouse: false })
|
||||
expect(await Bun.file(path.join(directory, "cli.json")).json()).toEqual({
|
||||
theme: { name: "legacy" },
|
||||
animations: false,
|
||||
mouse: false,
|
||||
})
|
||||
} finally {
|
||||
await Bun.$`rm -rf ${directory}`
|
||||
}
|
||||
})
|
||||
|
||||
test("updates a config draft while preserving JSONC comments", async () => {
|
||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
await Bun.write(path.join(directory, "cli.json"), "{\n // Keep this comment\n \"animations\": true\n}\n")
|
||||
|
||||
try {
|
||||
const config = await run(
|
||||
directory,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Config.Service
|
||||
return yield* service.update((draft) => {
|
||||
draft.prompt = { paste: "compact" }
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
expect(config).toEqual({ animations: true, prompt: { paste: "compact" } })
|
||||
expect(await Bun.file(path.join(directory, "cli.json")).text()).toContain("// Keep this comment")
|
||||
} finally {
|
||||
await Bun.$`rm -rf ${directory}`
|
||||
}
|
||||
})
|
||||
@@ -1,25 +0,0 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Effect } from "effect"
|
||||
import { expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { TuiConfig } from "../src/tui-config"
|
||||
|
||||
test("loads the global tui config", async () => {
|
||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
await Bun.write(path.join(directory, "tui.json"), JSON.stringify({ keybinds: { leader: "ctrl+o" } }))
|
||||
|
||||
try {
|
||||
const config = await Effect.runPromise(
|
||||
TuiConfig.load().pipe(
|
||||
Effect.provide(Global.layerWith({ config: directory })),
|
||||
Effect.provide(NodeFileSystem.layer),
|
||||
),
|
||||
)
|
||||
|
||||
expect(config.keybinds.get("leader")?.[0]?.key).toBe("ctrl+o")
|
||||
expect(config.keybinds.get("session.new")?.[0]?.key).toBe("<leader>n")
|
||||
} finally {
|
||||
await Bun.$`rm -rf ${directory}`
|
||||
}
|
||||
})
|
||||
@@ -406,7 +406,6 @@ export type Endpoint10_2Input = {
|
||||
readonly integrationID: Endpoint10_2Request["params"]["integrationID"]
|
||||
readonly location?: Endpoint10_2Request["query"]["location"]
|
||||
readonly key: Endpoint10_2Request["payload"]["key"]
|
||||
readonly inputs?: Endpoint10_2Request["payload"]["inputs"]
|
||||
readonly label?: Endpoint10_2Request["payload"]["label"]
|
||||
}
|
||||
export type Endpoint10_2Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.connect.key"]>>
|
||||
|
||||
@@ -504,14 +504,13 @@ type Endpoint10_2Input = {
|
||||
readonly integrationID: Endpoint10_2Request["params"]["integrationID"]
|
||||
readonly location?: Endpoint10_2Request["query"]["location"]
|
||||
readonly key: Endpoint10_2Request["payload"]["key"]
|
||||
readonly inputs?: Endpoint10_2Request["payload"]["inputs"]
|
||||
readonly label?: Endpoint10_2Request["payload"]["label"]
|
||||
}
|
||||
const Endpoint10_2 = (raw: RawClient["server.integration"]) => (input: Endpoint10_2Input) =>
|
||||
raw["integration.connect.key"]({
|
||||
params: { integrationID: input["integrationID"] },
|
||||
query: { location: input["location"] },
|
||||
payload: { key: input["key"], inputs: input["inputs"], label: input["label"] },
|
||||
payload: { key: input["key"], label: input["label"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint10_3Request = Parameters<RawClient["server.integration"]["integration.connect.oauth"]>[0]
|
||||
|
||||
@@ -59,11 +59,11 @@ const discoverLocal = Effect.fnUntraced(function* (options: Options) {
|
||||
export const start = Effect.fn("service.start")(function* (options: StartOptions = {}) {
|
||||
const compatible = yield* discover(options)
|
||||
if (compatible !== undefined) return compatible
|
||||
const mismatched = yield* find(options)
|
||||
yield* Effect.sync(() =>
|
||||
options.onStart?.(mismatched === undefined ? "missing" : "version-mismatch", mismatched?.info),
|
||||
)
|
||||
if (mismatched !== undefined) yield* kill(mismatched.info, options).pipe(Effect.ignore)
|
||||
const existing = yield* find(options)
|
||||
if (existing?.version !== undefined && (options.version === undefined || existing.version === options.version))
|
||||
return existing.endpoint
|
||||
yield* Effect.sync(() => options.onStart?.(existing === undefined ? "missing" : "version-mismatch", existing?.info))
|
||||
if (existing !== undefined) yield* kill(existing.info, options).pipe(Effect.ignore)
|
||||
|
||||
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
|
||||
if (command === undefined) return yield* Effect.fail(new Error("Missing service command"))
|
||||
@@ -138,6 +138,7 @@ const read = Effect.fnUntraced(function* (file?: string) {
|
||||
type LocalService = {
|
||||
readonly info: Info
|
||||
readonly endpoint: Endpoint
|
||||
readonly version?: string
|
||||
}
|
||||
|
||||
const probe = Effect.fnUntraced(function* (info: Info, version?: string, allowLegacy = false) {
|
||||
@@ -161,7 +162,7 @@ const probe = Effect.fnUntraced(function* (info: Info, version?: string, allowLe
|
||||
if (health.value.pid !== info.pid) return undefined
|
||||
if (info.version !== undefined && health.value.version !== info.version) return undefined
|
||||
if (version !== undefined && health.value.version !== version) return undefined
|
||||
return { info, endpoint } satisfies LocalService
|
||||
return { info, endpoint, version: health.value.version } satisfies LocalService
|
||||
}
|
||||
if (
|
||||
!allowLegacy ||
|
||||
|
||||
@@ -880,7 +880,7 @@ export function make(options: ClientOptions) {
|
||||
method: "POST",
|
||||
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/key`,
|
||||
query: { location: input["location"] },
|
||||
body: { key: input["key"], inputs: input["inputs"], label: input["label"] },
|
||||
body: { key: input["key"], label: input["label"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 401],
|
||||
empty: true,
|
||||
|
||||
@@ -183,6 +183,8 @@ export type ProviderV2Info = {
|
||||
|
||||
export type IntegrationWhen = { key: string; op: "eq" | "neq"; value: string }
|
||||
|
||||
export type IntegrationKeyMethod = { type: "key"; label?: string }
|
||||
|
||||
export type IntegrationEnvMethod = { type: "env"; names: Array<string> }
|
||||
|
||||
export type ConnectionCredentialInfo = { type: "credential"; id: string; label: string }
|
||||
@@ -1866,12 +1868,6 @@ export type IntegrationOAuthMethod = {
|
||||
prompts?: Array<IntegrationTextPrompt | IntegrationSelectPrompt>
|
||||
}
|
||||
|
||||
export type IntegrationKeyMethod = {
|
||||
type: "key"
|
||||
label?: string
|
||||
prompts?: Array<IntegrationTextPrompt | IntegrationSelectPrompt>
|
||||
}
|
||||
|
||||
export type FormField =
|
||||
| FormStringField
|
||||
| FormNumberField
|
||||
@@ -3280,21 +3276,8 @@ export type IntegrationConnectKeyInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
readonly key: {
|
||||
readonly key: string
|
||||
readonly inputs?: { readonly [x: string]: string } | undefined
|
||||
readonly label?: string | undefined
|
||||
}["key"]
|
||||
readonly inputs?: {
|
||||
readonly key: string
|
||||
readonly inputs?: { readonly [x: string]: string } | undefined
|
||||
readonly label?: string | undefined
|
||||
}["inputs"]
|
||||
readonly label?: {
|
||||
readonly key: string
|
||||
readonly inputs?: { readonly [x: string]: string } | undefined
|
||||
readonly label?: string | undefined
|
||||
}["label"]
|
||||
readonly key: { readonly key: string; readonly label?: string | undefined }["key"]
|
||||
readonly label?: { readonly key: string; readonly label?: string | undefined }["label"]
|
||||
}
|
||||
|
||||
export type IntegrationConnectKeyOutput = void
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { rename, writeFile } from "node:fs/promises"
|
||||
|
||||
const [registration, mode] = process.argv.slice(2)
|
||||
if (registration === undefined || mode === undefined) throw new Error("Missing service fixture arguments")
|
||||
|
||||
let requests = 0
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(request) {
|
||||
if (new URL(request.url).pathname !== "/api/health") return new Response(null, { status: 404 })
|
||||
requests += 1
|
||||
if (mode === "modern" && requests === 1) {
|
||||
await writeFile(registration + ".first-request", "")
|
||||
while (!(await Bun.file(registration + ".release").exists())) await Bun.sleep(5)
|
||||
return new Response(null, { status: 503 })
|
||||
}
|
||||
if (mode === "legacy") return Response.json({ healthy: true })
|
||||
return Response.json({ healthy: true, version: "test", pid: process.pid })
|
||||
},
|
||||
})
|
||||
|
||||
await writeFile(
|
||||
registration + ".tmp",
|
||||
JSON.stringify({
|
||||
id: crypto.randomUUID(),
|
||||
version: mode === "legacy" ? undefined : "test",
|
||||
url: server.url.toString(),
|
||||
pid: process.pid,
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
)
|
||||
await rename(registration + ".tmp", registration)
|
||||
|
||||
const shutdown = () => {
|
||||
server.stop(true)
|
||||
process.exit()
|
||||
}
|
||||
process.on("SIGTERM", shutdown)
|
||||
process.on("SIGINT", shutdown)
|
||||
@@ -0,0 +1,91 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { afterEach, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { Service } from "../src/effect/index"
|
||||
|
||||
const fixture = join(import.meta.dir, "fixture/service.ts")
|
||||
const processes: Bun.Subprocess[] = []
|
||||
const directories: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
processes.forEach((process) => process.kill("SIGTERM"))
|
||||
await Promise.all(processes.splice(0).map((process) => process.exited))
|
||||
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
test("a concurrent same-version start cannot invalidate a resolved endpoint", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
spawn(registration, "modern")
|
||||
await waitForFile(registration)
|
||||
const original = await Bun.file(registration).json()
|
||||
|
||||
const starts: Service.StartReason[] = []
|
||||
const first = run(
|
||||
Service.start({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [],
|
||||
onStart: (reason) => starts.push(reason),
|
||||
}),
|
||||
)
|
||||
await waitForFile(registration + ".first-request")
|
||||
|
||||
const resolved = await run(Service.start({ file: registration, version: "test" }))
|
||||
expect(resolved.url).toBe(original.url)
|
||||
|
||||
await writeFile(registration + ".release", "")
|
||||
await first
|
||||
|
||||
expect(starts).toEqual([])
|
||||
expect(await Bun.file(registration).json()).toEqual(original)
|
||||
expect(await health(resolved.url)).toEqual({ healthy: true, version: "test", pid: original.pid })
|
||||
})
|
||||
|
||||
test("a legacy health response is still replaced", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
const existing = spawn(registration, "legacy")
|
||||
await waitForFile(registration)
|
||||
|
||||
const starts: Service.StartReason[] = []
|
||||
const result = run(Service.start({ file: registration, command: [], onStart: (reason) => starts.push(reason) }))
|
||||
|
||||
await expect(result).rejects.toThrow("Missing service command")
|
||||
expect(starts).toEqual(["version-mismatch"])
|
||||
await existing.exited
|
||||
})
|
||||
|
||||
function run<A, E>(effect: Effect.Effect<A, E, never>) {
|
||||
return Effect.runPromise(effect.pipe(Effect.provide(NodeFileSystem.layer)))
|
||||
}
|
||||
|
||||
function spawn(registration: string, mode: string, ...args: string[]) {
|
||||
const subprocess = Bun.spawn([process.execPath, fixture, registration, mode, ...args], {
|
||||
stdout: "ignore",
|
||||
stderr: "inherit",
|
||||
})
|
||||
processes.push(subprocess)
|
||||
return subprocess
|
||||
}
|
||||
|
||||
async function temp() {
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-client-service-"))
|
||||
directories.push(directory)
|
||||
return directory
|
||||
}
|
||||
|
||||
async function waitForFile(file: string) {
|
||||
for (let attempt = 0; attempt < 600; attempt++) {
|
||||
if (await Bun.file(file).exists()) return
|
||||
await Bun.sleep(5)
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${file}`)
|
||||
}
|
||||
|
||||
async function health(url: string) {
|
||||
return fetch(new URL("/api/health", url), { signal: AbortSignal.timeout(1_000) }).then((response) => response.json())
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
- Do not add a speculative generic permission or approval policy. A host omits tools it does not expose and enforces domain authorization inside each provided tool.
|
||||
- Keep Code Mode unaware of host session, channel, and conversation models. The hosting application supplies trusted execution scope around it.
|
||||
- Tool schemas are the model-facing Interface. Keep arguments minimal and natural to the operation; never add unrelated IDs as ambient capability tokens.
|
||||
- When interpreter behavior or support changes, update `interpreter-support.md` and direct tests in the same PR. Update `codemode.md` when the package design, integration status, or rationale changes.
|
||||
- When interpreter behavior or support changes, update `interpreter-support.md` and direct tests in the same PR.
|
||||
|
||||
## OpenAPI
|
||||
|
||||
|
||||
@@ -177,8 +177,8 @@ No limit has a default, on purpose: execution budgets are host policy. A host wi
|
||||
interruption should set `maxOutputBytes` and `timeoutMs`. Limits are safe integers; invalid configuration throws a
|
||||
`RangeError` at construction. Exceeding `maxOutputBytes` never fails the execution - oversized output is truncated
|
||||
with an in-band marker. The timeout interrupts in-flight tool fibers and pure busy loops alike; a value the program
|
||||
already returned survives a cleanup timeout as a success with a `TimeoutExceeded` warning. Two internals are fixed
|
||||
constants, not knobs: at most 8 concurrent tool calls, and 32 levels of data nesting at boundaries.
|
||||
already returned survives a cleanup timeout as a success with a `TimeoutExceeded` warning. CodeMode does not limit
|
||||
tool-call concurrency. Data nesting at boundaries is limited to 32 levels.
|
||||
|
||||
## Boundaries and Non-Goals
|
||||
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
# CodeMode Design and Status
|
||||
|
||||
This is the living design and status document for `@opencode-ai/codemode` and its existing V2 OpenCode adapter.
|
||||
It records current behavior, intentional boundaries, durable rationale, and material remaining work.
|
||||
|
||||
Completed implementation history, branch names, test counts, and closed findings belong in git, not here. Remove
|
||||
completed work instead of preserving checked-off chronology.
|
||||
|
||||
Detailed package API documentation lives in [README.md](./README.md), and the checkable language/runtime matrix lives
|
||||
in [interpreter-support.md](./interpreter-support.md). OpenAPI-specific follow-ups live in
|
||||
[src/openapi/TODO.md](./src/openapi/TODO.md).
|
||||
|
||||
## How CodeMode Works
|
||||
|
||||
### Purpose
|
||||
|
||||
CodeMode gives a model one `execute` tool backed by a confined JavaScript interpreter. Inside the program, the model
|
||||
can call an explicit tree of schema-described tools, sequence dependent work, run independent calls concurrently,
|
||||
and filter or aggregate results before returning them to the agent loop.
|
||||
|
||||
The goals are:
|
||||
|
||||
- Reduce model context consumed by large tool catalogs.
|
||||
- Avoid an agent round-trip between every dependent tool call.
|
||||
- Keep large intermediate results inside the program instead of sending them through model context.
|
||||
- Give generated code only the authority explicitly supplied by the host.
|
||||
|
||||
CodeMode is an orchestration language, not a general JavaScript runtime or an application authorization system.
|
||||
|
||||
### Runtime
|
||||
|
||||
The generic runtime lives in `packages/codemode` and is host-neutral:
|
||||
|
||||
1. The host builds a tree of `Tool.make(...)` definitions and calls `CodeMode.make(...)` or `CodeMode.execute(...)`.
|
||||
2. CodeMode generates model instructions, a budgeted inline catalog, and the global `search(...)` built-in.
|
||||
3. TypeScript syntax is transpiled away, Acorn parses the resulting JavaScript, and an owned tree-walking interpreter
|
||||
executes it without `eval`.
|
||||
4. Tool inputs and outputs cross schema and plain-data boundaries before they become visible on either side.
|
||||
5. Execution returns `CodeMode.Result`. Expected program and tool failures are diagnostic data; host interruption
|
||||
remains Effect interruption.
|
||||
|
||||
Effect Schemas validate and transform tool inputs and outputs. JSON Schemas render model-facing signatures but do not
|
||||
validate values; adapter-provided values still cross the plain-data boundary. A tool without an output schema is
|
||||
advertised as `Promise<unknown>`.
|
||||
|
||||
### Discovery and model workflow
|
||||
|
||||
The model sees a token-budgeted catalog. Every namespace remains visible, and complete signatures are selected
|
||||
round-robin across namespaces so one large namespace cannot starve the others. The global `search(...)` built-in is
|
||||
always callable - synchronously, counted as an admitted tool call - and is advertised when the inline catalog is
|
||||
partial.
|
||||
|
||||
The intended workflow is:
|
||||
|
||||
1. Pick an exact signature from the inline catalog, or return `search(...)` results and use a selected path in the
|
||||
next execution.
|
||||
2. Call the exact returned path without guessing or normalizing segments.
|
||||
3. Narrow `Promise<unknown>` results before reading fields.
|
||||
4. Start independent calls together and await them with `Promise.all`.
|
||||
5. Filter and aggregate inside the program, then return only the data needed by the model.
|
||||
|
||||
Search returns directly usable JavaScript paths, descriptions, and complete TypeScript signatures. It supports exact
|
||||
path lookup, namespace browsing, deterministic ranking, and pagination.
|
||||
|
||||
### Tool execution
|
||||
|
||||
Every sandbox promise starts eagerly on a run-once fiber owned by the whole CodeMode execution, including tool calls,
|
||||
async functions, chained `.then`/`.catch`/`.finally` reactions, `new Promise(executor)` constructions, and the
|
||||
`Promise.all`/`allSettled`/`race`/`any`/`resolve`/`reject` statics. Nested functions therefore cannot end the lifetime
|
||||
of work they started.
|
||||
Independent aggregate batches overlap, and rejection is observed at the eventual `await` or chained rejection handler.
|
||||
`Promise.race` and `Promise.any` use native non-cancelling settlement semantics: the deciding member wins while losers
|
||||
continue running, and an all-rejected `Promise.any` rejects with an `AggregateError`. `new Promise(...)` hands the
|
||||
executor first-class resolve/reject callables that may escape and settle the promise later, exactly once.
|
||||
Reaction ordering matches what V8 makes observable - handlers and await continuations are deferred and run in attach
|
||||
order, and a combinator settles one reaction turn after its deciding member - without promising exact microtask-count
|
||||
parity beyond that. At normal completion CodeMode interrupts everything still running - race losers,
|
||||
fail-fast `Promise.all` stragglers, and fire-and-forget calls alike: the program has returned, so no future await can
|
||||
exist, and work whose completion matters must be awaited by the program. Waiting for any class of leftover instead
|
||||
would let it hold the execution open, or deadlock it when queued work needs tool-call permits the leftovers occupy.
|
||||
Rejections that settled un-awaited before the return become `Success.warnings` diagnostics. A fatal program failure or
|
||||
host interruption closes the execution promise scope and interrupts its active fibers instead. A timeout does the
|
||||
same, except that a value the program already returned is preserved alongside a `TimeoutExceeded` warning rather than
|
||||
discarded. At most eight tool calls execute concurrently.
|
||||
|
||||
The public execution-policy knobs are `timeoutMs`, `maxToolCalls`, and `maxOutputBytes`. The package supplies no
|
||||
defaults because budgets are host policy. The interpreter also enforces fixed internal boundaries for tool-call
|
||||
concurrency and data nesting depth. `maxOutputBytes` bounds retained payload bytes, not the complete rendered message;
|
||||
warning diagnostics have an equal separate budget so a large value cannot starve them, and fixed truncation notices and
|
||||
host-added framing are intentionally outside the budgets.
|
||||
|
||||
### Data, files, and failures
|
||||
|
||||
Program results and tool arguments are JSON-like data. Dates become ISO strings at host boundaries; RegExp, Map, and
|
||||
Set values become `{}` as they do under JSON serialization. Promise and runtime reference values cannot cross the
|
||||
boundary.
|
||||
|
||||
Unknown host failures and invalid outputs are sanitized. `ToolError` is the explicit channel for a safe message that a
|
||||
tool wants the model to see. Diagnostic categories distinguish parsing, unsupported syntax, unknown tools, invalid
|
||||
data, tool failures, limits, timeouts, execution failures, and warning truncation.
|
||||
|
||||
Files and other attachment content stay outside the interpreter. A host may collect them while child tools execute and
|
||||
attach them to the outer result, but the program receives only the structured tool output.
|
||||
|
||||
### V2 OpenCode adapter
|
||||
|
||||
CodeMode is integrated into V2 through `packages/core/src/tool/registry.ts` and
|
||||
`packages/core/src/tool/execute.ts`:
|
||||
|
||||
- Core has one canonical `Tool` representation. Location-scoped producers register direct or deferred tools through
|
||||
`Tools.Service`.
|
||||
- Each model step snapshots effective registrations, applies catalog visibility filtering, and exposes direct tools
|
||||
normally.
|
||||
- When visible deferred tools exist, Core reserves and materializes one `execute` tool. Grouped deferred tools become
|
||||
CodeMode namespaces instead of flattened model-facing names.
|
||||
- Nested calls execute the registered `Tool` values captured for the model request; later registrations affect later
|
||||
requests.
|
||||
- Authorization and side-effect ordering remain responsibilities of the leaf tool. Catalog visibility is not execution
|
||||
authorization.
|
||||
- Structured child output enters the interpreter. File parts are collected host-side and attached to the outer result.
|
||||
- Nested call statuses are returned as final `execute` metadata for the TUI.
|
||||
- `execute` is the one model-facing tool invocation. Nested calls reuse its invocation context and do not independently
|
||||
run registry hooks or model-output bounding; this keeps complete intermediate structured values available for
|
||||
in-program filtering. The outer `execute` settlement is the single model-output bounding boundary.
|
||||
- Core supplies no CodeMode timeout or tool-call limit. User cancellation interrupts the outer invocation and its
|
||||
supervised children; the outer settlement applies Core's normal output-retention policy.
|
||||
|
||||
MCP tools use this canonical path: they register as grouped tools and are deferred while CodeMode is enabled. Existing
|
||||
output schemas are preserved in generated signatures. Direct Core tools remain direct and are not ambient globals
|
||||
inside CodeMode.
|
||||
|
||||
## Intentionally Unsupported
|
||||
|
||||
These are product boundaries rather than DSL backlog:
|
||||
|
||||
- Ambient filesystem, process, environment, network, credential, or application access. External work must go through
|
||||
supplied tools.
|
||||
- Modules, imports, dynamic imports, `eval`, arbitrary host globals, npm packages, and prototype mutation.
|
||||
- Generic permission prompts, authorization policy, durable pause/resume, replay, storage, or exactly-once external
|
||||
side effects. Hosts and tools own those concerns.
|
||||
- Heuristic parsing of text tool results as JSON. A result should not silently change type based on its contents.
|
||||
|
||||
The OpenAPI adapter may gain more transports and encodings, but it must continue skipping operations it cannot
|
||||
represent accurately rather than guessing semantics.
|
||||
|
||||
## Decisions and Rationale
|
||||
|
||||
| Decision | Rationale |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Keep an owned tree-walking interpreter. | The product need is bounded tool orchestration, not arbitrary JavaScript. Owning the language surface keeps authority and behavior explicit. |
|
||||
| Treat schemas as the model-facing interface. | Signatures drive correct calls; Effect Schema also provides the runtime validation boundary, while JSON Schema supports adapter interoperability. |
|
||||
| Keep authority host-owned. | CodeMode can only confine programs to supplied tools. The host chooses those tools, and each tool enforces its own authorization and side-effect policy. |
|
||||
| Use progressive catalog disclosure plus search. | Large tool sets should not consume the prompt, but every namespace must remain discoverable and speculative search calls should remain valid. |
|
||||
| Start promises eagerly and supervise them for the execution. | This preserves normal call-time parallelism and run-once settlement while allowing pending work to be interrupted when the program returns. |
|
||||
| Keep files outside the sandbox value space. | Models should compose structured data without routing binary payloads through generated code or context. |
|
||||
| Treat `execute` as the model-facing invocation boundary. | Nested calls are implementation details of one orchestration program. Reusing the outer context and bounding only the final result preserves complete intermediate data without inventing durable child-call identities. |
|
||||
| Return expected failures as data. | Models need actionable diagnostics without exposing private host causes; host interruption and defects must still propagate correctly. |
|
||||
| Leave execution-limit defaults to hosts. | Appropriate budgets depend on the surrounding product and its own cancellation, retention, and output-bounding policies. |
|
||||
| Skip unsupported OpenAPI operations. | Incorrect parameter encoding, authentication, or transport behavior is worse than a precise `skipped` reason. |
|
||||
|
||||
## Remaining Work
|
||||
|
||||
The [interpreter support checklist](./interpreter-support.md) owns concrete DSL, standard-library, semantic-correctness,
|
||||
diagnostic, and data-boundary work. OpenAPI adapter work remains in [src/openapi/TODO.md](./src/openapi/TODO.md).
|
||||
@@ -24,7 +24,7 @@ ultimate source of truth.
|
||||
- [x] Tool calls through the host-provided `tools` tree only.
|
||||
- [x] The global `search(...)` built-in: synchronous tool discovery that counts as an admitted tool call and is
|
||||
shadowable by program declarations like other globals.
|
||||
- [x] Cooperative timeout, tool-call accounting, output bounding, and a maximum of eight concurrent tool calls.
|
||||
- [x] Cooperative timeout, an optional total tool-call limit, output bounding, and unrestricted tool-call concurrency.
|
||||
- [ ] Full JavaScript or TypeScript compatibility. CodeMode is a bounded orchestration language.
|
||||
|
||||
## Values and literals
|
||||
@@ -176,9 +176,9 @@ ultimate source of truth.
|
||||
## Strings
|
||||
|
||||
- [x] Case/normalization: `toLowerCase`, `toUpperCase`, `normalize`.
|
||||
- [x] Trimming: `trim`, `trimStart`, `trimEnd`, `trimLeft`, and `trimRight`.
|
||||
- [x] Trimming: `trim`, `trimStart`, and `trimEnd`.
|
||||
- [x] Searching/tests: `includes`, `startsWith`, `endsWith`, `indexOf`, `lastIndexOf`, and `search`.
|
||||
- [x] Slicing/access: `slice`, `substring`, `substr`, `at`, `charAt`, `charCodeAt`, and `codePointAt`.
|
||||
- [x] Slicing/access: `slice`, `substring`, `at`, `charAt`, `charCodeAt`, and `codePointAt`.
|
||||
- [x] Construction/transformation: `split`, `concat`, `repeat`, `padStart`, `padEnd`, `replace`, and `replaceAll`.
|
||||
- [x] Regular-expression integration: `match`, materialized `matchAll`, `replace`, `replaceAll`, `split`, and `search`.
|
||||
- [x] `localeCompare`; locale and options arguments are currently ignored.
|
||||
@@ -291,7 +291,6 @@ ultimate source of truth.
|
||||
These are actionable implementation items. Check them off only when behavior and direct tests land.
|
||||
|
||||
- [x] Return real promises from `Promise.all`, `Promise.allSettled`, and `Promise.race`.
|
||||
- [ ] Bound pending tool-call admission/allocation in addition to execution concurrency.
|
||||
- [ ] Guarantee every advertised tool path is executable, including dotted and blocked path segments.
|
||||
- [ ] Define safe outbound handling for non-finite numbers and `undefined` so invalid values cannot silently become
|
||||
`null` in render-only or OpenAPI tool calls.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { executeWithLimits } from "./interpreter/runtime.js"
|
||||
import { executeWithLimits } from "./interpreter/execute.js"
|
||||
import { type HostTools, type Services, type ToolDescription, ToolRuntime } from "./tool-runtime.js"
|
||||
import type { Definition } from "./tool.js"
|
||||
|
||||
@@ -9,15 +9,15 @@ export type { ToolCall, ToolCallEnded, ToolCallHooks, ToolCallStarted, ToolDescr
|
||||
/** Resource budgets enforced independently during each CodeMode program execution. */
|
||||
export type ExecutionLimits = {
|
||||
/**
|
||||
* Wall-clock milliseconds before execution is interrupted; result delivery additionally
|
||||
* waits for tool interruption cleanup. No default: absent means no timeout.
|
||||
* Wall-clock milliseconds before interruption. Result delivery waits for tool cleanup.
|
||||
* No default: absent means no timeout.
|
||||
*/
|
||||
readonly timeoutMs?: number
|
||||
/** Maximum number of tool calls admitted by the runtime. No default: absent means unlimited. */
|
||||
readonly maxToolCalls?: number
|
||||
/**
|
||||
* Maximum UTF-8 bytes retained from the result value and logs; warnings have a separate
|
||||
* budget of the same size. Fixed truncation notices and host formatting are additional.
|
||||
* Maximum UTF-8 bytes retained from the result and logs. Warnings have a separate equal budget;
|
||||
* truncation notices and host formatting are additional.
|
||||
*/
|
||||
readonly maxOutputBytes?: number
|
||||
}
|
||||
@@ -94,7 +94,6 @@ const ToolCallSchema = Schema.Struct({ name: Schema.String })
|
||||
export const Success = Schema.Struct({
|
||||
ok: Schema.Literal(true),
|
||||
value: Schema.Json,
|
||||
// Runtime-authored non-fatal diagnostics; program console output stays in `logs`.
|
||||
warnings: Schema.optionalKey(Schema.Array(Diagnostic)),
|
||||
logs: Schema.optionalKey(Schema.Array(Schema.String)),
|
||||
truncated: Schema.optionalKey(Schema.Boolean),
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { Diagnostic } from "../codemode.js"
|
||||
import { ToolError } from "../tool-error.js"
|
||||
import { copyOut, ToolRuntimeError, type SafeObject } from "../tool-runtime.js"
|
||||
import { type AstNode, formatLocation, InterpreterRuntimeError, ProgramThrow, sourceLocation } from "./model.js"
|
||||
import { containsRuntimeReference } from "./references.js"
|
||||
import { spreadItems } from "../stdlib/collections.js"
|
||||
import { coerceToString, createAggregateErrorValue, createErrorValue, errorConstructors } from "../stdlib/value.js"
|
||||
|
||||
export const normalizeError = (error: unknown): Diagnostic => {
|
||||
if (error instanceof InterpreterRuntimeError) {
|
||||
return {
|
||||
kind: error.kind,
|
||||
message: `${error.message}${formatLocation(error.node)}`,
|
||||
...(error.node?.loc ? { location: sourceLocation(error.node) } : {}),
|
||||
...(error.suggestions ? { suggestions: error.suggestions } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
if (error instanceof ToolRuntimeError) {
|
||||
return {
|
||||
kind: error.kind,
|
||||
message: error.message,
|
||||
...(error.suggestions.length > 0 ? { suggestions: error.suggestions } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
if (error instanceof ToolError) {
|
||||
return { kind: "ToolFailure", message: error.message }
|
||||
}
|
||||
|
||||
if (error instanceof ProgramThrow) {
|
||||
const value = error.value
|
||||
let message: string
|
||||
if (containsRuntimeReference(value)) {
|
||||
// Never expose runtime reference internals through thrown values.
|
||||
message = "a non-data value"
|
||||
} else if (typeof value === "string") {
|
||||
message = value
|
||||
} else if (
|
||||
value !== null &&
|
||||
typeof value === "object" &&
|
||||
typeof (value as { message?: unknown }).message === "string"
|
||||
) {
|
||||
message = (value as { message: string }).message
|
||||
} else {
|
||||
try {
|
||||
message = JSON.stringify(copyOut(value)) ?? String(value)
|
||||
} catch {
|
||||
message = String(value)
|
||||
}
|
||||
}
|
||||
return { kind: "ExecutionFailure", message: `Uncaught: ${message}` }
|
||||
}
|
||||
|
||||
if (error instanceof RangeError && /call stack|recursion/i.test(error.message)) {
|
||||
return {
|
||||
kind: "ExecutionFailure",
|
||||
message: "Execution exceeded the maximum nesting depth.",
|
||||
}
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
kind: error.name === "SyntaxError" ? "ParseError" : "ExecutionFailure",
|
||||
message: error.message,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "ExecutionFailure",
|
||||
message: String(error),
|
||||
}
|
||||
}
|
||||
|
||||
export const caughtErrorValue = (thrown: unknown): unknown => {
|
||||
if (thrown instanceof ProgramThrow) return thrown.value
|
||||
if (thrown instanceof InterpreterRuntimeError) return createErrorValue(thrown.errorName, thrown.message)
|
||||
const name = thrown instanceof Error && errorConstructors.has(thrown.name) ? thrown.name : "Error"
|
||||
return createErrorValue(name, normalizeError(thrown).message)
|
||||
}
|
||||
|
||||
export const constructErrorValue = (name: string, args: Array<unknown>, node: AstNode): SafeObject => {
|
||||
if (name !== "AggregateError") return createErrorValue(name, args[0] === undefined ? "" : coerceToString(args[0]))
|
||||
const errors = spreadItems(args[0])
|
||||
if (errors === undefined) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"new AggregateError(...) expects an array of errors (e.g. new AggregateError(errors, message?)).",
|
||||
node,
|
||||
).as("TypeError")
|
||||
}
|
||||
// Error values must not alias caller-owned arrays.
|
||||
return createAggregateErrorValue([...errors], args[1] === undefined ? "" : coerceToString(args[1]))
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { parse } from "acorn"
|
||||
import { Cause, Effect, Scope } from "effect"
|
||||
import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript"
|
||||
import type { DataValue, Diagnostic, ExecuteOptions, ResolvedExecutionLimits, Result } from "../codemode.js"
|
||||
import { copyIn, copyOut, ToolRuntime, type HostTools, type Services } from "../tool-runtime.js"
|
||||
import { normalizeError } from "./errors.js"
|
||||
import { InterpreterRuntimeError, isRecord, type ProgramNode } from "./model.js"
|
||||
import { PromiseRuntime } from "./promises.js"
|
||||
import { Interpreter } from "./runtime.js"
|
||||
|
||||
export const executeWithLimits = <const Tools extends Record<string, unknown>>(
|
||||
options: ExecuteOptions<Tools>,
|
||||
limits: ResolvedExecutionLimits,
|
||||
searchIndex: ToolRuntime.DiscoveryPlan["searchIndex"],
|
||||
): Effect.Effect<Result, never, Services<Tools>> => {
|
||||
if (options.code.trim().length === 0) {
|
||||
return Effect.succeed({
|
||||
ok: false,
|
||||
error: { kind: "ParseError", message: "Code cannot be empty." },
|
||||
toolCalls: [],
|
||||
})
|
||||
}
|
||||
|
||||
// Allocate execution state inside suspension so reused Effects never share it.
|
||||
return Effect.suspend(() => {
|
||||
const tools = ToolRuntime.make(
|
||||
(options.tools ?? {}) as HostTools<Services<Tools>>,
|
||||
limits.maxToolCalls,
|
||||
searchIndex,
|
||||
{
|
||||
onToolCallStart: options.onToolCallStart,
|
||||
onToolCallEnd: options.onToolCallEnd,
|
||||
},
|
||||
)
|
||||
const logs: Array<string> = []
|
||||
const logged = () => (logs.length > 0 ? { logs: [...logs] } : {})
|
||||
// Set only after copy-out so timeouts cannot report invalid values as completed.
|
||||
let returned: { value: DataValue; promises: PromiseRuntime<Services<Tools>> } | undefined
|
||||
|
||||
const base = Effect.acquireUseRelease(
|
||||
Scope.make("parallel"),
|
||||
(scope) =>
|
||||
Effect.gen(function* () {
|
||||
const program = parseProgram(options.code)
|
||||
const promises = new PromiseRuntime<Services<Tools>>(scope)
|
||||
const interpreter = new Interpreter<Services<Tools>>(tools.invoke, tools.search, tools.keys, promises, logs)
|
||||
const value = yield* interpreter.run(program)
|
||||
const result = copyOut(copyIn(value, "Execution result"), true) as DataValue
|
||||
returned = { value: result, promises }
|
||||
const warnings = yield* promises.interrupt()
|
||||
return {
|
||||
ok: true,
|
||||
value: result,
|
||||
...(warnings.length > 0 ? { warnings } : {}),
|
||||
...logged(),
|
||||
toolCalls: tools.calls,
|
||||
} satisfies Result
|
||||
}),
|
||||
(scope, exit) => Scope.close(scope, exit),
|
||||
)
|
||||
const timeoutMs = limits.timeoutMs
|
||||
const operation =
|
||||
timeoutMs === undefined
|
||||
? base
|
||||
: base.pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: timeoutMs,
|
||||
orElse: () =>
|
||||
Effect.sync(() => {
|
||||
if (returned === undefined) {
|
||||
return {
|
||||
ok: false,
|
||||
error: { kind: "TimeoutExceeded", message: `Execution timed out after ${timeoutMs}ms.` },
|
||||
...logged(),
|
||||
toolCalls: tools.calls,
|
||||
} satisfies Result
|
||||
}
|
||||
// Keep the timeout warning first so truncation preserves it.
|
||||
return {
|
||||
ok: true,
|
||||
value: returned.value,
|
||||
warnings: [
|
||||
{
|
||||
kind: "TimeoutExceeded",
|
||||
message: `The program returned, but background work was still running at the ${timeoutMs}ms timeout and was interrupted. Await all started promises.`,
|
||||
},
|
||||
...returned.promises.diagnostics(),
|
||||
],
|
||||
...logged(),
|
||||
toolCalls: tools.calls,
|
||||
} satisfies Result
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
return operation.pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Cause.hasInterruptsOnly(cause)
|
||||
? Effect.interrupt
|
||||
: Effect.succeed({
|
||||
ok: false,
|
||||
error: normalizeError(Cause.squash(cause)),
|
||||
...logged(),
|
||||
toolCalls: tools.calls,
|
||||
} satisfies Result),
|
||||
),
|
||||
Effect.map((result) =>
|
||||
limits.maxOutputBytes === undefined ? result : boundOutput(result, limits.maxOutputBytes),
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const parseProgram = (code: string): ProgramNode => {
|
||||
const transpiled = transpileModule(`async function __codemode__() {\n${code}\n}`, {
|
||||
reportDiagnostics: true,
|
||||
compilerOptions: {
|
||||
target: ScriptTarget.ESNext,
|
||||
module: ModuleKind.ESNext,
|
||||
},
|
||||
})
|
||||
const diagnostic = transpiled.diagnostics?.find((item) => item.category === DiagnosticCategory.Error)
|
||||
|
||||
if (diagnostic) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`Failed to parse TypeScript: ${flattenDiagnosticMessageText(diagnostic.messageText, "\n")}`,
|
||||
undefined,
|
||||
"ParseError",
|
||||
)
|
||||
}
|
||||
|
||||
const bodyStart = transpiled.outputText.indexOf("{") + 1
|
||||
const bodyEnd = transpiled.outputText.lastIndexOf("}")
|
||||
const executableCode = transpiled.outputText.slice(bodyStart, bodyEnd)
|
||||
const parsed = parse(executableCode, {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "script",
|
||||
allowReturnOutsideFunction: true,
|
||||
allowAwaitOutsideFunction: true,
|
||||
locations: true,
|
||||
}) as unknown
|
||||
|
||||
if (!isRecord(parsed) || parsed.type !== "Program" || !Array.isArray(parsed.body)) {
|
||||
throw new InterpreterRuntimeError("Failed to parse script as a Program node.")
|
||||
}
|
||||
|
||||
return parsed as ProgramNode
|
||||
}
|
||||
|
||||
const utf8ByteLength = (value: string): number => new TextEncoder().encode(value).byteLength
|
||||
|
||||
// Drop a replacement character produced by truncating inside a UTF-8 sequence.
|
||||
const utf8Truncate = (value: string, maxBytes: number): string => {
|
||||
const bytes = new TextEncoder().encode(value)
|
||||
if (bytes.byteLength <= maxBytes) return value
|
||||
const text = new TextDecoder("utf-8").decode(bytes.slice(0, Math.max(0, maxBytes)))
|
||||
return text.endsWith("\uFFFD") ? text.slice(0, -1) : text
|
||||
}
|
||||
|
||||
// Warnings have a separate budget so result data cannot starve diagnostics.
|
||||
const boundOutput = (result: Result, maxOutputBytes: number): Result => {
|
||||
let truncated = false
|
||||
|
||||
let value: DataValue = null
|
||||
let valueBytes = 0
|
||||
if (result.ok) {
|
||||
const serialized = JSON.stringify(result.value) ?? "null"
|
||||
const bytes = utf8ByteLength(serialized)
|
||||
if (bytes > maxOutputBytes) {
|
||||
truncated = true
|
||||
value = `${utf8Truncate(serialized, maxOutputBytes)} [result truncated: ${bytes} bytes exceeds the ${maxOutputBytes}-byte output limit; return a smaller value]`
|
||||
valueBytes = maxOutputBytes
|
||||
} else {
|
||||
value = result.value
|
||||
valueBytes = bytes
|
||||
}
|
||||
}
|
||||
|
||||
const warnings = result.ok ? (result.warnings ?? []) : []
|
||||
const keptWarnings: Array<Diagnostic> = []
|
||||
let warningBytes = 0
|
||||
for (const warning of warnings) {
|
||||
const bytes = utf8ByteLength(JSON.stringify(warning)) + 1
|
||||
if (warningBytes + bytes > maxOutputBytes) break
|
||||
warningBytes += bytes
|
||||
keptWarnings.push(warning)
|
||||
}
|
||||
if (keptWarnings.length < warnings.length) {
|
||||
truncated = true
|
||||
keptWarnings.push({
|
||||
kind: "Truncated",
|
||||
message: `${warnings.length - keptWarnings.length} additional warnings omitted by the output limit.`,
|
||||
})
|
||||
}
|
||||
|
||||
const logs = result.logs ?? []
|
||||
const kept: Array<string> = []
|
||||
const logBudget = Math.max(0, maxOutputBytes - valueBytes)
|
||||
let logBytes = 0
|
||||
for (const line of logs) {
|
||||
const lineBytes = utf8ByteLength(line) + 1
|
||||
if (logBytes + lineBytes > logBudget) break
|
||||
logBytes += lineBytes
|
||||
kept.push(line)
|
||||
}
|
||||
if (kept.length < logs.length) {
|
||||
truncated = true
|
||||
kept.push(`[logs truncated: showing ${kept.length} of ${logs.length} lines]`)
|
||||
}
|
||||
|
||||
if (!truncated) return result
|
||||
const warningsPart = keptWarnings.length > 0 ? { warnings: keptWarnings } : {}
|
||||
const logsPart = kept.length > 0 ? { logs: kept } : {}
|
||||
return result.ok
|
||||
? {
|
||||
ok: true,
|
||||
value,
|
||||
...warningsPart,
|
||||
...logsPart,
|
||||
truncated: true,
|
||||
toolCalls: result.toolCalls,
|
||||
}
|
||||
: { ok: false, error: result.error, ...logsPart, truncated: true, toolCalls: result.toolCalls }
|
||||
}
|
||||
@@ -0,0 +1,819 @@
|
||||
import { Effect } from "effect"
|
||||
import {
|
||||
type AstNode,
|
||||
CodeModeFunction,
|
||||
CoercionFunction,
|
||||
GlobalMethodReference,
|
||||
IntrinsicReference,
|
||||
InterpreterRuntimeError,
|
||||
PromiseCapabilityFunction,
|
||||
supportedSyntaxMessage,
|
||||
UriFunction,
|
||||
} from "./model.js"
|
||||
import { rejectCircularInsertion } from "./references.js"
|
||||
import { isBlockedMember, type SafeObject } from "../tool-runtime.js"
|
||||
import {
|
||||
SandboxDate,
|
||||
SandboxMap,
|
||||
SandboxPromise,
|
||||
SandboxRegExp,
|
||||
SandboxSet,
|
||||
SandboxURL,
|
||||
SandboxURLSearchParams,
|
||||
} from "../values.js"
|
||||
import { invokeDateMethod, invokeDateStatic } from "../stdlib/date.js"
|
||||
import { invokeJsonMethod } from "../stdlib/json.js"
|
||||
import { invokeMathMethod } from "../stdlib/math.js"
|
||||
import { invokeNumberMethod, invokeNumberStatic } from "../stdlib/number.js"
|
||||
import { invokeObjectMethod } from "../stdlib/object.js"
|
||||
import { invokeRegExpMethod, matchToValue, toHostRegex } from "../stdlib/regexp.js"
|
||||
import { invokeStringStatic } from "../stdlib/string.js"
|
||||
import { invokeUriFunction, invokeURLMethod, invokeURLStatic, uriArgument } from "../stdlib/url.js"
|
||||
import { boundedData, coerceToNumber, coerceToString, invokeCoercion } from "../stdlib/value.js"
|
||||
|
||||
export type CallbackRunner<R> = {
|
||||
readonly invokeFunction: (fn: CodeModeFunction, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
||||
readonly settlePromise: (promise: SandboxPromise) => Effect.Effect<unknown, unknown, never>
|
||||
}
|
||||
|
||||
export const invokeIntrinsic = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
ref: IntrinsicReference,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
): Effect.Effect<unknown, unknown, R> => {
|
||||
if (typeof ref.receiver === "string") {
|
||||
if (
|
||||
(ref.name === "replace" || ref.name === "replaceAll") &&
|
||||
(args[1] instanceof CodeModeFunction || args[1] instanceof CoercionFunction || args[1] instanceof UriFunction)
|
||||
) {
|
||||
return invokeStringReplacer(runner, ref.receiver, ref.name, args, node)
|
||||
}
|
||||
return Effect.succeed(invokeStringMethod(ref.receiver, ref.name, args, node))
|
||||
}
|
||||
if (typeof ref.receiver === "number") {
|
||||
return Effect.succeed(invokeNumberMethod(ref.receiver, ref.name, args, node))
|
||||
}
|
||||
if (Array.isArray(ref.receiver)) {
|
||||
return invokeArrayMethod(runner, ref.receiver, ref.name, args, node)
|
||||
}
|
||||
if (ref.receiver instanceof SandboxDate) {
|
||||
return Effect.succeed(invokeDateMethod(ref.receiver, ref.name, node))
|
||||
}
|
||||
if (ref.receiver instanceof SandboxRegExp) {
|
||||
return Effect.succeed(invokeRegExpMethod(ref.receiver, ref.name, args, node))
|
||||
}
|
||||
if (ref.receiver instanceof SandboxMap) {
|
||||
return invokeMapMethod(runner, ref.receiver, ref.name, args, node)
|
||||
}
|
||||
if (ref.receiver instanceof SandboxSet) {
|
||||
return invokeSetMethod(runner, ref.receiver, ref.name, args, node)
|
||||
}
|
||||
if (ref.receiver instanceof SandboxURL) {
|
||||
return Effect.succeed(invokeURLMethod(ref.receiver, ref.name, node))
|
||||
}
|
||||
if (ref.receiver instanceof SandboxURLSearchParams) {
|
||||
return invokeURLSearchParamsMethod(runner, ref.receiver, ref.name, args, node)
|
||||
}
|
||||
throw new InterpreterRuntimeError(`Method '${ref.name}' is not available in CodeMode.`, node)
|
||||
}
|
||||
|
||||
export const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array<unknown>, node: AstNode): unknown => {
|
||||
if (ref.namespace === "console")
|
||||
throw new InterpreterRuntimeError(`console.${ref.name} is not available in CodeMode.`, node)
|
||||
if (ref.namespace === "Object") return invokeObjectMethod(ref.name, args, node)
|
||||
if (ref.namespace === "Math") return invokeMathMethod(ref.name, args, node)
|
||||
if (ref.namespace === "Array") return invokeArrayStatic(ref.name, args, node)
|
||||
if (ref.namespace === "Number") return invokeNumberStatic(ref.name, args, node)
|
||||
if (ref.namespace === "String") return invokeStringStatic(ref.name, args, node)
|
||||
if (ref.namespace === "URL") return invokeURLStatic(ref.name, args, node)
|
||||
if (ref.namespace === "Date") return invokeDateStatic(ref.name, args, node)
|
||||
if (
|
||||
ref.namespace === "RegExp" ||
|
||||
ref.namespace === "Map" ||
|
||||
ref.namespace === "Set" ||
|
||||
ref.namespace === "URLSearchParams"
|
||||
) {
|
||||
throw new InterpreterRuntimeError(`${ref.namespace}.${ref.name} is not available in CodeMode.`, node)
|
||||
}
|
||||
return invokeJsonMethod(ref.name, args, node)
|
||||
}
|
||||
|
||||
const invokeStringMethod = (value: string, name: string, args: Array<unknown>, node: AstNode): unknown => {
|
||||
const str = (index: number): string => {
|
||||
const arg = args[index]
|
||||
if (typeof arg !== "string")
|
||||
throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a string.`, node)
|
||||
return arg
|
||||
}
|
||||
const num = (index: number): number => {
|
||||
const arg = args[index]
|
||||
if (typeof arg !== "number")
|
||||
throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a number.`, node)
|
||||
return arg
|
||||
}
|
||||
const optNum = (index: number): number | undefined => (args[index] === undefined ? undefined : num(index))
|
||||
const optStr = (index: number): string | undefined => (args[index] === undefined ? undefined : str(index))
|
||||
|
||||
let result: unknown
|
||||
switch (name) {
|
||||
case "toLowerCase":
|
||||
result = value.toLowerCase()
|
||||
break
|
||||
case "toUpperCase":
|
||||
result = value.toUpperCase()
|
||||
break
|
||||
case "trim":
|
||||
result = value.trim()
|
||||
break
|
||||
case "trimStart":
|
||||
result = value.trimStart()
|
||||
break
|
||||
case "trimEnd":
|
||||
result = value.trimEnd()
|
||||
break
|
||||
// Locale/options are deliberately unsupported; comparison uses the host default locale.
|
||||
case "localeCompare":
|
||||
result = value.localeCompare(str(0))
|
||||
break
|
||||
case "normalize": {
|
||||
const form = optStr(0)
|
||||
try {
|
||||
result = value.normalize(form)
|
||||
} catch {
|
||||
throw new InterpreterRuntimeError(
|
||||
`String.normalize expects the form "NFC", "NFD", "NFKC", or "NFKD" (got ${JSON.stringify(form)}).`,
|
||||
node,
|
||||
).as("RangeError")
|
||||
}
|
||||
break
|
||||
}
|
||||
case "split": {
|
||||
if (args.length === 0) {
|
||||
result = [value]
|
||||
break
|
||||
}
|
||||
if (args[0] instanceof SandboxRegExp) {
|
||||
result = value.split(args[0].regex, optNum(1))
|
||||
break
|
||||
}
|
||||
const requestedLimit = optNum(1)
|
||||
result = value.split(str(0), requestedLimit === undefined ? undefined : requestedLimit >>> 0)
|
||||
break
|
||||
}
|
||||
case "slice":
|
||||
result = value.slice(optNum(0), optNum(1))
|
||||
break
|
||||
case "includes":
|
||||
result = value.includes(str(0), optNum(1))
|
||||
break
|
||||
case "startsWith":
|
||||
result = value.startsWith(str(0), optNum(1))
|
||||
break
|
||||
case "endsWith":
|
||||
result = value.endsWith(str(0), optNum(1))
|
||||
break
|
||||
case "indexOf":
|
||||
result = value.indexOf(str(0), optNum(1))
|
||||
break
|
||||
case "lastIndexOf":
|
||||
result = value.lastIndexOf(str(0), optNum(1))
|
||||
break
|
||||
case "replace":
|
||||
case "replaceAll": {
|
||||
if (args[0] instanceof SandboxRegExp) {
|
||||
const pattern = args[0].regex
|
||||
const replacement = str(1)
|
||||
if (name === "replaceAll" && !pattern.global) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`String.replaceAll requires a regular expression with the global (g) flag: write /${pattern.source}/${pattern.flags}g, or use String.replace to replace only the first match.`,
|
||||
node,
|
||||
)
|
||||
}
|
||||
result = name === "replace" ? value.replace(pattern, replacement) : value.replaceAll(pattern, replacement)
|
||||
break
|
||||
}
|
||||
if (name === "replace") {
|
||||
result = value.replace(str(0), str(1))
|
||||
break
|
||||
}
|
||||
result = value.replaceAll(str(0), str(1))
|
||||
break
|
||||
}
|
||||
case "match": {
|
||||
const pattern = toHostRegex(args[0], name, node)
|
||||
const matched = value.match(pattern)
|
||||
if (matched === null) return null
|
||||
// Preserve the own `index` and `groups` properties on non-global matches.
|
||||
if (pattern.global) return boundedData(matched, "String.match result")
|
||||
return matchToValue(matched)
|
||||
}
|
||||
case "matchAll": {
|
||||
const pattern = toHostRegex(args[0], name, node, "g")
|
||||
if (!pattern.global) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`String.matchAll requires a regular expression with the global (g) flag: write /${pattern.source}/${pattern.flags}g, or use String.match for a single match.`,
|
||||
node,
|
||||
)
|
||||
}
|
||||
return Array.from(value.matchAll(pattern), matchToValue)
|
||||
}
|
||||
case "search": {
|
||||
result = value.search(toHostRegex(args[0], name, node))
|
||||
break
|
||||
}
|
||||
case "repeat": {
|
||||
const count = num(0)
|
||||
if (!Number.isFinite(count) || count < 0)
|
||||
throw new InterpreterRuntimeError("String.repeat expects a finite non-negative count.", node)
|
||||
result = value.repeat(count)
|
||||
break
|
||||
}
|
||||
case "padStart":
|
||||
result = value.padStart(num(0), optStr(1))
|
||||
break
|
||||
case "padEnd":
|
||||
result = value.padEnd(num(0), optStr(1))
|
||||
break
|
||||
case "charAt":
|
||||
result = value.charAt(optNum(0) ?? 0)
|
||||
break
|
||||
case "at":
|
||||
result = value.at(optNum(0) ?? 0)
|
||||
break
|
||||
case "substring":
|
||||
result = value.substring(optNum(0) ?? 0, optNum(1))
|
||||
break
|
||||
case "charCodeAt":
|
||||
result = value.charCodeAt(optNum(0) ?? 0)
|
||||
break
|
||||
case "codePointAt":
|
||||
result = value.codePointAt(optNum(0) ?? 0)
|
||||
break
|
||||
case "toString":
|
||||
result = value
|
||||
break
|
||||
case "concat": {
|
||||
result = value.concat(...args.map((_, index) => str(index)))
|
||||
break
|
||||
}
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`String method '${name}' is not available in CodeMode.`, node)
|
||||
}
|
||||
return boundedData(result, `String.${name} result`)
|
||||
}
|
||||
|
||||
const invokeArrayStatic = (name: string, args: Array<unknown>, node: AstNode): unknown => {
|
||||
switch (name) {
|
||||
case "isArray":
|
||||
return Array.isArray(args[0])
|
||||
case "of":
|
||||
return [...args]
|
||||
case "from": {
|
||||
if (args.length > 1) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"Array.from(...) does not support a map function in CodeMode; call .map() on the result instead.",
|
||||
node,
|
||||
"UnsupportedSyntax",
|
||||
[supportedSyntaxMessage],
|
||||
)
|
||||
}
|
||||
if (args[0] instanceof SandboxMap) return Array.from(args[0].map.entries(), ([key, item]) => [key, item])
|
||||
if (args[0] instanceof SandboxSet) return Array.from(args[0].set.values())
|
||||
if (args[0] instanceof SandboxURLSearchParams) {
|
||||
return Array.from(args[0].params.entries(), ([key, value]) => [key, value])
|
||||
}
|
||||
const source = args[0]
|
||||
if (source instanceof SandboxPromise) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"Array.from received an un-awaited Promise; await it before creating the array.",
|
||||
node,
|
||||
"InvalidDataValue",
|
||||
)
|
||||
}
|
||||
if (typeof source === "string") return Array.from(source)
|
||||
if (Array.isArray(source)) return [...source]
|
||||
if (
|
||||
source !== null &&
|
||||
typeof source === "object" &&
|
||||
(Object.getPrototypeOf(source) === Object.prototype || Object.getPrototypeOf(source) === null) &&
|
||||
typeof (source as { length?: unknown }).length === "number"
|
||||
) {
|
||||
return Array.from(source as ArrayLike<unknown>)
|
||||
}
|
||||
throw new InterpreterRuntimeError(
|
||||
"Array.from expects an array, string, Map, Set, or array-like value.",
|
||||
node,
|
||||
"InvalidDataValue",
|
||||
)
|
||||
}
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`Array.${name} is not available in CodeMode.`, node)
|
||||
}
|
||||
}
|
||||
|
||||
const invokeStringReplacer = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
value: string,
|
||||
name: "replace" | "replaceAll",
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
): Effect.Effect<unknown, unknown, R> => {
|
||||
const apply = applyCollectionCallback(runner, args[1], `String.${name}`, node)
|
||||
const matches: Array<{ readonly match: string; readonly offset: number; readonly args: Array<unknown> }> = []
|
||||
const collect = (...callbackArgs: Array<unknown>): string => {
|
||||
const match = callbackArgs[0]
|
||||
const groups = callbackArgs[callbackArgs.length - 1]
|
||||
const hasGroups = groups !== null && typeof groups === "object"
|
||||
const offset = callbackArgs[callbackArgs.length - (hasGroups ? 3 : 2)]
|
||||
if (typeof match !== "string" || typeof offset !== "number") {
|
||||
throw new InterpreterRuntimeError(`String.${name} produced an invalid replacement match.`, node)
|
||||
}
|
||||
if (hasGroups) {
|
||||
const safeGroups: SafeObject = Object.create(null) as SafeObject
|
||||
for (const [key, group] of Object.entries(groups)) {
|
||||
if (!isBlockedMember(key)) safeGroups[key] = group
|
||||
}
|
||||
callbackArgs[callbackArgs.length - 1] = safeGroups
|
||||
}
|
||||
matches.push({ match, offset, args: callbackArgs })
|
||||
return match
|
||||
}
|
||||
|
||||
const pattern = args[0]
|
||||
if (pattern instanceof SandboxRegExp) {
|
||||
if (name === "replaceAll" && !pattern.regex.global) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`String.replaceAll requires a regular expression with the global (g) flag: write /${pattern.regex.source}/${pattern.regex.flags}g, or use String.replace to replace only the first match.`,
|
||||
node,
|
||||
)
|
||||
}
|
||||
if (name === "replace") value.replace(pattern.regex, collect)
|
||||
else value.replaceAll(pattern.regex, collect)
|
||||
} else {
|
||||
if (typeof pattern !== "string") {
|
||||
throw new InterpreterRuntimeError(`String.${name} expects argument 1 to be a string.`, node)
|
||||
}
|
||||
if (name === "replace") value.replace(pattern, collect)
|
||||
else value.replaceAll(pattern, collect)
|
||||
}
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const output: Array<string> = []
|
||||
let end = 0
|
||||
for (const match of matches) {
|
||||
const replacement = yield* apply(match.args)
|
||||
const resolved =
|
||||
args[1] instanceof CodeModeFunction && args[1].async && replacement instanceof SandboxPromise
|
||||
? yield* runner.settlePromise(replacement)
|
||||
: replacement
|
||||
output.push(
|
||||
value.slice(end, match.offset),
|
||||
coerceToString(boundedData(resolved, `String.${name} replacer result`)),
|
||||
)
|
||||
end = match.offset + match.match.length
|
||||
}
|
||||
output.push(value.slice(end))
|
||||
return boundedData(output.join(""), `String.${name} result`)
|
||||
})
|
||||
}
|
||||
|
||||
export const applyCollectionCallback = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
callback: unknown,
|
||||
name: string,
|
||||
node: AstNode,
|
||||
): ((args: Array<unknown>) => Effect.Effect<unknown, unknown, R>) => {
|
||||
if (
|
||||
!(callback instanceof CodeModeFunction) &&
|
||||
!(callback instanceof CoercionFunction) &&
|
||||
!(callback instanceof UriFunction) &&
|
||||
!(callback instanceof PromiseCapabilityFunction)
|
||||
) {
|
||||
throw new InterpreterRuntimeError(`${name} expects a function callback.`, node)
|
||||
}
|
||||
return (callbackArgs) =>
|
||||
callback instanceof CoercionFunction
|
||||
? Effect.succeed(invokeCoercion(callback, callbackArgs, node))
|
||||
: callback instanceof UriFunction
|
||||
? Effect.succeed(invokeUriFunction(callback, callbackArgs, node))
|
||||
: callback instanceof PromiseCapabilityFunction
|
||||
? Effect.sync(() => callback.settle(callbackArgs[0]))
|
||||
: runner.invokeFunction(callback, callbackArgs)
|
||||
}
|
||||
|
||||
const invokeMapMethod = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
target: SandboxMap,
|
||||
name: string,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
): Effect.Effect<unknown, unknown, R> => {
|
||||
switch (name) {
|
||||
case "get":
|
||||
return Effect.succeed(target.map.get(args[0]))
|
||||
case "has":
|
||||
return Effect.succeed(target.map.has(args[0]))
|
||||
case "set":
|
||||
return Effect.sync(() => {
|
||||
target.map.set(args[0], args[1])
|
||||
return target
|
||||
})
|
||||
case "delete":
|
||||
return Effect.sync(() => target.map.delete(args[0]))
|
||||
case "clear":
|
||||
return Effect.sync(() => {
|
||||
target.map.clear()
|
||||
return undefined
|
||||
})
|
||||
case "keys":
|
||||
return Effect.sync(() => Array.from(target.map.keys()))
|
||||
case "values":
|
||||
return Effect.sync(() => Array.from(target.map.values()))
|
||||
case "entries":
|
||||
return Effect.sync(() => Array.from(target.map.entries(), ([key, item]): Array<unknown> => [key, item]))
|
||||
case "forEach": {
|
||||
const apply = applyCollectionCallback(runner, args[0], "Map.forEach", node)
|
||||
return Effect.gen(function* () {
|
||||
for (const [key, item] of Array.from(target.map.entries())) yield* apply([item, key, target])
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`Map method '${name}' is not available in CodeMode.`, node)
|
||||
}
|
||||
}
|
||||
|
||||
const invokeSetMethod = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
target: SandboxSet,
|
||||
name: string,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
): Effect.Effect<unknown, unknown, R> => {
|
||||
switch (name) {
|
||||
case "has":
|
||||
return Effect.succeed(target.set.has(args[0]))
|
||||
case "add":
|
||||
return Effect.sync(() => {
|
||||
target.set.add(args[0])
|
||||
return target
|
||||
})
|
||||
case "delete":
|
||||
return Effect.sync(() => target.set.delete(args[0]))
|
||||
case "clear":
|
||||
return Effect.sync(() => {
|
||||
target.set.clear()
|
||||
return undefined
|
||||
})
|
||||
case "keys":
|
||||
case "values":
|
||||
return Effect.sync(() => Array.from(target.set.values()))
|
||||
case "entries":
|
||||
return Effect.sync(() => Array.from(target.set.values(), (item): Array<unknown> => [item, item]))
|
||||
case "forEach": {
|
||||
const apply = applyCollectionCallback(runner, args[0], "Set.forEach", node)
|
||||
return Effect.gen(function* () {
|
||||
for (const item of Array.from(target.set.values())) yield* apply([item, item, target])
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`Set method '${name}' is not available in CodeMode.`, node)
|
||||
}
|
||||
}
|
||||
|
||||
const invokeURLSearchParamsMethod = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
target: SandboxURLSearchParams,
|
||||
name: string,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
): Effect.Effect<unknown, unknown, R> => {
|
||||
const arg = (index: number): string => uriArgument(args[index], `URLSearchParams.${name} argument ${index + 1}`)
|
||||
const requireArgs = (count: number): void => {
|
||||
if (args.length < count) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`URLSearchParams.${name} requires ${count} argument${count === 1 ? "" : "s"}.`,
|
||||
node,
|
||||
).as("TypeError")
|
||||
}
|
||||
}
|
||||
switch (name) {
|
||||
case "append": {
|
||||
requireArgs(2)
|
||||
return Effect.sync(() => {
|
||||
target.params.append(arg(0), arg(1))
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
case "delete": {
|
||||
requireArgs(1)
|
||||
return Effect.sync(() => {
|
||||
if (args[1] !== undefined) target.params.delete(arg(0), arg(1))
|
||||
else target.params.delete(arg(0))
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
case "get":
|
||||
requireArgs(1)
|
||||
return Effect.sync(() => target.params.get(arg(0)))
|
||||
case "getAll":
|
||||
requireArgs(1)
|
||||
return Effect.sync(() => target.params.getAll(arg(0)))
|
||||
case "has":
|
||||
requireArgs(1)
|
||||
return Effect.sync(() => (args[1] !== undefined ? target.params.has(arg(0), arg(1)) : target.params.has(arg(0))))
|
||||
case "set": {
|
||||
requireArgs(2)
|
||||
return Effect.sync(() => {
|
||||
target.params.set(arg(0), arg(1))
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
case "sort":
|
||||
return Effect.sync(() => {
|
||||
target.params.sort()
|
||||
return undefined
|
||||
})
|
||||
case "keys":
|
||||
return Effect.sync(() => Array.from(target.params.keys()))
|
||||
case "values":
|
||||
return Effect.sync(() => Array.from(target.params.values()))
|
||||
case "entries":
|
||||
return Effect.sync(() => Array.from(target.params.entries(), ([key, value]): Array<unknown> => [key, value]))
|
||||
case "toString":
|
||||
return Effect.sync(() => target.params.toString())
|
||||
case "forEach": {
|
||||
requireArgs(1)
|
||||
const apply = applyCollectionCallback(runner, args[0], "URLSearchParams.forEach", node)
|
||||
return Effect.gen(function* () {
|
||||
for (const [key, value] of Array.from(target.params.entries())) yield* apply([value, key, target])
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`URLSearchParams method '${name}' is not available in CodeMode.`, node)
|
||||
}
|
||||
}
|
||||
|
||||
const invokeArrayMethod = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
target: Array<unknown>,
|
||||
name: string,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
): Effect.Effect<unknown, unknown, R> => {
|
||||
const optNumber = (value: unknown, label: string): number | undefined => {
|
||||
if (value === undefined) return undefined
|
||||
if (typeof value !== "number")
|
||||
throw new InterpreterRuntimeError(`Array.${name} expects ${label} to be a number.`, node)
|
||||
return value
|
||||
}
|
||||
switch (name) {
|
||||
case "join": {
|
||||
if (args.length > 1 || (args.length === 1 && typeof args[0] !== "string")) {
|
||||
throw new InterpreterRuntimeError("Array.join expects zero arguments or one string separator.", node)
|
||||
}
|
||||
const input = boundedData(target, "Array.join input") as Array<unknown>
|
||||
return Effect.succeed(
|
||||
input.map((item) => coerceToString(item ?? "")).join(args.length === 0 ? "," : (args[0] as string)),
|
||||
)
|
||||
}
|
||||
case "includes":
|
||||
if (args.length === 0 || args.length > 2)
|
||||
throw new InterpreterRuntimeError("Array.includes expects a value and optional start index.", node)
|
||||
return Effect.succeed(target.includes(args[0], optNumber(args[1], "start index")))
|
||||
case "indexOf":
|
||||
return Effect.succeed(target.indexOf(args[0], optNumber(args[1], "start index")))
|
||||
case "lastIndexOf":
|
||||
return Effect.succeed(
|
||||
args[1] === undefined
|
||||
? target.lastIndexOf(args[0])
|
||||
: target.lastIndexOf(args[0], optNumber(args[1], "start index")),
|
||||
)
|
||||
case "at":
|
||||
return Effect.succeed(target.at(optNumber(args[0], "index") ?? 0))
|
||||
case "slice":
|
||||
return Effect.succeed(target.slice(optNumber(args[0], "start"), optNumber(args[1], "end")))
|
||||
case "concat":
|
||||
return Effect.succeed(target.concat(...args))
|
||||
case "flat":
|
||||
return Effect.succeed(target.flat(optNumber(args[0], "depth") ?? 1))
|
||||
case "reverse":
|
||||
return Effect.succeed(target.reverse())
|
||||
case "sort":
|
||||
return Effect.map(sortArray(runner, target, args[0], node), (sorted) => {
|
||||
target.splice(0, target.length, ...sorted)
|
||||
return target
|
||||
})
|
||||
case "toSorted":
|
||||
return sortArray(runner, target, args[0], node)
|
||||
case "toReversed":
|
||||
return Effect.succeed([...target].reverse())
|
||||
case "with": {
|
||||
const index = optNumber(args[0], "index") ?? 0
|
||||
const resolved = index < 0 ? target.length + index : index
|
||||
if (resolved < 0 || resolved >= target.length) {
|
||||
throw new InterpreterRuntimeError("Array.with index is out of range.", node)
|
||||
}
|
||||
const copied = [...target]
|
||||
copied[resolved] = args[1]
|
||||
return Effect.succeed(copied)
|
||||
}
|
||||
case "push": {
|
||||
// Validate all insertions before mutating to avoid partial cyclic updates.
|
||||
for (const item of args) rejectCircularInsertion(target, item, "Array.push result", node)
|
||||
target.push(...args)
|
||||
return Effect.succeed(target.length)
|
||||
}
|
||||
case "unshift": {
|
||||
for (const item of args) rejectCircularInsertion(target, item, "Array.unshift result", node)
|
||||
target.unshift(...args)
|
||||
return Effect.succeed(target.length)
|
||||
}
|
||||
case "pop":
|
||||
return Effect.succeed(target.pop())
|
||||
case "shift":
|
||||
return Effect.succeed(target.shift())
|
||||
case "splice": {
|
||||
if (args.length === 0) return Effect.succeed(target.splice(0, 0))
|
||||
const start = optNumber(args[0], "start") ?? 0
|
||||
if (args.length === 1) return Effect.succeed(target.splice(start))
|
||||
const deleteCount = optNumber(args[1], "delete count") ?? 0
|
||||
const inserted = args.slice(2)
|
||||
for (const item of inserted) rejectCircularInsertion(target, item, "Array.splice result", node)
|
||||
return Effect.succeed(target.splice(start, deleteCount, ...inserted))
|
||||
}
|
||||
case "fill": {
|
||||
rejectCircularInsertion(target, args[0], "Array.fill result", node)
|
||||
return Effect.succeed(target.fill(args[0], optNumber(args[1], "start"), optNumber(args[2], "end")))
|
||||
}
|
||||
case "copyWithin":
|
||||
return Effect.succeed(
|
||||
target.copyWithin(
|
||||
optNumber(args[0], "target index") ?? 0,
|
||||
optNumber(args[1], "start") ?? 0,
|
||||
optNumber(args[2], "end"),
|
||||
),
|
||||
)
|
||||
case "keys":
|
||||
return Effect.succeed(Array.from(target.keys()))
|
||||
case "values":
|
||||
return Effect.succeed([...target])
|
||||
case "entries":
|
||||
return Effect.succeed(Array.from(target.entries(), ([index, item]): Array<unknown> => [index, item]))
|
||||
}
|
||||
|
||||
const apply = applyCollectionCallback(runner, args[0], `Array.${name}`, node)
|
||||
return Effect.gen(function* () {
|
||||
// Fix iteration length while reading existing elements live.
|
||||
const length = target.length
|
||||
switch (name) {
|
||||
case "map": {
|
||||
const values: Array<unknown> = []
|
||||
values.length = length
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
if (!(index in target)) continue
|
||||
values[index] = yield* apply([target[index], index, target])
|
||||
}
|
||||
return values
|
||||
}
|
||||
case "flatMap": {
|
||||
const values: Array<unknown> = []
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
if (!(index in target)) continue
|
||||
const mapped = yield* apply([target[index], index, target])
|
||||
if (Array.isArray(mapped)) values.push(...mapped)
|
||||
else values.push(mapped)
|
||||
}
|
||||
return values
|
||||
}
|
||||
case "filter": {
|
||||
const values: Array<unknown> = []
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
if (!(index in target)) continue
|
||||
const item = target[index]
|
||||
if (yield* apply([item, index, target])) values.push(item)
|
||||
}
|
||||
return values
|
||||
}
|
||||
case "find":
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const item = target[index]
|
||||
if (yield* apply([item, index, target])) return item
|
||||
}
|
||||
return undefined
|
||||
case "findIndex":
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
if (yield* apply([target[index], index, target])) return index
|
||||
}
|
||||
return -1
|
||||
case "some":
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
if (!(index in target)) continue
|
||||
if (yield* apply([target[index], index, target])) return true
|
||||
}
|
||||
return false
|
||||
case "every":
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
if (!(index in target)) continue
|
||||
if (!(yield* apply([target[index], index, target]))) return false
|
||||
}
|
||||
return true
|
||||
case "forEach":
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
if (index in target) yield* apply([target[index], index, target])
|
||||
}
|
||||
return undefined
|
||||
case "reduce": {
|
||||
let accumulator: unknown
|
||||
let start: number
|
||||
if (args.length >= 2) {
|
||||
accumulator = args[1]
|
||||
start = 0
|
||||
} else {
|
||||
if (length === 0)
|
||||
throw new InterpreterRuntimeError("Array.reduce of an empty array with no initial value.", node)
|
||||
accumulator = target[0]
|
||||
start = 1
|
||||
}
|
||||
for (let index = start; index < length; index += 1) {
|
||||
if (!(index in target)) continue
|
||||
accumulator = yield* apply([accumulator, target[index], index, target])
|
||||
}
|
||||
return accumulator
|
||||
}
|
||||
case "reduceRight": {
|
||||
let accumulator: unknown
|
||||
let start: number
|
||||
if (args.length >= 2) {
|
||||
accumulator = args[1]
|
||||
start = length - 1
|
||||
} else {
|
||||
if (length === 0)
|
||||
throw new InterpreterRuntimeError("Array.reduceRight of an empty array with no initial value.", node)
|
||||
accumulator = target[length - 1]
|
||||
start = length - 2
|
||||
}
|
||||
for (let index = start; index >= 0; index -= 1) {
|
||||
if (!(index in target)) continue
|
||||
accumulator = yield* apply([accumulator, target[index], index, target])
|
||||
}
|
||||
return accumulator
|
||||
}
|
||||
case "findLast":
|
||||
for (let index = length - 1; index >= 0; index -= 1) {
|
||||
if (yield* apply([target[index], index, target])) return target[index]
|
||||
}
|
||||
return undefined
|
||||
case "findLastIndex":
|
||||
for (let index = length - 1; index >= 0; index -= 1) {
|
||||
if (yield* apply([target[index], index, target])) return index
|
||||
}
|
||||
return -1
|
||||
}
|
||||
throw new InterpreterRuntimeError(`Array method '${name}' is not available in CodeMode.`, node)
|
||||
})
|
||||
}
|
||||
|
||||
const sortArray = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
target: Array<unknown>,
|
||||
comparator: unknown,
|
||||
node: AstNode,
|
||||
): Effect.Effect<Array<unknown>, unknown, R> => {
|
||||
if (comparator !== undefined && !(comparator instanceof CodeModeFunction)) {
|
||||
throw new InterpreterRuntimeError("Array.sort expects an arrow function comparator.", node)
|
||||
}
|
||||
if (!(comparator instanceof CodeModeFunction)) {
|
||||
return Effect.sync(() =>
|
||||
[...target].sort((a, b) => {
|
||||
const left = coerceToString(a)
|
||||
const right = coerceToString(b)
|
||||
return left < right ? -1 : left > right ? 1 : 0
|
||||
}),
|
||||
)
|
||||
}
|
||||
const mergeSort = (items: Array<unknown>): Effect.Effect<Array<unknown>, unknown, R> => {
|
||||
if (items.length <= 1) return Effect.succeed(items)
|
||||
const midpoint = Math.floor(items.length / 2)
|
||||
return Effect.gen(function* () {
|
||||
const left = yield* mergeSort(items.slice(0, midpoint))
|
||||
const right = yield* mergeSort(items.slice(midpoint))
|
||||
const merged: Array<unknown> = []
|
||||
let leftIndex = 0
|
||||
let rightIndex = 0
|
||||
while (leftIndex < left.length && rightIndex < right.length) {
|
||||
// Treat a NaN comparator result as equal to preserve stable ordering.
|
||||
const order = coerceToNumber(yield* runner.invokeFunction(comparator, [left[leftIndex], right[rightIndex]]))
|
||||
if (Number.isNaN(order) || order <= 0) merged.push(left[leftIndex++])
|
||||
else merged.push(right[rightIndex++])
|
||||
}
|
||||
return [...merged, ...left.slice(leftIndex), ...right.slice(rightIndex)]
|
||||
})
|
||||
}
|
||||
const defined = target.filter((item) => item !== undefined)
|
||||
const undefinedCount = target.length - defined.length
|
||||
return Effect.map(mergeSort(defined), (items) => [...items, ...Array(undefinedCount).fill(undefined)])
|
||||
}
|
||||
@@ -76,8 +76,6 @@ export class PromiseInstanceMethodReference {
|
||||
) {}
|
||||
}
|
||||
|
||||
// The resolve/reject callables handed to a `new Promise(executor)` executor. `settle` closes
|
||||
// over the promise's deferred and is first-settlement-wins; later calls are no-ops, as in JS.
|
||||
export class PromiseCapabilityFunction {
|
||||
constructor(readonly settle: (value: unknown) => void) {}
|
||||
}
|
||||
@@ -114,8 +112,6 @@ export class UriFunction {
|
||||
constructor(readonly name: "encodeURI" | "encodeURIComponent" | "decodeURI" | "decodeURIComponent") {}
|
||||
}
|
||||
|
||||
// The global `search` built-in: synchronous tool discovery that shares the tool admission
|
||||
// pipeline (budget, audit, hooks) without living in the `tools` tree.
|
||||
export class SearchFunction {}
|
||||
|
||||
export class ProgramThrow {
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
import { Cause, Deferred, Effect, Exit, Fiber, Scope } from "effect"
|
||||
import type { Diagnostic } from "../codemode.js"
|
||||
import type { SafeObject } from "../tool-runtime.js"
|
||||
import {
|
||||
type AstNode,
|
||||
CodeModeFunction,
|
||||
CoercionFunction,
|
||||
InterpreterRuntimeError,
|
||||
ProgramThrow,
|
||||
PromiseCapabilityFunction,
|
||||
PromiseInstanceMethodReference,
|
||||
PromiseMethodReference,
|
||||
UriFunction,
|
||||
} from "./model.js"
|
||||
import { caughtErrorValue, normalizeError } from "./errors.js"
|
||||
import { applyCollectionCallback, type CallbackRunner } from "./methods.js"
|
||||
import { typeofValue } from "./references.js"
|
||||
import { spreadItems } from "../stdlib/collections.js"
|
||||
import { createAggregateErrorValue } from "../stdlib/value.js"
|
||||
import { SandboxPromise } from "../values.js"
|
||||
|
||||
// Observation only controls rejection reporting; program completion interrupts all promise work.
|
||||
export class PromiseRuntime<R> {
|
||||
private readonly active = new Set<SandboxPromise>()
|
||||
private readonly ids = new WeakMap<SandboxPromise, number>()
|
||||
private readonly observed = new WeakSet<SandboxPromise>()
|
||||
private readonly failures = new Map<number, Diagnostic>()
|
||||
private nextID = 0
|
||||
|
||||
constructor(private readonly scope: Scope.Scope) {}
|
||||
|
||||
create(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<SandboxPromise, never, R> {
|
||||
return Effect.suspend(() => {
|
||||
// Allocate before forking so reruns get distinct IDs and diagnostics retain creation order.
|
||||
const id = this.nextID++
|
||||
return Effect.map(Effect.forkIn(effect, this.scope, { startImmediately: true }), (fiber) => {
|
||||
const promise = new SandboxPromise(fiber)
|
||||
this.active.add(promise)
|
||||
this.ids.set(promise, id)
|
||||
fiber.addObserver((exit) => {
|
||||
this.active.delete(promise)
|
||||
if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause) || this.observed.has(promise)) {
|
||||
this.ids.delete(promise)
|
||||
return
|
||||
}
|
||||
const failure = normalizeError(Cause.squash(exit.cause))
|
||||
this.failures.set(id, {
|
||||
...failure,
|
||||
message: `Unhandled rejection from an un-awaited promise: ${failure.message}`,
|
||||
})
|
||||
})
|
||||
return promise
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Observation must be recorded when responsibility transfers, before the consumer fiber runs.
|
||||
markObserved(promise: SandboxPromise): void {
|
||||
this.observed.add(promise)
|
||||
const id = this.ids.get(promise)
|
||||
this.ids.delete(promise)
|
||||
if (id !== undefined) this.failures.delete(id)
|
||||
}
|
||||
|
||||
await(promise: SandboxPromise): Effect.Effect<Exit.Exit<unknown, unknown>> {
|
||||
return Fiber.await(promise.fiber)
|
||||
}
|
||||
|
||||
diagnostics(): Array<Diagnostic> {
|
||||
return [...this.failures].sort(([left], [right]) => left - right).map(([, failure]) => failure)
|
||||
}
|
||||
|
||||
// Re-check because a straggler can create promises before its interruption lands.
|
||||
interrupt(): Effect.Effect<Array<Diagnostic>> {
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
while (self.active.size > 0) {
|
||||
yield* Fiber.interruptAll([...self.active].map((promise) => promise.fiber))
|
||||
}
|
||||
return self.diagnostics()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const selfResolutionError = (node?: AstNode): InterpreterRuntimeError =>
|
||||
new InterpreterRuntimeError("Chaining cycle detected: a promise cannot resolve with itself.", node).as("TypeError")
|
||||
|
||||
export const invokePromiseMethod = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
promises: PromiseRuntime<R>,
|
||||
ref: PromiseMethodReference,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
): Effect.Effect<unknown, unknown, R> => {
|
||||
if (ref.name === "resolve") {
|
||||
const value = args[0]
|
||||
return value instanceof SandboxPromise ? Effect.succeed(value) : promises.create(Effect.succeed(value))
|
||||
}
|
||||
if (ref.name === "reject") {
|
||||
return promises.create(Effect.fail(new ProgramThrow(args[0])))
|
||||
}
|
||||
|
||||
const spread = spreadItems(args[0])
|
||||
if (spread === undefined) {
|
||||
return promises.create(
|
||||
Effect.fail(
|
||||
new InterpreterRuntimeError(
|
||||
`Promise.${ref.name} expects an array of promises or plain values (e.g. Promise.${ref.name}(items.map((item) => tools.ns.tool(item)))).`,
|
||||
node,
|
||||
).as("TypeError"),
|
||||
),
|
||||
)
|
||||
}
|
||||
const items = Array.from(spread)
|
||||
|
||||
for (const item of items) {
|
||||
if (item instanceof SandboxPromise) promises.markObserved(item)
|
||||
}
|
||||
|
||||
switch (ref.name) {
|
||||
case "all": {
|
||||
const observations = items.map((item) =>
|
||||
item instanceof SandboxPromise ? Effect.flatten(promises.await(item)) : Effect.succeed(item),
|
||||
)
|
||||
return promises.create(settleAfterTurn(Effect.all(observations, { concurrency: "unbounded" })))
|
||||
}
|
||||
case "allSettled": {
|
||||
const observations = items.map((item) =>
|
||||
item instanceof SandboxPromise ? promises.await(item) : Effect.succeed(Exit.succeed(item)),
|
||||
)
|
||||
return promises.create(
|
||||
settleAfterTurn(
|
||||
Effect.gen(function* () {
|
||||
const outcomes: Array<unknown> = []
|
||||
for (const observation of observations) {
|
||||
const exit = yield* observation
|
||||
if (Exit.isSuccess(exit)) {
|
||||
outcomes.push(
|
||||
Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value }),
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (Cause.hasInterruptsOnly(exit.cause)) {
|
||||
// Teardown interruption is not a program-level rejection.
|
||||
return yield* Effect.failCause(exit.cause)
|
||||
}
|
||||
outcomes.push(
|
||||
Object.assign(Object.create(null) as SafeObject, {
|
||||
status: "rejected",
|
||||
reason: caughtErrorValue(Cause.squash(exit.cause)),
|
||||
}),
|
||||
)
|
||||
}
|
||||
return outcomes
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
case "race": {
|
||||
if (items.length === 0) {
|
||||
return promises.create(
|
||||
Effect.fail(
|
||||
new InterpreterRuntimeError(
|
||||
"Promise.race([]) would never settle; provide at least one promise or value.",
|
||||
node,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
const observations = items.map((item) =>
|
||||
item instanceof SandboxPromise ? promises.await(item) : Effect.succeed(Exit.succeed(item)),
|
||||
)
|
||||
return promises.create(settleAfterTurn(Effect.flatten(Effect.raceAll(observations))))
|
||||
}
|
||||
case "any": {
|
||||
const flipped = items.map((item) =>
|
||||
item instanceof SandboxPromise
|
||||
? Effect.flatMap(promises.await(item), (exit) => {
|
||||
if (Exit.isSuccess(exit)) return Effect.fail(new PromiseAnyFulfilled(exit.value))
|
||||
if (Cause.hasInterruptsOnly(exit.cause)) return Effect.failCause(exit.cause)
|
||||
return Effect.succeed(caughtErrorValue(Cause.squash(exit.cause)))
|
||||
})
|
||||
: Effect.fail(new PromiseAnyFulfilled(item)),
|
||||
)
|
||||
const body = Effect.all(flipped, { concurrency: "unbounded" }).pipe(
|
||||
Effect.flatMap((reasons) =>
|
||||
Effect.fail(new ProgramThrow(createAggregateErrorValue(reasons, "All promises were rejected"))),
|
||||
),
|
||||
Effect.catch((error) =>
|
||||
error instanceof PromiseAnyFulfilled ? Effect.succeed(error.value) : Effect.fail(error),
|
||||
),
|
||||
)
|
||||
return promises.create(settleAfterTurn(body))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const invokePromiseInstanceMethod = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
promises: PromiseRuntime<R>,
|
||||
ref: PromiseInstanceMethodReference,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
): Effect.Effect<SandboxPromise, never, R> => {
|
||||
const method = `Promise.prototype.${ref.name}`
|
||||
promises.markObserved(ref.promise)
|
||||
if (ref.name === "finally") {
|
||||
return chainFinally(runner, promises, ref.promise, reactionHandler(args[0], method, node), method, node)
|
||||
}
|
||||
const onFulfilled = ref.name === "then" ? reactionHandler(args[0], method, node) : undefined
|
||||
const onRejected = reactionHandler(ref.name === "then" ? args[1] : args[0], method, node)
|
||||
return chainReaction(runner, promises, ref.promise, onFulfilled, onRejected, method, node)
|
||||
}
|
||||
|
||||
export const constructPromise = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
promises: PromiseRuntime<R>,
|
||||
executor: unknown,
|
||||
node: AstNode,
|
||||
): Effect.Effect<SandboxPromise, unknown, R> => {
|
||||
if (!(executor instanceof CodeModeFunction)) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"new Promise(...) expects an executor function (e.g. new Promise((resolve, reject) => { ... })).",
|
||||
node,
|
||||
).as("TypeError")
|
||||
}
|
||||
return Effect.gen(function* () {
|
||||
const deferred = Deferred.makeUnsafe<unknown, unknown>()
|
||||
const box: { own?: SandboxPromise } = {}
|
||||
const promise = yield* promises.create(
|
||||
Effect.flatMap(Deferred.await(deferred), (value) => {
|
||||
if (!(value instanceof SandboxPromise)) return Effect.succeed(value)
|
||||
if (value === box.own) return Effect.fail(selfResolutionError(node))
|
||||
return runner.settlePromise(value)
|
||||
}),
|
||||
)
|
||||
box.own = promise
|
||||
const resolve = new PromiseCapabilityFunction((value) => {
|
||||
Deferred.doneUnsafe(deferred, Exit.succeed(value))
|
||||
})
|
||||
const reject = new PromiseCapabilityFunction((value) => {
|
||||
Deferred.doneUnsafe(deferred, Exit.fail(new ProgramThrow(value)))
|
||||
})
|
||||
const executed = yield* Effect.exit(runner.invokeFunction(executor, [resolve, reject]))
|
||||
if (!Exit.isSuccess(executed)) {
|
||||
if (Cause.hasInterruptsOnly(executed.cause)) return yield* Effect.failCause(executed.cause)
|
||||
Deferred.doneUnsafe(deferred, Exit.fail(Cause.squash(executed.cause)))
|
||||
}
|
||||
return promise
|
||||
})
|
||||
}
|
||||
|
||||
// Settle one reaction turn after the deciding member, after its existing reactions.
|
||||
const settleAfterTurn = <A, E, R>(body: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
|
||||
Effect.flatMap(Effect.exit(body), (exit) => Effect.andThen(Effect.yieldNow, exit))
|
||||
|
||||
class PromiseAnyFulfilled {
|
||||
constructor(readonly value: unknown) {}
|
||||
}
|
||||
|
||||
type ReactionHandler = CodeModeFunction | CoercionFunction | UriFunction | PromiseCapabilityFunction
|
||||
|
||||
const reactionHandler = (value: unknown, method: string, node: AstNode): ReactionHandler | undefined => {
|
||||
if (
|
||||
value instanceof CodeModeFunction ||
|
||||
value instanceof CoercionFunction ||
|
||||
value instanceof UriFunction ||
|
||||
value instanceof PromiseCapabilityFunction
|
||||
) {
|
||||
return value
|
||||
}
|
||||
if (typeofValue(value) === "function") {
|
||||
throw new InterpreterRuntimeError(
|
||||
`${method} handlers must be plain functions; wrap other callables in an arrow function, e.g. (value) => tools.ns.tool(value).`,
|
||||
node,
|
||||
)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Teardown bypasses handlers; settled reactions yield once so handlers never run inline.
|
||||
const reactionExit = <R>(
|
||||
promises: PromiseRuntime<R>,
|
||||
source: SandboxPromise,
|
||||
): Effect.Effect<Exit.Exit<unknown, unknown>, unknown, R> =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* promises.await(source)
|
||||
if (!Exit.isSuccess(exit) && Cause.hasInterruptsOnly(exit.cause)) return yield* Effect.failCause(exit.cause)
|
||||
yield* Effect.yieldNow
|
||||
return exit
|
||||
})
|
||||
|
||||
const chainReaction = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
promises: PromiseRuntime<R>,
|
||||
source: SandboxPromise,
|
||||
onFulfilled: ReactionHandler | undefined,
|
||||
onRejected: ReactionHandler | undefined,
|
||||
method: string,
|
||||
node: AstNode,
|
||||
): Effect.Effect<SandboxPromise, never, R> => {
|
||||
const box: { derived?: SandboxPromise } = {}
|
||||
const body = Effect.gen(function* () {
|
||||
const exit = yield* reactionExit(promises, source)
|
||||
const handler = Exit.isSuccess(exit) ? onFulfilled : onRejected
|
||||
if (handler === undefined) return yield* exit
|
||||
const input = Exit.isSuccess(exit) ? exit.value : caughtErrorValue(Cause.squash(exit.cause))
|
||||
const result = yield* applyCollectionCallback(runner, handler, method, node)([input])
|
||||
if (result === box.derived) return yield* Effect.fail(selfResolutionError(node))
|
||||
if (result instanceof SandboxPromise) return yield* runner.settlePromise(result)
|
||||
return result
|
||||
})
|
||||
return Effect.map(promises.create(body), (derived) => {
|
||||
box.derived = derived
|
||||
return derived
|
||||
})
|
||||
}
|
||||
|
||||
const chainFinally = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
promises: PromiseRuntime<R>,
|
||||
source: SandboxPromise,
|
||||
cleanup: ReactionHandler | undefined,
|
||||
method: string,
|
||||
node: AstNode,
|
||||
): Effect.Effect<SandboxPromise, never, R> =>
|
||||
promises.create(
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* reactionExit(promises, source)
|
||||
if (cleanup !== undefined) {
|
||||
const result = yield* applyCollectionCallback(runner, cleanup, method, node)([])
|
||||
if (result instanceof SandboxPromise) yield* runner.settlePromise(result)
|
||||
}
|
||||
return yield* exit
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
type AstNode,
|
||||
CodeModeFunction,
|
||||
CoercionFunction,
|
||||
ErrorConstructorReference,
|
||||
GlobalMethodReference,
|
||||
GlobalNamespace,
|
||||
InterpreterRuntimeError,
|
||||
IntrinsicReference,
|
||||
PromiseCapabilityFunction,
|
||||
PromiseInstanceMethodReference,
|
||||
PromiseMethodReference,
|
||||
PromiseNamespace,
|
||||
SearchFunction,
|
||||
UriFunction,
|
||||
} from "./model.js"
|
||||
import { ToolReference } from "../tool-runtime.js"
|
||||
import { isSandboxValue, SandboxPromise } from "../values.js"
|
||||
|
||||
export const isRuntimeReference = (value: unknown): boolean =>
|
||||
value instanceof CodeModeFunction ||
|
||||
value instanceof ToolReference ||
|
||||
value instanceof IntrinsicReference ||
|
||||
value instanceof GlobalNamespace ||
|
||||
value instanceof GlobalMethodReference ||
|
||||
value instanceof PromiseNamespace ||
|
||||
value instanceof PromiseMethodReference ||
|
||||
value instanceof PromiseInstanceMethodReference ||
|
||||
value instanceof SandboxPromise ||
|
||||
value instanceof CoercionFunction ||
|
||||
value instanceof UriFunction ||
|
||||
value instanceof SearchFunction ||
|
||||
value instanceof PromiseCapabilityFunction ||
|
||||
value instanceof ErrorConstructorReference ||
|
||||
isSandboxValue(value)
|
||||
|
||||
export const containsRuntimeReference = (value: unknown, seen = new Set<object>()): boolean => {
|
||||
if (isRuntimeReference(value)) return true
|
||||
if (value === null || typeof value !== "object") return false
|
||||
if (seen.has(value)) return false
|
||||
seen.add(value)
|
||||
const contains = Array.isArray(value)
|
||||
? value.some((item) => containsRuntimeReference(item, seen))
|
||||
: Object.values(value).some((item) => containsRuntimeReference(item, seen))
|
||||
seen.delete(value)
|
||||
return contains
|
||||
}
|
||||
|
||||
// Sandbox values are data here, not opaque interpreter references.
|
||||
export const containsOpaqueReference = (value: unknown, seen = new Set<object>()): boolean => {
|
||||
if (isSandboxValue(value)) return false
|
||||
if (isRuntimeReference(value)) return true
|
||||
if (value === null || typeof value !== "object") return false
|
||||
if (seen.has(value)) return false
|
||||
seen.add(value)
|
||||
const contains = Array.isArray(value)
|
||||
? value.some((item) => containsOpaqueReference(item, seen))
|
||||
: Object.values(value).some((item) => containsOpaqueReference(item, seen))
|
||||
seen.delete(value)
|
||||
return contains
|
||||
}
|
||||
|
||||
// Reject cycles before mutation so later boundary walks remain safe.
|
||||
export const rejectCircularInsertion = (
|
||||
container: object,
|
||||
value: unknown,
|
||||
label: string,
|
||||
node: AstNode,
|
||||
seen = new Set<object>(),
|
||||
): void => {
|
||||
if (value === container)
|
||||
throw new InterpreterRuntimeError(`${label} contains a circular value.`, node, "InvalidDataValue")
|
||||
if (value === null || typeof value !== "object" || isRuntimeReference(value) || seen.has(value)) return
|
||||
seen.add(value)
|
||||
const items = Array.isArray(value) ? value : Object.values(value)
|
||||
for (const item of items) rejectCircularInsertion(container, item, label, node, seen)
|
||||
seen.delete(value)
|
||||
}
|
||||
|
||||
export const typeofValue = (value: unknown): string => {
|
||||
if (
|
||||
value instanceof CodeModeFunction ||
|
||||
value instanceof CoercionFunction ||
|
||||
value instanceof IntrinsicReference ||
|
||||
value instanceof GlobalMethodReference ||
|
||||
value instanceof PromiseMethodReference ||
|
||||
value instanceof PromiseInstanceMethodReference ||
|
||||
value instanceof PromiseNamespace ||
|
||||
value instanceof PromiseCapabilityFunction ||
|
||||
value instanceof ErrorConstructorReference
|
||||
)
|
||||
return "function"
|
||||
if (value instanceof UriFunction || value instanceof SearchFunction) return "function"
|
||||
if (value instanceof ToolReference) return value.path.length > 0 ? "function" : "object"
|
||||
if (value instanceof GlobalNamespace) {
|
||||
return value.name === "Math" || value.name === "JSON" || value.name === "console" ? "object" : "function"
|
||||
}
|
||||
return typeof value
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
||||
import { type AstNode, type Binding, InterpreterRuntimeError } from "./model.js"
|
||||
|
||||
export class ScopeStack {
|
||||
private readonly scopes: Array<Map<string, Binding>>
|
||||
|
||||
constructor(scopes: Array<Map<string, Binding>>) {
|
||||
this.scopes = scopes
|
||||
}
|
||||
|
||||
declare(name: string, value: unknown, mutable: boolean, node: AstNode): void {
|
||||
const scope = this.current()
|
||||
|
||||
const existing = scope.get(name)
|
||||
if (existing && existing.initialized !== false) {
|
||||
throw new InterpreterRuntimeError(`Identifier '${name}' has already been declared.`, node)
|
||||
}
|
||||
|
||||
scope.set(name, { mutable, value, initialized: true })
|
||||
}
|
||||
|
||||
get(name: string, node: AstNode): unknown {
|
||||
const binding = this.resolve(name)
|
||||
|
||||
if (!binding) {
|
||||
throw new InterpreterRuntimeError(`Unknown identifier '${name}'.`, node).as("ReferenceError")
|
||||
}
|
||||
|
||||
if (binding.initialized === false) {
|
||||
throw new InterpreterRuntimeError(`Cannot access '${name}' before initialization.`, node).as("ReferenceError")
|
||||
}
|
||||
|
||||
return binding.value
|
||||
}
|
||||
|
||||
set(name: string, value: unknown, node: AstNode): unknown {
|
||||
const binding = this.resolve(name)
|
||||
|
||||
if (!binding) {
|
||||
throw new InterpreterRuntimeError(`Unknown identifier '${name}'.`, node).as("ReferenceError")
|
||||
}
|
||||
|
||||
if (!binding.mutable) {
|
||||
throw new InterpreterRuntimeError(`Cannot assign to constant '${name}'.`, node).as("TypeError")
|
||||
}
|
||||
|
||||
binding.value = value
|
||||
return value
|
||||
}
|
||||
|
||||
resolve(name: string): Binding | undefined {
|
||||
for (let index = this.scopes.length - 1; index >= 0; index -= 1) {
|
||||
const scope = this.scopes[index]
|
||||
const binding = scope?.get(name)
|
||||
|
||||
if (binding) {
|
||||
return binding
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
current(): Map<string, Binding> {
|
||||
const scope = this.scopes[this.scopes.length - 1]
|
||||
|
||||
if (!scope) {
|
||||
throw new InterpreterRuntimeError("Interpreter scope stack is empty.")
|
||||
}
|
||||
|
||||
return scope
|
||||
}
|
||||
|
||||
push(scope: Map<string, Binding> = new Map()): void {
|
||||
this.scopes.push(scope)
|
||||
}
|
||||
|
||||
pop(): void {
|
||||
this.scopes.pop()
|
||||
}
|
||||
|
||||
capture(): Array<Map<string, Binding>> {
|
||||
return this.scopes.slice()
|
||||
}
|
||||
}
|
||||
@@ -31,10 +31,8 @@ export type {
|
||||
} from "./types.js"
|
||||
|
||||
/**
|
||||
* Builds a CodeMode tool subtree from an OpenAPI 3.x document, one tool per
|
||||
* operation. Auth is resolved host-side via `auth.resolve` and never
|
||||
* model-visible. Tools require `HttpClient.HttpClient`; unrepresentable
|
||||
* operations land in `skipped`.
|
||||
* Builds one CodeMode tool per representable OpenAPI 3.x operation. Auth remains host-side,
|
||||
* tools require `HttpClient.HttpClient`, and unrepresentable operations land in `skipped`.
|
||||
*/
|
||||
export const fromSpec = (options: Options): Result => {
|
||||
const document = options.spec
|
||||
|
||||
@@ -56,7 +56,7 @@ const buildRequest = (
|
||||
input: Readonly<Record<string, unknown>>,
|
||||
): Effect.Effect<HttpClientRequest.HttpClientRequest, ToolError> =>
|
||||
Effect.gen(function* () {
|
||||
// Validate every model-controlled value before auth resolution, which may refresh tokens.
|
||||
// Validate model input before auth resolution can refresh credentials.
|
||||
const url = buildUrl(plan, input)
|
||||
if (url instanceof ToolError) return yield* Effect.fail(url)
|
||||
const missing = plan.fields.find(
|
||||
@@ -77,7 +77,6 @@ const buildRequest = (
|
||||
request = serialized
|
||||
}
|
||||
|
||||
// Host headers first, then declared header parameters.
|
||||
request = HttpClientRequest.setHeaders(request, plan.headers)
|
||||
for (const field of plan.fields) {
|
||||
if (field.location !== "header") continue
|
||||
@@ -169,7 +168,7 @@ const applyCredentials = (
|
||||
continue
|
||||
}
|
||||
if (credential.type === "basic") {
|
||||
// Buffer instead of btoa: btoa throws on non-Latin-1 credentials.
|
||||
// Basic auth credentials are UTF-8; btoa rejects non-Latin-1 input.
|
||||
const duplicate = add(
|
||||
"header",
|
||||
"authorization",
|
||||
@@ -183,7 +182,6 @@ const applyCredentials = (
|
||||
if (duplicate !== undefined) return duplicate
|
||||
continue
|
||||
}
|
||||
// apiKey: the carrier comes from the scheme declaration.
|
||||
if (definition.type !== "apiKey") {
|
||||
return toolError(
|
||||
`Security scheme '${name}' is not an apiKey scheme; resolve a bearer, basic, or header credential for it.`,
|
||||
@@ -212,8 +210,7 @@ const buildUrl = (plan: Plan, input: Readonly<Record<string, unknown>>): string
|
||||
),
|
||||
)
|
||||
if (fieldValue instanceof ToolError) return fieldValue
|
||||
// '.'/'..' survive encoding and URL normalization collapses them, letting a
|
||||
// model-supplied value retarget the request to a different endpoint.
|
||||
// URL normalization collapses encoded `.` and `..`, which could retarget the request.
|
||||
if (fieldValue === "" || fieldValue === "." || fieldValue === "..") {
|
||||
return toolError(`Invalid path parameter '${field.inputName}'.`)
|
||||
}
|
||||
|
||||
@@ -23,8 +23,7 @@ const asArray = (value: unknown): ReadonlyArray<unknown> => (Array.isArray(value
|
||||
export const nonEmptyString = (value: unknown): string | undefined =>
|
||||
typeof value === "string" && value !== "" ? value : undefined
|
||||
|
||||
// Guards record lookups keyed by spec- or model-controlled names against
|
||||
// prototype-inherited values (e.g. a parameter named `toString`).
|
||||
// Spec- and model-controlled keys must not resolve inherited properties.
|
||||
export const own = <T>(record: Readonly<Record<string, T>>, key: string): T | undefined =>
|
||||
Object.hasOwn(record, key) ? record[key] : undefined
|
||||
|
||||
@@ -106,7 +105,7 @@ const operationParameters = (
|
||||
pathItem: Record<string, unknown>,
|
||||
operation: Record<string, unknown>,
|
||||
): Parsed<ReadonlyArray<PlannedField>> => {
|
||||
// Operation-level parameters override path-level ones sharing (location, name).
|
||||
// OpenAPI operation parameters override path parameters with the same location and name.
|
||||
const declared = new Map<
|
||||
string,
|
||||
{ readonly name: string; readonly location: string; readonly parameter: Record<string, unknown> }
|
||||
|
||||
@@ -22,9 +22,8 @@ export type SecurityScheme =
|
||||
| { readonly type: "openIdConnect" }
|
||||
|
||||
/**
|
||||
* Credential material returned by a host auth resolver. The carrier for `apiKey`
|
||||
* comes from the scheme definition, not the credential. `header` is the escape
|
||||
* hatch for nonstandard schemes.
|
||||
* Credential material returned by a host auth resolver. `apiKey` uses the scheme's carrier;
|
||||
* `header` supports nonstandard schemes.
|
||||
*/
|
||||
export type Credential =
|
||||
| { readonly type: "bearer"; readonly token: string }
|
||||
@@ -33,9 +32,7 @@ export type Credential =
|
||||
| { readonly type: "header"; readonly name: string; readonly value: string }
|
||||
|
||||
/**
|
||||
* Resolves credential material for one named security scheme at call time.
|
||||
* `undefined` means unavailable, try the next OR alternative; a failure aborts
|
||||
* the call rather than falling through.
|
||||
* Resolves credentials at call time. `undefined` tries the next OR alternative; failure aborts.
|
||||
*/
|
||||
export type AuthResolver = (context: {
|
||||
readonly name: string
|
||||
@@ -74,9 +71,7 @@ export type Parsed<T> = { readonly ok: true; readonly value: T } | { readonly ok
|
||||
export type InputLocation = "path" | "query" | "header" | "body"
|
||||
|
||||
export type InputField = {
|
||||
/** Model-visible field name after cross-location collision handling. */
|
||||
readonly inputName: string
|
||||
/** Original parameter or body-property name used on the wire. */
|
||||
readonly name: string
|
||||
readonly location: InputLocation
|
||||
readonly required: boolean
|
||||
@@ -92,7 +87,6 @@ export type OperationInput = {
|
||||
readonly body: Body | undefined
|
||||
}
|
||||
|
||||
/** One OR alternative: scheme name -> required scopes. Empty object = unauthenticated is acceptable. */
|
||||
export type SecurityRequirement = Readonly<Record<string, ReadonlyArray<string>>>
|
||||
|
||||
export type Plan = {
|
||||
|
||||
@@ -1,4 +1,122 @@
|
||||
import { containsOpaqueReference, containsRuntimeReference, isRuntimeReference } from "../interpreter/references.js"
|
||||
import { copyIn, copyOut } from "../tool-runtime.js"
|
||||
import {
|
||||
isSandboxValue,
|
||||
SandboxDate,
|
||||
SandboxMap,
|
||||
SandboxPromise,
|
||||
SandboxRegExp,
|
||||
SandboxSet,
|
||||
SandboxURL,
|
||||
SandboxURLSearchParams,
|
||||
} from "../values.js"
|
||||
import { boundedData, coerceToString } from "./value.js"
|
||||
|
||||
export const consoleMethods = new Set(["log", "info", "debug", "warn", "error", "dir", "table"])
|
||||
|
||||
/** Console formatting recursion ceiling; deeper values render as "...". */
|
||||
export const MAX_CONSOLE_DEPTH = 32
|
||||
const MAX_CONSOLE_DEPTH = 32
|
||||
|
||||
export const formatConsoleMessage = (name: string, args: Array<unknown>): string => {
|
||||
if (name === "dir") return args.length === 0 ? "undefined" : formatConsoleArgument(args[0])
|
||||
if (name === "table") return formatConsoleTable(args[0], args[1])
|
||||
const prefix = name === "warn" ? "[warn] " : name === "error" ? "[error] " : name === "debug" ? "[debug] " : ""
|
||||
return `${prefix}${args.map((arg) => formatConsoleArgument(arg)).join(" ")}`
|
||||
}
|
||||
|
||||
const formatConsoleArgument = (value: unknown): string => {
|
||||
if (value === undefined) return "undefined"
|
||||
if (typeof value === "string") return value
|
||||
return formatConsoleValue(value, new Set(), 0)
|
||||
}
|
||||
|
||||
const formatConsoleValue = (value: unknown, seen: Set<object>, depth: number): string => {
|
||||
if (value === null || value === undefined) return "null"
|
||||
if (typeof value === "string") return JSON.stringify(value)
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value)
|
||||
if (typeof value !== "object") return String(value)
|
||||
if (value instanceof SandboxPromise) return "[Promise (await it to get its value)]"
|
||||
if (value instanceof SandboxDate) return coerceToString(value)
|
||||
if (value instanceof SandboxRegExp) return coerceToString(value)
|
||||
if (value instanceof SandboxURL) return coerceToString(value)
|
||||
if (value instanceof SandboxURLSearchParams) return coerceToString(value)
|
||||
if (depth > MAX_CONSOLE_DEPTH) return "..."
|
||||
if (seen.has(value)) return "[Circular]"
|
||||
if (value instanceof SandboxMap) {
|
||||
seen.add(value)
|
||||
try {
|
||||
const entries = Array.from(value.map.entries(), ([key, item]): Array<unknown> => [key, item])
|
||||
return `Map(${value.map.size}) ${formatConsoleValue(entries, seen, depth + 1)}`
|
||||
} finally {
|
||||
seen.delete(value)
|
||||
}
|
||||
}
|
||||
if (value instanceof SandboxSet) {
|
||||
seen.add(value)
|
||||
try {
|
||||
return `Set(${value.set.size}) ${formatConsoleValue(Array.from(value.set.values()), seen, depth + 1)}`
|
||||
} finally {
|
||||
seen.delete(value)
|
||||
}
|
||||
}
|
||||
if (isRuntimeReference(value)) return "[CodeMode reference]"
|
||||
seen.add(value)
|
||||
try {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((item) => formatConsoleValue(item, seen, depth + 1)).join(",")}]`
|
||||
}
|
||||
return `{${Object.entries(value)
|
||||
.map(([key, item]) => `${JSON.stringify(key)}:${formatConsoleValue(item, seen, depth + 1)}`)
|
||||
.join(",")}}`
|
||||
} finally {
|
||||
seen.delete(value)
|
||||
}
|
||||
}
|
||||
|
||||
const formatConsoleTable = (value: unknown, columnsArgument: unknown): string => {
|
||||
if (value === undefined) return "undefined"
|
||||
if (containsOpaqueReference(value)) return "[CodeMode reference]"
|
||||
const data = boundedData(value, "console.table argument")
|
||||
const columns = consoleTableColumns(columnsArgument)
|
||||
const rows = consoleTableRows(data, columns)
|
||||
const keys = columns ?? Array.from(new Set(rows.flatMap((row) => Object.keys(row.values))))
|
||||
const header = ["(index)", ...keys].join("\t")
|
||||
return [
|
||||
header,
|
||||
...rows.map((row) => [row.index, ...keys.map((key) => formatConsoleTableCell(row.values[key]))].join("\t")),
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
const consoleTableColumns = (value: unknown): ReadonlyArray<string> | undefined => {
|
||||
if (value === undefined) return undefined
|
||||
if (containsRuntimeReference(value)) return undefined
|
||||
const columns = copyOut(copyIn(value, "console.table columns"), true)
|
||||
return Array.isArray(columns) ? columns.map((column) => String(column)) : undefined
|
||||
}
|
||||
|
||||
const consoleTableRows = (
|
||||
data: unknown,
|
||||
columns: ReadonlyArray<string> | undefined,
|
||||
): Array<{ readonly index: string; readonly values: Record<string, unknown> }> => {
|
||||
if (Array.isArray(data)) {
|
||||
return data.map((item, index) => ({ index: String(index), values: consoleTableValues(item, columns) }))
|
||||
}
|
||||
if (data !== null && typeof data === "object" && !isSandboxValue(data)) {
|
||||
return Object.entries(data).map(([index, item]) => ({ index, values: consoleTableValues(item, columns) }))
|
||||
}
|
||||
return [{ index: "0", values: { Value: data } }]
|
||||
}
|
||||
|
||||
const consoleTableValues = (value: unknown, columns: ReadonlyArray<string> | undefined): Record<string, unknown> => {
|
||||
if (value !== null && typeof value === "object" && !Array.isArray(value) && !isSandboxValue(value)) {
|
||||
const source = value as Record<string, unknown>
|
||||
if (columns !== undefined) return Object.fromEntries(columns.map((column) => [column, source[column]]))
|
||||
return Object.fromEntries(Object.entries(source))
|
||||
}
|
||||
return { Value: value }
|
||||
}
|
||||
|
||||
const formatConsoleTableCell = (value: unknown): string => {
|
||||
if (value === undefined) return ""
|
||||
if (typeof value === "string") return value
|
||||
return formatConsoleValue(value, new Set(), 0)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import type { PromiseMethodName } from "../interpreter/model.js"
|
||||
|
||||
export const promiseStatics = new Set<PromiseMethodName>(["all", "allSettled", "race", "any", "resolve", "reject"])
|
||||
|
||||
/** Maximum number of eagerly forked tool calls that may run concurrently. */
|
||||
export const TOOL_CALL_CONCURRENCY = 8
|
||||
|
||||
@@ -4,12 +4,9 @@ export const stringMethods = new Set([
|
||||
"trim",
|
||||
"trimStart",
|
||||
"trimEnd",
|
||||
"trimLeft",
|
||||
"trimRight",
|
||||
"split",
|
||||
"slice",
|
||||
"substring",
|
||||
"substr",
|
||||
"includes",
|
||||
"startsWith",
|
||||
"endsWith",
|
||||
|
||||
@@ -44,36 +44,30 @@ type ServicesOf<Tools, Depth extends ReadonlyArray<unknown>> = Depth["length"] e
|
||||
: ServicesOf<Tools[keyof Tools], [...Depth, unknown]>
|
||||
: never
|
||||
|
||||
/** Minimal audit record retained for each admitted tool call. */
|
||||
export type ToolCall = {
|
||||
readonly name: string
|
||||
}
|
||||
|
||||
/** Decoded tool call observed immediately before tool execution. */
|
||||
export type ToolCallStarted = {
|
||||
readonly index: number
|
||||
readonly name: string
|
||||
readonly input: unknown
|
||||
}
|
||||
|
||||
/** Completed tool call observed immediately after tool execution settles. */
|
||||
export type ToolCallEnded = {
|
||||
readonly index: number
|
||||
readonly name: string
|
||||
readonly input: unknown
|
||||
readonly durationMs: number
|
||||
readonly outcome: "success" | "failure"
|
||||
/** Model-safe failure message; present only when `outcome` is `"failure"`. */
|
||||
readonly message?: string
|
||||
}
|
||||
|
||||
/** Non-throwing observation hooks fired around each admitted tool call. */
|
||||
export type ToolCallHooks<R = never> = {
|
||||
readonly onToolCallStart?: ((call: ToolCallStarted) => Effect.Effect<void, never, R>) | undefined
|
||||
readonly onToolCallEnd?: ((call: ToolCallEnded) => Effect.Effect<void, never, R>) | undefined
|
||||
}
|
||||
|
||||
/** Model-visible description of one schema-backed tool. */
|
||||
export type ToolDescription = {
|
||||
readonly path: string
|
||||
readonly description: string
|
||||
@@ -113,11 +107,6 @@ export class ToolReference {
|
||||
constructor(readonly path: ReadonlyArray<string>) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum nesting depth for values crossing a data boundary. Fixed (not a configurable
|
||||
* limit) purely because it produces a clearer diagnostic than a native stack-overflow
|
||||
* RangeError would.
|
||||
*/
|
||||
const MAX_VALUE_DEPTH = 32
|
||||
|
||||
export class ToolRuntimeError extends Error {
|
||||
@@ -152,21 +141,7 @@ const blockedMemberNames = new Set(["__proto__", "constructor", "prototype"])
|
||||
|
||||
export const isBlockedMember = (name: string): boolean => blockedMemberNames.has(name)
|
||||
|
||||
/**
|
||||
* Validates and copies a value against the plain-data contract (depth, circularity, plain
|
||||
* objects only, blocked properties, data-only leaves).
|
||||
*
|
||||
* Two modes share the walk:
|
||||
* - **Boundary** (`preserveSandboxValues` false, the default): the host<->sandbox boundary -
|
||||
* final results, tool-call arguments, `JSON.stringify`. Sandbox value types serialize
|
||||
* exactly as JSON.stringify would: Date/URL -> strings, the remaining value types -> {}.
|
||||
* - **Intra-sandbox checkpoint** (`preserveSandboxValues` true; see `boundedData` in
|
||||
* codemode.ts): standard-library value instances pass through untouched (treated as leaves,
|
||||
* contents not walked), so values flowing through `Object.*` helpers, coercion inputs, and
|
||||
* other in-sandbox checkpoints stay fully usable (`.getTime()`, `.has()`, ...).
|
||||
*
|
||||
* Both modes reject un-awaited promises with an await-hinting diagnostic.
|
||||
*/
|
||||
// Checkpoint mode preserves sandbox values; boundary mode JSON-normalizes them.
|
||||
export const copyIn = (value: unknown, label: string, preserveSandboxValues = false): unknown =>
|
||||
copyBounded(value, label, 0, new Set(), preserveSandboxValues)
|
||||
|
||||
@@ -185,10 +160,6 @@ const copyBounded = (
|
||||
value === undefined ||
|
||||
typeof value === "string" ||
|
||||
typeof value === "boolean" ||
|
||||
// NaN/Infinity are allowed to exist as in-sandbox intermediates (matching real JS and a real
|
||||
// engine) so defensive guards like `Number.isNaN(x)` / `parseInt(x) || 0` can run. They are
|
||||
// normalized to `null` when the value leaves the sandbox - see copyOut - exactly as
|
||||
// JSON.stringify already does at any tool boundary.
|
||||
typeof value === "number"
|
||||
) {
|
||||
return value
|
||||
@@ -198,8 +169,6 @@ const copyBounded = (
|
||||
throw new ToolRuntimeError("InvalidDataValue", `${label} must contain data only.`)
|
||||
}
|
||||
|
||||
// An un-awaited promise never crosses a data checkpoint as `{}`; the diagnostic tells the
|
||||
// model exactly how to fix the program instead.
|
||||
if (value instanceof SandboxPromise) {
|
||||
throw new ToolRuntimeError(
|
||||
"InvalidDataValue",
|
||||
@@ -208,9 +177,6 @@ const copyBounded = (
|
||||
}
|
||||
|
||||
if (preserveSandboxValues) {
|
||||
// Intra-sandbox checkpoints keep sandbox value instances alive as leaves; their contents
|
||||
// are never walked here (Map/Set members are validated where mutation happens, and the
|
||||
// real boundary still serializes them below).
|
||||
if (
|
||||
value instanceof SandboxDate ||
|
||||
value instanceof SandboxRegExp ||
|
||||
@@ -221,8 +187,6 @@ const copyBounded = (
|
||||
) {
|
||||
return value
|
||||
}
|
||||
// Host instances cannot normally reach an intra-sandbox checkpoint (tool results cross
|
||||
// the boundary first), but wrap them defensively rather than degrading to JSON forms.
|
||||
if (value instanceof Date) return new SandboxDate(value.getTime())
|
||||
if (value instanceof RegExp) return new SandboxRegExp(value.source, value.flags)
|
||||
if (value instanceof Map) {
|
||||
@@ -241,9 +205,6 @@ const copyBounded = (
|
||||
if (value instanceof URLSearchParams) return new SandboxURLSearchParams(new URLSearchParams(value))
|
||||
}
|
||||
|
||||
// Sandbox value types (and their host counterparts, which a host tool may legitimately
|
||||
// return) serialize exactly as JSON.stringify would at the data boundary: Date/URL use
|
||||
// toJSON(), while RegExp/Map/Set/URLSearchParams have no JSON form beyond {}.
|
||||
if (value instanceof SandboxDate) {
|
||||
return Number.isFinite(value.time) ? new Date(value.time).toISOString() : null
|
||||
}
|
||||
@@ -274,7 +235,7 @@ const copyBounded = (
|
||||
if (Array.isArray(value)) {
|
||||
const copied = value.map((item) => copyBounded(item, label, depth + 1, seen, preserveSandboxValues))
|
||||
if (preserveSandboxValues) {
|
||||
// Array metadata is not serialized, but intra-sandbox copies must retain it.
|
||||
// Checkpoint copies retain array metadata that boundary copies omit.
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (Object.hasOwn(copied, key)) continue
|
||||
if (isBlockedMember(key)) {
|
||||
@@ -305,9 +266,6 @@ const copyBounded = (
|
||||
|
||||
export const copyOut = (value: unknown, undefinedAsNull = false): unknown => {
|
||||
if (value === undefined && undefinedAsNull) return null
|
||||
// Normalize non-finite numbers to null as the value crosses out of the sandbox (final return
|
||||
// and tool-call arguments both funnel through here), matching JSON semantics - NaN/Infinity
|
||||
// have no JSON representation, so JSON.stringify would produce null anyway.
|
||||
if (typeof value === "number" && !Number.isFinite(value)) {
|
||||
return null
|
||||
}
|
||||
@@ -353,18 +311,10 @@ export type DiscoveryPlan = {
|
||||
|
||||
export type SearchEntry = {
|
||||
readonly description: ToolDescription
|
||||
/** Top-level namespace (first path segment), matched by the search `namespace` option. */
|
||||
readonly namespace: string
|
||||
/** Lowercased path + description + input property names/descriptions, for substring matching. */
|
||||
readonly searchText: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a query into lowercased search terms. camelCase boundaries are split
|
||||
* (`resolveLibrary` -> `resolve library`) and every non-alphanumeric character is a
|
||||
* separator, so `resolve-library-id`, `resolveLibraryId`, and `resolve library id` all
|
||||
* tokenize alike. Empties and the `*` wildcard are dropped.
|
||||
*/
|
||||
const tokenize = (query: string): Array<string> =>
|
||||
query
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
||||
@@ -372,13 +322,6 @@ const tokenize = (query: string): Array<string> =>
|
||||
.split(/[^a-z0-9]+/)
|
||||
.filter((term) => term.length > 0 && term !== "*")
|
||||
|
||||
/**
|
||||
* A term plus its naive singular variants (trailing "s"/"es" stripped), so a plural
|
||||
* query term ("issues") still matches indexed text that only carries the singular
|
||||
* ("issue"). Matching is one-directional substring containment, so the variants are
|
||||
* needed only on the query side; scoring weights are unchanged - each field check
|
||||
* passes when ANY form matches.
|
||||
*/
|
||||
const termForms = (term: string): Array<string> => {
|
||||
const forms = [term]
|
||||
if (term.endsWith("es") && term.length > 3) forms.push(term.slice(0, -2))
|
||||
@@ -400,8 +343,6 @@ const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Definition =>
|
||||
request.namespace === undefined
|
||||
? searchIndex
|
||||
: searchIndex.filter((entry) => entry.namespace === request.namespace)
|
||||
// A query that names one tool path exactly (canonical path or rendered JavaScript
|
||||
// expression) is a lookup, not a search: return that tool alone.
|
||||
const trimmed = query.trim()
|
||||
const pathQuery = trimmed.startsWith("tools.") ? trimmed.slice("tools.".length) : trimmed
|
||||
const exact =
|
||||
@@ -411,9 +352,6 @@ const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Definition =>
|
||||
(entry) => entry.description.path === pathQuery || toolExpression(entry.description.path) === trimmed,
|
||||
)
|
||||
const terms = tokenize(query).map(termForms)
|
||||
// Additive field-weighted scoring, summed across terms: exact path or path segment
|
||||
// (20) > path substring (8) > description substring (4) > any searchable text,
|
||||
// including input parameter names and descriptions (2).
|
||||
const ranked =
|
||||
exact !== undefined
|
||||
? [exact]
|
||||
@@ -451,15 +389,12 @@ const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Definition =>
|
||||
}),
|
||||
})
|
||||
|
||||
// The built-in `search` is a synchronous global function, not a tool-tree entry, so its
|
||||
// advertised signature is rendered by hand instead of through `describeDefinition`.
|
||||
const searchSignature = (() => {
|
||||
const definition = makeSearchTool([])
|
||||
return `search(input: ${inputTypeScript(definition, true)}): ${outputTypeScript(definition, true)}`
|
||||
})()
|
||||
|
||||
const catalogLine = (tool: ToolDescription) => {
|
||||
// Keep the tool description concise; the full schema documentation remains in the signature.
|
||||
const line = tool.description.split("\n", 1)[0]!.trim()
|
||||
const description = line.length > 120 ? line.slice(0, 119) + "..." : line
|
||||
return description === "" ? ` - ${tool.signature}` : ` - ${tool.signature} // ${description}`
|
||||
@@ -479,21 +414,10 @@ const toSearchEntry = <R>(path: string, definition: Definition<R>, description:
|
||||
.toLowerCase(),
|
||||
})
|
||||
|
||||
/** The runtime search index over every described tool. Search is always registered. */
|
||||
export const searchIndex = <R>(tools: HostTools<R>): ReadonlyArray<SearchEntry> =>
|
||||
visibleDefinitions(tools).map(({ path, definition, description }) => toSearchEntry(path, definition, description))
|
||||
|
||||
/**
|
||||
* Budgeted catalog: every namespace is always listed with its tool count; full call
|
||||
* signatures are inlined against the `catalogBudget` (estimated tokens,
|
||||
* chars/4) round-robin across namespaces - in each round (namespaces alphabetical), every
|
||||
* namespace still holding un-inlined tools attempts to place its next-cheapest line, and
|
||||
* a namespace whose next line does not fit is done while the others keep going - so every
|
||||
* namespace gets some representation before any namespace gets everything. The section
|
||||
* states exactly how comprehensive it is - overall (COMPLETE vs PARTIAL) and per
|
||||
* namespace. Namespace stub lines are never budgeted: every namespace appears with its
|
||||
* tool count even at budget 0.
|
||||
*/
|
||||
// Budget signatures round-robin so every namespace remains visible.
|
||||
export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBudget): DiscoveryPlan => {
|
||||
if (!Number.isSafeInteger(catalogBudget) || catalogBudget < 0) {
|
||||
throw new RangeError("discovery.catalogBudget must be a non-negative safe integer")
|
||||
@@ -510,12 +434,6 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
|
||||
}
|
||||
const ordered = [...namespaces].sort(([left], [right]) => left.localeCompare(right))
|
||||
|
||||
// Select which signatures fit the budget before emitting, so the list can state
|
||||
// exactly how comprehensive it is. Round-robin fairness: in each round (namespaces
|
||||
// alphabetical), every namespace still holding un-inlined tools tries to place its
|
||||
// next-cheapest line against the shared budget; a namespace whose next line does not
|
||||
// fit is done - the others keep going - so every namespace gets some representation
|
||||
// before any namespace gets everything.
|
||||
const selections = ordered.map(([namespace, group]) => ({
|
||||
namespace,
|
||||
picked: new Set<ToolDescription>(),
|
||||
@@ -547,10 +465,6 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
|
||||
|
||||
const empty = described.length === 0
|
||||
|
||||
// Section order is deliberate: workflow first (the top is the least likely part of a long
|
||||
// description to be truncated or skimmed away), then rules, then syntax, with the budgeted
|
||||
// catalog at the bottom. Example call forms use placeholders - never a real or fabricated
|
||||
// tool name - and show both dot and bracket notation so non-identifier names are not normalized.
|
||||
const intro = [
|
||||
empty
|
||||
? "This is a restricted JavaScript language for calling tools, not a general-purpose runtime."
|
||||
@@ -562,8 +476,6 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
|
||||
: ["Do not infer or normalize tool names; use only exact signatures shown below or returned by search."]),
|
||||
]
|
||||
|
||||
// The search step exists only when search is advertised (PARTIAL catalog); a COMPLETE
|
||||
// catalog already shows every signature, so step 1 picks from the list instead.
|
||||
const workflow = empty
|
||||
? []
|
||||
: [
|
||||
@@ -627,8 +539,6 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
|
||||
for (const [namespace, group] of ordered) {
|
||||
const picked = shown.get(namespace)!
|
||||
const count = `${group.length} tool${group.length === 1 ? "" : "s"}`
|
||||
// Annotate only when a namespace is not fully shown, so a comprehensive
|
||||
// namespace reads cleanly and a truncated one is unambiguous.
|
||||
const label =
|
||||
picked.size === group.length
|
||||
? count
|
||||
@@ -651,13 +561,6 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The enumerable names at one node of the callable tool tree - namespace names at the root,
|
||||
* tool/namespace names below - powering `Object.keys(tools)` and `for...in` over tool
|
||||
* references. A callable tool is a leaf and enumerates as `[]` (like `Object.keys` of a
|
||||
* function in JS). An unknown path is an `UnknownTool` error pointing at the working
|
||||
* discovery idioms, mirroring how calling an unknown tool fails.
|
||||
*/
|
||||
const namespaceKeys = <R>(tools: HostTools<R>, path: ReadonlyArray<string>): ReadonlyArray<string> => {
|
||||
let value: HostTool<R> | Definition<R> | HostTools<R> = tools
|
||||
for (const segment of path) {
|
||||
@@ -705,18 +608,12 @@ export type ToolRuntime<R = never> = {
|
||||
readonly root: ToolReference
|
||||
readonly calls: Array<ToolCall>
|
||||
readonly invoke: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
||||
/**
|
||||
* The built-in `search` global: a synchronous discovery call that shares the tool
|
||||
* admission pipeline (budget, audit, hooks) without living in the `tools` tree.
|
||||
*/
|
||||
readonly search: (args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
||||
/** Enumerable namespace/tool names at one node of the callable tool tree; see `namespaceKeys`. */
|
||||
readonly keys: (path: ReadonlyArray<string>) => ReadonlyArray<string>
|
||||
}
|
||||
|
||||
export const make = <R>(
|
||||
tools: HostTools<R>,
|
||||
/** Undefined means unlimited tool calls. */
|
||||
maxToolCalls: number | undefined,
|
||||
searchIndex: ReadonlyArray<SearchEntry>,
|
||||
hooks?: ToolCallHooks<R>,
|
||||
@@ -724,8 +621,7 @@ export const make = <R>(
|
||||
const calls: Array<ToolCall> = []
|
||||
const searchTool = makeSearchTool(searchIndex)
|
||||
|
||||
// Wraps the settling portion of a tool call so onToolCallEnd observes success and failure
|
||||
// symmetrically. Interruption (e.g. the execution timeout) fires neither outcome.
|
||||
// End hooks observe settled success or failure; interruption emits neither outcome.
|
||||
const observeEnd = <A, E>(effect: Effect.Effect<A, E, R>, call: ToolCallStarted): Effect.Effect<A, E, R> => {
|
||||
const onEnd = hooks?.onToolCallEnd
|
||||
if (onEnd === undefined) return effect
|
||||
|
||||
@@ -5,13 +5,8 @@ const isEffectSchema = (schema: SchemaType): schema is Schema.Decoder<unknown> &
|
||||
|
||||
const renderLiteral = (value: unknown): string => JSON.stringify(value) ?? "unknown"
|
||||
|
||||
/**
|
||||
* Bare TypeScript identifier - usable unquoted as an object key (and, in the tool runtime,
|
||||
* with dot access as a tool-path segment). Anything else must be quoted/bracketed.
|
||||
*/
|
||||
export const identifierSegment = /^[A-Za-z_$][A-Za-z0-9_$]*$/
|
||||
|
||||
/** Renders a property name as a valid TS object key: bare when an identifier, quoted otherwise. */
|
||||
const renderKey = (name: string): string => (identifierSegment.test(name) ? name : JSON.stringify(name))
|
||||
|
||||
const effectNumberSentinel = (schema: JsonSchema) =>
|
||||
@@ -27,16 +22,10 @@ const intersection = (members: ReadonlyArray<string>): string => {
|
||||
return concrete.map((member) => (member.includes(" | ") ? `(${member})` : member)).join(" & ")
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursion ceiling for schema rendering. Object, array, and union recursion all increment
|
||||
* depth, so this bounds every recursion path - pathological or structurally cyclic schemas
|
||||
* degrade to `unknown` instead of overflowing the stack (rendering must never throw).
|
||||
*/
|
||||
const MAX_RENDER_DEPTH = 8
|
||||
|
||||
type RenderContext = {
|
||||
readonly definitions: Readonly<Record<string, JsonSchema>>
|
||||
/** Indented, JSDoc-annotated multiline rendering (search results); compact single line otherwise. */
|
||||
readonly pretty: boolean
|
||||
}
|
||||
|
||||
@@ -64,10 +53,6 @@ const hasUnresolvedRef = (
|
||||
].some((item) => hasUnresolvedRef(item, definitions, seen, nextVisited))
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema constraints a TypeScript type cannot express natively but a model benefits from,
|
||||
* surfaced as JSDoc tags (`@deprecated`, `@default`, `@format`, `@minItems`, `@maxItems`).
|
||||
*/
|
||||
const docTags = (schema: JsonSchema): Array<string> => {
|
||||
const tags: Array<string> = []
|
||||
if (schema.deprecated === true) tags.push("@deprecated")
|
||||
@@ -75,9 +60,7 @@ const docTags = (schema: JsonSchema): Array<string> => {
|
||||
try {
|
||||
const rendered = JSON.stringify(schema.default)
|
||||
if (rendered !== undefined) tags.push(`@default ${rendered}`)
|
||||
} catch {
|
||||
// unserializable default: skip rather than emit a broken tag
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
if (typeof schema.format === "string") tags.push(`@format ${schema.format}`)
|
||||
if (typeof schema.minItems === "number") tags.push(`@minItems ${schema.minItems}`)
|
||||
@@ -85,13 +68,7 @@ const docTags = (schema: JsonSchema): Array<string> => {
|
||||
return tags
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a schema `description` plus `tags` as a JSDoc comment at the given indent,
|
||||
* preserving multi-line text (a single line stays `/** ... *\/`; multiple lines become a
|
||||
* `*`-prefixed block). `*\/` is neutralized so nothing can close the comment early, and
|
||||
* blank leading/trailing lines are trimmed. Returns "" (else a trailing newline) so
|
||||
* callers can prepend it directly to the field line.
|
||||
*/
|
||||
// Neutralize `*\/` so model-provided schema text cannot terminate generated documentation.
|
||||
const jsdoc = (description: string | undefined, tags: ReadonlyArray<string>, pad: string): string => {
|
||||
const lines = [...(description === undefined ? [] : description.split("\n")), ...tags].map((line) =>
|
||||
line.replaceAll("*/", "* /").replace(/\s+$/, ""),
|
||||
@@ -128,17 +105,11 @@ const renderSchema = (
|
||||
if (schema.enum) return schema.enum.map(renderLiteral).join(" | ")
|
||||
const alternatives = schema.anyOf ?? schema.oneOf
|
||||
if (alternatives) {
|
||||
// Effect's number schema emits `anyOf: [{ type: "number" }, { const: "NaN" },
|
||||
// { const: "Infinity" }, { const: "-Infinity" }]`. Collapse only that artifact;
|
||||
// real JSON Schema unions such as `string | number` or `number | null` must keep
|
||||
// every branch.
|
||||
if (
|
||||
alternatives.some((item) => item.type === "number") &&
|
||||
alternatives.every((item) => item.type === "number" || effectNumberSentinel(item))
|
||||
)
|
||||
return "number"
|
||||
// An empty Schema.Struct({}) emits `anyOf: [{ type: "object" }, { type: "array" }]`
|
||||
// (no properties/items); render the bare shape as {} instead of `{} | Array<unknown>`.
|
||||
if (
|
||||
alternatives.length === 2 &&
|
||||
alternatives[0]?.type === "object" &&
|
||||
@@ -183,7 +154,6 @@ const renderSchema = (
|
||||
return fields.length === 0 ? "{}" : `{ ${fields.join("; ")} }`
|
||||
}
|
||||
|
||||
// Pretty: an indented block, each described field preceded by its JSDoc comment.
|
||||
if (properties.length === 0 && indexType === undefined) return "{}"
|
||||
const pad = " ".repeat(depth + 1)
|
||||
const lines = properties.map(
|
||||
@@ -208,7 +178,6 @@ export const toTypeScript = (schema: Schema.Top, decoded = false, pretty = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Renders a raw JSON Schema document as a TypeScript type string. */
|
||||
export const jsonSchemaToTypeScript = (schema: JsonSchema, pretty = false): string => {
|
||||
try {
|
||||
return renderSchema(schema, { definitions: { ...(schema.definitions ?? {}), ...(schema.$defs ?? {}) }, pretty })
|
||||
@@ -217,20 +186,12 @@ export const jsonSchemaToTypeScript = (schema: JsonSchema, pretty = false): stri
|
||||
}
|
||||
}
|
||||
|
||||
/** One input property of a tool, extracted best-effort from its input schema. */
|
||||
export type InputProperty = {
|
||||
readonly name: string
|
||||
readonly description: string | undefined
|
||||
readonly required: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The property names, descriptions, and required flags of a tool's input schema - the raw
|
||||
* material for search text. Best-effort: Effect Schemas go through their
|
||||
* JSON Schema document (the same emission signature rendering uses); JSON Schemas are read
|
||||
* directly, resolving a trivial top-level `$ref` into `$defs`/`definitions` when present.
|
||||
* Anything unresolvable yields `[]` (search falls back to path + description).
|
||||
*/
|
||||
export const inputProperties = <R>(definition: Definition<R>): Array<InputProperty> => {
|
||||
try {
|
||||
const document = isEffectSchema(definition.input)
|
||||
@@ -262,20 +223,11 @@ export const inputProperties = <R>(definition: Definition<R>): Array<InputProper
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The model-visible TypeScript type of a tool's input. `pretty` renders an indented
|
||||
* multiline block with schema descriptions and constraints as JSDoc comments on the
|
||||
* fields; the default stays the compact single-line form.
|
||||
*/
|
||||
export const inputTypeScript = <R>(definition: Definition<R>, pretty = false): string =>
|
||||
isEffectSchema(definition.input)
|
||||
? toTypeScript(definition.input, false, pretty)
|
||||
: jsonSchemaToTypeScript(definition.input, pretty)
|
||||
|
||||
/**
|
||||
* The model-visible TypeScript type of a tool's result; tools without an output schema
|
||||
* return `unknown`. `pretty` renders the JSDoc-annotated multiline form, as for inputs.
|
||||
*/
|
||||
export const outputTypeScript = <R>(definition: Definition<R>, pretty = false): string =>
|
||||
definition.output === undefined
|
||||
? "unknown"
|
||||
@@ -283,18 +235,9 @@ export const outputTypeScript = <R>(definition: Definition<R>, pretty = false):
|
||||
? toTypeScript(definition.output, true, pretty)
|
||||
: jsonSchemaToTypeScript(definition.output, pretty)
|
||||
|
||||
/**
|
||||
* Decodes tool input before `run` is invoked. Effect Schemas validate (throwing on failure);
|
||||
* JSON-Schema-described inputs pass through unvalidated (render-only).
|
||||
*/
|
||||
export const decodeInput = <R>(definition: Definition<R>, value: unknown): unknown =>
|
||||
isEffectSchema(definition.input) ? Schema.decodeUnknownSync(definition.input)(value) : value
|
||||
|
||||
/**
|
||||
* Decodes a tool result before it is exposed to the program. Effect Schemas validate and
|
||||
* transform (throwing on failure); JSON Schema outputs and tools without an output schema pass
|
||||
* the host value through unchanged.
|
||||
*/
|
||||
export const decodeOutput = <R>(definition: Definition<R>, value: unknown): unknown =>
|
||||
definition.output !== undefined && isEffectSchema(definition.output)
|
||||
? Schema.decodeUnknownSync(definition.output)(value)
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
|
||||
/**
|
||||
* JSON Schema subset accepted for render-only tool schemas.
|
||||
*
|
||||
* A JSON-Schema-described side of a tool is used to generate the model-visible TypeScript
|
||||
* signature only - CodeMode performs no validation against it. This is the natural shape for
|
||||
* adapter-provided tools (e.g. MCP definitions) whose schemas arrive as JSON Schema documents.
|
||||
* JSON Schema subset for model-visible signatures. CodeMode does not validate values against
|
||||
* these schemas.
|
||||
*/
|
||||
export type JsonSchema = {
|
||||
readonly type?: string | ReadonlyArray<string>
|
||||
@@ -41,10 +38,8 @@ export type Definition<R = never> = {
|
||||
readonly run: (input: unknown) => Effect.Effect<unknown, unknown, R>
|
||||
}
|
||||
|
||||
/** The value `run` receives: the decoded type for Effect Schemas, `unknown` for JSON Schemas. */
|
||||
type InputType<S> = S extends Schema.Decoder<unknown> ? S["Type"] : unknown
|
||||
|
||||
/** The value `run` returns: the encoded type for Effect Schemas, `unknown` otherwise. */
|
||||
type ResultType<S> = S extends Schema.Decoder<unknown> ? S["Encoded"] : unknown
|
||||
|
||||
/** Options for defining one CodeMode tool. */
|
||||
@@ -61,29 +56,9 @@ export const isDefinition = <R = never>(value: unknown): value is Definition<R>
|
||||
/**
|
||||
* Defines one schema-described tool available to a CodeMode program through `tools.*`.
|
||||
*
|
||||
* `input` and `output` each accept a validating Effect Schema or a render-only JSON Schema
|
||||
* document. Effect Schema input is decoded before `run` is invoked, and `run` returns the
|
||||
* encoded representation of an Effect Schema `output`, which CodeMode decodes before returning
|
||||
* it to the program. JSON Schemas only shape the model-visible signature; values pass through
|
||||
* unvalidated. `output` is optional - without it the signature advertises `unknown` and the
|
||||
* host result is exposed as-is. The host tool remains responsible for authorization and
|
||||
* durable side-effect handling.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const lookup = Tool.make({
|
||||
* description: "Look up an order",
|
||||
* input: Schema.Struct({ id: Schema.String }),
|
||||
* output: Schema.Struct({ status: Schema.String }),
|
||||
* run: ({ id }) => Effect.succeed({ status: "open" }),
|
||||
* })
|
||||
*
|
||||
* const fromJsonSchema = Tool.make({
|
||||
* description: "Call an adapter-described tool",
|
||||
* input: { type: "object", properties: { id: { type: "string" } }, required: ["id"] },
|
||||
* run: (input) => callHost(input),
|
||||
* })
|
||||
* ```
|
||||
* Effect Schemas validate values; JSON Schemas only shape the model-visible signature.
|
||||
* Without `output`, results are exposed as `unknown`. Hosts remain responsible for authorization
|
||||
* and durable side effects.
|
||||
*/
|
||||
export const make = <I extends SchemaType, const O extends SchemaType | undefined = undefined, R = never>(
|
||||
options: Options<I, O, R>,
|
||||
|
||||
@@ -26,6 +26,22 @@ describe("CodeMode host failure boundary", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("does not rewrite explicit safe tool failures", async () => {
|
||||
const result = await run(
|
||||
Tool.make({
|
||||
description: "Fail safely",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
run: () => Effect.fail(toolError("File not found: /tmp/report.json")),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.ok ? undefined : result.error).toStrictEqual({
|
||||
kind: "ToolFailure",
|
||||
message: "File not found: /tmp/report.json",
|
||||
})
|
||||
})
|
||||
|
||||
test("sanitizes unknown host failures and defects", async () => {
|
||||
for (const failure of [
|
||||
Effect.fail(new UnsafeHostError({ reason: "Authorization: Bearer typed-secret" })),
|
||||
|
||||
@@ -302,9 +302,12 @@ describe("CodeMode-specific string behavior", () => {
|
||||
expect(await value(`try { "x".normalize("nope"); return "no" } catch (e) { return e.message }`)).toContain('"NFC"')
|
||||
})
|
||||
|
||||
test("trimLeft/trimRight alias trimStart/trimEnd", async () => {
|
||||
expect(await value(`return " x ".trimLeft()`)).toBe("x ")
|
||||
expect(await value(`return " x ".trimRight()`)).toBe(" x")
|
||||
test("does not expose obsolete string aliases", async () => {
|
||||
expect(await value(`return [typeof "x".trimLeft, typeof "x".trimRight, typeof "x".substr]`)).toEqual([
|
||||
"undefined",
|
||||
"undefined",
|
||||
"undefined",
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -560,7 +560,7 @@ describe("Promise.all over arbitrary arrays", () => {
|
||||
expect(trace.maxActive).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
test("caps live tool-call concurrency at the fixed internal constant (8)", async () => {
|
||||
test("does not cap live tool-call concurrency", async () => {
|
||||
const trace = makeTrace()
|
||||
const result = await value(
|
||||
`
|
||||
@@ -572,8 +572,7 @@ describe("Promise.all over arbitrary arrays", () => {
|
||||
{ trace },
|
||||
)
|
||||
expect(result).toBe(20)
|
||||
expect(trace.maxActive).toBeGreaterThan(1)
|
||||
expect(trace.maxActive).toBeLessThanOrEqual(8)
|
||||
expect(trace.maxActive).toBe(20)
|
||||
})
|
||||
|
||||
test("resolves the empty array", async () => {
|
||||
|
||||
@@ -49,12 +49,6 @@
|
||||
* - test/built-ins/String/prototype/substring/S15.5.4.15_A2_T8.js
|
||||
* - test/built-ins/String/prototype/substring/S15.5.4.15_A2_T9.js
|
||||
* - test/built-ins/String/prototype/substring/S15.5.4.15_A2_T10.js
|
||||
* - test/annexB/built-ins/String/prototype/substr/start-negative.js
|
||||
* - test/annexB/built-ins/String/prototype/substr/length-negative.js
|
||||
* - test/annexB/built-ins/String/prototype/substr/length-positive.js
|
||||
* - test/annexB/built-ins/String/prototype/substr/length-falsey.js
|
||||
* - test/annexB/built-ins/String/prototype/substr/length-undef.js
|
||||
* - test/annexB/built-ins/String/prototype/substr/surrogate-pairs.js
|
||||
* - test/built-ins/String/prototype/includes/String.prototype.includes_FailMissingLetter.js
|
||||
* - test/built-ins/String/prototype/includes/String.prototype.includes_SuccessNoLocation.js
|
||||
* - test/built-ins/String/prototype/includes/String.prototype.includes_FailBadLocation.js
|
||||
@@ -460,67 +454,6 @@ const cases = [
|
||||
expected: ["this_is_"],
|
||||
labels: ['#1: __string.substring(0,8) === "this_is_"'],
|
||||
},
|
||||
{
|
||||
path: "test/annexB/built-ins/String/prototype/substr/start-negative.js",
|
||||
code: `return ["abc".substr(-1), "abc".substr(-2), "abc".substr(-3), "abc".substr(-4), "abc".substr(-1.1)]`,
|
||||
expected: ["c", "bc", "abc", "abc", "c"],
|
||||
labels: ["-1", "-2", "-3", "size + intStart < 0", "floating point rounding semantics"],
|
||||
},
|
||||
{
|
||||
path: "test/annexB/built-ins/String/prototype/substr/length-negative.js",
|
||||
code: `return [
|
||||
"abc".substr(0, -1), "abc".substr(0, -2), "abc".substr(0, -3), "abc".substr(0, -4),
|
||||
"abc".substr(1, -1), "abc".substr(1, -2), "abc".substr(1, -3), "abc".substr(1, -4),
|
||||
"abc".substr(2, -1), "abc".substr(2, -2), "abc".substr(2, -3), "abc".substr(2, -4),
|
||||
"abc".substr(3, -1), "abc".substr(3, -2), "abc".substr(3, -3), "abc".substr(3, -4),
|
||||
]`,
|
||||
expected: ["", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""],
|
||||
labels: [
|
||||
"0, -1", "0, -2", "0, -3", "0, -4", "1, -1", "1, -2", "1, -3", "1, -4",
|
||||
"2, -1", "2, -2", "2, -3", "2, -4", "3, -1", "3, -2", "3, -3", "3, -4",
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "test/annexB/built-ins/String/prototype/substr/length-positive.js",
|
||||
code: `return [
|
||||
"abc".substr(0, 1), "abc".substr(0, 2), "abc".substr(0, 3), "abc".substr(0, 4),
|
||||
"abc".substr(1, 1), "abc".substr(1, 2), "abc".substr(1, 3), "abc".substr(1, 4),
|
||||
"abc".substr(2, 1), "abc".substr(2, 2), "abc".substr(2, 3), "abc".substr(2, 4),
|
||||
"abc".substr(3, 1), "abc".substr(3, 2), "abc".substr(3, 3), "abc".substr(3, 4),
|
||||
]`,
|
||||
expected: ["a", "ab", "abc", "abc", "b", "bc", "bc", "bc", "c", "c", "c", "c", "", "", "", ""],
|
||||
labels: [
|
||||
"0, 1", "0, 1", "0, 1", "0, 1", "1, 1", "1, 1", "1, 1", "1, 1",
|
||||
"2, 1", "2, 1", "2, 1", "2, 1", "3, 1", "3, 1", "3, 1", "3, 1",
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "test/annexB/built-ins/String/prototype/substr/length-falsey.js",
|
||||
code: `return ["abc".substr(0, NaN), "abc".substr(1, NaN), "abc".substr(2, NaN), "abc".substr(3, NaN)]`,
|
||||
expected: ["", "", "", ""],
|
||||
labels: ["start: 0, length: NaN", "start: 1, length: NaN", "start: 2, length: NaN", "start: 3, length: NaN"],
|
||||
},
|
||||
{
|
||||
path: "test/annexB/built-ins/String/prototype/substr/length-undef.js",
|
||||
code: `return [
|
||||
"abc".substr(0), "abc".substr(1), "abc".substr(2), "abc".substr(3),
|
||||
"abc".substr(0, undefined), "abc".substr(1, undefined), "abc".substr(2, undefined), "abc".substr(3, undefined),
|
||||
]`,
|
||||
expected: ["abc", "bc", "c", "", "abc", "bc", "c", ""],
|
||||
labels: [
|
||||
"start: 0, length: unspecified", "start: 1, length: unspecified", "start: 2, length: unspecified", "start: 3, length: unspecified",
|
||||
"start: 0, length: undefined", "start: 1, length: undefined", "start: 2, length: undefined", "start: 3, length: undefined",
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "test/annexB/built-ins/String/prototype/substr/surrogate-pairs.js",
|
||||
code: `return [
|
||||
"\uD834\uDF06".substr(0), "\uD834\uDF06".substr(1), "\uD834\uDF06".substr(2),
|
||||
"\uD834\uDF06".substr(0, 0), "\uD834\uDF06".substr(0, 1), "\uD834\uDF06".substr(0, 2),
|
||||
]`,
|
||||
expected: ["\uD834\uDF06", "\uDF06", "", "", "\uD834", "\uD834\uDF06"],
|
||||
labels: ["start: 0", "start: 1", "start: 2", "end: 0", "end: 1", "end: 2"],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/String/prototype/includes/String.prototype.includes_FailMissingLetter.js",
|
||||
code: `return ["word".includes("a", 0)]`, expected: [false], labels: ['"word".includes("a", 0)'],
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@tsconfig/bun/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noUncheckedIndexedAccess": false
|
||||
"noUncheckedIndexedAccess": false,
|
||||
"noUnusedLocals": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,7 +200,6 @@ const layer = Layer.effect(
|
||||
.up({
|
||||
targets: [".opencode", ".claude", ".agents", ...names.toReversed()],
|
||||
start: location.directory,
|
||||
stop: location.project.directory,
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
|
||||
@@ -52,9 +52,6 @@ export const Flag = {
|
||||
get OPENCODE_EXPERIMENTAL_REFERENCES() {
|
||||
return enabledByExperimental("OPENCODE_EXPERIMENTAL_REFERENCES")
|
||||
},
|
||||
get CODEMODE_ENABLED() {
|
||||
return process.env["CODEMODE_ENABLED"] === undefined || truthy("CODEMODE_ENABLED")
|
||||
},
|
||||
get OPENCODE_TUI_CONFIG() {
|
||||
return process.env["OPENCODE_TUI_CONFIG"]
|
||||
},
|
||||
|
||||
@@ -85,7 +85,6 @@ export interface OAuthImplementation {
|
||||
export interface KeyImplementation {
|
||||
readonly integrationID: ID
|
||||
readonly method: KeyMethod
|
||||
readonly authorize?: (key: string, inputs: Inputs) => Effect.Effect<Credential.Key, unknown>
|
||||
}
|
||||
|
||||
export interface EnvImplementation {
|
||||
@@ -120,7 +119,6 @@ type Entry = {
|
||||
ref: Types.DeepMutable<Ref>
|
||||
methods: Types.DeepMutable<Method>[]
|
||||
implementations: Map<MethodID, Types.DeepMutable<OAuthImplementation>>
|
||||
key?: Types.DeepMutable<KeyImplementation>
|
||||
}
|
||||
|
||||
type Data = {
|
||||
@@ -158,8 +156,6 @@ export interface Interface extends State.Transformable<Draft> {
|
||||
readonly integrationID: ID
|
||||
/** Secret entered by the user. */
|
||||
readonly key: string
|
||||
/** Answers to the method's optional prompts. */
|
||||
readonly inputs?: Inputs
|
||||
/** User-facing label for the stored credential. */
|
||||
readonly label?: string
|
||||
}) => Effect.Effect<void, AuthorizationError>
|
||||
@@ -241,7 +237,6 @@ const layer = Layer.effect(
|
||||
ref: { id, name: id },
|
||||
methods: [],
|
||||
implementations: new Map(),
|
||||
key: undefined,
|
||||
}
|
||||
if (!draft.integrations.has(id)) draft.integrations.set(id, current)
|
||||
update(current.ref)
|
||||
@@ -258,7 +253,6 @@ const layer = Layer.effect(
|
||||
},
|
||||
methods: [],
|
||||
implementations: new Map<MethodID, Types.DeepMutable<OAuthImplementation>>(),
|
||||
key: undefined,
|
||||
}
|
||||
if (!draft.integrations.has(implementation.integrationID)) {
|
||||
draft.integrations.set(implementation.integrationID, current)
|
||||
@@ -276,9 +270,6 @@ const layer = Layer.effect(
|
||||
implementation as Types.DeepMutable<OAuthImplementation>,
|
||||
)
|
||||
}
|
||||
if (implementation.method.type === "key") {
|
||||
current.key = implementation as Types.DeepMutable<KeyImplementation>
|
||||
}
|
||||
},
|
||||
remove: (integrationID, method) => {
|
||||
const current = draft.integrations.get(integrationID)
|
||||
@@ -290,7 +281,6 @@ const layer = Layer.effect(
|
||||
})
|
||||
if (index !== -1) current.methods.splice(index, 1)
|
||||
if (method.type === "oauth") current.implementations.delete(method.id)
|
||||
if (method.type === "key") current.key = undefined
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -375,10 +365,10 @@ const layer = Layer.effect(
|
||||
? { status: "complete", time: attempt.time, removeAt: settledAt + terminalRetention }
|
||||
: {
|
||||
status: "failed",
|
||||
message: message(persistence.cause),
|
||||
time: attempt.time,
|
||||
removeAt: settledAt + terminalRetention,
|
||||
}
|
||||
message: message(persistence.cause),
|
||||
time: attempt.time,
|
||||
removeAt: settledAt + terminalRetention,
|
||||
}
|
||||
// Persisting attempts cannot be cancelled, expired, or claimed again.
|
||||
yield* SynchronizedRef.update(attempts, (current) => new Map(current).set(attemptID, terminal))
|
||||
if (Exit.isFailure(persistence)) yield* Effect.failCause(persistence.cause)
|
||||
@@ -448,16 +438,15 @@ const layer = Layer.effect(
|
||||
return value
|
||||
}),
|
||||
key: Effect.fn("Integration.connection.key")(function* (input) {
|
||||
const entry = state.get().integrations.get(input.integrationID)
|
||||
const method = entry?.methods.some((method) => method.type === "key")
|
||||
if (!entry || !method) return yield* Effect.die(new Error(`Key method not found: ${input.integrationID}`))
|
||||
const value = entry.key?.authorize
|
||||
? yield* authorize(entry.key.authorize(input.key, input.inputs ?? {}))
|
||||
: Credential.Key.make({ type: "key", key: input.key })
|
||||
const method = state
|
||||
.get()
|
||||
.integrations.get(input.integrationID)
|
||||
?.methods.some((method) => method.type === "key")
|
||||
if (!method) return yield* Effect.die(new Error(`Key method not found: ${input.integrationID}`))
|
||||
yield* credentials.create({
|
||||
integrationID: input.integrationID,
|
||||
label: input.label,
|
||||
value,
|
||||
value: Credential.Key.make({ type: "key", key: input.key }),
|
||||
})
|
||||
yield* events.publish(Event.ConnectionUpdated, { integrationID: input.integrationID })
|
||||
yield* events.publish(Event.Updated, {})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * as McpGuidance from "./guidance"
|
||||
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { PermissionV2 } from "../permission"
|
||||
@@ -18,11 +17,7 @@ type Summary = typeof Summary.Type
|
||||
const entries = (servers: ReadonlyArray<Summary>) =>
|
||||
servers.flatMap((server) => [
|
||||
` <server name="${server.server}">`,
|
||||
...(Flag.CODEMODE_ENABLED
|
||||
? [
|
||||
` Use tools from this server through \`execute\` under \`tools[${JSON.stringify(McpTool.group(server.server))}]\`.`,
|
||||
]
|
||||
: []),
|
||||
` Use tools from this server through \`execute\` under \`tools[${JSON.stringify(McpTool.group(server.server))}]\`.`,
|
||||
...server.instructions.split("\n").map((line) => ` ${line}`),
|
||||
" </server>",
|
||||
])
|
||||
@@ -81,7 +76,7 @@ export const layer = Layer.effect(
|
||||
removed: () => "MCP server instructions are no longer available.",
|
||||
},
|
||||
})
|
||||
if (Flag.CODEMODE_ENABLED && PermissionV2.evaluate("execute", "*", agent.permissions).effect === "deny")
|
||||
if (PermissionV2.evaluate("execute", "*", agent.permissions).effect === "deny")
|
||||
return source(Instructions.removed)
|
||||
const [instructions, tools] = yield* Effect.all([mcp.instructions(), mcp.tools()], {
|
||||
concurrency: "unbounded",
|
||||
@@ -90,12 +85,9 @@ export const layer = Layer.effect(
|
||||
const visible = instructions
|
||||
.filter((item) => {
|
||||
const owned = tools.filter((tool) => tool.server === item.server)
|
||||
return (
|
||||
(!Flag.CODEMODE_ENABLED && owned.length === 0) ||
|
||||
owned.some(
|
||||
(tool) =>
|
||||
PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny",
|
||||
)
|
||||
return owned.some(
|
||||
(tool) =>
|
||||
PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny",
|
||||
)
|
||||
})
|
||||
.map((item) => ({ server: item.server, instructions: item.instructions }))
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
export * as PluginHost from "./host"
|
||||
|
||||
import type { Plugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import type {
|
||||
IntegrationInputs,
|
||||
IntegrationKeyMethodRegistration,
|
||||
IntegrationOAuthMethodRegistration,
|
||||
} from "@opencode-ai/plugin/v2/effect/integration"
|
||||
import { EventManifest } from "@opencode-ai/schema/event-manifest"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
@@ -176,7 +171,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||
integration.connection.key({
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
key: input.key,
|
||||
inputs: input.inputs,
|
||||
label: input.label,
|
||||
}),
|
||||
oauth: (input) =>
|
||||
@@ -213,15 +207,14 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||
method: {
|
||||
list: (id) => mutable(draft.method.list(Integration.ID.make(id))),
|
||||
update: (input) => {
|
||||
if (input.method.type === "oauth") {
|
||||
const oauth = input as IntegrationOAuthMethodRegistration
|
||||
const methodID = Integration.MethodID.make(oauth.method.id)
|
||||
const refresh = oauth.refresh
|
||||
if ("authorize" in input) {
|
||||
const methodID = Integration.MethodID.make(input.method.id)
|
||||
const refresh = input.refresh
|
||||
draft.method.update({
|
||||
integrationID: Integration.ID.make(oauth.integrationID),
|
||||
method: { ...oauth.method, id: methodID },
|
||||
authorize: (inputs: IntegrationInputs) =>
|
||||
oauth.authorize(inputs).pipe(
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: { ...input.method, id: methodID },
|
||||
authorize: (inputs) =>
|
||||
input.authorize(inputs).pipe(
|
||||
Effect.map((authorization) => {
|
||||
if (authorization.mode === "auto") {
|
||||
return {
|
||||
@@ -263,7 +256,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(oauth.label ? { label: oauth.label } : {}),
|
||||
...(input.label ? { label: input.label } : {}),
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -274,18 +267,9 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||
})
|
||||
return
|
||||
}
|
||||
const registration = input as IntegrationKeyMethodRegistration
|
||||
draft.method.update({
|
||||
integrationID: Integration.ID.make(registration.integrationID),
|
||||
method: { type: "key", label: registration.method.label, prompts: registration.method.prompts },
|
||||
...(registration.authorize
|
||||
? {
|
||||
authorize: (key, inputs) =>
|
||||
registration.authorize!(key, inputs).pipe(
|
||||
Effect.map((credential) => Schema.decodeUnknownSync(Credential.Key)(credential)),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: { type: "key", label: input.method.label },
|
||||
})
|
||||
},
|
||||
remove: (id, method) =>
|
||||
|
||||
@@ -7,7 +7,6 @@ import { CloudflareAIGatewayPlugin } from "./provider/cloudflare-ai-gateway"
|
||||
import { CloudflareWorkersAIPlugin } from "./provider/cloudflare-workers-ai"
|
||||
import { CoherePlugin } from "./provider/cohere"
|
||||
import { DeepInfraPlugin } from "./provider/deepinfra"
|
||||
import { DigitalOceanPlugin } from "./provider/digitalocean"
|
||||
import { DynamicProviderPlugin } from "./provider/dynamic"
|
||||
import { GatewayPlugin } from "./provider/gateway"
|
||||
import { GithubCopilotPlugin } from "./provider/github-copilot"
|
||||
@@ -25,7 +24,6 @@ import { OpenAICompatiblePlugin } from "./provider/openai-compatible"
|
||||
import { OpencodePlugin } from "./provider/opencode"
|
||||
import { OpenRouterPlugin } from "./provider/openrouter"
|
||||
import { PerplexityPlugin } from "./provider/perplexity"
|
||||
import { PoePlugin } from "./provider/poe"
|
||||
import { SapAICorePlugin } from "./provider/sap-ai-core"
|
||||
import { TogetherAIPlugin } from "./provider/togetherai"
|
||||
import { VercelPlugin } from "./provider/vercel"
|
||||
@@ -45,7 +43,6 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
|
||||
CloudflareWorkersAIPlugin,
|
||||
CoherePlugin,
|
||||
DeepInfraPlugin,
|
||||
DigitalOceanPlugin,
|
||||
GatewayPlugin,
|
||||
GithubCopilotPlugin,
|
||||
GitLabPlugin,
|
||||
@@ -63,7 +60,6 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
|
||||
OpenAIPlugin,
|
||||
OpenRouterPlugin,
|
||||
PerplexityPlugin,
|
||||
PoePlugin,
|
||||
SapAICorePlugin,
|
||||
TogetherAIPlugin,
|
||||
VercelPlugin,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Credential } from "../../credential"
|
||||
import { Integration } from "../../integration"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
function selectLanguage(sdk: any, modelID: string, useChat: boolean) {
|
||||
@@ -15,34 +13,6 @@ function selectLanguage(sdk: any, modelID: string, useChat: boolean) {
|
||||
export const AzurePlugin = define({
|
||||
id: "opencode.provider.azure",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const integration = yield* Integration.Service
|
||||
yield* integration.transform((draft) => {
|
||||
draft.method.update({
|
||||
integrationID: Integration.ID.make("azure"),
|
||||
method: {
|
||||
type: "key",
|
||||
label: "API key",
|
||||
prompts: process.env.AZURE_RESOURCE_NAME
|
||||
? []
|
||||
: [
|
||||
{
|
||||
type: "text",
|
||||
key: "resourceName",
|
||||
message: "Enter Azure Resource Name",
|
||||
placeholder: "e.g. my-models",
|
||||
},
|
||||
],
|
||||
},
|
||||
authorize: (key, inputs) =>
|
||||
Effect.succeed(
|
||||
Credential.Key.make({
|
||||
type: "key",
|
||||
key,
|
||||
...(inputs.resourceName ? { metadata: { resourceName: inputs.resourceName } } : {}),
|
||||
}),
|
||||
),
|
||||
})
|
||||
})
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (!ProviderV2.isAISDK(item.provider.package)) continue
|
||||
|
||||
@@ -2,54 +2,10 @@ import os from "os"
|
||||
import { InstallationVersion } from "../../installation/version"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Credential } from "../../credential"
|
||||
import { Integration } from "../../integration"
|
||||
|
||||
const integrationID = Integration.ID.make("cloudflare-ai-gateway")
|
||||
|
||||
export const CloudflareAIGatewayPlugin = define({
|
||||
id: "opencode.provider.cloudflare-ai-gateway",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const integration = yield* Integration.Service
|
||||
yield* integration.transform((draft) => {
|
||||
draft.method.update({
|
||||
integrationID,
|
||||
method: {
|
||||
type: "key",
|
||||
label: "Gateway API token",
|
||||
prompts: [
|
||||
...(process.env.CLOUDFLARE_ACCOUNT_ID
|
||||
? []
|
||||
: [
|
||||
{
|
||||
type: "text" as const,
|
||||
key: "accountId",
|
||||
message: "Enter your Cloudflare Account ID",
|
||||
placeholder: "e.g. 1234567890abcdef1234567890abcdef",
|
||||
},
|
||||
]),
|
||||
...(process.env.CLOUDFLARE_GATEWAY_ID
|
||||
? []
|
||||
: [
|
||||
{
|
||||
type: "text" as const,
|
||||
key: "gatewayId",
|
||||
message: "Enter your Cloudflare AI Gateway ID",
|
||||
placeholder: "e.g. my-gateway",
|
||||
},
|
||||
]),
|
||||
],
|
||||
},
|
||||
authorize: (key, inputs) =>
|
||||
Effect.succeed(
|
||||
Credential.Key.make({
|
||||
type: "key",
|
||||
key,
|
||||
...(Object.keys(inputs).length ? { metadata: inputs } : {}),
|
||||
}),
|
||||
),
|
||||
})
|
||||
})
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
|
||||
@@ -2,44 +2,13 @@ import os from "os"
|
||||
import { InstallationVersion } from "../../installation/version"
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Credential } from "../../credential"
|
||||
import { Integration } from "../../integration"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
const providerID = ProviderV2.ID.make("cloudflare-workers-ai")
|
||||
const integrationID = Integration.ID.make("cloudflare-workers-ai")
|
||||
|
||||
export const CloudflareWorkersAIPlugin = define({
|
||||
id: "opencode.provider.cloudflare-workers-ai",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const integration = yield* Integration.Service
|
||||
yield* integration.transform((draft) => {
|
||||
draft.method.update({
|
||||
integrationID,
|
||||
method: {
|
||||
type: "key",
|
||||
label: "API key",
|
||||
prompts: process.env.CLOUDFLARE_ACCOUNT_ID
|
||||
? []
|
||||
: [
|
||||
{
|
||||
type: "text",
|
||||
key: "accountId",
|
||||
message: "Enter your Cloudflare Account ID",
|
||||
placeholder: "e.g. 1234567890abcdef1234567890abcdef",
|
||||
},
|
||||
],
|
||||
},
|
||||
authorize: (key, inputs) =>
|
||||
Effect.succeed(
|
||||
Credential.Key.make({
|
||||
type: "key",
|
||||
key,
|
||||
...(inputs.accountId ? { metadata: { accountId: inputs.accountId } } : {}),
|
||||
}),
|
||||
),
|
||||
})
|
||||
})
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
const item = evt.provider.get(providerID)
|
||||
if (!item) return
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
import { createServer } from "node:http"
|
||||
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect"
|
||||
import { Credential } from "../../credential"
|
||||
import { EventV2 } from "../../event"
|
||||
import { InstallationVersion } from "../../installation/version"
|
||||
import { Integration } from "../../integration"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { OauthCallbackPage } from "../../oauth/page"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
import type { PluginInternal } from "../internal"
|
||||
|
||||
const clientID = "b1a6c5158156caac821fd1b30253ca8acb52454a48fa744420e41889cb589f82"
|
||||
const authorizeURL = "https://cloud.digitalocean.com/v1/oauth/authorize"
|
||||
const genaiURL = "https://api.digitalocean.com/v2/gen-ai"
|
||||
const inferenceURL = "https://inference.do-ai.run/v1"
|
||||
const callbackPort = 1456
|
||||
const callbackPath = "/auth/callback"
|
||||
const tokenPath = "/auth/token"
|
||||
const scopes = "genai:read inference:query"
|
||||
const refreshInterval = 5 * 60 * 1000
|
||||
const integrationID = Integration.ID.make("digitalocean")
|
||||
const methodID = Integration.MethodID.make("implicit")
|
||||
|
||||
const Token = Schema.Struct({
|
||||
access_token: Schema.String,
|
||||
expires_in: Schema.NumberFromString,
|
||||
state: Schema.String,
|
||||
})
|
||||
const Router = Schema.Struct({
|
||||
name: Schema.String,
|
||||
uuid: Schema.optional(Schema.String),
|
||||
description: Schema.optional(Schema.String),
|
||||
})
|
||||
type Router = typeof Router.Type
|
||||
const RouterResponse = Schema.Struct({ model_routers: Schema.optional(Schema.Array(Router)) })
|
||||
|
||||
const oauth = {
|
||||
integrationID,
|
||||
method: {
|
||||
id: methodID,
|
||||
type: "oauth",
|
||||
label: "Login with DigitalOcean",
|
||||
},
|
||||
authorize: () =>
|
||||
Effect.gen(function* () {
|
||||
const state = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("hex")
|
||||
const token = yield* Deferred.make<typeof Token.Type, Error>()
|
||||
const server = createServer((request, response) => {
|
||||
const url = new URL(request.url ?? "/", `http://localhost:${callbackPort}`)
|
||||
if (request.method === "GET" && url.pathname === callbackPath) {
|
||||
response
|
||||
.writeHead(200, { "Content-Type": "text/html" })
|
||||
.end(OauthCallbackPage.bootstrap({ tokenPath, provider: "DigitalOcean" }))
|
||||
return
|
||||
}
|
||||
if (request.method !== "POST" || url.pathname !== tokenPath) {
|
||||
response.writeHead(404).end("Not found")
|
||||
return
|
||||
}
|
||||
const chunks: Buffer[] = []
|
||||
request.on("data", (chunk: Buffer) => chunks.push(chunk))
|
||||
request.on("end", () => {
|
||||
const decoded = Schema.decodeUnknownOption(Schema.fromJsonString(Token))(Buffer.concat(chunks).toString())
|
||||
if (Option.isNone(decoded) || decoded.value.state !== state) {
|
||||
const error = Option.isNone(decoded) ? "Invalid OAuth callback" : "Invalid OAuth state"
|
||||
Effect.runFork(Deferred.fail(token, new Error(error)))
|
||||
response.writeHead(400, { "Content-Type": "application/json" }).end(JSON.stringify({ error }))
|
||||
return
|
||||
}
|
||||
Effect.runFork(Deferred.succeed(token, decoded.value))
|
||||
response.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify({ ok: true }))
|
||||
})
|
||||
})
|
||||
yield* Effect.callback<void, Error>((resume) => {
|
||||
server.once("error", (error) => resume(Effect.fail(error)))
|
||||
server.listen(callbackPort, "localhost", () => resume(Effect.void))
|
||||
})
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => server.close()))
|
||||
const redirect = `http://localhost:${callbackPort}${callbackPath}`
|
||||
return {
|
||||
mode: "auto" as const,
|
||||
url: `${authorizeURL}?${new URLSearchParams({
|
||||
response_type: "token",
|
||||
client_id: clientID,
|
||||
redirect_uri: redirect,
|
||||
scope: scopes,
|
||||
state,
|
||||
})}`,
|
||||
instructions:
|
||||
"Sign in to DigitalOcean in your browser. OpenCode will use the resulting token for inference and load your Inference Routers.",
|
||||
callback: Deferred.await(token).pipe(
|
||||
Effect.flatMap((value) =>
|
||||
listRouters(value.access_token).pipe(
|
||||
Effect.catch((cause) =>
|
||||
Effect.logWarning("failed to sync DigitalOcean inference routers", { cause }).pipe(Effect.as([])),
|
||||
),
|
||||
Effect.map((routers) =>
|
||||
Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID,
|
||||
refresh: value.access_token,
|
||||
access: value.access_token,
|
||||
expires: Date.now() + value.expires_in * 1000,
|
||||
metadata: { routers, routersFetchedAt: Date.now() },
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
}
|
||||
}),
|
||||
} satisfies IntegrationOAuthMethodRegistration
|
||||
|
||||
export const DigitalOceanPlugin = define({
|
||||
id: "opencode.provider.digitalocean",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const events = yield* EventV2.Service
|
||||
const loading = Semaphore.makeUnsafe(1)
|
||||
const loaded: { routers: readonly Router[] } = { routers: [] }
|
||||
|
||||
const load = Effect.fn("DigitalOceanPlugin.load")(function* () {
|
||||
const connection = yield* ctx.integration.connection.active(integrationID)
|
||||
const saved = connection
|
||||
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
: undefined
|
||||
if (saved?.type !== "oauth") {
|
||||
loaded.routers = []
|
||||
return
|
||||
}
|
||||
const cached = Schema.decodeUnknownOption(Schema.Array(Router))(saved.metadata?.routers)
|
||||
loaded.routers = cached._tag === "Some" ? cached.value : []
|
||||
const fetchedAt = saved.metadata?.routersFetchedAt
|
||||
if (typeof fetchedAt === "number" && Date.now() - fetchedAt <= refreshInterval) return
|
||||
if (saved.expires <= Date.now()) return
|
||||
const routers = yield* listRouters(saved.access).pipe(
|
||||
Effect.catch((cause) =>
|
||||
Effect.logWarning("failed to refresh DigitalOcean inference routers", { cause }).pipe(Effect.as(undefined)),
|
||||
),
|
||||
)
|
||||
if (!routers) return
|
||||
loaded.routers = routers
|
||||
})
|
||||
|
||||
yield* ctx.integration.transform((draft) => {
|
||||
draft.update(integrationID, (integration) => {
|
||||
integration.name = "DigitalOcean"
|
||||
})
|
||||
draft.method.update(oauth)
|
||||
draft.method.update({
|
||||
integrationID,
|
||||
method: { type: "key", label: "Paste Model Access Key" },
|
||||
})
|
||||
})
|
||||
yield* ctx.catalog.transform((catalog) => {
|
||||
if (!catalog.provider.get(ProviderV2.ID.make(integrationID))) return
|
||||
for (const router of loaded.routers) {
|
||||
const id = ModelV2.ID.make(`router:${router.name}`)
|
||||
catalog.model.update(ProviderV2.ID.make(integrationID), id, (model) => {
|
||||
model.modelID = id
|
||||
model.name = router.name
|
||||
model.family = ModelV2.Family.make("digitalocean-inference-routers")
|
||||
model.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
|
||||
model.settings = ProviderV2.mergeOverlay(model.settings, { baseURL: inferenceURL })
|
||||
model.capabilities = { tools: true, input: ["text"], output: ["text"] }
|
||||
model.limit = { context: 128_000, output: 8_192 }
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
|
||||
yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
||||
Stream.filter((event) => event.data.integrationID === integrationID),
|
||||
Stream.runForEach(refresh),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* refresh()
|
||||
}),
|
||||
} satisfies PluginInternal.InternalPlugin)
|
||||
|
||||
function listRouters(access: string) {
|
||||
return Effect.tryPromise({
|
||||
try: async (signal) => {
|
||||
const response = await fetch(`${genaiURL}/models/routers`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${access}`,
|
||||
Accept: "application/json",
|
||||
"User-Agent": `opencode/${InstallationVersion}`,
|
||||
},
|
||||
signal,
|
||||
})
|
||||
if (!response.ok) throw new Error(`DigitalOcean router request failed: ${response.status}`)
|
||||
const body = Schema.decodeUnknownSync(RouterResponse)(await response.json())
|
||||
return body.model_routers ?? []
|
||||
},
|
||||
catch: (cause) => cause,
|
||||
})
|
||||
}
|
||||
@@ -1,153 +1,12 @@
|
||||
import { createServer } from "node:http"
|
||||
import os from "os"
|
||||
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
|
||||
import { InstallationVersion } from "../../installation/version"
|
||||
import { Deferred, Effect, Schema } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Credential } from "../../credential"
|
||||
import { Integration } from "../../integration"
|
||||
import { OauthCallbackPage } from "../../oauth/page"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
const defaultInstanceUrl = "https://gitlab.com"
|
||||
const bundledClientID = "1d89f9fdb23ee96d4e603201f6861dab6e143c5c3c00469a018a2d94bdc03d4e"
|
||||
const callbackHost = "127.0.0.1"
|
||||
const callbackPort = 8080
|
||||
const callbackPath = "/callback"
|
||||
const redirectURI = `http://${callbackHost}:${callbackPort}${callbackPath}`
|
||||
const methodID = Integration.MethodID.make("oauth")
|
||||
|
||||
const Token = Schema.Struct({
|
||||
access_token: Schema.String,
|
||||
refresh_token: Schema.optional(Schema.String),
|
||||
expires_in: Schema.optional(Schema.Number),
|
||||
})
|
||||
type Token = typeof Token.Type
|
||||
|
||||
const oauth = {
|
||||
integrationID: Integration.ID.make("gitlab"),
|
||||
method: {
|
||||
id: methodID,
|
||||
type: "oauth",
|
||||
label: "GitLab OAuth",
|
||||
prompts: [
|
||||
{
|
||||
type: "text",
|
||||
key: "instanceUrl",
|
||||
message: "GitLab instance URL",
|
||||
placeholder: defaultInstanceUrl,
|
||||
},
|
||||
],
|
||||
},
|
||||
authorize: (inputs) =>
|
||||
Effect.gen(function* () {
|
||||
const instanceUrl = normalizeInstanceUrl(inputs.instanceUrl)
|
||||
const pkce = yield* Effect.promise(generatePKCE)
|
||||
const state = randomString(32)
|
||||
const code = yield* Deferred.make<string, Error>()
|
||||
const server = createServer((request, response) => {
|
||||
const url = new URL(request.url ?? "/", redirectURI)
|
||||
if (url.pathname !== callbackPath) {
|
||||
response.writeHead(404).end("Not found")
|
||||
return
|
||||
}
|
||||
const error = url.searchParams.get("error_description") ?? url.searchParams.get("error")
|
||||
const value = url.searchParams.get("code")
|
||||
if (error) {
|
||||
Effect.runFork(Deferred.fail(code, new Error(error)))
|
||||
response
|
||||
.writeHead(400, { "Content-Type": "text/html" })
|
||||
.end(OauthCallbackPage.error(error, { provider: "GitLab" }))
|
||||
return
|
||||
}
|
||||
if (!value || url.searchParams.get("state") !== state) {
|
||||
const message = value ? "Invalid OAuth state" : "Missing authorization code"
|
||||
Effect.runFork(Deferred.fail(code, new Error(message)))
|
||||
response
|
||||
.writeHead(400, { "Content-Type": "text/html" })
|
||||
.end(OauthCallbackPage.error(message, { provider: "GitLab" }))
|
||||
return
|
||||
}
|
||||
Effect.runFork(Deferred.succeed(code, value))
|
||||
response.writeHead(200, { "Content-Type": "text/html" }).end(OauthCallbackPage.success({ provider: "GitLab" }))
|
||||
})
|
||||
yield* Effect.callback<void, Error>((resume) => {
|
||||
server.once("error", (error) => resume(Effect.fail(error)))
|
||||
server.listen(callbackPort, callbackHost, () => resume(Effect.void))
|
||||
})
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => server.close()))
|
||||
return {
|
||||
mode: "auto" as const,
|
||||
url: `${instanceUrl}/oauth/authorize?${new URLSearchParams({
|
||||
client_id: clientID(),
|
||||
redirect_uri: redirectURI,
|
||||
response_type: "code",
|
||||
state,
|
||||
scope: "api",
|
||||
code_challenge: pkce.challenge,
|
||||
code_challenge_method: "S256",
|
||||
})}`,
|
||||
instructions: "Complete authorization in your browser. This window will close automatically.",
|
||||
callback: Deferred.await(code).pipe(
|
||||
Effect.flatMap((value) =>
|
||||
token(instanceUrl, {
|
||||
grant_type: "authorization_code",
|
||||
code: value,
|
||||
redirect_uri: redirectURI,
|
||||
client_id: clientID(),
|
||||
code_verifier: pkce.verifier,
|
||||
}),
|
||||
),
|
||||
Effect.flatMap((value) => credential(value, instanceUrl)),
|
||||
),
|
||||
}
|
||||
}),
|
||||
refresh: (value) => {
|
||||
const instanceUrl = normalizeMetadataInstanceUrl(value.metadata?.instanceUrl)
|
||||
return token(instanceUrl, {
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: value.refresh,
|
||||
client_id: clientID(),
|
||||
}).pipe(Effect.flatMap((next) => credential(next, instanceUrl, next.refresh_token ?? value.refresh)))
|
||||
},
|
||||
label: (value) => (typeof value.metadata?.instanceUrl === "string" ? value.metadata.instanceUrl : undefined),
|
||||
} satisfies IntegrationOAuthMethodRegistration
|
||||
|
||||
export const GitLabPlugin = define({
|
||||
id: "opencode.provider.gitlab",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.integration.transform((draft) => {
|
||||
draft.method.update(oauth)
|
||||
draft.method.update({
|
||||
integrationID: Integration.ID.make("gitlab"),
|
||||
method: {
|
||||
type: "key",
|
||||
label: "GitLab Personal Access Token",
|
||||
prompts: [
|
||||
{
|
||||
type: "text",
|
||||
key: "instanceUrl",
|
||||
message: "GitLab instance URL",
|
||||
placeholder: defaultInstanceUrl,
|
||||
},
|
||||
],
|
||||
},
|
||||
authorize: (key, inputs) =>
|
||||
Effect.gen(function* () {
|
||||
const instanceUrl = normalizeInstanceUrl(inputs.instanceUrl)
|
||||
const response = yield* send(`${instanceUrl}/api/v4/user`, {
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
})
|
||||
if (!response.ok)
|
||||
return yield* Effect.fail(new Error(`GitLab token validation failed (${response.status})`))
|
||||
return Credential.Key.make({ type: "key", key, metadata: { instanceUrl } })
|
||||
}),
|
||||
})
|
||||
draft.method.update({
|
||||
integrationID: Integration.ID.make("gitlab"),
|
||||
method: { type: "env", names: ["GITLAB_TOKEN"] },
|
||||
})
|
||||
})
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
@@ -204,79 +63,3 @@ export const GitLabPlugin = define({
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
function clientID() {
|
||||
return process.env.GITLAB_OAUTH_CLIENT_ID ?? bundledClientID
|
||||
}
|
||||
|
||||
function normalizeInstanceUrl(value?: string) {
|
||||
const input = value?.trim() || process.env.GITLAB_INSTANCE_URL || defaultInstanceUrl
|
||||
return normalizeURL(input)
|
||||
}
|
||||
|
||||
function normalizeMetadataInstanceUrl(value: unknown) {
|
||||
if (typeof value !== "string" || !value) throw new Error("GitLab OAuth credential is missing instanceUrl metadata")
|
||||
return normalizeURL(value)
|
||||
}
|
||||
|
||||
function normalizeURL(value: string) {
|
||||
const url = new URL(value)
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new Error("GitLab instance URL must use http or https")
|
||||
}
|
||||
return `${url.protocol}//${url.host}`
|
||||
}
|
||||
|
||||
function token(instanceUrl: string, body: Record<string, string>) {
|
||||
return send(`${instanceUrl}/oauth/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
||||
body: new URLSearchParams(body).toString(),
|
||||
}).pipe(
|
||||
Effect.flatMap((response) => {
|
||||
if (response.ok) {
|
||||
return Effect.promise(() => response.json()).pipe(Effect.map(Schema.decodeUnknownSync(Token)))
|
||||
}
|
||||
return Effect.promise(() => response.text()).pipe(
|
||||
Effect.flatMap((detail) =>
|
||||
Effect.fail(new Error(`GitLab token request failed (${response.status})${detail ? `: ${detail}` : ""}`)),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function send(url: string, init: RequestInit) {
|
||||
return Effect.tryPromise({
|
||||
try: (signal) => fetch(url, { ...init, signal }),
|
||||
catch: (cause) => cause,
|
||||
})
|
||||
}
|
||||
|
||||
function credential(tokens: Token, instanceUrl: string, currentRefresh?: string) {
|
||||
const refresh = tokens.refresh_token ?? currentRefresh
|
||||
if (!refresh) return Effect.fail(new Error("GitLab token response is missing refresh_token"))
|
||||
return Effect.succeed(
|
||||
Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID,
|
||||
access: tokens.access_token,
|
||||
refresh,
|
||||
expires: Date.now() + (tokens.expires_in ?? 7200) * 1000,
|
||||
metadata: { instanceUrl },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function generatePKCE() {
|
||||
const verifier = randomString(64)
|
||||
const challenge = Buffer.from(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))).toString(
|
||||
"base64url",
|
||||
)
|
||||
return { verifier, challenge }
|
||||
}
|
||||
|
||||
function randomString(length: number) {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||
return Array.from(crypto.getRandomValues(new Uint8Array(length)), (byte) => chars[byte % chars.length]).join("")
|
||||
}
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
import { createServer } from "node:http"
|
||||
import type { AddressInfo } from "node:net"
|
||||
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Deferred, Effect, Schema } from "effect"
|
||||
import { Credential } from "../../credential"
|
||||
import { Integration } from "../../integration"
|
||||
import { OauthCallbackPage } from "../../oauth/page"
|
||||
|
||||
const clientID = "client_728290227fc048cc9262091a1ea197ea"
|
||||
const authorizationEndpoint = "https://poe.com/oauth/authorize"
|
||||
const tokenEndpoint = "https://api.poe.com/token"
|
||||
const callbackPath = "/callback"
|
||||
const integrationID = Integration.ID.make("poe")
|
||||
const methodID = Integration.MethodID.make("browser")
|
||||
|
||||
const Token = Schema.Struct({
|
||||
api_key: Schema.String,
|
||||
api_key_expires_in: Schema.optional(Schema.NullOr(Schema.Number)),
|
||||
})
|
||||
|
||||
const oauth = {
|
||||
integrationID,
|
||||
method: {
|
||||
id: methodID,
|
||||
type: "oauth",
|
||||
label: "Login with Poe (browser)",
|
||||
},
|
||||
authorize: () =>
|
||||
Effect.gen(function* () {
|
||||
const verifier = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url")
|
||||
const challenge = Buffer.from(
|
||||
yield* Effect.promise(() => crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))),
|
||||
).toString("base64url")
|
||||
const code = yield* Deferred.make<string, Error>()
|
||||
const server = createServer((request, response) => {
|
||||
const url = new URL(request.url ?? "/", "http://127.0.0.1")
|
||||
if (url.pathname !== callbackPath) {
|
||||
response.writeHead(404).end("Not found")
|
||||
return
|
||||
}
|
||||
const error = url.searchParams.get("error")
|
||||
if (error) {
|
||||
const description = url.searchParams.get("error_description") ?? error
|
||||
Effect.runFork(Deferred.fail(code, new Error(`OAuth authorization failed: ${error} - ${description}`)))
|
||||
response
|
||||
.writeHead(400, { "Content-Type": "text/html" })
|
||||
.end(OauthCallbackPage.error(description, { provider: "Poe" }))
|
||||
return
|
||||
}
|
||||
const value = url.searchParams.get("code")
|
||||
if (!value) {
|
||||
Effect.runFork(Deferred.fail(code, new Error("OAuth callback missing authorization code")))
|
||||
response
|
||||
.writeHead(400, { "Content-Type": "text/html" })
|
||||
.end(OauthCallbackPage.error("Missing authorization code", { provider: "Poe" }))
|
||||
return
|
||||
}
|
||||
Effect.runFork(Deferred.succeed(code, value))
|
||||
response.writeHead(200, { "Content-Type": "text/html" }).end(OauthCallbackPage.success({ provider: "Poe" }))
|
||||
})
|
||||
yield* Effect.callback<void, Error>((resume) => {
|
||||
server.once("error", (error) => resume(Effect.fail(error)))
|
||||
server.listen(0, "127.0.0.1", () => resume(Effect.void))
|
||||
})
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
server.closeAllConnections()
|
||||
server.close()
|
||||
}),
|
||||
)
|
||||
const address = server.address() as AddressInfo
|
||||
const redirectURI = `http://127.0.0.1:${address.port}${callbackPath}`
|
||||
return {
|
||||
mode: "auto" as const,
|
||||
url: `${authorizationEndpoint}?${new URLSearchParams({
|
||||
response_type: "code",
|
||||
client_id: clientID,
|
||||
scope: "apikey:create",
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
redirect_uri: redirectURI,
|
||||
})}`,
|
||||
instructions: "Complete authorization in your browser. This window will close automatically.",
|
||||
callback: Deferred.await(code).pipe(
|
||||
Effect.flatMap((value) =>
|
||||
Effect.tryPromise({
|
||||
try: (signal) =>
|
||||
fetch(tokenEndpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code: value,
|
||||
code_verifier: verifier,
|
||||
client_id: clientID,
|
||||
redirect_uri: redirectURI,
|
||||
}).toString(),
|
||||
signal,
|
||||
}),
|
||||
catch: (cause) => cause,
|
||||
}),
|
||||
),
|
||||
Effect.flatMap((response) => {
|
||||
if (response.ok)
|
||||
return Effect.promise(() => response.json()).pipe(Effect.map(Schema.decodeUnknownSync(Token)))
|
||||
return Effect.promise(() => response.text()).pipe(
|
||||
Effect.flatMap((detail) =>
|
||||
Effect.fail(new Error(`Poe token exchange failed (${response.status})${detail ? `: ${detail}` : ""}`)),
|
||||
),
|
||||
)
|
||||
}),
|
||||
Effect.map((token) =>
|
||||
Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID,
|
||||
access: token.api_key,
|
||||
refresh: token.api_key,
|
||||
expires:
|
||||
token.api_key_expires_in == null
|
||||
? Number.MAX_SAFE_INTEGER
|
||||
: Date.now() + token.api_key_expires_in * 1000,
|
||||
}),
|
||||
),
|
||||
),
|
||||
}
|
||||
}),
|
||||
} satisfies IntegrationOAuthMethodRegistration
|
||||
|
||||
export const PoePlugin = define({
|
||||
id: "opencode.provider.poe",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.integration.transform((draft) => {
|
||||
draft.update(integrationID, (integration) => {
|
||||
integration.name = "Poe"
|
||||
})
|
||||
draft.method.update(oauth)
|
||||
draft.method.update({ integrationID, method: { type: "key", label: "Manually enter API Key" } })
|
||||
})
|
||||
}),
|
||||
})
|
||||
@@ -1,46 +1,12 @@
|
||||
import { createServer } from "node:http"
|
||||
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Deferred, Effect, Schema } from "effect"
|
||||
import { Credential } from "../../credential"
|
||||
import { InstallationVersion } from "../../installation/version"
|
||||
import { Integration } from "../../integration"
|
||||
import { OauthCallbackPage } from "../../oauth/page"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise<Response>
|
||||
|
||||
const integrationID = Integration.ID.make("snowflake-cortex")
|
||||
const browserMethodID = Integration.MethodID.make("snowflake-browser")
|
||||
const clientID = "LOCAL_APPLICATION"
|
||||
const callbackHost = "127.0.0.1"
|
||||
|
||||
type TokenResponse = {
|
||||
access_token: string
|
||||
refresh_token?: string
|
||||
expires_in?: number
|
||||
}
|
||||
|
||||
const TokenResponse = Schema.Struct({
|
||||
access_token: Schema.String,
|
||||
refresh_token: Schema.optional(Schema.String),
|
||||
expires_in: Schema.optional(Schema.Number),
|
||||
})
|
||||
|
||||
export function oauthScope(role: string | undefined) {
|
||||
if (!role) return "refresh_token"
|
||||
return /^[-_A-Za-z0-9]+$/.test(role)
|
||||
? `refresh_token session:role:${role}`
|
||||
: `refresh_token session:role-encoded:${encodeURIComponent(role)}`
|
||||
}
|
||||
|
||||
// Exported for testing: intercepts Cortex-specific request/response quirks.
|
||||
export function cortexFetch(upstream: FetchLike = fetch) {
|
||||
return async (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
||||
const headers = new Headers(url instanceof Request ? url.headers : undefined)
|
||||
if (init?.headers) new Headers(init.headers).forEach((value, key) => headers.set(key, value))
|
||||
headers.set("User-Agent", `opencode/${InstallationVersion}`)
|
||||
init = { ...init, headers }
|
||||
if (init?.body && typeof init.body === "string") {
|
||||
try {
|
||||
const body = JSON.parse(init.body)
|
||||
@@ -101,24 +67,10 @@ export function cortexFetch(upstream: FetchLike = fetch) {
|
||||
export const SnowflakeCortexPlugin = define({
|
||||
id: "opencode.provider.snowflake-cortex",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.integration.transform((draft) => {
|
||||
draft.method.update(browser)
|
||||
draft.method.update({
|
||||
integrationID: "snowflake-cortex",
|
||||
method: { type: "key", label: "Paste PAT or bearer token manually" },
|
||||
})
|
||||
draft.method.update({
|
||||
integrationID: "snowflake-cortex",
|
||||
method: { type: "env", names: ["SNOWFLAKE_CORTEX_TOKEN", "SNOWFLAKE_CORTEX_PAT"] },
|
||||
})
|
||||
})
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.make("snowflake-cortex")) return
|
||||
const account = normalizeAccount(
|
||||
process.env.SNOWFLAKE_ACCOUNT ?? (typeof evt.options.account === "string" ? evt.options.account : ""),
|
||||
)
|
||||
const token =
|
||||
process.env.SNOWFLAKE_CORTEX_TOKEN ??
|
||||
process.env.SNOWFLAKE_CORTEX_PAT ??
|
||||
@@ -126,7 +78,6 @@ export const SnowflakeCortexPlugin = define({
|
||||
(typeof evt.options.apiKey === "string" ? evt.options.apiKey : undefined)
|
||||
const upstream = typeof evt.options.fetch === "function" ? (evt.options.fetch as FetchLike) : undefined
|
||||
if (evt.options.includeUsage !== false) evt.options.includeUsage = true
|
||||
if (account) evt.options.baseURL = `https://${account}.snowflakecomputing.com/api/v2/cortex/v1`
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible"))
|
||||
evt.sdk = mod.createOpenAICompatible({
|
||||
...evt.options,
|
||||
@@ -137,180 +88,3 @@ export const SnowflakeCortexPlugin = define({
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
const accountPrompt = {
|
||||
type: "text" as const,
|
||||
key: "account",
|
||||
message: "Snowflake Account Identifier",
|
||||
placeholder: "myorg-myaccount",
|
||||
}
|
||||
|
||||
const browser = {
|
||||
integrationID,
|
||||
method: {
|
||||
id: browserMethodID,
|
||||
type: "oauth",
|
||||
label: "Login with Snowflake (External Browser)",
|
||||
prompts: [
|
||||
accountPrompt,
|
||||
{
|
||||
type: "text",
|
||||
key: "role",
|
||||
message: "Snowflake Role (optional)",
|
||||
placeholder: "PUBLIC",
|
||||
},
|
||||
],
|
||||
},
|
||||
authorize: (inputs) =>
|
||||
Effect.gen(function* () {
|
||||
const account = normalizeAccount(inputs.account ?? "")
|
||||
if (!account) return yield* Effect.fail(new Error("Snowflake account is required"))
|
||||
|
||||
const pkce = yield* Effect.promise(generatePKCE)
|
||||
const state = randomString(64)
|
||||
const code = yield* Deferred.make<string, Error>()
|
||||
const server = createServer((request, response) => {
|
||||
const url = new URL(request.url ?? "/", `http://${callbackHost}`)
|
||||
if (url.pathname !== "/") {
|
||||
response.writeHead(404).end("Not found")
|
||||
return
|
||||
}
|
||||
const error = url.searchParams.get("error_description") ?? url.searchParams.get("error")
|
||||
const value = url.searchParams.get("code")
|
||||
if (error) {
|
||||
Effect.runFork(Deferred.fail(code, new Error(error)))
|
||||
response
|
||||
.writeHead(400, { "Content-Type": "text/html" })
|
||||
.end(OauthCallbackPage.error(error, { provider: "Snowflake" }))
|
||||
return
|
||||
}
|
||||
if (!value || url.searchParams.get("state") !== state) {
|
||||
const message = value ? "Invalid state - potential CSRF attack" : "Missing authorization code"
|
||||
Effect.runFork(Deferred.fail(code, new Error(message)))
|
||||
response
|
||||
.writeHead(400, { "Content-Type": "text/html" })
|
||||
.end(OauthCallbackPage.error(message, { provider: "Snowflake" }))
|
||||
return
|
||||
}
|
||||
Effect.runFork(Deferred.succeed(code, value))
|
||||
response
|
||||
.writeHead(200, { "Content-Type": "text/html" })
|
||||
.end(OauthCallbackPage.success({ provider: "Snowflake" }))
|
||||
})
|
||||
const port = yield* Effect.callback<number, Error>((resume) => {
|
||||
server.once("error", (error) => resume(Effect.fail(error)))
|
||||
server.listen(0, callbackHost, () => {
|
||||
const address = server.address()
|
||||
if (!address || typeof address === "string") {
|
||||
resume(Effect.fail(new Error("Unable to resolve Snowflake OAuth callback port")))
|
||||
return
|
||||
}
|
||||
resume(Effect.succeed(address.port))
|
||||
})
|
||||
})
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => server.close()))
|
||||
const redirect = `http://${callbackHost}:${port}/`
|
||||
const role = inputs.role?.trim() || undefined
|
||||
const url = `https://${account}.snowflakecomputing.com/oauth/authorize?${new URLSearchParams({
|
||||
client_id: clientID,
|
||||
response_type: "code",
|
||||
redirect_uri: redirect,
|
||||
scope: oauthScope(role),
|
||||
state,
|
||||
code_challenge: pkce.challenge,
|
||||
code_challenge_method: "S256",
|
||||
})}`
|
||||
return {
|
||||
mode: "auto" as const,
|
||||
url,
|
||||
instructions:
|
||||
"Complete Snowflake sign-in in your browser. OpenCode will capture the OAuth callback and store the bearer token automatically.",
|
||||
callback: Deferred.await(code).pipe(
|
||||
Effect.flatMap((value) =>
|
||||
token(account, {
|
||||
grant_type: "authorization_code",
|
||||
code: value,
|
||||
redirect_uri: redirect,
|
||||
client_id: clientID,
|
||||
code_verifier: pkce.verifier,
|
||||
}),
|
||||
),
|
||||
Effect.flatMap((value) =>
|
||||
value.refresh_token
|
||||
? Effect.succeed(credential(value, account, value.refresh_token))
|
||||
: Effect.fail(
|
||||
new Error(
|
||||
"Snowflake token response did not include refresh_token. Ensure integration issues refresh tokens and scope includes refresh_token.",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
}
|
||||
}),
|
||||
refresh: (value) => {
|
||||
const account = typeof value.metadata?.account === "string" ? normalizeAccount(value.metadata.account) : ""
|
||||
if (!account) return Effect.fail(new Error("Snowflake OAuth credential is missing account metadata"))
|
||||
return token(account, {
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: value.refresh,
|
||||
client_id: clientID,
|
||||
}).pipe(Effect.map((next) => credential(next, account, next.refresh_token ?? value.refresh)))
|
||||
},
|
||||
label: (value) => (typeof value.metadata?.account === "string" ? value.metadata.account : undefined),
|
||||
} satisfies IntegrationOAuthMethodRegistration
|
||||
|
||||
function token(account: string, body: Record<string, string>) {
|
||||
return Effect.tryPromise({
|
||||
try: async (signal) => {
|
||||
const response = await fetch(`https://${account}.snowflakecomputing.com/oauth/token-request`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
Authorization: `Basic ${Buffer.from(`${clientID}:${clientID}`).toString("base64")}`,
|
||||
"User-Agent": `opencode/${InstallationVersion}`,
|
||||
},
|
||||
body: new URLSearchParams(body).toString(),
|
||||
signal,
|
||||
})
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => "")
|
||||
throw new Error(`Snowflake token request failed (${response.status})${detail ? `: ${detail}` : ""}`)
|
||||
}
|
||||
return Schema.decodeUnknownSync(TokenResponse)(await response.json())
|
||||
},
|
||||
catch: (cause) => cause,
|
||||
})
|
||||
}
|
||||
|
||||
function credential(tokens: TokenResponse, account: string, refresh: string) {
|
||||
return Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: browserMethodID,
|
||||
access: tokens.access_token,
|
||||
refresh,
|
||||
expires: Date.now() + (tokens.expires_in ?? 600) * 1000,
|
||||
metadata: { account },
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeAccount(input: string) {
|
||||
return input
|
||||
.trim()
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/\.snowflakecomputing\.com\/?$/, "")
|
||||
.replace(/\/+$/, "")
|
||||
}
|
||||
|
||||
async function generatePKCE() {
|
||||
const verifier = randomString(64)
|
||||
const challenge = Buffer.from(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))).toString(
|
||||
"base64url",
|
||||
)
|
||||
return { verifier, challenge }
|
||||
}
|
||||
|
||||
function randomString(length: number) {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||
return Array.from(crypto.getRandomValues(new Uint8Array(length)), (byte) => chars[byte % chars.length]).join("")
|
||||
}
|
||||
|
||||
@@ -1,146 +1,10 @@
|
||||
import { createServer } from "node:http"
|
||||
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Clock, Deferred, Effect, Option, Schema } from "effect"
|
||||
import { Credential } from "../../credential"
|
||||
import { InstallationVersion } from "../../installation/version"
|
||||
import { Integration } from "../../integration"
|
||||
import { OauthCallbackPage } from "../../oauth/page"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
const clientID = "b1a00492-073a-47ea-816f-4c329264a828"
|
||||
const issuer = "https://auth.x.ai/oauth2"
|
||||
const deviceGrant = "urn:ietf:params:oauth:grant-type:device_code"
|
||||
const scope = "openid profile email offline_access grok-cli:access api:access"
|
||||
const callbackHost = "127.0.0.1"
|
||||
const callbackPort = 56121
|
||||
const callbackPath = "/callback"
|
||||
const redirectURI = `http://${callbackHost}:${callbackPort}${callbackPath}`
|
||||
const pollingSafetyMargin = 3000
|
||||
const browserMethodID = Integration.MethodID.make("browser")
|
||||
const deviceMethodID = Integration.MethodID.make("device")
|
||||
|
||||
type Pkce = {
|
||||
verifier: string
|
||||
challenge: string
|
||||
}
|
||||
|
||||
const Token = Schema.Struct({
|
||||
access_token: Schema.String,
|
||||
refresh_token: Schema.optional(Schema.String),
|
||||
expires_in: Schema.optional(Schema.Number),
|
||||
})
|
||||
type Token = typeof Token.Type
|
||||
|
||||
const Device = Schema.Struct({
|
||||
device_code: Schema.String,
|
||||
user_code: Schema.String,
|
||||
verification_uri: Schema.String,
|
||||
verification_uri_complete: Schema.optional(Schema.String),
|
||||
expires_in: Schema.optional(Schema.Number),
|
||||
interval: Schema.optional(Schema.Number),
|
||||
})
|
||||
|
||||
const DeviceError = Schema.Struct({
|
||||
error: Schema.optional(Schema.String),
|
||||
error_description: Schema.optional(Schema.String),
|
||||
})
|
||||
const decodeDeviceError = Schema.decodeUnknownOption(Schema.fromJsonString(DeviceError))
|
||||
|
||||
const browser = {
|
||||
integrationID: Integration.ID.make("xai"),
|
||||
method: {
|
||||
id: browserMethodID,
|
||||
type: "oauth",
|
||||
label: "xAI Grok OAuth (SuperGrok Subscription)",
|
||||
},
|
||||
authorize: () =>
|
||||
Effect.gen(function* () {
|
||||
const pkce = yield* Effect.promise(generatePKCE)
|
||||
const state = randomString(32)
|
||||
const code = yield* Deferred.make<string, Error>()
|
||||
const server = createServer((request, response) => {
|
||||
const url = new URL(request.url ?? "/", redirectURI)
|
||||
if (url.pathname !== callbackPath) {
|
||||
response.writeHead(404).end("Not found")
|
||||
return
|
||||
}
|
||||
const error = url.searchParams.get("error_description") ?? url.searchParams.get("error")
|
||||
const value = url.searchParams.get("code")
|
||||
if (error) {
|
||||
Effect.runFork(Deferred.fail(code, new Error(error)))
|
||||
response
|
||||
.writeHead(400, { "Content-Type": "text/html" })
|
||||
.end(OauthCallbackPage.error(error, { provider: "xAI" }))
|
||||
return
|
||||
}
|
||||
if (!value || url.searchParams.get("state") !== state) {
|
||||
const message = value ? "Invalid OAuth state" : "Missing authorization code"
|
||||
Effect.runFork(Deferred.fail(code, new Error(message)))
|
||||
response
|
||||
.writeHead(400, { "Content-Type": "text/html" })
|
||||
.end(OauthCallbackPage.error(message, { provider: "xAI" }))
|
||||
return
|
||||
}
|
||||
Effect.runFork(Deferred.succeed(code, value))
|
||||
response.writeHead(200, { "Content-Type": "text/html" }).end(OauthCallbackPage.success({ provider: "xAI" }))
|
||||
})
|
||||
yield* Effect.callback<void, Error>((resume) => {
|
||||
server.once("error", (error) => resume(Effect.fail(error)))
|
||||
server.listen(callbackPort, callbackHost, () => resume(Effect.void))
|
||||
})
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => server.close()))
|
||||
return {
|
||||
mode: "auto" as const,
|
||||
url: authorizeURL(pkce, state, randomString(32)),
|
||||
instructions: "Complete authorization in your browser. This window will close automatically.",
|
||||
callback: Deferred.await(code).pipe(
|
||||
Effect.flatMap((value) => exchange(value, pkce)),
|
||||
Effect.flatMap((tokens) => credential(browserMethodID, tokens)),
|
||||
),
|
||||
}
|
||||
}),
|
||||
refresh: (value) => refresh(browserMethodID, Credential.OAuth.make({ ...value, methodID: browserMethodID })),
|
||||
} satisfies IntegrationOAuthMethodRegistration
|
||||
|
||||
const device = {
|
||||
integrationID: Integration.ID.make("xai"),
|
||||
method: {
|
||||
id: deviceMethodID,
|
||||
type: "oauth",
|
||||
label: "xAI Grok OAuth (Headless / Remote / VPS)",
|
||||
},
|
||||
authorize: () =>
|
||||
request(
|
||||
`${issuer}/device/code`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: headers(),
|
||||
body: new URLSearchParams({ client_id: clientID, scope }).toString(),
|
||||
},
|
||||
Device,
|
||||
).pipe(
|
||||
Effect.map((value) => ({
|
||||
mode: "auto" as const,
|
||||
url: value.verification_uri_complete ?? value.verification_uri,
|
||||
instructions: `Open ${value.verification_uri} on any device and enter code: ${value.user_code}`,
|
||||
callback: poll(value).pipe(Effect.flatMap((tokens) => credential(deviceMethodID, tokens))),
|
||||
})),
|
||||
),
|
||||
refresh: (value) => refresh(deviceMethodID, Credential.OAuth.make({ ...value, methodID: deviceMethodID })),
|
||||
} satisfies IntegrationOAuthMethodRegistration
|
||||
|
||||
export const XAIPlugin = define({
|
||||
id: "opencode.provider.xai",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.integration.transform((draft) => {
|
||||
draft.update("xai", (integration) => {
|
||||
integration.name = "xAI"
|
||||
})
|
||||
draft.method.update(browser)
|
||||
draft.method.update(device)
|
||||
draft.method.update({ integrationID: "xai", method: { type: "key", label: "Manually enter API Key" } })
|
||||
})
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
@@ -158,167 +22,3 @@ export const XAIPlugin = define({
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
function exchange(code: string, pkce: Pkce) {
|
||||
return request(
|
||||
`${issuer}/token`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: headers(),
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: redirectURI,
|
||||
client_id: clientID,
|
||||
code_verifier: pkce.verifier,
|
||||
}).toString(),
|
||||
},
|
||||
Token,
|
||||
)
|
||||
}
|
||||
|
||||
function refresh(methodID: Integration.MethodID, value: Credential.OAuth) {
|
||||
return request(
|
||||
`${issuer}/token`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: headers(),
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: value.refresh,
|
||||
client_id: clientID,
|
||||
}).toString(),
|
||||
},
|
||||
Token,
|
||||
).pipe(Effect.flatMap((tokens) => credential(methodID, tokens, value.refresh, value.metadata)))
|
||||
}
|
||||
|
||||
function poll(device: typeof Device.Type): Effect.Effect<Token, unknown> {
|
||||
return Effect.gen(function* () {
|
||||
const started = yield* Clock.currentTimeMillis
|
||||
const expires = started + positiveSeconds(device.expires_in, 300) * 1000
|
||||
const loop = (interval: number): Effect.Effect<Token, unknown> =>
|
||||
Effect.gen(function* () {
|
||||
if ((yield* Clock.currentTimeMillis) >= expires) {
|
||||
return yield* Effect.fail(new Error("xAI device authorization timed out"))
|
||||
}
|
||||
const response = yield* send(`${issuer}/token`, {
|
||||
method: "POST",
|
||||
headers: headers(),
|
||||
body: new URLSearchParams({
|
||||
grant_type: deviceGrant,
|
||||
client_id: clientID,
|
||||
device_code: device.device_code,
|
||||
}).toString(),
|
||||
})
|
||||
if (response.ok) return yield* decode(response, Token)
|
||||
const error = yield* Effect.promise(() => response.text()).pipe(
|
||||
Effect.map((body) => Option.getOrUndefined(decodeDeviceError(body))),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
)
|
||||
if (error?.error === "authorization_pending") {
|
||||
return yield* Effect.sleep(interval + pollingSafetyMargin).pipe(Effect.andThen(loop(interval)))
|
||||
}
|
||||
if (error?.error === "slow_down") {
|
||||
const next = interval + 5000
|
||||
return yield* Effect.sleep(next + pollingSafetyMargin).pipe(Effect.andThen(loop(next)))
|
||||
}
|
||||
if (error?.error === "access_denied" || error?.error === "authorization_denied") {
|
||||
return yield* Effect.fail(new Error("xAI device authorization was denied"))
|
||||
}
|
||||
if (error?.error === "expired_token") {
|
||||
return yield* Effect.fail(new Error("xAI device code expired - please re-run login"))
|
||||
}
|
||||
const detail = error?.error_description ?? error?.error
|
||||
return yield* Effect.fail(
|
||||
new Error(`xAI device token exchange failed (${response.status})${detail ? `: ${detail}` : ""}`),
|
||||
)
|
||||
})
|
||||
return yield* loop(Math.max(positiveSeconds(device.interval, 5) * 1000, 1000))
|
||||
})
|
||||
}
|
||||
|
||||
function request<S extends Schema.Decoder<unknown>>(url: string, init: RequestInit, schema: S) {
|
||||
return send(url, init).pipe(
|
||||
Effect.flatMap((response) => {
|
||||
if (response.ok) return decode(response, schema)
|
||||
return Effect.promise(() => response.text()).pipe(
|
||||
Effect.flatMap((detail) =>
|
||||
Effect.fail(new Error(`xAI request failed (${response.status})${detail ? `: ${detail}` : ""}`)),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function send(url: string, init: RequestInit) {
|
||||
return Effect.tryPromise({
|
||||
try: (signal) => fetch(url, { ...init, signal }),
|
||||
catch: (cause) => cause,
|
||||
})
|
||||
}
|
||||
|
||||
function decode<S extends Schema.Decoder<unknown>>(response: Response, schema: S) {
|
||||
return Effect.promise(() => response.json()).pipe(Effect.map(Schema.decodeUnknownSync(schema)))
|
||||
}
|
||||
|
||||
function credential(
|
||||
methodID: Integration.MethodID,
|
||||
tokens: Token,
|
||||
currentRefresh?: string,
|
||||
metadata?: Readonly<Record<string, unknown>>,
|
||||
) {
|
||||
const refresh = tokens.refresh_token ?? currentRefresh
|
||||
if (!refresh) return Effect.fail(new Error("xAI token response is missing refresh_token"))
|
||||
return Effect.succeed(
|
||||
Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID,
|
||||
refresh,
|
||||
access: tokens.access_token,
|
||||
expires: Date.now() + positiveSeconds(tokens.expires_in, 3600) * 1000,
|
||||
metadata,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function headers() {
|
||||
return {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
"User-Agent": `opencode/${InstallationVersion}`,
|
||||
}
|
||||
}
|
||||
|
||||
function positiveSeconds(value: unknown, fallback: number) {
|
||||
const seconds = Number(value)
|
||||
return Number.isFinite(seconds) && seconds > 0 ? seconds : fallback
|
||||
}
|
||||
|
||||
async function generatePKCE(): Promise<Pkce> {
|
||||
const verifier = randomString(64)
|
||||
const challenge = Buffer.from(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))).toString(
|
||||
"base64url",
|
||||
)
|
||||
return { verifier, challenge }
|
||||
}
|
||||
|
||||
function randomString(length: number) {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||
return Array.from(crypto.getRandomValues(new Uint8Array(length)), (byte) => chars[byte % chars.length]).join("")
|
||||
}
|
||||
|
||||
function authorizeURL(pkce: Pkce, state: string, nonce: string) {
|
||||
return `${issuer}/authorize?${new URLSearchParams({
|
||||
response_type: "code",
|
||||
client_id: clientID,
|
||||
redirect_uri: redirectURI,
|
||||
scope,
|
||||
code_challenge: pkce.challenge,
|
||||
code_challenge_method: "S256",
|
||||
state,
|
||||
nonce,
|
||||
plan: "generic",
|
||||
referrer: "opencode",
|
||||
})}`
|
||||
}
|
||||
|
||||
@@ -29,7 +29,8 @@ Leaves own resolution, permission, and side-effect ordering. Translate only expe
|
||||
## Registration
|
||||
|
||||
Built-ins and plugin tools register through `Tools.Service.register({ [name]: tool })`. Registrations may provide a
|
||||
group, which flattens direct model names to `<group>_<tool>`, and may be deferred from direct model exposure.
|
||||
group, which flattens direct model names to `<group>_<tool>`, and default into CodeMode (`codemode` defaults true;
|
||||
`codemode: false` keeps the tool on the provider's native tool list).
|
||||
|
||||
Registrations are scoped:
|
||||
|
||||
|
||||
@@ -212,6 +212,7 @@ export const Plugin = {
|
||||
}),
|
||||
"edit",
|
||||
),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -58,16 +58,16 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
|
||||
})
|
||||
if (registration.group === undefined) {
|
||||
const path = registration.name
|
||||
if (Object.hasOwn(tools, path)) throw new TypeError(`Deferred tool namespace conflict: ${path}`)
|
||||
if (Object.hasOwn(tools, path)) throw new TypeError(`CodeMode tool namespace conflict: ${path}`)
|
||||
tools[path] = value
|
||||
continue
|
||||
}
|
||||
const path = registration.name
|
||||
const namespace = registration.group
|
||||
const group = tools[namespace]
|
||||
if (group && Tool.isDefinition(group)) throw new TypeError(`Deferred tool namespace conflict: ${namespace}`)
|
||||
if (group && Tool.isDefinition(group)) throw new TypeError(`CodeMode tool namespace conflict: ${namespace}`)
|
||||
if (group) {
|
||||
if (Object.hasOwn(group, path)) throw new TypeError(`Deferred tool namespace conflict: ${namespace}.${path}`)
|
||||
if (Object.hasOwn(group, path)) throw new TypeError(`CodeMode tool namespace conflict: ${namespace}.${path}`)
|
||||
group[path] = value
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@ export const Plugin = {
|
||||
),
|
||||
),
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -137,6 +137,7 @@ export const Plugin = {
|
||||
),
|
||||
),
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -5,7 +5,7 @@ import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
||||
import { Effect, Exit, type JsonSchema, Layer, Scope, Semaphore, Stream } from "effect"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { EventV2 } from "../event"
|
||||
import { Flag } from "../flag/flag"
|
||||
|
||||
import { MCP } from "../mcp"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Tool } from "./tool"
|
||||
@@ -107,7 +107,7 @@ export const layer = Layer.effectDiscard(
|
||||
const next = yield* Scope.fork(scope)
|
||||
yield* Effect.forEach(
|
||||
groups,
|
||||
([group, record]) => tools.register(record, { group, deferred: Flag.CODEMODE_ENABLED }),
|
||||
([group, record]) => tools.register(record, { group }),
|
||||
{
|
||||
discard: true,
|
||||
},
|
||||
|
||||
@@ -191,6 +191,7 @@ export const Plugin = {
|
||||
}),
|
||||
"edit",
|
||||
),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -106,6 +106,7 @@ export const Plugin = {
|
||||
}),
|
||||
),
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -139,6 +139,7 @@ export const Plugin = {
|
||||
)
|
||||
},
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -3,7 +3,6 @@ export * as ToolRegistry from "./registry"
|
||||
import { ToolOutput, type ToolCall, type ToolDefinition, type ToolResultValue } from "@opencode-ai/llm"
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import type { AgentV2 } from "../agent"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { SessionMessage } from "../session/message"
|
||||
import { SessionSchema } from "../session/schema"
|
||||
@@ -56,7 +55,7 @@ const registryLayer = Layer.effect(
|
||||
readonly tool: AnyTool
|
||||
readonly name: string
|
||||
readonly group?: string
|
||||
readonly deferred: boolean
|
||||
readonly codemode: boolean
|
||||
}
|
||||
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
|
||||
|
||||
@@ -132,7 +131,8 @@ const registryLayer = Layer.effect(
|
||||
register: Effect.fn("ToolRegistry.register")(function* (tools, options) {
|
||||
const entries = registrationEntries(tools, options?.group)
|
||||
if (entries.length === 0) return
|
||||
const reserved = options?.deferred ? undefined : entries.find((entry) => entry.key === "execute")
|
||||
const codemode = options?.codemode ?? true
|
||||
const reserved = codemode ? undefined : entries.find((entry) => entry.key === "execute")
|
||||
if (reserved)
|
||||
return yield* Effect.fail(
|
||||
new RegistrationError({ name: reserved.key, message: 'Tool name "execute" is reserved for CodeMode' }),
|
||||
@@ -149,7 +149,7 @@ const registryLayer = Layer.effect(
|
||||
tool: entry.tool,
|
||||
name: entry.name,
|
||||
group: entry.group,
|
||||
deferred: options?.deferred ?? false,
|
||||
codemode,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -168,18 +168,17 @@ const registryLayer = Layer.effect(
|
||||
}),
|
||||
materialize: Effect.fn("ToolRegistry.materialize")(function* (permissions) {
|
||||
const direct = new Map<string, Registration>()
|
||||
const deferred = new Map<string, Registration>()
|
||||
const codemode = new Map<string, Registration>()
|
||||
const rules = permissions ?? []
|
||||
for (const [name, entries] of local) {
|
||||
const registration = entries.at(-1)?.registration
|
||||
if (!registration) continue
|
||||
if (registration.deferred && !Flag.CODEMODE_ENABLED) continue
|
||||
if (whollyDisabled(permission(registration.tool, name), rules)) continue
|
||||
if (registration.deferred) deferred.set(name, registration)
|
||||
if (registration.codemode) codemode.set(name, registration)
|
||||
else direct.set(name, registration)
|
||||
}
|
||||
const execute =
|
||||
deferred.size > 0 && !whollyDisabled("execute", rules) ? ExecuteTool.create(deferred) : undefined
|
||||
codemode.size > 0 && !whollyDisabled("execute", rules) ? ExecuteTool.create(codemode) : undefined
|
||||
return {
|
||||
definitions: [
|
||||
...Array.from(direct, ([name, registration]) => definition(name, registration.tool)),
|
||||
|
||||
@@ -132,6 +132,8 @@ export const Plugin = {
|
||||
return runtime.session.synthetic({
|
||||
sessionID,
|
||||
text: `<shell id="${callID}" state="${state}" command="${command}">\n${text}\n</shell>`,
|
||||
description: command,
|
||||
metadata: { source: "shell", state },
|
||||
})
|
||||
}),
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
@@ -276,6 +278,7 @@ export const Plugin = {
|
||||
),
|
||||
),
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -97,6 +97,7 @@ export const Plugin = {
|
||||
}).pipe(Effect.mapError((error) => unableToLoad(input.id, error)))
|
||||
}),
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -208,6 +208,7 @@ export const Plugin = {
|
||||
return { sessionID: child.id, status: "completed" as const, output: result?.info.output ?? NO_TEXT }
|
||||
}),
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -174,6 +174,7 @@ export const Plugin = {
|
||||
}
|
||||
}).pipe(Effect.mapError((error) => new ToolFailure({ message: `Unable to fetch ${input.url}`, error }))),
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -252,6 +252,7 @@ export const Plugin = {
|
||||
)
|
||||
},
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -91,6 +91,7 @@ export const Plugin = {
|
||||
}),
|
||||
"edit",
|
||||
),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -876,7 +876,7 @@ describe("Config", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("loads global, ancestor, and .opencode configuration up to the project boundary", () =>
|
||||
it.live("loads global and ancestor configuration across the project boundary", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
@@ -936,6 +936,7 @@ describe("Config", () => {
|
||||
])
|
||||
expect(documents.map((document) => document.info.$schema)).toEqual([
|
||||
"global",
|
||||
"outside",
|
||||
"root",
|
||||
"parent",
|
||||
"directory",
|
||||
@@ -951,6 +952,8 @@ describe("Config", () => {
|
||||
AbsolutePath.make(path.join(root, ".agents")),
|
||||
"global",
|
||||
AbsolutePath.make(global),
|
||||
"outside",
|
||||
AbsolutePath.make(path.join(tmp.path, "opencode.json")),
|
||||
"root",
|
||||
AbsolutePath.make(path.join(root, "opencode.json")),
|
||||
"parent",
|
||||
|
||||
@@ -260,6 +260,7 @@ describe("PluginV2", () => {
|
||||
output: Schema.Struct({ ok: Schema.Boolean }),
|
||||
execute: () => Effect.succeed({ ok: true }),
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie),
|
||||
@@ -273,7 +274,7 @@ describe("PluginV2", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("groups tool names and defers registrations from direct exposure", () =>
|
||||
it.effect("groups tool names and routes codemode registrations through execute", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
@@ -289,9 +290,9 @@ describe("PluginV2", () => {
|
||||
effect: (ctx) =>
|
||||
ctx.tool
|
||||
.transform((draft) => {
|
||||
draft.add("plain", tool("Plain"))
|
||||
draft.add("look/up", tool("Lookup"), { group: "context 7" })
|
||||
draft.add("search", tool("Search"), { group: "context 7", deferred: true })
|
||||
draft.add("plain", tool("Plain"), { codemode: false })
|
||||
draft.add("look/up", tool("Lookup"), { group: "context 7", codemode: false })
|
||||
draft.add("search", tool("Search"), { group: "context 7" })
|
||||
})
|
||||
.pipe(Effect.orDie),
|
||||
})
|
||||
@@ -330,6 +331,7 @@ describe("PluginV2", () => {
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
execute: ({ text }) => Effect.sync(() => executed.push({ text })).pipe(Effect.as({ text })),
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -218,16 +218,14 @@ export function integrationHost(integration: Integration.Interface): PluginConte
|
||||
method: {
|
||||
list: (id) => draft.method.list(Integration.ID.make(id)).map(method),
|
||||
update: (input) => {
|
||||
if (input.method.type === "oauth") {
|
||||
const oauth =
|
||||
input as import("@opencode-ai/plugin/v2/effect/integration").IntegrationOAuthMethodRegistration
|
||||
const methodID = Integration.MethodID.make(oauth.method.id)
|
||||
const refresh = oauth.refresh
|
||||
if ("authorize" in input) {
|
||||
const methodID = Integration.MethodID.make(input.method.id)
|
||||
const refresh = input.refresh
|
||||
draft.method.update({
|
||||
integrationID: Integration.ID.make(oauth.integrationID),
|
||||
method: { ...oauth.method, id: methodID },
|
||||
authorize: (inputs: import("@opencode-ai/plugin/v2/effect/integration").IntegrationInputs) =>
|
||||
oauth.authorize(inputs).pipe(
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: { ...input.method, id: methodID },
|
||||
authorize: (inputs) =>
|
||||
input.authorize(inputs).pipe(
|
||||
Effect.map((authorization) => {
|
||||
if (authorization.mode === "auto") {
|
||||
return {
|
||||
@@ -269,7 +267,7 @@ export function integrationHost(integration: Integration.Interface): PluginConte
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(oauth.label ? { label: oauth.label } : {}),
|
||||
...(input.label ? { label: input.label } : {}),
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -280,19 +278,9 @@ export function integrationHost(integration: Integration.Interface): PluginConte
|
||||
})
|
||||
return
|
||||
}
|
||||
const registration =
|
||||
input as import("@opencode-ai/plugin/v2/effect/integration").IntegrationKeyMethodRegistration
|
||||
draft.method.update({
|
||||
integrationID: Integration.ID.make(registration.integrationID),
|
||||
method: registration.method,
|
||||
...(registration.authorize
|
||||
? {
|
||||
authorize: (key, inputs) =>
|
||||
registration.authorize!(key, inputs).pipe(
|
||||
Effect.map((credential) => Credential.Key.make(credential)),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: input.method,
|
||||
})
|
||||
},
|
||||
remove: (id, item) => draft.method.remove(Integration.ID.make(id), internalMethod(item)),
|
||||
|
||||
@@ -135,6 +135,7 @@ describe("fromPromise", () => {
|
||||
await ctx.tool.transform((tools) => {
|
||||
tools.add({
|
||||
name: "hello",
|
||||
options: { codemode: false },
|
||||
description: "Hello",
|
||||
input: Schema.Struct({ name: Schema.String }),
|
||||
output: Schema.String,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { describe, expect } from "bun:test"
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
@@ -61,55 +60,6 @@ function fakeSelectorSdk(calls: string[]) {
|
||||
}
|
||||
|
||||
describe("AzurePlugin", () => {
|
||||
it.effect("registers an API key method and stores prompted resource metadata", () =>
|
||||
withEnv({ AZURE_RESOURCE_NAME: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
yield* addPlugin()
|
||||
expect((yield* integrations.get(Integration.ID.make("azure")))?.methods).toEqual([
|
||||
{
|
||||
type: "key",
|
||||
label: "API key",
|
||||
prompts: [
|
||||
{
|
||||
type: "text",
|
||||
key: "resourceName",
|
||||
message: "Enter Azure Resource Name",
|
||||
placeholder: "e.g. my-models",
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
yield* integrations.connection.key({
|
||||
integrationID: Integration.ID.make("azure"),
|
||||
key: "secret",
|
||||
inputs: { resourceName: "my-models" },
|
||||
})
|
||||
const connection = required(yield* integrations.connection.active(Integration.ID.make("azure")))
|
||||
expect(yield* integrations.connection.resolve(connection)).toMatchObject({
|
||||
type: "key",
|
||||
key: "secret",
|
||||
metadata: { resourceName: "my-models" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("omits the resource prompt when Azure resource env is configured", () =>
|
||||
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
yield* addPlugin()
|
||||
expect((yield* integrations.get(Integration.ID.make("azure")))?.methods).toContainEqual({
|
||||
type: "key",
|
||||
label: "API key",
|
||||
prompts: [],
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("resolves resourceName from env", () =>
|
||||
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { describe, expect, mock } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
@@ -103,69 +102,6 @@ mock.module("ai-gateway-provider/providers/unified", () => ({
|
||||
}))
|
||||
|
||||
describe("CloudflareAIGatewayPlugin", () => {
|
||||
it.effect("registers a gateway token method and stores prompted gateway metadata", () =>
|
||||
withEnv(
|
||||
cloudflareEnv({
|
||||
CLOUDFLARE_ACCOUNT_ID: undefined,
|
||||
CLOUDFLARE_GATEWAY_ID: undefined,
|
||||
CLOUDFLARE_API_TOKEN: undefined,
|
||||
}),
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
yield* addPlugin()
|
||||
const integrationID = Integration.ID.make("cloudflare-ai-gateway")
|
||||
expect((yield* integrations.get(integrationID))?.methods).toEqual([
|
||||
{
|
||||
type: "key",
|
||||
label: "Gateway API token",
|
||||
prompts: [
|
||||
{
|
||||
type: "text",
|
||||
key: "accountId",
|
||||
message: "Enter your Cloudflare Account ID",
|
||||
placeholder: "e.g. 1234567890abcdef1234567890abcdef",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
key: "gatewayId",
|
||||
message: "Enter your Cloudflare AI Gateway ID",
|
||||
placeholder: "e.g. my-gateway",
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
yield* integrations.connection.key({
|
||||
integrationID,
|
||||
key: "secret",
|
||||
inputs: { accountId: "acct", gatewayId: "gateway" },
|
||||
})
|
||||
const connection = yield* integrations.connection.active(integrationID)
|
||||
if (!connection) throw new Error("Expected connection")
|
||||
expect(yield* integrations.connection.resolve(connection)).toMatchObject({
|
||||
type: "key",
|
||||
key: "secret",
|
||||
metadata: { accountId: "acct", gatewayId: "gateway" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("omits gateway prompts backed by Cloudflare env", () =>
|
||||
withEnv(cloudflareEnv(), () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
yield* addPlugin()
|
||||
expect((yield* integrations.get(Integration.ID.make("cloudflare-ai-gateway")))?.methods).toContainEqual({
|
||||
type: "key",
|
||||
label: "Gateway API token",
|
||||
prompts: [],
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("requires account, gateway, and token before creating the unified SDK", () =>
|
||||
withEnv(
|
||||
{
|
||||
|
||||
@@ -2,7 +2,6 @@ import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
@@ -80,56 +79,6 @@ function cloudflareHeaders(sdk: unknown, modelID = "@cf/model") {
|
||||
}
|
||||
|
||||
describe("CloudflareWorkersAIPlugin", () => {
|
||||
it.effect("registers an API key method and stores prompted account metadata", () =>
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
yield* addPlugin()
|
||||
const integrationID = Integration.ID.make("cloudflare-workers-ai")
|
||||
expect((yield* integrations.get(integrationID))?.methods).toEqual([
|
||||
{
|
||||
type: "key",
|
||||
label: "API key",
|
||||
prompts: [
|
||||
{
|
||||
type: "text",
|
||||
key: "accountId",
|
||||
message: "Enter your Cloudflare Account ID",
|
||||
placeholder: "e.g. 1234567890abcdef1234567890abcdef",
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
yield* integrations.connection.key({
|
||||
integrationID,
|
||||
key: "secret",
|
||||
inputs: { accountId: "acct" },
|
||||
})
|
||||
const connection = required(yield* integrations.connection.active(integrationID))
|
||||
expect(yield* integrations.connection.resolve(connection)).toMatchObject({
|
||||
type: "key",
|
||||
key: "secret",
|
||||
metadata: { accountId: "acct" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("omits the account prompt when Cloudflare account env is configured", () =>
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
yield* addPlugin()
|
||||
expect((yield* integrations.get(Integration.ID.make("cloudflare-workers-ai")))?.methods).toContainEqual({
|
||||
type: "key",
|
||||
label: "API key",
|
||||
prompts: [],
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("maps account ID to endpoint URL and creates an OpenAI-compatible SDK", () =>
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { DigitalOceanPlugin } from "@opencode-ai/core/plugin/provider/digitalocean"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const integrationID = Integration.ID.make("digitalocean")
|
||||
const providerID = ProviderV2.ID.make("digitalocean")
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* DigitalOceanPlugin.effect(host)
|
||||
})
|
||||
|
||||
describe("DigitalOceanPlugin", () => {
|
||||
it.effect("registers implicit OAuth and manual model access keys", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
expect((yield* (yield* Integration.Service).get(integrationID))?.methods).toEqual([
|
||||
{
|
||||
id: Integration.MethodID.make("implicit"),
|
||||
type: "oauth",
|
||||
label: "Login with DigitalOcean",
|
||||
},
|
||||
{ type: "key", label: "Paste Model Access Key" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("adds cached inference routers to the DigitalOcean catalog", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const credentials = yield* Credential.Service
|
||||
yield* catalog.transform((draft) => {
|
||||
draft.provider.update(providerID, (provider) => {
|
||||
provider.name = "DigitalOcean"
|
||||
})
|
||||
})
|
||||
yield* credentials.create({
|
||||
integrationID,
|
||||
value: Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make("implicit"),
|
||||
refresh: "token",
|
||||
access: "token",
|
||||
expires: Date.now() + 60_000,
|
||||
metadata: {
|
||||
routers: [
|
||||
{ name: "production", uuid: "router-1" },
|
||||
{ name: "support", description: "Support router" },
|
||||
],
|
||||
routersFetchedAt: Date.now(),
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
yield* addPlugin()
|
||||
|
||||
const model = yield* catalog.model.get(providerID, ModelV2.ID.make("router:production"))
|
||||
expect(model).toMatchObject({
|
||||
id: "router:production",
|
||||
modelID: "router:production",
|
||||
providerID: "digitalocean",
|
||||
name: "production",
|
||||
family: "digitalocean-inference-routers",
|
||||
package: ProviderV2.aisdk("@ai-sdk/openai-compatible"),
|
||||
settings: { baseURL: "https://inference.do-ai.run/v1" },
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
limit: { context: 128_000, output: 8_192 },
|
||||
})
|
||||
expect(yield* catalog.model.get(providerID, ModelV2.ID.make("router:support"))).toBeDefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("stores a manually registered model access key", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const integrations = yield* Integration.Service
|
||||
yield* integrations.connection.key({ integrationID, key: "model-access-key" })
|
||||
expect((yield* (yield* Credential.Service).list(integrationID))[0]?.value).toEqual({
|
||||
type: "key",
|
||||
key: "model-access-key",
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,6 +1,4 @@
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { describe, expect, mock } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
@@ -43,20 +41,6 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () =
|
||||
)
|
||||
}
|
||||
|
||||
function eventually<A>(
|
||||
effect: Effect.Effect<A>,
|
||||
predicate: (value: A) => boolean,
|
||||
remaining = 1000,
|
||||
): Effect.Effect<A, Error> {
|
||||
return Effect.gen(function* () {
|
||||
const value = yield* effect
|
||||
if (predicate(value)) return value
|
||||
if (remaining === 0) return yield* Effect.fail(new Error("Timed out waiting for value"))
|
||||
yield* Effect.promise(() => Bun.sleep(1))
|
||||
return yield* eventually(effect, predicate, remaining - 1)
|
||||
})
|
||||
}
|
||||
|
||||
void mock.module("gitlab-ai-provider", () => ({
|
||||
VERSION: "test-version",
|
||||
createGitLab: (options: Record<string, unknown>) => {
|
||||
@@ -71,155 +55,6 @@ void mock.module("gitlab-ai-provider", () => ({
|
||||
}))
|
||||
|
||||
describe("GitLabPlugin", () => {
|
||||
it.effect("registers OAuth, PAT, and environment methods", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
expect((yield* (yield* Integration.Service).get(Integration.ID.make("gitlab")))?.methods).toEqual([
|
||||
{
|
||||
id: Integration.MethodID.make("oauth"),
|
||||
type: "oauth",
|
||||
label: "GitLab OAuth",
|
||||
prompts: [
|
||||
{
|
||||
type: "text",
|
||||
key: "instanceUrl",
|
||||
message: "GitLab instance URL",
|
||||
placeholder: "https://gitlab.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "key",
|
||||
label: "GitLab Personal Access Token",
|
||||
prompts: [
|
||||
{
|
||||
type: "text",
|
||||
key: "instanceUrl",
|
||||
message: "GitLab instance URL",
|
||||
placeholder: "https://gitlab.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: "env", names: ["GITLAB_TOKEN"] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("validates PATs and stores normalized instance URL metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const original = globalThis.fetch
|
||||
const calls: [string, RequestInit | undefined][] = []
|
||||
globalThis.fetch = Object.assign(
|
||||
async (input: string | URL | Request, init?: RequestInit) => {
|
||||
calls.push([String(input), init])
|
||||
return new Response(JSON.stringify({ id: 1 }), { status: 200 })
|
||||
},
|
||||
{ preconnect: original.preconnect },
|
||||
)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => (globalThis.fetch = original)))
|
||||
const integrations = yield* Integration.Service
|
||||
yield* integrations.connection.key({
|
||||
integrationID: Integration.ID.make("gitlab"),
|
||||
key: "glpat-test",
|
||||
inputs: { instanceUrl: "https://gitlab.example/path/" },
|
||||
})
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]?.[0]).toBe("https://gitlab.example/api/v4/user")
|
||||
expect(calls[0]?.[1]?.headers).toEqual({ Authorization: "Bearer glpat-test" })
|
||||
expect((yield* (yield* Credential.Service).list(Integration.ID.make("gitlab")))[0]?.value).toEqual({
|
||||
type: "key",
|
||||
key: "glpat-test",
|
||||
metadata: { instanceUrl: "https://gitlab.example" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects invalid PAT instance URLs before validation", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const integrations = yield* Integration.Service
|
||||
const exit = yield* integrations.connection
|
||||
.key({
|
||||
integrationID: Integration.ID.make("gitlab"),
|
||||
key: "glpat-test",
|
||||
inputs: { instanceUrl: "file:///tmp/gitlab" },
|
||||
})
|
||||
.pipe(Effect.exit)
|
||||
expect(exit._tag).toBe("Failure")
|
||||
expect(yield* (yield* Credential.Service).list(Integration.ID.make("gitlab"))).toHaveLength(0)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("completes OAuth PKCE and refreshes with instance metadata", () =>
|
||||
withEnv({ GITLAB_OAUTH_CLIENT_ID: "test-client" }, () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const original = globalThis.fetch
|
||||
const tokenBodies: URLSearchParams[] = []
|
||||
globalThis.fetch = Object.assign(
|
||||
async (input: string | URL | Request, init?: RequestInit) => {
|
||||
if (String(input).startsWith("http://127.0.0.1:8080/")) return original(input, init)
|
||||
tokenBodies.push(new URLSearchParams(String(init?.body)))
|
||||
return Response.json({
|
||||
access_token: tokenBodies.length === 1 ? "access" : "refreshed-access",
|
||||
refresh_token: tokenBodies.length === 1 ? "refresh" : "rotated-refresh",
|
||||
expires_in: 1,
|
||||
})
|
||||
},
|
||||
{ preconnect: original.preconnect },
|
||||
)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => (globalThis.fetch = original)))
|
||||
|
||||
const integrations = yield* Integration.Service
|
||||
const attempt = yield* integrations.connection.oauth({
|
||||
integrationID: Integration.ID.make("gitlab"),
|
||||
methodID: Integration.MethodID.make("oauth"),
|
||||
inputs: { instanceUrl: "http://gitlab.example/path" },
|
||||
})
|
||||
const authorize = new URL(attempt.url)
|
||||
expect(authorize.origin).toBe("http://gitlab.example")
|
||||
expect(authorize.pathname).toBe("/oauth/authorize")
|
||||
expect(authorize.searchParams.get("client_id")).toBe("test-client")
|
||||
expect(authorize.searchParams.get("redirect_uri")).toBe("http://127.0.0.1:8080/callback")
|
||||
expect(authorize.searchParams.get("code_challenge_method")).toBe("S256")
|
||||
expect(authorize.searchParams.get("code_challenge")).toBeTruthy()
|
||||
yield* Effect.promise(() =>
|
||||
fetch(`http://127.0.0.1:8080/callback?code=test-code&state=${authorize.searchParams.get("state")}`),
|
||||
)
|
||||
yield* eventually(integrations.attempt.status(attempt.attemptID), (status) => status.status === "complete")
|
||||
|
||||
const saved = (yield* (yield* Credential.Service).list(Integration.ID.make("gitlab")))[0]
|
||||
expect(saved?.value).toEqual({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make("oauth"),
|
||||
access: "access",
|
||||
refresh: "refresh",
|
||||
expires: expect.any(Number),
|
||||
metadata: { instanceUrl: "http://gitlab.example" },
|
||||
})
|
||||
expect(tokenBodies[0]?.get("grant_type")).toBe("authorization_code")
|
||||
expect(tokenBodies[0]?.get("code_verifier")).toBeTruthy()
|
||||
|
||||
if (saved?.value.type !== "oauth") throw new Error("Expected OAuth credential")
|
||||
yield* (yield* Credential.Service).update(saved.id, { value: { ...saved.value, expires: 0 } })
|
||||
const resolved = yield* integrations.connection.resolve({
|
||||
type: "credential",
|
||||
id: saved!.id,
|
||||
label: saved!.label,
|
||||
})
|
||||
expect(resolved).toMatchObject({
|
||||
type: "oauth",
|
||||
access: "refreshed-access",
|
||||
refresh: "rotated-refresh",
|
||||
metadata: { instanceUrl: "http://gitlab.example" },
|
||||
})
|
||||
expect(tokenBodies[1]?.get("grant_type")).toBe("refresh_token")
|
||||
expect(tokenBodies[1]?.get("refresh_token")).toBe("refresh")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("creates SDKs with legacy default instance URL, token env, headers, and feature flags", () =>
|
||||
withEnv(
|
||||
{
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { PoePlugin } from "@opencode-ai/core/plugin/provider/poe"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const integrationID = Integration.ID.make("poe")
|
||||
const methodID = Integration.MethodID.make("browser")
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* PoePlugin.effect(host)
|
||||
})
|
||||
|
||||
describe("PoePlugin", () => {
|
||||
it.effect("registers browser OAuth and manual API key methods", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const integrations = yield* Integration.Service
|
||||
const integration = yield* integrations.get(integrationID)
|
||||
expect(integration?.name).toBe("Poe")
|
||||
expect(integration?.methods).toEqual([
|
||||
{ id: methodID, type: "oauth", label: "Login with Poe (browser)" },
|
||||
{ type: "key", label: "Manually enter API Key" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("stores manually entered Poe API keys", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const integrations = yield* Integration.Service
|
||||
const credentials = yield* Credential.Service
|
||||
yield* integrations.connection.key({ integrationID, key: "poe-test" })
|
||||
expect((yield* credentials.list(integrationID))[0]?.value).toEqual({ type: "key", key: "poe-test" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("starts a PKCE browser authorization on an ephemeral loopback server", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const integrations = yield* Integration.Service
|
||||
const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
|
||||
const url = new URL(attempt.url)
|
||||
const redirect = new URL(url.searchParams.get("redirect_uri") ?? "")
|
||||
|
||||
expect(url.origin + url.pathname).toBe("https://poe.com/oauth/authorize")
|
||||
expect(url.searchParams.get("response_type")).toBe("code")
|
||||
expect(url.searchParams.get("client_id")).toBe("client_728290227fc048cc9262091a1ea197ea")
|
||||
expect(url.searchParams.get("scope")).toBe("apikey:create")
|
||||
expect(url.searchParams.get("code_challenge_method")).toBe("S256")
|
||||
expect(url.searchParams.get("code_challenge")).toMatch(/^[A-Za-z0-9_-]{43}$/)
|
||||
expect(redirect.hostname).toBe("127.0.0.1")
|
||||
expect(redirect.pathname).toBe("/callback")
|
||||
expect(Number(redirect.port)).toBeGreaterThan(0)
|
||||
|
||||
yield* integrations.attempt.cancel(attempt.attemptID)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,11 +1,10 @@
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { describe, expect, it as bun_it } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { SnowflakeCortexPlugin, cortexFetch, oauthScope } from "@opencode-ai/core/plugin/provider/snowflake-cortex"
|
||||
import { SnowflakeCortexPlugin, cortexFetch } from "@opencode-ai/core/plugin/provider/snowflake-cortex"
|
||||
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { testEffect } from "../lib/effect"
|
||||
@@ -42,61 +41,6 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () =
|
||||
}
|
||||
|
||||
describe("SnowflakeCortexPlugin", () => {
|
||||
it.effect("registers browser OAuth, key, and environment methods", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
expect((yield* (yield* Integration.Service).get(Integration.ID.make("snowflake-cortex")))?.methods).toEqual([
|
||||
{
|
||||
id: Integration.MethodID.make("snowflake-browser"),
|
||||
type: "oauth",
|
||||
label: "Login with Snowflake (External Browser)",
|
||||
prompts: [
|
||||
{
|
||||
type: "text",
|
||||
key: "account",
|
||||
message: "Snowflake Account Identifier",
|
||||
placeholder: "myorg-myaccount",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
key: "role",
|
||||
message: "Snowflake Role (optional)",
|
||||
placeholder: "PUBLIC",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: "key", label: "Paste PAT or bearer token manually" },
|
||||
{ type: "env", names: ["SNOWFLAKE_CORTEX_TOKEN", "SNOWFLAKE_CORTEX_PAT"] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses account metadata to derive the Cortex endpoint", () =>
|
||||
withEnv({ SNOWFLAKE_ACCOUNT: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { account: "https://myorg-myaccount.snowflakecomputing.com/", apiKey: "test-pat" },
|
||||
})
|
||||
expect(result.options.baseURL).toBe("https://myorg-myaccount.snowflakecomputing.com/api/v2/cortex/v1")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("uses Snowflake-compatible OAuth scopes", () =>
|
||||
Effect.sync(() => {
|
||||
expect(oauthScope(undefined)).toBe("refresh_token")
|
||||
expect(oauthScope("PUBLIC")).toBe("refresh_token session:role:PUBLIC")
|
||||
expect(oauthScope("AUTH SNOWFLAKE")).toBe("refresh_token session:role-encoded:AUTH%20SNOWFLAKE")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("is registered in ProviderPlugins before OpenAICompatiblePlugin", () =>
|
||||
Effect.sync(() => {
|
||||
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.snowflake-cortex")
|
||||
@@ -250,7 +194,6 @@ describe("cortexFetch", () => {
|
||||
const body = JSON.parse(captured[0].body as string)
|
||||
expect(body.max_completion_tokens).toBe(1024)
|
||||
expect(body.max_tokens).toBeUndefined()
|
||||
expect(new Headers(captured[0].headers).get("User-Agent")).toMatch(/^opencode\//)
|
||||
})
|
||||
|
||||
bun_it("preserves body when max_tokens is absent", async () => {
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
@@ -18,8 +16,7 @@ const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
const integration = yield* Integration.Service
|
||||
yield* XAIPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integration))
|
||||
yield* XAIPlugin.effect(host)
|
||||
})
|
||||
|
||||
function fakeSelectorSdk(calls: string[]) {
|
||||
@@ -36,39 +33,6 @@ function fakeSelectorSdk(calls: string[]) {
|
||||
}
|
||||
|
||||
describe("XAIPlugin", () => {
|
||||
it.effect("registers browser OAuth, device OAuth, and API key methods", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const integration = yield* (yield* Integration.Service).get(Integration.ID.make("xai"))
|
||||
expect(integration?.name).toBe("xAI")
|
||||
expect(integration?.methods).toEqual([
|
||||
{
|
||||
id: Integration.MethodID.make("browser"),
|
||||
type: "oauth",
|
||||
label: "xAI Grok OAuth (SuperGrok Subscription)",
|
||||
},
|
||||
{
|
||||
id: Integration.MethodID.make("device"),
|
||||
type: "oauth",
|
||||
label: "xAI Grok OAuth (Headless / Remote / VPS)",
|
||||
},
|
||||
{ type: "key", label: "Manually enter API Key" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("stores API keys through the registered key method", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const integrations = yield* Integration.Service
|
||||
yield* integrations.connection.key({ integrationID: Integration.ID.make("xai"), key: "xai-test" })
|
||||
expect((yield* (yield* Credential.Service).list(Integration.ID.make("xai")))[0]?.value).toEqual({
|
||||
type: "key",
|
||||
key: "xai-test",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("creates an xAI SDK only for @ai-sdk/xai", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
|
||||
@@ -70,7 +70,7 @@ describe("ToolRegistry", () => {
|
||||
bash: make(),
|
||||
edit: make("edit"),
|
||||
write: make("edit"),
|
||||
})
|
||||
}, { codemode: false })
|
||||
const names = (permissions: PermissionV2.Ruleset) =>
|
||||
toolDefinitions(service, permissions).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name)))
|
||||
|
||||
@@ -95,8 +95,8 @@ describe("ToolRegistry", () => {
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
const shared = make()
|
||||
yield* service.register({ first: shared })
|
||||
yield* service.register({ second: Tool.withPermission(shared, "edit") })
|
||||
yield* service.register({ first: shared }, { codemode: false })
|
||||
yield* service.register({ second: Tool.withPermission(shared, "edit") }, { codemode: false })
|
||||
Tool.withPermission(shared, "question")
|
||||
|
||||
expect(
|
||||
@@ -110,7 +110,7 @@ describe("ToolRegistry", () => {
|
||||
it.effect("reuses model definitions across requests", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
yield* service.register({ echo: make() })
|
||||
yield* service.register({ echo: make() }, { codemode: false })
|
||||
const first = yield* toolDefinitions(service)
|
||||
const second = yield* toolDefinitions(service)
|
||||
|
||||
@@ -122,7 +122,7 @@ describe("ToolRegistry", () => {
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
const scope = yield* Scope.make()
|
||||
yield* service.register({ echo: make() }).pipe(Scope.provide(scope))
|
||||
yield* service.register({ echo: make() }, { codemode: false }).pipe(Scope.provide(scope))
|
||||
expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["echo"])
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(yield* toolDefinitions(service)).toEqual([])
|
||||
@@ -135,7 +135,7 @@ describe("ToolRegistry", () => {
|
||||
const scope = yield* Scope.make()
|
||||
const registered = yield* Deferred.make<void>()
|
||||
const fiber = yield* service
|
||||
.register({ echo: make() })
|
||||
.register({ echo: make() }, { codemode: false })
|
||||
.pipe(
|
||||
Effect.andThen(Deferred.succeed(registered, undefined)),
|
||||
Effect.andThen(Effect.never),
|
||||
@@ -161,7 +161,7 @@ describe("ToolRegistry", () => {
|
||||
output: Schema.Struct({ ok: Schema.Boolean }),
|
||||
execute: () => Effect.fail(new Tool.Failure({ message: "Denied" })),
|
||||
}),
|
||||
})
|
||||
}, { codemode: false })
|
||||
expect(
|
||||
yield* executeTool(service, {
|
||||
sessionID,
|
||||
@@ -184,7 +184,7 @@ describe("ToolRegistry", () => {
|
||||
output: Schema.Struct({}),
|
||||
execute: () => Effect.die("unexpected executor defect"),
|
||||
}),
|
||||
})
|
||||
}, { codemode: false })
|
||||
expect(
|
||||
yield* service.materialize().pipe(
|
||||
Effect.flatMap((materialized) =>
|
||||
@@ -203,7 +203,7 @@ describe("ToolRegistry", () => {
|
||||
it.effect("propagates retention failures through settlement", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
yield* service.register({ echo: make() })
|
||||
yield* service.register({ echo: make() }, { codemode: false })
|
||||
const materialized = yield* service.materialize()
|
||||
const exit = yield* materialized.settle(call("echo", "call-retention-failure")).pipe(Effect.exit)
|
||||
|
||||
@@ -234,7 +234,7 @@ describe("ToolRegistry", () => {
|
||||
output: Schema.Struct({ ok: Schema.Boolean }),
|
||||
execute: (_, context) => Effect.sync(() => contexts.push(context)).pipe(Effect.as({ ok: true })),
|
||||
}),
|
||||
})
|
||||
}, { codemode: false })
|
||||
yield* executeTool(service, {
|
||||
sessionID,
|
||||
...identity,
|
||||
@@ -248,7 +248,7 @@ describe("ToolRegistry", () => {
|
||||
Effect.gen(function* () {
|
||||
bounds.length = 0
|
||||
const service = yield* ToolRegistry.Service
|
||||
yield* service.register({ bounded: make() })
|
||||
yield* service.register({ bounded: make() }, { codemode: false })
|
||||
expect(
|
||||
yield* settleTool(service, {
|
||||
sessionID,
|
||||
@@ -282,7 +282,7 @@ describe("ToolRegistry", () => {
|
||||
execute: ({ value }) => Effect.sync(() => executed.push(value)).pipe(Effect.as({ value })),
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: String(output.value) }],
|
||||
}),
|
||||
})
|
||||
}, { codemode: false })
|
||||
|
||||
expect(
|
||||
yield* executeTool(service, {
|
||||
@@ -319,7 +319,7 @@ describe("ToolRegistry", () => {
|
||||
}),
|
||||
execute: () => Effect.succeed({ value: "invalid" }),
|
||||
}),
|
||||
})
|
||||
}, { codemode: false })
|
||||
expect(
|
||||
yield* executeTool(service, {
|
||||
sessionID,
|
||||
@@ -334,10 +334,10 @@ describe("ToolRegistry", () => {
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
const scope = yield* Scope.make()
|
||||
yield* service.register({ echo: constant("advertised") }).pipe(Scope.provide(scope))
|
||||
yield* service.register({ echo: constant("advertised") }, { codemode: false }).pipe(Scope.provide(scope))
|
||||
const request = yield* service.materialize()
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
yield* service.register({ echo: constant("replacement") })
|
||||
yield* service.register({ echo: constant("replacement") }, { codemode: false })
|
||||
|
||||
expect((yield* request.settle(call("echo"))).result).toEqual({ type: "text", value: "advertised" })
|
||||
expect(yield* executeTool(service, call("echo"))).toEqual({ type: "text", value: "replacement" })
|
||||
@@ -347,9 +347,9 @@ describe("ToolRegistry", () => {
|
||||
it.effect("reveals the previous registration after an overlay closes", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
yield* service.register({ echo: constant("base") })
|
||||
yield* service.register({ echo: constant("base") }, { codemode: false })
|
||||
const overlay = yield* Scope.make()
|
||||
yield* service.register({ echo: constant("overlay") }).pipe(Scope.provide(overlay))
|
||||
yield* service.register({ echo: constant("overlay") }, { codemode: false }).pipe(Scope.provide(overlay))
|
||||
|
||||
expect(yield* executeTool(service, call("echo"))).toEqual({ type: "text", value: "overlay" })
|
||||
yield* Scope.close(overlay, Exit.void)
|
||||
@@ -357,37 +357,31 @@ describe("ToolRegistry", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("executes deferred tools advertised in a model request", () =>
|
||||
it.effect("executes codemode tools advertised in a model request", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
const executed: string[] = []
|
||||
const scope = yield* Scope.make()
|
||||
yield* service
|
||||
.register(
|
||||
{
|
||||
echo: Tool.make({
|
||||
description: "Echo text",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
execute: ({ text }) => Effect.sync(() => executed.push(`old:${text}`)).pipe(Effect.as({ text })),
|
||||
}),
|
||||
},
|
||||
{ deferred: true },
|
||||
)
|
||||
.pipe(Scope.provide(scope))
|
||||
const materialized = yield* service.materialize()
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
yield* service.register(
|
||||
{
|
||||
.register({
|
||||
echo: Tool.make({
|
||||
description: "Echo text",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
execute: ({ text }) => Effect.sync(() => executed.push(`new:${text}`)).pipe(Effect.as({ text })),
|
||||
execute: ({ text }) => Effect.sync(() => executed.push(`old:${text}`)).pipe(Effect.as({ text })),
|
||||
}),
|
||||
},
|
||||
{ deferred: true },
|
||||
)
|
||||
})
|
||||
.pipe(Scope.provide(scope))
|
||||
const materialized = yield* service.materialize()
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
yield* service.register({
|
||||
echo: Tool.make({
|
||||
description: "Echo text",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
execute: ({ text }) => Effect.sync(() => executed.push(`new:${text}`)).pipe(Effect.as({ text })),
|
||||
}),
|
||||
})
|
||||
|
||||
const settlement = yield* materialized.settle({
|
||||
...call("execute"),
|
||||
|
||||
@@ -261,7 +261,7 @@ const echo = Layer.effectDiscard(
|
||||
output: Schema.Any,
|
||||
execute: () => Effect.succeed({ big: 1n }),
|
||||
}),
|
||||
}),
|
||||
}, { codemode: false }),
|
||||
),
|
||||
)
|
||||
const echoNode = makeLocationNode({ name: "test/session-runner-tools", layer: echo, deps: [ToolRegistry.node] })
|
||||
@@ -782,7 +782,7 @@ describe("SessionRunnerLLM", () => {
|
||||
return { answer: query.toUpperCase() }
|
||||
}),
|
||||
}),
|
||||
})
|
||||
}, { codemode: false })
|
||||
yield* admit(session, "Use application context")
|
||||
responses = [reply.tool("call-location", "location_context", { query: "hello" }), []]
|
||||
|
||||
@@ -827,7 +827,7 @@ describe("SessionRunnerLLM", () => {
|
||||
output: Schema.Struct({ value: Schema.String }),
|
||||
execute: () => Effect.sync(() => executions.push("advertised")).pipe(Effect.as({ value: "advertised" })),
|
||||
}),
|
||||
})
|
||||
}, { codemode: false })
|
||||
.pipe(Scope.provide(scope))
|
||||
yield* admit(session, "Use the reloaded tool")
|
||||
responses = [
|
||||
@@ -852,7 +852,7 @@ describe("SessionRunnerLLM", () => {
|
||||
output: Schema.Struct({ value: Schema.String }),
|
||||
execute: () => Effect.sync(() => executions.push("replacement")).pipe(Effect.as({ value: "replacement" })),
|
||||
}),
|
||||
})
|
||||
}, { codemode: false })
|
||||
yield* Deferred.succeed(streamGate, undefined)
|
||||
yield* Fiber.join(run)
|
||||
|
||||
@@ -3189,7 +3189,7 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.mapError(() => new Tool.Failure({ message: "Permission blocked" })),
|
||||
),
|
||||
}),
|
||||
})
|
||||
}, { codemode: false })
|
||||
yield* admit(session, "Call blocked")
|
||||
|
||||
responses = [reply.tool("call-blocked", "blocked", {}), reply.stop()]
|
||||
@@ -3221,7 +3221,7 @@ describe("SessionRunnerLLM", () => {
|
||||
output: Schema.Struct({}),
|
||||
execute: () => Effect.die(new PermissionV2.DeclinedError()),
|
||||
}),
|
||||
})
|
||||
}, { codemode: false })
|
||||
yield* admit(session, "Call declined")
|
||||
|
||||
response = reply.tool("call-declined", "declined", {})
|
||||
@@ -3261,7 +3261,7 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.mapError(() => new Tool.Failure({ message: "Use another tool" })),
|
||||
),
|
||||
}),
|
||||
})
|
||||
}, { codemode: false })
|
||||
yield* admit(session, "Call corrected")
|
||||
|
||||
responses = [reply.tool("call-corrected", "corrected", {}), reply.stop()]
|
||||
@@ -3321,7 +3321,7 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const registry = yield* ToolRegistry.Service
|
||||
yield* registry.register({ permissionfail: permissionFail })
|
||||
yield* registry.register({ permissionfail: permissionFail }, { codemode: false })
|
||||
yield* admit(session, "Reject permission")
|
||||
responses = [
|
||||
reply.tool("call-permission", "permissionfail", {}),
|
||||
@@ -3366,7 +3366,7 @@ describe("SessionRunnerLLM", () => {
|
||||
output: Schema.Struct({}),
|
||||
execute: () => Effect.die(new QuestionTool.CancelledError()),
|
||||
}),
|
||||
})
|
||||
}, { codemode: false })
|
||||
yield* admit(session, "Ask then stop")
|
||||
|
||||
responses = [reply.tool("call-question", "question", {}), []]
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "fs/promises"
|
||||
import { realpathSync } from "node:fs"
|
||||
import path from "path"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { DateTime, Duration, Effect, Fiber, Layer, Scope } from "effect"
|
||||
import { DateTime, Duration, Effect, Fiber, Layer, Scope, Stream } from "effect"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
@@ -443,6 +443,12 @@ describe("ShellTool", () => {
|
||||
reset()
|
||||
return withSession(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const admitted = yield* events.subscribe(SessionEvent.InputAdmitted).pipe(
|
||||
Stream.filter((event) => event.data.sessionID === sessionID && event.data.input.type === "synthetic"),
|
||||
Stream.runHead,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
const settled = yield* settleTool(registry, call({ command: idleCommand, timeout: 50, background: true }))
|
||||
const structured = settled.output?.structured as Record<string, unknown> | undefined
|
||||
const shellID = typeof structured?.shellID === "string" ? structured.shellID : undefined
|
||||
@@ -454,6 +460,13 @@ describe("ShellTool", () => {
|
||||
const id = ShellSchema.ID.make(shellID)
|
||||
expect((yield* shell.list()).map((info) => info.id)).toContain(id)
|
||||
expect((yield* shell.wait(id)).status).toBe("timeout")
|
||||
expect((yield* Fiber.join(admitted)).valueOrUndefined?.data.input.data).toMatchObject({
|
||||
description: idleCommand,
|
||||
metadata: {
|
||||
source: "shell",
|
||||
state: "completed",
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
||||
Vendored
+4
-3
@@ -315,11 +315,12 @@ export default Plugin.define({
|
||||
Unsupported characters in tool and group names are normalized to underscores.
|
||||
The resulting exposed key must begin with a letter and contain at most 64
|
||||
letters, digits, underscores, or hyphens. Set `options` on the declaration to
|
||||
configure registration with `{ group, deferred }`:
|
||||
configure registration with `{ group, codemode }`:
|
||||
|
||||
- `group` prefixes and groups the exposed tool name.
|
||||
- `deferred: true` makes the tool available through the deferred `execute`
|
||||
tool instead of exposing it directly.
|
||||
- `codemode` defaults to `true` and makes the tool available through the
|
||||
`execute` CodeMode tool. Set `codemode: false` to expose it directly to the
|
||||
provider.
|
||||
|
||||
The executor receives a second context argument containing `sessionID`,
|
||||
`agent`, `assistantMessageID`, and `toolCallID`.
|
||||
|
||||
@@ -3,14 +3,19 @@ title: "Migrate from V1"
|
||||
description: "Move from OpenCode V1 to the OpenCode 2.0 beta."
|
||||
---
|
||||
|
||||
<Note>
|
||||
The only intentional breaking changes in V2 are the server API and the plugin API. All other functionality is intended
|
||||
to remain compatible with V1.
|
||||
</Note>
|
||||
## Breaking changes
|
||||
|
||||
Existing config files, agent definitions, command definitions, skills, and other files in `.opencode/` should continue to
|
||||
work without changes. If one of these stops working in V2, treat it as a beta compatibility bug rather than an expected
|
||||
migration requirement.
|
||||
V2 has three intentional breaking changes:
|
||||
|
||||
- [Plugins](#plugins) use a new plugin API.
|
||||
- The [server API and clients](#server-api-and-clients) have new contracts.
|
||||
- [TUI configuration](#tui-configuration) moves from layered `tui.json(c)` files to one global `cli.json` file (auto migrated).
|
||||
|
||||
All other functionality is intended to remain compatible with V1.
|
||||
|
||||
Existing server config files, agent definitions, command definitions, skills, and other files in `.opencode/` should
|
||||
continue to work without changes. If one of these stops working in V2, treat it as a beta compatibility bug rather than
|
||||
an expected migration requirement.
|
||||
|
||||
<Tip>
|
||||
Run `/report` if existing V1 functionality does not work in V2. The report skill collects diagnostics and helps you file
|
||||
@@ -466,6 +471,58 @@ If a V1 setup relied on a `CLAUDE.md` fallback, move that guidance into the appl
|
||||
discovers `AGENTS.md`; because non-API V1 behavior is intended to remain compatible, also run `/report` with the affected
|
||||
project details. See [Instructions](/instructions).
|
||||
|
||||
## TUI configuration
|
||||
|
||||
V1 loaded `tui.json(c)` from the global config directory and from project directories discovered while walking up from
|
||||
the current directory. V2 instead stores CLI and TUI settings in one global file:
|
||||
|
||||
```text
|
||||
~/.config/opencode/cli.json
|
||||
```
|
||||
|
||||
The CLI owns this file. The background service does not load it, and V2 does not discover or merge project-local
|
||||
`tui.json(c)` or `cli.json` files.
|
||||
|
||||
The native V2 format groups related settings. For example:
|
||||
|
||||
```jsonc
|
||||
// V1: ~/.config/opencode/tui.json
|
||||
{
|
||||
"theme": "tokyonight",
|
||||
"scroll_speed": 2,
|
||||
"scroll_acceleration": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
|
||||
// V2: ~/.config/opencode/cli.json
|
||||
{
|
||||
"theme": {
|
||||
"name": "tokyonight"
|
||||
},
|
||||
"scroll": {
|
||||
"speed": 2,
|
||||
"acceleration": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
V2 migrates the global TUI configuration automatically. On the first CLI or TUI startup, when `cli.json` does not already
|
||||
exist, it:
|
||||
|
||||
- Reads `~/.config/opencode/tui.json`.
|
||||
- Reads persisted TUI preferences from the legacy `kv.json` state file.
|
||||
- Converts supported settings to the native grouped format and writes `~/.config/opencode/cli.json`.
|
||||
- Leaves the V1 files unchanged so V1 can continue using them.
|
||||
|
||||
Migration runs only while `cli.json` is absent. Once that file exists, V2 treats it as the source of truth and does not
|
||||
continually synchronize later changes from `tui.json` or `kv.json`. If you created `cli.json` before starting V2, merge any
|
||||
V1 settings you still need into it manually.
|
||||
|
||||
Project-local V1 TUI configuration is not migrated because V2 has no project-local CLI configuration. Move settings you
|
||||
still want into the global `cli.json`; when multiple projects used different values for the same setting, choose the
|
||||
global behavior you want V2 to use.
|
||||
|
||||
## Plugins
|
||||
|
||||
Rename `plugin` to `plugins`. Replace a package-and-options tuple with an object:
|
||||
|
||||
@@ -303,7 +303,13 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
|
||||
input: 272_000,
|
||||
output: 128_000,
|
||||
}
|
||||
: model.limit,
|
||||
: model.id.includes("gpt-5.6")
|
||||
? {
|
||||
context: 500_000,
|
||||
input: 372_000,
|
||||
output: 128_000,
|
||||
}
|
||||
: model.limit,
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -149,6 +149,30 @@ describe("plugin.codex", () => {
|
||||
await enabled.dispose?.()
|
||||
})
|
||||
|
||||
test("uses Codex context limits for OAuth GPT models", async () => {
|
||||
const hooks = await CodexAuthPlugin({} as never)
|
||||
const limit = { context: 1_050_000, input: 922_000, output: 128_000 }
|
||||
const provider = {
|
||||
models: Object.fromEntries(
|
||||
["gpt-5.4", "gpt-5.5", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"].map((id) => [
|
||||
id,
|
||||
{ id, api: { id }, limit, cost: {} },
|
||||
]),
|
||||
),
|
||||
}
|
||||
|
||||
const models = await hooks.provider!.models!(provider as never, { auth: { type: "oauth" } } as never)
|
||||
|
||||
expect(models["gpt-5.4"]?.limit).toEqual(limit)
|
||||
expect(models["gpt-5.5"]?.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
|
||||
expect(models["gpt-5.6-sol"]?.limit).toEqual({ context: 500_000, input: 372_000, output: 128_000 })
|
||||
expect(models["gpt-5.6-terra"]?.limit).toEqual({ context: 500_000, input: 372_000, output: 128_000 })
|
||||
expect(models["gpt-5.6-luna"]?.limit).toEqual({ context: 500_000, input: 372_000, output: 128_000 })
|
||||
expect(await hooks.provider!.models!(provider as never, { auth: { type: "api" } } as never)).toBe(
|
||||
provider.models as never,
|
||||
)
|
||||
})
|
||||
|
||||
test("deduplicates concurrent Codex token refreshes", async () => {
|
||||
let auth = {
|
||||
type: "oauth" as const,
|
||||
|
||||
@@ -161,10 +161,10 @@ it.instance(
|
||||
const providers = yield* list
|
||||
expect(providers[ProviderV2.ID.anthropic]).toBeDefined()
|
||||
const models = Object.keys(providers[ProviderV2.ID.anthropic].models)
|
||||
expect(models).toContain("claude-sonnet-4-20250514")
|
||||
expect(models).toContain("claude-sonnet-4-6")
|
||||
expect(models.length).toBe(1)
|
||||
}),
|
||||
{ config: { provider: { anthropic: { whitelist: ["claude-sonnet-4-20250514"] } } } },
|
||||
{ config: { provider: { anthropic: { whitelist: ["claude-sonnet-4-6"] } } } },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
@@ -303,10 +303,10 @@ it.instance("getModel returns model for valid provider/model", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key")
|
||||
const provider = yield* Provider.Service
|
||||
const model = yield* provider.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-20250514"))
|
||||
const model = yield* provider.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-6"))
|
||||
expect(model).toBeDefined()
|
||||
expect(String(model.providerID)).toBe("anthropic")
|
||||
expect(String(model.id)).toBe("claude-sonnet-4-20250514")
|
||||
expect(String(model.id)).toBe("claude-sonnet-4-6")
|
||||
const language = yield* provider.getLanguage(model)
|
||||
expect(language).toBeDefined()
|
||||
}),
|
||||
@@ -437,7 +437,7 @@ it.instance(
|
||||
"model options are merged from existing model",
|
||||
Effect.gen(function* () {
|
||||
const providers = yield* list
|
||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
|
||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"]
|
||||
expect(model.options.customOption).toBe("custom-value")
|
||||
}),
|
||||
{
|
||||
@@ -445,7 +445,7 @@ it.instance(
|
||||
provider: {
|
||||
anthropic: {
|
||||
options: { apiKey: "test-api-key" },
|
||||
models: { "claude-sonnet-4-20250514": { options: { customOption: "custom-value" } } },
|
||||
models: { "claude-sonnet-4-6": { options: { customOption: "custom-value" } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -551,7 +551,7 @@ it.instance(
|
||||
Effect.gen(function* () {
|
||||
yield* set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
const providers = yield* list
|
||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
|
||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"]
|
||||
expect(model.name).toBe("Custom Name for Sonnet")
|
||||
expect(model.capabilities.toolcall).toBe(true)
|
||||
expect(model.capabilities.attachment).toBe(true)
|
||||
@@ -559,7 +559,7 @@ it.instance(
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
provider: { anthropic: { models: { "claude-sonnet-4-20250514": { name: "Custom Name for Sonnet" } } } },
|
||||
provider: { anthropic: { models: { "claude-sonnet-4-6": { name: "Custom Name for Sonnet" } } } },
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -592,16 +592,16 @@ it.instance(
|
||||
const providers = yield* list
|
||||
expect(providers[ProviderV2.ID.anthropic]).toBeDefined()
|
||||
const models = Object.keys(providers[ProviderV2.ID.anthropic].models)
|
||||
expect(models).toContain("claude-sonnet-4-20250514")
|
||||
expect(models).not.toContain("claude-opus-4-20250514")
|
||||
expect(models).toContain("claude-sonnet-4-6")
|
||||
expect(models).not.toContain("claude-opus-4-6")
|
||||
expect(models.length).toBe(1)
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
provider: {
|
||||
anthropic: {
|
||||
whitelist: ["claude-sonnet-4-20250514", "claude-opus-4-20250514"],
|
||||
blacklist: ["claude-opus-4-20250514"],
|
||||
whitelist: ["claude-sonnet-4-6", "claude-opus-4-6"],
|
||||
blacklist: ["claude-opus-4-6"],
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -775,9 +775,9 @@ it.instance(
|
||||
const model = yield* Provider.use.getSmallModel(ProviderV2.ID.anthropic)
|
||||
expect(model).toBeDefined()
|
||||
expect(String(model?.providerID)).toBe("anthropic")
|
||||
expect(String(model?.id)).toBe("claude-sonnet-4-20250514")
|
||||
expect(String(model?.id)).toBe("claude-sonnet-4-6")
|
||||
}),
|
||||
{ config: { small_model: "anthropic/claude-sonnet-4-20250514" } },
|
||||
{ config: { small_model: "anthropic/claude-sonnet-4-6" } },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
@@ -1096,8 +1096,8 @@ it.instance(
|
||||
it.instance("getModel returns consistent results", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
const model1 = yield* Provider.use.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-20250514"))
|
||||
const model2 = yield* Provider.use.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-20250514"))
|
||||
const model1 = yield* Provider.use.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-6"))
|
||||
const model2 = yield* Provider.use.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-6"))
|
||||
expect(model1.providerID).toEqual(model2.providerID)
|
||||
expect(model1.id).toEqual(model2.id)
|
||||
expect(model1).toEqual(model2)
|
||||
@@ -1459,7 +1459,7 @@ it.instance("model variants are generated for reasoning models", () =>
|
||||
yield* set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
const providers = yield* list
|
||||
// Claude sonnet 4 has reasoning capability
|
||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
|
||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"]
|
||||
expect(model.capabilities.reasoning).toBe(true)
|
||||
expect(model.variants).toBeDefined()
|
||||
expect(Object.keys(model.variants!).length).toBeGreaterThan(0)
|
||||
@@ -1471,7 +1471,7 @@ it.instance(
|
||||
Effect.gen(function* () {
|
||||
yield* set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
const providers = yield* list
|
||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
|
||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"]
|
||||
expect(model.variants).toBeDefined()
|
||||
expect(model.variants!["high"]).toBeUndefined()
|
||||
// max variant should still exist
|
||||
@@ -1481,7 +1481,7 @@ it.instance(
|
||||
config: {
|
||||
provider: {
|
||||
anthropic: {
|
||||
models: { "claude-sonnet-4-20250514": { variants: { high: { disabled: true } } } },
|
||||
models: { "claude-sonnet-4-6": { variants: { high: { disabled: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1493,7 +1493,7 @@ it.instance(
|
||||
Effect.gen(function* () {
|
||||
yield* set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
const providers = yield* list
|
||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
|
||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"]
|
||||
expect(model.variants!["high"]).toBeDefined()
|
||||
expect(model.variants!["high"].thinking.budgetTokens).toBe(20000)
|
||||
}),
|
||||
@@ -1502,7 +1502,7 @@ it.instance(
|
||||
provider: {
|
||||
anthropic: {
|
||||
models: {
|
||||
"claude-sonnet-4-20250514": {
|
||||
"claude-sonnet-4-6": {
|
||||
variants: { high: { thinking: { type: "enabled", budgetTokens: 20000 } } },
|
||||
},
|
||||
},
|
||||
@@ -1517,7 +1517,7 @@ it.instance(
|
||||
Effect.gen(function* () {
|
||||
yield* set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
const providers = yield* list
|
||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
|
||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"]
|
||||
expect(model.variants!["max"]).toBeDefined()
|
||||
expect(model.variants!["max"].disabled).toBeUndefined()
|
||||
expect(model.variants!["max"].customField).toBe("test")
|
||||
@@ -1527,7 +1527,7 @@ it.instance(
|
||||
provider: {
|
||||
anthropic: {
|
||||
models: {
|
||||
"claude-sonnet-4-20250514": {
|
||||
"claude-sonnet-4-6": {
|
||||
variants: { max: { disabled: false, customField: "test" } },
|
||||
},
|
||||
},
|
||||
@@ -1542,7 +1542,7 @@ it.instance(
|
||||
Effect.gen(function* () {
|
||||
yield* set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
const providers = yield* list
|
||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
|
||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"]
|
||||
expect(model.variants).toBeDefined()
|
||||
expect(Object.keys(model.variants!).length).toBe(0)
|
||||
}),
|
||||
@@ -1551,8 +1551,13 @@ it.instance(
|
||||
provider: {
|
||||
anthropic: {
|
||||
models: {
|
||||
"claude-sonnet-4-20250514": {
|
||||
variants: { high: { disabled: true }, max: { disabled: true } },
|
||||
"claude-sonnet-4-6": {
|
||||
variants: {
|
||||
low: { disabled: true },
|
||||
medium: { disabled: true },
|
||||
high: { disabled: true },
|
||||
max: { disabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1566,7 +1571,7 @@ it.instance(
|
||||
Effect.gen(function* () {
|
||||
yield* set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
const providers = yield* list
|
||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
|
||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"]
|
||||
expect(model.variants!["high"]).toBeDefined()
|
||||
// Should have both the generated thinking config and the custom option
|
||||
expect(model.variants!["high"].thinking).toBeDefined()
|
||||
@@ -1577,7 +1582,7 @@ it.instance(
|
||||
provider: {
|
||||
anthropic: {
|
||||
models: {
|
||||
"claude-sonnet-4-20250514": { variants: { high: { extraOption: "custom-value" } } },
|
||||
"claude-sonnet-4-6": { variants: { high: { extraOption: "custom-value" } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+165932
-110539
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user