feat(core): add pluggable web search (#35558)

Co-authored-by: Dax Raad <d@ironbay.co>
This commit is contained in:
Shoubhit Dash 2026-07-26 09:25:05 +05:30 committed by GitHub
commit efb629a33a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
57 changed files with 1700 additions and 587 deletions

View file

@ -27,6 +27,7 @@ import { ConfigModel } from "./config/model"
import { ConfigPlugin } from "./config/plugin"
import { ConfigProvider } from "./config/provider"
import { ConfigReference } from "./config/reference"
import { ConfigWebSearch } from "./config/websearch"
import { ConfigToolOutput } from "./config/tool-output"
import { ConfigVariable } from "./config/variable"
import { ConfigWatcher } from "./config/watcher"
@ -108,6 +109,9 @@ export class Info extends Schema.Class<Info>("Config.Info")({
references: ConfigReference.Info.pipe(Schema.optional).annotate({
description: "Named local directories or Git repositories available as external context",
}),
websearch: ConfigWebSearch.Info.pipe(Schema.optional).annotate({
description: "Web search provider selection",
}),
plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({
description: "Ordered plugin enablement directives and external package declarations",
}),

View file

@ -0,0 +1,27 @@
export * as ConfigWebSearchPlugin from "./websearch"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Effect, Stream } from "effect"
import { Config } from "../../config"
export const Plugin = define({
id: "opencode.config.websearch",
effect: Effect.fn(function* (ctx) {
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)
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() =>
config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(ctx.websearch.reload()),
),
),
Effect.forkScoped({ startImmediately: true }),
)
}),
})

View file

@ -0,0 +1,8 @@
export * as ConfigWebSearch from "./websearch"
import { WebSearch } from "@opencode-ai/schema/websearch"
import { Schema } from "effect"
export class Info extends Schema.Class<Info>("ConfigWebSearch.Info")({
provider: WebSearch.ID,
}) {}

View file

@ -29,6 +29,7 @@ import { Pty } from "./pty"
import { QuestionV2 } from "./question"
import { Shell } from "./shell"
import { Reference } from "./reference"
import { WebSearch } from "./websearch"
import { ReferenceInstructions } from "./reference/instructions"
import { SessionRunnerLLM } from "./session/runner/llm"
import { SessionRunnerModel } from "./session/runner/model"
@ -56,6 +57,7 @@ const locationServiceNodes = [
AgentV2.node,
CommandV2.node,
Reference.node,
WebSearch.node,
Integration.node,
Catalog.node,
ModelResolver.node,

View file

@ -14,6 +14,7 @@ import { Integration } from "./integration"
import { Location } from "./location"
import { PluginHost } from "./plugin/host"
import { PluginRuntime } from "./plugin/runtime"
import { WebSearch } from "./websearch"
import { Reference } from "./reference"
import { SkillV2 } from "./skill"
import { State } from "./state"
@ -158,5 +159,6 @@ export const node = makeLocationNode({
ToolHooks.node,
PluginHooks.node,
PluginRuntime.node,
WebSearch.node,
],
})

View file

@ -1,6 +1,8 @@
export * as PluginHost from "./host"
import type { Plugin } from "@opencode-ai/plugin/v2/effect"
import { Plugin } from "@opencode-ai/plugin/v2/effect"
import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
import type { CredentialOAuth } from "@opencode-ai/sdk/v2/types"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
import { App } from "../app"
import { Effect, Schema, Stream } from "effect"
@ -23,6 +25,7 @@ import { Tool } from "../tool/tool"
import { Tools } from "../tool/tools"
import { ToolHooks } from "../tool/hooks"
import { WorkspaceV2 } from "../workspace"
import { WebSearch } from "../websearch"
import { PluginHooks } from "./hooks"
const mutable = <T>(value: T) => value as DeepMutable<T>
@ -38,6 +41,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
const reference = yield* Reference.Service
const skill = yield* SkillV2.Service
const tools = yield* Tools.Service
const websearch = yield* WebSearch.Service
const toolHooks = yield* ToolHooks.Service
const hooks = yield* PluginHooks.Service
const runtime = yield* PluginRuntime.Service
@ -247,79 +251,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
remove: (id) => draft.remove(Integration.ID.make(id)),
method: {
list: (id) => mutable(draft.method.list(Integration.ID.make(id))),
update: (input) => {
if ("authorize" in input) {
const methodID = Integration.MethodID.make(input.method.id)
const refresh = input.refresh
draft.method.update({
integrationID: Integration.ID.make(input.integrationID),
method: { ...input.method, id: methodID },
authorize: (inputs) =>
input.authorize(inputs).pipe(
Effect.map((authorization) => {
if (authorization.mode === "auto") {
return {
...authorization,
callback: authorization.callback.pipe(
Effect.map((credential) =>
Credential.OAuth.make({
...credential,
methodID: Integration.MethodID.make(credential.methodID),
}),
),
),
}
}
return {
...authorization,
callback: (code: string) =>
authorization.callback(code).pipe(
Effect.map((credential) =>
Credential.OAuth.make({
...credential,
methodID: Integration.MethodID.make(credential.methodID),
}),
),
),
}
}),
),
...(refresh
? {
refresh: (value: Credential.OAuth) =>
refresh(value).pipe(
Effect.map((next) =>
Credential.OAuth.make({
...next,
methodID: Integration.MethodID.make(next.methodID),
}),
),
),
}
: {}),
...(input.label ? { label: input.label } : {}),
})
return
}
if (input.method.type === "env") {
draft.method.update({
integrationID: Integration.ID.make(input.integrationID),
method: { type: "env", names: input.method.names },
})
return
}
if (input.method.type === "command") {
draft.method.update({
integrationID: Integration.ID.make(input.integrationID),
method: Schema.decodeUnknownSync(Integration.CommandMethod)(input.method),
})
return
}
draft.method.update({
integrationID: Integration.ID.make(input.integrationID),
method: { type: "key", label: input.method.label },
})
},
update: (input) => draft.method.update(methodImplementation(input)),
remove: (id, method) =>
draft.method.remove(Integration.ID.make(id), Schema.decodeUnknownSync(Integration.Method)(method)),
},
@ -430,6 +362,32 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
})
},
},
websearch: {
providers: () => response(websearch.providers()),
query: (input) =>
response(
websearch.query({
query: input.query,
providerID: input.providerID === undefined ? undefined : WebSearch.ID.make(input.providerID),
}),
),
reload: websearch.reload,
transform: (callback) =>
websearch.transform((draft) => {
callback({
add: (definition) =>
draft.add({
id: WebSearch.ID.make(definition.id),
name: definition.name,
execute: definition.execute,
}),
default: {
get: draft.default.get,
set: (providerID) => draft.default.set(WebSearch.ID.make(providerID)),
},
})
}),
},
session: {
hook: (name, callback) => hooks.register("session", name, callback),
create: (input) =>
@ -449,3 +407,50 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
},
} satisfies Plugin.Context
})
function methodImplementation(input: IntegrationMethodRegistration): Integration.Implementation {
if ("authorize" in input) {
const refresh = input.refresh
return {
integrationID: Integration.ID.make(input.integrationID),
method: { ...input.method, id: Integration.MethodID.make(input.method.id) },
authorize: (inputs) =>
input.authorize(inputs).pipe(
Effect.map((authorization) => {
if (authorization.mode === "auto") {
return {
...authorization,
callback: authorization.callback.pipe(Effect.map(credential)),
}
}
return {
...authorization,
callback: (code: string) => authorization.callback(code).pipe(Effect.map(credential)),
}
}),
),
...(refresh ? { refresh: (value: Credential.OAuth) => refresh(value).pipe(Effect.map(credential)) } : {}),
...(input.label ? { label: input.label } : {}),
}
}
if (input.method.type === "env") {
return {
integrationID: Integration.ID.make(input.integrationID),
method: { type: "env", names: input.method.names },
}
}
if (input.method.type === "command") {
return {
integrationID: Integration.ID.make(input.integrationID),
method: Schema.decodeUnknownSync(Integration.CommandMethod)(input.method),
}
}
return {
integrationID: Integration.ID.make(input.integrationID),
method: { type: "key", label: input.method.label },
}
}
function credential(value: CredentialOAuth) {
return Credential.OAuth.make({ ...value, methodID: Integration.MethodID.make(value.methodID) })
}

View file

