refactor(search): persist provider in global config

This commit is contained in:
Shoubhit Dash 2026-07-07 19:27:50 +05:30
commit ccad83549d
38 changed files with 736 additions and 589 deletions

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,20 +894,37 @@ 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"]
type Endpoint26_0Request = Parameters<RawClient["server.search"]["search.provider.get"]>[0]
export type Endpoint26_0Input = { readonly location?: Endpoint26_0Request["query"]["location"] }
export type Endpoint26_0Output = EffectValue<ReturnType<RawClient["server.search"]["search.provider.get"]>>
export type SearchProviderOperation<E = never> = (input?: Endpoint26_0Input) => Effect.Effect<Endpoint26_0Output, E>
type Endpoint26_1Request = Parameters<RawClient["server.search"]["search.provider.select"]>[0]
export type Endpoint26_1Input = {
readonly location?: Endpoint26_1Request["query"]["location"]
readonly providerID: Endpoint26_1Request["payload"]["providerID"]
}
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 type Endpoint26_1Output = EffectValue<ReturnType<RawClient["server.search"]["search.provider.select"]>>
export type SearchSelectProviderOperation<E = never> = (
input: Endpoint26_1Input,
) => Effect.Effect<Endpoint26_1Output, E>
type Endpoint26_2Request = Parameters<RawClient["server.search"]["search.query"]>[0]
export type Endpoint26_2Input = {
readonly location?: Endpoint26_2Request["query"]["location"]
readonly query: Endpoint26_2Request["payload"]["query"]
readonly providerID?: Endpoint26_2Request["payload"]["providerID"]
readonly numResults?: Endpoint26_2Request["payload"]["numResults"]
readonly livecrawl?: Endpoint26_2Request["payload"]["livecrawl"]
readonly type?: Endpoint26_2Request["payload"]["type"]
readonly contextMaxCharacters?: Endpoint26_2Request["payload"]["contextMaxCharacters"]
}
export type Endpoint26_2Output = EffectValue<ReturnType<RawClient["server.search"]["search.query"]>>
export type SearchQueryOperation<E = never> = (input: Endpoint26_2Input) => Effect.Effect<Endpoint26_2Output, E>
export interface SearchApi<E = never> {
readonly provider: SearchProviderOperation<E>
readonly selectProvider: SearchSelectProviderOperation<E>
readonly query: SearchQueryOperation<E>
}

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,17 +1071,33 @@ 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"]
type Endpoint26_0Request = Parameters<RawClient["server.search"]["search.provider.get"]>[0]
type Endpoint26_0Input = { readonly location?: Endpoint26_0Request["query"]["location"] }
const Endpoint26_0 = (raw: RawClient["server.search"]) => (input?: Endpoint26_0Input) =>
raw["search.provider.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint26_1Request = Parameters<RawClient["server.search"]["search.provider.select"]>[0]
type Endpoint26_1Input = {
readonly location?: Endpoint26_1Request["query"]["location"]
readonly providerID: Endpoint26_1Request["payload"]["providerID"]
}
const Endpoint26_0 = (raw: RawClient["server.search"]) => (input: Endpoint26_0Input) =>
const Endpoint26_1 = (raw: RawClient["server.search"]) => (input: Endpoint26_1Input) =>
raw["search.provider.select"]({
query: { location: input["location"] },
payload: { providerID: input["providerID"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint26_2Request = Parameters<RawClient["server.search"]["search.query"]>[0]
type Endpoint26_2Input = {
readonly location?: Endpoint26_2Request["query"]["location"]
readonly query: Endpoint26_2Request["payload"]["query"]
readonly providerID?: Endpoint26_2Request["payload"]["providerID"]
readonly numResults?: Endpoint26_2Request["payload"]["numResults"]
readonly livecrawl?: Endpoint26_2Request["payload"]["livecrawl"]
readonly type?: Endpoint26_2Request["payload"]["type"]
readonly contextMaxCharacters?: Endpoint26_2Request["payload"]["contextMaxCharacters"]
}
const Endpoint26_2 = (raw: RawClient["server.search"]) => (input: Endpoint26_2Input) =>
raw["search.query"]({
query: { location: input["location"] },
payload: {
@ -1108,7 +1110,11 @@ const Endpoint26_0 = (raw: RawClient["server.search"]) => (input: Endpoint26_0In
},
}).pipe(Effect.mapError(mapClientError))
const adaptGroup26 = (raw: RawClient["server.search"]) => ({ query: Endpoint26_0(raw) })
const adaptGroup26 = (raw: RawClient["server.search"]) => ({
provider: Endpoint26_0(raw),
selectProvider: Endpoint26_1(raw),
query: Endpoint26_2(raw),
})
const adaptClient = (raw: RawClient) => ({
health: adaptGroup0(raw["server.health"]),

View file

@ -75,8 +75,6 @@ import type {
IntegrationListOutput,
IntegrationGetInput,
IntegrationGetOutput,
IntegrationSelectCapabilityInput,
IntegrationSelectCapabilityOutput,
IntegrationConnectKeyInput,
IntegrationConnectKeyOutput,
IntegrationConnectOauthInput,
@ -180,6 +178,10 @@ import type {
VcsDiffInput,
VcsDiffOutput,
DebugLocationOutput,
SearchProviderInput,
SearchProviderOutput,
SearchSelectProviderInput,
SearchSelectProviderOutput,
SearchQueryInput,
SearchQueryOutput,
} from "./types"
@ -817,19 +819,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>(
{
@ -1513,6 +1502,31 @@ export function make(options: ClientOptions) {
),
},
search: {
provider: (input?: SearchProviderInput, requestOptions?: RequestOptions) =>
request<SearchProviderOutput>(
{
method: "GET",
path: `/api/search/provider`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
selectProvider: (input: SearchSelectProviderInput, requestOptions?: RequestOptions) =>
request<SearchSelectProviderOutput>(
{
method: "POST",
path: `/api/search/provider`,
query: { location: input["location"] },
body: { providerID: input["providerID"] },
successStatus: 204,
declaredStatuses: [400, 503, 401],
empty: true,
},
requestOptions,
),
query: (input: SearchQueryInput, requestOptions?: RequestOptions) =>
request<SearchQueryOutput>(
{

View file

@ -2279,11 +2279,7 @@ 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 search?: { readonly connection: "optional" | "required" }
readonly connections: ReadonlyArray<
| { readonly type: "credential"; readonly id: string; readonly label: string }
| { readonly type: "env"; readonly name: string }
@ -2336,11 +2332,7 @@ 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 search?: { readonly connection: "optional" | "required" }
readonly connections: ReadonlyArray<
| { readonly type: "credential"; readonly id: string; readonly label: string }
| { readonly type: "env"; readonly name: string }
@ -2348,16 +2340,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?: {
@ -6191,6 +6173,30 @@ export type VcsDiffOutput = {
export type DebugLocationOutput = ReadonlyArray<{ readonly directory: string; readonly workspaceID?: string }>
export type SearchProviderInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type SearchProviderOutput = {
readonly location: {
readonly directory: string
readonly workspaceID?: string
readonly project: { readonly id: string; readonly directory: string }
}
readonly data: string | null
}
export type SearchSelectProviderInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly providerID: { readonly providerID: string }["providerID"]
}
export type SearchSelectProviderOutput = void
export type SearchQueryInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined

View file

@ -38,14 +38,13 @@ test("exposes every standard HTTP API group", () => {
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.search)).toEqual(["provider", "selectProvider", "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"])
@ -79,6 +78,31 @@ test("search.query uses the public HTTP contract", async () => {
expect(await request?.json()).toEqual({ query: "opencode", providerID: "exa", numResults: 5 })
})
test("search provider methods use the public HTTP contract", async () => {
const requests: Request[] = []
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
requests.push(request)
if (request.method === "POST") return new Response(null, { status: 204 })
return Response.json({
location: { directory: "/tmp/project", project: { id: "proj_test", directory: "/tmp/project" } },
data: "exa",
})
},
})
expect(await client.search.provider({ location: { directory: "/tmp/project" } })).toMatchObject({ data: "exa" })
await client.search.selectProvider({ providerID: "parallel", location: { directory: "/tmp/project" } })
expect(requests.map((request) => [request.method, request.url])).toEqual([
["GET", "http://localhost:3000/api/search/provider?location%5Bdirectory%5D=%2Ftmp%2Fproject"],
["POST", "http://localhost:3000/api/search/provider?location%5Bdirectory%5D=%2Ftmp%2Fproject"],
])
expect(await requests[1]?.json()).toEqual({ providerID: "parallel" })
})
test("file.read returns binary content from the public HTTP contract", async () => {
let request: Request | undefined
const client = OpenCode.make({

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,

View file

@ -1,4 +1,5 @@
export * as Config from "./config"
export * as ConfigGlobal from "./config/global"
import { makeLocationNode } from "./effect/app-node"
import path from "path"

View file

@ -0,0 +1,50 @@
export * as ConfigGlobal from "./global"
import { randomUUID } from "node:crypto"
import path from "node:path"
import { Context, Effect, Layer } from "effect"
import { applyEdits, modify, type JSONPath } from "jsonc-parser"
import { makeGlobalNode } from "../effect/app-node"
import { FSUtil } from "../fs-util"
import { Global } from "../global"
import { EffectFlock } from "../util/effect-flock"
export interface Interface {
readonly update: (path: JSONPath, value: unknown) => Effect.Effect<void, FSUtil.Error | EffectFlock.LockError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ConfigGlobal") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const flock = yield* EffectFlock.Service
return Service.of({
update: Effect.fn("ConfigGlobal.update")(function* (jsonPath, value) {
yield* flock.withLock(
Effect.gen(function* () {
const existing = yield* Effect.filter(
["opencode.jsonc", "opencode.json"].map((name) => path.join(global.config, name)),
fs.existsSafe,
)
const filepath = existing[0] ?? path.join(global.config, "opencode.json")
const text = (yield* fs.readFileStringSafe(filepath)) ?? "{}"
const next = applyEdits(
text,
modify(text, jsonPath, value, { formattingOptions: { tabSize: 2, insertSpaces: true } }),
)
const temp = `${filepath}.${randomUUID()}.tmp`
yield* fs.writeWithDirs(temp, next)
yield* fs.rename(temp, filepath).pipe(Effect.ensuring(fs.remove(temp).pipe(Effect.ignore)))
}),
"global-config",
)
}),
})
}),
)
export const node = makeGlobalNode({ service: Service, layer, deps: [EffectFlock.node, FSUtil.node, Global.node] })

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"),
])

View file

@ -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

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,

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,
@ -19,11 +18,9 @@ import {
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 +61,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
@ -106,7 +97,7 @@ export type Implementation = OAuthImplementation | KeyImplementation | EnvImplem
export interface SearchImplementation {
readonly integrationID: ID
readonly capability: SearchCapability
readonly connection: Integration.Search["connection"]
readonly execute: (
input: Search.Input,
context: { readonly credential?: Credential.Value; readonly sessionID?: string },
@ -155,12 +146,10 @@ 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
}
search: {
list: () => readonly SearchImplementation[]
update: (implementation: SearchImplementation) => void
remove: (integrationID: ID) => void
}
}
@ -218,13 +207,9 @@ 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>
}
readonly search: {
readonly list: () => Effect.Effect<readonly SearchImplementation[]>
readonly get: (integrationID: ID) => Effect.Effect<SearchImplementation | undefined>
}
}
@ -257,7 +242,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,30 +301,28 @@ 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
},
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
},
},
}),
@ -362,24 +344,15 @@ 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 }] : [],
search: entry.search ? { connection: entry.search.connection } : undefined,
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 +414,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 +559,18 @@ 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, {})
}),
},
search: {
list: Effect.fn("Integration.search.list")(function* () {
return Array.from(state.get().integrations.values()).flatMap((entry) =>
entry.search ? [entry.search as SearchImplementation] : [],
)
}),
get: Effect.fn("Integration.search.get")(function* (integrationID) {
return state.get().integrations.get(integrationID)?.search as SearchImplementation | undefined
}),
},
})
}),
)
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] })

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,
})

