diff --git a/packages/opencode/script/seed-e2e.ts b/packages/opencode/script/seed-e2e.ts index 6d414ec7fb..ec15bbe813 100644 --- a/packages/opencode/script/seed-e2e.ts +++ b/packages/opencode/script/seed-e2e.ts @@ -18,6 +18,7 @@ const seed = async () => { const { Project } = await import("../src/project/project") const { ModelID, ProviderID } = await import("../src/provider/schema") const { ToolRegistry } = await import("../src/tool/registry") + const { Effect } = await import("effect") try { await Instance.provide({ @@ -25,7 +26,12 @@ const seed = async () => { init: () => AppRuntime.runPromise(InstanceBootstrap), fn: async () => { await Config.waitForDependencies() - await ToolRegistry.ids() + await AppRuntime.runPromise( + Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + yield* registry.ids() + }), + ) const session = await Session.create({ title }) const messageID = MessageID.ascending() diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts index 32d10d5d71..25a32d23b3 100644 --- a/packages/opencode/src/cli/cmd/debug/agent.ts +++ b/packages/opencode/src/cli/cmd/debug/agent.ts @@ -12,6 +12,7 @@ import { Permission } from "../../../permission" import { iife } from "../../../util/iife" import { bootstrap } from "../../bootstrap" import { cmd } from "../cmd" +import { AppRuntime } from "@/effect/app-runtime" export const AgentCommand = cmd({ command: "agent ", @@ -71,11 +72,17 @@ export const AgentCommand = cmd({ }) async function getAvailableTools(agent: Agent.Info) { - const model = agent.model ?? (await Provider.defaultModel()) - return ToolRegistry.tools({ - ...model, - agent, - }) + return AppRuntime.runPromise( + Effect.gen(function* () { + const provider = yield* Provider.Service + const registry = yield* ToolRegistry.Service + const model = agent.model ?? (yield* provider.defaultModel()) + return yield* registry.tools({ + ...model, + agent, + }) + }), + ) } async function resolveTools(agent: Agent.Info, availableTools: Awaited>) { diff --git a/packages/opencode/src/server/instance/experimental.ts b/packages/opencode/src/server/instance/experimental.ts index 464617c69b..978aa03a99 100644 --- a/packages/opencode/src/server/instance/experimental.ts +++ b/packages/opencode/src/server/instance/experimental.ts @@ -162,7 +162,13 @@ export const ExperimentalRoutes = lazy(() => }, }), async (c) => { - return c.json(await ToolRegistry.ids()) + const ids = await AppRuntime.runPromise( + Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + return yield* registry.ids() + }), + ) + return c.json(ids) }, ) .get( @@ -205,11 +211,17 @@ export const ExperimentalRoutes = lazy(() => ), async (c) => { const { provider, model } = c.req.valid("query") - const tools = await ToolRegistry.tools({ - providerID: ProviderID.make(provider), - modelID: ModelID.make(model), - agent: await Agent.get(await Agent.defaultAgent()), - }) + const tools = await AppRuntime.runPromise( + Effect.gen(function* () { + const agents = yield* Agent.Service + const registry = yield* ToolRegistry.Service + return yield* registry.tools({ + providerID: ProviderID.make(provider), + modelID: ModelID.make(model), + agent: yield* agents.get(yield* agents.defaultAgent()), + }) + }), + ) return c.json( tools.map((t) => ({ id: t.id, diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index afb19a468c..3ed9e4b185 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -36,7 +36,6 @@ import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" import { Ripgrep } from "../file/ripgrep" import { Format } from "../format" import { InstanceState } from "@/effect/instance-state" -import { makeRuntime } from "@/effect/run-service" import { Env } from "../env" import { Question } from "../question" import { Todo } from "../session/todo" @@ -344,18 +343,4 @@ export namespace ToolRegistry { Layer.provide(Truncate.defaultLayer), ), ) - - const { runPromise } = makeRuntime(Service, defaultLayer) - - export async function ids() { - return runPromise((svc) => svc.ids()) - } - - export async function tools(input: { - providerID: ProviderID - modelID: ModelID - agent: Agent.Info - }): Promise<(Tool.Def & { id: string })[]> { - return runPromise((svc) => svc.tools(input)) - } } diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index e3a274bb21..5b59e314e1 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -1,157 +1,154 @@ -import { afterEach, describe, expect, test } from "bun:test" +import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node" +import { afterEach, describe, expect } from "bun:test" import path from "path" import fs from "fs/promises" -import { tmpdir } from "../fixture/fixture" +import { Effect, Layer } from "effect" import { Instance } from "../../src/project/instance" import { ToolRegistry } from "../../src/tool/registry" +import { provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const node = NodeChildProcessSpawner.layer.pipe( + Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)), +) + +const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node)) afterEach(async () => { await Instance.disposeAll() }) describe("tool.registry", () => { - test("loads tools from .opencode/tool (singular)", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - const opencodeDir = path.join(dir, ".opencode") - await fs.mkdir(opencodeDir, { recursive: true }) - - const toolDir = path.join(opencodeDir, "tool") - await fs.mkdir(toolDir, { recursive: true }) - - await Bun.write( - path.join(toolDir, "hello.ts"), - [ - "export default {", - " description: 'hello tool',", - " args: {},", - " execute: async () => {", - " return 'hello world'", - " },", - "}", - "", - ].join("\n"), + it.live("loads tools from .opencode/tool (singular)", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const opencode = path.join(dir, ".opencode") + const tool = path.join(opencode, "tool") + yield* Effect.promise(() => fs.mkdir(tool, { recursive: true })) + yield* Effect.promise(() => + Bun.write( + path.join(tool, "hello.ts"), + [ + "export default {", + " description: 'hello tool',", + " args: {},", + " execute: async () => {", + " return 'hello world'", + " },", + "}", + "", + ].join("\n"), + ), ) - }, - }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const ids = await ToolRegistry.ids() + const registry = yield* ToolRegistry.Service + const ids = yield* registry.ids() expect(ids).toContain("hello") - }, - }) - }) + }), + ), + ) - test("loads tools from .opencode/tools (plural)", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - const opencodeDir = path.join(dir, ".opencode") - await fs.mkdir(opencodeDir, { recursive: true }) - - const toolsDir = path.join(opencodeDir, "tools") - await fs.mkdir(toolsDir, { recursive: true }) - - await Bun.write( - path.join(toolsDir, "hello.ts"), - [ - "export default {", - " description: 'hello tool',", - " args: {},", - " execute: async () => {", - " return 'hello world'", - " },", - "}", - "", - ].join("\n"), + it.live("loads tools from .opencode/tools (plural)", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const opencode = path.join(dir, ".opencode") + const tools = path.join(opencode, "tools") + yield* Effect.promise(() => fs.mkdir(tools, { recursive: true })) + yield* Effect.promise(() => + Bun.write( + path.join(tools, "hello.ts"), + [ + "export default {", + " description: 'hello tool',", + " args: {},", + " execute: async () => {", + " return 'hello world'", + " },", + "}", + "", + ].join("\n"), + ), ) - }, - }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const ids = await ToolRegistry.ids() + const registry = yield* ToolRegistry.Service + const ids = yield* registry.ids() expect(ids).toContain("hello") - }, - }) - }) + }), + ), + ) - test("loads tools with external dependencies without crashing", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - const opencodeDir = path.join(dir, ".opencode") - await fs.mkdir(opencodeDir, { recursive: true }) - - const toolsDir = path.join(opencodeDir, "tools") - await fs.mkdir(toolsDir, { recursive: true }) - - await Bun.write( - path.join(opencodeDir, "package.json"), - JSON.stringify({ - name: "custom-tools", - dependencies: { - "@opencode-ai/plugin": "^0.0.0", - cowsay: "^1.6.0", - }, - }), + it.live("loads tools with external dependencies without crashing", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const opencode = path.join(dir, ".opencode") + const tools = path.join(opencode, "tools") + yield* Effect.promise(() => fs.mkdir(tools, { recursive: true })) + yield* Effect.promise(() => + Bun.write( + path.join(opencode, "package.json"), + JSON.stringify({ + name: "custom-tools", + dependencies: { + "@opencode-ai/plugin": "^0.0.0", + cowsay: "^1.6.0", + }, + }), + ), ) - - await Bun.write( - path.join(opencodeDir, "package-lock.json"), - JSON.stringify({ - name: "custom-tools", - lockfileVersion: 3, - packages: { - "": { - dependencies: { - "@opencode-ai/plugin": "^0.0.0", - cowsay: "^1.6.0", + yield* Effect.promise(() => + Bun.write( + path.join(opencode, "package-lock.json"), + JSON.stringify({ + name: "custom-tools", + lockfileVersion: 3, + packages: { + "": { + dependencies: { + "@opencode-ai/plugin": "^0.0.0", + cowsay: "^1.6.0", + }, }, }, - }, - }), + }), + ), ) - const cowsayDir = path.join(opencodeDir, "node_modules", "cowsay") - await fs.mkdir(cowsayDir, { recursive: true }) - await Bun.write( - path.join(cowsayDir, "package.json"), - JSON.stringify({ - name: "cowsay", - type: "module", - exports: "./index.js", - }), + const cowsay = path.join(opencode, "node_modules", "cowsay") + yield* Effect.promise(() => fs.mkdir(cowsay, { recursive: true })) + yield* Effect.promise(() => + Bun.write( + path.join(cowsay, "package.json"), + JSON.stringify({ + name: "cowsay", + type: "module", + exports: "./index.js", + }), + ), ) - await Bun.write( - path.join(cowsayDir, "index.js"), - ["export function say({ text }) {", " return `moo ${text}`", "}", ""].join("\n"), + yield* Effect.promise(() => + Bun.write( + path.join(cowsay, "index.js"), + ["export function say({ text }) {", " return `moo ${text}`", "}", ""].join("\n"), + ), ) - - await Bun.write( - path.join(toolsDir, "cowsay.ts"), - [ - "import { say } from 'cowsay'", - "export default {", - " description: 'tool that imports cowsay at top level',", - " args: { text: { type: 'string' } },", - " execute: async ({ text }: { text: string }) => {", - " return say({ text })", - " },", - "}", - "", - ].join("\n"), + yield* Effect.promise(() => + Bun.write( + path.join(tools, "cowsay.ts"), + [ + "import { say } from 'cowsay'", + "export default {", + " description: 'tool that imports cowsay at top level',", + " args: { text: { type: 'string' } },", + " execute: async ({ text }: { text: string }) => {", + " return say({ text })", + " },", + "}", + "", + ].join("\n"), + ), ) - }, - }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const ids = await ToolRegistry.ids() + const registry = yield* ToolRegistry.Service + const ids = yield* registry.ids() expect(ids).toContain("cowsay") - }, - }) - }) + }), + ), + ) }) diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index a3873dbebe..1cebf342db 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -1,3 +1,4 @@ +import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node" import { Effect, Layer, ManagedRuntime } from "effect" import { Agent } from "../../src/agent/agent" import { Skill } from "../../src/skill" @@ -11,8 +12,9 @@ import type { Tool } from "../../src/tool/tool" import { Instance } from "../../src/project/instance" import { SkillTool } from "../../src/tool/skill" import { ToolRegistry } from "../../src/tool/registry" -import { tmpdir } from "../fixture/fixture" +import { provideTmpdirInstance, tmpdir } from "../fixture/fixture" import { SessionID, MessageID } from "../../src/session/schema" +import { testEffect } from "../lib/effect" const baseCtx: Omit = { sessionID: SessionID.make("ses_test"), @@ -28,85 +30,94 @@ afterEach(async () => { await Instance.disposeAll() }) +const node = NodeChildProcessSpawner.layer.pipe( + Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)), +) + +const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node)) + describe("tool.skill", () => { - test("description lists skill location URL", async () => { - await using tmp = await tmpdir({ - git: true, - init: async (dir) => { - const skillDir = path.join(dir, ".opencode", "skill", "tool-skill") - await Bun.write( - path.join(skillDir, "SKILL.md"), - `--- + it.live("description lists skill location URL", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const skill = path.join(dir, ".opencode", "skill", "tool-skill") + yield* Effect.promise(() => + Bun.write( + path.join(skill, "SKILL.md"), + `--- name: tool-skill description: Skill for tool tests. --- # Tool Skill `, - ) - }, - }) + ), + ) + const home = process.env.OPENCODE_TEST_HOME + process.env.OPENCODE_TEST_HOME = dir + yield* Effect.addFinalizer(() => + Effect.sync(() => { + process.env.OPENCODE_TEST_HOME = home + }), + ) + const registry = yield* ToolRegistry.Service + const desc = + (yield* registry.tools({ + providerID: "opencode" as any, + modelID: "gpt-5" as any, + agent: { name: "build", mode: "primary", permission: [], options: {} }, + })).find((tool) => tool.id === SkillTool.id)?.description ?? "" + expect(desc).toContain("**tool-skill**: Skill for tool tests.") + }), + { git: true }, + ), + ) - const home = process.env.OPENCODE_TEST_HOME - process.env.OPENCODE_TEST_HOME = tmp.path - - try { - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const desc = await ToolRegistry.tools({ - providerID: "opencode" as any, - modelID: "gpt-5" as any, - agent: { name: "build", mode: "primary" as const, permission: [], options: {} }, - }).then((tools) => tools.find((tool) => tool.id === SkillTool.id)?.description ?? "") - expect(desc).toContain(`**tool-skill**: Skill for tool tests.`) - }, - }) - } finally { - process.env.OPENCODE_TEST_HOME = home - } - }) - - test("description sorts skills by name and is stable across calls", async () => { - await using tmp = await tmpdir({ - git: true, - init: async (dir) => { - for (const [name, description] of [ - ["zeta-skill", "Zeta skill."], - ["alpha-skill", "Alpha skill."], - ["middle-skill", "Middle skill."], - ]) { - const skillDir = path.join(dir, ".opencode", "skill", name) - await Bun.write( - path.join(skillDir, "SKILL.md"), - `--- + it.live("description sorts skills by name and is stable across calls", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + for (const [name, description] of [ + ["zeta-skill", "Zeta skill."], + ["alpha-skill", "Alpha skill."], + ["middle-skill", "Middle skill."], + ]) { + const skill = path.join(dir, ".opencode", "skill", name) + yield* Effect.promise(() => + Bun.write( + path.join(skill, "SKILL.md"), + `--- name: ${name} description: ${description} --- # ${name} `, + ), + ) + } + const home = process.env.OPENCODE_TEST_HOME + process.env.OPENCODE_TEST_HOME = dir + yield* Effect.addFinalizer(() => + Effect.sync(() => { + process.env.OPENCODE_TEST_HOME = home + }), ) - } - }, - }) - const home = process.env.OPENCODE_TEST_HOME - process.env.OPENCODE_TEST_HOME = tmp.path - - try { - await Instance.provide({ - directory: tmp.path, - fn: async () => { const agent = { name: "build", mode: "primary" as const, permission: [], options: {} } - const load = () => - ToolRegistry.tools({ - providerID: "opencode" as any, - modelID: "gpt-5" as any, - agent, - }).then((tools) => tools.find((tool) => tool.id === SkillTool.id)?.description ?? "") - const first = await load() - const second = await load() + const registry = yield* ToolRegistry.Service + const load = Effect.fnUntraced(function* () { + return ( + (yield* registry.tools({ + providerID: "opencode" as any, + modelID: "gpt-5" as any, + agent, + })).find((tool) => tool.id === SkillTool.id)?.description ?? "" + ) + }) + const first = yield* load() + const second = yield* load() expect(first).toBe(second) @@ -117,12 +128,10 @@ description: ${description} expect(alpha).toBeGreaterThan(-1) expect(middle).toBeGreaterThan(alpha) expect(zeta).toBeGreaterThan(middle) - }, - }) - } finally { - process.env.OPENCODE_TEST_HOME = home - } - }) + }), + { git: true }, + ), + ) test("execute returns skill content block with files", async () => { await using tmp = await tmpdir({