feat(plugin): support plugin-provided tools (#34619)

This commit is contained in:
Kit Langton 2026-06-30 13:18:56 -04:00 committed by GitHub
commit 194b0615e0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 1633 additions and 959 deletions

View file

@ -39,7 +39,7 @@ describe("AgentV2", () => {
Effect.gen(function* () {
const agent = yield* AgentV2.Service
expect(yield* agent.all()).toEqual([])
expect(yield* agent.list()).toEqual([])
expect(yield* agent.get(AgentV2.ID.make("build"))).toBeUndefined()
}),
)
@ -56,7 +56,7 @@ describe("AgentV2", () => {
)
expect(yield* agent.get(id)).toMatchObject({ id, description: "Reviews code", mode: "subagent" })
expect((yield* agent.all()).map((info) => info.id)).toEqual([id])
expect((yield* agent.list()).map((info) => info.id)).toEqual([id])
}),
)
@ -136,7 +136,7 @@ describe("AgentV2", () => {
),
)
const agents = yield* agent.all()
const agents = yield* agent.list()
expect(agents.map((item) => String(item.id)).sort()).toEqual([
"build",
"compaction",

View file

@ -1,288 +0,0 @@
import { describe, expect } from "bun:test"
import { Tool } from "@opencode-ai/core/tool/tool"
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { AgentV2 } from "@opencode-ai/core/agent"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { executeTool, settleTool, toolDefinitions } from "./lib/tool"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { Tools } from "@opencode-ai/core/tool/tools"
import { Deferred, Effect, Exit, Fiber, Layer, Schema, Scope } from "effect"
import { testEffect } from "./lib/effect"
const permission = Layer.mock(PermissionV2.Service, {
assert: () => Effect.void,
})
const applications = ApplicationTools.layer
const registry = ToolRegistry.layer.pipe(
Layer.provide(permission),
Layer.provide(applications),
Layer.provide(ToolOutputStore.defaultLayer),
)
const it = testEffect(Layer.mergeAll(applications, registry))
const sessionID = SessionV2.ID.make("ses_application_tool")
const agent = AgentV2.ID.make("build")
const assistantMessageID = SessionMessage.ID.make("msg_application_tool")
const contextual = (contexts: Tool.Context[]) =>
Tool.make({
description: "Read application context",
input: Schema.Struct({ query: Schema.String }),
output: Schema.Struct({ answer: Schema.String }),
execute: ({ query }, context) =>
Effect.sync(() => {
contexts.push(context)
return { answer: query.toUpperCase() }
}),
toModelOutput: ({ output }) => [
{ type: "text", text: output.answer },
{ type: "file", data: "aGVsbG8=", mime: "image/png", name: "result.png" },
],
})
describe("ApplicationTools", () => {
it.effect("keeps the Core carrier opaque and executes its single handler", () =>
Effect.gen(function* () {
const applications = yield* ApplicationTools.Service
const registry = yield* ToolRegistry.Service
const contexts: Tool.Context[] = []
const tool = contextual(contexts)
expect(Object.keys(tool)).toEqual([])
yield* applications.register({ opaque: tool })
expect(
yield* executeTool(registry, {
sessionID,
agent,
assistantMessageID,
call: { type: "tool-call", id: "call-opaque", name: "opaque", input: { query: "once" } },
}),
).toEqual({
type: "content",
value: [
{ type: "text", text: "ONCE" },
{ type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "result.png" },
],
})
expect(contexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-opaque" }])
}),
)
it.effect("exposes narrow scoped Location registration and sanitizes names", () =>
Effect.gen(function* () {
const tools: Tools.Interface = yield* Tools.Service
const registry = yield* ToolRegistry.Service
const scope = yield* Scope.make()
yield* tools.register({ "location.tool/search": contextual([]) }).pipe(Scope.provide(scope))
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["location_tool_search"])
yield* Scope.close(scope, Exit.void)
expect(yield* toolDefinitions(registry)).toEqual([])
}),
)
it.effect("filters an application tool by its name without adding execution authorization", () =>
Effect.gen(function* () {
const applications = yield* ApplicationTools.Service
const registry = yield* ToolRegistry.Service
const contexts: Tool.Context[] = []
yield* applications.register({ application_context: contextual(contexts) })
expect(
yield* toolDefinitions(registry, [{ action: "application_context", resource: "*", effect: "deny" }]),
).toEqual([])
expect(
yield* settleTool(registry, {
sessionID,
agent,
assistantMessageID,
call: { type: "tool-call", id: "call-denied", name: "application_context", input: { query: "hello" } },
}),
).toMatchObject({ result: { type: "content" } })
expect(contexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-denied" }])
}),
)
it.effect("advertises and executes a scoped application tool with Session context", () =>
Effect.gen(function* () {
const applications = yield* ApplicationTools.Service
const registry = yield* ToolRegistry.Service
const contexts: Tool.Context[] = []
yield* applications.register({ application_context: contextual(contexts) })
expect(yield* toolDefinitions(registry)).toMatchObject([
{ name: "application_context", description: "Read application context" },
])
expect(
yield* settleTool(registry, {
sessionID,
agent,
assistantMessageID,
call: { type: "tool-call", id: "call-context", name: "application_context", input: { query: "hello" } },
}),
).toEqual({
result: {
type: "content",
value: [
{ type: "text", text: "HELLO" },
{ type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "result.png" },
],
},
output: {
structured: { answer: "HELLO" },
content: [
{ type: "text", text: "HELLO" },
{ type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "result.png" },
],
},
})
expect(contexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-context" }])
}),
)
it.effect("removes an application tool when its registration scope closes", () =>
Effect.gen(function* () {
const applications = yield* ApplicationTools.Service
const registry = yield* ToolRegistry.Service
const scope = yield* Scope.make()
yield* applications.register({ temporary: contextual([]) }).pipe(Scope.provide(scope))
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["temporary"])
yield* Scope.close(scope, Exit.void)
expect(yield* toolDefinitions(registry)).toEqual([])
}),
)
it.effect("removes a tool before settling a call produced from an earlier definition", () =>
Effect.gen(function* () {
const applications = yield* ApplicationTools.Service
const registry = yield* ToolRegistry.Service
const registrationScope = yield* Scope.make()
yield* applications.register({ contextual: contextual([]) }).pipe(Scope.provide(registrationScope))
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["contextual"])
yield* Scope.close(registrationScope, Exit.void)
expect(
yield* settleTool(registry, {
sessionID,
agent,
assistantMessageID,
call: { type: "tool-call", id: "call-removed", name: "contextual", input: { query: "hello" } },
}),
).toEqual({ result: { type: "error", value: "Unknown tool: contextual" } })
}),
)
it.effect("does not leak a registration into an already closed scope", () =>
Effect.gen(function* () {
const applications = yield* ApplicationTools.Service
const registry = yield* ToolRegistry.Service
const scope = yield* Scope.make()
yield* Scope.close(scope, Exit.void)
yield* applications.register({ closed: contextual([]) }).pipe(Scope.provide(scope))
expect(yield* toolDefinitions(registry)).toEqual([])
}),
)
it.effect("preserves an interrupted application registration until its scope closes", () =>
Effect.gen(function* () {
const applications = yield* ApplicationTools.Service
const registry = yield* ToolRegistry.Service
const scope = yield* Scope.make()
const registered = yield* Deferred.make<void>()
const fiber = yield* applications
.register({ interrupted: contextual([]) })
.pipe(
Effect.andThen(Deferred.succeed(registered, undefined)),
Effect.andThen(Effect.never),
Scope.provide(scope),
Effect.forkChild,
)
yield* Deferred.await(registered)
yield* Fiber.interrupt(fiber)
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["interrupted"])
yield* Scope.close(scope, Exit.void)
expect(yield* toolDefinitions(registry)).toEqual([])
}),
)
it.effect("captures the registered record before later State rebuilds", () =>
Effect.gen(function* () {
const applications = yield* ApplicationTools.Service
const registry = yield* ToolRegistry.Service
const registered = { stable: contextual([]) }
yield* applications.register(registered)
Object.assign(registered, { late: contextual([]) })
yield* Effect.scoped(applications.register({ temporary: contextual([]) }))
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["stable"])
}),
)
it.effect("settles with the current same-name application tool and restores earlier registrations", () =>
Effect.gen(function* () {
const applications = yield* ApplicationTools.Service
const registry = yield* ToolRegistry.Service
const firstContexts: Tool.Context[] = []
const secondContexts: Tool.Context[] = []
const scope = yield* Scope.make()
yield* applications.register({ contextual: contextual(firstContexts) })
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["contextual"])
yield* applications.register({ contextual: contextual(secondContexts) }).pipe(Scope.provide(scope))
yield* settleTool(registry, {
sessionID,
agent,
assistantMessageID,
call: { type: "tool-call", id: "call-second", name: "contextual", input: { query: "second" } },
})
yield* Scope.close(scope, Exit.void)
yield* settleTool(registry, {
sessionID,
agent,
assistantMessageID,
call: { type: "tool-call", id: "call-first", name: "contextual", input: { query: "first" } },
})
expect(secondContexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-second" }])
expect(firstContexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-first" }])
}),
)
it.effect("keeps the Location tool when an application tool has the same name", () =>
Effect.gen(function* () {
const applications = yield* ApplicationTools.Service
const registry = yield* ToolRegistry.Service
const locationContexts: Tool.Context[] = []
const applicationContexts: Tool.Context[] = []
const location = contextual(locationContexts)
yield* registry.register({ shared: location })
yield* applications.register({ shared: contextual(applicationContexts) })
expect(
(yield* toolDefinitions(registry, [{ action: "shared", resource: "*", effect: "deny" }])).map(
(definition) => definition.name,
),
).toEqual([])
expect(
yield* settleTool(registry, {
sessionID,
agent,
assistantMessageID,
call: { type: "tool-call", id: "call-shared", name: "shared", input: { query: "location" } },
}),
).toMatchObject({ result: { type: "content" } })
expect(locationContexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-shared" }])
expect(applicationContexts).toEqual([])
}),
)
})

View file

@ -17,6 +17,10 @@ const it = testEffect(
Layer.mergeAll(AgentV2.locationLayer.pipe(Layer.provideMerge(EventV2.defaultLayer)), FSUtil.defaultLayer),
)
const decode = Schema.decodeUnknownSync(Config.Info)
const defaultPermissions = [
{ action: "*", resource: "*", effect: "allow" },
{ action: "external_directory", resource: "*", effect: "ask" },
] satisfies PermissionV2.Ruleset
describe("ConfigAgentPlugin.Plugin", () => {
it.effect("applies all global permissions before agent-specific permissions", () =>
@ -77,8 +81,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
const buildAgent = yield* agents.get(build)
if (!buildAgent) throw new Error("expected configured build agent")
expect(buildAgent.permissions).toEqual([
{ action: "*", resource: "*", effect: "allow" },
{ action: "external_directory", resource: "*", effect: "ask" },
...defaultPermissions,
{ action: "bash", resource: "*", effect: "allow" },
{ action: "bash", resource: "*", effect: "ask" },
{ action: "read", resource: "*", effect: "allow" },
@ -96,8 +99,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
model: { providerID: "openrouter", id: "openai/gpt-5", variant: "high" },
})
expect(reviewer.permissions).toEqual([
{ action: "*", resource: "*", effect: "allow" },
{ action: "external_directory", resource: "*", effect: "ask" },
...defaultPermissions,
{ action: "bash", resource: "*", effect: "ask" },
{ action: "read", resource: "*", effect: "allow" },
{ action: "edit", resource: "*", effect: "deny" },
@ -105,8 +107,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
])
expect(PermissionV2.evaluate("read", "README.md", reviewer.permissions).effect).toBe("deny")
expect((yield* agents.get(AgentV2.ID.make("late")))?.permissions).toEqual([
{ action: "*", resource: "*", effect: "allow" },
{ action: "external_directory", resource: "*", effect: "ask" },
...defaultPermissions,
{ action: "bash", resource: "*", effect: "ask" },
{ action: "read", resource: "*", effect: "allow" },
{ action: "edit", resource: "*", effect: "allow" },
@ -264,21 +265,13 @@ Use native v2 fields.`,
system: "Review carefully.",
description: "Markdown description",
request: { body: { temperature: 0.5 } },
permissions: [
{ action: "*", resource: "*", effect: "allow" },
{ action: "external_directory", resource: "*", effect: "ask" },
{ action: "edit", resource: "*", effect: "deny" },
],
permissions: [...defaultPermissions, { action: "edit", resource: "*", effect: "deny" }],
})
expect(yield* agents.get(AgentV2.ID.make("team/helper"))).toMatchObject({ system: "Help the team." })
expect(yield* agents.get(AgentV2.ID.make("native"))).toMatchObject({
system: "Use native v2 fields.",
request: { headers: { "x-agent": "native" }, body: { effort: "high" } },
permissions: [
{ action: "*", resource: "*", effect: "allow" },
{ action: "external_directory", resource: "*", effect: "ask" },
{ action: "edit", resource: "*", effect: "deny" },
],
permissions: [...defaultPermissions, { action: "edit", resource: "*", effect: "deny" }],
})
expect(yield* agents.get(AgentV2.ID.make("disabled"))).toBeUndefined()
expect(yield* agents.get(AgentV2.ID.make("plan"))).toMatchObject({ system: "Make a plan.", mode: "primary" })

View file

@ -15,7 +15,7 @@ class OtherError {
const tags = LayerNode.tags({ app: [] })
const make = tags.make("app")
const build = <A, E>(root: LayerNode.Node<A, E, any>) => LayerNode.compile(root) as Layer.Layer<A, E>
const build = <A, E>(root: LayerNode.Node<A, E, any>) => LayerNode.compile(root)
const aLayer = Layer.succeed(A, A.of({}))
const bLayer = Layer.effect(B, Effect.as(A, B.of({})))
const cLayer = Layer.effect(
@ -32,8 +32,6 @@ const b = make({ service: B, layer: bLayer, deps: [a] })
const c = make({ service: C, layer: cLayer, deps: [a, b] })
const failing = make({ service: A, layer: failingA, deps: [] })
const dependent = make({ service: B, layer: bLayer, deps: [failing] })
const inputA = LayerNode.unbound(A, tags.values.app)
const inputDependent = make({ service: B, layer: bLayer, deps: [inputA] })
make({ name: "manual-a", layer: aLayer, deps: [] })
@ -51,8 +49,8 @@ make({ service: C, layer: cLayer, deps: [a] })
const closed = build(LayerNode.group([c]))
const closedWithError = build(LayerNode.group([dependent]))
const checkClosed: Layer.Layer<C, never, never> = closed
const checkError: Layer.Layer<B, LayerError, never> = closedWithError
const checkClosed: Layer.Layer<C> = closed
const checkError: Layer.Layer<B, LayerError> = closedWithError
void checkClosed
void checkError
@ -64,7 +62,6 @@ LayerNode.replace(aLayer, Layer.succeed(B, B.of({})))
// @ts-expect-error Replacement cannot introduce a new error
LayerNode.replace(aLayer, Layer.effect(A, Effect.fail(new OtherError())))
// @ts-expect-error Replacement must be closed
LayerNode.replace(bLayer, bLayer)
class TagA extends Context.Service<TagA, {}>()("test/TagA") {}

View file

@ -2,8 +2,8 @@ import fs from "fs/promises"
import { tmpdir as osTmpdir } from "os"
import path from "path"
export const tmpdir = async () => {
const dir = await fs.realpath(await fs.mkdtemp(path.join(osTmpdir(), "opencode-core-test-")))
export const tmpdir = async (prefix = "opencode-core-test-") => {
const dir = await fs.realpath(await fs.mkdtemp(path.join(osTmpdir(), prefix)))
return {
path: dir,
async [Symbol.asyncDispose]() {

View file

@ -18,6 +18,22 @@ export const toolDefinitions = (
model = testModel,
) => registry.materialize({ permissions, model }).pipe(Effect.map((materialized) => materialized.definitions))
export function waitForTool(
registry: ToolRegistry.Interface,
name: string,
remaining = 1000,
): Effect.Effect<void, Error> {
return Effect.gen(function* () {
if ((yield* toolDefinitions(registry)).some((tool) => tool.name === name)) return
if (remaining === 0) {
yield* Effect.fail(new Error(`Timed out waiting for tool: ${name}`))
return
}
yield* Effect.promise(() => Bun.sleep(1))
yield* waitForTool(registry, name, remaining - 1)
})
}
export const settleTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput, model = testModel) =>
registry.materialize({ model }).pipe(Effect.flatMap((materialized) => materialized.settle(input)))

View file

@ -2,7 +2,6 @@ import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { DateTime, Effect, Equal, Hash, Layer, Schema } from "effect"
import { Tool } from "@opencode-ai/core/tool/tool"
import { define } from "@opencode-ai/plugin/v2/effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
@ -17,7 +16,7 @@ import { SessionV2 } from "@opencode-ai/core/session"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { toolDefinitions } from "./lib/tool"
import { toolDefinitions, waitForTool } from "./lib/tool"
import { FSUtil } from "../src/fs-util"
import { Credential } from "../src/credential"
import { Database } from "../src/database/database"
@ -28,14 +27,11 @@ import { Npm } from "../src/npm"
import { Project } from "../src/project"
import { Reference } from "../src/reference"
import { ToolRegistry } from "../src/tool/registry"
import { ApplicationTools } from "../src/tool/application-tools"
const applicationTools = ApplicationTools.layer
const it = testEffect(
Layer.merge(
Layer.mergeAll(applicationTools, Database.defaultLayer, EventV2.defaultLayer),
Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer),
locationServiceMapLayer.pipe(
Layer.provide(applicationTools),
Layer.provide(
Layer.mergeAll(
Project.defaultLayer,
@ -83,14 +79,6 @@ describe("LocationServiceMap", () => {
).pipe(
Effect.flatMap(([blocked, allowed]) =>
Effect.gen(function* () {
yield* (yield* ApplicationTools.Service).register({
application_context: Tool.make({
description: "Read application context",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.succeed({ ok: true }),
}),
})
yield* Effect.promise(() =>
fs.writeFile(
path.join(blocked.path, "opencode.json"),
@ -105,9 +93,12 @@ describe("LocationServiceMap", () => {
yield* Reference.Service
const catalog = yield* Catalog.Service
yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
const registry = yield* ToolRegistry.Service
yield* waitForTool(registry, "shell")
yield* waitForTool(registry, "subagent")
return {
providers: yield* catalog.provider.all(),
tools: yield* toolDefinitions(yield* ToolRegistry.Service),
tools: yield* toolDefinitions(registry),
}
}).pipe(
Effect.scoped,
@ -119,13 +110,14 @@ describe("LocationServiceMap", () => {
const blockedState = yield* update(blocked.path)
expect(blockedState.providers.some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(false)
expect(blockedState.tools.map((tool) => tool.name).sort()).toEqual([
"application_context",
"edit",
"glob",
"grep",
"question",
"read",
"shell",
"skill",
"subagent",
"todowrite",
"webfetch",
"websearch",
@ -134,13 +126,14 @@ describe("LocationServiceMap", () => {
const allowedState = yield* update(allowed.path)
expect(allowedState.providers.some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(true)
expect(allowedState.tools.map((tool) => tool.name).sort()).toEqual([
"application_context",
"edit",
"glob",
"grep",
"question",
"read",
"shell",
"skill",
"subagent",
"todowrite",
"webfetch",
"websearch",

View file

@ -1,9 +1,12 @@
import { describe, expect } from "bun:test"
import { Effect, Exit, Fiber } from "effect"
import { Effect, Exit, Fiber, Schema } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Tool } from "@opencode-ai/core/tool/tool"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { testEffect } from "./lib/effect"
import { testModel } from "./lib/tool"
import { PluginTestLayer } from "./plugin/fixture"
const it = testEffect(PluginTestLayer)
@ -68,4 +71,35 @@ describe("PluginV2", () => {
expect(yield* agents.get(AgentV2.ID.make("configured"))).toBeUndefined()
}),
)
it.effect("registers location tools through the plugin context", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const registry = yield* ToolRegistry.Service
const plugin = define({
id: "tool-plugin",
effect: (ctx) =>
ctx.tool
.register({
plugin_tool: Tool.make({
description: "Plugin tool",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.succeed({ ok: true }),
}),
})
.pipe(Effect.orDie),
})
yield* plugins.add(PluginV2.ID.make(plugin.id), plugin.effect)
expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).toContain(
"plugin_tool",
)
yield* plugins.remove(PluginV2.ID.make(plugin.id))
expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).not.toContain(
"plugin_tool",
)
}),
)
})

View file

@ -14,6 +14,7 @@ export function host(overrides: Overrides = {}): PluginContext {
return {
options: {},
agent: overrides.agent ?? {
list: () => Effect.die("unused agent.list"),
transform: () => Effect.die("unused agent.transform"),
reload: () => Effect.die("unused agent.reload"),
},
@ -49,11 +50,21 @@ export function host(overrides: Overrides = {}): PluginContext {
transform: () => Effect.die("unused skill.transform"),
reload: () => Effect.die("unused skill.reload"),
},
tool: overrides.tool ?? {
register: () => Effect.die("unused tool.register"),
},
session: overrides.session ?? {
create: () => Effect.die("unused session.create"),
get: () => Effect.die("unused session.get"),
prompt: () => Effect.die("unused session.prompt"),
interrupt: () => Effect.die("unused session.interrupt"),
},
}
}
export function agentHost(agent: AgentV2.Interface): PluginContext["agent"] {
return {
list: () => Effect.die("unused agent.list"),
reload: agent.reload,
transform: (callback) =>
agent.transform((draft) =>

View file

@ -2,7 +2,6 @@ import { describe, expect } from "bun:test"
import { Tool } from "@opencode-ai/core/tool/tool"
import { AgentV2 } from "@opencode-ai/core/agent"
import type { PermissionV2 } from "@opencode-ai/core/permission"
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
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,9 +27,8 @@ const outputStore = Layer.mock(ToolOutputStore.Service, {
)
},
})
const registry = ToolRegistry.layer.pipe(Layer.provide(ApplicationTools.layer), Layer.provide(outputStore))
const registry = ToolRegistry.layer.pipe(Layer.provide(outputStore))
const it = testEffect(registry)
const integrated = testEffect(Layer.mergeAll(ApplicationTools.layer, registry))
const identity = {
agent: AgentV2.ID.make("build"),
assistantMessageID: SessionMessage.ID.make("msg_registry"),
@ -399,38 +397,6 @@ describe("ToolRegistry", () => {
}),
)
integrated.effect("rejects an application call after a Location override is registered", () =>
Effect.gen(function* () {
const applications = yield* ApplicationTools.Service
const service = yield* ToolRegistry.Service
yield* applications.register({ echo: make() })
const materialized = yield* service.materialize({ model: testModel })
yield* service.register({ echo: make() })
expect((yield* materialized.settle(call("echo"))).result).toEqual({
type: "error",
value: "Stale tool call: echo",
})
}),
)
integrated.effect("rejects a Location call after removal reveals an application registration", () =>
Effect.gen(function* () {
const applications = yield* ApplicationTools.Service
const service = yield* ToolRegistry.Service
yield* applications.register({ echo: make() })
const scope = yield* Scope.make()
yield* service.register({ echo: make() }).pipe(Scope.provide(scope))
const materialized = yield* service.materialize({ model: testModel })
yield* Scope.close(scope, Exit.void)
expect((yield* materialized.settle(call("echo"))).result).toEqual({
type: "error",
value: "Stale tool call: echo",
})
}),
)
it.effect("keeps captured execution running after registration mutation", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service

View file

@ -36,7 +36,6 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionRunnerSystemPrompt } from "@opencode-ai/core/session/runner/system-prompt"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Config } from "@opencode-ai/core/config"
import { ConfigCompaction } from "@opencode-ai/core/config/compaction"
@ -124,12 +123,7 @@ const permission = Layer.succeed(
list: () => Effect.die("unused"),
}),
)
const applications = ApplicationTools.layer
const registry = ToolRegistry.layer.pipe(
Layer.provide(permission),
Layer.provide(applications),
Layer.provide(ToolOutputStore.defaultLayer),
)
const registry = ToolRegistry.layer.pipe(Layer.provide(permission), Layer.provide(ToolOutputStore.defaultLayer))
const agents = AgentV2.layer.pipe(Layer.provide(EventV2.defaultLayer))
const echo = Layer.effectDiscard(
ToolRegistry.Service.use((registry) =>
@ -286,7 +280,6 @@ const it = testEffect(
SessionStore.defaultLayer,
client,
permission,
applications,
agents,
registry,
echo,
@ -582,14 +575,14 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) =>
})
describe("SessionRunnerLLM", () => {
it.effect("advertises and executes a globally attached application tool", () =>
it.effect("advertises and executes a location registered tool", () =>
Effect.gen(function* () {
yield* setup
const applicationTools = yield* ApplicationTools.Service
const registry = yield* ToolRegistry.Service
const session = yield* SessionV2.Service
const contexts: Tool.Context[] = []
yield* applicationTools.register({
application_context: Tool.make({
yield* registry.register({
location_context: Tool.make({
description: "Read application context",
input: Schema.Struct({ query: Schema.String }),
output: Schema.Struct({ answer: Schema.String }),
@ -604,7 +597,7 @@ describe("SessionRunnerLLM", () => {
responses = [
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-application", name: "application_context", input: { query: "hello" } }),
LLMEvent.toolCall({ id: "call-location", name: "location_context", input: { query: "hello" } }),
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
],
@ -613,13 +606,13 @@ describe("SessionRunnerLLM", () => {
yield* session.resume(sessionID)
expect(requests[0]?.tools.map((tool) => tool.name)).toContain("application_context")
expect(requests[0]?.tools.map((tool) => tool.name)).toContain("location_context")
expect(contexts).toEqual([
{
sessionID,
agent: AgentV2.ID.make("build"),
assistantMessageID: expect.stringMatching(/^msg_/),
toolCallID: "call-application",
toolCallID: "call-location",
},
])
expect(yield* session.context(sessionID)).toMatchObject([
@ -629,7 +622,7 @@ describe("SessionRunnerLLM", () => {
content: [
{
type: "tool",
id: "call-application",
id: "call-location",
state: { status: "completed", structured: { answer: "HELLO" } },
},
],
@ -914,10 +907,7 @@ describe("SessionRunnerLLM", () => {
response = fragmentFixture("text", "text-no-system", ["Done"]).completeEvents
yield* session.resume(sessionID)
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([
"Build agent instructions",
"Initial context",
])
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Build agent instructions", "Initial context"])
}),
)

View file

@ -24,12 +24,13 @@ import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionStore } from "@opencode-ai/core/session/store"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { ShellTool } from "@opencode-ai/core/tool/shell"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
import { toolIdentity, executeTool, settleTool, toolDefinitions, waitForTool } from "./lib/tool"
const sessionID = SessionV2.ID.make("ses_shell_tool_test")
const sessionModel = ModelV2.Ref.make({ id: ModelV2.ID.make("test"), providerID: ProviderV2.ID.make("test") })
@ -122,7 +123,7 @@ const layer = AppNodeBuilder.build(
Job.node,
ToolOutputStore.cleanupNode,
SessionV2.node,
ShellTool.node,
PluginRuntime.providerNode,
LocationServiceMap.node,
filesystem,
FSUtil.node,
@ -167,6 +168,7 @@ const withSession = <A, E, R>(directory: string, body: (registry: ToolRegistry.I
const locations = yield* LocationServiceMap.Service
const locationLayer = locations.get(location)
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locationLayer))
yield* waitForTool(registry, ShellTool.name)
return yield* body(registry).pipe(Effect.provide(locationLayer))
})

View file

@ -18,12 +18,13 @@ import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionStore } from "@opencode-ai/core/session/store"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { SubagentTool } from "@opencode-ai/core/tool/subagent"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { executeTool, settleTool, testModel, toolIdentity } from "./lib/tool"
import { executeTool, settleTool, testModel, toolIdentity, waitForTool } from "./lib/tool"
const childText = "child final response"
const childModel = ModelV2.Ref.make({ id: ModelV2.ID.make("child"), providerID: ProviderV2.ID.make("test") })
@ -98,7 +99,7 @@ const layer = AppNodeBuilder.build(
Job.node,
ToolOutputStore.cleanupNode,
SessionV2.node,
SubagentTool.node,
PluginRuntime.providerNode,
LocationServiceMap.node,
]),
SessionExecution.node,
@ -142,6 +143,7 @@ describe("SubagentTool", () => {
const locations = yield* LocationServiceMap.Service
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location)))
yield* waitForTool(registry, SubagentTool.name)
expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).toContain(
SubagentTool.name,
)
@ -175,6 +177,7 @@ describe("SubagentTool", () => {
yield* withSubagent(parent.location)
const locations = yield* LocationServiceMap.Service
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location)))
yield* waitForTool(registry, SubagentTool.name)
const settled = yield* settleTool(registry, {
sessionID: parent.id,
@ -226,6 +229,7 @@ describe("SubagentTool", () => {
yield* withSubagent(parent.location)
const locations = yield* LocationServiceMap.Service
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location)))
yield* waitForTool(registry, SubagentTool.name)
expect(
yield* executeTool(registry, {
@ -257,6 +261,7 @@ describe("SubagentTool", () => {
yield* withSubagent(parent.location)
const locations = yield* LocationServiceMap.Service
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location)))
yield* waitForTool(registry, SubagentTool.name)
const settled = yield* settleTool(registry, {
sessionID: parent.id,