refactor(websearch): rename search domain

This commit is contained in:
Shoubhit Dash 2026-07-08 17:27:31 +05:30
commit c86f4e9b9b
51 changed files with 478 additions and 462 deletions

View file

@ -24,7 +24,7 @@ import { ConfigModel } from "./config/model"
import { ConfigPlugin } from "./config/plugin"
import { ConfigProvider } from "./config/provider"
import { ConfigReference } from "./config/reference"
import { ConfigSearch } from "./config/search"
import { ConfigWebSearch } from "./config/websearch"
import { ConfigToolOutput } from "./config/tool-output"
import { ConfigVariable } from "./config/variable"
import { ConfigWatcher } from "./config/watcher"
@ -104,7 +104,7 @@ 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",
}),
search: ConfigSearch.Info.pipe(Schema.optional).annotate({
websearch: ConfigWebSearch.Info.pipe(Schema.optional).annotate({
description: "Web search provider selection",
}),
plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({

View file

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

View file

@ -16,7 +16,7 @@ import {
Types,
} from "effect"
import { Integration } from "@opencode-ai/schema/integration"
import { Search } from "@opencode-ai/schema/search"
import { WebSearch } from "@opencode-ai/schema/websearch"
import { Credential } from "./credential"
import { State } from "./state"
import { EventV2 } from "./event"
@ -95,13 +95,13 @@ export interface EnvImplementation {
export type Implementation = OAuthImplementation | KeyImplementation | EnvImplementation
export interface SearchImplementation {
export interface WebSearchImplementation {
readonly integrationID: ID
readonly connection: Integration.Search["connection"]
readonly connection: Integration.WebSearch["connection"]
readonly execute: (
input: Search.Input,
input: WebSearch.Input,
context: { readonly credential?: Credential.Value; readonly sessionID?: string },
) => Effect.Effect<Search.ProviderOutput, unknown>
) => Effect.Effect<WebSearch.ProviderOutput, unknown>
}
export const Attempt = Integration.Attempt
@ -129,7 +129,7 @@ type Entry = {
ref: Types.DeepMutable<Ref>
methods: Types.DeepMutable<Method>[]
implementations: Map<MethodID, Types.DeepMutable<OAuthImplementation>>
search?: Types.DeepMutable<SearchImplementation>
websearch?: Types.DeepMutable<WebSearchImplementation>
}
type Data = {
@ -146,9 +146,9 @@ export type Draft = {
update: (implementation: Implementation) => void
remove: (integrationID: ID, method: Method) => void
}
search: {
list: () => readonly SearchImplementation[]
update: (implementation: SearchImplementation) => void
websearch: {
list: () => readonly WebSearchImplementation[]
update: (implementation: WebSearchImplementation) => void
remove: (integrationID: ID) => void
}
}
@ -207,9 +207,9 @@ export interface Interface extends State.Transformable<Draft> {
/** Cancels an attempt and releases its resources. */
readonly cancel: (attemptID: AttemptID) => Effect.Effect<void>
}
readonly search: {
readonly list: () => Effect.Effect<readonly SearchImplementation[]>
readonly get: (integrationID: ID) => Effect.Effect<SearchImplementation | undefined>
readonly websearch: {
readonly list: () => Effect.Effect<readonly WebSearchImplementation[]>
readonly get: (integrationID: ID) => Effect.Effect<WebSearchImplementation | undefined>
}
}
@ -301,10 +301,10 @@ const layer = Layer.effect(
if (method.type === "oauth") current.implementations.delete(method.id)
},
},
search: {
websearch: {
list: () =>
Array.from(draft.integrations.values()).flatMap((entry) =>
entry.search ? [entry.search as SearchImplementation] : [],
entry.websearch ? [entry.websearch as WebSearchImplementation] : [],
),
update: (implementation) => {
const current = draft.integrations.get(implementation.integrationID) ?? {
@ -318,11 +318,11 @@ const layer = Layer.effect(
if (!draft.integrations.has(implementation.integrationID)) {
draft.integrations.set(implementation.integrationID, current)
}
current.search = implementation as Types.DeepMutable<SearchImplementation>
current.websearch = implementation as Types.DeepMutable<WebSearchImplementation>
},
remove: (integrationID) => {
const current = draft.integrations.get(integrationID)
if (current) delete current.search
if (current) delete current.websearch
},
},
}),
@ -349,7 +349,7 @@ const layer = Layer.effect(
id: entry.ref.id,
name: entry.ref.name,
methods: entry.methods,
search: entry.search ? { connection: entry.search.connection } : undefined,
websearch: entry.websearch ? { connection: entry.websearch.connection } : undefined,
connections,
})
@ -559,14 +559,14 @@ const layer = Layer.effect(
if (attempt) yield* Scope.close(attempt.scope, Exit.void)
}),
},
search: {
list: Effect.fn("Integration.search.list")(function* () {
websearch: {
list: Effect.fn("Integration.websearch.list")(function* () {
return Array.from(state.get().integrations.values()).flatMap((entry) =>
entry.search ? [entry.search as SearchImplementation] : [],
entry.websearch ? [entry.websearch as WebSearchImplementation] : [],
)
}),
get: Effect.fn("Integration.search.get")(function* (integrationID) {
return state.get().integrations.get(integrationID)?.search as SearchImplementation | undefined
get: Effect.fn("Integration.websearch.get")(function* (integrationID) {
return state.get().integrations.get(integrationID)?.websearch as WebSearchImplementation | undefined
}),
},
})

View file

@ -34,7 +34,7 @@ import { Pty } from "./pty"
import { QuestionV2 } from "./question"
import { Shell } from "./shell"
import { Reference } from "./reference"
import { Search } from "./search"
import { WebSearch } from "./websearch"
import { ReferenceGuidance } from "./reference/guidance"
import { Ripgrep } from "./ripgrep"
import { SessionRunnerLLM } from "./session/runner/llm"
@ -84,7 +84,7 @@ const pluginSupervisorNode = makeLocationNode({
Form.node,
ReadToolFileSystem.node,
Reference.node,
Search.node,
WebSearch.node,
Ripgrep.node,
SessionInstructions.node,
SessionTodo.node,
@ -100,7 +100,7 @@ const locationServiceNodes = [
AgentV2.node,
CommandV2.node,
Reference.node,
Search.node,
WebSearch.node,
Integration.node,
Catalog.node,
AISDK.node,

View file

@ -345,11 +345,11 @@ function registerIntegration(draft: Integration.Draft, definition: IntegrationDe
}),
)
}
if (!definition.search) return
draft.search.update({
if (!definition.websearch) return
draft.websearch.update({
integrationID,
connection: definition.search.connection,
execute: definition.search.execute,
connection: definition.websearch.connection,
execute: definition.websearch.execute,
})
}

View file

@ -26,7 +26,7 @@ import { ModelsDev } from "../models-dev"
import { Npm } from "../npm"
import { PermissionV2 } from "../permission"
import { Reference } from "../reference"
import { Search } from "../search"
import { WebSearch } from "../websearch"
import { Ripgrep } from "../ripgrep"
import { SessionInstructions } from "../session/instructions"
import { SessionTodo } from "../session/todo"
@ -51,7 +51,7 @@ import { AgentPlugin } from "./agent"
import { CommandPlugin } from "./command"
import { ModelsDevPlugin } from "./models-dev"
import { ProviderPlugins } from "./provider"
import { SearchPlugins } from "./search"
import { WebSearchPlugins } from "./websearch"
import { PluginRuntime } from "./runtime"
import { SkillPlugin } from "./skill"
import { VariantPlugin } from "./variant"
@ -78,7 +78,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const form = yield* Form.Service
const read = yield* ReadToolFileSystem.Service
const reference = yield* Reference.Service
const search = yield* Search.Service
const websearch = yield* WebSearch.Service
const ripgrep = yield* Ripgrep.Service
const instructions = yield* SessionInstructions.Service
const todo = yield* SessionTodo.Service
@ -107,7 +107,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(Form.Service, form),
Context.make(ReadToolFileSystem.Service, read),
Context.make(Reference.Service, reference),
Context.make(Search.Service, search),
Context.make(WebSearch.Service, websearch),
Context.make(Ripgrep.Service, ripgrep),
Context.make(SessionInstructions.Service, instructions),
Context.make(SessionTodo.Service, todo),
@ -129,7 +129,7 @@ const pre = [
SkillPlugin.Plugin,
ModelsDevPlugin,
...ProviderPlugins,
...SearchPlugins,
...WebSearchPlugins,
PatchTool.Plugin,
EditTool.Plugin,
GlobTool.Plugin,

View file

@ -131,7 +131,7 @@ export function fromPromise(plugin: PromisePlugin) {
}
function adaptIntegration(definition: IntegrationDefinition) {
const { methods, search, ...definitionInfo } = definition
const { methods, websearch, ...definitionInfo } = definition
return {
...definitionInfo,
methods: methods?.map((method) => {
@ -163,16 +163,16 @@ function adaptIntegration(definition: IntegrationDefinition) {
: {}),
}
}),
...(search
...(websearch
? {
search: {
connection: search.connection,
websearch: {
connection: websearch.connection,
execute: (
input: Parameters<typeof search.execute>[0],
execution: Omit<Parameters<typeof search.execute>[1], "signal">,
input: Parameters<typeof websearch.execute>[0],
execution: Omit<Parameters<typeof websearch.execute>[1], "signal">,
) =>
Effect.tryPromise({
try: (signal) => search.execute(input, { ...execution, signal }),
try: (signal) => websearch.execute(input, { ...execution, signal }),
catch: (cause) => cause,
}),
},

View file

@ -1,4 +0,0 @@
import { SearchExa } from "./exa"
import { SearchParallel } from "./parallel"
export const SearchPlugins = [SearchExa.Plugin, SearchParallel.Plugin] as const

View file

@ -1,9 +1,9 @@
export * as SearchExa from "./exa"
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 { SearchMcp } from "./mcp"
import { WebSearchMcp } from "./mcp"
export const endpoint = "https://mcp.exa.ai/mcp"
@ -23,8 +23,8 @@ const Output = Schema.Struct({
})
export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
id: "opencode.search.exa",
effect: Effect.fn("SearchExa.Plugin")(function* (ctx) {
id: "opencode.websearch.exa",
effect: Effect.fn("WebSearchExa.Plugin")(function* (ctx) {
const http = yield* HttpClient.HttpClient
yield* ctx.integration.register({
id: "exa",
@ -33,17 +33,17 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
{ type: "key", label: "API key (optional)" },
{ type: "env", names: ["EXA_API_KEY"] },
],
search: {
websearch: {
connection: "optional",
execute: (input, context) => {
const url = new URL(endpoint)
if (context.credential?.type === "key") url.searchParams.set("exaApiKey", context.credential.key)
return SearchMcp.call(
return WebSearchMcp.call(
http,
url.toString(),
"web_search_exa",
{ input: Input, output: Output },
{ query: input.query },
{ query: input.query, numResults: 8 },
).pipe(
Effect.map((result) => {
const content = result?.content.find((item) => item.text)

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

@ -1,4 +1,4 @@
export * as SearchMcp from "./mcp"
export * as WebSearchMcp from "./mcp"
import { Duration, Effect, Schema } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"

View file

@ -1,10 +1,10 @@
export * as SearchParallel from "./parallel"
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 { InstallationVersion } from "../../installation/version"
import { SearchMcp } from "./mcp"
import { WebSearchMcp } from "./mcp"
export const endpoint = "https://search.parallel.ai/mcp"
@ -50,8 +50,8 @@ const Output = Schema.Struct({
})
export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
id: "opencode.search.parallel",
effect: Effect.fn("SearchParallel.Plugin")(function* (ctx) {
id: "opencode.websearch.parallel",
effect: Effect.fn("WebSearchParallel.Plugin")(function* (ctx) {
const http = yield* HttpClient.HttpClient
yield* ctx.integration.register({
id: "parallel",
@ -60,10 +60,10 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
{ type: "key", label: "API key (optional)" },
{ type: "env", names: ["PARALLEL_API_KEY"] },
],
search: {
websearch: {
connection: "optional",
execute: (input, context) =>
SearchMcp.call(
WebSearchMcp.call(
http,
endpoint,
"web_search",

View file

@ -5,7 +5,7 @@ import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Schema } from "effect"
import { Integration } from "../integration"
import { PermissionV2 } from "../permission"
import { Search } from "../search"
import { WebSearch } from "../websearch"
import { Tool } from "./tool"
export const name = "websearch"
@ -29,7 +29,7 @@ export const Plugin = {
id: "opencode.tool.websearch",
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) {
const permission = yield* PermissionV2.Service
const search = yield* Search.Service
const websearch = yield* WebSearch.Service
yield* ctx.tool
.transform((draft) =>
@ -51,7 +51,7 @@ export const Plugin = {
agent: context.agent,
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
})
const result = yield* search.query({ ...input, sessionID: context.sessionID })
const result = yield* websearch.query({ ...input, sessionID: context.sessionID })
return {
provider: result.providerID,
text: result.text || NO_RESULTS,

View file

@ -1,12 +1,12 @@
export * as Search from "./search"
export * as WebSearch from "./websearch"
import { Search } from "@opencode-ai/schema/search"
import { WebSearch } from "@opencode-ai/schema/websearch"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { Context, Effect, Layer, Schema, Semaphore, Stream } from "effect"
import path from "node:path"
import { Config } from "./config"
import { ConfigGlobal } from "./config/global"
import { ConfigSearch } from "./config/search"
import { ConfigWebSearch } from "./config/websearch"
import { makeLocationNode } from "./effect/app-node"
import { EventV2 } from "./event"
import { Form } from "./form"
@ -14,32 +14,32 @@ import { Global } from "./global"
import { Integration } from "./integration"
import { truthy } from "./flag/flag"
export const Input = Search.Input
export type Input = Search.Input
export const Input = WebSearch.Input
export type Input = WebSearch.Input
export const ProviderOutput = Search.ProviderOutput
export type ProviderOutput = Search.ProviderOutput
export const ProviderOutput = WebSearch.ProviderOutput
export type ProviderOutput = WebSearch.ProviderOutput
export const Result = Search.Result
export type Result = Search.Result
export const Result = WebSearch.Result
export type Result = WebSearch.Result
export class ProviderRequiredError extends Schema.TaggedErrorClass<ProviderRequiredError>()(
"Search.ProviderRequired",
"WebSearch.ProviderRequired",
{},
) {}
export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()("Search.ProviderNotFound", {
export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()("WebSearch.ProviderNotFound", {
providerID: Integration.ID,
}) {}
export class ConnectionRequiredError extends Schema.TaggedErrorClass<ConnectionRequiredError>()(
"Search.ConnectionRequired",
"WebSearch.ConnectionRequired",
{ providerID: Integration.ID },
) {}
export class CancelledError extends Schema.TaggedErrorClass<CancelledError>()("Search.Cancelled", {}) {}
export class CancelledError extends Schema.TaggedErrorClass<CancelledError>()("WebSearch.Cancelled", {}) {}
export class RequestError extends Schema.TaggedErrorClass<RequestError>()("Search.Request", {
export class RequestError extends Schema.TaggedErrorClass<RequestError>()("WebSearch.Request", {
providerID: Integration.ID,
cause: Schema.Defect(),
}) {}
@ -61,7 +61,7 @@ export interface Interface {
readonly query: (input: QueryInput) => Effect.Effect<Result, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Search") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/WebSearch") {}
const layer = Layer.effect(
Service,
@ -78,27 +78,27 @@ const layer = Layer.effect(
let pendingProviderID: Integration.ID | undefined
const requireProvider = (
providers: Map<Integration.ID, Integration.SearchImplementation>,
providers: Map<Integration.ID, Integration.WebSearchImplementation>,
providerID: Integration.ID,
) => {
const provider = providers.get(providerID)
return provider ? Effect.succeed(provider) : Effect.fail(new ProviderNotFoundError({ providerID }))
}
const globalProviderID = Effect.fn("Search.globalProviderID")(function* () {
const globalProviderID = Effect.fn("WebSearch.globalProviderID")(function* () {
const entries = (yield* config.entries()).filter(
(entry) => entry.type === "document" && entry.path && path.dirname(entry.path) === globalConfigPath,
)
return Config.latest(entries, "search")?.provider
return Config.latest(entries, "websearch")?.provider
})
const selected = Effect.fn("Search.selected")(function* () {
const selected = Effect.fn("WebSearch.selected")(function* () {
return pendingProviderID ?? (yield* globalProviderID())
})
const saveProvider = Effect.fn("Search.saveProvider")(function* (providerID: Integration.ID) {
const saveProvider = Effect.fn("WebSearch.saveProvider")(function* (providerID: Integration.ID) {
pendingProviderID = providerID
yield* configGlobal.update(["search"], new ConfigSearch.Info({ provider: providerID })).pipe(
yield* configGlobal.update(["websearch"], new ConfigWebSearch.Info({ provider: providerID })).pipe(
Effect.tapError(() => Effect.sync(() => (pendingProviderID = undefined))),
Effect.orDie,
)
@ -118,8 +118,8 @@ const layer = Layer.effect(
Effect.forkScoped,
)
const ask = Effect.fn("Search.ask")(function* (
providers: Map<Integration.ID, Integration.SearchImplementation>,
const ask = Effect.fn("WebSearch.ask")(function* (
providers: Map<Integration.ID, Integration.WebSearchImplementation>,
sessionID: string,
) {
if (providers.size === 0) return yield* new ProviderRequiredError()
@ -128,7 +128,7 @@ const layer = Layer.effect(
.ask({
sessionID,
title: "Choose a web search provider",
metadata: { kind: "search.provider" },
metadata: { kind: "websearch.provider" },
mode: "form",
fields: [
{
@ -161,8 +161,8 @@ const layer = Layer.effect(
return yield* requireProvider(providers, Integration.ID.make(answer))
})
const connect = Effect.fn("Search.connect")(function* (
provider: Integration.SearchImplementation,
const connect = Effect.fn("WebSearch.connect")(function* (
provider: Integration.WebSearchImplementation,
sessionID?: string,
) {
const active = yield* integrations.connection.active(provider.integrationID)
@ -183,12 +183,12 @@ const layer = Layer.effect(
return connected
})
const resolve = Effect.fn("Search.resolve")(function* (input: QueryInput) {
const resolve = Effect.fn("WebSearch.resolve")(function* (input: QueryInput) {
const providers = new Map(
(yield* integrations.search.list()).map((provider) => [provider.integrationID, provider]),
(yield* integrations.websearch.list()).map((provider) => [provider.integrationID, provider]),
)
if (input.providerID) return yield* requireProvider(providers, input.providerID)
const configuredProviderID = Config.latest(yield* config.entries(), "search")?.provider
const configuredProviderID = Config.latest(yield* config.entries(), "websearch")?.provider
if (configuredProviderID) return yield* requireProvider(providers, configuredProviderID)
if (process.env.OPENCODE_WEBSEARCH_PROVIDER) {
return yield* requireProvider(providers, Integration.ID.make(process.env.OPENCODE_WEBSEARCH_PROVIDER))
@ -219,12 +219,12 @@ const layer = Layer.effect(
return Service.of({
selected,
select: Effect.fn("Search.select")(function* (providerID) {
const provider = yield* integrations.search.get(providerID)
select: Effect.fn("WebSearch.select")(function* (providerID) {
const provider = yield* integrations.websearch.get(providerID)
if (!provider) return yield* new ProviderNotFoundError({ providerID })
yield* saveProvider(providerID)
}),
query: Effect.fn("Search.query")(function* (input) {
query: Effect.fn("WebSearch.query")(function* (input) {
const provider = yield* resolve(input)
const connection = yield* connect(provider, input.sessionID)
const credential = connection

View file

@ -75,11 +75,11 @@ describe("Config", () => {
})
const config = yield* ConfigGlobal.Service
yield* config.update(["search"], { provider: "exa" })
yield* config.update(["websearch"], { provider: "exa" })
const text = yield* Effect.promise(() => Bun.file(file).text())
expect(text).toContain("// user config")
expect(parse(text)).toEqual({ username: "tester", search: { provider: "exa" } })
expect(parse(text)).toEqual({ username: "tester", websearch: { provider: "exa" } })
}).pipe(
Effect.provide(
AppNodeBuilder.build(LayerNode.group([ConfigGlobal.node]), [

View file

@ -155,7 +155,7 @@ function resourceMcpLayer(url: string) {
complete: unusedIntegration,
cancel: unusedIntegration,
},
search: {
websearch: {
list: unusedIntegration,
get: unusedIntegration,
},

View file

@ -259,11 +259,11 @@ function registerIntegration(draft: Integration.Draft, definition: IntegrationDe
}),
)
}
if (!definition.search) return
draft.search.update({
if (!definition.websearch) return
draft.websearch.update({
integrationID,
connection: definition.search.connection,
execute: definition.search.execute,
connection: definition.websearch.connection,
execute: definition.websearch.execute,
})
}

View file

@ -95,19 +95,19 @@ describe("fromPromise", () => {
}),
)
it.effect("adapts promise search capability execution", () =>
it.effect("adapts promise web search capability execution", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make(plugin)
const promisePlugin = Plugin.define({
id: "promise-search",
id: "promise-websearch",
setup: async (ctx) => {
await ctx.integration.register({
id: "promise-search",
name: "Promise Search",
methods: [{ type: "env", names: ["PROMISE_SEARCH_KEY"] }],
search: {
id: "promise-websearch",
name: "Promise Web Search",
methods: [{ type: "env", names: ["PROMISE_WEBSEARCH_KEY"] }],
websearch: {
connection: "optional",
execute: async (input) => ({ text: `promise: ${input.query}` }),
},
@ -116,12 +116,12 @@ describe("fromPromise", () => {
})
yield* PluginPromise.fromPromise(promisePlugin).effect(host)
expect(yield* integrations.get(Integration.ID.make("promise-search"))).toMatchObject({
name: "Promise Search",
methods: [{ type: "env", names: ["PROMISE_SEARCH_KEY"] }],
expect(yield* integrations.get(Integration.ID.make("promise-websearch"))).toMatchObject({
name: "Promise Web Search",
methods: [{ type: "env", names: ["PROMISE_WEBSEARCH_KEY"] }],
})
const provider = yield* integrations.search.get(Integration.ID.make("promise-search"))
if (!provider) return yield* Effect.die("Expected promise search provider")
const provider = yield* integrations.websearch.get(Integration.ID.make("promise-websearch"))
if (!provider) return yield* Effect.die("Expected promise web search provider")
expect(yield* provider.execute({ query: "effect" }, {})).toEqual({ text: "promise: effect" })
}),
)

View file

@ -7,16 +7,16 @@ import { EventV2 } from "@opencode-ai/core/event"
import { Integration } from "@opencode-ai/core/integration"
import { testEffect } from "../lib/effect"
export interface SearchRequest {
export interface WebSearchRequest {
readonly url: string
readonly headers: Record<string, string>
readonly body: unknown
}
export const requests: SearchRequest[] = []
export const requests: WebSearchRequest[] = []
export const response = { body: "" }
export function resetSearchFixture(body: string) {
export function resetWebSearchFixture(body: string) {
requests.length = 0
response.body = body
}
@ -36,6 +36,6 @@ const http = Layer.succeed(
),
)
export const searchIntegrationTest = testEffect(
export const webSearchIntegrationTest = testEffect(
Layer.merge(AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node, EventV2.node])), http),
)

View file

@ -2,13 +2,13 @@ import { beforeEach, describe, expect } from "bun:test"
import { Effect } from "effect"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { SearchExa } from "@opencode-ai/core/plugin/search/exa"
import { SearchParallel } from "@opencode-ai/core/plugin/search/parallel"
import { WebSearchExa } from "@opencode-ai/core/plugin/websearch/exa"
import { WebSearchParallel } from "@opencode-ai/core/plugin/websearch/parallel"
import { host, integrationHost } from "./host"
import { requests, resetSearchFixture, searchIntegrationTest } from "./search-fixture"
import { requests, resetWebSearchFixture, webSearchIntegrationTest } from "./websearch-fixture"
beforeEach(() => {
resetSearchFixture(
resetWebSearchFixture(
`event: message\ndata: ${JSON.stringify({
jsonrpc: "2.0",
id: 1,
@ -17,46 +17,46 @@ beforeEach(() => {
)
})
const it = searchIntegrationTest
const it = webSearchIntegrationTest
describe("built-in search integrations", () => {
it.effect("registers and disposes an atomic search integration", () =>
describe("built-in web search integrations", () => {
it.effect("registers and disposes an atomic web search integration", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const registration = yield* integrationHost(integrations).register({
id: "test-search",
name: "Test Search",
id: "test-websearch",
name: "Test Web Search",
methods: [{ type: "key", label: "API key" }],
search: {
websearch: {
connection: "required",
execute: (input) => Effect.succeed({ text: input.query }),
},
})
expect(yield* integrations.get(Integration.ID.make("test-search"))).toMatchObject({
name: "Test Search",
expect(yield* integrations.get(Integration.ID.make("test-websearch"))).toMatchObject({
name: "Test Web Search",
methods: [{ type: "key", label: "API key" }],
search: { connection: "required" },
websearch: { connection: "required" },
})
yield* registration.dispose
expect(yield* integrations.get(Integration.ID.make("test-search"))).toBeUndefined()
expect(yield* integrations.get(Integration.ID.make("test-websearch"))).toBeUndefined()
}),
)
it.effect("registers Exa with its MCP schema", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
yield* SearchExa.Plugin.effect(host({ integration: integrationHost(integrations) }))
yield* WebSearchExa.Plugin.effect(host({ integration: integrationHost(integrations) }))
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"] }],
search: { connection: "optional" },
websearch: { connection: "optional" },
})
const provider = yield* integrations.search.get(Integration.ID.make("exa"))
if (!provider) return yield* Effect.die("Expected Exa search provider")
const provider = yield* integrations.websearch.get(Integration.ID.make("exa"))
if (!provider) return yield* Effect.die("Expected Exa web search provider")
expect(
yield* provider.execute(
{ query: "effect typescript" },
@ -65,7 +65,7 @@ describe("built-in search integrations", () => {
).toEqual({ text: "search results", metadata: { searchTime: 123 } })
expect(requests).toEqual([
{
url: `${SearchExa.endpoint}?exaApiKey=exa+secret`,
url: `${WebSearchExa.endpoint}?exaApiKey=exa+secret`,
headers: expect.any(Object),
body: {
jsonrpc: "2.0",
@ -73,7 +73,7 @@ describe("built-in search integrations", () => {
method: "tools/call",
params: {
name: "web_search_exa",
arguments: { query: "effect typescript" },
arguments: { query: "effect typescript", numResults: 8 },
},
},
},
@ -83,7 +83,7 @@ describe("built-in search integrations", () => {
it.effect("registers Parallel and keeps its credential in the authorization header", () =>
Effect.gen(function* () {
resetSearchFixture(
resetWebSearchFixture(
JSON.stringify({
jsonrpc: "2.0",
id: 1,
@ -107,9 +107,9 @@ describe("built-in search integrations", () => {
}),
)
const integrations = yield* Integration.Service
yield* SearchParallel.Plugin.effect(host({ integration: integrationHost(integrations) }))
const provider = yield* integrations.search.get(Integration.ID.make("parallel"))
if (!provider) return yield* Effect.die("Expected Parallel search provider")
yield* WebSearchParallel.Plugin.effect(host({ integration: integrationHost(integrations) }))
const provider = yield* integrations.websearch.get(Integration.ID.make("parallel"))
if (!provider) return yield* Effect.die("Expected Parallel web search provider")
const output = yield* provider.execute(
{ query: "effect layers" },
@ -136,7 +136,7 @@ describe("built-in search integrations", () => {
},
})
expect(requests[0]).toMatchObject({
url: SearchParallel.endpoint,
url: WebSearchParallel.endpoint,
headers: { authorization: "Bearer parallel-secret" },
body: {
jsonrpc: "2.0",

View file

@ -4,7 +4,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { Integration } from "@opencode-ai/core/integration"
import { Search } from "@opencode-ai/core/search"
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"
@ -16,18 +16,18 @@ import { executeTool, registerToolPlugin, settleTool, toolDefinitions, toolIdent
const webSearchToolNode = makeLocationNode({
name: "test/websearch-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(WebSearchTool.Plugin)),
deps: [ToolRegistry.toolsNode, PermissionV2.node, Search.node],
deps: [ToolRegistry.toolsNode, PermissionV2.node, WebSearch.node],
})
const sessionID = SessionV2.ID.make("ses_websearch_test")
const assertions: PermissionV2.AssertInput[] = []
const queries: Search.QueryInput[] = []
let result = new Search.Result({ providerID: Integration.ID.make("exa"), text: "search results" })
const queries: WebSearch.QueryInput[] = []
let result = new WebSearch.Result({ providerID: Integration.ID.make("exa"), text: "search results" })
beforeEach(() => {
assertions.length = 0
queries.length = 0
result = new Search.Result({ providerID: Integration.ID.make("exa"), text: "search results" })
result = new WebSearch.Result({ providerID: Integration.ID.make("exa"), text: "search results" })
})
const permission = Layer.succeed(
@ -41,9 +41,9 @@ const permission = Layer.succeed(
list: () => Effect.die("unused"),
}),
)
const search = Layer.succeed(
Search.Service,
Search.Service.of({
const websearch = Layer.succeed(
WebSearch.Service,
WebSearch.Service.of({
selected: () => Effect.succeed(undefined),
select: () => Effect.die("unused"),
query: (input) =>
@ -54,15 +54,15 @@ const search = Layer.succeed(
}),
)
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, Search.node, webSearchToolNode]), [
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebSearch.node, webSearchToolNode]), [
[PermissionV2.node, permission],
[Search.node, search],
[WebSearch.node, websearch],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
]),
)
describe("WebSearchTool registration", () => {
it.effect("asserts permission before delegating to Search", () =>
it.effect("asserts permission before delegating to WebSearch", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
@ -99,7 +99,7 @@ describe("WebSearchTool registration", () => {
it.effect("keeps provider metadata in structured output", () =>
Effect.gen(function* () {
result = new Search.Result({
result = new WebSearch.Result({
providerID: Integration.ID.make("parallel"),
text: "parallel results",
metadata: { requestID: "req_1" },
@ -124,7 +124,7 @@ describe("WebSearchTool registration", () => {
it.effect("uses the concise no-results fallback", () =>
Effect.gen(function* () {
result = new Search.Result({ providerID: Integration.ID.make("exa"), text: "" })
result = new WebSearch.Result({ providerID: Integration.ID.make("exa"), text: "" })
const registry = yield* ToolRegistry.Service
expect(

View file

@ -5,13 +5,13 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Config } from "@opencode-ai/core/config"
import { ConfigGlobal } from "@opencode-ai/core/config/global"
import { ConfigSearch } from "@opencode-ai/core/config/search"
import { ConfigWebSearch } from "@opencode-ai/core/config/websearch"
import { Credential } from "@opencode-ai/core/credential"
import { EventV2 } from "@opencode-ai/core/event"
import { Form } from "@opencode-ai/core/form"
import { Global } from "@opencode-ai/core/global"
import { Integration } from "@opencode-ai/core/integration"
import { Search } from "@opencode-ai/core/search"
import { WebSearch } from "@opencode-ai/core/websearch"
import { testEffect } from "./lib/effect"
let entries: Config.Entry[] = []
@ -23,7 +23,7 @@ const configGlobal = Layer.succeed(
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Search.node, Integration.node, Credential.node, EventV2.node, Form.node, ConfigGlobal.node]),
LayerNode.group([WebSearch.node, Integration.node, Credential.node, EventV2.node, Form.node, ConfigGlobal.node]),
[
[Config.node, config],
[ConfigGlobal.node, configGlobal],
@ -35,10 +35,10 @@ const register = (id: string, connection: "optional" | "required" = "optional")
Effect.gen(function* () {
const integrations = yield* Integration.Service
const integrationID = Integration.ID.make(id)
const calls: { input: Search.Input; credential?: Credential.Value; sessionID?: string }[] = []
const calls: { input: WebSearch.Input; credential?: Credential.Value; sessionID?: string }[] = []
yield* integrations.transform((draft) => {
draft.update(integrationID, (integration) => (integration.name = id.toUpperCase()))
draft.search.update({
draft.websearch.update({
integrationID,
connection,
execute: (input, context) =>
@ -56,20 +56,20 @@ beforeEach(() => {
writes.length = 0
})
describe("Search", () => {
describe("WebSearch", () => {
it.effect("executes an explicit provider without changing the default", () =>
Effect.gen(function* () {
const provider = yield* register("exa")
const search = yield* Search.Service
const websearch = yield* WebSearch.Service
expect(yield* search.query({ query: "effect", providerID: provider.integrationID })).toEqual(
new Search.Result({
expect(yield* websearch.query({ query: "effect", providerID: provider.integrationID })).toEqual(
new WebSearch.Result({
providerID: provider.integrationID,
text: "exa: effect",
metadata: { id: "exa" },
}),
)
expect(yield* search.selected()).toBeUndefined()
expect(yield* websearch.selected()).toBeUndefined()
expect(provider.calls).toEqual([
{
input: { query: "effect", providerID: provider.integrationID },
@ -84,29 +84,31 @@ describe("Search", () => {
Effect.gen(function* () {
yield* register("exa")
const parallel = yield* register("parallel")
const search = yield* Search.Service
yield* search.select(parallel.integrationID)
const websearch = yield* WebSearch.Service
yield* websearch.select(parallel.integrationID)
expect((yield* search.query({ query: "layers" })).providerID).toBe(parallel.integrationID)
expect(yield* search.selected()).toBe(parallel.integrationID)
expect(writes).toEqual([{ path: ["search"], value: new ConfigSearch.Info({ provider: parallel.integrationID }) }])
expect((yield* websearch.query({ query: "layers" })).providerID).toBe(parallel.integrationID)
expect(yield* websearch.selected()).toBe(parallel.integrationID)
expect(writes).toEqual([
{ path: ["websearch"], value: new ConfigWebSearch.Info({ provider: parallel.integrationID }) },
])
}),
)
it.effect("reads the selected provider from global config", () =>
Effect.gen(function* () {
const provider = yield* register("exa")
const search = yield* Search.Service
const websearch = yield* WebSearch.Service
entries = [
new Config.Document({
type: "document",
path: path.join(Global.Path.config, "opencode.json"),
info: new Config.Info({ search: new ConfigSearch.Info({ provider: provider.integrationID }) }),
info: new Config.Info({ websearch: new ConfigWebSearch.Info({ provider: provider.integrationID }) }),
}),
]
expect(yield* search.selected()).toBe(provider.integrationID)
expect((yield* search.query({ query: "configured" })).providerID).toBe(provider.integrationID)
expect(yield* websearch.selected()).toBe(provider.integrationID)
expect((yield* websearch.query({ query: "configured" })).providerID).toBe(provider.integrationID)
}),
)
@ -114,29 +116,29 @@ describe("Search", () => {
Effect.gen(function* () {
const exa = yield* register("exa")
const parallel = yield* register("parallel")
const search = yield* Search.Service
yield* search.select(exa.integrationID)
const websearch = yield* WebSearch.Service
yield* websearch.select(exa.integrationID)
entries = [
new Config.Document({
type: "document",
info: new Config.Info({ search: new ConfigSearch.Info({ provider: parallel.integrationID }) }),
info: new Config.Info({ websearch: new ConfigWebSearch.Info({ provider: parallel.integrationID }) }),
}),
]
expect((yield* search.query({ query: "configured" })).providerID).toBe(parallel.integrationID)
expect((yield* websearch.query({ query: "configured" })).providerID).toBe(parallel.integrationID)
}),
)
it.effect("serializes concurrent first-use onboarding and persists the answer", () =>
Effect.gen(function* () {
const provider = yield* register("exa")
const search = yield* Search.Service
const websearch = yield* WebSearch.Service
const forms = yield* Form.Service
const first = yield* search.query({ query: "one", sessionID: "ses_search" }).pipe(Effect.forkChild)
const second = yield* search.query({ query: "two", sessionID: "ses_search" }).pipe(Effect.forkChild)
const first = yield* websearch.query({ query: "one", sessionID: "ses_websearch" }).pipe(Effect.forkChild)
const second = yield* websearch.query({ query: "two", sessionID: "ses_websearch" }).pipe(Effect.forkChild)
yield* Effect.yieldNow
const pending = yield* forms.list({ sessionID: "ses_search" })
const pending = yield* forms.list({ sessionID: "ses_websearch" })
expect(pending).toHaveLength(1)
const form = pending[0]
if (!form) return yield* Effect.die("Expected an onboarding form")
@ -144,18 +146,18 @@ describe("Search", () => {
expect((yield* Fiber.join(first)).providerID).toBe(provider.integrationID)
expect((yield* Fiber.join(second)).providerID).toBe(provider.integrationID)
expect(yield* search.selected()).toBe(provider.integrationID)
expect(yield* websearch.selected()).toBe(provider.integrationID)
}),
)
it.effect("requires a connection before invoking a required provider", () =>
Effect.gen(function* () {
const provider = yield* register("private", "required")
const search = yield* Search.Service
const websearch = yield* WebSearch.Service
expect(
yield* search.query({ query: "secret", providerID: provider.integrationID }).pipe(Effect.flip),
).toBeInstanceOf(Search.ConnectionRequiredError)
yield* websearch.query({ query: "secret", providerID: provider.integrationID }).pipe(Effect.flip),
).toBeInstanceOf(WebSearch.ConnectionRequiredError)
expect(provider.calls).toEqual([])
}),
)
@ -165,9 +167,9 @@ describe("Search", () => {
const integrations = yield* Integration.Service
const scope = yield* Scope.fork(yield* Scope.Scope)
const provider = yield* register("temporary").pipe(Scope.provide(scope))
expect(yield* integrations.search.get(provider.integrationID)).toBeDefined()
expect(yield* integrations.websearch.get(provider.integrationID)).toBeDefined()
yield* Scope.close(scope, Exit.void)
expect(yield* integrations.search.get(provider.integrationID)).toBeUndefined()
expect(yield* integrations.websearch.get(provider.integrationID)).toBeUndefined()
}),
)
})