@ -13,6 +13,7 @@ import { ConfigProviderPlugin } from "../config/plugin/provider"
import { ConfigPolicyPlugin } from "../config/plugin/policy"
import { ConfigReferencePlugin } from "../config/plugin/reference"
import { ConfigSkillPlugin } from "../config/plugin/skill"
import { ConfigWebSearchPlugin } from "../config/plugin/websearch"
import { EventV2 } from "../event"
import { FileMutation } from "../file-mutation"
import { Form } from "../form"
@ -21,12 +22,14 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Image } from "../image"
import { Integration } from "../integration"
import { KV } from "../kv"
import { Location } from "../location"
import { LocationMutation } from "../location-mutation"
import { ModelsDev } from "../models-dev"
import { Npm } from "@opencode-ai/util/npm"
import { PermissionV2 } from "../permission"
import { Reference } from "../reference"
import { WebSearch } from "../websearch"
import { Ripgrep } from "../ripgrep"
import { SessionInstructions } from "../session/instructions"
import { Shell } from "../shell"
@ -50,6 +53,7 @@ import { AgentPlugin } from "./agent"
import { CommandPlugin } from "./command"
import { ModelsDevPlugin } from "./models-dev"
import { ProviderPlugins } from "./provider"
import { WebSearchPlugins } from "./websearch"
import { PluginRuntime } from "./runtime"
import { SkillPlugin } from "./skill"
import { SystemPromptPlugin } from "./system-prompt"
@ -70,6 +74,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const http = yield* HttpClient.HttpClient
const image = yield* Image.Service
const integration = yield* Integration.Service
const kv = yield* KV.Service
const location = yield* Location.Service
const locationMutation = yield* LocationMutation.Service
const models = yield* ModelsDev.Service
@ -79,12 +84,12 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const form = yield* Form.Service
const read = yield* ReadToolFileSystem.Service
const reference = yield* Reference.Service
const websearch = yield* WebSearch.Service
const ripgrep = yield* Ripgrep.Service
const instructions = yield* SessionInstructions.Service
const shell = yield* Shell.Service
const skill = yield* SkillV2.Service
const tools = yield* Tools.Service
const websearch = yield* WebSearchTool.ConfigService
const wellknown = yield* WellKnown.Service
return Context.mergeAll(
Context.make(AgentV2.Service, agent),
@ -99,6 +104,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(HttpClient.HttpClient, http),
Context.make(Image.Service, image),
Context.make(Integration.Service, integration),
Context.make(KV.Service, kv),
Context.make(Location.Service, location),
Context.make(LocationMutation.Service, locationMutation),
Context.make(ModelsDev.Service, models),
@ -108,12 +114,12 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(Form.Service, form),
Context.make(ReadToolFileSystem.Service, read),
Context.make(Reference.Service, reference),
Context.make(WebSearch.Service, websearch),
Context.make(Ripgrep.Service, ripgrep),
Context.make(SessionInstructions.Service, instructions),
Context.make(Shell.Service, shell),
Context.make(SkillV2.Service, skill),
Context.make(Tools.Service, tools),
Context.make(WebSearchTool.ConfigService, websearch),
Context.make(WellKnown.Service, wellknown),
)
})
@ -132,6 +138,7 @@ const pre = [
...SystemPromptPlugin.Plugins,
ModelsDevPlugin,
...ProviderPlugins,
...WebSearchPlugins,
PatchTool.Plugin,
EditTool.Plugin,
GlobTool.Plugin,
@ -153,6 +160,7 @@ const post = [
ConfigCommandPlugin.Plugin,
ConfigSkillPlugin.Plugin,
ConfigProviderPlugin.Plugin,
ConfigWebSearchPlugin.Plugin,
VariantPlugin.Plugin,
ConfigPolicyPlugin.Plugin,
] as const satisfies readonly InternalPlugin[]

View file

@ -12,6 +12,7 @@ import { AbsolutePath } from "@opencode-ai/schema/schema"
import { Session } from "@opencode-ai/schema/session"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Workspace } from "@opencode-ai/schema/workspace"
import { WebSearch } from "@opencode-ai/schema/websearch"
import { DateTime, Effect, Scope, Stream } from "effect"
import { Tool } from "../tool/tool"
@ -197,6 +198,31 @@ export function fromPromise(plugin: Plugin) {
hook: (name, callback) =>
register(host.tool.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
},
websearch: {
providers: (input) => run(host.websearch.providers(input)),
query: (input) =>
run(
host.websearch.query({
...input,
providerID: input.providerID === undefined ? undefined : WebSearch.ID.make(input.providerID),
}),
),
reload: () => run(host.websearch.reload()),
transform: (callback) =>
register(
host.websearch.transform((draft) => {
callback({
add: (definition) =>
draft.add({
id: definition.id,
name: definition.name,
execute: (input) => attempt((signal) => definition.execute(input, { signal })),
}),
default: draft.default,
})
}),
),
},
session: {
hook: (name, callback) =>
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
@ -270,6 +296,10 @@ export function fromPromise(plugin: Plugin) {
})
}
function attempt<A>(evaluate: (signal: AbortSignal) => PromiseLike<A>) {
return Effect.tryPromise({ try: evaluate, catch: (cause) => cause })
}
function model(input: { readonly id: string; readonly providerID: string; readonly variant?: string }) {
return Model.Ref.make({
id: Model.ID.make(input.id),

View file

@ -20,6 +20,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Image } from "../image"
import { Integration } from "../integration"
import { KV } from "../kv"
import { Location } from "../location"
import { LocationMutation } from "../location-mutation"
import { ModelsDev } from "../models-dev"
@ -34,7 +35,7 @@ import { Shell } from "../shell"
import { SkillV2 } from "../skill"
import { ReadToolFileSystem } from "../tool/read-filesystem"
import { ToolRegistry } from "../tool/registry"
import { WebSearchTool } from "../tool/websearch"
import { WebSearch } from "../websearch"
import { WellKnown } from "../wellknown"
import { PluginInternal } from "./internal"
import { PluginRuntime } from "./runtime"
@ -289,6 +290,7 @@ export const node = makeLocationNode({
httpClient,
Image.node,
Integration.node,
KV.node,
Location.node,
LocationMutation.node,
ModelsDev.node,
@ -303,7 +305,7 @@ export const node = makeLocationNode({
Shell.node,
SkillV2.node,
ToolRegistry.toolsNode,
WebSearchTool.configNode,
WebSearch.node,
WellKnown.node,
],
})

View file

@ -0,0 +1,82 @@
export * as WebSearchExa from "./exa"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Effect, Schema, Scope } from "effect"
import { HttpClient } from "effect/unstable/http"
import { WebSearchMcp } from "./mcp"
export const endpoint = "https://mcp.exa.ai/mcp"
const McpInput = Schema.Struct({
query: Schema.String,
numResults: Schema.Number.pipe(Schema.optional),
})
const McpOutput = Schema.Struct({
content: Schema.Array(
Schema.Struct({
type: Schema.Literal("text"),
text: Schema.String,
_meta: Schema.Struct({ searchTime: Schema.Number }).pipe(Schema.optional),
}),
),
})
export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
id: "opencode.websearch.exa",
effect: Effect.fn("WebSearchExa.Plugin")(function* (ctx) {
const http = yield* HttpClient.HttpClient
yield* ctx.integration.transform((draft) => {
draft.update("exa", (integration) => (integration.name = "Exa"))
draft.method.update({
integrationID: "exa",
method: { type: "key", label: "API key (optional)" },
})
draft.method.update({
integrationID: "exa",
method: { type: "env", names: ["EXA_API_KEY"] },
})
})
yield* ctx.websearch.transform((draft) => {
draft.add({
id: "exa",
name: "Exa",
execute: (input) =>
Effect.gen(function* () {
const connection = yield* ctx.integration.connection.active("exa")
const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined
const url = new URL(endpoint)
if (credential?.type === "key") url.searchParams.set("exaApiKey", credential.key)
const result = yield* WebSearchMcp.call(
http,
url.toString(),
"web_search_exa",
{ input: McpInput, output: McpOutput },
{ query: input.query, numResults: 8 },
)
const content = result?.content.find((item) => item.text)
return content ? parseResults(content.text) : []
}),
})
})
}),
})
function parseResults(text: string) {
return text.split(/\n\n---\n\n/).flatMap((block) => {
const url = block.match(/^URL:\s*(.+)$/m)?.[1]?.trim()
if (!url) return []
const title = block.match(/^Title:\s*(.+)$/m)?.[1]?.trim()
const publishedText = block.match(/^Published:\s*(.+)$/m)?.[1]?.trim()
const published = publishedText && publishedText !== "N/A" ? Date.parse(publishedText) : undefined
const content = block.match(/^(?:Highlights|Text):\s*\n?([\s\S]*)$/m)?.[1]?.trim()
return [
{
url,
...(title && title !== "N/A" ? { title } : {}),
...(content ? { content } : {}),
time: { ...(published !== undefined && Number.isFinite(published) ? { published } : {}) },
},
]
})
}

