diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index e99191cb72..a0f91e718b 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -32,6 +32,7 @@ import { Npm } from "@opencode-ai/util/npm" import { Permission } from "../permission" import { Reference } from "../reference" import { WebSearch } from "../websearch" +import { WebSearchPreference } from "../websearch-preference" import { Ripgrep } from "../ripgrep" import { SessionInstructions } from "../session/instructions" import { Shell } from "../shell" @@ -89,6 +90,7 @@ const services = Effect.fn("PluginInternal.services")(function* () { const read = yield* ReadToolFileSystem.Service const reference = yield* Reference.Service const websearch = yield* WebSearch.Service + const websearchPreference = yield* WebSearchPreference.Service const ripgrep = yield* Ripgrep.Service const instructions = yield* SessionInstructions.Service const shell = yield* Shell.Service @@ -121,6 +123,7 @@ const services = Effect.fn("PluginInternal.services")(function* () { Context.make(ReadToolFileSystem.Service, read), Context.make(Reference.Service, reference), Context.make(WebSearch.Service, websearch), + Context.make(WebSearchPreference.Service, websearchPreference), Context.make(Ripgrep.Service, ripgrep), Context.make(SessionInstructions.Service, instructions), Context.make(Shell.Service, shell), diff --git a/packages/core/src/plugin/supervisor.ts b/packages/core/src/plugin/supervisor.ts index 160528b8b8..44a8233379 100644 --- a/packages/core/src/plugin/supervisor.ts +++ b/packages/core/src/plugin/supervisor.ts @@ -39,6 +39,7 @@ import { Skill } from "../skill" import { ReadToolFileSystem } from "../tool/read-filesystem" import { Tool } from "../tool" import { WebSearch } from "../websearch" +import { WebSearchPreference } from "../websearch-preference" import { WellKnown } from "../wellknown" import { PluginInternal } from "./internal" import { PluginRuntime } from "./runtime" @@ -345,6 +346,7 @@ export const node = makeLocationNode({ Tool.node, Watcher.node, WebSearch.node, + WebSearchPreference.node, WellKnown.node, ], }) diff --git a/packages/core/src/tool/plugin/websearch.ts b/packages/core/src/tool/plugin/websearch.ts index a3b241cc84..29bae1e547 100644 --- a/packages/core/src/tool/plugin/websearch.ts +++ b/packages/core/src/tool/plugin/websearch.ts @@ -4,9 +4,9 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin import { ToolFailure } from "@opencode-ai/ai" import { Effect, Schema } from "effect" import { Form } from "../../form" -import { KV } from "../../kv" import { Permission } from "../../permission" import { WebSearch } from "../../websearch" +import { WebSearchPreference } from "../../websearch-preference" export const name = "websearch" export const NO_RESULTS = "No search results found. Please try a different query." @@ -28,7 +28,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 preference = yield* WebSearchPreference.Service yield* ctx.tool .transform((draft) => @@ -52,65 +52,77 @@ export const Plugin = { const result = yield* ctx.websearch.query(input).pipe( Effect.catch((error) => { if (!Schema.is(WebSearch.ProviderRequiredError)(error)) return Effect.fail(error) - return Effect.gen(function* () { - const providers = (yield* ctx.websearch.providers()).data - const defaultProvider = providers[0] - if (!defaultProvider) return yield* new WebSearch.ProviderRequiredError() - const response = yield* forms.ask({ - sessionID: context.sessionID, - title: "Web Search", - metadata: { kind: "websearch.provider" }, - fields: [ - { - key: "choice", - description: "Allow OpenCode to search the web for up-to-date information?", - type: "string", - required: true, - custom: false, - options: [ + return preference + .synchronized( + Effect.gen(function* () { + const providers = (yield* ctx.websearch.providers()).data + const stored = yield* preference.get() + if (stored === false) return yield* new WebSearch.DisabledError() + if (typeof stored === "string" && providers.some((provider) => provider.id === stored)) + return yield* Effect.void + const defaultProvider = providers[0] + if (!defaultProvider) return yield* new WebSearch.ProviderRequiredError() + const response = yield* forms.ask({ + sessionID: context.sessionID, + title: "Web Search", + metadata: { kind: "websearch.provider" }, + fields: [ { - value: "allow", - label: `Allow web search via ${defaultProvider.name}`, + key: "choice", + description: "Allow OpenCode to search the web for up-to-date information?", + type: "string", + required: true, + custom: false, + options: [ + { + value: "allow", + label: `Allow web search via ${defaultProvider.name}`, + }, + { + value: "choose", + label: "Choose another provider", + }, + { value: "disable", label: "Disable web search" }, + ], }, - { - value: "choose", - label: "Choose another provider", - }, - { value: "disable", label: "Disable web search" }, ], - }, - ], - }) - if (response.status === "cancelled") return yield* Effect.fail(new Error("Web search cancelled")) - if (response.answer.choice === "disable") { - yield* kv.set("websearch:provider", false) - return yield* new WebSearch.DisabledError() - } - const selection = - response.answer.choice === "choose" - ? yield* forms.ask({ - sessionID: context.sessionID, - title: "Choose a web search provider", - metadata: { kind: "websearch.provider" }, - fields: [ - { - key: "provider", - description: "Choose a provider for web search.", - type: "string", - required: true, - custom: false, - options: providers.map((provider) => ({ value: provider.id, label: provider.name })), - }, - ], - }) - : undefined - if (selection?.status === "cancelled") return yield* Effect.fail(new Error("Web search cancelled")) - const providerID = selection?.answer.provider ?? defaultProvider.id - if (typeof providerID !== "string" || !providers.some((provider) => provider.id === providerID)) - return yield* new WebSearch.ProviderRequiredError() - yield* kv.set("websearch:provider", providerID) - return yield* ctx.websearch.query(input) - }) + }) + if (response.status === "cancelled") + return yield* Effect.fail(new Error("Web search cancelled")) + if (response.answer.choice === "disable") { + yield* preference.set(false) + return yield* new WebSearch.DisabledError() + } + const selection = + response.answer.choice === "choose" + ? yield* forms.ask({ + sessionID: context.sessionID, + title: "Choose a web search provider", + metadata: { kind: "websearch.provider" }, + fields: [ + { + key: "provider", + description: "Choose a provider for web search.", + type: "string", + required: true, + custom: false, + options: providers.map((provider) => ({ + value: provider.id, + label: provider.name, + })), + }, + ], + }) + : undefined + if (selection?.status === "cancelled") + return yield* Effect.fail(new Error("Web search cancelled")) + const providerID = selection?.answer.provider ?? defaultProvider.id + if (typeof providerID !== "string" || !providers.some((provider) => provider.id === providerID)) + return yield* new WebSearch.ProviderRequiredError() + return yield* preference.set(providerID) + }), + ) + .pipe(Effect.andThen(ctx.websearch.query(input))) }), ) const output = { @@ -140,7 +152,7 @@ export const Plugin = { yield* ctx.session.hook("context", (event) => Effect.gen(function* () { - if ((yield* kv.get("websearch:provider")) === false) delete event.tools[name] + if ((yield* preference.get()) === false) delete event.tools[name] }), ) }), diff --git a/packages/core/src/websearch-preference.ts b/packages/core/src/websearch-preference.ts new file mode 100644 index 0000000000..364288e5b0 --- /dev/null +++ b/packages/core/src/websearch-preference.ts @@ -0,0 +1,30 @@ +export * as WebSearchPreference from "./websearch-preference" + +import { Context, Effect, Layer, Semaphore } from "effect" +import { makeGlobalNode } from "@opencode-ai/util/effect/app-node" +import { KV } from "./kv" + +const key = "websearch:provider" + +export interface Interface { + readonly get: () => Effect.Effect + readonly set: (value: KV.Value) => Effect.Effect + readonly synchronized: (effect: Effect.Effect) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/WebSearchPreference") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const kv = yield* KV.Service + const lock = Semaphore.makeUnsafe(1) + return Service.of({ + get: () => kv.get(key), + set: (value) => kv.set(key, value), + synchronized: (effect) => lock.withPermit(effect), + }) + }), +) + +export const node = makeGlobalNode({ service: Service, layer, deps: [KV.node] }) diff --git a/packages/core/src/websearch.ts b/packages/core/src/websearch.ts index 7f389bffce..10c554191b 100644 --- a/packages/core/src/websearch.ts +++ b/packages/core/src/websearch.ts @@ -4,8 +4,8 @@ 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" -import { KV } from "./kv" import { State } from "./state" +import { WebSearchPreference } from "./websearch-preference" export const ID = WebSearch.ID export type ID = WebSearch.ID @@ -75,7 +75,7 @@ const layer = Layer.effect( Service, Effect.gen(function* () { const bus = yield* Bus.Service - const kv = yield* KV.Service + const preference = yield* WebSearchPreference.Service const decodeResults = Schema.decodeUnknownEffect(Schema.Array(Result)) const state = State.create({ initial: () => ({ providers: new Map() }), @@ -98,7 +98,7 @@ const layer = Layer.effect( 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") + const stored = yield* preference.get() if (stored === false) return yield* new DisabledError() if (typeof stored !== "string") return return data.providers.get(ID.make(stored)) @@ -140,5 +140,5 @@ const layer = Layer.effect( export const node = makeLocationNode({ service: Service, layer, - deps: [Bus.node, KV.node], + deps: [Bus.node, WebSearchPreference.node], }) diff --git a/packages/core/test/tool-websearch.test.ts b/packages/core/test/tool-websearch.test.ts index 1f1ec9cb5d..65117fe56b 100644 --- a/packages/core/test/tool-websearch.test.ts +++ b/packages/core/test/tool-websearch.test.ts @@ -1,11 +1,12 @@ import { beforeEach, describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { Deferred, Effect, Layer } from "effect" 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 { Form } from "@opencode-ai/core/form" import { KV } from "@opencode-ai/core/kv" import { WebSearch } from "@opencode-ai/core/websearch" +import { WebSearchPreference } from "@opencode-ai/core/websearch-preference" import { Session } from "@opencode-ai/core/session" import { Tool } from "@opencode-ai/core/tool" import { WebSearchTool } from "@opencode-ai/core/tool/plugin/websearch" @@ -24,7 +25,7 @@ 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, WebSearchPreference.node, Form.node], }) const sessionID = Session.ID.make("ses_websearch_test") @@ -39,6 +40,8 @@ const providers = [ let providerRequired = false let formResponse: Form.TerminalState = { status: "cancelled" } const formResponses: Form.TerminalState[] = [] +let queryBarrier: Deferred.Deferred | undefined +let synchronizedQueries = 0 let result = new WebSearch.Response({ providerID: WebSearch.ID.make("exa"), results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }], @@ -52,6 +55,8 @@ beforeEach(() => { providerRequired = false formResponse = { status: "cancelled" } formResponses.length = 0 + queryBarrier = undefined + synchronizedQueries = 0 result = new WebSearch.Response({ providerID: WebSearch.ID.make("exa"), results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }], @@ -80,6 +85,11 @@ const websearch = Layer.succeed( 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 (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 }) @@ -316,6 +326,35 @@ describe("WebSearchTool registration", () => { }), ) + it.effect("shares provider consent across concurrent searches", () => + Effect.gen(function* () { + providerRequired = true + formResponse = { status: "answered", answer: { choice: "allow" } } + queryBarrier = yield* Deferred.make() + const registry = yield* Tool.Service + + const results = yield* Effect.all( + Array.from({ length: 5 }, (_, index) => + executeTool(registry, { + sessionID, + ...toolIdentity, + call: { + type: "tool-call", + id: `call-concurrent-${index}`, + name: "websearch", + input: { query: `effect ${index}` }, + }, + }), + ), + { concurrency: "unbounded" }, + ) + + expect(results.every((item) => item.status === "completed")).toBe(true) + expect(formRequests).toHaveLength(1) + expect(values.get("websearch:provider")).toBe("exa") + }), + ) + it.effect("persists the choice to disable web search", () => Effect.gen(function* () { providerRequired = true