diff --git a/packages/cli/test/debug-config.test.ts b/packages/cli/test/debug-config.test.ts index b7816ba650..5211c7f050 100644 --- a/packages/cli/test/debug-config.test.ts +++ b/packages/cli/test/debug-config.test.ts @@ -30,7 +30,6 @@ describe("debug config command", () => { ], }, }, - { type: "file", path: path.join(project, "opencode.json") }, ] let requested: URL | undefined const authorization: Array = [] diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 871d21f7ab..166306dde9 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -1787,7 +1787,7 @@ export type ConfigEntry = | { repository: string; branch?: string; description?: string; hidden?: boolean } | { path: string; description?: string; hidden?: boolean } } - websearch?: { provider: string } + websearch?: false | { provider: "random" | (string & {}) } plugins?: Array warming?: boolean | { prompt?: string; interval?: string; duration?: string } providers?: { @@ -1842,7 +1842,6 @@ export type ConfigEntry = } } | { type: "directory"; path: string } - | { type: "file"; path: string } | { type: "agents"; path: string } | { type: "claude"; path: string } diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index 4f0ab568c8..38f7d9d8b1 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -66,7 +66,6 @@ test("config.get returns ordered config entries for a location", async () => { ], }, }, - { type: "file" as const, path: "/tmp/project/opencode.json" }, ] const client = OpenCode.make({ baseUrl: "http://localhost:3000", diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 23a3d940a7..2ae081457b 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -4,13 +4,14 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import path from "path" import { isDeepStrictEqual } from "node:util" import { type ParseError, parse } from "jsonc-parser" +import { applyEdits, modify } from "jsonc-parser" import { Context, Effect, Layer, Option, PubSub, Ref, Schema, Semaphore, Stream } from "effect" +import { produce, type Draft } from "immer" import { AgentsDirectory, ClaudeDirectory, Directory, Document, - File, Info, type Entry, Event, @@ -36,6 +37,8 @@ export function latest(entries: readonly Entry[], key: K): export interface Interface { /** Returns location config documents and discovery sources from lowest to highest priority. */ readonly entries: () => Effect.Effect + /** Updates the first file-backed configuration document. */ + readonly update: (update: (draft: Draft) => void) => Effect.Effect /** * Streams raw filesystem updates under config roots. Config owns root * topology and watch reconciliation; domain owners filter this feed for the @@ -44,6 +47,11 @@ export interface Interface { readonly changes: () => Stream.Stream } +export class UpdateError extends Schema.TaggedErrorClass()("Config.UpdateError", { + message: Schema.String, + cause: Schema.optional(Schema.Defect()), +}) {} + export const Options = Schema.Struct({ project: Schema.optional(Schema.Boolean), file: Schema.optional(Schema.String), @@ -70,6 +78,18 @@ export const testLayer = (initial: Entry[] = []) => const updates = yield* PubSub.unbounded() const service = Test.of({ entries: () => Ref.get(entries), + update: (update) => + Effect.gen(function* () { + const current = yield* Ref.get(entries) + const index = current.findIndex((entry) => entry.type === "document" && entry.path !== undefined) + if (index === -1) return yield* Effect.fail(new UpdateError({ message: "No editable config document found" })) + const entry = current[index] + if (!entry || entry.type !== "document") + return yield* Effect.fail(new UpdateError({ message: "No editable config document found" })) + const info = produce(entry.info, update) + yield* Ref.set(entries, current.with(index, new Document({ type: "document", path: entry.path, info }))) + return info + }), changes: () => Stream.fromPubSub(updates), setEntries: (next) => Ref.set(entries, next), emitChange: (update) => PubSub.publish(updates, update).pipe(Effect.asVoid), @@ -91,6 +111,7 @@ export const layer = (options?: Options) => const wellknown = yield* WellKnown.Service const names = ["opencode.json", "opencode.jsonc"] const reloadLock = Semaphore.makeUnsafe(1) + const fileTargets = new Set() const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions) const parseInfo = Effect.fn("Config.parseInfo")(function* (text: string, source: string) { @@ -131,7 +152,7 @@ export const layer = (options?: Options) => const substituted = yield* ConfigVariable.substitute({ type: "path", path: filepath, text }) const info = yield* parseInfo(substituted, filepath) if (!info) return - return new Document({ type: "document", path: filepath, info }) + return new Document({ type: "document", path: AbsolutePath.make(filepath), info }) }) const loadWellknown = Effect.fn("Config.loadWellknown")(function* () { @@ -224,25 +245,20 @@ export const layer = (options?: Options) => const directPaths = discovered .filter((item) => ![".agents", ".claude", ".opencode"].includes(path.basename(item))) .toReversed() + fileTargets.clear() + directPaths.forEach((filepath) => fileTargets.add(AbsolutePath.make(filepath))) const direct = yield* Effect.forEach(directPaths, (filepath) => - loadFile(filepath).pipe( - Effect.map((config) => [ - ...(config ? [config] : []), - new File({ type: "file", path: AbsolutePath.make(filepath) }), - ]), - ), + loadFile(filepath), ).pipe( Effect.orDie, - Effect.map((entries) => entries.flat()), + Effect.map((entries) => entries.filter((entry): entry is Document => entry !== undefined)), ) const file = options?.file + if (file) fileTargets.add(AbsolutePath.make(path.resolve(file))) const explicit = file ? yield* loadFile(path.resolve(file)).pipe( - Effect.map((config) => [ - ...(config ? [config] : []), - new File({ type: "file", path: AbsolutePath.make(path.resolve(file)) }), - ]), + Effect.map((config) => (config ? [config] : [])), Effect.orDie, ) : [] @@ -285,7 +301,10 @@ export const layer = (options?: Options) => const watched = new Set() const reconcile = Effect.fn("Config.reconcileWatches")(function* (entries: readonly Entry[]) { const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : [])) - const files = entries.flatMap((entry) => (entry.type === "file" ? [entry.path] : [])) + const files = [ + ...entries.flatMap((entry) => (entry.type === "document" && entry.path ? [entry.path] : [])), + ...fileTargets, + ] const targets = [ ...directories.map((path) => ({ path, type: "directory" as const, ignore })), ...files @@ -308,9 +327,9 @@ export const layer = (options?: Options) => reloadLock.withPermit( Effect.gen(function* () { const next = yield* discover() + yield* reconcile(next) if (isDeepStrictEqual(configs, next)) return configs = next - yield* reconcile(next) yield* bus.publish(Event.Updated, {}) }), ), @@ -364,10 +383,47 @@ export const layer = (options?: Options) => ) yield* reconcile(initial) + const update = Effect.fn("Config.update")((mutate: (draft: Draft) => void) => + reloadLock.withPermit( + Effect.gen(function* () { + // TODO: Replace entry-order selection with an explicit config scope/target model. + const document = configs.find((entry) => entry.type === "document" && entry.path !== undefined) + if (!document || document.type !== "document" || !document.path) + return yield* Effect.fail(new UpdateError({ message: "No editable config document found" })) + const next = yield* Effect.try({ + try: () => produce(document.info, mutate), + catch: (cause) => new UpdateError({ message: "Config update failed", cause }), + }) + const edits = changes(document.info, next) + if (!edits.length) return document.info + const text = yield* fs.readFileString(document.path).pipe( + Effect.mapError((cause) => new UpdateError({ message: `Failed to read config: ${document.path}`, cause })), + ) + const updated = edits.reduce( + (text, edit) => + applyEdits( + text, + modify(text, edit.path, edit.value, { formattingOptions: { tabSize: 2, insertSpaces: true } }), + ), + text, + ) + const info = yield* parseInfo(updated, document.path) + if (!info) return yield* Effect.fail(new UpdateError({ message: `Invalid config update: ${document.path}` })) + const temporary = document.path + ".tmp" + yield* fs.writeFileString(temporary, updated.endsWith("\n") ? updated : updated + "\n").pipe( + Effect.andThen(fs.rename(temporary, document.path)), + Effect.mapError((cause) => new UpdateError({ message: `Failed to write config: ${document.path}`, cause })), + ) + return info + }), + ), + ) + return Service.of({ entries: Effect.fn("Config.entries")(function* () { return configs }), + update, changes: () => Stream.fromPubSub(updates), }) }), @@ -382,3 +438,26 @@ export function configured(options?: Options) { } export const node = configured() + +type Edit = { readonly path: (string | number)[]; readonly value: unknown } + +function changes(before: unknown, after: unknown, path: (string | number)[] = []): Edit[] { + if (Object.is(before, after)) return [] + if ( + before !== null && + after !== null && + typeof before === "object" && + typeof after === "object" && + !Array.isArray(before) && + !Array.isArray(after) + ) { + const previous = before as Record + const next = after as Record + return [...new Set([...Object.keys(previous), ...Object.keys(next)])].flatMap((key) => { + if (!(key in next)) return [{ path: [...path, key], value: undefined }] + if (!(key in previous)) return [{ path: [...path, key], value: next[key] }] + return changes(previous[key], next[key], [...path, key]) + }) + } + return [{ path, value: after }] +} diff --git a/packages/core/src/config/plugin/agent.ts b/packages/core/src/config/plugin/agent.ts index 1c7be8bdb7..c14f96bdd3 100644 --- a/packages/core/src/config/plugin/agent.ts +++ b/packages/core/src/config/plugin/agent.ts @@ -16,6 +16,7 @@ import { Permission } from "../../permission.js" import type { LocationMutation } from "../../location-mutation.js" import type { ReadTool } from "../../tool/plugin/read.js" import type { EditTool } from "../../tool/plugin/edit.js" +import { AbsolutePath } from "../../schema.js" const legacySources = [ { pattern: "{agent,agents}/**/*.md", primary: false }, @@ -210,5 +211,5 @@ function decode(file: { directory: string; filepath: string; primary: boolean }, }), ) if (!info) return - return new Document({ type: "document", path: file.filepath, info }) + return new Document({ type: "document", path: AbsolutePath.make(file.filepath), info }) } diff --git a/packages/core/src/config/plugin/websearch.ts b/packages/core/src/config/plugin/websearch.ts index d516117337..8bfef38665 100644 --- a/packages/core/src/config/plugin/websearch.ts +++ b/packages/core/src/config/plugin/websearch.ts @@ -10,8 +10,9 @@ export const Plugin = define({ const config = yield* Config.Service const loaded = { entries: yield* config.entries() } yield* ctx.websearch.transform((websearch) => { - const providerID = Config.latest(loaded.entries, "websearch")?.provider - if (providerID) websearch.default.set(providerID) + const selection = Config.latest(loaded.entries, "websearch") + if (selection === false) websearch.default.set(false) + if (selection) websearch.default.set(selection.provider) }) yield* ctx.event.subscribe().pipe( Stream.filter((event) => event.type === "config.updated"), diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 28062b5b66..8572e510ac 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -332,7 +332,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p }), default: { get: draft.default.get, - set: (providerID) => draft.default.set(WebSearch.ID.make(providerID)), + set: (selection) => + draft.default.set(selection === false || selection === "random" ? selection : WebSearch.ID.make(selection)), }, }) }), diff --git a/packages/core/src/tool/plugin/websearch.ts b/packages/core/src/tool/plugin/websearch.ts index aab4852050..8804542e15 100644 --- a/packages/core/src/tool/plugin/websearch.ts +++ b/packages/core/src/tool/plugin/websearch.ts @@ -4,8 +4,8 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin import { ToolFailure } from "@opencode-ai/ai" import { Effect, Schema, Semaphore } from "effect" import { HttpClientError } from "effect/unstable/http" +import { Config } from "../../config.js" import { Form } from "../../form.js" -import { KV } from "../../kv.js" import { Permission } from "../../permission.js" import { WebSearch } from "../../websearch.js" @@ -30,7 +30,7 @@ export const Plugin = { effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) { const permission = yield* Permission.Service const forms = yield* Form.Service - const kv = yield* KV.Service + const config = yield* Config.Service const websearch = yield* WebSearch.Service yield* ctx.tool @@ -65,7 +65,7 @@ export const Plugin = { return providerSelectionLock .withPermit( Effect.gen(function* () { - if (yield* websearch.default()) return yield* Effect.void + if (yield* websearch.default()) return const providers = (yield* ctx.websearch.providers()).data const defaultProvider = providers[0] if (!defaultProvider) return yield* new WebSearch.ProviderRequiredError() @@ -83,7 +83,7 @@ export const Plugin = { options: [ { value: "allow", - label: `Allow web search via ${defaultProvider.name}`, + label: `Allow search via ${providers.map((provider) => provider.name).join(", ")}`, }, { value: "choose", @@ -97,7 +97,9 @@ export const Plugin = { if (response.status === "cancelled") return yield* Effect.fail(new Error("Web search cancelled")) if (response.answer.choice === "disable") { - yield* kv.set("websearch:provider", false) + yield* config.update((draft) => { + draft.websearch = false + }) return yield* new WebSearch.DisabledError() } const selection = @@ -123,13 +125,19 @@ export const Plugin = { : undefined if (selection?.status === "cancelled") return yield* Effect.fail(new Error("Web search cancelled")) - const providerID = selection?.answer.provider ?? defaultProvider.id + const providerID = selection?.answer.provider ?? "random" if ( typeof providerID !== "string" || - !providers.some((provider) => provider.id === providerID) + (providerID !== "random" && !providers.some((provider) => provider.id === providerID)) ) return yield* new WebSearch.ProviderRequiredError() - return yield* kv.set("websearch:provider", providerID) + yield* config.update((draft) => { + draft.websearch = { + provider: providerID === "random" ? "random" : WebSearch.ID.make(providerID), + } + }) + if (providerID !== "random") return WebSearch.ID.make(providerID) + return providers[Math.floor(Math.random() * providers.length)]?.id }), ) .pipe( @@ -137,7 +145,12 @@ export const Plugin = { duration: "1 minute", orElse: () => Effect.fail(new Error("Web search cancelled")), }), - Effect.andThen(Effect.suspend(search)), + Effect.flatMap((providerID) => { + if (!providerID) return Effect.suspend(search) + return context + .progress({ provider: providerID }) + .pipe(Effect.andThen(ctx.websearch.query({ ...input, providerID }))) + }), ) }), ) @@ -193,7 +206,8 @@ export const Plugin = { yield* ctx.session.hook("context", (event) => Effect.gen(function* () { - if ((yield* kv.get("websearch:provider")) === false) delete event.tools[name] + const disabled = Config.latest(yield* config.entries(), "websearch") === false + if (disabled) delete event.tools[name] }), ) }), diff --git a/packages/core/src/websearch.ts b/packages/core/src/websearch.ts index 1e0f8c7f19..e23edffab6 100644 --- a/packages/core/src/websearch.ts +++ b/packages/core/src/websearch.ts @@ -4,7 +4,6 @@ import { WebSearch } from "@opencode-ai/schema/websearch" import { Context, Effect, Layer, Schema } from "effect" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { Bus } from "./bus.js" -import { KV } from "./kv.js" import { State } from "./state.js" export const ID = WebSearch.ID @@ -60,14 +59,14 @@ export class Service extends Context.Service()("@opencode/We type Data = { readonly providers: Map - defaultProviderID?: ID + selection?: ID | "random" | false } export type Draft = { add: (provider: ProviderImplementation) => void default: { - get: () => ID | undefined - set: (providerID: ID) => void + get: () => ID | "random" | false | undefined + set: (selection: ID | "random" | false) => void } } @@ -75,15 +74,14 @@ const layer = Layer.effect( Service, Effect.gen(function* () { const bus = yield* Bus.Service - const kv = yield* KV.Service const decodeResults = Schema.decodeUnknownEffect(Schema.Array(Result)) const state = State.create({ initial: () => ({ providers: new Map() }), draft: (draft) => ({ add: (provider) => draft.providers.set(provider.id, provider), default: { - get: () => draft.defaultProviderID, - set: (providerID) => (draft.defaultProviderID = providerID), + get: () => draft.selection, + set: (selection) => (draft.selection = selection), }, }), finalize: () => bus.publish(WebSearch.Event.Updated, {}).pipe(Effect.asVoid), @@ -96,12 +94,12 @@ const layer = Layer.effect( const defaultProvider = Effect.fn("WebSearch.default")(function* () { const data = state.get() - const configured = data.defaultProviderID ? data.providers.get(data.defaultProviderID) : undefined - if (configured) return configured - const stored = yield* kv.get("websearch:provider") - if (stored === false) return yield* new DisabledError() - if (typeof stored !== "string") return - return data.providers.get(ID.make(stored)) + if (data.selection === false) return yield* new DisabledError() + if (data.selection === "random") { + const providers = Array.from(data.providers.values()) + return providers[Math.floor(Math.random() * providers.length)] + } + return data.selection ? data.providers.get(data.selection) : undefined }) const resolve = Effect.fn("WebSearch.resolve")(function* (input: Input) { @@ -140,5 +138,5 @@ const layer = Layer.effect( export const node = makeLocationNode({ service: Service, layer, - deps: [Bus.node, KV.node], + deps: [Bus.node], }) diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 966b5c9912..3968c0ee40 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -72,6 +72,53 @@ const provider = { } describe("Config", () => { + it.live("updates the first file-backed document", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => { + const global = path.join(tmp.path, "global") + const project = path.join(tmp.path, "project") + const globalFile = path.join(global, "opencode.jsonc") + const projectFile = path.join(project, "opencode.json") + return Effect.promise(async () => { + await Promise.all([fs.mkdir(global, { recursive: true }), fs.mkdir(project, { recursive: true })]) + await Promise.all([ + fs.writeFile(globalFile, '{\n // Keep this comment.\n "shell": "global"\n}\n'), + fs.writeFile(projectFile, JSON.stringify({ shell: "project" })), + ]) + }).pipe( + Effect.andThen( + Effect.gen(function* () { + const config = yield* Config.Service + const updated = yield* config.update((draft) => { + draft.shell = "updated" + }) + + expect(updated.shell).toBe("updated") + expect(yield* Effect.promise(() => fs.readFile(globalFile, "utf8"))).toContain("// Keep this comment.") + expect(yield* Effect.promise(() => fs.readFile(globalFile, "utf8"))).toContain('"shell": "updated"') + expect(JSON.parse(yield* Effect.promise(() => fs.readFile(projectFile, "utf8")))).toEqual({ + shell: "project", + }) + }).pipe(Effect.provide(testLayer(project, global))), + ), + ) + }), + ), + ) + + it.effect("fails updates when no file-backed document exists", () => + Effect.gen(function* () { + const config = yield* Config.Service + const error = yield* config.update((draft) => void draft).pipe(Effect.flip) + expect(error.message).toBe("No editable config document found") + }).pipe( + Effect.provide(Config.testLayer([new Document({ type: "document", info: new Info({ shell: "virtual" }) })])), + ), + ) + it.live("loads explicit file and content overrides in priority order", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), @@ -858,7 +905,7 @@ describe("Config", () => { expect(documents.map((document) => document.type)).toEqual(["document", "document"]) expect(documents.map((document) => document.info.$schema)).toEqual(["base", "last"]) expect(documents[0]).toBeInstanceOf(Document) - expect(documents[0]?.path).toBe(path.join(tmp.path, "opencode.json")) + expect(documents[0]?.path).toBe(AbsolutePath.make(path.join(tmp.path, "opencode.json"))) expect(documents[1]?.info.providers?.last).toBeInstanceOf(ConfigProvider.Info) yield* Effect.promise(() => @@ -1405,9 +1452,14 @@ describe("Config", () => { ) return yield* Effect.gen(function* () { const config = yield* Config.Service + const watcher = yield* Watcher.Test const documents = (yield* config.entries()).filter((entry) => entry.type === "document") expect(documents.map((document) => document.info.$schema)).toEqual(["base"]) + expect(yield* watcher.subscriptions()).toContainEqual({ + path: path.join(tmp.path, "opencode.jsonc"), + type: "file", + }) }).pipe(Effect.provide(testLayer(tmp.path))) }), ), @@ -1491,13 +1543,9 @@ describe("Config", () => { "global", AbsolutePath.make(global), "outside", - AbsolutePath.make(path.join(tmp.path, "opencode.json")), "root", - AbsolutePath.make(path.join(root, "opencode.json")), "parent", - AbsolutePath.make(path.join(parent, "opencode.jsonc")), "directory", - AbsolutePath.make(path.join(directory, "opencode.json")), "root-dot", AbsolutePath.make(path.join(root, ".opencode")), "directory-dot", diff --git a/packages/core/test/config/reload.test.ts b/packages/core/test/config/reload.test.ts index aa18b3408b..13e1f1d128 100644 --- a/packages/core/test/config/reload.test.ts +++ b/packages/core/test/config/reload.test.ts @@ -18,6 +18,7 @@ import { Provider } from "@opencode-ai/core/provider" import { Reference } from "@opencode-ai/core/reference" import { Skill } from "@opencode-ai/core/skill" import { Effect, Schema } from "effect" +import { AbsolutePath } from "@opencode-ai/core/schema" import { testEffect } from "../lib/effect" import { PluginTestLayer } from "../plugin/fixture" @@ -79,7 +80,7 @@ describe("config plugin reloads", () => { function config(name: string) { return new Document({ type: "document", - path: document, + path: AbsolutePath.make(document), info: decode({ agents: { [name]: { description: `${title(name)} agent`, mode: "subagent" } }, commands: { [name]: { template: `${title(name)} command`, description: `${title(name)} command` } }, diff --git a/packages/core/test/formatter.test.ts b/packages/core/test/formatter.test.ts index 155b585c1f..967a6df512 100644 --- a/packages/core/test/formatter.test.ts +++ b/packages/core/test/formatter.test.ts @@ -1,7 +1,7 @@ import fs from "fs/promises" import path from "path" import { describe, expect } from "bun:test" -import { Effect, Layer, Schema, Stream } from "effect" +import { Effect, Layer, Schema } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AbsolutePath } from "@opencode-ai/core/schema" import { Npm } from "@opencode-ai/util/npm" @@ -27,16 +27,7 @@ function formatterLayer(directory: string, configured?: ConfigInput["formatter"] }), ] return AppNodeBuilder.build(Formatter.node, [ - [ - Config.node, - Layer.succeed( - Config.Service, - Config.Service.of({ - entries: () => Effect.succeed(entries), - changes: () => Stream.empty, - }), - ), - ], + [Config.node, Config.testLayer(entries)], [ Location.node, Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))), diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index cc879b8d38..602f9bd8ee 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -183,10 +183,14 @@ function resourceMcpLayer( Layer.provide( Layer.mergeAll( overrides?.entries - ? Layer.succeed( - Config.Service, - Config.Service.of({ entries: overrides.entries, changes: () => Stream.never }), - ) + ? Layer.succeed( + Config.Service, + Config.Service.of({ + entries: overrides.entries, + update: () => Effect.die("unused config update"), + changes: () => Stream.never, + }), + ) : Config.testLayer([ new Document({ type: "document", diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index 18ea39d3c3..1f30d918b3 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -388,7 +388,8 @@ export function webSearchHost(websearch: WebSearch.Interface): Plugin.Context["w }), default: { get: draft.default.get, - set: (providerID) => draft.default.set(WebSearch.ID.make(providerID)), + set: (selection) => + draft.default.set(selection === false || selection === "random" ? selection : WebSearch.ID.make(selection)), }, }) }), diff --git a/packages/core/test/tool-output.test.ts b/packages/core/test/tool-output.test.ts index 94db5b0a5e..b0bc3ba676 100644 --- a/packages/core/test/tool-output.test.ts +++ b/packages/core/test/tool-output.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import path from "path" -import { Effect, Layer, Stream } from "effect" +import { Effect } from "effect" import { Config } from "@opencode-ai/core/config" import { Document, Info } from "@opencode-ai/schema/config" import { ConfigToolOutput } from "@opencode-ai/schema/config/tool-output" @@ -20,13 +20,7 @@ const withStore = ( Effect.acquireUseRelease( Effect.promise(() => tmpdir()), (tmp) => { - const config = Layer.succeed( - Config.Service, - Config.Service.of({ - entries: () => Effect.succeed([new Document({ type: "document", info })]), - changes: () => Stream.empty, - }), - ) + const config = Config.testLayer([new Document({ type: "document", info })]) const layer = AppNodeBuilder.build(LayerNode.group([ToolOutput.node, FSUtil.node]), [ [Config.node, config], [Global.node, Global.layerWith({ data: tmp.path })], diff --git a/packages/core/test/tool-websearch.test.ts b/packages/core/test/tool-websearch.test.ts index 61ecbe7efe..0f5b1dacf5 100644 --- a/packages/core/test/tool-websearch.test.ts +++ b/packages/core/test/tool-websearch.test.ts @@ -1,12 +1,13 @@ import { beforeEach, describe, expect } from "bun:test" -import { Deferred, Effect, Layer } from "effect" +import { Deferred, Effect, Layer, Stream } from "effect" import { HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { Permission } from "@opencode-ai/core/permission" +import { Config } from "@opencode-ai/core/config" import { Form } from "@opencode-ai/core/form" -import { KV } from "@opencode-ai/core/kv" import { WebSearch } from "@opencode-ai/core/websearch" +import { Document, Info } from "@opencode-ai/schema/config" import { Session } from "@opencode-ai/core/session" import { toSessionError } from "@opencode-ai/core/session/to-session-error" import { Tool } from "@opencode-ai/core/tool" @@ -18,6 +19,7 @@ import { imagePassthrough } from "./lib/image" import { permissionLayer } from "./lib/permission" import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool" import { webSearchHost } from "./plugin/host" +import { produce } from "immer" const webSearchToolNode = makeLocationNode({ name: "test/websearch-tool-plugin", @@ -27,14 +29,14 @@ const webSearchToolNode = makeLocationNode({ yield* registerToolPlugin(WebSearchTool.Plugin, { websearch: webSearchHost(websearch) }) }), ), - deps: [Tool.node, Permission.node, WebSearch.node, Form.node, KV.node], + deps: [Tool.node, Permission.node, WebSearch.node, Form.node, Config.node], }) const sessionID = Session.ID.make("ses_websearch_test") const assertions: Permission.AssertInput[] = [] const queries: WebSearch.Input[] = [] const formRequests: Form.CreateInput[] = [] -const values = new Map() +let selection: WebSearch.ID | "random" | false | undefined const providers = [ { id: WebSearch.ID.make("exa"), name: "Exa" }, { id: WebSearch.ID.make("parallel"), name: "Parallel" }, @@ -54,7 +56,7 @@ beforeEach(() => { assertions.length = 0 queries.length = 0 formRequests.length = 0 - values.clear() + selection = undefined providerRequired = false formResponse = { status: "cancelled" } formResponses.length = 0 @@ -73,28 +75,39 @@ const permission = permissionLayer({ const websearch = Layer.succeed( WebSearch.Service, WebSearch.Service.of({ - transform: () => Effect.die("unused"), + transform: (transform) => + Effect.sync(() => { + transform({ + add: () => undefined, + default: { + get: () => selection, + set: (next) => (selection = next), + }, + }) + return { dispose: Effect.void } + }), reload: () => Effect.die("unused"), providers: () => Effect.succeed(providers), default: () => Effect.gen(function* () { - const stored = values.get("websearch:provider") - if (stored === false) return yield* new WebSearch.DisabledError() - return typeof stored === "string" ? providers.find((provider) => provider.id === stored) : undefined + if (selection === false) return yield* new WebSearch.DisabledError() + return selection ? providers.find((provider) => provider.id === selection) : undefined }), query: (input) => Effect.gen(function* () { queries.push(input) - const stored = values.get("websearch:provider") if (queryBarrier && synchronizedQueries < 5) { synchronizedQueries++ if (synchronizedQueries === 5) yield* Deferred.succeed(queryBarrier, undefined) yield* Deferred.await(queryBarrier) } if (queryError) return yield* queryError - if (providerRequired && typeof stored !== "string") return yield* new WebSearch.ProviderRequiredError() - if (typeof stored === "string") - return new WebSearch.Response({ providerID: WebSearch.ID.make(stored), results: result.results }) + if (providerRequired && !selection) return yield* new WebSearch.ProviderRequiredError() + if (selection) + return new WebSearch.Response({ + providerID: selection === "random" ? result.providerID : WebSearch.ID.make(selection), + results: result.results, + }) return result }), }), @@ -115,12 +128,26 @@ const form = Layer.succeed( cancel: () => Effect.die("unused"), }), ) -const kv = Layer.succeed( - KV.Service, - KV.Service.of({ - get: (key) => Effect.succeed(values.get(key)), - set: (key, value) => Effect.sync(() => values.set(key, value)).pipe(Effect.asVoid), - remove: (key) => Effect.sync(() => values.delete(key)).pipe(Effect.asVoid), +const config = Layer.succeed( + Config.Service, + Config.Service.of({ + entries: () => + Effect.succeed([ + new Document({ + type: "document", + info: new Info({ websearch: selection === undefined ? undefined : selection === false ? false : { provider: selection } }), + }), + ]), + update: (update) => + Effect.sync(() => { + const info = produce( + new Info({ websearch: selection === undefined ? undefined : selection === false ? false : { provider: selection } }), + update, + ) + selection = info.websearch === false ? false : info.websearch?.provider + return info + }), + changes: () => Stream.never, }), ) const it = testEffect( @@ -128,7 +155,7 @@ const it = testEffect( [Permission.node, permission], [WebSearch.node, websearch], [Form.node, form], - [KV.node, kv], + [Config.node, config], [Image.node, imagePassthrough], ]), ) @@ -247,7 +274,7 @@ describe("WebSearchTool registration", () => { call: { type: "tool-call", id: "call-enable", name: "websearch", input: { query: "effect" } }, }), ).toMatchObject({ status: "completed", metadata: { provider: "exa" } }) - expect(values.get("websearch:provider")).toBe("exa") + expect(selection).toBe("random") expect(queries).toHaveLength(2) expect(formRequests).toEqual([ { @@ -264,7 +291,7 @@ describe("WebSearchTool registration", () => { options: [ { value: "allow", - label: "Allow web search via Exa", + label: "Allow search via Exa, Parallel", }, { value: "choose", @@ -305,7 +332,7 @@ describe("WebSearchTool registration", () => { call: { type: "tool-call", id: "call-choose", name: "websearch", input: { query: "effect" } }, }), ).toMatchObject({ status: "completed", metadata: { provider: "parallel" } }) - expect(values.get("websearch:provider")).toBe("parallel") + expect(selection).toBe(WebSearch.ID.make("parallel")) expect(queries).toHaveLength(2) expect(formRequests[1]).toEqual({ sessionID, @@ -353,7 +380,7 @@ describe("WebSearchTool registration", () => { expect(results.every((item) => item.status === "completed")).toBe(true) expect(formRequests).toHaveLength(1) - expect(values.get("websearch:provider")).toBe("exa") + expect(selection).toBe("random") }), ) @@ -370,7 +397,7 @@ describe("WebSearchTool registration", () => { call: { type: "tool-call", id: "call-disable", name: "websearch", input: { query: "effect" } }, }), ).toMatchObject({ status: "error" }) - expect(values.get("websearch:provider")).toBe(false) + expect(selection).toBe(false) expect(queries).toHaveLength(1) }), ) @@ -379,7 +406,7 @@ describe("WebSearchTool registration", () => { Effect.gen(function* () { const registry = yield* Tool.Service const tools = yield* registry.snapshot() - values.set("websearch:provider", "exa") + selection = WebSearch.ID.make("exa") yield* Effect.forEach( [ diff --git a/packages/core/test/websearch.test.ts b/packages/core/test/websearch.test.ts index 73c19b1c59..22945f0a4c 100644 --- a/packages/core/test/websearch.test.ts +++ b/packages/core/test/websearch.test.ts @@ -3,11 +3,10 @@ import { Effect, Exit, Scope } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { Bus } from "@opencode-ai/core/bus" -import { KV } from "@opencode-ai/core/kv" import { WebSearch } from "@opencode-ai/core/websearch" import { testEffect } from "./lib/effect" -const it = testEffect(AppNodeBuilder.build(LayerNode.group([WebSearch.node, Bus.node, KV.node]))) +const it = testEffect(AppNodeBuilder.build(LayerNode.group([WebSearch.node, Bus.node]))) const register = (id: string) => Effect.gen(function* () { @@ -81,16 +80,14 @@ describe("WebSearch", () => { }), ) - it.effect("uses the provider stored in KV", () => + it.effect("chooses a registered provider for random selection", () => Effect.gen(function* () { yield* register("exa") - const parallel = yield* register("parallel") + yield* register("parallel") const websearch = yield* WebSearch.Service - const kv = yield* KV.Service - yield* kv.set("websearch:provider", parallel.providerID) + yield* websearch.transform((draft) => draft.default.set("random")) - expect((yield* websearch.query({ query: "stored" })).providerID).toBe(parallel.providerID) - yield* kv.remove("websearch:provider") + expect(["exa", "parallel"]).toContain((yield* websearch.query({ query: "random" })).providerID) }), ) @@ -98,11 +95,9 @@ describe("WebSearch", () => { Effect.gen(function* () { yield* register("exa") const websearch = yield* WebSearch.Service - const kv = yield* KV.Service - yield* kv.set("websearch:provider", false) + yield* websearch.transform((draft) => draft.default.set(false)) expect((yield* websearch.query({ query: "disabled" }).pipe(Effect.flip))._tag).toBe("WebSearch.Disabled") - yield* kv.remove("websearch:provider") }), ) diff --git a/packages/plugin/src/effect/websearch.ts b/packages/plugin/src/effect/websearch.ts index 03d913e98f..bd6c605da4 100644 --- a/packages/plugin/src/effect/websearch.ts +++ b/packages/plugin/src/effect/websearch.ts @@ -17,7 +17,7 @@ export interface WebSearchDomain extends WebsearchApi { export interface WebSearchDraft { add(definition: WebSearchDefinition): void readonly default: { - get(): string | undefined - set(providerID: string): void + get(): string | false | undefined + set(selection: string | false): void } } diff --git a/packages/plugin/src/promise/websearch.ts b/packages/plugin/src/promise/websearch.ts index 999f485235..1075249b20 100644 --- a/packages/plugin/src/promise/websearch.ts +++ b/packages/plugin/src/promise/websearch.ts @@ -19,7 +19,7 @@ export interface WebSearchDomain extends WebSearchApi { export interface WebSearchDraft { add(definition: WebSearchDefinition): void readonly default: { - get(): string | undefined - set(providerID: string): void + get(): string | false | undefined + set(selection: string | false): void } } diff --git a/packages/schema/src/config.ts b/packages/schema/src/config.ts index fad355eb81..16d94d6fb3 100644 --- a/packages/schema/src/config.ts +++ b/packages/schema/src/config.ts @@ -94,7 +94,7 @@ export class Info extends Schema.Class("Config.Info")({ references: ConfigReference.Info.pipe(optional).annotate({ description: "Named local directories or Git repositories available as external context", }), - websearch: ConfigWebSearch.Info.pipe(optional).annotate({ + websearch: ConfigWebSearch.Selection.pipe(optional).annotate({ description: "Web search provider selection", }), plugins: ConfigPlugin.Plugins.pipe(optional).annotate({ @@ -109,7 +109,7 @@ export class Info extends Schema.Class("Config.Info")({ export class Document extends Schema.Class("Config.Document")({ type: Schema.Literal("document"), - path: Schema.String.pipe(optional), + path: AbsolutePath.pipe(optional), info: Info, }) {} @@ -118,11 +118,6 @@ export class Directory extends Schema.Class("Config.Directory")({ path: AbsolutePath, }) {} -export class File extends Schema.Class("Config.File")({ - type: Schema.Literal("file"), - path: AbsolutePath, -}) {} - export class AgentsDirectory extends Schema.Class("Config.AgentsDirectory")({ type: Schema.Literal("agents"), path: AbsolutePath, @@ -133,7 +128,7 @@ export class ClaudeDirectory extends Schema.Class("Config.Claud path: AbsolutePath, }) {} -export const Entry = Schema.Union([Document, Directory, File, AgentsDirectory, ClaudeDirectory]).annotate({ +export const Entry = Schema.Union([Document, Directory, AgentsDirectory, ClaudeDirectory]).annotate({ identifier: "Config.Entry", }) export type Entry = typeof Entry.Type diff --git a/packages/schema/src/config/websearch.ts b/packages/schema/src/config/websearch.ts index c2a3cc7bdd..52ce393e0c 100644 --- a/packages/schema/src/config/websearch.ts +++ b/packages/schema/src/config/websearch.ts @@ -4,5 +4,8 @@ import { Schema } from "effect" import { WebSearch } from "../websearch.js" export class Info extends Schema.Class("ConfigWebSearch.Info")({ - provider: WebSearch.ID, + provider: Schema.Union([Schema.Literal("random"), WebSearch.ID]), }) {} + +export const Selection = Schema.Union([Schema.Literal(false), Info]) +export type Selection = typeof Selection.Type diff --git a/packages/schema/test/config.test.ts b/packages/schema/test/config.test.ts index 9cc4e5cb3c..44a117eb20 100644 --- a/packages/schema/test/config.test.ts +++ b/packages/schema/test/config.test.ts @@ -6,13 +6,22 @@ import { ConfigMCP } from "../src/config/mcp.js" import { ConfigProvider } from "../src/config/provider.js" import { Mcp } from "../src/mcp.js" import { AbsolutePath } from "../src/schema.js" +import { WebSearch } from "../src/websearch.js" describe("Config.Entry", () => { + test("accepts disabled, fixed, and random web search selection", () => { + const decode = Schema.decodeUnknownSync(Config.Info) + + expect(decode({ websearch: false }).websearch).toBe(false) + expect(decode({ websearch: { provider: "exa" } }).websearch).toEqual({ provider: WebSearch.ID.make("exa") }) + expect(decode({ websearch: { provider: "random" } }).websearch).toEqual({ provider: "random" }) + }) + test("round-trips every configuration entry type", () => { const entries = [ new Config.Document({ type: "document", - path: "/project/opencode.json", + path: AbsolutePath.make("/project/opencode.json"), info: new Config.Info({ permissions: [ { action: "shell", resource: "*", effect: "ask" }, @@ -22,7 +31,6 @@ describe("Config.Entry", () => { }), new Config.Document({ type: "document", info: new Config.Info({ shell: "/bin/zsh" }) }), new Config.Directory({ type: "directory", path: AbsolutePath.make("/project/.opencode") }), - new Config.File({ type: "file", path: AbsolutePath.make("/project/opencode.json") }), new Config.AgentsDirectory({ type: "agents", path: AbsolutePath.make("/project/.agents") }), new Config.ClaudeDirectory({ type: "claude", path: AbsolutePath.make("/project/.claude") }), ] @@ -37,7 +45,6 @@ describe("Config.Entry", () => { "document", "document", "directory", - "file", "agents", "claude", ]) diff --git a/packages/server/test/config.test.ts b/packages/server/test/config.test.ts index 2f899741b6..2baffda243 100644 --- a/packages/server/test/config.test.ts +++ b/packages/server/test/config.test.ts @@ -7,6 +7,7 @@ import { HttpServer } from "effect/unstable/http" import { tmpdir } from "../../core/test/fixture/tmpdir" import { it } from "../../core/test/lib/effect" import { ServerProcess } from "../src/process" +import { AbsolutePath } from "@opencode-ai/schema/schema" it.live("returns ordered config entries for the requested directory", () => Effect.acquireUseRelease( @@ -57,7 +58,7 @@ it.live("returns ordered config entries for the requested directory", () => { action: "shell", resource: "*", effect: "ask" }, { action: "shell", resource: "git status", effect: "allow" }, ]) - expect(entries.some((entry) => entry.type === "file" && entry.path === config)).toBe(true) + expect(document?.path).toBe(AbsolutePath.make(config)) if (!Array.isArray(body)) throw new Error("Expected a config entry array") const raw = body.find((entry) => isRecord(entry) && entry["type"] === "document" && entry["path"] === config) if (!isRecord(raw) || !isRecord(raw["info"])) throw new Error("Expected a config document")