feat(core): add integration-backed search

This commit is contained in:
Shoubhit Dash 2026-07-06 19:33:13 +05:30
commit 129012c3ee
50 changed files with 1745 additions and 478 deletions

View file

@ -1,9 +1,9 @@
{
"version": "7",
"dialect": "sqlite",
"id": "96e9fe64-d810-4102-8f79-3317a88bb6d2",
"id": "cd519535-0763-4d60-aeee-21ee226ffd7f",
"prevIds": [
"22e57fed-b9b8-4e94-a3b4-f94bece680a8"
"96e9fe64-d810-4102-8f79-3317a88bb6d2"
],
"ddl": [
{
@ -38,6 +38,10 @@
"name": "event",
"entityType": "tables"
},
{
"name": "integration_capability",
"entityType": "tables"
},
{
"name": "permission",
"entityType": "tables"
@ -556,6 +560,46 @@
"entityType": "columns",
"table": "event"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "capability",
"entityType": "columns",
"table": "integration_capability"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "integration_id",
"entityType": "columns",
"table": "integration_capability"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "integration_capability"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "integration_capability"
},
{
"type": "text",
"notNull": false,
@ -1859,6 +1903,15 @@
"table": "event",
"entityType": "pks"
},
{
"columns": [
"capability"
],
"nameExplicit": false,
"name": "integration_capability_pk",
"table": "integration_capability",
"entityType": "pks"
},
{
"columns": [
"id"

View file

@ -22,6 +22,7 @@ import { ConfigMCP } from "./config/mcp"
import { ConfigPlugin } from "./config/plugin"
import { ConfigProvider } from "./config/provider"
import { ConfigReference } from "./config/reference"
import { ConfigSearch } from "./config/search"
import { ConfigToolOutput } from "./config/tool-output"
import { ConfigVariable } from "./config/variable"
import { ConfigWatcher } from "./config/watcher"
@ -101,6 +102,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",
}),
search: ConfigSearch.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,8 @@
export * as ConfigSearch from "./search"
import { Integration } from "@opencode-ai/schema/integration"
import { Schema } from "effect"
export class Info extends Schema.Class<Info>("ConfigSearch.Info")({
provider: Integration.ID,
}) {}

View file

@ -44,5 +44,6 @@ export const migrations = (
import("./migration/20260703090000_reset_v2_event_rename_sweep"),
import("./migration/20260703181610_event_created_column"),
import("./migration/20260703190000_reset_v2_shell_event_payloads"),
import("./migration/20260706133920_integration-search"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]

View file

@ -0,0 +1,18 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260706133920_integration-search",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`integration_capability\` (
\`capability\` text PRIMARY KEY,
\`integration_id\` text NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL
);
`)
})
},
} satisfies DatabaseMigration.Migration

View file

