Compare commits

..

3 Commits

Author SHA1 Message Date
Aiden Cline b09ebbea3c refactor(cli): own Code Mode host wiring 2026-07-07 10:39:32 -05:00
Aiden Cline 42b63d6660 refactor(core): separate Code Mode host wiring 2026-07-07 09:31:59 -05:00
Aiden Cline e113aad5e0 feat(core): expose server API in Code Mode 2026-07-06 23:06:40 -05:00
72 changed files with 720 additions and 2288 deletions
+1
View File
@@ -101,6 +101,7 @@
"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
View File
@@ -33,6 +33,7 @@
"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:*",
+66
View File
@@ -0,0 +1,66 @@
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),
]),
)
}
+2
View File
@@ -13,6 +13,7 @@ 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"
@@ -63,6 +64,7 @@ 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)
+39
View File
@@ -0,0 +1,39 @@
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")}`,
},
])
})
+26 -58
View File
@@ -370,77 +370,63 @@ 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.capability.select"]>[0]
type Endpoint9_2Request = Parameters<RawClient["server.integration"]["integration.connect.key"]>[0]
export type Endpoint9_2Input = {
readonly integrationID: Endpoint9_2Request["params"]["integrationID"]
readonly location?: Endpoint9_2Request["query"]["location"]
readonly capability: Endpoint9_2Request["payload"]["capability"]
readonly key: Endpoint9_2Request["payload"]["key"]
readonly label?: Endpoint9_2Request["payload"]["label"]
}
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>
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>
type Endpoint9_3Request = Parameters<RawClient["server.integration"]["integration.connect.key"]>[0]
type Endpoint9_3Request = Parameters<RawClient["server.integration"]["integration.connect.oauth"]>[0]
export type Endpoint9_3Input = {
readonly integrationID: Endpoint9_3Request["params"]["integrationID"]
readonly location?: Endpoint9_3Request["query"]["location"]
readonly key: Endpoint9_3Request["payload"]["key"]
readonly methodID: Endpoint9_3Request["payload"]["methodID"]
readonly inputs: Endpoint9_3Request["payload"]["inputs"]
readonly label?: Endpoint9_3Request["payload"]["label"]
}
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.connect.oauth"]>[0]
export 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"]
}
export type Endpoint9_4Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.connect.oauth"]>>
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>
type Endpoint9_4Request = Parameters<RawClient["server.integration"]["integration.attempt.status"]>[0]
export type Endpoint9_4Input = {
readonly attemptID: Endpoint9_4Request["params"]["attemptID"]
readonly location?: Endpoint9_4Request["query"]["location"]
}
export type Endpoint9_4Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.attempt.status"]>>
export type IntegrationAttemptStatusOperation<E = never> = (
input: Endpoint9_4Input,
) => Effect.Effect<Endpoint9_4Output, E>
type Endpoint9_5Request = Parameters<RawClient["server.integration"]["integration.attempt.status"]>[0]
type Endpoint9_5Request = Parameters<RawClient["server.integration"]["integration.attempt.complete"]>[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.status"]>>
export type IntegrationAttemptStatusOperation<E = never> = (
export type Endpoint9_5Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.attempt.complete"]>>
export type IntegrationAttemptCompleteOperation<E = never> = (
input: Endpoint9_5Input,
) => Effect.Effect<Endpoint9_5Output, E>
type Endpoint9_6Request = Parameters<RawClient["server.integration"]["integration.attempt.complete"]>[0]
type Endpoint9_6Request = Parameters<RawClient["server.integration"]["integration.attempt.cancel"]>[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.complete"]>>
export type IntegrationAttemptCompleteOperation<E = never> = (
export type Endpoint9_6Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.attempt.cancel"]>>
export type IntegrationAttemptCancelOperation<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>
@@ -908,23 +894,6 @@ 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>
@@ -952,5 +921,4 @@ export interface AppApi<E = never> {
readonly projectCopy: ProjectCopyApi<E>
readonly vcs: VcsApi<E>
readonly debug: DebugApi<E>
readonly search: SearchApi<E>
}
+32 -72
View File
@@ -450,78 +450,65 @@ 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.capability.select"]>[0]
type Endpoint9_2Request = Parameters<RawClient["server.integration"]["integration.connect.key"]>[0]
type Endpoint9_2Input = {
readonly integrationID: Endpoint9_2Request["params"]["integrationID"]
readonly location?: Endpoint9_2Request["query"]["location"]
readonly capability: Endpoint9_2Request["payload"]["capability"]
readonly key: Endpoint9_2Request["payload"]["key"]
readonly label?: Endpoint9_2Request["payload"]["label"]
}
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_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"]
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"]
}
const Endpoint9_4 = (raw: RawClient["server.integration"]) => (input: Endpoint9_4Input) =>
const Endpoint9_3 = (raw: RawClient["server.integration"]) => (input: Endpoint9_3Input) =>
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_5Request = Parameters<RawClient["server.integration"]["integration.attempt.status"]>[0]
type Endpoint9_5Input = {
readonly attemptID: Endpoint9_5Request["params"]["attemptID"]
readonly location?: Endpoint9_5Request["query"]["location"]
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"]
}
const Endpoint9_5 = (raw: RawClient["server.integration"]) => (input: Endpoint9_5Input) =>
const Endpoint9_4 = (raw: RawClient["server.integration"]) => (input: Endpoint9_4Input) =>
raw["integration.attempt.status"]({
params: { attemptID: input["attemptID"] },
query: { location: input["location"] },
}).pipe(Effect.mapError(mapClientError))
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"]
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"]
}
const Endpoint9_6 = (raw: RawClient["server.integration"]) => (input: Endpoint9_6Input) =>
const Endpoint9_5 = (raw: RawClient["server.integration"]) => (input: Endpoint9_5Input) =>
raw["integration.attempt.complete"]({
params: { attemptID: input["attemptID"] },
query: { location: input["location"] },
payload: { code: input["code"] },
}).pipe(Effect.mapError(mapClientError))
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"]
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"]
}
const Endpoint9_7 = (raw: RawClient["server.integration"]) => (input: Endpoint9_7Input) =>
const Endpoint9_6 = (raw: RawClient["server.integration"]) => (input: Endpoint9_6Input) =>
raw["integration.attempt.cancel"]({
params: { attemptID: input["attemptID"] },
query: { location: input["location"] },
@@ -530,12 +517,11 @@ const Endpoint9_7 = (raw: RawClient["server.integration"]) => (input: Endpoint9_
const adaptGroup9 = (raw: RawClient["server.integration"]) => ({
list: Endpoint9_0(raw),
get: Endpoint9_1(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),
connectKey: Endpoint9_2(raw),
connectOauth: Endpoint9_3(raw),
attemptStatus: Endpoint9_4(raw),
attemptComplete: Endpoint9_5(raw),
attemptCancel: Endpoint9_6(raw),
})
type Endpoint10_0Request = Parameters<RawClient["server.mcp"]["mcp.list"]>[0]
@@ -1085,31 +1071,6 @@ 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"]),
@@ -1137,7 +1098,6 @@ 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 }) =>
-2
View File
@@ -14,7 +14,6 @@ export type {
PluginApi,
ProviderApi,
ReferenceApi,
SearchApi,
SessionApi,
SkillApi,
} from "./api.js"
@@ -37,7 +36,6 @@ 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"
-2
View File
@@ -7,7 +7,6 @@ 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"
@@ -37,7 +36,6 @@ 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,8 +75,6 @@ import type {
IntegrationListOutput,
IntegrationGetInput,
IntegrationGetOutput,
IntegrationSelectCapabilityInput,
IntegrationSelectCapabilityOutput,
IntegrationConnectKeyInput,
IntegrationConnectKeyOutput,
IntegrationConnectOauthInput,
@@ -180,8 +178,6 @@ import type {
VcsDiffInput,
VcsDiffOutput,
DebugLocationOutput,
SearchQueryInput,
SearchQueryOutput,
} from "./types"
import { ClientError } from "./client-error"
@@ -817,19 +813,6 @@ 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>(
{
@@ -1512,28 +1495,6 @@ 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,11 +2279,6 @@ 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 }
@@ -2336,11 +2331,6 @@ 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 }
@@ -2348,16 +2338,6 @@ 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?: {
@@ -2663,14 +2643,6 @@ 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
}
>
}
@@ -2783,14 +2755,6 @@ export type FormListOutput = {
readonly mode: "url"
readonly url: string
}
| {
readonly id: string
readonly sessionID: string
readonly title?: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly mode: "integration"
readonly integrationID: string
}
>
}["data"]
@@ -3487,14 +3451,6 @@ export type FormCreateOutput = {
readonly mode: "url"
readonly url: string
}
| {
readonly id: string
readonly sessionID: string
readonly title?: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly mode: "integration"
readonly integrationID: string
}
}["data"]
export type FormGetInput = {
@@ -3609,14 +3565,6 @@ export type FormGetOutput = {
readonly mode: "url"
readonly url: string
}
| {
readonly id: string
readonly sessionID: string
readonly title?: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly mode: "integration"
readonly integrationID: string
}
}["data"]
export type FormStateInput = {
@@ -5403,14 +5351,6 @@ export type EventSubscribeOutput =
readonly mode: "url"
readonly url: string
}
| {
readonly id: string
readonly sessionID: string
readonly title?: string
readonly metadata?: { readonly [x: string]: unknown }
readonly mode: "integration"
readonly integrationID: string
}
}
}
| {
@@ -6190,66 +6130,3 @@ 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 }
}
-1
View File
@@ -9,7 +9,6 @@ export type {
PluginApi,
ProviderApi,
ReferenceApi,
SearchApi,
SessionApi,
SkillApi,
} from "./api.js"
-29
View File
@@ -31,21 +31,18 @@ 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"])
@@ -53,32 +50,6 @@ 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({
-51
View File
@@ -36,10 +36,6 @@
"name": "event",
"entityType": "tables"
},
{
"name": "integration_capability",
"entityType": "tables"
},
{
"name": "permission",
"entityType": "tables"
@@ -558,46 +554,6 @@
"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,
@@ -1849,13 +1805,6 @@
"table": "event",
"entityType": "pks"
},
{
"columns": ["capability"],
"nameExplicit": false,
"name": "integration_capability_pk",
"table": "integration_capability",
"entityType": "pks"
},
{
"columns": ["id"],
"nameExplicit": false,
-4
View File
@@ -23,7 +23,6 @@ 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"
@@ -103,9 +102,6 @@ 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",
}),
-8
View File
@@ -1,8 +0,0 @@
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
View File
@@ -46,7 +46,6 @@ 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"),
])
@@ -1,18 +0,0 @@
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
-8
View File
@@ -87,14 +87,6 @@ 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,
+6 -15
View File
@@ -67,7 +67,6 @@ export class InvalidFormError extends Schema.TaggedErrorClass<InvalidFormError>(
export type CreateInput =
| (Omit<Form.FormInfo, "id"> & { readonly id?: ID })
| (Omit<Form.UrlInfo, "id"> & { readonly id?: ID })
| (Omit<Form.IntegrationInfo, "id"> & { readonly id?: ID })
export interface ReplyInput {
readonly id: ID
@@ -136,7 +135,10 @@ export const layer = Layer.effect(
title: input.title,
...(input.metadata === undefined ? {} : { metadata: input.metadata }),
}
const form = makeInfo(input, base)
const form: Info =
input.mode === "form"
? { ...base, mode: "form", fields: input.fields }
: { ...base, mode: "url", url: input.url }
const entry: Entry = {
form,
state: { status: "pending" },
@@ -226,9 +228,9 @@ export const locationLayer = layer
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] })
function validateAnswer(form: Info, answer: Answer) {
if (form.mode !== "form") {
if (form.mode === "url") {
if (Object.keys(answer).length === 0) return
return `${form.mode === "url" ? "URL" : "Integration"} forms must be answered with an empty answer`
return "URL forms must be answered with an empty answer"
}
const fields = new Map(form.fields.map((field) => [field.key, field]))
for (const key of Object.keys(answer)) {
@@ -247,17 +249,6 @@ 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]))
+4 -105
View File
@@ -1,7 +1,6 @@
export * as Integration from "./integration"
import { makeLocationNode } from "./effect/app-node"
import { eq } from "drizzle-orm"
import {
Cause,
Clock,
@@ -17,13 +16,10 @@ 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
@@ -64,12 +60,6 @@ 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
@@ -104,15 +94,6 @@ 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
@@ -138,7 +119,6 @@ type Entry = {
ref: Types.DeepMutable<Ref>
methods: Types.DeepMutable<Method>[]
implementations: Map<MethodID, Types.DeepMutable<OAuthImplementation>>
search?: Types.DeepMutable<SearchImplementation>
}
type Data = {
@@ -155,13 +135,6 @@ 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> {
@@ -218,14 +191,6 @@ 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") {}
@@ -257,7 +222,6 @@ 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>())
@@ -317,32 +281,6 @@ 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),
})
@@ -362,24 +300,14 @@ const layer = Layer.effect(
return [...credentials, ...env]
}
const project = (entry: Entry, connections: IntegrationConnection.Info[], selectedSearch: ID | undefined) =>
const project = (entry: Entry, connections: IntegrationConnection.Info[]) =>
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 })))
@@ -441,13 +369,12 @@ 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)), yield* selectedSearch())
return project(entry, resolveConnections(entry, yield* credentials.list(id)))
}),
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) ?? []), selected),
project(entry, resolveConnections(entry, saved.get(entry.ref.id) ?? [])),
).toSorted((a, b) => a.name.localeCompare(b.name))
}),
connection: {
@@ -587,36 +514,8 @@ 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, Database.node, EventV2.node] })
export const node = makeLocationNode({ service: Service, layer, deps: [Credential.node, EventV2.node] })
-9
View File
@@ -1,9 +0,0 @@
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,
})
+2 -3
View File
@@ -34,7 +34,6 @@ 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"
@@ -52,6 +51,7 @@ 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,7 +100,6 @@ const locationServiceNodes = [
AgentV2.node,
CommandV2.node,
Reference.node,
Search.node,
Integration.node,
Catalog.node,
AISDK.node,
-24
View File
@@ -164,7 +164,6 @@ 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),
@@ -270,29 +269,6 @@ 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)),
},
},
})
}),
},
+2 -5
View File
@@ -26,7 +26,6 @@ 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"
@@ -51,7 +50,6 @@ 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"
@@ -78,13 +76,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),
@@ -107,13 +105,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),
)
})
@@ -129,7 +127,6 @@ const pre = [
SkillPlugin.Plugin,
ModelsDevPlugin,
...ProviderPlugins,
...SearchPlugins,
ApplyPatchTool.Plugin,
EditTool.Plugin,
GlobTool.Plugin,
+1 -31
View File
@@ -79,42 +79,12 @@ 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: (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,
},
},
})
}),
),
transform: transform(host.integration),
reload: () => run(host.integration.reload()),
connection: {
active: (id) => Effect.runPromiseWith(context)(host.integration.connection.active(id)),
-43
View File
@@ -1,43 +0,0 @@
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 ?? "" })))
},
})
})
}),
})
@@ -1,126 +0,0 @@
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),
})
})
}),
})
-5
View File
@@ -1,5 +0,0 @@
import { SearchExa } from "./exa"
import { SearchFirecrawl } from "./firecrawl"
import { SearchParallel } from "./parallel"
export const SearchPlugins = [SearchExa.Plugin, SearchFirecrawl.Plugin, SearchParallel.Plugin] as const
-75
View File
@@ -1,75 +0,0 @@
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`)),
}),
)
})
@@ -1,47 +0,0 @@
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 ?? "" }))),
})
})
}),
})
-199
View File
@@ -1,199 +0,0 @@
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] })
+15 -1
View File
@@ -43,15 +43,20 @@ 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: Record<string, Tool.Definition<never> | Record<string, Tool.Definition<never>>> = {}
const tools = cloneTools(options.tools)
for (const [name, registration] of options.registrations) {
const child = definition(name, registration.tool)
const value = Tool.make({
@@ -163,6 +168,15 @@ 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 }
+22 -4
View File
@@ -1,4 +1,5 @@
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"
@@ -9,11 +10,12 @@ import { SessionMessage } from "../session/message"
import { SessionSchema } from "../session/schema"
import { ToolOutputStore } from "../tool-output-store"
import { Wildcard } from "../util/wildcard"
import { ExecuteTool } from "./execute"
import { ExecuteTool, type CodeModeTools } 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"
@@ -51,10 +53,20 @@ 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 = {
@@ -204,11 +216,13 @@ 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 && !whollyDisabled("execute", input.permissions ?? [])
(deferred.size > 0 || Object.keys(tools).length > 0) && !whollyDisabled("execute", input.permissions ?? [])
? ExecuteTool.create({
registrations: deferred,
current: (name) => local.get(name)?.at(-1)?.registration,
tools,
})
: undefined
return {
@@ -241,14 +255,18 @@ 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],
deps: [ToolOutputStore.node, ToolHooks.node, codeModeCatalogNode],
})
export const toolsNode = makeLocationNode({
service: Tools.Service,
layer,
deps: [ToolOutputStore.node, ToolHooks.node],
deps: [ToolOutputStore.node, ToolHooks.node, codeModeCatalogNode],
})
+192 -27
View File
@@ -2,58 +2,197 @@ export * as WebSearchTool from "./websearch"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Schema } from "effect"
import { Integration } from "../integration"
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 { 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 = SearchExa.endpoint
export const PARALLEL_URL = SearchParallel.endpoint
export const EXA_URL = "https://mcp.exa.ai/mcp"
export const PARALLEL_URL = "https://search.parallel.ai/mcp"
export const MAX_NUM_RESULTS = 20
export const MAX_CONTEXT_CHARACTERS = 50_000
export const MAX_RESPONSE_BYTES = SearchMcp.MAX_RESPONSE_BYTES
export const parseResponse = SearchMcp.parseResponse
export const MAX_RESPONSE_BYTES = 256 * 1024
export const description = `Search the web using the user's selected search integration. Use this for current information beyond knowledge cutoff.
/**
* 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.
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.
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.
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 (maximum: ${MAX_NUM_RESULTS})`,
description: `Number of search results to return (default: 8, maximum: ${MAX_NUM_RESULTS})`,
}),
livecrawl: Schema.optional(Schema.Literals(["fallback", "preferred"])).annotate({
description: "Live crawl preference when supported by the selected provider",
description:
"Live crawl mode - 'fallback': use live crawling as backup if cached unavailable, 'preferred': prioritize live crawling (default: 'fallback')",
}),
type: Schema.optional(Schema.Literals(["auto", "fast", "deep"])).annotate({
description: "Search depth preference when supported by the selected provider",
description: "Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search",
}),
contextMaxCharacters: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_CONTEXT_CHARACTERS))).annotate(
{ description: `Maximum context characters (maximum: ${MAX_CONTEXT_CHARACTERS})` },
{
description: `Maximum characters for context string optimized for models (default: 10000, maximum: ${MAX_CONTEXT_CHARACTERS})`,
},
),
})
export const Provider = Schema.Literals(["exa", "parallel"])
export type Provider = typeof Provider.Type
export interface Config {
readonly provider?: Provider
readonly enableExa: boolean
readonly enableParallel: boolean
readonly exaApiKey?: string
readonly parallelApiKey?: string
}
export class ConfigService extends Context.Service<ConfigService, Config>()("@opencode/v2/WebSearchConfig") {}
/** Isolates the retained product environment contract from the generic tool implementation. */
export const defaultConfigLayer = Layer.sync(ConfigService, () =>
ConfigService.of({
provider:
process.env.OPENCODE_WEBSEARCH_PROVIDER === "exa" || process.env.OPENCODE_WEBSEARCH_PROVIDER === "parallel"
? process.env.OPENCODE_WEBSEARCH_PROVIDER
: undefined,
enableExa: 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: Integration.ID,
provider: Provider,
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) =>
@@ -64,28 +203,54 @@ export const Plugin = {
input: Input,
output: Output,
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
execute: (input, context) =>
Effect.gen(function* () {
execute: (input, context) => {
const provider = selectProvider(context.sessionID, config, config.provider)
return Effect.gen(function* () {
yield* permission.assert({
action: name,
resources: [input.query],
save: ["*"],
metadata: input,
metadata: { ...input, provider },
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
})
const result = yield* search.query({ ...input, sessionID: context.sessionID })
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}` } : {}),
},
)
return {
provider: result.providerID,
text: result.text || NO_RESULTS,
metadata: result.metadata,
provider,
text: text ?? NO_RESULTS,
}
}).pipe(
Effect.mapError(
(error) => new ToolFailure({ message: `Unable to search the web for ${input.query}`, error }),
),
),
)
},
}),
),
)
-22
View File
@@ -4,7 +4,6 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Form } from "@opencode-ai/core/form"
import { Integration } from "@opencode-ai/core/integration"
import { SessionSchema } from "@opencode-ai/core/session/schema"
import { testEffect } from "./lib/effect"
@@ -60,27 +59,6 @@ describe("Form", () => {
}),
)
it.effect("uses an empty reply to complete an integration form", () =>
Effect.gen(function* () {
const service = yield* Form.Service
const created = yield* service.create({
sessionID: "ses_test",
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
+1 -1
View File
@@ -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: [], capabilities: [], connections: [] }),
new Integration.Info({ id: openai, name: "OpenAI", methods: [], connections: [] }),
)
yield* Scope.close(scope, Exit.void)
-14
View File
@@ -45,7 +45,6 @@ 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"),
@@ -189,7 +188,6 @@ 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"),
@@ -283,18 +281,6 @@ 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,7 +156,6 @@ describe("ModelsDevPlugin", () => {
names: ["ACME_API_KEY"],
},
],
capabilities: [],
connections: [],
}),
])
-26
View File
@@ -1,7 +1,6 @@
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"
@@ -94,29 +93,4 @@ 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" })
}),
)
})
@@ -1,69 +0,0 @@
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")
}),
)
})
@@ -1,41 +0,0 @@
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),
)
-109
View File
@@ -1,109 +0,0 @@
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")
}),
)
})
-148
View File
@@ -1,148 +0,0 @@
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,6 +30,27 @@ 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"),
@@ -53,6 +74,50 @@ 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
+189 -49
View File
@@ -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 { executeTool, registerToolPlugin, settleTool, toolDefinitions, toolIdentity } from "./lib/tool"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
const webSearchToolNode = makeLocationNode({
name: "test/websearch-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(WebSearchTool.Plugin)),
deps: [ToolRegistry.toolsNode, PermissionV2.node, Search.node],
deps: [ToolRegistry.toolsNode, PermissionV2.node, LayerNodePlatform.httpClient, WebSearchTool.configNode],
})
const sessionID = SessionV2.ID.make("ses_websearch_test")
@@ -27,13 +27,31 @@ const payload = (text: string) =>
result: { content: [{ type: "text", text }] },
})
describe("WebSearchTool input", () => {
describe("WebSearchTool provider selection", () => {
test("rejects out-of-range numeric controls", () => {
const decode = Schema.decodeUnknownSync(WebSearchTool.Input)
expect(() => decode({ query: "x", numResults: 0 })).toThrow()
expect(() => decode({ query: "x", numResults: WebSearchTool.MAX_NUM_RESULTS + 1 })).toThrow()
expect(() => decode({ query: "x", contextMaxCharacters: WebSearchTool.MAX_CONTEXT_CHARACTERS + 1 })).toThrow()
})
test("selects a stable provider per session", () => {
expect(WebSearchTool.selectProvider(sessionID)).toBe(WebSearchTool.selectProvider(sessionID))
})
test("supports an explicit operational override", () => {
expect(WebSearchTool.selectProvider(sessionID, { enableExa: false, enableParallel: false }, "parallel")).toBe(
"parallel",
)
expect(WebSearchTool.selectProvider(sessionID, { enableExa: false, enableParallel: false }, "exa")).toBe("exa")
})
test("prefers Parallel when both explicit flags are enabled", () => {
expect(WebSearchTool.selectProvider(sessionID, { enableExa: true, enableParallel: true })).toBe("parallel")
})
test("prefers Exa when only its explicit flag is enabled", () => {
expect(WebSearchTool.selectProvider(sessionID, { enableExa: true, enableParallel: false })).toBe("exa")
})
})
describe("WebSearchTool MCP response parser", () => {
@@ -50,16 +68,37 @@ 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[] = []
const queries: Search.QueryInput[] = []
let result = new Search.Result({ providerID: Integration.ID.make("exa"), text: "search results" })
let responseBody = payload("search results")
let makeResponse = () => new Response(responseBody, { status: 200 })
let config: WebSearchTool.Config = { enableExa: false, enableParallel: false }
beforeEach(() => {
assertions.length = 0
queries.length = 0
result = new Search.Result({ providerID: Integration.ID.make("exa"), text: "search results" })
responseBody = payload("search results")
makeResponse = () => new Response(responseBody, { status: 200 })
})
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({
@@ -71,27 +110,45 @@ const permission = Layer.succeed(
list: () => Effect.die("unused"),
}),
)
const search = Layer.succeed(
Search.Service,
Search.Service.of({
query: (input) =>
Effect.sync(() => {
queries.push(input)
return result
}),
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 it = testEffect(
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, Search.node, webSearchToolNode]), [
[PermissionV2.node, permission],
[Search.node, search],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
]),
AppNodeBuilder.build(
LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebSearchTool.configNode, webSearchToolNode]),
[
[PermissionV2.node, permission],
[LayerNodePlatform.httpClient, http],
[WebSearchTool.configNode, websearchConfig],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
],
),
)
describe("WebSearchTool registration", () => {
it.effect("asserts permission before delegating to Search", () =>
it.effect("registers websearch, asserts query permission, and calls Exa", () =>
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"])
@@ -101,7 +158,7 @@ describe("WebSearchTool registration", () => {
...toolIdentity,
call: {
type: "tool-call",
id: "call-search",
id: "call-exa",
name: "websearch",
input: {
query: "effect typescript",
@@ -112,7 +169,7 @@ describe("WebSearchTool registration", () => {
},
},
}),
).toEqual({ type: "text", value: "search results" })
).toEqual({ type: "text", value: "exa results" })
expect(assertions).toMatchObject([
{
sessionID,
@@ -125,50 +182,98 @@ describe("WebSearchTool registration", () => {
livecrawl: "preferred",
type: "fast",
contextMaxCharacters: 2500,
provider: "exa",
},
},
])
expect(queries).toEqual([
expect(requests).toEqual([
{
sessionID,
query: "effect typescript",
numResults: 3,
livecrawl: "preferred",
type: "fast",
contextMaxCharacters: 2500,
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,
},
},
},
},
])
}),
)
it.effect("keeps provider metadata in structured output", () =>
it.effect("calls Parallel with session ID and keeps bearer credentials out of output", () =>
Effect.gen(function* () {
result = new Search.Result({
providerID: Integration.ID.make("parallel"),
text: "parallel results",
metadata: { requestID: "req_1" },
})
requests.length = 0
assertions.length = 0
responseBody = payload("parallel results")
config = { provider: "parallel", enableExa: false, enableParallel: false, parallelApiKey: "parallel-secret" }
const registry = yield* ToolRegistry.Service
expect(
yield* settleTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-parallel", name: "websearch", input: { query: "effect layers" } },
}),
).toEqual({
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({
result: { type: "text", value: "parallel results" },
output: {
structured: { provider: "parallel", text: "parallel results", metadata: { requestID: "req_1" } },
structured: { provider: "parallel", text: "parallel results" },
content: [{ type: "text", text: "parallel results" }],
},
})
expect(JSON.stringify(settled)).not.toContain("parallel-secret")
}),
)
it.effect("uses the concise no-results fallback", () =>
it.effect("keeps an Exa credential in the transport URL and out of model output", () =>
Effect.gen(function* () {
result = new Search.Result({ providerID: Integration.ID.make("exa"), text: "" })
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 }
const registry = yield* ToolRegistry.Service
expect(
@@ -180,4 +285,39 @@ 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)
}),
)
})
+1 -6
View File
@@ -6,12 +6,7 @@ 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,
IntegrationSearchCapabilityRegistration,
} from "./integration.js"
export type { IntegrationDraft, IntegrationHooks, IntegrationMethodRegistration } from "./integration.js"
export type { ReferenceDraft, ReferenceHooks } from "./reference.js"
export type { SkillDraft, SkillHooks } from "./skill.js"
export * as Tool from "./tool.js"
@@ -10,7 +10,6 @@ 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"
@@ -45,18 +44,6 @@ 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
@@ -67,13 +54,6 @@ 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> {
+1 -6
View File
@@ -7,12 +7,7 @@ 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,
IntegrationSearchCapabilityRegistration,
} from "./integration.js"
export type { IntegrationDraft, IntegrationHooks, IntegrationMethodRegistration } from "./integration.js"
export type { ReferenceDraft, ReferenceHooks } from "./reference.js"
export type { SessionHooks } from "./runtime.js"
export type { SkillDraft, SkillHooks } from "./skill.js"
+2 -25
View File
@@ -1,32 +1,9 @@
import type { IntegrationApi } from "@opencode-ai/client/promise/api"
import type { IntegrationMethodRegistration } from "../effect/integration.js"
import type { IntegrationDraft, 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 { 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 type { IntegrationDraft, IntegrationMethodRegistration }
export interface IntegrationHooks extends IntegrationApi {
readonly transform: TransformHook<IntegrationDraft>
-3
View File
@@ -24,7 +24,6 @@ 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"
@@ -39,7 +38,6 @@ 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>
@@ -170,7 +168,6 @@ const makeApiFromGroup = <
.add(ProjectCopyGroup.middleware(locationMiddleware))
.add(VcsGroup.middleware(locationMiddleware))
.add(DebugGroup)
.add(SearchGroup.middleware(locationMiddleware))
.annotateMerge(
OpenApi.annotations({
title: "opencode HttpApi",
-2
View File
@@ -44,7 +44,6 @@ export const groupNames = {
"server.generate": "generate",
"server.provider": "provider",
"server.integration": "integration",
"server.search": "search",
"server.credential": "credential",
"server.form": "form",
"server.permission": "permission",
@@ -64,7 +63,6 @@ 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,23 +37,6 @@ 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 },
-31
View File
@@ -1,31 +0,0 @@
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.",
}),
)
+2 -10
View File
@@ -3,7 +3,6 @@ export * as Form from "./form.js"
import { Schema } from "effect"
import { ephemeral, inventory } from "./event.js"
import { ascending } from "./identifier.js"
import { IntegrationID } from "./integration-id.js"
import { NonNegativeInt, optional, statics } from "./schema.js"
const IDSchema = Schema.String.check(Schema.isStartsWith("frm_")).pipe(Schema.brand("Form.ID"))
@@ -125,15 +124,8 @@ export const UrlInfo = Schema.Struct({
}).annotate({ identifier: "Form.UrlInfo" })
export interface UrlInfo extends Schema.Schema.Type<typeof UrlInfo> {}
export const IntegrationInfo = Schema.Struct({
...InfoBase,
mode: Schema.Literal("integration"),
integrationID: IntegrationID,
}).annotate({ identifier: "Form.IntegrationInfo" })
export interface IntegrationInfo extends Schema.Schema.Type<typeof IntegrationInfo> {}
export const Info = Schema.Union([FormInfo, UrlInfo, IntegrationInfo]).pipe(Schema.toTaggedUnion("mode"))
export type Info = FormInfo | UrlInfo | IntegrationInfo
export const Info = Schema.Union([FormInfo, UrlInfo]).pipe(Schema.toTaggedUnion("mode"))
export type Info = FormInfo | UrlInfo
export const Value = Schema.Union([Schema.String, Schema.Number, Schema.Boolean, Schema.Array(Schema.String)]).annotate(
{
-1
View File
@@ -16,7 +16,6 @@ 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"
-11
View File
@@ -76,16 +76,6 @@ export type Method = typeof Method.Type
export const Inputs = Schema.Record(Schema.String, Schema.String).annotate({ identifier: "Integration.Inputs" })
export type Inputs = typeof Inputs.Type
export interface 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: {},
@@ -106,7 +96,6 @@ 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),
}) {}
-26
View File
@@ -1,26 +0,0 @@
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,
}) {}
-1
View File
@@ -20,7 +20,6 @@ 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,7 +1,6 @@
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")
@@ -9,7 +8,6 @@ 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",
@@ -33,7 +31,6 @@ test("re-exports canonical contracts directly from Schema", () => {
"Question",
"Reference",
"RelativePath",
"Search",
"Session",
"SessionInput",
"SessionMessage",
-34
View File
@@ -258,40 +258,6 @@ 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",
() =>
-110
View File
@@ -298,8 +298,6 @@ import type {
V2IntegrationAttemptCompleteResponses,
V2IntegrationAttemptStatusErrors,
V2IntegrationAttemptStatusResponses,
V2IntegrationCapabilitySelectErrors,
V2IntegrationCapabilitySelectResponses,
V2IntegrationConnectKeyErrors,
V2IntegrationConnectKeyResponses,
V2IntegrationConnectOauthErrors,
@@ -358,8 +356,6 @@ import type {
V2QuestionRequestListResponses,
V2ReferenceListErrors,
V2ReferenceListResponses,
V2SearchQueryErrors,
V2SearchQueryResponses,
V2SessionActiveErrors,
V2SessionActiveResponses,
V2SessionBackgroundErrors,
@@ -6688,52 +6684,6 @@ 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
@@ -7008,11 +6958,6 @@ 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 }))
@@ -8186,56 +8131,6 @@ 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 {
@@ -8361,11 +8256,6 @@ 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 {
+8 -136
View File
@@ -1482,7 +1482,7 @@ export type GlobalEvent = {
id: string
type: "form.created"
properties: {
form: FormFormInfo | FormUrlInfo | FormIntegrationInfo
form: FormFormInfo | FormUrlInfo
}
}
| {
@@ -3520,15 +3520,6 @@ 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
@@ -5611,12 +5602,6 @@ export type IntegrationEnvMethod = {
names: Array<string>
}
export type IntegrationSearchCapability = {
type: "search"
connection: "optional" | "required"
selected: boolean
}
export type ConnectionCredentialInfo = {
type: "credential"
id: string
@@ -5634,7 +5619,6 @@ export type IntegrationInfo = {
id: string
name: string
methods: Array<IntegrationMethod>
capabilities: Array<IntegrationSearchCapability>
connections: Array<ConnectionInfo>
}
@@ -6434,7 +6418,7 @@ export type FormCreated = {
type: "form.created"
location?: LocationRef
data: {
form: FormFormInfo | FormUrlInfo | FormIntegrationInfo
form: FormFormInfo | FormUrlInfo
}
}
@@ -6885,12 +6869,6 @@ 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"
@@ -7705,7 +7683,7 @@ export type EventFormCreated = {
id: string
type: "form.created"
properties: {
form: FormFormInfo | FormUrlInfo | FormIntegrationInfo
form: FormFormInfo | FormUrlInfo
}
}
@@ -9769,15 +9747,6 @@ 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
@@ -10445,15 +10414,6 @@ 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
@@ -10463,7 +10423,7 @@ export type FormCreatedV2 = {
type: "form.created"
location?: LocationRefV2
data: {
form: FormFormInfo1 | FormUrlInfo1 | FormIntegrationInfo1
form: FormFormInfo1 | FormUrlInfo1
}
}
@@ -16486,46 +16446,6 @@ 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
@@ -16969,7 +16889,7 @@ export type V2FormRequestListResponses = {
*/
200: {
location: LocationInfoV2
data: Array<FormFormInfoV2 | FormUrlInfoV2 | FormIntegrationInfoV2>
data: Array<FormFormInfoV2 | FormUrlInfoV2>
}
}
@@ -17006,7 +16926,7 @@ export type V2SessionFormListResponses = {
* Success
*/
200: {
data: Array<FormFormInfoV2 | FormUrlInfoV2 | FormIntegrationInfoV2>
data: Array<FormFormInfoV2 | FormUrlInfoV2>
}
}
@@ -17047,7 +16967,7 @@ export type V2SessionFormCreateResponses = {
* Success
*/
200: {
data: FormFormInfoV2 | FormUrlInfoV2 | FormIntegrationInfoV2
data: FormFormInfoV2 | FormUrlInfoV2
}
}
@@ -17085,7 +17005,7 @@ export type V2SessionFormGetResponses = {
* Success
*/
200: {
data: FormFormInfoV2 | FormUrlInfoV2 | FormIntegrationInfoV2
data: FormFormInfoV2 | FormUrlInfoV2
}
}
@@ -18658,54 +18578,6 @@ 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: {
-2
View File
@@ -20,7 +20,6 @@ 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"
@@ -39,7 +38,6 @@ export const handlers = Layer.mergeAll(
GenerateHandler,
ProviderHandler,
IntegrationHandler,
SearchHandler,
McpHandler,
CredentialHandler,
ProjectHandler,
@@ -33,21 +33,6 @@ 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) {
-62
View File
@@ -1,62 +0,0 @@
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,
}),
}),
),
)
}),
)
}),
)
+9 -6
View File
@@ -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 } from "node:http"
import { createServer, type Server } from "node:http"
import { ServerAuth } from "./auth"
import { createRoutes } from "./routes"
@@ -18,6 +18,7 @@ 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)
@@ -42,19 +43,21 @@ export const start = Effect.fn("ServerProcess.start")(function* (options: Option
})
function listen(options: Options) {
if (Option.isSome(options.port)) return bind(options.hostname, options.port.value, options.password)
if (Option.isSome(options.port)) return bind(options, options.port.value)
const next = (port: number): ReturnType<typeof bind> =>
bind(options.hostname, port, options.password).pipe(
bind(options, port).pipe(
Effect.catch((error) => (port === 65_535 ? Effect.fail(error) : next(port + 1))),
)
return next(4096)
}
function bind(hostname: string, port: number, password: string) {
function bind(options: Options, port: number) {
const server = createServer()
return Layer.build(
HttpRouter.serve(createRoutes(password), { disableListenLog: true }).pipe(
Layer.provideMerge(NodeHttpServer.layer(() => server, { port, host: hostname })),
HttpRouter.serve(createRoutes(options.password, options.replacements?.(server)), {
disableListenLog: true,
}).pipe(
Layer.provideMerge(NodeHttpServer.layer(() => server, { port, host: options.hostname })),
Layer.provide(AppNodeBuilder.build(LayerNode.group([Credential.node, PermissionSaved.node, Project.node]))),
),
).pipe(
+11 -3
View File
@@ -47,21 +47,28 @@ const applicationServices = LayerNode.group([
LocationServiceMap.node,
])
export function createRoutes(password?: string) {
export function createRoutes(password?: string, replacements: LayerNode.Replacements = []) {
return makeRoutes(
password
? ServerAuth.Config.configLayer({ username: "opencode", password: Option.some(password) })
: ServerAuth.Config.layer,
undefined,
replacements,
)
}
export function createEmbeddedRoutes(sdkPlugins?: SdkPlugins.Store) {
return makeRoutes(ServerAuth.Config.configLayer({ username: "opencode", password: Option.none() }), sdkPlugins)
export function createEmbeddedRoutes(sdkPlugins?: SdkPlugins.Store, replacements: LayerNode.Replacements = []) {
return makeRoutes(
ServerAuth.Config.configLayer({ username: "opencode", password: Option.none() }),
sdkPlugins,
replacements,
)
}
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 = [
@@ -69,6 +76,7 @@ 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,46 +53,28 @@ export function connectionSummary(integration: IntegrationInfo) {
.join(", ")
}
export function DialogIntegration(
props: { onConnected?: OnIntegrationConnected; integrationID?: string; connectionOnly?: boolean } = {},
) {
export function DialogIntegration(props: { onConnected?: OnIntegrationConnected } = {}) {
const data = useData()
const dialog = useDialog()
const { theme } = useTheme()
const options = createMemo(() =>
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)
},
}
}),
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),
}
}),
)
return (
@@ -108,64 +90,6 @@ export function DialogIntegration(
)
}
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[],
+1 -2
View File
@@ -2,7 +2,6 @@ import type {
AgentV2Info,
CommandV2Info,
FormFormInfo,
FormIntegrationInfo,
FormUrlInfo,
IntegrationInfo,
LocationRef,
@@ -31,7 +30,7 @@ export type DataSessionStatus = "idle" | "running"
const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
export type FormInfo = FormFormInfo | FormUrlInfo | FormIntegrationInfo
export type FormInfo = FormFormInfo | FormUrlInfo
type LocationData = {
agent?: AgentV2Info[]
+1 -46
View File
@@ -10,8 +10,6 @@ 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"
@@ -133,50 +131,7 @@ function display(field: Field, value: FormValue | undefined) {
}
export function FormPrompt(props: { form: FormInfo }) {
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>
)
return props.form.mode === "url" ? <UrlPrompt form={props.form} /> : <FieldsPrompt form={props.form} />
}
function UrlPrompt(props: { form: FormInfo & { mode: "url" } }) {
@@ -9,7 +9,6 @@ import {
const integration = (value: Partial<IntegrationInfo> & Pick<IntegrationInfo, "id" | "name">): IntegrationInfo => ({
methods: [],
capabilities: [],
connections: [],
...value,
})