refactor(websearch): decouple providers from integrations

This commit is contained in:
Shoubhit Dash 2026-07-09 20:04:20 +05:30
parent c3da28f76a
commit 820b1a1a46
40 changed files with 479 additions and 483 deletions

View file

@ -939,35 +939,43 @@ export interface DebugApi<E = never> {
readonly location: { readonly list: DebugLocationListOperation<E>; readonly evict: DebugLocationEvictOperation<E> }
}
type Endpoint27_0Request = Parameters<RawClient["server.websearch"]["websearch.provider.get"]>[0]
type Endpoint27_0Request = Parameters<RawClient["server.websearch"]["websearch.provider.list"]>[0]
export type Endpoint27_0Input = { readonly location?: Endpoint27_0Request["query"]["location"] }
export type Endpoint27_0Output = EffectValue<ReturnType<RawClient["server.websearch"]["websearch.provider.get"]>>
export type WebsearchProviderGetOperation<E = never> = (
export type Endpoint27_0Output = EffectValue<ReturnType<RawClient["server.websearch"]["websearch.provider.list"]>>
export type WebsearchProviderListOperation<E = never> = (
input?: Endpoint27_0Input,
) => Effect.Effect<Endpoint27_0Output, E>
type Endpoint27_1Request = Parameters<RawClient["server.websearch"]["websearch.provider.select"]>[0]
export type Endpoint27_1Input = {
readonly location?: Endpoint27_1Request["query"]["location"]
readonly providerID: Endpoint27_1Request["payload"]["providerID"]
}
export type Endpoint27_1Output = EffectValue<ReturnType<RawClient["server.websearch"]["websearch.provider.select"]>>
export type WebsearchProviderSelectOperation<E = never> = (
input: Endpoint27_1Input,
type Endpoint27_1Request = Parameters<RawClient["server.websearch"]["websearch.provider.selected"]>[0]
export type Endpoint27_1Input = { readonly location?: Endpoint27_1Request["query"]["location"] }
export type Endpoint27_1Output = EffectValue<ReturnType<RawClient["server.websearch"]["websearch.provider.selected"]>>
export type WebsearchProviderSelectedOperation<E = never> = (
input?: Endpoint27_1Input,
) => Effect.Effect<Endpoint27_1Output, E>
type Endpoint27_2Request = Parameters<RawClient["server.websearch"]["websearch.query"]>[0]
type Endpoint27_2Request = Parameters<RawClient["server.websearch"]["websearch.provider.select"]>[0]
export type Endpoint27_2Input = {
readonly location?: Endpoint27_2Request["query"]["location"]
readonly query: Endpoint27_2Request["payload"]["query"]
readonly providerID?: Endpoint27_2Request["payload"]["providerID"]
readonly providerID: Endpoint27_2Request["payload"]["providerID"]
}
export type Endpoint27_2Output = EffectValue<ReturnType<RawClient["server.websearch"]["websearch.query"]>>
export type WebsearchQueryOperation<E = never> = (input: Endpoint27_2Input) => Effect.Effect<Endpoint27_2Output, E>
export type Endpoint27_2Output = EffectValue<ReturnType<RawClient["server.websearch"]["websearch.provider.select"]>>
export type WebsearchProviderSelectOperation<E = never> = (
input: Endpoint27_2Input,
) => Effect.Effect<Endpoint27_2Output, E>
type Endpoint27_3Request = Parameters<RawClient["server.websearch"]["websearch.query"]>[0]
export type Endpoint27_3Input = {
readonly location?: Endpoint27_3Request["query"]["location"]
readonly query: Endpoint27_3Request["payload"]["query"]
readonly providerID?: Endpoint27_3Request["payload"]["providerID"]
}
export type Endpoint27_3Output = EffectValue<ReturnType<RawClient["server.websearch"]["websearch.query"]>>
export type WebsearchQueryOperation<E = never> = (input: Endpoint27_3Input) => Effect.Effect<Endpoint27_3Output, E>
export interface WebsearchApi<E = never> {
readonly provider: {
readonly get: WebsearchProviderGetOperation<E>
readonly list: WebsearchProviderListOperation<E>
readonly selected: WebsearchProviderSelectedOperation<E>
readonly select: WebsearchProviderSelectOperation<E>
}
readonly query: WebsearchQueryOperation<E>

View file

@ -1122,37 +1122,42 @@ const adaptGroup26 = (raw: RawClient["server.debug"]) => ({
location: { list: Endpoint26_0(raw), evict: Endpoint26_1(raw) },
})
type Endpoint27_0Request = Parameters<RawClient["server.websearch"]["websearch.provider.get"]>[0]
type Endpoint27_0Request = Parameters<RawClient["server.websearch"]["websearch.provider.list"]>[0]
type Endpoint27_0Input = { readonly location?: Endpoint27_0Request["query"]["location"] }
const Endpoint27_0 = (raw: RawClient["server.websearch"]) => (input?: Endpoint27_0Input) =>
raw["websearch.provider.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
raw["websearch.provider.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint27_1Request = Parameters<RawClient["server.websearch"]["websearch.provider.select"]>[0]
type Endpoint27_1Input = {
readonly location?: Endpoint27_1Request["query"]["location"]
readonly providerID: Endpoint27_1Request["payload"]["providerID"]
type Endpoint27_1Request = Parameters<RawClient["server.websearch"]["websearch.provider.selected"]>[0]
type Endpoint27_1Input = { readonly location?: Endpoint27_1Request["query"]["location"] }
const Endpoint27_1 = (raw: RawClient["server.websearch"]) => (input?: Endpoint27_1Input) =>
raw["websearch.provider.selected"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint27_2Request = Parameters<RawClient["server.websearch"]["websearch.provider.select"]>[0]
type Endpoint27_2Input = {
readonly location?: Endpoint27_2Request["query"]["location"]
readonly providerID: Endpoint27_2Request["payload"]["providerID"]
}
const Endpoint27_1 = (raw: RawClient["server.websearch"]) => (input: Endpoint27_1Input) =>
const Endpoint27_2 = (raw: RawClient["server.websearch"]) => (input: Endpoint27_2Input) =>
raw["websearch.provider.select"]({
query: { location: input["location"] },
payload: { providerID: input["providerID"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint27_2Request = Parameters<RawClient["server.websearch"]["websearch.query"]>[0]
type Endpoint27_2Input = {
readonly location?: Endpoint27_2Request["query"]["location"]
readonly query: Endpoint27_2Request["payload"]["query"]
readonly providerID?: Endpoint27_2Request["payload"]["providerID"]
type Endpoint27_3Request = Parameters<RawClient["server.websearch"]["websearch.query"]>[0]
type Endpoint27_3Input = {
readonly location?: Endpoint27_3Request["query"]["location"]
readonly query: Endpoint27_3Request["payload"]["query"]
readonly providerID?: Endpoint27_3Request["payload"]["providerID"]
}
const Endpoint27_2 = (raw: RawClient["server.websearch"]) => (input: Endpoint27_2Input) =>
const Endpoint27_3 = (raw: RawClient["server.websearch"]) => (input: Endpoint27_3Input) =>
raw["websearch.query"]({
query: { location: input["location"] },
payload: { query: input["query"], providerID: input["providerID"] },
}).pipe(Effect.mapError(mapClientError))
const adaptGroup27 = (raw: RawClient["server.websearch"]) => ({
provider: { get: Endpoint27_0(raw), select: Endpoint27_1(raw) },
query: Endpoint27_2(raw),
provider: { list: Endpoint27_0(raw), selected: Endpoint27_1(raw), select: Endpoint27_2(raw) },
query: Endpoint27_3(raw),
})
const adaptClient = (raw: RawClient) => ({

View file

@ -185,8 +185,10 @@ import type {
DebugLocationListOutput,
DebugLocationEvictInput,
DebugLocationEvictOutput,
WebsearchProviderGetInput,
WebsearchProviderGetOutput,
WebsearchProviderListInput,
WebsearchProviderListOutput,
WebsearchProviderSelectedInput,
WebsearchProviderSelectedOutput,
WebsearchProviderSelectInput,
WebsearchProviderSelectOutput,
WebsearchQueryInput,
@ -1586,13 +1588,25 @@ export function make(options: ClientOptions) {
},
websearch: {
provider: {
get: (input?: WebsearchProviderGetInput, requestOptions?: RequestOptions) =>
request<WebsearchProviderGetOutput>(
list: (input?: WebsearchProviderListInput, requestOptions?: RequestOptions) =>
request<WebsearchProviderListOutput>(
{
method: "GET",
path: `/api/websearch/provider`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [503, 401, 400],
empty: false,
},
requestOptions,
),
selected: (input?: WebsearchProviderSelectedInput, requestOptions?: RequestOptions) =>
request<WebsearchProviderSelectedOutput>(
{
method: "GET",
path: `/api/websearch/provider/selected`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
@ -1602,7 +1616,7 @@ export function make(options: ClientOptions) {
request<WebsearchProviderSelectOutput>(
{
method: "POST",
path: `/api/websearch/provider`,
path: `/api/websearch/provider/selected`,
query: { location: input["location"] },
body: { providerID: input["providerID"] },
successStatus: 204,

View file

@ -2412,7 +2412,6 @@ export type IntegrationListOutput = {
| { readonly type: "key"; readonly label?: string }
| { readonly type: "env"; readonly names: ReadonlyArray<string> }
>
readonly websearch?: { readonly connection: "optional" | "required" }
readonly connections: ReadonlyArray<
| { readonly type: "credential"; readonly id: string; readonly label: string }
| { readonly type: "env"; readonly name: string }
@ -2465,7 +2464,6 @@ export type IntegrationGetOutput = {
| { readonly type: "key"; readonly label?: string }
| { readonly type: "env"; readonly names: ReadonlyArray<string> }
>
readonly websearch?: { readonly connection: "optional" | "required" }
readonly connections: ReadonlyArray<
| { readonly type: "credential"; readonly id: string; readonly label: string }
| { readonly type: "env"; readonly name: string }
@ -2808,14 +2806,6 @@ export type FormRequestListOutput = {
readonly mode: "url"
readonly url: string
}
| {
readonly id: string
readonly sessionID: string
readonly title: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly mode: "integration"
readonly integrationID: string
}
>
}
@ -2928,14 +2918,6 @@ export type FormListOutput = {
readonly mode: "url"
readonly url: string
}
| {
readonly id: string
readonly sessionID: string
readonly title: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly mode: "integration"
readonly integrationID: string
}
>
}["data"]
@ -3632,14 +3614,6 @@ export type FormCreateOutput = {
readonly mode: "url"
readonly url: string
}
| {
readonly id: string
readonly sessionID: string
readonly title: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly mode: "integration"
readonly integrationID: string
}
}["data"]
export type FormGetInput = {
@ -3754,14 +3728,6 @@ export type FormGetOutput = {
readonly mode: "url"
readonly url: string
}
| {
readonly id: string
readonly sessionID: string
readonly title: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly mode: "integration"
readonly integrationID: string
}
}["data"]
export type FormStateInput = {
@ -5595,14 +5561,6 @@ export type EventSubscribeOutput =
readonly mode: "url"
readonly url: string
}
| {
readonly id: string
readonly sessionID: string
readonly title: string
readonly metadata?: { readonly [x: string]: unknown }
readonly mode: "integration"
readonly integrationID: string
}
}
}
| {
@ -5625,6 +5583,14 @@ export type EventSubscribeOutput =
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly id: string; readonly sessionID: string }
}
| {
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "websearch.updated"
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {}
}
| {
readonly id: string
readonly created: number
@ -6388,13 +6354,28 @@ export type DebugLocationEvictInput = {
export type DebugLocationEvictOutput = void
export type WebsearchProviderGetInput = {
export type WebsearchProviderListInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type WebsearchProviderGetOutput = {
export type WebsearchProviderListOutput = {
readonly location: {
readonly directory: string
readonly workspaceID?: string
readonly project: { readonly id: string; readonly directory: string }
}
readonly data: ReadonlyArray<{ readonly id: string; readonly name: string }>
}
export type WebsearchProviderSelectedInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type WebsearchProviderSelectedOutput = {
readonly location: {
readonly directory: string
readonly workspaceID?: string

View file

@ -41,7 +41,7 @@ test("exposes every standard HTTP API group", () => {
expect(Object.keys(client.integration.connect)).toEqual(["key", "oauth"])
expect(Object.keys(client.integration.attempt)).toEqual(["status", "complete", "cancel"])
expect(Object.keys(client.websearch)).toEqual(["provider", "query"])
expect(Object.keys(client.websearch.provider)).toEqual(["get", "select"])
expect(Object.keys(client.websearch.provider)).toEqual(["list", "selected", "select"])
expect(Object.keys(client.file)).toEqual(["read", "list", "find"])
expect(Object.keys(client.vcs)).toEqual(["status", "diff"])
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"])
@ -84,19 +84,27 @@ test("websearch provider methods use the public HTTP contract", async () => {
if (request.method === "POST") return new Response(null, { status: 204 })
return Response.json({
location: { directory: "/tmp/project", project: { id: "proj_test", directory: "/tmp/project" } },
data: "exa",
data: request.url.endsWith("/selected?location%5Bdirectory%5D=%2Ftmp%2Fproject")
? "exa"
: [{ id: "exa", name: "Exa" }],
})
},
})
expect(await client.websearch.provider.get({ location: { directory: "/tmp/project" } })).toMatchObject({ data: "exa" })
expect(await client.websearch.provider.list({ location: { directory: "/tmp/project" } })).toMatchObject({
data: [{ id: "exa", name: "Exa" }],
})
expect(await client.websearch.provider.selected({ location: { directory: "/tmp/project" } })).toMatchObject({
data: "exa",
})
await client.websearch.provider.select({ providerID: "parallel", location: { directory: "/tmp/project" } })
expect(requests.map((request) => [request.method, request.url])).toEqual([
["GET", "http://localhost:3000/api/websearch/provider?location%5Bdirectory%5D=%2Ftmp%2Fproject"],
["POST", "http://localhost:3000/api/websearch/provider?location%5Bdirectory%5D=%2Ftmp%2Fproject"],
["GET", "http://localhost:3000/api/websearch/provider/selected?location%5Bdirectory%5D=%2Ftmp%2Fproject"],
["POST", "http://localhost:3000/api/websearch/provider/selected?location%5Bdirectory%5D=%2Ftmp%2Fproject"],
])
expect(await requests[1]?.json()).toEqual({ providerID: "parallel" })
expect(await requests[2]?.json()).toEqual({ providerID: "parallel" })
})
test("server.get uses the public HTTP contract", async () => {

View file

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

View file

@ -67,7 +67,6 @@ 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
@ -137,11 +136,7 @@ export const layer = Layer.effect(
...(input.metadata === undefined ? {} : { metadata: input.metadata }),
}
const form: Info =
input.mode === "form"
? { ...base, mode: "form", fields: input.fields }
: input.mode === "url"
? { ...base, mode: "url", url: input.url }
: { ...base, mode: "integration", integrationID: input.integrationID }
input.mode === "form" ? { ...base, mode: "form", fields: input.fields } : { ...base, mode: "url", url: input.url }
const entry: Entry = {
form,
state: { status: "pending" },
@ -233,7 +228,7 @@ export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.n
function validateAnswer(form: Info, answer: Answer) {
if (form.mode !== "form") {
if (Object.keys(answer).length === 0) return
return `${form.mode === "url" ? "URL" : "Integration"} forms must be answered with an empty answer`
return "URL 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

@ -16,7 +16,6 @@ import {
Types,
} from "effect"
import { Integration } from "@opencode-ai/schema/integration"
import { WebSearch } from "@opencode-ai/schema/websearch"
import { Credential } from "./credential"
import { State } from "./state"
import { EventV2 } from "./event"
@ -95,15 +94,6 @@ export interface EnvImplementation {
export type Implementation = OAuthImplementation | KeyImplementation | EnvImplementation
export interface WebSearchImplementation {
readonly integrationID: ID
readonly connection: Integration.WebSearch["connection"]
readonly execute: (
input: WebSearch.Input,
context: { readonly credential?: Credential.Value; readonly sessionID?: string },
) => Effect.Effect<WebSearch.ProviderOutput, unknown>
}
export const Attempt = Integration.Attempt
export type Attempt = Integration.Attempt
@ -129,7 +119,6 @@ type Entry = {
ref: Types.DeepMutable<Ref>
methods: Types.DeepMutable<Method>[]
implementations: Map<MethodID, Types.DeepMutable<OAuthImplementation>>
websearch?: Types.DeepMutable<WebSearchImplementation>
}
type Data = {
@ -146,11 +135,6 @@ export type Draft = {
update: (implementation: Implementation) => void
remove: (integrationID: ID, method: Method) => void
}
websearch: {
list: () => readonly WebSearchImplementation[]
update: (implementation: WebSearchImplementation) => void
remove: (integrationID: ID) => void
}
}
export interface Interface extends State.Transformable<Draft> {
@ -207,10 +191,6 @@ export interface Interface extends State.Transformable<Draft> {
/** Cancels an attempt and releases its resources. */
readonly cancel: (attemptID: AttemptID) => Effect.Effect<void>
}
readonly websearch: {
readonly list: () => Effect.Effect<readonly WebSearchImplementation[]>
readonly get: (integrationID: ID) => Effect.Effect<WebSearchImplementation | undefined>
}
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Integration") {}
@ -302,30 +282,6 @@ const layer = Layer.effect(
if (method.type === "oauth") current.implementations.delete(method.id)
},
},
websearch: {
list: () =>
Array.from(draft.integrations.values()).flatMap((entry) =>
entry.websearch ? [entry.websearch as WebSearchImplementation] : [],
),
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.websearch = implementation as Types.DeepMutable<WebSearchImplementation>
},
remove: (integrationID) => {
const current = draft.integrations.get(integrationID)
if (current) delete current.websearch
},
},
}),
finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid),
})
@ -350,7 +306,6 @@ const layer = Layer.effect(
id: entry.ref.id,
name: entry.ref.name,
methods: entry.methods,
websearch: entry.websearch ? { connection: entry.websearch.connection } : undefined,
connections,
})
@ -595,16 +550,6 @@ const layer = Layer.effect(
if (attempt) yield* Scope.close(attempt.scope, Exit.void)
}),
},
websearch: {
list: Effect.fn("Integration.websearch.list")(function* () {
return Array.from(state.get().integrations.values()).flatMap((entry) =>
entry.websearch ? [entry.websearch as WebSearchImplementation] : [],
)
}),
get: Effect.fn("Integration.websearch.get")(function* (integrationID) {
return state.get().integrations.get(integrationID)?.websearch as WebSearchImplementation | undefined
}),
},
})
}),
)

View file

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

View file

@ -24,6 +24,7 @@ import { Tool } from "../tool/tool"
import { Tools } from "../tool/tools"
import { ToolHooks } from "../tool/hooks"
import { WorkspaceV2 } from "../workspace"
import { WebSearch } from "../websearch"
import { PluginHooks } from "./hooks"
const mutable = <T>(value: T) => value as DeepMutable<T>
@ -38,6 +39,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
const reference = yield* Reference.Service
const skill = yield* SkillV2.Service
const tools = yield* Tools.Service
const websearch = yield* WebSearch.Service
const toolHooks = yield* ToolHooks.Service
const hooks = yield* PluginHooks.Service
const runtime = yield* PluginRuntime.Service
@ -304,6 +306,14 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
})
},
},
websearch: {
register: (definition) =>
websearch.register({
id: WebSearch.ID.make(definition.id),
name: definition.name,
execute: definition.execute,
}),
},
session: {
hook: (name, callback) => hooks.register("session", name, callback),
create: (input) =>
@ -345,12 +355,6 @@ function registerIntegration(draft: Integration.Draft, definition: IntegrationDe
}),
)
}
if (!definition.websearch) return
draft.websearch.update({
integrationID,
connection: definition.websearch.connection,
execute: definition.websearch.execute,
})
}
function methodImplementation(input: IntegrationMethodRegistration): Integration.Implementation {

View file

@ -114,6 +114,20 @@ export function fromPromise(plugin: PromisePlugin) {
hook: (name, callback) =>
register(host.tool.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
},
websearch: {
register: (definition) =>
register(
host.websearch.register({
id: definition.id,
name: definition.name,
execute: (input, execution) =>
Effect.tryPromise({
try: (signal) => definition.execute(input, { ...execution, signal }),
catch: (cause) => cause,
}),
}),
),
},
session: {
create: (input) => run(host.session.create(input)),
get: (input) => run(host.session.get(input)),
@ -131,7 +145,7 @@ export function fromPromise(plugin: PromisePlugin) {
}
function adaptIntegration(definition: IntegrationDefinition) {
const { methods, websearch, ...definitionInfo } = definition
const { methods, ...definitionInfo } = definition
return {
...definitionInfo,
methods: methods?.map((method) => {
@ -163,20 +177,5 @@ function adaptIntegration(definition: IntegrationDefinition) {
: {}),
}
}),
...(websearch
? {
websearch: {
connection: websearch.connection,
execute: (
input: Parameters<typeof websearch.execute>[0],
execution: Omit<Parameters<typeof websearch.execute>[1], "signal">,
) =>
Effect.tryPromise({
try: (signal) => websearch.execute(input, { ...execution, signal }),
catch: (cause) => cause,
}),
},
}
: {}),
}
}

View file

@ -33,12 +33,17 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
{ type: "key", label: "API key (optional)" },
{ type: "env", names: ["EXA_API_KEY"] },
],
websearch: {
connection: "optional",
execute: (input, context) => {
})
yield* ctx.websearch.register({
id: "exa",
name: "Exa",
execute: (input) =>
Effect.gen(function* () {
const connection = yield* ctx.integration.connection.active("exa")
const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined
const url = new URL(endpoint)
if (context.credential?.type === "key") url.searchParams.set("exaApiKey", context.credential.key)
return WebSearchMcp.call(
if (credential?.type === "key") url.searchParams.set("exaApiKey", credential.key)
return yield* WebSearchMcp.call(
http,
url.toString(),
"web_search_exa",
@ -53,8 +58,7 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
}
}),
)
},
},
}),
})
}),
})

View file

@ -60,10 +60,15 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
{ type: "key", label: "API key (optional)" },
{ type: "env", names: ["PARALLEL_API_KEY"] },
],
websearch: {
connection: "optional",
execute: (input, context) =>
WebSearchMcp.call(
})
yield* ctx.websearch.register({
id: "parallel",
name: "Parallel",
execute: (input, context) =>
Effect.gen(function* () {
const connection = yield* ctx.integration.connection.active("parallel")
const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined
return yield* WebSearchMcp.call(
http,
endpoint,
"web_search",
@ -75,7 +80,7 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
},
{
"User-Agent": `opencode/${InstallationVersion}`,
...(context.credential?.type === "key" ? { Authorization: `Bearer ${context.credential.key}` } : {}),
...(credential?.type === "key" ? { Authorization: `Bearer ${credential.key}` } : {}),
},
).pipe(
Effect.map((result) => {
@ -85,8 +90,8 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
...(result ? { metadata: result.structuredContent } : {}),
}
}),
),
},
)
}),
})
}),
})