@ -87,6 +87,14 @@ export default {
CONSTRAINT \`fk_event_aggregate_id_event_sequence_aggregate_id_fk\` FOREIGN KEY (\`aggregate_id\`) REFERENCES \`event_sequence\`(\`aggregate_id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`integration_capability\` (
\`capability\` text PRIMARY KEY,
\`integration_id\` text NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL
);
`)
yield* tx.run(`
CREATE TABLE \`permission\` (
\`id\` text PRIMARY KEY,

View file

@ -67,6 +67,7 @@ export class InvalidFormError extends Schema.TaggedErrorClass<InvalidFormError>(
export type CreateInput =
| (Omit<Form.FormInfo, "id"> & { readonly id?: ID })
| (Omit<Form.UrlInfo, "id"> & { readonly id?: ID })
| (Omit<Form.IntegrationInfo, "id"> & { readonly id?: ID })
export interface ReplyInput {
readonly id: ID
@ -138,7 +139,9 @@ export const layer = Layer.effect(
const form: Info =
input.mode === "form"
? { ...base, mode: "form", fields: input.fields }
: { ...base, mode: "url", url: input.url }
: input.mode === "url"
? { ...base, mode: "url", url: input.url }
: { ...base, mode: "integration", integrationID: input.integrationID }
const entry: Entry = {
form,
state: { status: "pending" },
@ -228,9 +231,9 @@ export const locationLayer = layer
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] })
function validateAnswer(form: Info, answer: Answer) {
if (form.mode === "url") {
if (form.mode !== "form") {
if (Object.keys(answer).length === 0) return
return "URL forms must be answered with an empty answer"
return `${form.mode === "url" ? "URL" : "Integration"} forms must be answered with an empty answer`
}
const fields = new Map(form.fields.map((field) => [field.key, field]))
for (const key of Object.keys(answer)) {

View file

@ -1,6 +1,7 @@
export * as Integration from "./integration"
import { makeLocationNode } from "./effect/app-node"
import { eq } from "drizzle-orm"
import {
Cause,
Clock,
@ -16,10 +17,13 @@ import {
Types,
} from "effect"
import { Integration } from "@opencode-ai/schema/integration"
import { Search } from "@opencode-ai/schema/search"
import { Credential } from "./credential"
import { Database } from "./database/database"
import { State } from "./state"
import { EventV2 } from "./event"
import { IntegrationConnection } from "./integration/connection"
import { IntegrationCapabilityTable } from "./integration/sql"
export const ID = Integration.ID
export type ID = Integration.ID
@ -60,6 +64,12 @@ export type Info = Integration.Info
export const Inputs = Integration.Inputs
export type Inputs = Integration.Inputs
export const SearchCapability = Integration.SearchCapability
export type SearchCapability = Omit<Integration.SearchCapability, "selected">
export const Capability = Integration.Capability
export type Capability = Integration.Capability
export type OAuthAuthorization = {
readonly url: string
readonly instructions: string
@ -94,6 +104,15 @@ export interface EnvImplementation {
export type Implementation = OAuthImplementation | KeyImplementation | EnvImplementation
export interface SearchImplementation {
readonly integrationID: ID
readonly capability: SearchCapability
readonly execute: (
input: Search.Input,
context: { readonly credential?: Credential.Value; readonly sessionID?: string },
) => Effect.Effect<Search.ProviderOutput, unknown>
}
export const Attempt = Integration.Attempt
export type Attempt = Integration.Attempt
@ -119,6 +138,7 @@ type Entry = {
ref: Types.DeepMutable<Ref>
methods: Types.DeepMutable<Method>[]
implementations: Map<MethodID, Types.DeepMutable<OAuthImplementation>>
search?: Types.DeepMutable<SearchImplementation>
}
type Data = {
@ -135,6 +155,13 @@ export type Draft = {
update: (implementation: Implementation) => void
remove: (integrationID: ID, method: Method) => void
}
capability: {
search: {
list: () => readonly SearchImplementation[]
update: (implementation: SearchImplementation) => void
remove: (integrationID: ID) => void
}
}
}
export interface Interface extends State.Transformable<Draft> {
@ -191,6 +218,14 @@ export interface Interface extends State.Transformable<Draft> {
/** Cancels an attempt and releases its resources. */
readonly cancel: (attemptID: AttemptID) => Effect.Effect<void>
}
readonly capability: {
readonly search: {
readonly list: () => Effect.Effect<readonly SearchImplementation[]>
readonly get: (integrationID: ID) => Effect.Effect<SearchImplementation | undefined>
readonly selected: () => Effect.Effect<ID | undefined>
readonly select: (integrationID: ID) => Effect.Effect<void>
}
}
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Integration") {}
@ -222,6 +257,7 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const credentials = yield* Credential.Service
const { db } = yield* Database.Service
const events = yield* EventV2.Service
const scope = yield* Scope.Scope
const attempts = SynchronizedRef.makeUnsafe(new Map<AttemptID, AttemptEntry>())
@ -281,6 +317,32 @@ const layer = Layer.effect(
if (method.type === "oauth") current.implementations.delete(method.id)
},
},
capability: {
search: {
list: () =>
Array.from(draft.integrations.values()).flatMap((entry) =>
entry.search ? [entry.search as SearchImplementation] : [],
),
update: (implementation) => {
const current = draft.integrations.get(implementation.integrationID) ?? {
ref: {
id: implementation.integrationID,
name: implementation.integrationID,
},
methods: [],
implementations: new Map<MethodID, Types.DeepMutable<OAuthImplementation>>(),
}
if (!draft.integrations.has(implementation.integrationID)) {
draft.integrations.set(implementation.integrationID, current)
}
current.search = implementation as Types.DeepMutable<SearchImplementation>
},
remove: (integrationID) => {
const current = draft.integrations.get(integrationID)
if (current) delete current.search
},
},
},
}),
finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid),
})
@ -300,14 +362,24 @@ const layer = Layer.effect(
return [...credentials, ...env]
}
const project = (entry: Entry, connections: IntegrationConnection.Info[]) =>
const project = (entry: Entry, connections: IntegrationConnection.Info[], selectedSearch: ID | undefined) =>
new Info({
id: entry.ref.id,
name: entry.ref.name,
methods: entry.methods,
capabilities: entry.search ? [{ ...entry.search.capability, selected: entry.ref.id === selectedSearch }] : [],
connections,
})
const selectedSearch = Effect.fn("Integration.capability.search.selected")(function* () {
return (yield* db
.select({ integrationID: IntegrationCapabilityTable.integration_id })
.from(IntegrationCapabilityTable)
.where(eq(IntegrationCapabilityTable.capability, "search"))
.get()
.pipe(Effect.orDie))?.integrationID
})
const authorize = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(Effect.mapError((cause) => new AuthorizationError({ cause })))
@ -369,12 +441,13 @@ const layer = Layer.effect(
get: Effect.fn("Integration.get")(function* (id) {
const entry = state.get().integrations.get(id)
if (!entry) return undefined
return project(entry, resolveConnections(entry, yield* credentials.list(id)))
return project(entry, resolveConnections(entry, yield* credentials.list(id)), yield* selectedSearch())
}),
list: Effect.fn("Integration.list")(function* () {
const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID)
const selected = yield* selectedSearch()
return Array.from(state.get().integrations.values(), (entry) =>
project(entry, resolveConnections(entry, saved.get(entry.ref.id) ?? [])),
project(entry, resolveConnections(entry, saved.get(entry.ref.id) ?? []), selected),
).toSorted((a, b) => a.name.localeCompare(b.name))
}),
connection: {
@ -514,8 +587,36 @@ const layer = Layer.effect(
if (attempt) yield* Scope.close(attempt.scope, Exit.void)
}),
},
capability: {
search: {
list: Effect.fn("Integration.capability.search.list")(function* () {
return Array.from(state.get().integrations.values()).flatMap((entry) =>
entry.search ? [entry.search as SearchImplementation] : [],
)
}),
get: Effect.fn("Integration.capability.search.get")(function* (integrationID) {
return state.get().integrations.get(integrationID)?.search as SearchImplementation | undefined
}),
selected: selectedSearch,
select: Effect.fn("Integration.capability.search.select")(function* (integrationID) {
if (!state.get().integrations.get(integrationID)?.search) {
return yield* Effect.die(new Error(`Search capability not found: ${integrationID}`))
}
yield* db
.insert(IntegrationCapabilityTable)
.values({ capability: "search", integration_id: integrationID })
.onConflictDoUpdate({
target: IntegrationCapabilityTable.capability,
set: { integration_id: integrationID },
})
.run()
.pipe(Effect.orDie)
yield* events.publish(Event.Updated, {})
}),
},
},
})
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [Credential.node, EventV2.node] })
export const node = makeLocationNode({ service: Service, layer, deps: [Credential.node, Database.node, EventV2.node] })

View file

@ -0,0 +1,9 @@
import { sqliteTable, text } from "drizzle-orm/sqlite-core"
import { Timestamps } from "../database/schema.sql"
import type { Integration } from "../integration"
export const IntegrationCapabilityTable = sqliteTable("integration_capability", {
capability: text().$type<Integration.Capability["type"]>().primaryKey(),
integration_id: text().$type<Integration.ID>().notNull(),
...Timestamps,
})

View file

@ -34,6 +34,7 @@ import { Pty } from "./pty"
import { QuestionV2 } from "./question"
import { Shell } from "./shell"
import { Reference } from "./reference"
import { Search } from "./search"
import { ReferenceGuidance } from "./reference/guidance"
import { Ripgrep } from "./ripgrep"
import { SessionRunnerLLM } from "./session/runner/llm"
@ -51,7 +52,6 @@ import { SessionInstructions } from "./session/instructions"
import { McpTool } from "./tool/mcp"
import { ReadToolFileSystem } from "./tool/read-filesystem"
import { ToolRegistry } from "./tool/registry"
import { WebSearchTool } from "./tool/websearch"
import { ToolOutputStore } from "./tool-output-store"
import { Vcs } from "./vcs"
@ -84,13 +84,13 @@ const pluginSupervisorNode = makeLocationNode({
Form.node,
ReadToolFileSystem.node,
Reference.node,
Search.node,
Ripgrep.node,
SessionInstructions.node,
SessionTodo.node,
Shell.node,
SkillV2.node,
ToolRegistry.toolsNode,
WebSearchTool.configNode,
],
})
@ -100,6 +100,7 @@ const locationServiceNodes = [
AgentV2.node,
CommandV2.node,
Reference.node,
Search.node,
Integration.node,
Catalog.node,
AISDK.node,

View file

@ -163,6 +163,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
integration: {
list: () => response(integration.list()),
get: (input) => response(integration.get(Integration.ID.make(input.integrationID))),
selectCapability: (input) => integration.capability.search.select(Integration.ID.make(input.integrationID)),
connectKey: (input) =>
integration.connection.key({
integrationID: Integration.ID.make(input.integrationID),
@ -268,6 +269,29 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
remove: (id, method) =>
draft.method.remove(Integration.ID.make(id), Schema.decodeUnknownSync(Integration.Method)(method)),
},
capability: {
search: {
list: () =>
draft.capability.search.list().map((provider) => ({
integrationID: provider.integrationID,
capability: provider.capability,
execute: (input, context) =>
provider.execute(input, {
...context,
credential: context.credential
? Schema.decodeUnknownSync(Credential.Value)(context.credential)
: undefined,
}),
})),
update: (input) =>
draft.capability.search.update({
integrationID: Integration.ID.make(input.integrationID),
capability: input.capability,
execute: input.execute,
}),
remove: (id) => draft.capability.search.remove(Integration.ID.make(id)),
},
},
})
}),
},

View file

@ -26,6 +26,7 @@ import { ModelsDev } from "../models-dev"
import { Npm } from "../npm"
import { PermissionV2 } from "../permission"
import { Reference } from "../reference"
import { Search } from "../search"
import { Ripgrep } from "../ripgrep"
import { SessionInstructions } from "../session/instructions"
import { SessionTodo } from "../session/todo"
@ -50,6 +51,7 @@ import { AgentPlugin } from "./agent"
import { CommandPlugin } from "./command"
import { ModelsDevPlugin } from "./models-dev"
import { ProviderPlugins } from "./provider"
import { SearchPlugins } from "./search"
import { PluginRuntime } from "./runtime"
import { SkillPlugin } from "./skill"
import { VariantPlugin } from "./variant"
@ -76,13 +78,13 @@ 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 ripgrep = yield* Ripgrep.Service
const instructions = yield* SessionInstructions.Service
const todo = yield* SessionTodo.Service
const shell = yield* Shell.Service
const skill = yield* SkillV2.Service
const tools = yield* Tools.Service
const websearch = yield* WebSearchTool.ConfigService
return Context.mergeAll(
Context.make(AgentV2.Service, agent),
Context.make(Catalog.Service, catalog),
@ -105,13 +107,13 @@ 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(Ripgrep.Service, ripgrep),
Context.make(SessionInstructions.Service, instructions),
Context.make(SessionTodo.Service, todo),
Context.make(Shell.Service, shell),
Context.make(SkillV2.Service, skill),
Context.make(Tools.Service, tools),
Context.make(WebSearchTool.ConfigService, websearch),
)
})
@ -127,6 +129,7 @@ const pre = [
SkillPlugin.Plugin,
ModelsDevPlugin,
...ProviderPlugins,
...SearchPlugins,
ApplyPatchTool.Plugin,
EditTool.Plugin,
GlobTool.Plugin,

View file

@ -79,12 +79,42 @@ export function fromPromise(plugin: Plugin) {
integration: {
list: (input) => run(host.integration.list(input)),
get: (input) => run(host.integration.get(input)),
selectCapability: (input) => run(host.integration.selectCapability(input)),
connectKey: (input) => run(host.integration.connectKey(input)),
connectOauth: (input) => run(host.integration.connectOauth(input)),
attemptStatus: (input) => run(host.integration.attemptStatus(input)),
attemptComplete: (input) => run(host.integration.attemptComplete(input)),
attemptCancel: (input) => run(host.integration.attemptCancel(input)),
transform: transform(host.integration),
transform: (callback) =>
register(
host.integration.transform((draft) => {
callback({
...draft,
capability: {
search: {
list: () =>
draft.capability.search.list().map((provider) => ({
integrationID: provider.integrationID,
capability: provider.capability,
execute: (input, execution) =>
Effect.runPromiseWith(context)(provider.execute(input, execution)),
})),
update: (input) =>
draft.capability.search.update({
integrationID: input.integrationID,
capability: input.capability,
execute: (query, execution) =>
Effect.tryPromise({
try: (signal) => input.execute(query, { ...execution, signal }),
catch: (cause) => cause,
}),
}),
remove: draft.capability.search.remove,
},
},
})
}),
),
reload: () => run(host.integration.reload()),
connection: {
active: (id) => Effect.runPromiseWith(context)(host.integration.connection.active(id)),

View file

@ -0,0 +1,53 @@
export * as SearchExa from "./exa"
import { define } from "@opencode-ai/plugin/v2/effect"
import { Effect, Schema, Scope } from "effect"
import { HttpClient } from "effect/unstable/http"
import { SearchMcp } from "./mcp"
export const endpoint = "https://mcp.exa.ai/mcp"
const Args = Schema.Struct({
query: Schema.String,
type: Schema.String,
numResults: Schema.Number,
livecrawl: Schema.String,
contextMaxCharacters: Schema.optional(Schema.Number),
})
const url = (apiKey: string | undefined) => {
if (!apiKey) return endpoint
const value = new URL(endpoint)
value.searchParams.set("exaApiKey", apiKey)
return value.toString()
}
export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
id: "opencode.search.exa",
effect: Effect.fn("SearchExa.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"] } })
draft.capability.search.update({
integrationID: "exa",
capability: { type: "search", connection: "optional" },
execute: (input, context) =>
SearchMcp.call(
http,
url(context.credential?.type === "key" ? context.credential.key : undefined),
"web_search_exa",
Args,
{
query: input.query,
type: input.type ?? "auto",
numResults: input.numResults ?? 8,
livecrawl: input.livecrawl ?? "fallback",
contextMaxCharacters: input.contextMaxCharacters,
},
).pipe(Effect.map((text) => ({ text: text ?? "" }))),
})
})
}),
})

View file

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

View file

@ -0,0 +1,75 @@
export * as SearchMcp 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
const Result = Schema.Struct({
result: Schema.Struct({
content: Schema.Array(Schema.Struct({ type: Schema.String, text: Schema.String })),
}),
})
const decodeResult = Schema.decodeUnknownEffect(Schema.fromJsonString(Result))
const parsePayload = (payload: string) =>
Effect.gen(function* () {
const trimmed = payload.trim()
if (!trimmed.startsWith("{")) return undefined
return (yield* decodeResult(trimmed)).result.content.find((item) => item.text)?.text
})
export const parseResponse = Effect.fn("SearchMcp.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
}
})
const Request = <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 }),
})
export const call = <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(Request(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`)),
}),
)
})

