From 9e556b0f6ca3fecd0fef6198acddc11680a4b243 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 27 May 2026 16:27:46 -0400 Subject: [PATCH] feat(core): add location-scoped config loading --- bun.lock | 1 + packages/core/package.json | 1 + packages/core/src/agent.ts | 147 ------------- packages/core/src/config/config.ts | 89 ++++++++ packages/core/src/config/provider.ts | 121 ++++++++++ packages/core/src/config/schema.ts | 28 +++ packages/core/src/event.ts | 2 +- packages/core/src/location-layer.ts | 10 +- packages/core/src/location.ts | 35 ++- packages/core/src/plugin.ts | 22 -- packages/core/src/plugin/boot.ts | 12 +- packages/core/src/project.ts | 3 +- packages/core/test/catalog.test.ts | 7 +- packages/core/test/config/config.test.ts | 208 ++++++++++++++++++ packages/core/test/config/provider.test.ts | 138 ++++++++++++ packages/core/test/event.test.ts | 6 +- packages/core/test/fixture/location.ts | 12 + packages/core/test/location.test.ts | 38 ++++ .../core/test/plugin/provider-azure.test.ts | 6 +- .../provider-cloudflare-workers-ai.test.ts | 6 +- .../core/test/plugin/provider-gitlab.test.ts | 6 +- packages/core/test/plugin/provider-helper.ts | 7 +- .../test/plugin/provider-opencode.test.ts | 7 +- packages/core/test/project.test.ts | 2 +- packages/opencode/src/cli/cmd/debug/v2.ts | 3 +- .../instance/httpapi/groups/v2/location.ts | 5 +- 26 files changed, 727 insertions(+), 195 deletions(-) delete mode 100644 packages/core/src/agent.ts create mode 100644 packages/core/src/config/config.ts create mode 100644 packages/core/src/config/provider.ts create mode 100644 packages/core/src/config/schema.ts create mode 100644 packages/core/test/config/config.test.ts create mode 100644 packages/core/test/config/provider.test.ts create mode 100644 packages/core/test/fixture/location.ts create mode 100644 packages/core/test/location.test.ts diff --git a/bun.lock b/bun.lock index a5764fc15f..9cdf59bf28 100644 --- a/bun.lock +++ b/bun.lock @@ -256,6 +256,7 @@ "glob": "13.0.5", "google-auth-library": "10.5.0", "immer": "11.1.4", + "jsonc-parser": "3.3.1", "mime-types": "3.0.2", "minimatch": "10.2.5", "npm-package-arg": "13.0.2", diff --git a/packages/core/package.json b/packages/core/package.json index 3b92ecd2f4..9c8a55e99a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -63,6 +63,7 @@ "glob": "13.0.5", "google-auth-library": "10.5.0", "immer": "11.1.4", + "jsonc-parser": "3.3.1", "mime-types": "3.0.2", "minimatch": "10.2.5", "npm-package-arg": "13.0.2", diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts deleted file mode 100644 index 7f4456c59f..0000000000 --- a/packages/core/src/agent.ts +++ /dev/null @@ -1,147 +0,0 @@ -export * as AgentV2 from "./agent" - -import { Context, Effect, HashMap, Layer, Option, Order, pipe, Schema, Array } from "effect" -import { produce, type Draft } from "immer" -import { ModelV2 } from "./model" -import { PermissionV2 } from "./permission" -import { PluginV2 } from "./plugin" -import { ProviderV2 } from "./provider" - -export const ID = Schema.String.pipe(Schema.brand("AgentV2.ID")) -export type ID = typeof ID.Type - -export const Mode = Schema.Literals(["subagent", "primary", "all"]).annotate({ identifier: "AgentV2.Mode" }) -export type Mode = typeof Mode.Type - -export const Info = Schema.Struct({ - name: ID, - description: Schema.optional(Schema.String), - mode: Mode, - hidden: Schema.Boolean.pipe(Schema.optional), - color: Schema.String.pipe(Schema.optional), - permission: PermissionV2.Ruleset, - model: ModelV2.Ref.pipe(Schema.optional), - system: Schema.String.pipe(Schema.optional), - options: ProviderV2.Options.pipe(Schema.optional), - steps: Schema.Int.pipe(Schema.optional), -}).annotate({ identifier: "AgentV2.Info" }) -export type Info = typeof Info.Type - -export class NotFoundError extends Schema.TaggedErrorClass()("AgentV2.NotFound", { - agent: ID, -}) {} - -export class InvalidDefaultError extends Schema.TaggedErrorClass()("AgentV2.InvalidDefault", { - agent: ID, - reason: Schema.Literals(["missing", "subagent", "hidden"]), -}) {} - -export class NoDefaultError extends Schema.TaggedErrorClass()("AgentV2.NoDefault", {}) {} - -export interface Interface { - readonly get: (agent: ID) => Effect.Effect - readonly list: () => Effect.Effect - readonly update: (agent: ID, fn: (agent: Draft) => void) => Effect.Effect - readonly remove: (agent: ID) => Effect.Effect - readonly defaultInfo: () => Effect.Effect - readonly defaultAgent: () => Effect.Effect - readonly setDefault: (agent: ID) => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/v2/Agent") {} - -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - let agents = HashMap.empty() - let defaultAgent: ID | undefined - - const result: Interface = { - get: Effect.fn("AgentV2.get")(function* (agent) { - const match = HashMap.get(agents, agent) - if (!match.valueOrUndefined) return yield* new NotFoundError({ agent }) - return match.value - }), - - list: Effect.fn("AgentV2.list")(function* () { - return pipe( - HashMap.toValues(agents), - Array.sortWith((agent) => agent.name, Order.String), - ) - }), - - update: Effect.fnUntraced(function* (agent, fn) { - const next = produce( - HashMap.get(agents, agent).pipe( - Option.getOrElse( - () => - ({ - name: agent, - mode: "all", - permission: [], - options: { - headers: {}, - body: {}, - aisdk: { - provider: {}, - request: {}, - }, - }, - }) satisfies Info, - ), - ), - fn, - ) - const updated = yield* plugin.trigger("agent.update", {}, { agent: next, cancel: false }) - if (updated.cancel) return - agents = HashMap.set(agents, agent, { ...updated.agent, name: agent }) - }), - - remove: Effect.fn("AgentV2.remove")(function* (agent) { - const existing = Option.getOrUndefined(HashMap.get(agents, agent)) - if (!existing) return - if ((yield* plugin.trigger("agent.remove", { agent: existing }, { cancel: false })).cancel) return - agents = HashMap.remove(agents, agent) - if (defaultAgent === agent) defaultAgent = undefined - }), - - defaultInfo: Effect.fn("AgentV2.defaultInfo")(function* () { - const updated = yield* plugin.trigger("agent.default", {}, { agent: defaultAgent }) - const selected = updated.agent - if (selected) { - const agent = yield* result - .get(selected) - .pipe( - Effect.catchTag("AgentV2.NotFound", () => - Effect.fail(new InvalidDefaultError({ agent: selected, reason: "missing" })), - ), - ) - if (agent.mode === "subagent") return yield* new InvalidDefaultError({ agent: selected, reason: "subagent" }) - if (agent.hidden === true) return yield* new InvalidDefaultError({ agent: selected, reason: "hidden" }) - return agent - } - - const visible = pipe( - yield* result.list(), - Array.findFirst((agent) => agent.mode !== "subagent" && agent.hidden !== true), - ) - if (Option.isSome(visible)) return visible.value - return yield* new NoDefaultError() - }), - - defaultAgent: Effect.fn("AgentV2.defaultAgent")(function* () { - return (yield* result.defaultInfo()).name - }), - - setDefault: Effect.fn("AgentV2.setDefault")(function* (agent) { - yield* result.get(agent) - defaultAgent = agent - }), - } - - return Service.of(result) - }), -) - -export const defaultLayer = layer.pipe(Layer.provide(PluginV2.defaultLayer)) diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts new file mode 100644 index 0000000000..14686f4157 --- /dev/null +++ b/packages/core/src/config/config.ts @@ -0,0 +1,89 @@ +export * as Config from "./config" + +import path from "path" +import { type ParseError, parse } from "jsonc-parser" +import { Context, Effect, Layer, Option, Schema } from "effect" +import { AppFileSystem } from "../filesystem" +import { Global } from "../global" +import { Location } from "../location" +import { AbsolutePath } from "../schema" +import { ConfigV2 } from "./schema" + +export interface Interface { + /** Returns supplemental config directories from lowest to highest priority. */ + readonly directories: () => Effect.Effect + /** Loads location config files from lowest to highest priority. */ + readonly get: () => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/Config") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const global = yield* Global.Service + const location = yield* Location.Service + const names = ["config.json", "opencode.json", "opencode.jsonc"] + + const loadFile = Effect.fnUntraced(function* (filepath: string) { + const text = yield* fs.readFileStringSafe(filepath) + if (!text) return + + const errors: ParseError[] = [] + const input: unknown = parse(text, errors, { allowTrailingComma: true }) + if (errors.length) return + + const info = Option.getOrUndefined(Schema.decodeUnknownOption(ConfigV2.Info)(input, { errors: "all" })) + if (!info) return + return new ConfigV2.Loaded({ source: new ConfigV2.FileSource({ type: "file", path: filepath }), info }) + }) + + const loadDirectory = Effect.fnUntraced(function* (directory: AbsolutePath) { + return yield* Effect.forEach(names, (file) => loadFile(path.join(directory, file))).pipe( + Effect.map((configs) => configs.filter((config): config is ConfigV2.Loaded => config !== undefined)), + ) + }) + + const globalDirectory = AbsolutePath.make(global.config) + const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config) + // Read configuration once when this location opens. Later calls reuse these + // values until the location is reopened. + const directories = locationIsGlobal + ? [globalDirectory] + : [ + globalDirectory, + ...(yield* fs + .up({ targets: [".opencode"], start: location.directory, stop: location.project.directory }) + .pipe(Effect.orDie)) + .toReversed() + .map((directory) => AbsolutePath.make(directory)), + ] + // A config closer to the opened directory should win over one higher up. + // Search starts nearby, so reverse the results before applying them. + const directPaths = locationIsGlobal + ? [] + : (yield* fs + .up({ targets: names.toReversed(), start: location.directory, stop: location.project.directory }) + .pipe(Effect.orDie)).toReversed() + const direct = yield* Effect.forEach(directPaths, loadFile).pipe( + Effect.orDie, + Effect.map((configs) => configs.filter((config): config is ConfigV2.Loaded => config !== undefined)), + ) + const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie) + // Apply general settings first and more specific settings last: + // global config, project files, then `.opencode` files. + const configs = [...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()] + + return Service.of({ + directories: Effect.fn("Config.directories")(function* () { + return directories + }), + get: Effect.fn("Config.get")(function* () { + return configs + }), + }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Global.defaultLayer)) diff --git a/packages/core/src/config/provider.ts b/packages/core/src/config/provider.ts new file mode 100644 index 0000000000..47d839302c --- /dev/null +++ b/packages/core/src/config/provider.ts @@ -0,0 +1,121 @@ +export * as ConfigProvider from "./provider" + +import { Effect, Schema } from "effect" +import { Catalog } from "../catalog" +import { Config } from "./config" +import { ProviderV2 } from "../provider" +import { ModelV2 } from "../model" +import { PluginV2 } from "../plugin" + +class Model extends Schema.Class("ConfigV2.Model")({ + apiID: ModelV2.ID.pipe(Schema.optional), + family: ModelV2.Family.pipe(Schema.optional), + name: Schema.String.pipe(Schema.optional), + endpoint: ProviderV2.Endpoint.pipe(Schema.optional), + capabilities: ModelV2.Capabilities.pipe(Schema.optional), + options: Schema.Struct({ + ...ProviderV2.Options.fields, + variant: Schema.String.pipe(Schema.optional), + }).pipe(Schema.optional), + variants: Schema.Struct({ + id: ModelV2.VariantID, + ...ProviderV2.Options.fields, + }).pipe(Schema.Array, Schema.optional), + cost: ModelV2.Cost.pipe(Schema.Array).pipe(Schema.optional), + enabled: Schema.Boolean.pipe(Schema.optional), + limit: Schema.Struct({ + context: Schema.Int, + input: Schema.Int.pipe(Schema.optional), + output: Schema.Int, + }).pipe(Schema.optional), +}) {} + +export class Info extends Schema.Class("ConfigV2.Provider")({ + name: Schema.String.pipe(Schema.optional), + endpoint: ProviderV2.Endpoint.pipe(Schema.optional), + options: ProviderV2.Options.pipe(Schema.optional), + models: Schema.Record(Schema.String, Model).pipe(Schema.optional), +}) {} + +export const Plugin = PluginV2.define({ + id: PluginV2.ID.make("config-provider"), + effect: Effect.gen(function* () { + const catalog = yield* Catalog.Service + const config = yield* Config.Service + const load = yield* catalog.loader() + const files = yield* config.get() + + yield* load((catalog) => { + for (const file of files) { + for (const [id, item] of Object.entries(file.info.providers ?? {})) { + const providerID = ProviderV2.ID.make(id) + catalog.provider.update(providerID, (provider) => { + if (item.name !== undefined) provider.name = item.name + provider.enabled = { via: "custom", data: {} } + if (item.endpoint !== undefined) provider.endpoint = { ...item.endpoint } + if (item.options !== undefined) { + Object.assign(provider.options.headers, item.options.headers) + Object.assign(provider.options.body, item.options.body) + Object.assign(provider.options.aisdk.provider, item.options.aisdk.provider) + Object.assign(provider.options.aisdk.request, item.options.aisdk.request) + } + }) + + for (const [id, config] of Object.entries(item.models ?? {})) { + catalog.model.update(providerID, ModelV2.ID.make(id), (model) => { + if (config.apiID !== undefined) model.apiID = config.apiID + if (config.family !== undefined) model.family = config.family + if (config.name !== undefined) model.name = config.name + if (config.endpoint !== undefined) model.endpoint = { ...config.endpoint } + if (config.capabilities !== undefined) { + model.capabilities = { + tools: config.capabilities.tools, + input: [...config.capabilities.input], + output: [...config.capabilities.output], + } + } + if (config.options !== undefined) { + Object.assign(model.options.headers, config.options.headers) + Object.assign(model.options.body, config.options.body) + Object.assign(model.options.aisdk.provider, config.options.aisdk.provider) + Object.assign(model.options.aisdk.request, config.options.aisdk.request) + if (config.options.variant !== undefined) model.options.variant = config.options.variant + } + if (config.variants !== undefined) { + for (const variant of config.variants) { + let existing = model.variants.find((item) => item.id === variant.id) + if (!existing) { + existing = { + id: variant.id, + headers: {}, + body: {}, + aisdk: { + provider: {}, + request: {}, + }, + } + model.variants.push(existing) + } + Object.assign(existing.headers, variant.headers) + Object.assign(existing.body, variant.body) + Object.assign(existing.aisdk.provider, variant.aisdk.provider) + Object.assign(existing.aisdk.request, variant.aisdk.request) + } + } + if (config.cost !== undefined) { + model.cost = config.cost.map((cost) => ({ + tier: cost.tier && { ...cost.tier }, + input: cost.input, + output: cost.output, + cache: { ...cost.cache }, + })) + } + if (config.enabled !== undefined) model.enabled = config.enabled + if (config.limit !== undefined) model.limit = { ...config.limit } + }) + } + } + } + }) + }), +}) diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts new file mode 100644 index 0000000000..da4415c8eb --- /dev/null +++ b/packages/core/src/config/schema.ts @@ -0,0 +1,28 @@ +export * as ConfigV2 from "./schema" + +import { Schema } from "effect" +import { ConfigProvider } from "./provider" + +export class Info extends Schema.Class("ConfigV2.Info")({ + $schema: Schema.optional(Schema.String).annotate({ + description: "JSON schema reference for configuration validation", + }), + providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional), +}) {} + +export class FileSource extends Schema.Class("ConfigV2.FileSource")({ + type: Schema.Literal("file"), + path: Schema.String, +}) {} + +export class MemorySource extends Schema.Class("ConfigV2.MemorySource")({ + type: Schema.Literal("memory"), +}) {} + +export const Source = Schema.Union([FileSource, MemorySource]).pipe(Schema.toTaggedUnion("type")) +export type Source = typeof Source.Type + +export class Loaded extends Schema.Class("ConfigV2.Loaded")({ + source: Source, + info: Info, +}) {} diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index a4a5dd8595..339fbddecf 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -128,7 +128,7 @@ export const layer = Layer.effect( ...(options?.metadata ? { metadata: options.metadata } : {}), type: definition.type, ...(definition.version === undefined ? {} : { version: definition.version }), - ...(location ? { location } : {}), + ...(location ? { location: { directory: location.directory, workspaceID: location.workspaceID } } : {}), data, } as Payload return yield* publishEvent(event) diff --git a/packages/core/src/location-layer.ts b/packages/core/src/location-layer.ts index c40a940430..f7d87de802 100644 --- a/packages/core/src/location-layer.ts +++ b/packages/core/src/location-layer.ts @@ -4,10 +4,12 @@ import { Catalog } from "./catalog" import { PluginBoot } from "./plugin/boot" export class LocationServiceMap extends LayerMap.Service()("@opencode/example/LocationServiceMap", { - lookup: (ref: Location.Ref) => - Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer).pipe( - Layer.provide([Layer.succeed(Location.Service, Location.Service.of(ref))]), - ), + lookup: (ref: Location.Ref) => { + const result = Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer).pipe( + Layer.provideMerge(Location.defaultLayer(ref)), + ) + return result + }, idleTimeToLive: "5 minutes", dependencies: [], }) {} diff --git a/packages/core/src/location.ts b/packages/core/src/location.ts index 00ff9cd3ea..68c9a8f791 100644 --- a/packages/core/src/location.ts +++ b/packages/core/src/location.ts @@ -1,11 +1,40 @@ -import { Context, Schema } from "effect" +import { Context, Effect, Layer, Schema } from "effect" +import { Project } from "./project" +import { AbsolutePath } from "./schema" export * as Location from "./location" export const Ref = Schema.Struct({ - directory: Schema.String, + directory: AbsolutePath, workspaceID: Schema.optional(Schema.String), }).annotate({ identifier: "Location.Ref" }) export type Ref = typeof Ref.Type -export class Service extends Context.Service()("@opencode/Location") {} +export interface Interface { + readonly directory: AbsolutePath + readonly workspaceID?: string + readonly project: { + readonly id: Project.ID + readonly directory: AbsolutePath + } + readonly vcs?: Project.Vcs +} + +export class Service extends Context.Service()("@opencode/Location") {} + +export const layer = (ref: Ref) => + Layer.effect( + Service, + Effect.gen(function* () { + const project = yield* Project.Service + const resolved = yield* project.resolve(ref.directory) + return Service.of({ + directory: ref.directory, + workspaceID: ref.workspaceID, + project: { id: resolved.id, directory: resolved.directory }, + vcs: resolved.vcs, + }) + }), + ) + +export const defaultLayer = (ref: Ref) => layer(ref).pipe(Layer.provide(Project.defaultLayer)) diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index ab2d4cbf7d..e78dbc4059 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -4,7 +4,6 @@ import { createDraft, finishDraft, type Draft } from "immer" import type { LanguageModelV3 } from "@ai-sdk/provider" import { Context, Effect, Exit, Layer, PubSub, Schema, Scope, Stream } from "effect" import type { ModelV2 } from "./model" -import type { AgentV2 } from "./agent" import type { Catalog } from "./catalog" export const ID = Schema.String.pipe(Schema.brand("Plugin.ID")) @@ -43,27 +42,6 @@ type HookSpec = { sdk?: any } } - "agent.update": { - input: {} - output: { - agent: AgentV2.Info - cancel: boolean - } - } - "agent.remove": { - input: { - agent: AgentV2.Info - } - output: { - cancel: boolean - } - } - "agent.default": { - input: {} - output: { - agent?: AgentV2.ID - } - } } export type Hooks = { diff --git a/packages/core/src/plugin/boot.ts b/packages/core/src/plugin/boot.ts index 5624369e04..e6c3ac74a1 100644 --- a/packages/core/src/plugin/boot.ts +++ b/packages/core/src/plugin/boot.ts @@ -2,8 +2,9 @@ export * as PluginBoot from "./boot" import { Context, Deferred, Effect, Layer } from "effect" import { AccountV2 } from "../account" -import { AgentV2 } from "../agent" import { Catalog } from "../catalog" +import { Config } from "../config/config" +import { ConfigProvider } from "../config/provider" import { EventV2 } from "../event" import { Npm } from "../npm" import { PluginV2 } from "../plugin" @@ -15,7 +16,7 @@ import { ProviderPlugins } from "./provider" type Plugin = { id: PluginV2.ID effect: PluginV2.Effect< - Catalog.Service | AgentV2.Service | AccountV2.Service | Npm.Service | EventV2.Service | PluginV2.Service + Catalog.Service | AccountV2.Service | Npm.Service | EventV2.Service | PluginV2.Service | Config.Service > } @@ -28,10 +29,10 @@ export class Service extends Context.Service()("@opencode/v2 export const layer = Layer.effect( Service, Effect.gen(function* () { - const agent = yield* AgentV2.Service const catalog = yield* Catalog.Service const plugin = yield* PluginV2.Service const accounts = yield* AccountV2.Service + const config = yield* Config.Service const npm = yield* Npm.Service const events = yield* EventV2.Service const done = yield* Deferred.make() @@ -41,8 +42,8 @@ export const layer = Layer.effect( id: input.id, effect: input.effect.pipe( Effect.provideService(Catalog.Service, catalog), - Effect.provideService(AgentV2.Service, agent), Effect.provideService(AccountV2.Service, accounts), + Effect.provideService(Config.Service, config), Effect.provideService(Npm.Service, npm), Effect.provideService(EventV2.Service, events), Effect.provideService(PluginV2.Service, plugin), @@ -57,6 +58,7 @@ export const layer = Layer.effect( yield* add(item) } yield* add(ModelsDevPlugin) + yield* add(ConfigProvider.Plugin) }).pipe(Effect.withSpan("PluginBoot.boot")) yield* boot.pipe( @@ -72,10 +74,10 @@ export const layer = Layer.effect( ) export const defaultLayer = layer.pipe( - Layer.provide(AgentV2.defaultLayer), Layer.provide(Catalog.defaultLayer), Layer.provide(EventV2.defaultLayer), Layer.provide(PluginV2.defaultLayer), Layer.provide(AccountV2.defaultLayer), + Layer.provide(Config.defaultLayer), Layer.provide(Npm.defaultLayer), ) diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index 9c265d75be..90638873d7 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -25,7 +25,6 @@ export type Vcs = typeof Vcs.Type export class Info extends Schema.Class("Project.Info")({ id: ID, - vcs: Schema.optional(Vcs), }) {} export interface Interface { @@ -105,7 +104,7 @@ export const layer = Layer.effect( const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) { const repo = yield* git.find(input) - if (!repo) return { id: ID.global, directory: input, vcs: undefined } + if (!repo) return { id: ID.global, directory: AbsolutePath.make(path.parse(input).root), vcs: undefined } const previous = yield* cached(repo.store) const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo)) diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index 97f816d005..59997a88a6 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -6,9 +6,14 @@ import { Location } from "@opencode-ai/core/location" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { ProviderV2 } from "@opencode-ai/core/provider" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { location } from "./fixture/location" import { testEffect } from "./lib/effect" -const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" })) +const locationLayer = Layer.succeed( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make("test") })), +) const it = testEffect( Catalog.layer.pipe( Layer.provideMerge(EventV2.defaultLayer), diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts new file mode 100644 index 0000000000..c89a7ec145 --- /dev/null +++ b/packages/core/test/config/config.test.ts @@ -0,0 +1,208 @@ +import path from "path" +import fs from "fs/promises" +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { Config } from "@opencode-ai/core/config/config" +import { ConfigProvider } from "@opencode-ai/core/config/provider" +import { ConfigV2 } from "@opencode-ai/core/config/schema" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Global } from "@opencode-ai/core/global" +import { Location } from "@opencode-ai/core/location" +import { Project } from "@opencode-ai/core/project" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { location } from "../fixture/location" +import { tmpdir } from "../fixture/tmpdir" +import { testEffect } from "../lib/effect" + +const it = testEffect(Layer.empty) + +function testLayer( + directory: string, + globalDirectory = path.join(directory, "global"), + projectDirectory = directory, + vcs?: Project.Vcs, +) { + return Config.layer.pipe( + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Global.layerWith({ config: globalDirectory })), + Layer.provide( + Layer.succeed( + Location.Service, + Location.Service.of( + location( + { directory: AbsolutePath.make(directory) }, + { projectDirectory: AbsolutePath.make(projectDirectory), vcs }, + ), + ), + ), + ), + ) +} + +const provider = { + endpoint: { type: "unknown" }, + options: { + headers: {}, + body: {}, + aisdk: { + provider: {}, + request: {}, + }, + }, + models: {}, +} + +describe("Config", () => { + it.live("returns an empty configuration when directory files do not exist", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + const config = yield* Config.Service + const documents = yield* config.get() + + expect(documents).toEqual([]) + }).pipe(Effect.provide(testLayer(tmp.path))), + ), + ), + ) + + it.live("loads JSON and JSONC files from lowest to highest priority", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + yield* Effect.promise(() => + Promise.all([ + fs.writeFile( + path.join(tmp.path, "config.json"), + JSON.stringify({ $schema: "base", providers: { base: provider } }), + ), + fs.writeFile( + path.join(tmp.path, "opencode.json"), + JSON.stringify({ $schema: "middle", providers: { middle: provider } }), + ), + fs.writeFile( + path.join(tmp.path, "opencode.jsonc"), + `{ + // Later global files override scalar fields while retaining providers. + "$schema": "last", + "providers": { "last": ${JSON.stringify(provider)} }, + }`, + ), + ]), + ) + return yield* Effect.gen(function* () { + const config = yield* Config.Service + const documents = yield* config.get() + + expect(documents).toHaveLength(3) + expect(documents.map((document) => document.source.type)).toEqual(["file", "file", "file"]) + expect(documents.map((document) => document.info.$schema)).toEqual(["base", "middle", "last"]) + expect(documents[0]).toBeInstanceOf(ConfigV2.Loaded) + expect(documents[0]?.source).toBeInstanceOf(ConfigV2.FileSource) + expect(documents[0]?.source.type === "file" ? documents[0].source.path : undefined).toBe( + path.join(tmp.path, "config.json"), + ) + expect(documents[2]?.info.providers?.last).toBeInstanceOf(ConfigProvider.Info) + + yield* Effect.promise(() => + fs.writeFile(path.join(tmp.path, "opencode.jsonc"), JSON.stringify({ $schema: "changed" })), + ) + expect((yield* config.get()).map((document) => document.info.$schema)).toEqual(["base", "middle", "last"]) + }).pipe(Effect.provide(testLayer(tmp.path))) + }), + ), + ), + ) + + it.live("ignores invalid files while loading valid config values", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + yield* Effect.promise(() => + Promise.all([ + fs.writeFile(path.join(tmp.path, "config.json"), JSON.stringify({ $schema: "base" })), + fs.writeFile(path.join(tmp.path, "opencode.json"), "{ invalid"), + fs.writeFile(path.join(tmp.path, "opencode.jsonc"), JSON.stringify({ providers: { invalid: true } })), + ]), + ) + return yield* Effect.gen(function* () { + const config = yield* Config.Service + const documents = yield* config.get() + + expect(documents.map((document) => document.info.$schema)).toEqual(["base"]) + }).pipe(Effect.provide(testLayer(tmp.path))) + }), + ), + ), + ) + + it.live("loads global, ancestor, and .opencode configuration up to the project boundary", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => { + const global = path.join(tmp.path, "global") + const root = path.join(tmp.path, "repo") + const parent = path.join(root, "packages") + const directory = path.join(parent, "app") + return Effect.gen(function* () { + yield* Effect.promise(async () => { + await fs.mkdir(global, { recursive: true }) + await fs.mkdir(directory, { recursive: true }) + await fs.mkdir(path.join(root, ".opencode"), { recursive: true }) + await fs.mkdir(path.join(directory, ".opencode"), { recursive: true }) + await Promise.all([ + fs.writeFile(path.join(tmp.path, "opencode.json"), JSON.stringify({ $schema: "outside" })), + fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ $schema: "global" })), + fs.writeFile(path.join(root, "opencode.json"), JSON.stringify({ $schema: "root" })), + fs.writeFile(path.join(parent, "opencode.jsonc"), JSON.stringify({ $schema: "parent" })), + fs.writeFile(path.join(directory, "config.json"), JSON.stringify({ $schema: "directory" })), + fs.writeFile(path.join(root, ".opencode", "opencode.json"), JSON.stringify({ $schema: "root-dot" })), + fs.writeFile( + path.join(directory, ".opencode", "opencode.jsonc"), + JSON.stringify({ $schema: "directory-dot" }), + ), + ]) + }) + + return yield* Effect.gen(function* () { + const config = yield* Config.Service + const directories = yield* config.directories() + const documents = yield* config.get() + + expect(directories).toEqual([ + AbsolutePath.make(global), + AbsolutePath.make(path.join(root, ".opencode")), + AbsolutePath.make(path.join(directory, ".opencode")), + ]) + expect(documents.map((document) => document.info.$schema)).toEqual([ + "global", + "root", + "parent", + "directory", + "root-dot", + "directory-dot", + ]) + }).pipe( + Effect.provide( + testLayer(directory, global, root, { + type: "git", + store: AbsolutePath.make(path.join(root, ".git")), + }), + ), + ) + }) + }), + ), + ) +}) diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts new file mode 100644 index 0000000000..6f9b3181f2 --- /dev/null +++ b/packages/core/test/config/provider.test.ts @@ -0,0 +1,138 @@ +import { describe, expect } from "bun:test" +import { Effect, Schema } from "effect" +import { Catalog } from "@opencode-ai/core/catalog" +import { Config } from "@opencode-ai/core/config/config" +import { ConfigProvider } from "@opencode-ai/core/config/provider" +import { ConfigV2 } from "@opencode-ai/core/config/schema" +import { ModelV2 } from "@opencode-ai/core/model" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { it } from "../plugin/provider-helper" + +function options(headers: Record, variant?: string) { + return { + headers, + body: {}, + aisdk: { + provider: {}, + request: {}, + }, + variant, + } +} + +const decode = Schema.decodeUnknownSync(ConfigV2.Info) + +describe("ConfigProvider.Plugin", () => { + it.effect("loads configured providers and applies later model overrides", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const plugin = yield* PluginV2.Service + const providerID = ProviderV2.ID.make("custom") + const modelID = ModelV2.ID.make("chat") + const config = Config.Service.of({ + directories: () => Effect.succeed([]), + get: () => + Effect.succeed([ + new ConfigV2.Loaded({ + source: new ConfigV2.MemorySource({ type: "memory" }), + info: decode({ + providers: { + custom: { + name: "Configured", + endpoint: { type: "unknown" }, + options: options({ first: "first", shared: "first" }), + models: { + chat: { + name: "First", + capabilities: { tools: true, input: ["text"], output: ["text"] }, + enabled: false, + limit: { context: 100, output: 50 }, + options: options({ first: "first", shared: "first" }, "retained"), + variants: [ + { + id: "fast", + headers: { first: "first", shared: "first" }, + body: {}, + aisdk: { provider: {}, request: {} }, + }, + ], + }, + }, + }, + }, + }), + }), + new ConfigV2.Loaded({ + source: new ConfigV2.MemorySource({ type: "memory" }), + info: decode({ + providers: { + custom: { + endpoint: { type: "aisdk", package: "custom-sdk", url: "https://example.test" }, + options: options({ last: "last", shared: "last" }), + models: { + chat: { + apiID: "api-chat", + name: "Last", + options: options({ last: "last", shared: "last" }), + variants: [ + { + id: "fast", + headers: { last: "last", shared: "last" }, + body: {}, + aisdk: { provider: {}, request: {} }, + }, + { + id: "slow", + headers: { slow: "slow" }, + body: {}, + aisdk: { provider: {}, request: {} }, + }, + ], + }, + }, + }, + }, + }), + }), + new ConfigV2.Loaded({ + source: new ConfigV2.MemorySource({ type: "memory" }), + info: decode({ + providers: { + custom: { name: "Renamed" }, + }, + }), + }), + ]), + }) + + yield* plugin.add({ + ...ConfigProvider.Plugin, + effect: ConfigProvider.Plugin.effect.pipe( + Effect.provideService(Config.Service, config), + Effect.provideService(Catalog.Service, catalog), + ), + }) + + const provider = yield* catalog.provider.get(providerID) + const model = yield* catalog.model.get(providerID, modelID) + expect(provider.name).toBe("Renamed") + expect(provider.enabled).toEqual({ via: "custom", data: {} }) + expect(provider.endpoint).toEqual({ type: "aisdk", package: "custom-sdk", url: "https://example.test" }) + expect(provider.options.headers).toEqual({ first: "first", shared: "last", last: "last" }) + expect(model.apiID).toBe(ModelV2.ID.make("api-chat")) + expect(model.name).toBe("Last") + expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] }) + expect(model.enabled).toBe(false) + expect(model.limit).toEqual({ context: 100, output: 50 }) + expect(model.options.headers).toEqual({ first: "first", shared: "last", last: "last" }) + expect(model.options.variant).toBe("retained") + expect(model.variants.map((variant) => variant.id)).toEqual([ + ModelV2.VariantID.make("fast"), + ModelV2.VariantID.make("slow"), + ]) + expect(model.variants[0]?.headers).toEqual({ first: "first", shared: "last", last: "last" }) + expect(model.variants[1]?.headers).toEqual({ slow: "slow" }) + }), + ) +}) diff --git a/packages/core/test/event.test.ts b/packages/core/test/event.test.ts index b67b2897a1..f5cce54de4 100644 --- a/packages/core/test/event.test.ts +++ b/packages/core/test/event.test.ts @@ -2,11 +2,13 @@ import { describe, expect } from "bun:test" import { Effect, Fiber, Layer, Schema, Stream } from "effect" import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { location } from "./fixture/location" import { testEffect } from "./lib/effect" const locationLayer = Layer.succeed( Location.Service, - Location.Service.of({ directory: "project", workspaceID: "workspace" }), + Location.Service.of(location({ directory: AbsolutePath.make("project"), workspaceID: "workspace" })), ) const it = testEffect(EventV2.layer.pipe(Layer.provideMerge(locationLayer))) const itWithoutLocation = testEffect(EventV2.layer) @@ -46,7 +48,7 @@ describe("EventV2", () => { expect(event.type).toBe("test.message") expect(event).not.toHaveProperty("version") expect(event.data).toEqual({ text: "hello" }) - expect(event.location).toEqual({ directory: "project", workspaceID: "workspace" }) + expect(event.location).toEqual({ directory: AbsolutePath.make("project"), workspaceID: "workspace" }) }), ) diff --git a/packages/core/test/fixture/location.ts b/packages/core/test/fixture/location.ts new file mode 100644 index 0000000000..00b3ffbd13 --- /dev/null +++ b/packages/core/test/fixture/location.ts @@ -0,0 +1,12 @@ +import { Location } from "@opencode-ai/core/location" +import { Project } from "@opencode-ai/core/project" +import { AbsolutePath } from "@opencode-ai/core/schema" + +export function location(ref: Location.Ref, input: { projectDirectory?: AbsolutePath; vcs?: Project.Vcs } = {}) { + return { + directory: ref.directory, + workspaceID: ref.workspaceID, + project: { id: Project.ID.global, directory: input.projectDirectory ?? ref.directory }, + vcs: input.vcs, + } satisfies Location.Interface +} diff --git a/packages/core/test/location.test.ts b/packages/core/test/location.test.ts new file mode 100644 index 0000000000..305083bfed --- /dev/null +++ b/packages/core/test/location.test.ts @@ -0,0 +1,38 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { Location } from "@opencode-ai/core/location" +import { Project } from "@opencode-ai/core/project" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { testEffect } from "./lib/effect" + +const ref = { directory: AbsolutePath.make("/repo/packages/app"), workspaceID: "workspace" } +const projectLayer = Layer.succeed( + Project.Service, + Project.Service.of({ + resolve: () => + Effect.succeed({ + id: Project.ID.make("project"), + directory: AbsolutePath.make("/repo"), + vcs: { type: "git", store: AbsolutePath.make("/repo/.git") }, + }), + commit: () => Effect.void, + }), +) +const it = testEffect(Location.layer(ref).pipe(Layer.provide(projectLayer))) + +describe("Location", () => { + it.effect("resolves the current project and vcs information", () => + Effect.gen(function* () { + const location = yield* Location.Service + + expect(location.directory).toBe(AbsolutePath.make("/repo/packages/app")) + expect(location.workspaceID).toBe("workspace") + expect(location.project.id).toBe(Project.ID.make("project")) + expect(location.project.directory).toBe(AbsolutePath.make("/repo")) + expect(location.vcs).toEqual({ + type: "git", + store: AbsolutePath.make("/repo/.git"), + }) + }), + ) +}) diff --git a/packages/core/test/plugin/provider-azure.test.ts b/packages/core/test/plugin/provider-azure.test.ts index 8c8995a372..07422dfd48 100644 --- a/packages/core/test/plugin/provider-azure.test.ts +++ b/packages/core/test/plugin/provider-azure.test.ts @@ -8,6 +8,8 @@ import { PluginV2 } from "@opencode-ai/core/plugin" import { AccountPlugin } from "@opencode-ai/core/plugin/account" import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure" import { ProviderV2 } from "@opencode-ai/core/provider" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { location } from "../fixture/location" import { testEffect } from "../lib/effect" import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper" @@ -16,7 +18,9 @@ const itWithAccount = testEffect( Layer.provideMerge(PluginV2.defaultLayer), Layer.provideMerge(AccountV2.defaultLayer), Layer.provideMerge(EventV2.defaultLayer), - Layer.provideMerge(Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))), + Layer.provideMerge( + Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))), + ), Layer.provideMerge(npmLayer), ), ) diff --git a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts index d1db7b27a1..40e501bd35 100644 --- a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts @@ -9,6 +9,8 @@ import { PluginV2 } from "@opencode-ai/core/plugin" import { AccountPlugin } from "@opencode-ai/core/plugin/account" import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai" import { ProviderV2 } from "@opencode-ai/core/provider" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { location } from "../fixture/location" import { testEffect } from "../lib/effect" import { fakeSelectorSdk, it, model, npmLayer, withEnv } from "./provider-helper" @@ -17,7 +19,9 @@ const itWithAccount = testEffect( Layer.provideMerge(PluginV2.defaultLayer), Layer.provideMerge(AccountV2.defaultLayer), Layer.provideMerge(EventV2.defaultLayer), - Layer.provideMerge(Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))), + Layer.provideMerge( + Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))), + ), Layer.provideMerge(npmLayer), ), ) diff --git a/packages/core/test/plugin/provider-gitlab.test.ts b/packages/core/test/plugin/provider-gitlab.test.ts index e785fbbb7f..7158535cc6 100644 --- a/packages/core/test/plugin/provider-gitlab.test.ts +++ b/packages/core/test/plugin/provider-gitlab.test.ts @@ -8,6 +8,8 @@ import { PluginV2 } from "@opencode-ai/core/plugin" import { AccountPlugin } from "@opencode-ai/core/plugin/account" import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab" import { ProviderV2 } from "@opencode-ai/core/provider" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { location } from "../fixture/location" import { testEffect } from "../lib/effect" import { it, model, npmLayer, withEnv } from "./provider-helper" @@ -31,7 +33,9 @@ const itWithAccount = testEffect( Layer.provideMerge(PluginV2.defaultLayer), Layer.provideMerge(AccountV2.defaultLayer), Layer.provideMerge(EventV2.defaultLayer), - Layer.provideMerge(Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))), + Layer.provideMerge( + Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))), + ), Layer.provideMerge(npmLayer), ), ) diff --git a/packages/core/test/plugin/provider-helper.ts b/packages/core/test/plugin/provider-helper.ts index 1b8f1c65a0..5257140c50 100644 --- a/packages/core/test/plugin/provider-helper.ts +++ b/packages/core/test/plugin/provider-helper.ts @@ -8,10 +8,15 @@ import { Location } from "@opencode-ai/core/location" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { ProviderV2 } from "@opencode-ai/core/provider" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { location } from "../fixture/location" import { testEffect } from "../lib/effect" export const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href -const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" })) +const locationLayer = Layer.succeed( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make("test") })), +) export const npmLayer = Layer.succeed( Npm.Service, diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index 3f59a34977..62159a92ca 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -6,10 +6,15 @@ import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode" import { ProviderV2 } from "@opencode-ai/core/provider" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { location } from "../fixture/location" import { it, model, provider, withEnv } from "./provider-helper" const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }] -const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" })) +const locationLayer = Layer.succeed( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make("test") })), +) describe("OpencodePlugin", () => { it.effect("uses a public key and disables paid models without credentials", () => diff --git a/packages/core/test/project.test.ts b/packages/core/test/project.test.ts index c5b96b6389..b1ae5b45c3 100644 --- a/packages/core/test/project.test.ts +++ b/packages/core/test/project.test.ts @@ -49,7 +49,7 @@ describe("ProjectV2.resolve", () => { const result = yield* project.resolve(abs(tmp.path)) expect(result.id).toBe(Project.ID.make("global")) - expect(path.resolve(result.directory)).toBe(path.resolve(tmp.path)) + expect(path.resolve(result.directory)).toBe(path.parse(tmp.path).root) expect(result.previous).toBeUndefined() expect(result.vcs).toBeUndefined() }), diff --git a/packages/opencode/src/cli/cmd/debug/v2.ts b/packages/opencode/src/cli/cmd/debug/v2.ts index 56866a0e02..aab7018982 100644 --- a/packages/opencode/src/cli/cmd/debug/v2.ts +++ b/packages/opencode/src/cli/cmd/debug/v2.ts @@ -3,6 +3,7 @@ import { Effect, Option } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { PluginBoot } from "@opencode-ai/core/plugin/boot" +import { AbsolutePath } from "@opencode-ai/core/schema" import { effectCmd } from "../../effect-cmd" export const V2Command = effectCmd({ @@ -37,7 +38,7 @@ export const V2Command = effectCmd({ Effect.withSpan("Cli.debug.v2"), Effect.provide( LocationServiceMap.get({ - directory: process.cwd(), + directory: AbsolutePath.make(process.cwd()), }), ), Effect.provide(LocationServiceMap.layer), diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/location.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/location.ts index f2a9a33557..c9b21b5adf 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/location.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/location.ts @@ -1,6 +1,7 @@ import { Catalog } from "@opencode-ai/core/catalog" import { Location } from "@opencode-ai/core/location" import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { AbsolutePath } from "@opencode-ai/core/schema" import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { Effect, Layer, Schema } from "effect" import { HttpServerRequest } from "effect/unstable/http" @@ -40,7 +41,9 @@ export class V2LocationMiddleware extends HttpApiMiddleware.Service< function ref(request: HttpServerRequest.HttpServerRequest): Location.Ref { const query = new URL(request.url, "http://localhost").searchParams return { - directory: query.get("location[directory]") || request.headers["x-opencode-directory"] || process.cwd(), + directory: AbsolutePath.make( + query.get("location[directory]") || request.headers["x-opencode-directory"] || process.cwd(), + ), workspaceID: query.get("location[workspace]") || request.headers["x-opencode-workspace"], } }