diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 588d67cad6ec..cd9ced2979f4 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -894,6 +894,40 @@ export interface DebugApi { readonly location: DebugLocationOperation } +type Endpoint26_0Request = Parameters[0] +export type Endpoint26_0Input = { readonly location?: Endpoint26_0Request["query"]["location"] } +export type Endpoint26_0Output = EffectValue> +export type SearchProviderOperation = (input?: Endpoint26_0Input) => Effect.Effect + +type Endpoint26_1Request = Parameters[0] +export type Endpoint26_1Input = { + readonly location?: Endpoint26_1Request["query"]["location"] + readonly providerID: Endpoint26_1Request["payload"]["providerID"] +} +export type Endpoint26_1Output = EffectValue> +export type SearchSelectProviderOperation = ( + input: Endpoint26_1Input, +) => Effect.Effect + +type Endpoint26_2Request = Parameters[0] +export type Endpoint26_2Input = { + readonly location?: Endpoint26_2Request["query"]["location"] + readonly query: Endpoint26_2Request["payload"]["query"] + readonly providerID?: Endpoint26_2Request["payload"]["providerID"] + readonly numResults?: Endpoint26_2Request["payload"]["numResults"] + readonly livecrawl?: Endpoint26_2Request["payload"]["livecrawl"] + readonly type?: Endpoint26_2Request["payload"]["type"] + readonly contextMaxCharacters?: Endpoint26_2Request["payload"]["contextMaxCharacters"] +} +export type Endpoint26_2Output = EffectValue> +export type SearchQueryOperation = (input: Endpoint26_2Input) => Effect.Effect + +export interface SearchApi { + readonly provider: SearchProviderOperation + readonly selectProvider: SearchSelectProviderOperation + readonly query: SearchQueryOperation +} + export interface AppApi { readonly health: HealthApi readonly location: LocationApi @@ -921,4 +955,5 @@ export interface AppApi { readonly projectCopy: ProjectCopyApi readonly vcs: VcsApi readonly debug: DebugApi + readonly search: SearchApi } diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index 945bd0ffc094..4b3856e20395 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -1071,6 +1071,51 @@ const Endpoint25_0 = (raw: RawClient["server.debug"]) => () => const adaptGroup25 = (raw: RawClient["server.debug"]) => ({ location: Endpoint25_0(raw) }) +type Endpoint26_0Request = Parameters[0] +type Endpoint26_0Input = { readonly location?: Endpoint26_0Request["query"]["location"] } +const Endpoint26_0 = (raw: RawClient["server.search"]) => (input?: Endpoint26_0Input) => + raw["search.provider.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint26_1Request = Parameters[0] +type Endpoint26_1Input = { + readonly location?: Endpoint26_1Request["query"]["location"] + readonly providerID: Endpoint26_1Request["payload"]["providerID"] +} +const Endpoint26_1 = (raw: RawClient["server.search"]) => (input: Endpoint26_1Input) => + raw["search.provider.select"]({ + query: { location: input["location"] }, + payload: { providerID: input["providerID"] }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint26_2Request = Parameters[0] +type Endpoint26_2Input = { + readonly location?: Endpoint26_2Request["query"]["location"] + readonly query: Endpoint26_2Request["payload"]["query"] + readonly providerID?: Endpoint26_2Request["payload"]["providerID"] + readonly numResults?: Endpoint26_2Request["payload"]["numResults"] + readonly livecrawl?: Endpoint26_2Request["payload"]["livecrawl"] + readonly type?: Endpoint26_2Request["payload"]["type"] + readonly contextMaxCharacters?: Endpoint26_2Request["payload"]["contextMaxCharacters"] +} +const Endpoint26_2 = (raw: RawClient["server.search"]) => (input: Endpoint26_2Input) => + raw["search.query"]({ + query: { location: input["location"] }, + payload: { + query: input["query"], + providerID: input["providerID"], + numResults: input["numResults"], + livecrawl: input["livecrawl"], + type: input["type"], + contextMaxCharacters: input["contextMaxCharacters"], + }, + }).pipe(Effect.mapError(mapClientError)) + +const adaptGroup26 = (raw: RawClient["server.search"]) => ({ + provider: Endpoint26_0(raw), + selectProvider: Endpoint26_1(raw), + query: Endpoint26_2(raw), +}) + const adaptClient = (raw: RawClient) => ({ health: adaptGroup0(raw["server.health"]), location: adaptGroup1(raw["server.location"]), @@ -1098,6 +1143,7 @@ const adaptClient = (raw: RawClient) => ({ projectCopy: adaptGroup23(raw["server.projectCopy"]), vcs: adaptGroup24(raw["server.vcs"]), debug: adaptGroup25(raw["server.debug"]), + search: adaptGroup26(raw["server.search"]), }) export const make = (options?: { readonly baseUrl?: URL | string }) => diff --git a/packages/client/src/effect/index.ts b/packages/client/src/effect/index.ts index 3e47320953fc..109901fc12de 100644 --- a/packages/client/src/effect/index.ts +++ b/packages/client/src/effect/index.ts @@ -14,6 +14,7 @@ export type { PluginApi, ProviderApi, ReferenceApi, + SearchApi, SessionApi, SkillApi, } from "./api.js" @@ -36,6 +37,7 @@ export { Provider } from "@opencode-ai/schema/provider" export { Pty } from "@opencode-ai/schema/pty" export { Question } from "@opencode-ai/schema/question" export { Reference } from "@opencode-ai/schema/reference" +export { Search } from "@opencode-ai/schema/search" export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema" export { Session } from "@opencode-ai/schema/session" export { SessionInput } from "@opencode-ai/schema/session-input" diff --git a/packages/client/src/promise/api.ts b/packages/client/src/promise/api.ts index d31a1881fa84..86ca65e9b157 100644 --- a/packages/client/src/promise/api.ts +++ b/packages/client/src/promise/api.ts @@ -7,6 +7,7 @@ import type { PluginApi as EffectPluginApi, ProviderApi as EffectProviderApi, ReferenceApi as EffectReferenceApi, + SearchApi as EffectSearchApi, SessionApi as EffectSessionApi, SkillApi as EffectSkillApi, } from "../effect/api/api.js" @@ -36,6 +37,7 @@ export type ModelApi = PromisifyApi> export type PluginApi = PromisifyApi> export type ProviderApi = PromisifyApi> export type ReferenceApi = PromisifyApi> +export type SearchApi = PromisifyApi> export type SessionApi = PromisifyApi> export type SkillApi = PromisifyApi> diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 697d54d732dc..8a7567977900 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -178,6 +178,12 @@ import type { VcsDiffInput, VcsDiffOutput, DebugLocationOutput, + SearchProviderInput, + SearchProviderOutput, + SearchSelectProviderInput, + SearchSelectProviderOutput, + SearchQueryInput, + SearchQueryOutput, } from "./types" import { ClientError } from "./client-error" @@ -1495,6 +1501,53 @@ export function make(options: ClientOptions) { requestOptions, ), }, + search: { + provider: (input?: SearchProviderInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/search/provider`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + selectProvider: (input: SearchSelectProviderInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/search/provider`, + query: { location: input["location"] }, + body: { providerID: input["providerID"] }, + successStatus: 204, + declaredStatuses: [400, 503, 401], + empty: true, + }, + requestOptions, + ), + query: (input: SearchQueryInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/search`, + query: { location: input["location"] }, + body: { + query: input["query"], + providerID: input["providerID"], + numResults: input["numResults"], + livecrawl: input["livecrawl"], + type: input["type"], + contextMaxCharacters: input["contextMaxCharacters"], + }, + successStatus: 200, + declaredStatuses: [400, 503, 401], + empty: false, + }, + requestOptions, + ), + }, } } diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 7482d2372587..3e78b520be52 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -2279,6 +2279,7 @@ export type IntegrationListOutput = { | { readonly type: "key"; readonly label?: string } | { readonly type: "env"; readonly names: ReadonlyArray } > + readonly search?: { readonly connection: "optional" | "required" } readonly connections: ReadonlyArray< | { readonly type: "credential"; readonly id: string; readonly label: string } | { readonly type: "env"; readonly name: string } @@ -2331,6 +2332,7 @@ export type IntegrationGetOutput = { | { readonly type: "key"; readonly label?: string } | { readonly type: "env"; readonly names: ReadonlyArray } > + readonly search?: { readonly connection: "optional" | "required" } readonly connections: ReadonlyArray< | { readonly type: "credential"; readonly id: string; readonly label: string } | { readonly type: "env"; readonly name: string } @@ -2643,6 +2645,14 @@ export type FormListRequestsOutput = { 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 + } > } @@ -2755,6 +2765,14 @@ 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"] @@ -3451,6 +3469,14 @@ 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 = { @@ -3565,6 +3591,14 @@ 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 = { @@ -5351,6 +5385,14 @@ 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 + } } } | { @@ -6130,3 +6172,90 @@ export type VcsDiffOutput = { } export type DebugLocationOutput = ReadonlyArray<{ readonly directory: string; readonly workspaceID?: string }> + +export type SearchProviderInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type SearchProviderOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: string | null +} + +export type SearchSelectProviderInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] + readonly providerID: { readonly providerID: string }["providerID"] +} + +export type SearchSelectProviderOutput = void + +export type SearchQueryInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] + readonly query: { + readonly query: string + readonly providerID?: string + readonly numResults?: number + readonly livecrawl?: "fallback" | "preferred" + readonly type?: "auto" | "fast" | "deep" + readonly contextMaxCharacters?: number + }["query"] + readonly providerID?: { + readonly query: string + readonly providerID?: string + readonly numResults?: number + readonly livecrawl?: "fallback" | "preferred" + readonly type?: "auto" | "fast" | "deep" + readonly contextMaxCharacters?: number + }["providerID"] + readonly numResults?: { + readonly query: string + readonly providerID?: string + readonly numResults?: number + readonly livecrawl?: "fallback" | "preferred" + readonly type?: "auto" | "fast" | "deep" + readonly contextMaxCharacters?: number + }["numResults"] + readonly livecrawl?: { + readonly query: string + readonly providerID?: string + readonly numResults?: number + readonly livecrawl?: "fallback" | "preferred" + readonly type?: "auto" | "fast" | "deep" + readonly contextMaxCharacters?: number + }["livecrawl"] + readonly type?: { + readonly query: string + readonly providerID?: string + readonly numResults?: number + readonly livecrawl?: "fallback" | "preferred" + readonly type?: "auto" | "fast" | "deep" + readonly contextMaxCharacters?: number + }["type"] + readonly contextMaxCharacters?: { + readonly query: string + readonly providerID?: string + readonly numResults?: number + readonly livecrawl?: "fallback" | "preferred" + readonly type?: "auto" | "fast" | "deep" + readonly contextMaxCharacters?: number + }["contextMaxCharacters"] +} + +export type SearchQueryOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: { readonly providerID: string; readonly text: string; readonly metadata?: JsonValue } +} diff --git a/packages/client/src/promise/index.ts b/packages/client/src/promise/index.ts index fd889c64e55e..75b59c5d4be6 100644 --- a/packages/client/src/promise/index.ts +++ b/packages/client/src/promise/index.ts @@ -9,6 +9,7 @@ export type { PluginApi, ProviderApi, ReferenceApi, + SearchApi, SessionApi, SkillApi, } from "./api.js" diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index 4f2419075f85..3a7efe570fa9 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -31,6 +31,7 @@ test("exposes every standard HTTP API group", () => { "projectCopy", "vcs", "debug", + "search", ]) expect(Object.keys(client.debug)).toEqual(["location"]) expect(Object.keys(client.message)).toEqual(["list"]) @@ -43,6 +44,7 @@ test("exposes every standard HTTP API group", () => { "attemptComplete", "attemptCancel", ]) + expect(Object.keys(client.search)).toEqual(["provider", "selectProvider", "query"]) 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"]) @@ -50,6 +52,57 @@ test("exposes every standard HTTP API group", () => { expect(Object.keys(client.project)).toEqual(["list", "current", "directories"]) }) +test("search.query uses the public HTTP contract", async () => { + let request: Request | undefined + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async (input, init) => { + request = input instanceof Request ? input : new Request(input, init) + return Response.json({ + location: { directory: "/tmp/project", project: { id: "proj_test", directory: "/tmp/project" } }, + data: { providerID: "exa", text: "result", metadata: { requestID: "req_test" } }, + }) + }, + }) + + const result = await client.search.query({ + query: "opencode", + providerID: "exa", + numResults: 5, + location: { directory: "/tmp/project" }, + }) + + expect(result.data).toEqual({ providerID: "exa", text: "result", metadata: { requestID: "req_test" } }) + expect(request?.method).toBe("POST") + expect(request?.url).toBe("http://localhost:3000/api/search?location%5Bdirectory%5D=%2Ftmp%2Fproject") + expect(await request?.json()).toEqual({ query: "opencode", providerID: "exa", numResults: 5 }) +}) + +test("search provider methods use the public HTTP contract", async () => { + const requests: Request[] = [] + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init) + requests.push(request) + 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", + }) + }, + }) + + expect(await client.search.provider({ location: { directory: "/tmp/project" } })).toMatchObject({ data: "exa" }) + await client.search.selectProvider({ providerID: "parallel", location: { directory: "/tmp/project" } }) + + expect(requests.map((request) => [request.method, request.url])).toEqual([ + ["GET", "http://localhost:3000/api/search/provider?location%5Bdirectory%5D=%2Ftmp%2Fproject"], + ["POST", "http://localhost:3000/api/search/provider?location%5Bdirectory%5D=%2Ftmp%2Fproject"], + ]) + expect(await requests[1]?.json()).toEqual({ providerID: "parallel" }) +}) + test("file.read returns binary content from the public HTTP contract", async () => { let request: Request | undefined const client = OpenCode.make({ diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index ae05ddef7d87..97139218411a 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -1,4 +1,5 @@ export * as Config from "./config" +export * as ConfigGlobal from "./config/global" import { makeLocationNode } from "./effect/app-node" import path from "path" @@ -23,6 +24,7 @@ import { ConfigModel } from "./config/model" import { ConfigPlugin } from "./config/plugin" import { ConfigProvider } from "./config/provider" import { ConfigReference } from "./config/reference" +import { ConfigSearch } from "./config/search" import { ConfigToolOutput } from "./config/tool-output" import { ConfigVariable } from "./config/variable" import { ConfigWatcher } from "./config/watcher" @@ -102,6 +104,9 @@ export class Info extends Schema.Class("Config.Info")({ references: ConfigReference.Info.pipe(Schema.optional).annotate({ description: "Named local directories or Git repositories available as external context", }), + search: ConfigSearch.Info.pipe(Schema.optional).annotate({ + description: "Web search provider selection", + }), plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({ description: "Ordered plugin enablement directives and external package declarations", }), diff --git a/packages/core/src/config/global.ts b/packages/core/src/config/global.ts new file mode 100644 index 000000000000..efb375b911fd --- /dev/null +++ b/packages/core/src/config/global.ts @@ -0,0 +1,50 @@ +export * as ConfigGlobal from "./global" + +import { randomUUID } from "node:crypto" +import path from "node:path" +import { Context, Effect, Layer } from "effect" +import { applyEdits, modify, type JSONPath } from "jsonc-parser" +import { makeGlobalNode } from "../effect/app-node" +import { FSUtil } from "../fs-util" +import { Global } from "../global" +import { EffectFlock } from "../util/effect-flock" + +export interface Interface { + readonly update: (path: JSONPath, value: unknown) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/ConfigGlobal") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const global = yield* Global.Service + const flock = yield* EffectFlock.Service + + return Service.of({ + update: Effect.fn("ConfigGlobal.update")(function* (jsonPath, value) { + yield* flock.withLock( + Effect.gen(function* () { + const existing = yield* Effect.filter( + ["opencode.jsonc", "opencode.json"].map((name) => path.join(global.config, name)), + fs.existsSafe, + ) + const filepath = existing[0] ?? path.join(global.config, "opencode.json") + const text = (yield* fs.readFileStringSafe(filepath)) ?? "{}" + const next = applyEdits( + text, + modify(text, jsonPath, value, { formattingOptions: { tabSize: 2, insertSpaces: true } }), + ) + const temp = `${filepath}.${randomUUID()}.tmp` + yield* fs.writeWithDirs(temp, next) + yield* fs.rename(temp, filepath).pipe(Effect.ensuring(fs.remove(temp).pipe(Effect.ignore))) + }), + "global-config", + ) + }), + }) + }), +) + +export const node = makeGlobalNode({ service: Service, layer, deps: [EffectFlock.node, FSUtil.node, Global.node] }) diff --git a/packages/core/src/config/search.ts b/packages/core/src/config/search.ts new file mode 100644 index 000000000000..717347933983 --- /dev/null +++ b/packages/core/src/config/search.ts @@ -0,0 +1,8 @@ +export * as ConfigSearch from "./search" + +import { Integration } from "@opencode-ai/schema/integration" +import { Schema } from "effect" + +export class Info extends Schema.Class("ConfigSearch.Info")({ + provider: Integration.ID, +}) {} diff --git a/packages/core/src/form.ts b/packages/core/src/form.ts index 7cdda87b4c2a..f18f61a8ac19 100644 --- a/packages/core/src/form.ts +++ b/packages/core/src/form.ts @@ -67,6 +67,7 @@ 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 @@ -135,10 +136,7 @@ export const layer = Layer.effect( title: input.title, ...(input.metadata === undefined ? {} : { metadata: input.metadata }), } - const form: Info = - input.mode === "form" - ? { ...base, mode: "form", fields: input.fields } - : { ...base, mode: "url", url: input.url } + const form = makeInfo(input, base) const entry: Entry = { form, state: { status: "pending" }, @@ -228,9 +226,9 @@ export const locationLayer = layer export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] }) function validateAnswer(form: Info, answer: Answer) { - if (form.mode === "url") { + if (form.mode !== "form") { if (Object.keys(answer).length === 0) return - return "URL forms must be answered with an empty answer" + return `${form.mode === "url" ? "URL" : "Integration"} forms must be answered with an empty answer` } const fields = new Map(form.fields.map((field) => [field.key, field])) for (const key of Object.keys(answer)) { @@ -249,6 +247,17 @@ function validateAnswer(form: Info, answer: Answer) { } } +function makeInfo(input: CreateInput, base: Omit): Info { + switch (input.mode) { + case "form": + return { ...base, mode: "form", fields: input.fields } + case "url": + return { ...base, mode: "url", url: input.url } + case "integration": + return { ...base, mode: "integration", integrationID: input.integrationID } + } +} + function isActive(field: Form.Field, answer: Answer) { if (!field.when) return true return field.when.every((when) => matches(when, answer[when.key])) diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index 12dad07dd34d..5990d93fa79a 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -16,6 +16,7 @@ import { Types, } from "effect" import { Integration } from "@opencode-ai/schema/integration" +import { Search } from "@opencode-ai/schema/search" import { Credential } from "./credential" import { State } from "./state" import { EventV2 } from "./event" @@ -94,6 +95,15 @@ export interface EnvImplementation { export type Implementation = OAuthImplementation | KeyImplementation | EnvImplementation +export interface SearchImplementation { + readonly integrationID: ID + readonly connection: Integration.Search["connection"] + readonly execute: ( + input: Search.Input, + context: { readonly credential?: Credential.Value; readonly sessionID?: string }, + ) => Effect.Effect +} + export const Attempt = Integration.Attempt export type Attempt = Integration.Attempt @@ -119,6 +129,7 @@ type Entry = { ref: Types.DeepMutable methods: Types.DeepMutable[] implementations: Map> + search?: Types.DeepMutable } type Data = { @@ -135,6 +146,11 @@ export type Draft = { update: (implementation: Implementation) => void remove: (integrationID: ID, method: Method) => void } + search: { + list: () => readonly SearchImplementation[] + update: (implementation: SearchImplementation) => void + remove: (integrationID: ID) => void + } } export interface Interface extends State.Transformable { @@ -191,6 +207,10 @@ export interface Interface extends State.Transformable { /** Cancels an attempt and releases its resources. */ readonly cancel: (attemptID: AttemptID) => Effect.Effect } + readonly search: { + readonly list: () => Effect.Effect + readonly get: (integrationID: ID) => Effect.Effect + } } export class Service extends Context.Service()("@opencode/v2/Integration") {} @@ -281,6 +301,30 @@ const layer = Layer.effect( if (method.type === "oauth") current.implementations.delete(method.id) }, }, + search: { + list: () => + Array.from(draft.integrations.values()).flatMap((entry) => + entry.search ? [entry.search as SearchImplementation] : [], + ), + update: (implementation) => { + const current = draft.integrations.get(implementation.integrationID) ?? { + ref: { + id: implementation.integrationID, + name: implementation.integrationID, + }, + methods: [], + implementations: new Map>(), + } + if (!draft.integrations.has(implementation.integrationID)) { + draft.integrations.set(implementation.integrationID, current) + } + current.search = implementation as Types.DeepMutable + }, + remove: (integrationID) => { + const current = draft.integrations.get(integrationID) + if (current) delete current.search + }, + }, }), finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid), }) @@ -305,6 +349,7 @@ const layer = Layer.effect( id: entry.ref.id, name: entry.ref.name, methods: entry.methods, + search: entry.search ? { connection: entry.search.connection } : undefined, connections, }) @@ -514,6 +559,16 @@ const layer = Layer.effect( if (attempt) yield* Scope.close(attempt.scope, Exit.void) }), }, + search: { + list: Effect.fn("Integration.search.list")(function* () { + return Array.from(state.get().integrations.values()).flatMap((entry) => + entry.search ? [entry.search as SearchImplementation] : [], + ) + }), + get: Effect.fn("Integration.search.get")(function* (integrationID) { + return state.get().integrations.get(integrationID)?.search as SearchImplementation | undefined + }), + }, }) }), ) diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index fe2301651f7d..bedeb0497633 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -34,6 +34,7 @@ import { Pty } from "./pty" import { QuestionV2 } from "./question" import { Shell } from "./shell" import { Reference } from "./reference" +import { Search } from "./search" import { ReferenceGuidance } from "./reference/guidance" import { Ripgrep } from "./ripgrep" import { SessionRunnerLLM } from "./session/runner/llm" @@ -51,7 +52,6 @@ import { SessionInstructions } from "./session/instructions" import { McpTool } from "./tool/mcp" import { ReadToolFileSystem } from "./tool/read-filesystem" import { ToolRegistry } from "./tool/registry" -import { WebSearchTool } from "./tool/websearch" import { ToolOutputStore } from "./tool-output-store" import { Vcs } from "./vcs" @@ -84,13 +84,13 @@ const pluginSupervisorNode = makeLocationNode({ Form.node, ReadToolFileSystem.node, Reference.node, + Search.node, Ripgrep.node, SessionInstructions.node, SessionTodo.node, Shell.node, SkillV2.node, ToolRegistry.toolsNode, - WebSearchTool.configNode, ], }) @@ -100,6 +100,7 @@ const locationServiceNodes = [ AgentV2.node, CommandV2.node, Reference.node, + Search.node, Integration.node, Catalog.node, AISDK.node, diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 01ea0da0d3fe..46ec89c4374c 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -1,6 +1,7 @@ export * as PluginHost from "./host" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { IntegrationDefinition, IntegrationMethodRegistration, PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { CredentialOAuth } from "@opencode-ai/sdk/v2/types" import { EventManifest } from "@opencode-ai/schema/event-manifest" import { Effect, Schema, Stream } from "effect" import { AgentV2 } from "../agent" @@ -191,6 +192,10 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int connection.type === "credential" ? { ...connection, id: Credential.ID.make(connection.id) } : connection, ), }, + register: (definition) => + integration.transform((draft) => { + registerIntegration(draft, definition) + }), transform: (callback) => integration.transform((draft) => { callback({ @@ -200,72 +205,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int remove: (id) => draft.remove(Integration.ID.make(id)), method: { list: (id) => mutable(draft.method.list(Integration.ID.make(id))), - update: (input) => { - if ("authorize" in input) { - const methodID = Integration.MethodID.make(input.method.id) - const refresh = input.refresh - draft.method.update({ - integrationID: Integration.ID.make(input.integrationID), - method: { ...input.method, id: methodID }, - authorize: (inputs) => - input.authorize(inputs).pipe( - Effect.map((authorization) => { - if (authorization.mode === "auto") { - return { - ...authorization, - callback: authorization.callback.pipe( - Effect.map((credential) => - Credential.OAuth.make({ - ...credential, - methodID: Integration.MethodID.make(credential.methodID), - }), - ), - ), - } - } - return { - ...authorization, - callback: (code: string) => - authorization.callback(code).pipe( - Effect.map((credential) => - Credential.OAuth.make({ - ...credential, - methodID: Integration.MethodID.make(credential.methodID), - }), - ), - ), - } - }), - ), - ...(refresh - ? { - refresh: (value: Credential.OAuth) => - refresh(value).pipe( - Effect.map((next) => - Credential.OAuth.make({ - ...next, - methodID: Integration.MethodID.make(next.methodID), - }), - ), - ), - } - : {}), - ...(input.label ? { label: input.label } : {}), - }) - return - } - if (input.method.type === "env") { - draft.method.update({ - integrationID: Integration.ID.make(input.integrationID), - method: { type: "env", names: input.method.names }, - }) - return - } - draft.method.update({ - integrationID: Integration.ID.make(input.integrationID), - method: { type: "key", label: input.method.label }, - }) - }, + update: (input) => draft.method.update(methodImplementation(input)), remove: (id, method) => draft.method.remove(Integration.ID.make(id), Schema.decodeUnknownSync(Integration.Method)(method)), }, @@ -377,3 +317,75 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int }, } satisfies PluginContext }) + +function registerIntegration(draft: Integration.Draft, definition: IntegrationDefinition) { + const integrationID = Integration.ID.make(definition.id) + draft.update(integrationID, (integration) => (integration.name = definition.name)) + for (const method of definition.methods ?? []) { + if (method.type === "env") { + draft.method.update(methodImplementation({ integrationID: definition.id, method })) + continue + } + if (method.type === "key") { + draft.method.update(methodImplementation({ integrationID: definition.id, method })) + continue + } + const { authorize, refresh, credentialLabel, ...info } = method + draft.method.update( + methodImplementation({ + integrationID: definition.id, + method: info, + authorize, + ...(refresh ? { refresh } : {}), + ...(credentialLabel ? { label: credentialLabel } : {}), + }), + ) + } + if (!definition.search) return + draft.search.update({ + integrationID, + connection: definition.search.connection, + execute: definition.search.execute, + }) +} + +function methodImplementation(input: IntegrationMethodRegistration): Integration.Implementation { + if ("authorize" in input) { + const refresh = input.refresh + return { + integrationID: Integration.ID.make(input.integrationID), + method: { ...input.method, id: Integration.MethodID.make(input.method.id) }, + authorize: (inputs) => + input.authorize(inputs).pipe( + Effect.map((authorization) => { + if (authorization.mode === "auto") { + return { + ...authorization, + callback: authorization.callback.pipe(Effect.map(credential)), + } + } + return { + ...authorization, + callback: (code: string) => authorization.callback(code).pipe(Effect.map(credential)), + } + }), + ), + ...(refresh ? { refresh: (value: Credential.OAuth) => refresh(value).pipe(Effect.map(credential)) } : {}), + ...(input.label ? { label: input.label } : {}), + } + } + if (input.method.type === "env") { + return { + integrationID: Integration.ID.make(input.integrationID), + method: { type: "env", names: input.method.names }, + } + } + return { + integrationID: Integration.ID.make(input.integrationID), + method: { type: "key", label: input.method.label }, + } +} + +function credential(value: CredentialOAuth) { + return Credential.OAuth.make({ ...value, methodID: Integration.MethodID.make(value.methodID) }) +} diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 2789cc3c2e5b..b18caa5efa8e 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -26,6 +26,7 @@ import { ModelsDev } from "../models-dev" import { Npm } from "../npm" import { PermissionV2 } from "../permission" import { Reference } from "../reference" +import { Search } from "../search" import { Ripgrep } from "../ripgrep" import { SessionInstructions } from "../session/instructions" import { SessionTodo } from "../session/todo" @@ -50,6 +51,7 @@ import { AgentPlugin } from "./agent" import { CommandPlugin } from "./command" import { ModelsDevPlugin } from "./models-dev" import { ProviderPlugins } from "./provider" +import { SearchPlugins } from "./search" import { PluginRuntime } from "./runtime" import { SkillPlugin } from "./skill" import { VariantPlugin } from "./variant" @@ -76,13 +78,13 @@ const services = Effect.fn("PluginInternal.services")(function* () { const form = yield* Form.Service const read = yield* ReadToolFileSystem.Service const reference = yield* Reference.Service + const search = yield* Search.Service const ripgrep = yield* Ripgrep.Service const instructions = yield* SessionInstructions.Service const todo = yield* SessionTodo.Service const shell = yield* Shell.Service const skill = yield* SkillV2.Service const tools = yield* Tools.Service - const websearch = yield* WebSearchTool.ConfigService return Context.mergeAll( Context.make(AgentV2.Service, agent), Context.make(Catalog.Service, catalog), @@ -105,13 +107,13 @@ const services = Effect.fn("PluginInternal.services")(function* () { Context.make(Form.Service, form), Context.make(ReadToolFileSystem.Service, read), Context.make(Reference.Service, reference), + Context.make(Search.Service, search), Context.make(Ripgrep.Service, ripgrep), Context.make(SessionInstructions.Service, instructions), Context.make(SessionTodo.Service, todo), Context.make(Shell.Service, shell), Context.make(SkillV2.Service, skill), Context.make(Tools.Service, tools), - Context.make(WebSearchTool.ConfigService, websearch), ) }) @@ -127,6 +129,7 @@ const pre = [ SkillPlugin.Plugin, ModelsDevPlugin, ...ProviderPlugins, + ...SearchPlugins, ApplyPatchTool.Plugin, EditTool.Plugin, GlobTool.Plugin, diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts index bcd3db13235e..ae0703f094c6 100644 --- a/packages/core/src/plugin/promise.ts +++ b/packages/core/src/plugin/promise.ts @@ -1,7 +1,7 @@ export * as PluginPromise from "./promise" import { define } from "@opencode-ai/plugin/v2/effect" -import type { Plugin, PluginContext } from "@opencode-ai/plugin/v2/promise" +import type { IntegrationDefinition, Plugin, PluginContext } from "@opencode-ai/plugin/v2/promise" import { Effect, Scope, Stream } from "effect" type HostRegistration = { readonly dispose: Effect.Effect } @@ -84,6 +84,7 @@ export function fromPromise(plugin: Plugin) { attemptStatus: (input) => run(host.integration.attemptStatus(input)), attemptComplete: (input) => run(host.integration.attemptComplete(input)), attemptCancel: (input) => run(host.integration.attemptCancel(input)), + register: (definition) => register(host.integration.register(adaptIntegration(definition))), transform: transform(host.integration), reload: () => run(host.integration.reload()), connection: { @@ -117,3 +118,54 @@ export function fromPromise(plugin: Plugin) { }), }) } + +function adaptIntegration(definition: IntegrationDefinition) { + const { methods, search, ...info } = definition + return { + ...info, + methods: methods?.map((method) => { + if (method.type !== "oauth") return method + const { authorize, refresh, ...info } = method + return { + ...info, + authorize: (inputs: Parameters[0]) => + Effect.tryPromise({ try: () => authorize(inputs), catch: (cause) => cause }).pipe( + Effect.map((authorization) => { + if (authorization.mode === "auto") { + return { + ...authorization, + callback: Effect.tryPromise({ try: () => authorization.callback, catch: (cause) => cause }), + } + } + return { + ...authorization, + callback: (code: string) => + Effect.tryPromise({ try: () => authorization.callback(code), catch: (cause) => cause }), + } + }), + ), + ...(refresh + ? { + refresh: (credential: Parameters[0]) => + Effect.tryPromise({ try: () => refresh(credential), catch: (cause) => cause }), + } + : {}), + } + }), + ...(search + ? { + search: { + connection: search.connection, + execute: ( + input: Parameters[0], + execution: Omit[1], "signal">, + ) => + Effect.tryPromise({ + try: (signal) => search.execute(input, { ...execution, signal }), + catch: (cause) => cause, + }), + }, + } + : {}), + } +} diff --git a/packages/core/src/plugin/search/exa.ts b/packages/core/src/plugin/search/exa.ts new file mode 100644 index 000000000000..4d0f46dc6873 --- /dev/null +++ b/packages/core/src/plugin/search/exa.ts @@ -0,0 +1,45 @@ +export * as SearchExa from "./exa" + +import { define } from "@opencode-ai/plugin/v2/effect" +import { Effect, Schema, Scope } from "effect" +import { HttpClient } from "effect/unstable/http" +import { SearchMcp } from "./mcp" + +export const endpoint = "https://mcp.exa.ai/mcp" + +const Args = Schema.Struct({ + query: Schema.String, + type: Schema.String, + numResults: Schema.Number, + livecrawl: Schema.String, + contextMaxCharacters: Schema.optional(Schema.Number), +}) + +export const Plugin = define({ + id: "opencode.search.exa", + effect: Effect.fn("SearchExa.Plugin")(function* (ctx) { + const http = yield* HttpClient.HttpClient + yield* ctx.integration.register({ + id: "exa", + name: "Exa", + methods: [ + { type: "key", label: "API key (optional)" }, + { type: "env", names: ["EXA_API_KEY"] }, + ], + search: { + connection: "optional", + execute: (input, context) => { + const url = new URL(endpoint) + if (context.credential?.type === "key") url.searchParams.set("exaApiKey", context.credential.key) + return SearchMcp.call(http, url.toString(), "web_search_exa", Args, { + query: input.query, + type: input.type ?? "auto", + numResults: input.numResults ?? 8, + livecrawl: input.livecrawl ?? "fallback", + contextMaxCharacters: input.contextMaxCharacters, + }).pipe(Effect.map((text) => ({ text: text ?? "" }))) + }, + }, + }) + }), +}) diff --git a/packages/core/src/plugin/search/index.ts b/packages/core/src/plugin/search/index.ts new file mode 100644 index 000000000000..b816effe9b79 --- /dev/null +++ b/packages/core/src/plugin/search/index.ts @@ -0,0 +1,4 @@ +import { SearchExa } from "./exa" +import { SearchParallel } from "./parallel" + +export const SearchPlugins = [SearchExa.Plugin, SearchParallel.Plugin] as const diff --git a/packages/core/src/plugin/search/mcp.ts b/packages/core/src/plugin/search/mcp.ts new file mode 100644 index 000000000000..0bba789902fc --- /dev/null +++ b/packages/core/src/plugin/search/mcp.ts @@ -0,0 +1,75 @@ +export * as SearchMcp from "./mcp" + +import { Duration, Effect, Schema } from "effect" +import { HttpClient, HttpClientRequest } from "effect/unstable/http" +import { collectBoundedResponseBody } from "../../tool/http-body" + +export const MAX_RESPONSE_BYTES = 256 * 1024 + +const Result = Schema.Struct({ + result: Schema.Struct({ + content: Schema.Array(Schema.Struct({ type: Schema.String, text: Schema.String })), + }), +}) +const decodeResult = Schema.decodeUnknownEffect(Schema.fromJsonString(Result)) + +const parsePayload = (payload: string) => + Effect.gen(function* () { + const trimmed = payload.trim() + if (!trimmed.startsWith("{")) return undefined + return (yield* decodeResult(trimmed)).result.content.find((item) => item.text)?.text + }) + +export const parseResponse = Effect.fn("SearchMcp.parseResponse")(function* (body: string) { + const trimmed = body.trim() + const direct = trimmed ? yield* parsePayload(trimmed) : undefined + if (direct) return direct + for (const line of body.split("\n")) { + if (!line.startsWith("data: ")) continue + const data = yield* parsePayload(line.substring(6)) + if (data) return data + } +}) + +const Request = (args: Schema.Struct) => + Schema.Struct({ + jsonrpc: Schema.Literal("2.0"), + id: Schema.Literal(1), + method: Schema.Literal("tools/call"), + params: Schema.Struct({ name: Schema.String, arguments: args }), + }) + +export const call = ( + http: HttpClient.HttpClient, + url: string, + tool: string, + args: Schema.Struct, + value: Schema.Struct.Type, + headers: Record = {}, +) => + Effect.gen(function* () { + const request = yield* HttpClientRequest.post(url).pipe( + HttpClientRequest.accept("application/json, text/event-stream"), + HttpClientRequest.setHeaders(headers), + HttpClientRequest.schemaBodyJson(Request(args))({ + jsonrpc: "2.0" as const, + id: 1 as const, + method: "tools/call" as const, + params: { name: tool, arguments: value }, + }), + ) + return yield* Effect.gen(function* () { + const response = yield* HttpClient.filterStatusOk(http).execute(request) + const body = yield* collectBoundedResponseBody( + response, + MAX_RESPONSE_BYTES, + () => new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`), + ) + return yield* parseResponse(body.toString("utf8")) + }).pipe( + Effect.timeoutOrElse({ + duration: Duration.seconds(25), + orElse: () => Effect.fail(new Error(`${tool} request timed out`)), + }), + ) + }) diff --git a/packages/core/src/plugin/search/parallel.ts b/packages/core/src/plugin/search/parallel.ts new file mode 100644 index 000000000000..ccaaecaee1af --- /dev/null +++ b/packages/core/src/plugin/search/parallel.ts @@ -0,0 +1,49 @@ +export * as SearchParallel from "./parallel" + +import { define } from "@opencode-ai/plugin/v2/effect" +import { Effect, Schema, Scope } from "effect" +import { HttpClient } from "effect/unstable/http" +import { InstallationVersion } from "../../installation/version" +import { SearchMcp } from "./mcp" + +export const endpoint = "https://search.parallel.ai/mcp" + +const Args = Schema.Struct({ + objective: Schema.String, + search_queries: Schema.Array(Schema.String), + session_id: Schema.String, +}) + +export const Plugin = define({ + id: "opencode.search.parallel", + effect: Effect.fn("SearchParallel.Plugin")(function* (ctx) { + const http = yield* HttpClient.HttpClient + yield* ctx.integration.register({ + id: "parallel", + name: "Parallel", + methods: [ + { type: "key", label: "API key (optional)" }, + { type: "env", names: ["PARALLEL_API_KEY"] }, + ], + search: { + connection: "optional", + execute: (input, context) => + SearchMcp.call( + http, + endpoint, + "web_search", + Args, + { + objective: input.query, + search_queries: [input.query], + session_id: context.sessionID ?? "opencode", + }, + { + "User-Agent": `opencode/${InstallationVersion}`, + ...(context.credential?.type === "key" ? { Authorization: `Bearer ${context.credential.key}` } : {}), + }, + ).pipe(Effect.map((text) => ({ text: text ?? "" }))), + }, + }) + }), +}) diff --git a/packages/core/src/search.ts b/packages/core/src/search.ts new file mode 100644 index 000000000000..8cfb9e4bbcda --- /dev/null +++ b/packages/core/src/search.ts @@ -0,0 +1,253 @@ +export * as Search from "./search" + +import { Search } from "@opencode-ai/schema/search" +import { Config as ConfigSchema } from "@opencode-ai/schema/config" +import { Context, Effect, Layer, Schema, Semaphore, Stream } from "effect" +import path from "node:path" +import { Config } from "./config" +import { ConfigGlobal } from "./config/global" +import { ConfigSearch } from "./config/search" +import { 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" + +export const Input = Search.Input +export type Input = Search.Input + +export const ProviderOutput = Search.ProviderOutput +export type ProviderOutput = Search.ProviderOutput + +export const Result = Search.Result +export type Result = Search.Result + +export class ProviderRequiredError extends Schema.TaggedErrorClass()( + "Search.ProviderRequired", + {}, +) {} + +export class ProviderNotFoundError extends Schema.TaggedErrorClass()("Search.ProviderNotFound", { + providerID: Integration.ID, +}) {} + +export class ConnectionRequiredError extends Schema.TaggedErrorClass()( + "Search.ConnectionRequired", + { providerID: Integration.ID }, +) {} + +export class CancelledError extends Schema.TaggedErrorClass()("Search.Cancelled", {}) {} + +export class RequestError extends Schema.TaggedErrorClass()("Search.Request", { + providerID: Integration.ID, + cause: Schema.Defect(), +}) {} + +export type Error = + | ProviderRequiredError + | ProviderNotFoundError + | ConnectionRequiredError + | CancelledError + | RequestError + +export interface QueryInput extends Input { + readonly sessionID?: string +} + +export interface Interface { + readonly selected: () => Effect.Effect + readonly select: (providerID: Integration.ID) => Effect.Effect + readonly query: (input: QueryInput) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/Search") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const config = yield* Config.Service + const configGlobal = yield* ConfigGlobal.Service + 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) + let pending: Integration.ID | undefined + + const requireProvider = ( + providers: Map, + providerID: Integration.ID, + ) => { + const provider = providers.get(providerID) + return provider ? Effect.succeed(provider) : Effect.fail(new ProviderNotFoundError({ providerID })) + } + + const configuredProvider = Effect.fn("Search.configuredProvider")(function* () { + const providerID = Config.latest(yield* config.entries(), "search")?.provider + if (providerID) return providerID + if (process.env.OPENCODE_WEBSEARCH_PROVIDER) { + return Integration.ID.make(process.env.OPENCODE_WEBSEARCH_PROVIDER) + } + if (truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL")) { + return Integration.ID.make("parallel") + } + if (truthy("OPENCODE_EXPERIMENTAL") || truthy("OPENCODE_ENABLE_EXA") || truthy("OPENCODE_EXPERIMENTAL_EXA")) { + return Integration.ID.make("exa") + } + }) + + const globalProvider = Effect.fn("Search.globalProvider")(function* () { + const entries = (yield* config.entries()).filter( + (entry) => entry.type === "document" && entry.path && path.dirname(entry.path) === path.resolve(global.config), + ) + return Config.latest(entries, "search")?.provider + }) + + const selected = Effect.fn("Search.selected")(function* () { + return pending ?? (yield* globalProvider()) + }) + + const save = Effect.fn("Search.save")(function* (providerID: Integration.ID) { + pending = providerID + yield* configGlobal.update(["search"], new ConfigSearch.Info({ provider: providerID })).pipe( + Effect.tapError(() => Effect.sync(() => (pending = undefined))), + Effect.orDie, + ) + }) + + yield* events.subscribe(ConfigSchema.Event.Updated).pipe( + Stream.runForEach(() => + globalProvider().pipe( + Effect.tap((providerID) => + Effect.sync(() => { + if (providerID === pending) pending = undefined + }), + ), + Effect.ignore, + ), + ), + Effect.forkScoped, + ) + + const ask = Effect.fn("Search.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, + title: "Choose a web search provider", + metadata: { kind: "search.provider" }, + mode: "form", + fields: [ + { + key: "provider", + title: "Provider", + description: "This becomes your default and can be changed later from Connect integration.", + type: "string", + required: true, + custom: false, + options: Array.from(providers.values()) + .flatMap((provider) => { + const info = infos.get(provider.integrationID) + 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, + })), + }, + ], + }) + .pipe(Effect.orDie) + if (state.status === "cancelled") return yield* new CancelledError() + const answer = state.answer.provider + if (typeof answer !== "string") return yield* new ProviderRequiredError() + return yield* requireProvider(providers, Integration.ID.make(answer)) + }) + + const connect = Effect.fn("Search.connect")(function* ( + provider: Integration.SearchImplementation, + sessionID?: string, + ) { + const active = yield* integrations.connection.active(provider.integrationID) + if (active || provider.connection === "optional") return active + if (!sessionID) return yield* new ConnectionRequiredError({ providerID: provider.integrationID }) + const state = yield* forms + .ask({ + sessionID, + title: `Connect ${provider.integrationID}`, + metadata: { kind: "integration.connection" }, + mode: "integration", + integrationID: provider.integrationID, + }) + .pipe(Effect.orDie) + if (state.status === "cancelled") return yield* new CancelledError() + const connected = yield* integrations.connection.active(provider.integrationID) + if (!connected) return yield* new ConnectionRequiredError({ providerID: provider.integrationID }) + return connected + }) + + const resolve = Effect.fn("Search.resolve")(function* (input: QueryInput) { + const providers = new Map( + (yield* integrations.search.list()).map((provider) => [provider.integrationID, provider]), + ) + if (input.providerID) return yield* requireProvider(providers, input.providerID) + const override = yield* configuredProvider() + if (override) return yield* requireProvider(providers, override) + const providerID = yield* selected() + const provider = providerID ? providers.get(providerID) : undefined + if (provider) return provider + const sessionID = input.sessionID + if (!sessionID) return yield* new ProviderRequiredError() + return yield* onboarding.withPermit( + Effect.gen(function* () { + const current = yield* selected() + const selectedProvider = current ? providers.get(current) : undefined + if (selectedProvider) return selectedProvider + const provider = yield* ask(providers, sessionID) + yield* connect(provider, sessionID) + yield* save(provider.integrationID) + return provider + }), + ) + }) + + return Service.of({ + selected, + select: Effect.fn("Search.select")(function* (providerID) { + const provider = yield* integrations.search.get(providerID) + if (!provider) return yield* new ProviderNotFoundError({ providerID }) + yield* save(providerID) + }), + query: Effect.fn("Search.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( + Effect.flatMap(decodeOutput), + Effect.mapError((cause) => new RequestError({ providerID: provider.integrationID, cause })), + ) + return new Result({ providerID: provider.integrationID, ...output }) + }), + }) + }), +) + +export const node = makeLocationNode({ + service: Service, + layer, + deps: [Config.node, ConfigGlobal.node, EventV2.node, Form.node, Global.node, Integration.node], +}) diff --git a/packages/core/src/tool/websearch.ts b/packages/core/src/tool/websearch.ts index 4064dc0f850e..6678b19578f5 100644 --- a/packages/core/src/tool/websearch.ts +++ b/packages/core/src/tool/websearch.ts @@ -2,197 +2,58 @@ export * as WebSearchTool from "./websearch" import type { PluginContext } from "@opencode-ai/plugin/v2/effect" import { ToolFailure } from "@opencode-ai/llm" -import { Context, Duration, Effect, Layer, Schema } from "effect" -import { HttpClient, HttpClientRequest } from "effect/unstable/http" -import { makeLocationNode } from "../effect/app-node" -import { truthy } from "../flag/flag" -import { InstallationVersion } from "../installation/version" +import { Effect, Schema } from "effect" +import { Integration } from "../integration" import { PositiveInt } from "../schema" import { PermissionV2 } from "../permission" +import { Search } from "../search" +import { SearchExa } from "../plugin/search/exa" +import { SearchMcp } from "../plugin/search/mcp" +import { SearchParallel } from "../plugin/search/parallel" import { Tool } from "./tool" -import { collectBoundedResponseBody } from "./http-body" -import { checksum } from "../util/encode" export const name = "websearch" export const NO_RESULTS = "No search results found. Please try a different query." -export const EXA_URL = "https://mcp.exa.ai/mcp" -export const PARALLEL_URL = "https://search.parallel.ai/mcp" +export const EXA_URL = SearchExa.endpoint +export const PARALLEL_URL = SearchParallel.endpoint export const MAX_NUM_RESULTS = 20 export const MAX_CONTEXT_CHARACTERS = 50_000 -export const MAX_RESPONSE_BYTES = 256 * 1024 +export const MAX_RESPONSE_BYTES = SearchMcp.MAX_RESPONSE_BYTES +export const parseResponse = SearchMcp.parseResponse -/** - * Provider-independent local web search retained in V2 core for launch parity. - * This invokes the legacy Exa/Parallel product backends itself. It is distinct - * from provider-hosted web search tools, which remain route-owned and execute - * at the model provider. Ownership of this compromise can be revisited later. - */ -export const description = `Search the web using the session's local web search provider. Use this for current information beyond knowledge cutoff. +export const description = `Search the web using the user's selected search integration. Use this for current information beyond knowledge cutoff. -This is a provider-independent local tool backed by Exa or Parallel. Provider-hosted web search tools are separate and execute at the model provider. - -Optional controls support result count, live crawling ('fallback' or 'preferred'), search type ('auto', 'fast', or 'deep'), and maximum context characters. +Optional controls support result count, live crawling ('fallback' or 'preferred'), search type ('auto', 'fast', or 'deep'), and maximum context characters. Providers apply supported controls and otherwise use their defaults. The current year is ${new Date().getFullYear()}. Use this year when searching for recent information or current events.` export const Input = Schema.Struct({ query: Schema.String.annotate({ description: "Websearch query" }), numResults: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_NUM_RESULTS))).annotate({ - description: `Number of search results to return (default: 8, maximum: ${MAX_NUM_RESULTS})`, + description: `Number of search results to return (maximum: ${MAX_NUM_RESULTS})`, }), livecrawl: Schema.optional(Schema.Literals(["fallback", "preferred"])).annotate({ - description: - "Live crawl mode - 'fallback': use live crawling as backup if cached unavailable, 'preferred': prioritize live crawling (default: 'fallback')", + description: "Live crawl preference when supported by the selected provider", }), type: Schema.optional(Schema.Literals(["auto", "fast", "deep"])).annotate({ - description: "Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search", + description: "Search depth preference when supported by the selected provider", }), contextMaxCharacters: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_CONTEXT_CHARACTERS))).annotate( - { - description: `Maximum characters for context string optimized for models (default: 10000, maximum: ${MAX_CONTEXT_CHARACTERS})`, - }, + { description: `Maximum context characters (maximum: ${MAX_CONTEXT_CHARACTERS})` }, ), }) -export const Provider = Schema.Literals(["exa", "parallel"]) -export type Provider = typeof Provider.Type - -export interface Config { - readonly provider?: Provider - readonly enableExa: boolean - readonly enableParallel: boolean - readonly exaApiKey?: string - readonly parallelApiKey?: string -} - -export class ConfigService extends Context.Service()("@opencode/v2/WebSearchConfig") {} - -/** Isolates the retained product environment contract from the generic tool implementation. */ -export const defaultConfigLayer = Layer.sync(ConfigService, () => - ConfigService.of({ - provider: - process.env.OPENCODE_WEBSEARCH_PROVIDER === "exa" || process.env.OPENCODE_WEBSEARCH_PROVIDER === "parallel" - ? process.env.OPENCODE_WEBSEARCH_PROVIDER - : undefined, - enableExa: truthy("OPENCODE_EXPERIMENTAL") || truthy("OPENCODE_ENABLE_EXA") || truthy("OPENCODE_EXPERIMENTAL_EXA"), - enableParallel: truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL"), - exaApiKey: process.env.EXA_API_KEY, - parallelApiKey: process.env.PARALLEL_API_KEY, - }), -) - -export const configNode = makeLocationNode({ service: ConfigService, layer: defaultConfigLayer, deps: [] }) - -export function selectProvider( - sessionID: string, - flags: Pick = { enableExa: false, enableParallel: false }, - override?: Provider, -): Provider { - if (override) return override - if (flags.enableParallel) return "parallel" - if (flags.enableExa) return "exa" - return Number.parseInt(checksum(sessionID) ?? "0", 36) % 2 === 0 ? "exa" : "parallel" -} - -const McpResult = Schema.Struct({ - result: Schema.Struct({ - content: Schema.Array(Schema.Struct({ type: Schema.String, text: Schema.String })), - }), -}) -const decodeMcpResult = Schema.decodeUnknownEffect(Schema.fromJsonString(McpResult)) - -const parsePayload = (payload: string) => - Effect.gen(function* () { - const trimmed = payload.trim() - if (!trimmed.startsWith("{")) return undefined - return (yield* decodeMcpResult(trimmed)).result.content.find((item) => item.text)?.text - }) - -export const parseResponse = Effect.fn("WebSearchTool.parseResponse")(function* (body: string) { - const trimmed = body.trim() - const direct = trimmed ? yield* parsePayload(trimmed) : undefined - if (direct) return direct - for (const line of body.split("\n")) { - if (!line.startsWith("data: ")) continue - const data = yield* parsePayload(line.substring(6)) - if (data) return data - } - return undefined -}) - -const ExaArgs = Schema.Struct({ - query: Schema.String, - type: Schema.String, - numResults: Schema.Number, - livecrawl: Schema.String, - contextMaxCharacters: Schema.optional(Schema.Number), -}) -const ParallelArgs = Schema.Struct({ - objective: Schema.String, - search_queries: Schema.Array(Schema.String), - session_id: Schema.String, -}) -const McpRequest = (args: Schema.Struct) => - Schema.Struct({ - jsonrpc: Schema.Literal("2.0"), - id: Schema.Literal(1), - method: Schema.Literal("tools/call"), - params: Schema.Struct({ name: Schema.String, arguments: args }), - }) - -const exaUrl = (apiKey: string | undefined) => { - if (!apiKey) return EXA_URL - const url = new URL(EXA_URL) - url.searchParams.set("exaApiKey", apiKey) - return url.toString() -} - -const callMcp = ( - http: HttpClient.HttpClient, - url: string, - tool: string, - args: Schema.Struct, - value: Schema.Struct.Type, - headers: Record = {}, -) => - Effect.gen(function* () { - const request = yield* HttpClientRequest.post(url).pipe( - HttpClientRequest.accept("application/json, text/event-stream"), - HttpClientRequest.setHeaders(headers), - HttpClientRequest.schemaBodyJson(McpRequest(args))({ - jsonrpc: "2.0" as const, - id: 1 as const, - method: "tools/call" as const, - params: { name: tool, arguments: value }, - }), - ) - return yield* Effect.gen(function* () { - const response = yield* HttpClient.filterStatusOk(http).execute(request) - const body = yield* collectBoundedResponseBody( - response, - MAX_RESPONSE_BYTES, - () => new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`), - ) - return yield* parseResponse(body.toString("utf8")) - }).pipe( - Effect.timeoutOrElse({ - duration: Duration.seconds(25), - orElse: () => Effect.fail(new Error(`${tool} request timed out`)), - }), - ) - }) - const Output = Schema.Struct({ - provider: Provider, + provider: Integration.ID, text: Schema.String, + metadata: Schema.optional(Schema.Json), }) export const Plugin = { id: "opencode.tool.websearch", effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) { - const http = yield* HttpClient.HttpClient - const config = yield* ConfigService const permission = yield* PermissionV2.Service + const search = yield* Search.Service yield* ctx.tool .transform((draft) => @@ -203,54 +64,28 @@ export const Plugin = { input: Input, output: Output, toModelOutput: ({ output }) => [{ type: "text", text: output.text }], - execute: (input, context) => { - const provider = selectProvider(context.sessionID, config, config.provider) - return Effect.gen(function* () { + execute: (input, context) => + Effect.gen(function* () { yield* permission.assert({ action: name, resources: [input.query], save: ["*"], - metadata: { ...input, provider }, + metadata: input, sessionID: context.sessionID, agent: context.agent, source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, }) - - const text = - provider === "exa" - ? yield* callMcp(http, exaUrl(config.exaApiKey), "web_search_exa", ExaArgs, { - query: input.query, - type: input.type || "auto", - numResults: input.numResults || 8, - livecrawl: input.livecrawl || "fallback", - contextMaxCharacters: input.contextMaxCharacters, - }) - : yield* callMcp( - http, - PARALLEL_URL, - "web_search", - ParallelArgs, - { - objective: input.query, - search_queries: [input.query], - session_id: context.sessionID, - // V2 invocation context does not safely expose the model yet. - }, - { - "User-Agent": `opencode/${InstallationVersion}`, - ...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}), - }, - ) + const result = yield* search.query({ ...input, sessionID: context.sessionID }) return { - provider, - text: text ?? NO_RESULTS, + provider: result.providerID, + text: result.text || NO_RESULTS, + metadata: result.metadata, } }).pipe( Effect.mapError( (error) => new ToolFailure({ message: `Unable to search the web for ${input.query}`, error }), ), - ) - }, + ), }), ), ) diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 85db0a6bf3b5..496805d03838 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -4,6 +4,7 @@ import { describe, expect } from "bun:test" import { Effect, Fiber, Layer, PubSub, Schema, Stream } from "effect" import { FastCheck } from "effect/testing" import { Config } from "@opencode-ai/core/config" +import { ConfigGlobal } from "@opencode-ai/core/config/global" import { ConfigModel } from "@opencode-ai/core/config/model" import { Config as ConfigSchema } from "@opencode-ai/schema/config" import { ConfigProvider } from "@opencode-ai/core/config/provider" @@ -22,6 +23,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema" import { location } from "../fixture/location" import { tmpdir } from "../fixture/tmpdir" import { testEffect } from "../lib/effect" +import { parse } from "jsonc-parser" const it = testEffect(Layer.empty) const selection = Schema.decodeUnknownSync(ConfigModel.Selection) @@ -58,6 +60,37 @@ const provider = { } describe("Config", () => { + it.live("updates the global JSONC config without removing comments", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + const global = path.join(tmp.path, "global") + const file = path.join(global, "opencode.jsonc") + yield* Effect.promise(async () => { + await fs.mkdir(global, { recursive: true }) + await fs.writeFile(file, `// user config\n{\n "username": "tester"\n}\n`) + }) + + const config = yield* ConfigGlobal.Service + yield* config.update(["search"], { provider: "exa" }) + + const text = yield* Effect.promise(() => Bun.file(file).text()) + expect(text).toContain("// user config") + expect(parse(text)).toEqual({ username: "tester", search: { provider: "exa" } }) + }).pipe( + Effect.provide( + AppNodeBuilder.build(LayerNode.group([ConfigGlobal.node]), [ + [Global.node, Global.layerWith({ config: path.join(tmp.path, "global") })], + ]), + ), + ), + ), + ), + ) + it.live("reloads external config and publishes directory updates", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), diff --git a/packages/core/test/form.test.ts b/packages/core/test/form.test.ts index 8defff611fa3..b8921e05697b 100644 --- a/packages/core/test/form.test.ts +++ b/packages/core/test/form.test.ts @@ -4,6 +4,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" import { Form } from "@opencode-ai/core/form" +import { Integration } from "@opencode-ai/core/integration" import { SessionSchema } from "@opencode-ai/core/session/schema" import { testEffect } from "./lib/effect" @@ -59,6 +60,27 @@ describe("Form", () => { }), ) + it.effect("uses an empty reply to complete an integration form", () => + Effect.gen(function* () { + const service = yield* Form.Service + const created = yield* service.create({ + sessionID: "ses_test", + mode: "integration", + integrationID: Integration.ID.make("exa"), + }) + + const invalid = yield* service.reply({ id: created.id, answer: { connected: true } }).pipe(Effect.flip) + expect(invalid).toEqual( + new Form.InvalidAnswerError({ + id: created.id, + message: "Integration forms must be answered with an empty answer", + }), + ) + yield* service.reply({ id: created.id, answer: {} }) + expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: {} }) + }), + ) + it.effect("gates required fields and rejects inactive answers via when", () => Effect.gen(function* () { const service = yield* Form.Service diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index 2aa8789548a2..938db47f2538 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -1,11 +1,16 @@ -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { IntegrationDefinition, IntegrationMethodRegistration, PluginContext } from "@opencode-ai/plugin/v2/effect" import { AgentV2 } from "@opencode-ai/core/agent" import { Catalog } from "@opencode-ai/core/catalog" 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 type { IntegrationEnvMethod, IntegrationKeyMethod, IntegrationOAuthMethod } from "@opencode-ai/sdk/v2/types" +import type { + CredentialOAuth, + IntegrationEnvMethod, + IntegrationKeyMethod, + IntegrationOAuthMethod, +} from "@opencode-ai/sdk/v2/types" import { Effect, Stream } from "effect" type Overrides = Partial> @@ -50,6 +55,7 @@ export function host(overrides: Overrides = {}): PluginContext { attemptStatus: () => Effect.die("unused integration.attemptStatus"), attemptComplete: () => Effect.die("unused integration.attemptComplete"), attemptCancel: () => Effect.die("unused integration.attemptCancel"), + register: () => Effect.die("unused integration.register"), transform: () => Effect.die("unused integration.transform"), reload: () => Effect.die("unused integration.reload"), connection: { @@ -201,6 +207,10 @@ export function integrationHost(integration: Integration.Interface): PluginConte connection.type === "credential" ? { ...connection, id: Credential.ID.make(connection.id) } : connection, ), }, + register: (definition) => + integration.transform((draft) => { + registerIntegration(draft, definition) + }), transform: (callback) => integration.transform((draft) => callback({ @@ -213,72 +223,7 @@ export function integrationHost(integration: Integration.Interface): PluginConte remove: (id) => draft.remove(Integration.ID.make(id)), method: { list: (id) => draft.method.list(Integration.ID.make(id)).map(method), - update: (input) => { - if ("authorize" in input) { - const methodID = Integration.MethodID.make(input.method.id) - const refresh = input.refresh - draft.method.update({ - integrationID: Integration.ID.make(input.integrationID), - method: { ...input.method, id: methodID }, - authorize: (inputs) => - input.authorize(inputs).pipe( - Effect.map((authorization) => { - if (authorization.mode === "auto") { - return { - ...authorization, - callback: authorization.callback.pipe( - Effect.map((credential) => - Credential.OAuth.make({ - ...credential, - methodID: Integration.MethodID.make(credential.methodID), - }), - ), - ), - } - } - return { - ...authorization, - callback: (code: string) => - authorization.callback(code).pipe( - Effect.map((credential) => - Credential.OAuth.make({ - ...credential, - methodID: Integration.MethodID.make(credential.methodID), - }), - ), - ), - } - }), - ), - ...(refresh - ? { - refresh: (value: Credential.OAuth) => - refresh(value).pipe( - Effect.map((next) => - Credential.OAuth.make({ - ...next, - methodID: Integration.MethodID.make(next.methodID), - }), - ), - ), - } - : {}), - ...(input.label ? { label: input.label } : {}), - }) - return - } - if (input.method.type === "env") { - draft.method.update({ - integrationID: Integration.ID.make(input.integrationID), - method: { ...input.method, names: [...input.method.names] }, - }) - return - } - draft.method.update({ - integrationID: Integration.ID.make(input.integrationID), - method: input.method, - }) - }, + update: (input) => draft.method.update(methodImplementation(input)), remove: (id, item) => draft.method.remove(Integration.ID.make(id), internalMethod(item)), }, }), @@ -286,6 +231,72 @@ export function integrationHost(integration: Integration.Interface): PluginConte } } +function registerIntegration(draft: Integration.Draft, definition: IntegrationDefinition) { + const integrationID = Integration.ID.make(definition.id) + draft.update(integrationID, (integration) => (integration.name = definition.name)) + for (const item of definition.methods ?? []) { + if (item.type === "env") { + draft.method.update(methodImplementation({ integrationID: definition.id, method: item })) + continue + } + if (item.type === "key") { + draft.method.update(methodImplementation({ integrationID: definition.id, method: item })) + continue + } + const { authorize, refresh, credentialLabel, ...method } = item + draft.method.update( + methodImplementation({ + integrationID: definition.id, + method, + authorize, + ...(refresh ? { refresh } : {}), + ...(credentialLabel ? { label: credentialLabel } : {}), + }), + ) + } + if (!definition.search) return + draft.search.update({ + integrationID, + connection: definition.search.connection, + execute: definition.search.execute, + }) +} + +function methodImplementation(input: IntegrationMethodRegistration): Integration.Implementation { + if ("authorize" in input) { + const refresh = input.refresh + return { + integrationID: Integration.ID.make(input.integrationID), + method: { ...input.method, id: Integration.MethodID.make(input.method.id) }, + authorize: (inputs) => + input.authorize(inputs).pipe( + Effect.map((authorization) => { + if (authorization.mode === "auto") { + return { ...authorization, callback: authorization.callback.pipe(Effect.map(oauthCredential)) } + } + return { + ...authorization, + callback: (code: string) => authorization.callback(code).pipe(Effect.map(oauthCredential)), + } + }), + ), + ...(refresh ? { refresh: (value: Credential.OAuth) => refresh(value).pipe(Effect.map(oauthCredential)) } : {}), + ...(input.label ? { label: input.label } : {}), + } + } + if (input.method.type === "env") { + return { + integrationID: Integration.ID.make(input.integrationID), + method: { ...input.method, names: [...input.method.names] }, + } + } + return { integrationID: Integration.ID.make(input.integrationID), method: input.method } +} + +function oauthCredential(value: CredentialOAuth) { + return Credential.OAuth.make({ ...value, methodID: Integration.MethodID.make(value.methodID) }) +} + function method(value: Integration.Method) { if (value.type === "env") return { type: value.type, names: [...value.names] } if (value.type === "key") return { type: value.type, label: value.label } diff --git a/packages/core/test/plugin/promise.test.ts b/packages/core/test/plugin/promise.test.ts index fbe4029c3a2d..50253ac681b2 100644 --- a/packages/core/test/plugin/promise.test.ts +++ b/packages/core/test/plugin/promise.test.ts @@ -1,6 +1,7 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { AgentV2 } from "@opencode-ai/core/agent" +import { Integration } from "@opencode-ai/core/integration" import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginHost } from "@opencode-ai/core/plugin/host" import { PluginPromise } from "@opencode-ai/core/plugin/promise" @@ -93,4 +94,35 @@ describe("fromPromise", () => { expect(yield* agents.get(AgentV2.ID.make("temp"))).toBeUndefined() }), ) + + it.effect("adapts promise search capability execution", () => + Effect.gen(function* () { + const integrations = yield* Integration.Service + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make(plugin) + const promisePlugin = define({ + id: "promise-search", + setup: async (ctx) => { + await ctx.integration.register({ + id: "promise-search", + name: "Promise Search", + methods: [{ type: "env", names: ["PROMISE_SEARCH_KEY"] }], + search: { + connection: "optional", + execute: async (input) => ({ text: `promise: ${input.query}` }), + }, + }) + }, + }) + + yield* PluginPromise.fromPromise(promisePlugin).effect(host) + expect(yield* integrations.get(Integration.ID.make("promise-search"))).toMatchObject({ + name: "Promise Search", + methods: [{ type: "env", names: ["PROMISE_SEARCH_KEY"] }], + }) + const provider = yield* integrations.search.get(Integration.ID.make("promise-search")) + if (!provider) return yield* Effect.die("Expected promise search provider") + expect(yield* provider.execute({ query: "effect" }, {})).toEqual({ text: "promise: effect" }) + }), + ) }) diff --git a/packages/core/test/plugin/search-fixture.ts b/packages/core/test/plugin/search-fixture.ts new file mode 100644 index 000000000000..7472d57962b9 --- /dev/null +++ b/packages/core/test/plugin/search-fixture.ts @@ -0,0 +1,41 @@ +import { Effect, Layer } from "effect" +import { HttpClient, HttpClientResponse } from "effect/unstable/http" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Credential } from "@opencode-ai/core/credential" +import { EventV2 } from "@opencode-ai/core/event" +import { Integration } from "@opencode-ai/core/integration" +import { testEffect } from "../lib/effect" + +export interface SearchRequest { + readonly url: string + readonly headers: Record + readonly body: unknown +} + +export const requests: SearchRequest[] = [] +export const response = { body: "" } + +export function resetSearchFixture(body: string) { + requests.length = 0 + response.body = body +} + +const http = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`) + requests.push({ + url: request.url, + headers: request.headers, + body: JSON.parse(new TextDecoder().decode(request.body.body)), + }) + return HttpClientResponse.fromWeb(request, new Response(response.body, { status: 200 })) + }), + ), +) + +export const searchIntegrationTest = testEffect( + Layer.merge(AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node, EventV2.node])), http), +) diff --git a/packages/core/test/plugin/search.test.ts b/packages/core/test/plugin/search.test.ts new file mode 100644 index 000000000000..34ced3bdee0c --- /dev/null +++ b/packages/core/test/plugin/search.test.ts @@ -0,0 +1,131 @@ +import { beforeEach, describe, expect } from "bun:test" +import { Effect } from "effect" +import { Credential } from "@opencode-ai/core/credential" +import { Integration } from "@opencode-ai/core/integration" +import { SearchExa } from "@opencode-ai/core/plugin/search/exa" +import { SearchParallel } from "@opencode-ai/core/plugin/search/parallel" +import { host, integrationHost } from "./host" +import { requests, resetSearchFixture, searchIntegrationTest } from "./search-fixture" + +beforeEach(() => { + resetSearchFixture( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { content: [{ type: "text", text: "search results" }] }, + }), + ) +}) + +const it = searchIntegrationTest + +describe("built-in search integrations", () => { + it.effect("registers and disposes an atomic search integration", () => + Effect.gen(function* () { + const integrations = yield* Integration.Service + const registration = yield* integrationHost(integrations).register({ + id: "test-search", + name: "Test Search", + methods: [{ type: "key", label: "API key" }], + search: { + connection: "required", + execute: (input) => Effect.succeed({ text: input.query }), + }, + }) + + expect(yield* integrations.get(Integration.ID.make("test-search"))).toMatchObject({ + name: "Test Search", + methods: [{ type: "key", label: "API key" }], + search: { connection: "required" }, + }) + yield* registration.dispose + expect(yield* integrations.get(Integration.ID.make("test-search"))).toBeUndefined() + }), + ) + + it.effect("registers Exa and maps search hints to its MCP tool", () => + Effect.gen(function* () { + const integrations = yield* Integration.Service + yield* SearchExa.Plugin.effect(host({ integration: integrationHost(integrations) })) + + const info = yield* integrations.get(Integration.ID.make("exa")) + expect(info).toMatchObject({ + id: "exa", + name: "Exa", + methods: [{ type: "key" }, { type: "env", names: ["EXA_API_KEY"] }], + search: { connection: "optional" }, + }) + const provider = yield* integrations.search.get(Integration.ID.make("exa")) + if (!provider) return yield* Effect.die("Expected Exa search provider") + expect( + yield* provider.execute( + { + query: "effect typescript", + numResults: 3, + livecrawl: "preferred", + type: "fast", + contextMaxCharacters: 2500, + }, + { credential: Credential.Key.make({ type: "key", key: "exa secret" }) }, + ), + ).toEqual({ text: "search results" }) + expect(requests).toEqual([ + { + url: `${SearchExa.endpoint}?exaApiKey=exa+secret`, + headers: expect.any(Object), + body: { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "web_search_exa", + arguments: { + query: "effect typescript", + type: "fast", + numResults: 3, + livecrawl: "preferred", + contextMaxCharacters: 2500, + }, + }, + }, + }, + ]) + }), + ) + + it.effect("registers Parallel and keeps its credential in the authorization header", () => + Effect.gen(function* () { + const integrations = yield* Integration.Service + yield* SearchParallel.Plugin.effect(host({ integration: integrationHost(integrations) })) + const provider = yield* integrations.search.get(Integration.ID.make("parallel")) + if (!provider) return yield* Effect.die("Expected Parallel search provider") + + const output = yield* provider.execute( + { query: "effect layers" }, + { + sessionID: "ses_parallel", + credential: Credential.Key.make({ type: "key", key: "parallel-secret" }), + }, + ) + expect(output).toEqual({ text: "search results" }) + expect(requests[0]).toMatchObject({ + url: SearchParallel.endpoint, + headers: { authorization: "Bearer parallel-secret" }, + body: { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "web_search", + arguments: { + objective: "effect layers", + search_queries: ["effect layers"], + session_id: "ses_parallel", + }, + }, + }, + }) + expect(JSON.stringify(output)).not.toContain("parallel-secret") + }), + ) +}) diff --git a/packages/core/test/search.test.ts b/packages/core/test/search.test.ts new file mode 100644 index 000000000000..60347acc2f21 --- /dev/null +++ b/packages/core/test/search.test.ts @@ -0,0 +1,173 @@ +import { beforeEach, describe, expect } from "bun:test" +import { Effect, Exit, Fiber, Layer, Scope } from "effect" +import path from "node:path" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Config } from "@opencode-ai/core/config" +import { ConfigGlobal } from "@opencode-ai/core/config/global" +import { ConfigSearch } from "@opencode-ai/core/config/search" +import { Credential } from "@opencode-ai/core/credential" +import { EventV2 } from "@opencode-ai/core/event" +import { Form } from "@opencode-ai/core/form" +import { Global } from "@opencode-ai/core/global" +import { Integration } from "@opencode-ai/core/integration" +import { Search } from "@opencode-ai/core/search" +import { testEffect } from "./lib/effect" + +let entries: Config.Entry[] = [] +const writes: { path: readonly (string | number)[]; value: unknown }[] = [] +const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed(entries) })) +const configGlobal = Layer.succeed( + ConfigGlobal.Service, + ConfigGlobal.Service.of({ update: (path, value) => Effect.sync(() => writes.push({ path, value })) }), +) +const it = testEffect( + AppNodeBuilder.build( + LayerNode.group([Search.node, Integration.node, Credential.node, EventV2.node, Form.node, ConfigGlobal.node]), + [ + [Config.node, config], + [ConfigGlobal.node, configGlobal], + ], + ), +) + +const register = (id: string, connection: "optional" | "required" = "optional") => + Effect.gen(function* () { + const integrations = yield* Integration.Service + const integrationID = Integration.ID.make(id) + const calls: { input: Search.Input; credential?: Credential.Value; sessionID?: string }[] = [] + yield* integrations.transform((draft) => { + draft.update(integrationID, (integration) => (integration.name = id.toUpperCase())) + draft.search.update({ + integrationID, + connection, + execute: (input, context) => + Effect.sync(() => { + calls.push({ input, ...context }) + return { text: `${id}: ${input.query}`, metadata: { id } } + }), + }) + }) + return { integrationID, calls } + }) + +beforeEach(() => { + entries = [] + writes.length = 0 +}) + +describe("Search", () => { + it.effect("executes an explicit provider without changing the default", () => + Effect.gen(function* () { + const provider = yield* register("exa") + const search = yield* Search.Service + + expect(yield* search.query({ query: "effect", providerID: provider.integrationID })).toEqual( + new Search.Result({ + providerID: provider.integrationID, + text: "exa: effect", + metadata: { id: "exa" }, + }), + ) + expect(yield* search.selected()).toBeUndefined() + expect(provider.calls).toEqual([ + { + input: { query: "effect", providerID: provider.integrationID }, + credential: undefined, + sessionID: undefined, + }, + ]) + }), + ) + + it.effect("uses and persists the global provider selection", () => + Effect.gen(function* () { + yield* register("exa") + const parallel = yield* register("parallel") + const search = yield* Search.Service + yield* search.select(parallel.integrationID) + + expect((yield* search.query({ query: "layers" })).providerID).toBe(parallel.integrationID) + expect(yield* search.selected()).toBe(parallel.integrationID) + expect(writes).toEqual([{ path: ["search"], value: new ConfigSearch.Info({ provider: parallel.integrationID }) }]) + }), + ) + + it.effect("reads the selected provider from global config", () => + Effect.gen(function* () { + const provider = yield* register("exa") + const search = yield* Search.Service + entries = [ + new Config.Document({ + type: "document", + path: path.join(Global.Path.config, "opencode.json"), + info: new Config.Info({ search: new ConfigSearch.Info({ provider: provider.integrationID }) }), + }), + ] + + expect(yield* search.selected()).toBe(provider.integrationID) + expect((yield* search.query({ query: "configured" })).providerID).toBe(provider.integrationID) + }), + ) + + it.effect("prefers the location config over the global selection", () => + Effect.gen(function* () { + const exa = yield* register("exa") + const parallel = yield* register("parallel") + const search = yield* Search.Service + yield* search.select(exa.integrationID) + entries = [ + new Config.Document({ + type: "document", + info: new Config.Info({ search: new ConfigSearch.Info({ provider: parallel.integrationID }) }), + }), + ] + + expect((yield* search.query({ query: "configured" })).providerID).toBe(parallel.integrationID) + }), + ) + + it.effect("serializes concurrent first-use onboarding and persists the answer", () => + Effect.gen(function* () { + const provider = yield* register("exa") + const search = yield* Search.Service + const forms = yield* Form.Service + const first = yield* search.query({ query: "one", sessionID: "ses_search" }).pipe(Effect.forkChild) + const second = yield* search.query({ query: "two", sessionID: "ses_search" }).pipe(Effect.forkChild) + yield* Effect.yieldNow + + const pending = yield* forms.list({ sessionID: "ses_search" }) + expect(pending).toHaveLength(1) + const form = pending[0] + if (!form) return yield* Effect.die("Expected an onboarding form") + yield* forms.reply({ id: form.id, answer: { provider: provider.integrationID } }) + + expect((yield* Fiber.join(first)).providerID).toBe(provider.integrationID) + expect((yield* Fiber.join(second)).providerID).toBe(provider.integrationID) + expect(yield* search.selected()).toBe(provider.integrationID) + }), + ) + + it.effect("requires a connection before invoking a required provider", () => + Effect.gen(function* () { + const provider = yield* register("private", "required") + const search = yield* Search.Service + + expect( + yield* search.query({ query: "secret", providerID: provider.integrationID }).pipe(Effect.flip), + ).toBeInstanceOf(Search.ConnectionRequiredError) + expect(provider.calls).toEqual([]) + }), + ) + + it.effect("removes scoped provider registrations", () => + Effect.gen(function* () { + const integrations = yield* Integration.Service + const scope = yield* Scope.fork(yield* Scope.Scope) + const provider = yield* register("temporary").pipe(Scope.provide(scope)) + expect(yield* integrations.search.get(provider.integrationID)).toBeDefined() + yield* Scope.close(scope, Exit.void) + expect(yield* integrations.search.get(provider.integrationID)).toBeUndefined() + }), + ) +}) diff --git a/packages/core/test/tool-websearch.test.ts b/packages/core/test/tool-websearch.test.ts index 53984ef69b70..a9ea4bc55d0b 100644 --- a/packages/core/test/tool-websearch.test.ts +++ b/packages/core/test/tool-websearch.test.ts @@ -1,22 +1,22 @@ import { beforeEach, describe, expect, test } from "bun:test" import { Effect, Layer, Schema } from "effect" -import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" import { PermissionV2 } from "@opencode-ai/core/permission" +import { Integration } from "@opencode-ai/core/integration" +import { Search } from "@opencode-ai/core/search" import { SessionV2 } from "@opencode-ai/core/session" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { WebSearchTool } from "@opencode-ai/core/tool/websearch" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { makeLocationNode } from "@opencode-ai/core/effect/app-node" import { testEffect } from "./lib/effect" -import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool" +import { executeTool, registerToolPlugin, settleTool, toolDefinitions, toolIdentity } from "./lib/tool" const webSearchToolNode = makeLocationNode({ name: "test/websearch-tool-plugin", layer: Layer.effectDiscard(registerToolPlugin(WebSearchTool.Plugin)), - deps: [ToolRegistry.toolsNode, PermissionV2.node, LayerNodePlatform.httpClient, WebSearchTool.configNode], + deps: [ToolRegistry.toolsNode, PermissionV2.node, Search.node], }) const sessionID = SessionV2.ID.make("ses_websearch_test") @@ -27,31 +27,13 @@ const payload = (text: string) => result: { content: [{ type: "text", text }] }, }) -describe("WebSearchTool provider selection", () => { +describe("WebSearchTool input", () => { test("rejects out-of-range numeric controls", () => { const decode = Schema.decodeUnknownSync(WebSearchTool.Input) expect(() => decode({ query: "x", numResults: 0 })).toThrow() expect(() => decode({ query: "x", numResults: WebSearchTool.MAX_NUM_RESULTS + 1 })).toThrow() expect(() => decode({ query: "x", contextMaxCharacters: WebSearchTool.MAX_CONTEXT_CHARACTERS + 1 })).toThrow() }) - test("selects a stable provider per session", () => { - expect(WebSearchTool.selectProvider(sessionID)).toBe(WebSearchTool.selectProvider(sessionID)) - }) - - test("supports an explicit operational override", () => { - expect(WebSearchTool.selectProvider(sessionID, { enableExa: false, enableParallel: false }, "parallel")).toBe( - "parallel", - ) - expect(WebSearchTool.selectProvider(sessionID, { enableExa: false, enableParallel: false }, "exa")).toBe("exa") - }) - - test("prefers Parallel when both explicit flags are enabled", () => { - expect(WebSearchTool.selectProvider(sessionID, { enableExa: true, enableParallel: true })).toBe("parallel") - }) - - test("prefers Exa when only its explicit flag is enabled", () => { - expect(WebSearchTool.selectProvider(sessionID, { enableExa: true, enableParallel: false })).toBe("exa") - }) }) describe("WebSearchTool MCP response parser", () => { @@ -68,37 +50,16 @@ describe("WebSearchTool MCP response parser", () => { }) }) -interface Request { - readonly url: string - readonly headers: Record - readonly body: unknown -} - -const requests: Request[] = [] const assertions: PermissionV2.AssertInput[] = [] -let responseBody = payload("search results") -let makeResponse = () => new Response(responseBody, { status: 200 }) -let config: WebSearchTool.Config = { enableExa: false, enableParallel: false } +const queries: Search.QueryInput[] = [] +let result = new Search.Result({ providerID: Integration.ID.make("exa"), text: "search results" }) beforeEach(() => { - responseBody = payload("search results") - makeResponse = () => new Response(responseBody, { status: 200 }) + assertions.length = 0 + queries.length = 0 + result = new Search.Result({ providerID: Integration.ID.make("exa"), text: "search results" }) }) -const http = Layer.succeed( - HttpClient.HttpClient, - HttpClient.make((request) => - Effect.sync(() => { - if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`) - requests.push({ - url: request.url, - headers: request.headers, - body: JSON.parse(new TextDecoder().decode(request.body.body)), - }) - return HttpClientResponse.fromWeb(request, makeResponse()) - }), - ), -) const permission = Layer.succeed( PermissionV2.Service, PermissionV2.Service.of({ @@ -110,45 +71,29 @@ const permission = Layer.succeed( list: () => Effect.die("unused"), }), ) -const websearchConfig = Layer.succeed( - WebSearchTool.ConfigService, - WebSearchTool.ConfigService.of({ - get provider() { - return config.provider - }, - get enableExa() { - return config.enableExa - }, - get enableParallel() { - return config.enableParallel - }, - get exaApiKey() { - return config.exaApiKey - }, - get parallelApiKey() { - return config.parallelApiKey - }, +const search = Layer.succeed( + Search.Service, + Search.Service.of({ + selected: () => Effect.succeed(undefined), + select: () => Effect.die("unused"), + query: (input) => + Effect.sync(() => { + queries.push(input) + return result + }), }), ) const it = testEffect( - AppNodeBuilder.build( - LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebSearchTool.configNode, webSearchToolNode]), - [ - [PermissionV2.node, permission], - [LayerNodePlatform.httpClient, http], - [WebSearchTool.configNode, websearchConfig], - [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], - ], - ), + AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, Search.node, webSearchToolNode]), [ + [PermissionV2.node, permission], + [Search.node, search], + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + ]), ) describe("WebSearchTool registration", () => { - it.effect("registers websearch, asserts query permission, and calls Exa", () => + it.effect("asserts permission before delegating to Search", () => Effect.gen(function* () { - requests.length = 0 - assertions.length = 0 - responseBody = payload("exa results") - config = { provider: "exa", enableExa: false, enableParallel: false } const registry = yield* ToolRegistry.Service expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["websearch"]) @@ -158,7 +103,7 @@ describe("WebSearchTool registration", () => { ...toolIdentity, call: { type: "tool-call", - id: "call-exa", + id: "call-search", name: "websearch", input: { query: "effect typescript", @@ -169,7 +114,7 @@ describe("WebSearchTool registration", () => { }, }, }), - ).toEqual({ type: "text", value: "exa results" }) + ).toEqual({ type: "text", value: "search results" }) expect(assertions).toMatchObject([ { sessionID, @@ -182,98 +127,50 @@ describe("WebSearchTool registration", () => { livecrawl: "preferred", type: "fast", contextMaxCharacters: 2500, - provider: "exa", }, }, ]) - expect(requests).toEqual([ + expect(queries).toEqual([ { - url: WebSearchTool.EXA_URL, - headers: expect.any(Object), - body: { - jsonrpc: "2.0", - id: 1, - method: "tools/call", - params: { - name: "web_search_exa", - arguments: { - query: "effect typescript", - type: "fast", - numResults: 3, - livecrawl: "preferred", - contextMaxCharacters: 2500, - }, - }, - }, + sessionID, + query: "effect typescript", + numResults: 3, + livecrawl: "preferred", + type: "fast", + contextMaxCharacters: 2500, }, ]) }), ) - it.effect("calls Parallel with session ID and keeps bearer credentials out of output", () => + it.effect("keeps provider metadata in structured output", () => Effect.gen(function* () { - requests.length = 0 - assertions.length = 0 - responseBody = payload("parallel results") - config = { provider: "parallel", enableExa: false, enableParallel: false, parallelApiKey: "parallel-secret" } - const registry = yield* ToolRegistry.Service - - const settled = yield* settleTool(registry, { - sessionID, - ...toolIdentity, - call: { type: "tool-call", id: "call-parallel", name: "websearch", input: { query: "effect layers" } }, + result = new Search.Result({ + providerID: Integration.ID.make("parallel"), + text: "parallel results", + metadata: { requestID: "req_1" }, }) + const registry = yield* ToolRegistry.Service - expect(requests[0]).toMatchObject({ - url: WebSearchTool.PARALLEL_URL, - headers: { authorization: "Bearer parallel-secret" }, - body: { - jsonrpc: "2.0", - id: 1, - method: "tools/call", - params: { - name: "web_search", - arguments: { objective: "effect layers", search_queries: ["effect layers"], session_id: sessionID }, - }, - }, - }) - expect(requests[0]?.body).not.toHaveProperty("params.arguments.model_name") - expect(settled).toEqual({ + expect( + yield* settleTool(registry, { + sessionID, + ...toolIdentity, + call: { type: "tool-call", id: "call-parallel", name: "websearch", input: { query: "effect layers" } }, + }), + ).toEqual({ result: { type: "text", value: "parallel results" }, output: { - structured: { provider: "parallel", text: "parallel results" }, + structured: { provider: "parallel", text: "parallel results", metadata: { requestID: "req_1" } }, content: [{ type: "text", text: "parallel results" }], }, }) - expect(JSON.stringify(settled)).not.toContain("parallel-secret") }), ) - it.effect("keeps an Exa credential in the transport URL and out of model output", () => + it.effect("uses the concise no-results fallback", () => Effect.gen(function* () { - requests.length = 0 - assertions.length = 0 - responseBody = payload("credentialed exa results") - config = { provider: "exa", enableExa: false, enableParallel: false, exaApiKey: "exa secret" } - const registry = yield* ToolRegistry.Service - - const settled = yield* settleTool(registry, { - sessionID, - ...toolIdentity, - call: { type: "tool-call", id: "call-exa-key", name: "websearch", input: { query: "effect schema" } }, - }) - - expect(requests[0]?.url).toBe(`${WebSearchTool.EXA_URL}?exaApiKey=exa+secret`) - expect(JSON.stringify(settled)).not.toContain("exa secret") - }), - ) - - it.effect("returns the legacy no-results fallback as concise model text", () => - Effect.gen(function* () { - requests.length = 0 - assertions.length = 0 - responseBody = "" - config = { provider: "exa", enableExa: false, enableParallel: false } + result = new Search.Result({ providerID: Integration.ID.make("exa"), text: "" }) const registry = yield* ToolRegistry.Service expect( @@ -285,39 +182,4 @@ describe("WebSearchTool registration", () => { ).toEqual({ type: "text", value: WebSearchTool.NO_RESULTS }) }), ) - - it.effect("rejects oversized MCP response bodies", () => - Effect.gen(function* () { - requests.length = 0 - assertions.length = 0 - let chunksRead = 0 - let cancelled = false - makeResponse = () => - new Response( - new ReadableStream({ - pull(controller) { - chunksRead++ - if (chunksRead === 10) throw new Error("response was not stopped at the byte limit") - controller.enqueue(new Uint8Array(64 * 1024)) - }, - cancel() { - cancelled = true - }, - }), - { status: 200 }, - ) - config = { provider: "exa", enableExa: false, enableParallel: false } - const registry = yield* ToolRegistry.Service - - expect( - yield* executeTool(registry, { - sessionID, - ...toolIdentity, - call: { type: "tool-call", id: "call-large-response", name: "websearch", input: { query: "too much" } }, - }), - ).toEqual({ type: "error", value: "Unable to search the web for too much" }) - expect(chunksRead).toBeLessThan(10) - expect(cancelled).toBe(true) - }), - ) }) diff --git a/packages/plugin/src/v2/effect/index.ts b/packages/plugin/src/v2/effect/index.ts index 1332ce27521a..75a87dae1c15 100644 --- a/packages/plugin/src/v2/effect/index.ts +++ b/packages/plugin/src/v2/effect/index.ts @@ -6,7 +6,15 @@ export type { AISDKHooks } from "./aisdk.js" export type { CatalogDraft, CatalogHooks, CatalogProviderRecord } from "./catalog.js" export type { CommandDraft, CommandHooks } from "./command.js" export type { EventHooks } from "./event.js" -export type { IntegrationDraft, IntegrationHooks, IntegrationMethodRegistration } from "./integration.js" +export type { + IntegrationDraft, + IntegrationDefinition, + IntegrationHooks, + IntegrationMethodDefinition, + IntegrationMethodRegistration, + IntegrationOAuthMethodDefinition, + IntegrationSearchDefinition, +} from "./integration.js" export type { ReferenceDraft, ReferenceHooks } from "./reference.js" export type { SkillDraft, SkillHooks } from "./skill.js" export * as Tool from "./tool.js" diff --git a/packages/plugin/src/v2/effect/integration.ts b/packages/plugin/src/v2/effect/integration.ts index 1433786634e1..30c9d98fb119 100644 --- a/packages/plugin/src/v2/effect/integration.ts +++ b/packages/plugin/src/v2/effect/integration.ts @@ -10,8 +10,9 @@ import type { IntegrationRef, } from "@opencode-ai/sdk/v2/types" import type { IntegrationApi } from "@opencode-ai/client/effect/api" +import type { Search } from "@opencode-ai/schema/search" import type { Effect, Scope } from "effect" -import type { TransformHook } from "./registration.js" +import type { Registration, TransformHook } from "./registration.js" export type IntegrationOAuthAuthorization = { readonly url: string @@ -44,6 +45,29 @@ export type IntegrationMethodRegistration = readonly method: IntegrationEnvMethod } +export type IntegrationOAuthMethodDefinition = IntegrationOAuthMethod & { + readonly authorize: (inputs: IntegrationInputs) => Effect.Effect + readonly refresh?: (credential: CredentialOAuth) => Effect.Effect + readonly credentialLabel?: (credential: CredentialOAuth) => string | undefined +} + +export type IntegrationMethodDefinition = IntegrationOAuthMethodDefinition | IntegrationKeyMethod | IntegrationEnvMethod + +export interface IntegrationSearchDefinition { + readonly connection: "optional" | "required" + readonly execute: ( + input: Search.Input, + context: { readonly credential?: CredentialValue; readonly sessionID?: string }, + ) => Effect.Effect +} + +export interface IntegrationDefinition { + readonly id: string + readonly name: string + readonly methods?: readonly IntegrationMethodDefinition[] + readonly search?: IntegrationSearchDefinition +} + export interface IntegrationDraft { list(): readonly IntegrationRef[] get(id: string): IntegrationRef | undefined @@ -57,6 +81,7 @@ export interface IntegrationDraft { } export interface IntegrationHooks extends IntegrationApi { + readonly register: (definition: IntegrationDefinition) => Effect.Effect readonly transform: TransformHook readonly reload: () => Effect.Effect readonly connection: { diff --git a/packages/plugin/src/v2/promise/index.ts b/packages/plugin/src/v2/promise/index.ts index 4f5d5754eae7..7575d1a2664a 100644 --- a/packages/plugin/src/v2/promise/index.ts +++ b/packages/plugin/src/v2/promise/index.ts @@ -7,7 +7,16 @@ export type { AISDKHooks } from "./aisdk.js" export type { CatalogDraft, CatalogHooks, CatalogProviderRecord } from "./catalog.js" export type { CommandDraft, CommandHooks } from "./command.js" export type { EventHooks } from "./event.js" -export type { IntegrationDraft, IntegrationHooks, IntegrationMethodRegistration } from "./integration.js" +export type { + IntegrationDraft, + IntegrationDefinition, + IntegrationHooks, + IntegrationMethodDefinition, + IntegrationMethodRegistration, + IntegrationOAuthAuthorization, + IntegrationOAuthMethodDefinition, + IntegrationSearchDefinition, +} from "./integration.js" export type { ReferenceDraft, ReferenceHooks } from "./reference.js" export type { SessionHooks } from "./runtime.js" export type { SkillDraft, SkillHooks } from "./skill.js" diff --git a/packages/plugin/src/v2/promise/integration.ts b/packages/plugin/src/v2/promise/integration.ts index bd133e889eaf..4016442cc74b 100644 --- a/packages/plugin/src/v2/promise/integration.ts +++ b/packages/plugin/src/v2/promise/integration.ts @@ -1,11 +1,59 @@ import type { IntegrationApi } from "@opencode-ai/client/promise/api" -import type { IntegrationDraft, IntegrationMethodRegistration } from "../effect/integration.js" -import type { CredentialValue } from "@opencode-ai/sdk/v2/types" -import type { TransformHook } from "./registration.js" +import type { IntegrationMethodRegistration } from "../effect/integration.js" +import type { + CredentialOAuth, + CredentialValue, + IntegrationEnvMethod, + IntegrationInputs, + IntegrationKeyMethod, + IntegrationOAuthMethod, +} from "@opencode-ai/sdk/v2/types" +import type { Search } from "@opencode-ai/schema/search" +import type { Registration, TransformHook } from "./registration.js" -export type { IntegrationDraft, IntegrationMethodRegistration } +export type { IntegrationMethodRegistration } + +export type IntegrationOAuthAuthorization = { + readonly url: string + readonly instructions: string +} & ( + | { + readonly mode: "auto" + readonly callback: Promise + } + | { + readonly mode: "code" + readonly callback: (code: string) => Promise + } +) + +export type IntegrationOAuthMethodDefinition = IntegrationOAuthMethod & { + readonly authorize: (inputs: IntegrationInputs) => Promise + readonly refresh?: (credential: CredentialOAuth) => Promise + readonly credentialLabel?: (credential: CredentialOAuth) => string | undefined +} + +export type IntegrationMethodDefinition = IntegrationOAuthMethodDefinition | IntegrationKeyMethod | IntegrationEnvMethod + +export interface IntegrationSearchDefinition { + readonly connection: "optional" | "required" + readonly execute: ( + input: Search.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 search?: IntegrationSearchDefinition +} + +export type IntegrationDraft = import("../effect/integration.js").IntegrationDraft export interface IntegrationHooks extends IntegrationApi { + readonly register: (definition: IntegrationDefinition) => Promise readonly transform: TransformHook readonly reload: () => Promise readonly connection: { diff --git a/packages/protocol/src/api.ts b/packages/protocol/src/api.ts index 2f91fae33226..d2937fade063 100644 --- a/packages/protocol/src/api.ts +++ b/packages/protocol/src/api.ts @@ -24,6 +24,7 @@ import { ReferenceGroup } from "./groups/reference.js" import { Authorization } from "./middleware/authorization.js" import { LocationGroup } from "./groups/location.js" import { IntegrationGroup } from "./groups/integration.js" +import { SearchGroup } from "./groups/search.js" import { McpGroup } from "./groups/mcp.js" import { CredentialGroup } from "./groups/credential.js" import { ProjectGroup } from "./groups/project.js" @@ -38,6 +39,7 @@ type LocationGroups = | HttpApiGroup.AddMiddleware | HttpApiGroup.AddMiddleware | HttpApiGroup.AddMiddleware + | HttpApiGroup.AddMiddleware | HttpApiGroup.AddMiddleware | HttpApiGroup.AddMiddleware | HttpApiGroup.AddMiddleware @@ -168,6 +170,7 @@ const makeApiFromGroup = < .add(ProjectCopyGroup.middleware(locationMiddleware)) .add(VcsGroup.middleware(locationMiddleware)) .add(DebugGroup) + .add(SearchGroup.middleware(locationMiddleware)) .annotateMerge( OpenApi.annotations({ title: "opencode HttpApi", diff --git a/packages/protocol/src/client.ts b/packages/protocol/src/client.ts index 1abf66679661..3f054fe49d2e 100644 --- a/packages/protocol/src/client.ts +++ b/packages/protocol/src/client.ts @@ -44,6 +44,7 @@ export const groupNames = { "server.generate": "generate", "server.provider": "provider", "server.integration": "integration", + "server.search": "search", "server.credential": "credential", "server.form": "form", "server.permission": "permission", @@ -63,6 +64,8 @@ export const groupNames = { export const endpointNames = { "session.messages": "list", "integration.connect.key": "connectKey", + "search.provider.get": "provider", + "search.provider.select": "selectProvider", "integration.connect.oauth": "connectOauth", "integration.attempt.status": "attemptStatus", "integration.attempt.complete": "attemptComplete", diff --git a/packages/protocol/src/groups/search.ts b/packages/protocol/src/groups/search.ts new file mode 100644 index 000000000000..40085d1252a8 --- /dev/null +++ b/packages/protocol/src/groups/search.ts @@ -0,0 +1,62 @@ +import { Integration } from "@opencode-ai/schema/integration" +import { Location } from "@opencode-ai/schema/location" +import { Search } from "@opencode-ai/schema/search" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { InvalidRequestError, ServiceUnavailableError } from "../errors.js" +import { LocationQuery, locationQueryOpenApi } from "./location.js" + +export const SearchGroup = HttpApiGroup.make("server.search") + .add( + HttpApiEndpoint.get("search.provider.get", "/api/search/provider", { + query: LocationQuery, + success: Location.response(Schema.UndefinedOr(Integration.ID)), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.search.provider.get", + summary: "Get default search provider", + description: "Return the globally selected web search provider.", + }), + ), + ) + .add( + HttpApiEndpoint.post("search.provider.select", "/api/search/provider", { + query: LocationQuery, + payload: Schema.Struct({ providerID: Integration.ID }), + success: HttpApiSchema.NoContent, + error: [InvalidRequestError, ServiceUnavailableError], + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.search.provider.select", + summary: "Select default search provider", + description: "Persist the global web search provider in the user configuration.", + }), + ), + ) + .add( + HttpApiEndpoint.post("search.query", "/api/search", { + query: LocationQuery, + payload: Schema.Struct(Search.Input.fields), + success: Location.response(Search.Result), + error: [InvalidRequestError, ServiceUnavailableError], + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.search.query", + summary: "Search the web", + description: + "Run one web search through the selected integration. Specify a provider to override the configured default.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "search", + description: "Location-scoped web search routes.", + }), + ) diff --git a/packages/schema/src/form.ts b/packages/schema/src/form.ts index df9d455f1390..65cb536cac45 100644 --- a/packages/schema/src/form.ts +++ b/packages/schema/src/form.ts @@ -3,6 +3,7 @@ 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")) @@ -124,8 +125,15 @@ export const UrlInfo = Schema.Struct({ }).annotate({ identifier: "Form.UrlInfo" }) export interface UrlInfo extends Schema.Schema.Type {} -export const Info = Schema.Union([FormInfo, UrlInfo]).pipe(Schema.toTaggedUnion("mode")) -export type Info = FormInfo | UrlInfo +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 Value = Schema.Union([Schema.String, Schema.Number, Schema.Boolean, Schema.Array(Schema.String)]).annotate( { diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index 3f2efee68d1e..90118b481e28 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -16,6 +16,7 @@ export { Project } from "./project.js" export { ProjectCopy } from "./project-copy.js" export { Provider } from "./provider.js" export { Reference } from "./reference.js" +export { Search } from "./search.js" export { Revert } from "./revert.js" export { Session } from "./session.js" export { Vcs } from "./vcs.js" diff --git a/packages/schema/src/integration.ts b/packages/schema/src/integration.ts index 329bf8207ffa..ce5fa6661eb9 100644 --- a/packages/schema/src/integration.ts +++ b/packages/schema/src/integration.ts @@ -76,6 +76,11 @@ 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 Search extends Schema.Schema.Type {} +export const Search = Schema.Struct({ + connection: Schema.Literals(["optional", "required"]), +}).annotate({ identifier: "Integration.Search" }) + const Updated = ephemeral({ type: "integration.updated", schema: {}, @@ -96,6 +101,7 @@ export class Info extends Schema.Class("Integration.Info")({ id: ID, name: Schema.String, methods: Schema.Array(Method), + search: optional(Search), connections: Schema.Array(Connection.Info), }) {} diff --git a/packages/schema/src/search.ts b/packages/schema/src/search.ts new file mode 100644 index 000000000000..5b93fbf966e6 --- /dev/null +++ b/packages/schema/src/search.ts @@ -0,0 +1,26 @@ +export * as Search from "./search.js" + +import { Schema } from "effect" +import { IntegrationID } from "./integration-id.js" +import { optional, PositiveInt } from "./schema.js" + +export interface Input extends Schema.Schema.Type {} +export const Input = Schema.Struct({ + query: Schema.String, + providerID: IntegrationID.pipe(optional), + numResults: PositiveInt.check(Schema.isLessThanOrEqualTo(20)).pipe(optional), + livecrawl: Schema.Literals(["fallback", "preferred"]).pipe(optional), + type: Schema.Literals(["auto", "fast", "deep"]).pipe(optional), + contextMaxCharacters: PositiveInt.check(Schema.isLessThanOrEqualTo(50_000)).pipe(optional), +}).annotate({ identifier: "Search.Input" }) + +export interface ProviderOutput extends Schema.Schema.Type {} +export const ProviderOutput = Schema.Struct({ + text: Schema.String, + metadata: Schema.Json.pipe(optional), +}).annotate({ identifier: "Search.ProviderOutput" }) + +export class Result extends Schema.Class("Search.Result")({ + providerID: IntegrationID, + ...ProviderOutput.fields, +}) {} diff --git a/packages/sdk-next/src/index.ts b/packages/sdk-next/src/index.ts index d8ce5f656be8..4af002d5d851 100644 --- a/packages/sdk-next/src/index.ts +++ b/packages/sdk-next/src/index.ts @@ -20,6 +20,7 @@ export { Provider } from "@opencode-ai/schema/provider" export { Pty } from "@opencode-ai/schema/pty" export { Question } from "@opencode-ai/schema/question" export { Reference } from "@opencode-ai/schema/reference" +export { Search } from "@opencode-ai/schema/search" export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema" export { Session } from "@opencode-ai/schema/session" export { SessionInput } from "@opencode-ai/schema/session-input" diff --git a/packages/sdk-next/test/contract-identity.test.ts b/packages/sdk-next/test/contract-identity.test.ts index 830fe5f43b2a..169042ef7578 100644 --- a/packages/sdk-next/test/contract-identity.test.ts +++ b/packages/sdk-next/test/contract-identity.test.ts @@ -1,6 +1,7 @@ import { expect, test } from "bun:test" import { Agent } from "@opencode-ai/schema/agent" import { Model } from "@opencode-ai/schema/model" +import { Search } from "@opencode-ai/schema/search" import { Session } from "@opencode-ai/schema/session" const SDK = await import("../src/index") @@ -8,6 +9,7 @@ const SDK = await import("../src/index") test("re-exports canonical contracts directly from Schema", () => { expect(SDK.Agent).toBe(Agent) expect(SDK.Model).toBe(Model) + expect(SDK.Search).toBe(Search) expect(SDK.Session).toBe(Session) expect(Object.keys(SDK).sort()).toEqual([ "AbsolutePath", @@ -31,6 +33,7 @@ test("re-exports canonical contracts directly from Schema", () => { "Question", "Reference", "RelativePath", + "Search", "Session", "SessionInput", "SessionMessage", diff --git a/packages/sdk-next/test/embedded.test.ts b/packages/sdk-next/test/embedded.test.ts index b0efa6290971..5aa44ccfc149 100644 --- a/packages/sdk-next/test/embedded.test.ts +++ b/packages/sdk-next/test/embedded.test.ts @@ -258,6 +258,39 @@ it.live( 10_000, ) +it.live("embedded client exposes integration-backed search", () => + withEmbedded("opencode-embedded-search-", (fixture) => + Effect.gen(function* () { + const opencode = yield* fixture.sdk.OpenCode.create() + const providerID = fixture.sdk.Integration.ID.make("embedded-search") + yield* opencode.plugin({ + id: `embedded-search-${crypto.randomUUID()}`, + effect: (ctx) => + ctx.integration.register({ + id: providerID, + name: "Embedded search", + search: { + connection: "optional", + execute: (input) => Effect.succeed({ text: `Found ${input.query}`, metadata: { source: "embedded" } }), + }, + }), + }) + + const result = yield* opencode.search.query({ + query: "opencode", + providerID, + location: location(fixture), + }) + + expect(result.data).toEqual({ + providerID, + text: "Found opencode", + metadata: { source: "embedded" }, + }) + }), + ), +) + it.live( "Location-owned runner events reach the ready global client", () => diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 458f9c2d2ead..e37f5b1ccab1 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -356,6 +356,12 @@ import type { V2QuestionRequestListResponses, V2ReferenceListErrors, V2ReferenceListResponses, + V2SearchProviderGetErrors, + V2SearchProviderGetResponses, + V2SearchProviderSelectErrors, + V2SearchProviderSelectResponses, + V2SearchQueryErrors, + V2SearchQueryResponses, V2SessionActiveErrors, V2SessionActiveResponses, V2SessionBackgroundErrors, @@ -8131,6 +8137,127 @@ export class Debug extends HeyApiClient { } } +export class Provider3 extends HeyApiClient { + /** + * Get default search provider + * + * Return the globally selected web search provider. + */ + public get( + parameters?: { + location?: { + directory?: string | null + workspace?: string | null + } | null + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + return (options?.client ?? this.client).get({ + url: "/api/search/provider", + ...options, + ...params, + }) + } + + /** + * Select default search provider + * + * Persist the global web search provider in the user configuration. + */ + public select( + parameters?: { + location?: { + directory?: string | null + workspace?: string | null + } | null + providerID?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "location" }, + { in: "body", key: "providerID" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + V2SearchProviderSelectResponses, + V2SearchProviderSelectErrors, + ThrowOnError + >({ + url: "/api/search/provider", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class Search extends HeyApiClient { + /** + * Search the web + * + * Run one web search through the selected integration. Specify a provider to override the configured default. + */ + public query( + parameters?: { + location?: { + directory?: string | null + workspace?: string | null + } | null + query?: string + providerID?: string + numResults?: number + livecrawl?: "fallback" | "preferred" + type?: "auto" | "fast" | "deep" + contextMaxCharacters?: number + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "location" }, + { in: "body", key: "query" }, + { in: "body", key: "providerID" }, + { in: "body", key: "numResults" }, + { in: "body", key: "livecrawl" }, + { in: "body", key: "type" }, + { in: "body", key: "contextMaxCharacters" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/api/search", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + private _provider?: Provider3 + get provider(): Provider3 { + return (this._provider ??= new Provider3({ client: this.client })) + } +} + export class V2 extends HeyApiClient { private _health?: Health get health(): Health { @@ -8256,6 +8383,11 @@ export class V2 extends HeyApiClient { get debug(): Debug { return (this._debug ??= new Debug({ client: this.client })) } + + private _search?: Search + get search(): Search { + return (this._search ??= new Search({ client: this.client })) + } } export class OpencodeClient extends HeyApiClient { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 87aa5ddbe1c7..b4b80f839ad8 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1482,7 +1482,7 @@ export type GlobalEvent = { id: string type: "form.created" properties: { - form: FormFormInfo | FormUrlInfo + form: FormFormInfo | FormUrlInfo | FormIntegrationInfo } } | { @@ -3520,6 +3520,15 @@ export type FormUrlInfo = { url: string } +export type FormIntegrationInfo = { + id: string + sessionID: string + title?: string + metadata?: FormMetadata + mode: "integration" + integrationID: string +} + export type FormValue = | string | number @@ -5602,6 +5611,10 @@ export type IntegrationEnvMethod = { names: Array } +export type IntegrationSearch = { + connection: "optional" | "required" +} + export type ConnectionCredentialInfo = { type: "credential" id: string @@ -5619,6 +5632,7 @@ export type IntegrationInfo = { id: string name: string methods: Array + search?: IntegrationSearch connections: Array } @@ -6418,7 +6432,7 @@ export type FormCreated = { type: "form.created" location?: LocationRef data: { - form: FormFormInfo | FormUrlInfo + form: FormFormInfo | FormUrlInfo | FormIntegrationInfo } } @@ -6869,6 +6883,12 @@ export type ProjectCopyCopy = { export type VcsMode = "working" | "branch" +export type SearchResult = { + providerID: string + text: string + metadata?: unknown +} + export type EventModelsDevRefreshed = { id: string type: "models-dev.refreshed" @@ -7683,7 +7703,7 @@ export type EventFormCreated = { id: string type: "form.created" properties: { - form: FormFormInfo | FormUrlInfo + form: FormFormInfo | FormUrlInfo | FormIntegrationInfo } } @@ -9747,6 +9767,15 @@ export type FormUrlInfoV2 = { url: string } +export type FormIntegrationInfoV2 = { + id: string + sessionID: string + title?: string + metadata?: FormMetadata + mode: "integration" + integrationID: string +} + export type FormCreatePayloadV2 = { id?: string | null title?: string @@ -10414,6 +10443,15 @@ export type FormUrlInfo1 = { url: string } +export type FormIntegrationInfo1 = { + id: string + sessionID: string + title?: string + metadata?: FormMetadata1 + mode: "integration" + integrationID: string +} + export type FormCreatedV2 = { id: string created: number @@ -10423,7 +10461,7 @@ export type FormCreatedV2 = { type: "form.created" location?: LocationRefV2 data: { - form: FormFormInfo1 | FormUrlInfo1 + form: FormFormInfo1 | FormUrlInfo1 | FormIntegrationInfo1 } } @@ -16889,7 +16927,7 @@ export type V2FormRequestListResponses = { */ 200: { location: LocationInfoV2 - data: Array + data: Array } } @@ -16926,7 +16964,7 @@ export type V2SessionFormListResponses = { * Success */ 200: { - data: Array + data: Array } } @@ -16967,7 +17005,7 @@ export type V2SessionFormCreateResponses = { * Success */ 200: { - data: FormFormInfoV2 | FormUrlInfoV2 + data: FormFormInfoV2 | FormUrlInfoV2 | FormIntegrationInfoV2 } } @@ -17005,7 +17043,7 @@ export type V2SessionFormGetResponses = { * Success */ 200: { - data: FormFormInfoV2 | FormUrlInfoV2 + data: FormFormInfoV2 | FormUrlInfoV2 | FormIntegrationInfoV2 } } @@ -18578,6 +18616,131 @@ export type V2DebugLocationResponses = { export type V2DebugLocationResponse = V2DebugLocationResponses[keyof V2DebugLocationResponses] +export type V2SearchProviderGetData = { + body?: never + path?: never + query?: { + location?: { + directory?: string | null + workspace?: string | null + } | null + } + url: "/api/search/provider" +} + +export type V2SearchProviderGetErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestErrorV2 + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2SearchProviderGetError = V2SearchProviderGetErrors[keyof V2SearchProviderGetErrors] + +export type V2SearchProviderGetResponses = { + /** + * Success + */ + 200: { + location: LocationInfoV2 + data: string | null + } +} + +export type V2SearchProviderGetResponse = V2SearchProviderGetResponses[keyof V2SearchProviderGetResponses] + +export type V2SearchProviderSelectData = { + body: { + providerID: string + } + path?: never + query?: { + location?: { + directory?: string | null + workspace?: string | null + } | null + } + url: "/api/search/provider" +} + +export type V2SearchProviderSelectErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError1 | InvalidRequestErrorV2 + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * ServiceUnavailableError + */ + 503: ServiceUnavailableErrorV2 +} + +export type V2SearchProviderSelectError = V2SearchProviderSelectErrors[keyof V2SearchProviderSelectErrors] + +export type V2SearchProviderSelectResponses = { + /** + * + */ + 204: void +} + +export type V2SearchProviderSelectResponse = V2SearchProviderSelectResponses[keyof V2SearchProviderSelectResponses] + +export type V2SearchQueryData = { + body: { + query: string + providerID?: string + numResults?: number + livecrawl?: "fallback" | "preferred" + type?: "auto" | "fast" | "deep" + contextMaxCharacters?: number + } + path?: never + query?: { + location?: { + directory?: string | null + workspace?: string | null + } | null + } + url: "/api/search" +} + +export type V2SearchQueryErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError1 | InvalidRequestErrorV2 + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * ServiceUnavailableError + */ + 503: ServiceUnavailableErrorV2 +} + +export type V2SearchQueryError = V2SearchQueryErrors[keyof V2SearchQueryErrors] + +export type V2SearchQueryResponses = { + /** + * Success + */ + 200: { + location: LocationInfoV2 + data: SearchResult + } +} + +export type V2SearchQueryResponse = V2SearchQueryResponses[keyof V2SearchQueryResponses] + export type PtyConnectData = { body?: never path: { diff --git a/packages/server/src/handlers.ts b/packages/server/src/handlers.ts index 896cbbc59204..f6612faf0af3 100644 --- a/packages/server/src/handlers.ts +++ b/packages/server/src/handlers.ts @@ -20,6 +20,7 @@ import { QuestionHandler } from "./handlers/question" import { ReferenceHandler } from "./handlers/reference" import { LocationHandler } from "./handlers/location" import { IntegrationHandler } from "./handlers/integration" +import { SearchHandler } from "./handlers/search" import { McpHandler } from "./handlers/mcp" import { CredentialHandler } from "./handlers/credential" import { ProjectHandler } from "./handlers/project" @@ -38,6 +39,7 @@ export const handlers = Layer.mergeAll( GenerateHandler, ProviderHandler, IntegrationHandler, + SearchHandler, McpHandler, CredentialHandler, ProjectHandler, diff --git a/packages/server/src/handlers/search.ts b/packages/server/src/handlers/search.ts new file mode 100644 index 000000000000..5938af35b3d9 --- /dev/null +++ b/packages/server/src/handlers/search.ts @@ -0,0 +1,91 @@ +import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" +import { Search } from "@opencode-ai/core/search" +import { InvalidRequestError, ServiceUnavailableError } from "@opencode-ai/protocol/errors" +import { Effect } from "effect" +import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" +import { Api } from "../api" +import { response } from "../location" + +export const SearchHandler = HttpApiBuilder.group(Api, "server.search", (handlers) => + Effect.gen(function* () { + const ready = Effect.fn("server.search.ready")(function* () { + const plugins = yield* PluginSupervisor.Service + yield* plugins.ready.pipe( + Effect.timeoutOrElse({ + duration: "5 seconds", + orElse: () => + Effect.fail( + new ServiceUnavailableError({ + message: "Search integration initialization timed out", + service: "search", + }), + ), + }), + ) + }) + return handlers + .handle( + "search.provider.get", + Effect.fn("server.search.provider.get")(function* () { + const search = yield* Search.Service + return yield* response(search.selected()) + }), + ) + .handle( + "search.provider.select", + Effect.fn("server.search.provider.select")(function* (request) { + yield* ready() + const search = yield* Search.Service + yield* search.select(request.payload.providerID).pipe( + Effect.mapError( + (error) => + new InvalidRequestError({ + message: `Search provider not found: ${error.providerID}`, + kind: "search_provider_not_found", + field: "providerID", + }), + ), + ) + return HttpApiSchema.NoContent.make() + }), + ) + .handle( + "search.query", + Effect.fn("server.search.query")(function* (request) { + yield* ready() + const search = yield* Search.Service + return yield* response( + search.query(request.payload).pipe( + Effect.catchTags({ + "Search.ProviderRequired": () => + new InvalidRequestError({ + message: "Search provider is required", + kind: "search_provider_required", + field: "providerID", + }), + "Search.ProviderNotFound": (error) => + new InvalidRequestError({ + message: `Search provider not found: ${error.providerID}`, + kind: "search_provider_not_found", + field: "providerID", + }), + "Search.ConnectionRequired": (error) => + new InvalidRequestError({ + message: `Search provider requires a connection: ${error.providerID}`, + kind: "search_connection_required", + field: "providerID", + }), + "Search.Cancelled": () => + new InvalidRequestError({ message: "Search cancelled", kind: "search_cancelled" }), + "Search.Request": (error) => + new ServiceUnavailableError({ + message: `Search request failed: ${error.providerID}`, + service: error.providerID, + }), + }), + ), + ) + }), + ) + }), +) diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx index 52aba6d0e3ce..5e89327348b8 100644 --- a/packages/tui/src/component/dialog-integration.tsx +++ b/packages/tui/src/component/dialog-integration.tsx @@ -53,28 +53,47 @@ export function connectionSummary(integration: IntegrationInfo) { .join(", ") } -export function DialogIntegration(props: { onConnected?: OnIntegrationConnected } = {}) { +export function DialogIntegration( + props: { onConnected?: OnIntegrationConnected; integrationID?: string; connectionOnly?: boolean } = {}, +) { const data = useData() const dialog = useDialog() const { theme } = useTheme() const options = createMemo(() => - integrationOptions(data.location.integration.list() ?? []).map((integration) => { - const methods = connectMethods(integration) - const connected = integration.connections.length > 0 - return { - title: integration.name, - value: integration.id, - description: methods.length ? undefined : "Environment only", - footer: connectionSummary(integration) || undefined, - category: integration.id in INTEGRATION_PRIORITY ? "Popular" : "Services", - disabled: methods.length === 0, - gutter: connected ? () => โœ“ : undefined, - onSelect: () => - credentialConnections(integration).length - ? manageConnections(integration, methods, dialog, props.onConnected) - : selectMethod(integration, methods, dialog, props.onConnected), - } - }), + integrationOptions(data.location.integration.list() ?? []) + .filter((integration) => props.integrationID === undefined || integration.id === props.integrationID) + .map((integration) => { + const methods = connectMethods(integration) + const connected = integration.connections.length > 0 + const search = integration.search + const selected = data.location.search.provider() === integration.id + const credentials = credentialConnections(integration) + const description = search?.connection === "optional" ? "API key optional" : undefined + const category = search ? "Web search" : undefined + return { + title: integration.name, + value: integration.id, + description: description ?? (methods.length === 0 ? "Environment only" : undefined), + footer: + [connectionSummary(integration), selected ? "Web search default" : undefined] + .filter((value) => value !== undefined && value.length > 0) + .join(" ยท ") || undefined, + category: category ?? (integration.id in INTEGRATION_PRIORITY ? "Popular" : "Services"), + disabled: methods.length === 0 && !search, + gutter: connected ? () => โœ“ : undefined, + onSelect: () => { + if (props.connectionOnly) { + return credentials.length + ? manageConnections(integration, methods, dialog, props.onConnected) + : selectMethod(integration, methods, dialog, props.onConnected) + } + if (search) return manageIntegration(integration, methods, search, dialog) + return credentials.length + ? manageConnections(integration, methods, dialog, props.onConnected) + : selectMethod(integration, methods, dialog, props.onConnected) + }, + } + }), ) return ( @@ -90,6 +109,64 @@ export function DialogIntegration(props: { onConnected?: OnIntegrationConnected ) } +function manageIntegration( + integration: IntegrationInfo, + methods: ConnectMethod[], + search: NonNullable, + dialog: ReturnType, +) { + const connected = integration.connections.length > 0 + dialog.replace(() => { + const data = useData() + const sdk = useSDK() + const toast = useToast() + const credentials = credentialConnections(integration) + const selected = () => data.location.search.provider() === integration.id + const selectSearch = () => { + void sdk.api.search + .selectProvider({ + providerID: integration.id, + location: location(data), + }) + .then(async () => { + await Promise.all([data.location.integration.refresh(), data.location.search.refresh()]) + toast.show({ variant: "success", message: `${integration.name} is now the web search default` }) + dialog.clear() + }) + .catch(toast.error) + } + + return ( + selectMethod(integration, methods, dialog, selectSearch) + : selectSearch, + }, + ...(methods.length + ? [ + { + title: credentials.length ? "Manage connections" : "Connect", + value: "connect", + onSelect: () => + credentials.length + ? manageConnections(integration, methods, dialog) + : selectMethod(integration, methods, dialog), + }, + ] + : []), + ]} + /> + ) + }) +} + function manageConnections( integration: IntegrationInfo, methods: ConnectMethod[], diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 723f80633dde..96f3c5e04fca 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -2,6 +2,7 @@ import type { AgentV2Info, CommandV2Info, FormFormInfo, + FormIntegrationInfo, FormUrlInfo, IntegrationInfo, LocationRef, @@ -30,7 +31,7 @@ export type DataSessionStatus = "idle" | "running" const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_") -export type FormInfo = FormFormInfo | FormUrlInfo +export type FormInfo = FormFormInfo | FormUrlInfo | FormIntegrationInfo type LocationData = { agent?: AgentV2Info[] @@ -40,6 +41,7 @@ type LocationData = { model?: ModelV2Info[] provider?: ProviderV2Info[] reference?: ReferenceInfo[] + searchProvider?: string | null // Currently running shell commands for this location, keyed by shell id. Entries are removed // once the command exits or is deleted, so this only ever holds in-flight shells. shell?: Record @@ -751,6 +753,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ result.location.provider.refresh(event.location), ]) break + case "config.updated": + void result.location.search.refresh(event.location) + break // Authenticating an MCP integration reconnects its server, which emits mcp.status.changed, // so the mcp list refreshes here rather than off integration.updated. case "mcp.status.changed": @@ -973,6 +978,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ setStore("location", key, { ...store.location[key], reference: mutable(result.data) }) }, }, + search: { + provider(location?: LocationRef) { + return store.location[locationKey(location ?? defaultLocation())]?.searchProvider ?? undefined + }, + async refresh(ref?: LocationRef) { + const result = await sdk.api.search.provider({ location: locationQuery(ref ?? defaultLocation()) }) + const key = locationKey(result.location) + setStore("location", key, { ...store.location[key], searchProvider: result.data ?? null }) + }, + }, skill: { list(location?: LocationRef) { return store.location[locationKey(location ?? defaultLocation())]?.skill @@ -1013,6 +1028,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ result.location.model.refresh(), result.location.provider.refresh(), result.location.reference.refresh(), + result.location.search.refresh(), result.location.command.refresh(), result.location.skill.refresh(), result.shell.refresh(), diff --git a/packages/tui/src/routes/session/form.tsx b/packages/tui/src/routes/session/form.tsx index e6f7a4818c83..42f45f2c743c 100644 --- a/packages/tui/src/routes/session/form.tsx +++ b/packages/tui/src/routes/session/form.tsx @@ -10,6 +10,8 @@ import { useSDK } from "../../context/sdk" import { SplitBorder } from "../../ui/border" import { useTuiConfig } from "../../config" import { useBindings, useOpencodeModeStack } from "../../keymap" +import { DialogIntegration } from "../../component/dialog-integration" +import { useDialog } from "../../ui/dialog" const FORM_MODE = "form" @@ -131,7 +133,50 @@ function display(field: Field, value: FormValue | undefined) { } export function FormPrompt(props: { form: FormInfo }) { - return props.form.mode === "url" ? : + if (props.form.mode === "url") return + if (props.form.mode === "integration") return + return +} + +function IntegrationPrompt(props: { form: FormInfo & { mode: "integration" } }) { + const sdk = useSDK() + const dialog = useDialog() + const { theme } = useTheme() + let settled = false + + onMount(() => { + dialog.replace( + () => ( + { + settled = true + dialog.clear() + void sdk.api.form.reply({ sessionID: props.form.sessionID, formID: props.form.id, answer: {} }) + }} + /> + ), + () => { + if (settled) return + void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }) + }, + ) + }) + + return ( + + + {props.form.title ?? "Connect integration"} + Complete the connection dialog to continue. + + + ) } function UrlPrompt(props: { form: FormInfo & { mode: "url" } }) { diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index d1ea5e1272dc..fd970a96fe34 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -2345,9 +2345,16 @@ function WebFetch(props: ToolProps) { } function WebSearch(props: ToolProps) { + const data = useData() + const [provider, setProvider] = createSignal(data.location.search.provider() ?? stringValue(props.metadata.provider)) + createEffect(() => { + if (provider()) return + const next = data.location.search.provider() + if (next) setProvider(next) + }) return ( - {webSearchProviderLabel(props.metadata.provider)} "{stringValue(props.input.query)}"{" "} + {webSearchProviderLabel(provider())} "{stringValue(props.input.query)}"{" "} ({numberValue(props.metadata.numResults)} results) )