mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-10 01:58:31 +00:00
refactor(websearch): decouple providers from integrations
This commit is contained in:
parent
c3da28f76a
commit
820b1a1a46
40 changed files with 479 additions and 483 deletions
|
|
@ -939,35 +939,43 @@ export interface DebugApi<E = never> {
|
|||
readonly location: { readonly list: DebugLocationListOperation<E>; readonly evict: DebugLocationEvictOperation<E> }
|
||||
}
|
||||
|
||||
type Endpoint27_0Request = Parameters<RawClient["server.websearch"]["websearch.provider.get"]>[0]
|
||||
type Endpoint27_0Request = Parameters<RawClient["server.websearch"]["websearch.provider.list"]>[0]
|
||||
export type Endpoint27_0Input = { readonly location?: Endpoint27_0Request["query"]["location"] }
|
||||
export type Endpoint27_0Output = EffectValue<ReturnType<RawClient["server.websearch"]["websearch.provider.get"]>>
|
||||
export type WebsearchProviderGetOperation<E = never> = (
|
||||
export type Endpoint27_0Output = EffectValue<ReturnType<RawClient["server.websearch"]["websearch.provider.list"]>>
|
||||
export type WebsearchProviderListOperation<E = never> = (
|
||||
input?: Endpoint27_0Input,
|
||||
) => Effect.Effect<Endpoint27_0Output, E>
|
||||
|
||||
type Endpoint27_1Request = Parameters<RawClient["server.websearch"]["websearch.provider.select"]>[0]
|
||||
export type Endpoint27_1Input = {
|
||||
readonly location?: Endpoint27_1Request["query"]["location"]
|
||||
readonly providerID: Endpoint27_1Request["payload"]["providerID"]
|
||||
}
|
||||
export type Endpoint27_1Output = EffectValue<ReturnType<RawClient["server.websearch"]["websearch.provider.select"]>>
|
||||
export type WebsearchProviderSelectOperation<E = never> = (
|
||||
input: Endpoint27_1Input,
|
||||
type Endpoint27_1Request = Parameters<RawClient["server.websearch"]["websearch.provider.selected"]>[0]
|
||||
export type Endpoint27_1Input = { readonly location?: Endpoint27_1Request["query"]["location"] }
|
||||
export type Endpoint27_1Output = EffectValue<ReturnType<RawClient["server.websearch"]["websearch.provider.selected"]>>
|
||||
export type WebsearchProviderSelectedOperation<E = never> = (
|
||||
input?: Endpoint27_1Input,
|
||||
) => Effect.Effect<Endpoint27_1Output, E>
|
||||
|
||||
type Endpoint27_2Request = Parameters<RawClient["server.websearch"]["websearch.query"]>[0]
|
||||
type Endpoint27_2Request = Parameters<RawClient["server.websearch"]["websearch.provider.select"]>[0]
|
||||
export type Endpoint27_2Input = {
|
||||
readonly location?: Endpoint27_2Request["query"]["location"]
|
||||
readonly query: Endpoint27_2Request["payload"]["query"]
|
||||
readonly providerID?: Endpoint27_2Request["payload"]["providerID"]
|
||||
readonly providerID: Endpoint27_2Request["payload"]["providerID"]
|
||||
}
|
||||
export type Endpoint27_2Output = EffectValue<ReturnType<RawClient["server.websearch"]["websearch.query"]>>
|
||||
export type WebsearchQueryOperation<E = never> = (input: Endpoint27_2Input) => Effect.Effect<Endpoint27_2Output, E>
|
||||
export type Endpoint27_2Output = EffectValue<ReturnType<RawClient["server.websearch"]["websearch.provider.select"]>>
|
||||
export type WebsearchProviderSelectOperation<E = never> = (
|
||||
input: Endpoint27_2Input,
|
||||
) => Effect.Effect<Endpoint27_2Output, E>
|
||||
|
||||
type Endpoint27_3Request = Parameters<RawClient["server.websearch"]["websearch.query"]>[0]
|
||||
export type Endpoint27_3Input = {
|
||||
readonly location?: Endpoint27_3Request["query"]["location"]
|
||||
readonly query: Endpoint27_3Request["payload"]["query"]
|
||||
readonly providerID?: Endpoint27_3Request["payload"]["providerID"]
|
||||
}
|
||||
export type Endpoint27_3Output = EffectValue<ReturnType<RawClient["server.websearch"]["websearch.query"]>>
|
||||
export type WebsearchQueryOperation<E = never> = (input: Endpoint27_3Input) => Effect.Effect<Endpoint27_3Output, E>
|
||||
|
||||
export interface WebsearchApi<E = never> {
|
||||
readonly provider: {
|
||||
readonly get: WebsearchProviderGetOperation<E>
|
||||
readonly list: WebsearchProviderListOperation<E>
|
||||
readonly selected: WebsearchProviderSelectedOperation<E>
|
||||
readonly select: WebsearchProviderSelectOperation<E>
|
||||
}
|
||||
readonly query: WebsearchQueryOperation<E>
|
||||
|
|
|
|||
|
|
@ -1122,37 +1122,42 @@ const adaptGroup26 = (raw: RawClient["server.debug"]) => ({
|
|||
location: { list: Endpoint26_0(raw), evict: Endpoint26_1(raw) },
|
||||
})
|
||||
|
||||
type Endpoint27_0Request = Parameters<RawClient["server.websearch"]["websearch.provider.get"]>[0]
|
||||
type Endpoint27_0Request = Parameters<RawClient["server.websearch"]["websearch.provider.list"]>[0]
|
||||
type Endpoint27_0Input = { readonly location?: Endpoint27_0Request["query"]["location"] }
|
||||
const Endpoint27_0 = (raw: RawClient["server.websearch"]) => (input?: Endpoint27_0Input) =>
|
||||
raw["websearch.provider.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||
raw["websearch.provider.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint27_1Request = Parameters<RawClient["server.websearch"]["websearch.provider.select"]>[0]
|
||||
type Endpoint27_1Input = {
|
||||
readonly location?: Endpoint27_1Request["query"]["location"]
|
||||
readonly providerID: Endpoint27_1Request["payload"]["providerID"]
|
||||
type Endpoint27_1Request = Parameters<RawClient["server.websearch"]["websearch.provider.selected"]>[0]
|
||||
type Endpoint27_1Input = { readonly location?: Endpoint27_1Request["query"]["location"] }
|
||||
const Endpoint27_1 = (raw: RawClient["server.websearch"]) => (input?: Endpoint27_1Input) =>
|
||||
raw["websearch.provider.selected"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint27_2Request = Parameters<RawClient["server.websearch"]["websearch.provider.select"]>[0]
|
||||
type Endpoint27_2Input = {
|
||||
readonly location?: Endpoint27_2Request["query"]["location"]
|
||||
readonly providerID: Endpoint27_2Request["payload"]["providerID"]
|
||||
}
|
||||
const Endpoint27_1 = (raw: RawClient["server.websearch"]) => (input: Endpoint27_1Input) =>
|
||||
const Endpoint27_2 = (raw: RawClient["server.websearch"]) => (input: Endpoint27_2Input) =>
|
||||
raw["websearch.provider.select"]({
|
||||
query: { location: input["location"] },
|
||||
payload: { providerID: input["providerID"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint27_2Request = Parameters<RawClient["server.websearch"]["websearch.query"]>[0]
|
||||
type Endpoint27_2Input = {
|
||||
readonly location?: Endpoint27_2Request["query"]["location"]
|
||||
readonly query: Endpoint27_2Request["payload"]["query"]
|
||||
readonly providerID?: Endpoint27_2Request["payload"]["providerID"]
|
||||
type Endpoint27_3Request = Parameters<RawClient["server.websearch"]["websearch.query"]>[0]
|
||||
type Endpoint27_3Input = {
|
||||
readonly location?: Endpoint27_3Request["query"]["location"]
|
||||
readonly query: Endpoint27_3Request["payload"]["query"]
|
||||
readonly providerID?: Endpoint27_3Request["payload"]["providerID"]
|
||||
}
|
||||
const Endpoint27_2 = (raw: RawClient["server.websearch"]) => (input: Endpoint27_2Input) =>
|
||||
const Endpoint27_3 = (raw: RawClient["server.websearch"]) => (input: Endpoint27_3Input) =>
|
||||
raw["websearch.query"]({
|
||||
query: { location: input["location"] },
|
||||
payload: { query: input["query"], providerID: input["providerID"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
const adaptGroup27 = (raw: RawClient["server.websearch"]) => ({
|
||||
provider: { get: Endpoint27_0(raw), select: Endpoint27_1(raw) },
|
||||
query: Endpoint27_2(raw),
|
||||
provider: { list: Endpoint27_0(raw), selected: Endpoint27_1(raw), select: Endpoint27_2(raw) },
|
||||
query: Endpoint27_3(raw),
|
||||
})
|
||||
|
||||
const adaptClient = (raw: RawClient) => ({
|
||||
|
|
|
|||
|
|
@ -185,8 +185,10 @@ import type {
|
|||
DebugLocationListOutput,
|
||||
DebugLocationEvictInput,
|
||||
DebugLocationEvictOutput,
|
||||
WebsearchProviderGetInput,
|
||||
WebsearchProviderGetOutput,
|
||||
WebsearchProviderListInput,
|
||||
WebsearchProviderListOutput,
|
||||
WebsearchProviderSelectedInput,
|
||||
WebsearchProviderSelectedOutput,
|
||||
WebsearchProviderSelectInput,
|
||||
WebsearchProviderSelectOutput,
|
||||
WebsearchQueryInput,
|
||||
|
|
@ -1586,13 +1588,25 @@ export function make(options: ClientOptions) {
|
|||
},
|
||||
websearch: {
|
||||
provider: {
|
||||
get: (input?: WebsearchProviderGetInput, requestOptions?: RequestOptions) =>
|
||||
request<WebsearchProviderGetOutput>(
|
||||
list: (input?: WebsearchProviderListInput, requestOptions?: RequestOptions) =>
|
||||
request<WebsearchProviderListOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/websearch/provider`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [503, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
selected: (input?: WebsearchProviderSelectedInput, requestOptions?: RequestOptions) =>
|
||||
request<WebsearchProviderSelectedOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/websearch/provider/selected`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
|
|
@ -1602,7 +1616,7 @@ export function make(options: ClientOptions) {
|
|||
request<WebsearchProviderSelectOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/websearch/provider`,
|
||||
path: `/api/websearch/provider/selected`,
|
||||
query: { location: input["location"] },
|
||||
body: { providerID: input["providerID"] },
|
||||
successStatus: 204,
|
||||
|
|
|
|||
|
|
@ -2412,7 +2412,6 @@ export type IntegrationListOutput = {
|
|||
| { readonly type: "key"; readonly label?: string }
|
||||
| { readonly type: "env"; readonly names: ReadonlyArray<string> }
|
||||
>
|
||||
readonly websearch?: { readonly connection: "optional" | "required" }
|
||||
readonly connections: ReadonlyArray<
|
||||
| { readonly type: "credential"; readonly id: string; readonly label: string }
|
||||
| { readonly type: "env"; readonly name: string }
|
||||
|
|
@ -2465,7 +2464,6 @@ export type IntegrationGetOutput = {
|
|||
| { readonly type: "key"; readonly label?: string }
|
||||
| { readonly type: "env"; readonly names: ReadonlyArray<string> }
|
||||
>
|
||||
readonly websearch?: { readonly connection: "optional" | "required" }
|
||||
readonly connections: ReadonlyArray<
|
||||
| { readonly type: "credential"; readonly id: string; readonly label: string }
|
||||
| { readonly type: "env"; readonly name: string }
|
||||
|
|
@ -2808,14 +2806,6 @@ export type FormRequestListOutput = {
|
|||
readonly mode: "url"
|
||||
readonly url: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly title: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly mode: "integration"
|
||||
readonly integrationID: string
|
||||
}
|
||||
>
|
||||
}
|
||||
|
||||
|
|
@ -2928,14 +2918,6 @@ export type FormListOutput = {
|
|||
readonly mode: "url"
|
||||
readonly url: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly title: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly mode: "integration"
|
||||
readonly integrationID: string
|
||||
}
|
||||
>
|
||||
}["data"]
|
||||
|
||||
|
|
@ -3632,14 +3614,6 @@ export type FormCreateOutput = {
|
|||
readonly mode: "url"
|
||||
readonly url: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly title: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly mode: "integration"
|
||||
readonly integrationID: string
|
||||
}
|
||||
}["data"]
|
||||
|
||||
export type FormGetInput = {
|
||||
|
|
@ -3754,14 +3728,6 @@ export type FormGetOutput = {
|
|||
readonly mode: "url"
|
||||
readonly url: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly title: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly mode: "integration"
|
||||
readonly integrationID: string
|
||||
}
|
||||
}["data"]
|
||||
|
||||
export type FormStateInput = {
|
||||
|
|
@ -5595,14 +5561,6 @@ export type EventSubscribeOutput =
|
|||
readonly mode: "url"
|
||||
readonly url: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly title: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly mode: "integration"
|
||||
readonly integrationID: string
|
||||
}
|
||||
}
|
||||
}
|
||||
| {
|
||||
|
|
@ -5625,6 +5583,14 @@ export type EventSubscribeOutput =
|
|||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly id: string; readonly sessionID: string }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "websearch.updated"
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
|
|
@ -6388,13 +6354,28 @@ export type DebugLocationEvictInput = {
|
|||
|
||||
export type DebugLocationEvictOutput = void
|
||||
|
||||
export type WebsearchProviderGetInput = {
|
||||
export type WebsearchProviderListInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type WebsearchProviderGetOutput = {
|
||||
export type WebsearchProviderListOutput = {
|
||||
readonly location: {
|
||||
readonly directory: string
|
||||
readonly workspaceID?: string
|
||||
readonly project: { readonly id: string; readonly directory: string }
|
||||
}
|
||||
readonly data: ReadonlyArray<{ readonly id: string; readonly name: string }>
|
||||
}
|
||||
|
||||
export type WebsearchProviderSelectedInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type WebsearchProviderSelectedOutput = {
|
||||
readonly location: {
|
||||
readonly directory: string
|
||||
readonly workspaceID?: string
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ test("exposes every standard HTTP API group", () => {
|
|||
expect(Object.keys(client.integration.connect)).toEqual(["key", "oauth"])
|
||||
expect(Object.keys(client.integration.attempt)).toEqual(["status", "complete", "cancel"])
|
||||
expect(Object.keys(client.websearch)).toEqual(["provider", "query"])
|
||||
expect(Object.keys(client.websearch.provider)).toEqual(["get", "select"])
|
||||
expect(Object.keys(client.websearch.provider)).toEqual(["list", "selected", "select"])
|
||||
expect(Object.keys(client.file)).toEqual(["read", "list", "find"])
|
||||
expect(Object.keys(client.vcs)).toEqual(["status", "diff"])
|
||||
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"])
|
||||
|
|
@ -84,19 +84,27 @@ test("websearch provider methods use the public HTTP contract", async () => {
|
|||
if (request.method === "POST") return new Response(null, { status: 204 })
|
||||
return Response.json({
|
||||
location: { directory: "/tmp/project", project: { id: "proj_test", directory: "/tmp/project" } },
|
||||
data: "exa",
|
||||
data: request.url.endsWith("/selected?location%5Bdirectory%5D=%2Ftmp%2Fproject")
|
||||
? "exa"
|
||||
: [{ id: "exa", name: "Exa" }],
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
expect(await client.websearch.provider.get({ location: { directory: "/tmp/project" } })).toMatchObject({ data: "exa" })
|
||||
expect(await client.websearch.provider.list({ location: { directory: "/tmp/project" } })).toMatchObject({
|
||||
data: [{ id: "exa", name: "Exa" }],
|
||||
})
|
||||
expect(await client.websearch.provider.selected({ location: { directory: "/tmp/project" } })).toMatchObject({
|
||||
data: "exa",
|
||||
})
|
||||
await client.websearch.provider.select({ providerID: "parallel", location: { directory: "/tmp/project" } })
|
||||
|
||||
expect(requests.map((request) => [request.method, request.url])).toEqual([
|
||||
["GET", "http://localhost:3000/api/websearch/provider?location%5Bdirectory%5D=%2Ftmp%2Fproject"],
|
||||
["POST", "http://localhost:3000/api/websearch/provider?location%5Bdirectory%5D=%2Ftmp%2Fproject"],
|
||||
["GET", "http://localhost:3000/api/websearch/provider/selected?location%5Bdirectory%5D=%2Ftmp%2Fproject"],
|
||||
["POST", "http://localhost:3000/api/websearch/provider/selected?location%5Bdirectory%5D=%2Ftmp%2Fproject"],
|
||||
])
|
||||
expect(await requests[1]?.json()).toEqual({ providerID: "parallel" })
|
||||
expect(await requests[2]?.json()).toEqual({ providerID: "parallel" })
|
||||
})
|
||||
|
||||
test("server.get uses the public HTTP contract", async () => {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
export * as ConfigWebSearch from "./websearch"
|
||||
|
||||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
import { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
import { Schema } from "effect"
|
||||
|
||||
export class Info extends Schema.Class<Info>("ConfigWebSearch.Info")({
|
||||
provider: Integration.ID,
|
||||
provider: WebSearch.ID,
|
||||
}) {}
|
||||
|
|
|
|||
|
|
@ -67,7 +67,6 @@ export class InvalidFormError extends Schema.TaggedErrorClass<InvalidFormError>(
|
|||
export type CreateInput =
|
||||
| (Omit<Form.FormInfo, "id"> & { readonly id?: ID })
|
||||
| (Omit<Form.UrlInfo, "id"> & { readonly id?: ID })
|
||||
| (Omit<Form.IntegrationInfo, "id"> & { readonly id?: ID })
|
||||
|
||||
export interface ReplyInput {
|
||||
readonly id: ID
|
||||
|
|
@ -137,11 +136,7 @@ export const layer = Layer.effect(
|
|||
...(input.metadata === undefined ? {} : { metadata: input.metadata }),
|
||||
}
|
||||
const form: Info =
|
||||
input.mode === "form"
|
||||
? { ...base, mode: "form", fields: input.fields }
|
||||
: input.mode === "url"
|
||||
? { ...base, mode: "url", url: input.url }
|
||||
: { ...base, mode: "integration", integrationID: input.integrationID }
|
||||
input.mode === "form" ? { ...base, mode: "form", fields: input.fields } : { ...base, mode: "url", url: input.url }
|
||||
const entry: Entry = {
|
||||
form,
|
||||
state: { status: "pending" },
|
||||
|
|
@ -233,7 +228,7 @@ export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.n
|
|||
function validateAnswer(form: Info, answer: Answer) {
|
||||
if (form.mode !== "form") {
|
||||
if (Object.keys(answer).length === 0) return
|
||||
return `${form.mode === "url" ? "URL" : "Integration"} forms must be answered with an empty answer`
|
||||
return "URL forms must be answered with an empty answer"
|
||||
}
|
||||
const fields = new Map(form.fields.map((field) => [field.key, field]))
|
||||
for (const key of Object.keys(answer)) {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import {
|
|||
Types,
|
||||
} from "effect"
|
||||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
import { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
import { Credential } from "./credential"
|
||||
import { State } from "./state"
|
||||
import { EventV2 } from "./event"
|
||||
|
|
@ -95,15 +94,6 @@ export interface EnvImplementation {
|
|||
|
||||
export type Implementation = OAuthImplementation | KeyImplementation | EnvImplementation
|
||||
|
||||
export interface WebSearchImplementation {
|
||||
readonly integrationID: ID
|
||||
readonly connection: Integration.WebSearch["connection"]
|
||||
readonly execute: (
|
||||
input: WebSearch.Input,
|
||||
context: { readonly credential?: Credential.Value; readonly sessionID?: string },
|
||||
) => Effect.Effect<WebSearch.ProviderOutput, unknown>
|
||||
}
|
||||
|
||||
export const Attempt = Integration.Attempt
|
||||
export type Attempt = Integration.Attempt
|
||||
|
||||
|
|
@ -129,7 +119,6 @@ type Entry = {
|
|||
ref: Types.DeepMutable<Ref>
|
||||
methods: Types.DeepMutable<Method>[]
|
||||
implementations: Map<MethodID, Types.DeepMutable<OAuthImplementation>>
|
||||
websearch?: Types.DeepMutable<WebSearchImplementation>
|
||||
}
|
||||
|
||||
type Data = {
|
||||
|
|
@ -146,11 +135,6 @@ export type Draft = {
|
|||
update: (implementation: Implementation) => void
|
||||
remove: (integrationID: ID, method: Method) => void
|
||||
}
|
||||
websearch: {
|
||||
list: () => readonly WebSearchImplementation[]
|
||||
update: (implementation: WebSearchImplementation) => void
|
||||
remove: (integrationID: ID) => void
|
||||
}
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
|
|
@ -207,10 +191,6 @@ export interface Interface extends State.Transformable<Draft> {
|
|||
/** Cancels an attempt and releases its resources. */
|
||||
readonly cancel: (attemptID: AttemptID) => Effect.Effect<void>
|
||||
}
|
||||
readonly websearch: {
|
||||
readonly list: () => Effect.Effect<readonly WebSearchImplementation[]>
|
||||
readonly get: (integrationID: ID) => Effect.Effect<WebSearchImplementation | undefined>
|
||||
}
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Integration") {}
|
||||
|
|
@ -302,30 +282,6 @@ const layer = Layer.effect(
|
|||
if (method.type === "oauth") current.implementations.delete(method.id)
|
||||
},
|
||||
},
|
||||
websearch: {
|
||||
list: () =>
|
||||
Array.from(draft.integrations.values()).flatMap((entry) =>
|
||||
entry.websearch ? [entry.websearch as WebSearchImplementation] : [],
|
||||
),
|
||||
update: (implementation) => {
|
||||
const current = draft.integrations.get(implementation.integrationID) ?? {
|
||||
ref: {
|
||||
id: implementation.integrationID,
|
||||
name: implementation.integrationID,
|
||||
},
|
||||
methods: [],
|
||||
implementations: new Map<MethodID, Types.DeepMutable<OAuthImplementation>>(),
|
||||
}
|
||||
if (!draft.integrations.has(implementation.integrationID)) {
|
||||
draft.integrations.set(implementation.integrationID, current)
|
||||
}
|
||||
current.websearch = implementation as Types.DeepMutable<WebSearchImplementation>
|
||||
},
|
||||
remove: (integrationID) => {
|
||||
const current = draft.integrations.get(integrationID)
|
||||
if (current) delete current.websearch
|
||||
},
|
||||
},
|
||||
}),
|
||||
finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
|
|
@ -350,7 +306,6 @@ const layer = Layer.effect(
|
|||
id: entry.ref.id,
|
||||
name: entry.ref.name,
|
||||
methods: entry.methods,
|
||||
websearch: entry.websearch ? { connection: entry.websearch.connection } : undefined,
|
||||
connections,
|
||||
})
|
||||
|
||||
|
|
@ -595,16 +550,6 @@ const layer = Layer.effect(
|
|||
if (attempt) yield* Scope.close(attempt.scope, Exit.void)
|
||||
}),
|
||||
},
|
||||
websearch: {
|
||||
list: Effect.fn("Integration.websearch.list")(function* () {
|
||||
return Array.from(state.get().integrations.values()).flatMap((entry) =>
|
||||
entry.websearch ? [entry.websearch as WebSearchImplementation] : [],
|
||||
)
|
||||
}),
|
||||
get: Effect.fn("Integration.websearch.get")(function* (integrationID) {
|
||||
return state.get().integrations.get(integrationID)?.websearch as WebSearchImplementation | undefined
|
||||
}),
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { Integration } from "./integration"
|
|||
import { Location } from "./location"
|
||||
import { PluginHost } from "./plugin/host"
|
||||
import { PluginRuntime } from "./plugin/runtime"
|
||||
import { WebSearch } from "./websearch"
|
||||
import { Reference } from "./reference"
|
||||
import { SkillV2 } from "./skill"
|
||||
import { State } from "./state"
|
||||
|
|
@ -139,5 +140,6 @@ export const node = makeLocationNode({
|
|||
ToolHooks.node,
|
||||
PluginHooks.node,
|
||||
PluginRuntime.node,
|
||||
WebSearch.node,
|
||||
],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { Tool } from "../tool/tool"
|
|||
import { Tools } from "../tool/tools"
|
||||
import { ToolHooks } from "../tool/hooks"
|
||||
import { WorkspaceV2 } from "../workspace"
|
||||
import { WebSearch } from "../websearch"
|
||||
import { PluginHooks } from "./hooks"
|
||||
|
||||
const mutable = <T>(value: T) => value as DeepMutable<T>
|
||||
|
|
@ -38,6 +39,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||
const reference = yield* Reference.Service
|
||||
const skill = yield* SkillV2.Service
|
||||
const tools = yield* Tools.Service
|
||||
const websearch = yield* WebSearch.Service
|
||||
const toolHooks = yield* ToolHooks.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
|
|
@ -304,6 +306,14 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||
})
|
||||
},
|
||||
},
|
||||
websearch: {
|
||||
register: (definition) =>
|
||||
websearch.register({
|
||||
id: WebSearch.ID.make(definition.id),
|
||||
name: definition.name,
|
||||
execute: definition.execute,
|
||||
}),
|
||||
},
|
||||
session: {
|
||||
hook: (name, callback) => hooks.register("session", name, callback),
|
||||
create: (input) =>
|
||||
|
|
@ -345,12 +355,6 @@ function registerIntegration(draft: Integration.Draft, definition: IntegrationDe
|
|||
}),
|
||||
)
|
||||
}
|
||||
if (!definition.websearch) return
|
||||
draft.websearch.update({
|
||||
integrationID,
|
||||
connection: definition.websearch.connection,
|
||||
execute: definition.websearch.execute,
|
||||
})
|
||||
}
|
||||
|
||||
function methodImplementation(input: IntegrationMethodRegistration): Integration.Implementation {
|
||||
|
|
|
|||
|
|
@ -114,6 +114,20 @@ export function fromPromise(plugin: PromisePlugin) {
|
|||
hook: (name, callback) =>
|
||||
register(host.tool.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
||||
},
|
||||
websearch: {
|
||||
register: (definition) =>
|
||||
register(
|
||||
host.websearch.register({
|
||||
id: definition.id,
|
||||
name: definition.name,
|
||||
execute: (input, execution) =>
|
||||
Effect.tryPromise({
|
||||
try: (signal) => definition.execute(input, { ...execution, signal }),
|
||||
catch: (cause) => cause,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
},
|
||||
session: {
|
||||
create: (input) => run(host.session.create(input)),
|
||||
get: (input) => run(host.session.get(input)),
|
||||
|
|
@ -131,7 +145,7 @@ export function fromPromise(plugin: PromisePlugin) {
|
|||
}
|
||||
|
||||
function adaptIntegration(definition: IntegrationDefinition) {
|
||||
const { methods, websearch, ...definitionInfo } = definition
|
||||
const { methods, ...definitionInfo } = definition
|
||||
return {
|
||||
...definitionInfo,
|
||||
methods: methods?.map((method) => {
|
||||
|
|
@ -163,20 +177,5 @@ function adaptIntegration(definition: IntegrationDefinition) {
|
|||
: {}),
|
||||
}
|
||||
}),
|
||||
...(websearch
|
||||
? {
|
||||
websearch: {
|
||||
connection: websearch.connection,
|
||||
execute: (
|
||||
input: Parameters<typeof websearch.execute>[0],
|
||||
execution: Omit<Parameters<typeof websearch.execute>[1], "signal">,
|
||||
) =>
|
||||
Effect.tryPromise({
|
||||
try: (signal) => websearch.execute(input, { ...execution, signal }),
|
||||
catch: (cause) => cause,
|
||||
}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,12 +33,17 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
|
|||
{ type: "key", label: "API key (optional)" },
|
||||
{ type: "env", names: ["EXA_API_KEY"] },
|
||||
],
|
||||
websearch: {
|
||||
connection: "optional",
|
||||
execute: (input, context) => {
|
||||
})
|
||||
yield* ctx.websearch.register({
|
||||
id: "exa",
|
||||
name: "Exa",
|
||||
execute: (input) =>
|
||||
Effect.gen(function* () {
|
||||
const connection = yield* ctx.integration.connection.active("exa")
|
||||
const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined
|
||||
const url = new URL(endpoint)
|
||||
if (context.credential?.type === "key") url.searchParams.set("exaApiKey", context.credential.key)
|
||||
return WebSearchMcp.call(
|
||||
if (credential?.type === "key") url.searchParams.set("exaApiKey", credential.key)
|
||||
return yield* WebSearchMcp.call(
|
||||
http,
|
||||
url.toString(),
|
||||
"web_search_exa",
|
||||
|
|
@ -53,8 +58,7 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
|
|||
}
|
||||
}),
|
||||
)
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -60,10 +60,15 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
|
|||
{ type: "key", label: "API key (optional)" },
|
||||
{ type: "env", names: ["PARALLEL_API_KEY"] },
|
||||
],
|
||||
websearch: {
|
||||
connection: "optional",
|
||||
execute: (input, context) =>
|
||||
WebSearchMcp.call(
|
||||
})
|
||||
yield* ctx.websearch.register({
|
||||
id: "parallel",
|
||||
name: "Parallel",
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const connection = yield* ctx.integration.connection.active("parallel")
|
||||
const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined
|
||||
return yield* WebSearchMcp.call(
|
||||
http,
|
||||
endpoint,
|
||||
"web_search",
|
||||
|
|
@ -75,7 +80,7 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
|
|||
},
|
||||
{
|
||||
"User-Agent": `opencode/${InstallationVersion}`,
|
||||
...(context.credential?.type === "key" ? { Authorization: `Bearer ${context.credential.key}` } : {}),
|
||||
...(credential?.type === "key" ? { Authorization: `Bearer ${credential.key}` } : {}),
|
||||
},
|
||||
).pipe(
|
||||
Effect.map((result) => {
|
||||
|
|
@ -85,8 +90,8 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
|
|||
...(result ? { metadata: result.structuredContent } : {}),
|
||||
}
|
||||
}),
|
||||
),
|
||||
},
|
||||
)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ export * as WebSearchTool from "./websearch"
|
|||
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Integration } from "../integration"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { WebSearch } from "../websearch"
|
||||
import { Tool } from "./tool"
|
||||
|
|
@ -20,7 +19,7 @@ export const Input = Schema.Struct({
|
|||
})
|
||||
|
||||
const Output = Schema.Struct({
|
||||
provider: Integration.ID,
|
||||
provider: WebSearch.ID,
|
||||
text: Schema.String,
|
||||
metadata: Schema.optional(Schema.Json),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ export * as WebSearch from "./websearch"
|
|||
|
||||
import { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import { Context, Effect, Layer, Schema, Semaphore, Stream } from "effect"
|
||||
import { Context, Effect, Layer, Schema, Scope, Semaphore, Stream } from "effect"
|
||||
import path from "node:path"
|
||||
import { Config } from "./config"
|
||||
import { ConfigGlobal } from "./config/global"
|
||||
|
|
@ -11,8 +11,16 @@ import { makeLocationNode } from "./effect/app-node"
|
|||
import { EventV2 } from "./event"
|
||||
import { Form } from "./form"
|
||||
import { Global } from "./global"
|
||||
import { Integration } from "./integration"
|
||||
import { truthy } from "./flag/flag"
|
||||
import { State } from "./state"
|
||||
|
||||
export const ID = WebSearch.ID
|
||||
export type ID = WebSearch.ID
|
||||
|
||||
export const Provider = WebSearch.Provider
|
||||
export type Provider = WebSearch.Provider
|
||||
|
||||
export const Event = WebSearch.Event
|
||||
|
||||
export const Input = WebSearch.Input
|
||||
export type Input = WebSearch.Input
|
||||
|
|
@ -23,31 +31,32 @@ export type ProviderOutput = WebSearch.ProviderOutput
|
|||
export const Result = WebSearch.Result
|
||||
export type Result = WebSearch.Result
|
||||
|
||||
export interface ProviderImplementation extends Provider {
|
||||
readonly execute: (
|
||||
input: Pick<Input, "query">,
|
||||
context: { readonly sessionID?: string },
|
||||
) => Effect.Effect<ProviderOutput, unknown>
|
||||
}
|
||||
|
||||
export class ProviderRequiredError extends Schema.TaggedErrorClass<ProviderRequiredError>()(
|
||||
"WebSearch.ProviderRequired",
|
||||
{},
|
||||
) {}
|
||||
|
||||
export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()("WebSearch.ProviderNotFound", {
|
||||
providerID: Integration.ID,
|
||||
providerID: ID,
|
||||
}) {}
|
||||
|
||||
export class ConnectionRequiredError extends Schema.TaggedErrorClass<ConnectionRequiredError>()(
|
||||
"WebSearch.ConnectionRequired",
|
||||
{ providerID: Integration.ID },
|
||||
) {}
|
||||
|
||||
export class CancelledError extends Schema.TaggedErrorClass<CancelledError>()("WebSearch.Cancelled", {}) {}
|
||||
|
||||
export class RequestError extends Schema.TaggedErrorClass<RequestError>()("WebSearch.Request", {
|
||||
providerID: Integration.ID,
|
||||
providerID: ID,
|
||||
cause: Schema.Defect(),
|
||||
}) {}
|
||||
|
||||
export type Error =
|
||||
| ProviderRequiredError
|
||||
| ProviderNotFoundError
|
||||
| ConnectionRequiredError
|
||||
| CancelledError
|
||||
| RequestError
|
||||
|
||||
|
|
@ -56,13 +65,25 @@ export interface QueryInput extends Input {
|
|||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly selected: () => Effect.Effect<Integration.ID | undefined>
|
||||
readonly select: (providerID: Integration.ID) => Effect.Effect<void, ProviderNotFoundError>
|
||||
readonly register: (
|
||||
provider: ProviderImplementation,
|
||||
) => Effect.Effect<State.Registration, never, Scope.Scope>
|
||||
readonly list: () => Effect.Effect<readonly Provider[]>
|
||||
readonly selected: () => Effect.Effect<ID | undefined>
|
||||
readonly select: (providerID: ID) => Effect.Effect<void, ProviderNotFoundError>
|
||||
readonly query: (input: QueryInput) => Effect.Effect<Result, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/WebSearch") {}
|
||||
|
||||
type Data = {
|
||||
readonly providers: Map<ID, ProviderImplementation>
|
||||
}
|
||||
|
||||
type Draft = {
|
||||
register: (provider: ProviderImplementation) => void
|
||||
}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
|
|
@ -71,16 +92,19 @@ const layer = Layer.effect(
|
|||
const events = yield* EventV2.Service
|
||||
const forms = yield* Form.Service
|
||||
const global = yield* Global.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const onboarding = Semaphore.makeUnsafe(1)
|
||||
const decodeOutput = Schema.decodeUnknownEffect(ProviderOutput)
|
||||
const globalConfigPath = path.resolve(global.config)
|
||||
let pendingProviderID: Integration.ID | undefined
|
||||
let pendingProviderID: ID | undefined
|
||||
const state = State.create<Data, Draft>({
|
||||
initial: () => ({ providers: new Map() }),
|
||||
draft: (draft) => ({
|
||||
register: (provider) => draft.providers.set(provider.id, provider),
|
||||
}),
|
||||
finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
const requireProvider = (
|
||||
providers: Map<Integration.ID, Integration.WebSearchImplementation>,
|
||||
providerID: Integration.ID,
|
||||
) => {
|
||||
const requireProvider = (providers: Map<ID, ProviderImplementation>, providerID: ID) => {
|
||||
const provider = providers.get(providerID)
|
||||
return provider ? Effect.succeed(provider) : Effect.fail(new ProviderNotFoundError({ providerID }))
|
||||
}
|
||||
|
|
@ -96,7 +120,7 @@ const layer = Layer.effect(
|
|||
return pendingProviderID ?? (yield* globalProviderID())
|
||||
})
|
||||
|
||||
const saveProvider = Effect.fn("WebSearch.saveProvider")(function* (providerID: Integration.ID) {
|
||||
const saveProvider = Effect.fn("WebSearch.saveProvider")(function* (providerID: ID) {
|
||||
pendingProviderID = providerID
|
||||
yield* configGlobal.update(["websearch"], new ConfigWebSearch.Info({ provider: providerID })).pipe(
|
||||
Effect.tapError(() => Effect.sync(() => (pendingProviderID = undefined))),
|
||||
|
|
@ -118,12 +142,8 @@ const layer = Layer.effect(
|
|||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
const ask = Effect.fn("WebSearch.ask")(function* (
|
||||
providers: Map<Integration.ID, Integration.WebSearchImplementation>,
|
||||
sessionID: string,
|
||||
) {
|
||||
const ask = Effect.fn("WebSearch.ask")(function* (providers: Map<ID, ProviderImplementation>, sessionID: string) {
|
||||
if (providers.size === 0) return yield* new ProviderRequiredError()
|
||||
const infos = new Map((yield* integrations.list()).map((integration) => [integration.id, integration]))
|
||||
const state = yield* forms
|
||||
.ask({
|
||||
sessionID,
|
||||
|
|
@ -134,22 +154,15 @@ const layer = Layer.effect(
|
|||
{
|
||||
key: "provider",
|
||||
title: "Provider",
|
||||
description: "This becomes your default and can be changed later from Connect integration.",
|
||||
description: "This becomes your default and can be changed later from Connect.",
|
||||
type: "string",
|
||||
required: true,
|
||||
custom: false,
|
||||
options: Array.from(providers.values())
|
||||
.flatMap((provider) => {
|
||||
const info = infos.get(provider.integrationID)
|
||||
if (!info) return []
|
||||
const disconnected = provider.connection === "optional" ? "Keyless available" : "Connection required"
|
||||
return [{ info, description: info.connections.length ? "Connected" : disconnected }]
|
||||
})
|
||||
.toSorted((a, b) => a.info.name.localeCompare(b.info.name))
|
||||
.map(({ info, description }) => ({
|
||||
value: info.id,
|
||||
label: info.name,
|
||||
description,
|
||||
.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
.map((provider) => ({
|
||||
value: provider.id,
|
||||
label: provider.name,
|
||||
})),
|
||||
},
|
||||
],
|
||||
|
|
@ -158,46 +171,22 @@ const layer = Layer.effect(
|
|||
if (state.status === "cancelled") return yield* new CancelledError()
|
||||
const answer = state.answer.provider
|
||||
if (typeof answer !== "string") return yield* new ProviderRequiredError()
|
||||
return yield* requireProvider(providers, Integration.ID.make(answer))
|
||||
})
|
||||
|
||||
const connect = Effect.fn("WebSearch.connect")(function* (
|
||||
provider: Integration.WebSearchImplementation,
|
||||
sessionID?: string,
|
||||
) {
|
||||
const active = yield* integrations.connection.active(provider.integrationID)
|
||||
if (active || provider.connection === "optional") return active
|
||||
if (!sessionID) return yield* new ConnectionRequiredError({ providerID: provider.integrationID })
|
||||
const state = yield* forms
|
||||
.ask({
|
||||
sessionID,
|
||||
title: `Connect ${provider.integrationID}`,
|
||||
metadata: { kind: "integration.connection" },
|
||||
mode: "integration",
|
||||
integrationID: provider.integrationID,
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
if (state.status === "cancelled") return yield* new CancelledError()
|
||||
const connected = yield* integrations.connection.active(provider.integrationID)
|
||||
if (!connected) return yield* new ConnectionRequiredError({ providerID: provider.integrationID })
|
||||
return connected
|
||||
return yield* requireProvider(providers, ID.make(answer))
|
||||
})
|
||||
|
||||
const resolve = Effect.fn("WebSearch.resolve")(function* (input: QueryInput) {
|
||||
const providers = new Map(
|
||||
(yield* integrations.websearch.list()).map((provider) => [provider.integrationID, provider]),
|
||||
)
|
||||
const providers = state.get().providers
|
||||
if (input.providerID) return yield* requireProvider(providers, input.providerID)
|
||||
const configuredProviderID = Config.latest(yield* config.entries(), "websearch")?.provider
|
||||
if (configuredProviderID) return yield* requireProvider(providers, configuredProviderID)
|
||||
if (process.env.OPENCODE_WEBSEARCH_PROVIDER) {
|
||||
return yield* requireProvider(providers, Integration.ID.make(process.env.OPENCODE_WEBSEARCH_PROVIDER))
|
||||
return yield* requireProvider(providers, ID.make(process.env.OPENCODE_WEBSEARCH_PROVIDER))
|
||||
}
|
||||
if (truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL")) {
|
||||
return yield* requireProvider(providers, Integration.ID.make("parallel"))
|
||||
return yield* requireProvider(providers, ID.make("parallel"))
|
||||
}
|
||||
if (truthy("OPENCODE_EXPERIMENTAL") || truthy("OPENCODE_ENABLE_EXA") || truthy("OPENCODE_EXPERIMENTAL_EXA")) {
|
||||
return yield* requireProvider(providers, Integration.ID.make("exa"))
|
||||
return yield* requireProvider(providers, ID.make("exa"))
|
||||
}
|
||||
const providerID = yield* selected()
|
||||
const provider = providerID ? providers.get(providerID) : undefined
|
||||
|
|
@ -210,33 +199,32 @@ const layer = Layer.effect(
|
|||
const selectedProvider = current ? providers.get(current) : undefined
|
||||
if (selectedProvider) return selectedProvider
|
||||
const provider = yield* ask(providers, sessionID)
|
||||
yield* connect(provider, sessionID)
|
||||
yield* saveProvider(provider.integrationID)
|
||||
yield* saveProvider(provider.id)
|
||||
return provider
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
register: (provider) => state.transform((draft) => draft.register(provider)),
|
||||
list: Effect.fn("WebSearch.list")(function* () {
|
||||
return Array.from(state.get().providers.values(), (provider) => ({ id: provider.id, name: provider.name })).toSorted(
|
||||
(a, b) => a.name.localeCompare(b.name),
|
||||
)
|
||||
}),
|
||||
selected,
|
||||
select: Effect.fn("WebSearch.select")(function* (providerID) {
|
||||
const provider = yield* integrations.websearch.get(providerID)
|
||||
const provider = state.get().providers.get(providerID)
|
||||
if (!provider) return yield* new ProviderNotFoundError({ providerID })
|
||||
yield* saveProvider(providerID)
|
||||
}),
|
||||
query: Effect.fn("WebSearch.query")(function* (input) {
|
||||
const provider = yield* resolve(input)
|
||||
const connection = yield* connect(provider, input.sessionID)
|
||||
const credential = connection
|
||||
? yield* integrations.connection
|
||||
.resolve(connection)
|
||||
.pipe(Effect.mapError((cause) => new RequestError({ providerID: provider.integrationID, cause })))
|
||||
: undefined
|
||||
const output = yield* provider.execute(input, { credential, sessionID: input.sessionID }).pipe(
|
||||
const output = yield* provider.execute({ query: input.query }, { sessionID: input.sessionID }).pipe(
|
||||
Effect.flatMap(decodeOutput),
|
||||
Effect.mapError((cause) => new RequestError({ providerID: provider.integrationID, cause })),
|
||||
Effect.mapError((cause) => new RequestError({ providerID: provider.id, cause })),
|
||||
)
|
||||
return new Result({ providerID: provider.integrationID, ...output })
|
||||
return new Result({ providerID: provider.id, ...output })
|
||||
}),
|
||||
})
|
||||
}),
|
||||
|
|
@ -245,5 +233,5 @@ const layer = Layer.effect(
|
|||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Config.node, ConfigGlobal.node, EventV2.node, Form.node, Global.node, Integration.node],
|
||||
deps: [Config.node, ConfigGlobal.node, EventV2.node, Form.node, Global.node],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
|||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Form } from "@opencode-ai/core/form"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { SessionSchema } from "@opencode-ai/core/session/schema"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
|
|
@ -63,28 +62,6 @@ describe("Form", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("uses an empty reply to complete an integration form", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Form.Service
|
||||
const created = yield* service.create({
|
||||
sessionID: "ses_test",
|
||||
title: "Connect integration",
|
||||
mode: "integration",
|
||||
integrationID: Integration.ID.make("exa"),
|
||||
})
|
||||
|
||||
const invalid = yield* service.reply({ id: created.id, answer: { connected: true } }).pipe(Effect.flip)
|
||||
expect(invalid).toEqual(
|
||||
new Form.InvalidAnswerError({
|
||||
id: created.id,
|
||||
message: "Integration forms must be answered with an empty answer",
|
||||
}),
|
||||
)
|
||||
yield* service.reply({ id: created.id, answer: {} })
|
||||
expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: {} })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("gates required fields and rejects inactive answers via when", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Form.Service
|
||||
|
|
|
|||
|
|
@ -155,10 +155,6 @@ function resourceMcpLayer(url: string) {
|
|||
complete: unusedIntegration,
|
||||
cancel: unusedIntegration,
|
||||
},
|
||||
websearch: {
|
||||
list: unusedIntegration,
|
||||
get: unusedIntegration,
|
||||
},
|
||||
}),
|
||||
Layer.mock(Credential.Service, {}),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { AgentV2 } from "@opencode-ai/core/agent"
|
|||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { CommandV2 } from "@opencode-ai/core/command"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigGlobal } from "@opencode-ai/core/config/global"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
|
||||
|
|
@ -9,6 +11,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
|||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Form } from "@opencode-ai/core/form"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
|
|
@ -19,6 +22,7 @@ import { Reference } from "@opencode-ai/core/reference"
|
|||
import { SkillV2 } from "@opencode-ai/core/skill"
|
||||
import { ToolHooks } from "@opencode-ai/core/tool/hooks"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { tempLocationLayer } from "../fixture/location"
|
||||
|
||||
|
|
@ -39,6 +43,8 @@ export const PluginTestLayer = AppNodeBuilder.build(
|
|||
Npm.node,
|
||||
Credential.node,
|
||||
EventV2.node,
|
||||
Form.node,
|
||||
ConfigGlobal.node,
|
||||
LayerNodePlatform.httpClient,
|
||||
PluginV2.node,
|
||||
AgentV2.node,
|
||||
|
|
@ -52,9 +58,12 @@ export const PluginTestLayer = AppNodeBuilder.build(
|
|||
SkillV2.node,
|
||||
ToolHooks.node,
|
||||
ToolRegistry.toolsNode,
|
||||
WebSearch.node,
|
||||
]),
|
||||
[
|
||||
[Location.node, tempLocationLayer],
|
||||
[Npm.node, npmLayer],
|
||||
[Config.node, Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))],
|
||||
[ConfigGlobal.node, Layer.succeed(ConfigGlobal.Service, ConfigGlobal.Service.of({ update: () => Effect.void }))],
|
||||
],
|
||||
) as unknown as Layer.Layer<unknown, never>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Credential } from "@opencode-ai/core/credential"
|
|||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
import type {
|
||||
CredentialOAuth,
|
||||
IntegrationEnvMethod,
|
||||
|
|
@ -84,6 +85,9 @@ export function host(overrides: Overrides = {}): Plugin.Context {
|
|||
transform: () => Effect.die("unused tool.transform"),
|
||||
hook: () => Effect.die("unused tool.hook"),
|
||||
},
|
||||
websearch: overrides.websearch ?? {
|
||||
register: () => Effect.die("unused websearch.register"),
|
||||
},
|
||||
session: overrides.session ?? {
|
||||
create: () => Effect.die("unused session.create"),
|
||||
get: () => Effect.die("unused session.get"),
|
||||
|
|
@ -236,6 +240,17 @@ export function integrationHost(integration: Integration.Interface): Plugin.Cont
|
|||
}
|
||||
}
|
||||
|
||||
export function webSearchHost(websearch: WebSearch.Interface): Plugin.Context["websearch"] {
|
||||
return {
|
||||
register: (definition) =>
|
||||
websearch.register({
|
||||
id: WebSearch.ID.make(definition.id),
|
||||
name: definition.name,
|
||||
execute: definition.execute,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function registerIntegration(draft: Integration.Draft, definition: IntegrationDefinition) {
|
||||
const integrationID = Integration.ID.make(definition.id)
|
||||
draft.update(integrationID, (integration) => (integration.name = definition.name))
|
||||
|
|
@ -259,12 +274,6 @@ function registerIntegration(draft: Integration.Draft, definition: IntegrationDe
|
|||
}),
|
||||
)
|
||||
}
|
||||
if (!definition.websearch) return
|
||||
draft.websearch.update({
|
||||
integrationID,
|
||||
connection: definition.websearch.connection,
|
||||
execute: definition.websearch.execute,
|
||||
})
|
||||
}
|
||||
|
||||
function methodImplementation(input: IntegrationMethodRegistration): Integration.Implementation {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
import { Plugin } from "@opencode-ai/plugin/v2"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
|
@ -95,34 +95,33 @@ describe("fromPromise", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("adapts promise web search capability execution", () =>
|
||||
it.effect("registers a standalone web search provider", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const websearch = yield* WebSearch.Service
|
||||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
const promisePlugin = Plugin.define({
|
||||
id: "promise-websearch",
|
||||
setup: async (ctx) => {
|
||||
await ctx.integration.register({
|
||||
await ctx.websearch.register({
|
||||
id: "promise-websearch",
|
||||
name: "Promise Web Search",
|
||||
methods: [{ type: "env", names: ["PROMISE_WEBSEARCH_KEY"] }],
|
||||
websearch: {
|
||||
connection: "optional",
|
||||
execute: async (input) => ({ text: `promise: ${input.query}` }),
|
||||
},
|
||||
execute: async (input) => ({ text: `promise: ${input.query}` }),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
yield* PluginPromise.fromPromise(promisePlugin).effect(host)
|
||||
expect(yield* integrations.get(Integration.ID.make("promise-websearch"))).toMatchObject({
|
||||
expect(yield* websearch.list()).toContainEqual({
|
||||
id: WebSearch.ID.make("promise-websearch"),
|
||||
name: "Promise Web Search",
|
||||
methods: [{ type: "env", names: ["PROMISE_WEBSEARCH_KEY"] }],
|
||||
})
|
||||
const provider = yield* integrations.websearch.get(Integration.ID.make("promise-websearch"))
|
||||
if (!provider) return yield* Effect.die("Expected promise web search provider")
|
||||
expect(yield* provider.execute({ query: "effect" }, {})).toEqual({ text: "promise: effect" })
|
||||
expect(
|
||||
yield* websearch.query({ query: "effect", providerID: WebSearch.ID.make("promise-websearch") }),
|
||||
).toEqual(
|
||||
new WebSearch.Result({ providerID: WebSearch.ID.make("promise-websearch"), text: "promise: effect" }),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,8 +3,12 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
|||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigGlobal } from "@opencode-ai/core/config/global"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Form } from "@opencode-ai/core/form"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
export interface WebSearchRequest {
|
||||
|
|
@ -37,5 +41,24 @@ const http = Layer.succeed(
|
|||
)
|
||||
|
||||
export const webSearchIntegrationTest = testEffect(
|
||||
Layer.merge(AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node, EventV2.node])), http),
|
||||
Layer.merge(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
Integration.node,
|
||||
Credential.node,
|
||||
EventV2.node,
|
||||
Form.node,
|
||||
ConfigGlobal.node,
|
||||
WebSearch.node,
|
||||
]),
|
||||
[
|
||||
[Config.node, Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))],
|
||||
[
|
||||
ConfigGlobal.node,
|
||||
Layer.succeed(ConfigGlobal.Service, ConfigGlobal.Service.of({ update: () => Effect.void })),
|
||||
],
|
||||
],
|
||||
),
|
||||
http,
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { beforeEach, describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
import { WebSearchExa } from "@opencode-ai/core/plugin/websearch/exa"
|
||||
import { WebSearchParallel } from "@opencode-ai/core/plugin/websearch/parallel"
|
||||
import { host, integrationHost } from "./host"
|
||||
import { host, integrationHost, webSearchHost } from "./host"
|
||||
import { requests, resetWebSearchFixture, webSearchIntegrationTest } from "./websearch-fixture"
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -19,50 +19,54 @@ beforeEach(() => {
|
|||
|
||||
const it = webSearchIntegrationTest
|
||||
|
||||
describe("built-in web search integrations", () => {
|
||||
it.effect("registers and disposes an atomic web search integration", () =>
|
||||
describe("built-in web search providers", () => {
|
||||
it.effect("registers a provider without an integration", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const registration = yield* integrationHost(integrations).register({
|
||||
const websearch = yield* WebSearch.Service
|
||||
const registration = yield* webSearchHost(websearch).register({
|
||||
id: "test-websearch",
|
||||
name: "Test Web Search",
|
||||
methods: [{ type: "key", label: "API key" }],
|
||||
websearch: {
|
||||
connection: "required",
|
||||
execute: (input) => Effect.succeed({ text: input.query }),
|
||||
},
|
||||
execute: (input) => Effect.succeed({ text: input.query }),
|
||||
})
|
||||
|
||||
expect(yield* integrations.get(Integration.ID.make("test-websearch"))).toMatchObject({
|
||||
expect(yield* integrations.get(Integration.ID.make("test-websearch"))).toBeUndefined()
|
||||
expect(yield* websearch.list()).toContainEqual({
|
||||
id: WebSearch.ID.make("test-websearch"),
|
||||
name: "Test Web Search",
|
||||
methods: [{ type: "key", label: "API key" }],
|
||||
websearch: { connection: "required" },
|
||||
})
|
||||
yield* registration.dispose
|
||||
expect(yield* integrations.get(Integration.ID.make("test-websearch"))).toBeUndefined()
|
||||
expect(yield* websearch.list()).not.toContainEqual({
|
||||
id: WebSearch.ID.make("test-websearch"),
|
||||
name: "Test Web Search",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("registers Exa with its MCP schema", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
yield* WebSearchExa.Plugin.effect(host({ integration: integrationHost(integrations) }))
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* WebSearchExa.Plugin.effect(
|
||||
host({ integration: integrationHost(integrations), websearch: webSearchHost(websearch) }),
|
||||
)
|
||||
|
||||
const info = yield* integrations.get(Integration.ID.make("exa"))
|
||||
expect(info).toMatchObject({
|
||||
id: "exa",
|
||||
name: "Exa",
|
||||
methods: [{ type: "key" }, { type: "env", names: ["EXA_API_KEY"] }],
|
||||
websearch: { connection: "optional" },
|
||||
})
|
||||
const provider = yield* integrations.websearch.get(Integration.ID.make("exa"))
|
||||
if (!provider) return yield* Effect.die("Expected Exa web search provider")
|
||||
yield* integrations.connection.key({ integrationID: Integration.ID.make("exa"), key: "exa secret" })
|
||||
expect(
|
||||
yield* provider.execute(
|
||||
{ query: "effect typescript" },
|
||||
{ credential: Credential.Key.make({ type: "key", key: "exa secret" }) },
|
||||
),
|
||||
).toEqual({ text: "search results", metadata: { searchTime: 123 } })
|
||||
yield* websearch.query({ query: "effect typescript", providerID: WebSearch.ID.make("exa") }),
|
||||
).toEqual(
|
||||
new WebSearch.Result({
|
||||
providerID: WebSearch.ID.make("exa"),
|
||||
text: "search results",
|
||||
metadata: { searchTime: 123 },
|
||||
}),
|
||||
)
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
url: `${WebSearchExa.endpoint}?exaApiKey=exa+secret`,
|
||||
|
|
@ -107,34 +111,37 @@ describe("built-in web search integrations", () => {
|
|||
}),
|
||||
)
|
||||
const integrations = yield* Integration.Service
|
||||
yield* WebSearchParallel.Plugin.effect(host({ integration: integrationHost(integrations) }))
|
||||
const provider = yield* integrations.websearch.get(Integration.ID.make("parallel"))
|
||||
if (!provider) return yield* Effect.die("Expected Parallel web search provider")
|
||||
|
||||
const output = yield* provider.execute(
|
||||
{ query: "effect layers" },
|
||||
{
|
||||
sessionID: "ses_parallel",
|
||||
credential: Credential.Key.make({ type: "key", key: "parallel-secret" }),
|
||||
},
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* WebSearchParallel.Plugin.effect(
|
||||
host({ integration: integrationHost(integrations), websearch: webSearchHost(websearch) }),
|
||||
)
|
||||
expect(output).toEqual({
|
||||
text: "search results",
|
||||
metadata: {
|
||||
search_id: "search_1",
|
||||
results: [
|
||||
{
|
||||
url: "https://effect.website",
|
||||
title: "Effect",
|
||||
publish_date: null,
|
||||
excerpts: ["Effect documentation"],
|
||||
},
|
||||
],
|
||||
warnings: null,
|
||||
usage: [{ name: "sku_search", count: 1 }],
|
||||
session_id: "ses_parallel",
|
||||
},
|
||||
yield* integrations.connection.key({ integrationID: Integration.ID.make("parallel"), key: "parallel-secret" })
|
||||
|
||||
const output = yield* websearch.query({
|
||||
query: "effect layers",
|
||||
providerID: WebSearch.ID.make("parallel"),
|
||||
sessionID: "ses_parallel",
|
||||
})
|
||||
expect(output).toEqual(
|
||||
new WebSearch.Result({
|
||||
providerID: WebSearch.ID.make("parallel"),
|
||||
text: "search results",
|
||||
metadata: {
|
||||
search_id: "search_1",
|
||||
results: [
|
||||
{
|
||||
url: "https://effect.website",
|
||||
title: "Effect",
|
||||
publish_date: null,
|
||||
excerpts: ["Effect documentation"],
|
||||
},
|
||||
],
|
||||
warnings: null,
|
||||
usage: [{ name: "sku_search", count: 1 }],
|
||||
session_id: "ses_parallel",
|
||||
},
|
||||
}),
|
||||
)
|
||||
expect(requests[0]).toMatchObject({
|
||||
url: WebSearchParallel.endpoint,
|
||||
headers: { authorization: "Bearer parallel-secret" },
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { Effect, Layer } from "effect"
|
|||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
|
|
@ -22,12 +21,12 @@ const webSearchToolNode = makeLocationNode({
|
|||
const sessionID = SessionV2.ID.make("ses_websearch_test")
|
||||
const assertions: PermissionV2.AssertInput[] = []
|
||||
const queries: WebSearch.QueryInput[] = []
|
||||
let result = new WebSearch.Result({ providerID: Integration.ID.make("exa"), text: "search results" })
|
||||
let result = new WebSearch.Result({ providerID: WebSearch.ID.make("exa"), text: "search results" })
|
||||
|
||||
beforeEach(() => {
|
||||
assertions.length = 0
|
||||
queries.length = 0
|
||||
result = new WebSearch.Result({ providerID: Integration.ID.make("exa"), text: "search results" })
|
||||
result = new WebSearch.Result({ providerID: WebSearch.ID.make("exa"), text: "search results" })
|
||||
})
|
||||
|
||||
const permission = Layer.succeed(
|
||||
|
|
@ -44,6 +43,8 @@ const permission = Layer.succeed(
|
|||
const websearch = Layer.succeed(
|
||||
WebSearch.Service,
|
||||
WebSearch.Service.of({
|
||||
register: () => Effect.die("unused"),
|
||||
list: () => Effect.succeed([]),
|
||||
selected: () => Effect.succeed(undefined),
|
||||
select: () => Effect.die("unused"),
|
||||
query: (input) =>
|
||||
|
|
@ -100,7 +101,7 @@ describe("WebSearchTool registration", () => {
|
|||
it.effect("keeps provider metadata in structured output", () =>
|
||||
Effect.gen(function* () {
|
||||
result = new WebSearch.Result({
|
||||
providerID: Integration.ID.make("parallel"),
|
||||
providerID: WebSearch.ID.make("parallel"),
|
||||
text: "parallel results",
|
||||
metadata: { requestID: "req_1" },
|
||||
})
|
||||
|
|
@ -124,7 +125,7 @@ describe("WebSearchTool registration", () => {
|
|||
|
||||
it.effect("uses the concise no-results fallback", () =>
|
||||
Effect.gen(function* () {
|
||||
result = new WebSearch.Result({ providerID: Integration.ID.make("exa"), text: "" })
|
||||
result = new WebSearch.Result({ providerID: WebSearch.ID.make("exa"), text: "" })
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect(
|
||||
|
|
|
|||
|
|
@ -6,11 +6,9 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
|||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigGlobal } from "@opencode-ai/core/config/global"
|
||||
import { ConfigWebSearch } from "@opencode-ai/core/config/websearch"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Form } from "@opencode-ai/core/form"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
|
|
@ -23,7 +21,7 @@ const configGlobal = Layer.succeed(
|
|||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([WebSearch.node, Integration.node, Credential.node, EventV2.node, Form.node, ConfigGlobal.node]),
|
||||
LayerNode.group([WebSearch.node, EventV2.node, Form.node, ConfigGlobal.node]),
|
||||
[
|
||||
[Config.node, config],
|
||||
[ConfigGlobal.node, configGlobal],
|
||||
|
|
@ -31,24 +29,21 @@ const it = testEffect(
|
|||
),
|
||||
)
|
||||
|
||||
const register = (id: string, connection: "optional" | "required" = "optional") =>
|
||||
const register = (id: string) =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const integrationID = Integration.ID.make(id)
|
||||
const calls: { input: WebSearch.Input; credential?: Credential.Value; sessionID?: string }[] = []
|
||||
yield* integrations.transform((draft) => {
|
||||
draft.update(integrationID, (integration) => (integration.name = id.toUpperCase()))
|
||||
draft.websearch.update({
|
||||
integrationID,
|
||||
connection,
|
||||
execute: (input, context) =>
|
||||
Effect.sync(() => {
|
||||
calls.push({ input, ...context })
|
||||
return { text: `${id}: ${input.query}`, metadata: { id } }
|
||||
}),
|
||||
})
|
||||
const websearch = yield* WebSearch.Service
|
||||
const providerID = WebSearch.ID.make(id)
|
||||
const calls: { input: Pick<WebSearch.Input, "query">; sessionID?: string }[] = []
|
||||
yield* websearch.register({
|
||||
id: providerID,
|
||||
name: id.toUpperCase(),
|
||||
execute: (input, context) =>
|
||||
Effect.sync(() => {
|
||||
calls.push({ input, ...context })
|
||||
return { text: `${id}: ${input.query}`, metadata: { id } }
|
||||
}),
|
||||
})
|
||||
return { integrationID, calls }
|
||||
return { providerID, calls }
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -62,9 +57,9 @@ describe("WebSearch", () => {
|
|||
const provider = yield* register("exa")
|
||||
const websearch = yield* WebSearch.Service
|
||||
|
||||
expect(yield* websearch.query({ query: "effect", providerID: provider.integrationID })).toEqual(
|
||||
expect(yield* websearch.query({ query: "effect", providerID: provider.providerID })).toEqual(
|
||||
new WebSearch.Result({
|
||||
providerID: provider.integrationID,
|
||||
providerID: provider.providerID,
|
||||
text: "exa: effect",
|
||||
metadata: { id: "exa" },
|
||||
}),
|
||||
|
|
@ -72,8 +67,7 @@ describe("WebSearch", () => {
|
|||
expect(yield* websearch.selected()).toBeUndefined()
|
||||
expect(provider.calls).toEqual([
|
||||
{
|
||||
input: { query: "effect", providerID: provider.integrationID },
|
||||
credential: undefined,
|
||||
input: { query: "effect" },
|
||||
sessionID: undefined,
|
||||
},
|
||||
])
|
||||
|
|
@ -85,12 +79,12 @@ describe("WebSearch", () => {
|
|||
yield* register("exa")
|
||||
const parallel = yield* register("parallel")
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* websearch.select(parallel.integrationID)
|
||||
yield* websearch.select(parallel.providerID)
|
||||
|
||||
expect((yield* websearch.query({ query: "layers" })).providerID).toBe(parallel.integrationID)
|
||||
expect(yield* websearch.selected()).toBe(parallel.integrationID)
|
||||
expect((yield* websearch.query({ query: "layers" })).providerID).toBe(parallel.providerID)
|
||||
expect(yield* websearch.selected()).toBe(parallel.providerID)
|
||||
expect(writes).toEqual([
|
||||
{ path: ["websearch"], value: new ConfigWebSearch.Info({ provider: parallel.integrationID }) },
|
||||
{ path: ["websearch"], value: new ConfigWebSearch.Info({ provider: parallel.providerID }) },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
|
@ -103,12 +97,12 @@ describe("WebSearch", () => {
|
|||
new Config.Document({
|
||||
type: "document",
|
||||
path: path.join(Global.Path.config, "opencode.json"),
|
||||
info: new Config.Info({ websearch: new ConfigWebSearch.Info({ provider: provider.integrationID }) }),
|
||||
info: new Config.Info({ websearch: new ConfigWebSearch.Info({ provider: provider.providerID }) }),
|
||||
}),
|
||||
]
|
||||
|
||||
expect(yield* websearch.selected()).toBe(provider.integrationID)
|
||||
expect((yield* websearch.query({ query: "configured" })).providerID).toBe(provider.integrationID)
|
||||
expect(yield* websearch.selected()).toBe(provider.providerID)
|
||||
expect((yield* websearch.query({ query: "configured" })).providerID).toBe(provider.providerID)
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -117,15 +111,15 @@ describe("WebSearch", () => {
|
|||
const exa = yield* register("exa")
|
||||
const parallel = yield* register("parallel")
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* websearch.select(exa.integrationID)
|
||||
yield* websearch.select(exa.providerID)
|
||||
entries = [
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: new Config.Info({ websearch: new ConfigWebSearch.Info({ provider: parallel.integrationID }) }),
|
||||
info: new Config.Info({ websearch: new ConfigWebSearch.Info({ provider: parallel.providerID }) }),
|
||||
}),
|
||||
]
|
||||
|
||||
expect((yield* websearch.query({ query: "configured" })).providerID).toBe(parallel.integrationID)
|
||||
expect((yield* websearch.query({ query: "configured" })).providerID).toBe(parallel.providerID)
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -142,34 +136,22 @@ describe("WebSearch", () => {
|
|||
expect(pending).toHaveLength(1)
|
||||
const form = pending[0]
|
||||
if (!form) return yield* Effect.die("Expected an onboarding form")
|
||||
yield* forms.reply({ id: form.id, answer: { provider: provider.integrationID } })
|
||||
yield* forms.reply({ id: form.id, answer: { provider: provider.providerID } })
|
||||
|
||||
expect((yield* Fiber.join(first)).providerID).toBe(provider.integrationID)
|
||||
expect((yield* Fiber.join(second)).providerID).toBe(provider.integrationID)
|
||||
expect(yield* websearch.selected()).toBe(provider.integrationID)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("requires a connection before invoking a required provider", () =>
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* register("private", "required")
|
||||
const websearch = yield* WebSearch.Service
|
||||
|
||||
expect(
|
||||
yield* websearch.query({ query: "secret", providerID: provider.integrationID }).pipe(Effect.flip),
|
||||
).toBeInstanceOf(WebSearch.ConnectionRequiredError)
|
||||
expect(provider.calls).toEqual([])
|
||||
expect((yield* Fiber.join(first)).providerID).toBe(provider.providerID)
|
||||
expect((yield* Fiber.join(second)).providerID).toBe(provider.providerID)
|
||||
expect(yield* websearch.selected()).toBe(provider.providerID)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("removes scoped provider registrations", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const websearch = yield* WebSearch.Service
|
||||
const scope = yield* Scope.fork(yield* Scope.Scope)
|
||||
const provider = yield* register("temporary").pipe(Scope.provide(scope))
|
||||
expect(yield* integrations.websearch.get(provider.integrationID)).toBeDefined()
|
||||
expect(yield* websearch.list()).toContainEqual({ id: provider.providerID, name: "TEMPORARY" })
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(yield* integrations.websearch.get(provider.integrationID)).toBeUndefined()
|
||||
expect(yield* websearch.list()).not.toContainEqual({ id: provider.providerID, name: "TEMPORARY" })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import type {
|
|||
IntegrationRef,
|
||||
} from "@opencode-ai/sdk/v2/types"
|
||||
import type { IntegrationApi } from "@opencode-ai/client/effect/api"
|
||||
import type { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
import type { Effect, Scope } from "effect"
|
||||
import type { Registration, Transform } from "./registration.js"
|
||||
|
||||
|
|
@ -53,19 +52,10 @@ export type IntegrationOAuthMethodDefinition = IntegrationOAuthMethod & {
|
|||
|
||||
export type IntegrationMethodDefinition = IntegrationOAuthMethodDefinition | IntegrationKeyMethod | IntegrationEnvMethod
|
||||
|
||||
export interface IntegrationWebSearchDefinition {
|
||||
readonly connection: "optional" | "required"
|
||||
readonly execute: (
|
||||
input: WebSearch.Input,
|
||||
context: { readonly credential?: CredentialValue; readonly sessionID?: string },
|
||||
) => Effect.Effect<WebSearch.ProviderOutput, unknown>
|
||||
}
|
||||
|
||||
export interface IntegrationDefinition {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly methods?: readonly IntegrationMethodDefinition[]
|
||||
readonly websearch?: IntegrationWebSearchDefinition
|
||||
}
|
||||
|
||||
export interface IntegrationDraft {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import type { ReferenceDomain } from "./reference.js"
|
|||
import type { SessionDomain } from "./session.js"
|
||||
import type { SkillDomain } from "./skill.js"
|
||||
import type { ToolDomain } from "./tool.js"
|
||||
import type { WebSearchDomain } from "./websearch.js"
|
||||
|
||||
export interface Context {
|
||||
readonly options: PluginOptions
|
||||
|
|
@ -25,6 +26,7 @@ export interface Context {
|
|||
readonly session: SessionDomain
|
||||
readonly skill: SkillDomain
|
||||
readonly tool: ToolDomain
|
||||
readonly websearch: WebSearchDomain
|
||||
}
|
||||
|
||||
export interface Plugin<R = Scope.Scope> {
|
||||
|
|
|
|||
16
packages/plugin/src/v2/effect/websearch.ts
Normal file
16
packages/plugin/src/v2/effect/websearch.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
import type { Effect, Scope } from "effect"
|
||||
import type { Registration } from "./registration.js"
|
||||
|
||||
export interface WebSearchDefinition {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly execute: (
|
||||
input: Pick<WebSearch.Input, "query">,
|
||||
context: { readonly sessionID?: string },
|
||||
) => Effect.Effect<WebSearch.ProviderOutput, unknown>
|
||||
}
|
||||
|
||||
export interface WebSearchDomain {
|
||||
readonly register: (definition: WebSearchDefinition) => Effect.Effect<Registration, never, Scope.Scope>
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import type {
|
|||
IntegrationKeyMethod,
|
||||
IntegrationOAuthMethod,
|
||||
} from "@opencode-ai/sdk/v2/types"
|
||||
import type { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
import type { Registration, Transform } from "./registration.js"
|
||||
|
||||
export type { IntegrationDraft, IntegrationMethodRegistration }
|
||||
|
|
@ -35,19 +34,10 @@ export type IntegrationOAuthMethodDefinition = IntegrationOAuthMethod & {
|
|||
|
||||
export type IntegrationMethodDefinition = IntegrationOAuthMethodDefinition | IntegrationKeyMethod | IntegrationEnvMethod
|
||||
|
||||
export interface IntegrationWebSearchDefinition {
|
||||
readonly connection: "optional" | "required"
|
||||
readonly execute: (
|
||||
input: WebSearch.Input,
|
||||
context: { readonly credential?: CredentialValue; readonly sessionID?: string; readonly signal: AbortSignal },
|
||||
) => Promise<WebSearch.ProviderOutput>
|
||||
}
|
||||
|
||||
export interface IntegrationDefinition {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly methods?: readonly IntegrationMethodDefinition[]
|
||||
readonly websearch?: IntegrationWebSearchDefinition
|
||||
}
|
||||
|
||||
export interface IntegrationDomain extends IntegrationApi {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type { ReferenceDomain } from "./reference.js"
|
|||
import type { SessionDomain } from "./session.js"
|
||||
import type { SkillDomain } from "./skill.js"
|
||||
import type { ToolDomain } from "./tool.js"
|
||||
import type { WebSearchDomain } from "./websearch.js"
|
||||
|
||||
export interface Context {
|
||||
readonly options: PluginOptions
|
||||
|
|
@ -24,6 +25,7 @@ export interface Context {
|
|||
readonly session: SessionDomain
|
||||
readonly skill: SkillDomain
|
||||
readonly tool: ToolDomain
|
||||
readonly websearch: WebSearchDomain
|
||||
}
|
||||
|
||||
export interface Plugin {
|
||||
|
|
|
|||
15
packages/plugin/src/v2/promise/websearch.ts
Normal file
15
packages/plugin/src/v2/promise/websearch.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import type { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
import type { Registration } from "./registration.js"
|
||||
|
||||
export interface WebSearchDefinition {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly execute: (
|
||||
input: Pick<WebSearch.Input, "query">,
|
||||
context: { readonly sessionID?: string; readonly signal: AbortSignal },
|
||||
) => Promise<WebSearch.ProviderOutput>
|
||||
}
|
||||
|
||||
export interface WebSearchDomain {
|
||||
readonly register: (definition: WebSearchDefinition) => Promise<Registration>
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import { Model } from "@opencode-ai/schema/model"
|
|||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { Reference } from "@opencode-ai/schema/reference"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
|
||||
const Plugin = await import("../src/v2/effect/index")
|
||||
const PromisePlugin = await import("../src/v2/promise/index")
|
||||
|
|
@ -25,6 +26,7 @@ test.each([
|
|||
expect(entrypoint.Provider).toBe(Provider)
|
||||
expect(entrypoint.Reference).toBe(Reference)
|
||||
expect(entrypoint.Skill).toBe(Skill)
|
||||
expect(entrypoint.WebSearch).toBe(WebSearch)
|
||||
expect(Object.keys(entrypoint).sort()).toEqual([
|
||||
"Agent",
|
||||
"Command",
|
||||
|
|
@ -36,5 +38,6 @@ test.each([
|
|||
"Provider",
|
||||
"Reference",
|
||||
"Skill",
|
||||
"WebSearch",
|
||||
])
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
import { Location } from "@opencode-ai/schema/location"
|
||||
import { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
import { Schema } from "effect"
|
||||
|
|
@ -8,23 +7,38 @@ import { LocationQuery, locationQueryOpenApi } from "./location.js"
|
|||
|
||||
export const WebSearchGroup = HttpApiGroup.make("server.websearch")
|
||||
.add(
|
||||
HttpApiEndpoint.get("websearch.provider.get", "/api/websearch/provider", {
|
||||
HttpApiEndpoint.get("websearch.provider.list", "/api/websearch/provider", {
|
||||
query: LocationQuery,
|
||||
success: Location.response(Schema.UndefinedOr(Integration.ID)),
|
||||
success: Location.response(Schema.Array(WebSearch.Provider)),
|
||||
error: ServiceUnavailableError,
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.websearch.provider.get",
|
||||
summary: "Get default web search provider",
|
||||
identifier: "v2.websearch.provider.list",
|
||||
summary: "List web search providers",
|
||||
description: "Return the registered web search providers.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("websearch.provider.selected", "/api/websearch/provider/selected", {
|
||||
query: LocationQuery,
|
||||
success: Location.response(Schema.UndefinedOr(WebSearch.ID)),
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.websearch.provider.selected",
|
||||
summary: "Get selected web search provider",
|
||||
description: "Return the globally selected web search provider.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("websearch.provider.select", "/api/websearch/provider", {
|
||||
HttpApiEndpoint.post("websearch.provider.select", "/api/websearch/provider/selected", {
|
||||
query: LocationQuery,
|
||||
payload: Schema.Struct({ providerID: Integration.ID }),
|
||||
payload: Schema.Struct({ providerID: WebSearch.ID }),
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: [InvalidRequestError, ServiceUnavailableError],
|
||||
})
|
||||
|
|
@ -50,7 +64,7 @@ export const WebSearchGroup = HttpApiGroup.make("server.websearch")
|
|||
identifier: "v2.websearch.query",
|
||||
summary: "Search the web",
|
||||
description:
|
||||
"Run one web search through the selected integration. Specify a provider to override the configured default.",
|
||||
"Run one web search through the selected provider. Specify a provider to override the configured default.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ export * as Form from "./form.js"
|
|||
import { Schema } from "effect"
|
||||
import { ephemeral, inventory } from "./event.js"
|
||||
import { ascending } from "./identifier.js"
|
||||
import { IntegrationID } from "./integration-id.js"
|
||||
import { NonNegativeInt, optional, statics } from "./schema.js"
|
||||
|
||||
const IDSchema = Schema.String.check(Schema.isStartsWith("frm_")).pipe(Schema.brand("Form.ID"))
|
||||
|
|
@ -125,15 +124,8 @@ export const UrlInfo = Schema.Struct({
|
|||
}).annotate({ identifier: "Form.UrlInfo" })
|
||||
export interface UrlInfo extends Schema.Schema.Type<typeof UrlInfo> {}
|
||||
|
||||
export const IntegrationInfo = Schema.Struct({
|
||||
...InfoBase,
|
||||
mode: Schema.Literal("integration"),
|
||||
integrationID: IntegrationID,
|
||||
}).annotate({ identifier: "Form.IntegrationInfo" })
|
||||
export interface IntegrationInfo extends Schema.Schema.Type<typeof IntegrationInfo> {}
|
||||
|
||||
export const Info = Schema.Union([FormInfo, UrlInfo, IntegrationInfo]).pipe(Schema.toTaggedUnion("mode"))
|
||||
export type Info = FormInfo | UrlInfo | IntegrationInfo
|
||||
export const Info = Schema.Union([FormInfo, UrlInfo]).pipe(Schema.toTaggedUnion("mode"))
|
||||
export type Info = FormInfo | UrlInfo
|
||||
|
||||
export const Value = Schema.Union([Schema.String, Schema.Number, Schema.Boolean, Schema.Array(Schema.String)]).annotate(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -76,11 +76,6 @@ export type Method = typeof Method.Type
|
|||
export const Inputs = Schema.Record(Schema.String, Schema.String).annotate({ identifier: "Integration.Inputs" })
|
||||
export type Inputs = typeof Inputs.Type
|
||||
|
||||
export interface WebSearch extends Schema.Schema.Type<typeof WebSearch> {}
|
||||
export const WebSearch = Schema.Struct({
|
||||
connection: Schema.Literals(["optional", "required"]),
|
||||
}).annotate({ identifier: "Integration.WebSearch" })
|
||||
|
||||
const Updated = ephemeral({
|
||||
type: "integration.updated",
|
||||
schema: {},
|
||||
|
|
@ -101,7 +96,6 @@ export class Info extends Schema.Class<Info>("Integration.Info")({
|
|||
id: ID,
|
||||
name: Schema.String,
|
||||
methods: Schema.Array(Method),
|
||||
websearch: optional(WebSearch),
|
||||
connections: Schema.Array(Connection.Info),
|
||||
}) {}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,22 @@
|
|||
export * as WebSearch from "./websearch.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { IntegrationID } from "./integration-id.js"
|
||||
import { ephemeral, inventory } from "./event.js"
|
||||
import { optional } from "./schema.js"
|
||||
|
||||
export const ID = Schema.String.pipe(Schema.brand("WebSearch.ID"))
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
export interface Provider extends Schema.Schema.Type<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: IntegrationID.pipe(optional),
|
||||
providerID: ID.pipe(optional),
|
||||
}).annotate({ identifier: "WebSearch.Input" })
|
||||
|
||||
export interface ProviderOutput extends Schema.Schema.Type<typeof ProviderOutput> {}
|
||||
|
|
@ -17,6 +26,12 @@ export const ProviderOutput = Schema.Struct({
|
|||
}).annotate({ identifier: "WebSearch.ProviderOutput" })
|
||||
|
||||
export class Result extends Schema.Class<Result>("WebSearch.Result")({
|
||||
providerID: IntegrationID,
|
||||
providerID: ID,
|
||||
...ProviderOutput.fields,
|
||||
}) {}
|
||||
|
||||
const Updated = ephemeral({
|
||||
type: "websearch.updated",
|
||||
schema: {},
|
||||
})
|
||||
export const Event = { Updated, Definitions: inventory(Updated) }
|
||||
|
|
|
|||
|
|
@ -302,21 +302,18 @@ it.live(
|
|||
10_000,
|
||||
)
|
||||
|
||||
it.live("embedded client exposes integration-backed web search", () =>
|
||||
it.live("embedded client exposes plugin-backed web search", () =>
|
||||
withEmbedded("opencode-embedded-websearch-", (fixture) =>
|
||||
Effect.gen(function* () {
|
||||
const opencode = yield* fixture.sdk.OpenCode.create()
|
||||
const providerID = fixture.sdk.Integration.ID.make("embedded-websearch")
|
||||
const providerID = fixture.sdk.WebSearch.ID.make("embedded-websearch")
|
||||
yield* opencode.plugin({
|
||||
id: `embedded-websearch-${crypto.randomUUID()}`,
|
||||
effect: (ctx) =>
|
||||
ctx.integration.register({
|
||||
ctx.websearch.register({
|
||||
id: providerID,
|
||||
name: "Embedded web search",
|
||||
websearch: {
|
||||
connection: "optional",
|
||||
execute: (input) => Effect.succeed({ text: `Found ${input.query}`, metadata: { source: "embedded" } }),
|
||||
},
|
||||
execute: (input) => Effect.succeed({ text: `Found ${input.query}`, metadata: { source: "embedded" } }),
|
||||
}),
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export const WebSearchHandler = HttpApiBuilder.group(Api, "server.websearch", (h
|
|||
orElse: () =>
|
||||
Effect.fail(
|
||||
new ServiceUnavailableError({
|
||||
message: "Web search integration initialization timed out",
|
||||
message: "Web search provider initialization timed out",
|
||||
service: "websearch",
|
||||
}),
|
||||
),
|
||||
|
|
@ -25,8 +25,16 @@ export const WebSearchHandler = HttpApiBuilder.group(Api, "server.websearch", (h
|
|||
})
|
||||
return handlers
|
||||
.handle(
|
||||
"websearch.provider.get",
|
||||
Effect.fn("server.websearch.provider.get")(function* () {
|
||||
"websearch.provider.list",
|
||||
Effect.fn("server.websearch.provider.list")(function* () {
|
||||
yield* awaitPlugins()
|
||||
const websearch = yield* WebSearch.Service
|
||||
return yield* response(websearch.list())
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"websearch.provider.selected",
|
||||
Effect.fn("server.websearch.provider.selected")(function* () {
|
||||
const websearch = yield* WebSearch.Service
|
||||
return yield* response(websearch.selected())
|
||||
}),
|
||||
|
|
@ -69,12 +77,6 @@ export const WebSearchHandler = HttpApiBuilder.group(Api, "server.websearch", (h
|
|||
kind: "websearch_provider_not_found",
|
||||
field: "providerID",
|
||||
}),
|
||||
"WebSearch.ConnectionRequired": (error) =>
|
||||
new InvalidRequestError({
|
||||
message: `Web search provider requires a connection: ${error.providerID}`,
|
||||
kind: "websearch_connection_required",
|
||||
field: "providerID",
|
||||
}),
|
||||
"WebSearch.Cancelled": () =>
|
||||
new InvalidRequestError({ message: "Web search cancelled", kind: "websearch_cancelled" }),
|
||||
"WebSearch.Request": (error) =>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue