Procházet zdrojové kódy

feat(core): persist web search provider selection (#42663)

Dax před 1 dnem
rodič
revize
5c7f5840ee

+ 0 - 1
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<string | null> = []

+ 1 - 2
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<string | { package: string; options?: { [x: string]: JsonValue } }>
         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 }
 

+ 0 - 1
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",

+ 94 - 15
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<K extends keyof Info>(entries: readonly Entry[], key: K):
 export interface Interface {
   /** Returns location config documents and discovery sources from lowest to highest priority. */
   readonly entries: () => Effect.Effect<Entry[]>
+  /** Updates the first file-backed configuration document. */
+  readonly update: (update: (draft: Draft<Info>) => void) => Effect.Effect<Info, UpdateError>
   /**
    * 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<Watcher.Update>
 }
 
+export class UpdateError extends Schema.TaggedErrorClass<UpdateError>()("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<Watcher.Update>()
       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<AbsolutePath>()
       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<string>()
       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<Info>) => 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<string, unknown>
+    const next = after as Record<string, unknown>
+    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 }]
+}

+ 2 - 1
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 })
 }

+ 3 - 2
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"),

+ 2 - 1
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)),
             },
           })
         }),

+ 24 - 10
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]
       }),
     )
   }),

+ 12 - 14
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<Service, Interface>()("@opencode/We
 
 type Data = {
   readonly providers: Map<ID, ProviderImplementation>
-  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<Data, Draft>({
       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],
 })

+ 53 - 5
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",

+ 2 - 1
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` } },

+ 2 - 11
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) }))),

+ 8 - 4
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",

+ 2 - 1
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)),
           },
         })
       }),

+ 2 - 8
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 = <A, E, R>(
   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 })],

+ 53 - 26
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<string, KV.Value>()
+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(
         [

+ 6 - 11
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")
     }),
   )
 

+ 2 - 2
packages/plugin/src/effect/websearch.ts

@@ -17,7 +17,7 @@ export interface WebSearchDomain extends WebsearchApi<unknown> {
 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
   }
 }

+ 2 - 2
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
   }
 }

+ 3 - 8
packages/schema/src/config.ts

@@ -94,7 +94,7 @@ export class Info extends Schema.Class<Info>("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<Info>("Config.Info")({
 
 export class Document extends Schema.Class<Document>("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<Directory>("Config.Directory")({
   path: AbsolutePath,
 }) {}
 
-export class File extends Schema.Class<File>("Config.File")({
-  type: Schema.Literal("file"),
-  path: AbsolutePath,
-}) {}
-
 export class AgentsDirectory extends Schema.Class<AgentsDirectory>("Config.AgentsDirectory")({
   type: Schema.Literal("agents"),
   path: AbsolutePath,
@@ -133,7 +128,7 @@ export class ClaudeDirectory extends Schema.Class<ClaudeDirectory>("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

+ 4 - 1
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<Info>("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

+ 10 - 3
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",
     ])

+ 2 - 1
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")