diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 74d715b9ef..0f2a0a84f5 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -939,35 +939,43 @@ export interface DebugApi { readonly location: { readonly list: DebugLocationListOperation; readonly evict: DebugLocationEvictOperation } } -type Endpoint27_0Request = Parameters[0] +type Endpoint27_0Request = Parameters[0] export type Endpoint27_0Input = { readonly location?: Endpoint27_0Request["query"]["location"] } -export type Endpoint27_0Output = EffectValue> -export type WebsearchProviderGetOperation = ( +export type Endpoint27_0Output = EffectValue> +export type WebsearchProviderListOperation = ( input?: Endpoint27_0Input, ) => Effect.Effect -type Endpoint27_1Request = Parameters[0] -export type Endpoint27_1Input = { - readonly location?: Endpoint27_1Request["query"]["location"] - readonly providerID: Endpoint27_1Request["payload"]["providerID"] -} -export type Endpoint27_1Output = EffectValue> -export type WebsearchProviderSelectOperation = ( - input: Endpoint27_1Input, +type Endpoint27_1Request = Parameters[0] +export type Endpoint27_1Input = { readonly location?: Endpoint27_1Request["query"]["location"] } +export type Endpoint27_1Output = EffectValue> +export type WebsearchProviderSelectedOperation = ( + input?: Endpoint27_1Input, ) => Effect.Effect -type Endpoint27_2Request = Parameters[0] +type Endpoint27_2Request = Parameters[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> -export type WebsearchQueryOperation = (input: Endpoint27_2Input) => Effect.Effect +export type Endpoint27_2Output = EffectValue> +export type WebsearchProviderSelectOperation = ( + input: Endpoint27_2Input, +) => Effect.Effect + +type Endpoint27_3Request = Parameters[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> +export type WebsearchQueryOperation = (input: Endpoint27_3Input) => Effect.Effect export interface WebsearchApi { readonly provider: { - readonly get: WebsearchProviderGetOperation + readonly list: WebsearchProviderListOperation + readonly selected: WebsearchProviderSelectedOperation readonly select: WebsearchProviderSelectOperation } readonly query: WebsearchQueryOperation diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index 1c89dc326f..34d21b6e03 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -1122,37 +1122,42 @@ const adaptGroup26 = (raw: RawClient["server.debug"]) => ({ location: { list: Endpoint26_0(raw), evict: Endpoint26_1(raw) }, }) -type Endpoint27_0Request = Parameters[0] +type Endpoint27_0Request = Parameters[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[0] -type Endpoint27_1Input = { - readonly location?: Endpoint27_1Request["query"]["location"] - readonly providerID: Endpoint27_1Request["payload"]["providerID"] +type Endpoint27_1Request = Parameters[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[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[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[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) => ({ diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index d481cf26c4..e7ddf9bf4f 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -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( + list: (input?: WebsearchProviderListInput, requestOptions?: RequestOptions) => + request( { 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( + { + 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( { method: "POST", - path: `/api/websearch/provider`, + path: `/api/websearch/provider/selected`, query: { location: input["location"] }, body: { providerID: input["providerID"] }, successStatus: 204, diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index fd5fcfe15a..b1ecb4368b 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -2412,7 +2412,6 @@ export type IntegrationListOutput = { | { readonly type: "key"; readonly label?: string } | { readonly type: "env"; readonly names: ReadonlyArray } > - 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 } > - 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 diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index a18fe95c48..a6738ca7fc 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -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 () => { diff --git a/packages/core/src/config/websearch.ts b/packages/core/src/config/websearch.ts index c4ab0252c6..4db2a50fc6 100644 --- a/packages/core/src/config/websearch.ts +++ b/packages/core/src/config/websearch.ts @@ -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("ConfigWebSearch.Info")({ - provider: Integration.ID, + provider: WebSearch.ID, }) {} diff --git a/packages/core/src/form.ts b/packages/core/src/form.ts index 25d594e95c..d9b9ce46af 100644 --- a/packages/core/src/form.ts +++ b/packages/core/src/form.ts @@ -67,7 +67,6 @@ export class InvalidFormError extends Schema.TaggedErrorClass( export type CreateInput = | (Omit & { readonly id?: ID }) | (Omit & { readonly id?: ID }) - | (Omit & { 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)) { diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index 10f5d7a9cb..c4f7c3b8f2 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -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 -} - export const Attempt = Integration.Attempt export type Attempt = Integration.Attempt @@ -129,7 +119,6 @@ type Entry = { ref: Types.DeepMutable methods: Types.DeepMutable[] implementations: Map> - websearch?: Types.DeepMutable } 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 { @@ -207,10 +191,6 @@ export interface Interface extends State.Transformable { /** Cancels an attempt and releases its resources. */ readonly cancel: (attemptID: AttemptID) => Effect.Effect } - readonly websearch: { - readonly list: () => Effect.Effect - readonly get: (integrationID: ID) => Effect.Effect - } } export class Service extends Context.Service()("@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>(), - } - if (!draft.integrations.has(implementation.integrationID)) { - draft.integrations.set(implementation.integrationID, current) - } - current.websearch = implementation as Types.DeepMutable - }, - 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 - }), - }, }) }), ) diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index 4e0858fd16..3b96a5ef05 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -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, ], }) diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index e04a05f72f..6ff748d184 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -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 = (value: T) => value as DeepMutable @@ -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 { diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts index 0953c0cd4d..56dd976950 100644 --- a/packages/core/src/plugin/promise.ts +++ b/packages/core/src/plugin/promise.ts @@ -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[0], - execution: Omit[1], "signal">, - ) => - Effect.tryPromise({ - try: (signal) => websearch.execute(input, { ...execution, signal }), - catch: (cause) => cause, - }), - }, - } - : {}), } } diff --git a/packages/core/src/plugin/websearch/exa.ts b/packages/core/src/plugin/websearch/exa.ts index 6d962b642c..c3d6db4cd2 100644 --- a/packages/core/src/plugin/websearch/exa.ts +++ b/packages/core/src/plugin/websearch/exa.ts @@ -33,12 +33,17 @@ export const Plugin = define({ { 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({ } }), ) - }, - }, + }), }) }), }) diff --git a/packages/core/src/plugin/websearch/parallel.ts b/packages/core/src/plugin/websearch/parallel.ts index 31936a7d7f..16e3245afb 100644 --- a/packages/core/src/plugin/websearch/parallel.ts +++ b/packages/core/src/plugin/websearch/parallel.ts @@ -60,10 +60,15 @@ export const Plugin = define({ { 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({ }, { "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({ ...(result ? { metadata: result.structuredContent } : {}), } }), - ), - }, + ) + }), }) }), }) diff --git a/packages/core/src/tool/websearch.ts b/packages/core/src/tool/websearch.ts index ff5665b1b5..c9d5b109ff 100644 --- a/packages/core/src/tool/websearch.ts +++ b/packages/core/src/tool/websearch.ts @@ -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), }) diff --git a/packages/core/src/websearch.ts b/packages/core/src/websearch.ts index 8c5505d7de..0835f01abd 100644 --- a/packages/core/src/websearch.ts +++ b/packages/core/src/websearch.ts @@ -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, + context: { readonly sessionID?: string }, + ) => Effect.Effect +} + export class ProviderRequiredError extends Schema.TaggedErrorClass()( "WebSearch.ProviderRequired", {}, ) {} export class ProviderNotFoundError extends Schema.TaggedErrorClass()("WebSearch.ProviderNotFound", { - providerID: Integration.ID, + providerID: ID, }) {} -export class ConnectionRequiredError extends Schema.TaggedErrorClass()( - "WebSearch.ConnectionRequired", - { providerID: Integration.ID }, -) {} - export class CancelledError extends Schema.TaggedErrorClass()("WebSearch.Cancelled", {}) {} export class RequestError extends Schema.TaggedErrorClass()("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 - readonly select: (providerID: Integration.ID) => Effect.Effect + readonly register: ( + provider: ProviderImplementation, + ) => Effect.Effect + readonly list: () => Effect.Effect + readonly selected: () => Effect.Effect + readonly select: (providerID: ID) => Effect.Effect readonly query: (input: QueryInput) => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/WebSearch") {} +type Data = { + readonly providers: Map +} + +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({ + 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, - providerID: Integration.ID, - ) => { + const requireProvider = (providers: Map, 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, - sessionID: string, - ) { + const ask = Effect.fn("WebSearch.ask")(function* (providers: Map, 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], }) diff --git a/packages/core/test/form.test.ts b/packages/core/test/form.test.ts index a565afd594..f5cf16e159 100644 --- a/packages/core/test/form.test.ts +++ b/packages/core/test/form.test.ts @@ -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 diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index 10ff4025b1..32a0f94d88 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -155,10 +155,6 @@ function resourceMcpLayer(url: string) { complete: unusedIntegration, cancel: unusedIntegration, }, - websearch: { - list: unusedIntegration, - get: unusedIntegration, - }, }), Layer.mock(Credential.Service, {}), ), diff --git a/packages/core/test/plugin/fixture.ts b/packages/core/test/plugin/fixture.ts index ffef32738d..e058c43907 100644 --- a/packages/core/test/plugin/fixture.ts +++ b/packages/core/test/plugin/fixture.ts @@ -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 diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index 5f706d7dc0..c33464c998 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -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 { diff --git a/packages/core/test/plugin/promise.test.ts b/packages/core/test/plugin/promise.test.ts index 07454a7b9d..e02f48152a 100644 --- a/packages/core/test/plugin/promise.test.ts +++ b/packages/core/test/plugin/promise.test.ts @@ -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" }), + ) }), ) + }) diff --git a/packages/core/test/plugin/websearch-fixture.ts b/packages/core/test/plugin/websearch-fixture.ts index 24c5a5ca22..4ad61ad3e1 100644 --- a/packages/core/test/plugin/websearch-fixture.ts +++ b/packages/core/test/plugin/websearch-fixture.ts @@ -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, + ), ) diff --git a/packages/core/test/plugin/websearch.test.ts b/packages/core/test/plugin/websearch.test.ts index a417759162..1e7a6d23a4 100644 --- a/packages/core/test/plugin/websearch.test.ts +++ b/packages/core/test/plugin/websearch.test.ts @@ -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" }, diff --git a/packages/core/test/tool-websearch.test.ts b/packages/core/test/tool-websearch.test.ts index cb6dc445b2..de1d68ca22 100644 --- a/packages/core/test/tool-websearch.test.ts +++ b/packages/core/test/tool-websearch.test.ts @@ -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( diff --git a/packages/core/test/websearch.test.ts b/packages/core/test/websearch.test.ts index 39d93c2e0a..490e7c5308 100644 --- a/packages/core/test/websearch.test.ts +++ b/packages/core/test/websearch.test.ts @@ -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; 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" }) }), ) }) diff --git a/packages/plugin/src/v2/effect/index.ts b/packages/plugin/src/v2/effect/index.ts index e00e302b7a..09c46955a1 100644 --- a/packages/plugin/src/v2/effect/index.ts +++ b/packages/plugin/src/v2/effect/index.ts @@ -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" diff --git a/packages/plugin/src/v2/effect/integration.ts b/packages/plugin/src/v2/effect/integration.ts index b0bb1c64e5..14e15bfd66 100644 --- a/packages/plugin/src/v2/effect/integration.ts +++ b/packages/plugin/src/v2/effect/integration.ts @@ -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 -} - export interface IntegrationDefinition { readonly id: string readonly name: string readonly methods?: readonly IntegrationMethodDefinition[] - readonly websearch?: IntegrationWebSearchDefinition } export interface IntegrationDraft { diff --git a/packages/plugin/src/v2/effect/plugin.ts b/packages/plugin/src/v2/effect/plugin.ts index 3b0b6eb918..e89735d66a 100644 --- a/packages/plugin/src/v2/effect/plugin.ts +++ b/packages/plugin/src/v2/effect/plugin.ts @@ -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 { diff --git a/packages/plugin/src/v2/effect/websearch.ts b/packages/plugin/src/v2/effect/websearch.ts new file mode 100644 index 0000000000..5136dfd629 --- /dev/null +++ b/packages/plugin/src/v2/effect/websearch.ts @@ -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, + context: { readonly sessionID?: string }, + ) => Effect.Effect +} + +export interface WebSearchDomain { + readonly register: (definition: WebSearchDefinition) => Effect.Effect +} diff --git a/packages/plugin/src/v2/promise/index.ts b/packages/plugin/src/v2/promise/index.ts index ae8e6d18a1..786d150bb5 100644 --- a/packages/plugin/src/v2/promise/index.ts +++ b/packages/plugin/src/v2/promise/index.ts @@ -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" diff --git a/packages/plugin/src/v2/promise/integration.ts b/packages/plugin/src/v2/promise/integration.ts index e5eb38db6e..2c9a0fb6b2 100644 --- a/packages/plugin/src/v2/promise/integration.ts +++ b/packages/plugin/src/v2/promise/integration.ts @@ -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 -} - export interface IntegrationDefinition { readonly id: string readonly name: string readonly methods?: readonly IntegrationMethodDefinition[] - readonly websearch?: IntegrationWebSearchDefinition } export interface IntegrationDomain extends IntegrationApi { diff --git a/packages/plugin/src/v2/promise/plugin.ts b/packages/plugin/src/v2/promise/plugin.ts index 37bce3cc03..4b724e2015 100644 --- a/packages/plugin/src/v2/promise/plugin.ts +++ b/packages/plugin/src/v2/promise/plugin.ts @@ -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 { diff --git a/packages/plugin/src/v2/promise/websearch.ts b/packages/plugin/src/v2/promise/websearch.ts new file mode 100644 index 0000000000..94e3fc8c36 --- /dev/null +++ b/packages/plugin/src/v2/promise/websearch.ts @@ -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, + context: { readonly sessionID?: string; readonly signal: AbortSignal }, + ) => Promise +} + +export interface WebSearchDomain { + readonly register: (definition: WebSearchDefinition) => Promise +} diff --git a/packages/plugin/test/contract-identity.test.ts b/packages/plugin/test/contract-identity.test.ts index e0262760dc..88e2ccd4ea 100644 --- a/packages/plugin/test/contract-identity.test.ts +++ b/packages/plugin/test/contract-identity.test.ts @@ -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", ]) }) diff --git a/packages/protocol/src/groups/websearch.ts b/packages/protocol/src/groups/websearch.ts index 3ef77f19eb..10f7f9d7e3 100644 --- a/packages/protocol/src/groups/websearch.ts +++ b/packages/protocol/src/groups/websearch.ts @@ -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.", }), ), ) diff --git a/packages/schema/src/event-manifest.ts b/packages/schema/src/event-manifest.ts index 386d750340..a9c9aaa52e 100644 --- a/packages/schema/src/event-manifest.ts +++ b/packages/schema/src/event-manifest.ts @@ -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( diff --git a/packages/schema/src/form.ts b/packages/schema/src/form.ts index c4b6953857..98f863f7fa 100644 --- a/packages/schema/src/form.ts +++ b/packages/schema/src/form.ts @@ -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 {} -export const IntegrationInfo = Schema.Struct({ - ...InfoBase, - mode: Schema.Literal("integration"), - integrationID: IntegrationID, -}).annotate({ identifier: "Form.IntegrationInfo" }) -export interface IntegrationInfo extends Schema.Schema.Type {} - -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( { diff --git a/packages/schema/src/integration.ts b/packages/schema/src/integration.ts index 6013227a83..329bf8207f 100644 --- a/packages/schema/src/integration.ts +++ b/packages/schema/src/integration.ts @@ -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 {} -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("Integration.Info")({ id: ID, name: Schema.String, methods: Schema.Array(Method), - websearch: optional(WebSearch), connections: Schema.Array(Connection.Info), }) {} diff --git a/packages/schema/src/websearch.ts b/packages/schema/src/websearch.ts index 82eef0b7b7..4a2a2635b2 100644 --- a/packages/schema/src/websearch.ts +++ b/packages/schema/src/websearch.ts @@ -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 {} +export const Provider = Schema.Struct({ + id: ID, + name: Schema.String, +}).annotate({ identifier: "WebSearch.Provider" }) + export interface Input extends Schema.Schema.Type {} 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 {} @@ -17,6 +26,12 @@ export const ProviderOutput = Schema.Struct({ }).annotate({ identifier: "WebSearch.ProviderOutput" }) export class Result extends Schema.Class("WebSearch.Result")({ - providerID: IntegrationID, + providerID: ID, ...ProviderOutput.fields, }) {} + +const Updated = ephemeral({ + type: "websearch.updated", + schema: {}, +}) +export const Event = { Updated, Definitions: inventory(Updated) } diff --git a/packages/sdk-next/test/embedded.test.ts b/packages/sdk-next/test/embedded.test.ts index 3437869823..4515ebeb40 100644 --- a/packages/sdk-next/test/embedded.test.ts +++ b/packages/sdk-next/test/embedded.test.ts @@ -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" } }), }), }) diff --git a/packages/server/src/handlers/websearch.ts b/packages/server/src/handlers/websearch.ts index d89f9a7213..82460e605f 100644 --- a/packages/server/src/handlers/websearch.ts +++ b/packages/server/src/handlers/websearch.ts @@ -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) =>