Compare commits

...

1 Commits

Author SHA1 Message Date
James Long 655ab67157 fix(core): deduplicate websearch consent prompts 2026-08-06 15:40:15 +00:00
6 changed files with 151 additions and 65 deletions
+3
View File
@@ -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),
+2
View File
@@ -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,
],
})
+71 -59
View File
@@ -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]
}),
)
}),
+30
View File
@@ -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<KV.Value | undefined>
readonly set: (value: KV.Value) => Effect.Effect<void>
readonly synchronized: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
}
export class Service extends Context.Service<Service, Interface>()("@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] })
+4 -4
View File
@@ -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<Data, Draft>({
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],
})
+41 -2
View File
@@ -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<void> | 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<void>()
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