Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 69bdd18b6f | |||
| 7b8d8b8861 | |||
| 66220071b5 | |||
| f38e558910 | |||
| c8f86e2f34 | |||
| 56f44dbcaa | |||
| 5dc482a216 | |||
| 129012c3ee |
@@ -101,7 +101,6 @@
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/codemode": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
|
||||
@@ -33,7 +33,6 @@
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/codemode": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
export * as CodeModeHost from "./code-mode"
|
||||
|
||||
import { NodeHttpClient } from "@effect/platform-node"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { OpenAPI, Tool } from "@opencode-ai/codemode"
|
||||
import { Api } from "@opencode-ai/server/api"
|
||||
import { ServerAuth } from "@opencode-ai/server/auth"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { OpenApi } from "effect/unstable/httpapi"
|
||||
import type { Server } from "node:http"
|
||||
|
||||
export function replacements(server: Server, password: string): LayerNode.Replacements {
|
||||
return [ToolRegistry.codeModeReplacement(makeTools(client(server), password))]
|
||||
}
|
||||
|
||||
export function makeTools(client: Layer.Layer<HttpClient.HttpClient>, password: string): ToolRegistry.CodeModeTools {
|
||||
return {
|
||||
opencode: bindTools(
|
||||
OpenAPI.fromSpec({
|
||||
spec: { ...OpenApi.fromApi(Api) },
|
||||
baseUrl: "http://opencode.local",
|
||||
headers: ServerAuth.headers({ username: "opencode", password }),
|
||||
}).tools,
|
||||
client,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function client(server: Server) {
|
||||
return Layer.effect(
|
||||
HttpClient.HttpClient,
|
||||
Effect.gen(function* () {
|
||||
const client = yield* HttpClient.HttpClient
|
||||
return HttpClient.mapRequest(client, (request) => {
|
||||
const address = server.address()
|
||||
if (!address || typeof address === "string") throw new Error("OpenCode server is not listening")
|
||||
const local =
|
||||
address.address === "0.0.0.0" ? "127.0.0.1" : address.address === "::" ? "::1" : address.address
|
||||
const host = local.includes(":") && !local.startsWith("[") ? `[${local}]` : local
|
||||
const url = new URL(request.url)
|
||||
return HttpClientRequest.setUrl(
|
||||
request,
|
||||
new URL(`${url.pathname}${url.search}${url.hash}`, `http://${host}:${address.port}`),
|
||||
)
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(NodeHttpClient.layerNodeHttp))
|
||||
}
|
||||
|
||||
function bindTools(tools: OpenAPI.Tools, client: Layer.Layer<HttpClient.HttpClient>): ToolRegistry.CodeModeTools {
|
||||
return Object.fromEntries(
|
||||
Object.entries(tools).map(([name, value]) => [
|
||||
name,
|
||||
Tool.isDefinition<HttpClient.HttpClient>(value)
|
||||
? Tool.make({
|
||||
description: value.description,
|
||||
input: value.input,
|
||||
output: value.output,
|
||||
run: (input) => value.run(input).pipe(Effect.provide(client)),
|
||||
})
|
||||
: bindTools(value, client),
|
||||
]),
|
||||
)
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import { randomBytes, randomUUID } from "node:crypto"
|
||||
import path from "node:path"
|
||||
import { Effect, FileSystem, Logger, Option, Redacted, Schedule, Schema } from "effect"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { CodeModeHost } from "./code-mode"
|
||||
import { Env } from "./env"
|
||||
import { ServiceConfig } from "./services/service-config"
|
||||
import { Updater } from "./services/updater"
|
||||
@@ -64,7 +63,6 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
hostname: options.hostname ?? config.hostname ?? "127.0.0.1",
|
||||
port: Option.fromNullishOr(options.port ?? config.port),
|
||||
password,
|
||||
replacements: (server) => CodeModeHost.replacements(server, password),
|
||||
}).pipe(Effect.provide(Logger.layer([], { mergeWithExisting: false })))
|
||||
if (options.mode === "service") yield* register(address, password)
|
||||
const url = HttpServer.formatAddress(address)
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { CodeMode } from "@opencode-ai/codemode"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import { CodeModeHost } from "../src/code-mode"
|
||||
|
||||
test("exposes the authenticated OpenCode API through CodeMode", async () => {
|
||||
const requests: Array<{ readonly url: string; readonly authorization?: string }> = []
|
||||
const client = Layer.succeed(
|
||||
HttpClient.HttpClient,
|
||||
HttpClient.make((request) => {
|
||||
requests.push({ url: request.url, authorization: request.headers.authorization })
|
||||
return Effect.succeed(
|
||||
HttpClientResponse.fromWeb(
|
||||
request,
|
||||
Response.json(
|
||||
{ healthy: true, version: "test", pid: 1 },
|
||||
{ headers: { "content-type": "application/json" } },
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
const result = await CodeMode.make({ tools: CodeModeHost.makeTools(client, "secret") })
|
||||
.execute("return await tools.opencode.v2.health.get({})")
|
||||
.pipe(Effect.runPromise)
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { healthy: true, version: "test", pid: 1 },
|
||||
toolCalls: [{ name: "opencode.v2.health.get" }],
|
||||
})
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
url: "http://opencode.local/api/health",
|
||||
authorization: `Basic ${Buffer.from("opencode:secret").toString("base64")}`,
|
||||
},
|
||||
])
|
||||
})
|
||||
@@ -370,63 +370,77 @@ export type Endpoint9_1Input = {
|
||||
export type Endpoint9_1Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.get"]>>
|
||||
export type IntegrationGetOperation<E = never> = (input: Endpoint9_1Input) => Effect.Effect<Endpoint9_1Output, E>
|
||||
|
||||
type Endpoint9_2Request = Parameters<RawClient["server.integration"]["integration.connect.key"]>[0]
|
||||
type Endpoint9_2Request = Parameters<RawClient["server.integration"]["integration.capability.select"]>[0]
|
||||
export type Endpoint9_2Input = {
|
||||
readonly integrationID: Endpoint9_2Request["params"]["integrationID"]
|
||||
readonly location?: Endpoint9_2Request["query"]["location"]
|
||||
readonly key: Endpoint9_2Request["payload"]["key"]
|
||||
readonly label?: Endpoint9_2Request["payload"]["label"]
|
||||
readonly capability: Endpoint9_2Request["payload"]["capability"]
|
||||
}
|
||||
export type Endpoint9_2Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.connect.key"]>>
|
||||
export type IntegrationConnectKeyOperation<E = never> = (input: Endpoint9_2Input) => Effect.Effect<Endpoint9_2Output, E>
|
||||
export type Endpoint9_2Output = EffectValue<
|
||||
ReturnType<RawClient["server.integration"]["integration.capability.select"]>
|
||||
>
|
||||
export type IntegrationSelectCapabilityOperation<E = never> = (
|
||||
input: Endpoint9_2Input,
|
||||
) => Effect.Effect<Endpoint9_2Output, E>
|
||||
|
||||
type Endpoint9_3Request = Parameters<RawClient["server.integration"]["integration.connect.oauth"]>[0]
|
||||
type Endpoint9_3Request = Parameters<RawClient["server.integration"]["integration.connect.key"]>[0]
|
||||
export type Endpoint9_3Input = {
|
||||
readonly integrationID: Endpoint9_3Request["params"]["integrationID"]
|
||||
readonly location?: Endpoint9_3Request["query"]["location"]
|
||||
readonly methodID: Endpoint9_3Request["payload"]["methodID"]
|
||||
readonly inputs: Endpoint9_3Request["payload"]["inputs"]
|
||||
readonly key: Endpoint9_3Request["payload"]["key"]
|
||||
readonly label?: Endpoint9_3Request["payload"]["label"]
|
||||
}
|
||||
export type Endpoint9_3Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.connect.oauth"]>>
|
||||
export type IntegrationConnectOauthOperation<E = never> = (
|
||||
input: Endpoint9_3Input,
|
||||
) => Effect.Effect<Endpoint9_3Output, E>
|
||||
export type Endpoint9_3Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.connect.key"]>>
|
||||
export type IntegrationConnectKeyOperation<E = never> = (input: Endpoint9_3Input) => Effect.Effect<Endpoint9_3Output, E>
|
||||
|
||||
type Endpoint9_4Request = Parameters<RawClient["server.integration"]["integration.attempt.status"]>[0]
|
||||
type Endpoint9_4Request = Parameters<RawClient["server.integration"]["integration.connect.oauth"]>[0]
|
||||
export type Endpoint9_4Input = {
|
||||
readonly attemptID: Endpoint9_4Request["params"]["attemptID"]
|
||||
readonly integrationID: Endpoint9_4Request["params"]["integrationID"]
|
||||
readonly location?: Endpoint9_4Request["query"]["location"]
|
||||
readonly methodID: Endpoint9_4Request["payload"]["methodID"]
|
||||
readonly inputs: Endpoint9_4Request["payload"]["inputs"]
|
||||
readonly label?: Endpoint9_4Request["payload"]["label"]
|
||||
}
|
||||
export type Endpoint9_4Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.attempt.status"]>>
|
||||
export type IntegrationAttemptStatusOperation<E = never> = (
|
||||
export type Endpoint9_4Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.connect.oauth"]>>
|
||||
export type IntegrationConnectOauthOperation<E = never> = (
|
||||
input: Endpoint9_4Input,
|
||||
) => Effect.Effect<Endpoint9_4Output, E>
|
||||
|
||||
type Endpoint9_5Request = Parameters<RawClient["server.integration"]["integration.attempt.complete"]>[0]
|
||||
type Endpoint9_5Request = Parameters<RawClient["server.integration"]["integration.attempt.status"]>[0]
|
||||
export type Endpoint9_5Input = {
|
||||
readonly attemptID: Endpoint9_5Request["params"]["attemptID"]
|
||||
readonly location?: Endpoint9_5Request["query"]["location"]
|
||||
readonly code?: Endpoint9_5Request["payload"]["code"]
|
||||
}
|
||||
export type Endpoint9_5Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.attempt.complete"]>>
|
||||
export type IntegrationAttemptCompleteOperation<E = never> = (
|
||||
export type Endpoint9_5Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.attempt.status"]>>
|
||||
export type IntegrationAttemptStatusOperation<E = never> = (
|
||||
input: Endpoint9_5Input,
|
||||
) => Effect.Effect<Endpoint9_5Output, E>
|
||||
|
||||
type Endpoint9_6Request = Parameters<RawClient["server.integration"]["integration.attempt.cancel"]>[0]
|
||||
type Endpoint9_6Request = Parameters<RawClient["server.integration"]["integration.attempt.complete"]>[0]
|
||||
export type Endpoint9_6Input = {
|
||||
readonly attemptID: Endpoint9_6Request["params"]["attemptID"]
|
||||
readonly location?: Endpoint9_6Request["query"]["location"]
|
||||
readonly code?: Endpoint9_6Request["payload"]["code"]
|
||||
}
|
||||
export type Endpoint9_6Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.attempt.cancel"]>>
|
||||
export type IntegrationAttemptCancelOperation<E = never> = (
|
||||
export type Endpoint9_6Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.attempt.complete"]>>
|
||||
export type IntegrationAttemptCompleteOperation<E = never> = (
|
||||
input: Endpoint9_6Input,
|
||||
) => Effect.Effect<Endpoint9_6Output, E>
|
||||
|
||||
type Endpoint9_7Request = Parameters<RawClient["server.integration"]["integration.attempt.cancel"]>[0]
|
||||
export type Endpoint9_7Input = {
|
||||
readonly attemptID: Endpoint9_7Request["params"]["attemptID"]
|
||||
readonly location?: Endpoint9_7Request["query"]["location"]
|
||||
}
|
||||
export type Endpoint9_7Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.attempt.cancel"]>>
|
||||
export type IntegrationAttemptCancelOperation<E = never> = (
|
||||
input: Endpoint9_7Input,
|
||||
) => Effect.Effect<Endpoint9_7Output, E>
|
||||
|
||||
export interface IntegrationApi<E = never> {
|
||||
readonly list: IntegrationListOperation<E>
|
||||
readonly get: IntegrationGetOperation<E>
|
||||
readonly selectCapability: IntegrationSelectCapabilityOperation<E>
|
||||
readonly connectKey: IntegrationConnectKeyOperation<E>
|
||||
readonly connectOauth: IntegrationConnectOauthOperation<E>
|
||||
readonly attemptStatus: IntegrationAttemptStatusOperation<E>
|
||||
@@ -894,6 +908,23 @@ export interface DebugApi<E = never> {
|
||||
readonly location: DebugLocationOperation<E>
|
||||
}
|
||||
|
||||
type Endpoint26_0Request = Parameters<RawClient["server.search"]["search.query"]>[0]
|
||||
export type Endpoint26_0Input = {
|
||||
readonly location?: Endpoint26_0Request["query"]["location"]
|
||||
readonly query: Endpoint26_0Request["payload"]["query"]
|
||||
readonly providerID?: Endpoint26_0Request["payload"]["providerID"]
|
||||
readonly numResults?: Endpoint26_0Request["payload"]["numResults"]
|
||||
readonly livecrawl?: Endpoint26_0Request["payload"]["livecrawl"]
|
||||
readonly type?: Endpoint26_0Request["payload"]["type"]
|
||||
readonly contextMaxCharacters?: Endpoint26_0Request["payload"]["contextMaxCharacters"]
|
||||
}
|
||||
export type Endpoint26_0Output = EffectValue<ReturnType<RawClient["server.search"]["search.query"]>>
|
||||
export type SearchQueryOperation<E = never> = (input: Endpoint26_0Input) => Effect.Effect<Endpoint26_0Output, E>
|
||||
|
||||
export interface SearchApi<E = never> {
|
||||
readonly query: SearchQueryOperation<E>
|
||||
}
|
||||
|
||||
export interface AppApi<E = never> {
|
||||
readonly health: HealthApi<E>
|
||||
readonly location: LocationApi<E>
|
||||
@@ -921,4 +952,5 @@ export interface AppApi<E = never> {
|
||||
readonly projectCopy: ProjectCopyApi<E>
|
||||
readonly vcs: VcsApi<E>
|
||||
readonly debug: DebugApi<E>
|
||||
readonly search: SearchApi<E>
|
||||
}
|
||||
|
||||
@@ -450,65 +450,78 @@ const Endpoint9_1 = (raw: RawClient["server.integration"]) => (input: Endpoint9_
|
||||
query: { location: input["location"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint9_2Request = Parameters<RawClient["server.integration"]["integration.connect.key"]>[0]
|
||||
type Endpoint9_2Request = Parameters<RawClient["server.integration"]["integration.capability.select"]>[0]
|
||||
type Endpoint9_2Input = {
|
||||
readonly integrationID: Endpoint9_2Request["params"]["integrationID"]
|
||||
readonly location?: Endpoint9_2Request["query"]["location"]
|
||||
readonly key: Endpoint9_2Request["payload"]["key"]
|
||||
readonly label?: Endpoint9_2Request["payload"]["label"]
|
||||
readonly capability: Endpoint9_2Request["payload"]["capability"]
|
||||
}
|
||||
const Endpoint9_2 = (raw: RawClient["server.integration"]) => (input: Endpoint9_2Input) =>
|
||||
raw["integration.capability.select"]({
|
||||
params: { integrationID: input["integrationID"] },
|
||||
query: { location: input["location"] },
|
||||
payload: { capability: input["capability"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint9_3Request = Parameters<RawClient["server.integration"]["integration.connect.key"]>[0]
|
||||
type Endpoint9_3Input = {
|
||||
readonly integrationID: Endpoint9_3Request["params"]["integrationID"]
|
||||
readonly location?: Endpoint9_3Request["query"]["location"]
|
||||
readonly key: Endpoint9_3Request["payload"]["key"]
|
||||
readonly label?: Endpoint9_3Request["payload"]["label"]
|
||||
}
|
||||
const Endpoint9_3 = (raw: RawClient["server.integration"]) => (input: Endpoint9_3Input) =>
|
||||
raw["integration.connect.key"]({
|
||||
params: { integrationID: input["integrationID"] },
|
||||
query: { location: input["location"] },
|
||||
payload: { key: input["key"], label: input["label"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint9_3Request = Parameters<RawClient["server.integration"]["integration.connect.oauth"]>[0]
|
||||
type Endpoint9_3Input = {
|
||||
readonly integrationID: Endpoint9_3Request["params"]["integrationID"]
|
||||
readonly location?: Endpoint9_3Request["query"]["location"]
|
||||
readonly methodID: Endpoint9_3Request["payload"]["methodID"]
|
||||
readonly inputs: Endpoint9_3Request["payload"]["inputs"]
|
||||
readonly label?: Endpoint9_3Request["payload"]["label"]
|
||||
type Endpoint9_4Request = Parameters<RawClient["server.integration"]["integration.connect.oauth"]>[0]
|
||||
type Endpoint9_4Input = {
|
||||
readonly integrationID: Endpoint9_4Request["params"]["integrationID"]
|
||||
readonly location?: Endpoint9_4Request["query"]["location"]
|
||||
readonly methodID: Endpoint9_4Request["payload"]["methodID"]
|
||||
readonly inputs: Endpoint9_4Request["payload"]["inputs"]
|
||||
readonly label?: Endpoint9_4Request["payload"]["label"]
|
||||
}
|
||||
const Endpoint9_3 = (raw: RawClient["server.integration"]) => (input: Endpoint9_3Input) =>
|
||||
const Endpoint9_4 = (raw: RawClient["server.integration"]) => (input: Endpoint9_4Input) =>
|
||||
raw["integration.connect.oauth"]({
|
||||
params: { integrationID: input["integrationID"] },
|
||||
query: { location: input["location"] },
|
||||
payload: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint9_4Request = Parameters<RawClient["server.integration"]["integration.attempt.status"]>[0]
|
||||
type Endpoint9_4Input = {
|
||||
readonly attemptID: Endpoint9_4Request["params"]["attemptID"]
|
||||
readonly location?: Endpoint9_4Request["query"]["location"]
|
||||
type Endpoint9_5Request = Parameters<RawClient["server.integration"]["integration.attempt.status"]>[0]
|
||||
type Endpoint9_5Input = {
|
||||
readonly attemptID: Endpoint9_5Request["params"]["attemptID"]
|
||||
readonly location?: Endpoint9_5Request["query"]["location"]
|
||||
}
|
||||
const Endpoint9_4 = (raw: RawClient["server.integration"]) => (input: Endpoint9_4Input) =>
|
||||
const Endpoint9_5 = (raw: RawClient["server.integration"]) => (input: Endpoint9_5Input) =>
|
||||
raw["integration.attempt.status"]({
|
||||
params: { attemptID: input["attemptID"] },
|
||||
query: { location: input["location"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint9_5Request = Parameters<RawClient["server.integration"]["integration.attempt.complete"]>[0]
|
||||
type Endpoint9_5Input = {
|
||||
readonly attemptID: Endpoint9_5Request["params"]["attemptID"]
|
||||
readonly location?: Endpoint9_5Request["query"]["location"]
|
||||
readonly code?: Endpoint9_5Request["payload"]["code"]
|
||||
type Endpoint9_6Request = Parameters<RawClient["server.integration"]["integration.attempt.complete"]>[0]
|
||||
type Endpoint9_6Input = {
|
||||
readonly attemptID: Endpoint9_6Request["params"]["attemptID"]
|
||||
readonly location?: Endpoint9_6Request["query"]["location"]
|
||||
readonly code?: Endpoint9_6Request["payload"]["code"]
|
||||
}
|
||||
const Endpoint9_5 = (raw: RawClient["server.integration"]) => (input: Endpoint9_5Input) =>
|
||||
const Endpoint9_6 = (raw: RawClient["server.integration"]) => (input: Endpoint9_6Input) =>
|
||||
raw["integration.attempt.complete"]({
|
||||
params: { attemptID: input["attemptID"] },
|
||||
query: { location: input["location"] },
|
||||
payload: { code: input["code"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint9_6Request = Parameters<RawClient["server.integration"]["integration.attempt.cancel"]>[0]
|
||||
type Endpoint9_6Input = {
|
||||
readonly attemptID: Endpoint9_6Request["params"]["attemptID"]
|
||||
readonly location?: Endpoint9_6Request["query"]["location"]
|
||||
type Endpoint9_7Request = Parameters<RawClient["server.integration"]["integration.attempt.cancel"]>[0]
|
||||
type Endpoint9_7Input = {
|
||||
readonly attemptID: Endpoint9_7Request["params"]["attemptID"]
|
||||
readonly location?: Endpoint9_7Request["query"]["location"]
|
||||
}
|
||||
const Endpoint9_6 = (raw: RawClient["server.integration"]) => (input: Endpoint9_6Input) =>
|
||||
const Endpoint9_7 = (raw: RawClient["server.integration"]) => (input: Endpoint9_7Input) =>
|
||||
raw["integration.attempt.cancel"]({
|
||||
params: { attemptID: input["attemptID"] },
|
||||
query: { location: input["location"] },
|
||||
@@ -517,11 +530,12 @@ const Endpoint9_6 = (raw: RawClient["server.integration"]) => (input: Endpoint9_
|
||||
const adaptGroup9 = (raw: RawClient["server.integration"]) => ({
|
||||
list: Endpoint9_0(raw),
|
||||
get: Endpoint9_1(raw),
|
||||
connectKey: Endpoint9_2(raw),
|
||||
connectOauth: Endpoint9_3(raw),
|
||||
attemptStatus: Endpoint9_4(raw),
|
||||
attemptComplete: Endpoint9_5(raw),
|
||||
attemptCancel: Endpoint9_6(raw),
|
||||
selectCapability: Endpoint9_2(raw),
|
||||
connectKey: Endpoint9_3(raw),
|
||||
connectOauth: Endpoint9_4(raw),
|
||||
attemptStatus: Endpoint9_5(raw),
|
||||
attemptComplete: Endpoint9_6(raw),
|
||||
attemptCancel: Endpoint9_7(raw),
|
||||
})
|
||||
|
||||
type Endpoint10_0Request = Parameters<RawClient["server.mcp"]["mcp.list"]>[0]
|
||||
@@ -1071,6 +1085,31 @@ const Endpoint25_0 = (raw: RawClient["server.debug"]) => () =>
|
||||
|
||||
const adaptGroup25 = (raw: RawClient["server.debug"]) => ({ location: Endpoint25_0(raw) })
|
||||
|
||||
type Endpoint26_0Request = Parameters<RawClient["server.search"]["search.query"]>[0]
|
||||
type Endpoint26_0Input = {
|
||||
readonly location?: Endpoint26_0Request["query"]["location"]
|
||||
readonly query: Endpoint26_0Request["payload"]["query"]
|
||||
readonly providerID?: Endpoint26_0Request["payload"]["providerID"]
|
||||
readonly numResults?: Endpoint26_0Request["payload"]["numResults"]
|
||||
readonly livecrawl?: Endpoint26_0Request["payload"]["livecrawl"]
|
||||
readonly type?: Endpoint26_0Request["payload"]["type"]
|
||||
readonly contextMaxCharacters?: Endpoint26_0Request["payload"]["contextMaxCharacters"]
|
||||
}
|
||||
const Endpoint26_0 = (raw: RawClient["server.search"]) => (input: Endpoint26_0Input) =>
|
||||
raw["search.query"]({
|
||||
query: { location: input["location"] },
|
||||
payload: {
|
||||
query: input["query"],
|
||||
providerID: input["providerID"],
|
||||
numResults: input["numResults"],
|
||||
livecrawl: input["livecrawl"],
|
||||
type: input["type"],
|
||||
contextMaxCharacters: input["contextMaxCharacters"],
|
||||
},
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
const adaptGroup26 = (raw: RawClient["server.search"]) => ({ query: Endpoint26_0(raw) })
|
||||
|
||||
const adaptClient = (raw: RawClient) => ({
|
||||
health: adaptGroup0(raw["server.health"]),
|
||||
location: adaptGroup1(raw["server.location"]),
|
||||
@@ -1098,6 +1137,7 @@ const adaptClient = (raw: RawClient) => ({
|
||||
projectCopy: adaptGroup23(raw["server.projectCopy"]),
|
||||
vcs: adaptGroup24(raw["server.vcs"]),
|
||||
debug: adaptGroup25(raw["server.debug"]),
|
||||
search: adaptGroup26(raw["server.search"]),
|
||||
})
|
||||
|
||||
export const make = (options?: { readonly baseUrl?: URL | string }) =>
|
||||
|
||||
@@ -14,6 +14,7 @@ export type {
|
||||
PluginApi,
|
||||
ProviderApi,
|
||||
ReferenceApi,
|
||||
SearchApi,
|
||||
SessionApi,
|
||||
SkillApi,
|
||||
} from "./api.js"
|
||||
@@ -36,6 +37,7 @@ export { Provider } from "@opencode-ai/schema/provider"
|
||||
export { Pty } from "@opencode-ai/schema/pty"
|
||||
export { Question } from "@opencode-ai/schema/question"
|
||||
export { Reference } from "@opencode-ai/schema/reference"
|
||||
export { Search } from "@opencode-ai/schema/search"
|
||||
export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema"
|
||||
export { Session } from "@opencode-ai/schema/session"
|
||||
export { SessionInput } from "@opencode-ai/schema/session-input"
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
PluginApi as EffectPluginApi,
|
||||
ProviderApi as EffectProviderApi,
|
||||
ReferenceApi as EffectReferenceApi,
|
||||
SearchApi as EffectSearchApi,
|
||||
SessionApi as EffectSessionApi,
|
||||
SkillApi as EffectSkillApi,
|
||||
} from "../effect/api/api.js"
|
||||
@@ -36,6 +37,7 @@ export type ModelApi = PromisifyApi<EffectModelApi<unknown>>
|
||||
export type PluginApi = PromisifyApi<EffectPluginApi<unknown>>
|
||||
export type ProviderApi = PromisifyApi<EffectProviderApi<unknown>>
|
||||
export type ReferenceApi = PromisifyApi<EffectReferenceApi<unknown>>
|
||||
export type SearchApi = PromisifyApi<EffectSearchApi<unknown>>
|
||||
export type SessionApi = PromisifyApi<EffectSessionApi<unknown>>
|
||||
export type SkillApi = PromisifyApi<EffectSkillApi<unknown>>
|
||||
|
||||
|
||||
@@ -75,6 +75,8 @@ import type {
|
||||
IntegrationListOutput,
|
||||
IntegrationGetInput,
|
||||
IntegrationGetOutput,
|
||||
IntegrationSelectCapabilityInput,
|
||||
IntegrationSelectCapabilityOutput,
|
||||
IntegrationConnectKeyInput,
|
||||
IntegrationConnectKeyOutput,
|
||||
IntegrationConnectOauthInput,
|
||||
@@ -178,6 +180,8 @@ import type {
|
||||
VcsDiffInput,
|
||||
VcsDiffOutput,
|
||||
DebugLocationOutput,
|
||||
SearchQueryInput,
|
||||
SearchQueryOutput,
|
||||
} from "./types"
|
||||
import { ClientError } from "./client-error"
|
||||
|
||||
@@ -813,6 +817,19 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
selectCapability: (input: IntegrationSelectCapabilityInput, requestOptions?: RequestOptions) =>
|
||||
request<IntegrationSelectCapabilityOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/integration/${encodeURIComponent(input.integrationID)}/capability`,
|
||||
query: { location: input["location"] },
|
||||
body: { capability: input["capability"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
connectKey: (input: IntegrationConnectKeyInput, requestOptions?: RequestOptions) =>
|
||||
request<IntegrationConnectKeyOutput>(
|
||||
{
|
||||
@@ -1495,6 +1512,28 @@ export function make(options: ClientOptions) {
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
search: {
|
||||
query: (input: SearchQueryInput, requestOptions?: RequestOptions) =>
|
||||
request<SearchQueryOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/search`,
|
||||
query: { location: input["location"] },
|
||||
body: {
|
||||
query: input["query"],
|
||||
providerID: input["providerID"],
|
||||
numResults: input["numResults"],
|
||||
livecrawl: input["livecrawl"],
|
||||
type: input["type"],
|
||||
contextMaxCharacters: input["contextMaxCharacters"],
|
||||
},
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 503, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2279,6 +2279,11 @@ export type IntegrationListOutput = {
|
||||
| { readonly type: "key"; readonly label?: string }
|
||||
| { readonly type: "env"; readonly names: ReadonlyArray<string> }
|
||||
>
|
||||
readonly capabilities: ReadonlyArray<{
|
||||
readonly type: "search"
|
||||
readonly connection: "optional" | "required"
|
||||
readonly selected: boolean
|
||||
}>
|
||||
readonly connections: ReadonlyArray<
|
||||
| { readonly type: "credential"; readonly id: string; readonly label: string }
|
||||
| { readonly type: "env"; readonly name: string }
|
||||
@@ -2331,6 +2336,11 @@ export type IntegrationGetOutput = {
|
||||
| { readonly type: "key"; readonly label?: string }
|
||||
| { readonly type: "env"; readonly names: ReadonlyArray<string> }
|
||||
>
|
||||
readonly capabilities: ReadonlyArray<{
|
||||
readonly type: "search"
|
||||
readonly connection: "optional" | "required"
|
||||
readonly selected: boolean
|
||||
}>
|
||||
readonly connections: ReadonlyArray<
|
||||
| { readonly type: "credential"; readonly id: string; readonly label: string }
|
||||
| { readonly type: "env"; readonly name: string }
|
||||
@@ -2338,6 +2348,16 @@ export type IntegrationGetOutput = {
|
||||
} | null
|
||||
}
|
||||
|
||||
export type IntegrationSelectCapabilityInput = {
|
||||
readonly integrationID: { readonly integrationID: string }["integrationID"]
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
readonly capability: { readonly capability: "search" }["capability"]
|
||||
}
|
||||
|
||||
export type IntegrationSelectCapabilityOutput = void
|
||||
|
||||
export type IntegrationConnectKeyInput = {
|
||||
readonly integrationID: { readonly integrationID: string }["integrationID"]
|
||||
readonly location?: {
|
||||
@@ -2643,6 +2663,14 @@ export type FormListRequestsOutput = {
|
||||
readonly mode: "url"
|
||||
readonly url: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly title?: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly mode: "integration"
|
||||
readonly integrationID: string
|
||||
}
|
||||
>
|
||||
}
|
||||
|
||||
@@ -2755,6 +2783,14 @@ export type FormListOutput = {
|
||||
readonly mode: "url"
|
||||
readonly url: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly title?: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly mode: "integration"
|
||||
readonly integrationID: string
|
||||
}
|
||||
>
|
||||
}["data"]
|
||||
|
||||
@@ -3451,6 +3487,14 @@ export type FormCreateOutput = {
|
||||
readonly mode: "url"
|
||||
readonly url: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly title?: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly mode: "integration"
|
||||
readonly integrationID: string
|
||||
}
|
||||
}["data"]
|
||||
|
||||
export type FormGetInput = {
|
||||
@@ -3565,6 +3609,14 @@ export type FormGetOutput = {
|
||||
readonly mode: "url"
|
||||
readonly url: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly title?: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly mode: "integration"
|
||||
readonly integrationID: string
|
||||
}
|
||||
}["data"]
|
||||
|
||||
export type FormStateInput = {
|
||||
@@ -5351,6 +5403,14 @@ export type EventSubscribeOutput =
|
||||
readonly mode: "url"
|
||||
readonly url: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly title?: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly mode: "integration"
|
||||
readonly integrationID: string
|
||||
}
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -6130,3 +6190,66 @@ export type VcsDiffOutput = {
|
||||
}
|
||||
|
||||
export type DebugLocationOutput = ReadonlyArray<{ readonly directory: string; readonly workspaceID?: string }>
|
||||
|
||||
export type SearchQueryInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
readonly query: {
|
||||
readonly query: string
|
||||
readonly providerID?: string
|
||||
readonly numResults?: number
|
||||
readonly livecrawl?: "fallback" | "preferred"
|
||||
readonly type?: "auto" | "fast" | "deep"
|
||||
readonly contextMaxCharacters?: number
|
||||
}["query"]
|
||||
readonly providerID?: {
|
||||
readonly query: string
|
||||
readonly providerID?: string
|
||||
readonly numResults?: number
|
||||
readonly livecrawl?: "fallback" | "preferred"
|
||||
readonly type?: "auto" | "fast" | "deep"
|
||||
readonly contextMaxCharacters?: number
|
||||
}["providerID"]
|
||||
readonly numResults?: {
|
||||
readonly query: string
|
||||
readonly providerID?: string
|
||||
readonly numResults?: number
|
||||
readonly livecrawl?: "fallback" | "preferred"
|
||||
readonly type?: "auto" | "fast" | "deep"
|
||||
readonly contextMaxCharacters?: number
|
||||
}["numResults"]
|
||||
readonly livecrawl?: {
|
||||
readonly query: string
|
||||
readonly providerID?: string
|
||||
readonly numResults?: number
|
||||
readonly livecrawl?: "fallback" | "preferred"
|
||||
readonly type?: "auto" | "fast" | "deep"
|
||||
readonly contextMaxCharacters?: number
|
||||
}["livecrawl"]
|
||||
readonly type?: {
|
||||
readonly query: string
|
||||
readonly providerID?: string
|
||||
readonly numResults?: number
|
||||
readonly livecrawl?: "fallback" | "preferred"
|
||||
readonly type?: "auto" | "fast" | "deep"
|
||||
readonly contextMaxCharacters?: number
|
||||
}["type"]
|
||||
readonly contextMaxCharacters?: {
|
||||
readonly query: string
|
||||
readonly providerID?: string
|
||||
readonly numResults?: number
|
||||
readonly livecrawl?: "fallback" | "preferred"
|
||||
readonly type?: "auto" | "fast" | "deep"
|
||||
readonly contextMaxCharacters?: number
|
||||
}["contextMaxCharacters"]
|
||||
}
|
||||
|
||||
export type SearchQueryOutput = {
|
||||
readonly location: {
|
||||
readonly directory: string
|
||||
readonly workspaceID?: string
|
||||
readonly project: { readonly id: string; readonly directory: string }
|
||||
}
|
||||
readonly data: { readonly providerID: string; readonly text: string; readonly metadata?: JsonValue }
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export type {
|
||||
PluginApi,
|
||||
ProviderApi,
|
||||
ReferenceApi,
|
||||
SearchApi,
|
||||
SessionApi,
|
||||
SkillApi,
|
||||
} from "./api.js"
|
||||
|
||||
@@ -31,18 +31,21 @@ test("exposes every standard HTTP API group", () => {
|
||||
"projectCopy",
|
||||
"vcs",
|
||||
"debug",
|
||||
"search",
|
||||
])
|
||||
expect(Object.keys(client.debug)).toEqual(["location"])
|
||||
expect(Object.keys(client.message)).toEqual(["list"])
|
||||
expect(Object.keys(client.integration)).toEqual([
|
||||
"list",
|
||||
"get",
|
||||
"selectCapability",
|
||||
"connectKey",
|
||||
"connectOauth",
|
||||
"attemptStatus",
|
||||
"attemptComplete",
|
||||
"attemptCancel",
|
||||
])
|
||||
expect(Object.keys(client.search)).toEqual(["query"])
|
||||
expect(Object.keys(client.file)).toEqual(["read", "list", "find"])
|
||||
expect(Object.keys(client.vcs)).toEqual(["status", "diff"])
|
||||
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"])
|
||||
@@ -50,6 +53,32 @@ test("exposes every standard HTTP API group", () => {
|
||||
expect(Object.keys(client.project)).toEqual(["list", "current", "directories"])
|
||||
})
|
||||
|
||||
test("search.query uses the public HTTP contract", async () => {
|
||||
let request: Request | undefined
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input, init) => {
|
||||
request = input instanceof Request ? input : new Request(input, init)
|
||||
return Response.json({
|
||||
location: { directory: "/tmp/project", project: { id: "proj_test", directory: "/tmp/project" } },
|
||||
data: { providerID: "exa", text: "result", metadata: { requestID: "req_test" } },
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const result = await client.search.query({
|
||||
query: "opencode",
|
||||
providerID: "exa",
|
||||
numResults: 5,
|
||||
location: { directory: "/tmp/project" },
|
||||
})
|
||||
|
||||
expect(result.data).toEqual({ providerID: "exa", text: "result", metadata: { requestID: "req_test" } })
|
||||
expect(request?.method).toBe("POST")
|
||||
expect(request?.url).toBe("http://localhost:3000/api/search?location%5Bdirectory%5D=%2Ftmp%2Fproject")
|
||||
expect(await request?.json()).toEqual({ query: "opencode", providerID: "exa", numResults: 5 })
|
||||
})
|
||||
|
||||
test("file.read returns binary content from the public HTTP contract", async () => {
|
||||
let request: Request | undefined
|
||||
const client = OpenCode.make({
|
||||
|
||||
@@ -36,6 +36,10 @@
|
||||
"name": "event",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "integration_capability",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "permission",
|
||||
"entityType": "tables"
|
||||
@@ -554,6 +558,46 @@
|
||||
"entityType": "columns",
|
||||
"table": "event"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "capability",
|
||||
"entityType": "columns",
|
||||
"table": "integration_capability"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "integration_id",
|
||||
"entityType": "columns",
|
||||
"table": "integration_capability"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "time_created",
|
||||
"entityType": "columns",
|
||||
"table": "integration_capability"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "time_updated",
|
||||
"entityType": "columns",
|
||||
"table": "integration_capability"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
@@ -1805,6 +1849,13 @@
|
||||
"table": "event",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": ["capability"],
|
||||
"nameExplicit": false,
|
||||
"name": "integration_capability_pk",
|
||||
"table": "integration_capability",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": ["id"],
|
||||
"nameExplicit": false,
|
||||
|
||||
@@ -23,6 +23,7 @@ import { ConfigModel } from "./config/model"
|
||||
import { ConfigPlugin } from "./config/plugin"
|
||||
import { ConfigProvider } from "./config/provider"
|
||||
import { ConfigReference } from "./config/reference"
|
||||
import { ConfigSearch } from "./config/search"
|
||||
import { ConfigToolOutput } from "./config/tool-output"
|
||||
import { ConfigVariable } from "./config/variable"
|
||||
import { ConfigWatcher } from "./config/watcher"
|
||||
@@ -102,6 +103,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",
|
||||
}),
|
||||
search: ConfigSearch.Info.pipe(Schema.optional).annotate({
|
||||
description: "Web search provider selection",
|
||||
}),
|
||||
plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({
|
||||
description: "Ordered plugin enablement directives and external package declarations",
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export * as ConfigSearch from "./search"
|
||||
|
||||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
import { Schema } from "effect"
|
||||
|
||||
export class Info extends Schema.Class<Info>("ConfigSearch.Info")({
|
||||
provider: Integration.ID,
|
||||
}) {}
|
||||
+1
@@ -46,6 +46,7 @@ export const migrations = (
|
||||
import("./migration/20260703190000_reset_v2_shell_event_payloads"),
|
||||
import("./migration/20260703200000_reset_v2_session_events"),
|
||||
import("./migration/20260705180000_rename_instructions"),
|
||||
import("./migration/20260706133920_integration-search"),
|
||||
import("./migration/20260706223930_add-session-fork"),
|
||||
import("./migration/20260707010146_durable_session_inbox"),
|
||||
])
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260706133920_integration-search",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`integration_capability\` (
|
||||
\`capability\` text PRIMARY KEY,
|
||||
\`integration_id\` text NOT NULL,
|
||||
\`time_created\` integer NOT NULL,
|
||||
\`time_updated\` integer NOT NULL
|
||||
);
|
||||
`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -87,6 +87,14 @@ export default {
|
||||
CONSTRAINT \`fk_event_aggregate_id_event_sequence_aggregate_id_fk\` FOREIGN KEY (\`aggregate_id\`) REFERENCES \`event_sequence\`(\`aggregate_id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`integration_capability\` (
|
||||
\`capability\` text PRIMARY KEY,
|
||||
\`integration_id\` text NOT NULL,
|
||||
\`time_created\` integer NOT NULL,
|
||||
\`time_updated\` integer NOT NULL
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`permission\` (
|
||||
\`id\` text PRIMARY KEY,
|
||||
|
||||
@@ -67,6 +67,7 @@ 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
|
||||
@@ -135,10 +136,7 @@ export const layer = Layer.effect(
|
||||
title: input.title,
|
||||
...(input.metadata === undefined ? {} : { metadata: input.metadata }),
|
||||
}
|
||||
const form: Info =
|
||||
input.mode === "form"
|
||||
? { ...base, mode: "form", fields: input.fields }
|
||||
: { ...base, mode: "url", url: input.url }
|
||||
const form = makeInfo(input, base)
|
||||
const entry: Entry = {
|
||||
form,
|
||||
state: { status: "pending" },
|
||||
@@ -228,9 +226,9 @@ export const locationLayer = layer
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] })
|
||||
|
||||
function validateAnswer(form: Info, answer: Answer) {
|
||||
if (form.mode === "url") {
|
||||
if (form.mode !== "form") {
|
||||
if (Object.keys(answer).length === 0) return
|
||||
return "URL forms must be answered with an empty answer"
|
||||
return `${form.mode === "url" ? "URL" : "Integration"} forms must be answered with an empty answer`
|
||||
}
|
||||
const fields = new Map(form.fields.map((field) => [field.key, field]))
|
||||
for (const key of Object.keys(answer)) {
|
||||
@@ -249,6 +247,17 @@ function validateAnswer(form: Info, answer: Answer) {
|
||||
}
|
||||
}
|
||||
|
||||
function makeInfo(input: CreateInput, base: Omit<Info, "mode" | "fields" | "url" | "integrationID">): Info {
|
||||
switch (input.mode) {
|
||||
case "form":
|
||||
return { ...base, mode: "form", fields: input.fields }
|
||||
case "url":
|
||||
return { ...base, mode: "url", url: input.url }
|
||||
case "integration":
|
||||
return { ...base, mode: "integration", integrationID: input.integrationID }
|
||||
}
|
||||
}
|
||||
|
||||
function isActive(field: Form.Field, answer: Answer) {
|
||||
if (!field.when) return true
|
||||
return field.when.every((when) => matches(when, answer[when.key]))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * as Integration from "./integration"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import { eq } from "drizzle-orm"
|
||||
import {
|
||||
Cause,
|
||||
Clock,
|
||||
@@ -16,10 +17,13 @@ import {
|
||||
Types,
|
||||
} from "effect"
|
||||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
import { Search } from "@opencode-ai/schema/search"
|
||||
import { Credential } from "./credential"
|
||||
import { Database } from "./database/database"
|
||||
import { State } from "./state"
|
||||
import { EventV2 } from "./event"
|
||||
import { IntegrationConnection } from "./integration/connection"
|
||||
import { IntegrationCapabilityTable } from "./integration/sql"
|
||||
|
||||
export const ID = Integration.ID
|
||||
export type ID = Integration.ID
|
||||
@@ -60,6 +64,12 @@ export type Info = Integration.Info
|
||||
export const Inputs = Integration.Inputs
|
||||
export type Inputs = Integration.Inputs
|
||||
|
||||
export const SearchCapability = Integration.SearchCapability
|
||||
export type SearchCapability = Omit<Integration.SearchCapability, "selected">
|
||||
|
||||
export const Capability = Integration.Capability
|
||||
export type Capability = Integration.Capability
|
||||
|
||||
export type OAuthAuthorization = {
|
||||
readonly url: string
|
||||
readonly instructions: string
|
||||
@@ -94,6 +104,15 @@ export interface EnvImplementation {
|
||||
|
||||
export type Implementation = OAuthImplementation | KeyImplementation | EnvImplementation
|
||||
|
||||
export interface SearchImplementation {
|
||||
readonly integrationID: ID
|
||||
readonly capability: SearchCapability
|
||||
readonly execute: (
|
||||
input: Search.Input,
|
||||
context: { readonly credential?: Credential.Value; readonly sessionID?: string },
|
||||
) => Effect.Effect<Search.ProviderOutput, unknown>
|
||||
}
|
||||
|
||||
export const Attempt = Integration.Attempt
|
||||
export type Attempt = Integration.Attempt
|
||||
|
||||
@@ -119,6 +138,7 @@ type Entry = {
|
||||
ref: Types.DeepMutable<Ref>
|
||||
methods: Types.DeepMutable<Method>[]
|
||||
implementations: Map<MethodID, Types.DeepMutable<OAuthImplementation>>
|
||||
search?: Types.DeepMutable<SearchImplementation>
|
||||
}
|
||||
|
||||
type Data = {
|
||||
@@ -135,6 +155,13 @@ export type Draft = {
|
||||
update: (implementation: Implementation) => void
|
||||
remove: (integrationID: ID, method: Method) => void
|
||||
}
|
||||
capability: {
|
||||
search: {
|
||||
list: () => readonly SearchImplementation[]
|
||||
update: (implementation: SearchImplementation) => void
|
||||
remove: (integrationID: ID) => void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
@@ -191,6 +218,14 @@ export interface Interface extends State.Transformable<Draft> {
|
||||
/** Cancels an attempt and releases its resources. */
|
||||
readonly cancel: (attemptID: AttemptID) => Effect.Effect<void>
|
||||
}
|
||||
readonly capability: {
|
||||
readonly search: {
|
||||
readonly list: () => Effect.Effect<readonly SearchImplementation[]>
|
||||
readonly get: (integrationID: ID) => Effect.Effect<SearchImplementation | undefined>
|
||||
readonly selected: () => Effect.Effect<ID | undefined>
|
||||
readonly select: (integrationID: ID) => Effect.Effect<void>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Integration") {}
|
||||
@@ -222,6 +257,7 @@ const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const credentials = yield* Credential.Service
|
||||
const { db } = yield* Database.Service
|
||||
const events = yield* EventV2.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const attempts = SynchronizedRef.makeUnsafe(new Map<AttemptID, AttemptEntry>())
|
||||
@@ -281,6 +317,32 @@ const layer = Layer.effect(
|
||||
if (method.type === "oauth") current.implementations.delete(method.id)
|
||||
},
|
||||
},
|
||||
capability: {
|
||||
search: {
|
||||
list: () =>
|
||||
Array.from(draft.integrations.values()).flatMap((entry) =>
|
||||
entry.search ? [entry.search as SearchImplementation] : [],
|
||||
),
|
||||
update: (implementation) => {
|
||||
const current = draft.integrations.get(implementation.integrationID) ?? {
|
||||
ref: {
|
||||
id: implementation.integrationID,
|
||||
name: implementation.integrationID,
|
||||
},
|
||||
methods: [],
|
||||
implementations: new Map<MethodID, Types.DeepMutable<OAuthImplementation>>(),
|
||||
}
|
||||
if (!draft.integrations.has(implementation.integrationID)) {
|
||||
draft.integrations.set(implementation.integrationID, current)
|
||||
}
|
||||
current.search = implementation as Types.DeepMutable<SearchImplementation>
|
||||
},
|
||||
remove: (integrationID) => {
|
||||
const current = draft.integrations.get(integrationID)
|
||||
if (current) delete current.search
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
@@ -300,14 +362,24 @@ const layer = Layer.effect(
|
||||
return [...credentials, ...env]
|
||||
}
|
||||
|
||||
const project = (entry: Entry, connections: IntegrationConnection.Info[]) =>
|
||||
const project = (entry: Entry, connections: IntegrationConnection.Info[], selectedSearch: ID | undefined) =>
|
||||
new Info({
|
||||
id: entry.ref.id,
|
||||
name: entry.ref.name,
|
||||
methods: entry.methods,
|
||||
capabilities: entry.search ? [{ ...entry.search.capability, selected: entry.ref.id === selectedSearch }] : [],
|
||||
connections,
|
||||
})
|
||||
|
||||
const selectedSearch = Effect.fn("Integration.capability.search.selected")(function* () {
|
||||
return (yield* db
|
||||
.select({ integrationID: IntegrationCapabilityTable.integration_id })
|
||||
.from(IntegrationCapabilityTable)
|
||||
.where(eq(IntegrationCapabilityTable.capability, "search"))
|
||||
.get()
|
||||
.pipe(Effect.orDie))?.integrationID
|
||||
})
|
||||
|
||||
const authorize = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
effect.pipe(Effect.mapError((cause) => new AuthorizationError({ cause })))
|
||||
|
||||
@@ -369,12 +441,13 @@ const layer = Layer.effect(
|
||||
get: Effect.fn("Integration.get")(function* (id) {
|
||||
const entry = state.get().integrations.get(id)
|
||||
if (!entry) return undefined
|
||||
return project(entry, resolveConnections(entry, yield* credentials.list(id)))
|
||||
return project(entry, resolveConnections(entry, yield* credentials.list(id)), yield* selectedSearch())
|
||||
}),
|
||||
list: Effect.fn("Integration.list")(function* () {
|
||||
const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID)
|
||||
const selected = yield* selectedSearch()
|
||||
return Array.from(state.get().integrations.values(), (entry) =>
|
||||
project(entry, resolveConnections(entry, saved.get(entry.ref.id) ?? [])),
|
||||
project(entry, resolveConnections(entry, saved.get(entry.ref.id) ?? []), selected),
|
||||
).toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
}),
|
||||
connection: {
|
||||
@@ -514,8 +587,36 @@ const layer = Layer.effect(
|
||||
if (attempt) yield* Scope.close(attempt.scope, Exit.void)
|
||||
}),
|
||||
},
|
||||
capability: {
|
||||
search: {
|
||||
list: Effect.fn("Integration.capability.search.list")(function* () {
|
||||
return Array.from(state.get().integrations.values()).flatMap((entry) =>
|
||||
entry.search ? [entry.search as SearchImplementation] : [],
|
||||
)
|
||||
}),
|
||||
get: Effect.fn("Integration.capability.search.get")(function* (integrationID) {
|
||||
return state.get().integrations.get(integrationID)?.search as SearchImplementation | undefined
|
||||
}),
|
||||
selected: selectedSearch,
|
||||
select: Effect.fn("Integration.capability.search.select")(function* (integrationID) {
|
||||
if (!state.get().integrations.get(integrationID)?.search) {
|
||||
return yield* Effect.die(new Error(`Search capability not found: ${integrationID}`))
|
||||
}
|
||||
yield* db
|
||||
.insert(IntegrationCapabilityTable)
|
||||
.values({ capability: "search", integration_id: integrationID })
|
||||
.onConflictDoUpdate({
|
||||
target: IntegrationCapabilityTable.capability,
|
||||
set: { integration_id: integrationID },
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* events.publish(Event.Updated, {})
|
||||
}),
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Credential.node, EventV2.node] })
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Credential.node, Database.node, EventV2.node] })
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { sqliteTable, text } from "drizzle-orm/sqlite-core"
|
||||
import { Timestamps } from "../database/schema.sql"
|
||||
import type { Integration } from "../integration"
|
||||
|
||||
export const IntegrationCapabilityTable = sqliteTable("integration_capability", {
|
||||
capability: text().$type<Integration.Capability["type"]>().primaryKey(),
|
||||
integration_id: text().$type<Integration.ID>().notNull(),
|
||||
...Timestamps,
|
||||
})
|
||||
@@ -34,6 +34,7 @@ import { Pty } from "./pty"
|
||||
import { QuestionV2 } from "./question"
|
||||
import { Shell } from "./shell"
|
||||
import { Reference } from "./reference"
|
||||
import { Search } from "./search"
|
||||
import { ReferenceGuidance } from "./reference/guidance"
|
||||
import { Ripgrep } from "./ripgrep"
|
||||
import { SessionRunnerLLM } from "./session/runner/llm"
|
||||
@@ -51,7 +52,6 @@ import { SessionInstructions } from "./session/instructions"
|
||||
import { McpTool } from "./tool/mcp"
|
||||
import { ReadToolFileSystem } from "./tool/read-filesystem"
|
||||
import { ToolRegistry } from "./tool/registry"
|
||||
import { WebSearchTool } from "./tool/websearch"
|
||||
import { ToolOutputStore } from "./tool-output-store"
|
||||
import { Vcs } from "./vcs"
|
||||
|
||||
@@ -84,13 +84,13 @@ const pluginSupervisorNode = makeLocationNode({
|
||||
Form.node,
|
||||
ReadToolFileSystem.node,
|
||||
Reference.node,
|
||||
Search.node,
|
||||
Ripgrep.node,
|
||||
SessionInstructions.node,
|
||||
SessionTodo.node,
|
||||
Shell.node,
|
||||
SkillV2.node,
|
||||
ToolRegistry.toolsNode,
|
||||
WebSearchTool.configNode,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -100,6 +100,7 @@ const locationServiceNodes = [
|
||||
AgentV2.node,
|
||||
CommandV2.node,
|
||||
Reference.node,
|
||||
Search.node,
|
||||
Integration.node,
|
||||
Catalog.node,
|
||||
AISDK.node,
|
||||
|
||||
@@ -164,6 +164,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||
integration: {
|
||||
list: () => response(integration.list()),
|
||||
get: (input) => response(integration.get(Integration.ID.make(input.integrationID))),
|
||||
selectCapability: (input) => integration.capability.search.select(Integration.ID.make(input.integrationID)),
|
||||
connectKey: (input) =>
|
||||
integration.connection.key({
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
@@ -269,6 +270,29 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||
remove: (id, method) =>
|
||||
draft.method.remove(Integration.ID.make(id), Schema.decodeUnknownSync(Integration.Method)(method)),
|
||||
},
|
||||
capability: {
|
||||
search: {
|
||||
list: () =>
|
||||
draft.capability.search.list().map((provider) => ({
|
||||
integrationID: provider.integrationID,
|
||||
capability: provider.capability,
|
||||
execute: (input, context) =>
|
||||
provider.execute(input, {
|
||||
...context,
|
||||
credential: context.credential
|
||||
? Schema.decodeUnknownSync(Credential.Value)(context.credential)
|
||||
: undefined,
|
||||
}),
|
||||
})),
|
||||
update: (input) =>
|
||||
draft.capability.search.update({
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
capability: input.capability,
|
||||
execute: input.execute,
|
||||
}),
|
||||
remove: (id) => draft.capability.search.remove(Integration.ID.make(id)),
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -26,6 +26,7 @@ import { ModelsDev } from "../models-dev"
|
||||
import { Npm } from "../npm"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Reference } from "../reference"
|
||||
import { Search } from "../search"
|
||||
import { Ripgrep } from "../ripgrep"
|
||||
import { SessionInstructions } from "../session/instructions"
|
||||
import { SessionTodo } from "../session/todo"
|
||||
@@ -50,6 +51,7 @@ import { AgentPlugin } from "./agent"
|
||||
import { CommandPlugin } from "./command"
|
||||
import { ModelsDevPlugin } from "./models-dev"
|
||||
import { ProviderPlugins } from "./provider"
|
||||
import { SearchPlugins } from "./search"
|
||||
import { PluginRuntime } from "./runtime"
|
||||
import { SkillPlugin } from "./skill"
|
||||
import { VariantPlugin } from "./variant"
|
||||
@@ -76,13 +78,13 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
const form = yield* Form.Service
|
||||
const read = yield* ReadToolFileSystem.Service
|
||||
const reference = yield* Reference.Service
|
||||
const search = yield* Search.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const instructions = yield* SessionInstructions.Service
|
||||
const todo = yield* SessionTodo.Service
|
||||
const shell = yield* Shell.Service
|
||||
const skill = yield* SkillV2.Service
|
||||
const tools = yield* Tools.Service
|
||||
const websearch = yield* WebSearchTool.ConfigService
|
||||
return Context.mergeAll(
|
||||
Context.make(AgentV2.Service, agent),
|
||||
Context.make(Catalog.Service, catalog),
|
||||
@@ -105,13 +107,13 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
Context.make(Form.Service, form),
|
||||
Context.make(ReadToolFileSystem.Service, read),
|
||||
Context.make(Reference.Service, reference),
|
||||
Context.make(Search.Service, search),
|
||||
Context.make(Ripgrep.Service, ripgrep),
|
||||
Context.make(SessionInstructions.Service, instructions),
|
||||
Context.make(SessionTodo.Service, todo),
|
||||
Context.make(Shell.Service, shell),
|
||||
Context.make(SkillV2.Service, skill),
|
||||
Context.make(Tools.Service, tools),
|
||||
Context.make(WebSearchTool.ConfigService, websearch),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -127,6 +129,7 @@ const pre = [
|
||||
SkillPlugin.Plugin,
|
||||
ModelsDevPlugin,
|
||||
...ProviderPlugins,
|
||||
...SearchPlugins,
|
||||
ApplyPatchTool.Plugin,
|
||||
EditTool.Plugin,
|
||||
GlobTool.Plugin,
|
||||
|
||||
@@ -79,12 +79,42 @@ export function fromPromise(plugin: Plugin) {
|
||||
integration: {
|
||||
list: (input) => run(host.integration.list(input)),
|
||||
get: (input) => run(host.integration.get(input)),
|
||||
selectCapability: (input) => run(host.integration.selectCapability(input)),
|
||||
connectKey: (input) => run(host.integration.connectKey(input)),
|
||||
connectOauth: (input) => run(host.integration.connectOauth(input)),
|
||||
attemptStatus: (input) => run(host.integration.attemptStatus(input)),
|
||||
attemptComplete: (input) => run(host.integration.attemptComplete(input)),
|
||||
attemptCancel: (input) => run(host.integration.attemptCancel(input)),
|
||||
transform: transform(host.integration),
|
||||
transform: (callback) =>
|
||||
register(
|
||||
host.integration.transform((draft) => {
|
||||
callback({
|
||||
...draft,
|
||||
capability: {
|
||||
search: {
|
||||
list: () =>
|
||||
draft.capability.search.list().map((provider) => ({
|
||||
integrationID: provider.integrationID,
|
||||
capability: provider.capability,
|
||||
execute: (input, execution) =>
|
||||
Effect.runPromiseWith(context)(provider.execute(input, execution)),
|
||||
})),
|
||||
update: (input) =>
|
||||
draft.capability.search.update({
|
||||
integrationID: input.integrationID,
|
||||
capability: input.capability,
|
||||
execute: (query, execution) =>
|
||||
Effect.tryPromise({
|
||||
try: (signal) => input.execute(query, { ...execution, signal }),
|
||||
catch: (cause) => cause,
|
||||
}),
|
||||
}),
|
||||
remove: draft.capability.search.remove,
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
),
|
||||
reload: () => run(host.integration.reload()),
|
||||
connection: {
|
||||
active: (id) => Effect.runPromiseWith(context)(host.integration.connection.active(id)),
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
export * as SearchExa from "./exa"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Effect, Schema, Scope } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { SearchMcp } from "./mcp"
|
||||
|
||||
export const endpoint = "https://mcp.exa.ai/mcp"
|
||||
|
||||
const Args = Schema.Struct({
|
||||
query: Schema.String,
|
||||
type: Schema.String,
|
||||
numResults: Schema.Number,
|
||||
livecrawl: Schema.String,
|
||||
contextMaxCharacters: Schema.optional(Schema.Number),
|
||||
})
|
||||
|
||||
export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
|
||||
id: "opencode.search.exa",
|
||||
effect: Effect.fn("SearchExa.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"] } })
|
||||
draft.capability.search.update({
|
||||
integrationID: "exa",
|
||||
capability: { type: "search", connection: "optional" },
|
||||
execute: (input, context) => {
|
||||
const url = new URL(endpoint)
|
||||
if (context.credential?.type === "key") url.searchParams.set("exaApiKey", context.credential.key)
|
||||
return SearchMcp.call(http, url.toString(), "web_search_exa", Args, {
|
||||
query: input.query,
|
||||
type: input.type ?? "auto",
|
||||
numResults: input.numResults ?? 8,
|
||||
livecrawl: input.livecrawl ?? "fallback",
|
||||
contextMaxCharacters: input.contextMaxCharacters,
|
||||
}).pipe(Effect.map((text) => ({ text: text ?? "" })))
|
||||
},
|
||||
})
|
||||
})
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
export * as SearchFirecrawl from "./firecrawl"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import type { Search } from "@opencode-ai/schema/search"
|
||||
import { Duration, Effect, Schema, Scope } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { collectBoundedResponseBody } from "../../tool/http-body"
|
||||
import { SearchMcp } from "./mcp"
|
||||
|
||||
export const endpoint = "https://api.firecrawl.dev/v2/search"
|
||||
|
||||
const FirecrawlRequest = Schema.Struct({
|
||||
query: Schema.String,
|
||||
limit: Schema.optional(Schema.Number),
|
||||
})
|
||||
|
||||
const ResultBase = {
|
||||
url: Schema.String,
|
||||
title: Schema.optional(Schema.String),
|
||||
position: Schema.optional(Schema.Number),
|
||||
}
|
||||
const PageResult = {
|
||||
...ResultBase,
|
||||
markdown: Schema.optional(Schema.NullOr(Schema.String)),
|
||||
}
|
||||
const WebResult = Schema.Struct({
|
||||
...PageResult,
|
||||
description: Schema.optional(Schema.String),
|
||||
category: Schema.optional(Schema.String),
|
||||
})
|
||||
const NewsResult = Schema.Struct({
|
||||
...PageResult,
|
||||
snippet: Schema.optional(Schema.String),
|
||||
date: Schema.optional(Schema.String),
|
||||
})
|
||||
const ImageResult = Schema.Struct({
|
||||
...ResultBase,
|
||||
imageUrl: Schema.String,
|
||||
imageWidth: Schema.optional(Schema.Number),
|
||||
imageHeight: Schema.optional(Schema.Number),
|
||||
})
|
||||
const FirecrawlResponse = Schema.Struct({
|
||||
success: Schema.Literal(true),
|
||||
data: Schema.Struct({
|
||||
web: Schema.optional(Schema.Array(WebResult)),
|
||||
news: Schema.optional(Schema.Array(NewsResult)),
|
||||
images: Schema.optional(Schema.Array(ImageResult)),
|
||||
}),
|
||||
warning: Schema.optional(Schema.NullOr(Schema.String)),
|
||||
id: Schema.optional(Schema.String),
|
||||
creditsUsed: Schema.optional(Schema.Number),
|
||||
})
|
||||
const decodeJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Json))
|
||||
const decodeResponse = Schema.decodeUnknownEffect(FirecrawlResponse)
|
||||
|
||||
const formatResults = (response: typeof FirecrawlResponse.Type) =>
|
||||
[
|
||||
...(response.data.web ?? []).map((result) =>
|
||||
[`## ${result.title ?? result.url}`, `URL: ${result.url}`, result.description, result.markdown || undefined]
|
||||
.filter((line) => line !== undefined)
|
||||
.join("\n\n"),
|
||||
),
|
||||
...(response.data.news ?? []).map((result) =>
|
||||
[
|
||||
`## ${result.title ?? result.url}`,
|
||||
`URL: ${result.url}`,
|
||||
result.date ? `Date: ${result.date}` : undefined,
|
||||
result.snippet,
|
||||
result.markdown || undefined,
|
||||
]
|
||||
.filter((line) => line !== undefined)
|
||||
.join("\n\n"),
|
||||
),
|
||||
...(response.data.images ?? []).map((result) =>
|
||||
[`## ${result.title ?? result.url}`, `Source: ${result.url}`, `Image: ${result.imageUrl}`].join("\n\n"),
|
||||
),
|
||||
].join("\n\n")
|
||||
|
||||
const search = (
|
||||
http: HttpClient.HttpClient,
|
||||
input: Pick<Search.Input, "query" | "numResults" | "contextMaxCharacters">,
|
||||
apiKey?: string,
|
||||
) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpClientRequest.post(endpoint).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.setHeaders(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
||||
HttpClientRequest.schemaBodyJson(FirecrawlRequest)({ query: input.query, limit: input.numResults }),
|
||||
)
|
||||
const response = yield* HttpClient.filterStatusOk(http).execute(request)
|
||||
const body = yield* collectBoundedResponseBody(
|
||||
response,
|
||||
SearchMcp.MAX_RESPONSE_BYTES,
|
||||
() => new Error(`Firecrawl response exceeded ${SearchMcp.MAX_RESPONSE_BYTES} bytes`),
|
||||
)
|
||||
const metadata = yield* decodeJson(body.toString("utf8"))
|
||||
const result = yield* decodeResponse(metadata)
|
||||
const text = formatResults(result)
|
||||
return {
|
||||
text: input.contextMaxCharacters ? text.slice(0, input.contextMaxCharacters) : text,
|
||||
metadata,
|
||||
}
|
||||
}).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: Duration.seconds(25),
|
||||
orElse: () => Effect.fail(new Error("Firecrawl search request timed out")),
|
||||
}),
|
||||
)
|
||||
|
||||
export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
|
||||
id: "opencode.search.firecrawl",
|
||||
effect: Effect.fn("SearchFirecrawl.Plugin")(function* (ctx) {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
yield* ctx.integration.transform((draft) => {
|
||||
draft.update("firecrawl", (integration) => (integration.name = "Firecrawl"))
|
||||
draft.method.update({ integrationID: "firecrawl", method: { type: "key", label: "API key (optional)" } })
|
||||
draft.method.update({ integrationID: "firecrawl", method: { type: "env", names: ["FIRECRAWL_API_KEY"] } })
|
||||
draft.capability.search.update({
|
||||
integrationID: "firecrawl",
|
||||
capability: { type: "search", connection: "optional" },
|
||||
execute: (input, context) =>
|
||||
search(http, input, context.credential?.type === "key" ? context.credential.key : undefined),
|
||||
})
|
||||
})
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SearchExa } from "./exa"
|
||||
import { SearchFirecrawl } from "./firecrawl"
|
||||
import { SearchParallel } from "./parallel"
|
||||
|
||||
export const SearchPlugins = [SearchExa.Plugin, SearchFirecrawl.Plugin, SearchParallel.Plugin] as const
|
||||
@@ -0,0 +1,75 @@
|
||||
export * as SearchMcp from "./mcp"
|
||||
|
||||
import { Duration, Effect, Schema } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { collectBoundedResponseBody } from "../../tool/http-body"
|
||||
|
||||
export const MAX_RESPONSE_BYTES = 256 * 1024
|
||||
|
||||
const Result = Schema.Struct({
|
||||
result: Schema.Struct({
|
||||
content: Schema.Array(Schema.Struct({ type: Schema.String, text: Schema.String })),
|
||||
}),
|
||||
})
|
||||
const decodeResult = Schema.decodeUnknownEffect(Schema.fromJsonString(Result))
|
||||
|
||||
const parsePayload = (payload: string) =>
|
||||
Effect.gen(function* () {
|
||||
const trimmed = payload.trim()
|
||||
if (!trimmed.startsWith("{")) return undefined
|
||||
return (yield* decodeResult(trimmed)).result.content.find((item) => item.text)?.text
|
||||
})
|
||||
|
||||
export const parseResponse = Effect.fn("SearchMcp.parseResponse")(function* (body: string) {
|
||||
const trimmed = body.trim()
|
||||
const direct = trimmed ? yield* parsePayload(trimmed) : undefined
|
||||
if (direct) return direct
|
||||
for (const line of body.split("\n")) {
|
||||
if (!line.startsWith("data: ")) continue
|
||||
const data = yield* parsePayload(line.substring(6))
|
||||
if (data) return data
|
||||
}
|
||||
})
|
||||
|
||||
const Request = <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 }),
|
||||
})
|
||||
|
||||
export const call = <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(Request(args))({
|
||||
jsonrpc: "2.0" as const,
|
||||
id: 1 as const,
|
||||
method: "tools/call" as const,
|
||||
params: { name: tool, arguments: value },
|
||||
}),
|
||||
)
|
||||
return yield* Effect.gen(function* () {
|
||||
const response = yield* HttpClient.filterStatusOk(http).execute(request)
|
||||
const body = yield* collectBoundedResponseBody(
|
||||
response,
|
||||
MAX_RESPONSE_BYTES,
|
||||
() => new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`),
|
||||
)
|
||||
return yield* parseResponse(body.toString("utf8"))
|
||||
}).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: Duration.seconds(25),
|
||||
orElse: () => Effect.fail(new Error(`${tool} request timed out`)),
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
export * as SearchParallel from "./parallel"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Effect, Schema, Scope } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { InstallationVersion } from "../../installation/version"
|
||||
import { SearchMcp } from "./mcp"
|
||||
|
||||
export const endpoint = "https://search.parallel.ai/mcp"
|
||||
|
||||
const Args = Schema.Struct({
|
||||
objective: Schema.String,
|
||||
search_queries: Schema.Array(Schema.String),
|
||||
session_id: Schema.String,
|
||||
})
|
||||
|
||||
export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
|
||||
id: "opencode.search.parallel",
|
||||
effect: Effect.fn("SearchParallel.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"] } })
|
||||
draft.capability.search.update({
|
||||
integrationID: "parallel",
|
||||
capability: { type: "search", connection: "optional" },
|
||||
execute: (input, context) =>
|
||||
SearchMcp.call(
|
||||
http,
|
||||
endpoint,
|
||||
"web_search",
|
||||
Args,
|
||||
{
|
||||
objective: input.query,
|
||||
search_queries: [input.query],
|
||||
session_id: context.sessionID ?? "opencode",
|
||||
},
|
||||
{
|
||||
"User-Agent": `opencode/${InstallationVersion}`,
|
||||
...(context.credential?.type === "key" ? { Authorization: `Bearer ${context.credential.key}` } : {}),
|
||||
},
|
||||
).pipe(Effect.map((text) => ({ text: text ?? "" }))),
|
||||
})
|
||||
})
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,199 @@
|
||||
export * as Search from "./search"
|
||||
|
||||
import { Search } from "@opencode-ai/schema/search"
|
||||
import { Context, Effect, Layer, Schema, Semaphore } from "effect"
|
||||
import { Config } from "./config"
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import { Form } from "./form"
|
||||
import { Integration } from "./integration"
|
||||
import { truthy } from "./flag/flag"
|
||||
|
||||
export const Input = Search.Input
|
||||
export type Input = Search.Input
|
||||
|
||||
export const ProviderOutput = Search.ProviderOutput
|
||||
export type ProviderOutput = Search.ProviderOutput
|
||||
|
||||
export const Result = Search.Result
|
||||
export type Result = Search.Result
|
||||
|
||||
export class ProviderRequiredError extends Schema.TaggedErrorClass<ProviderRequiredError>()(
|
||||
"Search.ProviderRequired",
|
||||
{},
|
||||
) {}
|
||||
|
||||
export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()("Search.ProviderNotFound", {
|
||||
providerID: Integration.ID,
|
||||
}) {}
|
||||
|
||||
export class ConnectionRequiredError extends Schema.TaggedErrorClass<ConnectionRequiredError>()(
|
||||
"Search.ConnectionRequired",
|
||||
{ providerID: Integration.ID },
|
||||
) {}
|
||||
|
||||
export class CancelledError extends Schema.TaggedErrorClass<CancelledError>()("Search.Cancelled", {}) {}
|
||||
|
||||
export class RequestError extends Schema.TaggedErrorClass<RequestError>()("Search.Request", {
|
||||
providerID: Integration.ID,
|
||||
cause: Schema.Defect(),
|
||||
}) {}
|
||||
|
||||
export type Error =
|
||||
| ProviderRequiredError
|
||||
| ProviderNotFoundError
|
||||
| ConnectionRequiredError
|
||||
| CancelledError
|
||||
| RequestError
|
||||
|
||||
export interface QueryInput extends Input {
|
||||
readonly sessionID?: string
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly query: (input: QueryInput) => Effect.Effect<Result, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Search") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const forms = yield* Form.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const onboarding = Semaphore.makeUnsafe(1)
|
||||
const decodeOutput = Schema.decodeUnknownEffect(ProviderOutput)
|
||||
|
||||
const requireProvider = (
|
||||
providers: Map<Integration.ID, Integration.SearchImplementation>,
|
||||
providerID: Integration.ID,
|
||||
) => {
|
||||
const provider = providers.get(providerID)
|
||||
return provider ? Effect.succeed(provider) : Effect.fail(new ProviderNotFoundError({ providerID }))
|
||||
}
|
||||
|
||||
const configuredProvider = Effect.fn("Search.configuredProvider")(function* () {
|
||||
const providerID = Config.latest(yield* config.entries(), "search")?.provider
|
||||
if (providerID) return providerID
|
||||
if (process.env.OPENCODE_WEBSEARCH_PROVIDER) {
|
||||
return Integration.ID.make(process.env.OPENCODE_WEBSEARCH_PROVIDER)
|
||||
}
|
||||
if (truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL")) {
|
||||
return Integration.ID.make("parallel")
|
||||
}
|
||||
if (truthy("OPENCODE_EXPERIMENTAL") || truthy("OPENCODE_ENABLE_EXA") || truthy("OPENCODE_EXPERIMENTAL_EXA")) {
|
||||
return Integration.ID.make("exa")
|
||||
}
|
||||
})
|
||||
|
||||
const ask = Effect.fn("Search.ask")(function* (
|
||||
providers: Map<Integration.ID, Integration.SearchImplementation>,
|
||||
sessionID: string,
|
||||
) {
|
||||
if (providers.size === 0) return yield* new ProviderRequiredError()
|
||||
const infos = new Map((yield* integrations.list()).map((integration) => [integration.id, integration]))
|
||||
const state = yield* forms
|
||||
.ask({
|
||||
sessionID,
|
||||
title: "Choose a web search provider",
|
||||
metadata: { kind: "search.provider" },
|
||||
mode: "form",
|
||||
fields: [
|
||||
{
|
||||
key: "provider",
|
||||
title: "Provider",
|
||||
description: "This becomes your default and can be changed later from Connect integration.",
|
||||
type: "string",
|
||||
required: true,
|
||||
custom: false,
|
||||
options: Array.from(providers.values())
|
||||
.flatMap((provider) => {
|
||||
const info = infos.get(provider.integrationID)
|
||||
if (!info) return []
|
||||
const disconnected =
|
||||
provider.capability.connection === "optional" ? "Keyless available" : "Connection required"
|
||||
return [{ info, description: info.connections.length ? "Connected" : disconnected }]
|
||||
})
|
||||
.toSorted((a, b) => a.info.name.localeCompare(b.info.name))
|
||||
.map(({ info, description }) => ({
|
||||
value: info.id,
|
||||
label: info.name,
|
||||
description,
|
||||
})),
|
||||
},
|
||||
],
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
if (state.status === "cancelled") return yield* new CancelledError()
|
||||
const answer = state.answer.provider
|
||||
if (typeof answer !== "string") return yield* new ProviderRequiredError()
|
||||
return yield* requireProvider(providers, Integration.ID.make(answer))
|
||||
})
|
||||
|
||||
const connect = Effect.fn("Search.connect")(function* (
|
||||
provider: Integration.SearchImplementation,
|
||||
sessionID?: string,
|
||||
) {
|
||||
const active = yield* integrations.connection.active(provider.integrationID)
|
||||
if (active || provider.capability.connection === "optional") return active
|
||||
if (!sessionID) return yield* new ConnectionRequiredError({ providerID: provider.integrationID })
|
||||
const state = yield* forms
|
||||
.ask({
|
||||
sessionID,
|
||||
title: `Connect ${provider.integrationID}`,
|
||||
metadata: { kind: "integration.connection" },
|
||||
mode: "integration",
|
||||
integrationID: provider.integrationID,
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
if (state.status === "cancelled") return yield* new CancelledError()
|
||||
const connected = yield* integrations.connection.active(provider.integrationID)
|
||||
if (!connected) return yield* new ConnectionRequiredError({ providerID: provider.integrationID })
|
||||
return connected
|
||||
})
|
||||
|
||||
const select = Effect.fn("Search.select")(function* (input: QueryInput) {
|
||||
const providers = new Map(
|
||||
(yield* integrations.capability.search.list()).map((provider) => [provider.integrationID, provider]),
|
||||
)
|
||||
if (input.providerID) return yield* requireProvider(providers, input.providerID)
|
||||
const override = yield* configuredProvider()
|
||||
if (override) return yield* requireProvider(providers, override)
|
||||
const selected = yield* integrations.capability.search.selected()
|
||||
const provider = selected ? providers.get(selected) : undefined
|
||||
if (provider) return provider
|
||||
const sessionID = input.sessionID
|
||||
if (!sessionID) return yield* new ProviderRequiredError()
|
||||
return yield* onboarding.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const current = yield* integrations.capability.search.selected()
|
||||
const selected = current ? providers.get(current) : undefined
|
||||
if (selected) return selected
|
||||
const provider = yield* ask(providers, sessionID)
|
||||
yield* connect(provider, sessionID)
|
||||
yield* integrations.capability.search.select(provider.integrationID)
|
||||
return provider
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
query: Effect.fn("Search.query")(function* (input) {
|
||||
const provider = yield* select(input)
|
||||
const connection = yield* connect(provider, input.sessionID)
|
||||
const credential = connection
|
||||
? yield* integrations.connection
|
||||
.resolve(connection)
|
||||
.pipe(Effect.mapError((cause) => new RequestError({ providerID: provider.integrationID, cause })))
|
||||
: undefined
|
||||
const output = yield* provider.execute(input, { credential, sessionID: input.sessionID }).pipe(
|
||||
Effect.flatMap(decodeOutput),
|
||||
Effect.mapError((cause) => new RequestError({ providerID: provider.integrationID, cause })),
|
||||
)
|
||||
return new Result({ providerID: provider.integrationID, ...output })
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Config.node, Form.node, Integration.node] })
|
||||
@@ -43,20 +43,15 @@ export interface Registration {
|
||||
readonly group?: string
|
||||
}
|
||||
|
||||
export interface CodeModeTools {
|
||||
[name: string]: Tool.Definition<never> | CodeModeTools
|
||||
}
|
||||
|
||||
export const create = (options: {
|
||||
readonly registrations: ReadonlyMap<string, Registration>
|
||||
readonly current: (name: string) => Registration | undefined
|
||||
readonly tools: CodeModeTools
|
||||
}) => {
|
||||
const runtime = (
|
||||
invoke: (name: string, registration: Registration, input: unknown) => Effect.Effect<unknown, unknown>,
|
||||
hooks?: CodeMode.ToolCallHooks,
|
||||
) => {
|
||||
const tools = cloneTools(options.tools)
|
||||
const tools: Record<string, Tool.Definition<never> | Record<string, Tool.Definition<never>>> = {}
|
||||
for (const [name, registration] of options.registrations) {
|
||||
const child = definition(name, registration.tool)
|
||||
const value = Tool.make({
|
||||
@@ -168,15 +163,6 @@ export const create = (options: {
|
||||
})
|
||||
}
|
||||
|
||||
function cloneTools(tools: CodeModeTools): CodeModeTools {
|
||||
return Object.assign(
|
||||
Object.create(null),
|
||||
Object.fromEntries(
|
||||
Object.entries(tools).map(([name, value]) => [name, Tool.isDefinition(value) ? value : cloneTools(value)]),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function displayInput(input: unknown): Record<string, unknown> | undefined {
|
||||
if (input === null || input === undefined) return
|
||||
if (typeof input !== "object" || Array.isArray(input)) return { input }
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export * as ToolRegistry from "./registry"
|
||||
export type { CodeModeTools } from "./execute"
|
||||
|
||||
import { ToolOutput, type ToolCall, type ToolDefinition, type ToolResultValue } from "@opencode-ai/llm"
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
@@ -10,12 +9,11 @@ import { SessionMessage } from "../session/message"
|
||||
import { SessionSchema } from "../session/schema"
|
||||
import { ToolOutputStore } from "../tool-output-store"
|
||||
import { Wildcard } from "../util/wildcard"
|
||||
import { ExecuteTool, type CodeModeTools } from "./execute"
|
||||
import { ExecuteTool } from "./execute"
|
||||
import { definition, permission, registrationEntries, RegistrationError, settle, type AnyTool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
import { ToolHooks } from "./hooks"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { LayerNode } from "../effect/layer-node"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { toSessionError } from "../session/to-session-error"
|
||||
|
||||
@@ -53,20 +51,10 @@ export interface Settlement {
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolRegistry") {}
|
||||
class CodeModeCatalog extends Context.Service<CodeModeCatalog, { readonly tools: CodeModeTools }>()(
|
||||
"@opencode/v2/CodeModeCatalog",
|
||||
) {}
|
||||
|
||||
const codeModeCatalogNode = makeLocationNode({
|
||||
service: CodeModeCatalog,
|
||||
layer: Layer.succeed(CodeModeCatalog, CodeModeCatalog.of({ tools: {} })),
|
||||
deps: [],
|
||||
})
|
||||
|
||||
const registryLayer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const codeModeTools = (yield* CodeModeCatalog).tools
|
||||
const resources = yield* ToolOutputStore.Service
|
||||
const toolHooks = yield* ToolHooks.Service
|
||||
type Registration = {
|
||||
@@ -216,13 +204,11 @@ const registryLayer = Layer.effect(
|
||||
}
|
||||
const direct = new Map(Array.from(registrations).filter(([, registration]) => !registration.deferred))
|
||||
const deferred = new Map(Array.from(registrations).filter(([, registration]) => registration.deferred))
|
||||
const tools = Flag.CODEMODE_ENABLED ? codeModeTools : {}
|
||||
const execute =
|
||||
(deferred.size > 0 || Object.keys(tools).length > 0) && !whollyDisabled("execute", input.permissions ?? [])
|
||||
deferred.size > 0 && !whollyDisabled("execute", input.permissions ?? [])
|
||||
? ExecuteTool.create({
|
||||
registrations: deferred,
|
||||
current: (name) => local.get(name)?.at(-1)?.registration,
|
||||
tools,
|
||||
})
|
||||
: undefined
|
||||
return {
|
||||
@@ -255,18 +241,14 @@ function whollyDisabled(action: string, rules: PermissionV2.Ruleset) {
|
||||
return rule?.resource === "*" && rule.effect === "deny"
|
||||
}
|
||||
|
||||
export function codeModeReplacement(tools: CodeModeTools): LayerNode.Replacement {
|
||||
return [codeModeCatalogNode, Layer.succeed(CodeModeCatalog, CodeModeCatalog.of({ tools }))]
|
||||
}
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [ToolOutputStore.node, ToolHooks.node, codeModeCatalogNode],
|
||||
deps: [ToolOutputStore.node, ToolHooks.node],
|
||||
})
|
||||
|
||||
export const toolsNode = makeLocationNode({
|
||||
service: Tools.Service,
|
||||
layer,
|
||||
deps: [ToolOutputStore.node, ToolHooks.node, codeModeCatalogNode],
|
||||
deps: [ToolOutputStore.node, ToolHooks.node],
|
||||
})
|
||||
|
||||
@@ -2,197 +2,58 @@ export * as WebSearchTool from "./websearch"
|
||||
|
||||
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { Context, Duration, Effect, Layer, Schema } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { truthy } from "../flag/flag"
|
||||
import { InstallationVersion } from "../installation/version"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Integration } from "../integration"
|
||||
import { PositiveInt } from "../schema"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Search } from "../search"
|
||||
import { SearchExa } from "../plugin/search/exa"
|
||||
import { SearchMcp } from "../plugin/search/mcp"
|
||||
import { SearchParallel } from "../plugin/search/parallel"
|
||||
import { Tool } from "./tool"
|
||||
import { collectBoundedResponseBody } from "./http-body"
|
||||
import { checksum } from "../util/encode"
|
||||
|
||||
export const name = "websearch"
|
||||
export const NO_RESULTS = "No search results found. Please try a different query."
|
||||
export const EXA_URL = "https://mcp.exa.ai/mcp"
|
||||
export const PARALLEL_URL = "https://search.parallel.ai/mcp"
|
||||
export const EXA_URL = SearchExa.endpoint
|
||||
export const PARALLEL_URL = SearchParallel.endpoint
|
||||
export const MAX_NUM_RESULTS = 20
|
||||
export const MAX_CONTEXT_CHARACTERS = 50_000
|
||||
export const MAX_RESPONSE_BYTES = 256 * 1024
|
||||
export const MAX_RESPONSE_BYTES = SearchMcp.MAX_RESPONSE_BYTES
|
||||
export const parseResponse = SearchMcp.parseResponse
|
||||
|
||||
/**
|
||||
* Provider-independent local web search retained in V2 core for launch parity.
|
||||
* This invokes the legacy Exa/Parallel product backends itself. It is distinct
|
||||
* from provider-hosted web search tools, which remain route-owned and execute
|
||||
* at the model provider. Ownership of this compromise can be revisited later.
|
||||
*/
|
||||
export const description = `Search the web using the session's local web search provider. Use this for current information beyond knowledge cutoff.
|
||||
export const description = `Search the web using the user's selected search integration. Use this for current information beyond knowledge cutoff.
|
||||
|
||||
This is a provider-independent local tool backed by Exa or Parallel. Provider-hosted web search tools are separate and execute at the model provider.
|
||||
|
||||
Optional controls support result count, live crawling ('fallback' or 'preferred'), search type ('auto', 'fast', or 'deep'), and maximum context characters.
|
||||
Optional controls support result count, live crawling ('fallback' or 'preferred'), search type ('auto', 'fast', or 'deep'), and maximum context characters. Providers apply supported controls and otherwise use their defaults.
|
||||
|
||||
The current year is ${new Date().getFullYear()}. Use this year when searching for recent information or current events.`
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
query: Schema.String.annotate({ description: "Websearch query" }),
|
||||
numResults: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_NUM_RESULTS))).annotate({
|
||||
description: `Number of search results to return (default: 8, maximum: ${MAX_NUM_RESULTS})`,
|
||||
description: `Number of search results to return (maximum: ${MAX_NUM_RESULTS})`,
|
||||
}),
|
||||
livecrawl: Schema.optional(Schema.Literals(["fallback", "preferred"])).annotate({
|
||||
description:
|
||||
"Live crawl mode - 'fallback': use live crawling as backup if cached unavailable, 'preferred': prioritize live crawling (default: 'fallback')",
|
||||
description: "Live crawl preference when supported by the selected provider",
|
||||
}),
|
||||
type: Schema.optional(Schema.Literals(["auto", "fast", "deep"])).annotate({
|
||||
description: "Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search",
|
||||
description: "Search depth preference when supported by the selected provider",
|
||||
}),
|
||||
contextMaxCharacters: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_CONTEXT_CHARACTERS))).annotate(
|
||||
{
|
||||
description: `Maximum characters for context string optimized for models (default: 10000, maximum: ${MAX_CONTEXT_CHARACTERS})`,
|
||||
},
|
||||
{ description: `Maximum context characters (maximum: ${MAX_CONTEXT_CHARACTERS})` },
|
||||
),
|
||||
})
|
||||
|
||||
export const Provider = Schema.Literals(["exa", "parallel"])
|
||||
export type Provider = typeof Provider.Type
|
||||
|
||||
export interface Config {
|
||||
readonly provider?: Provider
|
||||
readonly enableExa: boolean
|
||||
readonly enableParallel: boolean
|
||||
readonly exaApiKey?: string
|
||||
readonly parallelApiKey?: string
|
||||
}
|
||||
|
||||
export class ConfigService extends Context.Service<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: truthy("OPENCODE_EXPERIMENTAL") || truthy("OPENCODE_ENABLE_EXA") || truthy("OPENCODE_EXPERIMENTAL_EXA"),
|
||||
enableParallel: truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL"),
|
||||
exaApiKey: process.env.EXA_API_KEY,
|
||||
parallelApiKey: process.env.PARALLEL_API_KEY,
|
||||
}),
|
||||
)
|
||||
|
||||
export const configNode = makeLocationNode({ service: ConfigService, layer: defaultConfigLayer, deps: [] })
|
||||
|
||||
export function selectProvider(
|
||||
sessionID: string,
|
||||
flags: Pick<Config, "enableExa" | "enableParallel"> = { enableExa: false, enableParallel: false },
|
||||
override?: Provider,
|
||||
): Provider {
|
||||
if (override) return override
|
||||
if (flags.enableParallel) return "parallel"
|
||||
if (flags.enableExa) return "exa"
|
||||
return Number.parseInt(checksum(sessionID) ?? "0", 36) % 2 === 0 ? "exa" : "parallel"
|
||||
}
|
||||
|
||||
const McpResult = Schema.Struct({
|
||||
result: Schema.Struct({
|
||||
content: Schema.Array(Schema.Struct({ type: Schema.String, text: Schema.String })),
|
||||
}),
|
||||
})
|
||||
const decodeMcpResult = Schema.decodeUnknownEffect(Schema.fromJsonString(McpResult))
|
||||
|
||||
const parsePayload = (payload: string) =>
|
||||
Effect.gen(function* () {
|
||||
const trimmed = payload.trim()
|
||||
if (!trimmed.startsWith("{")) return undefined
|
||||
return (yield* decodeMcpResult(trimmed)).result.content.find((item) => item.text)?.text
|
||||
})
|
||||
|
||||
export const parseResponse = Effect.fn("WebSearchTool.parseResponse")(function* (body: string) {
|
||||
const trimmed = body.trim()
|
||||
const direct = trimmed ? yield* parsePayload(trimmed) : undefined
|
||||
if (direct) return direct
|
||||
for (const line of body.split("\n")) {
|
||||
if (!line.startsWith("data: ")) continue
|
||||
const data = yield* parsePayload(line.substring(6))
|
||||
if (data) return data
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const ExaArgs = Schema.Struct({
|
||||
query: Schema.String,
|
||||
type: Schema.String,
|
||||
numResults: Schema.Number,
|
||||
livecrawl: Schema.String,
|
||||
contextMaxCharacters: Schema.optional(Schema.Number),
|
||||
})
|
||||
const ParallelArgs = Schema.Struct({
|
||||
objective: Schema.String,
|
||||
search_queries: Schema.Array(Schema.String),
|
||||
session_id: Schema.String,
|
||||
})
|
||||
const McpRequest = <F extends Schema.Struct.Fields>(args: Schema.Struct<F>) =>
|
||||
Schema.Struct({
|
||||
jsonrpc: Schema.Literal("2.0"),
|
||||
id: Schema.Literal(1),
|
||||
method: Schema.Literal("tools/call"),
|
||||
params: Schema.Struct({ name: Schema.String, arguments: args }),
|
||||
})
|
||||
|
||||
const exaUrl = (apiKey: string | undefined) => {
|
||||
if (!apiKey) return EXA_URL
|
||||
const url = new URL(EXA_URL)
|
||||
url.searchParams.set("exaApiKey", apiKey)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
const callMcp = <F extends Schema.Struct.Fields>(
|
||||
http: HttpClient.HttpClient,
|
||||
url: string,
|
||||
tool: string,
|
||||
args: Schema.Struct<F>,
|
||||
value: Schema.Struct.Type<F>,
|
||||
headers: Record<string, string> = {},
|
||||
) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpClientRequest.post(url).pipe(
|
||||
HttpClientRequest.accept("application/json, text/event-stream"),
|
||||
HttpClientRequest.setHeaders(headers),
|
||||
HttpClientRequest.schemaBodyJson(McpRequest(args))({
|
||||
jsonrpc: "2.0" as const,
|
||||
id: 1 as const,
|
||||
method: "tools/call" as const,
|
||||
params: { name: tool, arguments: value },
|
||||
}),
|
||||
)
|
||||
return yield* Effect.gen(function* () {
|
||||
const response = yield* HttpClient.filterStatusOk(http).execute(request)
|
||||
const body = yield* collectBoundedResponseBody(
|
||||
response,
|
||||
MAX_RESPONSE_BYTES,
|
||||
() => new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`),
|
||||
)
|
||||
return yield* parseResponse(body.toString("utf8"))
|
||||
}).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: Duration.seconds(25),
|
||||
orElse: () => Effect.fail(new Error(`${tool} request timed out`)),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const Output = Schema.Struct({
|
||||
provider: Provider,
|
||||
provider: Integration.ID,
|
||||
text: Schema.String,
|
||||
metadata: Schema.optional(Schema.Json),
|
||||
})
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.websearch",
|
||||
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const config = yield* ConfigService
|
||||
const permission = yield* PermissionV2.Service
|
||||
const search = yield* Search.Service
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((draft) =>
|
||||
@@ -203,54 +64,28 @@ export const Plugin = {
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
|
||||
execute: (input, context) => {
|
||||
const provider = selectProvider(context.sessionID, config, config.provider)
|
||||
return Effect.gen(function* () {
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [input.query],
|
||||
save: ["*"],
|
||||
metadata: { ...input, provider },
|
||||
metadata: input,
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||
})
|
||||
|
||||
const text =
|
||||
provider === "exa"
|
||||
? yield* callMcp(http, exaUrl(config.exaApiKey), "web_search_exa", ExaArgs, {
|
||||
query: input.query,
|
||||
type: input.type || "auto",
|
||||
numResults: input.numResults || 8,
|
||||
livecrawl: input.livecrawl || "fallback",
|
||||
contextMaxCharacters: input.contextMaxCharacters,
|
||||
})
|
||||
: yield* callMcp(
|
||||
http,
|
||||
PARALLEL_URL,
|
||||
"web_search",
|
||||
ParallelArgs,
|
||||
{
|
||||
objective: input.query,
|
||||
search_queries: [input.query],
|
||||
session_id: context.sessionID,
|
||||
// V2 invocation context does not safely expose the model yet.
|
||||
},
|
||||
{
|
||||
"User-Agent": `opencode/${InstallationVersion}`,
|
||||
...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}),
|
||||
},
|
||||
)
|
||||
const result = yield* search.query({ ...input, sessionID: context.sessionID })
|
||||
return {
|
||||
provider,
|
||||
text: text ?? NO_RESULTS,
|
||||
provider: result.providerID,
|
||||
text: result.text || NO_RESULTS,
|
||||
metadata: result.metadata,
|
||||
}
|
||||
}).pipe(
|
||||
Effect.mapError(
|
||||
(error) => new ToolFailure({ message: `Unable to search the web for ${input.query}`, error }),
|
||||
),
|
||||
)
|
||||
},
|
||||
),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Form } from "@opencode-ai/core/form"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { SessionSchema } from "@opencode-ai/core/session/schema"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
@@ -59,6 +60,27 @@ describe("Form", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses an empty reply to complete an integration form", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Form.Service
|
||||
const created = yield* service.create({
|
||||
sessionID: "ses_test",
|
||||
mode: "integration",
|
||||
integrationID: Integration.ID.make("exa"),
|
||||
})
|
||||
|
||||
const invalid = yield* service.reply({ id: created.id, answer: { connected: true } }).pipe(Effect.flip)
|
||||
expect(invalid).toEqual(
|
||||
new Form.InvalidAnswerError({
|
||||
id: created.id,
|
||||
message: "Integration forms must be answered with an empty answer",
|
||||
}),
|
||||
)
|
||||
yield* service.reply({ id: created.id, answer: {} })
|
||||
expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: {} })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("gates required fields and rejects inactive answers via when", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Form.Service
|
||||
|
||||
@@ -21,7 +21,7 @@ describe("Integration", () => {
|
||||
.transform((editor) => editor.update(openai, (integration) => (integration.name = "OpenAI")))
|
||||
.pipe(Scope.provide(scope))
|
||||
expect(yield* integrations.get(openai)).toEqual(
|
||||
new Integration.Info({ id: openai, name: "OpenAI", methods: [], connections: [] }),
|
||||
new Integration.Info({ id: openai, name: "OpenAI", methods: [], capabilities: [], connections: [] }),
|
||||
)
|
||||
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
|
||||
@@ -45,6 +45,7 @@ export function host(overrides: Overrides = {}): PluginContext {
|
||||
integration: overrides.integration ?? {
|
||||
list: () => Effect.die("unused integration.list"),
|
||||
get: () => Effect.die("unused integration.get"),
|
||||
selectCapability: () => Effect.die("unused integration.selectCapability"),
|
||||
connectKey: () => Effect.die("unused integration.connectKey"),
|
||||
connectOauth: () => Effect.die("unused integration.connectOauth"),
|
||||
attemptStatus: () => Effect.die("unused integration.attemptStatus"),
|
||||
@@ -188,6 +189,7 @@ export function integrationHost(integration: Integration.Interface): PluginConte
|
||||
return {
|
||||
list: () => Effect.die("unused integration.list"),
|
||||
get: () => Effect.die("unused integration.get"),
|
||||
selectCapability: () => Effect.die("unused integration.selectCapability"),
|
||||
connectKey: () => Effect.die("unused integration.connectKey"),
|
||||
connectOauth: () => Effect.die("unused integration.connectOauth"),
|
||||
attemptStatus: () => Effect.die("unused integration.attemptStatus"),
|
||||
@@ -281,6 +283,18 @@ export function integrationHost(integration: Integration.Interface): PluginConte
|
||||
},
|
||||
remove: (id, item) => draft.method.remove(Integration.ID.make(id), internalMethod(item)),
|
||||
},
|
||||
capability: {
|
||||
search: {
|
||||
list: () => [],
|
||||
update: (input) =>
|
||||
draft.capability.search.update({
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
capability: input.capability,
|
||||
execute: input.execute,
|
||||
}),
|
||||
remove: (id) => draft.capability.search.remove(Integration.ID.make(id)),
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
@@ -156,6 +156,7 @@ describe("ModelsDevPlugin", () => {
|
||||
names: ["ACME_API_KEY"],
|
||||
},
|
||||
],
|
||||
capabilities: [],
|
||||
connections: [],
|
||||
}),
|
||||
])
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
|
||||
@@ -93,4 +94,29 @@ describe("fromPromise", () => {
|
||||
expect(yield* agents.get(AgentV2.ID.make("temp"))).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("adapts promise search capability execution", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
const promisePlugin = define({
|
||||
id: "promise-search",
|
||||
setup: async (ctx) => {
|
||||
await ctx.integration.transform((draft) => {
|
||||
draft.capability.search.update({
|
||||
integrationID: "promise-search",
|
||||
capability: { type: "search", connection: "optional" },
|
||||
execute: async (input) => ({ text: `promise: ${input.query}` }),
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
yield* PluginPromise.fromPromise(promisePlugin).effect(host)
|
||||
const provider = yield* integrations.capability.search.get(Integration.ID.make("promise-search"))
|
||||
if (!provider) return yield* Effect.die("Expected promise search provider")
|
||||
expect(yield* provider.execute({ query: "effect" }, {})).toEqual({ text: "promise: effect" })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
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 { SearchFirecrawl } from "@opencode-ai/core/plugin/search/firecrawl"
|
||||
import { host, integrationHost } from "./host"
|
||||
import { requests, resetSearchFixture, searchIntegrationTest } from "./search-fixture"
|
||||
|
||||
const metadata = {
|
||||
success: true,
|
||||
data: {
|
||||
web: [
|
||||
{
|
||||
url: "https://effect.website/",
|
||||
title: "Effect",
|
||||
description: "Build production TypeScript applications.",
|
||||
position: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
id: "search_1",
|
||||
creditsUsed: 2,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetSearchFixture(JSON.stringify(metadata))
|
||||
})
|
||||
|
||||
const it = searchIntegrationTest
|
||||
|
||||
describe("Firecrawl search integration", () => {
|
||||
it.effect("registers Firecrawl and uses its keyless default response", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
yield* SearchFirecrawl.Plugin.effect(host({ integration: integrationHost(integrations) }))
|
||||
const provider = yield* integrations.capability.search.get(Integration.ID.make("firecrawl"))
|
||||
if (!provider) return yield* Effect.die("Expected Firecrawl search provider")
|
||||
|
||||
const output = yield* provider.execute({ query: "effect", numResults: 3 }, {})
|
||||
expect(output).toEqual({
|
||||
text: "## Effect\n\nURL: https://effect.website/\n\nBuild production TypeScript applications.",
|
||||
metadata,
|
||||
})
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
url: SearchFirecrawl.endpoint,
|
||||
headers: expect.not.objectContaining({ authorization: expect.anything() }),
|
||||
body: { query: "effect", limit: 3 },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("sends a configured Firecrawl key as a bearer credential", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
yield* SearchFirecrawl.Plugin.effect(host({ integration: integrationHost(integrations) }))
|
||||
const provider = yield* integrations.capability.search.get(Integration.ID.make("firecrawl"))
|
||||
if (!provider) return yield* Effect.die("Expected Firecrawl search provider")
|
||||
|
||||
const output = yield* provider.execute(
|
||||
{ query: "effect" },
|
||||
{ credential: Credential.Key.make({ type: "key", key: "firecrawl-secret" }) },
|
||||
)
|
||||
expect(requests[0]?.headers).toMatchObject({ authorization: "Bearer firecrawl-secret" })
|
||||
expect(JSON.stringify(output)).not.toContain("firecrawl-secret")
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
export interface SearchRequest {
|
||||
readonly url: string
|
||||
readonly headers: Record<string, string>
|
||||
readonly body: unknown
|
||||
}
|
||||
|
||||
export const requests: SearchRequest[] = []
|
||||
export const response = { body: "" }
|
||||
|
||||
export function resetSearchFixture(body: string) {
|
||||
requests.length = 0
|
||||
response.body = body
|
||||
}
|
||||
|
||||
const http = Layer.succeed(
|
||||
HttpClient.HttpClient,
|
||||
HttpClient.make((request) =>
|
||||
Effect.sync(() => {
|
||||
if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`)
|
||||
requests.push({
|
||||
url: request.url,
|
||||
headers: request.headers,
|
||||
body: JSON.parse(new TextDecoder().decode(request.body.body)),
|
||||
})
|
||||
return HttpClientResponse.fromWeb(request, new Response(response.body, { status: 200 }))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
export const searchIntegrationTest = testEffect(
|
||||
Layer.merge(AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node, EventV2.node])), http),
|
||||
)
|
||||
@@ -0,0 +1,109 @@
|
||||
import { beforeEach, describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { SearchExa } from "@opencode-ai/core/plugin/search/exa"
|
||||
import { SearchParallel } from "@opencode-ai/core/plugin/search/parallel"
|
||||
import { host, integrationHost } from "./host"
|
||||
import { requests, resetSearchFixture, searchIntegrationTest } from "./search-fixture"
|
||||
|
||||
beforeEach(() => {
|
||||
resetSearchFixture(
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
result: { content: [{ type: "text", text: "search results" }] },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const it = searchIntegrationTest
|
||||
|
||||
describe("built-in search integrations", () => {
|
||||
it.effect("registers Exa and maps search hints to its MCP tool", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
yield* SearchExa.Plugin.effect(host({ integration: integrationHost(integrations) }))
|
||||
|
||||
const info = yield* integrations.get(Integration.ID.make("exa"))
|
||||
expect(info).toMatchObject({
|
||||
id: "exa",
|
||||
name: "Exa",
|
||||
methods: [{ type: "key" }, { type: "env", names: ["EXA_API_KEY"] }],
|
||||
capabilities: [{ type: "search", connection: "optional", selected: false }],
|
||||
})
|
||||
const provider = yield* integrations.capability.search.get(Integration.ID.make("exa"))
|
||||
if (!provider) return yield* Effect.die("Expected Exa search provider")
|
||||
expect(
|
||||
yield* provider.execute(
|
||||
{
|
||||
query: "effect typescript",
|
||||
numResults: 3,
|
||||
livecrawl: "preferred",
|
||||
type: "fast",
|
||||
contextMaxCharacters: 2500,
|
||||
},
|
||||
{ credential: Credential.Key.make({ type: "key", key: "exa secret" }) },
|
||||
),
|
||||
).toEqual({ text: "search results" })
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
url: `${SearchExa.endpoint}?exaApiKey=exa+secret`,
|
||||
headers: expect.any(Object),
|
||||
body: {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "web_search_exa",
|
||||
arguments: {
|
||||
query: "effect typescript",
|
||||
type: "fast",
|
||||
numResults: 3,
|
||||
livecrawl: "preferred",
|
||||
contextMaxCharacters: 2500,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("registers Parallel and keeps its credential in the authorization header", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
yield* SearchParallel.Plugin.effect(host({ integration: integrationHost(integrations) }))
|
||||
const provider = yield* integrations.capability.search.get(Integration.ID.make("parallel"))
|
||||
if (!provider) return yield* Effect.die("Expected Parallel search provider")
|
||||
|
||||
const output = yield* provider.execute(
|
||||
{ query: "effect layers" },
|
||||
{
|
||||
sessionID: "ses_parallel",
|
||||
credential: Credential.Key.make({ type: "key", key: "parallel-secret" }),
|
||||
},
|
||||
)
|
||||
expect(output).toEqual({ text: "search results" })
|
||||
expect(requests[0]).toMatchObject({
|
||||
url: SearchParallel.endpoint,
|
||||
headers: { authorization: "Bearer parallel-secret" },
|
||||
body: {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "web_search",
|
||||
arguments: {
|
||||
objective: "effect layers",
|
||||
search_queries: ["effect layers"],
|
||||
session_id: "ses_parallel",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(JSON.stringify(output)).not.toContain("parallel-secret")
|
||||
}),
|
||||
)
|
||||
|
||||
})
|
||||
@@ -0,0 +1,148 @@
|
||||
import { beforeEach, describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Fiber, Layer, Scope } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigSearch } from "@opencode-ai/core/config/search"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Form } from "@opencode-ai/core/form"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Search } from "@opencode-ai/core/search"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
let entries: Config.Entry[] = []
|
||||
const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed(entries) }))
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Search.node, Integration.node, Credential.node, EventV2.node, Form.node]), [
|
||||
[Config.node, config],
|
||||
]),
|
||||
)
|
||||
|
||||
const register = (id: string, connection: "optional" | "required" = "optional") =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const integrationID = Integration.ID.make(id)
|
||||
const calls: { input: Search.Input; credential?: Credential.Value; sessionID?: string }[] = []
|
||||
yield* integrations.transform((draft) => {
|
||||
draft.update(integrationID, (integration) => (integration.name = id.toUpperCase()))
|
||||
draft.capability.search.update({
|
||||
integrationID,
|
||||
capability: { type: "search", connection },
|
||||
execute: (input, context) =>
|
||||
Effect.sync(() => {
|
||||
calls.push({ input, ...context })
|
||||
return { text: `${id}: ${input.query}`, metadata: { id } }
|
||||
}),
|
||||
})
|
||||
})
|
||||
return { integrationID, calls }
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
entries = []
|
||||
})
|
||||
|
||||
describe("Search", () => {
|
||||
it.effect("executes an explicit provider without changing the default", () =>
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* register("exa")
|
||||
const search = yield* Search.Service
|
||||
const integrations = yield* Integration.Service
|
||||
|
||||
expect(yield* search.query({ query: "effect", providerID: provider.integrationID })).toEqual(
|
||||
new Search.Result({
|
||||
providerID: provider.integrationID,
|
||||
text: "exa: effect",
|
||||
metadata: { id: "exa" },
|
||||
}),
|
||||
)
|
||||
expect(yield* integrations.capability.search.selected()).toBeUndefined()
|
||||
expect(provider.calls).toEqual([
|
||||
{
|
||||
input: { query: "effect", providerID: provider.integrationID },
|
||||
credential: undefined,
|
||||
sessionID: undefined,
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses the persisted integration capability selection", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* register("exa")
|
||||
const parallel = yield* register("parallel")
|
||||
const integrations = yield* Integration.Service
|
||||
const search = yield* Search.Service
|
||||
yield* integrations.capability.search.select(parallel.integrationID)
|
||||
|
||||
expect((yield* search.query({ query: "layers" })).providerID).toBe(parallel.integrationID)
|
||||
expect((yield* integrations.get(parallel.integrationID))?.capabilities).toEqual([
|
||||
{ type: "search", connection: "optional", selected: true },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("prefers the location config over the global selection", () =>
|
||||
Effect.gen(function* () {
|
||||
const exa = yield* register("exa")
|
||||
const parallel = yield* register("parallel")
|
||||
const integrations = yield* Integration.Service
|
||||
const search = yield* Search.Service
|
||||
yield* integrations.capability.search.select(exa.integrationID)
|
||||
entries = [
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: new Config.Info({ search: new ConfigSearch.Info({ provider: parallel.integrationID }) }),
|
||||
}),
|
||||
]
|
||||
|
||||
expect((yield* search.query({ query: "configured" })).providerID).toBe(parallel.integrationID)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("serializes concurrent first-use onboarding and persists the answer", () =>
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* register("exa")
|
||||
const search = yield* Search.Service
|
||||
const forms = yield* Form.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const first = yield* search.query({ query: "one", sessionID: "ses_search" }).pipe(Effect.forkChild)
|
||||
const second = yield* search.query({ query: "two", sessionID: "ses_search" }).pipe(Effect.forkChild)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
const pending = yield* forms.list({ sessionID: "ses_search" })
|
||||
expect(pending).toHaveLength(1)
|
||||
const form = pending[0]
|
||||
if (!form) return yield* Effect.die("Expected an onboarding form")
|
||||
yield* forms.reply({ id: form.id, answer: { provider: provider.integrationID } })
|
||||
|
||||
expect((yield* Fiber.join(first)).providerID).toBe(provider.integrationID)
|
||||
expect((yield* Fiber.join(second)).providerID).toBe(provider.integrationID)
|
||||
expect(yield* integrations.capability.search.selected()).toBe(provider.integrationID)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("requires a connection before invoking a required provider", () =>
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* register("private", "required")
|
||||
const search = yield* Search.Service
|
||||
|
||||
expect(
|
||||
yield* search.query({ query: "secret", providerID: provider.integrationID }).pipe(Effect.flip),
|
||||
).toBeInstanceOf(Search.ConnectionRequiredError)
|
||||
expect(provider.calls).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("removes scoped provider registrations", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const scope = yield* Scope.fork(yield* Scope.Scope)
|
||||
const provider = yield* register("temporary").pipe(Scope.provide(scope))
|
||||
expect(yield* integrations.capability.search.get(provider.integrationID)).toBeDefined()
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(yield* integrations.capability.search.get(provider.integrationID)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -30,27 +30,6 @@ const outputStore = Layer.mock(ToolOutputStore.Service, {
|
||||
})
|
||||
const registryLayer = AppNodeBuilder.build(ToolRegistry.node, [[ToolOutputStore.node, outputStore]])
|
||||
const it = testEffect(registryLayer)
|
||||
const codeModeTools: ToolRegistry.CodeModeTools = {
|
||||
opencode: {
|
||||
v2: {
|
||||
health: {
|
||||
get: {
|
||||
_tag: "CodeModeTool" as const,
|
||||
description: "Get server health",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({ healthy: Schema.Boolean }),
|
||||
run: () => Effect.succeed({ healthy: true }),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
const codeModeIt = testEffect(
|
||||
AppNodeBuilder.build(ToolRegistry.node, [
|
||||
[ToolOutputStore.node, outputStore],
|
||||
ToolRegistry.codeModeReplacement(codeModeTools),
|
||||
]),
|
||||
)
|
||||
const identity = {
|
||||
agent: AgentV2.ID.make("build"),
|
||||
assistantMessageID: SessionMessage.ID.make("msg_registry"),
|
||||
@@ -74,50 +53,6 @@ const make = (permission?: string) => {
|
||||
}
|
||||
|
||||
describe("ToolRegistry", () => {
|
||||
codeModeIt.effect("includes host Code Mode trees without hosted tool registration", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
const definitions = yield* toolDefinitions(service)
|
||||
expect(definitions.map((tool) => tool.name)).toEqual(["execute"])
|
||||
expect(definitions[0]?.description).toContain("tools.opencode.v2.health.get")
|
||||
|
||||
expect(
|
||||
yield* executeTool(service, {
|
||||
sessionID,
|
||||
...identity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-opencode-health",
|
||||
name: "execute",
|
||||
input: { code: "return await tools.opencode.v2.health.get({})" },
|
||||
},
|
||||
}),
|
||||
).toEqual({ type: "text", value: '{\n "healthy": true\n}' })
|
||||
}),
|
||||
)
|
||||
|
||||
codeModeIt.effect("keeps host Code Mode trees immutable while merging deferred tools", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
yield* service.register({ echo: make() }, { group: "opencode", deferred: true })
|
||||
|
||||
expect((yield* toolDefinitions(service))[0]?.description).toContain("tools.opencode.echo")
|
||||
expect((yield* toolDefinitions(service))[0]?.description).toContain("tools.opencode.echo")
|
||||
expect(
|
||||
yield* executeTool(service, {
|
||||
sessionID,
|
||||
...identity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-opencode-echo",
|
||||
name: "execute",
|
||||
input: { code: 'return await tools.opencode.echo({ text: "hello" })' },
|
||||
},
|
||||
}),
|
||||
).toEqual({ type: "text", value: '{\n "text": "hello"\n}' })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { beforeEach, describe, expect, test } from "bun:test"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Search } from "@opencode-ai/core/search"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { WebSearchTool } from "@opencode-ai/core/tool/websearch"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
|
||||
import { executeTool, registerToolPlugin, settleTool, toolDefinitions, toolIdentity } from "./lib/tool"
|
||||
|
||||
const webSearchToolNode = makeLocationNode({
|
||||
name: "test/websearch-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(WebSearchTool.Plugin)),
|
||||
deps: [ToolRegistry.toolsNode, PermissionV2.node, LayerNodePlatform.httpClient, WebSearchTool.configNode],
|
||||
deps: [ToolRegistry.toolsNode, PermissionV2.node, Search.node],
|
||||
})
|
||||
|
||||
const sessionID = SessionV2.ID.make("ses_websearch_test")
|
||||
@@ -27,31 +27,13 @@ const payload = (text: string) =>
|
||||
result: { content: [{ type: "text", text }] },
|
||||
})
|
||||
|
||||
describe("WebSearchTool provider selection", () => {
|
||||
describe("WebSearchTool input", () => {
|
||||
test("rejects out-of-range numeric controls", () => {
|
||||
const decode = Schema.decodeUnknownSync(WebSearchTool.Input)
|
||||
expect(() => decode({ query: "x", numResults: 0 })).toThrow()
|
||||
expect(() => decode({ query: "x", numResults: WebSearchTool.MAX_NUM_RESULTS + 1 })).toThrow()
|
||||
expect(() => decode({ query: "x", contextMaxCharacters: WebSearchTool.MAX_CONTEXT_CHARACTERS + 1 })).toThrow()
|
||||
})
|
||||
test("selects a stable provider per session", () => {
|
||||
expect(WebSearchTool.selectProvider(sessionID)).toBe(WebSearchTool.selectProvider(sessionID))
|
||||
})
|
||||
|
||||
test("supports an explicit operational override", () => {
|
||||
expect(WebSearchTool.selectProvider(sessionID, { enableExa: false, enableParallel: false }, "parallel")).toBe(
|
||||
"parallel",
|
||||
)
|
||||
expect(WebSearchTool.selectProvider(sessionID, { enableExa: false, enableParallel: false }, "exa")).toBe("exa")
|
||||
})
|
||||
|
||||
test("prefers Parallel when both explicit flags are enabled", () => {
|
||||
expect(WebSearchTool.selectProvider(sessionID, { enableExa: true, enableParallel: true })).toBe("parallel")
|
||||
})
|
||||
|
||||
test("prefers Exa when only its explicit flag is enabled", () => {
|
||||
expect(WebSearchTool.selectProvider(sessionID, { enableExa: true, enableParallel: false })).toBe("exa")
|
||||
})
|
||||
})
|
||||
|
||||
describe("WebSearchTool MCP response parser", () => {
|
||||
@@ -68,37 +50,16 @@ describe("WebSearchTool MCP response parser", () => {
|
||||
})
|
||||
})
|
||||
|
||||
interface Request {
|
||||
readonly url: string
|
||||
readonly headers: Record<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: Search.QueryInput[] = []
|
||||
let result = new Search.Result({ providerID: Integration.ID.make("exa"), text: "search results" })
|
||||
|
||||
beforeEach(() => {
|
||||
responseBody = payload("search results")
|
||||
makeResponse = () => new Response(responseBody, { status: 200 })
|
||||
assertions.length = 0
|
||||
queries.length = 0
|
||||
result = new Search.Result({ providerID: Integration.ID.make("exa"), text: "search results" })
|
||||
})
|
||||
|
||||
const http = Layer.succeed(
|
||||
HttpClient.HttpClient,
|
||||
HttpClient.make((request) =>
|
||||
Effect.sync(() => {
|
||||
if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`)
|
||||
requests.push({
|
||||
url: request.url,
|
||||
headers: request.headers,
|
||||
body: JSON.parse(new TextDecoder().decode(request.body.body)),
|
||||
})
|
||||
return HttpClientResponse.fromWeb(request, makeResponse())
|
||||
}),
|
||||
),
|
||||
)
|
||||
const permission = Layer.succeed(
|
||||
PermissionV2.Service,
|
||||
PermissionV2.Service.of({
|
||||
@@ -110,45 +71,27 @@ const permission = Layer.succeed(
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const websearchConfig = Layer.succeed(
|
||||
WebSearchTool.ConfigService,
|
||||
WebSearchTool.ConfigService.of({
|
||||
get provider() {
|
||||
return config.provider
|
||||
},
|
||||
get enableExa() {
|
||||
return config.enableExa
|
||||
},
|
||||
get enableParallel() {
|
||||
return config.enableParallel
|
||||
},
|
||||
get exaApiKey() {
|
||||
return config.exaApiKey
|
||||
},
|
||||
get parallelApiKey() {
|
||||
return config.parallelApiKey
|
||||
},
|
||||
const search = Layer.succeed(
|
||||
Search.Service,
|
||||
Search.Service.of({
|
||||
query: (input) =>
|
||||
Effect.sync(() => {
|
||||
queries.push(input)
|
||||
return result
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebSearchTool.configNode, webSearchToolNode]),
|
||||
[
|
||||
[PermissionV2.node, permission],
|
||||
[LayerNodePlatform.httpClient, http],
|
||||
[WebSearchTool.configNode, websearchConfig],
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
],
|
||||
),
|
||||
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, Search.node, webSearchToolNode]), [
|
||||
[PermissionV2.node, permission],
|
||||
[Search.node, search],
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
]),
|
||||
)
|
||||
|
||||
describe("WebSearchTool registration", () => {
|
||||
it.effect("registers websearch, asserts query permission, and calls Exa", () =>
|
||||
it.effect("asserts permission before delegating to Search", () =>
|
||||
Effect.gen(function* () {
|
||||
requests.length = 0
|
||||
assertions.length = 0
|
||||
responseBody = payload("exa results")
|
||||
config = { provider: "exa", enableExa: false, enableParallel: false }
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["websearch"])
|
||||
@@ -158,7 +101,7 @@ describe("WebSearchTool registration", () => {
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-exa",
|
||||
id: "call-search",
|
||||
name: "websearch",
|
||||
input: {
|
||||
query: "effect typescript",
|
||||
@@ -169,7 +112,7 @@ describe("WebSearchTool registration", () => {
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({ type: "text", value: "exa results" })
|
||||
).toEqual({ type: "text", value: "search results" })
|
||||
expect(assertions).toMatchObject([
|
||||
{
|
||||
sessionID,
|
||||
@@ -182,98 +125,50 @@ describe("WebSearchTool registration", () => {
|
||||
livecrawl: "preferred",
|
||||
type: "fast",
|
||||
contextMaxCharacters: 2500,
|
||||
provider: "exa",
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(requests).toEqual([
|
||||
expect(queries).toEqual([
|
||||
{
|
||||
url: WebSearchTool.EXA_URL,
|
||||
headers: expect.any(Object),
|
||||
body: {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "web_search_exa",
|
||||
arguments: {
|
||||
query: "effect typescript",
|
||||
type: "fast",
|
||||
numResults: 3,
|
||||
livecrawl: "preferred",
|
||||
contextMaxCharacters: 2500,
|
||||
},
|
||||
},
|
||||
},
|
||||
sessionID,
|
||||
query: "effect typescript",
|
||||
numResults: 3,
|
||||
livecrawl: "preferred",
|
||||
type: "fast",
|
||||
contextMaxCharacters: 2500,
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("calls Parallel with session ID and keeps bearer credentials out of output", () =>
|
||||
it.effect("keeps provider metadata in structured output", () =>
|
||||
Effect.gen(function* () {
|
||||
requests.length = 0
|
||||
assertions.length = 0
|
||||
responseBody = payload("parallel results")
|
||||
config = { provider: "parallel", enableExa: false, enableParallel: false, parallelApiKey: "parallel-secret" }
|
||||
result = new Search.Result({
|
||||
providerID: Integration.ID.make("parallel"),
|
||||
text: "parallel results",
|
||||
metadata: { requestID: "req_1" },
|
||||
})
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
const settled = yield* settleTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-parallel", name: "websearch", input: { query: "effect layers" } },
|
||||
})
|
||||
|
||||
expect(requests[0]).toMatchObject({
|
||||
url: WebSearchTool.PARALLEL_URL,
|
||||
headers: { authorization: "Bearer parallel-secret" },
|
||||
body: {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "web_search",
|
||||
arguments: { objective: "effect layers", search_queries: ["effect layers"], session_id: sessionID },
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(requests[0]?.body).not.toHaveProperty("params.arguments.model_name")
|
||||
expect(settled).toEqual({
|
||||
expect(
|
||||
yield* settleTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-parallel", name: "websearch", input: { query: "effect layers" } },
|
||||
}),
|
||||
).toEqual({
|
||||
result: { type: "text", value: "parallel results" },
|
||||
output: {
|
||||
structured: { provider: "parallel", text: "parallel results" },
|
||||
structured: { provider: "parallel", text: "parallel results", metadata: { requestID: "req_1" } },
|
||||
content: [{ type: "text", text: "parallel results" }],
|
||||
},
|
||||
})
|
||||
expect(JSON.stringify(settled)).not.toContain("parallel-secret")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps an Exa credential in the transport URL and out of model output", () =>
|
||||
it.effect("uses the concise no-results fallback", () =>
|
||||
Effect.gen(function* () {
|
||||
requests.length = 0
|
||||
assertions.length = 0
|
||||
responseBody = payload("credentialed exa results")
|
||||
config = { provider: "exa", enableExa: false, enableParallel: false, exaApiKey: "exa secret" }
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
const settled = yield* settleTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-exa-key", name: "websearch", input: { query: "effect schema" } },
|
||||
})
|
||||
|
||||
expect(requests[0]?.url).toBe(`${WebSearchTool.EXA_URL}?exaApiKey=exa+secret`)
|
||||
expect(JSON.stringify(settled)).not.toContain("exa secret")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("returns the legacy no-results fallback as concise model text", () =>
|
||||
Effect.gen(function* () {
|
||||
requests.length = 0
|
||||
assertions.length = 0
|
||||
responseBody = ""
|
||||
config = { provider: "exa", enableExa: false, enableParallel: false }
|
||||
result = new Search.Result({ providerID: Integration.ID.make("exa"), text: "" })
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect(
|
||||
@@ -285,39 +180,4 @@ describe("WebSearchTool registration", () => {
|
||||
).toEqual({ type: "text", value: WebSearchTool.NO_RESULTS })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects oversized MCP response bodies", () =>
|
||||
Effect.gen(function* () {
|
||||
requests.length = 0
|
||||
assertions.length = 0
|
||||
let chunksRead = 0
|
||||
let cancelled = false
|
||||
makeResponse = () =>
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
pull(controller) {
|
||||
chunksRead++
|
||||
if (chunksRead === 10) throw new Error("response was not stopped at the byte limit")
|
||||
controller.enqueue(new Uint8Array(64 * 1024))
|
||||
},
|
||||
cancel() {
|
||||
cancelled = true
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
)
|
||||
config = { provider: "exa", enableExa: false, enableParallel: false }
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-large-response", name: "websearch", input: { query: "too much" } },
|
||||
}),
|
||||
).toEqual({ type: "error", value: "Unable to search the web for too much" })
|
||||
expect(chunksRead).toBeLessThan(10)
|
||||
expect(cancelled).toBe(true)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -6,7 +6,12 @@ export type { AISDKHooks } from "./aisdk.js"
|
||||
export type { CatalogDraft, CatalogHooks, CatalogProviderRecord } from "./catalog.js"
|
||||
export type { CommandDraft, CommandHooks } from "./command.js"
|
||||
export type { EventHooks } from "./event.js"
|
||||
export type { IntegrationDraft, IntegrationHooks, IntegrationMethodRegistration } from "./integration.js"
|
||||
export type {
|
||||
IntegrationDraft,
|
||||
IntegrationHooks,
|
||||
IntegrationMethodRegistration,
|
||||
IntegrationSearchCapabilityRegistration,
|
||||
} from "./integration.js"
|
||||
export type { ReferenceDraft, ReferenceHooks } from "./reference.js"
|
||||
export type { SkillDraft, SkillHooks } from "./skill.js"
|
||||
export * as Tool from "./tool.js"
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
IntegrationRef,
|
||||
} from "@opencode-ai/sdk/v2/types"
|
||||
import type { IntegrationApi } from "@opencode-ai/client/effect/api"
|
||||
import type { Search } from "@opencode-ai/schema/search"
|
||||
import type { Effect, Scope } from "effect"
|
||||
import type { TransformHook } from "./registration.js"
|
||||
|
||||
@@ -44,6 +45,18 @@ export type IntegrationMethodRegistration =
|
||||
readonly method: IntegrationEnvMethod
|
||||
}
|
||||
|
||||
export interface IntegrationSearchCapabilityRegistration {
|
||||
readonly integrationID: string
|
||||
readonly capability: {
|
||||
readonly type: "search"
|
||||
readonly connection: "optional" | "required"
|
||||
}
|
||||
readonly execute: (
|
||||
input: Search.Input,
|
||||
context: { readonly credential?: CredentialValue; readonly sessionID?: string },
|
||||
) => Effect.Effect<Search.ProviderOutput, unknown>
|
||||
}
|
||||
|
||||
export interface IntegrationDraft {
|
||||
list(): readonly IntegrationRef[]
|
||||
get(id: string): IntegrationRef | undefined
|
||||
@@ -54,6 +67,13 @@ export interface IntegrationDraft {
|
||||
update(input: IntegrationMethodRegistration): void
|
||||
remove(integrationID: string, method: IntegrationMethod): void
|
||||
}
|
||||
readonly capability: {
|
||||
readonly search: {
|
||||
list(): readonly IntegrationSearchCapabilityRegistration[]
|
||||
update(input: IntegrationSearchCapabilityRegistration): void
|
||||
remove(integrationID: string): void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface IntegrationHooks extends IntegrationApi<unknown> {
|
||||
|
||||
@@ -7,7 +7,12 @@ export type { AISDKHooks } from "./aisdk.js"
|
||||
export type { CatalogDraft, CatalogHooks, CatalogProviderRecord } from "./catalog.js"
|
||||
export type { CommandDraft, CommandHooks } from "./command.js"
|
||||
export type { EventHooks } from "./event.js"
|
||||
export type { IntegrationDraft, IntegrationHooks, IntegrationMethodRegistration } from "./integration.js"
|
||||
export type {
|
||||
IntegrationDraft,
|
||||
IntegrationHooks,
|
||||
IntegrationMethodRegistration,
|
||||
IntegrationSearchCapabilityRegistration,
|
||||
} from "./integration.js"
|
||||
export type { ReferenceDraft, ReferenceHooks } from "./reference.js"
|
||||
export type { SessionHooks } from "./runtime.js"
|
||||
export type { SkillDraft, SkillHooks } from "./skill.js"
|
||||
|
||||
@@ -1,9 +1,32 @@
|
||||
import type { IntegrationApi } from "@opencode-ai/client/promise/api"
|
||||
import type { IntegrationDraft, IntegrationMethodRegistration } from "../effect/integration.js"
|
||||
import type { IntegrationMethodRegistration } from "../effect/integration.js"
|
||||
import type { CredentialValue } from "@opencode-ai/sdk/v2/types"
|
||||
import type { Search } from "@opencode-ai/schema/search"
|
||||
import type { TransformHook } from "./registration.js"
|
||||
|
||||
export type { IntegrationDraft, IntegrationMethodRegistration }
|
||||
export type { IntegrationMethodRegistration }
|
||||
|
||||
export interface IntegrationSearchCapabilityRegistration {
|
||||
readonly integrationID: string
|
||||
readonly capability: {
|
||||
readonly type: "search"
|
||||
readonly connection: "optional" | "required"
|
||||
}
|
||||
readonly execute: (
|
||||
input: Search.Input,
|
||||
context: { readonly credential?: CredentialValue; readonly sessionID?: string; readonly signal: AbortSignal },
|
||||
) => Promise<Search.ProviderOutput>
|
||||
}
|
||||
|
||||
export interface IntegrationDraft extends Omit<import("../effect/integration.js").IntegrationDraft, "capability"> {
|
||||
readonly capability: {
|
||||
readonly search: {
|
||||
list(): readonly IntegrationSearchCapabilityRegistration[]
|
||||
update(input: IntegrationSearchCapabilityRegistration): void
|
||||
remove(integrationID: string): void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface IntegrationHooks extends IntegrationApi {
|
||||
readonly transform: TransformHook<IntegrationDraft>
|
||||
|
||||
@@ -24,6 +24,7 @@ import { ReferenceGroup } from "./groups/reference.js"
|
||||
import { Authorization } from "./middleware/authorization.js"
|
||||
import { LocationGroup } from "./groups/location.js"
|
||||
import { IntegrationGroup } from "./groups/integration.js"
|
||||
import { SearchGroup } from "./groups/search.js"
|
||||
import { McpGroup } from "./groups/mcp.js"
|
||||
import { CredentialGroup } from "./groups/credential.js"
|
||||
import { ProjectGroup } from "./groups/project.js"
|
||||
@@ -38,6 +39,7 @@ type LocationGroups<LocationId extends HttpApiMiddleware.AnyId> =
|
||||
| HttpApiGroup.AddMiddleware<typeof GenerateGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof ProviderGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof IntegrationGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof SearchGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof McpGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof CredentialGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof ProjectGroup, LocationId>
|
||||
@@ -168,6 +170,7 @@ const makeApiFromGroup = <
|
||||
.add(ProjectCopyGroup.middleware(locationMiddleware))
|
||||
.add(VcsGroup.middleware(locationMiddleware))
|
||||
.add(DebugGroup)
|
||||
.add(SearchGroup.middleware(locationMiddleware))
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "opencode HttpApi",
|
||||
|
||||
@@ -44,6 +44,7 @@ export const groupNames = {
|
||||
"server.generate": "generate",
|
||||
"server.provider": "provider",
|
||||
"server.integration": "integration",
|
||||
"server.search": "search",
|
||||
"server.credential": "credential",
|
||||
"server.form": "form",
|
||||
"server.permission": "permission",
|
||||
@@ -63,6 +64,7 @@ export const groupNames = {
|
||||
export const endpointNames = {
|
||||
"session.messages": "list",
|
||||
"integration.connect.key": "connectKey",
|
||||
"integration.capability.select": "selectCapability",
|
||||
"integration.connect.oauth": "connectOauth",
|
||||
"integration.attempt.status": "attemptStatus",
|
||||
"integration.attempt.complete": "attemptComplete",
|
||||
|
||||
@@ -37,6 +37,23 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration")
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("integration.capability.select", "/api/integration/:integrationID/capability", {
|
||||
params: { integrationID: Integration.ID },
|
||||
query: LocationQuery,
|
||||
payload: Schema.Struct({ capability: Schema.Literal("search") }),
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: InvalidRequestError,
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.integration.capability.select",
|
||||
summary: "Select integration capability",
|
||||
description: "Set the default integration for a capability.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("integration.connect.key", "/api/integration/:integrationID/connect/key", {
|
||||
params: { integrationID: Integration.ID },
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Location } from "@opencode-ai/schema/location"
|
||||
import { Search } from "@opencode-ai/schema/search"
|
||||
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 SearchGroup = HttpApiGroup.make("server.search")
|
||||
.add(
|
||||
HttpApiEndpoint.post("search.query", "/api/search", {
|
||||
query: LocationQuery,
|
||||
payload: Schema.Struct(Search.Input.fields),
|
||||
success: Location.response(Search.Result),
|
||||
error: [InvalidRequestError, ServiceUnavailableError],
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.search.query",
|
||||
summary: "Search the web",
|
||||
description:
|
||||
"Run one web search through the selected integration. Specify a provider to override the configured default.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "search",
|
||||
description: "Location-scoped web search routes.",
|
||||
}),
|
||||
)
|
||||
@@ -3,6 +3,7 @@ export * as Form from "./form.js"
|
||||
import { Schema } from "effect"
|
||||
import { ephemeral, inventory } from "./event.js"
|
||||
import { ascending } from "./identifier.js"
|
||||
import { IntegrationID } from "./integration-id.js"
|
||||
import { NonNegativeInt, optional, statics } from "./schema.js"
|
||||
|
||||
const IDSchema = Schema.String.check(Schema.isStartsWith("frm_")).pipe(Schema.brand("Form.ID"))
|
||||
@@ -124,8 +125,15 @@ export const UrlInfo = Schema.Struct({
|
||||
}).annotate({ identifier: "Form.UrlInfo" })
|
||||
export interface UrlInfo extends Schema.Schema.Type<typeof UrlInfo> {}
|
||||
|
||||
export const Info = Schema.Union([FormInfo, UrlInfo]).pipe(Schema.toTaggedUnion("mode"))
|
||||
export type Info = FormInfo | UrlInfo
|
||||
export const IntegrationInfo = Schema.Struct({
|
||||
...InfoBase,
|
||||
mode: Schema.Literal("integration"),
|
||||
integrationID: IntegrationID,
|
||||
}).annotate({ identifier: "Form.IntegrationInfo" })
|
||||
export interface IntegrationInfo extends Schema.Schema.Type<typeof IntegrationInfo> {}
|
||||
|
||||
export const Info = Schema.Union([FormInfo, UrlInfo, IntegrationInfo]).pipe(Schema.toTaggedUnion("mode"))
|
||||
export type Info = FormInfo | UrlInfo | IntegrationInfo
|
||||
|
||||
export const Value = Schema.Union([Schema.String, Schema.Number, Schema.Boolean, Schema.Array(Schema.String)]).annotate(
|
||||
{
|
||||
|
||||
@@ -16,6 +16,7 @@ export { Project } from "./project.js"
|
||||
export { ProjectCopy } from "./project-copy.js"
|
||||
export { Provider } from "./provider.js"
|
||||
export { Reference } from "./reference.js"
|
||||
export { Search } from "./search.js"
|
||||
export { Revert } from "./revert.js"
|
||||
export { Session } from "./session.js"
|
||||
export { Vcs } from "./vcs.js"
|
||||
|
||||
@@ -76,6 +76,16 @@ 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 SearchCapability extends Schema.Schema.Type<typeof SearchCapability> {}
|
||||
export const SearchCapability = Schema.Struct({
|
||||
type: Schema.Literal("search"),
|
||||
connection: Schema.Literals(["optional", "required"]),
|
||||
selected: Schema.Boolean,
|
||||
}).annotate({ identifier: "Integration.SearchCapability" })
|
||||
|
||||
export const Capability = SearchCapability
|
||||
export type Capability = SearchCapability
|
||||
|
||||
const Updated = ephemeral({
|
||||
type: "integration.updated",
|
||||
schema: {},
|
||||
@@ -96,6 +106,7 @@ export class Info extends Schema.Class<Info>("Integration.Info")({
|
||||
id: ID,
|
||||
name: Schema.String,
|
||||
methods: Schema.Array(Method),
|
||||
capabilities: Schema.Array(Capability),
|
||||
connections: Schema.Array(Connection.Info),
|
||||
}) {}
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
export * as Search from "./search.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { IntegrationID } from "./integration-id.js"
|
||||
import { optional, PositiveInt } from "./schema.js"
|
||||
|
||||
export interface Input extends Schema.Schema.Type<typeof Input> {}
|
||||
export const Input = Schema.Struct({
|
||||
query: Schema.String,
|
||||
providerID: IntegrationID.pipe(optional),
|
||||
numResults: PositiveInt.check(Schema.isLessThanOrEqualTo(20)).pipe(optional),
|
||||
livecrawl: Schema.Literals(["fallback", "preferred"]).pipe(optional),
|
||||
type: Schema.Literals(["auto", "fast", "deep"]).pipe(optional),
|
||||
contextMaxCharacters: PositiveInt.check(Schema.isLessThanOrEqualTo(50_000)).pipe(optional),
|
||||
}).annotate({ identifier: "Search.Input" })
|
||||
|
||||
export interface ProviderOutput extends Schema.Schema.Type<typeof ProviderOutput> {}
|
||||
export const ProviderOutput = Schema.Struct({
|
||||
text: Schema.String,
|
||||
metadata: Schema.Json.pipe(optional),
|
||||
}).annotate({ identifier: "Search.ProviderOutput" })
|
||||
|
||||
export class Result extends Schema.Class<Result>("Search.Result")({
|
||||
providerID: IntegrationID,
|
||||
...ProviderOutput.fields,
|
||||
}) {}
|
||||
@@ -20,6 +20,7 @@ export { Provider } from "@opencode-ai/schema/provider"
|
||||
export { Pty } from "@opencode-ai/schema/pty"
|
||||
export { Question } from "@opencode-ai/schema/question"
|
||||
export { Reference } from "@opencode-ai/schema/reference"
|
||||
export { Search } from "@opencode-ai/schema/search"
|
||||
export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema"
|
||||
export { Session } from "@opencode-ai/schema/session"
|
||||
export { SessionInput } from "@opencode-ai/schema/session-input"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Search } from "@opencode-ai/schema/search"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
|
||||
const SDK = await import("../src/index")
|
||||
@@ -8,6 +9,7 @@ const SDK = await import("../src/index")
|
||||
test("re-exports canonical contracts directly from Schema", () => {
|
||||
expect(SDK.Agent).toBe(Agent)
|
||||
expect(SDK.Model).toBe(Model)
|
||||
expect(SDK.Search).toBe(Search)
|
||||
expect(SDK.Session).toBe(Session)
|
||||
expect(Object.keys(SDK).sort()).toEqual([
|
||||
"AbsolutePath",
|
||||
@@ -31,6 +33,7 @@ test("re-exports canonical contracts directly from Schema", () => {
|
||||
"Question",
|
||||
"Reference",
|
||||
"RelativePath",
|
||||
"Search",
|
||||
"Session",
|
||||
"SessionInput",
|
||||
"SessionMessage",
|
||||
|
||||
@@ -258,6 +258,40 @@ it.live(
|
||||
10_000,
|
||||
)
|
||||
|
||||
it.live("embedded client exposes integration-backed search", () =>
|
||||
withEmbedded("opencode-embedded-search-", (fixture) =>
|
||||
Effect.gen(function* () {
|
||||
const opencode = yield* fixture.sdk.OpenCode.create()
|
||||
const providerID = fixture.sdk.Integration.ID.make("embedded-search")
|
||||
yield* opencode.plugin({
|
||||
id: `embedded-search-${crypto.randomUUID()}`,
|
||||
effect: (ctx) =>
|
||||
ctx.integration.transform((draft) => {
|
||||
draft.update(providerID, (integration) => (integration.name = "Embedded search"))
|
||||
draft.capability.search.update({
|
||||
integrationID: providerID,
|
||||
capability: { type: "search", connection: "optional" },
|
||||
execute: (input) =>
|
||||
Effect.succeed({ text: `Found ${input.query}`, metadata: { source: "embedded" } }),
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
const result = yield* opencode.search.query({
|
||||
query: "opencode",
|
||||
providerID,
|
||||
location: location(fixture),
|
||||
})
|
||||
|
||||
expect(result.data).toEqual({
|
||||
providerID,
|
||||
text: "Found opencode",
|
||||
metadata: { source: "embedded" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"Location-owned runner events reach the ready global client",
|
||||
() =>
|
||||
|
||||
@@ -298,6 +298,8 @@ import type {
|
||||
V2IntegrationAttemptCompleteResponses,
|
||||
V2IntegrationAttemptStatusErrors,
|
||||
V2IntegrationAttemptStatusResponses,
|
||||
V2IntegrationCapabilitySelectErrors,
|
||||
V2IntegrationCapabilitySelectResponses,
|
||||
V2IntegrationConnectKeyErrors,
|
||||
V2IntegrationConnectKeyResponses,
|
||||
V2IntegrationConnectOauthErrors,
|
||||
@@ -356,6 +358,8 @@ import type {
|
||||
V2QuestionRequestListResponses,
|
||||
V2ReferenceListErrors,
|
||||
V2ReferenceListResponses,
|
||||
V2SearchQueryErrors,
|
||||
V2SearchQueryResponses,
|
||||
V2SessionActiveErrors,
|
||||
V2SessionActiveResponses,
|
||||
V2SessionBackgroundErrors,
|
||||
@@ -6684,6 +6688,52 @@ export class Provider2 extends HeyApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
export class Capability extends HeyApiClient {
|
||||
/**
|
||||
* Select integration capability
|
||||
*
|
||||
* Set the default integration for a capability.
|
||||
*/
|
||||
public select<ThrowOnError extends boolean = false>(
|
||||
parameters: {
|
||||
integrationID: string
|
||||
location?: {
|
||||
directory?: string | null
|
||||
workspace?: string | null
|
||||
} | null
|
||||
capability?: "search"
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "path", key: "integrationID" },
|
||||
{ in: "query", key: "location" },
|
||||
{ in: "body", key: "capability" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).post<
|
||||
V2IntegrationCapabilitySelectResponses,
|
||||
V2IntegrationCapabilitySelectErrors,
|
||||
ThrowOnError
|
||||
>({
|
||||
url: "/api/integration/{integrationID}/capability",
|
||||
...options,
|
||||
...params,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
...params.headers,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class Connect extends HeyApiClient {
|
||||
/**
|
||||
* Connect with key
|
||||
@@ -6958,6 +7008,11 @@ export class Integration extends HeyApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
private _capability?: Capability
|
||||
get capability(): Capability {
|
||||
return (this._capability ??= new Capability({ client: this.client }))
|
||||
}
|
||||
|
||||
private _connect?: Connect
|
||||
get connect(): Connect {
|
||||
return (this._connect ??= new Connect({ client: this.client }))
|
||||
@@ -8131,6 +8186,56 @@ export class Debug extends HeyApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
export class Search extends HeyApiClient {
|
||||
/**
|
||||
* Search the web
|
||||
*
|
||||
* Run one web search through the selected integration. Specify a provider to override the configured default.
|
||||
*/
|
||||
public query<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
location?: {
|
||||
directory?: string | null
|
||||
workspace?: string | null
|
||||
} | null
|
||||
query?: string
|
||||
providerID?: string
|
||||
numResults?: number
|
||||
livecrawl?: "fallback" | "preferred"
|
||||
type?: "auto" | "fast" | "deep"
|
||||
contextMaxCharacters?: number
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "query", key: "location" },
|
||||
{ in: "body", key: "query" },
|
||||
{ in: "body", key: "providerID" },
|
||||
{ in: "body", key: "numResults" },
|
||||
{ in: "body", key: "livecrawl" },
|
||||
{ in: "body", key: "type" },
|
||||
{ in: "body", key: "contextMaxCharacters" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).post<V2SearchQueryResponses, V2SearchQueryErrors, ThrowOnError>({
|
||||
url: "/api/search",
|
||||
...options,
|
||||
...params,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
...params.headers,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class V2 extends HeyApiClient {
|
||||
private _health?: Health
|
||||
get health(): Health {
|
||||
@@ -8256,6 +8361,11 @@ export class V2 extends HeyApiClient {
|
||||
get debug(): Debug {
|
||||
return (this._debug ??= new Debug({ client: this.client }))
|
||||
}
|
||||
|
||||
private _search?: Search
|
||||
get search(): Search {
|
||||
return (this._search ??= new Search({ client: this.client }))
|
||||
}
|
||||
}
|
||||
|
||||
export class OpencodeClient extends HeyApiClient {
|
||||
|
||||
@@ -1482,7 +1482,7 @@ export type GlobalEvent = {
|
||||
id: string
|
||||
type: "form.created"
|
||||
properties: {
|
||||
form: FormFormInfo | FormUrlInfo
|
||||
form: FormFormInfo | FormUrlInfo | FormIntegrationInfo
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -3520,6 +3520,15 @@ export type FormUrlInfo = {
|
||||
url: string
|
||||
}
|
||||
|
||||
export type FormIntegrationInfo = {
|
||||
id: string
|
||||
sessionID: string
|
||||
title?: string
|
||||
metadata?: FormMetadata
|
||||
mode: "integration"
|
||||
integrationID: string
|
||||
}
|
||||
|
||||
export type FormValue =
|
||||
| string
|
||||
| number
|
||||
@@ -5602,6 +5611,12 @@ export type IntegrationEnvMethod = {
|
||||
names: Array<string>
|
||||
}
|
||||
|
||||
export type IntegrationSearchCapability = {
|
||||
type: "search"
|
||||
connection: "optional" | "required"
|
||||
selected: boolean
|
||||
}
|
||||
|
||||
export type ConnectionCredentialInfo = {
|
||||
type: "credential"
|
||||
id: string
|
||||
@@ -5619,6 +5634,7 @@ export type IntegrationInfo = {
|
||||
id: string
|
||||
name: string
|
||||
methods: Array<IntegrationMethod>
|
||||
capabilities: Array<IntegrationSearchCapability>
|
||||
connections: Array<ConnectionInfo>
|
||||
}
|
||||
|
||||
@@ -6418,7 +6434,7 @@ export type FormCreated = {
|
||||
type: "form.created"
|
||||
location?: LocationRef
|
||||
data: {
|
||||
form: FormFormInfo | FormUrlInfo
|
||||
form: FormFormInfo | FormUrlInfo | FormIntegrationInfo
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6869,6 +6885,12 @@ export type ProjectCopyCopy = {
|
||||
|
||||
export type VcsMode = "working" | "branch"
|
||||
|
||||
export type SearchResult = {
|
||||
providerID: string
|
||||
text: string
|
||||
metadata?: unknown
|
||||
}
|
||||
|
||||
export type EventModelsDevRefreshed = {
|
||||
id: string
|
||||
type: "models-dev.refreshed"
|
||||
@@ -7683,7 +7705,7 @@ export type EventFormCreated = {
|
||||
id: string
|
||||
type: "form.created"
|
||||
properties: {
|
||||
form: FormFormInfo | FormUrlInfo
|
||||
form: FormFormInfo | FormUrlInfo | FormIntegrationInfo
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9747,6 +9769,15 @@ export type FormUrlInfoV2 = {
|
||||
url: string
|
||||
}
|
||||
|
||||
export type FormIntegrationInfoV2 = {
|
||||
id: string
|
||||
sessionID: string
|
||||
title?: string
|
||||
metadata?: FormMetadata
|
||||
mode: "integration"
|
||||
integrationID: string
|
||||
}
|
||||
|
||||
export type FormCreatePayloadV2 = {
|
||||
id?: string | null
|
||||
title?: string
|
||||
@@ -10414,6 +10445,15 @@ export type FormUrlInfo1 = {
|
||||
url: string
|
||||
}
|
||||
|
||||
export type FormIntegrationInfo1 = {
|
||||
id: string
|
||||
sessionID: string
|
||||
title?: string
|
||||
metadata?: FormMetadata1
|
||||
mode: "integration"
|
||||
integrationID: string
|
||||
}
|
||||
|
||||
export type FormCreatedV2 = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -10423,7 +10463,7 @@ export type FormCreatedV2 = {
|
||||
type: "form.created"
|
||||
location?: LocationRefV2
|
||||
data: {
|
||||
form: FormFormInfo1 | FormUrlInfo1
|
||||
form: FormFormInfo1 | FormUrlInfo1 | FormIntegrationInfo1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16446,6 +16486,46 @@ export type V2IntegrationGetResponses = {
|
||||
|
||||
export type V2IntegrationGetResponse = V2IntegrationGetResponses[keyof V2IntegrationGetResponses]
|
||||
|
||||
export type V2IntegrationCapabilitySelectData = {
|
||||
body: {
|
||||
capability: "search"
|
||||
}
|
||||
path: {
|
||||
integrationID: string
|
||||
}
|
||||
query?: {
|
||||
location?: {
|
||||
directory?: string | null
|
||||
workspace?: string | null
|
||||
} | null
|
||||
}
|
||||
url: "/api/integration/{integrationID}/capability"
|
||||
}
|
||||
|
||||
export type V2IntegrationCapabilitySelectErrors = {
|
||||
/**
|
||||
* InvalidRequestError
|
||||
*/
|
||||
400: InvalidRequestError1 | InvalidRequestErrorV2
|
||||
/**
|
||||
* UnauthorizedError
|
||||
*/
|
||||
401: UnauthorizedError
|
||||
}
|
||||
|
||||
export type V2IntegrationCapabilitySelectError =
|
||||
V2IntegrationCapabilitySelectErrors[keyof V2IntegrationCapabilitySelectErrors]
|
||||
|
||||
export type V2IntegrationCapabilitySelectResponses = {
|
||||
/**
|
||||
* <No Content>
|
||||
*/
|
||||
204: void
|
||||
}
|
||||
|
||||
export type V2IntegrationCapabilitySelectResponse =
|
||||
V2IntegrationCapabilitySelectResponses[keyof V2IntegrationCapabilitySelectResponses]
|
||||
|
||||
export type V2IntegrationConnectKeyData = {
|
||||
body: {
|
||||
key: string
|
||||
@@ -16889,7 +16969,7 @@ export type V2FormRequestListResponses = {
|
||||
*/
|
||||
200: {
|
||||
location: LocationInfoV2
|
||||
data: Array<FormFormInfoV2 | FormUrlInfoV2>
|
||||
data: Array<FormFormInfoV2 | FormUrlInfoV2 | FormIntegrationInfoV2>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16926,7 +17006,7 @@ export type V2SessionFormListResponses = {
|
||||
* Success
|
||||
*/
|
||||
200: {
|
||||
data: Array<FormFormInfoV2 | FormUrlInfoV2>
|
||||
data: Array<FormFormInfoV2 | FormUrlInfoV2 | FormIntegrationInfoV2>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16967,7 +17047,7 @@ export type V2SessionFormCreateResponses = {
|
||||
* Success
|
||||
*/
|
||||
200: {
|
||||
data: FormFormInfoV2 | FormUrlInfoV2
|
||||
data: FormFormInfoV2 | FormUrlInfoV2 | FormIntegrationInfoV2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17005,7 +17085,7 @@ export type V2SessionFormGetResponses = {
|
||||
* Success
|
||||
*/
|
||||
200: {
|
||||
data: FormFormInfoV2 | FormUrlInfoV2
|
||||
data: FormFormInfoV2 | FormUrlInfoV2 | FormIntegrationInfoV2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18578,6 +18658,54 @@ export type V2DebugLocationResponses = {
|
||||
|
||||
export type V2DebugLocationResponse = V2DebugLocationResponses[keyof V2DebugLocationResponses]
|
||||
|
||||
export type V2SearchQueryData = {
|
||||
body: {
|
||||
query: string
|
||||
providerID?: string
|
||||
numResults?: number
|
||||
livecrawl?: "fallback" | "preferred"
|
||||
type?: "auto" | "fast" | "deep"
|
||||
contextMaxCharacters?: number
|
||||
}
|
||||
path?: never
|
||||
query?: {
|
||||
location?: {
|
||||
directory?: string | null
|
||||
workspace?: string | null
|
||||
} | null
|
||||
}
|
||||
url: "/api/search"
|
||||
}
|
||||
|
||||
export type V2SearchQueryErrors = {
|
||||
/**
|
||||
* InvalidRequestError
|
||||
*/
|
||||
400: InvalidRequestError1 | InvalidRequestErrorV2
|
||||
/**
|
||||
* UnauthorizedError
|
||||
*/
|
||||
401: UnauthorizedError
|
||||
/**
|
||||
* ServiceUnavailableError
|
||||
*/
|
||||
503: ServiceUnavailableErrorV2
|
||||
}
|
||||
|
||||
export type V2SearchQueryError = V2SearchQueryErrors[keyof V2SearchQueryErrors]
|
||||
|
||||
export type V2SearchQueryResponses = {
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
200: {
|
||||
location: LocationInfoV2
|
||||
data: SearchResult
|
||||
}
|
||||
}
|
||||
|
||||
export type V2SearchQueryResponse = V2SearchQueryResponses[keyof V2SearchQueryResponses]
|
||||
|
||||
export type PtyConnectData = {
|
||||
body?: never
|
||||
path: {
|
||||
|
||||
@@ -20,6 +20,7 @@ import { QuestionHandler } from "./handlers/question"
|
||||
import { ReferenceHandler } from "./handlers/reference"
|
||||
import { LocationHandler } from "./handlers/location"
|
||||
import { IntegrationHandler } from "./handlers/integration"
|
||||
import { SearchHandler } from "./handlers/search"
|
||||
import { McpHandler } from "./handlers/mcp"
|
||||
import { CredentialHandler } from "./handlers/credential"
|
||||
import { ProjectHandler } from "./handlers/project"
|
||||
@@ -38,6 +39,7 @@ export const handlers = Layer.mergeAll(
|
||||
GenerateHandler,
|
||||
ProviderHandler,
|
||||
IntegrationHandler,
|
||||
SearchHandler,
|
||||
McpHandler,
|
||||
CredentialHandler,
|
||||
ProjectHandler,
|
||||
|
||||
@@ -33,6 +33,21 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration"
|
||||
return yield* response(service.get(ctx.params.integrationID))
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"integration.capability.select",
|
||||
Effect.fn(function* (ctx) {
|
||||
const service = yield* Integration.Service
|
||||
const integration = yield* service.get(ctx.params.integrationID)
|
||||
if (!integration?.capabilities.some((capability) => capability.type === ctx.payload.capability)) {
|
||||
return yield* new InvalidRequestError({
|
||||
message: `Capability not found: ${ctx.payload.capability}`,
|
||||
kind: "integration_capability_not_found",
|
||||
})
|
||||
}
|
||||
yield* service.capability.search.select(ctx.params.integrationID)
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"integration.connect.key",
|
||||
Effect.fn(function* (ctx) {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { Search } from "@opencode-ai/core/search"
|
||||
import { InvalidRequestError, ServiceUnavailableError } from "@opencode-ai/protocol/errors"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
import { response } from "../location"
|
||||
|
||||
export const SearchHandler = HttpApiBuilder.group(Api, "server.search", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
return handlers.handle(
|
||||
"search.query",
|
||||
Effect.fn("server.search.query")(function* (request) {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.ready.pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: "5 seconds",
|
||||
orElse: () =>
|
||||
Effect.fail(
|
||||
new ServiceUnavailableError({
|
||||
message: "Search integration initialization timed out",
|
||||
service: "search",
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
const search = yield* Search.Service
|
||||
return yield* response(
|
||||
search.query(request.payload).pipe(
|
||||
Effect.catchTags({
|
||||
"Search.ProviderRequired": () =>
|
||||
new InvalidRequestError({
|
||||
message: "Search provider is required",
|
||||
kind: "search_provider_required",
|
||||
field: "providerID",
|
||||
}),
|
||||
"Search.ProviderNotFound": (error) =>
|
||||
new InvalidRequestError({
|
||||
message: `Search provider not found: ${error.providerID}`,
|
||||
kind: "search_provider_not_found",
|
||||
field: "providerID",
|
||||
}),
|
||||
"Search.ConnectionRequired": (error) =>
|
||||
new InvalidRequestError({
|
||||
message: `Search provider requires a connection: ${error.providerID}`,
|
||||
kind: "search_connection_required",
|
||||
field: "providerID",
|
||||
}),
|
||||
"Search.Cancelled": () =>
|
||||
new InvalidRequestError({ message: "Search cancelled", kind: "search_cancelled" }),
|
||||
"Search.Request": (error) =>
|
||||
new ServiceUnavailableError({
|
||||
message: `Search request failed: ${error.providerID}`,
|
||||
service: error.providerID,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
@@ -10,7 +10,7 @@ import { HealthGroup } from "@opencode-ai/protocol/groups/health"
|
||||
import { Context, Effect, Layer, Option } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import { HttpApi, HttpApiClient } from "effect/unstable/httpapi"
|
||||
import { createServer, type Server } from "node:http"
|
||||
import { createServer } from "node:http"
|
||||
import { ServerAuth } from "./auth"
|
||||
import { createRoutes } from "./routes"
|
||||
|
||||
@@ -18,7 +18,6 @@ export type Options = {
|
||||
readonly hostname: string
|
||||
readonly port: Option.Option<number>
|
||||
readonly password: string
|
||||
readonly replacements?: (server: Server) => LayerNode.Replacements
|
||||
}
|
||||
|
||||
const ReadinessApi = HttpApi.make("readiness").add(HealthGroup)
|
||||
@@ -43,21 +42,19 @@ export const start = Effect.fn("ServerProcess.start")(function* (options: Option
|
||||
})
|
||||
|
||||
function listen(options: Options) {
|
||||
if (Option.isSome(options.port)) return bind(options, options.port.value)
|
||||
if (Option.isSome(options.port)) return bind(options.hostname, options.port.value, options.password)
|
||||
const next = (port: number): ReturnType<typeof bind> =>
|
||||
bind(options, port).pipe(
|
||||
bind(options.hostname, port, options.password).pipe(
|
||||
Effect.catch((error) => (port === 65_535 ? Effect.fail(error) : next(port + 1))),
|
||||
)
|
||||
return next(4096)
|
||||
}
|
||||
|
||||
function bind(options: Options, port: number) {
|
||||
function bind(hostname: string, port: number, password: string) {
|
||||
const server = createServer()
|
||||
return Layer.build(
|
||||
HttpRouter.serve(createRoutes(options.password, options.replacements?.(server)), {
|
||||
disableListenLog: true,
|
||||
}).pipe(
|
||||
Layer.provideMerge(NodeHttpServer.layer(() => server, { port, host: options.hostname })),
|
||||
HttpRouter.serve(createRoutes(password), { disableListenLog: true }).pipe(
|
||||
Layer.provideMerge(NodeHttpServer.layer(() => server, { port, host: hostname })),
|
||||
Layer.provide(AppNodeBuilder.build(LayerNode.group([Credential.node, PermissionSaved.node, Project.node]))),
|
||||
),
|
||||
).pipe(
|
||||
|
||||
@@ -47,28 +47,21 @@ const applicationServices = LayerNode.group([
|
||||
LocationServiceMap.node,
|
||||
])
|
||||
|
||||
export function createRoutes(password?: string, replacements: LayerNode.Replacements = []) {
|
||||
export function createRoutes(password?: string) {
|
||||
return makeRoutes(
|
||||
password
|
||||
? ServerAuth.Config.configLayer({ username: "opencode", password: Option.some(password) })
|
||||
: ServerAuth.Config.layer,
|
||||
undefined,
|
||||
replacements,
|
||||
)
|
||||
}
|
||||
|
||||
export function createEmbeddedRoutes(sdkPlugins?: SdkPlugins.Store, replacements: LayerNode.Replacements = []) {
|
||||
return makeRoutes(
|
||||
ServerAuth.Config.configLayer({ username: "opencode", password: Option.none() }),
|
||||
sdkPlugins,
|
||||
replacements,
|
||||
)
|
||||
export function createEmbeddedRoutes(sdkPlugins?: SdkPlugins.Store) {
|
||||
return makeRoutes(ServerAuth.Config.configLayer({ username: "opencode", password: Option.none() }), sdkPlugins)
|
||||
}
|
||||
|
||||
function makeRoutes<AuthError, AuthServices>(
|
||||
auth: Layer.Layer<ServerAuth.Config, AuthError, AuthServices>,
|
||||
sdkPlugins?: SdkPlugins.Store,
|
||||
hostReplacements: LayerNode.Replacements = [],
|
||||
) {
|
||||
const pluginRuntimeCell = PluginRuntime.makeCell()
|
||||
const replacements: LayerNode.Replacements = [
|
||||
@@ -76,7 +69,6 @@ function makeRoutes<AuthError, AuthServices>(
|
||||
[PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)],
|
||||
[PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)],
|
||||
...(sdkPlugins ? [[SdkPlugins.node, SdkPlugins.layerWithStore(sdkPlugins)] as const] : []),
|
||||
...hostReplacements,
|
||||
]
|
||||
const serviceLayer = simulateEnabled()
|
||||
? Layer.unwrap(
|
||||
|
||||
@@ -53,28 +53,46 @@ export function connectionSummary(integration: IntegrationInfo) {
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
export function DialogIntegration(props: { onConnected?: OnIntegrationConnected } = {}) {
|
||||
export function DialogIntegration(
|
||||
props: { onConnected?: OnIntegrationConnected; integrationID?: string; connectionOnly?: boolean } = {},
|
||||
) {
|
||||
const data = useData()
|
||||
const dialog = useDialog()
|
||||
const { theme } = useTheme()
|
||||
const options = createMemo(() =>
|
||||
integrationOptions(data.location.integration.list() ?? []).map((integration) => {
|
||||
const methods = connectMethods(integration)
|
||||
const connected = integration.connections.length > 0
|
||||
return {
|
||||
title: integration.name,
|
||||
value: integration.id,
|
||||
description: methods.length ? undefined : "Environment only",
|
||||
footer: connectionSummary(integration) || undefined,
|
||||
category: integration.id in INTEGRATION_PRIORITY ? "Popular" : "Services",
|
||||
disabled: methods.length === 0,
|
||||
gutter: connected ? () => <text fg={theme.success}>✓</text> : undefined,
|
||||
onSelect: () =>
|
||||
credentialConnections(integration).length
|
||||
? manageConnections(integration, methods, dialog, props.onConnected)
|
||||
: selectMethod(integration, methods, dialog, props.onConnected),
|
||||
}
|
||||
}),
|
||||
integrationOptions(data.location.integration.list() ?? [])
|
||||
.filter((integration) => props.integrationID === undefined || integration.id === props.integrationID)
|
||||
.map((integration) => {
|
||||
const methods = connectMethods(integration)
|
||||
const connected = integration.connections.length > 0
|
||||
const search = integration.capabilities.find((capability) => capability.type === "search")
|
||||
const credentials = credentialConnections(integration)
|
||||
const description = search?.connection === "optional" ? "API key optional" : undefined
|
||||
const category = search ? "Web search" : undefined
|
||||
return {
|
||||
title: integration.name,
|
||||
value: integration.id,
|
||||
description: description ?? (methods.length === 0 ? "Environment only" : undefined),
|
||||
footer:
|
||||
[connectionSummary(integration), search?.selected ? "Web search default" : undefined]
|
||||
.filter((value) => value !== undefined && value.length > 0)
|
||||
.join(" · ") || undefined,
|
||||
category: category ?? (integration.id in INTEGRATION_PRIORITY ? "Popular" : "Services"),
|
||||
disabled: methods.length === 0 && !search,
|
||||
gutter: connected ? () => <text fg={theme.success}>✓</text> : undefined,
|
||||
onSelect: () => {
|
||||
if (props.connectionOnly) {
|
||||
return credentials.length
|
||||
? manageConnections(integration, methods, dialog, props.onConnected)
|
||||
: selectMethod(integration, methods, dialog, props.onConnected)
|
||||
}
|
||||
if (search) return manageIntegration(integration, methods, search, dialog)
|
||||
return credentials.length
|
||||
? manageConnections(integration, methods, dialog, props.onConnected)
|
||||
: selectMethod(integration, methods, dialog, props.onConnected)
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -90,6 +108,64 @@ export function DialogIntegration(props: { onConnected?: OnIntegrationConnected
|
||||
)
|
||||
}
|
||||
|
||||
function manageIntegration(
|
||||
integration: IntegrationInfo,
|
||||
methods: ConnectMethod[],
|
||||
search: IntegrationInfo["capabilities"][number],
|
||||
dialog: ReturnType<typeof useDialog>,
|
||||
) {
|
||||
const connected = integration.connections.length > 0
|
||||
dialog.replace(() => {
|
||||
const data = useData()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const credentials = credentialConnections(integration)
|
||||
const selectSearch = () => {
|
||||
void sdk.api.integration
|
||||
.selectCapability({
|
||||
integrationID: integration.id,
|
||||
capability: "search",
|
||||
location: location(data),
|
||||
})
|
||||
.then(async () => {
|
||||
await data.location.integration.refresh()
|
||||
toast.show({ variant: "success", message: `${integration.name} is now the web search default` })
|
||||
dialog.clear()
|
||||
})
|
||||
.catch(toast.error)
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title={integration.name}
|
||||
options={[
|
||||
{
|
||||
title: search.selected ? "Web search default" : "Use for web search",
|
||||
value: "search",
|
||||
disabled: search.selected,
|
||||
onSelect:
|
||||
search.connection === "required" && !connected
|
||||
? () => selectMethod(integration, methods, dialog, selectSearch)
|
||||
: selectSearch,
|
||||
},
|
||||
...(methods.length
|
||||
? [
|
||||
{
|
||||
title: credentials.length ? "Manage connections" : "Connect",
|
||||
value: "connect",
|
||||
onSelect: () =>
|
||||
credentials.length
|
||||
? manageConnections(integration, methods, dialog)
|
||||
: selectMethod(integration, methods, dialog),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function manageConnections(
|
||||
integration: IntegrationInfo,
|
||||
methods: ConnectMethod[],
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
AgentV2Info,
|
||||
CommandV2Info,
|
||||
FormFormInfo,
|
||||
FormIntegrationInfo,
|
||||
FormUrlInfo,
|
||||
IntegrationInfo,
|
||||
LocationRef,
|
||||
@@ -30,7 +31,7 @@ export type DataSessionStatus = "idle" | "running"
|
||||
|
||||
const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
|
||||
|
||||
export type FormInfo = FormFormInfo | FormUrlInfo
|
||||
export type FormInfo = FormFormInfo | FormUrlInfo | FormIntegrationInfo
|
||||
|
||||
type LocationData = {
|
||||
agent?: AgentV2Info[]
|
||||
|
||||
@@ -10,6 +10,8 @@ import { useSDK } from "../../context/sdk"
|
||||
import { SplitBorder } from "../../ui/border"
|
||||
import { useTuiConfig } from "../../config"
|
||||
import { useBindings, useOpencodeModeStack } from "../../keymap"
|
||||
import { DialogIntegration } from "../../component/dialog-integration"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
|
||||
const FORM_MODE = "form"
|
||||
|
||||
@@ -131,7 +133,50 @@ function display(field: Field, value: FormValue | undefined) {
|
||||
}
|
||||
|
||||
export function FormPrompt(props: { form: FormInfo }) {
|
||||
return props.form.mode === "url" ? <UrlPrompt form={props.form} /> : <FieldsPrompt form={props.form} />
|
||||
if (props.form.mode === "url") return <UrlPrompt form={props.form} />
|
||||
if (props.form.mode === "integration") return <IntegrationPrompt form={props.form} />
|
||||
return <FieldsPrompt form={props.form} />
|
||||
}
|
||||
|
||||
function IntegrationPrompt(props: { form: FormInfo & { mode: "integration" } }) {
|
||||
const sdk = useSDK()
|
||||
const dialog = useDialog()
|
||||
const { theme } = useTheme()
|
||||
let settled = false
|
||||
|
||||
onMount(() => {
|
||||
dialog.replace(
|
||||
() => (
|
||||
<DialogIntegration
|
||||
integrationID={props.form.integrationID}
|
||||
connectionOnly
|
||||
onConnected={() => {
|
||||
settled = true
|
||||
dialog.clear()
|
||||
void sdk.api.form.reply({ sessionID: props.form.sessionID, formID: props.form.id, answer: {} })
|
||||
}}
|
||||
/>
|
||||
),
|
||||
() => {
|
||||
if (settled) return
|
||||
void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id })
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<box
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
border={["left"]}
|
||||
borderColor={theme.accent}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
>
|
||||
<box gap={1} paddingLeft={2} paddingRight={3} paddingTop={1} paddingBottom={1}>
|
||||
<text fg={theme.text}>{props.form.title ?? "Connect integration"}</text>
|
||||
<text fg={theme.textMuted}>Complete the connection dialog to continue.</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function UrlPrompt(props: { form: FormInfo & { mode: "url" } }) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
|
||||
const integration = (value: Partial<IntegrationInfo> & Pick<IntegrationInfo, "id" | "name">): IntegrationInfo => ({
|
||||
methods: [],
|
||||
capabilities: [],
|
||||
connections: [],
|
||||
...value,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user