From 3f79699bce940b6206ed8450ef8214fc31f6c834 Mon Sep 17 00:00:00 2001 From: Dax Date: Tue, 18 Aug 2026 13:32:08 -0400 Subject: [PATCH] feat(core): persist websearch provider selection (#43268) --- packages/core/src/tool/plugin/websearch.ts | 17 +++++------- packages/core/src/websearch.ts | 30 ++++++++++++++++------ packages/core/test/tool-websearch.test.ts | 3 ++- packages/core/test/websearch.test.ts | 28 +++++++++++++++++++- 4 files changed, 57 insertions(+), 21 deletions(-) diff --git a/packages/core/src/tool/plugin/websearch.ts b/packages/core/src/tool/plugin/websearch.ts index 8804542e158..b5eb034d7c4 100644 --- a/packages/core/src/tool/plugin/websearch.ts +++ b/packages/core/src/tool/plugin/websearch.ts @@ -4,7 +4,6 @@ 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 { Permission } from "../../permission.js" import { WebSearch } from "../../websearch.js" @@ -30,7 +29,6 @@ export const Plugin = { effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) { const permission = yield* Permission.Service const forms = yield* Form.Service - const config = yield* Config.Service const websearch = yield* WebSearch.Service yield* ctx.tool @@ -97,9 +95,7 @@ export const Plugin = { if (response.status === "cancelled") return yield* Effect.fail(new Error("Web search cancelled")) if (response.answer.choice === "disable") { - yield* config.update((draft) => { - draft.websearch = false - }) + yield* websearch.select(false) return yield* new WebSearch.DisabledError() } const selection = @@ -131,11 +127,7 @@ export const Plugin = { (providerID !== "random" && !providers.some((provider) => provider.id === providerID)) ) return yield* new WebSearch.ProviderRequiredError() - yield* config.update((draft) => { - draft.websearch = { - provider: providerID === "random" ? "random" : WebSearch.ID.make(providerID), - } - }) + yield* websearch.select(providerID === "random" ? "random" : WebSearch.ID.make(providerID)) if (providerID !== "random") return WebSearch.ID.make(providerID) return providers[Math.floor(Math.random() * providers.length)]?.id }), @@ -206,7 +198,10 @@ export const Plugin = { yield* ctx.session.hook("context", (event) => Effect.gen(function* () { - const disabled = Config.latest(yield* config.entries(), "websearch") === false + const disabled = yield* websearch.default().pipe( + Effect.as(false), + Effect.catchTag("WebSearch.Disabled", () => Effect.succeed(true)), + ) if (disabled) delete event.tools[name] }), ) diff --git a/packages/core/src/websearch.ts b/packages/core/src/websearch.ts index 7881793d4da..9532321e39c 100644 --- a/packages/core/src/websearch.ts +++ b/packages/core/src/websearch.ts @@ -1,9 +1,10 @@ export * as WebSearch from "./websearch.js" import { WebSearch } from "@opencode-ai/schema/websearch" -import { Context, Effect, Layer, Schema } from "effect" +import { Context, Effect, Layer, Option, 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 @@ -24,6 +25,10 @@ export type Result = WebSearch.Result export const Response = WebSearch.Response export type Response = WebSearch.Response +export const ProviderKey = "websearch:provider" +export const Selection = Schema.Union([ID, Schema.Literal("random"), Schema.Literal(false)]) +export type Selection = typeof Selection.Type + export interface ProviderImplementation extends Provider { readonly execute: (input: ProviderInput) => Effect.Effect } @@ -49,6 +54,7 @@ export type Error = ProviderRequiredError | ProviderNotFoundError | DisabledErro export interface Interface extends State.Transformable { readonly providers: () => Effect.Effect readonly default: () => Effect.Effect + readonly select: (selection: Selection) => Effect.Effect readonly query: (input: Input) => Effect.Effect } @@ -56,14 +62,14 @@ export class Service extends Context.Service()("@opencode/We type Data = { readonly providers: Map - selection?: ID | "random" | false + selection?: Selection } export type Draft = { add: (provider: ProviderImplementation) => void default: { - get: () => ID | "random" | false | undefined - set: (selection: ID | "random" | false) => void + get: () => Selection | undefined + set: (selection: Selection) => void } } @@ -71,6 +77,7 @@ 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() }), @@ -91,12 +98,16 @@ const layer = Layer.effect( const defaultProvider = Effect.fn("WebSearch.default")(function* () { const data = state.get() - if (data.selection === false) return yield* new DisabledError() - if (data.selection === "random") { + const stored = data.selection === undefined ? yield* kv.get(ProviderKey) : undefined + const decoded = Schema.decodeUnknownOption(Selection)(stored) + if (stored !== undefined && Option.isNone(decoded)) yield* kv.remove(ProviderKey) + const selection = data.selection ?? Option.getOrUndefined(decoded) + if (selection === false) return yield* new DisabledError() + if (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 + return selection ? data.providers.get(selection) : undefined }) const resolve = Effect.fn("WebSearch.resolve")(function* (input: Input) { @@ -120,6 +131,9 @@ const layer = Layer.effect( const provider = yield* defaultProvider() return provider && { id: provider.id, name: provider.name } }), + select: Effect.fn("WebSearch.select")(function* (selection) { + yield* kv.set(ProviderKey, selection) + }), query: Effect.fn("WebSearch.query")(function* (input) { const provider = yield* resolve(input) const results = yield* provider.execute({ query: input.query }).pipe( @@ -135,5 +149,5 @@ const layer = Layer.effect( export const node = makeLocationNode({ service: Service, layer, - deps: [Bus.node], + deps: [Bus.node, KV.node], }) diff --git a/packages/core/test/tool-websearch.test.ts b/packages/core/test/tool-websearch.test.ts index ecff805dbc9..f45faca93bf 100644 --- a/packages/core/test/tool-websearch.test.ts +++ b/packages/core/test/tool-websearch.test.ts @@ -29,7 +29,7 @@ const webSearchToolNode = makeLocationNode({ yield* registerToolPlugin(WebSearchTool.Plugin, { websearch: webSearchHost(websearch) }) }), ), - deps: [Tool.node, Permission.node, WebSearch.node, Form.node, Config.node], + deps: [Tool.node, Permission.node, WebSearch.node, Form.node], }) const sessionID = Session.ID.make("ses_websearch_test") @@ -93,6 +93,7 @@ const websearch = Layer.succeed( if (selection === false) return yield* new WebSearch.DisabledError() return selection ? providers.find((provider) => provider.id === selection) : undefined }), + select: (next) => Effect.sync(() => (selection = next)), query: (input) => Effect.gen(function* () { queries.push(input) diff --git a/packages/core/test/websearch.test.ts b/packages/core/test/websearch.test.ts index 22945f0a4c9..3d512e5db27 100644 --- a/packages/core/test/websearch.test.ts +++ b/packages/core/test/websearch.test.ts @@ -3,10 +3,11 @@ 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]))) +const it = testEffect(AppNodeBuilder.build(LayerNode.group([WebSearch.node, Bus.node, KV.node]))) const register = (id: string) => Effect.gen(function* () { @@ -80,6 +81,31 @@ describe("WebSearch", () => { }), ) + it.effect("persists the selected provider in KV", () => + Effect.gen(function* () { + const parallel = yield* register("parallel") + const websearch = yield* WebSearch.Service + const kv = yield* KV.Service + + yield* websearch.select(parallel.providerID) + + expect(yield* kv.get(WebSearch.ProviderKey)).toBe(parallel.providerID) + expect((yield* websearch.query({ query: "remembered" })).providerID).toBe(parallel.providerID) + }), + ) + + it.effect("keeps config transforms above the persisted selection", () => + Effect.gen(function* () { + const exa = yield* register("exa") + const parallel = yield* register("parallel") + const websearch = yield* WebSearch.Service + yield* websearch.select(parallel.providerID) + yield* websearch.transform((draft) => draft.default.set(exa.providerID)) + + expect((yield* websearch.query({ query: "configured" })).providerID).toBe(exa.providerID) + }), + ) + it.effect("chooses a registered provider for random selection", () => Effect.gen(function* () { yield* register("exa")