View file

@ -0,0 +1,4 @@
import { WebSearchExa } from "./exa"
import { WebSearchParallel } from "./parallel"
export const WebSearchPlugins = [WebSearchExa.Plugin, WebSearchParallel.Plugin] as const

View file

@ -0,0 +1,68 @@
export * as WebSearchMcp from "./mcp"
import { Duration, Effect, Schema } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { collectBoundedResponseBody } from "../../tool/http-body"
export const MAX_RESPONSE_BYTES = 256 * 1024
export const parseResponse = <F extends Schema.Struct.Fields>(body: string, result: Schema.Struct<F>) => {
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ result })))
const parse = (payload: string) => {
const trimmed = payload.trim()
if (!trimmed.startsWith("{")) return Effect.succeed(undefined)
return decode(trimmed).pipe(Effect.map((response) => response.result))
}
return Effect.gen(function* () {
const trimmed = body.trim()
const direct = trimmed ? yield* parse(trimmed) : undefined
if (direct) return direct
for (const line of body.split("\n")) {
if (!line.startsWith("data: ")) continue
const data = yield* parse(line.substring(6))
if (data) return data
}
})
}
export const call = <F extends Schema.Struct.Fields, R extends Schema.Struct.Fields>(
http: HttpClient.HttpClient,
url: string,
tool: string,
schema: { readonly input: Schema.Struct<F>; readonly output: Schema.Struct<R> },
value: Schema.Struct.Type<F>,
headers: Record<string, string> = {},
) =>
Effect.gen(function* () {
const request = yield* HttpClientRequest.post(url).pipe(
HttpClientRequest.accept("application/json, text/event-stream"),
HttpClientRequest.setHeaders(headers),
HttpClientRequest.schemaBodyJson(
Schema.Struct({
jsonrpc: Schema.Literal("2.0"),
id: Schema.Literal(1),
method: Schema.Literal("tools/call"),
params: Schema.Struct({ name: Schema.String, arguments: schema.input }),
}),
)({
jsonrpc: "2.0" as const,
id: 1 as const,
method: "tools/call" as const,
params: { name: tool, arguments: value },
}),
)
return yield* Effect.gen(function* () {
const response = yield* HttpClient.filterStatusOk(http).execute(request)
const body = yield* collectBoundedResponseBody(
response,
MAX_RESPONSE_BYTES,
() => new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`),
)
return yield* parseResponse(body.toString("utf8"), schema.output)
}).pipe(
Effect.timeoutOrElse({
duration: Duration.seconds(25),
orElse: () => Effect.fail(new Error(`${tool} request timed out`)),
}),
)
})

View file

@ -0,0 +1,103 @@
export * as WebSearchParallel from "./parallel"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Effect, Schema, Scope } from "effect"
import { HttpClient } from "effect/unstable/http"
import { App } from "../../app"
import { WebSearchMcp } from "./mcp"
export const endpoint = "https://search.parallel.ai/mcp"
const McpInput = Schema.Struct({
objective: Schema.String,
search_queries: Schema.Array(Schema.String),
model_name: Schema.String.check(Schema.isMaxLength(100)).pipe(Schema.optional),
})
const SearchResponse = Schema.Struct({
search_id: Schema.String,
results: Schema.Array(
Schema.Struct({
url: Schema.String,
title: Schema.NullOr(Schema.String).pipe(Schema.optional),
publish_date: Schema.NullOr(Schema.String).pipe(Schema.optional),
excerpts: Schema.Array(Schema.String),
}),
),
warnings: Schema.NullOr(
Schema.Array(
Schema.Struct({
type: Schema.Literals(["spec_validation_warning", "input_validation_warning", "warning"]),
message: Schema.String,
detail: Schema.NullOr(Schema.Record(Schema.String, Schema.Json)).pipe(Schema.optional),
}),
),
).pipe(Schema.optional),
usage: Schema.NullOr(
Schema.Array(
Schema.Struct({
name: Schema.String,
count: Schema.Int,
}),
),
).pipe(Schema.optional),
session_id: Schema.String,
})
const McpOutput = Schema.Struct({
content: Schema.Array(Schema.Struct({ type: Schema.Literal("text"), text: Schema.String })),
structuredContent: SearchResponse,
})
export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
id: "opencode.websearch.parallel",
effect: Effect.fn("WebSearchParallel.Plugin")(function* (ctx) {
const http = yield* HttpClient.HttpClient
yield* ctx.integration.transform((draft) => {
draft.update("parallel", (integration) => (integration.name = "Parallel"))
draft.method.update({
integrationID: "parallel",
method: { type: "key", label: "API key (optional)" },
})
draft.method.update({
integrationID: "parallel",
method: { type: "env", names: ["PARALLEL_API_KEY"] },
})
})
yield* ctx.websearch.transform((draft) => {
draft.add({
id: "parallel",
name: "Parallel",
execute: (input) =>
Effect.gen(function* () {
const connection = yield* ctx.integration.connection.active("parallel")
const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined
const result = yield* WebSearchMcp.call(
http,
endpoint,
"web_search",
{ input: McpInput, output: McpOutput },
{
objective: input.query,
search_queries: [input.query],
},
{
"User-Agent": App.useragent(ctx.app),
...(credential?.type === "key" ? { Authorization: `Bearer ${credential.key}` } : {}),
},
)
return (
result?.structuredContent.results.map((item) => {
const published = item.publish_date ? Date.parse(item.publish_date) : undefined
return {
url: item.url,
...(item.title ? { title: item.title } : {}),
...(item.excerpts.length ? { content: item.excerpts.join("\n\n") } : {}),
time: { ...(published !== undefined && Number.isFinite(published) ? { published } : {}) },
}
}) ?? []
)
}),
})
})
}),
})

View file

@ -2,262 +2,122 @@ export * as WebSearchTool from "./websearch"
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import { ToolFailure } from "@opencode-ai/ai"
import { Context, Duration, Effect, Layer, Schema } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { App } from "../app"
import { PositiveInt } from "../schema"
import { Effect, Schema } from "effect"
import { Form } from "../form"
import { KV } from "../kv"
import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
import { collectBoundedResponseBody } from "./http-body"
import { checksum } from "../util/encode"
import { WebSearch } from "../websearch"
export const name = "websearch"
export const NO_RESULTS = "No search results found. Please try a different query."
export const EXA_URL = "https://mcp.exa.ai/mcp"
export const PARALLEL_URL = "https://search.parallel.ai/mcp"
export const MAX_NUM_RESULTS = 20
export const MAX_CONTEXT_CHARACTERS = 50_000
export const MAX_RESPONSE_BYTES = 256 * 1024
/**
* Provider-independent local web search retained in V2 core for launch parity.
* This invokes the legacy Exa/Parallel product backends itself. It is distinct
* from provider-hosted web search tools, which remain route-owned and execute
* at the model provider. Ownership of this compromise can be revisited later.
*/
export const description = `Search the web using the session's local web search provider. Use this for current information beyond knowledge cutoff.
This is a provider-independent local tool backed by Exa or Parallel. Provider-hosted web search tools are separate and execute at the model provider.
Optional controls support result count, live crawling ('fallback' or 'preferred'), search type ('auto', 'fast', or 'deep'), and maximum context characters.
export const description = `Search the web using the user's selected search integration. Use this for current information beyond knowledge cutoff.
The current year is ${new Date().getFullYear()}. Use this year when searching for recent information or current events.`
export const Input = Schema.Struct({
query: Schema.String.annotate({ description: "Websearch query" }),
numResults: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_NUM_RESULTS))).annotate({
description: `Number of search results to return (default: 8, maximum: ${MAX_NUM_RESULTS})`,
}),
livecrawl: Schema.optional(Schema.Literals(["fallback", "preferred"])).annotate({
description:
"Live crawl mode - 'fallback': use live crawling as backup if cached unavailable, 'preferred': prioritize live crawling (default: 'fallback')",
}),
type: Schema.optional(Schema.Literals(["auto", "fast", "deep"])).annotate({
description: "Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search",
}),
contextMaxCharacters: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_CONTEXT_CHARACTERS))).annotate(
{
description: `Maximum characters for context string optimized for models (default: 10000, maximum: ${MAX_CONTEXT_CHARACTERS})`,
},
),
})
export const Provider = Schema.Literals(["exa", "parallel"])
export type Provider = typeof Provider.Type
export interface Config {
readonly provider?: Provider
readonly enableExa: boolean
readonly enableParallel: boolean
readonly exaApiKey?: string
readonly parallelApiKey?: string
}
export class ConfigService extends Context.Service<ConfigService, Config>()("@opencode/v2/WebSearchConfig") {}
/** Isolates the retained product environment contract from the generic tool implementation. */
export const defaultConfigLayer = Layer.sync(ConfigService, () =>
ConfigService.of({
provider:
process.env.OPENCODE_WEBSEARCH_PROVIDER === "exa" || process.env.OPENCODE_WEBSEARCH_PROVIDER === "parallel"
? process.env.OPENCODE_WEBSEARCH_PROVIDER
: undefined,
enableExa:
["1", "true"].includes(process.env.OPENCODE_EXPERIMENTAL?.toLowerCase() ?? "") ||
["1", "true"].includes(process.env.OPENCODE_ENABLE_EXA?.toLowerCase() ?? "") ||
["1", "true"].includes(process.env.OPENCODE_EXPERIMENTAL_EXA?.toLowerCase() ?? ""),
enableParallel:
["1", "true"].includes(process.env.OPENCODE_ENABLE_PARALLEL?.toLowerCase() ?? "") ||
["1", "true"].includes(process.env.OPENCODE_EXPERIMENTAL_PARALLEL?.toLowerCase() ?? ""),
exaApiKey: process.env.EXA_API_KEY,
parallelApiKey: process.env.PARALLEL_API_KEY,
}),
)
export const configNode = makeLocationNode({ service: ConfigService, layer: defaultConfigLayer, deps: [] })
export function selectProvider(
sessionID: string,
flags: Pick<Config, "enableExa" | "enableParallel"> = { enableExa: false, enableParallel: false },
override?: Provider,
): Provider {
if (override) return override
if (flags.enableParallel) return "parallel"
if (flags.enableExa) return "exa"
return Number.parseInt(checksum(sessionID) ?? "0", 36) % 2 === 0 ? "exa" : "parallel"
}
const McpResult = Schema.Struct({
result: Schema.Struct({
content: Schema.Array(Schema.Struct({ type: Schema.String, text: Schema.String })),
}),
})
const decodeMcpResult = Schema.decodeUnknownEffect(Schema.fromJsonString(McpResult))
const parsePayload = (payload: string) =>
Effect.gen(function* () {
const trimmed = payload.trim()
if (!trimmed.startsWith("{")) return undefined
return (yield* decodeMcpResult(trimmed)).result.content.find((item) => item.text)?.text
})
export const parseResponse = Effect.fn("WebSearchTool.parseResponse")(function* (body: string) {
const trimmed = body.trim()
const direct = trimmed ? yield* parsePayload(trimmed) : undefined
if (direct) return direct
for (const line of body.split("\n")) {
if (!line.startsWith("data: ")) continue
const data = yield* parsePayload(line.substring(6))
if (data) return data
}
return undefined
})
const ExaArgs = Schema.Struct({
query: Schema.String,
type: Schema.String,
numResults: Schema.Number,
livecrawl: Schema.String,
contextMaxCharacters: Schema.optional(Schema.Number),
})
const ParallelArgs = Schema.Struct({
objective: Schema.String,
search_queries: Schema.Array(Schema.String),
session_id: Schema.String,
})
const McpRequest = <F extends Schema.Struct.Fields>(args: Schema.Struct<F>) =>
Schema.Struct({
jsonrpc: Schema.Literal("2.0"),
id: Schema.Literal(1),
method: Schema.Literal("tools/call"),
params: Schema.Struct({ name: Schema.String, arguments: args }),
})
const exaUrl = (apiKey: string | undefined) => {
if (!apiKey) return EXA_URL
const url = new URL(EXA_URL)
url.searchParams.set("exaApiKey", apiKey)
return url.toString()
}
const callMcp = <F extends Schema.Struct.Fields>(
http: HttpClient.HttpClient,
url: string,
tool: string,
args: Schema.Struct<F>,
value: Schema.Struct.Type<F>,
headers: Record<string, string> = {},
) =>
Effect.gen(function* () {
const request = yield* HttpClientRequest.post(url).pipe(
HttpClientRequest.accept("application/json, text/event-stream"),
HttpClientRequest.setHeaders(headers),
HttpClientRequest.schemaBodyJson(McpRequest(args))({
jsonrpc: "2.0" as const,
id: 1 as const,
method: "tools/call" as const,
params: { name: tool, arguments: value },
}),
)
return yield* Effect.gen(function* () {
const response = yield* HttpClient.filterStatusOk(http).execute(request)
const body = yield* collectBoundedResponseBody(
response,
MAX_RESPONSE_BYTES,
() => new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`),
)
return yield* parseResponse(body.toString("utf8"))
}).pipe(
Effect.timeoutOrElse({
duration: Duration.seconds(25),
orElse: () => Effect.fail(new Error(`${tool} request timed out`)),
}),
)
})
const Output = Schema.Struct({
provider: Provider,
text: Schema.String,
provider: WebSearch.ID,
results: Schema.Array(WebSearch.Result),
})
export const Plugin = {
id: "opencode.tool.websearch",
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) {
const http = yield* HttpClient.HttpClient
const config = yield* ConfigService
const permission = yield* PermissionV2.Service
const forms = yield* Form.Service
const kv = yield* KV.Service
yield* ctx.tool
.transform((draft) =>
draft.add(
name,
Tool.make({
{
description,
input: Input,
output: Output,
execute: (input, context) => {
const provider = selectProvider(context.sessionID, config, config.provider)
return Effect.gen(function* () {
execute: (input, context) =>
Effect.gen(function* () {
yield* permission.assert({
action: name,
resources: [input.query],
save: ["*"],
metadata: { ...input, provider },
metadata: input,
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.messageID, callID: context.callID },
})
const text =
provider === "exa"
? yield* callMcp(http, exaUrl(config.exaApiKey), "web_search_exa", ExaArgs, {
query: input.query,
type: input.type || "auto",
numResults: input.numResults || 8,
livecrawl: input.livecrawl || "fallback",
contextMaxCharacters: input.contextMaxCharacters,
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
if (providers.length === 0) return yield* new WebSearch.ProviderRequiredError()
const response = yield* forms.ask({
sessionID: context.sessionID,
title: "Choose a web search provider",
metadata: { kind: "websearch.provider" },
fields: [
{
key: "provider",
title: "Provider",
description: "This becomes your default and can be changed later in configuration.",
type: "string",
required: true,
custom: false,
options: [
...providers.map((provider) => ({ value: provider.id, label: provider.name })),
{ value: "__disable__", label: "Disable web search" },
],
},
],
})
: yield* callMcp(
http,
PARALLEL_URL,
"web_search",
ParallelArgs,
{
objective: input.query,
search_queries: [input.query],
session_id: context.sessionID,
// V2 invocation context does not safely expose the model yet.
},
{
"User-Agent": App.useragent(ctx.app),
...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}),
},
)
if (response.status === "cancelled") return yield* Effect.fail(new Error("Web search cancelled"))
const answer = response.answer.provider
if (answer === "__disable__") {
yield* kv.set("websearch:provider", false)
return yield* new WebSearch.DisabledError()
}
if (typeof answer !== "string" || !providers.some((provider) => provider.id === answer))
return yield* new WebSearch.ProviderRequiredError()
yield* kv.set("websearch:provider", answer)
return yield* ctx.websearch.query(input)
})
}),
)
const output = {
provider,
text: text ?? NO_RESULTS,
provider: result.data.providerID,
results: result.data.results,
}
return { output, content: output.text, metadata: { provider: output.provider } }
const content = output.results.length
? output.results
.map((result) => {
const title = result.title ?? result.url
const published = result.time.published
? `\nPublished: ${new Date(result.time.published).toISOString()}`
: ""
return `## [${title}](${result.url})${published}${result.content ? `\n\n${result.content}` : ""}`
})
.join("\n\n")
: NO_RESULTS
return { output, content, metadata: { provider: output.provider } }
}).pipe(
Effect.mapError(
(error) => new ToolFailure({ message: `Unable to search the web for ${input.query}`, error }),
),
)
},
}),
),
},
{ codemode: false },
),
)
.pipe(Effect.orDie)
yield* ctx.session.hook("context", (event) =>
Effect.gen(function* () {
if ((yield* kv.get("websearch:provider")) === false) delete event.tools[name]
}),
)
}),
}

