feat(core): add pluggable web search (#35558)
Co-authored-by: Dax Raad <d@ironbay.co>
This commit is contained in:
parent
2ddc91a0e8
commit
efb629a33a
57 changed files with 1700 additions and 587 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1042,6 +1042,25 @@ export interface DebugApi<E = never> {
|
|||
readonly location: { readonly list: DebugLocationListOperation<E>; readonly evict: DebugLocationEvictOperation<E> }
|
||||
}
|
||||
|
||||
type Endpoint27_0Request = Parameters<RawClient["server.websearch"]["websearch.providers"]>[0]
|
||||
export type Endpoint27_0Input = { readonly location?: Endpoint27_0Request["query"]["location"] }
|
||||
export type Endpoint27_0Output = EffectValue<ReturnType<RawClient["server.websearch"]["websearch.providers"]>>
|
||||
export type WebsearchProvidersOperation<E = never> = (input?: Endpoint27_0Input) => Effect.Effect<Endpoint27_0Output, E>
|
||||
|
||||
type Endpoint27_1Request = Parameters<RawClient["server.websearch"]["websearch.query"]>[0]
|
||||
export type Endpoint27_1Input = {
|
||||
readonly location?: Endpoint27_1Request["query"]["location"]
|
||||
readonly query: Endpoint27_1Request["payload"]["query"]
|
||||
readonly providerID?: Endpoint27_1Request["payload"]["providerID"]
|
||||
}
|
||||
export type Endpoint27_1Output = EffectValue<ReturnType<RawClient["server.websearch"]["websearch.query"]>>
|
||||
export type WebsearchQueryOperation<E = never> = (input: Endpoint27_1Input) => Effect.Effect<Endpoint27_1Output, E>
|
||||
|
||||
export interface WebsearchApi<E = never> {
|
||||
readonly providers: WebsearchProvidersOperation<E>
|
||||
readonly query: WebsearchQueryOperation<E>
|
||||
}
|
||||
|
||||
export interface AppApi<E = never> {
|
||||
readonly health: HealthApi<E>
|
||||
readonly server: ServerApi<E>
|
||||
|
|
@ -1070,4 +1089,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,28 @@ const adaptGroup26 = (raw: RawClient["server.debug"]) => ({
|
|||
location: { list: Endpoint26_0(raw), evict: Endpoint26_1(raw) },
|
||||
})
|
||||
|
||||
type Endpoint27_0Request = Parameters<RawClient["server.websearch"]["websearch.providers"]>[0]
|
||||
type Endpoint27_0Input = { readonly location?: Endpoint27_0Request["query"]["location"] }
|
||||
const Endpoint27_0 = (raw: RawClient["server.websearch"]) => (input?: Endpoint27_0Input) =>
|
||||
raw["websearch.providers"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint27_1Request = Parameters<RawClient["server.websearch"]["websearch.query"]>[0]
|
||||
type Endpoint27_1Input = {
|
||||
readonly location?: Endpoint27_1Request["query"]["location"]
|
||||
readonly query: Endpoint27_1Request["payload"]["query"]
|
||||
readonly providerID?: Endpoint27_1Request["payload"]["providerID"]
|
||||
}
|
||||
const Endpoint27_1 = (raw: RawClient["server.websearch"]) => (input: Endpoint27_1Input) =>
|
||||
raw["websearch.query"]({
|
||||
query: { location: input["location"] },
|
||||
payload: { query: input["query"], providerID: input["providerID"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
const adaptGroup27 = (raw: RawClient["server.websearch"]) => ({
|
||||
providers: Endpoint27_0(raw),
|
||||
query: Endpoint27_1(raw),
|
||||
})
|
||||
|
||||
const adaptClient = (raw: RawClient) => ({
|
||||
health: adaptGroup0(raw["server.health"]),
|
||||
server: adaptGroup1(raw["server.server"]),
|
||||
|
|
@ -1272,6 +1294,7 @@ const adaptClient = (raw: RawClient) => ({
|
|||
projectCopy: adaptGroup24(raw["server.projectCopy"]),
|
||||
vcs: adaptGroup25(raw["server.vcs"]),
|
||||
debug: adaptGroup26(raw["server.debug"]),
|
||||
websearch: adaptGroup27(raw["server.websearch"]),
|
||||
})
|
||||
|
||||
export const make = (options?: { readonly baseUrl?: URL | string }) =>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,10 @@ import type {
|
|||
DebugLocationListOutput,
|
||||
DebugLocationEvictInput,
|
||||
DebugLocationEvictOutput,
|
||||
WebsearchProvidersInput,
|
||||
WebsearchProvidersOutput,
|
||||
WebsearchQueryInput,
|
||||
WebsearchQueryOutput,
|
||||
} from "./types"
|
||||
import { ClientError } from "./client-error"
|
||||
|
||||
|
|
@ -1735,6 +1739,33 @@ export function make(options: ClientOptions) {
|
|||
),
|
||||
},
|
||||
},
|
||||
websearch: {
|
||||
providers: (input?: WebsearchProvidersInput, requestOptions?: RequestOptions) =>
|
||||
request<WebsearchProvidersOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/websearch/provider`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [503, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
query: (input: WebsearchQueryInput, requestOptions?: RequestOptions) =>
|
||||
request<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,
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -539,6 +539,10 @@ export type VcsFileStatus = {
|
|||
status: "added" | "deleted" | "modified"
|
||||
}
|
||||
|
||||
export type WebSearchProvider = { id: string; name: string }
|
||||
|
||||
export type WebSearchResult = { url: string; title?: string; content?: string; time: { published?: number } }
|
||||
|
||||
export type SessionMessageModelSelected = {
|
||||
id: string
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
|
|
@ -1058,6 +1062,15 @@ export type FormCancelled = {
|
|||
data: { id: string; sessionID: string }
|
||||
}
|
||||
|
||||
export type WebsearchUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "websearch.updated"
|
||||
location?: LocationRef
|
||||
data: {}
|
||||
}
|
||||
|
||||
export type SessionIdle = {
|
||||
id: string
|
||||
created: number
|
||||
|
|
@ -2355,6 +2368,7 @@ export type V2Event =
|
|||
| FormCreated
|
||||
| FormReplied
|
||||
| FormCancelled
|
||||
| WebsearchUpdated
|
||||
| SessionStatus2
|
||||
| SessionIdle
|
||||
| TuiPromptAppend
|
||||
|
|
@ -4958,3 +4972,27 @@ export type DebugLocationEvictInput = {
|
|||
}
|
||||
|
||||
export type DebugLocationEvictOutput = void
|
||||
|
||||
export type WebsearchProvidersInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type WebsearchProvidersOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<WebSearchProvider>
|
||||
}
|
||||
|
||||
export type WebsearchQueryInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
readonly query: { readonly query: string; readonly providerID?: string }["query"]
|
||||
readonly providerID?: { readonly query: string; readonly providerID?: string }["providerID"]
|
||||
}
|
||||
|
||||
export type WebsearchQueryOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: { providerID: string; results: Array<WebSearchResult> }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export type {
|
|||
PluginApi,
|
||||
ProviderApi,
|
||||
ReferenceApi,
|
||||
WebSearchApi,
|
||||
SessionApi,
|
||||
SkillApi,
|
||||
} from "./api.js"
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ test("exposes every standard HTTP API group", () => {
|
|||
"projectCopy",
|
||||
"vcs",
|
||||
"debug",
|
||||
"websearch",
|
||||
])
|
||||
expect(Object.keys(client.debug)).toEqual(["location"])
|
||||
expect(Object.keys(client.debug.location)).toEqual(["list", "evict"])
|
||||
|
|
@ -41,6 +42,7 @@ test("exposes every standard HTTP API group", () => {
|
|||
expect(Object.keys(client.integration.connect)).toEqual(["key"])
|
||||
expect(Object.keys(client.integration.oauth)).toEqual(["connect", "status", "complete", "cancel"])
|
||||
expect(Object.keys(client.integration.command)).toEqual(["connect", "status", "cancel"])
|
||||
expect(Object.keys(client.websearch)).toEqual(["providers", "query"])
|
||||
expect(Object.keys(client.file)).toEqual(["read", "list", "find"])
|
||||
expect(Object.keys(client.vcs)).toEqual(["status", "diff"])
|
||||
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"])
|
||||
|
|
@ -48,6 +50,37 @@ test("exposes every standard HTTP API group", () => {
|
|||
expect(Object.keys(client.project)).toEqual(["list", "current", "directories"])
|
||||
})
|
||||
|
||||
test("websearch.query uses the public HTTP contract", async () => {
|
||||
let request: Request | undefined
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input, init) => {
|
||||
request = input instanceof Request ? input : new Request(input, init)
|
||||
return Response.json({
|
||||
location: { directory: "/tmp/project", project: { id: "proj_test", directory: "/tmp/project" } },
|
||||
data: {
|
||||
providerID: "exa",
|
||||
results: [{ url: "https://example.com", title: "Result", content: "result", time: {} }],
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const result = await client.websearch.query({
|
||||
query: "opencode",
|
||||
providerID: "exa",
|
||||
location: { directory: "/tmp/project" },
|
||||
})
|
||||
|
||||
expect(result.data).toEqual({
|
||||
providerID: "exa",
|
||||
results: [{ url: "https://example.com", title: "Result", content: "result", time: {} }],
|
||||
})
|
||||
expect(request?.method).toBe("POST")
|
||||
expect(request?.url).toBe("http://localhost:3000/api/websearch?location%5Bdirectory%5D=%2Ftmp%2Fproject")
|
||||
expect(await request?.json()).toEqual({ query: "opencode", providerID: "exa" })
|
||||
})
|
||||
|
||||
test("server.get uses the public HTTP contract", async () => {
|
||||
let request: Request | undefined
|
||||
const client = OpenCode.make({
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { ConfigModel } from "./config/model"
|
|||
import { ConfigPlugin } from "./config/plugin"
|
||||
import { ConfigProvider } from "./config/provider"
|
||||
import { ConfigReference } from "./config/reference"
|
||||
import { ConfigWebSearch } from "./config/websearch"
|
||||
import { ConfigToolOutput } from "./config/tool-output"
|
||||
import { ConfigVariable } from "./config/variable"
|
||||
import { ConfigWatcher } from "./config/watcher"
|
||||
|
|
@ -108,6 +109,9 @@ export class Info extends Schema.Class<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",
|
||||
}),
|
||||
|
|
|
|||
27
packages/core/src/config/plugin/websearch.ts
Normal file
27
packages/core/src/config/plugin/websearch.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
export * as ConfigWebSearchPlugin from "./websearch"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.config.websearch",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const loaded = { entries: yield* config.entries() }
|
||||
yield* ctx.websearch.transform((websearch) => {
|
||||
const providerID = Config.latest(loaded.entries, "websearch")?.provider
|
||||
if (providerID) websearch.default.set(providerID)
|
||||
})
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runForEach(() =>
|
||||
config.entries().pipe(
|
||||
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
|
||||
Effect.andThen(ctx.websearch.reload()),
|
||||
),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}),
|
||||
})
|
||||
8
packages/core/src/config/websearch.ts
Normal file
8
packages/core/src/config/websearch.ts
Normal 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,
|
||||
}) {}
|
||||
|
|
@ -29,6 +29,7 @@ import { Pty } from "./pty"
|
|||
import { QuestionV2 } from "./question"
|
||||
import { Shell } from "./shell"
|
||||
import { Reference } from "./reference"
|
||||
import { WebSearch } from "./websearch"
|
||||
import { ReferenceInstructions } from "./reference/instructions"
|
||||
import { SessionRunnerLLM } from "./session/runner/llm"
|
||||
import { SessionRunnerModel } from "./session/runner/model"
|
||||
|
|
@ -56,6 +57,7 @@ const locationServiceNodes = [
|
|||
AgentV2.node,
|
||||
CommandV2.node,
|
||||
Reference.node,
|
||||
WebSearch.node,
|
||||
Integration.node,
|
||||
Catalog.node,
|
||||
ModelResolver.node,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { Integration } from "./integration"
|
|||
import { Location } from "./location"
|
||||
import { PluginHost } from "./plugin/host"
|
||||
import { PluginRuntime } from "./plugin/runtime"
|
||||
import { WebSearch } from "./websearch"
|
||||
import { Reference } from "./reference"
|
||||
import { SkillV2 } from "./skill"
|
||||
import { State } from "./state"
|
||||
|
|
@ -158,5 +159,6 @@ export const node = makeLocationNode({
|
|||
ToolHooks.node,
|
||||
PluginHooks.node,
|
||||
PluginRuntime.node,
|
||||
WebSearch.node,
|
||||
],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
export * as PluginHost from "./host"
|
||||
|
||||
import type { Plugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Plugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
|
||||
import type { CredentialOAuth } from "@opencode-ai/sdk/v2/types"
|
||||
import { EventManifest } from "@opencode-ai/schema/event-manifest"
|
||||
import { App } from "../app"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
|
|
@ -23,6 +25,7 @@ import { Tool } from "../tool/tool"
|
|||
import { Tools } from "../tool/tools"
|
||||
import { ToolHooks } from "../tool/hooks"
|
||||
import { WorkspaceV2 } from "../workspace"
|
||||
import { WebSearch } from "../websearch"
|
||||
import { PluginHooks } from "./hooks"
|
||||
|
||||
const mutable = <T>(value: T) => value as DeepMutable<T>
|
||||
|
|
@ -38,6 +41,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||
const reference = yield* Reference.Service
|
||||
const skill = yield* SkillV2.Service
|
||||
const tools = yield* Tools.Service
|
||||
const websearch = yield* WebSearch.Service
|
||||
const toolHooks = yield* ToolHooks.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
|
|
@ -247,79 +251,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||
remove: (id) => draft.remove(Integration.ID.make(id)),
|
||||
method: {
|
||||
list: (id) => mutable(draft.method.list(Integration.ID.make(id))),
|
||||
update: (input) => {
|
||||
if ("authorize" in input) {
|
||||
const methodID = Integration.MethodID.make(input.method.id)
|
||||
const refresh = input.refresh
|
||||
draft.method.update({
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: { ...input.method, id: methodID },
|
||||
authorize: (inputs) =>
|
||||
input.authorize(inputs).pipe(
|
||||
Effect.map((authorization) => {
|
||||
if (authorization.mode === "auto") {
|
||||
return {
|
||||
...authorization,
|
||||
callback: authorization.callback.pipe(
|
||||
Effect.map((credential) =>
|
||||
Credential.OAuth.make({
|
||||
...credential,
|
||||
methodID: Integration.MethodID.make(credential.methodID),
|
||||
}),
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
return {
|
||||
...authorization,
|
||||
callback: (code: string) =>
|
||||
authorization.callback(code).pipe(
|
||||
Effect.map((credential) =>
|
||||
Credential.OAuth.make({
|
||||
...credential,
|
||||
methodID: Integration.MethodID.make(credential.methodID),
|
||||
}),
|
||||
),
|
||||
),
|
||||
}
|
||||
}),
|
||||
),
|
||||
...(refresh
|
||||
? {
|
||||
refresh: (value: Credential.OAuth) =>
|
||||
refresh(value).pipe(
|
||||
Effect.map((next) =>
|
||||
Credential.OAuth.make({
|
||||
...next,
|
||||
methodID: Integration.MethodID.make(next.methodID),
|
||||
}),
|
||||
),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(input.label ? { label: input.label } : {}),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (input.method.type === "env") {
|
||||
draft.method.update({
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: { type: "env", names: input.method.names },
|
||||
})
|
||||
return
|
||||
}
|
||||
if (input.method.type === "command") {
|
||||
draft.method.update({
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: Schema.decodeUnknownSync(Integration.CommandMethod)(input.method),
|
||||
})
|
||||
return
|
||||
}
|
||||
draft.method.update({
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: { type: "key", label: input.method.label },
|
||||
})
|
||||
},
|
||||
update: (input) => draft.method.update(methodImplementation(input)),
|
||||
remove: (id, method) =>
|
||||
draft.method.remove(Integration.ID.make(id), Schema.decodeUnknownSync(Integration.Method)(method)),
|
||||
},
|
||||
|
|
@ -430,6 +362,32 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||
})
|
||||
},
|
||||
},
|
||||
websearch: {
|
||||
providers: () => response(websearch.providers()),
|
||||
query: (input) =>
|
||||
response(
|
||||
websearch.query({
|
||||
query: input.query,
|
||||
providerID: input.providerID === undefined ? undefined : WebSearch.ID.make(input.providerID),
|
||||
}),
|
||||
),
|
||||
reload: websearch.reload,
|
||||
transform: (callback) =>
|
||||
websearch.transform((draft) => {
|
||||
callback({
|
||||
add: (definition) =>
|
||||
draft.add({
|
||||
id: WebSearch.ID.make(definition.id),
|
||||
name: definition.name,
|
||||
execute: definition.execute,
|
||||
}),
|
||||
default: {
|
||||
get: draft.default.get,
|
||||
set: (providerID) => draft.default.set(WebSearch.ID.make(providerID)),
|
||||
},
|
||||
})
|
||||
}),
|
||||
},
|
||||
session: {
|
||||
hook: (name, callback) => hooks.register("session", name, callback),
|
||||
create: (input) =>
|
||||
|
|
@ -449,3 +407,50 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||
},
|
||||
} satisfies Plugin.Context
|
||||
})
|
||||
|
||||
function methodImplementation(input: IntegrationMethodRegistration): Integration.Implementation {
|
||||
if ("authorize" in input) {
|
||||
const refresh = input.refresh
|
||||
return {
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: { ...input.method, id: Integration.MethodID.make(input.method.id) },
|
||||
authorize: (inputs) =>
|
||||
input.authorize(inputs).pipe(
|
||||
Effect.map((authorization) => {
|
||||
if (authorization.mode === "auto") {
|
||||
return {
|
||||
...authorization,
|
||||
callback: authorization.callback.pipe(Effect.map(credential)),
|
||||
}
|
||||
}
|
||||
return {
|
||||
...authorization,
|
||||
callback: (code: string) => authorization.callback(code).pipe(Effect.map(credential)),
|
||||
}
|
||||
}),
|
||||
),
|
||||
...(refresh ? { refresh: (value: Credential.OAuth) => refresh(value).pipe(Effect.map(credential)) } : {}),
|
||||
...(input.label ? { label: input.label } : {}),
|
||||
}
|
||||
}
|
||||
if (input.method.type === "env") {
|
||||
return {
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: { type: "env", names: input.method.names },
|
||||
}
|
||||
}
|
||||
if (input.method.type === "command") {
|
||||
return {
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: Schema.decodeUnknownSync(Integration.CommandMethod)(input.method),
|
||||
}
|
||||
}
|
||||
return {
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: { type: "key", label: input.method.label },
|
||||
}
|
||||
}
|
||||
|
||||
function credential(value: CredentialOAuth) {
|
||||
return Credential.OAuth.make({ ...value, methodID: Integration.MethodID.make(value.methodID) })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { ConfigProviderPlugin } from "../config/plugin/provider"
|
|||
import { ConfigPolicyPlugin } from "../config/plugin/policy"
|
||||
import { ConfigReferencePlugin } from "../config/plugin/reference"
|
||||
import { ConfigSkillPlugin } from "../config/plugin/skill"
|
||||
import { ConfigWebSearchPlugin } from "../config/plugin/websearch"
|
||||
import { EventV2 } from "../event"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { Form } from "../form"
|
||||
|
|
@ -21,12 +22,14 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
|||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Image } from "../image"
|
||||
import { Integration } from "../integration"
|
||||
import { KV } from "../kv"
|
||||
import { Location } from "../location"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { ModelsDev } from "../models-dev"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Reference } from "../reference"
|
||||
import { WebSearch } from "../websearch"
|
||||
import { Ripgrep } from "../ripgrep"
|
||||
import { SessionInstructions } from "../session/instructions"
|
||||
import { Shell } from "../shell"
|
||||
|
|
@ -50,6 +53,7 @@ import { AgentPlugin } from "./agent"
|
|||
import { CommandPlugin } from "./command"
|
||||
import { ModelsDevPlugin } from "./models-dev"
|
||||
import { ProviderPlugins } from "./provider"
|
||||
import { WebSearchPlugins } from "./websearch"
|
||||
import { PluginRuntime } from "./runtime"
|
||||
import { SkillPlugin } from "./skill"
|
||||
import { SystemPromptPlugin } from "./system-prompt"
|
||||
|
|
@ -70,6 +74,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
|||
const http = yield* HttpClient.HttpClient
|
||||
const image = yield* Image.Service
|
||||
const integration = yield* Integration.Service
|
||||
const kv = yield* KV.Service
|
||||
const location = yield* Location.Service
|
||||
const locationMutation = yield* LocationMutation.Service
|
||||
const models = yield* ModelsDev.Service
|
||||
|
|
@ -79,12 +84,12 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
|||
const form = yield* Form.Service
|
||||
const read = yield* ReadToolFileSystem.Service
|
||||
const reference = yield* Reference.Service
|
||||
const websearch = yield* WebSearch.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const instructions = yield* SessionInstructions.Service
|
||||
const shell = yield* Shell.Service
|
||||
const skill = yield* SkillV2.Service
|
||||
const tools = yield* Tools.Service
|
||||
const websearch = yield* WebSearchTool.ConfigService
|
||||
const wellknown = yield* WellKnown.Service
|
||||
return Context.mergeAll(
|
||||
Context.make(AgentV2.Service, agent),
|
||||
|
|
@ -99,6 +104,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
|||
Context.make(HttpClient.HttpClient, http),
|
||||
Context.make(Image.Service, image),
|
||||
Context.make(Integration.Service, integration),
|
||||
Context.make(KV.Service, kv),
|
||||
Context.make(Location.Service, location),
|
||||
Context.make(LocationMutation.Service, locationMutation),
|
||||
Context.make(ModelsDev.Service, models),
|
||||
|
|
@ -108,12 +114,12 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
|||
Context.make(Form.Service, form),
|
||||
Context.make(ReadToolFileSystem.Service, read),
|
||||
Context.make(Reference.Service, reference),
|
||||
Context.make(WebSearch.Service, websearch),
|
||||
Context.make(Ripgrep.Service, ripgrep),
|
||||
Context.make(SessionInstructions.Service, instructions),
|
||||
Context.make(Shell.Service, shell),
|
||||
Context.make(SkillV2.Service, skill),
|
||||
Context.make(Tools.Service, tools),
|
||||
Context.make(WebSearchTool.ConfigService, websearch),
|
||||
Context.make(WellKnown.Service, wellknown),
|
||||
)
|
||||
})
|
||||
|
|
@ -132,6 +138,7 @@ const pre = [
|
|||
...SystemPromptPlugin.Plugins,
|
||||
ModelsDevPlugin,
|
||||
...ProviderPlugins,
|
||||
...WebSearchPlugins,
|
||||
PatchTool.Plugin,
|
||||
EditTool.Plugin,
|
||||
GlobTool.Plugin,
|
||||
|
|
@ -153,6 +160,7 @@ const post = [
|
|||
ConfigCommandPlugin.Plugin,
|
||||
ConfigSkillPlugin.Plugin,
|
||||
ConfigProviderPlugin.Plugin,
|
||||
ConfigWebSearchPlugin.Plugin,
|
||||
VariantPlugin.Plugin,
|
||||
ConfigPolicyPlugin.Plugin,
|
||||
] as const satisfies readonly InternalPlugin[]
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { AbsolutePath } from "@opencode-ai/schema/schema"
|
|||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
import { DateTime, Effect, Scope, Stream } from "effect"
|
||||
import { Tool } from "../tool/tool"
|
||||
|
||||
|
|
@ -197,6 +198,31 @@ export function fromPromise(plugin: Plugin) {
|
|||
hook: (name, callback) =>
|
||||
register(host.tool.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
||||
},
|
||||
websearch: {
|
||||
providers: (input) => run(host.websearch.providers(input)),
|
||||
query: (input) =>
|
||||
run(
|
||||
host.websearch.query({
|
||||
...input,
|
||||
providerID: input.providerID === undefined ? undefined : WebSearch.ID.make(input.providerID),
|
||||
}),
|
||||
),
|
||||
reload: () => run(host.websearch.reload()),
|
||||
transform: (callback) =>
|
||||
register(
|
||||
host.websearch.transform((draft) => {
|
||||
callback({
|
||||
add: (definition) =>
|
||||
draft.add({
|
||||
id: definition.id,
|
||||
name: definition.name,
|
||||
execute: (input) => attempt((signal) => definition.execute(input, { signal })),
|
||||
}),
|
||||
default: draft.default,
|
||||
})
|
||||
}),
|
||||
),
|
||||
},
|
||||
session: {
|
||||
hook: (name, callback) =>
|
||||
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
||||
|
|
@ -270,6 +296,10 @@ export function fromPromise(plugin: Plugin) {
|
|||
})
|
||||
}
|
||||
|
||||
function attempt<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),
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
|||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Image } from "../image"
|
||||
import { Integration } from "../integration"
|
||||
import { KV } from "../kv"
|
||||
import { Location } from "../location"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { ModelsDev } from "../models-dev"
|
||||
|
|
@ -34,7 +35,7 @@ import { Shell } from "../shell"
|
|||
import { SkillV2 } from "../skill"
|
||||
import { ReadToolFileSystem } from "../tool/read-filesystem"
|
||||
import { ToolRegistry } from "../tool/registry"
|
||||
import { WebSearchTool } from "../tool/websearch"
|
||||
import { WebSearch } from "../websearch"
|
||||
import { WellKnown } from "../wellknown"
|
||||
import { PluginInternal } from "./internal"
|
||||
import { PluginRuntime } from "./runtime"
|
||||
|
|
@ -289,6 +290,7 @@ export const node = makeLocationNode({
|
|||
httpClient,
|
||||
Image.node,
|
||||
Integration.node,
|
||||
KV.node,
|
||||
Location.node,
|
||||
LocationMutation.node,
|
||||
ModelsDev.node,
|
||||
|
|
@ -303,7 +305,7 @@ export const node = makeLocationNode({
|
|||
Shell.node,
|
||||
SkillV2.node,
|
||||
ToolRegistry.toolsNode,
|
||||
WebSearchTool.configNode,
|
||||
WebSearch.node,
|
||||
WellKnown.node,
|
||||
],
|
||||
})
|
||||
|
|
|
|||
82
packages/core/src/plugin/websearch/exa.ts
Normal file
82
packages/core/src/plugin/websearch/exa.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
export * as WebSearchExa from "./exa"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Effect, Schema, Scope } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { WebSearchMcp } from "./mcp"
|
||||
|
||||
export const endpoint = "https://mcp.exa.ai/mcp"
|
||||
|
||||
const McpInput = Schema.Struct({
|
||||
query: Schema.String,
|
||||
numResults: Schema.Number.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
const McpOutput = Schema.Struct({
|
||||
content: Schema.Array(
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("text"),
|
||||
text: Schema.String,
|
||||
_meta: Schema.Struct({ searchTime: Schema.Number }).pipe(Schema.optional),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
|
||||
id: "opencode.websearch.exa",
|
||||
effect: Effect.fn("WebSearchExa.Plugin")(function* (ctx) {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
yield* ctx.integration.transform((draft) => {
|
||||
draft.update("exa", (integration) => (integration.name = "Exa"))
|
||||
draft.method.update({
|
||||
integrationID: "exa",
|
||||
method: { type: "key", label: "API key (optional)" },
|
||||
})
|
||||
draft.method.update({
|
||||
integrationID: "exa",
|
||||
method: { type: "env", names: ["EXA_API_KEY"] },
|
||||
})
|
||||
})
|
||||
yield* ctx.websearch.transform((draft) => {
|
||||
draft.add({
|
||||
id: "exa",
|
||||
name: "Exa",
|
||||
execute: (input) =>
|
||||
Effect.gen(function* () {
|
||||
const connection = yield* ctx.integration.connection.active("exa")
|
||||
const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined
|
||||
const url = new URL(endpoint)
|
||||
if (credential?.type === "key") url.searchParams.set("exaApiKey", credential.key)
|
||||
const result = yield* WebSearchMcp.call(
|
||||
http,
|
||||
url.toString(),
|
||||
"web_search_exa",
|
||||
{ input: McpInput, output: McpOutput },
|
||||
{ query: input.query, numResults: 8 },
|
||||
)
|
||||
const content = result?.content.find((item) => item.text)
|
||||
return content ? parseResults(content.text) : []
|
||||
}),
|
||||
})
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
function parseResults(text: string) {
|
||||
return text.split(/\n\n---\n\n/).flatMap((block) => {
|
||||
const url = block.match(/^URL:\s*(.+)$/m)?.[1]?.trim()
|
||||
if (!url) return []
|
||||
const title = block.match(/^Title:\s*(.+)$/m)?.[1]?.trim()
|
||||
const publishedText = block.match(/^Published:\s*(.+)$/m)?.[1]?.trim()
|
||||
const published = publishedText && publishedText !== "N/A" ? Date.parse(publishedText) : undefined
|
||||
const content = block.match(/^(?:Highlights|Text):\s*\n?([\s\S]*)$/m)?.[1]?.trim()
|
||||
return [
|
||||
{
|
||||
url,
|
||||
...(title && title !== "N/A" ? { title } : {}),
|
||||
...(content ? { content } : {}),
|
||||
time: { ...(published !== undefined && Number.isFinite(published) ? { published } : {}) },
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
4
packages/core/src/plugin/websearch/index.ts
Normal file
4
packages/core/src/plugin/websearch/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { WebSearchExa } from "./exa"
|
||||
import { WebSearchParallel } from "./parallel"
|
||||
|
||||
export const WebSearchPlugins = [WebSearchExa.Plugin, WebSearchParallel.Plugin] as const
|
||||
68
packages/core/src/plugin/websearch/mcp.ts
Normal file
68
packages/core/src/plugin/websearch/mcp.ts
Normal 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`)),
|
||||
}),
|
||||
)
|
||||
})
|
||||
103
packages/core/src/plugin/websearch/parallel.ts
Normal file
103
packages/core/src/plugin/websearch/parallel.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
export * as WebSearchParallel from "./parallel"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Effect, Schema, Scope } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { App } from "../../app"
|
||||
import { WebSearchMcp } from "./mcp"
|
||||
|
||||
export const endpoint = "https://search.parallel.ai/mcp"
|
||||
|
||||
const McpInput = Schema.Struct({
|
||||
objective: Schema.String,
|
||||
search_queries: Schema.Array(Schema.String),
|
||||
model_name: Schema.String.check(Schema.isMaxLength(100)).pipe(Schema.optional),
|
||||
})
|
||||
|
||||
const SearchResponse = Schema.Struct({
|
||||
search_id: Schema.String,
|
||||
results: Schema.Array(
|
||||
Schema.Struct({
|
||||
url: Schema.String,
|
||||
title: Schema.NullOr(Schema.String).pipe(Schema.optional),
|
||||
publish_date: Schema.NullOr(Schema.String).pipe(Schema.optional),
|
||||
excerpts: Schema.Array(Schema.String),
|
||||
}),
|
||||
),
|
||||
warnings: Schema.NullOr(
|
||||
Schema.Array(
|
||||
Schema.Struct({
|
||||
type: Schema.Literals(["spec_validation_warning", "input_validation_warning", "warning"]),
|
||||
message: Schema.String,
|
||||
detail: Schema.NullOr(Schema.Record(Schema.String, Schema.Json)).pipe(Schema.optional),
|
||||
}),
|
||||
),
|
||||
).pipe(Schema.optional),
|
||||
usage: Schema.NullOr(
|
||||
Schema.Array(
|
||||
Schema.Struct({
|
||||
name: Schema.String,
|
||||
count: Schema.Int,
|
||||
}),
|
||||
),
|
||||
).pipe(Schema.optional),
|
||||
session_id: Schema.String,
|
||||
})
|
||||
const McpOutput = Schema.Struct({
|
||||
content: Schema.Array(Schema.Struct({ type: Schema.Literal("text"), text: Schema.String })),
|
||||
structuredContent: SearchResponse,
|
||||
})
|
||||
|
||||
export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
|
||||
id: "opencode.websearch.parallel",
|
||||
effect: Effect.fn("WebSearchParallel.Plugin")(function* (ctx) {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
yield* ctx.integration.transform((draft) => {
|
||||
draft.update("parallel", (integration) => (integration.name = "Parallel"))
|
||||
draft.method.update({
|
||||
integrationID: "parallel",
|
||||
method: { type: "key", label: "API key (optional)" },
|
||||
})
|
||||
draft.method.update({
|
||||
integrationID: "parallel",
|
||||
method: { type: "env", names: ["PARALLEL_API_KEY"] },
|
||||
})
|
||||
})
|
||||
yield* ctx.websearch.transform((draft) => {
|
||||
draft.add({
|
||||
id: "parallel",
|
||||
name: "Parallel",
|
||||
execute: (input) =>
|
||||
Effect.gen(function* () {
|
||||
const connection = yield* ctx.integration.connection.active("parallel")
|
||||
const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined
|
||||
const result = yield* WebSearchMcp.call(
|
||||
http,
|
||||
endpoint,
|
||||
"web_search",
|
||||
{ input: McpInput, output: McpOutput },
|
||||
{
|
||||
objective: input.query,
|
||||
search_queries: [input.query],
|
||||
},
|
||||
{
|
||||
"User-Agent": App.useragent(ctx.app),
|
||||
...(credential?.type === "key" ? { Authorization: `Bearer ${credential.key}` } : {}),
|
||||
},
|
||||
)
|
||||
return (
|
||||
result?.structuredContent.results.map((item) => {
|
||||
const published = item.publish_date ? Date.parse(item.publish_date) : undefined
|
||||
return {
|
||||
url: item.url,
|
||||
...(item.title ? { title: item.title } : {}),
|
||||
...(item.excerpts.length ? { content: item.excerpts.join("\n\n") } : {}),
|
||||
time: { ...(published !== undefined && Number.isFinite(published) ? { published } : {}) },
|
||||
}
|
||||
}) ?? []
|
||||
)
|
||||
}),
|
||||
})
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
|
@ -2,262 +2,122 @@ export * as WebSearchTool from "./websearch"
|
|||
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Context, Duration, Effect, Layer, Schema } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { App } from "../app"
|
||||
import { PositiveInt } from "../schema"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Form } from "../form"
|
||||
import { KV } from "../kv"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Tool } from "./tool"
|
||||
import { collectBoundedResponseBody } from "./http-body"
|
||||
import { checksum } from "../util/encode"
|
||||
import { WebSearch } from "../websearch"
|
||||
|
||||
export const name = "websearch"
|
||||
export const NO_RESULTS = "No search results found. Please try a different query."
|
||||
export const EXA_URL = "https://mcp.exa.ai/mcp"
|
||||
export const PARALLEL_URL = "https://search.parallel.ai/mcp"
|
||||
export const MAX_NUM_RESULTS = 20
|
||||
export const MAX_CONTEXT_CHARACTERS = 50_000
|
||||
export const MAX_RESPONSE_BYTES = 256 * 1024
|
||||
|
||||
/**
|
||||
* Provider-independent local web search retained in V2 core for launch parity.
|
||||
* This invokes the legacy Exa/Parallel product backends itself. It is distinct
|
||||
* from provider-hosted web search tools, which remain route-owned and execute
|
||||
* at the model provider. Ownership of this compromise can be revisited later.
|
||||
*/
|
||||
export const description = `Search the web using the session's local web search provider. Use this for current information beyond knowledge cutoff.
|
||||
|
||||
This is a provider-independent local tool backed by Exa or Parallel. Provider-hosted web search tools are separate and execute at the model provider.
|
||||
|
||||
Optional controls support result count, live crawling ('fallback' or 'preferred'), search type ('auto', 'fast', or 'deep'), and maximum context characters.
|
||||
export const description = `Search the web using the user's selected search integration. Use this for current information beyond knowledge cutoff.
|
||||
|
||||
The current year is ${new Date().getFullYear()}. Use this year when searching for recent information or current events.`
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
query: Schema.String.annotate({ description: "Websearch query" }),
|
||||
numResults: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_NUM_RESULTS))).annotate({
|
||||
description: `Number of search results to return (default: 8, maximum: ${MAX_NUM_RESULTS})`,
|
||||
}),
|
||||
livecrawl: Schema.optional(Schema.Literals(["fallback", "preferred"])).annotate({
|
||||
description:
|
||||
"Live crawl mode - 'fallback': use live crawling as backup if cached unavailable, 'preferred': prioritize live crawling (default: 'fallback')",
|
||||
}),
|
||||
type: Schema.optional(Schema.Literals(["auto", "fast", "deep"])).annotate({
|
||||
description: "Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search",
|
||||
}),
|
||||
contextMaxCharacters: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_CONTEXT_CHARACTERS))).annotate(
|
||||
{
|
||||
description: `Maximum characters for context string optimized for models (default: 10000, maximum: ${MAX_CONTEXT_CHARACTERS})`,
|
||||
},
|
||||
),
|
||||
})
|
||||
|
||||
export const Provider = Schema.Literals(["exa", "parallel"])
|
||||
export type Provider = typeof Provider.Type
|
||||
|
||||
export interface Config {
|
||||
readonly provider?: Provider
|
||||
readonly enableExa: boolean
|
||||
readonly enableParallel: boolean
|
||||
readonly exaApiKey?: string
|
||||
readonly parallelApiKey?: string
|
||||
}
|
||||
|
||||
export class ConfigService extends Context.Service<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,
|
||||
text: Schema.String,
|
||||
provider: WebSearch.ID,
|
||||
results: Schema.Array(WebSearch.Result),
|
||||
})
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.websearch",
|
||||
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const config = yield* ConfigService
|
||||
const permission = yield* PermissionV2.Service
|
||||
const forms = yield* Form.Service
|
||||
const kv = yield* KV.Service
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((draft) =>
|
||||
draft.add(
|
||||
name,
|
||||
Tool.make({
|
||||
{
|
||||
description,
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) => {
|
||||
const provider = selectProvider(context.sessionID, config, config.provider)
|
||||
return Effect.gen(function* () {
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [input.query],
|
||||
save: ["*"],
|
||||
metadata: { ...input, provider },
|
||||
metadata: input,
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: { type: "tool", messageID: context.messageID, callID: context.callID },
|
||||
})
|
||||
|
||||
const text =
|
||||
provider === "exa"
|
||||
? yield* callMcp(http, exaUrl(config.exaApiKey), "web_search_exa", ExaArgs, {
|
||||
query: input.query,
|
||||
type: input.type || "auto",
|
||||
numResults: input.numResults || 8,
|
||||
livecrawl: input.livecrawl || "fallback",
|
||||
contextMaxCharacters: input.contextMaxCharacters,
|
||||
const result = yield* ctx.websearch.query(input).pipe(
|
||||
Effect.catch((error) => {
|
||||
if (!Schema.is(WebSearch.ProviderRequiredError)(error)) return Effect.fail(error)
|
||||
return Effect.gen(function* () {
|
||||
const providers = (yield* ctx.websearch.providers()).data
|
||||
if (providers.length === 0) return yield* new WebSearch.ProviderRequiredError()
|
||||
const response = yield* forms.ask({
|
||||
sessionID: context.sessionID,
|
||||
title: "Choose a web search provider",
|
||||
metadata: { kind: "websearch.provider" },
|
||||
fields: [
|
||||
{
|
||||
key: "provider",
|
||||
title: "Provider",
|
||||
description: "This becomes your default and can be changed later in configuration.",
|
||||
type: "string",
|
||||
required: true,
|
||||
custom: false,
|
||||
options: [
|
||||
...providers.map((provider) => ({ value: provider.id, label: provider.name })),
|
||||
{ value: "__disable__", label: "Disable web search" },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
: yield* callMcp(
|
||||
http,
|
||||
PARALLEL_URL,
|
||||
"web_search",
|
||||
ParallelArgs,
|
||||
{
|
||||
objective: input.query,
|
||||
search_queries: [input.query],
|
||||
session_id: context.sessionID,
|
||||
// V2 invocation context does not safely expose the model yet.
|
||||
},
|
||||
{
|
||||
"User-Agent": App.useragent(ctx.app),
|
||||
...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}),
|
||||
},
|
||||
)
|
||||
if (response.status === "cancelled") return yield* Effect.fail(new Error("Web search cancelled"))
|
||||
const answer = response.answer.provider
|
||||
if (answer === "__disable__") {
|
||||
yield* kv.set("websearch:provider", false)
|
||||
return yield* new WebSearch.DisabledError()
|
||||
}
|
||||
if (typeof answer !== "string" || !providers.some((provider) => provider.id === answer))
|
||||
return yield* new WebSearch.ProviderRequiredError()
|
||||
yield* kv.set("websearch:provider", answer)
|
||||
return yield* ctx.websearch.query(input)
|
||||
})
|
||||
}),
|
||||
)
|
||||
const output = {
|
||||
provider,
|
||||
text: text ?? NO_RESULTS,
|
||||
provider: result.data.providerID,
|
||||
results: result.data.results,
|
||||
}
|
||||
return { output, content: output.text, metadata: { provider: output.provider } }
|
||||
const content = output.results.length
|
||||
? output.results
|
||||
.map((result) => {
|
||||
const title = result.title ?? result.url
|
||||
const published = result.time.published
|
||||
? `\nPublished: ${new Date(result.time.published).toISOString()}`
|
||||
: ""
|
||||
return `## [${title}](${result.url})${published}${result.content ? `\n\n${result.content}` : ""}`
|
||||
})
|
||||
.join("\n\n")
|
||||
: NO_RESULTS
|
||||
return { output, content, metadata: { provider: output.provider } }
|
||||
}).pipe(
|
||||
Effect.mapError(
|
||||
(error) => new ToolFailure({ message: `Unable to search the web for ${input.query}`, error }),
|
||||
),
|
||||
)
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
yield* ctx.session.hook("context", (event) =>
|
||||
Effect.gen(function* () {
|
||||
if ((yield* kv.get("websearch:provider")) === false) delete event.tools[name]
|
||||
}),
|
||||
)
|
||||
}),
|
||||
}
|
||||
|
|
|
|||
144
packages/core/src/websearch.ts
Normal file
144
packages/core/src/websearch.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
export * as WebSearch from "./websearch"
|
||||
|
||||
import { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { EventV2 } from "./event"
|
||||
import { KV } from "./kv"
|
||||
import { State } from "./state"
|
||||
|
||||
export const ID = WebSearch.ID
|
||||
export type ID = WebSearch.ID
|
||||
|
||||
export const Provider = WebSearch.Provider
|
||||
export type Provider = WebSearch.Provider
|
||||
|
||||
export const Event = WebSearch.Event
|
||||
|
||||
export const Input = WebSearch.Input
|
||||
export type Input = WebSearch.Input
|
||||
export type ProviderInput = WebSearch.ProviderInput
|
||||
|
||||
export const Result = WebSearch.Result
|
||||
export type Result = WebSearch.Result
|
||||
|
||||
export const Response = WebSearch.Response
|
||||
export type Response = WebSearch.Response
|
||||
|
||||
export interface ProviderImplementation extends Provider {
|
||||
readonly execute: (input: ProviderInput) => Effect.Effect<readonly Result[], unknown>
|
||||
}
|
||||
|
||||
export class ProviderRequiredError extends Schema.TaggedErrorClass<ProviderRequiredError>()(
|
||||
"WebSearch.ProviderRequired",
|
||||
{},
|
||||
) {}
|
||||
|
||||
export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()(
|
||||
"WebSearch.ProviderNotFound",
|
||||
{
|
||||
providerID: ID,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class DisabledError extends Schema.TaggedErrorClass<DisabledError>()("WebSearch.Disabled", {}) {}
|
||||
|
||||
export class RequestError extends Schema.TaggedErrorClass<RequestError>()("WebSearch.Request", {
|
||||
providerID: ID,
|
||||
cause: Schema.Defect(),
|
||||
}) {}
|
||||
|
||||
export type Error = ProviderRequiredError | ProviderNotFoundError | DisabledError | RequestError
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly providers: () => Effect.Effect<readonly Provider[]>
|
||||
readonly default: () => Effect.Effect<Provider | undefined, DisabledError>
|
||||
readonly query: (input: Input) => Effect.Effect<Response, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/WebSearch") {}
|
||||
|
||||
type Data = {
|
||||
readonly providers: Map<ID, ProviderImplementation>
|
||||
defaultProviderID?: ID
|
||||
}
|
||||
|
||||
export type Draft = {
|
||||
add: (provider: ProviderImplementation) => void
|
||||
default: {
|
||||
get: () => ID | undefined
|
||||
set: (providerID: ID) => void
|
||||
}
|
||||
}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const kv = yield* KV.Service
|
||||
const decodeResults = Schema.decodeUnknownEffect(Schema.Array(Result))
|
||||
const state = State.create<Data, Draft>({
|
||||
initial: () => ({ providers: new Map() }),
|
||||
draft: (draft) => ({
|
||||
add: (provider) => draft.providers.set(provider.id, provider),
|
||||
default: {
|
||||
get: () => draft.defaultProviderID,
|
||||
set: (providerID) => (draft.defaultProviderID = providerID),
|
||||
},
|
||||
}),
|
||||
finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
const requireProvider = (providers: Map<ID, ProviderImplementation>, providerID: ID) => {
|
||||
const provider = providers.get(providerID)
|
||||
return provider ? Effect.succeed(provider) : Effect.fail(new ProviderNotFoundError({ providerID }))
|
||||
}
|
||||
|
||||
const defaultProvider = Effect.fn("WebSearch.default")(function* () {
|
||||
const data = state.get()
|
||||
const configured = data.defaultProviderID ? data.providers.get(data.defaultProviderID) : undefined
|
||||
if (configured) return configured
|
||||
const stored = yield* kv.get("websearch:provider")
|
||||
if (stored === false) return yield* new DisabledError()
|
||||
if (typeof stored !== "string") return
|
||||
return data.providers.get(ID.make(stored))
|
||||
})
|
||||
|
||||
const resolve = Effect.fn("WebSearch.resolve")(function* (input: Input) {
|
||||
const providers = state.get().providers
|
||||
if (input.providerID) return yield* requireProvider(providers, input.providerID)
|
||||
const provider = yield* defaultProvider()
|
||||
if (!provider) return yield* new ProviderRequiredError()
|
||||
return provider
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
providers: Effect.fn("WebSearch.providers")(function* () {
|
||||
return Array.from(state.get().providers.values(), (provider) => ({
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
})).toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
}),
|
||||
default: Effect.fn("WebSearch.defaultInfo")(function* () {
|
||||
const provider = yield* defaultProvider()
|
||||
return provider && { id: provider.id, name: provider.name }
|
||||
}),
|
||||
query: Effect.fn("WebSearch.query")(function* (input) {
|
||||
const provider = yield* resolve(input)
|
||||
const results = yield* provider.execute({ query: input.query }).pipe(
|
||||
Effect.flatMap(decodeResults),
|
||||
Effect.mapError((cause) => new RequestError({ providerID: provider.id, cause })),
|
||||
)
|
||||
return new Response({ providerID: provider.id, results })
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [EventV2.node, KV.node],
|
||||
})
|
||||
|
|
@ -121,23 +121,15 @@ describe("Config", () => {
|
|||
const config = yield* Config.Service
|
||||
const entries = yield* config.entries()
|
||||
expect(
|
||||
entries.flatMap((entry) =>
|
||||
entry.type === "document" && entry.info.shell ? [entry.info.shell] : [],
|
||||
),
|
||||
entries.flatMap((entry) => (entry.type === "document" && entry.info.shell ? [entry.info.shell] : [])),
|
||||
).toEqual(["global", "explicit", "project", "content"])
|
||||
expect(Config.latest(entries, "shell")).toBe("content")
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
testLayer(
|
||||
project,
|
||||
global,
|
||||
project,
|
||||
undefined,
|
||||
undefined,
|
||||
emptyCredentialNode,
|
||||
emptyWellknownNode,
|
||||
{ file: explicit, content: JSON.stringify({ shell: "content" }) },
|
||||
),
|
||||
testLayer(project, global, project, undefined, undefined, emptyCredentialNode, emptyWellknownNode, {
|
||||
file: explicit,
|
||||
content: JSON.stringify({ shell: "content" }),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -155,31 +147,24 @@ describe("Config", () => {
|
|||
const global = path.join(tmp.path, "global")
|
||||
const project = path.join(tmp.path, "project")
|
||||
return Effect.promise(async () => {
|
||||
await fs.mkdir(global, { recursive: true })
|
||||
await fs.mkdir(project, { recursive: true })
|
||||
await fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ shell: "global" }))
|
||||
await fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ shell: "project" }))
|
||||
}).pipe(
|
||||
Effect.andThen(
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
expect(Config.latest(yield* config.entries(), "shell")).toBe("global")
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
testLayer(
|
||||
project,
|
||||
global,
|
||||
project,
|
||||
undefined,
|
||||
undefined,
|
||||
emptyCredentialNode,
|
||||
emptyWellknownNode,
|
||||
{ project: false },
|
||||
),
|
||||
),
|
||||
await fs.mkdir(global, { recursive: true })
|
||||
await fs.mkdir(project, { recursive: true })
|
||||
await fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ shell: "global" }))
|
||||
await fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ shell: "project" }))
|
||||
}).pipe(
|
||||
Effect.andThen(
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
expect(Config.latest(yield* config.entries(), "shell")).toBe("global")
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
testLayer(project, global, project, undefined, undefined, emptyCredentialNode, emptyWellknownNode, {
|
||||
project: false,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -53,13 +53,17 @@ export function waitForCodeModeTool(
|
|||
* full plugin host. Only the tool domain is live; focused tool tests exercise
|
||||
* registration, snapshots, and execution through the same path production uses.
|
||||
*/
|
||||
export const registerToolPlugin = <R>(plugin: {
|
||||
readonly id: string
|
||||
readonly effect: (context: PluginContext) => Effect.Effect<void, never, R>
|
||||
}): Effect.Effect<void, never, R | Tools.Service | Scope.Scope> =>
|
||||
export const registerToolPlugin = <R>(
|
||||
plugin: {
|
||||
readonly id: string
|
||||
readonly effect: (context: PluginContext) => Effect.Effect<void, never, R>
|
||||
},
|
||||
overrides: Parameters<typeof host>[0] = {},
|
||||
): Effect.Effect<void, never, R | Tools.Service | Scope.Scope> =>
|
||||
Effect.gen(function* () {
|
||||
const tools = yield* Tools.Service
|
||||
const context = host({
|
||||
...overrides,
|
||||
session: {
|
||||
hook: () => Effect.succeed({ dispose: Effect.void }),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { AgentV2 } from "@opencode-ai/core/agent"
|
|||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { CommandV2 } from "@opencode-ai/core/command"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
|
||||
|
|
@ -9,6 +10,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
|||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Form } from "@opencode-ai/core/form"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
|
|
@ -19,6 +21,7 @@ import { Reference } from "@opencode-ai/core/reference"
|
|||
import { SkillV2 } from "@opencode-ai/core/skill"
|
||||
import { ToolHooks } from "@opencode-ai/core/tool/hooks"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { tempLocationLayer } from "../fixture/location"
|
||||
|
||||
|
|
@ -39,6 +42,7 @@ export const PluginTestLayer = AppNodeBuilder.build(
|
|||
Npm.node,
|
||||
Credential.node,
|
||||
EventV2.node,
|
||||
Form.node,
|
||||
LayerNodePlatform.httpClient,
|
||||
PluginV2.node,
|
||||
AgentV2.node,
|
||||
|
|
@ -52,9 +56,11 @@ export const PluginTestLayer = AppNodeBuilder.build(
|
|||
SkillV2.node,
|
||||
ToolHooks.node,
|
||||
ToolRegistry.toolsNode,
|
||||
WebSearch.node,
|
||||
]),
|
||||
[
|
||||
[Location.node, tempLocationLayer],
|
||||
[Npm.node, npmLayer],
|
||||
[Config.node, Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))],
|
||||
],
|
||||
) as unknown as Layer.Layer<unknown, never>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Plugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
import type {
|
||||
CredentialOAuth,
|
||||
IntegrationCommandMethod,
|
||||
IntegrationEnvMethod,
|
||||
IntegrationKeyMethod,
|
||||
|
|
@ -13,11 +18,11 @@ import type {
|
|||
} from "@opencode-ai/sdk/v2/types"
|
||||
import { Effect, Stream } from "effect"
|
||||
|
||||
type Overrides = Partial<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 {
|
||||
app: overrides.app ?? { name: "test", version: "test", channel: "test" },
|
||||
options: {},
|
||||
|
|
@ -92,6 +97,12 @@ export function host(overrides: Overrides = {}): PluginContext {
|
|||
transform: () => Effect.die("unused tool.transform"),
|
||||
hook: () => Effect.die("unused tool.hook"),
|
||||
},
|
||||
websearch: overrides.websearch ?? {
|
||||
providers: () => Effect.die("unused websearch.providers"),
|
||||
query: () => Effect.die("unused websearch.query"),
|
||||
transform: () => Effect.die("unused websearch.transform"),
|
||||
reload: () => Effect.die("unused websearch.reload"),
|
||||
},
|
||||
session: {
|
||||
hook: overrides.session?.hook ?? (() => Effect.die("unused session.hook")),
|
||||
create: overrides.session?.create ?? (() => Effect.die("unused session.create")),
|
||||
|
|
@ -105,7 +116,7 @@ export function host(overrides: Overrides = {}): PluginContext {
|
|||
}
|
||||
}
|
||||
|
||||
export function agentHost(agent: AgentV2.Interface): PluginContext["agent"] {
|
||||
export function agentHost(agent: AgentV2.Interface): Plugin.Context["agent"] {
|
||||
return {
|
||||
get: (id) => agent.get(AgentV2.ID.make(id)).pipe(Effect.map((value) => value && agentInfo(value))),
|
||||
list: () => Effect.die("unused agent.list"),
|
||||
|
|
@ -131,7 +142,7 @@ export function agentHost(agent: AgentV2.Interface): PluginContext["agent"] {
|
|||
}
|
||||
}
|
||||
|
||||
export function catalogHost(catalog: Catalog.Interface): PluginContext["catalog"] {
|
||||
export function catalogHost(catalog: Catalog.Interface): Plugin.Context["catalog"] {
|
||||
return {
|
||||
provider: {
|
||||
list: () => Effect.die("unused catalog.provider.list"),
|
||||
|
|
@ -207,7 +218,7 @@ export function catalogHost(catalog: Catalog.Interface): PluginContext["catalog"
|
|||
}
|
||||
}
|
||||
|
||||
export function integrationHost(integration: Integration.Interface): PluginContext["integration"] {
|
||||
export function integrationHost(integration: Integration.Interface): Plugin.Context["integration"] {
|
||||
return {
|
||||
list: () => Effect.die("unused integration.list"),
|
||||
get: () => Effect.die("unused integration.get"),
|
||||
|
|
@ -329,6 +340,40 @@ export function integrationHost(integration: Integration.Interface): PluginConte
|
|||
}
|
||||
}
|
||||
|
||||
export function webSearchHost(websearch: WebSearch.Interface): Plugin.Context["websearch"] {
|
||||
const location = Location.Info.make({
|
||||
directory: AbsolutePath.make("/tmp/websearch-test"),
|
||||
project: { id: Project.ID.make("websearch-test"), directory: AbsolutePath.make("/tmp/websearch-test") },
|
||||
})
|
||||
return {
|
||||
providers: () => websearch.providers().pipe(Effect.map((data) => ({ location, data }))),
|
||||
query: (input) =>
|
||||
websearch
|
||||
.query({ query: input.query, providerID: input.providerID && WebSearch.ID.make(input.providerID) })
|
||||
.pipe(Effect.map((data) => ({ location, data }))),
|
||||
reload: websearch.reload,
|
||||
transform: (callback) =>
|
||||
websearch.transform((draft) => {
|
||||
callback({
|
||||
add: (definition) =>
|
||||
draft.add({
|
||||
id: WebSearch.ID.make(definition.id),
|
||||
name: definition.name,
|
||||
execute: definition.execute,
|
||||
}),
|
||||
default: {
|
||||
get: draft.default.get,
|
||||
set: (providerID) => draft.default.set(WebSearch.ID.make(providerID)),
|
||||
},
|
||||
})
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function oauthCredential(value: CredentialOAuth) {
|
||||
return Credential.OAuth.make({ ...value, methodID: Integration.MethodID.make(value.methodID) })
|
||||
}
|
||||
|
||||
function method(value: Integration.Method) {
|
||||
if (value.type === "env") return { type: value.type, names: [...value.names] }
|
||||
if (value.type === "key") return { type: value.type, label: value.label }
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { PluginV2 } from "@opencode-ai/core/plugin"
|
|||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionPending } from "@opencode-ai/core/session/pending"
|
||||
|
|
@ -244,6 +245,38 @@ describe("fromPromise", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("registers a standalone web search provider", () =>
|
||||
Effect.gen(function* () {
|
||||
const websearch = yield* WebSearch.Service
|
||||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
const promisePlugin = Plugin.define({
|
||||
id: "promise-websearch",
|
||||
setup: async (ctx) => {
|
||||
await ctx.websearch.transform((draft) => {
|
||||
draft.add({
|
||||
id: "promise-websearch",
|
||||
name: "Promise Web Search",
|
||||
execute: async (input) => [{ url: "https://example.com", content: `promise: ${input.query}`, time: {} }],
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
yield* PluginPromise.fromPromise(promisePlugin).effect(host)
|
||||
expect(yield* websearch.providers()).toContainEqual({
|
||||
id: WebSearch.ID.make("promise-websearch"),
|
||||
name: "Promise Web Search",
|
||||
})
|
||||
expect(yield* websearch.query({ query: "effect", providerID: WebSearch.ID.make("promise-websearch") })).toEqual(
|
||||
new WebSearch.Response({
|
||||
providerID: WebSearch.ID.make("promise-websearch"),
|
||||
results: [{ url: "https://example.com", content: "promise: effect", time: {} }],
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("runs the setup cleanup when the plugin scope closes", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
|
|
|
|||
50
packages/core/test/plugin/websearch-fixture.ts
Normal file
50
packages/core/test/plugin/websearch-fixture.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { Effect, Layer } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Form } from "@opencode-ai/core/form"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
interface WebSearchRequest {
|
||||
readonly url: string
|
||||
readonly headers: Record<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, WebSearch.node]),
|
||||
[[Config.node, Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))]],
|
||||
),
|
||||
http,
|
||||
),
|
||||
)
|
||||
170
packages/core/test/plugin/websearch.test.ts
Normal file
170
packages/core/test/plugin/websearch.test.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import { beforeEach, describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
import { WebSearchExa } from "@opencode-ai/core/plugin/websearch/exa"
|
||||
import { WebSearchParallel } from "@opencode-ai/core/plugin/websearch/parallel"
|
||||
import { host, integrationHost, webSearchHost } from "./host"
|
||||
import { requests, resetWebSearchFixture, webSearchIntegrationTest } from "./websearch-fixture"
|
||||
|
||||
beforeEach(() => {
|
||||
resetWebSearchFixture(
|
||||
`event: message\ndata: ${JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
result: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Title: Effect\nURL: https://effect.website\nPublished: 2026-07-25T00:00:00.000Z\nAuthor: N/A\nHighlights:\nEffect documentation",
|
||||
_meta: { searchTime: 123 },
|
||||
},
|
||||
],
|
||||
},
|
||||
})}\n\n`,
|
||||
)
|
||||
})
|
||||
|
||||
const it = webSearchIntegrationTest
|
||||
|
||||
describe("built-in web search providers", () => {
|
||||
it.effect("registers a provider without an integration", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const websearch = yield* WebSearch.Service
|
||||
const registration = yield* webSearchHost(websearch).transform((draft) => {
|
||||
draft.add({
|
||||
id: "test-websearch",
|
||||
name: "Test Web Search",
|
||||
execute: (input) => Effect.succeed([{ url: "https://example.com", content: input.query, time: {} }]),
|
||||
})
|
||||
})
|
||||
|
||||
expect(yield* integrations.get(Integration.ID.make("test-websearch"))).toBeUndefined()
|
||||
expect(yield* websearch.providers()).toContainEqual({
|
||||
id: WebSearch.ID.make("test-websearch"),
|
||||
name: "Test Web Search",
|
||||
})
|
||||
yield* registration.dispose
|
||||
expect(yield* websearch.providers()).not.toContainEqual({
|
||||
id: WebSearch.ID.make("test-websearch"),
|
||||
name: "Test Web Search",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("registers Exa with its MCP schema", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* WebSearchExa.Plugin.effect(
|
||||
host({ integration: integrationHost(integrations), websearch: webSearchHost(websearch) }),
|
||||
)
|
||||
|
||||
const info = yield* integrations.get(Integration.ID.make("exa"))
|
||||
expect(info).toMatchObject({
|
||||
id: "exa",
|
||||
name: "Exa",
|
||||
methods: [{ type: "key" }, { type: "env", names: ["EXA_API_KEY"] }],
|
||||
})
|
||||
yield* integrations.connection.key({ integrationID: Integration.ID.make("exa"), key: "exa secret" })
|
||||
expect(yield* websearch.query({ query: "effect typescript", providerID: WebSearch.ID.make("exa") })).toEqual(
|
||||
new WebSearch.Response({
|
||||
providerID: WebSearch.ID.make("exa"),
|
||||
results: [
|
||||
{
|
||||
url: "https://effect.website",
|
||||
title: "Effect",
|
||||
content: "Effect documentation",
|
||||
time: { published: Date.parse("2026-07-25T00:00:00.000Z") },
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
url: `${WebSearchExa.endpoint}?exaApiKey=exa+secret`,
|
||||
headers: expect.any(Object),
|
||||
body: {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "web_search_exa",
|
||||
arguments: { query: "effect typescript", numResults: 8 },
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("registers Parallel and keeps its credential in the authorization header", () =>
|
||||
Effect.gen(function* () {
|
||||
resetWebSearchFixture(
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
result: {
|
||||
content: [{ type: "text", text: "search results" }],
|
||||
structuredContent: {
|
||||
search_id: "search_1",
|
||||
results: [
|
||||
{
|
||||
url: "https://effect.website",
|
||||
title: "Effect",
|
||||
publish_date: null,
|
||||
excerpts: ["Effect documentation"],
|
||||
},
|
||||
],
|
||||
warnings: null,
|
||||
usage: [{ name: "sku_search", count: 1 }],
|
||||
session_id: "ses_parallel",
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
const integrations = yield* Integration.Service
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* WebSearchParallel.Plugin.effect(
|
||||
host({ integration: integrationHost(integrations), websearch: webSearchHost(websearch) }),
|
||||
)
|
||||
yield* integrations.connection.key({ integrationID: Integration.ID.make("parallel"), key: "parallel-secret" })
|
||||
|
||||
const output = yield* websearch.query({
|
||||
query: "effect layers",
|
||||
providerID: WebSearch.ID.make("parallel"),
|
||||
})
|
||||
expect(output).toEqual(
|
||||
new WebSearch.Response({
|
||||
providerID: WebSearch.ID.make("parallel"),
|
||||
results: [
|
||||
{
|
||||
url: "https://effect.website",
|
||||
title: "Effect",
|
||||
content: "Effect documentation",
|
||||
time: {},
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(requests[0]).toMatchObject({
|
||||
url: WebSearchParallel.endpoint,
|
||||
headers: { authorization: "Bearer parallel-secret" },
|
||||
body: {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "web_search",
|
||||
arguments: {
|
||||
objective: "effect layers",
|
||||
search_queries: ["effect layers"],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(JSON.stringify(output)).not.toContain("parallel-secret")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
import { beforeEach, describe, expect, test } from "bun:test"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import { beforeEach, describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { Form } from "@opencode-ai/core/form"
|
||||
import { KV } from "@opencode-ai/core/kv"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { WebSearchTool } from "@opencode-ai/core/tool/websearch"
|
||||
|
|
@ -14,93 +15,36 @@ import { Image } from "@opencode-ai/core/image"
|
|||
import { testEffect } from "./lib/effect"
|
||||
import { imagePassthrough } from "./lib/image"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
|
||||
import { webSearchHost } from "./plugin/host"
|
||||
|
||||
const webSearchToolNode = makeLocationNode({
|
||||
name: "test/websearch-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(WebSearchTool.Plugin)),
|
||||
deps: [ToolRegistry.toolsNode, PermissionV2.node, LayerNodePlatform.httpClient, WebSearchTool.configNode],
|
||||
layer: Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* registerToolPlugin(WebSearchTool.Plugin, { websearch: webSearchHost(websearch) })
|
||||
}),
|
||||
),
|
||||
deps: [ToolRegistry.toolsNode, PermissionV2.node, WebSearch.node, Form.node, KV.node],
|
||||
})
|
||||
|
||||
const sessionID = SessionV2.ID.make("ses_websearch_test")
|
||||
const payload = (text: string) =>
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
result: { content: [{ type: "text", text }] },
|
||||
})
|
||||
|
||||
describe("WebSearchTool provider selection", () => {
|
||||
test("rejects out-of-range numeric controls", () => {
|
||||
const decode = Schema.decodeUnknownSync(WebSearchTool.Input)
|
||||
expect(() => decode({ query: "x", numResults: 0 })).toThrow()
|
||||
expect(() => decode({ query: "x", numResults: WebSearchTool.MAX_NUM_RESULTS + 1 })).toThrow()
|
||||
expect(() => decode({ query: "x", contextMaxCharacters: WebSearchTool.MAX_CONTEXT_CHARACTERS + 1 })).toThrow()
|
||||
})
|
||||
test("selects a stable provider per session", () => {
|
||||
expect(WebSearchTool.selectProvider(sessionID)).toBe(WebSearchTool.selectProvider(sessionID))
|
||||
})
|
||||
|
||||
test("supports an explicit operational override", () => {
|
||||
expect(WebSearchTool.selectProvider(sessionID, { enableExa: false, enableParallel: false }, "parallel")).toBe(
|
||||
"parallel",
|
||||
)
|
||||
expect(WebSearchTool.selectProvider(sessionID, { enableExa: false, enableParallel: false }, "exa")).toBe("exa")
|
||||
})
|
||||
|
||||
test("prefers Parallel when both explicit flags are enabled", () => {
|
||||
expect(WebSearchTool.selectProvider(sessionID, { enableExa: true, enableParallel: true })).toBe("parallel")
|
||||
})
|
||||
|
||||
test("prefers Exa when only its explicit flag is enabled", () => {
|
||||
expect(WebSearchTool.selectProvider(sessionID, { enableExa: true, enableParallel: false })).toBe("exa")
|
||||
})
|
||||
})
|
||||
|
||||
describe("WebSearchTool MCP response parser", () => {
|
||||
test("parses plain JSON-RPC responses", async () => {
|
||||
expect(await Effect.runPromise(WebSearchTool.parseResponse(payload("search results")))).toBe("search results")
|
||||
})
|
||||
|
||||
test("parses SSE JSON-RPC responses and ignores non-JSON frames", async () => {
|
||||
expect(
|
||||
await Effect.runPromise(
|
||||
WebSearchTool.parseResponse(`data: [DONE]\nevent: message\ndata: ${payload("search results")}\n\n`),
|
||||
),
|
||||
).toBe("search results")
|
||||
})
|
||||
})
|
||||
|
||||
interface Request {
|
||||
readonly url: string
|
||||
readonly headers: Record<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.Input[] = []
|
||||
let result = new WebSearch.Response({
|
||||
providerID: WebSearch.ID.make("exa"),
|
||||
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
responseBody = payload("search results")
|
||||
makeResponse = () => new Response(responseBody, { status: 200 })
|
||||
assertions.length = 0
|
||||
queries.length = 0
|
||||
result = new WebSearch.Response({
|
||||
providerID: WebSearch.ID.make("exa"),
|
||||
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
|
||||
})
|
||||
})
|
||||
|
||||
const http = Layer.succeed(
|
||||
HttpClient.HttpClient,
|
||||
HttpClient.make((request) =>
|
||||
Effect.sync(() => {
|
||||
if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`)
|
||||
requests.push({
|
||||
url: request.url,
|
||||
headers: request.headers,
|
||||
body: JSON.parse(new TextDecoder().decode(request.body.body)),
|
||||
})
|
||||
return HttpClientResponse.fromWeb(request, makeResponse())
|
||||
}),
|
||||
),
|
||||
)
|
||||
const permission = Layer.succeed(
|
||||
PermissionV2.Service,
|
||||
PermissionV2.Service.of({
|
||||
|
|
@ -112,33 +56,48 @@ const permission = Layer.succeed(
|
|||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const websearchConfig = Layer.succeed(
|
||||
WebSearchTool.ConfigService,
|
||||
WebSearchTool.ConfigService.of({
|
||||
get provider() {
|
||||
return config.provider
|
||||
},
|
||||
get enableExa() {
|
||||
return config.enableExa
|
||||
},
|
||||
get enableParallel() {
|
||||
return config.enableParallel
|
||||
},
|
||||
get exaApiKey() {
|
||||
return config.exaApiKey
|
||||
},
|
||||
get parallelApiKey() {
|
||||
return config.parallelApiKey
|
||||
},
|
||||
const websearch = Layer.succeed(
|
||||
WebSearch.Service,
|
||||
WebSearch.Service.of({
|
||||
transform: () => Effect.die("unused"),
|
||||
reload: () => Effect.die("unused"),
|
||||
providers: () => Effect.succeed([]),
|
||||
default: () => Effect.succeed(undefined),
|
||||
query: (input) =>
|
||||
Effect.sync(() => {
|
||||
queries.push(input)
|
||||
return result
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const form = Layer.succeed(
|
||||
Form.Service,
|
||||
Form.Service.of({
|
||||
create: () => Effect.die("unused"),
|
||||
ask: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
state: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
cancel: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const kv = Layer.succeed(
|
||||
KV.Service,
|
||||
KV.Service.of({
|
||||
get: () => Effect.succeed(undefined),
|
||||
set: () => Effect.void,
|
||||
remove: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebSearchTool.configNode, webSearchToolNode]),
|
||||
LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebSearch.node, webSearchToolNode]),
|
||||
[
|
||||
[PermissionV2.node, permission],
|
||||
[LayerNodePlatform.httpClient, http],
|
||||
[WebSearchTool.configNode, websearchConfig],
|
||||
[WebSearch.node, websearch],
|
||||
[Form.node, form],
|
||||
[KV.node, kv],
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
[Image.node, imagePassthrough],
|
||||
],
|
||||
|
|
@ -146,12 +105,8 @@ const it = testEffect(
|
|||
)
|
||||
|
||||
describe("WebSearchTool registration", () => {
|
||||
it.effect("registers websearch, asserts query permission, and calls Exa", () =>
|
||||
it.effect("asserts permission before delegating to WebSearch", () =>
|
||||
Effect.gen(function* () {
|
||||
requests.length = 0
|
||||
assertions.length = 0
|
||||
responseBody = payload("exa results")
|
||||
config = { provider: "exa", enableExa: false, enableParallel: false }
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["websearch", "execute"])
|
||||
|
|
@ -161,20 +116,14 @@ describe("WebSearchTool registration", () => {
|
|||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-exa",
|
||||
id: "call-search",
|
||||
name: "websearch",
|
||||
input: {
|
||||
query: "effect typescript",
|
||||
numResults: 3,
|
||||
livecrawl: "preferred",
|
||||
type: "fast",
|
||||
contextMaxCharacters: 2500,
|
||||
},
|
||||
input: { query: "effect typescript" },
|
||||
},
|
||||
}),
|
||||
).toMatchObject({
|
||||
status: "completed",
|
||||
content: [{ type: "text", text: "exa results" }],
|
||||
content: [{ type: "text", text: "## [Search results](https://example.com)\n\nsearch results" }],
|
||||
})
|
||||
expect(assertions).toMatchObject([
|
||||
{
|
||||
|
|
@ -182,103 +131,65 @@ describe("WebSearchTool registration", () => {
|
|||
action: "websearch",
|
||||
resources: ["effect typescript"],
|
||||
save: ["*"],
|
||||
metadata: {
|
||||
query: "effect typescript",
|
||||
numResults: 3,
|
||||
livecrawl: "preferred",
|
||||
type: "fast",
|
||||
contextMaxCharacters: 2500,
|
||||
provider: "exa",
|
||||
},
|
||||
metadata: { query: "effect typescript" },
|
||||
},
|
||||
])
|
||||
expect(requests).toEqual([
|
||||
expect(queries).toEqual([
|
||||
{
|
||||
url: WebSearchTool.EXA_URL,
|
||||
headers: expect.any(Object),
|
||||
body: {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "web_search_exa",
|
||||
arguments: {
|
||||
query: "effect typescript",
|
||||
type: "fast",
|
||||
numResults: 3,
|
||||
livecrawl: "preferred",
|
||||
contextMaxCharacters: 2500,
|
||||
},
|
||||
},
|
||||
},
|
||||
query: "effect typescript",
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("calls Parallel with session ID and keeps bearer credentials out of output", () =>
|
||||
it.effect("keeps normalized results in structured output", () =>
|
||||
Effect.gen(function* () {
|
||||
requests.length = 0
|
||||
assertions.length = 0
|
||||
responseBody = payload("parallel results")
|
||||
config = { provider: "parallel", enableExa: false, enableParallel: false, parallelApiKey: "parallel-secret" }
|
||||
result = new WebSearch.Response({
|
||||
providerID: WebSearch.ID.make("parallel"),
|
||||
results: [
|
||||
{
|
||||
url: "https://effect.website",
|
||||
title: "Effect",
|
||||
content: "parallel results",
|
||||
time: { published: Date.parse("2026-07-25T00:00:00.000Z") },
|
||||
},
|
||||
],
|
||||
})
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
const settled = yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-parallel", name: "websearch", input: { query: "effect layers" } },
|
||||
})
|
||||
|
||||
expect(requests[0]).toMatchObject({
|
||||
url: WebSearchTool.PARALLEL_URL,
|
||||
headers: { authorization: "Bearer parallel-secret" },
|
||||
body: {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "web_search",
|
||||
arguments: { objective: "effect layers", search_queries: ["effect layers"], session_id: sessionID },
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(requests[0]?.body).not.toHaveProperty("params.arguments.model_name")
|
||||
expect(settled).toEqual({
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-parallel", name: "websearch", input: { query: "effect layers" } },
|
||||
}),
|
||||
).toEqual({
|
||||
status: "completed",
|
||||
output: { provider: "parallel", text: "parallel results" },
|
||||
content: [{ type: "text", text: "parallel results" }],
|
||||
output: {
|
||||
provider: "parallel",
|
||||
results: [
|
||||
{
|
||||
url: "https://effect.website",
|
||||
title: "Effect",
|
||||
content: "parallel results",
|
||||
time: { published: Date.parse("2026-07-25T00:00:00.000Z") },
|
||||
},
|
||||
],
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "## [Effect](https://effect.website)\nPublished: 2026-07-25T00:00:00.000Z\n\nparallel results",
|
||||
},
|
||||
],
|
||||
metadata: { provider: "parallel" },
|
||||
})
|
||||
expect(JSON.stringify(settled)).not.toContain("parallel-secret")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps an Exa credential in the transport URL and out of model output", () =>
|
||||
it.effect("uses the concise no-results fallback", () =>
|
||||
Effect.gen(function* () {
|
||||
requests.length = 0
|
||||
assertions.length = 0
|
||||
responseBody = payload("credentialed exa results")
|
||||
config = { provider: "exa", enableExa: false, enableParallel: false, exaApiKey: "exa secret" }
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
const settled = yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-exa-key", name: "websearch", input: { query: "effect schema" } },
|
||||
})
|
||||
|
||||
expect(requests[0]?.url).toBe(`${WebSearchTool.EXA_URL}?exaApiKey=exa+secret`)
|
||||
expect(JSON.stringify(settled)).not.toContain("exa secret")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("returns the legacy no-results fallback as concise model text", () =>
|
||||
Effect.gen(function* () {
|
||||
requests.length = 0
|
||||
assertions.length = 0
|
||||
responseBody = ""
|
||||
config = { provider: "exa", enableExa: false, enableParallel: false }
|
||||
result = new WebSearch.Response({ providerID: WebSearch.ID.make("exa"), results: [] })
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect(
|
||||
|
|
@ -293,44 +204,4 @@ describe("WebSearchTool registration", () => {
|
|||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects oversized MCP response bodies", () =>
|
||||
Effect.gen(function* () {
|
||||
requests.length = 0
|
||||
assertions.length = 0
|
||||
let chunksRead = 0
|
||||
let cancelled = false
|
||||
makeResponse = () =>
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
pull(controller) {
|
||||
chunksRead++
|
||||
if (chunksRead === 10) throw new Error("response was not stopped at the byte limit")
|
||||
controller.enqueue(new Uint8Array(64 * 1024))
|
||||
},
|
||||
cancel() {
|
||||
cancelled = true
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
)
|
||||
config = { provider: "exa", enableExa: false, enableParallel: false }
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-large-response", name: "websearch", input: { query: "too much" } },
|
||||
}),
|
||||
// toSessionError unwraps the "Unable to search the web for <query>" ToolFailure
|
||||
// to its byte-limit cause message.
|
||||
).toEqual({
|
||||
status: "error",
|
||||
error: { type: "unknown", message: expect.stringContaining("response exceeded") },
|
||||
})
|
||||
expect(chunksRead).toBeLessThan(10)
|
||||
expect(cancelled).toBe(true)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
129
packages/core/test/websearch.test.ts
Normal file
129
packages/core/test/websearch.test.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Scope } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { KV } from "@opencode-ai/core/kv"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([WebSearch.node, EventV2.node, KV.node])))
|
||||
|
||||
const register = (id: string) =>
|
||||
Effect.gen(function* () {
|
||||
const websearch = yield* WebSearch.Service
|
||||
const providerID = WebSearch.ID.make(id)
|
||||
const calls: WebSearch.ProviderInput[] = []
|
||||
yield* websearch.transform((draft) => {
|
||||
draft.add({
|
||||
id: providerID,
|
||||
name: id.toUpperCase(),
|
||||
execute: (input) =>
|
||||
Effect.sync(() => {
|
||||
calls.push(input)
|
||||
return [
|
||||
{
|
||||
url: `https://${id}.example.com`,
|
||||
title: input.query,
|
||||
content: `${id}: ${input.query}`,
|
||||
time: {},
|
||||
},
|
||||
]
|
||||
}),
|
||||
})
|
||||
})
|
||||
return { providerID, calls }
|
||||
})
|
||||
|
||||
describe("WebSearch", () => {
|
||||
it.effect("executes an explicit provider without changing the default", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* register("exa")
|
||||
const parallel = yield* register("parallel")
|
||||
const websearch = yield* WebSearch.Service
|
||||
|
||||
expect(yield* websearch.query({ query: "effect", providerID: parallel.providerID })).toEqual(
|
||||
new WebSearch.Response({
|
||||
providerID: parallel.providerID,
|
||||
results: [
|
||||
{
|
||||
url: "https://parallel.example.com",
|
||||
title: "effect",
|
||||
content: "parallel: effect",
|
||||
time: {},
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect((yield* websearch.query({ query: "default" }).pipe(Effect.flip))._tag).toBe("WebSearch.ProviderRequired")
|
||||
expect(parallel.calls).toEqual([{ query: "effect" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("requires a provider when no default is set", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* register("exa")
|
||||
yield* register("parallel")
|
||||
const websearch = yield* WebSearch.Service
|
||||
|
||||
expect((yield* websearch.query({ query: "layers" }).pipe(Effect.flip))._tag).toBe("WebSearch.ProviderRequired")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses the default set by a transform", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* register("exa")
|
||||
const parallel = yield* register("parallel")
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* websearch.transform((draft) => draft.default.set(parallel.providerID))
|
||||
|
||||
expect((yield* websearch.query({ query: "configured" })).providerID).toBe(parallel.providerID)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses the provider stored in KV", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* register("exa")
|
||||
const parallel = yield* register("parallel")
|
||||
const websearch = yield* WebSearch.Service
|
||||
const kv = yield* KV.Service
|
||||
yield* kv.set("websearch:provider", parallel.providerID)
|
||||
|
||||
expect((yield* websearch.query({ query: "stored" })).providerID).toBe(parallel.providerID)
|
||||
yield* kv.remove("websearch:provider")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails when web search is explicitly disabled", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* register("exa")
|
||||
const websearch = yield* WebSearch.Service
|
||||
const kv = yield* KV.Service
|
||||
yield* kv.set("websearch:provider", false)
|
||||
|
||||
expect((yield* websearch.query({ query: "disabled" }).pipe(Effect.flip))._tag).toBe("WebSearch.Disabled")
|
||||
yield* kv.remove("websearch:provider")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("falls back when the configured default is unavailable", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* register("exa")
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* websearch.transform((draft) => draft.default.set(WebSearch.ID.make("missing")))
|
||||
|
||||
expect((yield* websearch.query({ query: "fallback" }).pipe(Effect.flip))._tag).toBe("WebSearch.ProviderRequired")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("removes scoped provider registrations", () =>
|
||||
Effect.gen(function* () {
|
||||
const websearch = yield* WebSearch.Service
|
||||
const scope = yield* Scope.fork(yield* Scope.Scope)
|
||||
const provider = yield* register("temporary").pipe(Scope.provide(scope))
|
||||
expect(yield* websearch.providers()).toContainEqual({ id: provider.providerID, name: "TEMPORARY" })
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(yield* websearch.providers()).not.toContainEqual({ id: provider.providerID, name: "TEMPORARY" })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import type { ReferenceDomain } from "./reference.js"
|
|||
import type { SessionDomain } from "./session.js"
|
||||
import type { SkillDomain } from "./skill.js"
|
||||
import type { ToolDomain } from "./tool.js"
|
||||
import type { WebSearchDomain } from "./websearch.js"
|
||||
|
||||
export interface Context {
|
||||
readonly app: App
|
||||
|
|
@ -27,6 +28,7 @@ export interface Context {
|
|||
readonly session: SessionDomain
|
||||
readonly skill: SkillDomain
|
||||
readonly tool: ToolDomain
|
||||
readonly websearch: WebSearchDomain
|
||||
}
|
||||
|
||||
export interface Plugin<R = Scope.Scope> {
|
||||
|
|
|
|||
23
packages/plugin/src/v2/effect/websearch.ts
Normal file
23
packages/plugin/src/v2/effect/websearch.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import type { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
import type { WebsearchApi } from "@opencode-ai/client/effect/api"
|
||||
import type { Effect } from "effect"
|
||||
import type { Transform } from "./registration.js"
|
||||
|
||||
export interface WebSearchDefinition {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly execute: (input: WebSearch.ProviderInput) => Effect.Effect<readonly WebSearch.Result[], unknown>
|
||||
}
|
||||
|
||||
export interface WebSearchDomain extends WebsearchApi<unknown> {
|
||||
readonly transform: Transform<WebSearchDraft>
|
||||
readonly reload: () => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export interface WebSearchDraft {
|
||||
add(definition: WebSearchDefinition): void
|
||||
readonly default: {
|
||||
get(): string | undefined
|
||||
set(providerID: string): void
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -1,10 +1,32 @@
|
|||
import type { IntegrationApi } from "@opencode-ai/client/promise/api"
|
||||
import type { IntegrationDraft, IntegrationMethodRegistration } from "../effect/integration.js"
|
||||
import type { CredentialValue } from "@opencode-ai/sdk/v2/types"
|
||||
import type {
|
||||
CredentialOAuth,
|
||||
CredentialValue,
|
||||
IntegrationEnvMethod,
|
||||
IntegrationInputs,
|
||||
IntegrationKeyMethod,
|
||||
IntegrationOAuthMethod,
|
||||
} from "@opencode-ai/sdk/v2/types"
|
||||
import type { Transform } from "./registration.js"
|
||||
|
||||
export type { IntegrationDraft, IntegrationMethodRegistration }
|
||||
|
||||
export type IntegrationOAuthAuthorization = {
|
||||
readonly url: string
|
||||
readonly instructions: string
|
||||
readonly expiresAt?: number
|
||||
} & (
|
||||
| {
|
||||
readonly mode: "auto"
|
||||
readonly callback: Promise<CredentialOAuth>
|
||||
}
|
||||
| {
|
||||
readonly mode: "code"
|
||||
readonly callback: (code: string) => Promise<CredentialOAuth>
|
||||
}
|
||||
)
|
||||
|
||||
export interface IntegrationDomain extends Omit<IntegrationApi, "wellknown"> {
|
||||
readonly transform: Transform<IntegrationDraft>
|
||||
readonly reload: () => Promise<void>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import type { ReferenceDomain } from "./reference.js"
|
|||
import type { SessionDomain } from "./session.js"
|
||||
import type { SkillDomain } from "./skill.js"
|
||||
import type { ToolDomain } from "./tool.js"
|
||||
import type { WebSearchDomain } from "./websearch.js"
|
||||
|
||||
export interface Context {
|
||||
readonly app: App
|
||||
|
|
@ -26,6 +27,7 @@ export interface Context {
|
|||
readonly session: SessionDomain
|
||||
readonly skill: SkillDomain
|
||||
readonly tool: ToolDomain
|
||||
readonly websearch: WebSearchDomain
|
||||
}
|
||||
|
||||
export type Cleanup = () => Promise<void> | void
|
||||
|
|
|
|||
25
packages/plugin/src/v2/promise/websearch.ts
Normal file
25
packages/plugin/src/v2/promise/websearch.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import type { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
import type { WebSearchApi } from "@opencode-ai/client/promise/api"
|
||||
import type { Transform } from "./registration.js"
|
||||
|
||||
export interface WebSearchDefinition {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly execute: (
|
||||
input: WebSearch.ProviderInput,
|
||||
context: { readonly signal: AbortSignal },
|
||||
) => Promise<readonly WebSearch.Result[]>
|
||||
}
|
||||
|
||||
export interface WebSearchDomain extends WebSearchApi {
|
||||
readonly transform: Transform<WebSearchDraft>
|
||||
readonly reload: () => Promise<void>
|
||||
}
|
||||
|
||||
export interface WebSearchDraft {
|
||||
add(definition: WebSearchDefinition): void
|
||||
readonly default: {
|
||||
get(): string | undefined
|
||||
set(providerID: string): void
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
])
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
46
packages/protocol/src/groups/websearch.ts
Normal file
46
packages/protocol/src/groups/websearch.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { Location } from "@opencode-ai/schema/location"
|
||||
import { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { InvalidRequestError, ServiceUnavailableError } from "../errors.js"
|
||||
import { LocationQuery, locationQueryOpenApi } from "./location.js"
|
||||
|
||||
export const WebSearchGroup = HttpApiGroup.make("server.websearch")
|
||||
.add(
|
||||
HttpApiEndpoint.get("websearch.providers", "/api/websearch/provider", {
|
||||
query: LocationQuery,
|
||||
success: Location.response(Schema.Array(WebSearch.Provider)),
|
||||
error: ServiceUnavailableError,
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.websearch.providers",
|
||||
summary: "List web search providers",
|
||||
description: "Return the registered web search providers.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("websearch.query", "/api/websearch", {
|
||||
query: LocationQuery,
|
||||
payload: Schema.Struct(WebSearch.Input.fields),
|
||||
success: Location.response(WebSearch.Response),
|
||||
error: [InvalidRequestError, ServiceUnavailableError],
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.websearch.query",
|
||||
summary: "Search the web",
|
||||
description:
|
||||
"Run one web search through the selected provider. Specify a provider to override the configured default.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "websearch",
|
||||
description: "Location-scoped web search routes.",
|
||||
}),
|
||||
)
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
42
packages/schema/src/websearch.ts
Normal file
42
packages/schema/src/websearch.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
export * as WebSearch from "./websearch.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { ephemeral, inventory } from "./event.js"
|
||||
import { optional } from "./schema.js"
|
||||
|
||||
export const ID = Schema.String.pipe(Schema.brand("WebSearch.ID"))
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
export interface Provider extends Schema.Schema.Type<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 Result extends Schema.Schema.Type<typeof Result> {}
|
||||
export const Result = Schema.Struct({
|
||||
url: Schema.String,
|
||||
title: Schema.String.pipe(optional),
|
||||
content: Schema.String.pipe(optional),
|
||||
time: Schema.Struct({
|
||||
published: Schema.Finite.pipe(optional),
|
||||
}),
|
||||
}).annotate({ identifier: "WebSearch.Result" })
|
||||
|
||||
export class Response extends Schema.Class<Response>("WebSearch.Response")({
|
||||
providerID: ID,
|
||||
results: Schema.Array(Result),
|
||||
}) {}
|
||||
|
||||
const Updated = ephemeral({
|
||||
type: "websearch.updated",
|
||||
schema: {},
|
||||
})
|
||||
export const Event = { Updated, Definitions: inventory(Updated) }
|
||||
|
|
@ -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",
|
||||
])
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -328,6 +328,38 @@ it.live(
|
|||
10_000,
|
||||
)
|
||||
|
||||
it.live("embedded client exposes plugin-backed web search", () =>
|
||||
withEmbedded("opencode-embedded-websearch-", (fixture) =>
|
||||
Effect.gen(function* () {
|
||||
const opencode = yield* fixture.sdk.OpenCode.create()
|
||||
const providerID = fixture.sdk.WebSearch.ID.make("embedded-websearch")
|
||||
yield* opencode.plugin({
|
||||
id: `embedded-websearch-${crypto.randomUUID()}`,
|
||||
effect: (ctx) =>
|
||||
ctx.websearch.transform((draft) => {
|
||||
draft.add({
|
||||
id: providerID,
|
||||
name: "Embedded web search",
|
||||
execute: (input) =>
|
||||
Effect.succeed([{ url: "https://example.com", content: `Found ${input.query}`, time: {} }]),
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
const result = yield* opencode.websearch.query({
|
||||
query: "opencode",
|
||||
providerID,
|
||||
location: location(fixture),
|
||||
})
|
||||
|
||||
expect(result.data).toEqual({
|
||||
providerID,
|
||||
results: [{ url: "https://example.com", content: "Found opencode", time: {} }],
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"Location-owned runner events reach the ready global client",
|
||||
() =>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
68
packages/server/src/handlers/websearch.ts
Normal file
68
packages/server/src/handlers/websearch.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
import { InvalidRequestError, ServiceUnavailableError } from "@opencode-ai/protocol/errors"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
import { response } from "../location"
|
||||
|
||||
export const WebSearchHandler = HttpApiBuilder.group(Api, "server.websearch", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const awaitPlugins = Effect.fn("server.websearch.awaitPlugins")(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.flush.pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: "5 seconds",
|
||||
orElse: () =>
|
||||
Effect.fail(
|
||||
new ServiceUnavailableError({
|
||||
message: "Web search provider initialization timed out",
|
||||
service: "websearch",
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
})
|
||||
return handlers
|
||||
.handle(
|
||||
"websearch.providers",
|
||||
Effect.fn("server.websearch.providers")(function* () {
|
||||
yield* awaitPlugins()
|
||||
const websearch = yield* WebSearch.Service
|
||||
return yield* response(websearch.providers())
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"websearch.query",
|
||||
Effect.fn("server.websearch.query")(function* (request) {
|
||||
yield* awaitPlugins()
|
||||
const websearch = yield* WebSearch.Service
|
||||
return yield* response(
|
||||
websearch.query(request.payload).pipe(
|
||||
Effect.catchTags({
|
||||
"WebSearch.ProviderRequired": () =>
|
||||
new InvalidRequestError({
|
||||
message: "Web search provider is required",
|
||||
kind: "websearch_provider_required",
|
||||
field: "providerID",
|
||||
}),
|
||||
"WebSearch.ProviderNotFound": (error) =>
|
||||
new InvalidRequestError({
|
||||
message: `Web search provider not found: ${error.providerID}`,
|
||||
kind: "websearch_provider_not_found",
|
||||
field: "providerID",
|
||||
}),
|
||||
"WebSearch.Disabled": () =>
|
||||
new InvalidRequestError({ message: "Web search is disabled", kind: "websearch_disabled" }),
|
||||
"WebSearch.Request": (error) =>
|
||||
new ServiceUnavailableError({
|
||||
message: `Web search request failed: ${error.providerID}`,
|
||||
service: error.providerID,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
|
@ -59,29 +59,43 @@ export function connectionSummary(integration: IntegrationInfo) {
|
|||
.join(", ")
|
||||
}
|
||||
|
||||
export function DialogIntegration(props: { onConnected?: OnIntegrationConnected } = {}) {
|
||||
export function DialogIntegration(
|
||||
props: { onConnected?: OnIntegrationConnected; integrationID?: string; connectionOnly?: boolean } = {},
|
||||
) {
|
||||
const data = useData()
|
||||
const dialog = useDialog()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const options = createMemo(() =>
|
||||
integrationOptions(data.location.integration.list() ?? []).map((integration) => {
|
||||
const options = createMemo(() => {
|
||||
const providers = data.location.websearch.list() ?? []
|
||||
const providersByID = new Map(providers.map((provider) => [provider.id, provider]))
|
||||
const integrations = integrationOptions(data.location.integration.list() ?? []).filter(
|
||||
(integration) => props.integrationID === undefined || integration.id === props.integrationID,
|
||||
)
|
||||
return integrations.map((integration) => {
|
||||
const methods = connectMethods(integration)
|
||||
const connected = integration.connections.length > 0
|
||||
const provider = providersByID.get(integration.id)
|
||||
const credentials = credentialConnections(integration)
|
||||
let category = "Services"
|
||||
if (integration.id in INTEGRATION_PRIORITY) category = "Popular"
|
||||
if (provider) category = "Web search"
|
||||
return {
|
||||
title: integration.name,
|
||||
value: integration.id,
|
||||
description: methods.length ? undefined : "Environment only",
|
||||
description: methods.length === 0 ? "Environment only" : undefined,
|
||||
footer: connectionSummary(integration) || undefined,
|
||||
category: integration.id in INTEGRATION_PRIORITY ? "Popular" : "Services",
|
||||
disabled: methods.length === 0,
|
||||
gutter: connected ? () => <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),
|
||||
category,
|
||||
disabled: methods.length === 0 && credentials.length === 0,
|
||||
gutter:
|
||||
integration.connections.length > 0
|
||||
? () => <text fg={themeV2.text.feedback.success.default}>✓</text>
|
||||
: undefined,
|
||||
onSelect: () => {
|
||||
if (credentials.length) return manageConnections(integration, methods, dialog, props.onConnected)
|
||||
return selectMethod(integration, methods, dialog, props.onConnected)
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -56,6 +57,7 @@ type LocationData = {
|
|||
model?: ModelInfo[]
|
||||
provider?: ProviderV2Info[]
|
||||
reference?: ReferenceInfo[]
|
||||
websearch?: WebSearchProvider[]
|
||||
// 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>
|
||||
|
|
@ -875,6 +877,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
result.location.provider.sync(event.location),
|
||||
])
|
||||
break
|
||||
case "config.updated":
|
||||
case "websearch.updated":
|
||||
void result.location.websearch.refresh(event.location)
|
||||
break
|
||||
// Authenticating an MCP integration reconnects its server, which emits mcp.status.changed,
|
||||
// so the mcp list syncs here rather than off integration.updated.
|
||||
case "mcp.status.changed":
|
||||
|
|
@ -1251,6 +1257,20 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
sync.invalidate(`location.reference:${locationKey(ref ?? defaultLocation())}`)
|
||||
},
|
||||
},
|
||||
websearch: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.websearch
|
||||
},
|
||||
async refresh(ref?: LocationRef) {
|
||||
const input = { location: locationQuery(ref ?? defaultLocation()) }
|
||||
const providers = await client.api.websearch.providers(input)
|
||||
const key = locationKey(providers.location)
|
||||
setStore("location", key, {
|
||||
...store.location[key],
|
||||
websearch: providers.data,
|
||||
})
|
||||
},
|
||||
},
|
||||
skill: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.skill
|
||||
|
|
|
|||
|
|
@ -213,6 +213,8 @@ test("refreshes resources into reactive getters", async () => {
|
|||
location,
|
||||
data: [{ id: "build", request: { headers: {}, body: {} }, mode: "primary", hidden: false, permissions: [] }],
|
||||
})
|
||||
if (url.pathname === "/api/websearch/provider")
|
||||
return json({ location, data: [{ id: "standalone", name: "Standalone" }] })
|
||||
return undefined
|
||||
}, events)
|
||||
let data!: ReturnType<typeof useData>
|
||||
|
|
@ -248,6 +250,7 @@ test("refreshes resources into reactive getters", async () => {
|
|||
await data.session.sync("ses_test")
|
||||
await data.session.message.sync("ses_test")
|
||||
await data.location.agent.sync()
|
||||
await data.location.websearch.refresh()
|
||||
|
||||
expect(data.session.get("ses_test")?.title).toBe("Test session")
|
||||
expect(data.session.message.list("ses_test").map((message) => message.id)).toEqual(["msg_first", "msg_second"])
|
||||
|
|
@ -256,6 +259,7 @@ test("refreshes resources into reactive getters", async () => {
|
|||
expect(app.captureCharFrame()).toContain("msg_second")
|
||||
expect(data.location.default()).toEqual({ directory, workspaceID: undefined })
|
||||
expect(data.location.agent.list(location)?.map((agent) => agent.id)).toEqual(["build"])
|
||||
expect(data.location.websearch.list(location)).toEqual([{ id: "standalone", name: "Standalone" }])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -125,6 +125,9 @@ 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 === "/provider") return json({ all: [], default: {}, connected: [] })
|
||||
if (url.pathname === "/session") return json([])
|
||||
if (url.pathname === "/vcs") return json({ branch: "main" })
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue