From 7164662be2fcf72410e3f895c9dee9b564b750a3 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 13 Apr 2026 14:18:05 +0000 Subject: [PATCH 01/11] chore: generate --- packages/sdk/js/src/v2/gen/types.gen.ts | 238 +++++----- packages/sdk/openapi.json | 604 ++++++++++++------------ 2 files changed, 421 insertions(+), 421 deletions(-) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 4aab9f4395..6512f40a2f 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -33,6 +33,13 @@ export type EventProjectUpdated = { properties: Project } +export type EventServerInstanceDisposed = { + type: "server.instance.disposed" + properties: { + directory: string + } +} + export type EventInstallationUpdated = { type: "installation.updated" properties: { @@ -47,13 +54,6 @@ export type EventInstallationUpdateAvailable = { } } -export type EventServerInstanceDisposed = { - type: "server.instance.disposed" - properties: { - directory: string - } -} - export type EventServerConnected = { type: "server.connected" properties: { @@ -68,6 +68,21 @@ export type EventGlobalDisposed = { } } +export type EventFileEdited = { + type: "file.edited" + properties: { + file: string + } +} + +export type EventFileWatcherUpdated = { + type: "file.watcher.updated" + properties: { + file: string + event: "add" | "change" | "unlink" + } +} + export type EventLspClientDiagnostics = { type: "lsp.client.diagnostics" properties: { @@ -215,107 +230,6 @@ export type EventSessionError = { } } -export type EventFileEdited = { - type: "file.edited" - properties: { - file: string - } -} - -export type EventFileWatcherUpdated = { - type: "file.watcher.updated" - properties: { - file: string - event: "add" | "change" | "unlink" - } -} - -export type EventVcsBranchUpdated = { - type: "vcs.branch.updated" - properties: { - branch?: string - } -} - -export type EventTuiPromptAppend = { - type: "tui.prompt.append" - properties: { - text: string - } -} - -export type EventTuiCommandExecute = { - type: "tui.command.execute" - properties: { - command: - | "session.list" - | "session.new" - | "session.share" - | "session.interrupt" - | "session.compact" - | "session.page.up" - | "session.page.down" - | "session.line.up" - | "session.line.down" - | "session.half.page.up" - | "session.half.page.down" - | "session.first" - | "session.last" - | "prompt.clear" - | "prompt.submit" - | "agent.cycle" - | string - } -} - -export type EventTuiToastShow = { - type: "tui.toast.show" - properties: { - title?: string - message: string - variant: "info" | "success" | "warning" | "error" - /** - * Duration in milliseconds - */ - duration?: number - } -} - -export type EventTuiSessionSelect = { - type: "tui.session.select" - properties: { - /** - * Session ID to navigate to - */ - sessionID: string - } -} - -export type EventMcpToolsChanged = { - type: "mcp.tools.changed" - properties: { - server: string - } -} - -export type EventMcpBrowserOpenFailed = { - type: "mcp.browser.open.failed" - properties: { - mcpName: string - url: string - } -} - -export type EventCommandExecuted = { - type: "command.executed" - properties: { - name: string - sessionID: string - arguments: string - messageID: string - } -} - export type QuestionOption = { /** * Display text (1-5 words, concise) @@ -446,6 +360,92 @@ export type EventSessionCompacted = { } } +export type EventTuiPromptAppend = { + type: "tui.prompt.append" + properties: { + text: string + } +} + +export type EventTuiCommandExecute = { + type: "tui.command.execute" + properties: { + command: + | "session.list" + | "session.new" + | "session.share" + | "session.interrupt" + | "session.compact" + | "session.page.up" + | "session.page.down" + | "session.line.up" + | "session.line.down" + | "session.half.page.up" + | "session.half.page.down" + | "session.first" + | "session.last" + | "prompt.clear" + | "prompt.submit" + | "agent.cycle" + | string + } +} + +export type EventTuiToastShow = { + type: "tui.toast.show" + properties: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + /** + * Duration in milliseconds + */ + duration?: number + } +} + +export type EventTuiSessionSelect = { + type: "tui.session.select" + properties: { + /** + * Session ID to navigate to + */ + sessionID: string + } +} + +export type EventMcpToolsChanged = { + type: "mcp.tools.changed" + properties: { + server: string + } +} + +export type EventMcpBrowserOpenFailed = { + type: "mcp.browser.open.failed" + properties: { + mcpName: string + url: string + } +} + +export type EventCommandExecuted = { + type: "command.executed" + properties: { + name: string + sessionID: string + arguments: string + messageID: string + } +} + +export type EventVcsBranchUpdated = { + type: "vcs.branch.updated" + properties: { + branch?: string + } +} + export type EventWorktreeReady = { type: "worktree.ready" properties: { @@ -973,11 +973,13 @@ export type EventSessionDeleted = { export type Event = | EventProjectUpdated + | EventServerInstanceDisposed | EventInstallationUpdated | EventInstallationUpdateAvailable - | EventServerInstanceDisposed | EventServerConnected | EventGlobalDisposed + | EventFileEdited + | EventFileWatcherUpdated | EventLspClientDiagnostics | EventLspUpdated | EventMessagePartDelta @@ -985,16 +987,6 @@ export type Event = | EventPermissionReplied | EventSessionDiff | EventSessionError - | EventFileEdited - | EventFileWatcherUpdated - | EventVcsBranchUpdated - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow - | EventTuiSessionSelect - | EventMcpToolsChanged - | EventMcpBrowserOpenFailed - | EventCommandExecuted | EventQuestionAsked | EventQuestionReplied | EventQuestionRejected @@ -1002,6 +994,14 @@ export type Event = | EventSessionStatus | EventSessionIdle | EventSessionCompacted + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow + | EventTuiSessionSelect + | EventMcpToolsChanged + | EventMcpBrowserOpenFailed + | EventCommandExecuted + | EventVcsBranchUpdated | EventWorktreeReady | EventWorktreeFailed | EventPtyCreated diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index e8d6e6a087..5369d1c2e5 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -7230,6 +7230,25 @@ }, "required": ["type", "properties"] }, + "Event.server.instance.disposed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "server.instance.disposed" + }, + "properties": { + "type": "object", + "properties": { + "directory": { + "type": "string" + } + }, + "required": ["directory"] + } + }, + "required": ["type", "properties"] + }, "Event.installation.updated": { "type": "object", "properties": { @@ -7268,25 +7287,6 @@ }, "required": ["type", "properties"] }, - "Event.server.instance.disposed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "server.instance.disposed" - }, - "properties": { - "type": "object", - "properties": { - "directory": { - "type": "string" - } - }, - "required": ["directory"] - } - }, - "required": ["type", "properties"] - }, "Event.server.connected": { "type": "object", "properties": { @@ -7315,6 +7315,60 @@ }, "required": ["type", "properties"] }, + "Event.file.edited": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "file.edited" + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + } + }, + "required": ["file"] + } + }, + "required": ["type", "properties"] + }, + "Event.file.watcher.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "file.watcher.updated" + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "event": { + "anyOf": [ + { + "type": "string", + "const": "add" + }, + { + "type": "string", + "const": "change" + }, + { + "type": "string", + "const": "unlink" + } + ] + } + }, + "required": ["file", "event"] + } + }, + "required": ["type", "properties"] + }, "Event.lsp.client.diagnostics": { "type": "object", "properties": { @@ -7731,264 +7785,6 @@ }, "required": ["type", "properties"] }, - "Event.file.edited": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "file.edited" - }, - "properties": { - "type": "object", - "properties": { - "file": { - "type": "string" - } - }, - "required": ["file"] - } - }, - "required": ["type", "properties"] - }, - "Event.file.watcher.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "file.watcher.updated" - }, - "properties": { - "type": "object", - "properties": { - "file": { - "type": "string" - }, - "event": { - "anyOf": [ - { - "type": "string", - "const": "add" - }, - { - "type": "string", - "const": "change" - }, - { - "type": "string", - "const": "unlink" - } - ] - } - }, - "required": ["file", "event"] - } - }, - "required": ["type", "properties"] - }, - "Event.vcs.branch.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "vcs.branch.updated" - }, - "properties": { - "type": "object", - "properties": { - "branch": { - "type": "string" - } - } - } - }, - "required": ["type", "properties"] - }, - "Event.tui.prompt.append": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "tui.prompt.append" - }, - "properties": { - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - }, - "required": ["type", "properties"] - }, - "Event.tui.command.execute": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "tui.command.execute" - }, - "properties": { - "type": "object", - "properties": { - "command": { - "anyOf": [ - { - "type": "string", - "enum": [ - "session.list", - "session.new", - "session.share", - "session.interrupt", - "session.compact", - "session.page.up", - "session.page.down", - "session.line.up", - "session.line.down", - "session.half.page.up", - "session.half.page.down", - "session.first", - "session.last", - "prompt.clear", - "prompt.submit", - "agent.cycle" - ] - }, - { - "type": "string" - } - ] - } - }, - "required": ["command"] - } - }, - "required": ["type", "properties"] - }, - "Event.tui.toast.show": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "tui.toast.show" - }, - "properties": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "message": { - "type": "string" - }, - "variant": { - "type": "string", - "enum": ["info", "success", "warning", "error"] - }, - "duration": { - "description": "Duration in milliseconds", - "default": 5000, - "type": "number" - } - }, - "required": ["message", "variant"] - } - }, - "required": ["type", "properties"] - }, - "Event.tui.session.select": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "tui.session.select" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "description": "Session ID to navigate to", - "type": "string", - "pattern": "^ses.*" - } - }, - "required": ["sessionID"] - } - }, - "required": ["type", "properties"] - }, - "Event.mcp.tools.changed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "mcp.tools.changed" - }, - "properties": { - "type": "object", - "properties": { - "server": { - "type": "string" - } - }, - "required": ["server"] - } - }, - "required": ["type", "properties"] - }, - "Event.mcp.browser.open.failed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "mcp.browser.open.failed" - }, - "properties": { - "type": "object", - "properties": { - "mcpName": { - "type": "string" - }, - "url": { - "type": "string" - } - }, - "required": ["mcpName", "url"] - } - }, - "required": ["type", "properties"] - }, - "Event.command.executed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "command.executed" - }, - "properties": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "arguments": { - "type": "string" - }, - "messageID": { - "type": "string", - "pattern": "^msg.*" - } - }, - "required": ["name", "sessionID", "arguments", "messageID"] - } - }, - "required": ["type", "properties"] - }, "QuestionOption": { "type": "object", "properties": { @@ -8289,6 +8085,210 @@ }, "required": ["type", "properties"] }, + "Event.tui.prompt.append": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "tui.prompt.append" + }, + "properties": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": ["text"] + } + }, + "required": ["type", "properties"] + }, + "Event.tui.command.execute": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "tui.command.execute" + }, + "properties": { + "type": "object", + "properties": { + "command": { + "anyOf": [ + { + "type": "string", + "enum": [ + "session.list", + "session.new", + "session.share", + "session.interrupt", + "session.compact", + "session.page.up", + "session.page.down", + "session.line.up", + "session.line.down", + "session.half.page.up", + "session.half.page.down", + "session.first", + "session.last", + "prompt.clear", + "prompt.submit", + "agent.cycle" + ] + }, + { + "type": "string" + } + ] + } + }, + "required": ["command"] + } + }, + "required": ["type", "properties"] + }, + "Event.tui.toast.show": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "tui.toast.show" + }, + "properties": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "message": { + "type": "string" + }, + "variant": { + "type": "string", + "enum": ["info", "success", "warning", "error"] + }, + "duration": { + "description": "Duration in milliseconds", + "default": 5000, + "type": "number" + } + }, + "required": ["message", "variant"] + } + }, + "required": ["type", "properties"] + }, + "Event.tui.session.select": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "tui.session.select" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "description": "Session ID to navigate to", + "type": "string", + "pattern": "^ses.*" + } + }, + "required": ["sessionID"] + } + }, + "required": ["type", "properties"] + }, + "Event.mcp.tools.changed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "mcp.tools.changed" + }, + "properties": { + "type": "object", + "properties": { + "server": { + "type": "string" + } + }, + "required": ["server"] + } + }, + "required": ["type", "properties"] + }, + "Event.mcp.browser.open.failed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "mcp.browser.open.failed" + }, + "properties": { + "type": "object", + "properties": { + "mcpName": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": ["mcpName", "url"] + } + }, + "required": ["type", "properties"] + }, + "Event.command.executed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "command.executed" + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "arguments": { + "type": "string" + }, + "messageID": { + "type": "string", + "pattern": "^msg.*" + } + }, + "required": ["name", "sessionID", "arguments", "messageID"] + } + }, + "required": ["type", "properties"] + }, + "Event.vcs.branch.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "vcs.branch.updated" + }, + "properties": { + "type": "object", + "properties": { + "branch": { + "type": "string" + } + } + } + }, + "required": ["type", "properties"] + }, "Event.worktree.ready": { "type": "object", "properties": { @@ -9874,21 +9874,27 @@ { "$ref": "#/components/schemas/Event.project.updated" }, + { + "$ref": "#/components/schemas/Event.server.instance.disposed" + }, { "$ref": "#/components/schemas/Event.installation.updated" }, { "$ref": "#/components/schemas/Event.installation.update-available" }, - { - "$ref": "#/components/schemas/Event.server.instance.disposed" - }, { "$ref": "#/components/schemas/Event.server.connected" }, { "$ref": "#/components/schemas/Event.global.disposed" }, + { + "$ref": "#/components/schemas/Event.file.edited" + }, + { + "$ref": "#/components/schemas/Event.file.watcher.updated" + }, { "$ref": "#/components/schemas/Event.lsp.client.diagnostics" }, @@ -9911,13 +9917,25 @@ "$ref": "#/components/schemas/Event.session.error" }, { - "$ref": "#/components/schemas/Event.file.edited" + "$ref": "#/components/schemas/Event.question.asked" }, { - "$ref": "#/components/schemas/Event.file.watcher.updated" + "$ref": "#/components/schemas/Event.question.replied" }, { - "$ref": "#/components/schemas/Event.vcs.branch.updated" + "$ref": "#/components/schemas/Event.question.rejected" + }, + { + "$ref": "#/components/schemas/Event.todo.updated" + }, + { + "$ref": "#/components/schemas/Event.session.status" + }, + { + "$ref": "#/components/schemas/Event.session.idle" + }, + { + "$ref": "#/components/schemas/Event.session.compacted" }, { "$ref": "#/components/schemas/Event.tui.prompt.append" @@ -9941,25 +9959,7 @@ "$ref": "#/components/schemas/Event.command.executed" }, { - "$ref": "#/components/schemas/Event.question.asked" - }, - { - "$ref": "#/components/schemas/Event.question.replied" - }, - { - "$ref": "#/components/schemas/Event.question.rejected" - }, - { - "$ref": "#/components/schemas/Event.todo.updated" - }, - { - "$ref": "#/components/schemas/Event.session.status" - }, - { - "$ref": "#/components/schemas/Event.session.idle" - }, - { - "$ref": "#/components/schemas/Event.session.compacted" + "$ref": "#/components/schemas/Event.vcs.branch.updated" }, { "$ref": "#/components/schemas/Event.worktree.ready" From 9ae8dc2d017316c9ff0b9833926719bf088ce873 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 13 Apr 2026 11:09:32 -0400 Subject: [PATCH 02/11] refactor: remove ToolRegistry runtime facade (#22307) --- packages/opencode/script/seed-e2e.ts | 8 +- packages/opencode/src/cli/cmd/debug/agent.ts | 17 +- .../src/server/instance/experimental.ts | 24 +- packages/opencode/src/tool/registry.ts | 15 - packages/opencode/test/tool/registry.test.ts | 259 +++++++++--------- packages/opencode/test/tool/skill.test.ts | 147 +++++----- 6 files changed, 243 insertions(+), 227 deletions(-) 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({ From 7239b38b7f15ca2a393fa655342e46212b0b6cb9 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 13 Apr 2026 11:18:10 -0400 Subject: [PATCH 03/11] refactor(skill): remove async facade exports (#22308) --- packages/opencode/src/cli/cmd/debug/skill.ts | 9 +- .../opencode/src/server/instance/index.ts | 8 +- packages/opencode/src/skill/index.ts | 19 - packages/opencode/test/skill/skill.test.ts | 531 +++++++++--------- 4 files changed, 281 insertions(+), 286 deletions(-) diff --git a/packages/opencode/src/cli/cmd/debug/skill.ts b/packages/opencode/src/cli/cmd/debug/skill.ts index ebe3df1808..79179411b6 100644 --- a/packages/opencode/src/cli/cmd/debug/skill.ts +++ b/packages/opencode/src/cli/cmd/debug/skill.ts @@ -1,4 +1,6 @@ import { EOL } from "os" +import { Effect } from "effect" +import { AppRuntime } from "@/effect/app-runtime" import { Skill } from "../../../skill" import { bootstrap } from "../../bootstrap" import { cmd } from "../cmd" @@ -9,7 +11,12 @@ export const SkillCommand = cmd({ builder: (yargs) => yargs, async handler() { await bootstrap(process.cwd(), async () => { - const skills = await Skill.all() + const skills = await AppRuntime.runPromise( + Effect.gen(function* () { + const skill = yield* Skill.Service + return yield* skill.all() + }), + ) process.stdout.write(JSON.stringify(skills, null, 2) + EOL) }) }, diff --git a/packages/opencode/src/server/instance/index.ts b/packages/opencode/src/server/instance/index.ts index 2acc424e4e..2879d9aa12 100644 --- a/packages/opencode/src/server/instance/index.ts +++ b/packages/opencode/src/server/instance/index.ts @@ -24,6 +24,7 @@ import { ProviderRoutes } from "./provider" import { EventRoutes } from "./event" import { WorkspaceRouterMiddleware } from "./middleware" import { AppRuntime } from "@/effect/app-runtime" +import { Effect } from "effect" export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => new Hono() @@ -215,7 +216,12 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => }, }), async (c) => { - const skills = await Skill.all() + const skills = await AppRuntime.runPromise( + Effect.gen(function* () { + const skill = yield* Skill.Service + return yield* skill.all() + }), + ) return c.json(skills) }, ) diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index be74c0b342..6c4f290a08 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -7,7 +7,6 @@ import { NamedError } from "@opencode-ai/util/error" import type { Agent } from "@/agent/agent" import { Bus } from "@/bus" import { InstanceState } from "@/effect/instance-state" -import { makeRuntime } from "@/effect/run-service" import { Flag } from "@/flag/flag" import { Global } from "@/global" import { Permission } from "@/permission" @@ -262,22 +261,4 @@ export namespace Skill { .map((skill) => `- **${skill.name}**: ${skill.description}`), ].join("\n") } - - const { runPromise } = makeRuntime(Service, defaultLayer) - - export async function get(name: string) { - return runPromise((skill) => skill.get(name)) - } - - export async function all() { - return runPromise((skill) => skill.all()) - } - - export async function dirs() { - return runPromise((skill) => skill.dirs()) - } - - export async function available(agent?: Agent.Info) { - return runPromise((skill) => skill.available(agent)) - } } diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index 12e16f86a1..0a14e30b7d 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -1,13 +1,17 @@ -import { afterEach, test, expect } from "bun:test" +import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node" +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" import { Skill } from "../../src/skill" -import { Instance } from "../../src/project/instance" -import { tmpdir } from "../fixture/fixture" +import { provideInstance, provideTmpdirInstance, tmpdir } from "../fixture/fixture" +import { testEffect } from "../lib/effect" import path from "path" import fs from "fs/promises" -afterEach(async () => { - await Instance.disposeAll() -}) +const node = NodeChildProcessSpawner.layer.pipe( + Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)), +) + +const it = testEffect(Layer.mergeAll(Skill.defaultLayer, node)) async function createGlobalSkill(homeDir: string) { const skillDir = path.join(homeDir, ".claude", "skills", "global-test-skill") @@ -26,14 +30,29 @@ This skill is loaded from the global home directory. ) } -test("discovers skills from .opencode/skill/ directory", async () => { - await using tmp = await tmpdir({ - git: true, - init: async (dir) => { - const skillDir = path.join(dir, ".opencode", "skill", "test-skill") - await Bun.write( - path.join(skillDir, "SKILL.md"), - `--- +const withHome = (home: string, self: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => { + const prev = process.env.OPENCODE_TEST_HOME + process.env.OPENCODE_TEST_HOME = home + return prev + }), + () => self, + (prev) => + Effect.sync(() => { + process.env.OPENCODE_TEST_HOME = prev + }), + ) + +describe("skill", () => { + it.live("discovers skills from .opencode/skill/ directory", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + yield* Effect.promise(() => + Bun.write( + path.join(dir, ".opencode", "skill", "test-skill", "SKILL.md"), + `--- name: test-skill description: A test skill for verification. --- @@ -42,230 +61,217 @@ description: A test skill for verification. Instructions here. `, - ) - }, - }) + ), + ) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const skills = await Skill.all() - expect(skills.length).toBe(1) - const testSkill = skills.find((s) => s.name === "test-skill") - expect(testSkill).toBeDefined() - expect(testSkill!.description).toBe("A test skill for verification.") - expect(testSkill!.location).toContain(path.join("skill", "test-skill", "SKILL.md")) - }, - }) -}) + const skill = yield* Skill.Service + const list = yield* skill.all() + expect(list.length).toBe(1) + const item = list.find((x) => x.name === "test-skill") + expect(item).toBeDefined() + expect(item!.description).toBe("A test skill for verification.") + expect(item!.location).toContain(path.join("skill", "test-skill", "SKILL.md")) + }), + { git: true }, + ), + ) -test("returns skill directories from Skill.dirs", async () => { - await using tmp = await tmpdir({ - git: true, - init: async (dir) => { - const skillDir = path.join(dir, ".opencode", "skill", "dir-skill") - await Bun.write( - path.join(skillDir, "SKILL.md"), - `--- + it.live("returns skill directories from Skill.dirs", () => + provideTmpdirInstance( + (dir) => + withHome( + dir, + Effect.gen(function* () { + yield* Effect.promise(() => + Bun.write( + path.join(dir, ".opencode", "skill", "dir-skill", "SKILL.md"), + `--- name: dir-skill description: Skill for dirs test. --- # Dir Skill `, - ) - }, - }) + ), + ) - const home = process.env.OPENCODE_TEST_HOME - process.env.OPENCODE_TEST_HOME = tmp.path + const skill = yield* Skill.Service + const dirs = yield* skill.dirs() + expect(dirs).toContain(path.join(dir, ".opencode", "skill", "dir-skill")) + expect(dirs.length).toBe(1) + }), + ), + { git: true }, + ), + ) - try { - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const dirs = await Skill.dirs() - const skillDir = path.join(tmp.path, ".opencode", "skill", "dir-skill") - expect(dirs).toContain(skillDir) - expect(dirs.length).toBe(1) - }, - }) - } finally { - process.env.OPENCODE_TEST_HOME = home - } -}) - -test("discovers multiple skills from .opencode/skill/ directory", async () => { - await using tmp = await tmpdir({ - git: true, - init: async (dir) => { - const skillDir1 = path.join(dir, ".opencode", "skill", "skill-one") - const skillDir2 = path.join(dir, ".opencode", "skill", "skill-two") - await Bun.write( - path.join(skillDir1, "SKILL.md"), - `--- + it.live("discovers multiple skills from .opencode/skill/ directory", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + yield* Effect.promise(() => + Promise.all([ + Bun.write( + path.join(dir, ".opencode", "skill", "skill-one", "SKILL.md"), + `--- name: skill-one description: First test skill. --- # Skill One `, - ) - await Bun.write( - path.join(skillDir2, "SKILL.md"), - `--- + ), + Bun.write( + path.join(dir, ".opencode", "skill", "skill-two", "SKILL.md"), + `--- name: skill-two description: Second test skill. --- # Skill Two `, - ) - }, - }) + ), + ]), + ) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const skills = await Skill.all() - expect(skills.length).toBe(2) - expect(skills.find((s) => s.name === "skill-one")).toBeDefined() - expect(skills.find((s) => s.name === "skill-two")).toBeDefined() - }, - }) -}) + const skill = yield* Skill.Service + const list = yield* skill.all() + expect(list.length).toBe(2) + expect(list.find((x) => x.name === "skill-one")).toBeDefined() + expect(list.find((x) => x.name === "skill-two")).toBeDefined() + }), + { git: true }, + ), + ) -test("skips skills with missing frontmatter", async () => { - await using tmp = await tmpdir({ - git: true, - init: async (dir) => { - const skillDir = path.join(dir, ".opencode", "skill", "no-frontmatter") - await Bun.write( - path.join(skillDir, "SKILL.md"), - `# No Frontmatter + it.live("skips skills with missing frontmatter", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + yield* Effect.promise(() => + Bun.write( + path.join(dir, ".opencode", "skill", "no-frontmatter", "SKILL.md"), + `# No Frontmatter Just some content without YAML frontmatter. `, - ) - }, - }) + ), + ) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const skills = await Skill.all() - expect(skills).toEqual([]) - }, - }) -}) + const skill = yield* Skill.Service + expect(yield* skill.all()).toEqual([]) + }), + { git: true }, + ), + ) -test("discovers skills from .claude/skills/ directory", async () => { - await using tmp = await tmpdir({ - git: true, - init: async (dir) => { - const skillDir = path.join(dir, ".claude", "skills", "claude-skill") - await Bun.write( - path.join(skillDir, "SKILL.md"), - `--- + it.live("discovers skills from .claude/skills/ directory", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + yield* Effect.promise(() => + Bun.write( + path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"), + `--- name: claude-skill description: A skill in the .claude/skills directory. --- # Claude Skill `, + ), + ) + + const skill = yield* Skill.Service + const list = yield* skill.all() + expect(list.length).toBe(1) + const item = list.find((x) => x.name === "claude-skill") + expect(item).toBeDefined() + expect(item!.location).toContain(path.join(".claude", "skills", "claude-skill", "SKILL.md")) + }), + { git: true }, + ), + ) + + it.live("discovers global skills from ~/.claude/skills/ directory", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir({ git: true })), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), ) - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const skills = await Skill.all() - expect(skills.length).toBe(1) - const claudeSkill = skills.find((s) => s.name === "claude-skill") - expect(claudeSkill).toBeDefined() - expect(claudeSkill!.location).toContain(path.join(".claude", "skills", "claude-skill", "SKILL.md")) - }, - }) -}) + yield* withHome( + tmp.path, + Effect.gen(function* () { + yield* Effect.promise(() => createGlobalSkill(tmp.path)) + yield* Effect.gen(function* () { + const skill = yield* Skill.Service + const list = yield* skill.all() + expect(list.length).toBe(1) + expect(list[0].name).toBe("global-test-skill") + expect(list[0].description).toBe("A global skill from ~/.claude/skills for testing.") + expect(list[0].location).toContain(path.join(".claude", "skills", "global-test-skill", "SKILL.md")) + }).pipe(provideInstance(tmp.path)) + }), + ) + }), + ) -test("discovers global skills from ~/.claude/skills/ directory", async () => { - await using tmp = await tmpdir({ git: true }) + it.live("returns empty array when no skills exist", () => + provideTmpdirInstance( + () => + Effect.gen(function* () { + const skill = yield* Skill.Service + expect(yield* skill.all()).toEqual([]) + }), + { git: true }, + ), + ) - const originalHome = process.env.OPENCODE_TEST_HOME - process.env.OPENCODE_TEST_HOME = tmp.path - - try { - await createGlobalSkill(tmp.path) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const skills = await Skill.all() - expect(skills.length).toBe(1) - expect(skills[0].name).toBe("global-test-skill") - expect(skills[0].description).toBe("A global skill from ~/.claude/skills for testing.") - expect(skills[0].location).toContain(path.join(".claude", "skills", "global-test-skill", "SKILL.md")) - }, - }) - } finally { - process.env.OPENCODE_TEST_HOME = originalHome - } -}) - -test("returns empty array when no skills exist", async () => { - await using tmp = await tmpdir({ git: true }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const skills = await Skill.all() - expect(skills).toEqual([]) - }, - }) -}) - -test("discovers skills from .agents/skills/ directory", async () => { - await using tmp = await tmpdir({ - git: true, - init: async (dir) => { - const skillDir = path.join(dir, ".agents", "skills", "agent-skill") - await Bun.write( - path.join(skillDir, "SKILL.md"), - `--- + it.live("discovers skills from .agents/skills/ directory", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + yield* Effect.promise(() => + Bun.write( + path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"), + `--- name: agent-skill description: A skill in the .agents/skills directory. --- # Agent Skill `, + ), + ) + + const skill = yield* Skill.Service + const list = yield* skill.all() + expect(list.length).toBe(1) + const item = list.find((x) => x.name === "agent-skill") + expect(item).toBeDefined() + expect(item!.location).toContain(path.join(".agents", "skills", "agent-skill", "SKILL.md")) + }), + { git: true }, + ), + ) + + it.live("discovers global skills from ~/.agents/skills/ directory", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir({ git: true })), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), ) - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const skills = await Skill.all() - expect(skills.length).toBe(1) - const agentSkill = skills.find((s) => s.name === "agent-skill") - expect(agentSkill).toBeDefined() - expect(agentSkill!.location).toContain(path.join(".agents", "skills", "agent-skill", "SKILL.md")) - }, - }) -}) - -test("discovers global skills from ~/.agents/skills/ directory", async () => { - await using tmp = await tmpdir({ git: true }) - - const originalHome = process.env.OPENCODE_TEST_HOME - process.env.OPENCODE_TEST_HOME = tmp.path - - try { - const skillDir = path.join(tmp.path, ".agents", "skills", "global-agent-skill") - await fs.mkdir(skillDir, { recursive: true }) - await Bun.write( - path.join(skillDir, "SKILL.md"), - `--- + yield* withHome( + tmp.path, + Effect.gen(function* () { + const skillDir = path.join(tmp.path, ".agents", "skills", "global-agent-skill") + yield* Effect.promise(() => fs.mkdir(skillDir, { recursive: true })) + yield* Effect.promise(() => + Bun.write( + path.join(skillDir, "SKILL.md"), + `--- name: global-agent-skill description: A global skill from ~/.agents/skills for testing. --- @@ -274,119 +280,114 @@ description: A global skill from ~/.agents/skills for testing. This skill is loaded from the global home directory. `, - ) + ), + ) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const skills = await Skill.all() - expect(skills.length).toBe(1) - expect(skills[0].name).toBe("global-agent-skill") - expect(skills[0].description).toBe("A global skill from ~/.agents/skills for testing.") - expect(skills[0].location).toContain(path.join(".agents", "skills", "global-agent-skill", "SKILL.md")) - }, - }) - } finally { - process.env.OPENCODE_TEST_HOME = originalHome - } -}) + yield* Effect.gen(function* () { + const skill = yield* Skill.Service + const list = yield* skill.all() + expect(list.length).toBe(1) + expect(list[0].name).toBe("global-agent-skill") + expect(list[0].description).toBe("A global skill from ~/.agents/skills for testing.") + expect(list[0].location).toContain(path.join(".agents", "skills", "global-agent-skill", "SKILL.md")) + }).pipe(provideInstance(tmp.path)) + }), + ) + }), + ) -test("discovers skills from both .claude/skills/ and .agents/skills/", async () => { - await using tmp = await tmpdir({ - git: true, - init: async (dir) => { - const claudeDir = path.join(dir, ".claude", "skills", "claude-skill") - const agentDir = path.join(dir, ".agents", "skills", "agent-skill") - await Bun.write( - path.join(claudeDir, "SKILL.md"), - `--- + it.live("discovers skills from both .claude/skills/ and .agents/skills/", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + yield* Effect.promise(() => + Promise.all([ + Bun.write( + path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"), + `--- name: claude-skill description: A skill in the .claude/skills directory. --- # Claude Skill `, - ) - await Bun.write( - path.join(agentDir, "SKILL.md"), - `--- + ), + Bun.write( + path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"), + `--- name: agent-skill description: A skill in the .agents/skills directory. --- # Agent Skill `, - ) - }, - }) + ), + ]), + ) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const skills = await Skill.all() - expect(skills.length).toBe(2) - expect(skills.find((s) => s.name === "claude-skill")).toBeDefined() - expect(skills.find((s) => s.name === "agent-skill")).toBeDefined() - }, - }) -}) + const skill = yield* Skill.Service + const list = yield* skill.all() + expect(list.length).toBe(2) + expect(list.find((x) => x.name === "claude-skill")).toBeDefined() + expect(list.find((x) => x.name === "agent-skill")).toBeDefined() + }), + { git: true }, + ), + ) -test("properly resolves directories that skills live in", async () => { - await using tmp = await tmpdir({ - git: true, - init: async (dir) => { - const opencodeSkillDir = path.join(dir, ".opencode", "skill", "agent-skill") - const opencodeSkillsDir = path.join(dir, ".opencode", "skills", "agent-skill") - const claudeDir = path.join(dir, ".claude", "skills", "claude-skill") - const agentDir = path.join(dir, ".agents", "skills", "agent-skill") - await Bun.write( - path.join(claudeDir, "SKILL.md"), - `--- + it.live("properly resolves directories that skills live in", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + yield* Effect.promise(() => + Promise.all([ + Bun.write( + path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"), + `--- name: claude-skill description: A skill in the .claude/skills directory. --- # Claude Skill `, - ) - await Bun.write( - path.join(agentDir, "SKILL.md"), - `--- + ), + Bun.write( + path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"), + `--- name: agent-skill description: A skill in the .agents/skills directory. --- # Agent Skill `, - ) - await Bun.write( - path.join(opencodeSkillDir, "SKILL.md"), - `--- + ), + Bun.write( + path.join(dir, ".opencode", "skill", "agent-skill", "SKILL.md"), + `--- name: opencode-skill description: A skill in the .opencode/skill directory. --- # OpenCode Skill `, - ) - await Bun.write( - path.join(opencodeSkillsDir, "SKILL.md"), - `--- + ), + Bun.write( + path.join(dir, ".opencode", "skills", "agent-skill", "SKILL.md"), + `--- name: opencode-skill description: A skill in the .opencode/skills directory. --- # OpenCode Skill `, - ) - }, - }) + ), + ]), + ) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const dirs = await Skill.dirs() - expect(dirs.length).toBe(4) - }, - }) + const skill = yield* Skill.Service + expect((yield* skill.dirs()).length).toBe(4) + }), + { git: true }, + ), + ) }) From ca6200121bd370d0335556b2390f230dbe37fd1d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 13 Apr 2026 11:22:20 -0400 Subject: [PATCH 04/11] refactor: remove vcs async facade exports (#22304) --- packages/opencode/src/project/vcs.ts | 19 ----- .../opencode/src/server/instance/index.ts | 26 ++++-- packages/opencode/test/project/vcs.test.ts | 81 ++++++++++++++++--- 3 files changed, 89 insertions(+), 37 deletions(-) diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index 1b1f21f908..aede980d6d 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -4,7 +4,6 @@ import path from "path" import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" import { InstanceState } from "@/effect/instance-state" -import { makeRuntime } from "@/effect/run-service" import { AppFileSystem } from "@/filesystem" import { FileWatcher } from "@/file/watcher" import { Git } from "@/git" @@ -231,22 +230,4 @@ export namespace Vcs { Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Bus.layer), ) - - const { runPromise } = makeRuntime(Service, defaultLayer) - - export async function init() { - return runPromise((svc) => svc.init()) - } - - export async function branch() { - return runPromise((svc) => svc.branch()) - } - - export async function defaultBranch() { - return runPromise((svc) => svc.defaultBranch()) - } - - export async function diff(mode: Mode) { - return runPromise((svc) => svc.diff(mode)) - } } diff --git a/packages/opencode/src/server/instance/index.ts b/packages/opencode/src/server/instance/index.ts index 2879d9aa12..a2625af82d 100644 --- a/packages/opencode/src/server/instance/index.ts +++ b/packages/opencode/src/server/instance/index.ts @@ -1,6 +1,7 @@ import { describeRoute, resolver, validator } from "hono-openapi" import { Hono } from "hono" import type { UpgradeWebSocket } from "hono/ws" +import { Effect } from "effect" import z from "zod" import { Format } from "../../format" import { TuiRoutes } from "./tui" @@ -120,11 +121,17 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => }, }), async (c) => { - const [branch, default_branch] = await Promise.all([Vcs.branch(), Vcs.defaultBranch()]) - return c.json({ - branch, - default_branch, - }) + return c.json( + await AppRuntime.runPromise( + Effect.gen(function* () { + const vcs = yield* Vcs.Service + const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], { + concurrency: 2, + }) + return { branch, default_branch } + }), + ), + ) }, ) .get( @@ -151,7 +158,14 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => }), ), async (c) => { - return c.json(await Vcs.diff(c.req.valid("query").mode)) + return c.json( + await AppRuntime.runPromise( + Effect.gen(function* () { + const vcs = yield* Vcs.Service + return yield* vcs.diff(c.req.valid("query").mode) + }), + ), + ) }, ) .get( diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index 1610902af5..5461de5c33 100644 --- a/packages/opencode/test/project/vcs.test.ts +++ b/packages/opencode/test/project/vcs.test.ts @@ -1,5 +1,6 @@ import { $ } from "bun" import { afterEach, describe, expect, test } from "bun:test" +import { Effect } from "effect" import fs from "fs/promises" import path from "path" import { tmpdir } from "../fixture/fixture" @@ -20,8 +21,14 @@ async function withVcs(directory: string, body: () => Promise) { return Instance.provide({ directory, fn: async () => { - void AppRuntime.runPromise(FileWatcher.Service.use((svc) => svc.init())) - Vcs.init() + await AppRuntime.runPromise( + Effect.gen(function* () { + const watcher = yield* FileWatcher.Service + const vcs = yield* Vcs.Service + yield* watcher.init() + yield* vcs.init() + }), + ) await Bun.sleep(500) await body() }, @@ -32,7 +39,12 @@ function withVcsOnly(directory: string, body: () => Promise) { return Instance.provide({ directory, fn: async () => { - Vcs.init() + await AppRuntime.runPromise( + Effect.gen(function* () { + const vcs = yield* Vcs.Service + yield* vcs.init() + }), + ) await body() }, }) @@ -80,7 +92,12 @@ describeVcs("Vcs", () => { await using tmp = await tmpdir({ git: true }) await withVcs(tmp.path, async () => { - const branch = await Vcs.branch() + const branch = await AppRuntime.runPromise( + Effect.gen(function* () { + const vcs = yield* Vcs.Service + return yield* vcs.branch() + }), + ) expect(branch).toBeDefined() expect(typeof branch).toBe("string") }) @@ -90,7 +107,12 @@ describeVcs("Vcs", () => { await using tmp = await tmpdir() await withVcs(tmp.path, async () => { - const branch = await Vcs.branch() + const branch = await AppRuntime.runPromise( + Effect.gen(function* () { + const vcs = yield* Vcs.Service + return yield* vcs.branch() + }), + ) expect(branch).toBeUndefined() }) }) @@ -123,7 +145,12 @@ describeVcs("Vcs", () => { await fs.writeFile(head, `ref: refs/heads/${branch}\n`) await pending - const current = await Vcs.branch() + const current = await AppRuntime.runPromise( + Effect.gen(function* () { + const vcs = yield* Vcs.Service + return yield* vcs.branch() + }), + ) expect(current).toBe(branch) }) }) @@ -139,7 +166,12 @@ describe("Vcs diff", () => { await $`git branch -M main`.cwd(tmp.path).quiet() await withVcsOnly(tmp.path, async () => { - const branch = await Vcs.defaultBranch() + const branch = await AppRuntime.runPromise( + Effect.gen(function* () { + const vcs = yield* Vcs.Service + return yield* vcs.defaultBranch() + }), + ) expect(branch).toBe("main") }) }) @@ -150,7 +182,12 @@ describe("Vcs diff", () => { await $`git config init.defaultBranch trunk`.cwd(tmp.path).quiet() await withVcsOnly(tmp.path, async () => { - const branch = await Vcs.defaultBranch() + const branch = await AppRuntime.runPromise( + Effect.gen(function* () { + const vcs = yield* Vcs.Service + return yield* vcs.defaultBranch() + }), + ) expect(branch).toBe("trunk") }) }) @@ -163,7 +200,12 @@ describe("Vcs diff", () => { await $`git worktree add -b feature/test ${dir} HEAD`.cwd(tmp.path).quiet() await withVcsOnly(dir, async () => { - const [branch, base] = await Promise.all([Vcs.branch(), Vcs.defaultBranch()]) + const [branch, base] = await AppRuntime.runPromise( + Effect.gen(function* () { + const vcs = yield* Vcs.Service + return yield* Effect.all([vcs.branch(), vcs.defaultBranch()], { concurrency: 2 }) + }), + ) expect(branch).toBe("feature/test") expect(base).toBe("main") }) @@ -177,7 +219,12 @@ describe("Vcs diff", () => { await fs.writeFile(path.join(tmp.path, "file.txt"), "changed\n", "utf-8") await withVcsOnly(tmp.path, async () => { - const diff = await Vcs.diff("git") + const diff = await AppRuntime.runPromise( + Effect.gen(function* () { + const vcs = yield* Vcs.Service + return yield* vcs.diff("git") + }), + ) expect(diff).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -194,7 +241,12 @@ describe("Vcs diff", () => { await fs.writeFile(path.join(tmp.path, weird), "hello\n", "utf-8") await withVcsOnly(tmp.path, async () => { - const diff = await Vcs.diff("git") + const diff = await AppRuntime.runPromise( + Effect.gen(function* () { + const vcs = yield* Vcs.Service + return yield* vcs.diff("git") + }), + ) expect(diff).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -215,7 +267,12 @@ describe("Vcs diff", () => { await $`git commit --no-gpg-sign -m "branch file"`.cwd(tmp.path).quiet() await withVcsOnly(tmp.path, async () => { - const diff = await Vcs.diff("branch") + const diff = await AppRuntime.runPromise( + Effect.gen(function* () { + const vcs = yield* Vcs.Service + return yield* vcs.diff("branch") + }), + ) expect(diff).toEqual( expect.arrayContaining([ expect.objectContaining({ From 79cc15335ec63dd04dd99d504250b03927848b3c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 13 Apr 2026 11:36:56 -0400 Subject: [PATCH 05/11] fix: dispose e2e app runtime (#22316) --- packages/opencode/script/seed-e2e.ts | 1 + packages/opencode/src/server/instance/index.ts | 1 - packages/opencode/src/worktree/index.ts | 4 ++-- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/script/seed-e2e.ts b/packages/opencode/script/seed-e2e.ts index ec15bbe813..fe83b8ec08 100644 --- a/packages/opencode/script/seed-e2e.ts +++ b/packages/opencode/script/seed-e2e.ts @@ -62,6 +62,7 @@ const seed = async () => { }) } finally { await Instance.disposeAll().catch(() => {}) + await AppRuntime.dispose().catch(() => {}) } } diff --git a/packages/opencode/src/server/instance/index.ts b/packages/opencode/src/server/instance/index.ts index a2625af82d..6009130a2f 100644 --- a/packages/opencode/src/server/instance/index.ts +++ b/packages/opencode/src/server/instance/index.ts @@ -25,7 +25,6 @@ import { ProviderRoutes } from "./provider" import { EventRoutes } from "./event" import { WorkspaceRouterMiddleware } from "./middleware" import { AppRuntime } from "@/effect/app-runtime" -import { Effect } from "effect" export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => new Hono() diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 3a3a39c31a..b6430fa6c7 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -17,10 +17,10 @@ import { Effect, Layer, Path, Scope, Context, Stream } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { NodePath } from "@effect/platform-node" import { AppFileSystem } from "@/filesystem" +import { BootstrapRuntime } from "@/effect/bootstrap-runtime" import { makeRuntime } from "@/effect/run-service" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" import { InstanceState } from "@/effect/instance-state" -import { AppRuntime } from "@/effect/app-runtime" export namespace Worktree { const log = Log.create({ service: "worktree" }) @@ -267,7 +267,7 @@ export namespace Worktree { const booted = yield* Effect.promise(() => Instance.provide({ directory: info.directory, - init: () => AppRuntime.runPromise(InstanceBootstrap), + init: () => BootstrapRuntime.runPromise(InstanceBootstrap), fn: () => undefined, }) .then(() => true) From 3644581b55c45bd889cc198cdf48744a68a9b612 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 13 Apr 2026 11:39:37 -0400 Subject: [PATCH 06/11] refactor(file): stream ripgrep search parsing (#22303) --- packages/opencode/src/file/ripgrep.ts | 59 +++++++++++++++++++++------ 1 file changed, 47 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index 4100563d5f..81cd2bf0dc 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -3,7 +3,7 @@ import path from "path" import { Global } from "../global" import fs from "fs/promises" import z from "zod" -import { Effect, Layer, Context } from "effect" +import { Effect, Layer, Context, Schema } from "effect" import * as Stream from "effect/Stream" import { ChildProcess } from "effect/unstable/process" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" @@ -94,6 +94,40 @@ export namespace Ripgrep { const Result = z.union([Begin, Match, End, Summary]) + const Hit = Schema.Struct({ + type: Schema.Literal("match"), + data: Schema.Struct({ + path: Schema.Struct({ + text: Schema.String, + }), + lines: Schema.Struct({ + text: Schema.String, + }), + line_number: Schema.Number, + absolute_offset: Schema.Number, + submatches: Schema.mutable( + Schema.Array( + Schema.Struct({ + match: Schema.Struct({ + text: Schema.String, + }), + start: Schema.Number, + end: Schema.Number, + }), + ), + ), + }), + }) + + const Row = Schema.Union([ + Schema.Struct({ type: Schema.Literal("begin"), data: Schema.Unknown }), + Hit, + Schema.Struct({ type: Schema.Literal("end"), data: Schema.Unknown }), + Schema.Struct({ type: Schema.Literal("summary"), data: Schema.Unknown }), + ]) + + const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Row)) + export type Result = z.infer export type Match = z.infer export type Item = Match["data"] @@ -389,9 +423,19 @@ export namespace Ripgrep { }), ) - const [stdout, stderr, code] = yield* Effect.all( + const [items, stderr, code] = yield* Effect.all( [ - Stream.mkString(Stream.decodeText(handle.stdout)), + Stream.decodeText(handle.stdout).pipe( + Stream.splitLines, + Stream.filter((line) => line.length > 0), + Stream.mapEffect((line) => + decode(line).pipe(Effect.mapError((cause) => new Error("invalid ripgrep output", { cause }))), + ), + Stream.filter((row): row is Schema.Schema.Type => row.type === "match"), + Stream.map((row): Item => row.data), + Stream.runCollect, + Effect.map((chunk) => [...chunk]), + ), Stream.mkString(Stream.decodeText(handle.stderr)), handle.exitCode, ], @@ -402,15 +446,6 @@ export namespace Ripgrep { return yield* Effect.fail(new Error(`ripgrep failed: ${stderr}`)) } - const items = stdout - .trim() - .split(/\r?\n/) - .filter(Boolean) - .map((line) => JSON.parse(line)) - .map((parsed) => Result.parse(parsed)) - .filter((row): row is Match => row.type === "match") - .map((row) => row.data) - return { items, partial: code === 2, From 6825b0bbc7f6f3863a5159b9153e0f3878ace76e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 13 Apr 2026 11:47:05 -0400 Subject: [PATCH 07/11] refactor(pty): remove async facade exports (#22305) --- packages/opencode/src/pty/index.ts | 31 --- packages/opencode/src/server/instance/pty.ts | 64 +++++- .../test/pty/pty-output-isolation.test.ts | 193 +++++++++--------- .../opencode/test/pty/pty-session.test.ts | 98 +++++---- packages/opencode/test/pty/pty-shell.test.ts | 42 ++-- 5 files changed, 235 insertions(+), 193 deletions(-) diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index a563bb954b..1891721851 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -1,7 +1,6 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" import { InstanceState } from "@/effect/instance-state" -import { makeRuntime } from "@/effect/run-service" import { Instance } from "@/project/instance" import type { Proc } from "#pty" import z from "zod" @@ -361,34 +360,4 @@ export namespace Pty { ) export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Plugin.defaultLayer)) - - const { runPromise } = makeRuntime(Service, defaultLayer) - - export async function list() { - return runPromise((svc) => svc.list()) - } - - export async function get(id: PtyID) { - return runPromise((svc) => svc.get(id)) - } - - export async function write(id: PtyID, data: string) { - return runPromise((svc) => svc.write(id, data)) - } - - export async function connect(id: PtyID, ws: Socket, cursor?: number) { - return runPromise((svc) => svc.connect(id, ws, cursor)) - } - - export async function create(input: CreateInput) { - return runPromise((svc) => svc.create(input)) - } - - export async function update(id: PtyID, input: UpdateInput) { - return runPromise((svc) => svc.update(id, input)) - } - - export async function remove(id: PtyID) { - return runPromise((svc) => svc.remove(id)) - } } diff --git a/packages/opencode/src/server/instance/pty.ts b/packages/opencode/src/server/instance/pty.ts index c333f4dd69..576cbe5de6 100644 --- a/packages/opencode/src/server/instance/pty.ts +++ b/packages/opencode/src/server/instance/pty.ts @@ -1,7 +1,9 @@ import { Hono, type MiddlewareHandler } from "hono" import { describeRoute, validator, resolver } from "hono-openapi" import type { UpgradeWebSocket } from "hono/ws" +import { Effect } from "effect" import z from "zod" +import { AppRuntime } from "@/effect/app-runtime" import { Pty } from "@/pty" import { PtyID } from "@/pty/schema" import { NotFoundError } from "../../storage/db" @@ -27,7 +29,14 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { }, }), async (c) => { - return c.json(await Pty.list()) + return c.json( + await AppRuntime.runPromise( + Effect.gen(function* () { + const pty = yield* Pty.Service + return yield* pty.list() + }), + ), + ) }, ) .post( @@ -50,7 +59,12 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { }), validator("json", Pty.CreateInput), async (c) => { - const info = await Pty.create(c.req.valid("json")) + const info = await AppRuntime.runPromise( + Effect.gen(function* () { + const pty = yield* Pty.Service + return yield* pty.create(c.req.valid("json")) + }), + ) return c.json(info) }, ) @@ -74,7 +88,12 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { }), validator("param", z.object({ ptyID: PtyID.zod })), async (c) => { - const info = await Pty.get(c.req.valid("param").ptyID) + const info = await AppRuntime.runPromise( + Effect.gen(function* () { + const pty = yield* Pty.Service + return yield* pty.get(c.req.valid("param").ptyID) + }), + ) if (!info) { throw new NotFoundError({ message: "Session not found" }) } @@ -102,7 +121,12 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { validator("param", z.object({ ptyID: PtyID.zod })), validator("json", Pty.UpdateInput), async (c) => { - const info = await Pty.update(c.req.valid("param").ptyID, c.req.valid("json")) + const info = await AppRuntime.runPromise( + Effect.gen(function* () { + const pty = yield* Pty.Service + return yield* pty.update(c.req.valid("param").ptyID, c.req.valid("json")) + }), + ) return c.json(info) }, ) @@ -126,7 +150,12 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { }), validator("param", z.object({ ptyID: PtyID.zod })), async (c) => { - await Pty.remove(c.req.valid("param").ptyID) + await AppRuntime.runPromise( + Effect.gen(function* () { + const pty = yield* Pty.Service + yield* pty.remove(c.req.valid("param").ptyID) + }), + ) return c.json(true) }, ) @@ -150,6 +179,11 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { }), validator("param", z.object({ ptyID: PtyID.zod })), upgradeWebSocket(async (c) => { + type Handler = { + onMessage: (message: string | ArrayBuffer) => void + onClose: () => void + } + const id = PtyID.zod.parse(c.req.param("ptyID")) const cursor = (() => { const value = c.req.query("cursor") @@ -158,8 +192,17 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { if (!Number.isSafeInteger(parsed) || parsed < -1) return return parsed })() - let handler: Awaited> - if (!(await Pty.get(id))) throw new Error("Session not found") + let handler: Handler | undefined + if ( + !(await AppRuntime.runPromise( + Effect.gen(function* () { + const pty = yield* Pty.Service + return yield* pty.get(id) + }), + )) + ) { + throw new Error("Session not found") + } type Socket = { readyState: number @@ -185,7 +228,12 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { ws.close() return } - handler = await Pty.connect(id, socket, cursor) + handler = await AppRuntime.runPromise( + Effect.gen(function* () { + const pty = yield* Pty.Service + return yield* pty.connect(id, socket, cursor) + }), + ) ready = true for (const msg of pending) handler?.onMessage(msg) pending.length = 0 diff --git a/packages/opencode/test/pty/pty-output-isolation.test.ts b/packages/opencode/test/pty/pty-output-isolation.test.ts index ec1bbd4690..9ef9741bad 100644 --- a/packages/opencode/test/pty/pty-output-isolation.test.ts +++ b/packages/opencode/test/pty/pty-output-isolation.test.ts @@ -1,4 +1,6 @@ import { describe, expect, test } from "bun:test" +import { AppRuntime } from "../../src/effect/app-runtime" +import { Effect } from "effect" import { Instance } from "../../src/project/instance" import { Pty } from "../../src/pty" import { tmpdir } from "../fixture/fixture" @@ -10,48 +12,48 @@ describe("pty", () => { await Instance.provide({ directory: dir.path, - fn: async () => { - const a = await Pty.create({ command: "cat", title: "a" }) - const b = await Pty.create({ command: "cat", title: "b" }) - try { - const outA: string[] = [] - const outB: string[] = [] + fn: () => + AppRuntime.runPromise( + Effect.gen(function* () { + const pty = yield* Pty.Service + const a = yield* pty.create({ command: "cat", title: "a" }) + const b = yield* pty.create({ command: "cat", title: "b" }) + try { + const outA: string[] = [] + const outB: string[] = [] - const ws = { - readyState: 1, - data: { events: { connection: "a" } }, - send: (data: unknown) => { - outA.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8")) - }, - close: () => { - // no-op (simulate abrupt drop) - }, - } + const ws = { + readyState: 1, + data: { events: { connection: "a" } }, + send: (data: unknown) => { + outA.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8")) + }, + close: () => { + // no-op (simulate abrupt drop) + }, + } - // Connect "a" first with ws. - Pty.connect(a.id, ws as any) + yield* pty.connect(a.id, ws as any) - // Now "reuse" the same ws object for another connection. - ws.data = { events: { connection: "b" } } - ws.send = (data: unknown) => { - outB.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8")) - } - Pty.connect(b.id, ws as any) + ws.data = { events: { connection: "b" } } + ws.send = (data: unknown) => { + outB.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8")) + } + yield* pty.connect(b.id, ws as any) - // Clear connect metadata writes. - outA.length = 0 - outB.length = 0 + outA.length = 0 + outB.length = 0 - // Output from a must never show up in b. - Pty.write(a.id, "AAA\n") - await sleep(100) + yield* pty.write(a.id, "AAA\n") + yield* Effect.promise(() => sleep(100)) - expect(outB.join("")).not.toContain("AAA") - } finally { - await Pty.remove(a.id) - await Pty.remove(b.id) - } - }, + expect(outB.join("")).not.toContain("AAA") + } finally { + yield* pty.remove(a.id) + yield* pty.remove(b.id) + } + }), + ), }) }) @@ -60,42 +62,43 @@ describe("pty", () => { await Instance.provide({ directory: dir.path, - fn: async () => { - const a = await Pty.create({ command: "cat", title: "a" }) - try { - const outA: string[] = [] - const outB: string[] = [] + fn: () => + AppRuntime.runPromise( + Effect.gen(function* () { + const pty = yield* Pty.Service + const a = yield* pty.create({ command: "cat", title: "a" }) + try { + const outA: string[] = [] + const outB: string[] = [] - const ws = { - readyState: 1, - data: { events: { connection: "a" } }, - send: (data: unknown) => { - outA.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8")) - }, - close: () => { - // no-op (simulate abrupt drop) - }, - } + const ws = { + readyState: 1, + data: { events: { connection: "a" } }, + send: (data: unknown) => { + outA.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8")) + }, + close: () => { + // no-op (simulate abrupt drop) + }, + } - // Connect "a" first. - Pty.connect(a.id, ws as any) - outA.length = 0 + yield* pty.connect(a.id, ws as any) + outA.length = 0 - // Simulate Bun reusing the same websocket object for another - // connection before the next onOpen calls Pty.connect. - ws.data = { events: { connection: "b" } } - ws.send = (data: unknown) => { - outB.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8")) - } + ws.data = { events: { connection: "b" } } + ws.send = (data: unknown) => { + outB.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8")) + } - Pty.write(a.id, "AAA\n") - await sleep(100) + yield* pty.write(a.id, "AAA\n") + yield* Effect.promise(() => sleep(100)) - expect(outB.join("")).not.toContain("AAA") - } finally { - await Pty.remove(a.id) - } - }, + expect(outB.join("")).not.toContain("AAA") + } finally { + yield* pty.remove(a.id) + } + }), + ), }) }) @@ -104,38 +107,40 @@ describe("pty", () => { await Instance.provide({ directory: dir.path, - fn: async () => { - const a = await Pty.create({ command: "cat", title: "a" }) - try { - const out: string[] = [] + fn: () => + AppRuntime.runPromise( + Effect.gen(function* () { + const pty = yield* Pty.Service + const a = yield* pty.create({ command: "cat", title: "a" }) + try { + const out: string[] = [] - const ctx = { connId: 1 } - const ws = { - readyState: 1, - data: ctx, - send: (data: unknown) => { - out.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8")) - }, - close: () => { - // no-op - }, - } + const ctx = { connId: 1 } + const ws = { + readyState: 1, + data: ctx, + send: (data: unknown) => { + out.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8")) + }, + close: () => { + // no-op + }, + } - Pty.connect(a.id, ws as any) - out.length = 0 + yield* pty.connect(a.id, ws as any) + out.length = 0 - // Mutating fields on ws.data should not look like a new - // connection lifecycle when the object identity stays stable. - ctx.connId = 2 + ctx.connId = 2 - Pty.write(a.id, "AAA\n") - await sleep(100) + yield* pty.write(a.id, "AAA\n") + yield* Effect.promise(() => sleep(100)) - expect(out.join("")).toContain("AAA") - } finally { - await Pty.remove(a.id) - } - }, + expect(out.join("")).toContain("AAA") + } finally { + yield* pty.remove(a.id) + } + }), + ), }) }) }) diff --git a/packages/opencode/test/pty/pty-session.test.ts b/packages/opencode/test/pty/pty-session.test.ts index f7a949c921..3e4d658355 100644 --- a/packages/opencode/test/pty/pty-session.test.ts +++ b/packages/opencode/test/pty/pty-session.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from "bun:test" +import { AppRuntime } from "../../src/effect/app-runtime" import { Bus } from "../../src/bus" +import { Effect } from "effect" import { Instance } from "../../src/project/instance" import { Pty } from "../../src/pty" import type { PtyID } from "../../src/pty/schema" @@ -27,33 +29,37 @@ describe("pty", () => { await Instance.provide({ directory: dir.path, - fn: async () => { - const log: Array<{ type: "created" | "exited" | "deleted"; id: PtyID }> = [] - const off = [ - Bus.subscribe(Pty.Event.Created, (evt) => log.push({ type: "created", id: evt.properties.info.id })), - Bus.subscribe(Pty.Event.Exited, (evt) => log.push({ type: "exited", id: evt.properties.id })), - Bus.subscribe(Pty.Event.Deleted, (evt) => log.push({ type: "deleted", id: evt.properties.id })), - ] + fn: () => + AppRuntime.runPromise( + Effect.gen(function* () { + const pty = yield* Pty.Service + const log: Array<{ type: "created" | "exited" | "deleted"; id: PtyID }> = [] + const off = [ + Bus.subscribe(Pty.Event.Created, (evt) => log.push({ type: "created", id: evt.properties.info.id })), + Bus.subscribe(Pty.Event.Exited, (evt) => log.push({ type: "exited", id: evt.properties.id })), + Bus.subscribe(Pty.Event.Deleted, (evt) => log.push({ type: "deleted", id: evt.properties.id })), + ] - let id: PtyID | undefined - try { - const info = await Pty.create({ - command: "/usr/bin/env", - args: ["sh", "-c", "sleep 0.1"], - title: "sleep", - }) - id = info.id + let id: PtyID | undefined + try { + const info = yield* pty.create({ + command: "/usr/bin/env", + args: ["sh", "-c", "sleep 0.1"], + title: "sleep", + }) + id = info.id - await wait(() => pick(log, id!).includes("exited")) + yield* Effect.promise(() => wait(() => pick(log, id!).includes("exited"))) - await Pty.remove(id) - await wait(() => pick(log, id!).length >= 3) - expect(pick(log, id!)).toEqual(["created", "exited", "deleted"]) - } finally { - off.forEach((x) => x()) - if (id) await Pty.remove(id) - } - }, + yield* pty.remove(id) + yield* Effect.promise(() => wait(() => pick(log, id!).length >= 3)) + expect(pick(log, id!)).toEqual(["created", "exited", "deleted"]) + } finally { + off.forEach((x) => x()) + if (id) yield* pty.remove(id) + } + }), + ), }) }) @@ -64,29 +70,33 @@ describe("pty", () => { await Instance.provide({ directory: dir.path, - fn: async () => { - const log: Array<{ type: "created" | "exited" | "deleted"; id: PtyID }> = [] - const off = [ - Bus.subscribe(Pty.Event.Created, (evt) => log.push({ type: "created", id: evt.properties.info.id })), - Bus.subscribe(Pty.Event.Exited, (evt) => log.push({ type: "exited", id: evt.properties.id })), - Bus.subscribe(Pty.Event.Deleted, (evt) => log.push({ type: "deleted", id: evt.properties.id })), - ] + fn: () => + AppRuntime.runPromise( + Effect.gen(function* () { + const pty = yield* Pty.Service + const log: Array<{ type: "created" | "exited" | "deleted"; id: PtyID }> = [] + const off = [ + Bus.subscribe(Pty.Event.Created, (evt) => log.push({ type: "created", id: evt.properties.info.id })), + Bus.subscribe(Pty.Event.Exited, (evt) => log.push({ type: "exited", id: evt.properties.id })), + Bus.subscribe(Pty.Event.Deleted, (evt) => log.push({ type: "deleted", id: evt.properties.id })), + ] - let id: PtyID | undefined - try { - const info = await Pty.create({ command: "/bin/sh", title: "sh" }) - id = info.id + let id: PtyID | undefined + try { + const info = yield* pty.create({ command: "/bin/sh", title: "sh" }) + id = info.id - await sleep(100) + yield* Effect.promise(() => sleep(100)) - await Pty.remove(id) - await wait(() => pick(log, id!).length >= 3) - expect(pick(log, id!)).toEqual(["created", "exited", "deleted"]) - } finally { - off.forEach((x) => x()) - if (id) await Pty.remove(id) - } - }, + yield* pty.remove(id) + yield* Effect.promise(() => wait(() => pick(log, id!).length >= 3)) + expect(pick(log, id!)).toEqual(["created", "exited", "deleted"]) + } finally { + off.forEach((x) => x()) + if (id) yield* pty.remove(id) + } + }), + ), }) }) }) diff --git a/packages/opencode/test/pty/pty-shell.test.ts b/packages/opencode/test/pty/pty-shell.test.ts index 65e7e1f901..d5182061d0 100644 --- a/packages/opencode/test/pty/pty-shell.test.ts +++ b/packages/opencode/test/pty/pty-shell.test.ts @@ -1,4 +1,6 @@ import { describe, expect, test } from "bun:test" +import { AppRuntime } from "../../src/effect/app-runtime" +import { Effect } from "effect" import { Instance } from "../../src/project/instance" import { Pty } from "../../src/pty" import { Shell } from "../../src/shell/shell" @@ -17,14 +19,18 @@ describe("pty shell args", () => { await using dir = await tmpdir() await Instance.provide({ directory: dir.path, - fn: async () => { - const info = await Pty.create({ command: ps, title: "pwsh" }) - try { - expect(info.args).toEqual([]) - } finally { - await Pty.remove(info.id) - } - }, + fn: () => + AppRuntime.runPromise( + Effect.gen(function* () { + const pty = yield* Pty.Service + const info = yield* pty.create({ command: ps, title: "pwsh" }) + try { + expect(info.args).toEqual([]) + } finally { + yield* pty.remove(info.id) + } + }), + ), }) }, { timeout: 30000 }, @@ -43,14 +49,18 @@ describe("pty shell args", () => { await using dir = await tmpdir() await Instance.provide({ directory: dir.path, - fn: async () => { - const info = await Pty.create({ command: bash, title: "bash" }) - try { - expect(info.args).toEqual(["-l"]) - } finally { - await Pty.remove(info.id) - } - }, + fn: () => + AppRuntime.runPromise( + Effect.gen(function* () { + const pty = yield* Pty.Service + const info = yield* pty.create({ command: bash, title: "bash" }) + try { + expect(info.args).toEqual(["-l"]) + } finally { + yield* pty.remove(info.id) + } + }), + ), }) }, { timeout: 30000 }, From c22e34853df71b4d31825614bea61e7c9184f0ba Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 13 Apr 2026 12:31:43 -0400 Subject: [PATCH 08/11] refactor(auth): remove async auth facade exports (#22306) --- packages/opencode/src/auth/index.ts | 19 --- packages/opencode/src/cli/cmd/providers.ts | 50 +++++-- packages/opencode/src/server/control/index.ts | 16 ++- packages/opencode/src/session/llm.ts | 26 ++-- packages/opencode/test/auth/auth.test.ts | 132 +++++++++++------- packages/opencode/test/bus/bus-effect.test.ts | 6 +- packages/opencode/test/skill/skill.test.ts | 6 +- packages/opencode/test/tool/registry.test.ts | 6 +- packages/opencode/test/tool/skill.test.ts | 6 +- 9 files changed, 158 insertions(+), 109 deletions(-) diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index 2e83fe287e..b1502da78c 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -1,6 +1,5 @@ import path from "path" import { Effect, Layer, Record, Result, Schema, Context } from "effect" -import { makeRuntime } from "@/effect/run-service" import { zod } from "@/util/effect-zod" import { Global } from "../global" import { AppFileSystem } from "../filesystem" @@ -89,22 +88,4 @@ export namespace Auth { ) export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer)) - - const { runPromise } = makeRuntime(Service, defaultLayer) - - export async function get(providerID: string) { - return runPromise((service) => service.get(providerID)) - } - - export async function all(): Promise> { - return runPromise((service) => service.all()) - } - - export async function set(key: string, info: Info) { - return runPromise((service) => service.set(key, info)) - } - - export async function remove(key: string) { - return runPromise((service) => service.remove(key)) - } } diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 52da441904..829e4e1b42 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -1,4 +1,5 @@ import { Auth } from "../../auth" +import { AppRuntime } from "../../effect/app-runtime" import { cmd } from "./cmd" import * as prompts from "@clack/prompts" import { UI } from "../ui" @@ -13,9 +14,18 @@ import { Instance } from "../../project/instance" import type { Hooks } from "@opencode-ai/plugin" import { Process } from "../../util/process" import { text } from "node:stream/consumers" +import { Effect } from "effect" type PluginAuth = NonNullable +const put = (key: string, info: Auth.Info) => + AppRuntime.runPromise( + Effect.gen(function* () { + const auth = yield* Auth.Service + yield* auth.set(key, info) + }), + ) + async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, methodName?: string): Promise { let index = 0 if (methodName) { @@ -93,7 +103,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, const saveProvider = result.provider ?? provider if ("refresh" in result) { const { type: _, provider: __, refresh, access, expires, ...extraFields } = result - await Auth.set(saveProvider, { + await put(saveProvider, { type: "oauth", refresh, access, @@ -102,7 +112,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, }) } if ("key" in result) { - await Auth.set(saveProvider, { + await put(saveProvider, { type: "api", key: result.key, }) @@ -125,7 +135,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, const saveProvider = result.provider ?? provider if ("refresh" in result) { const { type: _, provider: __, refresh, access, expires, ...extraFields } = result - await Auth.set(saveProvider, { + await put(saveProvider, { type: "oauth", refresh, access, @@ -134,7 +144,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, }) } if ("key" in result) { - await Auth.set(saveProvider, { + await put(saveProvider, { type: "api", key: result.key, }) @@ -161,7 +171,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, } if (result.type === "success") { const saveProvider = result.provider ?? provider - await Auth.set(saveProvider, { + await put(saveProvider, { type: "api", key: result.key ?? key, }) @@ -221,7 +231,12 @@ export const ProvidersListCommand = cmd({ const homedir = os.homedir() const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`) - const results = Object.entries(await Auth.all()) + const results = await AppRuntime.runPromise( + Effect.gen(function* () { + const auth = yield* Auth.Service + return Object.entries(yield* auth.all()) + }), + ) const database = await ModelsDev.get() for (const [providerID, result] of results) { @@ -300,7 +315,7 @@ export const ProvidersLoginCommand = cmd({ prompts.outro("Done") return } - await Auth.set(url, { + await put(url, { type: "wellknown", key: wellknown.auth.env, token: token.trim(), @@ -447,7 +462,7 @@ export const ProvidersLoginCommand = cmd({ validate: (x) => (x && x.length > 0 ? undefined : "Required"), }) if (prompts.isCancel(key)) throw new UI.CancelledError() - await Auth.set(provider, { + await put(provider, { type: "api", key, }) @@ -463,22 +478,33 @@ export const ProvidersLogoutCommand = cmd({ describe: "log out from a configured provider", async handler(_args) { UI.empty() - const credentials = await Auth.all().then((x) => Object.entries(x)) + const credentials: Array<[string, Auth.Info]> = await AppRuntime.runPromise( + Effect.gen(function* () { + const auth = yield* Auth.Service + return Object.entries(yield* auth.all()) + }), + ) prompts.intro("Remove credential") if (credentials.length === 0) { prompts.log.error("No credentials found") return } const database = await ModelsDev.get() - const providerID = await prompts.select({ + const selected = await prompts.select({ message: "Select provider", options: credentials.map(([key, value]) => ({ label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")", value: key, })), }) - if (prompts.isCancel(providerID)) throw new UI.CancelledError() - await Auth.remove(providerID) + if (prompts.isCancel(selected)) throw new UI.CancelledError() + const providerID = selected as string + await AppRuntime.runPromise( + Effect.gen(function* () { + const auth = yield* Auth.Service + yield* auth.remove(providerID) + }), + ) prompts.outro("Logout successful") }, }) diff --git a/packages/opencode/src/server/control/index.ts b/packages/opencode/src/server/control/index.ts index aae77f2f05..cf8949c954 100644 --- a/packages/opencode/src/server/control/index.ts +++ b/packages/opencode/src/server/control/index.ts @@ -1,5 +1,7 @@ import { Auth } from "@/auth" +import { AppRuntime } from "@/effect/app-runtime" import { Log } from "@/util/log" +import { Effect } from "effect" import { ProviderID } from "@/provider/schema" import { Hono } from "hono" import { describeRoute, resolver, validator, openAPIRouteHandler } from "hono-openapi" @@ -39,7 +41,12 @@ export function ControlPlaneRoutes(): Hono { async (c) => { const providerID = c.req.valid("param").providerID const info = c.req.valid("json") - await Auth.set(providerID, info) + await AppRuntime.runPromise( + Effect.gen(function* () { + const auth = yield* Auth.Service + yield* auth.set(providerID, info) + }), + ) return c.json(true) }, ) @@ -69,7 +76,12 @@ export function ControlPlaneRoutes(): Hono { ), async (c) => { const providerID = c.req.valid("param").providerID - await Auth.remove(providerID) + await AppRuntime.runPromise( + Effect.gen(function* () { + const auth = yield* Auth.Service + yield* auth.remove(providerID) + }), + ) return c.json(true) }, ) diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index f6e5c9a3f2..c3607e1770 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -94,14 +94,24 @@ export namespace LLM { modelID: input.model.id, providerID: input.model.providerID, }) - const [language, cfg, provider, auth] = await Promise.all([ - Provider.getLanguage(input.model), - Config.get(), - Provider.getProvider(input.model.providerID), - Auth.get(input.model.providerID), - ]) + const [language, cfg, provider, info] = await Effect.runPromise( + Effect.gen(function* () { + const auth = yield* Auth.Service + const cfg = yield* Config.Service + const provider = yield* Provider.Service + return yield* Effect.all( + [ + provider.getLanguage(input.model), + cfg.get(), + provider.getProvider(input.model.providerID), + auth.get(input.model.providerID), + ], + { concurrency: "unbounded" }, + ) + }).pipe(Effect.provide(Layer.mergeAll(Auth.defaultLayer, Config.defaultLayer, Provider.defaultLayer))), + ) // TODO: move this to a proper hook - const isOpenaiOauth = provider.id === "openai" && auth?.type === "oauth" + const isOpenaiOauth = provider.id === "openai" && info?.type === "oauth" const system: string[] = [] system.push( @@ -200,7 +210,7 @@ export namespace LLM { }, ) - const tools = await resolveTools(input) + const tools = resolveTools(input) // LiteLLM and some Anthropic proxies require the tools parameter to be present // when message history contains tool calls, even if no tools are being used. diff --git a/packages/opencode/test/auth/auth.test.ts b/packages/opencode/test/auth/auth.test.ts index a569c71139..864649d7ae 100644 --- a/packages/opencode/test/auth/auth.test.ts +++ b/packages/opencode/test/auth/auth.test.ts @@ -1,58 +1,86 @@ -import { test, expect } from "bun:test" +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" import { Auth } from "../../src/auth" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" -test("set normalizes trailing slashes in keys", async () => { - await Auth.set("https://example.com/", { - type: "wellknown", - key: "TOKEN", - token: "abc", - }) - const data = await Auth.all() - expect(data["https://example.com"]).toBeDefined() - expect(data["https://example.com/"]).toBeUndefined() -}) +const node = CrossSpawnSpawner.defaultLayer -test("set cleans up pre-existing trailing-slash entry", async () => { - // Simulate a pre-fix entry with trailing slash - await Auth.set("https://example.com/", { - type: "wellknown", - key: "TOKEN", - token: "old", - }) - // Re-login with normalized key (as the CLI does post-fix) - await Auth.set("https://example.com", { - type: "wellknown", - key: "TOKEN", - token: "new", - }) - const data = await Auth.all() - const keys = Object.keys(data).filter((k) => k.includes("example.com")) - expect(keys).toEqual(["https://example.com"]) - const entry = data["https://example.com"]! - expect(entry.type).toBe("wellknown") - if (entry.type === "wellknown") expect(entry.token).toBe("new") -}) +const it = testEffect(Layer.mergeAll(Auth.defaultLayer, node)) -test("remove deletes both trailing-slash and normalized keys", async () => { - await Auth.set("https://example.com", { - type: "wellknown", - key: "TOKEN", - token: "abc", - }) - await Auth.remove("https://example.com/") - const data = await Auth.all() - expect(data["https://example.com"]).toBeUndefined() - expect(data["https://example.com/"]).toBeUndefined() -}) +describe("Auth", () => { + it.live("set normalizes trailing slashes in keys", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const auth = yield* Auth.Service + yield* auth.set("https://example.com/", { + type: "wellknown", + key: "TOKEN", + token: "abc", + }) + const data = yield* auth.all() + expect(data["https://example.com"]).toBeDefined() + expect(data["https://example.com/"]).toBeUndefined() + }), + ), + ) -test("set and remove are no-ops on keys without trailing slashes", async () => { - await Auth.set("anthropic", { - type: "api", - key: "sk-test", - }) - const data = await Auth.all() - expect(data["anthropic"]).toBeDefined() - await Auth.remove("anthropic") - const after = await Auth.all() - expect(after["anthropic"]).toBeUndefined() + it.live("set cleans up pre-existing trailing-slash entry", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const auth = yield* Auth.Service + yield* auth.set("https://example.com/", { + type: "wellknown", + key: "TOKEN", + token: "old", + }) + yield* auth.set("https://example.com", { + type: "wellknown", + key: "TOKEN", + token: "new", + }) + const data = yield* auth.all() + const keys = Object.keys(data).filter((key) => key.includes("example.com")) + expect(keys).toEqual(["https://example.com"]) + const entry = data["https://example.com"]! + expect(entry.type).toBe("wellknown") + if (entry.type === "wellknown") expect(entry.token).toBe("new") + }), + ), + ) + + it.live("remove deletes both trailing-slash and normalized keys", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const auth = yield* Auth.Service + yield* auth.set("https://example.com", { + type: "wellknown", + key: "TOKEN", + token: "abc", + }) + yield* auth.remove("https://example.com/") + const data = yield* auth.all() + expect(data["https://example.com"]).toBeUndefined() + expect(data["https://example.com/"]).toBeUndefined() + }), + ), + ) + + it.live("set and remove are no-ops on keys without trailing slashes", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const auth = yield* Auth.Service + yield* auth.set("anthropic", { + type: "api", + key: "sk-test", + }) + const data = yield* auth.all() + expect(data["anthropic"]).toBeDefined() + yield* auth.remove("anthropic") + const after = yield* auth.all() + expect(after["anthropic"]).toBeUndefined() + }), + ), + ) }) diff --git a/packages/opencode/test/bus/bus-effect.test.ts b/packages/opencode/test/bus/bus-effect.test.ts index 6f3bcbcfab..6f96a89c87 100644 --- a/packages/opencode/test/bus/bus-effect.test.ts +++ b/packages/opencode/test/bus/bus-effect.test.ts @@ -1,10 +1,10 @@ -import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node" import { describe, expect } from "bun:test" import { Deferred, Effect, Layer, Stream } from "effect" import z from "zod" import { Bus } from "../../src/bus" import { BusEvent } from "../../src/bus/bus-event" import { Instance } from "../../src/project/instance" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -13,9 +13,7 @@ const TestEvent = { Pong: BusEvent.define("test.effect.pong", z.object({ message: z.string() })), } -const node = NodeChildProcessSpawner.layer.pipe( - Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)), -) +const node = CrossSpawnSpawner.defaultLayer const live = Layer.mergeAll(Bus.layer, node) diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index 0a14e30b7d..21c6c7e651 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -1,15 +1,13 @@ -import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node" import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { Skill } from "../../src/skill" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { provideInstance, provideTmpdirInstance, tmpdir } from "../fixture/fixture" import { testEffect } from "../lib/effect" import path from "path" import fs from "fs/promises" -const node = NodeChildProcessSpawner.layer.pipe( - Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)), -) +const node = CrossSpawnSpawner.defaultLayer const it = testEffect(Layer.mergeAll(Skill.defaultLayer, node)) diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 5b59e314e1..dea84bdcd4 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -1,16 +1,14 @@ -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 { Effect, Layer } from "effect" import { Instance } from "../../src/project/instance" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" 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 node = CrossSpawnSpawner.defaultLayer const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node)) diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index 1cebf342db..b8b1394edf 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -1,7 +1,7 @@ -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" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { Ripgrep } from "../../src/file/ripgrep" import { Truncate } from "../../src/tool/truncate" import { afterEach, describe, expect, test } from "bun:test" @@ -30,9 +30,7 @@ afterEach(async () => { await Instance.disposeAll() }) -const node = NodeChildProcessSpawner.layer.pipe( - Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)), -) +const node = CrossSpawnSpawner.defaultLayer const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node)) From 5bc2d2498d06e50dda17c4daca73df1648ab971b Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 13 Apr 2026 11:34:38 -0500 Subject: [PATCH 09/11] test: ensure project and global instructions are loaded (#22317) --- .../opencode/test/session/instruction.test.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/packages/opencode/test/session/instruction.test.ts b/packages/opencode/test/session/instruction.test.ts index 4ba3b78e42..c46bbd20bd 100644 --- a/packages/opencode/test/session/instruction.test.ts +++ b/packages/opencode/test/session/instruction.test.ts @@ -219,6 +219,59 @@ describe("Instruction.resolve", () => { test.todo("fetches remote instructions from config URLs via HttpClient", () => {}) }) +describe("Instruction.system", () => { + test("loads both project and global AGENTS.md when both exist", async () => { + const originalConfigDir = process.env["OPENCODE_CONFIG_DIR"] + delete process.env["OPENCODE_CONFIG_DIR"] + + await using globalTmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions") + }, + }) + await using projectTmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "AGENTS.md"), "# Project Instructions") + }, + }) + + const originalGlobalConfig = Global.Path.config + ;(Global.Path as { config: string }).config = globalTmp.path + + try { + await Instance.provide({ + directory: projectTmp.path, + fn: () => + run( + Instruction.Service.use((svc) => + Effect.gen(function* () { + const paths = yield* svc.systemPaths() + expect(paths.has(path.join(projectTmp.path, "AGENTS.md"))).toBe(true) + expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true) + + const rules = yield* svc.system() + expect(rules).toHaveLength(2) + expect(rules).toContain( + `Instructions from: ${path.join(projectTmp.path, "AGENTS.md")}\n# Project Instructions`, + ) + expect(rules).toContain( + `Instructions from: ${path.join(globalTmp.path, "AGENTS.md")}\n# Global Instructions`, + ) + }), + ), + ), + }) + } finally { + ;(Global.Path as { config: string }).config = originalGlobalConfig + if (originalConfigDir === undefined) { + delete process.env["OPENCODE_CONFIG_DIR"] + } else { + process.env["OPENCODE_CONFIG_DIR"] = originalConfigDir + } + } + }) +}) + describe("Instruction.systemPaths OPENCODE_CONFIG_DIR", () => { let originalConfigDir: string | undefined From 663e798e7624c345dd302fb316c67c7ce10551b0 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 13 Apr 2026 12:40:00 -0400 Subject: [PATCH 10/11] refactor(provider): remove async facade exports (#22320) --- packages/opencode/src/cli/cmd/debug/agent.ts | 9 +- packages/opencode/src/cli/cmd/models.ts | 72 ++++--- packages/opencode/src/provider/provider.ts | 31 --- .../opencode/src/server/instance/config.ts | 9 +- .../opencode/src/server/instance/provider.ts | 47 +++-- .../test/provider/amazon-bedrock.test.ts | 31 ++- .../opencode/test/provider/gitlab-duo.test.ts | 36 ++-- .../opencode/test/provider/provider.test.ts | 195 +++++++++++------- packages/opencode/test/session/llm.test.ts | 28 ++- .../test/session/prompt-effect.test.ts | 2 +- 10 files changed, 261 insertions(+), 199 deletions(-) diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts index 25a32d23b3..fbaf8d78dc 100644 --- a/packages/opencode/src/cli/cmd/debug/agent.ts +++ b/packages/opencode/src/cli/cmd/debug/agent.ts @@ -125,7 +125,14 @@ function parseToolParams(input?: string) { async function createToolContext(agent: Agent.Info) { const session = await Session.create({ title: `Debug tool run (${agent.name})` }) const messageID = MessageID.ascending() - const model = agent.model ?? (await Provider.defaultModel()) + const model = + agent.model ?? + (await AppRuntime.runPromise( + Effect.gen(function* () { + const provider = yield* Provider.Service + return yield* provider.defaultModel() + }), + )) const now = Date.now() const message: MessageV2.Assistant = { id: messageID, diff --git a/packages/opencode/src/cli/cmd/models.ts b/packages/opencode/src/cli/cmd/models.ts index 4670aa5f2f..ad9300da2e 100644 --- a/packages/opencode/src/cli/cmd/models.ts +++ b/packages/opencode/src/cli/cmd/models.ts @@ -6,6 +6,8 @@ import { ModelsDev } from "../../provider/models" import { cmd } from "./cmd" import { UI } from "../ui" import { EOL } from "os" +import { AppRuntime } from "@/effect/app-runtime" +import { Effect } from "effect" export const ModelsCommand = cmd({ command: "models [provider]", @@ -35,43 +37,51 @@ export const ModelsCommand = cmd({ await Instance.provide({ directory: process.cwd(), async fn() { - const providers = await Provider.list() + await AppRuntime.runPromise( + Effect.gen(function* () { + const svc = yield* Provider.Service + const providers = yield* svc.list() - function printModels(providerID: ProviderID, verbose?: boolean) { - const provider = providers[providerID] - const sortedModels = Object.entries(provider.models).sort(([a], [b]) => a.localeCompare(b)) - for (const [modelID, model] of sortedModels) { - process.stdout.write(`${providerID}/${modelID}`) - process.stdout.write(EOL) - if (verbose) { - process.stdout.write(JSON.stringify(model, null, 2)) - process.stdout.write(EOL) + const print = (providerID: ProviderID, verbose?: boolean) => { + const provider = providers[providerID] + const sorted = Object.entries(provider.models).sort(([a], [b]) => a.localeCompare(b)) + for (const [modelID, model] of sorted) { + process.stdout.write(`${providerID}/${modelID}`) + process.stdout.write(EOL) + if (verbose) { + process.stdout.write(JSON.stringify(model, null, 2)) + process.stdout.write(EOL) + } + } } - } - } - if (args.provider) { - const provider = providers[ProviderID.make(args.provider)] - if (!provider) { - UI.error(`Provider not found: ${args.provider}`) - return - } + if (args.provider) { + const providerID = ProviderID.make(args.provider) + const provider = providers[providerID] + if (!provider) { + yield* Effect.sync(() => UI.error(`Provider not found: ${args.provider}`)) + return + } - printModels(ProviderID.make(args.provider), args.verbose) - return - } + yield* Effect.sync(() => print(providerID, args.verbose)) + return + } - const providerIDs = Object.keys(providers).sort((a, b) => { - const aIsOpencode = a.startsWith("opencode") - const bIsOpencode = b.startsWith("opencode") - if (aIsOpencode && !bIsOpencode) return -1 - if (!aIsOpencode && bIsOpencode) return 1 - return a.localeCompare(b) - }) + const ids = Object.keys(providers).sort((a, b) => { + const aIsOpencode = a.startsWith("opencode") + const bIsOpencode = b.startsWith("opencode") + if (aIsOpencode && !bIsOpencode) return -1 + if (!aIsOpencode && bIsOpencode) return 1 + return a.localeCompare(b) + }) - for (const providerID of providerIDs) { - printModels(ProviderID.make(providerID), args.verbose) - } + yield* Effect.sync(() => { + for (const providerID of ids) { + print(ProviderID.make(providerID), args.verbose) + } + }) + }), + ) }, }) }, diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index a26b254d5a..bf27f090ab 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -21,7 +21,6 @@ import path from "path" import { Effect, Layer, Context } from "effect" import { EffectLogger } from "@/effect/logger" import { InstanceState } from "@/effect/instance-state" -import { makeRuntime } from "@/effect/run-service" import { AppFileSystem } from "@/filesystem" import { isRecord } from "@/util/record" @@ -1693,36 +1692,6 @@ export namespace Provider { ), ) - const { runPromise } = makeRuntime(Service, defaultLayer) - - export async function list() { - return runPromise((svc) => svc.list()) - } - - export async function getProvider(providerID: ProviderID) { - return runPromise((svc) => svc.getProvider(providerID)) - } - - export async function getModel(providerID: ProviderID, modelID: ModelID) { - return runPromise((svc) => svc.getModel(providerID, modelID)) - } - - export async function getLanguage(model: Model) { - return runPromise((svc) => svc.getLanguage(model)) - } - - export async function closest(providerID: ProviderID, query: string[]) { - return runPromise((svc) => svc.closest(providerID, query)) - } - - export async function getSmallModel(providerID: ProviderID) { - return runPromise((svc) => svc.getSmallModel(providerID)) - } - - export async function defaultModel() { - return runPromise((svc) => svc.defaultModel()) - } - const priority = ["gpt-5", "claude-sonnet-4", "big-pickle", "gemini-3-pro"] export function sort(models: T[]) { return sortBy( diff --git a/packages/opencode/src/server/instance/config.ts b/packages/opencode/src/server/instance/config.ts index 85d28f6aa6..2b28ba450d 100644 --- a/packages/opencode/src/server/instance/config.ts +++ b/packages/opencode/src/server/instance/config.ts @@ -7,6 +7,8 @@ import { mapValues } from "remeda" import { errors } from "../error" import { Log } from "../../util/log" import { lazy } from "../../util/lazy" +import { AppRuntime } from "../../effect/app-runtime" +import { Effect } from "effect" const log = Log.create({ service: "server" }) @@ -82,7 +84,12 @@ export const ConfigRoutes = lazy(() => }), async (c) => { using _ = log.time("providers") - const providers = await Provider.list().then((x) => mapValues(x, (item) => item)) + const providers = await AppRuntime.runPromise( + Effect.gen(function* () { + const svc = yield* Provider.Service + return mapValues(yield* svc.list(), (item) => item) + }), + ) return c.json({ providers: Object.values(providers), default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id), diff --git a/packages/opencode/src/server/instance/provider.ts b/packages/opencode/src/server/instance/provider.ts index efd126ea0f..ca203d6a4b 100644 --- a/packages/opencode/src/server/instance/provider.ts +++ b/packages/opencode/src/server/instance/provider.ts @@ -11,6 +11,7 @@ import { mapValues } from "remeda" import { errors } from "../error" import { lazy } from "../../util/lazy" import { Log } from "../../util/log" +import { Effect } from "effect" const log = Log.create({ service: "server" }) @@ -40,27 +41,35 @@ export const ProviderRoutes = lazy(() => }, }), async (c) => { - const config = await Config.get() - const disabled = new Set(config.disabled_providers ?? []) - const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined - - const allProviders = await ModelsDev.get() - const filteredProviders: Record = {} - for (const [key, value] of Object.entries(allProviders)) { - if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) { - filteredProviders[key] = value - } - } - - const connected = await Provider.list() - const providers = Object.assign( - mapValues(filteredProviders, (x) => Provider.fromModelsDevProvider(x)), - connected, + const result = await AppRuntime.runPromise( + Effect.gen(function* () { + const svc = yield* Provider.Service + const config = yield* Effect.promise(() => Config.get()) + const all = yield* Effect.promise(() => ModelsDev.get()) + const disabled = new Set(config.disabled_providers ?? []) + const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined + const filtered: Record = {} + for (const [key, value] of Object.entries(all)) { + if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) { + filtered[key] = value + } + } + const connected = yield* svc.list() + const providers = Object.assign( + mapValues(filtered, (x) => Provider.fromModelsDevProvider(x)), + connected, + ) + return { + all: Object.values(providers), + default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id), + connected: Object.keys(connected), + } + }), ) return c.json({ - all: Object.values(providers), - default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id), - connected: Object.keys(connected), + all: result.all, + default: result.default, + connected: result.connected, }) }, ) diff --git a/packages/opencode/test/provider/amazon-bedrock.test.ts b/packages/opencode/test/provider/amazon-bedrock.test.ts index 3358e92300..712f36086f 100644 --- a/packages/opencode/test/provider/amazon-bedrock.test.ts +++ b/packages/opencode/test/provider/amazon-bedrock.test.ts @@ -9,6 +9,17 @@ import { Provider } from "../../src/provider/provider" import { Env } from "../../src/env" import { Global } from "../../src/global" import { Filesystem } from "../../src/util/filesystem" +import { Effect } from "effect" +import { AppRuntime } from "../../src/effect/app-runtime" + +async function list() { + return AppRuntime.runPromise( + Effect.gen(function* () { + const provider = yield* Provider.Service + return yield* provider.list() + }), + ) +} test("Bedrock: config region takes precedence over AWS_REGION env var", async () => { await using tmp = await tmpdir({ @@ -35,7 +46,7 @@ test("Bedrock: config region takes precedence over AWS_REGION env var", async () Env.set("AWS_PROFILE", "default") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1") }, @@ -60,7 +71,7 @@ test("Bedrock: falls back to AWS_REGION env var when no config region", async () Env.set("AWS_PROFILE", "default") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1") }, @@ -116,7 +127,7 @@ test("Bedrock: loads when bearer token from auth.json is present", async () => { Env.set("AWS_BEARER_TOKEN_BEDROCK", "") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1") }, @@ -161,7 +172,7 @@ test("Bedrock: config profile takes precedence over AWS_PROFILE env var", async Env.set("AWS_ACCESS_KEY_ID", "test-key-id") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.region).toBe("us-east-1") }, @@ -192,7 +203,7 @@ test("Bedrock: includes custom endpoint in options when specified", async () => Env.set("AWS_PROFILE", "default") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.endpoint).toBe( "https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com", @@ -228,7 +239,7 @@ test("Bedrock: autoloads when AWS_WEB_IDENTITY_TOKEN_FILE is present", async () Env.set("AWS_ACCESS_KEY_ID", "") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.region).toBe("us-east-1") }, @@ -268,7 +279,7 @@ test("Bedrock: model with us. prefix should not be double-prefixed", async () => Env.set("AWS_PROFILE", "default") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() // The model should exist with the us. prefix expect(providers[ProviderID.amazonBedrock].models["us.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined() @@ -305,7 +316,7 @@ test("Bedrock: model with global. prefix should not be prefixed", async () => { Env.set("AWS_PROFILE", "default") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].models["global.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined() }, @@ -341,7 +352,7 @@ test("Bedrock: model with eu. prefix should not be double-prefixed", async () => Env.set("AWS_PROFILE", "default") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].models["eu.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined() }, @@ -377,7 +388,7 @@ test("Bedrock: model without prefix in US region should get us. prefix added", a Env.set("AWS_PROFILE", "default") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() // Non-prefixed model should still be registered expect(providers[ProviderID.amazonBedrock].models["anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined() diff --git a/packages/opencode/test/provider/gitlab-duo.test.ts b/packages/opencode/test/provider/gitlab-duo.test.ts index b669a1e21a..9b5441fe22 100644 --- a/packages/opencode/test/provider/gitlab-duo.test.ts +++ b/packages/opencode/test/provider/gitlab-duo.test.ts @@ -30,7 +30,7 @@ // Env.set("GITLAB_TOKEN", "test-gitlab-token") // }, // fn: async () => { -// const providers = await Provider.list() +// const providers = await list() // expect(providers[ProviderID.gitlab]).toBeDefined() // expect(providers[ProviderID.gitlab].key).toBe("test-gitlab-token") // }, @@ -62,7 +62,7 @@ // Env.set("GITLAB_INSTANCE_URL", "https://gitlab.example.com") // }, // fn: async () => { -// const providers = await Provider.list() +// const providers = await list() // expect(providers[ProviderID.gitlab]).toBeDefined() // expect(providers[ProviderID.gitlab].options?.instanceUrl).toBe("https://gitlab.example.com") // }, @@ -100,7 +100,7 @@ // Env.set("GITLAB_TOKEN", "") // }, // fn: async () => { -// const providers = await Provider.list() +// const providers = await list() // expect(providers[ProviderID.gitlab]).toBeDefined() // }, // }) @@ -135,7 +135,7 @@ // Env.set("GITLAB_TOKEN", "") // }, // fn: async () => { -// const providers = await Provider.list() +// const providers = await list() // expect(providers[ProviderID.gitlab]).toBeDefined() // expect(providers[ProviderID.gitlab].key).toBe("glpat-test-pat-token") // }, @@ -167,7 +167,7 @@ // Env.set("GITLAB_INSTANCE_URL", "https://gitlab.company.internal") // }, // fn: async () => { -// const providers = await Provider.list() +// const providers = await list() // expect(providers[ProviderID.gitlab]).toBeDefined() // expect(providers[ProviderID.gitlab].options?.instanceUrl).toBe("https://gitlab.company.internal") // }, @@ -198,7 +198,7 @@ // Env.set("GITLAB_TOKEN", "env-token") // }, // fn: async () => { -// const providers = await Provider.list() +// const providers = await list() // expect(providers[ProviderID.gitlab]).toBeDefined() // }, // }) @@ -221,7 +221,7 @@ // Env.set("GITLAB_TOKEN", "test-token") // }, // fn: async () => { -// const providers = await Provider.list() +// const providers = await list() // expect(providers[ProviderID.gitlab]).toBeDefined() // expect(providers[ProviderID.gitlab].options?.aiGatewayHeaders?.["anthropic-beta"]).toContain( // "context-1m-2025-08-07", @@ -257,7 +257,7 @@ // Env.set("GITLAB_TOKEN", "test-token") // }, // fn: async () => { -// const providers = await Provider.list() +// const providers = await list() // expect(providers[ProviderID.gitlab]).toBeDefined() // expect(providers[ProviderID.gitlab].options?.featureFlags).toBeDefined() // expect(providers[ProviderID.gitlab].options?.featureFlags?.duo_agent_platform_agentic_chat).toBe(true) @@ -282,7 +282,7 @@ // Env.set("GITLAB_TOKEN", "test-token") // }, // fn: async () => { -// const providers = await Provider.list() +// const providers = await list() // expect(providers[ProviderID.gitlab]).toBeDefined() // const models = Object.keys(providers[ProviderID.gitlab].models) // expect(models.length).toBeGreaterThan(0) @@ -306,7 +306,7 @@ // Env.set("GITLAB_TOKEN", "test-token") // }, // fn: async () => { -// const providers = await Provider.list() +// const providers = await list() // const gitlab = providers[ProviderID.gitlab] // expect(gitlab).toBeDefined() // gitlab.models["duo-workflow-sonnet-4-6"] = { @@ -332,10 +332,10 @@ // release_date: "", // variants: {}, // } -// const model = await Provider.getModel(ProviderID.gitlab, ModelID.make("duo-workflow-sonnet-4-6")) +// const model = await getModel(ProviderID.gitlab, ModelID.make("duo-workflow-sonnet-4-6")) // expect(model).toBeDefined() // expect(model.options?.workflowRef).toBe("claude_sonnet_4_6") -// const language = await Provider.getLanguage(model) +// const language = await getLanguage(model) // expect(language).toBeDefined() // expect(language).toBeInstanceOf(GitLabWorkflowLanguageModel) // }, @@ -354,11 +354,11 @@ // Env.set("GITLAB_TOKEN", "test-token") // }, // fn: async () => { -// const providers = await Provider.list() +// const providers = await list() // expect(providers[ProviderID.gitlab]).toBeDefined() -// const model = await Provider.getModel(ProviderID.gitlab, ModelID.make("duo-chat-sonnet-4-5")) +// const model = await getModel(ProviderID.gitlab, ModelID.make("duo-chat-sonnet-4-5")) // expect(model).toBeDefined() -// const language = await Provider.getLanguage(model) +// const language = await getLanguage(model) // expect(language).toBeDefined() // expect(language).not.toBeInstanceOf(GitLabWorkflowLanguageModel) // }, @@ -377,10 +377,10 @@ // Env.set("GITLAB_TOKEN", "test-token") // }, // fn: async () => { -// const providers = await Provider.list() +// const providers = await list() // const gitlab = providers[ProviderID.gitlab] // expect(gitlab.options?.featureFlags).toBeDefined() -// const model = await Provider.getModel(ProviderID.gitlab, ModelID.make("duo-chat-sonnet-4-5")) +// const model = await getModel(ProviderID.gitlab, ModelID.make("duo-chat-sonnet-4-5")) // expect(model).toBeDefined() // expect(model.options).toBeDefined() // }, @@ -401,7 +401,7 @@ // Env.set("GITLAB_TOKEN", "test-token") // }, // fn: async () => { -// const providers = await Provider.list() +// const providers = await list() // const models = Object.keys(providers[ProviderID.gitlab].models) // expect(models).toContain("duo-chat-haiku-4-5") // expect(models).toContain("duo-chat-sonnet-4-5") diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 9cadc391a1..73e77be5fd 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -11,8 +11,47 @@ import { Provider } from "../../src/provider/provider" import { ProviderID, ModelID } from "../../src/provider/schema" import { Filesystem } from "../../src/util/filesystem" import { Env } from "../../src/env" +import { Effect } from "effect" +import { AppRuntime } from "../../src/effect/app-runtime" -function paid(providers: Awaited>) { +async function run(fn: (provider: Provider.Interface) => Effect.Effect) { + return AppRuntime.runPromise( + Effect.gen(function* () { + const provider = yield* Provider.Service + return yield* fn(provider) + }), + ) +} + +async function list() { + return run((provider) => provider.list()) +} + +async function getProvider(providerID: ProviderID) { + return run((provider) => provider.getProvider(providerID)) +} + +async function getModel(providerID: ProviderID, modelID: ModelID) { + return run((provider) => provider.getModel(providerID, modelID)) +} + +async function getLanguage(model: Provider.Model) { + return run((provider) => provider.getLanguage(model)) +} + +async function closest(providerID: ProviderID, query: string[]) { + return run((provider) => provider.closest(providerID, query)) +} + +async function getSmallModel(providerID: ProviderID) { + return run((provider) => provider.getSmallModel(providerID)) +} + +async function defaultModel() { + return run((provider) => provider.defaultModel()) +} + +function paid(providers: Awaited>) { const item = providers[ProviderID.make("opencode")] expect(item).toBeDefined() return Object.values(item.models).filter((model) => model.cost.input > 0).length @@ -35,7 +74,7 @@ test("provider loaded from env variable", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() // Provider should retain its connection source even if custom loaders // merge additional options. @@ -66,7 +105,7 @@ test("provider loaded from config with apiKey option", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() }, }) @@ -90,7 +129,7 @@ test("disabled_providers excludes provider", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.anthropic]).toBeUndefined() }, }) @@ -115,7 +154,7 @@ test("enabled_providers restricts to only listed providers", async () => { Env.set("OPENAI_API_KEY", "test-openai-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.openai]).toBeUndefined() }, @@ -144,7 +183,7 @@ test("model whitelist filters models for provider", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() const models = Object.keys(providers[ProviderID.anthropic].models) expect(models).toContain("claude-sonnet-4-20250514") @@ -175,7 +214,7 @@ test("model blacklist excludes specific models", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() const models = Object.keys(providers[ProviderID.anthropic].models) expect(models).not.toContain("claude-sonnet-4-20250514") @@ -210,7 +249,7 @@ test("custom model alias via config", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.anthropic].models["my-alias"]).toBeDefined() expect(providers[ProviderID.anthropic].models["my-alias"].name).toBe("My Custom Alias") @@ -253,7 +292,7 @@ test("custom provider with npm package", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.make("custom-provider")]).toBeDefined() expect(providers[ProviderID.make("custom-provider")].name).toBe("Custom Provider") expect(providers[ProviderID.make("custom-provider")].models["custom-model"]).toBeDefined() @@ -286,7 +325,7 @@ test("env variable takes precedence, config merges options", async () => { Env.set("ANTHROPIC_API_KEY", "env-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() // Config options should be merged expect(providers[ProviderID.anthropic].options.timeout).toBe(60000) @@ -312,11 +351,11 @@ test("getModel returns model for valid provider/model", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const model = await Provider.getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) + const model = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) expect(model).toBeDefined() expect(String(model.providerID)).toBe("anthropic") expect(String(model.id)).toBe("claude-sonnet-4-20250514") - const language = await Provider.getLanguage(model) + const language = await getLanguage(model) expect(language).toBeDefined() }, }) @@ -339,7 +378,7 @@ test("getModel throws ModelNotFoundError for invalid model", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - expect(Provider.getModel(ProviderID.anthropic, ModelID.make("nonexistent-model"))).rejects.toThrow() + expect(getModel(ProviderID.anthropic, ModelID.make("nonexistent-model"))).rejects.toThrow() }, }) }) @@ -358,7 +397,7 @@ test("getModel throws ModelNotFoundError for invalid provider", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - expect(Provider.getModel(ProviderID.make("nonexistent-provider"), ModelID.make("some-model"))).rejects.toThrow() + expect(getModel(ProviderID.make("nonexistent-provider"), ModelID.make("some-model"))).rejects.toThrow() }, }) }) @@ -392,7 +431,7 @@ test("defaultModel returns first available model when no config set", async () = Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const model = await Provider.defaultModel() + const model = await defaultModel() expect(model.providerID).toBeDefined() expect(model.modelID).toBeDefined() }, @@ -417,7 +456,7 @@ test("defaultModel respects config model setting", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const model = await Provider.defaultModel() + const model = await defaultModel() expect(String(model.providerID)).toBe("anthropic") expect(String(model.modelID)).toBe("claude-sonnet-4-20250514") }, @@ -456,7 +495,7 @@ test("provider with baseURL from config", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.make("custom-openai")]).toBeDefined() expect(providers[ProviderID.make("custom-openai")].options.baseURL).toBe("https://custom.openai.com/v1") }, @@ -494,7 +533,7 @@ test("model cost defaults to zero when not specified", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const providers = await Provider.list() + const providers = await list() const model = providers[ProviderID.make("test-provider")].models["test-model"] expect(model.cost.input).toBe(0) expect(model.cost.output).toBe(0) @@ -532,7 +571,7 @@ test("model options are merged from existing model", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.options.customOption).toBe("custom-value") }, @@ -561,7 +600,7 @@ test("provider removed when all models filtered out", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.anthropic]).toBeUndefined() }, }) @@ -584,7 +623,7 @@ test("closest finds model by partial match", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const result = await Provider.closest(ProviderID.anthropic, ["sonnet-4"]) + const result = await closest(ProviderID.anthropic, ["sonnet-4"]) expect(result).toBeDefined() expect(String(result?.providerID)).toBe("anthropic") expect(String(result?.modelID)).toContain("sonnet-4") @@ -606,7 +645,7 @@ test("closest returns undefined for nonexistent provider", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await Provider.closest(ProviderID.make("nonexistent"), ["model"]) + const result = await closest(ProviderID.make("nonexistent"), ["model"]) expect(result).toBeUndefined() }, }) @@ -639,10 +678,10 @@ test("getModel uses realIdByKey for aliased models", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.anthropic].models["my-sonnet"]).toBeDefined() - const model = await Provider.getModel(ProviderID.anthropic, ModelID.make("my-sonnet")) + const model = await getModel(ProviderID.anthropic, ModelID.make("my-sonnet")) expect(model).toBeDefined() expect(String(model.id)).toBe("my-sonnet") expect(model.name).toBe("My Sonnet Alias") @@ -682,7 +721,7 @@ test("provider api field sets model api.url", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const providers = await Provider.list() + const providers = await list() // api field is stored on model.api.url, used by getSDK to set baseURL expect(providers[ProviderID.make("custom-api")].models["model-1"].api.url).toBe("https://api.example.com/v1") }, @@ -722,7 +761,7 @@ test("explicit baseURL overrides api field", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.make("custom-api")].options.baseURL).toBe("https://custom.override.com/v1") }, }) @@ -754,7 +793,7 @@ test("model inherits properties from existing database model", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.name).toBe("Custom Name for Sonnet") expect(model.capabilities.toolcall).toBe(true) @@ -782,7 +821,7 @@ test("disabled_providers prevents loading even with env var", async () => { Env.set("OPENAI_API_KEY", "test-openai-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.openai]).toBeUndefined() }, }) @@ -807,7 +846,7 @@ test("enabled_providers with empty array allows no providers", async () => { Env.set("OPENAI_API_KEY", "test-openai-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(Object.keys(providers).length).toBe(0) }, }) @@ -836,7 +875,7 @@ test("whitelist and blacklist can be combined", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() const models = Object.keys(providers[ProviderID.anthropic].models) expect(models).toContain("claude-sonnet-4-20250514") @@ -875,7 +914,7 @@ test("model modalities default correctly", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const providers = await Provider.list() + const providers = await list() const model = providers[ProviderID.make("test-provider")].models["test-model"] expect(model.capabilities.input.text).toBe(true) expect(model.capabilities.output.text).toBe(true) @@ -918,7 +957,7 @@ test("model with custom cost values", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const providers = await Provider.list() + const providers = await list() const model = providers[ProviderID.make("test-provider")].models["test-model"] expect(model.cost.input).toBe(5) expect(model.cost.output).toBe(15) @@ -945,7 +984,7 @@ test("getSmallModel returns appropriate small model", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const model = await Provider.getSmallModel(ProviderID.anthropic) + const model = await getSmallModel(ProviderID.anthropic) expect(model).toBeDefined() expect(model?.id).toContain("haiku") }, @@ -970,7 +1009,7 @@ test("getSmallModel respects config small_model override", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const model = await Provider.getSmallModel(ProviderID.anthropic) + const model = await getSmallModel(ProviderID.anthropic) expect(model).toBeDefined() expect(String(model?.providerID)).toBe("anthropic") expect(String(model?.id)).toBe("claude-sonnet-4-20250514") @@ -1019,7 +1058,7 @@ test("multiple providers can be configured simultaneously", async () => { Env.set("OPENAI_API_KEY", "test-openai-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.openai]).toBeDefined() expect(providers[ProviderID.anthropic].options.timeout).toBe(30000) @@ -1060,7 +1099,7 @@ test("provider with custom npm package", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.make("local-llm")]).toBeDefined() expect(providers[ProviderID.make("local-llm")].models["llama-3"].api.npm).toBe("@ai-sdk/openai-compatible") expect(providers[ProviderID.make("local-llm")].options.baseURL).toBe("http://localhost:11434/v1") @@ -1097,7 +1136,7 @@ test("model alias name defaults to alias key when id differs", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.anthropic].models["sonnet"].name).toBe("sonnet") }, }) @@ -1137,7 +1176,7 @@ test("provider with multiple env var options only includes apiKey when single en Env.set("MULTI_ENV_KEY_1", "test-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.make("multi-env")]).toBeDefined() // When multiple env options exist, key should NOT be auto-set expect(providers[ProviderID.make("multi-env")].key).toBeUndefined() @@ -1179,7 +1218,7 @@ test("provider with single env var includes apiKey automatically", async () => { Env.set("SINGLE_ENV_KEY", "my-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.make("single-env")]).toBeDefined() // Single env option should auto-set key expect(providers[ProviderID.make("single-env")].key).toBe("my-api-key") @@ -1216,7 +1255,7 @@ test("model cost overrides existing cost values", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.cost.input).toBe(999) expect(model.cost.output).toBe(888) @@ -1263,7 +1302,7 @@ test("completely new provider not in database can be configured", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.make("brand-new-provider")]).toBeDefined() expect(providers[ProviderID.make("brand-new-provider")].name).toBe("Brand New") const model = providers[ProviderID.make("brand-new-provider")].models["new-model"] @@ -1297,7 +1336,7 @@ test("disabled_providers and enabled_providers interaction", async () => { Env.set("GOOGLE_GENERATIVE_AI_API_KEY", "test-google") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() // anthropic: in enabled, not in disabled = allowed expect(providers[ProviderID.anthropic]).toBeDefined() // openai: in enabled, but also in disabled = NOT allowed @@ -1337,7 +1376,7 @@ test("model with tool_call false", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.make("no-tools")].models["basic-model"].capabilities.toolcall).toBe(false) }, }) @@ -1372,7 +1411,7 @@ test("model defaults tool_call to true when not specified", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.make("default-tools")].models["model"].capabilities.toolcall).toBe(true) }, }) @@ -1411,7 +1450,7 @@ test("model headers are preserved", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const providers = await Provider.list() + const providers = await list() const model = providers[ProviderID.make("headers-provider")].models["model"] expect(model.headers).toEqual({ "X-Custom-Header": "custom-value", @@ -1454,7 +1493,7 @@ test("provider env fallback - second env var used if first missing", async () => Env.set("FALLBACK_KEY", "fallback-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() // Provider should load because fallback env var is set expect(providers[ProviderID.make("fallback-env")]).toBeDefined() }, @@ -1478,8 +1517,8 @@ test("getModel returns consistent results", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const model1 = await Provider.getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) - const model2 = await Provider.getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) + const model1 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) + const model2 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) expect(model1.providerID).toEqual(model2.providerID) expect(model1.id).toEqual(model2.id) expect(model1).toEqual(model2) @@ -1516,7 +1555,7 @@ test("provider name defaults to id when not in database", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.make("my-custom-id")].name).toBe("my-custom-id") }, }) @@ -1540,7 +1579,7 @@ test("ModelNotFoundError includes suggestions for typos", async () => { }, fn: async () => { try { - await Provider.getModel(ProviderID.anthropic, ModelID.make("claude-sonet-4")) // typo: sonet instead of sonnet + await getModel(ProviderID.anthropic, ModelID.make("claude-sonet-4")) // typo: sonet instead of sonnet expect(true).toBe(false) // Should not reach here } catch (e: any) { expect(e.data.suggestions).toBeDefined() @@ -1568,7 +1607,7 @@ test("ModelNotFoundError for provider includes suggestions", async () => { }, fn: async () => { try { - await Provider.getModel(ProviderID.make("antropic"), ModelID.make("claude-sonnet-4")) // typo: antropic + await getModel(ProviderID.make("antropic"), ModelID.make("claude-sonnet-4")) // typo: antropic expect(true).toBe(false) // Should not reach here } catch (e: any) { expect(e.data.suggestions).toBeDefined() @@ -1592,7 +1631,7 @@ test("getProvider returns undefined for nonexistent provider", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const provider = await Provider.getProvider(ProviderID.make("nonexistent")) + const provider = await getProvider(ProviderID.make("nonexistent")) expect(provider).toBeUndefined() }, }) @@ -1615,7 +1654,7 @@ test("getProvider returns provider info", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const provider = await Provider.getProvider(ProviderID.anthropic) + const provider = await getProvider(ProviderID.anthropic) expect(provider).toBeDefined() expect(String(provider?.id)).toBe("anthropic") }, @@ -1639,7 +1678,7 @@ test("closest returns undefined when no partial match found", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const result = await Provider.closest(ProviderID.anthropic, ["nonexistent-xyz-model"]) + const result = await closest(ProviderID.anthropic, ["nonexistent-xyz-model"]) expect(result).toBeUndefined() }, }) @@ -1663,7 +1702,7 @@ test("closest checks multiple query terms in order", async () => { }, fn: async () => { // First term won't match, second will - const result = await Provider.closest(ProviderID.anthropic, ["nonexistent", "haiku"]) + const result = await closest(ProviderID.anthropic, ["nonexistent", "haiku"]) expect(result).toBeDefined() expect(result?.modelID).toContain("haiku") }, @@ -1699,7 +1738,7 @@ test("model limit defaults to zero when not specified", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const providers = await Provider.list() + const providers = await list() const model = providers[ProviderID.make("no-limit")].models["model"] expect(model.limit.context).toBe(0) expect(model.limit.output).toBe(0) @@ -1734,7 +1773,7 @@ test("provider options are deeply merged", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() // Custom options should be merged expect(providers[ProviderID.anthropic].options.timeout).toBe(30000) expect(providers[ProviderID.anthropic].options.headers["X-Custom"]).toBe("custom-value") @@ -1772,7 +1811,7 @@ test("custom model inherits npm package from models.dev provider config", async Env.set("OPENAI_API_KEY", "test-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() const model = providers[ProviderID.openai].models["my-custom-model"] expect(model).toBeDefined() expect(model.api.npm).toBe("@ai-sdk/openai") @@ -1807,7 +1846,7 @@ test("custom model inherits api.url from models.dev provider", async () => { Env.set("OPENROUTER_API_KEY", "test-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.openrouter]).toBeDefined() // New model not in database should inherit api.url from provider @@ -1908,7 +1947,7 @@ test("model variants are generated for reasoning models", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() // Claude sonnet 4 has reasoning capability const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.capabilities.reasoning).toBe(true) @@ -1946,7 +1985,7 @@ test("model variants can be disabled via config", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants).toBeDefined() expect(model.variants!["high"]).toBeUndefined() @@ -1989,7 +2028,7 @@ test("model variants can be customized via config", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants!["high"]).toBeDefined() expect(model.variants!["high"].thinking.budgetTokens).toBe(20000) @@ -2028,7 +2067,7 @@ test("disabled key is stripped from variant config", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants!["max"]).toBeDefined() expect(model.variants!["max"].disabled).toBeUndefined() @@ -2066,7 +2105,7 @@ test("all variants can be disabled via config", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants).toBeDefined() expect(Object.keys(model.variants!).length).toBe(0) @@ -2104,7 +2143,7 @@ test("variant config merges with generated variants", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants!["high"]).toBeDefined() // Should have both the generated thinking config and the custom option @@ -2142,7 +2181,7 @@ test("variants filtered in second pass for database models", async () => { Env.set("OPENAI_API_KEY", "test-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() const model = providers[ProviderID.openai].models["gpt-5"] expect(model.variants).toBeDefined() expect(model.variants!["high"]).toBeUndefined() @@ -2188,7 +2227,7 @@ test("custom model with variants enabled and disabled", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const providers = await Provider.list() + const providers = await list() const model = providers[ProviderID.make("custom-reasoning")].models["reasoning-model"] expect(model.variants).toBeDefined() // Enabled variants should exist @@ -2246,7 +2285,7 @@ test("Google Vertex: retains baseURL for custom proxy", async () => { Env.set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.make("vertex-proxy")]).toBeDefined() expect(providers[ProviderID.make("vertex-proxy")].options.baseURL).toBe("https://my-proxy.com/v1") }, @@ -2291,7 +2330,7 @@ test("Google Vertex: supports OpenAI compatible models", async () => { Env.set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() const model = providers[ProviderID.make("vertex-openai")].models["gpt-4"] expect(model).toBeDefined() @@ -2319,7 +2358,7 @@ test("cloudflare-ai-gateway loads with env variables", async () => { Env.set("CLOUDFLARE_API_TOKEN", "test-token") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined() }, }) @@ -2351,7 +2390,7 @@ test("cloudflare-ai-gateway forwards config metadata options", async () => { Env.set("CLOUDFLARE_API_TOKEN", "test-token") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined() expect(providers[ProviderID.make("cloudflare-ai-gateway")].options.metadata).toEqual({ invoked_by: "test", @@ -2399,7 +2438,7 @@ test("plugin config providers persist after instance dispose", async () => { directory: tmp.path, fn: async () => { await Plugin.init() - return Provider.list() + return list() }, }) expect(first[ProviderID.make("demo")]).toBeDefined() @@ -2409,7 +2448,7 @@ test("plugin config providers persist after instance dispose", async () => { const second = await Instance.provide({ directory: tmp.path, - fn: async () => Provider.list(), + fn: async () => list(), }) expect(second[ProviderID.make("demo")]).toBeDefined() expect(second[ProviderID.make("demo")].models[ModelID.make("chat")]).toBeDefined() @@ -2445,7 +2484,7 @@ test("plugin config enabled and disabled providers are honored", async () => { Env.set("OPENAI_API_KEY", "test-openai-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.openai]).toBeUndefined() }, @@ -2466,7 +2505,7 @@ test("opencode loader keeps paid models when config apiKey is present", async () const none = await Instance.provide({ directory: base.path, - fn: async () => paid(await Provider.list()), + fn: async () => paid(await list()), }) await using keyed = await tmpdir({ @@ -2489,7 +2528,7 @@ test("opencode loader keeps paid models when config apiKey is present", async () const keyedCount = await Instance.provide({ directory: keyed.path, - fn: async () => paid(await Provider.list()), + fn: async () => paid(await list()), }) expect(none).toBe(0) @@ -2510,7 +2549,7 @@ test("opencode loader keeps paid models when auth exists", async () => { const none = await Instance.provide({ directory: base.path, - fn: async () => paid(await Provider.list()), + fn: async () => paid(await list()), }) await using keyed = await tmpdir({ @@ -2544,7 +2583,7 @@ test("opencode loader keeps paid models when auth exists", async () => { const keyedCount = await Instance.provide({ directory: keyed.path, - fn: async () => paid(await Provider.list()), + fn: async () => paid(await list()), }) expect(none).toBe(0) diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 1fa2e61eb2..3974ca9810 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -1,7 +1,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test" import path from "path" import { tool, type ModelMessage } from "ai" -import { Cause, Exit, Stream } from "effect" +import { Cause, Effect, Exit, Stream } from "effect" import z from "zod" import { makeRuntime } from "../../src/effect/run-service" import { LLM } from "../../src/session/llm" @@ -15,6 +15,16 @@ import { tmpdir } from "../fixture/fixture" import type { Agent } from "../../src/agent/agent" import type { MessageV2 } from "../../src/session/message-v2" import { SessionID, MessageID } from "../../src/session/schema" +import { AppRuntime } from "../../src/effect/app-runtime" + +async function getModel(providerID: ProviderID, modelID: ModelID) { + return AppRuntime.runPromise( + Effect.gen(function* () { + const provider = yield* Provider.Service + return yield* provider.getModel(providerID, modelID) + }), + ) +} describe("session.llm.hasToolCalls", () => { test("returns false for empty messages array", () => { @@ -325,7 +335,7 @@ describe("session.llm.stream", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id)) + const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) const sessionID = SessionID.make("session-test-1") const agent = { name: "test", @@ -416,7 +426,7 @@ describe("session.llm.stream", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id)) + const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) const sessionID = SessionID.make("session-test-raw-abort") const agent = { name: "test", @@ -490,7 +500,7 @@ describe("session.llm.stream", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id)) + const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) const sessionID = SessionID.make("session-test-service-abort") const agent = { name: "test", @@ -581,7 +591,7 @@ describe("session.llm.stream", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id)) + const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) const sessionID = SessionID.make("session-test-tools") const agent = { name: "test", @@ -699,7 +709,7 @@ describe("session.llm.stream", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const resolved = await Provider.getModel(ProviderID.openai, ModelID.make(model.id)) + const resolved = await getModel(ProviderID.openai, ModelID.make(model.id)) const sessionID = SessionID.make("session-test-2") const agent = { name: "test", @@ -819,7 +829,7 @@ describe("session.llm.stream", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const resolved = await Provider.getModel(ProviderID.openai, ModelID.make(model.id)) + const resolved = await getModel(ProviderID.openai, ModelID.make(model.id)) const sessionID = SessionID.make("session-test-data-url") const agent = { name: "test", @@ -942,7 +952,7 @@ describe("session.llm.stream", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id)) + const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) const sessionID = SessionID.make("session-test-3") const agent = { name: "test", @@ -1043,7 +1053,7 @@ describe("session.llm.stream", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id)) + const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) const sessionID = SessionID.make("session-test-4") const agent = { name: "test", diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 911c9f3443..eafe682067 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -204,7 +204,7 @@ const it = testEffect(makeHttp()) const unix = process.platform !== "win32" ? it.live : it.live.skip // Config that registers a custom "test" provider with a "test-model" model -// so Provider.getModel("test", "test-model") succeeds inside the loop. +// so provider model lookup succeeds inside the loop. const cfg = { provider: { test: { From 21d7a85e76983026bdd131b045f197386ef4c7ab Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 13 Apr 2026 12:47:52 -0400 Subject: [PATCH 11/11] refactor(lsp): remove async facade exports (#22321) --- packages/opencode/src/cli/cmd/debug/lsp.ts | 19 +- packages/opencode/src/lsp/index.ts | 32 --- packages/opencode/src/server/instance/file.ts | 5 - .../opencode/src/server/instance/index.ts | 3 +- packages/opencode/test/lsp/index.test.ts | 94 ++++----- packages/opencode/test/lsp/lifecycle.test.ts | 187 +++++++++--------- 6 files changed, 159 insertions(+), 181 deletions(-) diff --git a/packages/opencode/src/cli/cmd/debug/lsp.ts b/packages/opencode/src/cli/cmd/debug/lsp.ts index 4b8a3e7d45..5f0a1807d8 100644 --- a/packages/opencode/src/cli/cmd/debug/lsp.ts +++ b/packages/opencode/src/cli/cmd/debug/lsp.ts @@ -1,4 +1,6 @@ import { LSP } from "../../../lsp" +import { AppRuntime } from "../../../effect/app-runtime" +import { Effect } from "effect" import { bootstrap } from "../../bootstrap" import { cmd } from "../cmd" import { Log } from "../../../util/log" @@ -19,9 +21,16 @@ const DiagnosticsCommand = cmd({ builder: (yargs) => yargs.positional("file", { type: "string", demandOption: true }), async handler(args) { await bootstrap(process.cwd(), async () => { - await LSP.touchFile(args.file, true) - await sleep(1000) - process.stdout.write(JSON.stringify(await LSP.diagnostics(), null, 2) + EOL) + const out = await AppRuntime.runPromise( + LSP.Service.use((lsp) => + Effect.gen(function* () { + yield* lsp.touchFile(args.file, true) + yield* Effect.sleep(1000) + return yield* lsp.diagnostics() + }), + ), + ) + process.stdout.write(JSON.stringify(out, null, 2) + EOL) }) }, }) @@ -33,7 +42,7 @@ export const SymbolsCommand = cmd({ async handler(args) { await bootstrap(process.cwd(), async () => { using _ = Log.Default.time("symbols") - const results = await LSP.workspaceSymbol(args.query) + const results = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.workspaceSymbol(args.query))) process.stdout.write(JSON.stringify(results, null, 2) + EOL) }) }, @@ -46,7 +55,7 @@ export const DocumentSymbolsCommand = cmd({ async handler(args) { await bootstrap(process.cwd(), async () => { using _ = Log.Default.time("document-symbols") - const results = await LSP.documentSymbol(args.uri) + const results = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.documentSymbol(args.uri))) process.stdout.write(JSON.stringify(results, null, 2) + EOL) }) }, diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index 8e34a88546..0c83890e55 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/packages/opencode/src/lsp/index.ts @@ -13,7 +13,6 @@ import { Process } from "../util/process" import { spawn as lspspawn } from "./launch" import { Effect, Layer, Context } from "effect" import { InstanceState } from "@/effect/instance-state" -import { makeRuntime } from "@/effect/run-service" export namespace LSP { const log = Log.create({ service: "lsp" }) @@ -508,37 +507,6 @@ export namespace LSP { export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer)) - const { runPromise } = makeRuntime(Service, defaultLayer) - - export const init = async () => runPromise((svc) => svc.init()) - - export const status = async () => runPromise((svc) => svc.status()) - - export const hasClients = async (file: string) => runPromise((svc) => svc.hasClients(file)) - - export const touchFile = async (input: string, waitForDiagnostics?: boolean) => - runPromise((svc) => svc.touchFile(input, waitForDiagnostics)) - - export const diagnostics = async () => runPromise((svc) => svc.diagnostics()) - - export const hover = async (input: LocInput) => runPromise((svc) => svc.hover(input)) - - export const definition = async (input: LocInput) => runPromise((svc) => svc.definition(input)) - - export const references = async (input: LocInput) => runPromise((svc) => svc.references(input)) - - export const implementation = async (input: LocInput) => runPromise((svc) => svc.implementation(input)) - - export const documentSymbol = async (uri: string) => runPromise((svc) => svc.documentSymbol(uri)) - - export const workspaceSymbol = async (query: string) => runPromise((svc) => svc.workspaceSymbol(query)) - - export const prepareCallHierarchy = async (input: LocInput) => runPromise((svc) => svc.prepareCallHierarchy(input)) - - export const incomingCalls = async (input: LocInput) => runPromise((svc) => svc.incomingCalls(input)) - - export const outgoingCalls = async (input: LocInput) => runPromise((svc) => svc.outgoingCalls(input)) - export namespace Diagnostic { const MAX_PER_FILE = 20 diff --git a/packages/opencode/src/server/instance/file.ts b/packages/opencode/src/server/instance/file.ts index 713513b38d..a869cf3673 100644 --- a/packages/opencode/src/server/instance/file.ts +++ b/packages/opencode/src/server/instance/file.ts @@ -105,11 +105,6 @@ export const FileRoutes = lazy(() => }), ), async (c) => { - /* - const query = c.req.valid("query").query - const result = await LSP.workspaceSymbol(query) - return c.json(result) - */ return c.json([]) }, ) diff --git a/packages/opencode/src/server/instance/index.ts b/packages/opencode/src/server/instance/index.ts index 6009130a2f..6d383afa7c 100644 --- a/packages/opencode/src/server/instance/index.ts +++ b/packages/opencode/src/server/instance/index.ts @@ -256,7 +256,8 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => }, }), async (c) => { - return c.json(await LSP.status()) + const items = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.status())) + return c.json(items) }, ) .get( diff --git a/packages/opencode/test/lsp/index.test.ts b/packages/opencode/test/lsp/index.test.ts index 7e514e39b1..b12a61ae3c 100644 --- a/packages/opencode/test/lsp/index.test.ts +++ b/packages/opencode/test/lsp/index.test.ts @@ -1,55 +1,55 @@ -import { describe, expect, spyOn, test } from "bun:test" +import { describe, expect, spyOn } from "bun:test" import path from "path" -import * as Lsp from "../../src/lsp/index" +import { Effect, Layer } from "effect" +import { LSP } from "../../src/lsp" import { LSPServer } from "../../src/lsp/server" -import { Instance } from "../../src/project/instance" -import { tmpdir } from "../fixture/fixture" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const it = testEffect(Layer.mergeAll(LSP.defaultLayer, CrossSpawnSpawner.defaultLayer)) describe("lsp.spawn", () => { - test("does not spawn builtin LSP for files outside instance", async () => { - await using tmp = await tmpdir() - const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined) + it.live("does not spawn builtin LSP for files outside instance", () => + provideTmpdirInstance((dir) => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined) - try { - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await Lsp.LSP.touchFile(path.join(tmp.path, "..", "outside.ts")) - await Lsp.LSP.hover({ - file: path.join(tmp.path, "..", "hover.ts"), - line: 0, - character: 0, - }) - }, - }) + try { + yield* lsp.touchFile(path.join(dir, "..", "outside.ts")) + yield* lsp.hover({ + file: path.join(dir, "..", "hover.ts"), + line: 0, + character: 0, + }) + expect(spy).toHaveBeenCalledTimes(0) + } finally { + spy.mockRestore() + } + }), + ), + ), + ) - expect(spy).toHaveBeenCalledTimes(0) - } finally { - spy.mockRestore() - await Instance.disposeAll() - } - }) + it.live("would spawn builtin LSP for files inside instance", () => + provideTmpdirInstance((dir) => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined) - test("would spawn builtin LSP for files inside instance", async () => { - await using tmp = await tmpdir() - const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined) - - try { - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await Lsp.LSP.hover({ - file: path.join(tmp.path, "src", "inside.ts"), - line: 0, - character: 0, - }) - }, - }) - - expect(spy).toHaveBeenCalledTimes(1) - } finally { - spy.mockRestore() - await Instance.disposeAll() - } - }) + try { + yield* lsp.hover({ + file: path.join(dir, "src", "inside.ts"), + line: 0, + character: 0, + }) + expect(spy).toHaveBeenCalledTimes(1) + } finally { + spy.mockRestore() + } + }), + ), + ), + ) }) diff --git a/packages/opencode/test/lsp/lifecycle.test.ts b/packages/opencode/test/lsp/lifecycle.test.ts index fb3ed8c21c..a6de869fcb 100644 --- a/packages/opencode/test/lsp/lifecycle.test.ts +++ b/packages/opencode/test/lsp/lifecycle.test.ts @@ -1,23 +1,13 @@ -import { describe, expect, test, spyOn, beforeEach, afterEach } from "bun:test" +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" import path from "path" -import * as Lsp from "../../src/lsp/index" +import { Effect, Layer } from "effect" +import { LSP } from "../../src/lsp" import { LSPServer } from "../../src/lsp/server" -import { Instance } from "../../src/project/instance" -import { tmpdir } from "../fixture/fixture" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" -function withInstance(fn: (dir: string) => Promise) { - return async () => { - await using tmp = await tmpdir() - try { - await Instance.provide({ - directory: tmp.path, - fn: () => fn(tmp.path), - }) - } finally { - await Instance.disposeAll() - } - } -} +const it = testEffect(Layer.mergeAll(LSP.defaultLayer, CrossSpawnSpawner.defaultLayer)) describe("LSP service lifecycle", () => { let spawnSpy: ReturnType @@ -30,97 +20,112 @@ describe("LSP service lifecycle", () => { spawnSpy.mockRestore() }) - test( - "init() completes without error", - withInstance(async () => { - await Lsp.LSP.init() - }), + it.live("init() completes without error", () => provideTmpdirInstance(() => LSP.Service.use((lsp) => lsp.init()))) + + it.live("status() returns empty array initially", () => + provideTmpdirInstance(() => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const result = yield* lsp.status() + expect(Array.isArray(result)).toBe(true) + expect(result.length).toBe(0) + }), + ), + ), ) - test( - "status() returns empty array initially", - withInstance(async () => { - const result = await Lsp.LSP.status() - expect(Array.isArray(result)).toBe(true) - expect(result.length).toBe(0) - }), + it.live("diagnostics() returns empty object initially", () => + provideTmpdirInstance(() => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const result = yield* lsp.diagnostics() + expect(typeof result).toBe("object") + expect(Object.keys(result).length).toBe(0) + }), + ), + ), ) - test( - "diagnostics() returns empty object initially", - withInstance(async () => { - const result = await Lsp.LSP.diagnostics() - expect(typeof result).toBe("object") - expect(Object.keys(result).length).toBe(0) - }), + it.live("hasClients() returns true for .ts files in instance", () => + provideTmpdirInstance((dir) => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const result = yield* lsp.hasClients(path.join(dir, "test.ts")) + expect(result).toBe(true) + }), + ), + ), ) - test( - "hasClients() returns true for .ts files in instance", - withInstance(async (dir) => { - const result = await Lsp.LSP.hasClients(path.join(dir, "test.ts")) - expect(result).toBe(true) - }), + it.live("hasClients() returns false for files outside instance", () => + provideTmpdirInstance((dir) => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const result = yield* lsp.hasClients(path.join(dir, "..", "outside.ts")) + expect(typeof result).toBe("boolean") + }), + ), + ), ) - test( - "hasClients() returns false for files outside instance", - withInstance(async (dir) => { - const result = await Lsp.LSP.hasClients(path.join(dir, "..", "outside.ts")) - // hasClients checks servers but doesn't check containsPath — getClients does - // So hasClients may return true even for outside files (it checks extension + root) - // The guard is in getClients, not hasClients - expect(typeof result).toBe("boolean") - }), + it.live("workspaceSymbol() returns empty array with no clients", () => + provideTmpdirInstance(() => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const result = yield* lsp.workspaceSymbol("test") + expect(Array.isArray(result)).toBe(true) + expect(result.length).toBe(0) + }), + ), + ), ) - test( - "workspaceSymbol() returns empty array with no clients", - withInstance(async () => { - const result = await Lsp.LSP.workspaceSymbol("test") - expect(Array.isArray(result)).toBe(true) - expect(result.length).toBe(0) - }), + it.live("definition() returns empty array for unknown file", () => + provideTmpdirInstance((dir) => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const result = yield* lsp.definition({ + file: path.join(dir, "nonexistent.ts"), + line: 0, + character: 0, + }) + expect(Array.isArray(result)).toBe(true) + }), + ), + ), ) - test( - "definition() returns empty array for unknown file", - withInstance(async (dir) => { - const result = await Lsp.LSP.definition({ - file: path.join(dir, "nonexistent.ts"), - line: 0, - character: 0, - }) - expect(Array.isArray(result)).toBe(true) - }), + it.live("references() returns empty array for unknown file", () => + provideTmpdirInstance((dir) => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const result = yield* lsp.references({ + file: path.join(dir, "nonexistent.ts"), + line: 0, + character: 0, + }) + expect(Array.isArray(result)).toBe(true) + }), + ), + ), ) - test( - "references() returns empty array for unknown file", - withInstance(async (dir) => { - const result = await Lsp.LSP.references({ - file: path.join(dir, "nonexistent.ts"), - line: 0, - character: 0, - }) - expect(Array.isArray(result)).toBe(true) - }), - ) - - test( - "multiple init() calls are idempotent", - withInstance(async () => { - await Lsp.LSP.init() - await Lsp.LSP.init() - await Lsp.LSP.init() - // Should not throw or create duplicate state - }), + it.live("multiple init() calls are idempotent", () => + provideTmpdirInstance(() => + LSP.Service.use((lsp) => + Effect.gen(function* () { + yield* lsp.init() + yield* lsp.init() + yield* lsp.init() + }), + ), + ), ) }) describe("LSP.Diagnostic", () => { test("pretty() formats error diagnostic", () => { - const result = Lsp.LSP.Diagnostic.pretty({ + const result = LSP.Diagnostic.pretty({ range: { start: { line: 9, character: 4 }, end: { line: 9, character: 10 } }, message: "Type 'string' is not assignable to type 'number'", severity: 1, @@ -129,7 +134,7 @@ describe("LSP.Diagnostic", () => { }) test("pretty() formats warning diagnostic", () => { - const result = Lsp.LSP.Diagnostic.pretty({ + const result = LSP.Diagnostic.pretty({ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } }, message: "Unused variable", severity: 2, @@ -138,7 +143,7 @@ describe("LSP.Diagnostic", () => { }) test("pretty() defaults to ERROR when no severity", () => { - const result = Lsp.LSP.Diagnostic.pretty({ + const result = LSP.Diagnostic.pretty({ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } }, message: "Something wrong", } as any)