Merge branch 'dev' into kit/ripgrep-schema-source

This commit is contained in:
Kit Langton
2026-04-13 13:45:51 -04:00
committed by GitHub
63 changed files with 2997 additions and 1076 deletions

1
.github/VOUCHED.td vendored
View File

@@ -25,6 +25,7 @@ kommander
-opencodeengineer bot that spams issues
r44vc0rp
rekram1-node
-ricardo-m-l
-robinmordasiewicz
simonklee
-spider-yamet clawdbot/llm psychosis, spam pinging the team

View File

@@ -0,0 +1,16 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_workspace` (
`id` text PRIMARY KEY,
`type` text NOT NULL,
`name` text DEFAULT '' NOT NULL,
`branch` text,
`directory` text,
`extra` text,
`project_id` text NOT NULL,
CONSTRAINT `fk_workspace_project_id_project_id_fk` FOREIGN KEY (`project_id`) REFERENCES `project`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
INSERT INTO `__new_workspace`(`id`, `type`, `branch`, `name`, `directory`, `extra`, `project_id`) SELECT `id`, `type`, `branch`, `name`, `directory`, `extra`, `project_id` FROM `workspace`;--> statement-breakpoint
DROP TABLE `workspace`;--> statement-breakpoint
ALTER TABLE `__new_workspace` RENAME TO `workspace`;--> statement-breakpoint
PRAGMA foreign_keys=ON;

File diff suppressed because it is too large Load Diff

View File

@@ -25,7 +25,7 @@ const seed = async () => {
directory: dir,
init: () => AppRuntime.runPromise(InstanceBootstrap),
fn: async () => {
await Config.waitForDependencies()
await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.waitForDependencies()))
await AppRuntime.runPromise(
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service

View File

@@ -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,

View File

@@ -1,5 +1,6 @@
import { EOL } from "os"
import { Config } from "../../../config/config"
import { AppRuntime } from "@/effect/app-runtime"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
@@ -9,7 +10,7 @@ export const ConfigCommand = cmd({
builder: (yargs) => yargs,
async handler() {
await bootstrap(process.cwd(), async () => {
const config = await Config.get()
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
process.stdout.write(JSON.stringify(config, null, 2) + EOL)
})
},

View File

@@ -1,4 +1,6 @@
import { EOL } from "os"
import { Effect } from "effect"
import { AppRuntime } from "@/effect/app-runtime"
import { File } from "../../../file"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
@@ -15,7 +17,11 @@ const FileSearchCommand = cmd({
}),
async handler(args) {
await bootstrap(process.cwd(), async () => {
const results = await File.search({ query: args.query })
const results = await AppRuntime.runPromise(
Effect.gen(function* () {
return yield* File.Service.use((svc) => svc.search({ query: args.query }))
}),
)
process.stdout.write(results.join(EOL) + EOL)
})
},
@@ -32,7 +38,11 @@ const FileReadCommand = cmd({
}),
async handler(args) {
await bootstrap(process.cwd(), async () => {
const content = await File.read(args.path)
const content = await AppRuntime.runPromise(
Effect.gen(function* () {
return yield* File.Service.use((svc) => svc.read(args.path))
}),
)
process.stdout.write(JSON.stringify(content, null, 2) + EOL)
})
},
@@ -44,7 +54,11 @@ const FileStatusCommand = cmd({
builder: (yargs) => yargs,
async handler() {
await bootstrap(process.cwd(), async () => {
const status = await File.status()
const status = await AppRuntime.runPromise(
Effect.gen(function* () {
return yield* File.Service.use((svc) => svc.status())
}),
)
process.stdout.write(JSON.stringify(status, null, 2) + EOL)
})
},
@@ -61,7 +75,11 @@ const FileListCommand = cmd({
}),
async handler(args) {
await bootstrap(process.cwd(), async () => {
const files = await File.list(args.path)
const files = await AppRuntime.runPromise(
Effect.gen(function* () {
return yield* File.Service.use((svc) => svc.list(args.path))
}),
)
process.stdout.write(JSON.stringify(files, null, 2) + EOL)
})
},

View File

@@ -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)
})
},

View File

@@ -15,6 +15,8 @@ import { Global } from "../../global"
import { modify, applyEdits } from "jsonc-parser"
import { Filesystem } from "../../util/filesystem"
import { Bus } from "../../bus"
import { AppRuntime } from "../../effect/app-runtime"
import { Effect } from "effect"
function getAuthStatusIcon(status: MCP.AuthStatus): string {
switch (status) {
@@ -50,6 +52,47 @@ function isMcpRemote(config: McpEntry): config is McpRemote {
return isMcpConfigured(config) && config.type === "remote"
}
function configuredServers(config: Config.Info) {
return Object.entries(config.mcp ?? {}).filter((entry): entry is [string, McpConfigured] => isMcpConfigured(entry[1]))
}
function oauthServers(config: Config.Info) {
return configuredServers(config).filter(
(entry): entry is [string, McpRemote] => isMcpRemote(entry[1]) && entry[1].oauth !== false,
)
}
async function listState() {
return AppRuntime.runPromise(
Effect.gen(function* () {
const cfg = yield* Config.Service
const mcp = yield* MCP.Service
const config = yield* cfg.get()
const statuses = yield* mcp.status()
const stored = yield* Effect.all(
Object.fromEntries(configuredServers(config).map(([name]) => [name, mcp.hasStoredTokens(name)])),
{ concurrency: "unbounded" },
)
return { config, statuses, stored }
}),
)
}
async function authState() {
return AppRuntime.runPromise(
Effect.gen(function* () {
const cfg = yield* Config.Service
const mcp = yield* MCP.Service
const config = yield* cfg.get()
const auth = yield* Effect.all(
Object.fromEntries(oauthServers(config).map(([name]) => [name, mcp.getAuthStatus(name)])),
{ concurrency: "unbounded" },
)
return { config, auth }
}),
)
}
export const McpCommand = cmd({
command: "mcp",
describe: "manage MCP (Model Context Protocol) servers",
@@ -75,13 +118,8 @@ export const McpListCommand = cmd({
UI.empty()
prompts.intro("MCP Servers")
const config = await Config.get()
const mcpServers = config.mcp ?? {}
const statuses = await MCP.status()
const servers = Object.entries(mcpServers).filter((entry): entry is [string, McpConfigured] =>
isMcpConfigured(entry[1]),
)
const { config, statuses, stored } = await listState()
const servers = configuredServers(config)
if (servers.length === 0) {
prompts.log.warn("No MCP servers configured")
@@ -92,7 +130,7 @@ export const McpListCommand = cmd({
for (const [name, serverConfig] of servers) {
const status = statuses[name]
const hasOAuth = isMcpRemote(serverConfig) && !!serverConfig.oauth
const hasStoredTokens = await MCP.hasStoredTokens(name)
const hasStoredTokens = stored[name]
let statusIcon: string
let statusText: string
@@ -152,15 +190,11 @@ export const McpAuthCommand = cmd({
UI.empty()
prompts.intro("MCP OAuth Authentication")
const config = await Config.get()
const { config, auth } = await authState()
const mcpServers = config.mcp ?? {}
const servers = oauthServers(config)
// Get OAuth-capable servers (remote servers with oauth not explicitly disabled)
const oauthServers = Object.entries(mcpServers).filter(
(entry): entry is [string, McpRemote] => isMcpRemote(entry[1]) && entry[1].oauth !== false,
)
if (oauthServers.length === 0) {
if (servers.length === 0) {
prompts.log.warn("No OAuth-capable MCP servers configured")
prompts.log.info("Remote MCP servers support OAuth by default. Add a remote server in opencode.json:")
prompts.log.info(`
@@ -177,19 +211,17 @@ export const McpAuthCommand = cmd({
let serverName = args.name
if (!serverName) {
// Build options with auth status
const options = await Promise.all(
oauthServers.map(async ([name, cfg]) => {
const authStatus = await MCP.getAuthStatus(name)
const icon = getAuthStatusIcon(authStatus)
const statusText = getAuthStatusText(authStatus)
const url = cfg.url
return {
label: `${icon} ${name} (${statusText})`,
value: name,
hint: url,
}
}),
)
const options = servers.map(([name, cfg]) => {
const authStatus = auth[name]
const icon = getAuthStatusIcon(authStatus)
const statusText = getAuthStatusText(authStatus)
const url = cfg.url
return {
label: `${icon} ${name} (${statusText})`,
value: name,
hint: url,
}
})
const selected = await prompts.select({
message: "Select MCP server to authenticate",
@@ -213,7 +245,8 @@ export const McpAuthCommand = cmd({
}
// Check if already authenticated
const authStatus = await MCP.getAuthStatus(serverName)
const authStatus =
auth[serverName] ?? (await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.getAuthStatus(serverName))))
if (authStatus === "authenticated") {
const confirm = await prompts.confirm({
message: `${serverName} already has valid credentials. Re-authenticate?`,
@@ -240,7 +273,7 @@ export const McpAuthCommand = cmd({
})
try {
const status = await MCP.authenticate(serverName)
const status = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.authenticate(serverName)))
if (status.status === "connected") {
spinner.stop("Authentication successful!")
@@ -289,22 +322,17 @@ export const McpAuthListCommand = cmd({
UI.empty()
prompts.intro("MCP OAuth Status")
const config = await Config.get()
const mcpServers = config.mcp ?? {}
const { config, auth } = await authState()
const servers = oauthServers(config)
// Get OAuth-capable servers
const oauthServers = Object.entries(mcpServers).filter(
(entry): entry is [string, McpRemote] => isMcpRemote(entry[1]) && entry[1].oauth !== false,
)
if (oauthServers.length === 0) {
if (servers.length === 0) {
prompts.log.warn("No OAuth-capable MCP servers configured")
prompts.outro("Done")
return
}
for (const [name, serverConfig] of oauthServers) {
const authStatus = await MCP.getAuthStatus(name)
for (const [name, serverConfig] of servers) {
const authStatus = auth[name]
const icon = getAuthStatusIcon(authStatus)
const statusText = getAuthStatusText(authStatus)
const url = serverConfig.url
@@ -312,7 +340,7 @@ export const McpAuthListCommand = cmd({
prompts.log.info(`${icon} ${name} ${UI.Style.TEXT_DIM}${statusText}\n ${UI.Style.TEXT_DIM}${url}`)
}
prompts.outro(`${oauthServers.length} OAuth-capable server(s)`)
prompts.outro(`${servers.length} OAuth-capable server(s)`)
},
})
},
@@ -334,7 +362,7 @@ export const McpLogoutCommand = cmd({
prompts.intro("MCP OAuth Logout")
const authPath = path.join(Global.Path.data, "mcp-auth.json")
const credentials = await McpAuth.all()
const credentials = await AppRuntime.runPromise(McpAuth.Service.use((auth) => auth.all()))
const serverNames = Object.keys(credentials)
if (serverNames.length === 0) {
@@ -372,7 +400,7 @@ export const McpLogoutCommand = cmd({
return
}
await MCP.removeAuth(serverName)
await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.removeAuth(serverName)))
prompts.log.success(`Removed OAuth credentials for ${serverName}`)
prompts.outro("Done")
},
@@ -595,7 +623,7 @@ export const McpDebugCommand = cmd({
UI.empty()
prompts.intro("MCP OAuth Debug")
const config = await Config.get()
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
const mcpServers = config.mcp ?? {}
const serverName = args.name
@@ -622,10 +650,18 @@ export const McpDebugCommand = cmd({
prompts.log.info(`URL: ${serverConfig.url}`)
// Check stored auth status
const authStatus = await MCP.getAuthStatus(serverName)
const { authStatus, entry } = await AppRuntime.runPromise(
Effect.gen(function* () {
const mcp = yield* MCP.Service
const auth = yield* McpAuth.Service
return {
authStatus: yield* mcp.getAuthStatus(serverName),
entry: yield* auth.get(serverName),
}
}),
)
prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`)
const entry = await McpAuth.get(serverName)
if (entry?.tokens) {
prompts.log.info(` Access token: ${entry.tokens.accessToken.substring(0, 20)}...`)
if (entry.tokens.expiresAt) {

View File

@@ -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)
}
})
}),
)
},
})
},

View File

@@ -326,7 +326,7 @@ export const ProvidersLoginCommand = cmd({
}
await ModelsDev.refresh(true).catch(() => {})
const config = await Config.get()
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
const disabled = new Set(config.disabled_providers ?? [])
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined

View File

@@ -9,6 +9,12 @@ import { setTimeout as sleep } from "node:timers/promises"
import { useSDK } from "../context/sdk"
import { useToast } from "../ui/toast"
type Adaptor = {
type: string
name: string
description: string
}
function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID: string) {
return createOpencodeClient({
baseUrl: sdk.url,
@@ -63,9 +69,27 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
const sdk = useSDK()
const toast = useToast()
const [creating, setCreating] = createSignal<string>()
const [adaptors, setAdaptors] = createSignal<Adaptor[]>()
onMount(() => {
dialog.setSize("medium")
void (async () => {
const dir = sync.path.directory || sdk.directory
const url = new URL("/experimental/workspace/adaptor", sdk.url)
if (dir) url.searchParams.set("directory", dir)
const res = await sdk
.fetch(url)
.then((x) => x.json() as Promise<Adaptor[]>)
.catch(() => undefined)
if (!res) {
toast.show({
message: "Failed to load workspace adaptors",
variant: "error",
})
return
}
setAdaptors(res)
})()
})
const options = createMemo(() => {
@@ -79,13 +103,21 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
},
]
}
return [
{
title: "Worktree",
value: "worktree" as const,
description: "Create a local git worktree",
},
]
const list = adaptors()
if (!list) {
return [
{
title: "Loading workspaces...",
value: "loading" as const,
description: "Fetching available workspace adaptors",
},
]
}
return list.map((item) => ({
title: item.name,
value: item.type,
description: item.description,
}))
})
const create = async (type: string) => {
@@ -113,7 +145,7 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
skipFilter={true}
options={options()}
onSelect={(option) => {
if (option.value === "creating") return
if (option.value === "creating" || option.value === "loading") return
void create(option.value)
}}
/>

View File

@@ -81,7 +81,7 @@ export const rpc = {
})
},
async reload() {
await Config.invalidate(true)
await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.invalidate(true)))
},
async shutdown() {
Log.Default.info("worker shutting down")

View File

@@ -1,5 +1,6 @@
import type { Argv, InferredOptionTypes } from "yargs"
import { Config } from "../config/config"
import { AppRuntime } from "@/effect/app-runtime"
const options = {
port: {
@@ -37,7 +38,7 @@ export function withNetworkOptions<T>(yargs: Argv<T>) {
}
export async function resolveNetworkOptions(args: NetworkOptions) {
const config = await Config.getGlobal()
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))
const portExplicitlySet = process.argv.includes("--port")
const hostnameExplicitlySet = process.argv.includes("--hostname")
const mdnsExplicitlySet = process.argv.includes("--mdns")

View File

@@ -5,7 +5,7 @@ import { Flag } from "@/flag/flag"
import { Installation } from "@/installation"
export async function upgrade() {
const config = await Config.getGlobal()
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))
const method = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method()))
const latest = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest(method))).catch(() => {})
if (!latest) return

View File

@@ -33,7 +33,6 @@ import { ConfigPaths } from "./paths"
import type { ConsoleState } from "./console-state"
import { AppFileSystem } from "@/filesystem"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Context, Duration, Effect, Exit, Fiber, Layer, Option } from "effect"
import { Flock } from "@/util/flock"
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
@@ -1661,42 +1660,4 @@ export namespace Config {
Layer.provide(Auth.defaultLayer),
Layer.provide(Account.defaultLayer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function get() {
return runPromise((svc) => svc.get())
}
export async function getGlobal() {
return runPromise((svc) => svc.getGlobal())
}
export async function getConsoleState() {
return runPromise((svc) => svc.getConsoleState())
}
export async function installDependencies(dir: string, input?: InstallInput) {
return runPromise((svc) => svc.installDependencies(dir, input))
}
export async function update(config: Info) {
return runPromise((svc) => svc.update(config))
}
export async function updateGlobal(config: Info) {
return runPromise((svc) => svc.updateGlobal(config))
}
export async function invalidate(wait = false) {
return runPromise((svc) => svc.invalidate(wait))
}
export async function directories() {
return runPromise((svc) => svc.directories())
}
export async function waitForDependencies() {
return runPromise((svc) => svc.waitForDependencies())
}
}

View File

@@ -10,6 +10,7 @@ import { Flag } from "@/flag/flag"
import { Log } from "@/util/log"
import { isRecord } from "@/util/record"
import { Global } from "@/global"
import { AppRuntime } from "@/effect/app-runtime"
export namespace TuiConfig {
const log = Log.create({ service: "tui.config" })
@@ -51,7 +52,7 @@ export namespace TuiConfig {
}
function installDeps(dir: string): Promise<void> {
return Config.installDependencies(dir)
return AppRuntime.runPromise(Config.Service.use((cfg) => cfg.installDependencies(dir)))
}
async function mergeFile(acc: Acc, file: string) {

View File

@@ -1,20 +1,52 @@
import { lazy } from "@/util/lazy"
import type { Adaptor } from "../types"
import type { ProjectID } from "@/project/schema"
import type { WorkspaceAdaptor } from "../types"
const ADAPTORS: Record<string, () => Promise<Adaptor>> = {
export type WorkspaceAdaptorEntry = {
type: string
name: string
description: string
}
const BUILTIN: Record<string, () => Promise<WorkspaceAdaptor>> = {
worktree: lazy(async () => (await import("./worktree")).WorktreeAdaptor),
}
export function getAdaptor(type: string): Promise<Adaptor> {
return ADAPTORS[type]()
const state = new Map<ProjectID, Map<string, WorkspaceAdaptor>>()
export async function getAdaptor(projectID: ProjectID, type: string): Promise<WorkspaceAdaptor> {
const custom = state.get(projectID)?.get(type)
if (custom) return custom
const builtin = BUILTIN[type]
if (builtin) return builtin()
throw new Error(`Unknown workspace adaptor: ${type}`)
}
export function installAdaptor(type: string, adaptor: Adaptor) {
// This is experimental: mostly used for testing right now, but we
// will likely allow this in the future. Need to figure out the
// TypeScript story
// @ts-expect-error we force the builtin types right now, but we
// will implement a way to extend the types for custom adaptors
ADAPTORS[type] = () => adaptor
export async function listAdaptors(projectID: ProjectID): Promise<WorkspaceAdaptorEntry[]> {
const builtin = await Promise.all(
Object.entries(BUILTIN).map(async ([type, init]) => {
const adaptor = await init()
return {
type,
name: adaptor.name,
description: adaptor.description,
}
}),
)
const custom = [...(state.get(projectID)?.entries() ?? [])].map(([type, adaptor]) => ({
type,
name: adaptor.name,
description: adaptor.description,
}))
return [...builtin, ...custom]
}
// Plugins can be loaded per-project so we need to scope them. If you
// want to install a global one pass `ProjectID.global`
export function registerAdaptor(projectID: ProjectID, type: string, adaptor: WorkspaceAdaptor) {
const adaptors = state.get(projectID) ?? new Map<string, WorkspaceAdaptor>()
adaptors.set(type, adaptor)
state.set(projectID, adaptors)
}

View File

@@ -1,18 +1,18 @@
import z from "zod"
import { Worktree } from "@/worktree"
import { type Adaptor, WorkspaceInfo } from "../types"
import { type WorkspaceAdaptor, WorkspaceInfo } from "../types"
const Config = WorkspaceInfo.extend({
name: WorkspaceInfo.shape.name.unwrap(),
const WorktreeConfig = z.object({
name: WorkspaceInfo.shape.name,
branch: WorkspaceInfo.shape.branch.unwrap(),
directory: WorkspaceInfo.shape.directory.unwrap(),
})
type Config = z.infer<typeof Config>
export const WorktreeAdaptor: Adaptor = {
export const WorktreeAdaptor: WorkspaceAdaptor = {
name: "Worktree",
description: "Create a git worktree",
async configure(info) {
const worktree = await Worktree.makeWorktreeInfo(info.name ?? undefined)
const worktree = await Worktree.makeWorktreeInfo(undefined)
return {
...info,
name: worktree.name,
@@ -21,7 +21,7 @@ export const WorktreeAdaptor: Adaptor = {
}
},
async create(info) {
const config = Config.parse(info)
const config = WorktreeConfig.parse(info)
await Worktree.createFromInfo({
name: config.name,
directory: config.directory,
@@ -29,11 +29,11 @@ export const WorktreeAdaptor: Adaptor = {
})
},
async remove(info) {
const config = Config.parse(info)
const config = WorktreeConfig.parse(info)
await Worktree.remove({ directory: config.directory })
},
target(info) {
const config = Config.parse(info)
const config = WorktreeConfig.parse(info)
return {
type: "local",
directory: config.directory,

View File

@@ -5,8 +5,8 @@ import { WorkspaceID } from "./schema"
export const WorkspaceInfo = z.object({
id: WorkspaceID.zod,
type: z.string(),
name: z.string(),
branch: z.string().nullable(),
name: z.string().nullable(),
directory: z.string().nullable(),
extra: z.unknown().nullable(),
projectID: ProjectID.zod,
@@ -24,9 +24,11 @@ export type Target =
headers?: HeadersInit
}
export type Adaptor = {
configure(input: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
create(config: WorkspaceInfo, from?: WorkspaceInfo): Promise<void>
remove(config: WorkspaceInfo): Promise<void>
target(config: WorkspaceInfo): Target | Promise<Target>
export type WorkspaceAdaptor = {
name: string
description: string
configure(info: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
create(info: WorkspaceInfo, from?: WorkspaceInfo): Promise<void>
remove(info: WorkspaceInfo): Promise<void>
target(info: WorkspaceInfo): Target | Promise<Target>
}

View File

@@ -6,8 +6,8 @@ import type { WorkspaceID } from "./schema"
export const WorkspaceTable = sqliteTable("workspace", {
id: text().$type<WorkspaceID>().primaryKey(),
type: text().notNull(),
name: text().notNull().default(""),
branch: text(),
name: text(),
directory: text(),
extra: text({ mode: "json" }),
project_id: text()

View File

@@ -9,6 +9,7 @@ import { SyncEvent } from "@/sync"
import { Log } from "@/util/log"
import { Filesystem } from "@/util/filesystem"
import { ProjectID } from "@/project/schema"
import { Slug } from "@opencode-ai/util/slug"
import { WorkspaceTable } from "./workspace.sql"
import { getAdaptor } from "./adaptors"
import { WorkspaceInfo } from "./types"
@@ -66,9 +67,9 @@ export namespace Workspace {
export const create = fn(CreateInput, async (input) => {
const id = WorkspaceID.ascending(input.id)
const adaptor = await getAdaptor(input.type)
const adaptor = await getAdaptor(input.projectID, input.type)
const config = await adaptor.configure({ ...input, id, name: null, directory: null })
const config = await adaptor.configure({ ...input, id, name: Slug.create(), directory: null })
const info: Info = {
id,
@@ -124,7 +125,7 @@ export namespace Workspace {
stopSync(id)
const info = fromRow(row)
const adaptor = await getAdaptor(row.type)
const adaptor = await getAdaptor(info.projectID, row.type)
adaptor.remove(info)
Database.use((db) => db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run())
return info
@@ -162,7 +163,7 @@ export namespace Workspace {
log.info("connecting to sync: " + space.id)
setStatus(space.id, "connecting")
const adaptor = await getAdaptor(space.type)
const adaptor = await getAdaptor(space.projectID, space.type)
const target = await adaptor.target(space)
if (target.type === "local") return

View File

@@ -1,6 +1,5 @@
import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem"
import { Git } from "@/git"
import { Effect, Layer, Context } from "effect"
@@ -644,26 +643,4 @@ export namespace File {
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export function init() {
return runPromise((svc) => svc.init())
}
export async function status() {
return runPromise((svc) => svc.status())
}
export async function read(file: string): Promise<Content> {
return runPromise((svc) => svc.read(file))
}
export async function list(dir?: string) {
return runPromise((svc) => svc.list(dir))
}
export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
return runPromise((svc) => svc.search(input))
}
}

View File

@@ -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

View File

@@ -27,7 +27,6 @@ import open from "open"
import { Effect, Exit, Layer, Option, Context, Stream } from "effect"
import { EffectLogger } from "@/effect/logger"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
@@ -890,37 +889,4 @@ export namespace MCP {
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
// --- Async facade functions ---
export const status = async () => runPromise((svc) => svc.status())
export const tools = async () => runPromise((svc) => svc.tools())
export const prompts = async () => runPromise((svc) => svc.prompts())
export const resources = async () => runPromise((svc) => svc.resources())
export const add = async (name: string, mcp: Config.Mcp) => runPromise((svc) => svc.add(name, mcp))
export const connect = async (name: string) => runPromise((svc) => svc.connect(name))
export const disconnect = async (name: string) => runPromise((svc) => svc.disconnect(name))
export const startAuth = async (mcpName: string) => runPromise((svc) => svc.startAuth(mcpName))
export const authenticate = async (mcpName: string) => runPromise((svc) => svc.authenticate(mcpName))
export const finishAuth = async (mcpName: string, authorizationCode: string) =>
runPromise((svc) => svc.finishAuth(mcpName, authorizationCode))
export const removeAuth = async (mcpName: string) => runPromise((svc) => svc.removeAuth(mcpName))
export const supportsOAuth = async (mcpName: string) => runPromise((svc) => svc.supportsOAuth(mcpName))
export const hasStoredTokens = async (mcpName: string) => runPromise((svc) => svc.hasStoredTokens(mcpName))
export const getAuthStatus = async (mcpName: string) => runPromise((svc) => svc.getAuthStatus(mcpName))
}

View File

@@ -1,4 +1,10 @@
import type { Hooks, PluginInput, Plugin as PluginInstance, PluginModule } from "@opencode-ai/plugin"
import type {
Hooks,
PluginInput,
Plugin as PluginInstance,
PluginModule,
WorkspaceAdaptor as PluginWorkspaceAdaptor,
} from "@opencode-ai/plugin"
import { Config } from "../config/config"
import { Bus } from "../bus"
import { Log } from "../util/log"
@@ -18,6 +24,8 @@ import { makeRuntime } from "@/effect/run-service"
import { errorMessage } from "@/util/error"
import { PluginLoader } from "./loader"
import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared"
import { registerAdaptor } from "@/control-plane/adaptors"
import type { WorkspaceAdaptor } from "@/control-plane/types"
export namespace Plugin {
const log = Log.create({ service: "plugin" })
@@ -132,6 +140,11 @@ export namespace Plugin {
project: ctx.project,
worktree: ctx.worktree,
directory: ctx.directory,
experimental_workspace: {
register(type: string, adaptor: PluginWorkspaceAdaptor) {
registerAdaptor(ctx.project.id, type, adaptor as WorkspaceAdaptor)
},
},
get serverUrl(): URL {
return Server.url ?? new URL("http://localhost:4096")
},

View File

@@ -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<T extends { id: string }>(models: T[]) {
return sortBy(

View File

@@ -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" })
@@ -30,7 +32,7 @@ export const ConfigRoutes = lazy(() =>
},
}),
async (c) => {
return c.json(await Config.get())
return c.json(await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())))
},
)
.patch(
@@ -54,7 +56,7 @@ export const ConfigRoutes = lazy(() =>
validator("json", Config.Info),
async (c) => {
const config = c.req.valid("json")
await Config.update(config)
await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.update(config)))
return c.json(config)
},
)
@@ -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),

View File

@@ -408,7 +408,14 @@ export const ExperimentalRoutes = lazy(() =>
},
}),
async (c) => {
return c.json(await MCP.resources())
return c.json(
await AppRuntime.runPromise(
Effect.gen(function* () {
const mcp = yield* MCP.Service
return yield* mcp.resources()
}),
),
)
},
),
)

View File

@@ -1,5 +1,6 @@
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import { Effect } from "effect"
import z from "zod"
import { AppRuntime } from "../../effect/app-runtime"
import { File } from "../../file"
@@ -72,12 +73,18 @@ export const FileRoutes = lazy(() =>
const dirs = c.req.valid("query").dirs
const type = c.req.valid("query").type
const limit = c.req.valid("query").limit
const results = await File.search({
query,
limit: limit ?? 10,
dirs: dirs !== "false",
type,
})
const results = await AppRuntime.runPromise(
Effect.gen(function* () {
return yield* File.Service.use((svc) =>
svc.search({
query,
limit: limit ?? 10,
dirs: dirs !== "false",
type,
}),
)
}),
)
return c.json(results)
},
)
@@ -105,11 +112,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([])
},
)
@@ -138,7 +140,11 @@ export const FileRoutes = lazy(() =>
),
async (c) => {
const path = c.req.valid("query").path
const content = await File.list(path)
const content = await AppRuntime.runPromise(
Effect.gen(function* () {
return yield* File.Service.use((svc) => svc.list(path))
}),
)
return c.json(content)
},
)
@@ -167,7 +173,11 @@ export const FileRoutes = lazy(() =>
),
async (c) => {
const path = c.req.valid("query").path
const content = await File.read(path)
const content = await AppRuntime.runPromise(
Effect.gen(function* () {
return yield* File.Service.use((svc) => svc.read(path))
}),
)
return c.json(content)
},
)
@@ -189,7 +199,11 @@ export const FileRoutes = lazy(() =>
},
}),
async (c) => {
const content = await File.status()
const content = await AppRuntime.runPromise(
Effect.gen(function* () {
return yield* File.Service.use((svc) => svc.status())
}),
)
return c.json(content)
},
),

View File

@@ -199,7 +199,7 @@ export const GlobalRoutes = lazy(() =>
},
}),
async (c) => {
return c.json(await Config.getGlobal())
return c.json(await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal())))
},
)
.patch(
@@ -223,7 +223,7 @@ export const GlobalRoutes = lazy(() =>
validator("json", Config.Info),
async (c) => {
const config = c.req.valid("json")
const next = await Config.updateGlobal(config)
const next = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.updateGlobal(config)))
return c.json(next)
},
)

View File

@@ -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(

View File

@@ -3,8 +3,10 @@ import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
import { MCP } from "../../mcp"
import { Config } from "../../config/config"
import { AppRuntime } from "../../effect/app-runtime"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
import { Effect } from "effect"
export const McpRoutes = lazy(() =>
new Hono()
@@ -26,7 +28,7 @@ export const McpRoutes = lazy(() =>
},
}),
async (c) => {
return c.json(await MCP.status())
return c.json(await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.status())))
},
)
.post(
@@ -56,7 +58,7 @@ export const McpRoutes = lazy(() =>
),
async (c) => {
const { name, config } = c.req.valid("json")
const result = await MCP.add(name, config)
const result = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.add(name, config)))
return c.json(result.status)
},
)
@@ -84,12 +86,21 @@ export const McpRoutes = lazy(() =>
}),
async (c) => {
const name = c.req.param("name")
const supportsOAuth = await MCP.supportsOAuth(name)
if (!supportsOAuth) {
const result = await AppRuntime.runPromise(
Effect.gen(function* () {
const mcp = yield* MCP.Service
const supports = yield* mcp.supportsOAuth(name)
if (!supports) return { supports }
return {
supports,
auth: yield* mcp.startAuth(name),
}
}),
)
if (!result.supports) {
return c.json({ error: `MCP server ${name} does not support OAuth` }, 400)
}
const result = await MCP.startAuth(name)
return c.json(result)
return c.json(result.auth)
},
)
.post(
@@ -120,7 +131,7 @@ export const McpRoutes = lazy(() =>
async (c) => {
const name = c.req.param("name")
const { code } = c.req.valid("json")
const status = await MCP.finishAuth(name, code)
const status = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.finishAuth(name, code)))
return c.json(status)
},
)
@@ -144,12 +155,21 @@ export const McpRoutes = lazy(() =>
}),
async (c) => {
const name = c.req.param("name")
const supportsOAuth = await MCP.supportsOAuth(name)
if (!supportsOAuth) {
const result = await AppRuntime.runPromise(
Effect.gen(function* () {
const mcp = yield* MCP.Service
const supports = yield* mcp.supportsOAuth(name)
if (!supports) return { supports }
return {
supports,
status: yield* mcp.authenticate(name),
}
}),
)
if (!result.supports) {
return c.json({ error: `MCP server ${name} does not support OAuth` }, 400)
}
const status = await MCP.authenticate(name)
return c.json(status)
return c.json(result.status)
},
)
.delete(
@@ -172,7 +192,7 @@ export const McpRoutes = lazy(() =>
}),
async (c) => {
const name = c.req.param("name")
await MCP.removeAuth(name)
await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.removeAuth(name)))
return c.json({ success: true as const })
},
)
@@ -195,7 +215,7 @@ export const McpRoutes = lazy(() =>
validator("param", z.object({ name: z.string() })),
async (c) => {
const { name } = c.req.valid("param")
await MCP.connect(name)
await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.connect(name)))
return c.json(true)
},
)
@@ -218,7 +238,7 @@ export const McpRoutes = lazy(() =>
validator("param", z.object({ name: z.string() })),
async (c) => {
const { name } = c.req.valid("param")
await MCP.disconnect(name)
await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.disconnect(name)))
return c.json(true)
},
),

View File

@@ -95,7 +95,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
})
}
const adaptor = await getAdaptor(workspace.type)
const adaptor = await getAdaptor(workspace.projectID, workspace.type)
const target = await adaptor.target(workspace)
if (target.type === "local") {

View File

@@ -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,36 @@ 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<string, (typeof allProviders)[string]> = {}
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 cfg = yield* Config.Service
const config = yield* cfg.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<string, (typeof all)[string]> = {}
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,
})
},
)

View File

@@ -1,13 +1,41 @@
import { Hono } from "hono"
import { describeRoute, resolver, validator } from "hono-openapi"
import z from "zod"
import { listAdaptors } from "../../control-plane/adaptors"
import { Workspace } from "../../control-plane/workspace"
import { Instance } from "../../project/instance"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
const WorkspaceAdaptor = z.object({
type: z.string(),
name: z.string(),
description: z.string(),
})
export const WorkspaceRoutes = lazy(() =>
new Hono()
.get(
"/adaptor",
describeRoute({
summary: "List workspace adaptors",
description: "List all available workspace adaptors for the current project.",
operationId: "experimental.workspace.adaptor.list",
responses: {
200: {
description: "Workspace adaptors",
content: {
"application/json": {
schema: resolver(z.array(WorkspaceAdaptor)),
},
},
},
},
}),
async (c) => {
return c.json(await listAdaptors(Instance.project.id))
},
)
.post(
"/",
describeRoute({

View File

@@ -5,6 +5,9 @@ import { Instance } from "../../src/project/instance"
import { Config } from "../../src/config/config"
import { Agent as AgentSvc } from "../../src/agent/agent"
import { Color } from "../../src/util/color"
import { AppRuntime } from "../../src/effect/app-runtime"
const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get()))
test("agent color parsed from project config", async () => {
await using tmp = await tmpdir({
@@ -24,7 +27,7 @@ test("agent color parsed from project config", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const cfg = await Config.get()
const cfg = await load()
expect(cfg.agent?.["build"]?.color).toBe("#FFA500")
expect(cfg.agent?.["plan"]?.color).toBe("primary")
},

View File

@@ -33,15 +33,25 @@ const emptyAuth = Layer.mock(Auth.Service)({
all: () => Effect.succeed({}),
})
const it = testEffect(
Config.layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(emptyAuth),
Layer.provide(emptyAccount),
Layer.provideMerge(infra),
),
const layer = Config.layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(emptyAuth),
Layer.provide(emptyAccount),
Layer.provideMerge(infra),
)
const it = testEffect(layer)
const load = () => Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(layer)))
const save = (config: Config.Info) =>
Effect.runPromise(Config.Service.use((svc) => svc.update(config)).pipe(Effect.scoped, Effect.provide(layer)))
const clear = (wait = false) =>
Effect.runPromise(Config.Service.use((svc) => svc.invalidate(wait)).pipe(Effect.scoped, Effect.provide(layer)))
const listDirs = () =>
Effect.runPromise(Config.Service.use((svc) => svc.directories()).pipe(Effect.scoped, Effect.provide(layer)))
const ready = () =>
Effect.runPromise(Config.Service.use((svc) => svc.waitForDependencies()).pipe(Effect.scoped, Effect.provide(layer)))
const installDeps = (dir: string, input?: Config.InstallInput) =>
Config.Service.use((svc) => svc.installDependencies(dir, input))
@@ -49,12 +59,12 @@ const installDeps = (dir: string, input?: Config.InstallInput) =>
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
beforeEach(async () => {
await Config.invalidate(true)
await clear(true)
})
afterEach(async () => {
await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {})
await Config.invalidate(true)
await clear(true)
})
async function writeManagedSettings(settings: object, filename = "opencode.json") {
@@ -72,7 +82,7 @@ async function check(map: (dir: string) => string) {
await using tmp = await tmpdir({ git: true, config: { snapshot: true } })
const prev = Global.Path.config
;(Global.Path as { config: string }).config = globalTmp.path
await Config.invalidate()
await clear()
try {
await writeConfig(globalTmp.path, {
$schema: "https://opencode.ai/config.json",
@@ -81,7 +91,7 @@ async function check(map: (dir: string) => string) {
await Instance.provide({
directory: map(tmp.path),
fn: async () => {
const cfg = await Config.get()
const cfg = await load()
expect(cfg.snapshot).toBe(true)
expect(Instance.directory).toBe(Filesystem.resolve(tmp.path))
expect(Instance.project.id).not.toBe(ProjectID.global)
@@ -90,7 +100,7 @@ async function check(map: (dir: string) => string) {
} finally {
await Instance.disposeAll()
;(Global.Path as { config: string }).config = prev
await Config.invalidate()
await clear()
}
}
@@ -99,7 +109,7 @@ test("loads config with defaults when no files exist", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const config = await load()
expect(config.username).toBeDefined()
},
})
@@ -118,7 +128,7 @@ test("loads JSON config file", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const config = await load()
expect(config.model).toBe("test/model")
expect(config.username).toBe("testuser")
},
@@ -156,7 +166,7 @@ test("ignores legacy tui keys in opencode config", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const config = await load()
expect(config.model).toBe("test/model")
expect((config as Record<string, unknown>).theme).toBeUndefined()
expect((config as Record<string, unknown>).tui).toBeUndefined()
@@ -181,7 +191,7 @@ test("loads JSONC config file", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const config = await load()
expect(config.model).toBe("test/model")
expect(config.username).toBe("testuser")
},
@@ -209,7 +219,7 @@ test("jsonc overrides json in the same directory", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const config = await load()
expect(config.model).toBe("base")
expect(config.username).toBe("base")
},
@@ -232,7 +242,7 @@ test("handles environment variable substitution", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const config = await load()
expect(config.username).toBe("test-user")
},
})
@@ -264,7 +274,7 @@ test("preserves env variables when adding $schema to config", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const config = await load()
expect(config.username).toBe("secret_value")
// Read the file to verify the env variable was preserved
@@ -358,7 +368,7 @@ test("handles file inclusion substitution", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const config = await load()
expect(config.username).toBe("test-user")
},
})
@@ -377,7 +387,7 @@ test("handles file inclusion with replacement tokens", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const config = await load()
expect(config.username).toBe("const out = await Bun.$`echo hi`")
},
})
@@ -396,7 +406,7 @@ test("validates config schema and throws on invalid fields", async () => {
directory: tmp.path,
fn: async () => {
// Strict schema should throw an error for invalid fields
await expect(Config.get()).rejects.toThrow()
await expect(load()).rejects.toThrow()
},
})
})
@@ -410,7 +420,7 @@ test("throws error for invalid JSON", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await expect(Config.get()).rejects.toThrow()
await expect(load()).rejects.toThrow()
},
})
})
@@ -433,7 +443,7 @@ test("handles agent configuration", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const config = await load()
expect(config.agent?.["test_agent"]).toEqual(
expect.objectContaining({
model: "test/model",
@@ -464,7 +474,7 @@ test("treats agent variant as model-scoped setting (not provider option)", async
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const config = await load()
const agent = config.agent?.["test_agent"]
expect(agent?.variant).toBe("xhigh")
@@ -494,7 +504,7 @@ test("handles command configuration", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const config = await load()
expect(config.command?.["test_command"]).toEqual({
template: "test template",
description: "test command",
@@ -519,7 +529,7 @@ test("migrates autoshare to share field", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const config = await load()
expect(config.share).toBe("auto")
expect(config.autoshare).toBe(true)
},
@@ -546,7 +556,7 @@ test("migrates mode field to agent field", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const config = await load()
expect(config.agent?.["test_mode"]).toEqual({
model: "test/model",
temperature: 0.5,
@@ -578,7 +588,7 @@ Test agent prompt`,
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const config = await load()
expect(config.agent?.["test"]).toEqual(
expect.objectContaining({
name: "test",
@@ -622,7 +632,7 @@ Nested agent prompt`,
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const config = await load()
expect(config.agent?.["helper"]).toMatchObject({
name: "helper",
@@ -671,7 +681,7 @@ Nested command template`,
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const config = await load()
expect(config.command?.["hello"]).toEqual({
description: "Test command",
@@ -716,7 +726,7 @@ Nested command template`,
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const config = await load()
expect(config.command?.["hello"]).toEqual({
description: "Test command",
@@ -737,7 +747,7 @@ test("updates config and writes to file", async () => {
directory: tmp.path,
fn: async () => {
const newConfig = { model: "updated/model" }
await Config.update(newConfig as any)
await save(newConfig as any)
const writtenConfig = await Filesystem.readJson(path.join(tmp.path, "config.json"))
expect(writtenConfig.model).toBe("updated/model")
@@ -750,7 +760,7 @@ test("gets config directories", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const dirs = await Config.directories()
const dirs = await listDirs()
expect(dirs.length).toBeGreaterThanOrEqual(1)
},
})
@@ -780,7 +790,7 @@ test("does not try to install dependencies in read-only OPENCODE_CONFIG_DIR", as
await Instance.provide({
directory: tmp.path,
fn: async () => {
await Config.get()
await load()
},
})
} finally {
@@ -814,8 +824,8 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await Config.get()
await Config.waitForDependencies()
await load()
await ready()
},
})
@@ -996,7 +1006,7 @@ test("resolves scoped npm plugins in config", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const config = await load()
const pluginEntries = config.plugin ?? []
expect(pluginEntries).toContain("@scope/plugin")
},
@@ -1034,7 +1044,7 @@ test("merges plugin arrays from global and local configs", async () => {
await Instance.provide({
directory: path.join(tmp.path, "project"),
fn: async () => {
const config = await Config.get()
const config = await load()
const plugins = config.plugin ?? []
// Should contain both global and local plugins
@@ -1070,7 +1080,7 @@ Helper subagent prompt`,
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const config = await load()
expect(config.agent?.["helper"]).toMatchObject({
name: "helper",
model: "test/model",
@@ -1109,7 +1119,7 @@ test("merges instructions arrays from global and local configs", async () => {
await Instance.provide({
directory: path.join(tmp.path, "project"),
fn: async () => {
const config = await Config.get()
const config = await load()
const instructions = config.instructions ?? []
expect(instructions).toContain("global-instructions.md")
@@ -1148,7 +1158,7 @@ test("deduplicates duplicate instructions from global and local configs", async
await Instance.provide({
directory: path.join(tmp.path, "project"),
fn: async () => {
const config = await Config.get()
const config = await load()
const instructions = config.instructions ?? []
expect(instructions).toContain("global-only.md")
@@ -1193,7 +1203,7 @@ test("deduplicates duplicate plugins from global and local configs", async () =>
await Instance.provide({
directory: path.join(tmp.path, "project"),
fn: async () => {
const config = await Config.get()
const config = await load()
const plugins = config.plugin ?? []
// Should contain all unique plugins
@@ -1242,7 +1252,7 @@ test("keeps plugin origins aligned with merged plugin list", async () => {
await Instance.provide({
directory: path.join(tmp.path, "project"),
fn: async () => {
const cfg = await Config.get()
const cfg = await load()
const plugins = cfg.plugin ?? []
const origins = cfg.plugin_origins ?? []
const names = plugins.map((item) => Config.pluginSpecifier(item))
@@ -1283,7 +1293,7 @@ test("migrates legacy tools config to permissions - allow", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const config = await load()
expect(config.agent?.["test"]?.permission).toEqual({
bash: "allow",
read: "allow",
@@ -1314,7 +1324,7 @@ test("migrates legacy tools config to permissions - deny", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const config = await load()
expect(config.agent?.["test"]?.permission).toEqual({
bash: "deny",
webfetch: "deny",
@@ -1344,7 +1354,7 @@ test("migrates legacy write tool to edit permission", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const config = await load()
expect(config.agent?.["test"]?.permission).toEqual({
edit: "allow",
})
@@ -1376,7 +1386,7 @@ test("managed settings override user settings", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const config = await load()
expect(config.model).toBe("managed/model")
expect(config.share).toBe("disabled")
expect(config.username).toBe("testuser")
@@ -1404,7 +1414,7 @@ test("managed settings override project settings", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const config = await load()
expect(config.autoupdate).toBe(false)
expect(config.disabled_providers).toEqual(["openai"])
},
@@ -1424,7 +1434,7 @@ test("missing managed settings file is not an error", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const config = await load()
expect(config.model).toBe("user/model")
},
})
@@ -1451,7 +1461,7 @@ test("migrates legacy edit tool to edit permission", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const config = await load()
expect(config.agent?.["test"]?.permission).toEqual({
edit: "deny",
})
@@ -1480,7 +1490,7 @@ test("migrates legacy patch tool to edit permission", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const config = await load()
expect(config.agent?.["test"]?.permission).toEqual({
edit: "allow",
})
@@ -1509,7 +1519,7 @@ test("migrates legacy multiedit tool to edit permission", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const config = await load()
expect(config.agent?.["test"]?.permission).toEqual({
edit: "deny",
})
@@ -1541,7 +1551,7 @@ test("migrates mixed legacy tools config", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const config = await load()
expect(config.agent?.["test"]?.permission).toEqual({
bash: "allow",
edit: "allow",
@@ -1576,7 +1586,7 @@ test("merges legacy tools with existing permission config", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const config = await load()
expect(config.agent?.["test"]?.permission).toEqual({
glob: "allow",
bash: "allow",
@@ -1611,7 +1621,7 @@ test("permission config preserves key order", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const config = await load()
expect(Object.keys(config.permission!)).toEqual([
"*",
"edit",
@@ -1671,7 +1681,7 @@ test("project config can override MCP server enabled status", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const config = await load()
// jira should be enabled (overridden by project config)
expect(config.mcp?.jira).toEqual({
type: "remote",
@@ -1727,7 +1737,7 @@ test("MCP config deep merges preserving base config properties", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const config = await load()
expect(config.mcp?.myserver).toEqual({
type: "remote",
url: "https://myserver.example.com/mcp",
@@ -1778,7 +1788,7 @@ test("local .opencode config can override MCP from project config", async () =>
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const config = await load()
expect(config.mcp?.docs?.enabled).toBe(true)
},
})
@@ -2029,7 +2039,7 @@ describe("deduplicatePluginOrigins", () => {
await Instance.provide({
directory: path.join(tmp.path, "project"),
fn: async () => {
const config = await Config.get()
const config = await load()
const plugins = config.plugin ?? []
expect(plugins.some((p) => Config.pluginSpecifier(p) === "my-plugin@1.0.0")).toBe(true)
@@ -2061,7 +2071,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const config = await load()
// Project config should NOT be loaded - model should be default, not "project/model"
expect(config.model).not.toBe("project/model")
expect(config.username).not.toBe("project-user")
@@ -2092,7 +2102,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const directories = await Config.directories()
const directories = await listDirs()
// Project .opencode should NOT be in directories list
const hasProjectOpencode = directories.some((d) => d.startsWith(tmp.path))
expect(hasProjectOpencode).toBe(false)
@@ -2117,7 +2127,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
directory: tmp.path,
fn: async () => {
// Should still get default config (from global or defaults)
const config = await Config.get()
const config = await load()
expect(config).toBeDefined()
expect(config.username).toBeDefined()
},
@@ -2160,7 +2170,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
fn: async () => {
// The relative instruction should be skipped without error
// We're mainly verifying this doesn't throw and the config loads
const config = await Config.get()
const config = await load()
expect(config).toBeDefined()
// The instruction should have been skipped (warning logged)
// We can't easily test the warning was logged, but we verify
@@ -2218,7 +2228,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
await Instance.provide({
directory: projectTmp.path,
fn: async () => {
const config = await Config.get()
const config = await load()
// Should load from OPENCODE_CONFIG_DIR, not project
expect(config.model).toBe("configdir/model")
},
@@ -2253,7 +2263,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const config = await load()
expect(config.username).toBe("test_api_key_12345")
},
})
@@ -2287,7 +2297,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const config = await load()
expect(config.username).toBe("secret_key_from_file")
},
})

View File

@@ -7,12 +7,15 @@ import { Config } from "../../src/config/config"
import { TuiConfig } from "../../src/config/tui"
import { Global } from "../../src/global"
import { Filesystem } from "../../src/util/filesystem"
import { AppRuntime } from "../../src/effect/app-runtime"
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
const wintest = process.platform === "win32" ? test : test.skip
const clear = (wait = false) => AppRuntime.runPromise(Config.Service.use((svc) => svc.invalidate(wait)))
const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get()))
beforeEach(async () => {
await Config.invalidate(true)
await clear(true)
})
afterEach(async () => {
@@ -23,7 +26,7 @@ afterEach(async () => {
await fs.rm(path.join(Global.Path.config, "tui.json"), { force: true }).catch(() => {})
await fs.rm(path.join(Global.Path.config, "tui.jsonc"), { force: true }).catch(() => {})
await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {})
await Config.invalidate(true)
await clear(true)
})
test("keeps server and tui plugin merge semantics aligned", async () => {
@@ -79,7 +82,7 @@ test("keeps server and tui plugin merge semantics aligned", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const server = await Config.get()
const server = await load()
const tui = await TuiConfig.get()
const serverPlugins = (server.plugin ?? []).map((item) => Config.pluginSpecifier(item))
const tuiPlugins = (tui.plugin ?? []).map((item) => Config.pluginSpecifier(item))

View File

@@ -0,0 +1,71 @@
import { describe, expect, test } from "bun:test"
import { getAdaptor, registerAdaptor } from "../../src/control-plane/adaptors"
import { ProjectID } from "../../src/project/schema"
import type { WorkspaceInfo } from "../../src/control-plane/types"
function info(projectID: WorkspaceInfo["projectID"], type: string): WorkspaceInfo {
return {
id: "workspace-test" as WorkspaceInfo["id"],
type,
name: "workspace-test",
branch: null,
directory: null,
extra: null,
projectID,
}
}
function adaptor(dir: string) {
return {
name: dir,
description: dir,
configure(input: WorkspaceInfo) {
return input
},
async create() {},
async remove() {},
target() {
return {
type: "local" as const,
directory: dir,
}
},
}
}
describe("control-plane/adaptors", () => {
test("isolates custom adaptors by project", async () => {
const type = `demo-${Math.random().toString(36).slice(2)}`
const one = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`)
const two = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`)
registerAdaptor(one, type, adaptor("/one"))
registerAdaptor(two, type, adaptor("/two"))
expect(await (await getAdaptor(one, type)).target(info(one, type))).toEqual({
type: "local",
directory: "/one",
})
expect(await (await getAdaptor(two, type)).target(info(two, type))).toEqual({
type: "local",
directory: "/two",
})
})
test("latest install wins within a project", async () => {
const type = `demo-${Math.random().toString(36).slice(2)}`
const id = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`)
registerAdaptor(id, type, adaptor("/one"))
expect(await (await getAdaptor(id, type)).target(info(id, type))).toEqual({
type: "local",
directory: "/one",
})
registerAdaptor(id, type, adaptor("/two"))
expect(await (await getAdaptor(id, type)).target(info(id, type))).toEqual({
type: "local",
directory: "/two",
})
})
})

View File

@@ -1,10 +1,16 @@
import { $ } from "bun"
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import fs from "fs/promises"
import path from "path"
import { File } from "../../src/file"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import { provideInstance, tmpdir } from "../fixture/fixture"
const run = <A, E>(eff: Effect.Effect<A, E, File.Service>) =>
Effect.runPromise(provideInstance(Instance.directory)(eff.pipe(Effect.provide(File.defaultLayer))))
const status = () => run(File.Service.use((svc) => svc.status()))
const read = (file: string) => run(File.Service.use((svc) => svc.read(file)))
const wintest = process.platform === "win32" ? test : test.skip
@@ -27,7 +33,7 @@ describe("file fsmonitor", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await File.status()
await status()
},
})
@@ -52,7 +58,7 @@ describe("file fsmonitor", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await File.read("tracked.txt")
await read("tracked.txt")
},
})

View File

@@ -1,18 +1,28 @@
import { afterEach, describe, test, expect } from "bun:test"
import { $ } from "bun"
import { Effect } from "effect"
import path from "path"
import fs from "fs/promises"
import { File } from "../../src/file"
import { Instance } from "../../src/project/instance"
import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture"
import { provideInstance, tmpdir } from "../fixture/fixture"
afterEach(async () => {
await Instance.disposeAll()
})
const init = () => run(File.Service.use((svc) => svc.init()))
const run = <A, E>(eff: Effect.Effect<A, E, File.Service>) =>
Effect.runPromise(provideInstance(Instance.directory)(eff.pipe(Effect.provide(File.defaultLayer))))
const status = () => run(File.Service.use((svc) => svc.status()))
const read = (file: string) => run(File.Service.use((svc) => svc.read(file)))
const list = (dir?: string) => run(File.Service.use((svc) => svc.list(dir)))
const search = (input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) =>
run(File.Service.use((svc) => svc.search(input)))
describe("file/index Filesystem patterns", () => {
describe("File.read() - text content", () => {
describe("read() - text content", () => {
test("reads text file via Filesystem.readText()", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "test.txt")
@@ -21,7 +31,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("test.txt")
const result = await read("test.txt")
expect(result.type).toBe("text")
expect(result.content).toBe("Hello World")
},
@@ -35,7 +45,7 @@ describe("file/index Filesystem patterns", () => {
directory: tmp.path,
fn: async () => {
// Non-existent file should return empty content
const result = await File.read("nonexistent.txt")
const result = await read("nonexistent.txt")
expect(result.type).toBe("text")
expect(result.content).toBe("")
},
@@ -50,7 +60,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("test.txt")
const result = await read("test.txt")
expect(result.content).toBe("content with spaces")
},
})
@@ -64,7 +74,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("empty.txt")
const result = await read("empty.txt")
expect(result.type).toBe("text")
expect(result.content).toBe("")
},
@@ -79,14 +89,14 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("multiline.txt")
const result = await read("multiline.txt")
expect(result.content).toBe("line1\nline2\nline3")
},
})
})
})
describe("File.read() - binary content", () => {
describe("read() - binary content", () => {
test("reads binary file via Filesystem.readArrayBuffer()", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "image.png")
@@ -96,7 +106,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("image.png")
const result = await read("image.png")
expect(result.type).toBe("text") // Images return as text with base64 encoding
expect(result.encoding).toBe("base64")
expect(result.mimeType).toBe("image/png")
@@ -113,7 +123,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("binary.so")
const result = await read("binary.so")
expect(result.type).toBe("binary")
expect(result.content).toBe("")
},
@@ -121,7 +131,7 @@ describe("file/index Filesystem patterns", () => {
})
})
describe("File.read() - Filesystem.mimeType()", () => {
describe("read() - Filesystem.mimeType()", () => {
test("detects MIME type via Filesystem.mimeType()", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "test.json")
@@ -132,7 +142,7 @@ describe("file/index Filesystem patterns", () => {
fn: async () => {
expect(Filesystem.mimeType(filepath)).toContain("application/json")
const result = await File.read("test.json")
const result = await read("test.json")
expect(result.type).toBe("text")
},
})
@@ -161,7 +171,7 @@ describe("file/index Filesystem patterns", () => {
})
})
describe("File.list() - Filesystem.exists() and readText()", () => {
describe("list() - Filesystem.exists() and readText()", () => {
test("reads .gitignore via Filesystem.exists() and readText()", async () => {
await using tmp = await tmpdir({ git: true })
@@ -171,7 +181,7 @@ describe("file/index Filesystem patterns", () => {
const gitignorePath = path.join(tmp.path, ".gitignore")
await fs.writeFile(gitignorePath, "node_modules\ndist\n", "utf-8")
// This is used internally in File.list()
// This is used internally in list()
expect(await Filesystem.exists(gitignorePath)).toBe(true)
const content = await Filesystem.readText(gitignorePath)
@@ -204,8 +214,8 @@ describe("file/index Filesystem patterns", () => {
const gitignorePath = path.join(tmp.path, ".gitignore")
expect(await Filesystem.exists(gitignorePath)).toBe(false)
// File.list() should still work
const nodes = await File.list()
// list() should still work
const nodes = await list()
expect(Array.isArray(nodes)).toBe(true)
},
})
@@ -244,8 +254,8 @@ describe("file/index Filesystem patterns", () => {
// Filesystem.readText() on non-existent file throws
await expect(Filesystem.readText(nonExistentPath)).rejects.toThrow()
// But File.read() handles this gracefully
const result = await File.read("does-not-exist.txt")
// But read() handles this gracefully
const result = await read("does-not-exist.txt")
expect(result.content).toBe("")
},
})
@@ -272,8 +282,8 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
// File.read() handles missing images gracefully
const result = await File.read("broken.png")
// read() handles missing images gracefully
const result = await read("broken.png")
expect(result.type).toBe("text")
expect(result.content).toBe("")
},
@@ -290,7 +300,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("test.ts")
const result = await read("test.ts")
expect(result.type).toBe("text")
expect(result.content).toBe("export const value = 1")
},
@@ -305,7 +315,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("test.mts")
const result = await read("test.mts")
expect(result.type).toBe("text")
expect(result.content).toBe("export const value = 1")
},
@@ -320,7 +330,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("test.sh")
const result = await read("test.sh")
expect(result.type).toBe("text")
expect(result.content).toBe("#!/usr/bin/env bash\necho hello")
},
@@ -335,7 +345,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("Dockerfile")
const result = await read("Dockerfile")
expect(result.type).toBe("text")
expect(result.content).toBe("FROM alpine:3.20")
},
@@ -350,7 +360,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("test.txt")
const result = await read("test.txt")
expect(result.encoding).toBeUndefined()
expect(result.type).toBe("text")
},
@@ -365,7 +375,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("test.jpg")
const result = await read("test.jpg")
expect(result.encoding).toBe("base64")
expect(result.mimeType).toBe("image/jpeg")
},
@@ -380,7 +390,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await expect(File.read("../outside.txt")).rejects.toThrow("Access denied")
await expect(read("../outside.txt")).rejects.toThrow("Access denied")
},
})
})
@@ -391,13 +401,13 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await expect(File.read("../outside.txt")).rejects.toThrow("Access denied")
await expect(read("../outside.txt")).rejects.toThrow("Access denied")
},
})
})
})
describe("File.status()", () => {
describe("status()", () => {
test("detects modified file", async () => {
await using tmp = await tmpdir({ git: true })
const filepath = path.join(tmp.path, "file.txt")
@@ -409,7 +419,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.status()
const result = await status()
const entry = result.find((f) => f.path === "file.txt")
expect(entry).toBeDefined()
expect(entry!.status).toBe("modified")
@@ -426,7 +436,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.status()
const result = await status()
const entry = result.find((f) => f.path === "new.txt")
expect(entry).toBeDefined()
expect(entry!.status).toBe("added")
@@ -447,7 +457,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.status()
const result = await status()
// Deleted files appear in both numstat (as "modified") and diff-filter=D (as "deleted")
const entries = result.filter((f) => f.path === "gone.txt")
expect(entries.some((e) => e.status === "deleted")).toBe(true)
@@ -470,7 +480,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.status()
const result = await status()
expect(result.some((f) => f.path === "keep.txt" && f.status === "modified")).toBe(true)
expect(result.some((f) => f.path === "remove.txt" && f.status === "deleted")).toBe(true)
expect(result.some((f) => f.path === "brand-new.txt" && f.status === "added")).toBe(true)
@@ -484,7 +494,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.status()
const result = await status()
expect(result).toEqual([])
},
})
@@ -496,7 +506,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.status()
const result = await status()
expect(result).toEqual([])
},
})
@@ -519,7 +529,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.status()
const result = await status()
const entry = result.find((f) => f.path === "data.bin")
expect(entry).toBeDefined()
expect(entry!.status).toBe("modified")
@@ -530,7 +540,7 @@ describe("file/index Filesystem patterns", () => {
})
})
describe("File.list()", () => {
describe("list()", () => {
test("returns files and directories with correct shape", async () => {
await using tmp = await tmpdir({ git: true })
await fs.mkdir(path.join(tmp.path, "subdir"))
@@ -540,7 +550,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const nodes = await File.list()
const nodes = await list()
expect(nodes.length).toBeGreaterThanOrEqual(2)
for (const node of nodes) {
expect(node).toHaveProperty("name")
@@ -564,7 +574,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const nodes = await File.list()
const nodes = await list()
const dirs = nodes.filter((n) => n.type === "directory")
const files = nodes.filter((n) => n.type === "file")
// Dirs come first
@@ -589,7 +599,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const nodes = await File.list()
const nodes = await list()
const names = nodes.map((n) => n.name)
expect(names).not.toContain(".git")
expect(names).not.toContain(".DS_Store")
@@ -608,7 +618,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const nodes = await File.list()
const nodes = await list()
const logNode = nodes.find((n) => n.name === "app.log")
const tsNode = nodes.find((n) => n.name === "main.ts")
const buildNode = nodes.find((n) => n.name === "build")
@@ -628,7 +638,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const nodes = await File.list("sub")
const nodes = await list("sub")
expect(nodes.length).toBe(2)
expect(nodes.map((n) => n.name).sort()).toEqual(["a.txt", "b.txt"])
// Paths should be relative to project root (normalize for Windows)
@@ -643,7 +653,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await expect(File.list("../outside")).rejects.toThrow("Access denied")
await expect(list("../outside")).rejects.toThrow("Access denied")
},
})
})
@@ -655,7 +665,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const nodes = await File.list()
const nodes = await list()
expect(nodes.length).toBeGreaterThanOrEqual(1)
// Without git, ignored should be false for all
for (const node of nodes) {
@@ -666,7 +676,7 @@ describe("file/index Filesystem patterns", () => {
})
})
describe("File.search()", () => {
describe("search()", () => {
async function setupSearchableRepo() {
const tmp = await tmpdir({ git: true })
await fs.writeFile(path.join(tmp.path, "index.ts"), "code", "utf-8")
@@ -685,9 +695,9 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await File.init()
await init()
const result = await File.search({ query: "", type: "file" })
const result = await search({ query: "", type: "file" })
expect(result.length).toBeGreaterThan(0)
},
})
@@ -699,7 +709,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.search({ query: "main", type: "file" })
const result = await search({ query: "main", type: "file" })
expect(result.some((f) => f.includes("main"))).toBe(true)
},
})
@@ -711,9 +721,9 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await File.init()
await init()
const result = await File.search({ query: "", type: "directory" })
const result = await search({ query: "", type: "directory" })
expect(result.length).toBeGreaterThan(0)
// Find first hidden dir index
const firstHidden = result.findIndex((d) => d.split("/").some((p) => p.startsWith(".") && p.length > 1))
@@ -731,9 +741,9 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await File.init()
await init()
const result = await File.search({ query: "main", type: "file" })
const result = await search({ query: "main", type: "file" })
expect(result.some((f) => f.includes("main"))).toBe(true)
},
})
@@ -745,9 +755,9 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await File.init()
await init()
const result = await File.search({ query: "", type: "file" })
const result = await search({ query: "", type: "file" })
// Files don't end with /
for (const f of result) {
expect(f.endsWith("/")).toBe(false)
@@ -762,9 +772,9 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await File.init()
await init()
const result = await File.search({ query: "", type: "directory" })
const result = await search({ query: "", type: "directory" })
// Directories end with /
for (const d of result) {
expect(d.endsWith("/")).toBe(true)
@@ -779,9 +789,9 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await File.init()
await init()
const result = await File.search({ query: "", type: "file", limit: 2 })
const result = await search({ query: "", type: "file", limit: 2 })
expect(result.length).toBeLessThanOrEqual(2)
},
})
@@ -793,9 +803,9 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await File.init()
await init()
const result = await File.search({ query: ".hidden", type: "directory" })
const result = await search({ query: ".hidden", type: "directory" })
expect(result.length).toBeGreaterThan(0)
expect(result[0]).toContain(".hidden")
},
@@ -808,19 +818,19 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await File.init()
expect(await File.search({ query: "fresh", type: "file" })).toEqual([])
await init()
expect(await search({ query: "fresh", type: "file" })).toEqual([])
await fs.writeFile(path.join(tmp.path, "fresh.ts"), "fresh", "utf-8")
const result = await File.search({ query: "fresh", type: "file" })
const result = await search({ query: "fresh", type: "file" })
expect(result).toContain("fresh.ts")
},
})
})
})
describe("File.read() - diff/patch", () => {
describe("read() - diff/patch", () => {
test("returns diff and patch for modified tracked file", async () => {
await using tmp = await tmpdir({ git: true })
const filepath = path.join(tmp.path, "file.txt")
@@ -832,7 +842,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("file.txt")
const result = await read("file.txt")
expect(result.type).toBe("text")
expect(result.content).toBe("modified content")
expect(result.diff).toBeDefined()
@@ -856,7 +866,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("staged.txt")
const result = await read("staged.txt")
expect(result.diff).toBeDefined()
expect(result.patch).toBeDefined()
},
@@ -873,7 +883,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("clean.txt")
const result = await read("clean.txt")
expect(result.type).toBe("text")
expect(result.content).toBe("unchanged")
expect(result.diff).toBeUndefined()
@@ -893,10 +903,10 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: one.path,
fn: async () => {
await File.init()
const results = await File.search({ query: "a.ts", type: "file" })
await init()
const results = await search({ query: "a.ts", type: "file" })
expect(results).toContain("a.ts")
const results2 = await File.search({ query: "b.ts", type: "file" })
const results2 = await search({ query: "b.ts", type: "file" })
expect(results2).not.toContain("b.ts")
},
})
@@ -904,10 +914,10 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: two.path,
fn: async () => {
await File.init()
const results = await File.search({ query: "b.ts", type: "file" })
await init()
const results = await search({ query: "b.ts", type: "file" })
expect(results).toContain("b.ts")
const results2 = await File.search({ query: "a.ts", type: "file" })
const results2 = await search({ query: "a.ts", type: "file" })
expect(results2).not.toContain("a.ts")
},
})
@@ -920,8 +930,8 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await File.init()
const results = await File.search({ query: "before", type: "file" })
await init()
const results = await search({ query: "before", type: "file" })
expect(results).toContain("before.ts")
},
})
@@ -934,10 +944,10 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await File.init()
const results = await File.search({ query: "after", type: "file" })
await init()
const results = await search({ query: "after", type: "file" })
expect(results).toContain("after.ts")
const stale = await File.search({ query: "before", type: "file" })
const stale = await search({ query: "before", type: "file" })
expect(stale).not.toContain("before.ts")
},
})

View File

@@ -1,10 +1,16 @@
import { test, expect, describe } from "bun:test"
import { Effect } from "effect"
import path from "path"
import fs from "fs/promises"
import { Filesystem } from "../../src/util/filesystem"
import { File } from "../../src/file"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import { provideInstance, tmpdir } from "../fixture/fixture"
const run = <A, E>(eff: Effect.Effect<A, E, File.Service>) =>
Effect.runPromise(provideInstance(Instance.directory)(eff.pipe(Effect.provide(File.defaultLayer))))
const read = (file: string) => run(File.Service.use((svc) => svc.read(file)))
const list = (dir?: string) => run(File.Service.use((svc) => svc.list(dir)))
describe("Filesystem.contains", () => {
test("allows paths within project", () => {
@@ -32,10 +38,10 @@ describe("Filesystem.contains", () => {
})
/*
* Integration tests for File.read() and File.list() path traversal protection.
* Integration tests for read() and list() path traversal protection.
*
* These tests verify the HTTP API code path is protected. The HTTP endpoints
* in server.ts (GET /file/content, GET /file) call File.read()/File.list()
* in server.ts (GET /file/content, GET /file) call read()/list()
* directly - they do NOT go through ReadTool or the agent permission layer.
*
* This is a SEPARATE code path from ReadTool, which has its own checks.
@@ -51,7 +57,7 @@ describe("File.read path traversal protection", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await expect(File.read("../../../etc/passwd")).rejects.toThrow("Access denied: path escapes project directory")
await expect(read("../../../etc/passwd")).rejects.toThrow("Access denied: path escapes project directory")
},
})
})
@@ -62,7 +68,7 @@ describe("File.read path traversal protection", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await expect(File.read("src/nested/../../../../../../../etc/passwd")).rejects.toThrow(
await expect(read("src/nested/../../../../../../../etc/passwd")).rejects.toThrow(
"Access denied: path escapes project directory",
)
},
@@ -79,7 +85,7 @@ describe("File.read path traversal protection", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("valid.txt")
const result = await read("valid.txt")
expect(result.content).toBe("valid content")
},
})
@@ -93,7 +99,7 @@ describe("File.list path traversal protection", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await expect(File.list("../../../etc")).rejects.toThrow("Access denied: path escapes project directory")
await expect(list("../../../etc")).rejects.toThrow("Access denied: path escapes project directory")
},
})
})
@@ -108,7 +114,7 @@ describe("File.list path traversal protection", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.list("subdir")
const result = await list("subdir")
expect(Array.isArray(result)).toBe(true)
},
})

View File

@@ -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()
}
}),
),
),
)
})

View File

@@ -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<void>) {
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<typeof spyOn>
@@ -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)

View File

@@ -1,4 +1,6 @@
import { test, expect, mock, beforeEach } from "bun:test"
import { Effect } from "effect"
import type { MCP as MCPNS } from "../../src/mcp/index"
// Track what options were passed to each transport constructor
const transportCalls: Array<{
@@ -44,8 +46,10 @@ beforeEach(() => {
// Import MCP after mocking
const { MCP } = await import("../../src/mcp/index")
const { AppRuntime } = await import("../../src/effect/app-runtime")
const { Instance } = await import("../../src/project/instance")
const { tmpdir } = await import("../fixture/fixture")
const service = MCP.Service as unknown as Effect.Effect<MCPNS.Interface, never, never>
test("headers are passed to transports when oauth is enabled (default)", async () => {
await using tmp = await tmpdir({
@@ -73,14 +77,21 @@ test("headers are passed to transports when oauth is enabled (default)", async (
directory: tmp.path,
fn: async () => {
// Trigger MCP initialization - it will fail to connect but we can check the transport options
await MCP.add("test-server", {
type: "remote",
url: "https://example.com/mcp",
headers: {
Authorization: "Bearer test-token",
"X-Custom-Header": "custom-value",
},
}).catch(() => {})
await AppRuntime.runPromise(
Effect.gen(function* () {
const mcp = yield* service
yield* mcp
.add("test-server", {
type: "remote",
url: "https://example.com/mcp",
headers: {
Authorization: "Bearer test-token",
"X-Custom-Header": "custom-value",
},
})
.pipe(Effect.catch(() => Effect.void))
}),
)
// Both transports should have been created with headers
expect(transportCalls.length).toBeGreaterThanOrEqual(1)
@@ -106,14 +117,21 @@ test("headers are passed to transports when oauth is explicitly disabled", async
fn: async () => {
transportCalls.length = 0
await MCP.add("test-server-no-oauth", {
type: "remote",
url: "https://example.com/mcp",
oauth: false,
headers: {
Authorization: "Bearer test-token",
},
}).catch(() => {})
await AppRuntime.runPromise(
Effect.gen(function* () {
const mcp = yield* service
yield* mcp
.add("test-server-no-oauth", {
type: "remote",
url: "https://example.com/mcp",
oauth: false,
headers: {
Authorization: "Bearer test-token",
},
})
.pipe(Effect.catch(() => Effect.void))
}),
)
expect(transportCalls.length).toBeGreaterThanOrEqual(1)
@@ -137,10 +155,17 @@ test("no requestInit when headers are not provided", async () => {
fn: async () => {
transportCalls.length = 0
await MCP.add("test-server-no-headers", {
type: "remote",
url: "https://example.com/mcp",
}).catch(() => {})
await AppRuntime.runPromise(
Effect.gen(function* () {
const mcp = yield* service
yield* mcp
.add("test-server-no-headers", {
type: "remote",
url: "https://example.com/mcp",
})
.pipe(Effect.catch(() => Effect.void))
}),
)
expect(transportCalls.length).toBeGreaterThanOrEqual(1)

View File

@@ -1,4 +1,6 @@
import { test, expect, mock, beforeEach } from "bun:test"
import { Effect } from "effect"
import type { MCP as MCPNS } from "../../src/mcp/index"
// --- Mock infrastructure ---
@@ -170,7 +172,10 @@ const { tmpdir } = await import("../fixture/fixture")
// --- Helper ---
function withInstance(config: Record<string, any>, fn: () => Promise<void>) {
function withInstance(
config: Record<string, unknown>,
fn: (mcp: MCPNS.Interface) => Effect.Effect<void, unknown, never>,
) {
return async () => {
await using tmp = await tmpdir({
init: async (dir) => {
@@ -187,7 +192,7 @@ function withInstance(config: Record<string, any>, fn: () => Promise<void>) {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await fn()
await Effect.runPromise(MCP.Service.use(fn).pipe(Effect.provide(MCP.defaultLayer)))
// dispose instance to clean up state between tests
await Instance.dispose()
},
@@ -201,28 +206,30 @@ function withInstance(config: Record<string, any>, fn: () => Promise<void>) {
test(
"tools() reuses cached tool definitions after connect",
withInstance({}, async () => {
lastCreatedClientName = "my-server"
const serverState = getOrCreateClientState("my-server")
serverState.tools = [
{ name: "do_thing", description: "does a thing", inputSchema: { type: "object", properties: {} } },
]
withInstance({}, (mcp) =>
Effect.gen(function* () {
lastCreatedClientName = "my-server"
const serverState = getOrCreateClientState("my-server")
serverState.tools = [
{ name: "do_thing", description: "does a thing", inputSchema: { type: "object", properties: {} } },
]
// First: add the server successfully
const addResult = await MCP.add("my-server", {
type: "local",
command: ["echo", "test"],
})
expect((addResult.status as any)["my-server"]?.status ?? (addResult.status as any).status).toBe("connected")
// First: add the server successfully
const addResult = yield* mcp.add("my-server", {
type: "local",
command: ["echo", "test"],
})
expect((addResult.status as any)["my-server"]?.status ?? (addResult.status as any).status).toBe("connected")
expect(serverState.listToolsCalls).toBe(1)
expect(serverState.listToolsCalls).toBe(1)
const toolsA = await MCP.tools()
const toolsB = await MCP.tools()
expect(Object.keys(toolsA).length).toBeGreaterThan(0)
expect(Object.keys(toolsB).length).toBeGreaterThan(0)
expect(serverState.listToolsCalls).toBe(1)
}),
const toolsA = yield* mcp.tools()
const toolsB = yield* mcp.tools()
expect(Object.keys(toolsA).length).toBeGreaterThan(0)
expect(Object.keys(toolsB).length).toBeGreaterThan(0)
expect(serverState.listToolsCalls).toBe(1)
}),
),
)
// ========================================================================
@@ -231,30 +238,32 @@ test(
test(
"tool change notifications refresh cached tool definitions",
withInstance({}, async () => {
lastCreatedClientName = "status-server"
const serverState = getOrCreateClientState("status-server")
withInstance({}, (mcp) =>
Effect.gen(function* () {
lastCreatedClientName = "status-server"
const serverState = getOrCreateClientState("status-server")
await MCP.add("status-server", {
type: "local",
command: ["echo", "test"],
})
yield* mcp.add("status-server", {
type: "local",
command: ["echo", "test"],
})
const before = await MCP.tools()
expect(Object.keys(before).some((key) => key.includes("test_tool"))).toBe(true)
expect(serverState.listToolsCalls).toBe(1)
const before = yield* mcp.tools()
expect(Object.keys(before).some((key) => key.includes("test_tool"))).toBe(true)
expect(serverState.listToolsCalls).toBe(1)
serverState.tools = [{ name: "next_tool", description: "next", inputSchema: { type: "object", properties: {} } }]
serverState.tools = [{ name: "next_tool", description: "next", inputSchema: { type: "object", properties: {} } }]
const handler = Array.from(serverState.notificationHandlers.values())[0]
expect(handler).toBeDefined()
await handler?.()
const handler = Array.from(serverState.notificationHandlers.values())[0]
expect(handler).toBeDefined()
yield* Effect.promise(() => handler?.())
const after = await MCP.tools()
expect(Object.keys(after).some((key) => key.includes("next_tool"))).toBe(true)
expect(Object.keys(after).some((key) => key.includes("test_tool"))).toBe(false)
expect(serverState.listToolsCalls).toBe(2)
}),
const after = yield* mcp.tools()
expect(Object.keys(after).some((key) => key.includes("next_tool"))).toBe(true)
expect(Object.keys(after).some((key) => key.includes("test_tool"))).toBe(false)
expect(serverState.listToolsCalls).toBe(2)
}),
),
)
// ========================================================================
@@ -270,28 +279,29 @@ test(
command: ["echo", "test"],
},
},
async () => {
lastCreatedClientName = "disc-server"
getOrCreateClientState("disc-server")
(mcp) =>
Effect.gen(function* () {
lastCreatedClientName = "disc-server"
getOrCreateClientState("disc-server")
await MCP.add("disc-server", {
type: "local",
command: ["echo", "test"],
})
yield* mcp.add("disc-server", {
type: "local",
command: ["echo", "test"],
})
const statusBefore = await MCP.status()
expect(statusBefore["disc-server"]?.status).toBe("connected")
const statusBefore = yield* mcp.status()
expect(statusBefore["disc-server"]?.status).toBe("connected")
await MCP.disconnect("disc-server")
yield* mcp.disconnect("disc-server")
const statusAfter = await MCP.status()
expect(statusAfter["disc-server"]?.status).toBe("disabled")
const statusAfter = yield* mcp.status()
expect(statusAfter["disc-server"]?.status).toBe("disabled")
// Tools should be empty after disconnect
const tools = await MCP.tools()
const serverTools = Object.keys(tools).filter((k) => k.startsWith("disc-server"))
expect(serverTools.length).toBe(0)
},
// Tools should be empty after disconnect
const tools = yield* mcp.tools()
const serverTools = Object.keys(tools).filter((k) => k.startsWith("disc-server"))
expect(serverTools.length).toBe(0)
}),
),
)
@@ -304,26 +314,29 @@ test(
command: ["echo", "test"],
},
},
async () => {
lastCreatedClientName = "reconn-server"
const serverState = getOrCreateClientState("reconn-server")
serverState.tools = [{ name: "my_tool", description: "a tool", inputSchema: { type: "object", properties: {} } }]
(mcp) =>
Effect.gen(function* () {
lastCreatedClientName = "reconn-server"
const serverState = getOrCreateClientState("reconn-server")
serverState.tools = [
{ name: "my_tool", description: "a tool", inputSchema: { type: "object", properties: {} } },
]
await MCP.add("reconn-server", {
type: "local",
command: ["echo", "test"],
})
yield* mcp.add("reconn-server", {
type: "local",
command: ["echo", "test"],
})
await MCP.disconnect("reconn-server")
expect((await MCP.status())["reconn-server"]?.status).toBe("disabled")
yield* mcp.disconnect("reconn-server")
expect((yield* mcp.status())["reconn-server"]?.status).toBe("disabled")
// Reconnect
await MCP.connect("reconn-server")
expect((await MCP.status())["reconn-server"]?.status).toBe("connected")
// Reconnect
yield* mcp.connect("reconn-server")
expect((yield* mcp.status())["reconn-server"]?.status).toBe("connected")
const tools = await MCP.tools()
expect(Object.keys(tools).some((k) => k.includes("my_tool"))).toBe(true)
},
const tools = yield* mcp.tools()
expect(Object.keys(tools).some((k) => k.includes("my_tool"))).toBe(true)
}),
),
)
@@ -335,30 +348,32 @@ test(
"add() closes the old client when replacing a server",
// Don't put the server in config — add it dynamically so we control
// exactly which client instance is "first" vs "second".
withInstance({}, async () => {
lastCreatedClientName = "replace-server"
const firstState = getOrCreateClientState("replace-server")
withInstance({}, (mcp) =>
Effect.gen(function* () {
lastCreatedClientName = "replace-server"
const firstState = getOrCreateClientState("replace-server")
await MCP.add("replace-server", {
type: "local",
command: ["echo", "test"],
})
yield* mcp.add("replace-server", {
type: "local",
command: ["echo", "test"],
})
expect(firstState.closed).toBe(false)
expect(firstState.closed).toBe(false)
// Create new state for second client
clientStates.delete("replace-server")
const secondState = getOrCreateClientState("replace-server")
// Create new state for second client
clientStates.delete("replace-server")
const secondState = getOrCreateClientState("replace-server")
// Re-add should close the first client
await MCP.add("replace-server", {
type: "local",
command: ["echo", "test"],
})
// Re-add should close the first client
yield* mcp.add("replace-server", {
type: "local",
command: ["echo", "test"],
})
expect(firstState.closed).toBe(true)
expect(secondState.closed).toBe(false)
}),
expect(firstState.closed).toBe(true)
expect(secondState.closed).toBe(false)
}),
),
)
// ========================================================================
@@ -378,37 +393,38 @@ test(
command: ["echo", "bad"],
},
},
async () => {
// Set up good server
const goodState = getOrCreateClientState("good-server")
goodState.tools = [{ name: "good_tool", description: "works", inputSchema: { type: "object", properties: {} } }]
(mcp) =>
Effect.gen(function* () {
// Set up good server
const goodState = getOrCreateClientState("good-server")
goodState.tools = [{ name: "good_tool", description: "works", inputSchema: { type: "object", properties: {} } }]
// Set up bad server - will fail on listTools during create()
const badState = getOrCreateClientState("bad-server")
badState.listToolsShouldFail = true
// Set up bad server - will fail on listTools during create()
const badState = getOrCreateClientState("bad-server")
badState.listToolsShouldFail = true
// Add good server first
lastCreatedClientName = "good-server"
await MCP.add("good-server", {
type: "local",
command: ["echo", "good"],
})
// Add good server first
lastCreatedClientName = "good-server"
yield* mcp.add("good-server", {
type: "local",
command: ["echo", "good"],
})
// Add bad server - should fail but not affect good server
lastCreatedClientName = "bad-server"
await MCP.add("bad-server", {
type: "local",
command: ["echo", "bad"],
})
// Add bad server - should fail but not affect good server
lastCreatedClientName = "bad-server"
yield* mcp.add("bad-server", {
type: "local",
command: ["echo", "bad"],
})
const status = await MCP.status()
expect(status["good-server"]?.status).toBe("connected")
expect(status["bad-server"]?.status).toBe("failed")
const status = yield* mcp.status()
expect(status["good-server"]?.status).toBe("connected")
expect(status["bad-server"]?.status).toBe("failed")
// Good server's tools should still be available
const tools = await MCP.tools()
expect(Object.keys(tools).some((k) => k.includes("good_tool"))).toBe(true)
},
// Good server's tools should still be available
const tools = yield* mcp.tools()
expect(Object.keys(tools).some((k) => k.includes("good_tool"))).toBe(true)
}),
),
)
@@ -426,21 +442,22 @@ test(
enabled: false,
},
},
async () => {
const countBefore = clientCreateCount
(mcp) =>
Effect.gen(function* () {
const countBefore = clientCreateCount
await MCP.add("disabled-server", {
type: "local",
command: ["echo", "test"],
enabled: false,
} as any)
yield* mcp.add("disabled-server", {
type: "local",
command: ["echo", "test"],
enabled: false,
} as any)
// No client should have been created
expect(clientCreateCount).toBe(countBefore)
// No client should have been created
expect(clientCreateCount).toBe(countBefore)
const status = await MCP.status()
expect(status["disabled-server"]?.status).toBe("disabled")
},
const status = yield* mcp.status()
expect(status["disabled-server"]?.status).toBe("disabled")
}),
),
)
@@ -457,22 +474,23 @@ test(
command: ["echo", "test"],
},
},
async () => {
lastCreatedClientName = "prompt-server"
const serverState = getOrCreateClientState("prompt-server")
serverState.prompts = [{ name: "my-prompt", description: "A test prompt" }]
(mcp) =>
Effect.gen(function* () {
lastCreatedClientName = "prompt-server"
const serverState = getOrCreateClientState("prompt-server")
serverState.prompts = [{ name: "my-prompt", description: "A test prompt" }]
await MCP.add("prompt-server", {
type: "local",
command: ["echo", "test"],
})
yield* mcp.add("prompt-server", {
type: "local",
command: ["echo", "test"],
})
const prompts = await MCP.prompts()
expect(Object.keys(prompts).length).toBe(1)
const key = Object.keys(prompts)[0]
expect(key).toContain("prompt-server")
expect(key).toContain("my-prompt")
},
const prompts = yield* mcp.prompts()
expect(Object.keys(prompts).length).toBe(1)
const key = Object.keys(prompts)[0]
expect(key).toContain("prompt-server")
expect(key).toContain("my-prompt")
}),
),
)
@@ -485,22 +503,23 @@ test(
command: ["echo", "test"],
},
},
async () => {
lastCreatedClientName = "resource-server"
const serverState = getOrCreateClientState("resource-server")
serverState.resources = [{ name: "my-resource", uri: "file:///test.txt", description: "A test resource" }]
(mcp) =>
Effect.gen(function* () {
lastCreatedClientName = "resource-server"
const serverState = getOrCreateClientState("resource-server")
serverState.resources = [{ name: "my-resource", uri: "file:///test.txt", description: "A test resource" }]
await MCP.add("resource-server", {
type: "local",
command: ["echo", "test"],
})
yield* mcp.add("resource-server", {
type: "local",
command: ["echo", "test"],
})
const resources = await MCP.resources()
expect(Object.keys(resources).length).toBe(1)
const key = Object.keys(resources)[0]
expect(key).toContain("resource-server")
expect(key).toContain("my-resource")
},
const resources = yield* mcp.resources()
expect(Object.keys(resources).length).toBe(1)
const key = Object.keys(resources)[0]
expect(key).toContain("resource-server")
expect(key).toContain("my-resource")
}),
),
)
@@ -513,21 +532,22 @@ test(
command: ["echo", "test"],
},
},
async () => {
lastCreatedClientName = "prompt-disc-server"
const serverState = getOrCreateClientState("prompt-disc-server")
serverState.prompts = [{ name: "hidden-prompt", description: "Should not appear" }]
(mcp) =>
Effect.gen(function* () {
lastCreatedClientName = "prompt-disc-server"
const serverState = getOrCreateClientState("prompt-disc-server")
serverState.prompts = [{ name: "hidden-prompt", description: "Should not appear" }]
await MCP.add("prompt-disc-server", {
type: "local",
command: ["echo", "test"],
})
yield* mcp.add("prompt-disc-server", {
type: "local",
command: ["echo", "test"],
})
await MCP.disconnect("prompt-disc-server")
yield* mcp.disconnect("prompt-disc-server")
const prompts = await MCP.prompts()
expect(Object.keys(prompts).length).toBe(0)
},
const prompts = yield* mcp.prompts()
expect(Object.keys(prompts).length).toBe(0)
}),
),
)
@@ -537,12 +557,14 @@ test(
test(
"connect() on nonexistent server does not throw",
withInstance({}, async () => {
// Should not throw
await MCP.connect("nonexistent")
const status = await MCP.status()
expect(status["nonexistent"]).toBeUndefined()
}),
withInstance({}, (mcp) =>
Effect.gen(function* () {
// Should not throw
yield* mcp.connect("nonexistent")
const status = yield* mcp.status()
expect(status["nonexistent"]).toBeUndefined()
}),
),
)
// ========================================================================
@@ -551,10 +573,12 @@ test(
test(
"disconnect() on nonexistent server does not throw",
withInstance({}, async () => {
await MCP.disconnect("nonexistent")
// Should complete without error
}),
withInstance({}, (mcp) =>
Effect.gen(function* () {
yield* mcp.disconnect("nonexistent")
// Should complete without error
}),
),
)
// ========================================================================
@@ -563,10 +587,12 @@ test(
test(
"tools() returns empty when no MCP servers are configured",
withInstance({}, async () => {
const tools = await MCP.tools()
expect(Object.keys(tools).length).toBe(0)
}),
withInstance({}, (mcp) =>
Effect.gen(function* () {
const tools = yield* mcp.tools()
expect(Object.keys(tools).length).toBe(0)
}),
),
)
// ========================================================================
@@ -582,27 +608,28 @@ test(
command: ["echo", "test"],
},
},
async () => {
lastCreatedClientName = "fail-connect"
getOrCreateClientState("fail-connect")
connectShouldFail = true
connectError = "Connection refused"
(mcp) =>
Effect.gen(function* () {
lastCreatedClientName = "fail-connect"
getOrCreateClientState("fail-connect")
connectShouldFail = true
connectError = "Connection refused"
await MCP.add("fail-connect", {
type: "local",
command: ["echo", "test"],
})
yield* mcp.add("fail-connect", {
type: "local",
command: ["echo", "test"],
})
const status = await MCP.status()
expect(status["fail-connect"]?.status).toBe("failed")
if (status["fail-connect"]?.status === "failed") {
expect(status["fail-connect"].error).toContain("Connection refused")
}
const status = yield* mcp.status()
expect(status["fail-connect"]?.status).toBe("failed")
if (status["fail-connect"]?.status === "failed") {
expect(status["fail-connect"].error).toContain("Connection refused")
}
// No tools should be available
const tools = await MCP.tools()
expect(Object.keys(tools).length).toBe(0)
},
// No tools should be available
const tools = yield* mcp.tools()
expect(Object.keys(tools).length).toBe(0)
}),
),
)
@@ -648,28 +675,29 @@ test(
command: ["echo", "test"],
},
},
async () => {
lastCreatedClientName = "my.special-server"
const serverState = getOrCreateClientState("my.special-server")
serverState.tools = [
{ name: "tool-a", description: "Tool A", inputSchema: { type: "object", properties: {} } },
{ name: "tool.b", description: "Tool B", inputSchema: { type: "object", properties: {} } },
]
(mcp) =>
Effect.gen(function* () {
lastCreatedClientName = "my.special-server"
const serverState = getOrCreateClientState("my.special-server")
serverState.tools = [
{ name: "tool-a", description: "Tool A", inputSchema: { type: "object", properties: {} } },
{ name: "tool.b", description: "Tool B", inputSchema: { type: "object", properties: {} } },
]
await MCP.add("my.special-server", {
type: "local",
command: ["echo", "test"],
})
yield* mcp.add("my.special-server", {
type: "local",
command: ["echo", "test"],
})
const tools = await MCP.tools()
const keys = Object.keys(tools)
const tools = yield* mcp.tools()
const keys = Object.keys(tools)
// Server name dots should be replaced with underscores
expect(keys.some((k) => k.startsWith("my_special-server_"))).toBe(true)
// Tool name dots should be replaced with underscores
expect(keys.some((k) => k.endsWith("tool_b"))).toBe(true)
expect(keys.length).toBe(2)
},
// Server name dots should be replaced with underscores
expect(keys.some((k) => k.startsWith("my_special-server_"))).toBe(true)
// Tool name dots should be replaced with underscores
expect(keys.some((k) => k.endsWith("tool_b"))).toBe(true)
expect(keys.length).toBe(2)
}),
),
)
@@ -679,23 +707,25 @@ test(
test(
"local stdio transport is closed when connect times out (no process leak)",
withInstance({}, async () => {
lastCreatedClientName = "hanging-server"
getOrCreateClientState("hanging-server")
connectShouldHang = true
withInstance({}, (mcp) =>
Effect.gen(function* () {
lastCreatedClientName = "hanging-server"
getOrCreateClientState("hanging-server")
connectShouldHang = true
const addResult = await MCP.add("hanging-server", {
type: "local",
command: ["node", "fake.js"],
timeout: 100,
})
const addResult = yield* mcp.add("hanging-server", {
type: "local",
command: ["node", "fake.js"],
timeout: 100,
})
const serverStatus = (addResult.status as any)["hanging-server"] ?? addResult.status
expect(serverStatus.status).toBe("failed")
expect(serverStatus.error).toContain("timed out")
// Transport must be closed to avoid orphaned child process
expect(transportCloseCount).toBeGreaterThanOrEqual(1)
}),
const serverStatus = (addResult.status as any)["hanging-server"] ?? addResult.status
expect(serverStatus.status).toBe("failed")
expect(serverStatus.error).toContain("timed out")
// Transport must be closed to avoid orphaned child process
expect(transportCloseCount).toBeGreaterThanOrEqual(1)
}),
),
)
// ========================================================================
@@ -704,23 +734,25 @@ test(
test(
"remote transport is closed when connect times out",
withInstance({}, async () => {
lastCreatedClientName = "hanging-remote"
getOrCreateClientState("hanging-remote")
connectShouldHang = true
withInstance({}, (mcp) =>
Effect.gen(function* () {
lastCreatedClientName = "hanging-remote"
getOrCreateClientState("hanging-remote")
connectShouldHang = true
const addResult = await MCP.add("hanging-remote", {
type: "remote",
url: "http://localhost:9999/mcp",
timeout: 100,
oauth: false,
})
const addResult = yield* mcp.add("hanging-remote", {
type: "remote",
url: "http://localhost:9999/mcp",
timeout: 100,
oauth: false,
})
const serverStatus = (addResult.status as any)["hanging-remote"] ?? addResult.status
expect(serverStatus.status).toBe("failed")
// Transport must be closed to avoid leaked HTTP connections
expect(transportCloseCount).toBeGreaterThanOrEqual(1)
}),
const serverStatus = (addResult.status as any)["hanging-remote"] ?? addResult.status
expect(serverStatus.status).toBe("failed")
// Transport must be closed to avoid leaked HTTP connections
expect(transportCloseCount).toBeGreaterThanOrEqual(1)
}),
),
)
// ========================================================================
@@ -729,22 +761,24 @@ test(
test(
"failed remote transport is closed before trying next transport",
withInstance({}, async () => {
lastCreatedClientName = "fail-remote"
getOrCreateClientState("fail-remote")
connectShouldFail = true
connectError = "Connection refused"
withInstance({}, (mcp) =>
Effect.gen(function* () {
lastCreatedClientName = "fail-remote"
getOrCreateClientState("fail-remote")
connectShouldFail = true
connectError = "Connection refused"
const addResult = await MCP.add("fail-remote", {
type: "remote",
url: "http://localhost:9999/mcp",
timeout: 5000,
oauth: false,
})
const addResult = yield* mcp.add("fail-remote", {
type: "remote",
url: "http://localhost:9999/mcp",
timeout: 5000,
oauth: false,
})
const serverStatus = (addResult.status as any)["fail-remote"] ?? addResult.status
expect(serverStatus.status).toBe("failed")
// Both StreamableHTTP and SSE transports should be closed
expect(transportCloseCount).toBeGreaterThanOrEqual(2)
}),
const serverStatus = (addResult.status as any)["fail-remote"] ?? addResult.status
expect(serverStatus.status).toBe("failed")
// Both StreamableHTTP and SSE transports should be closed
expect(transportCloseCount).toBeGreaterThanOrEqual(2)
}),
),
)

View File

@@ -1,4 +1,6 @@
import { test, expect, mock, beforeEach } from "bun:test"
import { Effect } from "effect"
import type { MCP as MCPNS } from "../../src/mcp/index"
// Mock UnauthorizedError to match the SDK's class
class MockUnauthorizedError extends Error {
@@ -122,10 +124,14 @@ test("first connect to OAuth server shows needs_auth instead of failed", async (
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await MCP.add("test-oauth", {
type: "remote",
url: "https://example.com/mcp",
})
const result = await Effect.runPromise(
MCP.Service.use((mcp) =>
mcp.add("test-oauth", {
type: "remote",
url: "https://example.com/mcp",
}),
).pipe(Effect.provide(MCP.defaultLayer)),
)
const serverStatus = result.status as Record<string, { status: string; error?: string }>

View File

@@ -1,5 +1,7 @@
import { test, expect, mock, beforeEach } from "bun:test"
import { EventEmitter } from "events"
import { Effect } from "effect"
import type { MCP as MCPNS } from "../../src/mcp/index"
// Track open() calls and control failure behavior
let openShouldFail = false
@@ -100,10 +102,12 @@ beforeEach(() => {
// Import modules after mocking
const { MCP } = await import("../../src/mcp/index")
const { AppRuntime } = await import("../../src/effect/app-runtime")
const { Bus } = await import("../../src/bus")
const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback")
const { Instance } = await import("../../src/project/instance")
const { tmpdir } = await import("../fixture/fixture")
const service = MCP.Service as unknown as Effect.Effect<MCPNS.Interface, never, never>
test("BrowserOpenFailed event is published when open() throws", async () => {
await using tmp = await tmpdir({
@@ -136,7 +140,12 @@ test("BrowserOpenFailed event is published when open() throws", async () => {
// Run authenticate with a timeout to avoid waiting forever for the callback
// Attach a handler immediately so callback shutdown rejections
// don't show up as unhandled between tests.
const authPromise = MCP.authenticate("test-oauth-server").catch(() => undefined)
const authPromise = AppRuntime.runPromise(
Effect.gen(function* () {
const mcp = yield* service
return yield* mcp.authenticate("test-oauth-server")
}),
).catch(() => undefined)
// Config.get() can be slow in tests, so give it plenty of time.
await new Promise((resolve) => setTimeout(resolve, 2_000))
@@ -185,7 +194,12 @@ test("BrowserOpenFailed event is NOT published when open() succeeds", async () =
})
// Run authenticate with a timeout to avoid waiting forever for the callback
const authPromise = MCP.authenticate("test-oauth-server-2").catch(() => undefined)
const authPromise = AppRuntime.runPromise(
Effect.gen(function* () {
const mcp = yield* service
return yield* mcp.authenticate("test-oauth-server-2")
}),
).catch(() => undefined)
// Config.get() can be slow in tests; also covers the ~500ms open() error-detection window.
await new Promise((resolve) => setTimeout(resolve, 2_000))
@@ -230,7 +244,12 @@ test("open() is called with the authorization URL", async () => {
openCalledWith = undefined
// Run authenticate with a timeout to avoid waiting forever for the callback
const authPromise = MCP.authenticate("test-oauth-server-3").catch(() => undefined)
const authPromise = AppRuntime.runPromise(
Effect.gen(function* () {
const mcp = yield* service
return yield* mcp.authenticate("test-oauth-server-3")
}),
).catch(() => undefined)
// Config.get() can be slow in tests; also covers the ~500ms open() error-detection window.
await new Promise((resolve) => setTimeout(resolve, 2_000))

View File

@@ -3,6 +3,9 @@ import { Permission } from "../src/permission"
import { Config } from "../src/config/config"
import { Instance } from "../src/project/instance"
import { tmpdir } from "./fixture/fixture"
import { AppRuntime } from "../src/effect/app-runtime"
const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get()))
afterEach(async () => {
await Instance.disposeAll()
@@ -158,7 +161,7 @@ describe("permission.task with real config files", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const config = await load()
const ruleset = Permission.fromConfig(config.permission ?? {})
// general and orchestrator-fast should be allowed, code-reviewer denied
expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
@@ -183,7 +186,7 @@ describe("permission.task with real config files", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const config = await load()
const ruleset = Permission.fromConfig(config.permission ?? {})
// general and code-reviewer should be ask, orchestrator-* denied
expect(Permission.evaluate("task", "general", ruleset).action).toBe("ask")
@@ -208,7 +211,7 @@ describe("permission.task with real config files", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const config = await load()
const ruleset = Permission.fromConfig(config.permission ?? {})
expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
@@ -235,7 +238,7 @@ describe("permission.task with real config files", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const config = await load()
const ruleset = Permission.fromConfig(config.permission ?? {})
// Verify task permissions
@@ -273,7 +276,7 @@ describe("permission.task with real config files", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const config = await load()
const ruleset = Permission.fromConfig(config.permission ?? {})
// Last matching rule wins - "*" deny is last, so all agents are denied
@@ -304,7 +307,7 @@ describe("permission.task with real config files", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const config = await load()
const ruleset = Permission.fromConfig(config.permission ?? {})
// Evaluate uses findLast - "general" allow comes after "*" deny

View File

@@ -125,6 +125,9 @@ test("remaps fallback oauth model urls to the enterprise host", async () => {
project: {} as never,
directory: "",
worktree: "",
experimental_workspace: {
register() {},
},
serverUrl: new URL("https://example.com"),
$: {} as never,
})

View File

@@ -0,0 +1,99 @@
import { afterAll, afterEach, describe, expect, test } from "bun:test"
import path from "path"
import { pathToFileURL } from "url"
import { tmpdir } from "../fixture/fixture"
const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1"
const { Plugin } = await import("../../src/plugin/index")
const { Workspace } = await import("../../src/control-plane/workspace")
const { Instance } = await import("../../src/project/instance")
afterEach(async () => {
await Instance.disposeAll()
})
afterAll(() => {
if (disableDefault === undefined) {
delete process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
return
}
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = disableDefault
})
describe("plugin.workspace", () => {
test("plugin can install a workspace adaptor", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const type = `plug-${Math.random().toString(36).slice(2)}`
const file = path.join(dir, "plugin.ts")
const mark = path.join(dir, "created.json")
const space = path.join(dir, "space")
await Bun.write(
file,
[
"export default async ({ experimental_workspace }) => {",
` experimental_workspace.register(${JSON.stringify(type)}, {`,
' name: "plug",',
' description: "plugin workspace adaptor",',
" configure(input) {",
` return { ...input, name: \"plug\", branch: \"plug/main\", directory: ${JSON.stringify(space)} }`,
" },",
" async create(input) {",
` await Bun.write(${JSON.stringify(mark)}, JSON.stringify(input))`,
" },",
" async remove() {},",
" target(input) {",
' return { type: "local", directory: input.directory }',
" },",
" })",
" return {}",
"}",
"",
].join("\n"),
)
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify(
{
$schema: "https://opencode.ai/config.json",
plugin: [pathToFileURL(file).href],
},
null,
2,
),
)
return { mark, space, type }
},
})
const info = await Instance.provide({
directory: tmp.path,
fn: async () => {
await Plugin.init()
return Workspace.create({
type: tmp.extra.type,
branch: null,
extra: { key: "value" },
projectID: Instance.project.id,
})
},
})
expect(info.type).toBe(tmp.extra.type)
expect(info.name).toBe("plug")
expect(info.branch).toBe("plug/main")
expect(info.directory).toBe(tmp.extra.space)
expect(info.extra).toEqual({ key: "value" })
expect(JSON.parse(await Bun.file(tmp.extra.mark).text())).toMatchObject({
type: tmp.extra.type,
name: "plug",
branch: "plug/main",
directory: tmp.extra.space,
extra: { key: "value" },
})
})
})

View File

@@ -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()

View File

@@ -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")

View File

@@ -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<ReturnType<typeof Provider.list>>) {
async function run<A, E>(fn: (provider: Provider.Interface) => Effect.Effect<A, E, never>) {
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<ReturnType<typeof list>>) {
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)

View File

@@ -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",

View File

@@ -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: {

View File

@@ -0,0 +1,34 @@
import type { Plugin } from "@opencode-ai/plugin"
import { mkdir, rm } from "node:fs/promises"
export const FolderWorkspacePlugin: Plugin = async ({ experimental_workspace }) => {
experimental_workspace.register("folder", {
name: "Folder",
description: "Create a blank folder",
configure(config) {
const rand = "" + Math.random()
return {
...config,
directory: `/tmp/folder/folder-${rand}`,
}
},
async create(config) {
if (!config.directory) return
await mkdir(config.directory, { recursive: true })
},
async remove(config) {
await rm(config.directory!, { recursive: true, force: true })
},
target(config) {
return {
type: "local",
directory: config.directory!,
}
},
})
return {}
}
export default FolderWorkspacePlugin

View File

@@ -24,11 +24,44 @@ export type ProviderContext = {
options: Record<string, any>
}
export type WorkspaceInfo = {
id: string
type: string
name: string
branch: string | null
directory: string | null
extra: unknown | null
projectID: string
}
export type WorkspaceTarget =
| {
type: "local"
directory: string
}
| {
type: "remote"
url: string | URL
headers?: HeadersInit
}
export type WorkspaceAdaptor = {
name: string
description: string
configure(config: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
create(config: WorkspaceInfo, from?: WorkspaceInfo): Promise<void>
remove(config: WorkspaceInfo): Promise<void>
target(config: WorkspaceInfo): WorkspaceTarget | Promise<WorkspaceTarget>
}
export type PluginInput = {
client: ReturnType<typeof createOpencodeClient>
project: Project
directory: string
worktree: string
experimental_workspace: {
register(type: string, adaptor: WorkspaceAdaptor): void
}
serverUrl: URL
$: BunShell
}

View File

@@ -2,6 +2,7 @@
"$schema": "https://json.schemastore.org/tsconfig.json",
"extends": "@tsconfig/node22/tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"module": "nodenext",
"declaration": true,

View File

@@ -29,6 +29,7 @@ import type {
ExperimentalConsoleSwitchOrgResponses,
ExperimentalResourceListResponses,
ExperimentalSessionListResponses,
ExperimentalWorkspaceAdaptorListResponses,
ExperimentalWorkspaceCreateErrors,
ExperimentalWorkspaceCreateResponses,
ExperimentalWorkspaceListResponses,
@@ -1086,6 +1087,38 @@ export class Console extends HeyApiClient {
}
}
export class Adaptor extends HeyApiClient {
/**
* List workspace adaptors
*
* List all available workspace adaptors for the current project.
*/
public list<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).get<ExperimentalWorkspaceAdaptorListResponses, unknown, ThrowOnError>({
url: "/experimental/workspace/adaptor",
...options,
...params,
})
}
}
export class Workspace extends HeyApiClient {
/**
* List workspaces
@@ -1229,6 +1262,11 @@ export class Workspace extends HeyApiClient {
...params,
})
}
private _adaptor?: Adaptor
get adaptor(): Adaptor {
return (this._adaptor ??= new Adaptor({ client: this.client }))
}
}
export class Session extends HeyApiClient {

View File

@@ -1772,8 +1772,8 @@ export type ToolList = Array<ToolListItem>
export type Workspace = {
id: string
type: string
name: string
branch: string | null
name: string | null
directory: string | null
extra: unknown | null
projectID: string
@@ -2812,6 +2812,30 @@ export type ToolListResponses = {
export type ToolListResponse = ToolListResponses[keyof ToolListResponses]
export type ExperimentalWorkspaceAdaptorListData = {
body?: never
path?: never
query?: {
directory?: string
workspace?: string
}
url: "/experimental/workspace/adaptor"
}
export type ExperimentalWorkspaceAdaptorListResponses = {
/**
* Workspace adaptors
*/
200: Array<{
type: string
name: string
description: string
}>
}
export type ExperimentalWorkspaceAdaptorListResponse =
ExperimentalWorkspaceAdaptorListResponses[keyof ExperimentalWorkspaceAdaptorListResponses]
export type ExperimentalWorkspaceListData = {
body?: never
path?: never

View File

@@ -1526,6 +1526,62 @@
]
}
},
"/experimental/workspace/adaptor": {
"get": {
"operationId": "experimental.workspace.adaptor.list",
"parameters": [
{
"in": "query",
"name": "directory",
"schema": {
"type": "string"
}
},
{
"in": "query",
"name": "workspace",
"schema": {
"type": "string"
}
}
],
"summary": "List workspace adaptors",
"description": "List all available workspace adaptors for the current project.",
"responses": {
"200": {
"description": "Workspace adaptors",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "object",
"properties": {
"type": {
"type": "string"
},
"name": {
"type": "string"
},
"description": {
"type": "string"
}
},
"required": ["type", "name", "description"]
}
}
}
}
}
},
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.adaptor.list({\n ...\n})"
}
]
}
},
"/experimental/workspace": {
"post": {
"operationId": "experimental.workspace.create",
@@ -11885,17 +11941,10 @@
"type": {
"type": "string"
},
"branch": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"name": {
"type": "string"
},
"branch": {
"anyOf": [
{
"type": "string"
@@ -11927,7 +11976,7 @@
"type": "string"
}
},
"required": ["id", "type", "branch", "name", "directory", "extra", "projectID"]
"required": ["id", "type", "name", "branch", "directory", "extra", "projectID"]
},
"Worktree": {
"type": "object",