From efb629a33ac495d6ca9b71ae45be283e10cde9ba Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Sun, 26 Jul 2026 09:25:05 +0530 Subject: [PATCH] feat(core): add pluggable web search (#35558) Co-authored-by: Dax Raad --- AGENTS.md | 1 - packages/client/src/effect/api.ts | 4 +- packages/client/src/effect/api/api.ts | 20 ++ .../client/src/effect/generated/client.ts | 23 ++ packages/client/src/effect/index.ts | 2 + packages/client/src/promise/api.ts | 1 + .../client/src/promise/generated/client.ts | 31 ++ .../client/src/promise/generated/types.ts | 38 ++ packages/client/src/promise/index.ts | 1 + packages/client/test/promise.test.ts | 33 ++ packages/core/src/config.ts | 4 + packages/core/src/config/plugin/websearch.ts | 27 ++ packages/core/src/config/websearch.ts | 8 + packages/core/src/location-services.ts | 2 + packages/core/src/plugin.ts | 2 + packages/core/src/plugin/host.ts | 153 ++++---- packages/core/src/plugin/internal.ts | 12 +- packages/core/src/plugin/promise.ts | 30 ++ packages/core/src/plugin/supervisor.ts | 6 +- packages/core/src/plugin/websearch/exa.ts | 82 +++++ packages/core/src/plugin/websearch/index.ts | 4 + packages/core/src/plugin/websearch/mcp.ts | 68 ++++ .../core/src/plugin/websearch/parallel.ts | 103 ++++++ packages/core/src/tool/websearch.ts | 284 ++++----------- packages/core/src/websearch.ts | 144 ++++++++ packages/core/test/config/config.test.ts | 57 ++- packages/core/test/lib/tool.ts | 12 +- packages/core/test/plugin/fixture.ts | 6 + packages/core/test/plugin/host.ts | 59 ++- packages/core/test/plugin/promise.test.ts | 33 ++ .../core/test/plugin/websearch-fixture.ts | 50 +++ packages/core/test/plugin/websearch.test.ts | 170 +++++++++ packages/core/test/tool-websearch.test.ts | 339 ++++++------------ packages/core/test/websearch.test.ts | 129 +++++++ packages/plugin/src/v2/effect/index.ts | 1 + packages/plugin/src/v2/effect/plugin.ts | 2 + packages/plugin/src/v2/effect/websearch.ts | 23 ++ packages/plugin/src/v2/promise/index.ts | 1 + packages/plugin/src/v2/promise/integration.ts | 24 +- packages/plugin/src/v2/promise/plugin.ts | 2 + packages/plugin/src/v2/promise/websearch.ts | 25 ++ .../plugin/test/contract-identity.test.ts | 5 +- packages/protocol/src/api.ts | 3 + packages/protocol/src/client.ts | 1 + packages/protocol/src/groups/websearch.ts | 46 +++ packages/schema/src/event-manifest.ts | 2 + packages/schema/src/index.ts | 1 + packages/schema/src/websearch.ts | 42 +++ packages/sdk-next/src/index.ts | 1 + .../sdk-next/test/contract-identity.test.ts | 3 + packages/sdk-next/test/embedded.test.ts | 32 ++ packages/server/src/handlers.ts | 2 + packages/server/src/handlers/websearch.ts | 68 ++++ .../tui/src/component/dialog-integration.tsx | 42 ++- packages/tui/src/context/data.tsx | 20 ++ packages/tui/test/cli/tui/data.test.tsx | 4 + packages/tui/test/fixture/tui-client.ts | 3 + 57 files changed, 1702 insertions(+), 589 deletions(-) create mode 100644 packages/core/src/config/plugin/websearch.ts create mode 100644 packages/core/src/config/websearch.ts create mode 100644 packages/core/src/plugin/websearch/exa.ts create mode 100644 packages/core/src/plugin/websearch/index.ts create mode 100644 packages/core/src/plugin/websearch/mcp.ts create mode 100644 packages/core/src/plugin/websearch/parallel.ts create mode 100644 packages/core/src/websearch.ts create mode 100644 packages/core/test/plugin/websearch-fixture.ts create mode 100644 packages/core/test/plugin/websearch.test.ts create mode 100644 packages/core/test/websearch.test.ts create mode 100644 packages/plugin/src/v2/effect/websearch.ts create mode 100644 packages/plugin/src/v2/promise/websearch.ts create mode 100644 packages/protocol/src/groups/websearch.ts create mode 100644 packages/schema/src/websearch.ts create mode 100644 packages/server/src/handlers/websearch.ts diff --git a/AGENTS.md b/AGENTS.md index 14f9f27f145..027d22647ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,3 @@ -- To regenerate the legacy JavaScript SDK, run `./packages/sdk/js/script/build.ts`. - After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly. - Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server. - Do not modify `packages/opencode` unless the user explicitly asks for V1 work. `packages/opencode` is the V1 implementation and is present for reference only. New implementation changes should land in the V2 package set: `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required. diff --git a/packages/client/src/effect/api.ts b/packages/client/src/effect/api.ts index e7cb2012f58..d0b21703454 100644 --- a/packages/client/src/effect/api.ts +++ b/packages/client/src/effect/api.ts @@ -1,7 +1,9 @@ -import type { ModelApi, ProviderApi } from "./api/api.js" +import type { ModelApi, ProviderApi, WebsearchApi } from "./api/api.js" export type * from "./api/api.js" +export type WebSearchApi = WebsearchApi + export interface CatalogApi { readonly provider: ProviderApi readonly model: ModelApi diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 5a001c2630e..3c229bc6151 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -1042,6 +1042,25 @@ export interface DebugApi { readonly location: { readonly list: DebugLocationListOperation; readonly evict: DebugLocationEvictOperation } } +type Endpoint27_0Request = Parameters[0] +export type Endpoint27_0Input = { readonly location?: Endpoint27_0Request["query"]["location"] } +export type Endpoint27_0Output = EffectValue> +export type WebsearchProvidersOperation = (input?: Endpoint27_0Input) => Effect.Effect + +type Endpoint27_1Request = Parameters[0] +export type Endpoint27_1Input = { + readonly location?: Endpoint27_1Request["query"]["location"] + readonly query: Endpoint27_1Request["payload"]["query"] + readonly providerID?: Endpoint27_1Request["payload"]["providerID"] +} +export type Endpoint27_1Output = EffectValue> +export type WebsearchQueryOperation = (input: Endpoint27_1Input) => Effect.Effect + +export interface WebsearchApi { + readonly providers: WebsearchProvidersOperation + readonly query: WebsearchQueryOperation +} + export interface AppApi { readonly health: HealthApi readonly server: ServerApi @@ -1070,4 +1089,5 @@ export interface AppApi { readonly projectCopy: ProjectCopyApi readonly vcs: VcsApi readonly debug: DebugApi + readonly websearch: WebsearchApi } diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index de18f5c05e2..ce791ce4f17 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -1244,6 +1244,28 @@ const adaptGroup26 = (raw: RawClient["server.debug"]) => ({ location: { list: Endpoint26_0(raw), evict: Endpoint26_1(raw) }, }) +type Endpoint27_0Request = Parameters[0] +type Endpoint27_0Input = { readonly location?: Endpoint27_0Request["query"]["location"] } +const Endpoint27_0 = (raw: RawClient["server.websearch"]) => (input?: Endpoint27_0Input) => + raw["websearch.providers"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint27_1Request = Parameters[0] +type Endpoint27_1Input = { + readonly location?: Endpoint27_1Request["query"]["location"] + readonly query: Endpoint27_1Request["payload"]["query"] + readonly providerID?: Endpoint27_1Request["payload"]["providerID"] +} +const Endpoint27_1 = (raw: RawClient["server.websearch"]) => (input: Endpoint27_1Input) => + raw["websearch.query"]({ + query: { location: input["location"] }, + payload: { query: input["query"], providerID: input["providerID"] }, + }).pipe(Effect.mapError(mapClientError)) + +const adaptGroup27 = (raw: RawClient["server.websearch"]) => ({ + providers: Endpoint27_0(raw), + query: Endpoint27_1(raw), +}) + const adaptClient = (raw: RawClient) => ({ health: adaptGroup0(raw["server.health"]), server: adaptGroup1(raw["server.server"]), @@ -1272,6 +1294,7 @@ const adaptClient = (raw: RawClient) => ({ projectCopy: adaptGroup24(raw["server.projectCopy"]), vcs: adaptGroup25(raw["server.vcs"]), debug: adaptGroup26(raw["server.debug"]), + websearch: adaptGroup27(raw["server.websearch"]), }) 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 27424aad823..500352062f2 100644 --- a/packages/client/src/effect/index.ts +++ b/packages/client/src/effect/index.ts @@ -14,6 +14,7 @@ export type { PluginApi, ProviderApi, ReferenceApi, + WebSearchApi, SessionApi, SkillApi, } from "./api.js" @@ -35,6 +36,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 { WebSearch } from "@opencode-ai/schema/websearch" export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema" export { Session } from "@opencode-ai/schema/session" export { SessionPending } from "@opencode-ai/schema/session-pending" diff --git a/packages/client/src/promise/api.ts b/packages/client/src/promise/api.ts index b4bc8635b2c..9614fef04c2 100644 --- a/packages/client/src/promise/api.ts +++ b/packages/client/src/promise/api.ts @@ -8,6 +8,7 @@ export type ModelApi = Client["model"] export type PluginApi = Client["plugin"] export type ProviderApi = Client["provider"] export type ReferenceApi = Client["reference"] +export type WebSearchApi = Client["websearch"] export type SessionApi = Client["session"] export type SkillApi = Client["skill"] diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 97c84a5d7aa..b5fd3fd6c3f 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -207,6 +207,10 @@ import type { DebugLocationListOutput, DebugLocationEvictInput, DebugLocationEvictOutput, + WebsearchProvidersInput, + WebsearchProvidersOutput, + WebsearchQueryInput, + WebsearchQueryOutput, } from "./types" import { ClientError } from "./client-error" @@ -1735,6 +1739,33 @@ export function make(options: ClientOptions) { ), }, }, + websearch: { + providers: (input?: WebsearchProvidersInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/websearch/provider`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [503, 401, 400], + empty: false, + }, + requestOptions, + ), + query: (input: WebsearchQueryInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/websearch`, + query: { location: input["location"] }, + body: { query: input["query"], providerID: input["providerID"] }, + 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 ef614aa1bbd..19ae6c3f5dd 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -539,6 +539,10 @@ export type VcsFileStatus = { status: "added" | "deleted" | "modified" } +export type WebSearchProvider = { id: string; name: string } + +export type WebSearchResult = { url: string; title?: string; content?: string; time: { published?: number } } + export type SessionMessageModelSelected = { id: string metadata?: { [x: string]: JsonValue } @@ -1058,6 +1062,15 @@ export type FormCancelled = { data: { id: string; sessionID: string } } +export type WebsearchUpdated = { + id: string + created: number + metadata?: { [x: string]: any } + type: "websearch.updated" + location?: LocationRef + data: {} +} + export type SessionIdle = { id: string created: number @@ -2355,6 +2368,7 @@ export type V2Event = | FormCreated | FormReplied | FormCancelled + | WebsearchUpdated | SessionStatus2 | SessionIdle | TuiPromptAppend @@ -4958,3 +4972,27 @@ export type DebugLocationEvictInput = { } export type DebugLocationEvictOutput = void + +export type WebsearchProvidersInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type WebsearchProvidersOutput = { + location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + data: Array +} + +export type WebsearchQueryInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] + readonly query: { readonly query: string; readonly providerID?: string }["query"] + readonly providerID?: { readonly query: string; readonly providerID?: string }["providerID"] +} + +export type WebsearchQueryOutput = { + location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + data: { providerID: string; results: Array } +} diff --git a/packages/client/src/promise/index.ts b/packages/client/src/promise/index.ts index fd889c64e55..dd64831e013 100644 --- a/packages/client/src/promise/index.ts +++ b/packages/client/src/promise/index.ts @@ -9,6 +9,7 @@ export type { PluginApi, ProviderApi, ReferenceApi, + WebSearchApi, SessionApi, SkillApi, } from "./api.js" diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index d3c966ef9be..c3c0512f174 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -32,6 +32,7 @@ test("exposes every standard HTTP API group", () => { "projectCopy", "vcs", "debug", + "websearch", ]) expect(Object.keys(client.debug)).toEqual(["location"]) expect(Object.keys(client.debug.location)).toEqual(["list", "evict"]) @@ -41,6 +42,7 @@ test("exposes every standard HTTP API group", () => { expect(Object.keys(client.integration.connect)).toEqual(["key"]) expect(Object.keys(client.integration.oauth)).toEqual(["connect", "status", "complete", "cancel"]) expect(Object.keys(client.integration.command)).toEqual(["connect", "status", "cancel"]) + expect(Object.keys(client.websearch)).toEqual(["providers", "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"]) @@ -48,6 +50,37 @@ test("exposes every standard HTTP API group", () => { expect(Object.keys(client.project)).toEqual(["list", "current", "directories"]) }) +test("websearch.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", + results: [{ url: "https://example.com", title: "Result", content: "result", time: {} }], + }, + }) + }, + }) + + const result = await client.websearch.query({ + query: "opencode", + providerID: "exa", + location: { directory: "/tmp/project" }, + }) + + expect(result.data).toEqual({ + providerID: "exa", + results: [{ url: "https://example.com", title: "Result", content: "result", time: {} }], + }) + expect(request?.method).toBe("POST") + expect(request?.url).toBe("http://localhost:3000/api/websearch?location%5Bdirectory%5D=%2Ftmp%2Fproject") + expect(await request?.json()).toEqual({ query: "opencode", providerID: "exa" }) +}) + test("server.get uses 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 2f338b6b35e..99cdff45e13 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -27,6 +27,7 @@ import { ConfigModel } from "./config/model" import { ConfigPlugin } from "./config/plugin" import { ConfigProvider } from "./config/provider" import { ConfigReference } from "./config/reference" +import { ConfigWebSearch } from "./config/websearch" import { ConfigToolOutput } from "./config/tool-output" import { ConfigVariable } from "./config/variable" import { ConfigWatcher } from "./config/watcher" @@ -108,6 +109,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", }), + websearch: ConfigWebSearch.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/plugin/websearch.ts b/packages/core/src/config/plugin/websearch.ts new file mode 100644 index 00000000000..1a063549fac --- /dev/null +++ b/packages/core/src/config/plugin/websearch.ts @@ -0,0 +1,27 @@ +export * as ConfigWebSearchPlugin from "./websearch" + +import { define } from "@opencode-ai/plugin/v2/effect/plugin" +import { Effect, Stream } from "effect" +import { Config } from "../../config" + +export const Plugin = define({ + id: "opencode.config.websearch", + effect: Effect.fn(function* (ctx) { + const config = yield* Config.Service + const loaded = { entries: yield* config.entries() } + yield* ctx.websearch.transform((websearch) => { + const providerID = Config.latest(loaded.entries, "websearch")?.provider + if (providerID) websearch.default.set(providerID) + }) + yield* ctx.event.subscribe().pipe( + Stream.filter((event) => event.type === "config.updated"), + Stream.runForEach(() => + config.entries().pipe( + Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))), + Effect.andThen(ctx.websearch.reload()), + ), + ), + Effect.forkScoped({ startImmediately: true }), + ) + }), +}) diff --git a/packages/core/src/config/websearch.ts b/packages/core/src/config/websearch.ts new file mode 100644 index 00000000000..4db2a50fc64 --- /dev/null +++ b/packages/core/src/config/websearch.ts @@ -0,0 +1,8 @@ +export * as ConfigWebSearch from "./websearch" + +import { WebSearch } from "@opencode-ai/schema/websearch" +import { Schema } from "effect" + +export class Info extends Schema.Class("ConfigWebSearch.Info")({ + provider: WebSearch.ID, +}) {} diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 5208f6a7cc5..3cce750142c 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -29,6 +29,7 @@ import { Pty } from "./pty" import { QuestionV2 } from "./question" import { Shell } from "./shell" import { Reference } from "./reference" +import { WebSearch } from "./websearch" import { ReferenceInstructions } from "./reference/instructions" import { SessionRunnerLLM } from "./session/runner/llm" import { SessionRunnerModel } from "./session/runner/model" @@ -56,6 +57,7 @@ const locationServiceNodes = [ AgentV2.node, CommandV2.node, Reference.node, + WebSearch.node, Integration.node, Catalog.node, ModelResolver.node, diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index 6007b67abb6..bb093a7b97a 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -14,6 +14,7 @@ import { Integration } from "./integration" import { Location } from "./location" import { PluginHost } from "./plugin/host" import { PluginRuntime } from "./plugin/runtime" +import { WebSearch } from "./websearch" import { Reference } from "./reference" import { SkillV2 } from "./skill" import { State } from "./state" @@ -158,5 +159,6 @@ export const node = makeLocationNode({ ToolHooks.node, PluginHooks.node, PluginRuntime.node, + WebSearch.node, ], }) diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 640fdea1482..730ddafecc5 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -1,6 +1,8 @@ export * as PluginHost from "./host" -import type { Plugin } from "@opencode-ai/plugin/v2/effect" +import { Plugin } from "@opencode-ai/plugin/v2/effect" +import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration" +import type { CredentialOAuth } from "@opencode-ai/sdk/v2/types" import { EventManifest } from "@opencode-ai/schema/event-manifest" import { App } from "../app" import { Effect, Schema, Stream } from "effect" @@ -23,6 +25,7 @@ import { Tool } from "../tool/tool" import { Tools } from "../tool/tools" import { ToolHooks } from "../tool/hooks" import { WorkspaceV2 } from "../workspace" +import { WebSearch } from "../websearch" import { PluginHooks } from "./hooks" const mutable = (value: T) => value as DeepMutable @@ -38,6 +41,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int const reference = yield* Reference.Service const skill = yield* SkillV2.Service const tools = yield* Tools.Service + const websearch = yield* WebSearch.Service const toolHooks = yield* ToolHooks.Service const hooks = yield* PluginHooks.Service const runtime = yield* PluginRuntime.Service @@ -247,79 +251,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 - } - if (input.method.type === "command") { - draft.method.update({ - integrationID: Integration.ID.make(input.integrationID), - method: Schema.decodeUnknownSync(Integration.CommandMethod)(input.method), - }) - 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)), }, @@ -430,6 +362,32 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int }) }, }, + websearch: { + providers: () => response(websearch.providers()), + query: (input) => + response( + websearch.query({ + query: input.query, + providerID: input.providerID === undefined ? undefined : WebSearch.ID.make(input.providerID), + }), + ), + reload: websearch.reload, + transform: (callback) => + websearch.transform((draft) => { + callback({ + add: (definition) => + draft.add({ + id: WebSearch.ID.make(definition.id), + name: definition.name, + execute: definition.execute, + }), + default: { + get: draft.default.get, + set: (providerID) => draft.default.set(WebSearch.ID.make(providerID)), + }, + }) + }), + }, session: { hook: (name, callback) => hooks.register("session", name, callback), create: (input) => @@ -449,3 +407,50 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int }, } satisfies Plugin.Context }) + +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 }, + } + } + if (input.method.type === "command") { + return { + integrationID: Integration.ID.make(input.integrationID), + method: Schema.decodeUnknownSync(Integration.CommandMethod)(input.method), + } + } + 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 5457a13d3da..815dd4339d1 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -13,6 +13,7 @@ import { ConfigProviderPlugin } from "../config/plugin/provider" import { ConfigPolicyPlugin } from "../config/plugin/policy" import { ConfigReferencePlugin } from "../config/plugin/reference" import { ConfigSkillPlugin } from "../config/plugin/skill" +import { ConfigWebSearchPlugin } from "../config/plugin/websearch" import { EventV2 } from "../event" import { FileMutation } from "../file-mutation" import { Form } from "../form" @@ -21,12 +22,14 @@ import { FSUtil } from "@opencode-ai/util/fs-util" import { Global } from "@opencode-ai/util/global" import { Image } from "../image" import { Integration } from "../integration" +import { KV } from "../kv" import { Location } from "../location" import { LocationMutation } from "../location-mutation" import { ModelsDev } from "../models-dev" import { Npm } from "@opencode-ai/util/npm" import { PermissionV2 } from "../permission" import { Reference } from "../reference" +import { WebSearch } from "../websearch" import { Ripgrep } from "../ripgrep" import { SessionInstructions } from "../session/instructions" import { Shell } from "../shell" @@ -50,6 +53,7 @@ import { AgentPlugin } from "./agent" import { CommandPlugin } from "./command" import { ModelsDevPlugin } from "./models-dev" import { ProviderPlugins } from "./provider" +import { WebSearchPlugins } from "./websearch" import { PluginRuntime } from "./runtime" import { SkillPlugin } from "./skill" import { SystemPromptPlugin } from "./system-prompt" @@ -70,6 +74,7 @@ const services = Effect.fn("PluginInternal.services")(function* () { const http = yield* HttpClient.HttpClient const image = yield* Image.Service const integration = yield* Integration.Service + const kv = yield* KV.Service const location = yield* Location.Service const locationMutation = yield* LocationMutation.Service const models = yield* ModelsDev.Service @@ -79,12 +84,12 @@ const services = Effect.fn("PluginInternal.services")(function* () { const form = yield* Form.Service const read = yield* ReadToolFileSystem.Service const reference = yield* Reference.Service + const websearch = yield* WebSearch.Service const ripgrep = yield* Ripgrep.Service const instructions = yield* SessionInstructions.Service const shell = yield* Shell.Service const skill = yield* SkillV2.Service const tools = yield* Tools.Service - const websearch = yield* WebSearchTool.ConfigService const wellknown = yield* WellKnown.Service return Context.mergeAll( Context.make(AgentV2.Service, agent), @@ -99,6 +104,7 @@ const services = Effect.fn("PluginInternal.services")(function* () { Context.make(HttpClient.HttpClient, http), Context.make(Image.Service, image), Context.make(Integration.Service, integration), + Context.make(KV.Service, kv), Context.make(Location.Service, location), Context.make(LocationMutation.Service, locationMutation), Context.make(ModelsDev.Service, models), @@ -108,12 +114,12 @@ const services = Effect.fn("PluginInternal.services")(function* () { Context.make(Form.Service, form), Context.make(ReadToolFileSystem.Service, read), Context.make(Reference.Service, reference), + Context.make(WebSearch.Service, websearch), Context.make(Ripgrep.Service, ripgrep), Context.make(SessionInstructions.Service, instructions), Context.make(Shell.Service, shell), Context.make(SkillV2.Service, skill), Context.make(Tools.Service, tools), - Context.make(WebSearchTool.ConfigService, websearch), Context.make(WellKnown.Service, wellknown), ) }) @@ -132,6 +138,7 @@ const pre = [ ...SystemPromptPlugin.Plugins, ModelsDevPlugin, ...ProviderPlugins, + ...WebSearchPlugins, PatchTool.Plugin, EditTool.Plugin, GlobTool.Plugin, @@ -153,6 +160,7 @@ const post = [ ConfigCommandPlugin.Plugin, ConfigSkillPlugin.Plugin, ConfigProviderPlugin.Plugin, + ConfigWebSearchPlugin.Plugin, VariantPlugin.Plugin, ConfigPolicyPlugin.Plugin, ] as const satisfies readonly InternalPlugin[] diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts index 950f6a2afcb..a5172b0c98a 100644 --- a/packages/core/src/plugin/promise.ts +++ b/packages/core/src/plugin/promise.ts @@ -12,6 +12,7 @@ import { AbsolutePath } from "@opencode-ai/schema/schema" import { Session } from "@opencode-ai/schema/session" import { SessionMessage } from "@opencode-ai/schema/session-message" import { Workspace } from "@opencode-ai/schema/workspace" +import { WebSearch } from "@opencode-ai/schema/websearch" import { DateTime, Effect, Scope, Stream } from "effect" import { Tool } from "../tool/tool" @@ -197,6 +198,31 @@ export function fromPromise(plugin: Plugin) { hook: (name, callback) => register(host.tool.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))), }, + websearch: { + providers: (input) => run(host.websearch.providers(input)), + query: (input) => + run( + host.websearch.query({ + ...input, + providerID: input.providerID === undefined ? undefined : WebSearch.ID.make(input.providerID), + }), + ), + reload: () => run(host.websearch.reload()), + transform: (callback) => + register( + host.websearch.transform((draft) => { + callback({ + add: (definition) => + draft.add({ + id: definition.id, + name: definition.name, + execute: (input) => attempt((signal) => definition.execute(input, { signal })), + }), + default: draft.default, + }) + }), + ), + }, session: { hook: (name, callback) => register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))), @@ -270,6 +296,10 @@ export function fromPromise(plugin: Plugin) { }) } +function attempt(evaluate: (signal: AbortSignal) => PromiseLike) { + return Effect.tryPromise({ try: evaluate, catch: (cause) => cause }) +} + function model(input: { readonly id: string; readonly providerID: string; readonly variant?: string }) { return Model.Ref.make({ id: Model.ID.make(input.id), diff --git a/packages/core/src/plugin/supervisor.ts b/packages/core/src/plugin/supervisor.ts index b512ac35c6c..c6b977d5a76 100644 --- a/packages/core/src/plugin/supervisor.ts +++ b/packages/core/src/plugin/supervisor.ts @@ -20,6 +20,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util" import { Global } from "@opencode-ai/util/global" import { Image } from "../image" import { Integration } from "../integration" +import { KV } from "../kv" import { Location } from "../location" import { LocationMutation } from "../location-mutation" import { ModelsDev } from "../models-dev" @@ -34,7 +35,7 @@ import { Shell } from "../shell" import { SkillV2 } from "../skill" import { ReadToolFileSystem } from "../tool/read-filesystem" import { ToolRegistry } from "../tool/registry" -import { WebSearchTool } from "../tool/websearch" +import { WebSearch } from "../websearch" import { WellKnown } from "../wellknown" import { PluginInternal } from "./internal" import { PluginRuntime } from "./runtime" @@ -289,6 +290,7 @@ export const node = makeLocationNode({ httpClient, Image.node, Integration.node, + KV.node, Location.node, LocationMutation.node, ModelsDev.node, @@ -303,7 +305,7 @@ export const node = makeLocationNode({ Shell.node, SkillV2.node, ToolRegistry.toolsNode, - WebSearchTool.configNode, + WebSearch.node, WellKnown.node, ], }) diff --git a/packages/core/src/plugin/websearch/exa.ts b/packages/core/src/plugin/websearch/exa.ts new file mode 100644 index 00000000000..ba759604078 --- /dev/null +++ b/packages/core/src/plugin/websearch/exa.ts @@ -0,0 +1,82 @@ +export * as WebSearchExa from "./exa" + +import { define } from "@opencode-ai/plugin/v2/effect/plugin" +import { Effect, Schema, Scope } from "effect" +import { HttpClient } from "effect/unstable/http" +import { WebSearchMcp } from "./mcp" + +export const endpoint = "https://mcp.exa.ai/mcp" + +const McpInput = Schema.Struct({ + query: Schema.String, + numResults: Schema.Number.pipe(Schema.optional), +}) + +const McpOutput = Schema.Struct({ + content: Schema.Array( + Schema.Struct({ + type: Schema.Literal("text"), + text: Schema.String, + _meta: Schema.Struct({ searchTime: Schema.Number }).pipe(Schema.optional), + }), + ), +}) + +export const Plugin = define({ + id: "opencode.websearch.exa", + effect: Effect.fn("WebSearchExa.Plugin")(function* (ctx) { + const http = yield* HttpClient.HttpClient + yield* ctx.integration.transform((draft) => { + draft.update("exa", (integration) => (integration.name = "Exa")) + draft.method.update({ + integrationID: "exa", + method: { type: "key", label: "API key (optional)" }, + }) + draft.method.update({ + integrationID: "exa", + method: { type: "env", names: ["EXA_API_KEY"] }, + }) + }) + yield* ctx.websearch.transform((draft) => { + draft.add({ + id: "exa", + name: "Exa", + execute: (input) => + Effect.gen(function* () { + const connection = yield* ctx.integration.connection.active("exa") + const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined + const url = new URL(endpoint) + if (credential?.type === "key") url.searchParams.set("exaApiKey", credential.key) + const result = yield* WebSearchMcp.call( + http, + url.toString(), + "web_search_exa", + { input: McpInput, output: McpOutput }, + { query: input.query, numResults: 8 }, + ) + const content = result?.content.find((item) => item.text) + return content ? parseResults(content.text) : [] + }), + }) + }) + }), +}) + +function parseResults(text: string) { + return text.split(/\n\n---\n\n/).flatMap((block) => { + const url = block.match(/^URL:\s*(.+)$/m)?.[1]?.trim() + if (!url) return [] + const title = block.match(/^Title:\s*(.+)$/m)?.[1]?.trim() + const publishedText = block.match(/^Published:\s*(.+)$/m)?.[1]?.trim() + const published = publishedText && publishedText !== "N/A" ? Date.parse(publishedText) : undefined + const content = block.match(/^(?:Highlights|Text):\s*\n?([\s\S]*)$/m)?.[1]?.trim() + return [ + { + url, + ...(title && title !== "N/A" ? { title } : {}), + ...(content ? { content } : {}), + time: { ...(published !== undefined && Number.isFinite(published) ? { published } : {}) }, + }, + ] + }) +} diff --git a/packages/core/src/plugin/websearch/index.ts b/packages/core/src/plugin/websearch/index.ts new file mode 100644 index 00000000000..8f8b5fec6de --- /dev/null +++ b/packages/core/src/plugin/websearch/index.ts @@ -0,0 +1,4 @@ +import { WebSearchExa } from "./exa" +import { WebSearchParallel } from "./parallel" + +export const WebSearchPlugins = [WebSearchExa.Plugin, WebSearchParallel.Plugin] as const diff --git a/packages/core/src/plugin/websearch/mcp.ts b/packages/core/src/plugin/websearch/mcp.ts new file mode 100644 index 00000000000..b479acdcaee --- /dev/null +++ b/packages/core/src/plugin/websearch/mcp.ts @@ -0,0 +1,68 @@ +export * as WebSearchMcp 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 + +export const parseResponse = (body: string, result: Schema.Struct) => { + const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ result }))) + const parse = (payload: string) => { + const trimmed = payload.trim() + if (!trimmed.startsWith("{")) return Effect.succeed(undefined) + return decode(trimmed).pipe(Effect.map((response) => response.result)) + } + return Effect.gen(function* () { + const trimmed = body.trim() + const direct = trimmed ? yield* parse(trimmed) : undefined + if (direct) return direct + for (const line of body.split("\n")) { + if (!line.startsWith("data: ")) continue + const data = yield* parse(line.substring(6)) + if (data) return data + } + }) +} + +export const call = ( + http: HttpClient.HttpClient, + url: string, + tool: string, + schema: { readonly input: Schema.Struct; readonly output: 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( + Schema.Struct({ + jsonrpc: Schema.Literal("2.0"), + id: Schema.Literal(1), + method: Schema.Literal("tools/call"), + params: Schema.Struct({ name: Schema.String, arguments: schema.input }), + }), + )({ + 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"), schema.output) + }).pipe( + Effect.timeoutOrElse({ + duration: Duration.seconds(25), + orElse: () => Effect.fail(new Error(`${tool} request timed out`)), + }), + ) + }) diff --git a/packages/core/src/plugin/websearch/parallel.ts b/packages/core/src/plugin/websearch/parallel.ts new file mode 100644 index 00000000000..e55302d8af7 --- /dev/null +++ b/packages/core/src/plugin/websearch/parallel.ts @@ -0,0 +1,103 @@ +export * as WebSearchParallel from "./parallel" + +import { define } from "@opencode-ai/plugin/v2/effect/plugin" +import { Effect, Schema, Scope } from "effect" +import { HttpClient } from "effect/unstable/http" +import { App } from "../../app" +import { WebSearchMcp } from "./mcp" + +export const endpoint = "https://search.parallel.ai/mcp" + +const McpInput = Schema.Struct({ + objective: Schema.String, + search_queries: Schema.Array(Schema.String), + model_name: Schema.String.check(Schema.isMaxLength(100)).pipe(Schema.optional), +}) + +const SearchResponse = Schema.Struct({ + search_id: Schema.String, + results: Schema.Array( + Schema.Struct({ + url: Schema.String, + title: Schema.NullOr(Schema.String).pipe(Schema.optional), + publish_date: Schema.NullOr(Schema.String).pipe(Schema.optional), + excerpts: Schema.Array(Schema.String), + }), + ), + warnings: Schema.NullOr( + Schema.Array( + Schema.Struct({ + type: Schema.Literals(["spec_validation_warning", "input_validation_warning", "warning"]), + message: Schema.String, + detail: Schema.NullOr(Schema.Record(Schema.String, Schema.Json)).pipe(Schema.optional), + }), + ), + ).pipe(Schema.optional), + usage: Schema.NullOr( + Schema.Array( + Schema.Struct({ + name: Schema.String, + count: Schema.Int, + }), + ), + ).pipe(Schema.optional), + session_id: Schema.String, +}) +const McpOutput = Schema.Struct({ + content: Schema.Array(Schema.Struct({ type: Schema.Literal("text"), text: Schema.String })), + structuredContent: SearchResponse, +}) + +export const Plugin = define({ + id: "opencode.websearch.parallel", + effect: Effect.fn("WebSearchParallel.Plugin")(function* (ctx) { + const http = yield* HttpClient.HttpClient + yield* ctx.integration.transform((draft) => { + draft.update("parallel", (integration) => (integration.name = "Parallel")) + draft.method.update({ + integrationID: "parallel", + method: { type: "key", label: "API key (optional)" }, + }) + draft.method.update({ + integrationID: "parallel", + method: { type: "env", names: ["PARALLEL_API_KEY"] }, + }) + }) + yield* ctx.websearch.transform((draft) => { + draft.add({ + id: "parallel", + name: "Parallel", + execute: (input) => + Effect.gen(function* () { + const connection = yield* ctx.integration.connection.active("parallel") + const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined + const result = yield* WebSearchMcp.call( + http, + endpoint, + "web_search", + { input: McpInput, output: McpOutput }, + { + objective: input.query, + search_queries: [input.query], + }, + { + "User-Agent": App.useragent(ctx.app), + ...(credential?.type === "key" ? { Authorization: `Bearer ${credential.key}` } : {}), + }, + ) + return ( + result?.structuredContent.results.map((item) => { + const published = item.publish_date ? Date.parse(item.publish_date) : undefined + return { + url: item.url, + ...(item.title ? { title: item.title } : {}), + ...(item.excerpts.length ? { content: item.excerpts.join("\n\n") } : {}), + time: { ...(published !== undefined && Number.isFinite(published) ? { published } : {}) }, + } + }) ?? [] + ) + }), + }) + }) + }), +}) diff --git a/packages/core/src/tool/websearch.ts b/packages/core/src/tool/websearch.ts index bc6f1bcfa7d..9e3a6256bd1 100644 --- a/packages/core/src/tool/websearch.ts +++ b/packages/core/src/tool/websearch.ts @@ -2,262 +2,122 @@ export * as WebSearchTool from "./websearch" import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { ToolFailure } from "@opencode-ai/ai" -import { Context, Duration, Effect, Layer, Schema } from "effect" -import { HttpClient, HttpClientRequest } from "effect/unstable/http" -import { makeLocationNode } from "@opencode-ai/util/effect/app-node" -import { App } from "../app" -import { PositiveInt } from "../schema" +import { Effect, Schema } from "effect" +import { Form } from "../form" +import { KV } from "../kv" import { PermissionV2 } from "../permission" -import { Tool } from "./tool" -import { collectBoundedResponseBody } from "./http-body" -import { checksum } from "../util/encode" +import { WebSearch } from "../websearch" 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 MAX_NUM_RESULTS = 20 -export const MAX_CONTEXT_CHARACTERS = 50_000 -export const MAX_RESPONSE_BYTES = 256 * 1024 -/** - * 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. - -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. +export const description = `Search the web using the user's selected search integration. Use this for current information beyond knowledge cutoff. 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})`, - }), - 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')", - }), - type: Schema.optional(Schema.Literals(["auto", "fast", "deep"])).annotate({ - description: "Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search", - }), - 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})`, - }, - ), }) -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: - ["1", "true"].includes(process.env.OPENCODE_EXPERIMENTAL?.toLowerCase() ?? "") || - ["1", "true"].includes(process.env.OPENCODE_ENABLE_EXA?.toLowerCase() ?? "") || - ["1", "true"].includes(process.env.OPENCODE_EXPERIMENTAL_EXA?.toLowerCase() ?? ""), - enableParallel: - ["1", "true"].includes(process.env.OPENCODE_ENABLE_PARALLEL?.toLowerCase() ?? "") || - ["1", "true"].includes(process.env.OPENCODE_EXPERIMENTAL_PARALLEL?.toLowerCase() ?? ""), - 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, - text: Schema.String, + provider: WebSearch.ID, + results: Schema.Array(WebSearch.Result), }) 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 forms = yield* Form.Service + const kv = yield* KV.Service yield* ctx.tool .transform((draft) => draft.add( name, - Tool.make({ + { description, input: Input, output: Output, - 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.messageID, callID: context.callID }, }) - - 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, + const result = yield* ctx.websearch.query(input).pipe( + Effect.catch((error) => { + if (!Schema.is(WebSearch.ProviderRequiredError)(error)) return Effect.fail(error) + return Effect.gen(function* () { + const providers = (yield* ctx.websearch.providers()).data + if (providers.length === 0) return yield* new WebSearch.ProviderRequiredError() + const response = yield* forms.ask({ + sessionID: context.sessionID, + title: "Choose a web search provider", + metadata: { kind: "websearch.provider" }, + fields: [ + { + key: "provider", + title: "Provider", + description: "This becomes your default and can be changed later in configuration.", + type: "string", + required: true, + custom: false, + options: [ + ...providers.map((provider) => ({ value: provider.id, label: provider.name })), + { value: "__disable__", label: "Disable web search" }, + ], + }, + ], }) - : 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": App.useragent(ctx.app), - ...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}), - }, - ) + if (response.status === "cancelled") return yield* Effect.fail(new Error("Web search cancelled")) + const answer = response.answer.provider + if (answer === "__disable__") { + yield* kv.set("websearch:provider", false) + return yield* new WebSearch.DisabledError() + } + if (typeof answer !== "string" || !providers.some((provider) => provider.id === answer)) + return yield* new WebSearch.ProviderRequiredError() + yield* kv.set("websearch:provider", answer) + return yield* ctx.websearch.query(input) + }) + }), + ) const output = { - provider, - text: text ?? NO_RESULTS, + provider: result.data.providerID, + results: result.data.results, } - return { output, content: output.text, metadata: { provider: output.provider } } + const content = output.results.length + ? output.results + .map((result) => { + const title = result.title ?? result.url + const published = result.time.published + ? `\nPublished: ${new Date(result.time.published).toISOString()}` + : "" + return `## [${title}](${result.url})${published}${result.content ? `\n\n${result.content}` : ""}` + }) + .join("\n\n") + : NO_RESULTS + return { output, content, metadata: { provider: output.provider } } }).pipe( Effect.mapError( (error) => new ToolFailure({ message: `Unable to search the web for ${input.query}`, error }), ), - ) - }, - }), + ), + }, { codemode: false }, ), ) .pipe(Effect.orDie) + + yield* ctx.session.hook("context", (event) => + Effect.gen(function* () { + if ((yield* kv.get("websearch:provider")) === false) delete event.tools[name] + }), + ) }), } diff --git a/packages/core/src/websearch.ts b/packages/core/src/websearch.ts new file mode 100644 index 00000000000..a1ac9107cd3 --- /dev/null +++ b/packages/core/src/websearch.ts @@ -0,0 +1,144 @@ +export * as WebSearch from "./websearch" + +import { WebSearch } from "@opencode-ai/schema/websearch" +import { Context, Effect, Layer, Schema } from "effect" +import { makeLocationNode } from "@opencode-ai/util/effect/app-node" +import { EventV2 } from "./event" +import { KV } from "./kv" +import { State } from "./state" + +export const ID = WebSearch.ID +export type ID = WebSearch.ID + +export const Provider = WebSearch.Provider +export type Provider = WebSearch.Provider + +export const Event = WebSearch.Event + +export const Input = WebSearch.Input +export type Input = WebSearch.Input +export type ProviderInput = WebSearch.ProviderInput + +export const Result = WebSearch.Result +export type Result = WebSearch.Result + +export const Response = WebSearch.Response +export type Response = WebSearch.Response + +export interface ProviderImplementation extends Provider { + readonly execute: (input: ProviderInput) => Effect.Effect +} + +export class ProviderRequiredError extends Schema.TaggedErrorClass()( + "WebSearch.ProviderRequired", + {}, +) {} + +export class ProviderNotFoundError extends Schema.TaggedErrorClass()( + "WebSearch.ProviderNotFound", + { + providerID: ID, + }, +) {} + +export class DisabledError extends Schema.TaggedErrorClass()("WebSearch.Disabled", {}) {} + +export class RequestError extends Schema.TaggedErrorClass()("WebSearch.Request", { + providerID: ID, + cause: Schema.Defect(), +}) {} + +export type Error = ProviderRequiredError | ProviderNotFoundError | DisabledError | RequestError + +export interface Interface extends State.Transformable { + readonly providers: () => Effect.Effect + readonly default: () => Effect.Effect + readonly query: (input: Input) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/WebSearch") {} + +type Data = { + readonly providers: Map + defaultProviderID?: ID +} + +export type Draft = { + add: (provider: ProviderImplementation) => void + default: { + get: () => ID | undefined + set: (providerID: ID) => void + } +} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const events = yield* EventV2.Service + const kv = yield* KV.Service + const decodeResults = Schema.decodeUnknownEffect(Schema.Array(Result)) + const state = State.create({ + initial: () => ({ providers: new Map() }), + draft: (draft) => ({ + add: (provider) => draft.providers.set(provider.id, provider), + default: { + get: () => draft.defaultProviderID, + set: (providerID) => (draft.defaultProviderID = providerID), + }, + }), + finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid), + }) + + const requireProvider = (providers: Map, providerID: ID) => { + const provider = providers.get(providerID) + return provider ? Effect.succeed(provider) : Effect.fail(new ProviderNotFoundError({ providerID })) + } + + const defaultProvider = Effect.fn("WebSearch.default")(function* () { + const data = state.get() + const configured = data.defaultProviderID ? data.providers.get(data.defaultProviderID) : undefined + if (configured) return configured + const stored = yield* kv.get("websearch:provider") + if (stored === false) return yield* new DisabledError() + if (typeof stored !== "string") return + return data.providers.get(ID.make(stored)) + }) + + const resolve = Effect.fn("WebSearch.resolve")(function* (input: Input) { + const providers = state.get().providers + if (input.providerID) return yield* requireProvider(providers, input.providerID) + const provider = yield* defaultProvider() + if (!provider) return yield* new ProviderRequiredError() + return provider + }) + + return Service.of({ + transform: state.transform, + reload: state.reload, + providers: Effect.fn("WebSearch.providers")(function* () { + return Array.from(state.get().providers.values(), (provider) => ({ + id: provider.id, + name: provider.name, + })).toSorted((a, b) => a.name.localeCompare(b.name)) + }), + default: Effect.fn("WebSearch.defaultInfo")(function* () { + const provider = yield* defaultProvider() + return provider && { id: provider.id, name: provider.name } + }), + query: Effect.fn("WebSearch.query")(function* (input) { + const provider = yield* resolve(input) + const results = yield* provider.execute({ query: input.query }).pipe( + Effect.flatMap(decodeResults), + Effect.mapError((cause) => new RequestError({ providerID: provider.id, cause })), + ) + return new Response({ providerID: provider.id, results }) + }), + }) + }), +) + +export const node = makeLocationNode({ + service: Service, + layer, + deps: [EventV2.node, KV.node], +}) diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 6584cc1c94c..b826da794d1 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -121,23 +121,15 @@ describe("Config", () => { const config = yield* Config.Service const entries = yield* config.entries() expect( - entries.flatMap((entry) => - entry.type === "document" && entry.info.shell ? [entry.info.shell] : [], - ), + entries.flatMap((entry) => (entry.type === "document" && entry.info.shell ? [entry.info.shell] : [])), ).toEqual(["global", "explicit", "project", "content"]) expect(Config.latest(entries, "shell")).toBe("content") }).pipe( Effect.provide( - testLayer( - project, - global, - project, - undefined, - undefined, - emptyCredentialNode, - emptyWellknownNode, - { file: explicit, content: JSON.stringify({ shell: "content" }) }, - ), + testLayer(project, global, project, undefined, undefined, emptyCredentialNode, emptyWellknownNode, { + file: explicit, + content: JSON.stringify({ shell: "content" }), + }), ), ), ), @@ -155,31 +147,24 @@ describe("Config", () => { const global = path.join(tmp.path, "global") const project = path.join(tmp.path, "project") return Effect.promise(async () => { - await fs.mkdir(global, { recursive: true }) - await fs.mkdir(project, { recursive: true }) - await fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ shell: "global" })) - await fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ shell: "project" })) - }).pipe( - Effect.andThen( - Effect.gen(function* () { - const config = yield* Config.Service - expect(Config.latest(yield* config.entries(), "shell")).toBe("global") - }).pipe( - Effect.provide( - testLayer( - project, - global, - project, - undefined, - undefined, - emptyCredentialNode, - emptyWellknownNode, - { project: false }, - ), - ), + await fs.mkdir(global, { recursive: true }) + await fs.mkdir(project, { recursive: true }) + await fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ shell: "global" })) + await fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ shell: "project" })) + }).pipe( + Effect.andThen( + Effect.gen(function* () { + const config = yield* Config.Service + expect(Config.latest(yield* config.entries(), "shell")).toBe("global") + }).pipe( + Effect.provide( + testLayer(project, global, project, undefined, undefined, emptyCredentialNode, emptyWellknownNode, { + project: false, + }), ), ), - ) + ), + ) }), ), ) diff --git a/packages/core/test/lib/tool.ts b/packages/core/test/lib/tool.ts index 34314cdc589..7bf51cd9320 100644 --- a/packages/core/test/lib/tool.ts +++ b/packages/core/test/lib/tool.ts @@ -53,13 +53,17 @@ export function waitForCodeModeTool( * full plugin host. Only the tool domain is live; focused tool tests exercise * registration, snapshots, and execution through the same path production uses. */ -export const registerToolPlugin = (plugin: { - readonly id: string - readonly effect: (context: PluginContext) => Effect.Effect -}): Effect.Effect => +export const registerToolPlugin = ( + plugin: { + readonly id: string + readonly effect: (context: PluginContext) => Effect.Effect + }, + overrides: Parameters[0] = {}, +): Effect.Effect => Effect.gen(function* () { const tools = yield* Tools.Service const context = host({ + ...overrides, session: { hook: () => Effect.succeed({ dispose: Effect.void }), }, diff --git a/packages/core/test/plugin/fixture.ts b/packages/core/test/plugin/fixture.ts index ffe005cb63f..fc56071558d 100644 --- a/packages/core/test/plugin/fixture.ts +++ b/packages/core/test/plugin/fixture.ts @@ -2,6 +2,7 @@ import { AgentV2 } from "@opencode-ai/core/agent" import { AISDK } from "@opencode-ai/core/aisdk" import { Catalog } from "@opencode-ai/core/catalog" import { CommandV2 } from "@opencode-ai/core/command" +import { Config } from "@opencode-ai/core/config" import { Credential } from "@opencode-ai/core/credential" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform" @@ -9,6 +10,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" import { FileSystem } from "@opencode-ai/core/filesystem" import { FSUtil } from "@opencode-ai/util/fs-util" +import { Form } from "@opencode-ai/core/form" import { Integration } from "@opencode-ai/core/integration" import { Location } from "@opencode-ai/core/location" import { Npm } from "@opencode-ai/util/npm" @@ -19,6 +21,7 @@ import { Reference } from "@opencode-ai/core/reference" import { SkillV2 } from "@opencode-ai/core/skill" import { ToolHooks } from "@opencode-ai/core/tool/hooks" import { ToolRegistry } from "@opencode-ai/core/tool/registry" +import { WebSearch } from "@opencode-ai/core/websearch" import { Effect, Layer } from "effect" import { tempLocationLayer } from "../fixture/location" @@ -39,6 +42,7 @@ export const PluginTestLayer = AppNodeBuilder.build( Npm.node, Credential.node, EventV2.node, + Form.node, LayerNodePlatform.httpClient, PluginV2.node, AgentV2.node, @@ -52,9 +56,11 @@ export const PluginTestLayer = AppNodeBuilder.build( SkillV2.node, ToolHooks.node, ToolRegistry.toolsNode, + WebSearch.node, ]), [ [Location.node, tempLocationLayer], [Npm.node, npmLayer], + [Config.node, Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))], ], ) as unknown as Layer.Layer diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index 1b28d6a8400..b7218ae690b 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -1,11 +1,16 @@ -import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" +import { Plugin } 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 { Location } from "@opencode-ai/core/location" import { ModelV2 } from "@opencode-ai/core/model" +import { Project } from "@opencode-ai/core/project" import { ProviderV2 } from "@opencode-ai/core/provider" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { WebSearch } from "@opencode-ai/core/websearch" import type { + CredentialOAuth, IntegrationCommandMethod, IntegrationEnvMethod, IntegrationKeyMethod, @@ -13,11 +18,11 @@ import type { } from "@opencode-ai/sdk/v2/types" import { Effect, Stream } from "effect" -type Overrides = Partial> & { - readonly session?: Partial +type Overrides = Partial> & { + readonly session?: Partial } -export function host(overrides: Overrides = {}): PluginContext { +export function host(overrides: Overrides = {}): Plugin.Context { return { app: overrides.app ?? { name: "test", version: "test", channel: "test" }, options: {}, @@ -92,6 +97,12 @@ export function host(overrides: Overrides = {}): PluginContext { transform: () => Effect.die("unused tool.transform"), hook: () => Effect.die("unused tool.hook"), }, + websearch: overrides.websearch ?? { + providers: () => Effect.die("unused websearch.providers"), + query: () => Effect.die("unused websearch.query"), + transform: () => Effect.die("unused websearch.transform"), + reload: () => Effect.die("unused websearch.reload"), + }, session: { hook: overrides.session?.hook ?? (() => Effect.die("unused session.hook")), create: overrides.session?.create ?? (() => Effect.die("unused session.create")), @@ -105,7 +116,7 @@ export function host(overrides: Overrides = {}): PluginContext { } } -export function agentHost(agent: AgentV2.Interface): PluginContext["agent"] { +export function agentHost(agent: AgentV2.Interface): Plugin.Context["agent"] { return { get: (id) => agent.get(AgentV2.ID.make(id)).pipe(Effect.map((value) => value && agentInfo(value))), list: () => Effect.die("unused agent.list"), @@ -131,7 +142,7 @@ export function agentHost(agent: AgentV2.Interface): PluginContext["agent"] { } } -export function catalogHost(catalog: Catalog.Interface): PluginContext["catalog"] { +export function catalogHost(catalog: Catalog.Interface): Plugin.Context["catalog"] { return { provider: { list: () => Effect.die("unused catalog.provider.list"), @@ -207,7 +218,7 @@ export function catalogHost(catalog: Catalog.Interface): PluginContext["catalog" } } -export function integrationHost(integration: Integration.Interface): PluginContext["integration"] { +export function integrationHost(integration: Integration.Interface): Plugin.Context["integration"] { return { list: () => Effect.die("unused integration.list"), get: () => Effect.die("unused integration.get"), @@ -329,6 +340,40 @@ export function integrationHost(integration: Integration.Interface): PluginConte } } +export function webSearchHost(websearch: WebSearch.Interface): Plugin.Context["websearch"] { + const location = Location.Info.make({ + directory: AbsolutePath.make("/tmp/websearch-test"), + project: { id: Project.ID.make("websearch-test"), directory: AbsolutePath.make("/tmp/websearch-test") }, + }) + return { + providers: () => websearch.providers().pipe(Effect.map((data) => ({ location, data }))), + query: (input) => + websearch + .query({ query: input.query, providerID: input.providerID && WebSearch.ID.make(input.providerID) }) + .pipe(Effect.map((data) => ({ location, data }))), + reload: websearch.reload, + transform: (callback) => + websearch.transform((draft) => { + callback({ + add: (definition) => + draft.add({ + id: WebSearch.ID.make(definition.id), + name: definition.name, + execute: definition.execute, + }), + default: { + get: draft.default.get, + set: (providerID) => draft.default.set(WebSearch.ID.make(providerID)), + }, + }) + }), + } +} + +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 76e2a1e31b1..97e30386f2b 100644 --- a/packages/core/test/plugin/promise.test.ts +++ b/packages/core/test/plugin/promise.test.ts @@ -8,6 +8,7 @@ import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginHooks } from "@opencode-ai/core/plugin/hooks" import { PluginHost } from "@opencode-ai/core/plugin/host" import { PluginPromise } from "@opencode-ai/core/plugin/promise" +import { WebSearch } from "@opencode-ai/core/websearch" import { SessionV2 } from "@opencode-ai/core/session" import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionPending } from "@opencode-ai/core/session/pending" @@ -244,6 +245,38 @@ describe("fromPromise", () => { }), ) + it.effect("registers a standalone web search provider", () => + Effect.gen(function* () { + const websearch = yield* WebSearch.Service + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make(plugin) + const promisePlugin = Plugin.define({ + id: "promise-websearch", + setup: async (ctx) => { + await ctx.websearch.transform((draft) => { + draft.add({ + id: "promise-websearch", + name: "Promise Web Search", + execute: async (input) => [{ url: "https://example.com", content: `promise: ${input.query}`, time: {} }], + }) + }) + }, + }) + + yield* PluginPromise.fromPromise(promisePlugin).effect(host) + expect(yield* websearch.providers()).toContainEqual({ + id: WebSearch.ID.make("promise-websearch"), + name: "Promise Web Search", + }) + expect(yield* websearch.query({ query: "effect", providerID: WebSearch.ID.make("promise-websearch") })).toEqual( + new WebSearch.Response({ + providerID: WebSearch.ID.make("promise-websearch"), + results: [{ url: "https://example.com", content: "promise: effect", time: {} }], + }), + ) + }), + ) + it.effect("runs the setup cleanup when the plugin scope closes", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service diff --git a/packages/core/test/plugin/websearch-fixture.ts b/packages/core/test/plugin/websearch-fixture.ts new file mode 100644 index 00000000000..406e093249c --- /dev/null +++ b/packages/core/test/plugin/websearch-fixture.ts @@ -0,0 +1,50 @@ +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/util/effect/layer-node" +import { Credential } from "@opencode-ai/core/credential" +import { Config } from "@opencode-ai/core/config" +import { EventV2 } from "@opencode-ai/core/event" +import { Form } from "@opencode-ai/core/form" +import { Integration } from "@opencode-ai/core/integration" +import { WebSearch } from "@opencode-ai/core/websearch" +import { testEffect } from "../lib/effect" + +interface WebSearchRequest { + readonly url: string + readonly headers: Record + readonly body: unknown +} + +export const requests: WebSearchRequest[] = [] +let responseBody = "" + +export function resetWebSearchFixture(body: string) { + requests.length = 0 + responseBody = 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(responseBody, { status: 200 })) + }), + ), +) + +export const webSearchIntegrationTest = testEffect( + Layer.merge( + AppNodeBuilder.build( + LayerNode.group([Integration.node, Credential.node, EventV2.node, Form.node, WebSearch.node]), + [[Config.node, Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))]], + ), + http, + ), +) diff --git a/packages/core/test/plugin/websearch.test.ts b/packages/core/test/plugin/websearch.test.ts new file mode 100644 index 00000000000..a45785e40dd --- /dev/null +++ b/packages/core/test/plugin/websearch.test.ts @@ -0,0 +1,170 @@ +import { beforeEach, describe, expect } from "bun:test" +import { Effect } from "effect" +import { Integration } from "@opencode-ai/core/integration" +import { WebSearch } from "@opencode-ai/core/websearch" +import { WebSearchExa } from "@opencode-ai/core/plugin/websearch/exa" +import { WebSearchParallel } from "@opencode-ai/core/plugin/websearch/parallel" +import { host, integrationHost, webSearchHost } from "./host" +import { requests, resetWebSearchFixture, webSearchIntegrationTest } from "./websearch-fixture" + +beforeEach(() => { + resetWebSearchFixture( + `event: message\ndata: ${JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { + content: [ + { + type: "text", + text: "Title: Effect\nURL: https://effect.website\nPublished: 2026-07-25T00:00:00.000Z\nAuthor: N/A\nHighlights:\nEffect documentation", + _meta: { searchTime: 123 }, + }, + ], + }, + })}\n\n`, + ) +}) + +const it = webSearchIntegrationTest + +describe("built-in web search providers", () => { + it.effect("registers a provider without an integration", () => + Effect.gen(function* () { + const integrations = yield* Integration.Service + const websearch = yield* WebSearch.Service + const registration = yield* webSearchHost(websearch).transform((draft) => { + draft.add({ + id: "test-websearch", + name: "Test Web Search", + execute: (input) => Effect.succeed([{ url: "https://example.com", content: input.query, time: {} }]), + }) + }) + + expect(yield* integrations.get(Integration.ID.make("test-websearch"))).toBeUndefined() + expect(yield* websearch.providers()).toContainEqual({ + id: WebSearch.ID.make("test-websearch"), + name: "Test Web Search", + }) + yield* registration.dispose + expect(yield* websearch.providers()).not.toContainEqual({ + id: WebSearch.ID.make("test-websearch"), + name: "Test Web Search", + }) + }), + ) + + it.effect("registers Exa with its MCP schema", () => + Effect.gen(function* () { + const integrations = yield* Integration.Service + const websearch = yield* WebSearch.Service + yield* WebSearchExa.Plugin.effect( + host({ integration: integrationHost(integrations), websearch: webSearchHost(websearch) }), + ) + + const info = yield* integrations.get(Integration.ID.make("exa")) + expect(info).toMatchObject({ + id: "exa", + name: "Exa", + methods: [{ type: "key" }, { type: "env", names: ["EXA_API_KEY"] }], + }) + yield* integrations.connection.key({ integrationID: Integration.ID.make("exa"), key: "exa secret" }) + expect(yield* websearch.query({ query: "effect typescript", providerID: WebSearch.ID.make("exa") })).toEqual( + new WebSearch.Response({ + providerID: WebSearch.ID.make("exa"), + results: [ + { + url: "https://effect.website", + title: "Effect", + content: "Effect documentation", + time: { published: Date.parse("2026-07-25T00:00:00.000Z") }, + }, + ], + }), + ) + expect(requests).toEqual([ + { + url: `${WebSearchExa.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", numResults: 8 }, + }, + }, + }, + ]) + }), + ) + + it.effect("registers Parallel and keeps its credential in the authorization header", () => + Effect.gen(function* () { + resetWebSearchFixture( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { + content: [{ type: "text", text: "search results" }], + structuredContent: { + search_id: "search_1", + results: [ + { + url: "https://effect.website", + title: "Effect", + publish_date: null, + excerpts: ["Effect documentation"], + }, + ], + warnings: null, + usage: [{ name: "sku_search", count: 1 }], + session_id: "ses_parallel", + }, + }, + }), + ) + const integrations = yield* Integration.Service + const websearch = yield* WebSearch.Service + yield* WebSearchParallel.Plugin.effect( + host({ integration: integrationHost(integrations), websearch: webSearchHost(websearch) }), + ) + yield* integrations.connection.key({ integrationID: Integration.ID.make("parallel"), key: "parallel-secret" }) + + const output = yield* websearch.query({ + query: "effect layers", + providerID: WebSearch.ID.make("parallel"), + }) + expect(output).toEqual( + new WebSearch.Response({ + providerID: WebSearch.ID.make("parallel"), + results: [ + { + url: "https://effect.website", + title: "Effect", + content: "Effect documentation", + time: {}, + }, + ], + }), + ) + expect(requests[0]).toMatchObject({ + url: WebSearchParallel.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"], + }, + }, + }, + }) + expect(JSON.stringify(output)).not.toContain("parallel-secret") + }), + ) +}) diff --git a/packages/core/test/tool-websearch.test.ts b/packages/core/test/tool-websearch.test.ts index 254dc4221d1..6c188968086 100644 --- a/packages/core/test/tool-websearch.test.ts +++ b/packages/core/test/tool-websearch.test.ts @@ -1,10 +1,11 @@ -import { beforeEach, describe, expect, test } from "bun:test" -import { Effect, Layer, Schema } from "effect" -import { HttpClient, HttpClientResponse } from "effect/unstable/http" +import { beforeEach, describe, expect } from "bun:test" +import { Effect, Layer } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/util/effect/layer-node" -import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform" import { PermissionV2 } from "@opencode-ai/core/permission" +import { Form } from "@opencode-ai/core/form" +import { KV } from "@opencode-ai/core/kv" +import { WebSearch } from "@opencode-ai/core/websearch" import { SessionV2 } from "@opencode-ai/core/session" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { WebSearchTool } from "@opencode-ai/core/tool/websearch" @@ -14,93 +15,36 @@ import { Image } from "@opencode-ai/core/image" import { testEffect } from "./lib/effect" import { imagePassthrough } from "./lib/image" import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool" +import { webSearchHost } from "./plugin/host" const webSearchToolNode = makeLocationNode({ name: "test/websearch-tool-plugin", - layer: Layer.effectDiscard(registerToolPlugin(WebSearchTool.Plugin)), - deps: [ToolRegistry.toolsNode, PermissionV2.node, LayerNodePlatform.httpClient, WebSearchTool.configNode], + layer: Layer.effectDiscard( + Effect.gen(function* () { + const websearch = yield* WebSearch.Service + yield* registerToolPlugin(WebSearchTool.Plugin, { websearch: webSearchHost(websearch) }) + }), + ), + deps: [ToolRegistry.toolsNode, PermissionV2.node, WebSearch.node, Form.node, KV.node], }) const sessionID = SessionV2.ID.make("ses_websearch_test") -const payload = (text: string) => - JSON.stringify({ - jsonrpc: "2.0", - id: 1, - result: { content: [{ type: "text", text }] }, - }) - -describe("WebSearchTool provider selection", () => { - 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", () => { - test("parses plain JSON-RPC responses", async () => { - expect(await Effect.runPromise(WebSearchTool.parseResponse(payload("search results")))).toBe("search results") - }) - - test("parses SSE JSON-RPC responses and ignores non-JSON frames", async () => { - expect( - await Effect.runPromise( - WebSearchTool.parseResponse(`data: [DONE]\nevent: message\ndata: ${payload("search results")}\n\n`), - ), - ).toBe("search results") - }) -}) - -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: WebSearch.Input[] = [] +let result = new WebSearch.Response({ + providerID: WebSearch.ID.make("exa"), + results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }], +}) beforeEach(() => { - responseBody = payload("search results") - makeResponse = () => new Response(responseBody, { status: 200 }) + assertions.length = 0 + queries.length = 0 + result = new WebSearch.Response({ + providerID: WebSearch.ID.make("exa"), + results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }], + }) }) -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({ @@ -112,33 +56,48 @@ 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 websearch = Layer.succeed( + WebSearch.Service, + WebSearch.Service.of({ + transform: () => Effect.die("unused"), + reload: () => Effect.die("unused"), + providers: () => Effect.succeed([]), + default: () => Effect.succeed(undefined), + query: (input) => + Effect.sync(() => { + queries.push(input) + return result + }), + }), +) +const form = Layer.succeed( + Form.Service, + Form.Service.of({ + create: () => Effect.die("unused"), + ask: () => Effect.die("unused"), + get: () => Effect.die("unused"), + list: () => Effect.die("unused"), + state: () => Effect.die("unused"), + reply: () => Effect.die("unused"), + cancel: () => Effect.die("unused"), + }), +) +const kv = Layer.succeed( + KV.Service, + KV.Service.of({ + get: () => Effect.succeed(undefined), + set: () => Effect.void, + remove: () => Effect.void, }), ) const it = testEffect( AppNodeBuilder.build( - LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebSearchTool.configNode, webSearchToolNode]), + LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebSearch.node, webSearchToolNode]), [ [PermissionV2.node, permission], - [LayerNodePlatform.httpClient, http], - [WebSearchTool.configNode, websearchConfig], + [WebSearch.node, websearch], + [Form.node, form], + [KV.node, kv], [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], [Image.node, imagePassthrough], ], @@ -146,12 +105,8 @@ const it = testEffect( ) describe("WebSearchTool registration", () => { - it.effect("registers websearch, asserts query permission, and calls Exa", () => + it.effect("asserts permission before delegating to WebSearch", () => 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", "execute"]) @@ -161,20 +116,14 @@ describe("WebSearchTool registration", () => { ...toolIdentity, call: { type: "tool-call", - id: "call-exa", + id: "call-search", name: "websearch", - input: { - query: "effect typescript", - numResults: 3, - livecrawl: "preferred", - type: "fast", - contextMaxCharacters: 2500, - }, + input: { query: "effect typescript" }, }, }), ).toMatchObject({ status: "completed", - content: [{ type: "text", text: "exa results" }], + content: [{ type: "text", text: "## [Search results](https://example.com)\n\nsearch results" }], }) expect(assertions).toMatchObject([ { @@ -182,103 +131,65 @@ describe("WebSearchTool registration", () => { action: "websearch", resources: ["effect typescript"], save: ["*"], - metadata: { - query: "effect typescript", - numResults: 3, - livecrawl: "preferred", - type: "fast", - contextMaxCharacters: 2500, - provider: "exa", - }, + metadata: { query: "effect typescript" }, }, ]) - 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, - }, - }, - }, + query: "effect typescript", }, ]) }), ) - it.effect("calls Parallel with session ID and keeps bearer credentials out of output", () => + it.effect("keeps normalized results in structured output", () => Effect.gen(function* () { - requests.length = 0 - assertions.length = 0 - responseBody = payload("parallel results") - config = { provider: "parallel", enableExa: false, enableParallel: false, parallelApiKey: "parallel-secret" } + result = new WebSearch.Response({ + providerID: WebSearch.ID.make("parallel"), + results: [ + { + url: "https://effect.website", + title: "Effect", + content: "parallel results", + time: { published: Date.parse("2026-07-25T00:00:00.000Z") }, + }, + ], + }) const registry = yield* ToolRegistry.Service - const settled = yield* executeTool(registry, { - sessionID, - ...toolIdentity, - call: { type: "tool-call", id: "call-parallel", name: "websearch", input: { query: "effect layers" } }, - }) - - expect(requests[0]).toMatchObject({ - url: WebSearchTool.PARALLEL_URL, - headers: { authorization: "Bearer parallel-secret" }, - body: { - jsonrpc: "2.0", - id: 1, - method: "tools/call", - params: { - name: "web_search", - arguments: { objective: "effect layers", search_queries: ["effect layers"], session_id: sessionID }, - }, - }, - }) - expect(requests[0]?.body).not.toHaveProperty("params.arguments.model_name") - expect(settled).toEqual({ + expect( + yield* executeTool(registry, { + sessionID, + ...toolIdentity, + call: { type: "tool-call", id: "call-parallel", name: "websearch", input: { query: "effect layers" } }, + }), + ).toEqual({ status: "completed", - output: { provider: "parallel", text: "parallel results" }, - content: [{ type: "text", text: "parallel results" }], + output: { + provider: "parallel", + results: [ + { + url: "https://effect.website", + title: "Effect", + content: "parallel results", + time: { published: Date.parse("2026-07-25T00:00:00.000Z") }, + }, + ], + }, + content: [ + { + type: "text", + text: "## [Effect](https://effect.website)\nPublished: 2026-07-25T00:00:00.000Z\n\nparallel results", + }, + ], metadata: { provider: "parallel" }, }) - 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* executeTool(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 WebSearch.Response({ providerID: WebSearch.ID.make("exa"), results: [] }) const registry = yield* ToolRegistry.Service expect( @@ -293,44 +204,4 @@ describe("WebSearchTool registration", () => { }) }), ) - - 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" } }, - }), - // toSessionError unwraps the "Unable to search the web for " ToolFailure - // to its byte-limit cause message. - ).toEqual({ - status: "error", - error: { type: "unknown", message: expect.stringContaining("response exceeded") }, - }) - expect(chunksRead).toBeLessThan(10) - expect(cancelled).toBe(true) - }), - ) }) diff --git a/packages/core/test/websearch.test.ts b/packages/core/test/websearch.test.ts new file mode 100644 index 00000000000..104c6286060 --- /dev/null +++ b/packages/core/test/websearch.test.ts @@ -0,0 +1,129 @@ +import { describe, expect } from "bun:test" +import { Effect, Exit, Scope } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/util/effect/layer-node" +import { EventV2 } from "@opencode-ai/core/event" +import { KV } from "@opencode-ai/core/kv" +import { WebSearch } from "@opencode-ai/core/websearch" +import { testEffect } from "./lib/effect" + +const it = testEffect(AppNodeBuilder.build(LayerNode.group([WebSearch.node, EventV2.node, KV.node]))) + +const register = (id: string) => + Effect.gen(function* () { + const websearch = yield* WebSearch.Service + const providerID = WebSearch.ID.make(id) + const calls: WebSearch.ProviderInput[] = [] + yield* websearch.transform((draft) => { + draft.add({ + id: providerID, + name: id.toUpperCase(), + execute: (input) => + Effect.sync(() => { + calls.push(input) + return [ + { + url: `https://${id}.example.com`, + title: input.query, + content: `${id}: ${input.query}`, + time: {}, + }, + ] + }), + }) + }) + return { providerID, calls } + }) + +describe("WebSearch", () => { + it.effect("executes an explicit provider without changing the default", () => + Effect.gen(function* () { + yield* register("exa") + const parallel = yield* register("parallel") + const websearch = yield* WebSearch.Service + + expect(yield* websearch.query({ query: "effect", providerID: parallel.providerID })).toEqual( + new WebSearch.Response({ + providerID: parallel.providerID, + results: [ + { + url: "https://parallel.example.com", + title: "effect", + content: "parallel: effect", + time: {}, + }, + ], + }), + ) + expect((yield* websearch.query({ query: "default" }).pipe(Effect.flip))._tag).toBe("WebSearch.ProviderRequired") + expect(parallel.calls).toEqual([{ query: "effect" }]) + }), + ) + + it.effect("requires a provider when no default is set", () => + Effect.gen(function* () { + yield* register("exa") + yield* register("parallel") + const websearch = yield* WebSearch.Service + + expect((yield* websearch.query({ query: "layers" }).pipe(Effect.flip))._tag).toBe("WebSearch.ProviderRequired") + }), + ) + + it.effect("uses the default set by a transform", () => + Effect.gen(function* () { + yield* register("exa") + const parallel = yield* register("parallel") + const websearch = yield* WebSearch.Service + yield* websearch.transform((draft) => draft.default.set(parallel.providerID)) + + expect((yield* websearch.query({ query: "configured" })).providerID).toBe(parallel.providerID) + }), + ) + + it.effect("uses the provider stored in KV", () => + Effect.gen(function* () { + yield* register("exa") + const parallel = yield* register("parallel") + const websearch = yield* WebSearch.Service + const kv = yield* KV.Service + yield* kv.set("websearch:provider", parallel.providerID) + + expect((yield* websearch.query({ query: "stored" })).providerID).toBe(parallel.providerID) + yield* kv.remove("websearch:provider") + }), + ) + + it.effect("fails when web search is explicitly disabled", () => + Effect.gen(function* () { + yield* register("exa") + const websearch = yield* WebSearch.Service + const kv = yield* KV.Service + yield* kv.set("websearch:provider", false) + + expect((yield* websearch.query({ query: "disabled" }).pipe(Effect.flip))._tag).toBe("WebSearch.Disabled") + yield* kv.remove("websearch:provider") + }), + ) + + it.effect("falls back when the configured default is unavailable", () => + Effect.gen(function* () { + yield* register("exa") + const websearch = yield* WebSearch.Service + yield* websearch.transform((draft) => draft.default.set(WebSearch.ID.make("missing"))) + + expect((yield* websearch.query({ query: "fallback" }).pipe(Effect.flip))._tag).toBe("WebSearch.ProviderRequired") + }), + ) + + it.effect("removes scoped provider registrations", () => + Effect.gen(function* () { + const websearch = yield* WebSearch.Service + const scope = yield* Scope.fork(yield* Scope.Scope) + const provider = yield* register("temporary").pipe(Scope.provide(scope)) + expect(yield* websearch.providers()).toContainEqual({ id: provider.providerID, name: "TEMPORARY" }) + yield* Scope.close(scope, Exit.void) + expect(yield* websearch.providers()).not.toContainEqual({ id: provider.providerID, name: "TEMPORARY" }) + }), + ) +}) diff --git a/packages/plugin/src/v2/effect/index.ts b/packages/plugin/src/v2/effect/index.ts index e00e302b7ad..09c46955a1f 100644 --- a/packages/plugin/src/v2/effect/index.ts +++ b/packages/plugin/src/v2/effect/index.ts @@ -9,3 +9,4 @@ export { Model } from "@opencode-ai/schema/model" export { Provider } from "@opencode-ai/schema/provider" export { Reference } from "@opencode-ai/schema/reference" export { Skill } from "@opencode-ai/schema/skill" +export { WebSearch } from "@opencode-ai/schema/websearch" diff --git a/packages/plugin/src/v2/effect/plugin.ts b/packages/plugin/src/v2/effect/plugin.ts index 02cb8a681fb..127e6a70b54 100644 --- a/packages/plugin/src/v2/effect/plugin.ts +++ b/packages/plugin/src/v2/effect/plugin.ts @@ -12,6 +12,7 @@ import type { ReferenceDomain } from "./reference.js" import type { SessionDomain } from "./session.js" import type { SkillDomain } from "./skill.js" import type { ToolDomain } from "./tool.js" +import type { WebSearchDomain } from "./websearch.js" export interface Context { readonly app: App @@ -27,6 +28,7 @@ export interface Context { readonly session: SessionDomain readonly skill: SkillDomain readonly tool: ToolDomain + readonly websearch: WebSearchDomain } export interface Plugin { diff --git a/packages/plugin/src/v2/effect/websearch.ts b/packages/plugin/src/v2/effect/websearch.ts new file mode 100644 index 00000000000..03d913e98f0 --- /dev/null +++ b/packages/plugin/src/v2/effect/websearch.ts @@ -0,0 +1,23 @@ +import type { WebSearch } from "@opencode-ai/schema/websearch" +import type { WebsearchApi } from "@opencode-ai/client/effect/api" +import type { Effect } from "effect" +import type { Transform } from "./registration.js" + +export interface WebSearchDefinition { + readonly id: string + readonly name: string + readonly execute: (input: WebSearch.ProviderInput) => Effect.Effect +} + +export interface WebSearchDomain extends WebsearchApi { + readonly transform: Transform + readonly reload: () => Effect.Effect +} + +export interface WebSearchDraft { + add(definition: WebSearchDefinition): void + readonly default: { + get(): string | undefined + set(providerID: string): void + } +} diff --git a/packages/plugin/src/v2/promise/index.ts b/packages/plugin/src/v2/promise/index.ts index ae8e6d18a19..786d150bb5b 100644 --- a/packages/plugin/src/v2/promise/index.ts +++ b/packages/plugin/src/v2/promise/index.ts @@ -10,3 +10,4 @@ export { Model } from "@opencode-ai/schema/model" export { Provider } from "@opencode-ai/schema/provider" export { Reference } from "@opencode-ai/schema/reference" export { Skill } from "@opencode-ai/schema/skill" +export { WebSearch } from "@opencode-ai/schema/websearch" diff --git a/packages/plugin/src/v2/promise/integration.ts b/packages/plugin/src/v2/promise/integration.ts index e666b83b8a5..6b121afc8bc 100644 --- a/packages/plugin/src/v2/promise/integration.ts +++ b/packages/plugin/src/v2/promise/integration.ts @@ -1,10 +1,32 @@ 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 { + CredentialOAuth, + CredentialValue, + IntegrationEnvMethod, + IntegrationInputs, + IntegrationKeyMethod, + IntegrationOAuthMethod, +} from "@opencode-ai/sdk/v2/types" import type { Transform } from "./registration.js" export type { IntegrationDraft, IntegrationMethodRegistration } +export type IntegrationOAuthAuthorization = { + readonly url: string + readonly instructions: string + readonly expiresAt?: number +} & ( + | { + readonly mode: "auto" + readonly callback: Promise + } + | { + readonly mode: "code" + readonly callback: (code: string) => Promise + } +) + export interface IntegrationDomain extends Omit { readonly transform: Transform readonly reload: () => Promise diff --git a/packages/plugin/src/v2/promise/plugin.ts b/packages/plugin/src/v2/promise/plugin.ts index a6a3389f7ac..35225b076c2 100644 --- a/packages/plugin/src/v2/promise/plugin.ts +++ b/packages/plugin/src/v2/promise/plugin.ts @@ -11,6 +11,7 @@ import type { ReferenceDomain } from "./reference.js" import type { SessionDomain } from "./session.js" import type { SkillDomain } from "./skill.js" import type { ToolDomain } from "./tool.js" +import type { WebSearchDomain } from "./websearch.js" export interface Context { readonly app: App @@ -26,6 +27,7 @@ export interface Context { readonly session: SessionDomain readonly skill: SkillDomain readonly tool: ToolDomain + readonly websearch: WebSearchDomain } export type Cleanup = () => Promise | void diff --git a/packages/plugin/src/v2/promise/websearch.ts b/packages/plugin/src/v2/promise/websearch.ts new file mode 100644 index 00000000000..999f4852357 --- /dev/null +++ b/packages/plugin/src/v2/promise/websearch.ts @@ -0,0 +1,25 @@ +import type { WebSearch } from "@opencode-ai/schema/websearch" +import type { WebSearchApi } from "@opencode-ai/client/promise/api" +import type { Transform } from "./registration.js" + +export interface WebSearchDefinition { + readonly id: string + readonly name: string + readonly execute: ( + input: WebSearch.ProviderInput, + context: { readonly signal: AbortSignal }, + ) => Promise +} + +export interface WebSearchDomain extends WebSearchApi { + readonly transform: Transform + readonly reload: () => Promise +} + +export interface WebSearchDraft { + add(definition: WebSearchDefinition): void + readonly default: { + get(): string | undefined + set(providerID: string): void + } +} diff --git a/packages/plugin/test/contract-identity.test.ts b/packages/plugin/test/contract-identity.test.ts index 243a7aa89be..264fcaa2f8d 100644 --- a/packages/plugin/test/contract-identity.test.ts +++ b/packages/plugin/test/contract-identity.test.ts @@ -8,6 +8,7 @@ import { Model } from "@opencode-ai/schema/model" import { Provider } from "@opencode-ai/schema/provider" import { Reference } from "@opencode-ai/schema/reference" import { Skill } from "@opencode-ai/schema/skill" +import { WebSearch } from "@opencode-ai/schema/websearch" const Plugin = await import("../src/v2/effect/index") const PromisePlugin = await import("../src/v2/promise/index") @@ -16,7 +17,7 @@ const TuiPlugin = await import("../src/v2/tui/index") test.each([ ["effect", Plugin], ["promise", PromisePlugin], -])("%s entrypoint exposes its canonical Schema contracts", (name, entrypoint) => { +])("%s entrypoint exposes its canonical Schema contracts", (_name, entrypoint) => { expect(entrypoint.Agent).toBe(Agent) expect(entrypoint.Command).toBe(Command) expect(entrypoint.Connection).toBe(Connection) @@ -26,6 +27,7 @@ test.each([ expect(entrypoint.Provider).toBe(Provider) expect(entrypoint.Reference).toBe(Reference) expect(entrypoint.Skill).toBe(Skill) + expect(entrypoint.WebSearch).toBe(WebSearch) expect(Object.keys(entrypoint).sort()).toEqual([ "Agent", "Command", @@ -37,6 +39,7 @@ test.each([ "Provider", "Reference", "Skill", + "WebSearch", ]) }) diff --git a/packages/protocol/src/api.ts b/packages/protocol/src/api.ts index df48ecb3a26..2249797df4a 100644 --- a/packages/protocol/src/api.ts +++ b/packages/protocol/src/api.ts @@ -25,6 +25,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 { WebSearchGroup } from "./groups/websearch.js" import { McpGroup } from "./groups/mcp.js" import { CredentialGroup } from "./groups/credential.js" import { ProjectGroup } from "./groups/project.js" @@ -39,6 +40,7 @@ type LocationGroups = | HttpApiGroup.AddMiddleware | HttpApiGroup.AddMiddleware | HttpApiGroup.AddMiddleware + | HttpApiGroup.AddMiddleware | HttpApiGroup.AddMiddleware | HttpApiGroup.AddMiddleware | HttpApiGroup.AddMiddleware @@ -169,6 +171,7 @@ const makeApiFromGroup = < .add(ProjectCopyGroup.middleware(locationMiddleware)) .add(VcsGroup.middleware(locationMiddleware)) .add(DebugGroup) + .add(WebSearchGroup.middleware(locationMiddleware)) .annotateMerge( OpenApi.annotations({ title: "opencode HttpApi", diff --git a/packages/protocol/src/client.ts b/packages/protocol/src/client.ts index e89525c1ded..24e68e7a516 100644 --- a/packages/protocol/src/client.ts +++ b/packages/protocol/src/client.ts @@ -45,6 +45,7 @@ export const groupNames = { "server.generate": "generate", "server.provider": "provider", "server.integration": "integration", + "server.websearch": "websearch", "server.credential": "credential", "server.form": "form", "server.permission": "permission", diff --git a/packages/protocol/src/groups/websearch.ts b/packages/protocol/src/groups/websearch.ts new file mode 100644 index 00000000000..0ebf5674df1 --- /dev/null +++ b/packages/protocol/src/groups/websearch.ts @@ -0,0 +1,46 @@ +import { Location } from "@opencode-ai/schema/location" +import { WebSearch } from "@opencode-ai/schema/websearch" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { InvalidRequestError, ServiceUnavailableError } from "../errors.js" +import { LocationQuery, locationQueryOpenApi } from "./location.js" + +export const WebSearchGroup = HttpApiGroup.make("server.websearch") + .add( + HttpApiEndpoint.get("websearch.providers", "/api/websearch/provider", { + query: LocationQuery, + success: Location.response(Schema.Array(WebSearch.Provider)), + error: ServiceUnavailableError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.websearch.providers", + summary: "List web search providers", + description: "Return the registered web search providers.", + }), + ), + ) + .add( + HttpApiEndpoint.post("websearch.query", "/api/websearch", { + query: LocationQuery, + payload: Schema.Struct(WebSearch.Input.fields), + success: Location.response(WebSearch.Response), + error: [InvalidRequestError, ServiceUnavailableError], + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.websearch.query", + summary: "Search the web", + description: + "Run one web search through the selected provider. Specify a provider to override the configured default.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "websearch", + description: "Location-scoped web search routes.", + }), + ) diff --git a/packages/schema/src/event-manifest.ts b/packages/schema/src/event-manifest.ts index 386d750340b..a9c9aaa52e9 100644 --- a/packages/schema/src/event-manifest.ts +++ b/packages/schema/src/event-manifest.ts @@ -36,6 +36,7 @@ import { TuiEvent } from "./tui-event.js" import { VcsEvent } from "./vcs-event.js" import { WorkspaceEvent } from "./workspace-event.js" import { WorktreeEvent } from "./worktree-event.js" +import { WebSearch } from "./websearch.js" const sessionV1DurableDefinitions = SessionV1.Event.Definitions.filter( (definition) => definition.durability === "durable", @@ -67,6 +68,7 @@ const featureDefinitions = Event.inventory( ...Shell.Event.Definitions, ...Question.Event.Definitions, ...Form.Event.Definitions, + ...WebSearch.Event.Definitions, ) export const ServerDefinitions = Event.inventory( diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index 7b09e92426c..0cbb430add2 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -18,6 +18,7 @@ export { Project } from "./project.js" export { ProjectCopy } from "./project-copy.js" export { Provider } from "./provider.js" export { Reference } from "./reference.js" +export { WebSearch } from "./websearch.js" export { Session } from "./session.js" export { Vcs } from "./vcs.js" export { SessionPending } from "./session-pending.js" diff --git a/packages/schema/src/websearch.ts b/packages/schema/src/websearch.ts new file mode 100644 index 00000000000..832b888dbd1 --- /dev/null +++ b/packages/schema/src/websearch.ts @@ -0,0 +1,42 @@ +export * as WebSearch from "./websearch.js" + +import { Schema } from "effect" +import { ephemeral, inventory } from "./event.js" +import { optional } from "./schema.js" + +export const ID = Schema.String.pipe(Schema.brand("WebSearch.ID")) +export type ID = typeof ID.Type + +export interface Provider extends Schema.Schema.Type {} +export const Provider = Schema.Struct({ + id: ID, + name: Schema.String, +}).annotate({ identifier: "WebSearch.Provider" }) + +export interface Input extends Schema.Schema.Type {} +export const Input = Schema.Struct({ + query: Schema.String, + providerID: ID.pipe(optional), +}).annotate({ identifier: "WebSearch.Input" }) +export type ProviderInput = Pick + +export interface Result extends Schema.Schema.Type {} +export const Result = Schema.Struct({ + url: Schema.String, + title: Schema.String.pipe(optional), + content: Schema.String.pipe(optional), + time: Schema.Struct({ + published: Schema.Finite.pipe(optional), + }), +}).annotate({ identifier: "WebSearch.Result" }) + +export class Response extends Schema.Class("WebSearch.Response")({ + providerID: ID, + results: Schema.Array(Result), +}) {} + +const Updated = ephemeral({ + type: "websearch.updated", + schema: {}, +}) +export const Event = { Updated, Definitions: inventory(Updated) } diff --git a/packages/sdk-next/src/index.ts b/packages/sdk-next/src/index.ts index a9d91eeec3b..2693877bba5 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 { WebSearch } from "@opencode-ai/schema/websearch" export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema" export { Session } from "@opencode-ai/schema/session" export { SessionPending } from "@opencode-ai/schema/session-pending" diff --git a/packages/sdk-next/test/contract-identity.test.ts b/packages/sdk-next/test/contract-identity.test.ts index 4aad96f13ab..0f662af27b0 100644 --- a/packages/sdk-next/test/contract-identity.test.ts +++ b/packages/sdk-next/test/contract-identity.test.ts @@ -11,6 +11,7 @@ import { Location } from "@opencode-ai/schema/location" import { Model } from "@opencode-ai/schema/model" import { Project } from "@opencode-ai/schema/project" import { Provider } from "@opencode-ai/schema/provider" +import { WebSearch } from "@opencode-ai/schema/websearch" import { Session } from "@opencode-ai/schema/session" import { SessionPending } from "@opencode-ai/schema/session-pending" import { SessionMessage } from "@opencode-ai/schema/session-message" @@ -24,6 +25,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.WebSearch).toBe(WebSearch) expect(SDK.Session).toBe(Session) expect(Object.keys(SDK).sort()).toEqual([ "AbsolutePath", @@ -52,6 +54,7 @@ test("re-exports canonical contracts directly from Schema", () => { "SessionPending", "Skill", "Tool", + "WebSearch", ]) }) diff --git a/packages/sdk-next/test/embedded.test.ts b/packages/sdk-next/test/embedded.test.ts index 68b8ef8e6ce..bff122bcad7 100644 --- a/packages/sdk-next/test/embedded.test.ts +++ b/packages/sdk-next/test/embedded.test.ts @@ -328,6 +328,38 @@ it.live( 10_000, ) +it.live("embedded client exposes plugin-backed web search", () => + withEmbedded("opencode-embedded-websearch-", (fixture) => + Effect.gen(function* () { + const opencode = yield* fixture.sdk.OpenCode.create() + const providerID = fixture.sdk.WebSearch.ID.make("embedded-websearch") + yield* opencode.plugin({ + id: `embedded-websearch-${crypto.randomUUID()}`, + effect: (ctx) => + ctx.websearch.transform((draft) => { + draft.add({ + id: providerID, + name: "Embedded web search", + execute: (input) => + Effect.succeed([{ url: "https://example.com", content: `Found ${input.query}`, time: {} }]), + }) + }), + }) + + const result = yield* opencode.websearch.query({ + query: "opencode", + providerID, + location: location(fixture), + }) + + expect(result.data).toEqual({ + providerID, + results: [{ url: "https://example.com", content: "Found opencode", time: {} }], + }) + }), + ), +) + it.live( "Location-owned runner events reach the ready global client", () => diff --git a/packages/server/src/handlers.ts b/packages/server/src/handlers.ts index 4b0b4acaa22..a0126ea89e7 100644 --- a/packages/server/src/handlers.ts +++ b/packages/server/src/handlers.ts @@ -21,6 +21,7 @@ import { QuestionHandler } from "./handlers/question" import { ReferenceHandler } from "./handlers/reference" import { LocationHandler } from "./handlers/location" import { IntegrationHandler } from "./handlers/integration" +import { WebSearchHandler } from "./handlers/websearch" import { McpHandler } from "./handlers/mcp" import { CredentialHandler } from "./handlers/credential" import { ProjectHandler } from "./handlers/project" @@ -41,6 +42,7 @@ export const handlers = Layer.mergeAll( GenerateHandler, ProviderHandler, IntegrationHandler, + WebSearchHandler, McpHandler, CredentialHandler, ProjectHandler, diff --git a/packages/server/src/handlers/websearch.ts b/packages/server/src/handlers/websearch.ts new file mode 100644 index 00000000000..8baa385c704 --- /dev/null +++ b/packages/server/src/handlers/websearch.ts @@ -0,0 +1,68 @@ +import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" +import { WebSearch } from "@opencode-ai/core/websearch" +import { InvalidRequestError, ServiceUnavailableError } from "@opencode-ai/protocol/errors" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { Api } from "../api" +import { response } from "../location" + +export const WebSearchHandler = HttpApiBuilder.group(Api, "server.websearch", (handlers) => + Effect.gen(function* () { + const awaitPlugins = Effect.fn("server.websearch.awaitPlugins")(function* () { + const plugins = yield* PluginSupervisor.Service + yield* plugins.flush.pipe( + Effect.timeoutOrElse({ + duration: "5 seconds", + orElse: () => + Effect.fail( + new ServiceUnavailableError({ + message: "Web search provider initialization timed out", + service: "websearch", + }), + ), + }), + ) + }) + return handlers + .handle( + "websearch.providers", + Effect.fn("server.websearch.providers")(function* () { + yield* awaitPlugins() + const websearch = yield* WebSearch.Service + return yield* response(websearch.providers()) + }), + ) + .handle( + "websearch.query", + Effect.fn("server.websearch.query")(function* (request) { + yield* awaitPlugins() + const websearch = yield* WebSearch.Service + return yield* response( + websearch.query(request.payload).pipe( + Effect.catchTags({ + "WebSearch.ProviderRequired": () => + new InvalidRequestError({ + message: "Web search provider is required", + kind: "websearch_provider_required", + field: "providerID", + }), + "WebSearch.ProviderNotFound": (error) => + new InvalidRequestError({ + message: `Web search provider not found: ${error.providerID}`, + kind: "websearch_provider_not_found", + field: "providerID", + }), + "WebSearch.Disabled": () => + new InvalidRequestError({ message: "Web search is disabled", kind: "websearch_disabled" }), + "WebSearch.Request": (error) => + new ServiceUnavailableError({ + message: `Web 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 a2c6a531ab1..8a168b457d3 100644 --- a/packages/tui/src/component/dialog-integration.tsx +++ b/packages/tui/src/component/dialog-integration.tsx @@ -59,29 +59,43 @@ 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 { themeV2 } = useTheme().contextual("elevated") - const options = createMemo(() => - integrationOptions(data.location.integration.list() ?? []).map((integration) => { + const options = createMemo(() => { + const providers = data.location.websearch.list() ?? [] + const providersByID = new Map(providers.map((provider) => [provider.id, provider])) + const integrations = integrationOptions(data.location.integration.list() ?? []).filter( + (integration) => props.integrationID === undefined || integration.id === props.integrationID, + ) + return integrations.map((integration) => { const methods = connectMethods(integration) - const connected = integration.connections.length > 0 + const provider = providersByID.get(integration.id) + const credentials = credentialConnections(integration) + let category = "Services" + if (integration.id in INTEGRATION_PRIORITY) category = "Popular" + if (provider) category = "Web search" return { title: integration.name, value: integration.id, - description: methods.length ? undefined : "Environment only", + description: methods.length === 0 ? "Environment only" : undefined, 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), + category, + disabled: methods.length === 0 && credentials.length === 0, + gutter: + integration.connections.length > 0 + ? () => + : undefined, + onSelect: () => { + if (credentials.length) return manageConnections(integration, methods, dialog, props.onConnected) + return selectMethod(integration, methods, dialog, props.onConnected) + }, } - }), - ) + }) + }) return ( @@ -875,6 +877,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ result.location.provider.sync(event.location), ]) break + case "config.updated": + case "websearch.updated": + void result.location.websearch.refresh(event.location) + break // Authenticating an MCP integration reconnects its server, which emits mcp.status.changed, // so the mcp list syncs here rather than off integration.updated. case "mcp.status.changed": @@ -1251,6 +1257,20 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ sync.invalidate(`location.reference:${locationKey(ref ?? defaultLocation())}`) }, }, + websearch: { + list(location?: LocationRef) { + return store.location[locationKey(location ?? defaultLocation())]?.websearch + }, + async refresh(ref?: LocationRef) { + const input = { location: locationQuery(ref ?? defaultLocation()) } + const providers = await client.api.websearch.providers(input) + const key = locationKey(providers.location) + setStore("location", key, { + ...store.location[key], + websearch: providers.data, + }) + }, + }, skill: { list(location?: LocationRef) { return store.location[locationKey(location ?? defaultLocation())]?.skill diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 3b4e5569f87..60a55785429 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -213,6 +213,8 @@ test("refreshes resources into reactive getters", async () => { location, data: [{ id: "build", request: { headers: {}, body: {} }, mode: "primary", hidden: false, permissions: [] }], }) + if (url.pathname === "/api/websearch/provider") + return json({ location, data: [{ id: "standalone", name: "Standalone" }] }) return undefined }, events) let data!: ReturnType @@ -248,6 +250,7 @@ test("refreshes resources into reactive getters", async () => { await data.session.sync("ses_test") await data.session.message.sync("ses_test") await data.location.agent.sync() + await data.location.websearch.refresh() expect(data.session.get("ses_test")?.title).toBe("Test session") expect(data.session.message.list("ses_test").map((message) => message.id)).toEqual(["msg_first", "msg_second"]) @@ -256,6 +259,7 @@ test("refreshes resources into reactive getters", async () => { expect(app.captureCharFrame()).toContain("msg_second") expect(data.location.default()).toEqual({ directory, workspaceID: undefined }) expect(data.location.agent.list(location)?.map((agent) => agent.id)).toEqual(["build"]) + expect(data.location.websearch.list(location)).toEqual([{ id: "standalone", name: "Standalone" }]) } finally { app.renderer.destroy() } diff --git a/packages/tui/test/fixture/tui-client.ts b/packages/tui/test/fixture/tui-client.ts index e60f79e964f..5cf10b8ca6f 100644 --- a/packages/tui/test/fixture/tui-client.ts +++ b/packages/tui/test/fixture/tui-client.ts @@ -125,6 +125,9 @@ export function createFetch(override?: FetchHandler, events?: ReturnType