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

@ -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")
}),
)
})