View file

@ -0,0 +1,144 @@
export * as WebSearch from "./websearch"
import { WebSearch } from "@opencode-ai/schema/websearch"
import { Context, Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { EventV2 } from "./event"
import { KV } from "./kv"
import { State } from "./state"
export const ID = WebSearch.ID
export type ID = WebSearch.ID
export const Provider = WebSearch.Provider
export type Provider = WebSearch.Provider
export const Event = WebSearch.Event
export const Input = WebSearch.Input
export type Input = WebSearch.Input
export type ProviderInput = WebSearch.ProviderInput
export const Result = WebSearch.Result
export type Result = WebSearch.Result
export const Response = WebSearch.Response
export type Response = WebSearch.Response
export interface ProviderImplementation extends Provider {
readonly execute: (input: ProviderInput) => Effect.Effect<readonly Result[], unknown>
}
export class ProviderRequiredError extends Schema.TaggedErrorClass<ProviderRequiredError>()(
"WebSearch.ProviderRequired",
{},
) {}
export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()(
"WebSearch.ProviderNotFound",
{
providerID: ID,
},
) {}
export class DisabledError extends Schema.TaggedErrorClass<DisabledError>()("WebSearch.Disabled", {}) {}
export class RequestError extends Schema.TaggedErrorClass<RequestError>()("WebSearch.Request", {
providerID: ID,
cause: Schema.Defect(),
}) {}
export type Error = ProviderRequiredError | ProviderNotFoundError | DisabledError | RequestError
export interface Interface extends State.Transformable<Draft> {
readonly providers: () => Effect.Effect<readonly Provider[]>
readonly default: () => Effect.Effect<Provider | undefined, DisabledError>
readonly query: (input: Input) => Effect.Effect<Response, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/WebSearch") {}
type Data = {
readonly providers: Map<ID, ProviderImplementation>
defaultProviderID?: ID
}
export type Draft = {
add: (provider: ProviderImplementation) => void
default: {
get: () => ID | undefined
set: (providerID: ID) => void
}
}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2.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),
},
}),
finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid),
})
const requireProvider = (providers: Map<ID, ProviderImplementation>, providerID: ID) => {
const provider = providers.get(providerID)
return provider ? Effect.succeed(provider) : Effect.fail(new ProviderNotFoundError({ providerID }))
}
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))
})
const resolve = Effect.fn("WebSearch.resolve")(function* (input: Input) {
const providers = state.get().providers
if (input.providerID) return yield* requireProvider(providers, input.providerID)
const provider = yield* defaultProvider()
if (!provider) return yield* new ProviderRequiredError()
return provider
})
return Service.of({
transform: state.transform,
reload: state.reload,
providers: Effect.fn("WebSearch.providers")(function* () {
return Array.from(state.get().providers.values(), (provider) => ({
id: provider.id,
name: provider.name,
})).toSorted((a, b) => a.name.localeCompare(b.name))
}),
default: Effect.fn("WebSearch.defaultInfo")(function* () {
const provider = yield* defaultProvider()
return provider && { id: provider.id, name: provider.name }
}),
query: Effect.fn("WebSearch.query")(function* (input) {
const provider = yield* resolve(input)
const results = yield* provider.execute({ query: input.query }).pipe(
Effect.flatMap(decodeResults),
Effect.mapError((cause) => new RequestError({ providerID: provider.id, cause })),
)
return new Response({ providerID: provider.id, results })
}),
})
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [EventV2.node, KV.node],
})