View file

@ -165,7 +165,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),
@ -343,9 +342,9 @@ function registerIntegration(draft: Integration.Draft, definition: IntegrationDe
)
}
if (!definition.search) return
draft.capability.search.update({
draft.search.update({
integrationID,
capability: { type: "search", connection: definition.search.connection },
connection: definition.search.connection,
execute: definition.search.execute,
})
}

View file

@ -79,7 +79,6 @@ 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)),

View file

@ -1,10 +1,16 @@
export * as Search from "./search"
import { Search } from "@opencode-ai/schema/search"
import { Context, Effect, Layer, Schema, Semaphore } from "effect"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { Context, Effect, Layer, Schema, Semaphore, Stream } from "effect"
import path from "node:path"
import { Config } from "./config"
import { ConfigGlobal } from "./config/global"
import { ConfigSearch } from "./config/search"
import { makeLocationNode } from "./effect/app-node"
import { EventV2 } from "./event"
import { Form } from "./form"
import { Global } from "./global"
import { Integration } from "./integration"
import { truthy } from "./flag/flag"
@ -50,6 +56,8 @@ export interface QueryInput extends Input {
}
export interface Interface {
readonly selected: () => Effect.Effect<Integration.ID | undefined>
readonly select: (providerID: Integration.ID) => Effect.Effect<void, ProviderNotFoundError>
readonly query: (input: QueryInput) => Effect.Effect<Result, Error>
}
@ -59,10 +67,14 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const configGlobal = yield* ConfigGlobal.Service
const events = yield* EventV2.Service
const forms = yield* Form.Service
const global = yield* Global.Service
const integrations = yield* Integration.Service
const onboarding = Semaphore.makeUnsafe(1)
const decodeOutput = Schema.decodeUnknownEffect(ProviderOutput)
let pending: Integration.ID | undefined
const requireProvider = (
providers: Map<Integration.ID, Integration.SearchImplementation>,
@ -86,6 +98,39 @@ const layer = Layer.effect(
}
})
const globalProvider = Effect.fn("Search.globalProvider")(function* () {
const entries = (yield* config.entries()).filter(
(entry) => entry.type === "document" && entry.path && path.dirname(entry.path) === path.resolve(global.config),
)
return Config.latest(entries, "search")?.provider
})
const selected = Effect.fn("Search.selected")(function* () {
return pending ?? (yield* globalProvider())
})
const save = Effect.fn("Search.save")(function* (providerID: Integration.ID) {
pending = providerID
yield* configGlobal.update(["search"], new ConfigSearch.Info({ provider: providerID })).pipe(
Effect.tapError(() => Effect.sync(() => (pending = undefined))),
Effect.orDie,
)
})
yield* events.subscribe(ConfigSchema.Event.Updated).pipe(
Stream.runForEach(() =>
globalProvider().pipe(
Effect.tap((providerID) =>
Effect.sync(() => {
if (providerID === pending) pending = undefined
}),
),
Effect.ignore,
),
),
Effect.forkScoped,
)
const ask = Effect.fn("Search.ask")(function* (
providers: Map<Integration.ID, Integration.SearchImplementation>,
sessionID: string,
@ -110,8 +155,7 @@ const layer = Layer.effect(
.flatMap((provider) => {
const info = infos.get(provider.integrationID)
if (!info) return []
const disconnected =
provider.capability.connection === "optional" ? "Keyless available" : "Connection required"
const disconnected = provider.connection === "optional" ? "Keyless available" : "Connection required"
return [{ info, description: info.connections.length ? "Connected" : disconnected }]
})
.toSorted((a, b) => a.info.name.localeCompare(b.info.name))
@ -135,7 +179,7 @@ const layer = Layer.effect(
sessionID?: string,
) {
const active = yield* integrations.connection.active(provider.integrationID)
if (active || provider.capability.connection === "optional") return active
if (active || provider.connection === "optional") return active
if (!sessionID) return yield* new ConnectionRequiredError({ providerID: provider.integrationID })
const state = yield* forms
.ask({
@ -152,34 +196,40 @@ const layer = Layer.effect(
return connected
})
const select = Effect.fn("Search.select")(function* (input: QueryInput) {
const resolve = Effect.fn("Search.resolve")(function* (input: QueryInput) {
const providers = new Map(
(yield* integrations.capability.search.list()).map((provider) => [provider.integrationID, provider]),
(yield* integrations.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
const providerID = yield* selected()
const provider = providerID ? providers.get(providerID) : undefined
if (provider) return provider
const sessionID = input.sessionID
if (!sessionID) return yield* new ProviderRequiredError()
return yield* onboarding.withPermit(
Effect.gen(function* () {
const current = yield* integrations.capability.search.selected()
const selected = current ? providers.get(current) : undefined
if (selected) return selected
const current = yield* selected()
const selectedProvider = current ? providers.get(current) : undefined
if (selectedProvider) return selectedProvider
const provider = yield* ask(providers, sessionID)
yield* connect(provider, sessionID)
yield* integrations.capability.search.select(provider.integrationID)
yield* save(provider.integrationID)
return provider
}),
)
})
return Service.of({
selected,
select: Effect.fn("Search.select")(function* (providerID) {
const provider = yield* integrations.search.get(providerID)
if (!provider) return yield* new ProviderNotFoundError({ providerID })
yield* save(providerID)
}),
query: Effect.fn("Search.query")(function* (input) {
const provider = yield* select(input)
const provider = yield* resolve(input)
const connection = yield* connect(provider, input.sessionID)
const credential = connection
? yield* integrations.connection
@ -196,4 +246,8 @@ const layer = Layer.effect(
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [Config.node, Form.node, Integration.node] })
export const node = makeLocationNode({
service: Service,
layer,
deps: [Config.node, ConfigGlobal.node, EventV2.node, Form.node, Global.node, Integration.node],
})

View file

@ -4,6 +4,7 @@ import { describe, expect } from "bun:test"
import { Effect, Fiber, Layer, PubSub, Schema, Stream } from "effect"
import { FastCheck } from "effect/testing"
import { Config } from "@opencode-ai/core/config"
import { ConfigGlobal } from "@opencode-ai/core/config/global"
import { ConfigModel } from "@opencode-ai/core/config/model"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { ConfigProvider } from "@opencode-ai/core/config/provider"
@ -22,6 +23,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
import { parse } from "jsonc-parser"
const it = testEffect(Layer.empty)
const selection = Schema.decodeUnknownSync(ConfigModel.Selection)
@ -58,6 +60,37 @@ const provider = {
}
describe("Config", () => {
it.live("updates the global JSONC config without removing comments", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const global = path.join(tmp.path, "global")
const file = path.join(global, "opencode.jsonc")
yield* Effect.promise(async () => {
await fs.mkdir(global, { recursive: true })
await fs.writeFile(file, `// user config\n{\n "username": "tester"\n}\n`)
})
const config = yield* ConfigGlobal.Service
yield* config.update(["search"], { provider: "exa" })
const text = yield* Effect.promise(() => Bun.file(file).text())
expect(text).toContain("// user config")
expect(parse(text)).toEqual({ username: "tester", search: { provider: "exa" } })
}).pipe(
Effect.provide(
AppNodeBuilder.build(LayerNode.group([ConfigGlobal.node]), [
[Global.node, Global.layerWith({ config: path.join(tmp.path, "global") })],
]),
),
),
),
),
)
it.live("reloads external config and publishes directory updates", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),

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)

View file

@ -50,7 +50,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"),
@ -195,7 +194,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"),
@ -257,9 +255,9 @@ function registerIntegration(draft: Integration.Draft, definition: IntegrationDe
)
}
if (!definition.search) return
draft.capability.search.update({
draft.search.update({
integrationID,
capability: { type: "search", connection: definition.search.connection },
connection: definition.search.connection,
execute: definition.search.execute,
})
}

View file

@ -156,7 +156,6 @@ describe("ModelsDevPlugin", () => {
names: ["ACME_API_KEY"],
},
],
capabilities: [],
connections: [],
}),
])

View file

@ -120,7 +120,7 @@ describe("fromPromise", () => {
name: "Promise Search",
methods: [{ type: "env", names: ["PROMISE_SEARCH_KEY"] }],
})
const provider = yield* integrations.capability.search.get(Integration.ID.make("promise-search"))
const provider = yield* integrations.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" })
}),

View file

@ -36,7 +36,7 @@ describe("built-in search integrations", () => {
expect(yield* integrations.get(Integration.ID.make("test-search"))).toMatchObject({
name: "Test Search",
methods: [{ type: "key", label: "API key" }],
capabilities: [{ type: "search", connection: "required" }],
search: { connection: "required" },
})
yield* registration.dispose
expect(yield* integrations.get(Integration.ID.make("test-search"))).toBeUndefined()
@ -53,9 +53,9 @@ describe("built-in search integrations", () => {
id: "exa",
name: "Exa",
methods: [{ type: "key" }, { type: "env", names: ["EXA_API_KEY"] }],
capabilities: [{ type: "search", connection: "optional", selected: false }],
search: { connection: "optional" },
})
const provider = yield* integrations.capability.search.get(Integration.ID.make("exa"))
const provider = yield* integrations.search.get(Integration.ID.make("exa"))
if (!provider) return yield* Effect.die("Expected Exa search provider")
expect(
yield* provider.execute(
@ -97,7 +97,7 @@ describe("built-in search integrations", () => {
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"))
const provider = yield* integrations.search.get(Integration.ID.make("parallel"))
if (!provider) return yield* Effect.die("Expected Parallel search provider")
const output = yield* provider.execute(

View file

@ -1,22 +1,34 @@
import { beforeEach, describe, expect } from "bun:test"
import { Effect, Exit, Fiber, Layer, Scope } from "effect"
import path from "node:path"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Config } from "@opencode-ai/core/config"
import { ConfigGlobal } from "@opencode-ai/core/config/global"
import { 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 { Global } from "@opencode-ai/core/global"
import { Integration } from "@opencode-ai/core/integration"
import { Search } from "@opencode-ai/core/search"
import { testEffect } from "./lib/effect"
let entries: Config.Entry[] = []
const writes: { path: readonly (string | number)[]; value: unknown }[] = []
const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed(entries) }))
const configGlobal = Layer.succeed(
ConfigGlobal.Service,
ConfigGlobal.Service.of({ update: (path, value) => Effect.sync(() => writes.push({ path, value })) }),
)
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Search.node, Integration.node, Credential.node, EventV2.node, Form.node]), [
[Config.node, config],
]),
AppNodeBuilder.build(
LayerNode.group([Search.node, Integration.node, Credential.node, EventV2.node, Form.node, ConfigGlobal.node]),
[
[Config.node, config],
[ConfigGlobal.node, configGlobal],
],
),
)
const register = (id: string, connection: "optional" | "required" = "optional") =>
@ -26,9 +38,9 @@ const register = (id: string, connection: "optional" | "required" = "optional")
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({
draft.search.update({
integrationID,
capability: { type: "search", connection },
connection,
execute: (input, context) =>
Effect.sync(() => {
calls.push({ input, ...context })
@ -41,6 +53,7 @@ const register = (id: string, connection: "optional" | "required" = "optional")
beforeEach(() => {
entries = []
writes.length = 0
})
describe("Search", () => {
@ -48,7 +61,6 @@ describe("Search", () => {
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({
@ -57,7 +69,7 @@ describe("Search", () => {
metadata: { id: "exa" },
}),
)
expect(yield* integrations.capability.search.selected()).toBeUndefined()
expect(yield* search.selected()).toBeUndefined()
expect(provider.calls).toEqual([
{
input: { query: "effect", providerID: provider.integrationID },
@ -68,18 +80,33 @@ describe("Search", () => {
}),
)
it.effect("uses the persisted integration capability selection", () =>
it.effect("uses and persists the global provider 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)
yield* 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 },
])
expect(yield* search.selected()).toBe(parallel.integrationID)
expect(writes).toEqual([{ path: ["search"], value: new ConfigSearch.Info({ provider: parallel.integrationID }) }])
}),
)
it.effect("reads the selected provider from global config", () =>
Effect.gen(function* () {
const provider = yield* register("exa")
const search = yield* Search.Service
entries = [
new Config.Document({
type: "document",
path: path.join(Global.Path.config, "opencode.json"),
info: new Config.Info({ search: new ConfigSearch.Info({ provider: provider.integrationID }) }),
}),
]
expect(yield* search.selected()).toBe(provider.integrationID)
expect((yield* search.query({ query: "configured" })).providerID).toBe(provider.integrationID)
}),
)
@ -87,9 +114,8 @@ describe("Search", () => {
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)
yield* search.select(exa.integrationID)
entries = [
new Config.Document({
type: "document",
@ -106,7 +132,6 @@ describe("Search", () => {
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
@ -119,7 +144,7 @@ describe("Search", () => {
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)
expect(yield* search.selected()).toBe(provider.integrationID)
}),
)
@ -140,9 +165,9 @@ describe("Search", () => {
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()
expect(yield* integrations.search.get(provider.integrationID)).toBeDefined()
yield* Scope.close(scope, Exit.void)
expect(yield* integrations.capability.search.get(provider.integrationID)).toBeUndefined()
expect(yield* integrations.search.get(provider.integrationID)).toBeUndefined()
}),
)
})

View file

@ -74,6 +74,8 @@ const permission = Layer.succeed(
const search = Layer.succeed(
Search.Service,
Search.Service.of({
selected: () => Effect.succeed(undefined),
select: () => Effect.die("unused"),
query: (input) =>
Effect.sync(() => {
queries.push(input)

View file

@ -64,7 +64,8 @@ export const groupNames = {
export const endpointNames = {
"session.messages": "list",
"integration.connect.key": "connectKey",
"integration.capability.select": "selectCapability",
"search.provider.get": "provider",
"search.provider.select": "selectProvider",
"integration.connect.oauth": "connectOauth",
"integration.attempt.status": "attemptStatus",
"integration.attempt.complete": "attemptComplete",

View file

@ -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 },

View file

@ -1,11 +1,42 @@
import { Integration } from "@opencode-ai/schema/integration"
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 { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, 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.get("search.provider.get", "/api/search/provider", {
query: LocationQuery,
success: Location.response(Schema.UndefinedOr(Integration.ID)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.search.provider.get",
summary: "Get default search provider",
description: "Return the globally selected web search provider.",
}),
),
)
.add(
HttpApiEndpoint.post("search.provider.select", "/api/search/provider", {
query: LocationQuery,
payload: Schema.Struct({ providerID: Integration.ID }),
success: HttpApiSchema.NoContent,
error: [InvalidRequestError, ServiceUnavailableError],
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.search.provider.select",
summary: "Select default search provider",
description: "Persist the global web search provider in the user configuration.",
}),
),
)
.add(
HttpApiEndpoint.post("search.query", "/api/search", {
query: LocationQuery,

View file

@ -76,15 +76,10 @@ 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"),
export interface Search extends Schema.Schema.Type<typeof Search> {}
export const Search = Schema.Struct({
connection: Schema.Literals(["optional", "required"]),
selected: Schema.Boolean,
}).annotate({ identifier: "Integration.SearchCapability" })
export const Capability = SearchCapability
export type Capability = SearchCapability
}).annotate({ identifier: "Integration.Search" })
const Updated = ephemeral({
type: "integration.updated",
@ -106,7 +101,7 @@ export class Info extends Schema.Class<Info>("Integration.Info")({
id: ID,
name: Schema.String,
methods: Schema.Array(Method),
capabilities: Schema.Array(Capability),
search: optional(Search),
connections: Schema.Array(Connection.Info),
}) {}

View file

@ -298,8 +298,6 @@ import type {
V2IntegrationAttemptCompleteResponses,
V2IntegrationAttemptStatusErrors,
V2IntegrationAttemptStatusResponses,
V2IntegrationCapabilitySelectErrors,
V2IntegrationCapabilitySelectResponses,
V2IntegrationConnectKeyErrors,
V2IntegrationConnectKeyResponses,
V2IntegrationConnectOauthErrors,
@ -358,6 +356,10 @@ import type {
V2QuestionRequestListResponses,
V2ReferenceListErrors,
V2ReferenceListResponses,
V2SearchProviderGetErrors,
V2SearchProviderGetResponses,
V2SearchProviderSelectErrors,
V2SearchProviderSelectResponses,
V2SearchQueryErrors,
V2SearchQueryResponses,
V2SessionActiveErrors,
@ -6688,52 +6690,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 +6964,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,6 +8137,72 @@ export class Debug extends HeyApiClient {
}
}
export class Provider3 extends HeyApiClient {
/**
* Get default search provider
*
* Return the globally selected web search provider.
*/
public get<ThrowOnError extends boolean = false>(
parameters?: {
location?: {
directory?: string | null
workspace?: string | null
} | null
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }])
return (options?.client ?? this.client).get<V2SearchProviderGetResponses, V2SearchProviderGetErrors, ThrowOnError>({
url: "/api/search/provider",
...options,
...params,
})
}
/**
* Select default search provider
*
* Persist the global web search provider in the user configuration.
*/
public select<ThrowOnError extends boolean = false>(
parameters?: {
location?: {
directory?: string | null
workspace?: string | null
} | null
providerID?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "location" },
{ in: "body", key: "providerID" },
],
},
],
)
return (options?.client ?? this.client).post<
V2SearchProviderSelectResponses,
V2SearchProviderSelectErrors,
ThrowOnError
>({
url: "/api/search/provider",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
}
export class Search extends HeyApiClient {
/**
* Search the web
@ -8234,6 +8251,11 @@ export class Search extends HeyApiClient {
},
})
}
private _provider?: Provider3
get provider(): Provider3 {
return (this._provider ??= new Provider3({ client: this.client }))
}
}
export class V2 extends HeyApiClient {

View file

@ -5611,10 +5611,8 @@ export type IntegrationEnvMethod = {
names: Array<string>
}
export type IntegrationSearchCapability = {
type: "search"
export type IntegrationSearch = {
connection: "optional" | "required"
selected: boolean
}
export type ConnectionCredentialInfo = {
@ -5634,7 +5632,7 @@ export type IntegrationInfo = {
id: string
name: string
methods: Array<IntegrationMethod>
capabilities: Array<IntegrationSearchCapability>
search?: IntegrationSearch
connections: Array<ConnectionInfo>
}
@ -16486,46 +16484,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
@ -18658,6 +18616,83 @@ export type V2DebugLocationResponses = {
export type V2DebugLocationResponse = V2DebugLocationResponses[keyof V2DebugLocationResponses]
export type V2SearchProviderGetData = {
body?: never
path?: never
query?: {
location?: {
directory?: string | null
workspace?: string | null
} | null
}
url: "/api/search/provider"
}
export type V2SearchProviderGetErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestErrorV2
/**
* UnauthorizedError
*/
401: UnauthorizedError
}
export type V2SearchProviderGetError = V2SearchProviderGetErrors[keyof V2SearchProviderGetErrors]
export type V2SearchProviderGetResponses = {
/**
* Success
*/
200: {
location: LocationInfoV2
data: string | null
}
}
export type V2SearchProviderGetResponse = V2SearchProviderGetResponses[keyof V2SearchProviderGetResponses]
export type V2SearchProviderSelectData = {
body: {
providerID: string
}
path?: never
query?: {
location?: {
directory?: string | null
workspace?: string | null
} | null
}
url: "/api/search/provider"
}
export type V2SearchProviderSelectErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestError1 | InvalidRequestErrorV2
/**
* UnauthorizedError
*/
401: UnauthorizedError
/**
* ServiceUnavailableError
*/
503: ServiceUnavailableErrorV2
}
export type V2SearchProviderSelectError = V2SearchProviderSelectErrors[keyof V2SearchProviderSelectErrors]
export type V2SearchProviderSelectResponses = {
/**
* <No Content>
*/
204: void
}
export type V2SearchProviderSelectResponse = V2SearchProviderSelectResponses[keyof V2SearchProviderSelectResponses]
export type V2SearchQueryData = {
body: {
query: string

View file

@ -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) {

View file

@ -2,61 +2,90 @@ 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 { HttpApiBuilder, HttpApiSchema } 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) =>
const ready = Effect.fn("server.search.ready")(function* () {
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",
}),
),
}),
)
})
return handlers
.handle(
"search.provider.get",
Effect.fn("server.search.provider.get")(function* () {
const search = yield* Search.Service
return yield* response(search.selected())
}),
)
.handle(
"search.provider.select",
Effect.fn("server.search.provider.select")(function* (request) {
yield* ready()
const search = yield* Search.Service
yield* search.select(request.payload.providerID).pipe(
Effect.mapError(
(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,
}),
}),
),
)
}),
)
),
)
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"search.query",
Effect.fn("server.search.query")(function* (request) {
yield* ready()
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,
}),
}),
),
)
}),
)
}),
)