View file

@ -0,0 +1,47 @@
export * as SearchParallel from "./parallel"
import { define } from "@opencode-ai/plugin/v2/effect"
import { Effect, Schema, Scope } from "effect"
import { HttpClient } from "effect/unstable/http"
import { InstallationVersion } from "../../installation/version"
import { SearchMcp } from "./mcp"
export const endpoint = "https://search.parallel.ai/mcp"
const Args = Schema.Struct({
objective: Schema.String,
search_queries: Schema.Array(Schema.String),
session_id: Schema.String,
})
export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
id: "opencode.search.parallel",
effect: Effect.fn("SearchParallel.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"] } })
draft.capability.search.update({
integrationID: "parallel",
capability: { type: "search", connection: "optional" },
execute: (input, context) =>
SearchMcp.call(
http,
endpoint,
"web_search",
Args,
{
objective: input.query,
search_queries: [input.query],
session_id: context.sessionID ?? "opencode",
},
{
"User-Agent": `opencode/${InstallationVersion}`,
...(context.credential?.type === "key" ? { Authorization: `Bearer ${context.credential.key}` } : {}),
},
).pipe(Effect.map((text) => ({ text: text ?? "" }))),
})
})
}),
})

204
packages/core/src/search.ts Normal file
View file

@ -0,0 +1,204 @@
export * as Search from "./search"
import { Search } from "@opencode-ai/schema/search"
import { Context, Effect, Layer, Schema, Semaphore } from "effect"
import { Config } from "./config"
import { makeLocationNode } from "./effect/app-node"
import { Form } from "./form"
import { Integration } from "./integration"
import { truthy } from "./flag/flag"
export const Input = Search.Input
export type Input = Search.Input
export const ProviderOutput = Search.ProviderOutput
export type ProviderOutput = Search.ProviderOutput
export const Result = Search.Result
export type Result = Search.Result
export class ProviderRequiredError extends Schema.TaggedErrorClass<ProviderRequiredError>()(
"Search.ProviderRequired",
{},
) {}
export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()("Search.ProviderNotFound", {
providerID: Integration.ID,
}) {}
export class ConnectionRequiredError extends Schema.TaggedErrorClass<ConnectionRequiredError>()(
"Search.ConnectionRequired",
{ providerID: Integration.ID },
) {}
export class CancelledError extends Schema.TaggedErrorClass<CancelledError>()("Search.Cancelled", {}) {}
export class RequestError extends Schema.TaggedErrorClass<RequestError>()("Search.Request", {
providerID: Integration.ID,
cause: Schema.Defect(),
}) {}
export type Error =
| ProviderRequiredError
| ProviderNotFoundError
| ConnectionRequiredError
| CancelledError
| RequestError
export interface QueryInput extends Input {
readonly sessionID?: string
}
export interface Interface {
readonly query: (input: QueryInput) => Effect.Effect<Result, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Search") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const forms = yield* Form.Service
const integrations = yield* Integration.Service
const onboarding = Semaphore.makeUnsafe(1)
const decodeOutput = Schema.decodeUnknownEffect(ProviderOutput)
const available = Effect.fn("Search.available")(function* () {
return new Map(
(yield* integrations.capability.search.list()).map((provider) => [provider.integrationID, provider]),
)
})
const requireProvider = (
providers: Map<Integration.ID, Integration.SearchImplementation>,
providerID: Integration.ID,
) => {
const provider = providers.get(providerID)
return provider ? Effect.succeed(provider) : Effect.fail(new ProviderNotFoundError({ providerID }))
}
const configured = Effect.fn("Search.configured")(function* () {
const providerID = Config.latest(yield* config.entries(), "search")?.provider
if (providerID) return providerID
if (process.env.OPENCODE_WEBSEARCH_PROVIDER) {
return Integration.ID.make(process.env.OPENCODE_WEBSEARCH_PROVIDER)
}
if (truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL")) {
return Integration.ID.make("parallel")
}
if (truthy("OPENCODE_EXPERIMENTAL") || truthy("OPENCODE_ENABLE_EXA") || truthy("OPENCODE_EXPERIMENTAL_EXA")) {
return Integration.ID.make("exa")
}
})
const ask = Effect.fn("Search.ask")(function* (
providers: Map<Integration.ID, Integration.SearchImplementation>,
sessionID: string,
) {
if (providers.size === 0) return yield* new ProviderRequiredError()
const infos = new Map((yield* integrations.list()).map((integration) => [integration.id, integration]))
const state = yield* forms
.ask({
sessionID,
title: "Choose a web search provider",
metadata: { kind: "search.provider" },
mode: "form",
fields: [
{
key: "provider",
title: "Provider",
description: "This becomes your default and can be changed later from Connect integration.",
type: "string",
required: true,
custom: false,
options: Array.from(providers.values())
.flatMap((provider) => {
const info = infos.get(provider.integrationID)
return info ? [{ provider, info }] : []
})
.toSorted((a, b) => a.info.name.localeCompare(b.info.name))
.map(({ provider, info }) => ({
value: info.id,
label: info.name,
description: info.connections.length
? "Connected"
: provider.capability.connection === "optional"
? "Keyless available"
: "Connection required",
})),
},
],
})
.pipe(Effect.orDie)
if (state.status === "cancelled") return yield* new CancelledError()
const answer = state.answer.provider
if (typeof answer !== "string") return yield* new ProviderRequiredError()
return yield* requireProvider(providers, Integration.ID.make(answer))
})
const connect = Effect.fn("Search.connect")(function* (
provider: Integration.SearchImplementation,
sessionID?: string,
) {
const active = yield* integrations.connection.active(provider.integrationID)
if (active || provider.capability.connection === "optional") return active
if (!sessionID) return yield* new ConnectionRequiredError({ providerID: provider.integrationID })
const state = yield* forms
.ask({
sessionID,
title: `Connect ${provider.integrationID}`,
metadata: { kind: "integration.connection" },
mode: "integration",
integrationID: provider.integrationID,
})
.pipe(Effect.orDie)
if (state.status === "cancelled") return yield* new CancelledError()
const connected = yield* integrations.connection.active(provider.integrationID)
if (!connected) return yield* new ConnectionRequiredError({ providerID: provider.integrationID })
return connected
})
const select = Effect.fn("Search.select")(function* (input: QueryInput) {
const providers = yield* available()
if (input.providerID) return yield* requireProvider(providers, input.providerID)
const override = yield* configured()
if (override) return yield* requireProvider(providers, override)
const selected = yield* integrations.capability.search.selected()
const provider = selected ? providers.get(selected) : undefined
if (provider) return provider
const sessionID = input.sessionID
if (!sessionID) return yield* new ProviderRequiredError()
return yield* onboarding.withPermit(
Effect.gen(function* () {
const current = yield* integrations.capability.search.selected()
const selected = current ? providers.get(current) : undefined
if (selected) return selected
const provider = yield* ask(providers, sessionID)
yield* connect(provider, sessionID)
yield* integrations.capability.search.select(provider.integrationID)
return provider
}),
)
})
const query = Effect.fn("Search.query")(function* (input: QueryInput) {
const provider = yield* select(input)
const connection = yield* connect(provider, input.sessionID)
const credential = connection
? yield* integrations.connection
.resolve(connection)
.pipe(Effect.mapError((cause) => new RequestError({ providerID: provider.integrationID, cause })))
: undefined
const output = yield* provider.execute(input, { credential, sessionID: input.sessionID }).pipe(
Effect.flatMap(decodeOutput),
Effect.mapError((cause) => new RequestError({ providerID: provider.integrationID, cause })),
)
return new Result({ providerID: provider.integrationID, ...output })
})
return Service.of({ query })
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [Config.node, Form.node, Integration.node] })