View file

@ -121,23 +121,15 @@ describe("Config", () => {
const config = yield* Config.Service
const entries = yield* config.entries()
expect(
entries.flatMap((entry) =>
entry.type === "document" && entry.info.shell ? [entry.info.shell] : [],
),
entries.flatMap((entry) => (entry.type === "document" && entry.info.shell ? [entry.info.shell] : [])),
).toEqual(["global", "explicit", "project", "content"])
expect(Config.latest(entries, "shell")).toBe("content")
}).pipe(
Effect.provide(
testLayer(
project,
global,
project,
undefined,
undefined,
emptyCredentialNode,
emptyWellknownNode,
{ file: explicit, content: JSON.stringify({ shell: "content" }) },
),
testLayer(project, global, project, undefined, undefined, emptyCredentialNode, emptyWellknownNode, {
file: explicit,
content: JSON.stringify({ shell: "content" }),
}),
),
),
),
@ -155,31 +147,24 @@ describe("Config", () => {
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
return Effect.promise(async () => {
await fs.mkdir(global, { recursive: true })
await fs.mkdir(project, { recursive: true })
await fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ shell: "global" }))
await fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ shell: "project" }))
}).pipe(
Effect.andThen(
Effect.gen(function* () {
const config = yield* Config.Service
expect(Config.latest(yield* config.entries(), "shell")).toBe("global")
}).pipe(
Effect.provide(
testLayer(
project,
global,
project,
undefined,
undefined,
emptyCredentialNode,
emptyWellknownNode,
{ project: false },
),
),
await fs.mkdir(global, { recursive: true })
await fs.mkdir(project, { recursive: true })
await fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ shell: "global" }))
await fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ shell: "project" }))
}).pipe(
Effect.andThen(
Effect.gen(function* () {
const config = yield* Config.Service
expect(Config.latest(yield* config.entries(), "shell")).toBe("global")
}).pipe(
Effect.provide(
testLayer(project, global, project, undefined, undefined, emptyCredentialNode, emptyWellknownNode, {
project: false,
}),
),
),
)
),
)
}),
),
)

View file

