From ccad83549d94f225b61860cfa1c687442418758d Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Tue, 7 Jul 2026 19:27:50 +0530 Subject: [PATCH] refactor(search): persist provider in global config --- packages/client/src/effect/api/api.ts | 105 +++++++------- .../client/src/effect/generated/client.ts | 120 ++++++++-------- .../client/src/promise/generated/client.ts | 44 ++++-- .../client/src/promise/generated/types.ts | 46 +++--- packages/client/test/promise.test.ts | 28 +++- packages/core/schema.json | 51 ------- packages/core/src/config.ts | 1 + packages/core/src/config/global.ts | 50 +++++++ packages/core/src/database/migration.gen.ts | 1 - .../20260706133920_integration-search.ts | 18 --- packages/core/src/database/schema.gen.ts | 8 -- packages/core/src/integration.ts | 134 ++++++------------ packages/core/src/integration/sql.ts | 9 -- packages/core/src/plugin/host.ts | 5 +- packages/core/src/plugin/promise.ts | 1 - packages/core/src/search.ts | 82 +++++++++-- packages/core/test/config/config.test.ts | 33 +++++ packages/core/test/integration.test.ts | 2 +- packages/core/test/plugin/host.ts | 6 +- packages/core/test/plugin/models-dev.test.ts | 1 - packages/core/test/plugin/promise.test.ts | 2 +- packages/core/test/plugin/search.test.ts | 8 +- packages/core/test/search.test.ts | 63 +++++--- packages/core/test/tool-websearch.test.ts | 2 + packages/protocol/src/client.ts | 3 +- packages/protocol/src/groups/integration.ts | 17 --- packages/protocol/src/groups/search.ts | 33 ++++- packages/schema/src/integration.ts | 13 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 128 ++++++++++------- packages/sdk/js/src/v2/gen/types.gen.ts | 123 ++++++++++------ packages/server/src/handlers/integration.ts | 15 -- packages/server/src/handlers/search.ts | 121 ++++++++++------ .../tui/src/component/dialog-integration.tsx | 21 +-- packages/tui/src/context/data.tsx | 15 ++ packages/tui/src/routes/session/index.tsx | 8 +- packages/tui/src/util/tool-display.ts | 8 -- .../cli/cmd/tui/integration-options.test.ts | 1 - packages/tui/test/util/tool-display.test.ts | 21 +-- 38 files changed, 747 insertions(+), 600 deletions(-) create mode 100644 packages/core/src/config/global.ts delete mode 100644 packages/core/src/database/migration/20260706133920_integration-search.ts delete mode 100644 packages/core/src/integration/sql.ts diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index e5ced5f774..cd9ced2979 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -370,77 +370,63 @@ export type Endpoint9_1Input = { export type Endpoint9_1Output = EffectValue> export type IntegrationGetOperation = (input: Endpoint9_1Input) => Effect.Effect -type Endpoint9_2Request = Parameters[0] +type Endpoint9_2Request = Parameters[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 -> -export type IntegrationSelectCapabilityOperation = ( - input: Endpoint9_2Input, -) => Effect.Effect +export type Endpoint9_2Output = EffectValue> +export type IntegrationConnectKeyOperation = (input: Endpoint9_2Input) => Effect.Effect -type Endpoint9_3Request = Parameters[0] +type Endpoint9_3Request = Parameters[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> -export type IntegrationConnectKeyOperation = (input: Endpoint9_3Input) => Effect.Effect - -type Endpoint9_4Request = Parameters[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> +export type Endpoint9_3Output = EffectValue> export type IntegrationConnectOauthOperation = ( + input: Endpoint9_3Input, +) => Effect.Effect + +type Endpoint9_4Request = Parameters[0] +export type Endpoint9_4Input = { + readonly attemptID: Endpoint9_4Request["params"]["attemptID"] + readonly location?: Endpoint9_4Request["query"]["location"] +} +export type Endpoint9_4Output = EffectValue> +export type IntegrationAttemptStatusOperation = ( input: Endpoint9_4Input, ) => Effect.Effect -type Endpoint9_5Request = Parameters[0] +type Endpoint9_5Request = Parameters[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> -export type IntegrationAttemptStatusOperation = ( +export type Endpoint9_5Output = EffectValue> +export type IntegrationAttemptCompleteOperation = ( input: Endpoint9_5Input, ) => Effect.Effect -type Endpoint9_6Request = Parameters[0] +type Endpoint9_6Request = Parameters[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> -export type IntegrationAttemptCompleteOperation = ( +export type Endpoint9_6Output = EffectValue> +export type IntegrationAttemptCancelOperation = ( input: Endpoint9_6Input, ) => Effect.Effect -type Endpoint9_7Request = Parameters[0] -export type Endpoint9_7Input = { - readonly attemptID: Endpoint9_7Request["params"]["attemptID"] - readonly location?: Endpoint9_7Request["query"]["location"] -} -export type Endpoint9_7Output = EffectValue> -export type IntegrationAttemptCancelOperation = ( - input: Endpoint9_7Input, -) => Effect.Effect - export interface IntegrationApi { readonly list: IntegrationListOperation readonly get: IntegrationGetOperation - readonly selectCapability: IntegrationSelectCapabilityOperation readonly connectKey: IntegrationConnectKeyOperation readonly connectOauth: IntegrationConnectOauthOperation readonly attemptStatus: IntegrationAttemptStatusOperation @@ -908,20 +894,37 @@ export interface DebugApi { readonly location: DebugLocationOperation } -type Endpoint26_0Request = Parameters[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[0] +export type Endpoint26_0Input = { readonly location?: Endpoint26_0Request["query"]["location"] } +export type Endpoint26_0Output = EffectValue> +export type SearchProviderOperation = (input?: Endpoint26_0Input) => Effect.Effect + +type Endpoint26_1Request = Parameters[0] +export type Endpoint26_1Input = { + readonly location?: Endpoint26_1Request["query"]["location"] + readonly providerID: Endpoint26_1Request["payload"]["providerID"] } -export type Endpoint26_0Output = EffectValue> -export type SearchQueryOperation = (input: Endpoint26_0Input) => Effect.Effect +export type Endpoint26_1Output = EffectValue> +export type SearchSelectProviderOperation = ( + input: Endpoint26_1Input, +) => Effect.Effect + +type Endpoint26_2Request = Parameters[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> +export type SearchQueryOperation = (input: Endpoint26_2Input) => Effect.Effect export interface SearchApi { + readonly provider: SearchProviderOperation + readonly selectProvider: SearchSelectProviderOperation readonly query: SearchQueryOperation } diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index e26055b353..4b3856e203 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -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[0] +type Endpoint9_2Request = Parameters[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[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[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[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[0] -type Endpoint9_5Input = { - readonly attemptID: Endpoint9_5Request["params"]["attemptID"] - readonly location?: Endpoint9_5Request["query"]["location"] +type Endpoint9_4Request = Parameters[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[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[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[0] -type Endpoint9_7Input = { - readonly attemptID: Endpoint9_7Request["params"]["attemptID"] - readonly location?: Endpoint9_7Request["query"]["location"] +type Endpoint9_6Request = Parameters[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[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[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[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[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[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"]), diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 32efe4a95b..8a75679779 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -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( - { - 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( { @@ -1513,6 +1502,31 @@ export function make(options: ClientOptions) { ), }, search: { + provider: (input?: SearchProviderInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/search/provider`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + selectProvider: (input: SearchSelectProviderInput, requestOptions?: RequestOptions) => + request( + { + 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( { diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 0731a86899..3e78b520be 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -2279,11 +2279,7 @@ export type IntegrationListOutput = { | { readonly type: "key"; readonly label?: string } | { readonly type: "env"; readonly names: ReadonlyArray } > - 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 } > - 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 diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index 593b17b784..3a7efe570f 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -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({ diff --git a/packages/core/schema.json b/packages/core/schema.json index 11707ed806..d419909444 100644 --- a/packages/core/schema.json +++ b/packages/core/schema.json @@ -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, diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index f50bdc0b7f..9713921841 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -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" diff --git a/packages/core/src/config/global.ts b/packages/core/src/config/global.ts new file mode 100644 index 0000000000..efb375b911 --- /dev/null +++ b/packages/core/src/config/global.ts @@ -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 +} + +export class Service extends Context.Service()("@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] }) diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 792de1dc5e..99ca206c1f 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -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"), ]) diff --git a/packages/core/src/database/migration/20260706133920_integration-search.ts b/packages/core/src/database/migration/20260706133920_integration-search.ts deleted file mode 100644 index fb7709be63..0000000000 --- a/packages/core/src/database/migration/20260706133920_integration-search.ts +++ /dev/null @@ -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 diff --git a/packages/core/src/database/schema.gen.ts b/packages/core/src/database/schema.gen.ts index 78346aea58..0bb6050bf6 100644 --- a/packages/core/src/database/schema.gen.ts +++ b/packages/core/src/database/schema.gen.ts @@ -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, diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index 959e7b644c..5990d93fa7 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -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 - -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 { /** Cancels an attempt and releases its resources. */ readonly cancel: (attemptID: AttemptID) => Effect.Effect } - readonly capability: { - readonly search: { - readonly list: () => Effect.Effect - readonly get: (integrationID: ID) => Effect.Effect - readonly selected: () => Effect.Effect - readonly select: (integrationID: ID) => Effect.Effect - } + readonly search: { + readonly list: () => Effect.Effect + readonly get: (integrationID: ID) => Effect.Effect } } @@ -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()) @@ -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>(), - } - if (!draft.integrations.has(implementation.integrationID)) { - draft.integrations.set(implementation.integrationID, current) - } - current.search = implementation as Types.DeepMutable - }, - 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>(), + } + if (!draft.integrations.has(implementation.integrationID)) { + draft.integrations.set(implementation.integrationID, current) + } + current.search = implementation as Types.DeepMutable + }, + 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 = (effect: Effect.Effect) => 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] }) diff --git a/packages/core/src/integration/sql.ts b/packages/core/src/integration/sql.ts deleted file mode 100644 index 996247afe0..0000000000 --- a/packages/core/src/integration/sql.ts +++ /dev/null @@ -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().primaryKey(), - integration_id: text().$type().notNull(), - ...Timestamps, -}) diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index b280eaa54e..46ec89c437 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -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, }) } diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts index 65967969dc..ae0703f094 100644 --- a/packages/core/src/plugin/promise.ts +++ b/packages/core/src/plugin/promise.ts @@ -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)), diff --git a/packages/core/src/search.ts b/packages/core/src/search.ts index 3583356877..8cfb9e4bbc 100644 --- a/packages/core/src/search.ts +++ b/packages/core/src/search.ts @@ -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 + readonly select: (providerID: Integration.ID) => Effect.Effect readonly query: (input: QueryInput) => Effect.Effect } @@ -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, @@ -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, 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], +}) diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 85db0a6bf3..496805d038 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -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()), diff --git a/packages/core/test/integration.test.ts b/packages/core/test/integration.test.ts index 08be8d78a9..376d2f9523 100644 --- a/packages/core/test/integration.test.ts +++ b/packages/core/test/integration.test.ts @@ -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) diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index 3d43c9b064..938db47f25 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -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, }) } diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts index 2498e2b1e0..545e09e0c3 100644 --- a/packages/core/test/plugin/models-dev.test.ts +++ b/packages/core/test/plugin/models-dev.test.ts @@ -156,7 +156,6 @@ describe("ModelsDevPlugin", () => { names: ["ACME_API_KEY"], }, ], - capabilities: [], connections: [], }), ]) diff --git a/packages/core/test/plugin/promise.test.ts b/packages/core/test/plugin/promise.test.ts index a4be50a50e..50253ac681 100644 --- a/packages/core/test/plugin/promise.test.ts +++ b/packages/core/test/plugin/promise.test.ts @@ -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" }) }), diff --git a/packages/core/test/plugin/search.test.ts b/packages/core/test/plugin/search.test.ts index 656faeef98..34ced3bdee 100644 --- a/packages/core/test/plugin/search.test.ts +++ b/packages/core/test/plugin/search.test.ts @@ -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( diff --git a/packages/core/test/search.test.ts b/packages/core/test/search.test.ts index 811711ac97..60347acc2f 100644 --- a/packages/core/test/search.test.ts +++ b/packages/core/test/search.test.ts @@ -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() }), ) }) diff --git a/packages/core/test/tool-websearch.test.ts b/packages/core/test/tool-websearch.test.ts index eea7534ebd..a9ea4bc55d 100644 --- a/packages/core/test/tool-websearch.test.ts +++ b/packages/core/test/tool-websearch.test.ts @@ -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) diff --git a/packages/protocol/src/client.ts b/packages/protocol/src/client.ts index 9c33ceaebd..3f054fe49d 100644 --- a/packages/protocol/src/client.ts +++ b/packages/protocol/src/client.ts @@ -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", diff --git a/packages/protocol/src/groups/integration.ts b/packages/protocol/src/groups/integration.ts index 9d6ff5f693..a6ec4d5282 100644 --- a/packages/protocol/src/groups/integration.ts +++ b/packages/protocol/src/groups/integration.ts @@ -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 }, diff --git a/packages/protocol/src/groups/search.ts b/packages/protocol/src/groups/search.ts index c0f9837986..40085d1252 100644 --- a/packages/protocol/src/groups/search.ts +++ b/packages/protocol/src/groups/search.ts @@ -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, diff --git a/packages/schema/src/integration.ts b/packages/schema/src/integration.ts index 66e73e0315..ce5fa6661e 100644 --- a/packages/schema/src/integration.ts +++ b/packages/schema/src/integration.ts @@ -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 {} -export const SearchCapability = Schema.Struct({ - type: Schema.Literal("search"), +export interface Search extends Schema.Schema.Type {} +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("Integration.Info")({ id: ID, name: Schema.String, methods: Schema.Array(Method), - capabilities: Schema.Array(Capability), + search: optional(Search), connections: Schema.Array(Connection.Info), }) {} diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index d5cfd38c59..e37f5b1cca 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -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( - parameters: { - integrationID: string - location?: { - directory?: string | null - workspace?: string | null - } | null - capability?: "search" - }, - options?: Options, - ) { - 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( + parameters?: { + location?: { + directory?: string | null + workspace?: string | null + } | null + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + return (options?.client ?? this.client).get({ + url: "/api/search/provider", + ...options, + ...params, + }) + } + + /** + * Select default search provider + * + * Persist the global web search provider in the user configuration. + */ + public select( + parameters?: { + location?: { + directory?: string | null + workspace?: string | null + } | null + providerID?: string + }, + options?: Options, + ) { + 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 { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 07809f07e8..b4b80f839a 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -5611,10 +5611,8 @@ export type IntegrationEnvMethod = { names: Array } -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 - capabilities: Array + search?: IntegrationSearch connections: Array } @@ -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 = { - /** - * - */ - 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 = { + /** + * + */ + 204: void +} + +export type V2SearchProviderSelectResponse = V2SearchProviderSelectResponses[keyof V2SearchProviderSelectResponses] + export type V2SearchQueryData = { body: { query: string diff --git a/packages/server/src/handlers/integration.ts b/packages/server/src/handlers/integration.ts index 0a00f7599e..6c29d58776 100644 --- a/packages/server/src/handlers/integration.ts +++ b/packages/server/src/handlers/integration.ts @@ -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) { diff --git a/packages/server/src/handlers/search.ts b/packages/server/src/handlers/search.ts index 45e5e3fbe2..5938af35b3 100644 --- a/packages/server/src/handlers/search.ts +++ b/packages/server/src/handlers/search.ts @@ -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, + }), + }), + ), + ) + }), + ) }), ) diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx index fbf9dfef5d..5e89327348 100644 --- a/packages/tui/src/component/dialog-integration.tsx +++ b/packages/tui/src/component/dialog-integration.tsx @@ -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, dialog: ReturnType, ) { 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) diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index b2d126864b..96f3c5e04f 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -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 @@ -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(), diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 5ce997182c..fd970a96fe 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -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 ( diff --git a/packages/tui/src/util/tool-display.ts b/packages/tui/src/util/tool-display.ts index c8c5fca873..18e256bd34 100644 --- a/packages/tui/src/util/tool-display.ts +++ b/packages/tui/src/util/tool-display.ts @@ -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[]) { - return integrations.find((integration) => - integration.capabilities.some((capability) => capability.type === "search" && capability.selected), - )?.id -} - export function toolDisplayMetadata(state: unknown): Record { if (!state || typeof state !== "object" || Array.isArray(state)) return {} if (!("status" in state) || state.status === "pending") return {} diff --git a/packages/tui/test/cli/cmd/tui/integration-options.test.ts b/packages/tui/test/cli/cmd/tui/integration-options.test.ts index e8ba0a3ddb..f9cc482c6f 100644 --- a/packages/tui/test/cli/cmd/tui/integration-options.test.ts +++ b/packages/tui/test/cli/cmd/tui/integration-options.test.ts @@ -9,7 +9,6 @@ import { const integration = (value: Partial & Pick): IntegrationInfo => ({ methods: [], - capabilities: [], connections: [], ...value, }) diff --git a/packages/tui/test/util/tool-display.test.ts b/packages/tui/test/util/tool-display.test.ts index 0ee2fd3b2a..f77f54cbab 100644 --- a/packages/tui/test/util/tool-display.test.ts +++ b/packages/tui/test/util/tool-display.test.ts @@ -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 }