View file

@ -65,7 +65,8 @@ export function DialogIntegration(
.map((integration) => {
const methods = connectMethods(integration)
const connected = integration.connections.length > 0
const search = integration.capabilities.find((capability) => capability.type === "search")
const search = integration.search
const selected = data.location.search.provider() === integration.id
const credentials = credentialConnections(integration)
const description = search?.connection === "optional" ? "API key optional" : undefined
const category = search ? "Web search" : undefined
@ -74,7 +75,7 @@ export function DialogIntegration(
value: integration.id,
description: description ?? (methods.length === 0 ? "Environment only" : undefined),
footer:
[connectionSummary(integration), search?.selected ? "Web search default" : undefined]
[connectionSummary(integration), selected ? "Web search default" : undefined]
.filter((value) => value !== undefined && value.length > 0)
.join(" · ") || undefined,
category: category ?? (integration.id in INTEGRATION_PRIORITY ? "Popular" : "Services"),
@ -111,7 +112,7 @@ export function DialogIntegration(
function manageIntegration(
integration: IntegrationInfo,
methods: ConnectMethod[],
search: IntegrationInfo["capabilities"][number],
search: NonNullable<IntegrationInfo["search"]>,
dialog: ReturnType<typeof useDialog>,
) {
const connected = integration.connections.length > 0
@ -120,15 +121,15 @@ function manageIntegration(
const sdk = useSDK()
const toast = useToast()
const credentials = credentialConnections(integration)
const selected = () => data.location.search.provider() === integration.id
const selectSearch = () => {
void sdk.api.integration
.selectCapability({
integrationID: integration.id,
capability: "search",
void sdk.api.search
.selectProvider({
providerID: integration.id,
location: location(data),
})
.then(async () => {
await data.location.integration.refresh()
await Promise.all([data.location.integration.refresh(), data.location.search.refresh()])
toast.show({ variant: "success", message: `${integration.name} is now the web search default` })
dialog.clear()
})
@ -140,9 +141,9 @@ function manageIntegration(
title={integration.name}
options={[
{
title: search.selected ? "Web search default" : "Use for web search",
title: selected() ? "Web search default" : "Use for web search",
value: "search",
disabled: search.selected,
disabled: selected(),
onSelect:
search.connection === "required" && !connected
? () => selectMethod(integration, methods, dialog, selectSearch)

View file

@ -41,6 +41,7 @@ type LocationData = {
model?: ModelV2Info[]
provider?: ProviderV2Info[]
reference?: ReferenceInfo[]
searchProvider?: string | null
// Currently running shell commands for this location, keyed by shell id. Entries are removed
// once the command exits or is deleted, so this only ever holds in-flight shells.
shell?: Record<string, Shell>
@ -752,6 +753,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
result.location.provider.refresh(event.location),
])
break
case "config.updated":
void result.location.search.refresh(event.location)
break
// Authenticating an MCP integration reconnects its server, which emits mcp.status.changed,
// so the mcp list refreshes here rather than off integration.updated.
case "mcp.status.changed":
@ -974,6 +978,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
setStore("location", key, { ...store.location[key], reference: mutable(result.data) })
},
},
search: {
provider(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.searchProvider ?? undefined
},
async refresh(ref?: LocationRef) {
const result = await sdk.api.search.provider({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(result.location)
setStore("location", key, { ...store.location[key], searchProvider: result.data ?? null })
},
},
skill: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.skill
@ -1014,6 +1028,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
result.location.model.refresh(),
result.location.provider.refresh(),
result.location.reference.refresh(),
result.location.search.refresh(),
result.location.command.refresh(),
result.location.skill.refresh(),
result.shell.refresh(),

View file

@ -37,7 +37,7 @@ import type {
} from "@opencode-ai/sdk/v2"
import { useLocal } from "../../context/local"
import { Locale } from "../../util/locale"
import { selectedWebSearchProvider, webSearchProviderLabel } from "../../util/tool-display"
import { webSearchProviderLabel } from "../../util/tool-display"
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { useSDK } from "../../context/sdk"
import { useEditorContext } from "../../context/editor"
@ -2346,12 +2346,10 @@ function WebFetch(props: ToolProps) {
function WebSearch(props: ToolProps) {
const data = useData()
const [provider, setProvider] = createSignal(
selectedWebSearchProvider(data.location.integration.list() ?? []) ?? stringValue(props.metadata.provider),
)
const [provider, setProvider] = createSignal(data.location.search.provider() ?? stringValue(props.metadata.provider))
createEffect(() => {
if (provider()) return
const next = selectedWebSearchProvider(data.location.integration.list() ?? [])
const next = data.location.search.provider()
if (next) setProvider(next)
})
return (

View file

@ -1,17 +1,9 @@
import type { IntegrationInfo } from "@opencode-ai/sdk/v2"
export function webSearchProviderLabel(provider: unknown) {
if (provider === "parallel") return "Parallel Web Search"
if (provider === "exa") return "Exa Web Search"
return "Web Search"
}
export function selectedWebSearchProvider(integrations: readonly Pick<IntegrationInfo, "id" | "capabilities">[]) {
return integrations.find((integration) =>
integration.capabilities.some((capability) => capability.type === "search" && capability.selected),
)?.id
}
export function toolDisplayMetadata(state: unknown): Record<string, unknown> {
if (!state || typeof state !== "object" || Array.isArray(state)) return {}
if (!("status" in state) || state.status === "pending") return {}

View file

@ -9,7 +9,6 @@ import {
const integration = (value: Partial<IntegrationInfo> & Pick<IntegrationInfo, "id" | "name">): IntegrationInfo => ({
methods: [],
capabilities: [],
connections: [],
...value,
})

View file

@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { selectedWebSearchProvider, toolDisplayMetadata, webSearchProviderLabel } from "../../src/util/tool-display"
import { toolDisplayMetadata, webSearchProviderLabel } from "../../src/util/tool-display"
describe("webSearchProviderLabel", () => {
test("labels known providers", () => {
@ -21,25 +21,6 @@ describe("webSearchProviderLabel", () => {
}
})
describe("selectedWebSearchProvider", () => {
test("returns the selected search integration", () => {
expect(
selectedWebSearchProvider([
{ id: "exa", capabilities: [{ type: "search", connection: "optional", selected: false }] },
{ id: "parallel", capabilities: [{ type: "search", connection: "optional", selected: true }] },
]),
).toBe("parallel")
})
test("ignores unselected and unrelated capabilities", () => {
expect(
selectedWebSearchProvider([
{ id: "exa", capabilities: [{ type: "search", connection: "optional", selected: false }] },
]),
).toBeUndefined()
})
})
describe("toolDisplayMetadata", () => {
test("returns structured metadata for non-pending states", () => {
const structured = { provider: "parallel", numResults: 3 }