diff --git a/packages/core/src/code-mode.ts b/packages/core/src/code-mode.ts new file mode 100644 index 0000000000..3975f3ab95 --- /dev/null +++ b/packages/core/src/code-mode.ts @@ -0,0 +1,127 @@ +export * as CodeModeV2 from "./code-mode" + +import type { CodeMode } from "@opencode-ai/plugin/v2/effect" +import { Context, Effect, Layer, Scope } from "effect" +import { makeLocationNode } from "./effect/app-node" +import { Flag } from "./flag/flag" +import { PermissionV2 } from "./permission" +import { ExecuteTool } from "./code-mode/execute" +import { permission, RegistrationError, type AnyTool } from "./tool/tool" +import { Wildcard } from "./util/wildcard" + +type Registration = { + readonly identity: object + readonly tool: AnyTool + readonly name: string + readonly path: readonly [string, ...string[]] +} + +export interface MaterializeInput { + readonly permissions?: PermissionV2.Ruleset +} + +export interface Interface { + readonly enabled: boolean + readonly register: ( + source: (draft: CodeMode.Draft) => void, + ) => Effect.Effect + readonly materialize: (input: MaterializeInput) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/CodeMode") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const enabled = Flag.CODEMODE_ENABLED + const local = new Map>() + + return Service.of({ + enabled, + register: Effect.fn("CodeMode.register")(function* (source) { + const pending: Array<{ readonly path: readonly [string, ...string[]]; readonly tool: AnyTool }> = [] + yield* Effect.sync(() => + source({ + add: (path, tool) => pending.push({ path, tool }), + }), + ) + if (pending.length === 0) return + + const entries = pending.map((entry) => ({ + ...entry, + key: entry.path.join("\0"), + name: entry.path.join("_"), + })) + const invalid = entries.find((entry) => entry.path.some((segment) => segment.length === 0)) + if (invalid) + return yield* new RegistrationError({ + name: invalid.path.join("."), + message: "Code Mode paths cannot contain empty segments", + }) + const keys = new Set() + const duplicate = entries.find((entry) => { + if (keys.has(entry.key)) return true + keys.add(entry.key) + return false + }) + if (duplicate) + return yield* new RegistrationError({ + name: duplicate.path.join("."), + message: `Duplicate Code Mode path: ${duplicate.path.join(".")}`, + }) + + yield* Effect.uninterruptible( + Effect.gen(function* () { + const token = {} + for (const entry of entries) + local.set(entry.key, [ + ...(local.get(entry.key) ?? []), + { + token, + registration: { + identity: {}, + tool: entry.tool, + name: entry.name, + path: entry.path, + }, + }, + ]) + yield* Effect.addFinalizer(() => + Effect.sync(() => { + for (const entry of entries) { + const registrations = local.get(entry.key)?.filter((item) => item.token !== token) ?? [] + if (registrations.length > 0) local.set(entry.key, registrations) + else local.delete(entry.key) + } + }), + ) + }), + ) + }), + materialize: Effect.fn("CodeMode.materialize")(function* (input) { + if (!enabled || whollyDisabled("execute", input.permissions ?? [])) return undefined + const registrations = new Map() + for (const [key, entries] of local) { + const registration = entries.at(-1)?.registration + if ( + registration && + !whollyDisabled(permission(registration.tool, registration.name), input.permissions ?? []) + ) + registrations.set(key, registration) + } + if (registrations.size === 0) return undefined + return ExecuteTool.create({ + registrations, + current: (key) => local.get(key)?.at(-1)?.registration, + }) + }), + }) + }), +) + +function whollyDisabled(action: string, rules: PermissionV2.Ruleset) { + const rule = rules.findLast((rule) => Wildcard.match(action, rule.action)) + return rule?.resource === "*" && rule.effect === "deny" +} + +export const node = makeLocationNode({ service: Service, layer, deps: [] }) diff --git a/packages/core/src/tool/execute.ts b/packages/core/src/code-mode/execute.ts similarity index 83% rename from packages/core/src/tool/execute.ts rename to packages/core/src/code-mode/execute.ts index f5bd776e5a..2031e4daf4 100644 --- a/packages/core/src/tool/execute.ts +++ b/packages/core/src/code-mode/execute.ts @@ -3,7 +3,7 @@ export * as ExecuteTool from "./execute" import { CodeMode, Tool, toolError } from "@opencode-ai/codemode" import { ToolOutput } from "@opencode-ai/llm" import { Effect, Ref, Schema } from "effect" -import { definition, make, settle, type AnyTool } from "./tool" +import { definition, make, settle, type AnyTool } from "../tool/tool" const ExecuteFile = Schema.Struct({ data: Schema.String, @@ -40,7 +40,11 @@ export interface Registration { readonly identity: object readonly tool: AnyTool readonly name: string - readonly group?: string + readonly path: readonly [string, ...string[]] +} + +interface CodeModeTools { + [name: string]: Tool.Definition | CodeModeTools } export const create = (options: { @@ -51,33 +55,16 @@ export const create = (options: { invoke: (name: string, registration: Registration, input: unknown) => Effect.Effect, hooks?: CodeMode.ToolCallHooks, ) => { - const tools: Record | Record>> = {} - for (const [name, registration] of options.registrations) { - const child = definition(name, registration.tool) + const tools: CodeModeTools = Object.create(null) + for (const [key, registration] of options.registrations) { + const child = definition(registration.name, registration.tool) const value = Tool.make({ description: child.description, input: child.inputSchema, output: child.outputSchema, - run: (input) => invoke(name, registration, input), + run: (input) => invoke(key, registration, input), }) - if (registration.group === undefined) { - const path = registration.name - if (Object.hasOwn(tools, path)) throw new TypeError(`Deferred 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) { - if (Object.hasOwn(group, path)) throw new TypeError(`Deferred tool namespace conflict: ${namespace}.${path}`) - group[path] = value - continue - } - const entries: Record> = {} - entries[path] = value - tools[namespace] = entries + addTool(tools, registration.path, value) } return CodeMode.make({ tools, ...hooks }) } @@ -112,15 +99,15 @@ export const create = (options: { ), ) const result = yield* runtime( - (name, registration, input) => + (key, registration, input) => Effect.gen(function* () { const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1) - const current = options.current(name) + const current = options.current(key) if (!current || current.identity !== registration.identity) - return yield* Effect.fail(toolError(`Stale tool call: ${name}`)) + return yield* Effect.fail(toolError(`Stale tool call: ${registration.path.join(".")}`)) const output = yield* settle( current.tool, - { type: "tool-call", id: context.toolCallID, name, input }, + { type: "tool-call", id: context.toolCallID, name: registration.name, input }, { sessionID: context.sessionID, agent: context.agent, @@ -163,6 +150,21 @@ export const create = (options: { }) } +function addTool(tools: CodeModeTools, path: readonly string[], value: Tool.Definition) { + const [name, ...rest] = path + if (name === undefined) return + if (rest.length === 0) { + if (Object.hasOwn(tools, name)) throw new TypeError(`Code Mode namespace conflict: ${path.join(".")}`) + tools[name] = value + return + } + const current = tools[name] + if (Tool.isDefinition(current)) throw new TypeError(`Code Mode namespace conflict: ${path.join(".")}`) + const child: CodeModeTools = current ?? Object.create(null) + tools[name] = child + addTool(child, rest, value) +} + function displayInput(input: unknown): Record | undefined { if (input === null || input === undefined) return if (typeof input !== "object" || Array.isArray(input)) return { input } diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 661e9795eb..0a506ea4a8 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -2,6 +2,7 @@ import { Effect, Layer, LayerMap } from "effect" import { AgentV2 } from "./agent" import { AISDK } from "./aisdk" import { Catalog } from "./catalog" +import { CodeModeV2 } from "./code-mode" import { CommandV2 } from "./command" import { Config } from "./config" import { LayerNode } from "./effect/layer-node" @@ -119,6 +120,7 @@ const locationServiceNodes = [ MCP.node, PermissionV2.node, ToolOutputStore.node, + CodeModeV2.node, ToolRegistry.node, ToolRegistry.toolsNode, Image.node, diff --git a/packages/core/src/mcp/guidance.ts b/packages/core/src/mcp/guidance.ts index 4f2c13b72c..86920561b4 100644 --- a/packages/core/src/mcp/guidance.ts +++ b/packages/core/src/mcp/guidance.ts @@ -1,7 +1,7 @@ export * as McpGuidance from "./guidance" import { makeLocationNode } from "../effect/app-node" -import { Flag } from "../flag/flag" +import { CodeModeV2 } from "../code-mode" import { Context, Effect, Layer, Schema } from "effect" import { AgentV2 } from "../agent" import { PermissionV2 } from "../permission" @@ -15,10 +15,10 @@ const Summary = Schema.Struct({ }) type Summary = typeof Summary.Type -const entries = (servers: ReadonlyArray) => +const entries = (servers: ReadonlyArray, codeMode: boolean) => servers.flatMap((server) => [ ` `, - ...(Flag.CODEMODE_ENABLED + ...(codeMode ? [ ` Use tools from this server through \`execute\` under \`tools[${JSON.stringify(McpTool.group(server.server))}]\`.`, ] @@ -27,10 +27,10 @@ const entries = (servers: ReadonlyArray) => " ", ]) -const render = (servers: ReadonlyArray) => - ["", ...entries(servers), ""].join("\n") +const render = (servers: ReadonlyArray, codeMode: boolean) => + ["", ...entries(servers, codeMode), ""].join("\n") -const update = (previous: ReadonlyArray, current: ReadonlyArray) => { +const update = (previous: ReadonlyArray, current: ReadonlyArray, codeMode: boolean) => { const diff = Instructions.diffByKey( previous, current, @@ -41,12 +41,15 @@ const update = (previous: ReadonlyArray, current: ReadonlyArray 0 || (diff.added.length === 0 && diff.removed.length === 0)) return [ "The available MCP server instructions have changed. This list supersedes the previous one.", - render(current), + render(current, codeMode), ].join("\n") return [ ...(diff.added.length === 0 ? [] - : ["New MCP server instructions are available in addition to those previously listed:", ...entries(diff.added)]), + : [ + "New MCP server instructions are available in addition to those previously listed:", + ...entries(diff.added, codeMode), + ]), ...(diff.removed.length === 0 ? [] : [ @@ -64,13 +67,14 @@ export class Service extends Context.Service()("@opencode/v2 export const layer = Layer.effect( Service, Effect.gen(function* () { + const codeMode = yield* CodeModeV2.Service const mcp = yield* MCP.Service return Service.of({ load: Effect.fn("McpGuidance.load")(function* (selection) { const agent = selection.info if (!agent) return Instructions.empty - if (Flag.CODEMODE_ENABLED && PermissionV2.evaluate("execute", "*", agent.permissions).effect === "deny") + if (codeMode.enabled && PermissionV2.evaluate("execute", "*", agent.permissions).effect === "deny") return Instructions.empty const [instructions, tools] = yield* Effect.all([mcp.instructions(), mcp.tools()], { concurrency: "unbounded", @@ -80,7 +84,7 @@ export const layer = Layer.effect( .filter((item) => { const owned = tools.filter((tool) => tool.server === item.server) return ( - (!Flag.CODEMODE_ENABLED && owned.length === 0) || + (!codeMode.enabled && owned.length === 0) || owned.some( (tool) => PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny", @@ -93,8 +97,8 @@ export const layer = Layer.effect( key: Instructions.Key.make("core/mcp-guidance"), codec: Schema.toCodecJson(Schema.Array(Summary)), load: Effect.succeed(visible), - baseline: render, - update, + baseline: (servers) => render(servers, codeMode.enabled), + update: (previous, current) => update(previous, current, codeMode.enabled), removed: () => "MCP server instructions are no longer available.", }) }), @@ -102,4 +106,4 @@ export const layer = Layer.effect( }), ) -export const node = makeLocationNode({ service: Service, layer, deps: [MCP.node] }) +export const node = makeLocationNode({ service: Service, layer, deps: [CodeModeV2.node, MCP.node] }) diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index 907920c10e..a57b5f0ac2 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -7,6 +7,7 @@ import { Context, Effect, Exit, Layer, Scope, Semaphore } from "effect" import { AgentV2 } from "./agent" import { AISDK } from "./aisdk" import { Catalog } from "./catalog" +import { CodeModeV2 } from "./code-mode" import { CommandV2 } from "./command" import { EventV2 } from "./event" import { Integration } from "./integration" @@ -124,6 +125,7 @@ export const node = makeLocationNode({ AgentV2.node, AISDK.node, Catalog.node, + CodeModeV2.node, CommandV2.node, Integration.node, Location.node, diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 01ea0da0d3..e5271d240d 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -6,6 +6,7 @@ import { Effect, Schema, Stream } from "effect" import { AgentV2 } from "../agent" import { AISDK } from "../aisdk" import { Catalog } from "../catalog" +import { CodeModeV2 } from "../code-mode" import { CommandV2 } from "../command" import { Credential } from "../credential" import { EventV2 } from "../event" @@ -28,6 +29,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int const agents = yield* AgentV2.Service const aisdk = yield* AISDK.Service const catalog = yield* Catalog.Service + const codeMode = yield* CodeModeV2.Service const commands = yield* CommandV2.Service const events = yield* EventV2.Service const integration = yield* Integration.Service @@ -158,6 +160,9 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int callback(draft) }), }, + codemode: { + register: codeMode.register, + }, event: { subscribe: () => events.subscribe().pipe(Stream.filter(EventManifest.isServer)), }, diff --git a/packages/core/src/tool/mcp.ts b/packages/core/src/tool/mcp.ts index 7503e4e94e..44f7b3de04 100644 --- a/packages/core/src/tool/mcp.ts +++ b/packages/core/src/tool/mcp.ts @@ -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 { CodeModeV2 } from "../code-mode" import { MCP } from "../mcp" import { PermissionV2 } from "../permission" import { Tool } from "./tool" @@ -21,6 +21,7 @@ export const name = (server: string, tool: string) => `${group(server)}_${tool.r export const layer = Layer.effectDiscard( Effect.gen(function* () { const mcp = yield* MCP.Service + const codeMode = yield* CodeModeV2.Service const tools = yield* Tools.Service const events = yield* EventV2.Service const permission = yield* PermissionV2.Service @@ -105,13 +106,14 @@ export const layer = Layer.effectDiscard( groups.set(tool.server, group) } const next = yield* Scope.fork(scope) - yield* Effect.forEach( - groups, - ([group, record]) => tools.register(record, { group, deferred: Flag.CODEMODE_ENABLED }), - { - discard: true, - }, - ).pipe(Scope.provide(next), Effect.orDie) + const register = codeMode.enabled + ? codeMode.register((draft) => { + for (const [server, record] of groups) + for (const [tool, value] of Object.entries(record)) + draft.add([group(server), tool.replace(/[^a-zA-Z0-9_-]/g, "_")], value) + }) + : Effect.forEach(groups, ([group, record]) => tools.register(record, { group }), { discard: true }) + yield* register.pipe(Scope.provide(next), Effect.orDie) if (current) yield* Scope.close(current, Exit.void) current = next }), @@ -128,5 +130,5 @@ export const layer = Layer.effectDiscard( export const node = makeLocationNode({ name: "mcp-tools", layer, - deps: [ToolRegistry.toolsNode, MCP.node, EventV2.node, PermissionV2.node], + deps: [CodeModeV2.node, ToolRegistry.toolsNode, MCP.node, EventV2.node, PermissionV2.node], }) diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index 0275cd8d87..dd04de1150 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -3,13 +3,12 @@ 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 { CodeModeV2 } from "../code-mode" import { PermissionV2 } from "../permission" import { SessionMessage } from "../session/message" import { SessionSchema } from "../session/schema" import { ToolOutputStore } from "../tool-output-store" import { Wildcard } from "../util/wildcard" -import { ExecuteTool } from "./execute" import { definition, permission, registrationEntries, RegistrationError, settle, type AnyTool } from "./tool" import { Tools } from "./tools" import { ToolHooks } from "./hooks" @@ -55,14 +54,12 @@ export class Service extends Context.Service()("@opencode/v2 const registryLayer = Layer.effect( Service, Effect.gen(function* () { + const codeMode = yield* CodeModeV2.Service const resources = yield* ToolOutputStore.Service const toolHooks = yield* ToolHooks.Service type Registration = { readonly identity: object readonly tool: AnyTool - readonly name: string - readonly group?: string - readonly deferred: boolean } const local = new Map>() @@ -150,7 +147,7 @@ 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 reserved = 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' }), @@ -166,9 +163,6 @@ const registryLayer = Layer.effect( registration: { identity: {}, tool: entry.tool, - name: entry.name, - group: entry.group, - deferred: options?.deferred ?? false, }, }, ]) @@ -195,30 +189,18 @@ const registryLayer = Layer.effect( const usePatch = input.model.provider.toLowerCase() === "openai" || input.model.id.toLowerCase().includes("gpt") for (const [name, registration] of registrations) { const wrongEditTool = name === "apply_patch" ? !usePatch : (name === "edit" || name === "write") && usePatch - if ( - wrongEditTool || - (registration.deferred && !Flag.CODEMODE_ENABLED) || - whollyDisabled(permission(registration.tool, name), input.permissions ?? []) - ) + if (wrongEditTool || whollyDisabled(permission(registration.tool, name), input.permissions ?? [])) registrations.delete(name) } - const direct = new Map(Array.from(registrations).filter(([, registration]) => !registration.deferred)) - const deferred = new Map(Array.from(registrations).filter(([, registration]) => registration.deferred)) - const execute = - deferred.size > 0 && !whollyDisabled("execute", input.permissions ?? []) - ? ExecuteTool.create({ - registrations: deferred, - current: (name) => local.get(name)?.at(-1)?.registration, - }) - : undefined + const execute = yield* codeMode.materialize({ permissions: input.permissions }) return { definitions: [ - ...Array.from(direct, ([name, registration]) => definition(name, registration.tool)), + ...Array.from(registrations, ([name, registration]) => definition(name, registration.tool)), ...(execute ? [definition("execute", execute)] : []), ], settle: (input) => { if (input.call.name === "execute" && execute) return settleTool(input, execute) - const registration = direct.get(input.call.name) + const registration = registrations.get(input.call.name) if (registration) return settleWith(input, registration.identity) return Effect.succeed({ result: { type: "error", value: `Unknown tool: ${input.call.name}` }, @@ -244,11 +226,11 @@ function whollyDisabled(action: string, rules: PermissionV2.Ruleset) { export const node = makeLocationNode({ service: Service, layer, - deps: [ToolOutputStore.node, ToolHooks.node], + deps: [CodeModeV2.node, ToolOutputStore.node, ToolHooks.node], }) export const toolsNode = makeLocationNode({ service: Tools.Service, layer, - deps: [ToolOutputStore.node, ToolHooks.node], + deps: [CodeModeV2.node, ToolOutputStore.node, ToolHooks.node], }) diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index 5d87102b7d..e1934ff7b0 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -191,7 +191,7 @@ describe("PluginV2", () => { }), ) - it.effect("groups tool names and defers registrations from direct exposure", () => + it.effect("registers direct and Code Mode tools through separate plugin domains", () => Effect.gen(function* () { const plugins = yield* PluginV2.Service const registry = yield* ToolRegistry.Service @@ -205,13 +205,17 @@ describe("PluginV2", () => { const plugin = define({ id: "grouped-tools", 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 }) - }) - .pipe(Effect.orDie), + Effect.gen(function* () { + yield* ctx.tool + .transform((draft) => { + draft.add("plain", tool("Plain")) + draft.add("look/up", tool("Lookup"), { group: "context 7" }) + }) + .pipe(Effect.orDie) + yield* ctx.codemode + .register((draft) => draft.add(["context_7", "search"], tool("Search"))) + .pipe(Effect.orDie) + }), }) yield* plugins.activate([{ plugin }]) @@ -221,6 +225,9 @@ describe("PluginV2", () => { "context_7_look_up", "execute", ]) + + yield* plugins.activate([]) + expect((yield* registry.materialize({ model: testModel })).definitions).toEqual([]) }), ) diff --git a/packages/core/test/plugin/fixture.ts b/packages/core/test/plugin/fixture.ts index ea6e65bb64..0e00f2b84b 100644 --- a/packages/core/test/plugin/fixture.ts +++ b/packages/core/test/plugin/fixture.ts @@ -1,6 +1,7 @@ import { AgentV2 } from "@opencode-ai/core/agent" import { AISDK } from "@opencode-ai/core/aisdk" import { Catalog } from "@opencode-ai/core/catalog" +import { CodeModeV2 } from "@opencode-ai/core/code-mode" import { CommandV2 } from "@opencode-ai/core/command" import { Credential } from "@opencode-ai/core/credential" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -43,6 +44,7 @@ export const PluginTestLayer = AppNodeBuilder.build( AgentV2.node, AISDK.node, Catalog.node, + CodeModeV2.node, CommandV2.node, Integration.node, PluginRuntime.node, diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index 2aa8789548..0883151d37 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -39,6 +39,9 @@ export function host(overrides: Overrides = {}): PluginContext { transform: () => Effect.die("unused command.transform"), reload: () => Effect.die("unused command.reload"), }, + codemode: overrides.codemode ?? { + register: () => Effect.die("unused codemode.register"), + }, event: overrides.event ?? { subscribe: () => Stream.empty, }, diff --git a/packages/core/test/session-runner-tool-registry.test.ts b/packages/core/test/session-runner-tool-registry.test.ts index 3c03094b5e..051c370c73 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -1,8 +1,10 @@ import { describe, expect } from "bun:test" import { Tool } from "@opencode-ai/core/tool/tool" import { AgentV2 } from "@opencode-ai/core/agent" +import { CodeModeV2 } from "@opencode-ai/core/code-mode" import type { PermissionV2 } from "@opencode-ai/core/permission" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { SessionV2 } from "@opencode-ai/core/session" import { SessionMessage } from "@opencode-ai/core/session/message" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" @@ -28,7 +30,9 @@ const outputStore = Layer.mock(ToolOutputStore.Service, { ) }, }) -const registryLayer = AppNodeBuilder.build(ToolRegistry.node, [[ToolOutputStore.node, outputStore]]) +const registryLayer = AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, CodeModeV2.node]), [ + [ToolOutputStore.node, outputStore], +]) const it = testEffect(registryLayer) const identity = { agent: AgentV2.ID.make("build"), @@ -53,6 +57,30 @@ const make = (permission?: string) => { } describe("ToolRegistry", () => { + it.effect("materializes nested Code Mode sources as execute", () => + Effect.gen(function* () { + const codeMode = yield* CodeModeV2.Service + const service = yield* ToolRegistry.Service + yield* codeMode.register((draft) => draft.add(["opencode", "v2", "echo"], make())) + + const definitions = yield* toolDefinitions(service) + expect(definitions.map((tool) => tool.name)).toEqual(["execute"]) + expect(definitions[0]?.description).toContain("tools.opencode.v2.echo") + expect( + yield* executeTool(service, { + sessionID, + ...identity, + call: { + type: "tool-call", + id: "call-code-mode", + name: "execute", + input: { code: 'return await tools.opencode.v2.echo({ text: "hello" })' }, + }, + }), + ).toEqual({ type: "text", value: '{\n "text": "hello"\n}' }) + }), + ) + it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () => Effect.gen(function* () { const service = yield* ToolRegistry.Service diff --git a/packages/plugin/src/v2/effect/code-mode.ts b/packages/plugin/src/v2/effect/code-mode.ts new file mode 100644 index 0000000000..9cc9bb6216 --- /dev/null +++ b/packages/plugin/src/v2/effect/code-mode.ts @@ -0,0 +1,12 @@ +import type { Effect, Scope } from "effect" +import type { AnyTool, RegistrationError } from "./tool.js" + +export type Path = readonly [string, ...string[]] + +export interface Draft { + add(path: Path, tool: AnyTool): void +} + +export interface Domain { + readonly register: (source: (draft: Draft) => void) => Effect.Effect +} diff --git a/packages/plugin/src/v2/effect/context.ts b/packages/plugin/src/v2/effect/context.ts index 219dc16581..5d19c23d3f 100644 --- a/packages/plugin/src/v2/effect/context.ts +++ b/packages/plugin/src/v2/effect/context.ts @@ -3,6 +3,7 @@ import type { AgentHooks } from "./agent.js" import type { AISDKHooks } from "./aisdk.js" import type { CatalogHooks } from "./catalog.js" import type { CommandHooks } from "./command.js" +import type { Domain } from "./code-mode.js" import type { EventHooks } from "./event.js" import type { IntegrationHooks } from "./integration.js" import type { PluginDomain } from "./plugin.js" @@ -17,6 +18,7 @@ export interface PluginContext { readonly aisdk: AISDKHooks readonly catalog: CatalogHooks readonly command: CommandHooks + readonly codemode: Domain readonly event: EventHooks readonly integration: IntegrationHooks readonly plugin: PluginDomain diff --git a/packages/plugin/src/v2/effect/index.ts b/packages/plugin/src/v2/effect/index.ts index 1332ce2752..c90d2d4f78 100644 --- a/packages/plugin/src/v2/effect/index.ts +++ b/packages/plugin/src/v2/effect/index.ts @@ -5,6 +5,7 @@ export type { AgentDraft, AgentHooks } from "./agent.js" export type { AISDKHooks } from "./aisdk.js" export type { CatalogDraft, CatalogHooks, CatalogProviderRecord } from "./catalog.js" export type { CommandDraft, CommandHooks } from "./command.js" +export * as CodeMode from "./code-mode.js" export type { EventHooks } from "./event.js" export type { IntegrationDraft, IntegrationHooks, IntegrationMethodRegistration } from "./integration.js" export type { ReferenceDraft, ReferenceHooks } from "./reference.js" diff --git a/packages/plugin/src/v2/effect/tool.ts b/packages/plugin/src/v2/effect/tool.ts index bb5931af16..9d9e695945 100644 --- a/packages/plugin/src/v2/effect/tool.ts +++ b/packages/plugin/src/v2/effect/tool.ts @@ -246,7 +246,6 @@ export interface ToolExecuteAfterEvent { export interface RegisterOptions { readonly group?: string - readonly deferred?: boolean } export interface ToolDraft { diff --git a/packages/plugin/test/contract-identity.test.ts b/packages/plugin/test/contract-identity.test.ts index 00efbd2aff..84e9c4d488 100644 --- a/packages/plugin/test/contract-identity.test.ts +++ b/packages/plugin/test/contract-identity.test.ts @@ -27,6 +27,7 @@ test.each([ expect(entrypoint.Skill).toBe(Skill) expect(Object.keys(entrypoint).sort()).toEqual([ "Agent", + ...(name === "effect" ? ["CodeMode"] : []), "Command", "Connection", "Credential",