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 { Permission } from "../permission"
import { Reference } from "../reference" import { Reference } from "../reference"
import { WebSearch } from "../websearch" import { WebSearch } from "../websearch"
import { WebSearchPreference } from "../websearch-preference"
import { Ripgrep } from "../ripgrep" import { Ripgrep } from "../ripgrep"
import { SessionInstructions } from "../session/instructions" import { SessionInstructions } from "../session/instructions"
import { Shell } from "../shell" import { Shell } from "../shell"
@@ -89,6 +90,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const read = yield* ReadToolFileSystem.Service const read = yield* ReadToolFileSystem.Service
const reference = yield* Reference.Service const reference = yield* Reference.Service
const websearch = yield* WebSearch.Service const websearch = yield* WebSearch.Service
const websearchPreference = yield* WebSearchPreference.Service
const ripgrep = yield* Ripgrep.Service const ripgrep = yield* Ripgrep.Service
const instructions = yield* SessionInstructions.Service const instructions = yield* SessionInstructions.Service
const shell = yield* Shell.Service const shell = yield* Shell.Service
@@ -121,6 +123,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(ReadToolFileSystem.Service, read), Context.make(ReadToolFileSystem.Service, read),
Context.make(Reference.Service, reference), Context.make(Reference.Service, reference),
Context.make(WebSearch.Service, websearch), Context.make(WebSearch.Service, websearch),
Context.make(WebSearchPreference.Service, websearchPreference),
Context.make(Ripgrep.Service, ripgrep), Context.make(Ripgrep.Service, ripgrep),
Context.make(SessionInstructions.Service, instructions), Context.make(SessionInstructions.Service, instructions),
Context.make(Shell.Service, shell), Context.make(Shell.Service, shell),
+2
View File
@@ -39,6 +39,7 @@ import { Skill } from "../skill"
import { ReadToolFileSystem } from "../tool/read-filesystem" import { ReadToolFileSystem } from "../tool/read-filesystem"
import { Tool } from "../tool" import { Tool } from "../tool"
import { WebSearch } from "../websearch" import { WebSearch } from "../websearch"
import { WebSearchPreference } from "../websearch-preference"
import { WellKnown } from "../wellknown" import { WellKnown } from "../wellknown"
import { PluginInternal } from "./internal" import { PluginInternal } from "./internal"
import { PluginRuntime } from "./runtime" import { PluginRuntime } from "./runtime"
@@ -345,6 +346,7 @@ export const node = makeLocationNode({
Tool.node, Tool.node,
Watcher.node, Watcher.node,
WebSearch.node, WebSearch.node,
WebSearchPreference.node,
WellKnown.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 { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
import { Form } from "../../form" import { Form } from "../../form"
import { KV } from "../../kv"
import { Permission } from "../../permission" import { Permission } from "../../permission"
import { WebSearch } from "../../websearch" import { WebSearch } from "../../websearch"
import { WebSearchPreference } from "../../websearch-preference"
export const name = "websearch" export const name = "websearch"
export const NO_RESULTS = "No search results found. Please try a different query." 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) { effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) {
const permission = yield* Permission.Service const permission = yield* Permission.Service
const forms = yield* Form.Service const forms = yield* Form.Service
const kv = yield* KV.Service const preference = yield* WebSearchPreference.Service
yield* ctx.tool yield* ctx.tool
.transform((draft) => .transform((draft) =>
@@ -52,65 +52,77 @@ export const Plugin = {
const result = yield* ctx.websearch.query(input).pipe( const result = yield* ctx.websearch.query(input).pipe(
Effect.catch((error) => { Effect.catch((error) => {
if (!Schema.is(WebSearch.ProviderRequiredError)(error)) return Effect.fail(error) if (!Schema.is(WebSearch.ProviderRequiredError)(error)) return Effect.fail(error)
return Effect.gen(function* () { return preference
const providers = (yield* ctx.websearch.providers()).data .synchronized(
const defaultProvider = providers[0] Effect.gen(function* () {
if (!defaultProvider) return yield* new WebSearch.ProviderRequiredError() const providers = (yield* ctx.websearch.providers()).data
const response = yield* forms.ask({ const stored = yield* preference.get()
sessionID: context.sessionID, if (stored === false) return yield* new WebSearch.DisabledError()
title: "Web Search", if (typeof stored === "string" && providers.some((provider) => provider.id === stored))
metadata: { kind: "websearch.provider" }, return yield* Effect.void
fields: [ const defaultProvider = providers[0]
{ if (!defaultProvider) return yield* new WebSearch.ProviderRequiredError()
key: "choice", const response = yield* forms.ask({
description: "Allow OpenCode to search the web for up-to-date information?", sessionID: context.sessionID,
type: "string", title: "Web Search",
required: true, metadata: { kind: "websearch.provider" },
custom: false, fields: [
options: [
{ {
value: "allow", key: "choice",
label: `Allow web search via ${defaultProvider.name}`, 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.status === "cancelled") return yield* Effect.fail(new Error("Web search cancelled")) if (response.answer.choice === "disable") {
if (response.answer.choice === "disable") { yield* preference.set(false)
yield* kv.set("websearch:provider", false) return yield* new WebSearch.DisabledError()
return yield* new WebSearch.DisabledError() }
} const selection =
const selection = response.answer.choice === "choose"
response.answer.choice === "choose" ? yield* forms.ask({
? yield* forms.ask({ sessionID: context.sessionID,
sessionID: context.sessionID, title: "Choose a web search provider",
title: "Choose a web search provider", metadata: { kind: "websearch.provider" },
metadata: { kind: "websearch.provider" }, fields: [
fields: [ {
{ key: "provider",
key: "provider", description: "Choose a provider for web search.",
description: "Choose a provider for web search.", type: "string",
type: "string", required: true,
required: true, custom: false,
custom: false, options: providers.map((provider) => ({
options: providers.map((provider) => ({ value: provider.id, label: provider.name })), 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 : undefined
if (typeof providerID !== "string" || !providers.some((provider) => provider.id === providerID)) if (selection?.status === "cancelled")
return yield* new WebSearch.ProviderRequiredError() return yield* Effect.fail(new Error("Web search cancelled"))
yield* kv.set("websearch:provider", providerID) const providerID = selection?.answer.provider ?? defaultProvider.id
return yield* ctx.websearch.query(input) 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 = { const output = {
@@ -140,7 +152,7 @@ export const Plugin = {
yield* ctx.session.hook("context", (event) => yield* ctx.session.hook("context", (event) =>
Effect.gen(function* () { 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 { Context, Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "./bus" import { Bus } from "./bus"
import { KV } from "./kv"
import { State } from "./state" import { State } from "./state"
import { WebSearchPreference } from "./websearch-preference"
export const ID = WebSearch.ID export const ID = WebSearch.ID
export type ID = WebSearch.ID export type ID = WebSearch.ID
@@ -75,7 +75,7 @@ const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const bus = yield* Bus.Service const bus = yield* Bus.Service
const kv = yield* KV.Service const preference = yield* WebSearchPreference.Service
const decodeResults = Schema.decodeUnknownEffect(Schema.Array(Result)) const decodeResults = Schema.decodeUnknownEffect(Schema.Array(Result))
const state = State.create<Data, Draft>({ const state = State.create<Data, Draft>({
initial: () => ({ providers: new Map() }), initial: () => ({ providers: new Map() }),
@@ -98,7 +98,7 @@ const layer = Layer.effect(
const data = state.get() const data = state.get()
const configured = data.defaultProviderID ? data.providers.get(data.defaultProviderID) : undefined const configured = data.defaultProviderID ? data.providers.get(data.defaultProviderID) : undefined
if (configured) return configured if (configured) return configured
const stored = yield* kv.get("websearch:provider") const stored = yield* preference.get()
if (stored === false) return yield* new DisabledError() if (stored === false) return yield* new DisabledError()
if (typeof stored !== "string") return if (typeof stored !== "string") return
return data.providers.get(ID.make(stored)) return data.providers.get(ID.make(stored))
@@ -140,5 +140,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({ export const node = makeLocationNode({
service: Service, service: Service,
layer, 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 { 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 { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Permission } from "@opencode-ai/core/permission" import { Permission } from "@opencode-ai/core/permission"
import { Form } from "@opencode-ai/core/form" import { Form } from "@opencode-ai/core/form"
import { KV } from "@opencode-ai/core/kv" import { KV } from "@opencode-ai/core/kv"
import { WebSearch } from "@opencode-ai/core/websearch" import { WebSearch } from "@opencode-ai/core/websearch"
import { WebSearchPreference } from "@opencode-ai/core/websearch-preference"
import { Session } from "@opencode-ai/core/session" import { Session } from "@opencode-ai/core/session"
import { Tool } from "@opencode-ai/core/tool" import { Tool } from "@opencode-ai/core/tool"
import { WebSearchTool } from "@opencode-ai/core/tool/plugin/websearch" import { WebSearchTool } from "@opencode-ai/core/tool/plugin/websearch"
@@ -24,7 +25,7 @@ const webSearchToolNode = makeLocationNode({
yield* registerToolPlugin(WebSearchTool.Plugin, { websearch: webSearchHost(websearch) }) 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") const sessionID = Session.ID.make("ses_websearch_test")
@@ -39,6 +40,8 @@ const providers = [
let providerRequired = false let providerRequired = false
let formResponse: Form.TerminalState = { status: "cancelled" } let formResponse: Form.TerminalState = { status: "cancelled" }
const formResponses: Form.TerminalState[] = [] const formResponses: Form.TerminalState[] = []
let queryBarrier: Deferred.Deferred<void> | undefined
let synchronizedQueries = 0
let result = new WebSearch.Response({ let result = new WebSearch.Response({
providerID: WebSearch.ID.make("exa"), providerID: WebSearch.ID.make("exa"),
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }], results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
@@ -52,6 +55,8 @@ beforeEach(() => {
providerRequired = false providerRequired = false
formResponse = { status: "cancelled" } formResponse = { status: "cancelled" }
formResponses.length = 0 formResponses.length = 0
queryBarrier = undefined
synchronizedQueries = 0
result = new WebSearch.Response({ result = new WebSearch.Response({
providerID: WebSearch.ID.make("exa"), providerID: WebSearch.ID.make("exa"),
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }], results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
@@ -80,6 +85,11 @@ const websearch = Layer.succeed(
Effect.gen(function* () { Effect.gen(function* () {
queries.push(input) queries.push(input)
const stored = values.get("websearch:provider") 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 (providerRequired && typeof stored !== "string") return yield* new WebSearch.ProviderRequiredError()
if (typeof stored === "string") if (typeof stored === "string")
return new WebSearch.Response({ providerID: WebSearch.ID.make(stored), results: result.results }) 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", () => it.effect("persists the choice to disable web search", () =>
Effect.gen(function* () { Effect.gen(function* () {
providerRequired = true providerRequired = true