View file

@ -2,197 +2,58 @@ export * as WebSearchTool from "./websearch"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
import { ToolFailure } from "@opencode-ai/llm"
import { Context, Duration, Effect, Layer, Schema } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { makeLocationNode } from "../effect/app-node"
import { truthy } from "../flag/flag"
import { InstallationVersion } from "../installation/version"
import { Effect, Schema } from "effect"
import { Integration } from "../integration"
import { PositiveInt } from "../schema"
import { PermissionV2 } from "../permission"
import { Search } from "../search"
import { SearchExa } from "../plugin/search/exa"
import { SearchMcp } from "../plugin/search/mcp"
import { SearchParallel } from "../plugin/search/parallel"
import { Tool } from "./tool"
import { collectBoundedResponseBody } from "./http-body"
import { checksum } from "../util/encode"
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 EXA_URL = SearchExa.endpoint
export const PARALLEL_URL = SearchParallel.endpoint
export const MAX_NUM_RESULTS = 20
export const MAX_CONTEXT_CHARACTERS = 50_000
export const MAX_RESPONSE_BYTES = 256 * 1024
export const MAX_RESPONSE_BYTES = SearchMcp.MAX_RESPONSE_BYTES
export const parseResponse = SearchMcp.parseResponse
/**
* 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.
export const description = `Search the web using the user's selected search integration. 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.
Optional controls support result count, live crawling ('fallback' or 'preferred'), search type ('auto', 'fast', or 'deep'), and maximum context characters. Providers apply supported controls and otherwise use their defaults.
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})`,
description: `Number of search results to return (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')",
description: "Live crawl preference when supported by the selected provider",
}),
type: Schema.optional(Schema.Literals(["auto", "fast", "deep"])).annotate({
description: "Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search",
description: "Search depth preference when supported by the selected provider",
}),
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})`,
},
{ description: `Maximum context characters (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: truthy("OPENCODE_EXPERIMENTAL") || truthy("OPENCODE_ENABLE_EXA") || truthy("OPENCODE_EXPERIMENTAL_EXA"),
enableParallel: truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL"),
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,
provider: Integration.ID,
text: Schema.String,
metadata: Schema.optional(Schema.Json),
})
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 search = yield* Search.Service
yield* ctx.tool
.register({
@ -201,50 +62,24 @@ export const Plugin = {
input: Input,
output: Output,
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
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.assistantMessageID, callID: context.toolCallID },
})
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,
})
: 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": `opencode/${InstallationVersion}`,
...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}),
},
)
const result = yield* search.query({ ...input, sessionID: context.sessionID })
return {
provider,
text: text ?? NO_RESULTS,
provider: result.providerID,
text: result.text || NO_RESULTS,
metadata: result.metadata,
}
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to search the web for ${input.query}` })))
},
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to search the web for ${input.query}` }))),
}),
})
.pipe(Effect.orDie)

View file

@ -4,6 +4,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Form } from "@opencode-ai/core/form"
import { Integration } from "@opencode-ai/core/integration"
import { SessionSchema } from "@opencode-ai/core/session/schema"
import { testEffect } from "./lib/effect"
@ -59,6 +60,27 @@ describe("Form", () => {
}),
)
it.effect("uses an empty reply to complete an integration form", () =>
Effect.gen(function* () {
const service = yield* Form.Service
const created = yield* service.create({
sessionID: "ses_test",
mode: "integration",
integrationID: Integration.ID.make("exa"),
})
const invalid = yield* service.reply({ id: created.id, answer: { connected: true } }).pipe(Effect.flip)
expect(invalid).toEqual(
new Form.InvalidAnswerError({
id: created.id,
message: "Integration forms must be answered with an empty answer",
}),
)
yield* service.reply({ id: created.id, answer: {} })
expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: {} })
}),
)
it.effect("gates required fields and rejects inactive answers via when", () =>
Effect.gen(function* () {
const service = yield* Form.Service

View file

@ -21,7 +21,7 @@ describe("Integration", () => {
.transform((editor) => editor.update(openai, (integration) => (integration.name = "OpenAI")))
.pipe(Scope.provide(scope))
expect(yield* integrations.get(openai)).toEqual(
new Integration.Info({ id: openai, name: "OpenAI", methods: [], connections: [] }),
new Integration.Info({ id: openai, name: "OpenAI", methods: [], capabilities: [], connections: [] }),
)
yield* Scope.close(scope, Exit.void)

View file

@ -45,6 +45,7 @@ export function host(overrides: Overrides = {}): PluginContext {
integration: overrides.integration ?? {
list: () => Effect.die("unused integration.list"),
get: () => Effect.die("unused integration.get"),
selectCapability: () => Effect.die("unused integration.selectCapability"),
connectKey: () => Effect.die("unused integration.connectKey"),
connectOauth: () => Effect.die("unused integration.connectOauth"),
attemptStatus: () => Effect.die("unused integration.attemptStatus"),
@ -188,6 +189,7 @@ export function integrationHost(integration: Integration.Interface): PluginConte
return {
list: () => Effect.die("unused integration.list"),
get: () => Effect.die("unused integration.get"),
selectCapability: () => Effect.die("unused integration.selectCapability"),
connectKey: () => Effect.die("unused integration.connectKey"),
connectOauth: () => Effect.die("unused integration.connectOauth"),
attemptStatus: () => Effect.die("unused integration.attemptStatus"),
@ -281,6 +283,18 @@ export function integrationHost(integration: Integration.Interface): PluginConte
},
remove: (id, item) => draft.method.remove(Integration.ID.make(id), internalMethod(item)),
},
capability: {
search: {
list: () => [],
update: (input) =>
draft.capability.search.update({
integrationID: Integration.ID.make(input.integrationID),
capability: input.capability,
execute: input.execute,
}),
remove: (id) => draft.capability.search.remove(Integration.ID.make(id)),
},
},
}),
),
}

View file

@ -156,6 +156,7 @@ describe("ModelsDevPlugin", () => {
names: ["ACME_API_KEY"],
},
],
capabilities: [],
connections: [],
}),
])

View file

@ -1,6 +1,7 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Integration } from "@opencode-ai/core/integration"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
@ -93,4 +94,29 @@ describe("fromPromise", () => {
expect(yield* agents.get(AgentV2.ID.make("temp"))).toBeUndefined()
}),
)
it.effect("adapts promise search capability execution", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make(plugin)
const promisePlugin = define({
id: "promise-search",
setup: async (ctx) => {
await ctx.integration.transform((draft) => {
draft.capability.search.update({
integrationID: "promise-search",
capability: { type: "search", connection: "optional" },
execute: async (input) => ({ text: `promise: ${input.query}` }),
})
})
},
})
yield* PluginPromise.fromPromise(promisePlugin).effect(host)
const provider = yield* integrations.capability.search.get(Integration.ID.make("promise-search"))
if (!provider) return yield* Effect.die("Expected promise search provider")
expect(yield* provider.execute({ query: "effect" }, {})).toEqual({ text: "promise: effect" })
}),
)
})

View file

@ -0,0 +1,41 @@
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/core/effect/layer-node"
import { Credential } from "@opencode-ai/core/credential"
import { EventV2 } from "@opencode-ai/core/event"
import { Integration } from "@opencode-ai/core/integration"
import { testEffect } from "../lib/effect"
export interface SearchRequest {
readonly url: string
readonly headers: Record<string, string>
readonly body: unknown
}
export const requests: SearchRequest[] = []
export const response = { body: "" }
export function resetSearchFixture(body: string) {
requests.length = 0
response.body = 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(response.body, { status: 200 }))
}),
),
)
export const searchIntegrationTest = testEffect(
Layer.merge(AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node, EventV2.node])), http),
)

View file

@ -0,0 +1,109 @@
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 { host, integrationHost } from "./host"
import { requests, resetSearchFixture, searchIntegrationTest } from "./search-fixture"
beforeEach(() => {
resetSearchFixture(
JSON.stringify({
jsonrpc: "2.0",
id: 1,
result: { content: [{ type: "text", text: "search results" }] },
}),
)
})
const it = searchIntegrationTest
describe("built-in search integrations", () => {
it.effect("registers Exa and maps search hints to its MCP tool", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
yield* SearchExa.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"] }],
capabilities: [{ type: "search", connection: "optional", selected: false }],
})
const provider = yield* integrations.capability.search.get(Integration.ID.make("exa"))
if (!provider) return yield* Effect.die("Expected Exa search provider")
expect(
yield* provider.execute(
{
query: "effect typescript",
numResults: 3,
livecrawl: "preferred",
type: "fast",
contextMaxCharacters: 2500,
},
{ credential: Credential.Key.make({ type: "key", key: "exa secret" }) },
),
).toEqual({ text: "search results" })
expect(requests).toEqual([
{
url: `${SearchExa.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",
type: "fast",
numResults: 3,
livecrawl: "preferred",
contextMaxCharacters: 2500,
},
},
},
},
])
}),
)
it.effect("registers Parallel and keeps its credential in the authorization header", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
yield* SearchParallel.Plugin.effect(host({ integration: integrationHost(integrations) }))
const provider = yield* integrations.capability.search.get(Integration.ID.make("parallel"))
if (!provider) return yield* Effect.die("Expected Parallel search provider")
const output = yield* provider.execute(
{ query: "effect layers" },
{
sessionID: "ses_parallel",
credential: Credential.Key.make({ type: "key", key: "parallel-secret" }),
},
)
expect(output).toEqual({ text: "search results" })
expect(requests[0]).toMatchObject({
url: SearchParallel.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"],
session_id: "ses_parallel",
},
},
},
})
expect(JSON.stringify(output)).not.toContain("parallel-secret")
}),
)
})

View file

@ -0,0 +1,148 @@
import { beforeEach, describe, expect } from "bun:test"
import { Effect, Exit, Fiber, Layer, Scope } from "effect"
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 { ConfigSearch } from "@opencode-ai/core/config/search"
import { Credential } from "@opencode-ai/core/credential"
import { EventV2 } from "@opencode-ai/core/event"
import { Form } from "@opencode-ai/core/form"
import { Integration } from "@opencode-ai/core/integration"
import { Search } from "@opencode-ai/core/search"
import { testEffect } from "./lib/effect"
let entries: Config.Entry[] = []
const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed(entries) }))
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Search.node, Integration.node, Credential.node, EventV2.node, Form.node]), [
[Config.node, config],
]),
)
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 }[] = []
yield* integrations.transform((draft) => {
draft.update(integrationID, (integration) => (integration.name = id.toUpperCase()))
draft.capability.search.update({
integrationID,
capability: { type: "search", connection },
execute: (input, context) =>
Effect.sync(() => {
calls.push({ input, ...context })
return { text: `${id}: ${input.query}`, metadata: { id } }
}),
})
})
return { integrationID, calls }
})
beforeEach(() => {
entries = []
})
describe("Search", () => {
it.effect("executes an explicit provider without changing the default", () =>
Effect.gen(function* () {
const provider = yield* register("exa")
const search = yield* Search.Service
const integrations = yield* Integration.Service
expect(yield* search.query({ query: "effect", providerID: provider.integrationID })).toEqual(
new Search.Result({
providerID: provider.integrationID,
text: "exa: effect",
metadata: { id: "exa" },
}),
)
expect(yield* integrations.capability.search.selected()).toBeUndefined()
expect(provider.calls).toEqual([
{
input: { query: "effect", providerID: provider.integrationID },
credential: undefined,
sessionID: undefined,
},
])
}),
)
it.effect("uses the persisted integration capability selection", () =>
Effect.gen(function* () {
yield* register("exa")
const parallel = yield* register("parallel")
const integrations = yield* Integration.Service
const search = yield* Search.Service
yield* integrations.capability.search.select(parallel.integrationID)
expect((yield* search.query({ query: "layers" })).providerID).toBe(parallel.integrationID)
expect((yield* integrations.get(parallel.integrationID))?.capabilities).toEqual([
{ type: "search", connection: "optional", selected: true },
])
}),
)
it.effect("prefers the location config over the global selection", () =>
Effect.gen(function* () {
const exa = yield* register("exa")
const parallel = yield* register("parallel")
const integrations = yield* Integration.Service
const search = yield* Search.Service
yield* integrations.capability.search.select(exa.integrationID)
entries = [
new Config.Document({
type: "document",
info: new Config.Info({ search: new ConfigSearch.Info({ provider: parallel.integrationID }) }),
}),
]
expect((yield* search.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 forms = yield* Form.Service
const integrations = yield* Integration.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)
yield* Effect.yieldNow
const pending = yield* forms.list({ sessionID: "ses_search" })
expect(pending).toHaveLength(1)
const form = pending[0]
if (!form) return yield* Effect.die("Expected an onboarding form")
yield* forms.reply({ id: form.id, answer: { provider: provider.integrationID } })
expect((yield* Fiber.join(first)).providerID).toBe(provider.integrationID)
expect((yield* Fiber.join(second)).providerID).toBe(provider.integrationID)
expect(yield* integrations.capability.search.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
expect(
yield* search.query({ query: "secret", providerID: provider.integrationID }).pipe(Effect.flip),
).toBeInstanceOf(Search.ConnectionRequiredError)
expect(provider.calls).toEqual([])
}),
)
it.effect("removes scoped provider registrations", () =>
Effect.gen(function* () {
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.capability.search.get(provider.integrationID)).toBeDefined()
yield* Scope.close(scope, Exit.void)
expect(yield* integrations.capability.search.get(provider.integrationID)).toBeUndefined()
}),
)
})

View file

@ -1,22 +1,22 @@
import { beforeEach, describe, expect, test } from "bun:test"
import { Effect, Layer, Schema } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { Integration } from "@opencode-ai/core/integration"
import { Search } from "@opencode-ai/core/search"
import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { WebSearchTool } from "@opencode-ai/core/tool/websearch"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
import { executeTool, registerToolPlugin, settleTool, toolDefinitions, toolIdentity } from "./lib/tool"
const webSearchToolNode = makeLocationNode({
name: "test/websearch-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(WebSearchTool.Plugin)),
deps: [ToolRegistry.toolsNode, PermissionV2.node, LayerNodePlatform.httpClient, WebSearchTool.configNode],
deps: [ToolRegistry.toolsNode, PermissionV2.node, Search.node],
})
const sessionID = SessionV2.ID.make("ses_websearch_test")
@ -27,31 +27,13 @@ const payload = (text: string) =>
result: { content: [{ type: "text", text }] },
})
describe("WebSearchTool provider selection", () => {
describe("WebSearchTool input", () => {
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", () => {
@ -68,37 +50,16 @@ describe("WebSearchTool MCP response parser", () => {
})
})
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: Search.QueryInput[] = []
let result = new Search.Result({ providerID: Integration.ID.make("exa"), text: "search results" })
beforeEach(() => {
responseBody = payload("search results")
makeResponse = () => new Response(responseBody, { status: 200 })
assertions.length = 0
queries.length = 0
result = new Search.Result({ providerID: Integration.ID.make("exa"), text: "search results" })
})
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({
@ -110,45 +71,27 @@ 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 search = Layer.succeed(
Search.Service,
Search.Service.of({
query: (input) =>
Effect.sync(() => {
queries.push(input)
return result
}),
}),
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebSearchTool.configNode, webSearchToolNode]),
[
[PermissionV2.node, permission],
[LayerNodePlatform.httpClient, http],
[WebSearchTool.configNode, websearchConfig],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
],
),
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, Search.node, webSearchToolNode]), [
[PermissionV2.node, permission],
[Search.node, search],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
]),
)
describe("WebSearchTool registration", () => {
it.effect("registers websearch, asserts query permission, and calls Exa", () =>
it.effect("asserts permission before delegating to Search", () =>
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"])
@ -158,7 +101,7 @@ describe("WebSearchTool registration", () => {
...toolIdentity,
call: {
type: "tool-call",
id: "call-exa",
id: "call-search",
name: "websearch",
input: {
query: "effect typescript",
@ -169,7 +112,7 @@ describe("WebSearchTool registration", () => {
},
},
}),
).toEqual({ type: "text", value: "exa results" })
).toEqual({ type: "text", value: "search results" })
expect(assertions).toMatchObject([
{
sessionID,
@ -182,98 +125,50 @@ describe("WebSearchTool registration", () => {
livecrawl: "preferred",
type: "fast",
contextMaxCharacters: 2500,
provider: "exa",
},
},
])
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,
},
},
},
sessionID,
query: "effect typescript",
numResults: 3,
livecrawl: "preferred",
type: "fast",
contextMaxCharacters: 2500,
},
])
}),
)
it.effect("calls Parallel with session ID and keeps bearer credentials out of output", () =>
it.effect("keeps provider metadata 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 Search.Result({
providerID: Integration.ID.make("parallel"),
text: "parallel results",
metadata: { requestID: "req_1" },
})
const registry = yield* ToolRegistry.Service
const settled = yield* settleTool(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* settleTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-parallel", name: "websearch", input: { query: "effect layers" } },
}),
).toEqual({
result: { type: "text", value: "parallel results" },
output: {
structured: { provider: "parallel", text: "parallel results" },
structured: { provider: "parallel", text: "parallel results", metadata: { requestID: "req_1" } },
content: [{ type: "text", text: "parallel results" }],
},
})
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* settleTool(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 Search.Result({ providerID: Integration.ID.make("exa"), text: "" })
const registry = yield* ToolRegistry.Service
expect(
@ -285,39 +180,4 @@ describe("WebSearchTool registration", () => {
).toEqual({ type: "text", value: WebSearchTool.NO_RESULTS })
}),
)
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" } },
}),
).toEqual({ type: "error", value: "Unable to search the web for too much" })
expect(chunksRead).toBeLessThan(10)
expect(cancelled).toBe(true)
}),
)
})