Compare commits

..

19 Commits

Author SHA1 Message Date
Kit Langton c5ac6ba4f3 refactor(tui): prototype settings side panel 2026-08-12 00:17:07 +00:00
Dax Raad 20af1f5e79 refactor(tui): compact settings values 2026-07-13 03:57:14 +00:00
Kit Langton 8daf912fbf feat(tui): add settings dialog 2026-07-13 03:57:14 +00:00
opencode-agent[bot] 3d545f960b fix(tui): restore clicked reverted prompt (#36567)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-12 21:51:50 -05:00
Kit Langton 58201a32c1 fix: standardize MCP server copy (#36598) 2026-07-12 22:23:59 -04:00
Aiden Cline 64ca9b8d77 refactor(core): replace deferred tool option with codemode (#36560) 2026-07-12 21:21:07 -05:00
Kit Langton a4b91d33d9 fix(tui): align skills dialog copy (#36592) 2026-07-12 22:02:24 -04:00
Kit Langton 397993e898 fix(client): preserve compatible background service (#36583) 2026-07-12 21:32:34 -04:00
Kit Langton c0ed0106b1 fix(tui): show background shell completion (#36534) 2026-07-12 21:01:36 -04:00
opencode-agent[bot] f112a73c06 fix(core): load config across git boundaries (#36577)
Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-13 00:05:41 +00:00
Dax Raad 7e9b9cb0fd docs: document TUI config migration 2026-07-12 19:28:26 -04:00
Dax Raad 09a6cecf23 refactor(tui): remove code concealment toggle 2026-07-12 18:12:36 -04:00
opencode-agent[bot] 6e55ddd078 fix(config): load configs across git boundaries (#36568)
Co-authored-by: Dax Raad <thdxr@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-12 22:07:20 +00:00
Dax Raad f8f8cc9546 refactor(tui): simplify config context 2026-07-12 17:12:48 -04:00
Aiden Cline 3c60470269 refactor(codemode): remove tool concurrency cap (#36545) 2026-07-12 16:07:53 -05:00
Dax Raad b2bdab24e6 fix(tui): preserve legacy host compatibility 2026-07-12 17:01:02 -04:00
Dax Raad 3568dd1b99 feat(tui): add managed config interface 2026-07-12 17:01:02 -04:00
opencode-agent[bot] 1e17202413 chore: merge dev into v2 (#36556)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Julian Coy <julian@ex-machina.co>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: James Long <longster@gmail.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Jay <air@live.ca>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: James Long <jlongster@users.noreply.github.com>
Co-authored-by: 冯基魁 <56265583+fengjikui@users.noreply.github.com>
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: Victor Navarro <vn4varro@gmail.com>
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: Nabs <nabil@instafork.com>
2026-07-12 15:16:08 -05:00
Aiden Cline 49cb73b348 refactor(codemode): split runtime into focused interpreter modules (#36540) 2026-07-12 12:47:56 -05:00
157 changed files with 169823 additions and 115531 deletions
+5
View File
@@ -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.
+1
View File
@@ -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:",
+4 -4
View File
@@ -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",
+1
View File
@@ -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 =
+109
View File
@@ -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 }]
}
+1
View File
@@ -0,0 +1 @@
export * as Config from "./config"
+142
View File
@@ -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))
})
+5
View File
@@ -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>
+12 -3
View File
@@ -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
+2
View File
@@ -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),
-24
View File
@@ -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" },
)
})
+125
View File
@@ -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}`
}
})
-25
View File
@@ -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}`
}
})
-1
View File
@@ -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]
+7 -6
View File
@@ -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,
+4 -21
View File
@@ -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
+39
View File
@@ -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)
+91
View File
@@ -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())
}
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
-164
View File
@@ -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).
+3 -4
View File
@@ -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.
+5 -6
View File
@@ -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()
}
}
+2 -4
View File
@@ -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
+3 -6
View File
@@ -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}'.`)
}
+2 -3
View File
@@ -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> }
+3 -9
View File
@@ -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 = {
+120 -2
View File
@@ -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)
}
-3
View File
@@ -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
-3
View File
@@ -4,12 +4,9 @@ export const stringMethods = new Set([
"trim",
"trimStart",
"trimEnd",
"trimLeft",
"trimRight",
"split",
"slice",
"substring",
"substr",
"includes",
"startsWith",
"endsWith",
+4 -108
View File
@@ -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
+2 -59
View File
@@ -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)
+5 -30
View File
@@ -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>,
+16
View File
@@ -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" })),
+6 -3
View File
@@ -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",
])
})
})
+2 -3
View File
@@ -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 -1
View File
@@ -2,6 +2,7 @@
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"noUncheckedIndexedAccess": false
"noUncheckedIndexedAccess": false,
"noUnusedLocals": true
}
}
-1
View File
@@ -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)
-3
View File
@@ -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"]
},
+10 -21
View File
@@ -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, {})
+5 -13
View File
@@ -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 }))
+10 -26
View File
@@ -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) =>
-4
View File
@@ -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 -218
View File
@@ -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("")
}
-141
View File
@@ -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 -301
View File
@@ -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",
})}`
}
+2 -1
View File
@@ -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:
+1
View File
@@ -212,6 +212,7 @@ export const Plugin = {
}),
"edit",
),
{ codemode: false },
),
)
.pipe(Effect.orDie)
+3 -3
View File
@@ -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
}
+1
View File
@@ -106,6 +106,7 @@ export const Plugin = {
),
),
}),
{ codemode: false },
),
)
.pipe(Effect.orDie)
+1
View File
@@ -137,6 +137,7 @@ export const Plugin = {
),
),
}),
{ codemode: false },
),
)
.pipe(Effect.orDie)
+2 -2
View File
@@ -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,
},
+1
View File
@@ -191,6 +191,7 @@ export const Plugin = {
}),
"edit",
),
{ codemode: false },
),
)
.pipe(Effect.orDie)
+1
View File
@@ -106,6 +106,7 @@ export const Plugin = {
}),
),
}),
{ codemode: false },
),
)
.pipe(Effect.orDie)
+1
View File
@@ -139,6 +139,7 @@ export const Plugin = {
)
},
}),
{ codemode: false },
),
)
.pipe(Effect.orDie)
+7 -8
View File
@@ -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)),
+3
View File
@@ -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)
+1
View File
@@ -97,6 +97,7 @@ export const Plugin = {
}).pipe(Effect.mapError((error) => unableToLoad(input.id, error)))
}),
}),
{ codemode: false },
),
)
.pipe(Effect.orDie)
+1
View File
@@ -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)
+1
View File
@@ -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)
+1
View File
@@ -252,6 +252,7 @@ export const Plugin = {
)
},
}),
{ codemode: false },
),
)
.pipe(Effect.orDie)
+1
View File
@@ -91,6 +91,7 @@ export const Plugin = {
}),
"edit",
),
{ codemode: false },
),
)
.pipe(Effect.orDie)
+4 -1
View File
@@ -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",
+6 -4
View File
@@ -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)
+10 -22
View File
@@ -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 -37
View File
@@ -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"),
+9 -9
View File
@@ -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", {}), []]
+14 -1
View File
@@ -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",
},
})
}),
)
},
+4 -3
View File
@@ -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`.
+64 -7
View File
@@ -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:
+7 -1
View File
@@ -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" } } },
},
},
},
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