View file

@ -3,7 +3,6 @@ export * as WebSearchTool from "./websearch"
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Schema } from "effect"
import { Integration } from "../integration"
import { PermissionV2 } from "../permission"
import { WebSearch } from "../websearch"
import { Tool } from "./tool"
@ -20,7 +19,7 @@ export const Input = Schema.Struct({
})
const Output = Schema.Struct({
provider: Integration.ID,
provider: WebSearch.ID,
text: Schema.String,
metadata: Schema.optional(Schema.Json),
})

View file

@ -2,7 +2,7 @@ export * as WebSearch from "./websearch"
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 { Context, Effect, Layer, Schema, Scope, Semaphore, Stream } from "effect"
import path from "node:path"
import { Config } from "./config"
import { ConfigGlobal } from "./config/global"
@ -11,8 +11,16 @@ import { makeLocationNode } from "./effect/app-node"
import { EventV2 } from "./event"
import { Form } from "./form"
import { Global } from "./global"
import { Integration } from "./integration"
import { truthy } from "./flag/flag"
import { State } from "./state"
export const ID = WebSearch.ID
export type ID = WebSearch.ID
export const Provider = WebSearch.Provider
export type Provider = WebSearch.Provider
export const Event = WebSearch.Event
export const Input = WebSearch.Input
export type Input = WebSearch.Input
@ -23,31 +31,32 @@ export type ProviderOutput = WebSearch.ProviderOutput
export const Result = WebSearch.Result
export type Result = WebSearch.Result
export interface ProviderImplementation extends Provider {
readonly execute: (
input: Pick<Input, "query">,
context: { readonly sessionID?: string },
) => Effect.Effect<ProviderOutput, unknown>
}
export class ProviderRequiredError extends Schema.TaggedErrorClass<ProviderRequiredError>()(
"WebSearch.ProviderRequired",
{},
) {}
export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()("WebSearch.ProviderNotFound", {
providerID: Integration.ID,
providerID: ID,
}) {}
export class ConnectionRequiredError extends Schema.TaggedErrorClass<ConnectionRequiredError>()(
"WebSearch.ConnectionRequired",
{ providerID: Integration.ID },
) {}
export class CancelledError extends Schema.TaggedErrorClass<CancelledError>()("WebSearch.Cancelled", {}) {}
export class RequestError extends Schema.TaggedErrorClass<RequestError>()("WebSearch.Request", {
providerID: Integration.ID,
providerID: ID,
cause: Schema.Defect(),
}) {}
export type Error =
| ProviderRequiredError
| ProviderNotFoundError
| ConnectionRequiredError
| CancelledError
| RequestError
@ -56,13 +65,25 @@ export interface QueryInput extends Input {
}
export interface Interface {
readonly selected: () => Effect.Effect<Integration.ID | undefined>
readonly select: (providerID: Integration.ID) => Effect.Effect<void, ProviderNotFoundError>
readonly register: (
provider: ProviderImplementation,
) => Effect.Effect<State.Registration, never, Scope.Scope>
readonly list: () => Effect.Effect<readonly Provider[]>
readonly selected: () => Effect.Effect<ID | undefined>
readonly select: (providerID: ID) => Effect.Effect<void, ProviderNotFoundError>
readonly query: (input: QueryInput) => Effect.Effect<Result, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/WebSearch") {}
type Data = {
readonly providers: Map<ID, ProviderImplementation>
}
type Draft = {
register: (provider: ProviderImplementation) => void
}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
@ -71,16 +92,19 @@ const layer = Layer.effect(
const events = yield* EventV2.Service
const forms = yield* Form.Service
const global = yield* Global.Service
const integrations = yield* Integration.Service
const onboarding = Semaphore.makeUnsafe(1)
const decodeOutput = Schema.decodeUnknownEffect(ProviderOutput)
const globalConfigPath = path.resolve(global.config)
let pendingProviderID: Integration.ID | undefined
let pendingProviderID: ID | undefined
const state = State.create<Data, Draft>({
initial: () => ({ providers: new Map() }),
draft: (draft) => ({
register: (provider) => draft.providers.set(provider.id, provider),
}),
finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid),
})
const requireProvider = (
providers: Map<Integration.ID, Integration.WebSearchImplementation>,
providerID: Integration.ID,
) => {
const requireProvider = (providers: Map<ID, ProviderImplementation>, providerID: ID) => {
const provider = providers.get(providerID)
return provider ? Effect.succeed(provider) : Effect.fail(new ProviderNotFoundError({ providerID }))
}
@ -96,7 +120,7 @@ const layer = Layer.effect(
return pendingProviderID ?? (yield* globalProviderID())
})
const saveProvider = Effect.fn("WebSearch.saveProvider")(function* (providerID: Integration.ID) {
const saveProvider = Effect.fn("WebSearch.saveProvider")(function* (providerID: ID) {
pendingProviderID = providerID
yield* configGlobal.update(["websearch"], new ConfigWebSearch.Info({ provider: providerID })).pipe(
Effect.tapError(() => Effect.sync(() => (pendingProviderID = undefined))),
@ -118,12 +142,8 @@ const layer = Layer.effect(
Effect.forkScoped,
)
const ask = Effect.fn("WebSearch.ask")(function* (
providers: Map<Integration.ID, Integration.WebSearchImplementation>,
sessionID: string,
) {
const ask = Effect.fn("WebSearch.ask")(function* (providers: Map<ID, ProviderImplementation>, 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,
@ -134,22 +154,15 @@ const layer = Layer.effect(
{
key: "provider",
title: "Provider",
description: "This becomes your default and can be changed later from Connect integration.",
description: "This becomes your default and can be changed later from Connect.",
type: "string",
required: true,
custom: false,
options: Array.from(providers.values())
.flatMap((provider) => {
const info = infos.get(provider.integrationID)
if (!info) return []
const disconnected = provider.connection === "optional" ? "Keyless available" : "Connection required"
return [{ info, description: info.connections.length ? "Connected" : disconnected }]
})
.toSorted((a, b) => a.info.name.localeCompare(b.info.name))
.map(({ info, description }) => ({
value: info.id,
label: info.name,
description,
.toSorted((a, b) => a.name.localeCompare(b.name))
.map((provider) => ({
value: provider.id,
label: provider.name,
})),
},
],
@ -158,46 +171,22 @@ const layer = Layer.effect(
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("WebSearch.connect")(function* (
provider: Integration.WebSearchImplementation,
sessionID?: string,
) {
const active = yield* integrations.connection.active(provider.integrationID)
if (active || provider.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
return yield* requireProvider(providers, ID.make(answer))
})
const resolve = Effect.fn("WebSearch.resolve")(function* (input: QueryInput) {
const providers = new Map(
(yield* integrations.websearch.list()).map((provider) => [provider.integrationID, provider]),
)
const providers = state.get().providers
if (input.providerID) return yield* requireProvider(providers, input.providerID)
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))
return yield* requireProvider(providers, ID.make(process.env.OPENCODE_WEBSEARCH_PROVIDER))
}
if (truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL")) {
return yield* requireProvider(providers, Integration.ID.make("parallel"))
return yield* requireProvider(providers, ID.make("parallel"))
}
if (truthy("OPENCODE_EXPERIMENTAL") || truthy("OPENCODE_ENABLE_EXA") || truthy("OPENCODE_EXPERIMENTAL_EXA")) {
return yield* requireProvider(providers, Integration.ID.make("exa"))
return yield* requireProvider(providers, ID.make("exa"))
}
const providerID = yield* selected()
const provider = providerID ? providers.get(providerID) : undefined
@ -210,33 +199,32 @@ const layer = Layer.effect(
const selectedProvider = current ? providers.get(current) : undefined
if (selectedProvider) return selectedProvider
const provider = yield* ask(providers, sessionID)
yield* connect(provider, sessionID)
yield* saveProvider(provider.integrationID)
yield* saveProvider(provider.id)
return provider
}),
)
})
return Service.of({
register: (provider) => state.transform((draft) => draft.register(provider)),
list: Effect.fn("WebSearch.list")(function* () {
return Array.from(state.get().providers.values(), (provider) => ({ id: provider.id, name: provider.name })).toSorted(
(a, b) => a.name.localeCompare(b.name),
)
}),
selected,
select: Effect.fn("WebSearch.select")(function* (providerID) {
const provider = yield* integrations.websearch.get(providerID)
const provider = state.get().providers.get(providerID)
if (!provider) return yield* new ProviderNotFoundError({ providerID })
yield* saveProvider(providerID)
}),
query: Effect.fn("WebSearch.query")(function* (input) {
const provider = yield* resolve(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(
const output = yield* provider.execute({ query: input.query }, { sessionID: input.sessionID }).pipe(
Effect.flatMap(decodeOutput),
Effect.mapError((cause) => new RequestError({ providerID: provider.integrationID, cause })),
Effect.mapError((cause) => new RequestError({ providerID: provider.id, cause })),
)
return new Result({ providerID: provider.integrationID, ...output })
return new Result({ providerID: provider.id, ...output })
}),
})
}),
@ -245,5 +233,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [Config.node, ConfigGlobal.node, EventV2.node, Form.node, Global.node, Integration.node],
deps: [Config.node, ConfigGlobal.node, EventV2.node, Form.node, Global.node],
})

View file

@ -4,7 +4,6 @@ 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"
@ -63,28 +62,6 @@ 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",
title: "Connect integration",
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

@ -155,10 +155,6 @@ function resourceMcpLayer(url: string) {
complete: unusedIntegration,
cancel: unusedIntegration,
},
websearch: {
list: unusedIntegration,
get: unusedIntegration,
},
}),
Layer.mock(Credential.Service, {}),
),

View file

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

View file

@ -6,6 +6,7 @@ import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { WebSearch } from "@opencode-ai/core/websearch"
import type {
CredentialOAuth,
IntegrationEnvMethod,
@ -84,6 +85,9 @@ export function host(overrides: Overrides = {}): Plugin.Context {
transform: () => Effect.die("unused tool.transform"),
hook: () => Effect.die("unused tool.hook"),
},
websearch: overrides.websearch ?? {
register: () => Effect.die("unused websearch.register"),
},
session: overrides.session ?? {
create: () => Effect.die("unused session.create"),
get: () => Effect.die("unused session.get"),
@ -236,6 +240,17 @@ export function integrationHost(integration: Integration.Interface): Plugin.Cont
}
}
export function webSearchHost(websearch: WebSearch.Interface): Plugin.Context["websearch"] {
return {
register: (definition) =>
websearch.register({
id: WebSearch.ID.make(definition.id),
name: definition.name,
execute: definition.execute,
}),
}
}
function registerIntegration(draft: Integration.Draft, definition: IntegrationDefinition) {
const integrationID = Integration.ID.make(definition.id)
draft.update(integrationID, (integration) => (integration.name = definition.name))
@ -259,12 +274,6 @@ function registerIntegration(draft: Integration.Draft, definition: IntegrationDe
}),
)
}
if (!definition.websearch) return
draft.websearch.update({
integrationID,
connection: definition.websearch.connection,
execute: definition.websearch.execute,
})
}
function methodImplementation(input: IntegrationMethodRegistration): Integration.Implementation {

View file

@ -1,10 +1,10 @@
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"
import { WebSearch } from "@opencode-ai/core/websearch"
import { Plugin } from "@opencode-ai/plugin/v2"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
@ -95,34 +95,33 @@ describe("fromPromise", () => {
}),
)
it.effect("adapts promise web search capability execution", () =>
it.effect("registers a standalone web search provider", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const websearch = yield* WebSearch.Service
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make(plugin)
const promisePlugin = Plugin.define({
id: "promise-websearch",
setup: async (ctx) => {
await ctx.integration.register({
await ctx.websearch.register({
id: "promise-websearch",
name: "Promise Web Search",
methods: [{ type: "env", names: ["PROMISE_WEBSEARCH_KEY"] }],
websearch: {
connection: "optional",
execute: async (input) => ({ text: `promise: ${input.query}` }),
},
execute: async (input) => ({ text: `promise: ${input.query}` }),
})
},
})
yield* PluginPromise.fromPromise(promisePlugin).effect(host)
expect(yield* integrations.get(Integration.ID.make("promise-websearch"))).toMatchObject({
expect(yield* websearch.list()).toContainEqual({
id: WebSearch.ID.make("promise-websearch"),
name: "Promise Web Search",
methods: [{ type: "env", names: ["PROMISE_WEBSEARCH_KEY"] }],
})
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" })
expect(
yield* websearch.query({ query: "effect", providerID: WebSearch.ID.make("promise-websearch") }),
).toEqual(
new WebSearch.Result({ providerID: WebSearch.ID.make("promise-websearch"), text: "promise: effect" }),
)
}),
)
})

View file

@ -3,8 +3,12 @@ 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 { Config } from "@opencode-ai/core/config"
import { ConfigGlobal } from "@opencode-ai/core/config/global"
import { EventV2 } from "@opencode-ai/core/event"
import { Form } from "@opencode-ai/core/form"
import { Integration } from "@opencode-ai/core/integration"
import { WebSearch } from "@opencode-ai/core/websearch"
import { testEffect } from "../lib/effect"
export interface WebSearchRequest {
@ -37,5 +41,24 @@ const http = Layer.succeed(
)
export const webSearchIntegrationTest = testEffect(
Layer.merge(AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node, EventV2.node])), http),
Layer.merge(
AppNodeBuilder.build(
LayerNode.group([
Integration.node,
Credential.node,
EventV2.node,
Form.node,
ConfigGlobal.node,
WebSearch.node,
]),
[
[Config.node, Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))],
[
ConfigGlobal.node,
Layer.succeed(ConfigGlobal.Service, ConfigGlobal.Service.of({ update: () => Effect.void })),
],
],
),
http,
),
)