@ -53,13 +53,17 @@ export function waitForCodeModeTool(
* full plugin host. Only the tool domain is live; focused tool tests exercise
* registration, snapshots, and execution through the same path production uses.
*/
export const registerToolPlugin = <R>(plugin: {
readonly id: string
readonly effect: (context: PluginContext) => Effect.Effect<void, never, R>
}): Effect.Effect<void, never, R | Tools.Service | Scope.Scope> =>
export const registerToolPlugin = <R>(
plugin: {
readonly id: string
readonly effect: (context: PluginContext) => Effect.Effect<void, never, R>
},
overrides: Parameters<typeof host>[0] = {},
): Effect.Effect<void, never, R | Tools.Service | Scope.Scope> =>
Effect.gen(function* () {
const tools = yield* Tools.Service
const context = host({
...overrides,
session: {
hook: () => Effect.succeed({ dispose: Effect.void }),
},

View file

@ -2,6 +2,7 @@ import { AgentV2 } from "@opencode-ai/core/agent"
import { AISDK } from "@opencode-ai/core/aisdk"
import { Catalog } from "@opencode-ai/core/catalog"
import { CommandV2 } from "@opencode-ai/core/command"
import { Config } from "@opencode-ai/core/config"
import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
@ -9,6 +10,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Form } from "@opencode-ai/core/form"
import { Integration } from "@opencode-ai/core/integration"
import { Location } from "@opencode-ai/core/location"
import { Npm } from "@opencode-ai/util/npm"
@ -19,6 +21,7 @@ import { Reference } from "@opencode-ai/core/reference"
import { SkillV2 } from "@opencode-ai/core/skill"
import { ToolHooks } from "@opencode-ai/core/tool/hooks"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { WebSearch } from "@opencode-ai/core/websearch"
import { Effect, Layer } from "effect"
import { tempLocationLayer } from "../fixture/location"
@ -39,6 +42,7 @@ export const PluginTestLayer = AppNodeBuilder.build(
Npm.node,
Credential.node,
EventV2.node,
Form.node,
LayerNodePlatform.httpClient,
PluginV2.node,
AgentV2.node,
@ -52,9 +56,11 @@ export const PluginTestLayer = AppNodeBuilder.build(
SkillV2.node,
ToolHooks.node,
ToolRegistry.toolsNode,
WebSearch.node,
]),
[
[Location.node, tempLocationLayer],
[Npm.node, npmLayer],
[Config.node, Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))],
],
) as unknown as Layer.Layer<unknown, never>

View file

@ -1,11 +1,16 @@
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import { Plugin } from "@opencode-ai/plugin/v2/effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { Project } from "@opencode-ai/core/project"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { WebSearch } from "@opencode-ai/core/websearch"
import type {
CredentialOAuth,
IntegrationCommandMethod,
IntegrationEnvMethod,
IntegrationKeyMethod,
@ -13,11 +18,11 @@ import type {
} from "@opencode-ai/sdk/v2/types"
import { Effect, Stream } from "effect"
type Overrides = Partial<Omit<PluginContext, "options" | "session">> & {
readonly session?: Partial<PluginContext["session"]>
type Overrides = Partial<Omit<Plugin.Context, "options" | "session">> & {
readonly session?: Partial<Plugin.Context["session"]>
}
export function host(overrides: Overrides = {}): PluginContext {
export function host(overrides: Overrides = {}): Plugin.Context {
return {
app: overrides.app ?? { name: "test", version: "test", channel: "test" },
options: {},
@ -92,6 +97,12 @@ export function host(overrides: Overrides = {}): PluginContext {
transform: () => Effect.die("unused tool.transform"),
hook: () => Effect.die("unused tool.hook"),
},
websearch: overrides.websearch ?? {
providers: () => Effect.die("unused websearch.providers"),
query: () => Effect.die("unused websearch.query"),
transform: () => Effect.die("unused websearch.transform"),
reload: () => Effect.die("unused websearch.reload"),
},
session: {
hook: overrides.session?.hook ?? (() => Effect.die("unused session.hook")),
create: overrides.session?.create ?? (() => Effect.die("unused session.create")),
@ -105,7 +116,7 @@ export function host(overrides: Overrides = {}): PluginContext {
}
}
export function agentHost(agent: AgentV2.Interface): PluginContext["agent"] {
export function agentHost(agent: AgentV2.Interface): Plugin.Context["agent"] {
return {
get: (id) => agent.get(AgentV2.ID.make(id)).pipe(Effect.map((value) => value && agentInfo(value))),
list: () => Effect.die("unused agent.list"),
@ -131,7 +142,7 @@ export function agentHost(agent: AgentV2.Interface): PluginContext["agent"] {
}
}
export function catalogHost(catalog: Catalog.Interface): PluginContext["catalog"] {
export function catalogHost(catalog: Catalog.Interface): Plugin.Context["catalog"] {
return {
provider: {
list: () => Effect.die("unused catalog.provider.list"),
@ -207,7 +218,7 @@ export function catalogHost(catalog: Catalog.Interface): PluginContext["catalog"
}
}
export function integrationHost(integration: Integration.Interface): PluginContext["integration"] {
export function integrationHost(integration: Integration.Interface): Plugin.Context["integration"] {
return {
list: () => Effect.die("unused integration.list"),
get: () => Effect.die("unused integration.get"),
@ -329,6 +340,40 @@ export function integrationHost(integration: Integration.Interface): PluginConte
}
}
export function webSearchHost(websearch: WebSearch.Interface): Plugin.Context["websearch"] {
const location = Location.Info.make({
directory: AbsolutePath.make("/tmp/websearch-test"),
project: { id: Project.ID.make("websearch-test"), directory: AbsolutePath.make("/tmp/websearch-test") },
})
return {
providers: () => websearch.providers().pipe(Effect.map((data) => ({ location, data }))),
query: (input) =>
websearch
.query({ query: input.query, providerID: input.providerID && WebSearch.ID.make(input.providerID) })
.pipe(Effect.map((data) => ({ location, data }))),
reload: websearch.reload,
transform: (callback) =>
websearch.transform((draft) => {
callback({
add: (definition) =>
draft.add({
id: WebSearch.ID.make(definition.id),
name: definition.name,
execute: definition.execute,
}),
default: {
get: draft.default.get,
set: (providerID) => draft.default.set(WebSearch.ID.make(providerID)),
},
})
}),
}
}
function oauthCredential(value: CredentialOAuth) {
return Credential.OAuth.make({ ...value, methodID: Integration.MethodID.make(value.methodID) })
}
function method(value: Integration.Method) {
if (value.type === "env") return { type: value.type, names: [...value.names] }
if (value.type === "key") return { type: value.type, label: value.label }

View file

@ -8,6 +8,7 @@ import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
import { WebSearch } from "@opencode-ai/core/websearch"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionPending } from "@opencode-ai/core/session/pending"
@ -244,6 +245,38 @@ describe("fromPromise", () => {
}),
)
it.effect("registers a standalone web search provider", () =>
Effect.gen(function* () {
const websearch = yield* WebSearch.Service
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make(plugin)
const promisePlugin = Plugin.define({
id: "promise-websearch",
setup: async (ctx) => {
await ctx.websearch.transform((draft) => {
draft.add({
id: "promise-websearch",
name: "Promise Web Search",
execute: async (input) => [{ url: "https://example.com", content: `promise: ${input.query}`, time: {} }],
})
})
},
})
yield* PluginPromise.fromPromise(promisePlugin).effect(host)
expect(yield* websearch.providers()).toContainEqual({
id: WebSearch.ID.make("promise-websearch"),
name: "Promise Web Search",
})
expect(yield* websearch.query({ query: "effect", providerID: WebSearch.ID.make("promise-websearch") })).toEqual(
new WebSearch.Response({
providerID: WebSearch.ID.make("promise-websearch"),
results: [{ url: "https://example.com", content: "promise: effect", time: {} }],
}),
)
}),
)
it.effect("runs the setup cleanup when the plugin scope closes", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service

View file

@ -0,0 +1,50 @@
import { Effect, Layer } from "effect"
import { HttpClient, 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 { Credential } from "@opencode-ai/core/credential"
import { Config } from "@opencode-ai/core/config"
import { EventV2 } from "@opencode-ai/core/event"
import { Form } from "@opencode-ai/core/form"
import { Integration } from "@opencode-ai/core/integration"
import { WebSearch } from "@opencode-ai/core/websearch"
import { testEffect } from "../lib/effect"
interface WebSearchRequest {
readonly url: string
readonly headers: Record<string, string>
readonly body: unknown
}
export const requests: WebSearchRequest[] = []
let responseBody = ""
export function resetWebSearchFixture(body: string) {
requests.length = 0
responseBody = body
}
const http = Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.sync(() => {
if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`)
requests.push({
url: request.url,
headers: request.headers,
body: JSON.parse(new TextDecoder().decode(request.body.body)),
})
return HttpClientResponse.fromWeb(request, new Response(responseBody, { status: 200 }))
}),
),
)
export const webSearchIntegrationTest = testEffect(
Layer.merge(
AppNodeBuilder.build(
LayerNode.group([Integration.node, Credential.node, EventV2.node, Form.node, WebSearch.node]),
[[Config.node, Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))]],
),
http,
),
)

View file

@ -0,0 +1,170 @@
import { beforeEach, describe, expect } from "bun:test"
import { Effect } from "effect"
import { Integration } from "@opencode-ai/core/integration"
import { WebSearch } from "@opencode-ai/core/websearch"
import { WebSearchExa } from "@opencode-ai/core/plugin/websearch/exa"
import { WebSearchParallel } from "@opencode-ai/core/plugin/websearch/parallel"
import { host, integrationHost, webSearchHost } from "./host"
import { requests, resetWebSearchFixture, webSearchIntegrationTest } from "./websearch-fixture"
beforeEach(() => {
resetWebSearchFixture(
`event: message\ndata: ${JSON.stringify({
jsonrpc: "2.0",
id: 1,
result: {
content: [
{
type: "text",
text: "Title: Effect\nURL: https://effect.website\nPublished: 2026-07-25T00:00:00.000Z\nAuthor: N/A\nHighlights:\nEffect documentation",
_meta: { searchTime: 123 },
},
],
},
})}\n\n`,
)
})
const it = webSearchIntegrationTest
describe("built-in web search providers", () => {
it.effect("registers a provider without an integration", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const websearch = yield* WebSearch.Service
const registration = yield* webSearchHost(websearch).transform((draft) => {
draft.add({
id: "test-websearch",
name: "Test Web Search",
execute: (input) => Effect.succeed([{ url: "https://example.com", content: input.query, time: {} }]),
})
})
expect(yield* integrations.get(Integration.ID.make("test-websearch"))).toBeUndefined()
expect(yield* websearch.providers()).toContainEqual({
id: WebSearch.ID.make("test-websearch"),
name: "Test Web Search",
})
yield* registration.dispose
expect(yield* websearch.providers()).not.toContainEqual({
id: WebSearch.ID.make("test-websearch"),
name: "Test Web Search",
})
}),
)
it.effect("registers Exa with its MCP schema", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const websearch = yield* WebSearch.Service
yield* WebSearchExa.Plugin.effect(
host({ integration: integrationHost(integrations), websearch: webSearchHost(websearch) }),
)
const info = yield* integrations.get(Integration.ID.make("exa"))
expect(info).toMatchObject({
id: "exa",
name: "Exa",
methods: [{ type: "key" }, { type: "env", names: ["EXA_API_KEY"] }],
})
yield* integrations.connection.key({ integrationID: Integration.ID.make("exa"), key: "exa secret" })
expect(yield* websearch.query({ query: "effect typescript", providerID: WebSearch.ID.make("exa") })).toEqual(
new WebSearch.Response({
providerID: WebSearch.ID.make("exa"),
results: [
{
url: "https://effect.website",
title: "Effect",
content: "Effect documentation",
time: { published: Date.parse("2026-07-25T00:00:00.000Z") },
},
],
}),
)
expect(requests).toEqual([
{
url: `${WebSearchExa.endpoint}?exaApiKey=exa+secret`,
headers: expect.any(Object),
body: {
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: {
name: "web_search_exa",
arguments: { query: "effect typescript", numResults: 8 },
},
},
},
])
}),
)
it.effect("registers Parallel and keeps its credential in the authorization header", () =>
Effect.gen(function* () {
resetWebSearchFixture(
JSON.stringify({
jsonrpc: "2.0",
id: 1,
result: {
content: [{ type: "text", text: "search results" }],
structuredContent: {
search_id: "search_1",
results: [
{
url: "https://effect.website",
title: "Effect",
publish_date: null,
excerpts: ["Effect documentation"],
},
],
warnings: null,
usage: [{ name: "sku_search", count: 1 }],
session_id: "ses_parallel",
},
},
}),
)
const integrations = yield* Integration.Service
const websearch = yield* WebSearch.Service
yield* WebSearchParallel.Plugin.effect(
host({ integration: integrationHost(integrations), websearch: webSearchHost(websearch) }),
)
yield* integrations.connection.key({ integrationID: Integration.ID.make("parallel"), key: "parallel-secret" })
const output = yield* websearch.query({
query: "effect layers",
providerID: WebSearch.ID.make("parallel"),
})
expect(output).toEqual(
new WebSearch.Response({
providerID: WebSearch.ID.make("parallel"),
results: [
{
url: "https://effect.website",
title: "Effect",
content: "Effect documentation",
time: {},
},
],
}),
)
expect(requests[0]).toMatchObject({
url: WebSearchParallel.endpoint,
headers: { authorization: "Bearer parallel-secret" },
body: {
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: {
name: "web_search",
arguments: {
objective: "effect layers",
search_queries: ["effect layers"],
},
},
},
})
expect(JSON.stringify(output)).not.toContain("parallel-secret")
}),
)
})

View file

@ -1,10 +1,11 @@
import { beforeEach, describe, expect, test } from "bun:test"
import { Effect, Layer, Schema } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { beforeEach, describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
import { PermissionV2 } 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 { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { WebSearchTool } from "@opencode-ai/core/tool/websearch"
@ -14,93 +15,36 @@ import { Image } from "@opencode-ai/core/image"
import { testEffect } from "./lib/effect"
import { imagePassthrough } from "./lib/image"
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
import { webSearchHost } from "./plugin/host"
const webSearchToolNode = makeLocationNode({
name: "test/websearch-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(WebSearchTool.Plugin)),
deps: [ToolRegistry.toolsNode, PermissionV2.node, LayerNodePlatform.httpClient, WebSearchTool.configNode],
layer: Layer.effectDiscard(
Effect.gen(function* () {
const websearch = yield* WebSearch.Service
yield* registerToolPlugin(WebSearchTool.Plugin, { websearch: webSearchHost(websearch) })
}),
),
deps: [ToolRegistry.toolsNode, PermissionV2.node, WebSearch.node, Form.node, KV.node],
})
const sessionID = SessionV2.ID.make("ses_websearch_test")
const payload = (text: string) =>
JSON.stringify({
jsonrpc: "2.0",
id: 1,
result: { content: [{ type: "text", text }] },
})
describe("WebSearchTool provider selection", () => {
test("rejects out-of-range numeric controls", () => {
const decode = Schema.decodeUnknownSync(WebSearchTool.Input)
expect(() => decode({ query: "x", numResults: 0 })).toThrow()
expect(() => decode({ query: "x", numResults: WebSearchTool.MAX_NUM_RESULTS + 1 })).toThrow()
expect(() => decode({ query: "x", contextMaxCharacters: WebSearchTool.MAX_CONTEXT_CHARACTERS + 1 })).toThrow()
})
test("selects a stable provider per session", () => {
expect(WebSearchTool.selectProvider(sessionID)).toBe(WebSearchTool.selectProvider(sessionID))
})
test("supports an explicit operational override", () => {
expect(WebSearchTool.selectProvider(sessionID, { enableExa: false, enableParallel: false }, "parallel")).toBe(
"parallel",
)
expect(WebSearchTool.selectProvider(sessionID, { enableExa: false, enableParallel: false }, "exa")).toBe("exa")
})
test("prefers Parallel when both explicit flags are enabled", () => {
expect(WebSearchTool.selectProvider(sessionID, { enableExa: true, enableParallel: true })).toBe("parallel")
})
test("prefers Exa when only its explicit flag is enabled", () => {
expect(WebSearchTool.selectProvider(sessionID, { enableExa: true, enableParallel: false })).toBe("exa")
})
})
describe("WebSearchTool MCP response parser", () => {
test("parses plain JSON-RPC responses", async () => {
expect(await Effect.runPromise(WebSearchTool.parseResponse(payload("search results")))).toBe("search results")
})
test("parses SSE JSON-RPC responses and ignores non-JSON frames", async () => {
expect(
await Effect.runPromise(
WebSearchTool.parseResponse(`data: [DONE]\nevent: message\ndata: ${payload("search results")}\n\n`),
),
).toBe("search results")
})
})
interface Request {
readonly url: string
readonly headers: Record<string, string>
readonly body: unknown
}
const requests: Request[] = []
const assertions: PermissionV2.AssertInput[] = []
let responseBody = payload("search results")
let makeResponse = () => new Response(responseBody, { status: 200 })
let config: WebSearchTool.Config = { enableExa: false, enableParallel: false }
const queries: WebSearch.Input[] = []
let result = new WebSearch.Response({
providerID: WebSearch.ID.make("exa"),
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
})
beforeEach(() => {
responseBody = payload("search results")
makeResponse = () => new Response(responseBody, { status: 200 })
assertions.length = 0
queries.length = 0
result = new WebSearch.Response({
providerID: WebSearch.ID.make("exa"),
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
})
})
const http = Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.sync(() => {
if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`)
requests.push({
url: request.url,
headers: request.headers,
body: JSON.parse(new TextDecoder().decode(request.body.body)),
})
return HttpClientResponse.fromWeb(request, makeResponse())
}),
),
)
const permission = Layer.succeed(
PermissionV2.Service,
PermissionV2.Service.of({
@ -112,33 +56,48 @@ const permission = Layer.succeed(
list: () => Effect.die("unused"),
}),
)
const websearchConfig = Layer.succeed(
WebSearchTool.ConfigService,
WebSearchTool.ConfigService.of({
get provider() {
return config.provider
},
get enableExa() {
return config.enableExa
},
get enableParallel() {
return config.enableParallel
},
get exaApiKey() {
return config.exaApiKey
},
get parallelApiKey() {
return config.parallelApiKey
},
const websearch = Layer.succeed(
WebSearch.Service,
WebSearch.Service.of({
transform: () => Effect.die("unused"),
reload: () => Effect.die("unused"),
providers: () => Effect.succeed([]),
default: () => Effect.succeed(undefined),
query: (input) =>
Effect.sync(() => {
queries.push(input)
return result
}),
}),
)
const form = Layer.succeed(
Form.Service,
Form.Service.of({
create: () => Effect.die("unused"),
ask: () => Effect.die("unused"),
get: () => Effect.die("unused"),
list: () => Effect.die("unused"),
state: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
cancel: () => Effect.die("unused"),
}),
)
const kv = Layer.succeed(
KV.Service,
KV.Service.of({
get: () => Effect.succeed(undefined),
set: () => Effect.void,
remove: () => Effect.void,
}),
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebSearchTool.configNode, webSearchToolNode]),
LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebSearch.node, webSearchToolNode]),
[
[PermissionV2.node, permission],
[LayerNodePlatform.httpClient, http],
[WebSearchTool.configNode, websearchConfig],
[WebSearch.node, websearch],
[Form.node, form],
[KV.node, kv],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
[Image.node, imagePassthrough],
],
@ -146,12 +105,8 @@ const it = testEffect(
)
describe("WebSearchTool registration", () => {
it.effect("registers websearch, asserts query permission, and calls Exa", () =>
it.effect("asserts permission before delegating to WebSearch", () =>
Effect.gen(function* () {
requests.length = 0
assertions.length = 0
responseBody = payload("exa results")
config = { provider: "exa", enableExa: false, enableParallel: false }
const registry = yield* ToolRegistry.Service
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["websearch", "execute"])
@ -161,20 +116,14 @@ describe("WebSearchTool registration", () => {
...toolIdentity,
call: {
type: "tool-call",
id: "call-exa",
id: "call-search",
name: "websearch",
input: {
query: "effect typescript",
numResults: 3,
livecrawl: "preferred",
type: "fast",
contextMaxCharacters: 2500,
},
input: { query: "effect typescript" },
},
}),
).toMatchObject({
status: "completed",
content: [{ type: "text", text: "exa results" }],
content: [{ type: "text", text: "## [Search results](https://example.com)\n\nsearch results" }],
})
expect(assertions).toMatchObject([
{
@ -182,103 +131,65 @@ describe("WebSearchTool registration", () => {
action: "websearch",
resources: ["effect typescript"],
save: ["*"],
metadata: {
query: "effect typescript",
numResults: 3,
livecrawl: "preferred",
type: "fast",
contextMaxCharacters: 2500,
provider: "exa",
},
metadata: { query: "effect typescript" },
},
])
expect(requests).toEqual([
expect(queries).toEqual([
{
url: WebSearchTool.EXA_URL,
headers: expect.any(Object),
body: {
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: {
name: "web_search_exa",
arguments: {
query: "effect typescript",
type: "fast",
numResults: 3,
livecrawl: "preferred",
contextMaxCharacters: 2500,
},
},
},
query: "effect typescript",
},
])
}),
)
it.effect("calls Parallel with session ID and keeps bearer credentials out of output", () =>
it.effect("keeps normalized results in structured output", () =>
Effect.gen(function* () {
requests.length = 0
assertions.length = 0
responseBody = payload("parallel results")
config = { provider: "parallel", enableExa: false, enableParallel: false, parallelApiKey: "parallel-secret" }
result = new WebSearch.Response({
providerID: WebSearch.ID.make("parallel"),
results: [
{
url: "https://effect.website",
title: "Effect",
content: "parallel results",
time: { published: Date.parse("2026-07-25T00:00:00.000Z") },
},
],
})
const registry = yield* ToolRegistry.Service
const settled = yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-parallel", name: "websearch", input: { query: "effect layers" } },
})
expect(requests[0]).toMatchObject({
url: WebSearchTool.PARALLEL_URL,
headers: { authorization: "Bearer parallel-secret" },
body: {
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: {
name: "web_search",
arguments: { objective: "effect layers", search_queries: ["effect layers"], session_id: sessionID },
},
},
})
expect(requests[0]?.body).not.toHaveProperty("params.arguments.model_name")
expect(settled).toEqual({
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-parallel", name: "websearch", input: { query: "effect layers" } },
}),
).toEqual({
status: "completed",
output: { provider: "parallel", text: "parallel results" },
content: [{ type: "text", text: "parallel results" }],
output: {
provider: "parallel",
results: [
{
url: "https://effect.website",
title: "Effect",
content: "parallel results",
time: { published: Date.parse("2026-07-25T00:00:00.000Z") },
},
],
},
content: [
{
type: "text",
text: "## [Effect](https://effect.website)\nPublished: 2026-07-25T00:00:00.000Z\n\nparallel results",
},
],
metadata: { provider: "parallel" },
})
expect(JSON.stringify(settled)).not.toContain("parallel-secret")
}),
)
it.effect("keeps an Exa credential in the transport URL and out of model output", () =>
it.effect("uses the concise no-results fallback", () =>
Effect.gen(function* () {
requests.length = 0
assertions.length = 0
responseBody = payload("credentialed exa results")
config = { provider: "exa", enableExa: false, enableParallel: false, exaApiKey: "exa secret" }
const registry = yield* ToolRegistry.Service
const settled = yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-exa-key", name: "websearch", input: { query: "effect schema" } },
})
expect(requests[0]?.url).toBe(`${WebSearchTool.EXA_URL}?exaApiKey=exa+secret`)
expect(JSON.stringify(settled)).not.toContain("exa secret")
}),
)
it.effect("returns the legacy no-results fallback as concise model text", () =>
Effect.gen(function* () {
requests.length = 0
assertions.length = 0
responseBody = ""
config = { provider: "exa", enableExa: false, enableParallel: false }
result = new WebSearch.Response({ providerID: WebSearch.ID.make("exa"), results: [] })
const registry = yield* ToolRegistry.Service
expect(
@ -293,44 +204,4 @@ describe("WebSearchTool registration", () => {
})
}),
)
it.effect("rejects oversized MCP response bodies", () =>
Effect.gen(function* () {
requests.length = 0
assertions.length = 0
let chunksRead = 0
let cancelled = false
makeResponse = () =>
new Response(
new ReadableStream({
pull(controller) {
chunksRead++
if (chunksRead === 10) throw new Error("response was not stopped at the byte limit")
controller.enqueue(new Uint8Array(64 * 1024))
},
cancel() {
cancelled = true
},
}),
{ status: 200 },
)
config = { provider: "exa", enableExa: false, enableParallel: false }
const registry = yield* ToolRegistry.Service
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-large-response", name: "websearch", input: { query: "too much" } },
}),
// toSessionError unwraps the "Unable to search the web for <query>" ToolFailure
// to its byte-limit cause message.
).toEqual({
status: "error",
error: { type: "unknown", message: expect.stringContaining("response exceeded") },
})
expect(chunksRead).toBeLessThan(10)
expect(cancelled).toBe(true)
}),
)
})

View file

@ -0,0 +1,129 @@
import { describe, expect } from "bun:test"
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 { EventV2 } from "@opencode-ai/core/event"
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, EventV2.node, KV.node])))
const register = (id: string) =>
Effect.gen(function* () {
const websearch = yield* WebSearch.Service
const providerID = WebSearch.ID.make(id)
const calls: WebSearch.ProviderInput[] = []
yield* websearch.transform((draft) => {
draft.add({
id: providerID,
name: id.toUpperCase(),
execute: (input) =>
Effect.sync(() => {
calls.push(input)
return [
{
url: `https://${id}.example.com`,
title: input.query,
content: `${id}: ${input.query}`,
time: {},
},
]
}),
})
})
return { providerID, calls }
})
describe("WebSearch", () => {
it.effect("executes an explicit provider without changing the default", () =>
Effect.gen(function* () {
yield* register("exa")
const parallel = yield* register("parallel")
const websearch = yield* WebSearch.Service
expect(yield* websearch.query({ query: "effect", providerID: parallel.providerID })).toEqual(
new WebSearch.Response({
providerID: parallel.providerID,
results: [
{
url: "https://parallel.example.com",
title: "effect",
content: "parallel: effect",
time: {},
},
],
}),
)
expect((yield* websearch.query({ query: "default" }).pipe(Effect.flip))._tag).toBe("WebSearch.ProviderRequired")
expect(parallel.calls).toEqual([{ query: "effect" }])
}),
)
it.effect("requires a provider when no default is set", () =>
Effect.gen(function* () {
yield* register("exa")
yield* register("parallel")
const websearch = yield* WebSearch.Service
expect((yield* websearch.query({ query: "layers" }).pipe(Effect.flip))._tag).toBe("WebSearch.ProviderRequired")
}),
)
it.effect("uses the default set by a transform", () =>
Effect.gen(function* () {
yield* register("exa")
const parallel = yield* register("parallel")
const websearch = yield* WebSearch.Service
yield* websearch.transform((draft) => draft.default.set(parallel.providerID))
expect((yield* websearch.query({ query: "configured" })).providerID).toBe(parallel.providerID)
}),
)
it.effect("uses the provider stored in KV", () =>
Effect.gen(function* () {
yield* register("exa")
const parallel = yield* register("parallel")
const websearch = yield* WebSearch.Service
const kv = yield* KV.Service
yield* kv.set("websearch:provider", parallel.providerID)
expect((yield* websearch.query({ query: "stored" })).providerID).toBe(parallel.providerID)
yield* kv.remove("websearch:provider")
}),
)
it.effect("fails when web search is explicitly disabled", () =>
Effect.gen(function* () {
yield* register("exa")
const websearch = yield* WebSearch.Service
const kv = yield* KV.Service
yield* kv.set("websearch:provider", false)
expect((yield* websearch.query({ query: "disabled" }).pipe(Effect.flip))._tag).toBe("WebSearch.Disabled")
yield* kv.remove("websearch:provider")
}),
)
it.effect("falls back when the configured default is unavailable", () =>
Effect.gen(function* () {
yield* register("exa")
const websearch = yield* WebSearch.Service
yield* websearch.transform((draft) => draft.default.set(WebSearch.ID.make("missing")))
expect((yield* websearch.query({ query: "fallback" }).pipe(Effect.flip))._tag).toBe("WebSearch.ProviderRequired")
}),
)
it.effect("removes scoped provider registrations", () =>
Effect.gen(function* () {
const websearch = yield* WebSearch.Service
const scope = yield* Scope.fork(yield* Scope.Scope)
const provider = yield* register("temporary").pipe(Scope.provide(scope))
expect(yield* websearch.providers()).toContainEqual({ id: provider.providerID, name: "TEMPORARY" })
yield* Scope.close(scope, Exit.void)
expect(yield* websearch.providers()).not.toContainEqual({ id: provider.providerID, name: "TEMPORARY" })
}),
)
})