Compare commits

...
Author SHA1 Message Date
Shoubhit Dash dd01e4cd77 refactor: simplify websearch flow 2026-07-21 19:01:15 +05:30
Shoubhit Dash c0dc81ebab Merge remote-tracking branch 'origin/v2' into search-integration
# Conflicts:
#	packages/client/test/promise.test.ts
#	packages/core/src/location-services.ts
#	packages/core/src/plugin/host.ts
#	packages/core/src/plugin/internal.ts
#	packages/core/src/plugin/supervisor.ts
#	packages/core/src/tool/websearch.ts
#	packages/core/test/config/config.test.ts
#	packages/core/test/plugin/host.ts
#	packages/core/test/tool-websearch.test.ts
#	packages/plugin/src/v2/effect/integration.ts
#	packages/plugin/src/v2/promise/integration.ts
#	packages/sdk/js/src/v2/gen/types.gen.ts
#	packages/tui/src/component/dialog-integration.tsx
#	packages/tui/src/context/data.tsx
#	packages/tui/src/routes/session/index.tsx
#	packages/tui/test/cli/tui/data.test.tsx
2026-07-21 18:08:30 +05:30
Shoubhit Dash 34258be5d1 refactor(websearch): simplify provider flow 2026-07-14 18:46:16 +05:30
Shoubhit Dash fbe6e5c0d9 Merge remote-tracking branch 'origin/v2' into search-integration
# Conflicts:
#	packages/client/src/promise/api.ts
#	packages/client/src/promise/generated/types.ts
#	packages/core/src/form.ts
#	packages/core/src/plugin/promise.ts
#	packages/core/test/plugin/promise.test.ts
#	packages/plugin/test/contract-identity.test.ts
#	packages/sdk-next/test/contract-identity.test.ts
#	packages/tui/src/component/dialog-integration.tsx
#	packages/tui/src/routes/session/form.tsx
#	packages/tui/test/fixture/tui-client.ts
2026-07-14 18:40:07 +05:30
Shoubhit Dash 86ad303695 refactor(websearch): update provider consumers 2026-07-09 20:05:54 +05:30
Shoubhit Dash 820b1a1a46 refactor(websearch): decouple providers from integrations 2026-07-09 20:04:20 +05:30
Shoubhit Dash c3da28f76a Merge remote-tracking branch 'origin/v2' into search-integration 2026-07-09 19:04:04 +05:30
Shoubhit Dash c86f4e9b9b refactor(websearch): rename search domain 2026-07-08 17:27:31 +05:30
Shoubhit Dash 97aa6713b5 refactor(search): isolate provider schemas 2026-07-08 17:17:26 +05:30
Shoubhit Dash 228db3dd9a refactor(core): inline form info construction 2026-07-08 17:13:48 +05:30
Shoubhit Dash d9742d5ede test(tui): update bootstrap fixtures 2026-07-08 16:24:33 +05:30
Shoubhit Dash a6acc2397d Merge remote-tracking branch 'origin/v2' into search-integration
# Conflicts:
#	packages/client/src/promise/generated/client.ts
#	packages/client/src/promise/generated/types.ts
#	packages/client/test/promise.test.ts
#	packages/core/src/plugin/host.ts
#	packages/core/src/plugin/internal.ts
#	packages/core/src/plugin/promise.ts
#	packages/core/test/plugin/host.ts
#	packages/plugin/src/v2/effect/index.ts
#	packages/plugin/src/v2/effect/integration.ts
#	packages/plugin/src/v2/promise/index.ts
#	packages/plugin/src/v2/promise/integration.ts
#	packages/protocol/src/client.ts
#	packages/schema/src/index.ts
#	packages/sdk/js/src/v2/gen/sdk.gen.ts
2026-07-08 15:09:39 +05:30
Shoubhit Dash 61676a1d3c refactor(search): simplify provider flow 2026-07-08 14:45:30 +05:30
Shoubhit Dash ccad83549d refactor(search): persist provider in global config 2026-07-07 19:27:50 +05:30
Shoubhit Dash 48f8b5de98 feat(plugin): add integration registration 2026-07-07 18:46:51 +05:30
Shoubhit Dash 1f842fa654 fix(tui): stabilize websearch labels 2026-07-07 17:57:17 +05:30
Shoubhit Dash 7b8d8b8861 Merge remote-tracking branch 'origin/v2' into search-integration
# Conflicts:
#	packages/client/test/promise.test.ts
#	packages/core/schema.json
#	packages/core/src/database/migration.gen.ts
#	packages/core/src/tool/websearch.ts
#	packages/sdk-next/src/index.ts
#	packages/sdk/js/src/v2/gen/types.gen.ts
2026-07-07 17:38:46 +05:30
Shoubhit Dash 66220071b5 feat(sdk): expose web search 2026-07-07 17:33:55 +05:30
Shoubhit Dash 56f44dbcaa refactor: simplify search integration flow 2026-07-06 19:46:39 +05:30
Shoubhit Dash 129012c3ee feat(core): add integration-backed search 2026-07-06 19:33:13 +05:30
58 changed files with 2451 additions and 584 deletions
+3 -1
View File
@@ -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<E = never> = WebsearchApi<E>
export interface CatalogApi<E = never> {
readonly provider: ProviderApi<E>
readonly model: ModelApi<E>
+43
View File
@@ -1042,6 +1042,48 @@ export interface DebugApi<E = never> {
readonly location: { readonly list: DebugLocationListOperation<E>; readonly evict: DebugLocationEvictOperation<E> }
}
type Endpoint27_0Request = Parameters<RawClient["server.websearch"]["websearch.provider.list"]>[0]
export type Endpoint27_0Input = { readonly location?: Endpoint27_0Request["query"]["location"] }
export type Endpoint27_0Output = EffectValue<ReturnType<RawClient["server.websearch"]["websearch.provider.list"]>>
export type WebsearchProviderListOperation<E = never> = (
input?: Endpoint27_0Input,
) => Effect.Effect<Endpoint27_0Output, E>
type Endpoint27_1Request = Parameters<RawClient["server.websearch"]["websearch.provider.selected"]>[0]
export type Endpoint27_1Input = { readonly location?: Endpoint27_1Request["query"]["location"] }
export type Endpoint27_1Output = EffectValue<ReturnType<RawClient["server.websearch"]["websearch.provider.selected"]>>
export type WebsearchProviderSelectedOperation<E = never> = (
input?: Endpoint27_1Input,
) => Effect.Effect<Endpoint27_1Output, E>
type Endpoint27_2Request = Parameters<RawClient["server.websearch"]["websearch.provider.select"]>[0]
export type Endpoint27_2Input = {
readonly location?: Endpoint27_2Request["query"]["location"]
readonly providerID: Endpoint27_2Request["payload"]["providerID"]
}
export type Endpoint27_2Output = EffectValue<ReturnType<RawClient["server.websearch"]["websearch.provider.select"]>>
export type WebsearchProviderSelectOperation<E = never> = (
input: Endpoint27_2Input,
) => Effect.Effect<Endpoint27_2Output, E>
type Endpoint27_3Request = Parameters<RawClient["server.websearch"]["websearch.query"]>[0]
export type Endpoint27_3Input = {
readonly location?: Endpoint27_3Request["query"]["location"]
readonly query: Endpoint27_3Request["payload"]["query"]
readonly providerID?: Endpoint27_3Request["payload"]["providerID"]
}
export type Endpoint27_3Output = EffectValue<ReturnType<RawClient["server.websearch"]["websearch.query"]>>
export type WebsearchQueryOperation<E = never> = (input: Endpoint27_3Input) => Effect.Effect<Endpoint27_3Output, E>
export interface WebsearchApi<E = never> {
readonly provider: {
readonly list: WebsearchProviderListOperation<E>
readonly selected: WebsearchProviderSelectedOperation<E>
readonly select: WebsearchProviderSelectOperation<E>
}
readonly query: WebsearchQueryOperation<E>
}
export interface AppApi<E = never> {
readonly health: HealthApi<E>
readonly server: ServerApi<E>
@@ -1070,4 +1112,5 @@ export interface AppApi<E = never> {
readonly projectCopy: ProjectCopyApi<E>
readonly vcs: VcsApi<E>
readonly debug: DebugApi<E>
readonly websearch: WebsearchApi<E>
}
@@ -1244,6 +1244,44 @@ const adaptGroup26 = (raw: RawClient["server.debug"]) => ({
location: { list: Endpoint26_0(raw), evict: Endpoint26_1(raw) },
})
type Endpoint27_0Request = Parameters<RawClient["server.websearch"]["websearch.provider.list"]>[0]
type Endpoint27_0Input = { readonly location?: Endpoint27_0Request["query"]["location"] }
const Endpoint27_0 = (raw: RawClient["server.websearch"]) => (input?: Endpoint27_0Input) =>
raw["websearch.provider.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint27_1Request = Parameters<RawClient["server.websearch"]["websearch.provider.selected"]>[0]
type Endpoint27_1Input = { readonly location?: Endpoint27_1Request["query"]["location"] }
const Endpoint27_1 = (raw: RawClient["server.websearch"]) => (input?: Endpoint27_1Input) =>
raw["websearch.provider.selected"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint27_2Request = Parameters<RawClient["server.websearch"]["websearch.provider.select"]>[0]
type Endpoint27_2Input = {
readonly location?: Endpoint27_2Request["query"]["location"]
readonly providerID: Endpoint27_2Request["payload"]["providerID"]
}
const Endpoint27_2 = (raw: RawClient["server.websearch"]) => (input: Endpoint27_2Input) =>
raw["websearch.provider.select"]({
query: { location: input["location"] },
payload: { providerID: input["providerID"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint27_3Request = Parameters<RawClient["server.websearch"]["websearch.query"]>[0]
type Endpoint27_3Input = {
readonly location?: Endpoint27_3Request["query"]["location"]
readonly query: Endpoint27_3Request["payload"]["query"]
readonly providerID?: Endpoint27_3Request["payload"]["providerID"]
}
const Endpoint27_3 = (raw: RawClient["server.websearch"]) => (input: Endpoint27_3Input) =>
raw["websearch.query"]({
query: { location: input["location"] },
payload: { query: input["query"], providerID: input["providerID"] },
}).pipe(Effect.mapError(mapClientError))
const adaptGroup27 = (raw: RawClient["server.websearch"]) => ({
provider: { list: Endpoint27_0(raw), selected: Endpoint27_1(raw), select: Endpoint27_2(raw) },
query: Endpoint27_3(raw),
})
const adaptClient = (raw: RawClient) => ({
health: adaptGroup0(raw["server.health"]),
server: adaptGroup1(raw["server.server"]),
@@ -1272,6 +1310,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 }) =>
+2
View File
@@ -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"
+1
View File
@@ -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"]
@@ -207,6 +207,14 @@ import type {
DebugLocationListOutput,
DebugLocationEvictInput,
DebugLocationEvictOutput,
WebsearchProviderListInput,
WebsearchProviderListOutput,
WebsearchProviderSelectedInput,
WebsearchProviderSelectedOutput,
WebsearchProviderSelectInput,
WebsearchProviderSelectOutput,
WebsearchQueryInput,
WebsearchQueryOutput,
} from "./types"
import { ClientError } from "./client-error"
@@ -1735,6 +1743,60 @@ export function make(options: ClientOptions) {
),
},
},
websearch: {
provider: {
list: (input?: WebsearchProviderListInput, requestOptions?: RequestOptions) =>
request<WebsearchProviderListOutput>(
{
method: "GET",
path: `/api/websearch/provider`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [503, 401, 400],
empty: false,
},
requestOptions,
),
selected: (input?: WebsearchProviderSelectedInput, requestOptions?: RequestOptions) =>
request<WebsearchProviderSelectedOutput>(
{
method: "GET",
path: `/api/websearch/provider/selected`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
select: (input: WebsearchProviderSelectInput, requestOptions?: RequestOptions) =>
request<WebsearchProviderSelectOutput>(
{
method: "POST",
path: `/api/websearch/provider/selected`,
query: { location: input["location"] },
body: { providerID: input["providerID"] },
successStatus: 204,
declaredStatuses: [400, 503, 401],
empty: true,
},
requestOptions,
),
},
query: (input: WebsearchQueryInput, requestOptions?: RequestOptions) =>
request<WebsearchQueryOutput>(
{
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,
),
},
}
}
@@ -531,6 +531,8 @@ export type VcsFileStatus = {
status: "added" | "deleted" | "modified"
}
export type WebSearchProvider = { id: string; name: string }
export type SessionMessageModelSelected = {
id: string
metadata?: { [x: string]: JsonValue }
@@ -1051,6 +1053,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
@@ -2353,6 +2364,7 @@ export type V2Event =
| FormCreated
| FormReplied
| FormCancelled
| WebsearchUpdated
| SessionStatus2
| SessionIdle
| TuiPromptAppend
@@ -4956,3 +4968,47 @@ export type DebugLocationEvictInput = {
}
export type DebugLocationEvictOutput = void
export type WebsearchProviderListInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type WebsearchProviderListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: Array<WebSearchProvider>
}
export type WebsearchProviderSelectedInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type WebsearchProviderSelectedOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: string | null
}
export type WebsearchProviderSelectInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly providerID: { readonly providerID: string }["providerID"]
}
export type WebsearchProviderSelectOutput = void
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; text: string; metadata?: JsonValue }
}
+1
View File
@@ -9,6 +9,7 @@ export type {
PluginApi,
ProviderApi,
ReferenceApi,
WebSearchApi,
SessionApi,
SkillApi,
} from "./api.js"
+61
View File
@@ -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,8 @@ 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(["provider", "query"])
expect(Object.keys(client.websearch.provider)).toEqual(["list", "selected", "select"])
expect(Object.keys(client.file)).toEqual(["read", "list", "find"])
expect(Object.keys(client.vcs)).toEqual(["status", "diff"])
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"])
@@ -48,6 +51,64 @@ 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", text: "result", metadata: { requestID: "req_test" } },
})
},
})
const result = await client.websearch.query({
query: "opencode",
providerID: "exa",
location: { directory: "/tmp/project" },
})
expect(result.data).toEqual({ providerID: "exa", text: "result", metadata: { requestID: "req_test" } })
expect(request?.method).toBe("POST")
expect(request?.url).toBe("http://localhost:3000/api/websearch?location%5Bdirectory%5D=%2Ftmp%2Fproject")
expect(await request?.json()).toEqual({ query: "opencode", providerID: "exa" })
})
test("websearch provider methods use the public HTTP contract", async () => {
const requests: Request[] = []
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
requests.push(request)
if (request.method === "POST") return new Response(null, { status: 204 })
return Response.json({
location: { directory: "/tmp/project", project: { id: "proj_test", directory: "/tmp/project" } },
data: request.url.endsWith("/selected?location%5Bdirectory%5D=%2Ftmp%2Fproject")
? "exa"
: [{ id: "exa", name: "Exa" }],
})
},
})
expect(await client.websearch.provider.list({ location: { directory: "/tmp/project" } })).toMatchObject({
data: [{ id: "exa", name: "Exa" }],
})
expect(await client.websearch.provider.selected({ location: { directory: "/tmp/project" } })).toMatchObject({
data: "exa",
})
await client.websearch.provider.select({ providerID: "parallel", location: { directory: "/tmp/project" } })
expect(requests.map((request) => [request.method, request.url])).toEqual([
["GET", "http://localhost:3000/api/websearch/provider?location%5Bdirectory%5D=%2Ftmp%2Fproject"],
["GET", "http://localhost:3000/api/websearch/provider/selected?location%5Bdirectory%5D=%2Ftmp%2Fproject"],
["POST", "http://localhost:3000/api/websearch/provider/selected?location%5Bdirectory%5D=%2Ftmp%2Fproject"],
])
expect(await requests[2]?.json()).toEqual({ providerID: "parallel" })
})
test("server.get uses the public HTTP contract", async () => {
let request: Request | undefined
const client = OpenCode.make({
+5
View File
@@ -1,4 +1,5 @@
export * as Config from "./config"
export * as ConfigGlobal from "./config/global"
import { makeLocationNode } from "./effect/app-node"
import path from "path"
@@ -27,6 +28,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"
@@ -107,6 +109,9 @@ export class Info extends Schema.Class<Info>("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",
}),
+50
View File
@@ -0,0 +1,50 @@
export * as ConfigGlobal from "./global"
import { randomUUID } from "node:crypto"
import path from "node:path"
import { Context, Effect, Layer } from "effect"
import { applyEdits, modify, type JSONPath } from "jsonc-parser"
import { makeGlobalNode } from "../effect/app-node"
import { FSUtil } from "../fs-util"
import { Global } from "../global"
import { EffectFlock } from "../util/effect-flock"
export interface Interface {
readonly update: (jsonPath: JSONPath, value: unknown) => Effect.Effect<void, FSUtil.Error | EffectFlock.LockError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ConfigGlobal") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const flock = yield* EffectFlock.Service
return Service.of({
update: Effect.fn("ConfigGlobal.update")(function* (jsonPath, value) {
yield* flock.withLock(
Effect.gen(function* () {
const existingFiles = yield* Effect.filter(
["opencode.jsonc", "opencode.json"].map((name) => path.join(global.config, name)),
fs.existsSafe,
)
const filepath = existingFiles[0] ?? path.join(global.config, "opencode.json")
const text = (yield* fs.readFileStringSafe(filepath)) ?? "{}"
const next = applyEdits(
text,
modify(text, jsonPath, value, { formattingOptions: { tabSize: 2, insertSpaces: true } }),
)
const temp = `${filepath}.${randomUUID()}.tmp`
yield* fs.writeWithDirs(temp, next)
yield* fs.rename(temp, filepath).pipe(Effect.ensuring(fs.remove(temp).pipe(Effect.ignore)))
}),
"global-config",
)
}),
})
}),
)
export const node = makeGlobalNode({ service: Service, layer, deps: [EffectFlock.node, FSUtil.node, Global.node] })
+8
View File
@@ -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<Info>("ConfigWebSearch.Info")({
provider: WebSearch.ID,
}) {}
+2
View File
@@ -27,6 +27,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"
@@ -54,6 +55,7 @@ const locationServiceNodes = [
AgentV2.node,
CommandV2.node,
Reference.node,
WebSearch.node,
Integration.node,
Catalog.node,
AISDK.node,
+2
View File
@@ -13,6 +13,7 @@ import { Integration } from "./integration"
import { Location } from "./location"
import { PluginHost } from "./plugin/host"
import { PluginRuntime } from "./plugin/runtime"
import { WebSearch } from "./websearch"
import { Reference } from "./reference"
import { SkillV2 } from "./skill"
import { State } from "./state"
@@ -156,5 +157,6 @@ export const node = makeLocationNode({
ToolHooks.node,
PluginHooks.node,
PluginRuntime.node,
WebSearch.node,
],
})
+88 -76
View File
@@ -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 { IntegrationDefinition, 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 { Effect, Schema, Stream } from "effect"
import { AgentV2 } from "../agent"
@@ -22,6 +24,7 @@ import { Tool } from "../tool/tool"
import { Tools } from "../tool/tools"
import { ToolHooks } from "../tool/hooks"
import { WorkspaceV2 } from "../workspace"
import { WebSearch } from "../websearch"
import { PluginHooks } from "./hooks"
const mutable = <T>(value: T) => value as DeepMutable<T>
@@ -36,6 +39,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
const reference = yield* Reference.Service
const skill = yield* SkillV2.Service
const tools = yield* Tools.Service
const websearch = yield* WebSearch.Service
const toolHooks = yield* ToolHooks.Service
const hooks = yield* PluginHooks.Service
const runtime = yield* PluginRuntime.Service
@@ -124,8 +128,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
),
},
model: {
get: (providerID, modelID) =>
catalog.model.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)),
get: (providerID, modelID) => catalog.model.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)),
list: () => response(catalog.model.available()),
default: () => response(catalog.model.default()),
},
@@ -236,6 +239,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
connection.type === "credential" ? { ...connection, id: Credential.ID.make(connection.id) } : connection,
),
},
register: (definition) => integration.transform((draft) => registerIntegration(draft, definition)),
transform: (callback) =>
integration.transform((draft) => {
callback({
@@ -245,79 +249,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)),
},
@@ -415,6 +347,14 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
})
},
},
websearch: {
register: (definition) =>
websearch.register({
id: WebSearch.ID.make(definition.id),
name: definition.name,
execute: definition.execute,
}),
},
session: {
hook: (name, callback) => hooks.register("session", name, callback),
create: (input) =>
@@ -434,3 +374,75 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
},
} satisfies Plugin.Context
})
function registerIntegration(draft: Integration.Draft, definition: IntegrationDefinition) {
const integrationID = Integration.ID.make(definition.id)
draft.update(integrationID, (integration) => (integration.name = definition.name))
for (const method of definition.methods ?? []) {
if (method.type === "env") {
draft.method.update(methodImplementation({ integrationID: definition.id, method }))
continue
}
if (method.type === "key") {
draft.method.update(methodImplementation({ integrationID: definition.id, method }))
continue
}
const { authorize, refresh, credentialLabel, ...info } = method
draft.method.update(
methodImplementation({
integrationID: definition.id,
method: info,
authorize,
...(refresh ? { refresh } : {}),
...(credentialLabel ? { label: credentialLabel } : {}),
}),
)
}
}
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) })
}
+5 -2
View File
@@ -27,6 +27,7 @@ import { ModelsDev } from "../models-dev"
import { Npm } from "../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 +51,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"
@@ -78,12 +80,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),
@@ -107,12 +109,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),
)
})
@@ -131,6 +133,7 @@ const pre = [
...SystemPromptPlugin.Plugins,
ModelsDevPlugin,
...ProviderPlugins,
...WebSearchPlugins,
PatchTool.Plugin,
EditTool.Plugin,
GlobTool.Plugin,
+51
View File
@@ -1,5 +1,6 @@
export * as PluginPromise from "./promise"
import type { IntegrationDefinition } from "@opencode-ai/plugin/v2/integration"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import type { Context, Plugin } from "@opencode-ai/plugin/v2/plugin"
import type { AnyTool } from "@opencode-ai/plugin/v2/tool"
@@ -163,6 +164,7 @@ export function fromPromise(plugin: Plugin) {
}),
),
},
register: (definition) => register(host.integration.register(adaptIntegration(definition))),
transform: transform(host.integration),
reload: () => run(host.integration.reload()),
connection: {
@@ -195,6 +197,17 @@ export function fromPromise(plugin: Plugin) {
hook: (name, callback) =>
register(host.tool.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
},
websearch: {
register: (definition) =>
register(
host.websearch.register({
id: definition.id,
name: definition.name,
execute: (input, execution) =>
attempt((signal) => definition.execute(input, { ...execution, signal })),
}),
),
},
session: {
hook: (name, callback) =>
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
@@ -268,6 +281,44 @@ export function fromPromise(plugin: Plugin) {
})
}
function adaptIntegration(definition: IntegrationDefinition) {
const { methods, ...definitionInfo } = definition
return {
...definitionInfo,
methods: methods?.map((method) => {
if (method.type !== "oauth") return method
const { authorize, refresh, ...methodInfo } = method
return {
...methodInfo,
authorize: (inputs: Parameters<typeof authorize>[0]) =>
attempt(() => authorize(inputs)).pipe(
Effect.map((authorization) => {
if (authorization.mode === "auto") {
return {
...authorization,
callback: attempt(() => authorization.callback),
}
}
return {
...authorization,
callback: (code: string) => attempt(() => authorization.callback(code)),
}
}),
),
...(refresh
? {
refresh: (credential: Parameters<typeof refresh>[0]) => attempt(() => refresh(credential)),
}
: {}),
}
}),
}
}
function attempt<A>(evaluate: (signal: AbortSignal) => PromiseLike<A>) {
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),
+2 -2
View File
@@ -34,7 +34,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"
@@ -303,7 +303,7 @@ export const node = makeLocationNode({
Shell.node,
SkillV2.node,
ToolRegistry.toolsNode,
WebSearchTool.configNode,
WebSearch.node,
WellKnown.node,
],
})
+61
View File
@@ -0,0 +1,61 @@
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<HttpClient.HttpClient | Scope.Scope>({
id: "opencode.websearch.exa",
effect: Effect.fn("WebSearchExa.Plugin")(function* (ctx) {
const http = yield* HttpClient.HttpClient
yield* ctx.integration.register({
id: "exa",
name: "Exa",
methods: [
{ type: "key", label: "API key (optional)" },
{ type: "env", names: ["EXA_API_KEY"] },
],
})
yield* ctx.websearch.register({
id: "exa",
name: "Exa",
execute: (input) =>
Effect.gen(function* () {
const connection = yield* ctx.integration.connection.active("exa")
const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined
const url = new URL(endpoint)
if (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 {
text: content?.text ?? "",
...(content?._meta ? { metadata: content._meta } : {}),
}
}),
})
}),
})
@@ -0,0 +1,4 @@
import { WebSearchExa } from "./exa"
import { WebSearchParallel } from "./parallel"
export const WebSearchPlugins = [WebSearchExa.Plugin, WebSearchParallel.Plugin] as const
+68
View File
@@ -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 = <F extends Schema.Struct.Fields>(body: string, result: Schema.Struct<F>) => {
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 = <F extends Schema.Struct.Fields, R extends Schema.Struct.Fields>(
http: HttpClient.HttpClient,
url: string,
tool: string,
schema: { readonly input: Schema.Struct<F>; readonly output: Schema.Struct<R> },
value: Schema.Struct.Type<F>,
headers: Record<string, string> = {},
) =>
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`)),
}),
)
})
@@ -0,0 +1,94 @@
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 { InstallationVersion } from "../../installation/version"
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),
session_id: Schema.String.check(Schema.isMaxLength(100)).pipe(Schema.optional),
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<HttpClient.HttpClient | Scope.Scope>({
id: "opencode.websearch.parallel",
effect: Effect.fn("WebSearchParallel.Plugin")(function* (ctx) {
const http = yield* HttpClient.HttpClient
yield* ctx.integration.register({
id: "parallel",
name: "Parallel",
methods: [
{ type: "key", label: "API key (optional)" },
{ type: "env", names: ["PARALLEL_API_KEY"] },
],
})
yield* ctx.websearch.register({
id: "parallel",
name: "Parallel",
execute: (input, context) =>
Effect.gen(function* () {
const connection = yield* ctx.integration.connection.active("parallel")
const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined
const result = yield* WebSearchMcp.call(
http,
endpoint,
"web_search",
{ input: McpInput, output: McpOutput },
{
objective: input.query,
search_queries: [input.query],
...(context.sessionID ? { session_id: context.sessionID } : {}),
},
{
"User-Agent": `opencode/${InstallationVersion}`,
...(credential?.type === "key" ? { Authorization: `Bearer ${credential.key}` } : {}),
},
)
const content = result?.content.find((item) => item.text)
return {
text: content?.text ?? "",
...(result ? { metadata: result.structuredContent } : {}),
}
}),
})
}),
})
+14 -208
View File
@@ -2,201 +2,33 @@ 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 "../effect/app-node"
import { InstallationVersion } from "../installation/version"
import { PositiveInt } from "../schema"
import { Effect, Schema } from "effect"
import { PermissionV2 } from "../permission"
import { WebSearch } from "../websearch"
import { Tool } from "./tool"
import { collectBoundedResponseBody } from "./http-body"
import { checksum } from "../util/encode"
export const name = "websearch"
export const NO_RESULTS = "No search results found. Please try a different query."
export const EXA_URL = "https://mcp.exa.ai/mcp"
export const PARALLEL_URL = "https://search.parallel.ai/mcp"
export const 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<ConfigService, Config>()("@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<Config, "enableExa" | "enableParallel"> = { 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 = <F extends Schema.Struct.Fields>(args: Schema.Struct<F>) =>
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 = <F extends Schema.Struct.Fields>(
http: HttpClient.HttpClient,
url: string,
tool: string,
args: Schema.Struct<F>,
value: Schema.Struct.Type<F>,
headers: Record<string, string> = {},
) =>
Effect.gen(function* () {
const request = yield* HttpClientRequest.post(url).pipe(
HttpClientRequest.accept("application/json, text/event-stream"),
HttpClientRequest.setHeaders(headers),
HttpClientRequest.schemaBodyJson(McpRequest(args))({
jsonrpc: "2.0" as const,
id: 1 as const,
method: "tools/call" as const,
params: { name: tool, arguments: value },
}),
)
return yield* Effect.gen(function* () {
const response = yield* HttpClient.filterStatusOk(http).execute(request)
const body = yield* collectBoundedResponseBody(
response,
MAX_RESPONSE_BYTES,
() => new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`),
)
return yield* parseResponse(body.toString("utf8"))
}).pipe(
Effect.timeoutOrElse({
duration: Duration.seconds(25),
orElse: () => Effect.fail(new Error(`${tool} request timed out`)),
}),
)
})
const Output = Schema.Struct({
provider: Provider,
provider: WebSearch.ID,
text: Schema.String,
metadata: Schema.optional(Schema.Json),
})
export const Plugin = {
id: "opencode.tool.websearch",
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) {
const http = yield* HttpClient.HttpClient
const config = yield* ConfigService
const permission = yield* PermissionV2.Service
const websearch = yield* WebSearch.Service
yield* ctx.tool
.transform((draft) =>
@@ -207,54 +39,28 @@ export const Plugin = {
input: Input,
output: Output,
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
execute: (input, context) => {
const provider = selectProvider(context.sessionID, config, config.provider)
return Effect.gen(function* () {
execute: (input, context) =>
Effect.gen(function* () {
yield* permission.assert({
action: name,
resources: [input.query],
save: ["*"],
metadata: { ...input, provider },
metadata: input,
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.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,
})
: yield* callMcp(
http,
PARALLEL_URL,
"web_search",
ParallelArgs,
{
objective: input.query,
search_queries: [input.query],
session_id: context.sessionID,
// V2 invocation context does not safely expose the model yet.
},
{
"User-Agent": `opencode/${InstallationVersion}`,
...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}),
},
)
const result = yield* websearch.query({ ...input, sessionID: context.sessionID })
return {
provider,
text: text ?? NO_RESULTS,
provider: result.providerID,
text: result.text || NO_RESULTS,
metadata: result.metadata,
}
}).pipe(
Effect.mapError(
(error) => new ToolFailure({ message: `Unable to search the web for ${input.query}`, error }),
),
)
},
),
}),
{ codemode: false },
),
+240
View File
@@ -0,0 +1,240 @@
export * as WebSearch from "./websearch"
import { WebSearch } from "@opencode-ai/schema/websearch"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { Context, Effect, Layer, Schema, Scope, Semaphore, Stream } from "effect"
import path from "node:path"
import { Config } from "./config"
import { ConfigGlobal } from "./config/global"
import { ConfigWebSearch } from "./config/websearch"
import { makeLocationNode } from "./effect/app-node"
import { EventV2 } from "./event"
import { Form } from "./form"
import { Global } from "./global"
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 ProviderOutput = WebSearch.ProviderOutput
export type ProviderOutput = WebSearch.ProviderOutput
export const Result = WebSearch.Result
export type Result = WebSearch.Result
export interface ProviderImplementation extends Provider {
readonly execute: (
input: ProviderInput,
context: { readonly sessionID?: string },
) => Effect.Effect<ProviderOutput, unknown>
}
export class ProviderRequiredError extends Schema.TaggedErrorClass<ProviderRequiredError>()(
"WebSearch.ProviderRequired",
{},
) {}
export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()(
"WebSearch.ProviderNotFound",
{
providerID: ID,
},
) {}
export class CancelledError extends Schema.TaggedErrorClass<CancelledError>()("WebSearch.Cancelled", {}) {}
export class RequestError extends Schema.TaggedErrorClass<RequestError>()("WebSearch.Request", {
providerID: ID,
cause: Schema.Defect(),
}) {}
export type Error = ProviderRequiredError | ProviderNotFoundError | CancelledError | RequestError
export interface QueryInput extends Input {
readonly sessionID?: string
}
export interface Interface {
readonly register: (provider: ProviderImplementation) => Effect.Effect<State.Registration, never, Scope.Scope>
readonly list: () => Effect.Effect<readonly Provider[]>
readonly selected: () => Effect.Effect<ID | undefined>
readonly select: (providerID: ID) => Effect.Effect<void, ProviderNotFoundError>
readonly query: (input: QueryInput) => Effect.Effect<Result, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/WebSearch") {}
type Data = {
readonly providers: Map<ID, ProviderImplementation>
}
type Draft = {
register: (provider: ProviderImplementation) => void
}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const configGlobal = yield* ConfigGlobal.Service
const events = yield* EventV2.Service
const forms = yield* Form.Service
const global = yield* Global.Service
const onboarding = Semaphore.makeUnsafe(1)
const decodeOutput = Schema.decodeUnknownEffect(ProviderOutput)
const globalConfigPath = path.resolve(global.config)
let pendingProviderID: ID | undefined
const state = State.create<Data, Draft>({
initial: () => ({ providers: new Map() }),
draft: (draft) => ({
register: (provider) => draft.providers.set(provider.id, provider),
}),
finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid),
})
const requireProvider = (providers: Map<ID, ProviderImplementation>, providerID: ID) => {
const provider = providers.get(providerID)
return provider ? Effect.succeed(provider) : Effect.fail(new ProviderNotFoundError({ providerID }))
}
const globalProviderID = Effect.fn("WebSearch.globalProviderID")(function* () {
return Config.latest(
(yield* config.entries()).filter(
(entry) => entry.type === "document" && entry.path && path.dirname(entry.path) === globalConfigPath,
),
"websearch",
)?.provider
})
const selected = Effect.fn("WebSearch.selected")(function* () {
return pendingProviderID ?? (yield* globalProviderID())
})
const saveProvider = Effect.fn("WebSearch.saveProvider")(function* (providerID: ID) {
pendingProviderID = providerID
yield* configGlobal.update(["websearch"], new ConfigWebSearch.Info({ provider: providerID })).pipe(
Effect.tapError(() => Effect.sync(() => (pendingProviderID = undefined))),
Effect.orDie,
)
})
yield* events.subscribe(ConfigSchema.Event.Updated).pipe(
Stream.runForEach(() =>
globalProviderID().pipe(
Effect.tap((providerID) =>
Effect.sync(() => {
if (providerID === pendingProviderID) pendingProviderID = undefined
}),
),
Effect.ignore,
),
),
Effect.forkScoped,
)
const ask = Effect.fn("WebSearch.ask")(function* (providers: Map<ID, ProviderImplementation>, sessionID: string) {
if (providers.size === 0) return yield* new ProviderRequiredError()
const response = yield* forms
.ask({
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 from Connect.",
type: "string",
required: true,
custom: false,
options: Array.from(providers.values())
.toSorted((a, b) => a.name.localeCompare(b.name))
.map((provider) => ({
value: provider.id,
label: provider.name,
})),
},
],
})
.pipe(Effect.orDie)
if (response.status === "cancelled") return yield* new CancelledError()
const answer = response.answer.provider
if (typeof answer !== "string") return yield* new ProviderRequiredError()
return yield* requireProvider(providers, ID.make(answer))
})
const resolve = Effect.fn("WebSearch.resolve")(function* (input: QueryInput) {
const providers = state.get().providers
if (input.providerID) return yield* requireProvider(providers, input.providerID)
const configuredProviderID = Config.latest(yield* config.entries(), "websearch")?.provider
if (configuredProviderID) return yield* requireProvider(providers, configuredProviderID)
if (process.env.OPENCODE_WEBSEARCH_PROVIDER) {
return yield* requireProvider(providers, ID.make(process.env.OPENCODE_WEBSEARCH_PROVIDER))
}
if (truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL")) {
return yield* requireProvider(providers, ID.make("parallel"))
}
if (truthy("OPENCODE_EXPERIMENTAL") || truthy("OPENCODE_ENABLE_EXA") || truthy("OPENCODE_EXPERIMENTAL_EXA")) {
return yield* requireProvider(providers, ID.make("exa"))
}
const providerID = yield* selected()
const provider = providerID ? providers.get(providerID) : undefined
if (provider) return provider
const sessionID = input.sessionID
if (!sessionID) return yield* new ProviderRequiredError()
return yield* onboarding.withPermit(
Effect.gen(function* () {
const current = yield* selected()
const selectedProvider = current ? providers.get(current) : undefined
if (selectedProvider) return selectedProvider
const provider = yield* ask(providers, sessionID)
yield* saveProvider(provider.id)
return provider
}),
)
})
return Service.of({
register: (provider) => state.transform((draft) => draft.register(provider)),
list: Effect.fn("WebSearch.list")(function* () {
return Array.from(state.get().providers.values(), (provider) => ({
id: provider.id,
name: provider.name,
})).toSorted((a, b) => a.name.localeCompare(b.name))
}),
selected,
select: Effect.fn("WebSearch.select")(function* (providerID) {
if (!state.get().providers.has(providerID)) return yield* new ProviderNotFoundError({ providerID })
yield* saveProvider(providerID)
}),
query: Effect.fn("WebSearch.query")(function* (input) {
const provider = yield* resolve(input)
const output = yield* provider.execute({ query: input.query }, { sessionID: input.sessionID }).pipe(
Effect.flatMap(decodeOutput),
Effect.mapError((cause) => new RequestError({ providerID: provider.id, cause })),
)
return new Result({ providerID: provider.id, ...output })
}),
})
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Config.node, ConfigGlobal.node, EventV2.node, Form.node, Global.node],
})
function truthy(key: string) {
const value = process.env[key]?.toLowerCase()
return value === "true" || value === "1"
}
+54 -36
View File
@@ -4,6 +4,7 @@ import { describe, expect } from "bun:test"
import { Effect, Fiber, Layer, PubSub, Schema, Stream } from "effect"
import { FastCheck } from "effect/testing"
import { Config } from "@opencode-ai/core/config"
import { ConfigGlobal } from "@opencode-ai/core/config/global"
import { ConfigModel } from "@opencode-ai/core/config/model"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { ConfigProvider } from "@opencode-ai/core/config/provider"
@@ -26,6 +27,7 @@ import { Integration } from "@opencode-ai/schema/integration"
import { location } from "../fixture/location"
import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
import { parse } from "jsonc-parser"
const it = testEffect(Layer.empty)
const selection = Schema.decodeUnknownSync(ConfigModel.Selection)
@@ -100,6 +102,37 @@ const provider = {
}
describe("Config", () => {
it.live("updates the global JSONC config without removing comments", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const global = path.join(tmp.path, "global")
const file = path.join(global, "opencode.jsonc")
yield* Effect.promise(async () => {
await fs.mkdir(global, { recursive: true })
await fs.writeFile(file, `// user config\n{\n "username": "tester"\n}\n`)
})
const config = yield* ConfigGlobal.Service
yield* config.update(["websearch"], { provider: "exa" })
const text = yield* Effect.promise(() => Bun.file(file).text())
expect(text).toContain("// user config")
expect(parse(text)).toEqual({ username: "tester", websearch: { provider: "exa" } })
}).pipe(
Effect.provide(
AppNodeBuilder.build(LayerNode.group([ConfigGlobal.node]), [
[Global.node, Global.layerWith({ config: path.join(tmp.path, "global") })],
]),
),
),
),
),
)
it.live("loads explicit file and content overrides in priority order", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
@@ -121,23 +154,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 +180,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,
}),
),
),
)
),
)
}),
),
)
+9
View File
@@ -2,6 +2,8 @@ import { AgentV2 } from "@opencode-ai/core/agent"
import { AISDK } from "@opencode-ai/core/aisdk"
import { Catalog } from "@opencode-ai/core/catalog"
import { CommandV2 } from "@opencode-ai/core/command"
import { Config } from "@opencode-ai/core/config"
import { ConfigGlobal } from "@opencode-ai/core/config/global"
import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
@@ -9,6 +11,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Form } from "@opencode-ai/core/form"
import { Integration } from "@opencode-ai/core/integration"
import { Location } from "@opencode-ai/core/location"
import { Npm } from "@opencode-ai/core/npm"
@@ -19,6 +22,7 @@ import { Reference } from "@opencode-ai/core/reference"
import { SkillV2 } from "@opencode-ai/core/skill"
import { ToolHooks } from "@opencode-ai/core/tool/hooks"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { WebSearch } from "@opencode-ai/core/websearch"
import { Effect, Layer } from "effect"
import { tempLocationLayer } from "../fixture/location"
@@ -39,6 +43,8 @@ export const PluginTestLayer = AppNodeBuilder.build(
Npm.node,
Credential.node,
EventV2.node,
Form.node,
ConfigGlobal.node,
LayerNodePlatform.httpClient,
PluginV2.node,
AgentV2.node,
@@ -52,9 +58,12 @@ export const PluginTestLayer = AppNodeBuilder.build(
SkillV2.node,
ToolHooks.node,
ToolRegistry.toolsNode,
WebSearch.node,
]),
[
[Location.node, tempLocationLayer],
[Npm.node, npmLayer],
[Config.node, Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))],
[ConfigGlobal.node, Layer.succeed(ConfigGlobal.Service, ConfigGlobal.Service.of({ update: () => Effect.void }))],
],
) as unknown as Layer.Layer<unknown, never>
+96 -7
View File
@@ -1,11 +1,14 @@
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import { Plugin } from "@opencode-ai/plugin/v2/effect"
import type { IntegrationDefinition, IntegrationMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { WebSearch } from "@opencode-ai/core/websearch"
import type {
CredentialOAuth,
IntegrationCommandMethod,
IntegrationEnvMethod,
IntegrationKeyMethod,
@@ -13,11 +16,11 @@ import type {
} from "@opencode-ai/sdk/v2/types"
import { Effect, Stream } from "effect"
type Overrides = Partial<Omit<PluginContext, "options" | "session">> & {
readonly session?: Partial<PluginContext["session"]>
type Overrides = Partial<Omit<Plugin.Context, "options" | "session">> & {
readonly session?: Partial<Plugin.Context["session"]>
}
export function host(overrides: Overrides = {}): PluginContext {
export function host(overrides: Overrides = {}): Plugin.Context {
return {
options: {},
agent: overrides.agent ?? {
@@ -67,6 +70,7 @@ export function host(overrides: Overrides = {}): PluginContext {
status: () => Effect.die("unused integration.command.status"),
cancel: () => Effect.die("unused integration.command.cancel"),
},
register: () => Effect.die("unused integration.register"),
transform: () => Effect.die("unused integration.transform"),
reload: () => Effect.die("unused integration.reload"),
connection: {
@@ -91,6 +95,9 @@ export function host(overrides: Overrides = {}): PluginContext {
transform: () => Effect.die("unused tool.transform"),
hook: () => Effect.die("unused tool.hook"),
},
websearch: overrides.websearch ?? {
register: () => Effect.die("unused websearch.register"),
},
session: {
hook: overrides.session?.hook ?? (() => Effect.die("unused session.hook")),
create: overrides.session?.create ?? (() => Effect.die("unused session.create")),
@@ -104,7 +111,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"),
@@ -130,7 +137,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"),
@@ -206,7 +213,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"),
@@ -232,6 +239,7 @@ export function integrationHost(integration: Integration.Interface): PluginConte
connection.type === "credential" ? { ...connection, id: Credential.ID.make(connection.id) } : connection,
),
},
register: (definition) => integration.transform((draft) => registerIntegration(draft, definition)),
transform: (callback) =>
integration.transform((draft) =>
callback({
@@ -328,6 +336,87 @@ export function integrationHost(integration: Integration.Interface): PluginConte
}
}
export function webSearchHost(websearch: WebSearch.Interface): Plugin.Context["websearch"] {
return {
register: (definition) =>
websearch.register({
id: WebSearch.ID.make(definition.id),
name: definition.name,
execute: definition.execute,
}),
}
}
function registerIntegration(draft: Integration.Draft, definition: IntegrationDefinition) {
const integrationID = Integration.ID.make(definition.id)
draft.update(integrationID, (integration) => (integration.name = definition.name))
for (const method of definition.methods ?? []) {
if (method.type === "env") {
draft.method.update(methodImplementation({ integrationID: definition.id, method }))
continue
}
if (method.type === "key") {
draft.method.update(methodImplementation({ integrationID: definition.id, method }))
continue
}
const { authorize, refresh, credentialLabel, ...info } = method
draft.method.update(
methodImplementation({
integrationID: definition.id,
method: info,
authorize,
...(refresh ? { refresh } : {}),
...(credentialLabel ? { label: credentialLabel } : {}),
}),
)
}
}
function methodImplementation(input: IntegrationMethodRegistration): Integration.Implementation {
if ("authorize" in input) {
const refresh = input.refresh
return {
integrationID: Integration.ID.make(input.integrationID),
method: { ...input.method, id: Integration.MethodID.make(input.method.id) },
authorize: (inputs) =>
input.authorize(inputs).pipe(
Effect.map((authorization) => {
if (authorization.mode === "auto") {
return { ...authorization, callback: authorization.callback.pipe(Effect.map(oauthCredential)) }
}
return {
...authorization,
callback: (code: string) => authorization.callback(code).pipe(Effect.map(oauthCredential)),
}
}),
),
...(refresh ? { refresh: (value: Credential.OAuth) => refresh(value).pipe(Effect.map(oauthCredential)) } : {}),
...(input.label ? { label: input.label } : {}),
}
}
if (input.method.type === "env") {
return {
integrationID: Integration.ID.make(input.integrationID),
method: { ...input.method, names: [...input.method.names] },
}
}
if (input.method.type === "command") {
return {
integrationID: Integration.ID.make(input.integrationID),
method: {
...input.method,
id: Integration.MethodID.make(input.method.id),
command: [...input.method.command],
},
}
}
return { integrationID: Integration.ID.make(input.integrationID), method: input.method }
}
function oauthCredential(value: CredentialOAuth) {
return Credential.OAuth.make({ ...value, methodID: Integration.MethodID.make(value.methodID) })
}
function method(value: Integration.Method) {
if (value.type === "env") return { type: value.type, names: [...value.names] }
if (value.type === "key") return { type: value.type, label: value.label }
+30
View File
@@ -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"
@@ -243,6 +244,35 @@ 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.register({
id: "promise-websearch",
name: "Promise Web Search",
execute: async (input) => ({ text: `promise: ${input.query}` }),
})
},
})
yield* PluginPromise.fromPromise(promisePlugin).effect(host)
expect(yield* websearch.list()).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.Result({ providerID: WebSearch.ID.make("promise-websearch"), text: "promise: effect" }),
)
}),
)
it.effect("runs the setup cleanup when the plugin scope closes", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
@@ -0,0 +1,57 @@
import { Effect, Layer } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Credential } from "@opencode-ai/core/credential"
import { Config } from "@opencode-ai/core/config"
import { ConfigGlobal } from "@opencode-ai/core/config/global"
import { EventV2 } from "@opencode-ai/core/event"
import { Form } from "@opencode-ai/core/form"
import { Integration } from "@opencode-ai/core/integration"
import { WebSearch } from "@opencode-ai/core/websearch"
import { testEffect } from "../lib/effect"
interface WebSearchRequest {
readonly url: string
readonly headers: Record<string, string>
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, ConfigGlobal.node, WebSearch.node]),
[
[Config.node, Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))],
[
ConfigGlobal.node,
Layer.succeed(ConfigGlobal.Service, ConfigGlobal.Service.of({ update: () => Effect.void })),
],
],
),
http,
),
)
+165
View File
@@ -0,0 +1,165 @@
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: "search results", _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).register({
id: "test-websearch",
name: "Test Web Search",
execute: (input) => Effect.succeed({ text: input.query }),
})
expect(yield* integrations.get(Integration.ID.make("test-websearch"))).toBeUndefined()
expect(yield* websearch.list()).toContainEqual({
id: WebSearch.ID.make("test-websearch"),
name: "Test Web Search",
})
yield* registration.dispose
expect(yield* websearch.list()).not.toContainEqual({
id: WebSearch.ID.make("test-websearch"),
name: "Test Web Search",
})
}),
)
it.effect("registers Exa with its MCP schema", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
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.Result({
providerID: WebSearch.ID.make("exa"),
text: "search results",
metadata: { searchTime: 123 },
}),
)
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"),
sessionID: "ses_parallel",
})
expect(output).toEqual(
new WebSearch.Result({
providerID: WebSearch.ID.make("parallel"),
text: "search results",
metadata: {
search_id: "search_1",
results: [
{
url: "https://effect.website",
title: "Effect",
publish_date: null,
excerpts: ["Effect documentation"],
},
],
warnings: null,
usage: [{ name: "sku_search", count: 1 }],
session_id: "ses_parallel",
},
}),
)
expect(requests[0]).toMatchObject({
url: WebSearchParallel.endpoint,
headers: { authorization: "Bearer parallel-secret" },
body: {
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: {
name: "web_search",
arguments: {
objective: "effect layers",
search_queries: ["effect layers"],
session_id: "ses_parallel",
},
},
},
})
expect(JSON.stringify(output)).not.toContain("parallel-secret")
}),
)
})
+47 -227
View File
@@ -1,10 +1,9 @@
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/core/effect/layer-node"
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
import { PermissionV2 } from "@opencode-ai/core/permission"
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"
@@ -18,89 +17,20 @@ import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefiniti
const webSearchToolNode = makeLocationNode({
name: "test/websearch-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(WebSearchTool.Plugin)),
deps: [ToolRegistry.toolsNode, PermissionV2.node, LayerNodePlatform.httpClient, WebSearchTool.configNode],
deps: [ToolRegistry.toolsNode, PermissionV2.node, WebSearch.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<string, string>
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.QueryInput[] = []
let result = new WebSearch.Result({ providerID: WebSearch.ID.make("exa"), text: "search results" })
beforeEach(() => {
responseBody = payload("search results")
makeResponse = () => new Response(responseBody, { status: 200 })
assertions.length = 0
queries.length = 0
result = new WebSearch.Result({ providerID: WebSearch.ID.make("exa"), text: "search results" })
})
const http = Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.sync(() => {
if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`)
requests.push({
url: request.url,
headers: request.headers,
body: JSON.parse(new TextDecoder().decode(request.body.body)),
})
return HttpClientResponse.fromWeb(request, makeResponse())
}),
),
)
const permission = Layer.succeed(
PermissionV2.Service,
PermissionV2.Service.of({
@@ -112,33 +42,26 @@ 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({
register: () => Effect.die("unused"),
list: () => Effect.succeed([]),
selected: () => Effect.succeed(undefined),
select: () => Effect.die("unused"),
query: (input) =>
Effect.sync(() => {
queries.push(input)
return result
}),
}),
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebSearchTool.configNode, webSearchToolNode]),
LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebSearch.node, webSearchToolNode]),
[
[PermissionV2.node, permission],
[LayerNodePlatform.httpClient, http],
[WebSearchTool.configNode, websearchConfig],
[WebSearch.node, websearch],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
[Image.node, imagePassthrough],
],
@@ -146,12 +69,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"])
@@ -161,122 +80,58 @@ 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" },
},
}),
).toEqual({ type: "text", value: "exa results" })
).toEqual({ type: "text", value: "search results" })
expect(assertions).toMatchObject([
{
sessionID,
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,
},
},
},
sessionID,
query: "effect typescript",
},
])
}),
)
it.effect("calls Parallel with session ID and keeps bearer credentials out of output", () =>
it.effect("keeps provider metadata in structured output", () =>
Effect.gen(function* () {
requests.length = 0
assertions.length = 0
responseBody = payload("parallel results")
config = { provider: "parallel", enableExa: false, enableParallel: false, parallelApiKey: "parallel-secret" }
result = new WebSearch.Result({
providerID: WebSearch.ID.make("parallel"),
text: "parallel results",
metadata: { requestID: "req_1" },
})
const registry = yield* ToolRegistry.Service
const settled = yield* settleTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-parallel", name: "websearch", input: { query: "effect layers" } },
})
expect(requests[0]).toMatchObject({
url: WebSearchTool.PARALLEL_URL,
headers: { authorization: "Bearer parallel-secret" },
body: {
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: {
name: "web_search",
arguments: { objective: "effect layers", search_queries: ["effect layers"], session_id: sessionID },
},
},
})
expect(requests[0]?.body).not.toHaveProperty("params.arguments.model_name")
expect(settled).toEqual({
expect(
yield* settleTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-parallel", name: "websearch", input: { query: "effect layers" } },
}),
).toEqual({
result: { type: "text", value: "parallel results" },
output: {
structured: { provider: "parallel", text: "parallel results" },
structured: { provider: "parallel", text: "parallel results", metadata: { requestID: "req_1" } },
content: [{ type: "text", text: "parallel results" }],
},
})
expect(JSON.stringify(settled)).not.toContain("parallel-secret")
}),
)
it.effect("keeps an Exa credential in the transport URL and out of model output", () =>
it.effect("uses the concise no-results fallback", () =>
Effect.gen(function* () {
requests.length = 0
assertions.length = 0
responseBody = payload("credentialed exa results")
config = { provider: "exa", enableExa: false, enableParallel: false, exaApiKey: "exa secret" }
const registry = yield* ToolRegistry.Service
const settled = yield* settleTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-exa-key", name: "websearch", input: { query: "effect schema" } },
})
expect(requests[0]?.url).toBe(`${WebSearchTool.EXA_URL}?exaApiKey=exa+secret`)
expect(JSON.stringify(settled)).not.toContain("exa secret")
}),
)
it.effect("returns the legacy no-results fallback as concise model text", () =>
Effect.gen(function* () {
requests.length = 0
assertions.length = 0
responseBody = ""
config = { provider: "exa", enableExa: false, enableParallel: false }
result = new WebSearch.Result({ providerID: WebSearch.ID.make("exa"), text: "" })
const registry = yield* ToolRegistry.Service
expect(
@@ -288,39 +143,4 @@ describe("WebSearchTool registration", () => {
).toEqual({ type: "text", value: WebSearchTool.NO_RESULTS })
}),
)
it.effect("rejects oversized MCP response bodies", () =>
Effect.gen(function* () {
requests.length = 0
assertions.length = 0
let chunksRead = 0
let cancelled = false
makeResponse = () =>
new Response(
new ReadableStream({
pull(controller) {
chunksRead++
if (chunksRead === 10) throw new Error("response was not stopped at the byte limit")
controller.enqueue(new Uint8Array(64 * 1024))
},
cancel() {
cancelled = true
},
}),
{ status: 200 },
)
config = { provider: "exa", enableExa: false, enableParallel: false }
const registry = yield* ToolRegistry.Service
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-large-response", name: "websearch", input: { query: "too much" } },
}),
).toEqual({ type: "error", value: "Unable to search the web for too much" })
expect(chunksRead).toBeLessThan(10)
expect(cancelled).toBe(true)
}),
)
})
+157
View File
@@ -0,0 +1,157 @@
import { beforeEach, describe, expect } from "bun:test"
import { Effect, Exit, Fiber, Layer, Scope } from "effect"
import path from "node:path"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Config } from "@opencode-ai/core/config"
import { ConfigGlobal } from "@opencode-ai/core/config/global"
import { ConfigWebSearch } from "@opencode-ai/core/config/websearch"
import { EventV2 } from "@opencode-ai/core/event"
import { Form } from "@opencode-ai/core/form"
import { Global } from "@opencode-ai/core/global"
import { WebSearch } from "@opencode-ai/core/websearch"
import { testEffect } from "./lib/effect"
let entries: Config.Entry[] = []
const writes: { path: readonly (string | number)[]; value: unknown }[] = []
const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed(entries) }))
const configGlobal = Layer.succeed(
ConfigGlobal.Service,
ConfigGlobal.Service.of({ update: (path, value) => Effect.sync(() => writes.push({ path, value })) }),
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([WebSearch.node, EventV2.node, Form.node, ConfigGlobal.node]),
[
[Config.node, config],
[ConfigGlobal.node, configGlobal],
],
),
)
const register = (id: string) =>
Effect.gen(function* () {
const websearch = yield* WebSearch.Service
const providerID = WebSearch.ID.make(id)
const calls: { input: WebSearch.ProviderInput; sessionID?: string }[] = []
yield* websearch.register({
id: providerID,
name: id.toUpperCase(),
execute: (input, context) =>
Effect.sync(() => {
calls.push({ input, ...context })
return { text: `${id}: ${input.query}`, metadata: { id } }
}),
})
return { providerID, calls }
})
beforeEach(() => {
entries = []
writes.length = 0
})
describe("WebSearch", () => {
it.effect("executes an explicit provider without changing the default", () =>
Effect.gen(function* () {
const provider = yield* register("exa")
const websearch = yield* WebSearch.Service
expect(yield* websearch.query({ query: "effect", providerID: provider.providerID })).toEqual(
new WebSearch.Result({
providerID: provider.providerID,
text: "exa: effect",
metadata: { id: "exa" },
}),
)
expect(yield* websearch.selected()).toBeUndefined()
expect(provider.calls).toEqual([
{
input: { query: "effect" },
sessionID: undefined,
},
])
}),
)
it.effect("uses and persists the global provider selection", () =>
Effect.gen(function* () {
yield* register("exa")
const parallel = yield* register("parallel")
const websearch = yield* WebSearch.Service
yield* websearch.select(parallel.providerID)
expect((yield* websearch.query({ query: "layers" })).providerID).toBe(parallel.providerID)
expect(yield* websearch.selected()).toBe(parallel.providerID)
expect(writes).toEqual([
{ path: ["websearch"], value: new ConfigWebSearch.Info({ provider: parallel.providerID }) },
])
}),
)
it.effect("reads the selected provider from global config", () =>
Effect.gen(function* () {
const provider = yield* register("exa")
const websearch = yield* WebSearch.Service
entries = [
new Config.Document({
type: "document",
path: path.join(Global.Path.config, "opencode.json"),
info: new Config.Info({ websearch: new ConfigWebSearch.Info({ provider: provider.providerID }) }),
}),
]
expect(yield* websearch.selected()).toBe(provider.providerID)
expect((yield* websearch.query({ query: "configured" })).providerID).toBe(provider.providerID)
}),
)
it.effect("prefers the location config over the global selection", () =>
Effect.gen(function* () {
const exa = yield* register("exa")
const parallel = yield* register("parallel")
const websearch = yield* WebSearch.Service
yield* websearch.select(exa.providerID)
entries = [
new Config.Document({
type: "document",
info: new Config.Info({ websearch: new ConfigWebSearch.Info({ provider: parallel.providerID }) }),
}),
]
expect((yield* websearch.query({ query: "configured" })).providerID).toBe(parallel.providerID)
}),
)
it.effect("serializes concurrent first-use onboarding and persists the answer", () =>
Effect.gen(function* () {
const provider = yield* register("exa")
const websearch = yield* WebSearch.Service
const forms = yield* Form.Service
const first = yield* websearch.query({ query: "one", sessionID: "ses_websearch" }).pipe(Effect.forkChild)
const second = yield* websearch.query({ query: "two", sessionID: "ses_websearch" }).pipe(Effect.forkChild)
yield* Effect.yieldNow
const pending = yield* forms.list({ sessionID: "ses_websearch" })
expect(pending).toHaveLength(1)
const form = pending[0]
if (!form) return yield* Effect.die("Expected an onboarding form")
yield* forms.reply({ id: form.id, answer: { provider: provider.providerID } })
expect((yield* Fiber.join(first)).providerID).toBe(provider.providerID)
expect((yield* Fiber.join(second)).providerID).toBe(provider.providerID)
expect(yield* websearch.selected()).toBe(provider.providerID)
}),
)
it.effect("removes scoped provider registrations", () =>
Effect.gen(function* () {
const websearch = yield* WebSearch.Service
const scope = yield* Scope.fork(yield* Scope.Scope)
const provider = yield* register("temporary").pipe(Scope.provide(scope))
expect(yield* websearch.list()).toContainEqual({ id: provider.providerID, name: "TEMPORARY" })
yield* Scope.close(scope, Exit.void)
expect(yield* websearch.list()).not.toContainEqual({ id: provider.providerID, name: "TEMPORARY" })
}),
)
})
+1
View File
@@ -9,3 +9,4 @@ export { Model } from "@opencode-ai/schema/model"
export { Provider } from "@opencode-ai/schema/provider"
export { Reference } from "@opencode-ai/schema/reference"
export { Skill } from "@opencode-ai/schema/skill"
export { WebSearch } from "@opencode-ai/schema/websearch"
+16 -1
View File
@@ -12,7 +12,7 @@ import type {
} from "@opencode-ai/sdk/v2/types"
import type { IntegrationApi } from "@opencode-ai/client/effect/api"
import type { Effect, Scope } from "effect"
import type { Transform } from "./registration.js"
import type { Registration, Transform } from "./registration.js"
export type IntegrationOAuthAuthorization = {
readonly url: string
@@ -50,6 +50,20 @@ export type IntegrationMethodRegistration =
readonly method: IntegrationEnvMethod
}
export type IntegrationOAuthMethodDefinition = IntegrationOAuthMethod & {
readonly authorize: (inputs: IntegrationInputs) => Effect.Effect<IntegrationOAuthAuthorization, unknown, Scope.Scope>
readonly refresh?: (credential: CredentialOAuth) => Effect.Effect<CredentialOAuth, unknown>
readonly credentialLabel?: (credential: CredentialOAuth) => string | undefined
}
export type IntegrationMethodDefinition = IntegrationOAuthMethodDefinition | IntegrationKeyMethod | IntegrationEnvMethod
export interface IntegrationDefinition {
readonly id: string
readonly name: string
readonly methods?: readonly IntegrationMethodDefinition[]
}
export interface IntegrationDraft {
list(): readonly IntegrationRef[]
get(id: string): IntegrationRef | undefined
@@ -63,6 +77,7 @@ export interface IntegrationDraft {
}
export interface IntegrationDomain extends Omit<IntegrationApi<unknown>, "wellknown"> {
readonly register: (definition: IntegrationDefinition) => Effect.Effect<Registration, never, Scope.Scope>
readonly transform: Transform<IntegrationDraft>
readonly reload: () => Effect.Effect<void>
readonly connection: {
+2
View File
@@ -11,6 +11,7 @@ import type { ReferenceDomain } from "./reference.js"
import type { SessionDomain } from "./session.js"
import type { SkillDomain } from "./skill.js"
import type { ToolDomain } from "./tool.js"
import type { WebSearchDomain } from "./websearch.js"
export interface Context {
readonly options: PluginOptions
@@ -25,6 +26,7 @@ export interface Context {
readonly session: SessionDomain
readonly skill: SkillDomain
readonly tool: ToolDomain
readonly websearch: WebSearchDomain
}
export interface Plugin<R = Scope.Scope> {
@@ -0,0 +1,16 @@
import type { WebSearch } from "@opencode-ai/schema/websearch"
import type { Effect, Scope } from "effect"
import type { Registration } from "./registration.js"
export interface WebSearchDefinition {
readonly id: string
readonly name: string
readonly execute: (
input: WebSearch.ProviderInput,
context: { readonly sessionID?: string },
) => Effect.Effect<WebSearch.ProviderOutput, unknown>
}
export interface WebSearchDomain {
readonly register: (definition: WebSearchDefinition) => Effect.Effect<Registration, never, Scope.Scope>
}
+1
View File
@@ -10,3 +10,4 @@ export { Model } from "@opencode-ai/schema/model"
export { Provider } from "@opencode-ai/schema/provider"
export { Reference } from "@opencode-ai/schema/reference"
export { Skill } from "@opencode-ai/schema/skill"
export { WebSearch } from "@opencode-ai/schema/websearch"
+39 -2
View File
@@ -1,11 +1,48 @@
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 { Transform } from "./registration.js"
import type {
CredentialOAuth,
CredentialValue,
IntegrationEnvMethod,
IntegrationInputs,
IntegrationKeyMethod,
IntegrationOAuthMethod,
} from "@opencode-ai/sdk/v2/types"
import type { Registration, 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<CredentialOAuth>
}
| {
readonly mode: "code"
readonly callback: (code: string) => Promise<CredentialOAuth>
}
)
export type IntegrationOAuthMethodDefinition = IntegrationOAuthMethod & {
readonly authorize: (inputs: IntegrationInputs) => Promise<IntegrationOAuthAuthorization>
readonly refresh?: (credential: CredentialOAuth) => Promise<CredentialOAuth>
readonly credentialLabel?: (credential: CredentialOAuth) => string | undefined
}
export type IntegrationMethodDefinition = IntegrationOAuthMethodDefinition | IntegrationKeyMethod | IntegrationEnvMethod
export interface IntegrationDefinition {
readonly id: string
readonly name: string
readonly methods?: readonly IntegrationMethodDefinition[]
}
export interface IntegrationDomain extends Omit<IntegrationApi, "wellknown"> {
readonly register: (definition: IntegrationDefinition) => Promise<Registration>
readonly transform: Transform<IntegrationDraft>
readonly reload: () => Promise<void>
readonly connection: {
+2
View File
@@ -10,6 +10,7 @@ import type { ReferenceDomain } from "./reference.js"
import type { SessionDomain } from "./session.js"
import type { SkillDomain } from "./skill.js"
import type { ToolDomain } from "./tool.js"
import type { WebSearchDomain } from "./websearch.js"
export interface Context {
readonly options: PluginOptions
@@ -24,6 +25,7 @@ export interface Context {
readonly session: SessionDomain
readonly skill: SkillDomain
readonly tool: ToolDomain
readonly websearch: WebSearchDomain
}
export type Cleanup = () => Promise<void> | void
@@ -0,0 +1,15 @@
import type { WebSearch } from "@opencode-ai/schema/websearch"
import type { Registration } from "./registration.js"
export interface WebSearchDefinition {
readonly id: string
readonly name: string
readonly execute: (
input: WebSearch.ProviderInput,
context: { readonly sessionID?: string; readonly signal: AbortSignal },
) => Promise<WebSearch.ProviderOutput>
}
export interface WebSearchDomain {
readonly register: (definition: WebSearchDefinition) => Promise<Registration>
}
@@ -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",
])
})
+3
View File
@@ -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<LocationId extends HttpApiMiddleware.AnyId> =
| HttpApiGroup.AddMiddleware<typeof GenerateGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof ProviderGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof IntegrationGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof WebSearchGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof McpGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof CredentialGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof ProjectGroup, LocationId>
@@ -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",
+1
View File
@@ -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",
+76
View File
@@ -0,0 +1,76 @@
import { Location } from "@opencode-ai/schema/location"
import { WebSearch } from "@opencode-ai/schema/websearch"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { InvalidRequestError, ServiceUnavailableError } from "../errors.js"
import { LocationQuery, locationQueryOpenApi } from "./location.js"
export const WebSearchGroup = HttpApiGroup.make("server.websearch")
.add(
HttpApiEndpoint.get("websearch.provider.list", "/api/websearch/provider", {
query: LocationQuery,
success: Location.response(Schema.Array(WebSearch.Provider)),
error: ServiceUnavailableError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.websearch.provider.list",
summary: "List web search providers",
description: "Return the registered web search providers.",
}),
),
)
.add(
HttpApiEndpoint.get("websearch.provider.selected", "/api/websearch/provider/selected", {
query: LocationQuery,
success: Location.response(Schema.UndefinedOr(WebSearch.ID)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.websearch.provider.selected",
summary: "Get selected web search provider",
description: "Return the globally selected web search provider.",
}),
),
)
.add(
HttpApiEndpoint.post("websearch.provider.select", "/api/websearch/provider/selected", {
query: LocationQuery,
payload: Schema.Struct({ providerID: WebSearch.ID }),
success: HttpApiSchema.NoContent,
error: [InvalidRequestError, ServiceUnavailableError],
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.websearch.provider.select",
summary: "Select default web search provider",
description: "Persist the global web search provider in the user configuration.",
}),
),
)
.add(
HttpApiEndpoint.post("websearch.query", "/api/websearch", {
query: LocationQuery,
payload: Schema.Struct(WebSearch.Input.fields),
success: Location.response(WebSearch.Result),
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.",
}),
)
+2
View File
@@ -36,6 +36,7 @@ import { TuiEvent } from "./tui-event.js"
import { VcsEvent } from "./vcs-event.js"
import { WorkspaceEvent } from "./workspace-event.js"
import { WorktreeEvent } from "./worktree-event.js"
import { WebSearch } from "./websearch.js"
const sessionV1DurableDefinitions = SessionV1.Event.Definitions.filter(
(definition) => definition.durability === "durable",
@@ -67,6 +68,7 @@ const featureDefinitions = Event.inventory(
...Shell.Event.Definitions,
...Question.Event.Definitions,
...Form.Event.Definitions,
...WebSearch.Event.Definitions,
)
export const ServerDefinitions = Event.inventory(
+1
View File
@@ -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"
+38
View File
@@ -0,0 +1,38 @@
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<typeof Provider> {}
export const Provider = Schema.Struct({
id: ID,
name: Schema.String,
}).annotate({ identifier: "WebSearch.Provider" })
export interface Input extends Schema.Schema.Type<typeof Input> {}
export const Input = Schema.Struct({
query: Schema.String,
providerID: ID.pipe(optional),
}).annotate({ identifier: "WebSearch.Input" })
export type ProviderInput = Pick<Input, "query">
export interface ProviderOutput extends Schema.Schema.Type<typeof ProviderOutput> {}
export const ProviderOutput = Schema.Struct({
text: Schema.String,
metadata: Schema.Json.pipe(optional),
}).annotate({ identifier: "WebSearch.ProviderOutput" })
export class Result extends Schema.Class<Result>("WebSearch.Result")({
providerID: ID,
...ProviderOutput.fields,
}) {}
const Updated = ephemeral({
type: "websearch.updated",
schema: {},
})
export const Event = { Updated, Definitions: inventory(Updated) }
+1
View File
@@ -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"
@@ -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",
])
})
+30
View File
@@ -307,6 +307,36 @@ 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.register({
id: providerID,
name: "Embedded web search",
execute: (input) => Effect.succeed({ text: `Found ${input.query}`, metadata: { source: "embedded" } }),
}),
})
const result = yield* opencode.websearch.query({
query: "opencode",
providerID,
location: location(fixture),
})
expect(result.data).toEqual({
providerID,
text: "Found opencode",
metadata: { source: "embedded" },
})
}),
),
)
it.live(
"Location-owned runner events reach the ready global client",
() =>
+156
View File
@@ -476,6 +476,14 @@ import type {
V2VcsDiffResponses,
V2VcsStatusErrors,
V2VcsStatusResponses,
V2WebsearchProviderListErrors,
V2WebsearchProviderListResponses,
V2WebsearchProviderSelectedErrors,
V2WebsearchProviderSelectedResponses,
V2WebsearchProviderSelectErrors,
V2WebsearchProviderSelectResponses,
V2WebsearchQueryErrors,
V2WebsearchQueryResponses,
VcsApplyErrors,
VcsApplyResponses,
VcsDiffErrors,
@@ -8541,6 +8549,149 @@ export class Debug extends HeyApiClient {
}
}
export class Provider3 extends HeyApiClient {
/**
* List web search providers
*
* Return the registered web search providers.
*/
public list<ThrowOnError extends boolean = false>(
parameters?: {
location?: {
directory?: string | null
workspace?: string | null
} | null
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }])
return (options?.client ?? this.client).get<
V2WebsearchProviderListResponses,
V2WebsearchProviderListErrors,
ThrowOnError
>({
url: "/api/websearch/provider",
...options,
...params,
})
}
/**
* Get selected web search provider
*
* Return the globally selected web search provider.
*/
public selected<ThrowOnError extends boolean = false>(
parameters?: {
location?: {
directory?: string | null
workspace?: string | null
} | null
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }])
return (options?.client ?? this.client).get<
V2WebsearchProviderSelectedResponses,
V2WebsearchProviderSelectedErrors,
ThrowOnError
>({
url: "/api/websearch/provider/selected",
...options,
...params,
})
}
/**
* Select default web search provider
*
* Persist the global web search provider in the user configuration.
*/
public select<ThrowOnError extends boolean = false>(
parameters?: {
location?: {
directory?: string | null
workspace?: string | null
} | null
providerID?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "location" },
{ in: "body", key: "providerID" },
],
},
],
)
return (options?.client ?? this.client).post<
V2WebsearchProviderSelectResponses,
V2WebsearchProviderSelectErrors,
ThrowOnError
>({
url: "/api/websearch/provider/selected",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
}
export class Websearch extends HeyApiClient {
/**
* Search the web
*
* Run one web search through the selected provider. Specify a provider to override the configured default.
*/
public query<ThrowOnError extends boolean = false>(
parameters?: {
location?: {
directory?: string | null
workspace?: string | null
} | null
query?: string
providerID?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "location" },
{ in: "body", key: "query" },
{ in: "body", key: "providerID" },
],
},
],
)
return (options?.client ?? this.client).post<V2WebsearchQueryResponses, V2WebsearchQueryErrors, ThrowOnError>({
url: "/api/websearch",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
private _provider?: Provider3
get provider(): Provider3 {
return (this._provider ??= new Provider3({ client: this.client }))
}
}
export class V2 extends HeyApiClient {
private _health?: Health
get health(): Health {
@@ -8681,6 +8832,11 @@ export class V2 extends HeyApiClient {
get debug(): Debug {
return (this._debug ??= new Debug({ client: this.client }))
}
private _websearch?: Websearch
get websearch(): Websearch {
return (this._websearch ??= new Websearch({ client: this.client }))
}
}
export class OpencodeClient extends HeyApiClient {
+222 -1
View File
@@ -87,6 +87,7 @@ export type Event =
| EventFormCreated
| EventFormReplied
| EventFormCancelled
| EventWebsearchUpdated
| EventLspUpdated
| EventPermissionAsked
| EventPermissionReplied
@@ -1414,6 +1415,13 @@ export type GlobalEvent = {
sessionID: string
}
}
| {
id: string
type: "websearch.updated"
properties: {
[key: string]: unknown
}
}
| {
id: string
type: "lsp.updated"
@@ -2894,7 +2902,6 @@ export type SessionGenerateResponse = {
text: string
}
}
export type SessionLogItem = SessionEventDurable | EventLogSynced
export type SessionLogItemJsonString = string
@@ -3081,6 +3088,7 @@ export type V2Event =
| FormCreated
| FormReplied
| FormCancelled
| WebsearchUpdated
| LspUpdated
| PermissionAsked
| PermissionReplied
@@ -6677,6 +6685,19 @@ export type FormCancelled = {
}
}
export type WebsearchUpdated = {
id: string
created: number
metadata?: {
[key: string]: unknown
}
type: "websearch.updated"
location?: LocationRef
data: {
[key: string]: unknown
}
}
export type LspUpdated = {
id: string
created: number
@@ -7092,6 +7113,17 @@ export type ProjectCopyCopy = {
export type VcsMode = "working" | "branch"
export type WebSearchProvider = {
id: string
name: string
}
export type WebSearchResult = {
providerID: string
text: string
metadata?: unknown
}
export type EventModelsDevRefreshed = {
id: string
type: "models-dev.refreshed"
@@ -7941,6 +7973,14 @@ export type EventFormCancelled = {
}
}
export type EventWebsearchUpdated = {
id: string
type: "websearch.updated"
properties: {
[key: string]: unknown
}
}
export type EventLspUpdated = {
id: string
type: "lsp.updated"
@@ -8756,6 +8796,7 @@ export type V2EventV2 =
| FormCreatedV2
| FormRepliedV2
| FormCancelledV2
| WebsearchUpdatedV2
| SessionStatusV22
| SessionIdleV2
| TuiPromptAppendV2
@@ -10794,6 +10835,21 @@ export type FormCancelledV2 = {
}
}
export type WebsearchUpdatedV2 = {
id: string
created: number
metadata?: {
[key: string]: unknown
}
type: "websearch.updated"
location?: LocationRefV2
data:
| {
[key: string]: unknown
}
| Array<unknown>
}
export type SessionIdleV2 = {
id: string
created: number
@@ -19316,6 +19372,171 @@ export type V2DebugLocationListResponses = {
export type V2DebugLocationListResponse = V2DebugLocationListResponses[keyof V2DebugLocationListResponses]
export type V2WebsearchProviderListData = {
body?: never
path?: never
query?: {
location?: {
directory?: string | null
workspace?: string | null
} | null
}
url: "/api/websearch/provider"
}
export type V2WebsearchProviderListErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestErrorV2
/**
* UnauthorizedError
*/
401: UnauthorizedError
/**
* ServiceUnavailableError
*/
503: ServiceUnavailableErrorV2
}
export type V2WebsearchProviderListError = V2WebsearchProviderListErrors[keyof V2WebsearchProviderListErrors]
export type V2WebsearchProviderListResponses = {
/**
* Success
*/
200: {
location: LocationInfoV2
data: Array<WebSearchProvider>
}
}
export type V2WebsearchProviderListResponse = V2WebsearchProviderListResponses[keyof V2WebsearchProviderListResponses]
export type V2WebsearchProviderSelectedData = {
body?: never
path?: never
query?: {
location?: {
directory?: string | null
workspace?: string | null
} | null
}
url: "/api/websearch/provider/selected"
}
export type V2WebsearchProviderSelectedErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestErrorV2
/**
* UnauthorizedError
*/
401: UnauthorizedError
}
export type V2WebsearchProviderSelectedError =
V2WebsearchProviderSelectedErrors[keyof V2WebsearchProviderSelectedErrors]
export type V2WebsearchProviderSelectedResponses = {
/**
* Success
*/
200: {
location: LocationInfoV2
data: string | null
}
}
export type V2WebsearchProviderSelectedResponse =
V2WebsearchProviderSelectedResponses[keyof V2WebsearchProviderSelectedResponses]
export type V2WebsearchProviderSelectData = {
body: {
providerID: string
}
path?: never
query?: {
location?: {
directory?: string | null
workspace?: string | null
} | null
}
url: "/api/websearch/provider/selected"
}
export type V2WebsearchProviderSelectErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestError1 | InvalidRequestErrorV2
/**
* UnauthorizedError
*/
401: UnauthorizedError
/**
* ServiceUnavailableError
*/
503: ServiceUnavailableErrorV2
}
export type V2WebsearchProviderSelectError = V2WebsearchProviderSelectErrors[keyof V2WebsearchProviderSelectErrors]
export type V2WebsearchProviderSelectResponses = {
/**
* <No Content>
*/
204: void
}
export type V2WebsearchProviderSelectResponse =
V2WebsearchProviderSelectResponses[keyof V2WebsearchProviderSelectResponses]
export type V2WebsearchQueryData = {
body: {
query: string
providerID?: string
}
path?: never
query?: {
location?: {
directory?: string | null
workspace?: string | null
} | null
}
url: "/api/websearch"
}
export type V2WebsearchQueryErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestError1 | InvalidRequestErrorV2
/**
* UnauthorizedError
*/
401: UnauthorizedError
/**
* ServiceUnavailableError
*/
503: ServiceUnavailableErrorV2
}
export type V2WebsearchQueryError = V2WebsearchQueryErrors[keyof V2WebsearchQueryErrors]
export type V2WebsearchQueryResponses = {
/**
* Success
*/
200: {
location: LocationInfoV2
data: WebSearchResult
}
}
export type V2WebsearchQueryResponse = V2WebsearchQueryResponses[keyof V2WebsearchQueryResponses]
export type PtyConnectData = {
body?: never
path: {
+2
View File
@@ -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,
+93
View File
@@ -0,0 +1,93 @@
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, HttpApiSchema } 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.provider.list",
Effect.fn("server.websearch.provider.list")(function* () {
yield* awaitPlugins()
const websearch = yield* WebSearch.Service
return yield* response(websearch.list())
}),
)
.handle(
"websearch.provider.selected",
Effect.fn("server.websearch.provider.selected")(function* () {
const websearch = yield* WebSearch.Service
return yield* response(websearch.selected())
}),
)
.handle(
"websearch.provider.select",
Effect.fn("server.websearch.provider.select")(function* (request) {
yield* awaitPlugins()
const websearch = yield* WebSearch.Service
yield* websearch.select(request.payload.providerID).pipe(
Effect.mapError(
(error) =>
new InvalidRequestError({
message: `Web search provider not found: ${error.providerID}`,
kind: "websearch_provider_not_found",
field: "providerID",
}),
),
)
return HttpApiSchema.NoContent.make()
}),
)
.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.Cancelled": () =>
new InvalidRequestError({ message: "Web search cancelled", kind: "websearch_cancelled" }),
"WebSearch.Request": (error) =>
new ServiceUnavailableError({
message: `Web search request failed: ${error.providerID}`,
service: error.providerID,
}),
}),
),
)
}),
)
}),
)
+109 -20
View File
@@ -5,6 +5,7 @@ import type {
IntegrationInfo,
IntegrationOauthConnectOutput,
IntegrationOAuthMethod,
WebSearchProvider,
} from "@opencode-ai/client"
import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
import { useClipboard } from "../context/clipboard"
@@ -59,29 +60,60 @@ 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 methods = connectMethods(integration)
const connected = integration.connections.length > 0
return {
title: integration.name,
value: integration.id,
description: methods.length ? undefined : "Environment only",
footer: connectionSummary(integration) || undefined,
category: integration.id in INTEGRATION_PRIORITY ? "Popular" : "Services",
disabled: methods.length === 0,
gutter: connected ? () => <text fg={themeV2.text.feedback.success.default}></text> : undefined,
onSelect: () =>
credentialConnections(integration).length
? manageConnections(integration, methods, dialog, props.onConnected)
: selectMethod(integration, methods, dialog, props.onConnected),
}
}),
)
const options = createMemo(() => {
const providers = data.location.websearch.list() ?? []
const providersByID = new Map(providers.map((provider) => [provider.id, provider]))
const selectedProvider = data.location.websearch.provider()
const integrations = integrationOptions(data.location.integration.list() ?? []).filter(
(integration) => props.integrationID === undefined || integration.id === props.integrationID,
)
const integrationIDs = new Set(integrations.map((integration) => integration.id))
return [
...integrations.map((integration) => {
const methods = connectMethods(integration)
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 === 0 ? "Environment only" : undefined,
footer:
[connectionSummary(integration), selectedProvider === provider?.id ? "Web search default" : undefined]
.filter((value) => value !== undefined && value.length > 0)
.join(" · ") || undefined,
category,
disabled: methods.length === 0 && !provider,
gutter:
integration.connections.length > 0
? () => <text fg={themeV2.text.feedback.success.default}></text>
: undefined,
onSelect: () => {
if (!props.connectionOnly && provider) return manageWebSearch(provider, dialog, integration, methods)
if (credentials.length) return manageConnections(integration, methods, dialog, props.onConnected)
return selectMethod(integration, methods, dialog, props.onConnected)
},
}
}),
...providers
.filter((provider) => props.integrationID === undefined && !integrationIDs.has(provider.id))
.map((provider) => ({
title: provider.name,
value: provider.id,
footer: selectedProvider === provider.id ? "Web search default" : undefined,
category: "Web search",
onSelect: () => manageWebSearch(provider, dialog),
})),
]
})
return (
<DialogSelect
@@ -101,6 +133,63 @@ export function DialogIntegration(props: { onConnected?: OnIntegrationConnected
)
}
function manageWebSearch(
provider: WebSearchProvider,
dialog: ReturnType<typeof useDialog>,
integration?: IntegrationInfo,
methods: ConnectMethod[] = [],
) {
dialog.replace(() => {
const data = useData()
const client = useClient()
const toast = useToast()
const credentials = integration ? credentialConnections(integration) : []
const selected = createMemo(() => data.location.websearch.provider() === provider.id)
const selectWebSearch = () => {
void client.api.websearch.provider
.select({
providerID: provider.id,
location: location(data),
})
.then(async () => {
const refreshes = integration
? [data.location.integration.sync(), data.location.websearch.refresh()]
: [data.location.websearch.refresh()]
await Promise.all(refreshes)
toast.show({ variant: "success", message: `${provider.name} is now the web search default` })
dialog.clear()
})
.catch(toast.error)
}
return (
<DialogSelect
title={provider.name}
options={[
{
title: selected() ? "Web search default" : "Use for web search",
value: "websearch",
disabled: selected(),
onSelect: selectWebSearch,
},
...(integration && methods.length
? [
{
title: credentials.length ? "Manage connections" : "Connect",
value: "connect",
onSelect: () => {
if (credentials.length) return manageConnections(integration, methods, dialog)
return selectMethod(integration, methods, dialog)
},
},
]
: []),
]}
/>
)
})
}
function manageConnections(
integration: IntegrationInfo,
methods: ConnectMethod[],
+28
View File
@@ -27,6 +27,7 @@ import type {
ShellInfo,
SkillInfo,
OpenCodeEvent,
WebSearchProvider,
} from "@opencode-ai/client"
import type { Plugin } from "@opencode-ai/plugin/v2/tui"
import { createStore, produce, reconcile } from "solid-js/store"
@@ -55,6 +56,8 @@ type LocationData = {
model?: ModelInfo[]
provider?: ProviderV2Info[]
reference?: ReferenceInfo[]
websearch?: WebSearchProvider[]
websearchSelected?: WebSearchProvider["id"] | null
// Currently running shell commands for this location, keyed by shell id. Entries are removed
// once the command exits or is deleted, so this only ever holds in-flight shells.
shell?: Record<string, ShellInfo>
@@ -871,6 +874,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":
@@ -1247,6 +1254,27 @@ 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
},
provider(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.websearchSelected ?? undefined
},
async refresh(ref?: LocationRef) {
const input = { location: locationQuery(ref ?? defaultLocation()) }
const [providers, selected] = await Promise.all([
client.api.websearch.provider.list(input),
client.api.websearch.provider.selected(input),
])
const key = locationKey(providers.location)
setStore("location", key, {
...store.location[key],
websearch: providers.data,
websearchSelected: selected.data ?? null,
})
},
},
skill: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.skill
+6
View File
@@ -207,6 +207,9 @@ 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" }] })
if (url.pathname === "/api/websearch/provider/selected") return json({ location, data: "standalone" })
return undefined
}, events)
let data!: ReturnType<typeof useData>
@@ -242,6 +245,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"])
@@ -250,6 +254,8 @@ 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" }])
expect(data.location.websearch.provider(location)).toBe("standalone")
} finally {
app.renderer.destroy()
}
+7
View File
@@ -125,6 +125,13 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
})
if (url.pathname === "/api/reference")
return json({ location: { directory, project: { id: "proj_test", directory } }, data: [] })
if (url.pathname === "/api/websearch/provider") {
return json({ location: { directory, project: { id: "proj_test", directory } }, data: [] })
}
if (url.pathname === "/api/websearch/provider/selected") {
if (request.method === "POST") return new Response(null, { status: 204 })
return json({ location: { directory, project: { id: "proj_test", directory } }, data: null })
}
if (url.pathname === "/provider") return json({ all: [], default: {}, connected: [] })
if (url.pathname === "/session") return json([])
if (url.pathname === "/vcs") return json({ branch: "main" })