View file

@ -1,10 +1,10 @@
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 { WebSearch } from "@opencode-ai/core/websearch"
import { WebSearchExa } from "@opencode-ai/core/plugin/websearch/exa"
import { WebSearchParallel } from "@opencode-ai/core/plugin/websearch/parallel"
import { host, integrationHost } from "./host"
import { host, integrationHost, webSearchHost } from "./host"
import { requests, resetWebSearchFixture, webSearchIntegrationTest } from "./websearch-fixture"
beforeEach(() => {
@ -19,50 +19,54 @@ beforeEach(() => {
const it = webSearchIntegrationTest
describe("built-in web search integrations", () => {
it.effect("registers and disposes an atomic web search integration", () =>
describe("built-in web search providers", () => {
it.effect("registers a provider without an integration", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const registration = yield* integrationHost(integrations).register({
const websearch = yield* WebSearch.Service
const registration = yield* webSearchHost(websearch).register({
id: "test-websearch",
name: "Test Web Search",
methods: [{ type: "key", label: "API key" }],
websearch: {
connection: "required",
execute: (input) => Effect.succeed({ text: input.query }),
},
execute: (input) => Effect.succeed({ text: input.query }),
})
expect(yield* integrations.get(Integration.ID.make("test-websearch"))).toMatchObject({
expect(yield* integrations.get(Integration.ID.make("test-websearch"))).toBeUndefined()
expect(yield* websearch.list()).toContainEqual({
id: WebSearch.ID.make("test-websearch"),
name: "Test Web Search",
methods: [{ type: "key", label: "API key" }],
websearch: { connection: "required" },
})
yield* registration.dispose
expect(yield* integrations.get(Integration.ID.make("test-websearch"))).toBeUndefined()
expect(yield* websearch.list()).not.toContainEqual({
id: WebSearch.ID.make("test-websearch"),
name: "Test Web Search",
})
}),
)
it.effect("registers Exa with its MCP schema", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
yield* WebSearchExa.Plugin.effect(host({ integration: integrationHost(integrations) }))
const websearch = yield* WebSearch.Service
yield* WebSearchExa.Plugin.effect(
host({ integration: integrationHost(integrations), websearch: webSearchHost(websearch) }),
)
const info = yield* integrations.get(Integration.ID.make("exa"))
expect(info).toMatchObject({
id: "exa",
name: "Exa",
methods: [{ type: "key" }, { type: "env", names: ["EXA_API_KEY"] }],
websearch: { connection: "optional" },
})
const provider = yield* integrations.websearch.get(Integration.ID.make("exa"))
if (!provider) return yield* Effect.die("Expected Exa web search provider")
yield* integrations.connection.key({ integrationID: Integration.ID.make("exa"), key: "exa secret" })
expect(
yield* provider.execute(
{ query: "effect typescript" },
{ credential: Credential.Key.make({ type: "key", key: "exa secret" }) },
),
).toEqual({ text: "search results", metadata: { searchTime: 123 } })
yield* websearch.query({ query: "effect typescript", providerID: WebSearch.ID.make("exa") }),
).toEqual(
new WebSearch.Result({
providerID: WebSearch.ID.make("exa"),
text: "search results",
metadata: { searchTime: 123 },
}),
)
expect(requests).toEqual([
{
url: `${WebSearchExa.endpoint}?exaApiKey=exa+secret`,
@ -107,34 +111,37 @@ describe("built-in web search integrations", () => {
}),
)
const integrations = yield* Integration.Service
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" },
{
sessionID: "ses_parallel",
credential: Credential.Key.make({ type: "key", key: "parallel-secret" }),
},
const websearch = yield* WebSearch.Service
yield* WebSearchParallel.Plugin.effect(
host({ integration: integrationHost(integrations), websearch: webSearchHost(websearch) }),
)
expect(output).toEqual({
text: "search results",
metadata: {
search_id: "search_1",
results: [
{
url: "https://effect.website",
title: "Effect",
publish_date: null,
excerpts: ["Effect documentation"],
},
],
warnings: null,
usage: [{ name: "sku_search", count: 1 }],
session_id: "ses_parallel",
},
yield* integrations.connection.key({ integrationID: Integration.ID.make("parallel"), key: "parallel-secret" })
const output = yield* websearch.query({
query: "effect layers",
providerID: WebSearch.ID.make("parallel"),
sessionID: "ses_parallel",
})
expect(output).toEqual(
new WebSearch.Result({
providerID: WebSearch.ID.make("parallel"),
text: "search results",
metadata: {
search_id: "search_1",
results: [
{
url: "https://effect.website",
title: "Effect",
publish_date: null,
excerpts: ["Effect documentation"],
},
],
warnings: null,
usage: [{ name: "sku_search", count: 1 }],
session_id: "ses_parallel",
},
}),
)
expect(requests[0]).toMatchObject({
url: WebSearchParallel.endpoint,
headers: { authorization: "Bearer parallel-secret" },

View file

@ -3,7 +3,6 @@ import { Effect, Layer } from "effect"
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 { WebSearch } from "@opencode-ai/core/websearch"
import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
@ -22,12 +21,12 @@ const webSearchToolNode = makeLocationNode({
const sessionID = SessionV2.ID.make("ses_websearch_test")
const assertions: PermissionV2.AssertInput[] = []
const queries: WebSearch.QueryInput[] = []
let result = new WebSearch.Result({ providerID: Integration.ID.make("exa"), text: "search results" })
let result = new WebSearch.Result({ providerID: WebSearch.ID.make("exa"), text: "search results" })
beforeEach(() => {
assertions.length = 0
queries.length = 0
result = new WebSearch.Result({ providerID: Integration.ID.make("exa"), text: "search results" })
result = new WebSearch.Result({ providerID: WebSearch.ID.make("exa"), text: "search results" })
})
const permission = Layer.succeed(
@ -44,6 +43,8 @@ const permission = Layer.succeed(
const websearch = Layer.succeed(
WebSearch.Service,
WebSearch.Service.of({
register: () => Effect.die("unused"),
list: () => Effect.succeed([]),
selected: () => Effect.succeed(undefined),
select: () => Effect.die("unused"),
query: (input) =>
@ -100,7 +101,7 @@ describe("WebSearchTool registration", () => {
it.effect("keeps provider metadata in structured output", () =>
Effect.gen(function* () {
result = new WebSearch.Result({
providerID: Integration.ID.make("parallel"),
providerID: WebSearch.ID.make("parallel"),
text: "parallel results",
metadata: { requestID: "req_1" },
})
@ -124,7 +125,7 @@ describe("WebSearchTool registration", () => {
it.effect("uses the concise no-results fallback", () =>
Effect.gen(function* () {
result = new WebSearch.Result({ providerID: Integration.ID.make("exa"), text: "" })
result = new WebSearch.Result({ providerID: WebSearch.ID.make("exa"), text: "" })
const registry = yield* ToolRegistry.Service
expect(

View file

@ -6,11 +6,9 @@ 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 { 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 { WebSearch } from "@opencode-ai/core/websearch"
import { testEffect } from "./lib/effect"
@ -23,7 +21,7 @@ const configGlobal = Layer.succeed(
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([WebSearch.node, Integration.node, Credential.node, EventV2.node, Form.node, ConfigGlobal.node]),
LayerNode.group([WebSearch.node, EventV2.node, Form.node, ConfigGlobal.node]),
[
[Config.node, config],
[ConfigGlobal.node, configGlobal],
@ -31,24 +29,21 @@ const it = testEffect(
),
)
const register = (id: string, connection: "optional" | "required" = "optional") =>
const register = (id: string) =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const integrationID = Integration.ID.make(id)
const calls: { input: WebSearch.Input; credential?: Credential.Value; sessionID?: string }[] = []
yield* integrations.transform((draft) => {
draft.update(integrationID, (integration) => (integration.name = id.toUpperCase()))
draft.websearch.update({
integrationID,
connection,
execute: (input, context) =>
Effect.sync(() => {
calls.push({ input, ...context })
return { text: `${id}: ${input.query}`, metadata: { id } }
}),
})
const websearch = yield* WebSearch.Service
const providerID = WebSearch.ID.make(id)
const calls: { input: Pick<WebSearch.Input, "query">; sessionID?: string }[] = []
yield* websearch.register({
id: providerID,
name: id.toUpperCase(),
execute: (input, context) =>
Effect.sync(() => {
calls.push({ input, ...context })
return { text: `${id}: ${input.query}`, metadata: { id } }
}),
})
return { integrationID, calls }
return { providerID, calls }
})
beforeEach(() => {
@ -62,9 +57,9 @@ describe("WebSearch", () => {
const provider = yield* register("exa")
const websearch = yield* WebSearch.Service
expect(yield* websearch.query({ query: "effect", providerID: provider.integrationID })).toEqual(
expect(yield* websearch.query({ query: "effect", providerID: provider.providerID })).toEqual(
new WebSearch.Result({
providerID: provider.integrationID,
providerID: provider.providerID,
text: "exa: effect",
metadata: { id: "exa" },
}),
@ -72,8 +67,7 @@ describe("WebSearch", () => {
expect(yield* websearch.selected()).toBeUndefined()
expect(provider.calls).toEqual([
{
input: { query: "effect", providerID: provider.integrationID },
credential: undefined,
input: { query: "effect" },
sessionID: undefined,
},
])
@ -85,12 +79,12 @@ describe("WebSearch", () => {
yield* register("exa")
const parallel = yield* register("parallel")
const websearch = yield* WebSearch.Service
yield* websearch.select(parallel.integrationID)
yield* websearch.select(parallel.providerID)
expect((yield* websearch.query({ query: "layers" })).providerID).toBe(parallel.integrationID)
expect(yield* websearch.selected()).toBe(parallel.integrationID)
expect((yield* websearch.query({ query: "layers" })).providerID).toBe(parallel.providerID)
expect(yield* websearch.selected()).toBe(parallel.providerID)
expect(writes).toEqual([
{ path: ["websearch"], value: new ConfigWebSearch.Info({ provider: parallel.integrationID }) },
{ path: ["websearch"], value: new ConfigWebSearch.Info({ provider: parallel.providerID }) },
])
}),
)
@ -103,12 +97,12 @@ describe("WebSearch", () => {
new Config.Document({
type: "document",
path: path.join(Global.Path.config, "opencode.json"),
info: new Config.Info({ websearch: new ConfigWebSearch.Info({ provider: provider.integrationID }) }),
info: new Config.Info({ websearch: new ConfigWebSearch.Info({ provider: provider.providerID }) }),
}),
]
expect(yield* websearch.selected()).toBe(provider.integrationID)
expect((yield* websearch.query({ query: "configured" })).providerID).toBe(provider.integrationID)
expect(yield* websearch.selected()).toBe(provider.providerID)
expect((yield* websearch.query({ query: "configured" })).providerID).toBe(provider.providerID)
}),
)
@ -117,15 +111,15 @@ describe("WebSearch", () => {
const exa = yield* register("exa")
const parallel = yield* register("parallel")
const websearch = yield* WebSearch.Service
yield* websearch.select(exa.integrationID)
yield* websearch.select(exa.providerID)
entries = [
new Config.Document({
type: "document",
info: new Config.Info({ websearch: new ConfigWebSearch.Info({ provider: parallel.integrationID }) }),
info: new Config.Info({ websearch: new ConfigWebSearch.Info({ provider: parallel.providerID }) }),
}),
]
expect((yield* websearch.query({ query: "configured" })).providerID).toBe(parallel.integrationID)
expect((yield* websearch.query({ query: "configured" })).providerID).toBe(parallel.providerID)
}),
)
@ -142,34 +136,22 @@ describe("WebSearch", () => {
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 } })
yield* forms.reply({ id: form.id, answer: { provider: provider.providerID } })
expect((yield* Fiber.join(first)).providerID).toBe(provider.integrationID)
expect((yield* Fiber.join(second)).providerID).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 websearch = yield* WebSearch.Service
expect(
yield* websearch.query({ query: "secret", providerID: provider.integrationID }).pipe(Effect.flip),
).toBeInstanceOf(WebSearch.ConnectionRequiredError)
expect(provider.calls).toEqual([])
expect((yield* Fiber.join(first)).providerID).toBe(provider.providerID)
expect((yield* Fiber.join(second)).providerID).toBe(provider.providerID)
expect(yield* websearch.selected()).toBe(provider.providerID)
}),
)
it.effect("removes scoped provider registrations", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const websearch = yield* WebSearch.Service
const scope = yield* Scope.fork(yield* Scope.Scope)
const provider = yield* register("temporary").pipe(Scope.provide(scope))
expect(yield* integrations.websearch.get(provider.integrationID)).toBeDefined()
expect(yield* websearch.list()).toContainEqual({ id: provider.providerID, name: "TEMPORARY" })
yield* Scope.close(scope, Exit.void)
expect(yield* integrations.websearch.get(provider.integrationID)).toBeUndefined()
expect(yield* websearch.list()).not.toContainEqual({ id: provider.providerID, name: "TEMPORARY" })
}),
)
})

View file

@ -9,3 +9,4 @@ export { Model } from "@opencode-ai/schema/model"
export { Provider } from "@opencode-ai/schema/provider"
export { Reference } from "@opencode-ai/schema/reference"
export { Skill } from "@opencode-ai/schema/skill"
export { WebSearch } from "@opencode-ai/schema/websearch"

View file

@ -10,7 +10,6 @@ import type {
IntegrationRef,
} from "@opencode-ai/sdk/v2/types"
import type { IntegrationApi } from "@opencode-ai/client/effect/api"
import type { WebSearch } from "@opencode-ai/schema/websearch"
import type { Effect, Scope } from "effect"
import type { Registration, Transform } from "./registration.js"
@ -53,19 +52,10 @@ export type IntegrationOAuthMethodDefinition = IntegrationOAuthMethod & {
export type IntegrationMethodDefinition = IntegrationOAuthMethodDefinition | IntegrationKeyMethod | IntegrationEnvMethod
export interface IntegrationWebSearchDefinition {
readonly connection: "optional" | "required"
readonly execute: (
input: WebSearch.Input,
context: { readonly credential?: CredentialValue; readonly sessionID?: string },
) => Effect.Effect<WebSearch.ProviderOutput, unknown>
}
export interface IntegrationDefinition {
readonly id: string
readonly name: string
readonly methods?: readonly IntegrationMethodDefinition[]
readonly websearch?: IntegrationWebSearchDefinition
}
export interface IntegrationDraft {

View file

@ -11,6 +11,7 @@ import type { ReferenceDomain } from "./reference.js"
import type { SessionDomain } from "./session.js"
import type { SkillDomain } from "./skill.js"
import type { ToolDomain } from "./tool.js"
import type { WebSearchDomain } from "./websearch.js"
export interface Context {
readonly options: PluginOptions
@ -25,6 +26,7 @@ export interface Context {
readonly session: SessionDomain
readonly skill: SkillDomain
readonly tool: ToolDomain
readonly websearch: WebSearchDomain
}
export interface Plugin<R = Scope.Scope> {

View file

@ -0,0 +1,16 @@
import type { WebSearch } from "@opencode-ai/schema/websearch"
import type { Effect, Scope } from "effect"
import type { Registration } from "./registration.js"
export interface WebSearchDefinition {
readonly id: string
readonly name: string
readonly execute: (
input: Pick<WebSearch.Input, "query">,
context: { readonly sessionID?: string },
) => Effect.Effect<WebSearch.ProviderOutput, unknown>
}
export interface WebSearchDomain {
readonly register: (definition: WebSearchDefinition) => Effect.Effect<Registration, never, Scope.Scope>
}

View file

@ -10,3 +10,4 @@ export { Model } from "@opencode-ai/schema/model"
export { Provider } from "@opencode-ai/schema/provider"
export { Reference } from "@opencode-ai/schema/reference"
export { Skill } from "@opencode-ai/schema/skill"
export { WebSearch } from "@opencode-ai/schema/websearch"

View file

@ -8,7 +8,6 @@ import type {
IntegrationKeyMethod,
IntegrationOAuthMethod,
} from "@opencode-ai/sdk/v2/types"
import type { WebSearch } from "@opencode-ai/schema/websearch"
import type { Registration, Transform } from "./registration.js"
export type { IntegrationDraft, IntegrationMethodRegistration }
@ -35,19 +34,10 @@ export type IntegrationOAuthMethodDefinition = IntegrationOAuthMethod & {
export type IntegrationMethodDefinition = IntegrationOAuthMethodDefinition | IntegrationKeyMethod | IntegrationEnvMethod
export interface IntegrationWebSearchDefinition {
readonly connection: "optional" | "required"
readonly execute: (
input: WebSearch.Input,
context: { readonly credential?: CredentialValue; readonly sessionID?: string; readonly signal: AbortSignal },
) => Promise<WebSearch.ProviderOutput>
}
export interface IntegrationDefinition {
readonly id: string
readonly name: string
readonly methods?: readonly IntegrationMethodDefinition[]
readonly websearch?: IntegrationWebSearchDefinition
}
export interface IntegrationDomain extends IntegrationApi {

View file

@ -10,6 +10,7 @@ import type { ReferenceDomain } from "./reference.js"
import type { SessionDomain } from "./session.js"
import type { SkillDomain } from "./skill.js"
import type { ToolDomain } from "./tool.js"
import type { WebSearchDomain } from "./websearch.js"
export interface Context {
readonly options: PluginOptions
@ -24,6 +25,7 @@ export interface Context {
readonly session: SessionDomain
readonly skill: SkillDomain
readonly tool: ToolDomain
readonly websearch: WebSearchDomain
}
export interface Plugin {

View file

@ -0,0 +1,15 @@
import type { WebSearch } from "@opencode-ai/schema/websearch"
import type { Registration } from "./registration.js"
export interface WebSearchDefinition {
readonly id: string
readonly name: string
readonly execute: (
input: Pick<WebSearch.Input, "query">,
context: { readonly sessionID?: string; readonly signal: AbortSignal },
) => Promise<WebSearch.ProviderOutput>
}
export interface WebSearchDomain {
readonly register: (definition: WebSearchDefinition) => Promise<Registration>
}

View file

@ -8,6 +8,7 @@ import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
import { Reference } from "@opencode-ai/schema/reference"
import { Skill } from "@opencode-ai/schema/skill"
import { WebSearch } from "@opencode-ai/schema/websearch"
const Plugin = await import("../src/v2/effect/index")
const PromisePlugin = await import("../src/v2/promise/index")
@ -25,6 +26,7 @@ test.each([
expect(entrypoint.Provider).toBe(Provider)
expect(entrypoint.Reference).toBe(Reference)
expect(entrypoint.Skill).toBe(Skill)
expect(entrypoint.WebSearch).toBe(WebSearch)
expect(Object.keys(entrypoint).sort()).toEqual([
"Agent",
"Command",
@ -36,5 +38,6 @@ test.each([
"Provider",
"Reference",
"Skill",
"WebSearch",
])
})

View file

@ -1,4 +1,3 @@
import { Integration } from "@opencode-ai/schema/integration"
import { Location } from "@opencode-ai/schema/location"
import { WebSearch } from "@opencode-ai/schema/websearch"
import { Schema } from "effect"
@ -8,23 +7,38 @@ import { LocationQuery, locationQueryOpenApi } from "./location.js"
export const WebSearchGroup = HttpApiGroup.make("server.websearch")
.add(
HttpApiEndpoint.get("websearch.provider.get", "/api/websearch/provider", {
HttpApiEndpoint.get("websearch.provider.list", "/api/websearch/provider", {
query: LocationQuery,
success: Location.response(Schema.UndefinedOr(Integration.ID)),
success: Location.response(Schema.Array(WebSearch.Provider)),
error: ServiceUnavailableError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.websearch.provider.get",
summary: "Get default web search provider",
identifier: "v2.websearch.provider.list",
summary: "List web search providers",
description: "Return the registered web search providers.",
}),
),
)
.add(
HttpApiEndpoint.get("websearch.provider.selected", "/api/websearch/provider/selected", {
query: LocationQuery,
success: Location.response(Schema.UndefinedOr(WebSearch.ID)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.websearch.provider.selected",
summary: "Get selected web search provider",
description: "Return the globally selected web search provider.",
}),
),
)
.add(
HttpApiEndpoint.post("websearch.provider.select", "/api/websearch/provider", {
HttpApiEndpoint.post("websearch.provider.select", "/api/websearch/provider/selected", {
query: LocationQuery,
payload: Schema.Struct({ providerID: Integration.ID }),
payload: Schema.Struct({ providerID: WebSearch.ID }),
success: HttpApiSchema.NoContent,
error: [InvalidRequestError, ServiceUnavailableError],
})
@ -50,7 +64,7 @@ export const WebSearchGroup = HttpApiGroup.make("server.websearch")
identifier: "v2.websearch.query",
summary: "Search the web",
description:
"Run one web search through the selected integration. Specify a provider to override the configured default.",
"Run one web search through the selected provider. Specify a provider to override the configured default.",
}),
),
)

View file

@ -36,6 +36,7 @@ import { TuiEvent } from "./tui-event.js"
import { VcsEvent } from "./vcs-event.js"
import { WorkspaceEvent } from "./workspace-event.js"
import { WorktreeEvent } from "./worktree-event.js"
import { WebSearch } from "./websearch.js"
const sessionV1DurableDefinitions = SessionV1.Event.Definitions.filter(
(definition) => definition.durability === "durable",
@ -67,6 +68,7 @@ const featureDefinitions = Event.inventory(
...Shell.Event.Definitions,
...Question.Event.Definitions,
...Form.Event.Definitions,
...WebSearch.Event.Definitions,
)
export const ServerDefinitions = Event.inventory(

View file

@ -3,7 +3,6 @@ export * as Form from "./form.js"
import { Schema } from "effect"
import { ephemeral, inventory } from "./event.js"
import { ascending } from "./identifier.js"
import { IntegrationID } from "./integration-id.js"
import { NonNegativeInt, optional, statics } from "./schema.js"
const IDSchema = Schema.String.check(Schema.isStartsWith("frm_")).pipe(Schema.brand("Form.ID"))
@ -125,15 +124,8 @@ export const UrlInfo = Schema.Struct({
}).annotate({ identifier: "Form.UrlInfo" })
export interface UrlInfo extends Schema.Schema.Type<typeof UrlInfo> {}
export const IntegrationInfo = Schema.Struct({
...InfoBase,
mode: Schema.Literal("integration"),
integrationID: IntegrationID,
}).annotate({ identifier: "Form.IntegrationInfo" })
export interface IntegrationInfo extends Schema.Schema.Type<typeof IntegrationInfo> {}
export const Info = Schema.Union([FormInfo, UrlInfo, IntegrationInfo]).pipe(Schema.toTaggedUnion("mode"))
export type Info = FormInfo | UrlInfo | IntegrationInfo
export const Info = Schema.Union([FormInfo, UrlInfo]).pipe(Schema.toTaggedUnion("mode"))
export type Info = FormInfo | UrlInfo
export const Value = Schema.Union([Schema.String, Schema.Number, Schema.Boolean, Schema.Array(Schema.String)]).annotate(
{

View file

@ -76,11 +76,6 @@ export type Method = typeof Method.Type
export const Inputs = Schema.Record(Schema.String, Schema.String).annotate({ identifier: "Integration.Inputs" })
export type Inputs = typeof Inputs.Type
export interface WebSearch extends Schema.Schema.Type<typeof WebSearch> {}
export const WebSearch = Schema.Struct({
connection: Schema.Literals(["optional", "required"]),
}).annotate({ identifier: "Integration.WebSearch" })
const Updated = ephemeral({
type: "integration.updated",
schema: {},
@ -101,7 +96,6 @@ export class Info extends Schema.Class<Info>("Integration.Info")({
id: ID,
name: Schema.String,
methods: Schema.Array(Method),
websearch: optional(WebSearch),
connections: Schema.Array(Connection.Info),
}) {}

View file

@ -1,13 +1,22 @@
export * as WebSearch from "./websearch.js"
import { Schema } from "effect"
import { IntegrationID } from "./integration-id.js"
import { ephemeral, inventory } from "./event.js"
import { optional } from "./schema.js"
export const ID = Schema.String.pipe(Schema.brand("WebSearch.ID"))
export type ID = typeof ID.Type
export interface Provider extends Schema.Schema.Type<typeof Provider> {}
export const Provider = Schema.Struct({
id: ID,
name: Schema.String,
}).annotate({ identifier: "WebSearch.Provider" })
export interface Input extends Schema.Schema.Type<typeof Input> {}
export const Input = Schema.Struct({
query: Schema.String,
providerID: IntegrationID.pipe(optional),
providerID: ID.pipe(optional),
}).annotate({ identifier: "WebSearch.Input" })
export interface ProviderOutput extends Schema.Schema.Type<typeof ProviderOutput> {}
@ -17,6 +26,12 @@ export const ProviderOutput = Schema.Struct({
}).annotate({ identifier: "WebSearch.ProviderOutput" })
export class Result extends Schema.Class<Result>("WebSearch.Result")({
providerID: IntegrationID,
providerID: ID,
...ProviderOutput.fields,
}) {}
const Updated = ephemeral({
type: "websearch.updated",
schema: {},
})
export const Event = { Updated, Definitions: inventory(Updated) }

View file

@ -302,21 +302,18 @@ it.live(
10_000,
)
it.live("embedded client exposes integration-backed web search", () =>
it.live("embedded client exposes plugin-backed web search", () =>
withEmbedded("opencode-embedded-websearch-", (fixture) =>
Effect.gen(function* () {
const opencode = yield* fixture.sdk.OpenCode.create()
const providerID = fixture.sdk.Integration.ID.make("embedded-websearch")
const providerID = fixture.sdk.WebSearch.ID.make("embedded-websearch")
yield* opencode.plugin({
id: `embedded-websearch-${crypto.randomUUID()}`,
effect: (ctx) =>
ctx.integration.register({
ctx.websearch.register({
id: providerID,
name: "Embedded web search",
websearch: {
connection: "optional",
execute: (input) => Effect.succeed({ text: `Found ${input.query}`, metadata: { source: "embedded" } }),
},
execute: (input) => Effect.succeed({ text: `Found ${input.query}`, metadata: { source: "embedded" } }),
}),
})

View file

@ -16,7 +16,7 @@ export const WebSearchHandler = HttpApiBuilder.group(Api, "server.websearch", (h
orElse: () =>
Effect.fail(
new ServiceUnavailableError({
message: "Web search integration initialization timed out",
message: "Web search provider initialization timed out",
service: "websearch",
}),
),
@ -25,8 +25,16 @@ export const WebSearchHandler = HttpApiBuilder.group(Api, "server.websearch", (h
})
return handlers
.handle(
"websearch.provider.get",
Effect.fn("server.websearch.provider.get")(function* () {
"websearch.provider.list",
Effect.fn("server.websearch.provider.list")(function* () {
yield* awaitPlugins()
const websearch = yield* WebSearch.Service
return yield* response(websearch.list())
}),
)
.handle(
"websearch.provider.selected",
Effect.fn("server.websearch.provider.selected")(function* () {
const websearch = yield* WebSearch.Service
return yield* response(websearch.selected())
}),
@ -69,12 +77,6 @@ export const WebSearchHandler = HttpApiBuilder.group(Api, "server.websearch", (h
kind: "websearch_provider_not_found",
field: "providerID",
}),
"WebSearch.ConnectionRequired": (error) =>
new InvalidRequestError({
message: `Web search provider requires a connection: ${error.providerID}`,
kind: "websearch_connection_required",
field: "providerID",
}),
"WebSearch.Cancelled": () =>
new InvalidRequestError({ message: "Web search cancelled", kind: "websearch_cancelled" }),
"WebSearch.Request": (error) =>