From 129012c3eec43e916d33c2538947dda2263a905f Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Mon, 6 Jul 2026 19:33:13 +0530 Subject: [PATCH 1/6] feat(core): add integration-backed search --- packages/client/src/effect/api/api.ts | 60 +++-- .../client/src/effect/generated/client.ts | 62 +++-- .../client/src/promise/generated/client.ts | 15 ++ .../client/src/promise/generated/types.ts | 60 +++++ packages/client/test/promise.test.ts | 3 +- packages/core/schema.json | 57 ++++- packages/core/src/config.ts | 4 + packages/core/src/config/search.ts | 8 + packages/core/src/database/migration.gen.ts | 1 + .../20260706133920_integration-search.ts | 18 ++ packages/core/src/database/schema.gen.ts | 8 + packages/core/src/form.ts | 9 +- packages/core/src/integration.ts | 109 +++++++- packages/core/src/integration/sql.ts | 9 + packages/core/src/location-services.ts | 5 +- packages/core/src/plugin/host.ts | 24 ++ packages/core/src/plugin/internal.ts | 7 +- packages/core/src/plugin/promise.ts | 32 ++- packages/core/src/plugin/search/exa.ts | 53 ++++ packages/core/src/plugin/search/index.ts | 4 + packages/core/src/plugin/search/mcp.ts | 75 ++++++ packages/core/src/plugin/search/parallel.ts | 47 ++++ packages/core/src/search.ts | 204 +++++++++++++++ packages/core/src/tool/websearch.ts | 219 ++-------------- packages/core/test/form.test.ts | 22 ++ packages/core/test/integration.test.ts | 2 +- packages/core/test/plugin/host.ts | 14 ++ packages/core/test/plugin/models-dev.test.ts | 1 + packages/core/test/plugin/promise.test.ts | 26 ++ packages/core/test/plugin/search-fixture.ts | 41 +++ packages/core/test/plugin/search.test.ts | 109 ++++++++ packages/core/test/search.test.ts | 148 +++++++++++ packages/core/test/tool-websearch.test.ts | 238 ++++-------------- packages/plugin/src/v2/effect/index.ts | 7 +- packages/plugin/src/v2/effect/integration.ts | 20 ++ packages/plugin/src/v2/promise/index.ts | 7 +- packages/plugin/src/v2/promise/integration.ts | 27 +- packages/protocol/src/client.ts | 1 + packages/protocol/src/groups/integration.ts | 17 ++ packages/schema/src/form.ts | 12 +- packages/schema/src/index.ts | 1 + packages/schema/src/integration.ts | 11 + packages/schema/src/search.ts | 26 ++ packages/sdk/js/src/v2/gen/sdk.gen.ts | 67 +++++ packages/sdk/js/src/v2/gen/types.gen.ts | 158 +++++++++++- packages/server/src/handlers/integration.ts | 15 ++ .../tui/src/component/dialog-integration.tsx | 109 ++++++-- packages/tui/src/context/data.tsx | 3 +- packages/tui/src/routes/session/form.tsx | 47 +++- .../cli/cmd/tui/integration-options.test.ts | 1 + 50 files changed, 1745 insertions(+), 478 deletions(-) create mode 100644 packages/core/src/config/search.ts create mode 100644 packages/core/src/database/migration/20260706133920_integration-search.ts create mode 100644 packages/core/src/integration/sql.ts create mode 100644 packages/core/src/plugin/search/exa.ts create mode 100644 packages/core/src/plugin/search/index.ts create mode 100644 packages/core/src/plugin/search/mcp.ts create mode 100644 packages/core/src/plugin/search/parallel.ts create mode 100644 packages/core/src/search.ts create mode 100644 packages/core/test/plugin/search-fixture.ts create mode 100644 packages/core/test/plugin/search.test.ts create mode 100644 packages/core/test/search.test.ts create mode 100644 packages/schema/src/search.ts diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 9b67aee42b37..90b183731bd5 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -355,63 +355,77 @@ 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 key: Endpoint9_2Request["payload"]["key"] - readonly label?: Endpoint9_2Request["payload"]["label"] + readonly capability: Endpoint9_2Request["payload"]["capability"] } -export type Endpoint9_2Output = EffectValue> -export type IntegrationConnectKeyOperation = (input: Endpoint9_2Input) => Effect.Effect +export type Endpoint9_2Output = EffectValue< + ReturnType +> +export type IntegrationSelectCapabilityOperation = ( + 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 methodID: Endpoint9_3Request["payload"]["methodID"] - readonly inputs: Endpoint9_3Request["payload"]["inputs"] + readonly key: Endpoint9_3Request["payload"]["key"] readonly label?: Endpoint9_3Request["payload"]["label"] } -export type Endpoint9_3Output = EffectValue> -export type IntegrationConnectOauthOperation = ( - input: Endpoint9_3Input, -) => Effect.Effect +export type Endpoint9_3Output = EffectValue> +export type IntegrationConnectKeyOperation = (input: Endpoint9_3Input) => Effect.Effect -type Endpoint9_4Request = Parameters[0] +type Endpoint9_4Request = Parameters[0] export type Endpoint9_4Input = { - readonly attemptID: Endpoint9_4Request["params"]["attemptID"] + readonly integrationID: Endpoint9_4Request["params"]["integrationID"] readonly location?: Endpoint9_4Request["query"]["location"] + readonly methodID: Endpoint9_4Request["payload"]["methodID"] + readonly inputs: Endpoint9_4Request["payload"]["inputs"] + readonly label?: Endpoint9_4Request["payload"]["label"] } -export type Endpoint9_4Output = EffectValue> -export type IntegrationAttemptStatusOperation = ( +export type Endpoint9_4Output = EffectValue> +export type IntegrationConnectOauthOperation = ( 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 IntegrationAttemptCompleteOperation = ( +export type Endpoint9_5Output = EffectValue> +export type IntegrationAttemptStatusOperation = ( 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 IntegrationAttemptCancelOperation = ( +export type Endpoint9_6Output = EffectValue> +export type IntegrationAttemptCompleteOperation = ( 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 diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index 1001c1e0477d..ad3dac87ac14 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -437,65 +437,78 @@ 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 key: Endpoint9_2Request["payload"]["key"] - readonly label?: Endpoint9_2Request["payload"]["label"] + readonly capability: Endpoint9_2Request["payload"]["capability"] } const Endpoint9_2 = (raw: RawClient["server.integration"]) => (input: Endpoint9_2Input) => - raw["integration.connect.key"]({ + raw["integration.capability.select"]({ params: { integrationID: input["integrationID"] }, query: { location: input["location"] }, - payload: { key: input["key"], label: input["label"] }, + payload: { capability: input["capability"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint9_3Request = Parameters[0] +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 key: Endpoint9_3Request["payload"]["key"] readonly label?: Endpoint9_3Request["payload"]["label"] } const Endpoint9_3 = (raw: RawClient["server.integration"]) => (input: Endpoint9_3Input) => - raw["integration.connect.oauth"]({ + raw["integration.connect.key"]({ params: { integrationID: input["integrationID"] }, query: { location: input["location"] }, - payload: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] }, + payload: { key: input["key"], label: input["label"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint9_4Request = Parameters[0] +type Endpoint9_4Request = Parameters[0] type Endpoint9_4Input = { - readonly attemptID: Endpoint9_4Request["params"]["attemptID"] + readonly integrationID: Endpoint9_4Request["params"]["integrationID"] readonly location?: Endpoint9_4Request["query"]["location"] + readonly methodID: Endpoint9_4Request["payload"]["methodID"] + readonly inputs: Endpoint9_4Request["payload"]["inputs"] + readonly label?: Endpoint9_4Request["payload"]["label"] } const Endpoint9_4 = (raw: RawClient["server.integration"]) => (input: Endpoint9_4Input) => - raw["integration.attempt.status"]({ - params: { attemptID: input["attemptID"] }, + 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_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_5 = (raw: RawClient["server.integration"]) => (input: Endpoint9_5Input) => - raw["integration.attempt.complete"]({ + raw["integration.attempt.status"]({ params: { attemptID: input["attemptID"] }, query: { location: input["location"] }, - payload: { code: input["code"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint9_6Request = Parameters[0] +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"] } const Endpoint9_6 = (raw: RawClient["server.integration"]) => (input: Endpoint9_6Input) => + raw["integration.attempt.complete"]({ + params: { attemptID: input["attemptID"] }, + query: { location: input["location"] }, + payload: { code: input["code"] }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint9_7Request = Parameters[0] +type Endpoint9_7Input = { + readonly attemptID: Endpoint9_7Request["params"]["attemptID"] + readonly location?: Endpoint9_7Request["query"]["location"] +} +const Endpoint9_7 = (raw: RawClient["server.integration"]) => (input: Endpoint9_7Input) => raw["integration.attempt.cancel"]({ params: { attemptID: input["attemptID"] }, query: { location: input["location"] }, @@ -504,11 +517,12 @@ const Endpoint9_6 = (raw: RawClient["server.integration"]) => (input: Endpoint9_ const adaptGroup9 = (raw: RawClient["server.integration"]) => ({ list: Endpoint9_0(raw), get: Endpoint9_1(raw), - connectKey: Endpoint9_2(raw), - connectOauth: Endpoint9_3(raw), - attemptStatus: Endpoint9_4(raw), - attemptComplete: Endpoint9_5(raw), - attemptCancel: Endpoint9_6(raw), + selectCapability: Endpoint9_2(raw), + connectKey: Endpoint9_3(raw), + connectOauth: Endpoint9_4(raw), + attemptStatus: Endpoint9_5(raw), + attemptComplete: Endpoint9_6(raw), + attemptCancel: Endpoint9_7(raw), }) type Endpoint10_0Request = Parameters[0] diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index d3e18279e0d7..692973679ce8 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -73,6 +73,8 @@ import type { IntegrationListOutput, IntegrationGetInput, IntegrationGetOutput, + IntegrationSelectCapabilityInput, + IntegrationSelectCapabilityOutput, IntegrationConnectKeyInput, IntegrationConnectKeyOutput, IntegrationConnectOauthInput, @@ -794,6 +796,19 @@ 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( { diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index ac0efadb5c06..598abcd9ef1a 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -2240,6 +2240,11 @@ 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 connections: ReadonlyArray< | { readonly type: "credential"; readonly id: string; readonly label: string } | { readonly type: "env"; readonly name: string } @@ -2292,6 +2297,11 @@ 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 connections: ReadonlyArray< | { readonly type: "credential"; readonly id: string; readonly label: string } | { readonly type: "env"; readonly name: string } @@ -2299,6 +2309,16 @@ export type IntegrationGetOutput = { } | null } +export type IntegrationSelectCapabilityInput = { + readonly integrationID: { readonly integrationID: string }["integrationID"] + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] + readonly capability: { readonly capability: "search" }["capability"] +} + +export type IntegrationSelectCapabilityOutput = void + export type IntegrationConnectKeyInput = { readonly integrationID: { readonly integrationID: string }["integrationID"] readonly location?: { @@ -2604,6 +2624,14 @@ export type FormListRequestsOutput = { readonly mode: "url" readonly url: string } + | { + readonly id: string + readonly sessionID: string + readonly title?: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly mode: "integration" + readonly integrationID: string + } > } @@ -2716,6 +2744,14 @@ export type FormListOutput = { readonly mode: "url" readonly url: string } + | { + readonly id: string + readonly sessionID: string + readonly title?: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly mode: "integration" + readonly integrationID: string + } > }["data"] @@ -3412,6 +3448,14 @@ export type FormCreateOutput = { readonly mode: "url" readonly url: string } + | { + readonly id: string + readonly sessionID: string + readonly title?: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly mode: "integration" + readonly integrationID: string + } }["data"] export type FormGetInput = { @@ -3526,6 +3570,14 @@ export type FormGetOutput = { readonly mode: "url" readonly url: string } + | { + readonly id: string + readonly sessionID: string + readonly title?: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly mode: "integration" + readonly integrationID: string + } }["data"] export type FormStateInput = { @@ -5272,6 +5324,14 @@ export type EventSubscribeOutput = readonly mode: "url" readonly url: string } + | { + readonly id: string + readonly sessionID: string + readonly title?: string + readonly metadata?: { readonly [x: string]: unknown } + readonly mode: "integration" + readonly integrationID: string + } } } | { diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index 2dbd58a85855..a053752f9ebe 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -37,6 +37,7 @@ test("exposes every standard HTTP API group", () => { expect(Object.keys(client.integration)).toEqual([ "list", "get", + "selectCapability", "connectKey", "connectOauth", "attemptStatus", @@ -47,7 +48,7 @@ test("exposes every standard HTTP API group", () => { expect(Object.keys(client.vcs)).toEqual(["status", "diff"]) expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"]) expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "output", "remove"]) - expect(Object.keys(client.project)).toEqual(["current", "directories"]) + expect(Object.keys(client.project)).toEqual(["list", "current", "directories"]) }) test("file.read returns binary content from the public HTTP contract", async () => { diff --git a/packages/core/schema.json b/packages/core/schema.json index d9e0b20b22f7..e0d7de983afc 100644 --- a/packages/core/schema.json +++ b/packages/core/schema.json @@ -1,9 +1,9 @@ { "version": "7", "dialect": "sqlite", - "id": "96e9fe64-d810-4102-8f79-3317a88bb6d2", + "id": "cd519535-0763-4d60-aeee-21ee226ffd7f", "prevIds": [ - "22e57fed-b9b8-4e94-a3b4-f94bece680a8" + "96e9fe64-d810-4102-8f79-3317a88bb6d2" ], "ddl": [ { @@ -38,6 +38,10 @@ "name": "event", "entityType": "tables" }, + { + "name": "integration_capability", + "entityType": "tables" + }, { "name": "permission", "entityType": "tables" @@ -556,6 +560,46 @@ "entityType": "columns", "table": "event" }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "capability", + "entityType": "columns", + "table": "integration_capability" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "integration_id", + "entityType": "columns", + "table": "integration_capability" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "integration_capability" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "integration_capability" + }, { "type": "text", "notNull": false, @@ -1859,6 +1903,15 @@ "table": "event", "entityType": "pks" }, + { + "columns": [ + "capability" + ], + "nameExplicit": false, + "name": "integration_capability_pk", + "table": "integration_capability", + "entityType": "pks" + }, { "columns": [ "id" diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 4bd33ca24023..597e3db9bef4 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -22,6 +22,7 @@ import { ConfigMCP } from "./config/mcp" import { ConfigPlugin } from "./config/plugin" import { ConfigProvider } from "./config/provider" import { ConfigReference } from "./config/reference" +import { ConfigSearch } from "./config/search" import { ConfigToolOutput } from "./config/tool-output" import { ConfigVariable } from "./config/variable" import { ConfigWatcher } from "./config/watcher" @@ -101,6 +102,9 @@ export class Info extends Schema.Class("Config.Info")({ references: ConfigReference.Info.pipe(Schema.optional).annotate({ description: "Named local directories or Git repositories available as external context", }), + search: ConfigSearch.Info.pipe(Schema.optional).annotate({ + description: "Web search provider selection", + }), plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({ description: "Ordered plugin enablement directives and external package declarations", }), diff --git a/packages/core/src/config/search.ts b/packages/core/src/config/search.ts new file mode 100644 index 000000000000..717347933983 --- /dev/null +++ b/packages/core/src/config/search.ts @@ -0,0 +1,8 @@ +export * as ConfigSearch from "./search" + +import { Integration } from "@opencode-ai/schema/integration" +import { Schema } from "effect" + +export class Info extends Schema.Class("ConfigSearch.Info")({ + provider: Integration.ID, +}) {} diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index e6a236f5271d..1f5de459930c 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -44,5 +44,6 @@ export const migrations = ( import("./migration/20260703090000_reset_v2_event_rename_sweep"), import("./migration/20260703181610_event_created_column"), import("./migration/20260703190000_reset_v2_shell_event_payloads"), + import("./migration/20260706133920_integration-search"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260706133920_integration-search.ts b/packages/core/src/database/migration/20260706133920_integration-search.ts new file mode 100644 index 000000000000..fb7709be6315 --- /dev/null +++ b/packages/core/src/database/migration/20260706133920_integration-search.ts @@ -0,0 +1,18 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260706133920_integration-search", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(` + CREATE TABLE \`integration_capability\` ( + \`capability\` text PRIMARY KEY, + \`integration_id\` text NOT NULL, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL + ); + `) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/schema.gen.ts b/packages/core/src/database/schema.gen.ts index 17a7d12aedd8..d1da66fcbb1f 100644 --- a/packages/core/src/database/schema.gen.ts +++ b/packages/core/src/database/schema.gen.ts @@ -87,6 +87,14 @@ export default { CONSTRAINT \`fk_event_aggregate_id_event_sequence_aggregate_id_fk\` FOREIGN KEY (\`aggregate_id\`) REFERENCES \`event_sequence\`(\`aggregate_id\`) ON DELETE CASCADE ); `) + yield* tx.run(` + CREATE TABLE \`integration_capability\` ( + \`capability\` text PRIMARY KEY, + \`integration_id\` text NOT NULL, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL + ); + `) yield* tx.run(` CREATE TABLE \`permission\` ( \`id\` text PRIMARY KEY, diff --git a/packages/core/src/form.ts b/packages/core/src/form.ts index 7cdda87b4c2a..25d594e95cf6 100644 --- a/packages/core/src/form.ts +++ b/packages/core/src/form.ts @@ -67,6 +67,7 @@ export class InvalidFormError extends Schema.TaggedErrorClass( export type CreateInput = | (Omit & { readonly id?: ID }) | (Omit & { readonly id?: ID }) + | (Omit & { readonly id?: ID }) export interface ReplyInput { readonly id: ID @@ -138,7 +139,9 @@ export const layer = Layer.effect( const form: Info = input.mode === "form" ? { ...base, mode: "form", fields: input.fields } - : { ...base, mode: "url", url: input.url } + : input.mode === "url" + ? { ...base, mode: "url", url: input.url } + : { ...base, mode: "integration", integrationID: input.integrationID } const entry: Entry = { form, state: { status: "pending" }, @@ -228,9 +231,9 @@ export const locationLayer = layer export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] }) function validateAnswer(form: Info, answer: Answer) { - if (form.mode === "url") { + if (form.mode !== "form") { if (Object.keys(answer).length === 0) return - return "URL forms must be answered with an empty answer" + return `${form.mode === "url" ? "URL" : "Integration"} forms must be answered with an empty answer` } const fields = new Map(form.fields.map((field) => [field.key, field])) for (const key of Object.keys(answer)) { diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index 12dad07dd34d..959e7b644cdc 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -1,6 +1,7 @@ export * as Integration from "./integration" import { makeLocationNode } from "./effect/app-node" +import { eq } from "drizzle-orm" import { Cause, Clock, @@ -16,10 +17,13 @@ import { Types, } from "effect" import { Integration } from "@opencode-ai/schema/integration" +import { Search } from "@opencode-ai/schema/search" import { Credential } from "./credential" +import { Database } from "./database/database" import { State } from "./state" import { EventV2 } from "./event" import { IntegrationConnection } from "./integration/connection" +import { IntegrationCapabilityTable } from "./integration/sql" export const ID = Integration.ID export type ID = Integration.ID @@ -60,6 +64,12 @@ export type Info = Integration.Info export const Inputs = Integration.Inputs export type Inputs = Integration.Inputs +export const SearchCapability = Integration.SearchCapability +export type SearchCapability = Omit + +export const Capability = Integration.Capability +export type Capability = Integration.Capability + export type OAuthAuthorization = { readonly url: string readonly instructions: string @@ -94,6 +104,15 @@ export interface EnvImplementation { export type Implementation = OAuthImplementation | KeyImplementation | EnvImplementation +export interface SearchImplementation { + readonly integrationID: ID + readonly capability: SearchCapability + readonly execute: ( + input: Search.Input, + context: { readonly credential?: Credential.Value; readonly sessionID?: string }, + ) => Effect.Effect +} + export const Attempt = Integration.Attempt export type Attempt = Integration.Attempt @@ -119,6 +138,7 @@ type Entry = { ref: Types.DeepMutable methods: Types.DeepMutable[] implementations: Map> + search?: Types.DeepMutable } type Data = { @@ -135,6 +155,13 @@ export type Draft = { update: (implementation: Implementation) => void remove: (integrationID: ID, method: Method) => void } + capability: { + search: { + list: () => readonly SearchImplementation[] + update: (implementation: SearchImplementation) => void + remove: (integrationID: ID) => void + } + } } export interface Interface extends State.Transformable { @@ -191,6 +218,14 @@ 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 + } + } } export class Service extends Context.Service()("@opencode/v2/Integration") {} @@ -222,6 +257,7 @@ const layer = Layer.effect( Service, Effect.gen(function* () { const credentials = yield* Credential.Service + const { db } = yield* Database.Service const events = yield* EventV2.Service const scope = yield* Scope.Scope const attempts = SynchronizedRef.makeUnsafe(new Map()) @@ -281,6 +317,32 @@ const layer = Layer.effect( if (method.type === "oauth") current.implementations.delete(method.id) }, }, + capability: { + search: { + list: () => + Array.from(draft.integrations.values()).flatMap((entry) => + entry.search ? [entry.search as SearchImplementation] : [], + ), + update: (implementation) => { + const current = draft.integrations.get(implementation.integrationID) ?? { + ref: { + id: implementation.integrationID, + name: implementation.integrationID, + }, + methods: [], + implementations: new Map>(), + } + 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 + }, + }, + }, }), finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid), }) @@ -300,14 +362,24 @@ const layer = Layer.effect( return [...credentials, ...env] } - const project = (entry: Entry, connections: IntegrationConnection.Info[]) => + const project = (entry: Entry, connections: IntegrationConnection.Info[], selectedSearch: ID | undefined) => new Info({ id: entry.ref.id, name: entry.ref.name, methods: entry.methods, + capabilities: entry.search ? [{ ...entry.search.capability, selected: entry.ref.id === selectedSearch }] : [], connections, }) + const selectedSearch = Effect.fn("Integration.capability.search.selected")(function* () { + return (yield* db + .select({ integrationID: IntegrationCapabilityTable.integration_id }) + .from(IntegrationCapabilityTable) + .where(eq(IntegrationCapabilityTable.capability, "search")) + .get() + .pipe(Effect.orDie))?.integrationID + }) + const authorize = (effect: Effect.Effect) => effect.pipe(Effect.mapError((cause) => new AuthorizationError({ cause }))) @@ -369,12 +441,13 @@ const layer = Layer.effect( get: Effect.fn("Integration.get")(function* (id) { const entry = state.get().integrations.get(id) if (!entry) return undefined - return project(entry, resolveConnections(entry, yield* credentials.list(id))) + return project(entry, resolveConnections(entry, yield* credentials.list(id)), yield* selectedSearch()) }), list: Effect.fn("Integration.list")(function* () { const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID) + const selected = yield* selectedSearch() return Array.from(state.get().integrations.values(), (entry) => - project(entry, resolveConnections(entry, saved.get(entry.ref.id) ?? [])), + project(entry, resolveConnections(entry, saved.get(entry.ref.id) ?? []), selected), ).toSorted((a, b) => a.name.localeCompare(b.name)) }), connection: { @@ -514,8 +587,36 @@ const layer = Layer.effect( if (attempt) yield* Scope.close(attempt.scope, Exit.void) }), }, + capability: { + search: { + list: Effect.fn("Integration.capability.search.list")(function* () { + return Array.from(state.get().integrations.values()).flatMap((entry) => + entry.search ? [entry.search as SearchImplementation] : [], + ) + }), + get: Effect.fn("Integration.capability.search.get")(function* (integrationID) { + return state.get().integrations.get(integrationID)?.search as SearchImplementation | undefined + }), + selected: selectedSearch, + select: Effect.fn("Integration.capability.search.select")(function* (integrationID) { + if (!state.get().integrations.get(integrationID)?.search) { + return yield* Effect.die(new Error(`Search capability not found: ${integrationID}`)) + } + yield* db + .insert(IntegrationCapabilityTable) + .values({ capability: "search", integration_id: integrationID }) + .onConflictDoUpdate({ + target: IntegrationCapabilityTable.capability, + set: { integration_id: integrationID }, + }) + .run() + .pipe(Effect.orDie) + yield* events.publish(Event.Updated, {}) + }), + }, + }, }) }), ) -export const node = makeLocationNode({ service: Service, layer, deps: [Credential.node, EventV2.node] }) +export const node = makeLocationNode({ service: Service, layer, deps: [Credential.node, Database.node, EventV2.node] }) diff --git a/packages/core/src/integration/sql.ts b/packages/core/src/integration/sql.ts new file mode 100644 index 000000000000..996247afe087 --- /dev/null +++ b/packages/core/src/integration/sql.ts @@ -0,0 +1,9 @@ +import { sqliteTable, text } from "drizzle-orm/sqlite-core" +import { Timestamps } from "../database/schema.sql" +import type { Integration } from "../integration" + +export const IntegrationCapabilityTable = sqliteTable("integration_capability", { + capability: text().$type().primaryKey(), + integration_id: text().$type().notNull(), + ...Timestamps, +}) diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 21823f583b2c..632c3d63994b 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -34,6 +34,7 @@ import { Pty } from "./pty" import { QuestionV2 } from "./question" import { Shell } from "./shell" import { Reference } from "./reference" +import { Search } from "./search" import { ReferenceGuidance } from "./reference/guidance" import { Ripgrep } from "./ripgrep" import { SessionRunnerLLM } from "./session/runner/llm" @@ -51,7 +52,6 @@ import { SessionInstructions } from "./session/instructions" import { McpTool } from "./tool/mcp" import { ReadToolFileSystem } from "./tool/read-filesystem" import { ToolRegistry } from "./tool/registry" -import { WebSearchTool } from "./tool/websearch" import { ToolOutputStore } from "./tool-output-store" import { Vcs } from "./vcs" @@ -84,13 +84,13 @@ const pluginSupervisorNode = makeLocationNode({ Form.node, ReadToolFileSystem.node, Reference.node, + Search.node, Ripgrep.node, SessionInstructions.node, SessionTodo.node, Shell.node, SkillV2.node, ToolRegistry.toolsNode, - WebSearchTool.configNode, ], }) @@ -100,6 +100,7 @@ const locationServiceNodes = [ AgentV2.node, CommandV2.node, Reference.node, + Search.node, Integration.node, Catalog.node, AISDK.node, diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 2e58c6b1440d..fb96beae34ae 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -163,6 +163,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int integration: { list: () => response(integration.list()), get: (input) => response(integration.get(Integration.ID.make(input.integrationID))), + selectCapability: (input) => integration.capability.search.select(Integration.ID.make(input.integrationID)), connectKey: (input) => integration.connection.key({ integrationID: Integration.ID.make(input.integrationID), @@ -268,6 +269,29 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int remove: (id, method) => draft.method.remove(Integration.ID.make(id), Schema.decodeUnknownSync(Integration.Method)(method)), }, + capability: { + search: { + list: () => + draft.capability.search.list().map((provider) => ({ + integrationID: provider.integrationID, + capability: provider.capability, + execute: (input, context) => + provider.execute(input, { + ...context, + credential: context.credential + ? Schema.decodeUnknownSync(Credential.Value)(context.credential) + : undefined, + }), + })), + update: (input) => + draft.capability.search.update({ + integrationID: Integration.ID.make(input.integrationID), + capability: input.capability, + execute: input.execute, + }), + remove: (id) => draft.capability.search.remove(Integration.ID.make(id)), + }, + }, }) }), }, diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 2789cc3c2e5b..b18caa5efa8e 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -26,6 +26,7 @@ import { ModelsDev } from "../models-dev" import { Npm } from "../npm" import { PermissionV2 } from "../permission" import { Reference } from "../reference" +import { Search } from "../search" import { Ripgrep } from "../ripgrep" import { SessionInstructions } from "../session/instructions" import { SessionTodo } from "../session/todo" @@ -50,6 +51,7 @@ import { AgentPlugin } from "./agent" import { CommandPlugin } from "./command" import { ModelsDevPlugin } from "./models-dev" import { ProviderPlugins } from "./provider" +import { SearchPlugins } from "./search" import { PluginRuntime } from "./runtime" import { SkillPlugin } from "./skill" import { VariantPlugin } from "./variant" @@ -76,13 +78,13 @@ const services = Effect.fn("PluginInternal.services")(function* () { const form = yield* Form.Service const read = yield* ReadToolFileSystem.Service const reference = yield* Reference.Service + const search = yield* Search.Service const ripgrep = yield* Ripgrep.Service const instructions = yield* SessionInstructions.Service const todo = yield* SessionTodo.Service const shell = yield* Shell.Service const skill = yield* SkillV2.Service const tools = yield* Tools.Service - const websearch = yield* WebSearchTool.ConfigService return Context.mergeAll( Context.make(AgentV2.Service, agent), Context.make(Catalog.Service, catalog), @@ -105,13 +107,13 @@ const services = Effect.fn("PluginInternal.services")(function* () { Context.make(Form.Service, form), Context.make(ReadToolFileSystem.Service, read), Context.make(Reference.Service, reference), + Context.make(Search.Service, search), Context.make(Ripgrep.Service, ripgrep), Context.make(SessionInstructions.Service, instructions), Context.make(SessionTodo.Service, todo), Context.make(Shell.Service, shell), Context.make(SkillV2.Service, skill), Context.make(Tools.Service, tools), - Context.make(WebSearchTool.ConfigService, websearch), ) }) @@ -127,6 +129,7 @@ const pre = [ SkillPlugin.Plugin, ModelsDevPlugin, ...ProviderPlugins, + ...SearchPlugins, ApplyPatchTool.Plugin, EditTool.Plugin, GlobTool.Plugin, diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts index bcd3db13235e..aaf8ea95ada2 100644 --- a/packages/core/src/plugin/promise.ts +++ b/packages/core/src/plugin/promise.ts @@ -79,12 +79,42 @@ export function fromPromise(plugin: Plugin) { integration: { list: (input) => run(host.integration.list(input)), get: (input) => run(host.integration.get(input)), + selectCapability: (input) => run(host.integration.selectCapability(input)), connectKey: (input) => run(host.integration.connectKey(input)), connectOauth: (input) => run(host.integration.connectOauth(input)), attemptStatus: (input) => run(host.integration.attemptStatus(input)), attemptComplete: (input) => run(host.integration.attemptComplete(input)), attemptCancel: (input) => run(host.integration.attemptCancel(input)), - transform: transform(host.integration), + transform: (callback) => + register( + host.integration.transform((draft) => { + callback({ + ...draft, + capability: { + search: { + list: () => + draft.capability.search.list().map((provider) => ({ + integrationID: provider.integrationID, + capability: provider.capability, + execute: (input, execution) => + Effect.runPromiseWith(context)(provider.execute(input, execution)), + })), + update: (input) => + draft.capability.search.update({ + integrationID: input.integrationID, + capability: input.capability, + execute: (query, execution) => + Effect.tryPromise({ + try: (signal) => input.execute(query, { ...execution, signal }), + catch: (cause) => cause, + }), + }), + remove: draft.capability.search.remove, + }, + }, + }) + }), + ), reload: () => run(host.integration.reload()), connection: { active: (id) => Effect.runPromiseWith(context)(host.integration.connection.active(id)), diff --git a/packages/core/src/plugin/search/exa.ts b/packages/core/src/plugin/search/exa.ts new file mode 100644 index 000000000000..23e5d3764ccc --- /dev/null +++ b/packages/core/src/plugin/search/exa.ts @@ -0,0 +1,53 @@ +export * as SearchExa from "./exa" + +import { define } from "@opencode-ai/plugin/v2/effect" +import { Effect, Schema, Scope } from "effect" +import { HttpClient } from "effect/unstable/http" +import { SearchMcp } from "./mcp" + +export const endpoint = "https://mcp.exa.ai/mcp" + +const Args = Schema.Struct({ + query: Schema.String, + type: Schema.String, + numResults: Schema.Number, + livecrawl: Schema.String, + contextMaxCharacters: Schema.optional(Schema.Number), +}) + +const url = (apiKey: string | undefined) => { + if (!apiKey) return endpoint + const value = new URL(endpoint) + value.searchParams.set("exaApiKey", apiKey) + return value.toString() +} + +export const Plugin = define({ + id: "opencode.search.exa", + effect: Effect.fn("SearchExa.Plugin")(function* (ctx) { + const http = yield* HttpClient.HttpClient + yield* ctx.integration.transform((draft) => { + draft.update("exa", (integration) => (integration.name = "Exa")) + draft.method.update({ integrationID: "exa", method: { type: "key", label: "API key (optional)" } }) + draft.method.update({ integrationID: "exa", method: { type: "env", names: ["EXA_API_KEY"] } }) + draft.capability.search.update({ + integrationID: "exa", + capability: { type: "search", connection: "optional" }, + execute: (input, context) => + SearchMcp.call( + http, + url(context.credential?.type === "key" ? context.credential.key : undefined), + "web_search_exa", + Args, + { + query: input.query, + type: input.type ?? "auto", + numResults: input.numResults ?? 8, + livecrawl: input.livecrawl ?? "fallback", + contextMaxCharacters: input.contextMaxCharacters, + }, + ).pipe(Effect.map((text) => ({ text: text ?? "" }))), + }) + }) + }), +}) diff --git a/packages/core/src/plugin/search/index.ts b/packages/core/src/plugin/search/index.ts new file mode 100644 index 000000000000..b816effe9b79 --- /dev/null +++ b/packages/core/src/plugin/search/index.ts @@ -0,0 +1,4 @@ +import { SearchExa } from "./exa" +import { SearchParallel } from "./parallel" + +export const SearchPlugins = [SearchExa.Plugin, SearchParallel.Plugin] as const diff --git a/packages/core/src/plugin/search/mcp.ts b/packages/core/src/plugin/search/mcp.ts new file mode 100644 index 000000000000..0bba789902fc --- /dev/null +++ b/packages/core/src/plugin/search/mcp.ts @@ -0,0 +1,75 @@ +export * as SearchMcp from "./mcp" + +import { Duration, Effect, Schema } from "effect" +import { HttpClient, HttpClientRequest } from "effect/unstable/http" +import { collectBoundedResponseBody } from "../../tool/http-body" + +export const MAX_RESPONSE_BYTES = 256 * 1024 + +const Result = Schema.Struct({ + result: Schema.Struct({ + content: Schema.Array(Schema.Struct({ type: Schema.String, text: Schema.String })), + }), +}) +const decodeResult = Schema.decodeUnknownEffect(Schema.fromJsonString(Result)) + +const parsePayload = (payload: string) => + Effect.gen(function* () { + const trimmed = payload.trim() + if (!trimmed.startsWith("{")) return undefined + return (yield* decodeResult(trimmed)).result.content.find((item) => item.text)?.text + }) + +export const parseResponse = Effect.fn("SearchMcp.parseResponse")(function* (body: string) { + const trimmed = body.trim() + const direct = trimmed ? yield* parsePayload(trimmed) : undefined + if (direct) return direct + for (const line of body.split("\n")) { + if (!line.startsWith("data: ")) continue + const data = yield* parsePayload(line.substring(6)) + if (data) return data + } +}) + +const Request = (args: Schema.Struct) => + Schema.Struct({ + jsonrpc: Schema.Literal("2.0"), + id: Schema.Literal(1), + method: Schema.Literal("tools/call"), + params: Schema.Struct({ name: Schema.String, arguments: args }), + }) + +export const call = ( + http: HttpClient.HttpClient, + url: string, + tool: string, + args: Schema.Struct, + value: Schema.Struct.Type, + headers: Record = {}, +) => + Effect.gen(function* () { + const request = yield* HttpClientRequest.post(url).pipe( + HttpClientRequest.accept("application/json, text/event-stream"), + HttpClientRequest.setHeaders(headers), + HttpClientRequest.schemaBodyJson(Request(args))({ + jsonrpc: "2.0" as const, + id: 1 as const, + method: "tools/call" as const, + params: { name: tool, arguments: value }, + }), + ) + return yield* Effect.gen(function* () { + const response = yield* HttpClient.filterStatusOk(http).execute(request) + const body = yield* collectBoundedResponseBody( + response, + MAX_RESPONSE_BYTES, + () => new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`), + ) + return yield* parseResponse(body.toString("utf8")) + }).pipe( + Effect.timeoutOrElse({ + duration: Duration.seconds(25), + orElse: () => Effect.fail(new Error(`${tool} request timed out`)), + }), + ) + }) diff --git a/packages/core/src/plugin/search/parallel.ts b/packages/core/src/plugin/search/parallel.ts new file mode 100644 index 000000000000..75a9ab7c381f --- /dev/null +++ b/packages/core/src/plugin/search/parallel.ts @@ -0,0 +1,47 @@ +export * as SearchParallel from "./parallel" + +import { define } from "@opencode-ai/plugin/v2/effect" +import { Effect, Schema, Scope } from "effect" +import { HttpClient } from "effect/unstable/http" +import { InstallationVersion } from "../../installation/version" +import { SearchMcp } from "./mcp" + +export const endpoint = "https://search.parallel.ai/mcp" + +const Args = Schema.Struct({ + objective: Schema.String, + search_queries: Schema.Array(Schema.String), + session_id: Schema.String, +}) + +export const Plugin = define({ + id: "opencode.search.parallel", + effect: Effect.fn("SearchParallel.Plugin")(function* (ctx) { + const http = yield* HttpClient.HttpClient + yield* ctx.integration.transform((draft) => { + draft.update("parallel", (integration) => (integration.name = "Parallel")) + draft.method.update({ integrationID: "parallel", method: { type: "key", label: "API key (optional)" } }) + draft.method.update({ integrationID: "parallel", method: { type: "env", names: ["PARALLEL_API_KEY"] } }) + draft.capability.search.update({ + integrationID: "parallel", + capability: { type: "search", connection: "optional" }, + execute: (input, context) => + SearchMcp.call( + http, + endpoint, + "web_search", + Args, + { + objective: input.query, + search_queries: [input.query], + session_id: context.sessionID ?? "opencode", + }, + { + "User-Agent": `opencode/${InstallationVersion}`, + ...(context.credential?.type === "key" ? { Authorization: `Bearer ${context.credential.key}` } : {}), + }, + ).pipe(Effect.map((text) => ({ text: text ?? "" }))), + }) + }) + }), +}) diff --git a/packages/core/src/search.ts b/packages/core/src/search.ts new file mode 100644 index 000000000000..a11b1a029274 --- /dev/null +++ b/packages/core/src/search.ts @@ -0,0 +1,204 @@ +export * as Search from "./search" + +import { Search } from "@opencode-ai/schema/search" +import { Context, Effect, Layer, Schema, Semaphore } from "effect" +import { Config } from "./config" +import { makeLocationNode } from "./effect/app-node" +import { Form } from "./form" +import { Integration } from "./integration" +import { truthy } from "./flag/flag" + +export const Input = Search.Input +export type Input = Search.Input + +export const ProviderOutput = Search.ProviderOutput +export type ProviderOutput = Search.ProviderOutput + +export const Result = Search.Result +export type Result = Search.Result + +export class ProviderRequiredError extends Schema.TaggedErrorClass()( + "Search.ProviderRequired", + {}, +) {} + +export class ProviderNotFoundError extends Schema.TaggedErrorClass()("Search.ProviderNotFound", { + providerID: Integration.ID, +}) {} + +export class ConnectionRequiredError extends Schema.TaggedErrorClass()( + "Search.ConnectionRequired", + { providerID: Integration.ID }, +) {} + +export class CancelledError extends Schema.TaggedErrorClass()("Search.Cancelled", {}) {} + +export class RequestError extends Schema.TaggedErrorClass()("Search.Request", { + providerID: Integration.ID, + cause: Schema.Defect(), +}) {} + +export type Error = + | ProviderRequiredError + | ProviderNotFoundError + | ConnectionRequiredError + | CancelledError + | RequestError + +export interface QueryInput extends Input { + readonly sessionID?: string +} + +export interface Interface { + readonly query: (input: QueryInput) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/Search") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const config = yield* Config.Service + const forms = yield* Form.Service + const integrations = yield* Integration.Service + const onboarding = Semaphore.makeUnsafe(1) + const decodeOutput = Schema.decodeUnknownEffect(ProviderOutput) + + const available = Effect.fn("Search.available")(function* () { + return new Map( + (yield* integrations.capability.search.list()).map((provider) => [provider.integrationID, provider]), + ) + }) + + const requireProvider = ( + providers: Map, + providerID: Integration.ID, + ) => { + const provider = providers.get(providerID) + return provider ? Effect.succeed(provider) : Effect.fail(new ProviderNotFoundError({ providerID })) + } + + const configured = Effect.fn("Search.configured")(function* () { + const providerID = Config.latest(yield* config.entries(), "search")?.provider + if (providerID) return providerID + if (process.env.OPENCODE_WEBSEARCH_PROVIDER) { + return Integration.ID.make(process.env.OPENCODE_WEBSEARCH_PROVIDER) + } + if (truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL")) { + return Integration.ID.make("parallel") + } + if (truthy("OPENCODE_EXPERIMENTAL") || truthy("OPENCODE_ENABLE_EXA") || truthy("OPENCODE_EXPERIMENTAL_EXA")) { + return Integration.ID.make("exa") + } + }) + + const ask = Effect.fn("Search.ask")(function* ( + providers: Map, + sessionID: string, + ) { + if (providers.size === 0) return yield* new ProviderRequiredError() + const infos = new Map((yield* integrations.list()).map((integration) => [integration.id, integration])) + const state = yield* forms + .ask({ + sessionID, + title: "Choose a web search provider", + metadata: { kind: "search.provider" }, + mode: "form", + fields: [ + { + key: "provider", + title: "Provider", + description: "This becomes your default and can be changed later from Connect integration.", + type: "string", + required: true, + custom: false, + options: Array.from(providers.values()) + .flatMap((provider) => { + const info = infos.get(provider.integrationID) + return info ? [{ provider, info }] : [] + }) + .toSorted((a, b) => a.info.name.localeCompare(b.info.name)) + .map(({ provider, info }) => ({ + value: info.id, + label: info.name, + description: info.connections.length + ? "Connected" + : provider.capability.connection === "optional" + ? "Keyless available" + : "Connection required", + })), + }, + ], + }) + .pipe(Effect.orDie) + if (state.status === "cancelled") return yield* new CancelledError() + const answer = state.answer.provider + if (typeof answer !== "string") return yield* new ProviderRequiredError() + return yield* requireProvider(providers, Integration.ID.make(answer)) + }) + + const connect = Effect.fn("Search.connect")(function* ( + provider: Integration.SearchImplementation, + sessionID?: string, + ) { + const active = yield* integrations.connection.active(provider.integrationID) + if (active || provider.capability.connection === "optional") return active + if (!sessionID) return yield* new ConnectionRequiredError({ providerID: provider.integrationID }) + const state = yield* forms + .ask({ + sessionID, + title: `Connect ${provider.integrationID}`, + metadata: { kind: "integration.connection" }, + mode: "integration", + integrationID: provider.integrationID, + }) + .pipe(Effect.orDie) + if (state.status === "cancelled") return yield* new CancelledError() + const connected = yield* integrations.connection.active(provider.integrationID) + if (!connected) return yield* new ConnectionRequiredError({ providerID: provider.integrationID }) + return connected + }) + + const select = Effect.fn("Search.select")(function* (input: QueryInput) { + const providers = yield* available() + if (input.providerID) return yield* requireProvider(providers, input.providerID) + const override = yield* configured() + if (override) return yield* requireProvider(providers, override) + const selected = yield* integrations.capability.search.selected() + const provider = selected ? providers.get(selected) : undefined + if (provider) return provider + const sessionID = input.sessionID + if (!sessionID) return yield* new ProviderRequiredError() + return yield* onboarding.withPermit( + Effect.gen(function* () { + const current = yield* integrations.capability.search.selected() + const selected = current ? providers.get(current) : undefined + if (selected) return selected + const provider = yield* ask(providers, sessionID) + yield* connect(provider, sessionID) + yield* integrations.capability.search.select(provider.integrationID) + return provider + }), + ) + }) + + const query = Effect.fn("Search.query")(function* (input: QueryInput) { + const provider = yield* select(input) + const connection = yield* connect(provider, input.sessionID) + const credential = connection + ? yield* integrations.connection + .resolve(connection) + .pipe(Effect.mapError((cause) => new RequestError({ providerID: provider.integrationID, cause }))) + : undefined + const output = yield* provider.execute(input, { credential, sessionID: input.sessionID }).pipe( + Effect.flatMap(decodeOutput), + Effect.mapError((cause) => new RequestError({ providerID: provider.integrationID, cause })), + ) + return new Result({ providerID: provider.integrationID, ...output }) + }) + + return Service.of({ query }) + }), +) + +export const node = makeLocationNode({ service: Service, layer, deps: [Config.node, Form.node, Integration.node] }) diff --git a/packages/core/src/tool/websearch.ts b/packages/core/src/tool/websearch.ts index d0c984b89a36..4051149bc144 100644 --- a/packages/core/src/tool/websearch.ts +++ b/packages/core/src/tool/websearch.ts @@ -2,197 +2,58 @@ export * as WebSearchTool from "./websearch" import type { PluginContext } from "@opencode-ai/plugin/v2/effect" import { ToolFailure } from "@opencode-ai/llm" -import { Context, Duration, Effect, Layer, Schema } from "effect" -import { HttpClient, HttpClientRequest } from "effect/unstable/http" -import { makeLocationNode } from "../effect/app-node" -import { truthy } from "../flag/flag" -import { InstallationVersion } from "../installation/version" +import { Effect, Schema } from "effect" +import { Integration } from "../integration" import { PositiveInt } from "../schema" import { PermissionV2 } from "../permission" +import { Search } from "../search" +import { SearchExa } from "../plugin/search/exa" +import { SearchMcp } from "../plugin/search/mcp" +import { SearchParallel } from "../plugin/search/parallel" import { Tool } from "./tool" -import { collectBoundedResponseBody } from "./http-body" -import { checksum } from "../util/encode" export const name = "websearch" export const NO_RESULTS = "No search results found. Please try a different query." -export const EXA_URL = "https://mcp.exa.ai/mcp" -export const PARALLEL_URL = "https://search.parallel.ai/mcp" +export const EXA_URL = SearchExa.endpoint +export const PARALLEL_URL = SearchParallel.endpoint export const MAX_NUM_RESULTS = 20 export const MAX_CONTEXT_CHARACTERS = 50_000 -export const MAX_RESPONSE_BYTES = 256 * 1024 +export const MAX_RESPONSE_BYTES = SearchMcp.MAX_RESPONSE_BYTES +export const parseResponse = SearchMcp.parseResponse -/** - * Provider-independent local web search retained in V2 core for launch parity. - * This invokes the legacy Exa/Parallel product backends itself. It is distinct - * from provider-hosted web search tools, which remain route-owned and execute - * at the model provider. Ownership of this compromise can be revisited later. - */ -export const description = `Search the web using the session's local web search provider. Use this for current information beyond knowledge cutoff. +export const description = `Search the web using the user's selected search integration. Use this for current information beyond knowledge cutoff. -This is a provider-independent local tool backed by Exa or Parallel. Provider-hosted web search tools are separate and execute at the model provider. - -Optional controls support result count, live crawling ('fallback' or 'preferred'), search type ('auto', 'fast', or 'deep'), and maximum context characters. +Optional controls support result count, live crawling ('fallback' or 'preferred'), search type ('auto', 'fast', or 'deep'), and maximum context characters. Providers apply supported controls and otherwise use their defaults. The current year is ${new Date().getFullYear()}. Use this year when searching for recent information or current events.` export const Input = Schema.Struct({ query: Schema.String.annotate({ description: "Websearch query" }), numResults: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_NUM_RESULTS))).annotate({ - description: `Number of search results to return (default: 8, maximum: ${MAX_NUM_RESULTS})`, + description: `Number of search results to return (maximum: ${MAX_NUM_RESULTS})`, }), livecrawl: Schema.optional(Schema.Literals(["fallback", "preferred"])).annotate({ - description: - "Live crawl mode - 'fallback': use live crawling as backup if cached unavailable, 'preferred': prioritize live crawling (default: 'fallback')", + description: "Live crawl preference when supported by the selected provider", }), type: Schema.optional(Schema.Literals(["auto", "fast", "deep"])).annotate({ - description: "Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search", + description: "Search depth preference when supported by the selected provider", }), contextMaxCharacters: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_CONTEXT_CHARACTERS))).annotate( - { - description: `Maximum characters for context string optimized for models (default: 10000, maximum: ${MAX_CONTEXT_CHARACTERS})`, - }, + { description: `Maximum context characters (maximum: ${MAX_CONTEXT_CHARACTERS})` }, ), }) -export const Provider = Schema.Literals(["exa", "parallel"]) -export type Provider = typeof Provider.Type - -export interface Config { - readonly provider?: Provider - readonly enableExa: boolean - readonly enableParallel: boolean - readonly exaApiKey?: string - readonly parallelApiKey?: string -} - -export class ConfigService extends Context.Service()("@opencode/v2/WebSearchConfig") {} - -/** Isolates the retained product environment contract from the generic tool implementation. */ -export const defaultConfigLayer = Layer.sync(ConfigService, () => - ConfigService.of({ - provider: - process.env.OPENCODE_WEBSEARCH_PROVIDER === "exa" || process.env.OPENCODE_WEBSEARCH_PROVIDER === "parallel" - ? process.env.OPENCODE_WEBSEARCH_PROVIDER - : undefined, - enableExa: truthy("OPENCODE_EXPERIMENTAL") || truthy("OPENCODE_ENABLE_EXA") || truthy("OPENCODE_EXPERIMENTAL_EXA"), - enableParallel: truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL"), - exaApiKey: process.env.EXA_API_KEY, - parallelApiKey: process.env.PARALLEL_API_KEY, - }), -) - -export const configNode = makeLocationNode({ service: ConfigService, layer: defaultConfigLayer, deps: [] }) - -export function selectProvider( - sessionID: string, - flags: Pick = { enableExa: false, enableParallel: false }, - override?: Provider, -): Provider { - if (override) return override - if (flags.enableParallel) return "parallel" - if (flags.enableExa) return "exa" - return Number.parseInt(checksum(sessionID) ?? "0", 36) % 2 === 0 ? "exa" : "parallel" -} - -const McpResult = Schema.Struct({ - result: Schema.Struct({ - content: Schema.Array(Schema.Struct({ type: Schema.String, text: Schema.String })), - }), -}) -const decodeMcpResult = Schema.decodeUnknownEffect(Schema.fromJsonString(McpResult)) - -const parsePayload = (payload: string) => - Effect.gen(function* () { - const trimmed = payload.trim() - if (!trimmed.startsWith("{")) return undefined - return (yield* decodeMcpResult(trimmed)).result.content.find((item) => item.text)?.text - }) - -export const parseResponse = Effect.fn("WebSearchTool.parseResponse")(function* (body: string) { - const trimmed = body.trim() - const direct = trimmed ? yield* parsePayload(trimmed) : undefined - if (direct) return direct - for (const line of body.split("\n")) { - if (!line.startsWith("data: ")) continue - const data = yield* parsePayload(line.substring(6)) - if (data) return data - } - return undefined -}) - -const ExaArgs = Schema.Struct({ - query: Schema.String, - type: Schema.String, - numResults: Schema.Number, - livecrawl: Schema.String, - contextMaxCharacters: Schema.optional(Schema.Number), -}) -const ParallelArgs = Schema.Struct({ - objective: Schema.String, - search_queries: Schema.Array(Schema.String), - session_id: Schema.String, -}) -const McpRequest = (args: Schema.Struct) => - Schema.Struct({ - jsonrpc: Schema.Literal("2.0"), - id: Schema.Literal(1), - method: Schema.Literal("tools/call"), - params: Schema.Struct({ name: Schema.String, arguments: args }), - }) - -const exaUrl = (apiKey: string | undefined) => { - if (!apiKey) return EXA_URL - const url = new URL(EXA_URL) - url.searchParams.set("exaApiKey", apiKey) - return url.toString() -} - -const callMcp = ( - http: HttpClient.HttpClient, - url: string, - tool: string, - args: Schema.Struct, - value: Schema.Struct.Type, - headers: Record = {}, -) => - Effect.gen(function* () { - const request = yield* HttpClientRequest.post(url).pipe( - HttpClientRequest.accept("application/json, text/event-stream"), - HttpClientRequest.setHeaders(headers), - HttpClientRequest.schemaBodyJson(McpRequest(args))({ - jsonrpc: "2.0" as const, - id: 1 as const, - method: "tools/call" as const, - params: { name: tool, arguments: value }, - }), - ) - return yield* Effect.gen(function* () { - const response = yield* HttpClient.filterStatusOk(http).execute(request) - const body = yield* collectBoundedResponseBody( - response, - MAX_RESPONSE_BYTES, - () => new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`), - ) - return yield* parseResponse(body.toString("utf8")) - }).pipe( - Effect.timeoutOrElse({ - duration: Duration.seconds(25), - orElse: () => Effect.fail(new Error(`${tool} request timed out`)), - }), - ) - }) - const Output = Schema.Struct({ - provider: Provider, + provider: Integration.ID, text: Schema.String, + metadata: Schema.optional(Schema.Json), }) export const Plugin = { id: "opencode.tool.websearch", effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) { - const http = yield* HttpClient.HttpClient - const config = yield* ConfigService const permission = yield* PermissionV2.Service + const search = yield* Search.Service yield* ctx.tool .register({ @@ -201,50 +62,24 @@ export const Plugin = { input: Input, output: Output, toModelOutput: ({ output }) => [{ type: "text", text: output.text }], - execute: (input, context) => { - const provider = selectProvider(context.sessionID, config, config.provider) - return Effect.gen(function* () { + execute: (input, context) => + Effect.gen(function* () { yield* permission.assert({ action: name, resources: [input.query], save: ["*"], - metadata: { ...input, provider }, + metadata: input, sessionID: context.sessionID, agent: context.agent, source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, }) - - const text = - provider === "exa" - ? yield* callMcp(http, exaUrl(config.exaApiKey), "web_search_exa", ExaArgs, { - query: input.query, - type: input.type || "auto", - numResults: input.numResults || 8, - livecrawl: input.livecrawl || "fallback", - contextMaxCharacters: input.contextMaxCharacters, - }) - : yield* callMcp( - http, - PARALLEL_URL, - "web_search", - ParallelArgs, - { - objective: input.query, - search_queries: [input.query], - session_id: context.sessionID, - // V2 invocation context does not safely expose the model yet. - }, - { - "User-Agent": `opencode/${InstallationVersion}`, - ...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}), - }, - ) + const result = yield* search.query({ ...input, sessionID: context.sessionID }) return { - provider, - text: text ?? NO_RESULTS, + provider: result.providerID, + text: result.text || NO_RESULTS, + metadata: result.metadata, } - }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to search the web for ${input.query}` }))) - }, + }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to search the web for ${input.query}` }))), }), }) .pipe(Effect.orDie) diff --git a/packages/core/test/form.test.ts b/packages/core/test/form.test.ts index 8defff611fa3..b8921e05697b 100644 --- a/packages/core/test/form.test.ts +++ b/packages/core/test/form.test.ts @@ -4,6 +4,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" import { Form } from "@opencode-ai/core/form" +import { Integration } from "@opencode-ai/core/integration" import { SessionSchema } from "@opencode-ai/core/session/schema" import { testEffect } from "./lib/effect" @@ -59,6 +60,27 @@ describe("Form", () => { }), ) + it.effect("uses an empty reply to complete an integration form", () => + Effect.gen(function* () { + const service = yield* Form.Service + const created = yield* service.create({ + sessionID: "ses_test", + mode: "integration", + integrationID: Integration.ID.make("exa"), + }) + + const invalid = yield* service.reply({ id: created.id, answer: { connected: true } }).pipe(Effect.flip) + expect(invalid).toEqual( + new Form.InvalidAnswerError({ + id: created.id, + message: "Integration forms must be answered with an empty answer", + }), + ) + yield* service.reply({ id: created.id, answer: {} }) + expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: {} }) + }), + ) + it.effect("gates required fields and rejects inactive answers via when", () => Effect.gen(function* () { const service = yield* Form.Service diff --git a/packages/core/test/integration.test.ts b/packages/core/test/integration.test.ts index 376d2f9523bd..08be8d78a9cf 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: [], connections: [] }), + new Integration.Info({ id: openai, name: "OpenAI", methods: [], capabilities: [], connections: [] }), ) yield* Scope.close(scope, Exit.void) diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index 8ef98c6530f5..1e83c68972cf 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -45,6 +45,7 @@ export function host(overrides: Overrides = {}): PluginContext { integration: overrides.integration ?? { list: () => Effect.die("unused integration.list"), get: () => Effect.die("unused integration.get"), + selectCapability: () => Effect.die("unused integration.selectCapability"), connectKey: () => Effect.die("unused integration.connectKey"), connectOauth: () => Effect.die("unused integration.connectOauth"), attemptStatus: () => Effect.die("unused integration.attemptStatus"), @@ -188,6 +189,7 @@ export function integrationHost(integration: Integration.Interface): PluginConte return { list: () => Effect.die("unused integration.list"), get: () => Effect.die("unused integration.get"), + selectCapability: () => Effect.die("unused integration.selectCapability"), connectKey: () => Effect.die("unused integration.connectKey"), connectOauth: () => Effect.die("unused integration.connectOauth"), attemptStatus: () => Effect.die("unused integration.attemptStatus"), @@ -281,6 +283,18 @@ export function integrationHost(integration: Integration.Interface): PluginConte }, remove: (id, item) => draft.method.remove(Integration.ID.make(id), internalMethod(item)), }, + capability: { + search: { + list: () => [], + update: (input) => + draft.capability.search.update({ + integrationID: Integration.ID.make(input.integrationID), + capability: input.capability, + execute: input.execute, + }), + remove: (id) => draft.capability.search.remove(Integration.ID.make(id)), + }, + }, }), ), } diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts index b3dfe275ba8c..c368a3a358d9 100644 --- a/packages/core/test/plugin/models-dev.test.ts +++ b/packages/core/test/plugin/models-dev.test.ts @@ -156,6 +156,7 @@ 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 fbe4029c3a2d..f14bab47e0ab 100644 --- a/packages/core/test/plugin/promise.test.ts +++ b/packages/core/test/plugin/promise.test.ts @@ -1,6 +1,7 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { AgentV2 } from "@opencode-ai/core/agent" +import { Integration } from "@opencode-ai/core/integration" import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginHost } from "@opencode-ai/core/plugin/host" import { PluginPromise } from "@opencode-ai/core/plugin/promise" @@ -93,4 +94,29 @@ describe("fromPromise", () => { expect(yield* agents.get(AgentV2.ID.make("temp"))).toBeUndefined() }), ) + + it.effect("adapts promise search capability execution", () => + Effect.gen(function* () { + const integrations = yield* Integration.Service + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make(plugin) + const promisePlugin = define({ + id: "promise-search", + setup: async (ctx) => { + await ctx.integration.transform((draft) => { + draft.capability.search.update({ + integrationID: "promise-search", + capability: { type: "search", connection: "optional" }, + execute: async (input) => ({ text: `promise: ${input.query}` }), + }) + }) + }, + }) + + yield* PluginPromise.fromPromise(promisePlugin).effect(host) + const provider = yield* integrations.capability.search.get(Integration.ID.make("promise-search")) + if (!provider) return yield* Effect.die("Expected promise search provider") + expect(yield* provider.execute({ query: "effect" }, {})).toEqual({ text: "promise: effect" }) + }), + ) }) diff --git a/packages/core/test/plugin/search-fixture.ts b/packages/core/test/plugin/search-fixture.ts new file mode 100644 index 000000000000..7472d57962b9 --- /dev/null +++ b/packages/core/test/plugin/search-fixture.ts @@ -0,0 +1,41 @@ +import { Effect, Layer } from "effect" +import { HttpClient, HttpClientResponse } from "effect/unstable/http" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Credential } from "@opencode-ai/core/credential" +import { EventV2 } from "@opencode-ai/core/event" +import { Integration } from "@opencode-ai/core/integration" +import { testEffect } from "../lib/effect" + +export interface SearchRequest { + readonly url: string + readonly headers: Record + readonly body: unknown +} + +export const requests: SearchRequest[] = [] +export const response = { body: "" } + +export function resetSearchFixture(body: string) { + requests.length = 0 + response.body = body +} + +const http = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`) + requests.push({ + url: request.url, + headers: request.headers, + body: JSON.parse(new TextDecoder().decode(request.body.body)), + }) + return HttpClientResponse.fromWeb(request, new Response(response.body, { status: 200 })) + }), + ), +) + +export const searchIntegrationTest = testEffect( + Layer.merge(AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node, EventV2.node])), http), +) diff --git a/packages/core/test/plugin/search.test.ts b/packages/core/test/plugin/search.test.ts new file mode 100644 index 000000000000..d390ab067d8b --- /dev/null +++ b/packages/core/test/plugin/search.test.ts @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect } from "bun:test" +import { Effect } from "effect" +import { Credential } from "@opencode-ai/core/credential" +import { Integration } from "@opencode-ai/core/integration" +import { SearchExa } from "@opencode-ai/core/plugin/search/exa" +import { SearchParallel } from "@opencode-ai/core/plugin/search/parallel" +import { host, integrationHost } from "./host" +import { requests, resetSearchFixture, searchIntegrationTest } from "./search-fixture" + +beforeEach(() => { + resetSearchFixture( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { content: [{ type: "text", text: "search results" }] }, + }), + ) +}) + +const it = searchIntegrationTest + +describe("built-in search integrations", () => { + it.effect("registers Exa and maps search hints to its MCP tool", () => + Effect.gen(function* () { + const integrations = yield* Integration.Service + yield* SearchExa.Plugin.effect(host({ integration: integrationHost(integrations) })) + + const info = yield* integrations.get(Integration.ID.make("exa")) + expect(info).toMatchObject({ + id: "exa", + name: "Exa", + methods: [{ type: "key" }, { type: "env", names: ["EXA_API_KEY"] }], + capabilities: [{ type: "search", connection: "optional", selected: false }], + }) + const provider = yield* integrations.capability.search.get(Integration.ID.make("exa")) + if (!provider) return yield* Effect.die("Expected Exa search provider") + expect( + yield* provider.execute( + { + query: "effect typescript", + numResults: 3, + livecrawl: "preferred", + type: "fast", + contextMaxCharacters: 2500, + }, + { credential: Credential.Key.make({ type: "key", key: "exa secret" }) }, + ), + ).toEqual({ text: "search results" }) + expect(requests).toEqual([ + { + url: `${SearchExa.endpoint}?exaApiKey=exa+secret`, + headers: expect.any(Object), + body: { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "web_search_exa", + arguments: { + query: "effect typescript", + type: "fast", + numResults: 3, + livecrawl: "preferred", + contextMaxCharacters: 2500, + }, + }, + }, + }, + ]) + }), + ) + + it.effect("registers Parallel and keeps its credential in the authorization header", () => + Effect.gen(function* () { + const integrations = yield* Integration.Service + yield* SearchParallel.Plugin.effect(host({ integration: integrationHost(integrations) })) + const provider = yield* integrations.capability.search.get(Integration.ID.make("parallel")) + if (!provider) return yield* Effect.die("Expected Parallel search provider") + + const output = yield* provider.execute( + { query: "effect layers" }, + { + sessionID: "ses_parallel", + credential: Credential.Key.make({ type: "key", key: "parallel-secret" }), + }, + ) + expect(output).toEqual({ text: "search results" }) + expect(requests[0]).toMatchObject({ + url: SearchParallel.endpoint, + headers: { authorization: "Bearer parallel-secret" }, + body: { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "web_search", + arguments: { + objective: "effect layers", + search_queries: ["effect layers"], + session_id: "ses_parallel", + }, + }, + }, + }) + expect(JSON.stringify(output)).not.toContain("parallel-secret") + }), + ) + +}) diff --git a/packages/core/test/search.test.ts b/packages/core/test/search.test.ts new file mode 100644 index 000000000000..811711ac97fa --- /dev/null +++ b/packages/core/test/search.test.ts @@ -0,0 +1,148 @@ +import { beforeEach, describe, expect } from "bun:test" +import { Effect, Exit, Fiber, Layer, Scope } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Config } from "@opencode-ai/core/config" +import { ConfigSearch } from "@opencode-ai/core/config/search" +import { Credential } from "@opencode-ai/core/credential" +import { EventV2 } from "@opencode-ai/core/event" +import { Form } from "@opencode-ai/core/form" +import { Integration } from "@opencode-ai/core/integration" +import { Search } from "@opencode-ai/core/search" +import { testEffect } from "./lib/effect" + +let entries: Config.Entry[] = [] +const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed(entries) })) +const it = testEffect( + AppNodeBuilder.build(LayerNode.group([Search.node, Integration.node, Credential.node, EventV2.node, Form.node]), [ + [Config.node, config], + ]), +) + +const register = (id: string, connection: "optional" | "required" = "optional") => + Effect.gen(function* () { + const integrations = yield* Integration.Service + const integrationID = Integration.ID.make(id) + const calls: { input: Search.Input; credential?: Credential.Value; sessionID?: string }[] = [] + yield* integrations.transform((draft) => { + draft.update(integrationID, (integration) => (integration.name = id.toUpperCase())) + draft.capability.search.update({ + integrationID, + capability: { type: "search", connection }, + execute: (input, context) => + Effect.sync(() => { + calls.push({ input, ...context }) + return { text: `${id}: ${input.query}`, metadata: { id } } + }), + }) + }) + return { integrationID, calls } + }) + +beforeEach(() => { + entries = [] +}) + +describe("Search", () => { + it.effect("executes an explicit provider without changing the default", () => + Effect.gen(function* () { + const provider = yield* register("exa") + const search = yield* Search.Service + const integrations = yield* Integration.Service + + expect(yield* search.query({ query: "effect", providerID: provider.integrationID })).toEqual( + new Search.Result({ + providerID: provider.integrationID, + text: "exa: effect", + metadata: { id: "exa" }, + }), + ) + expect(yield* integrations.capability.search.selected()).toBeUndefined() + expect(provider.calls).toEqual([ + { + input: { query: "effect", providerID: provider.integrationID }, + credential: undefined, + sessionID: undefined, + }, + ]) + }), + ) + + it.effect("uses the persisted integration capability selection", () => + Effect.gen(function* () { + yield* register("exa") + const parallel = yield* register("parallel") + const integrations = yield* Integration.Service + const search = yield* Search.Service + yield* integrations.capability.search.select(parallel.integrationID) + + expect((yield* search.query({ query: "layers" })).providerID).toBe(parallel.integrationID) + expect((yield* integrations.get(parallel.integrationID))?.capabilities).toEqual([ + { type: "search", connection: "optional", selected: true }, + ]) + }), + ) + + it.effect("prefers the location config over the global selection", () => + Effect.gen(function* () { + const exa = yield* register("exa") + const parallel = yield* register("parallel") + const integrations = yield* Integration.Service + const search = yield* Search.Service + yield* integrations.capability.search.select(exa.integrationID) + entries = [ + new Config.Document({ + type: "document", + info: new Config.Info({ search: new ConfigSearch.Info({ provider: parallel.integrationID }) }), + }), + ] + + expect((yield* search.query({ query: "configured" })).providerID).toBe(parallel.integrationID) + }), + ) + + it.effect("serializes concurrent first-use onboarding and persists the answer", () => + Effect.gen(function* () { + const provider = yield* register("exa") + const search = yield* Search.Service + const forms = yield* Form.Service + const integrations = yield* Integration.Service + const first = yield* search.query({ query: "one", sessionID: "ses_search" }).pipe(Effect.forkChild) + const second = yield* search.query({ query: "two", sessionID: "ses_search" }).pipe(Effect.forkChild) + yield* Effect.yieldNow + + const pending = yield* forms.list({ sessionID: "ses_search" }) + expect(pending).toHaveLength(1) + const form = pending[0] + if (!form) return yield* Effect.die("Expected an onboarding form") + yield* forms.reply({ id: form.id, answer: { provider: provider.integrationID } }) + + expect((yield* Fiber.join(first)).providerID).toBe(provider.integrationID) + expect((yield* Fiber.join(second)).providerID).toBe(provider.integrationID) + expect(yield* integrations.capability.search.selected()).toBe(provider.integrationID) + }), + ) + + it.effect("requires a connection before invoking a required provider", () => + Effect.gen(function* () { + const provider = yield* register("private", "required") + const search = yield* Search.Service + + expect( + yield* search.query({ query: "secret", providerID: provider.integrationID }).pipe(Effect.flip), + ).toBeInstanceOf(Search.ConnectionRequiredError) + expect(provider.calls).toEqual([]) + }), + ) + + it.effect("removes scoped provider registrations", () => + Effect.gen(function* () { + const integrations = yield* Integration.Service + const scope = yield* Scope.fork(yield* Scope.Scope) + const provider = yield* register("temporary").pipe(Scope.provide(scope)) + expect(yield* integrations.capability.search.get(provider.integrationID)).toBeDefined() + yield* Scope.close(scope, Exit.void) + expect(yield* integrations.capability.search.get(provider.integrationID)).toBeUndefined() + }), + ) +}) diff --git a/packages/core/test/tool-websearch.test.ts b/packages/core/test/tool-websearch.test.ts index 53984ef69b70..eea7534ebdaf 100644 --- a/packages/core/test/tool-websearch.test.ts +++ b/packages/core/test/tool-websearch.test.ts @@ -1,22 +1,22 @@ import { beforeEach, describe, expect, test } from "bun:test" import { Effect, Layer, Schema } from "effect" -import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" import { PermissionV2 } from "@opencode-ai/core/permission" +import { Integration } from "@opencode-ai/core/integration" +import { Search } from "@opencode-ai/core/search" import { SessionV2 } from "@opencode-ai/core/session" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { WebSearchTool } from "@opencode-ai/core/tool/websearch" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { makeLocationNode } from "@opencode-ai/core/effect/app-node" import { testEffect } from "./lib/effect" -import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool" +import { executeTool, registerToolPlugin, settleTool, toolDefinitions, toolIdentity } from "./lib/tool" const webSearchToolNode = makeLocationNode({ name: "test/websearch-tool-plugin", layer: Layer.effectDiscard(registerToolPlugin(WebSearchTool.Plugin)), - deps: [ToolRegistry.toolsNode, PermissionV2.node, LayerNodePlatform.httpClient, WebSearchTool.configNode], + deps: [ToolRegistry.toolsNode, PermissionV2.node, Search.node], }) const sessionID = SessionV2.ID.make("ses_websearch_test") @@ -27,31 +27,13 @@ const payload = (text: string) => result: { content: [{ type: "text", text }] }, }) -describe("WebSearchTool provider selection", () => { +describe("WebSearchTool input", () => { test("rejects out-of-range numeric controls", () => { const decode = Schema.decodeUnknownSync(WebSearchTool.Input) expect(() => decode({ query: "x", numResults: 0 })).toThrow() expect(() => decode({ query: "x", numResults: WebSearchTool.MAX_NUM_RESULTS + 1 })).toThrow() expect(() => decode({ query: "x", contextMaxCharacters: WebSearchTool.MAX_CONTEXT_CHARACTERS + 1 })).toThrow() }) - test("selects a stable provider per session", () => { - expect(WebSearchTool.selectProvider(sessionID)).toBe(WebSearchTool.selectProvider(sessionID)) - }) - - test("supports an explicit operational override", () => { - expect(WebSearchTool.selectProvider(sessionID, { enableExa: false, enableParallel: false }, "parallel")).toBe( - "parallel", - ) - expect(WebSearchTool.selectProvider(sessionID, { enableExa: false, enableParallel: false }, "exa")).toBe("exa") - }) - - test("prefers Parallel when both explicit flags are enabled", () => { - expect(WebSearchTool.selectProvider(sessionID, { enableExa: true, enableParallel: true })).toBe("parallel") - }) - - test("prefers Exa when only its explicit flag is enabled", () => { - expect(WebSearchTool.selectProvider(sessionID, { enableExa: true, enableParallel: false })).toBe("exa") - }) }) describe("WebSearchTool MCP response parser", () => { @@ -68,37 +50,16 @@ describe("WebSearchTool MCP response parser", () => { }) }) -interface Request { - readonly url: string - readonly headers: Record - readonly body: unknown -} - -const requests: Request[] = [] const assertions: PermissionV2.AssertInput[] = [] -let responseBody = payload("search results") -let makeResponse = () => new Response(responseBody, { status: 200 }) -let config: WebSearchTool.Config = { enableExa: false, enableParallel: false } +const queries: Search.QueryInput[] = [] +let result = new Search.Result({ providerID: Integration.ID.make("exa"), text: "search results" }) beforeEach(() => { - responseBody = payload("search results") - makeResponse = () => new Response(responseBody, { status: 200 }) + assertions.length = 0 + queries.length = 0 + result = new Search.Result({ providerID: Integration.ID.make("exa"), text: "search results" }) }) -const http = Layer.succeed( - HttpClient.HttpClient, - HttpClient.make((request) => - Effect.sync(() => { - if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`) - requests.push({ - url: request.url, - headers: request.headers, - body: JSON.parse(new TextDecoder().decode(request.body.body)), - }) - return HttpClientResponse.fromWeb(request, makeResponse()) - }), - ), -) const permission = Layer.succeed( PermissionV2.Service, PermissionV2.Service.of({ @@ -110,45 +71,27 @@ const permission = Layer.succeed( list: () => Effect.die("unused"), }), ) -const websearchConfig = Layer.succeed( - WebSearchTool.ConfigService, - WebSearchTool.ConfigService.of({ - get provider() { - return config.provider - }, - get enableExa() { - return config.enableExa - }, - get enableParallel() { - return config.enableParallel - }, - get exaApiKey() { - return config.exaApiKey - }, - get parallelApiKey() { - return config.parallelApiKey - }, +const search = Layer.succeed( + Search.Service, + Search.Service.of({ + query: (input) => + Effect.sync(() => { + queries.push(input) + return result + }), }), ) const it = testEffect( - AppNodeBuilder.build( - LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebSearchTool.configNode, webSearchToolNode]), - [ - [PermissionV2.node, permission], - [LayerNodePlatform.httpClient, http], - [WebSearchTool.configNode, websearchConfig], - [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], - ], - ), + AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, Search.node, webSearchToolNode]), [ + [PermissionV2.node, permission], + [Search.node, search], + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + ]), ) describe("WebSearchTool registration", () => { - it.effect("registers websearch, asserts query permission, and calls Exa", () => + it.effect("asserts permission before delegating to Search", () => Effect.gen(function* () { - requests.length = 0 - assertions.length = 0 - responseBody = payload("exa results") - config = { provider: "exa", enableExa: false, enableParallel: false } const registry = yield* ToolRegistry.Service expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["websearch"]) @@ -158,7 +101,7 @@ describe("WebSearchTool registration", () => { ...toolIdentity, call: { type: "tool-call", - id: "call-exa", + id: "call-search", name: "websearch", input: { query: "effect typescript", @@ -169,7 +112,7 @@ describe("WebSearchTool registration", () => { }, }, }), - ).toEqual({ type: "text", value: "exa results" }) + ).toEqual({ type: "text", value: "search results" }) expect(assertions).toMatchObject([ { sessionID, @@ -182,98 +125,50 @@ describe("WebSearchTool registration", () => { livecrawl: "preferred", type: "fast", contextMaxCharacters: 2500, - provider: "exa", }, }, ]) - expect(requests).toEqual([ + expect(queries).toEqual([ { - url: WebSearchTool.EXA_URL, - headers: expect.any(Object), - body: { - jsonrpc: "2.0", - id: 1, - method: "tools/call", - params: { - name: "web_search_exa", - arguments: { - query: "effect typescript", - type: "fast", - numResults: 3, - livecrawl: "preferred", - contextMaxCharacters: 2500, - }, - }, - }, + sessionID, + query: "effect typescript", + numResults: 3, + livecrawl: "preferred", + type: "fast", + contextMaxCharacters: 2500, }, ]) }), ) - it.effect("calls Parallel with session ID and keeps bearer credentials out of output", () => + it.effect("keeps provider metadata in structured output", () => Effect.gen(function* () { - requests.length = 0 - assertions.length = 0 - responseBody = payload("parallel results") - config = { provider: "parallel", enableExa: false, enableParallel: false, parallelApiKey: "parallel-secret" } - const registry = yield* ToolRegistry.Service - - const settled = yield* settleTool(registry, { - sessionID, - ...toolIdentity, - call: { type: "tool-call", id: "call-parallel", name: "websearch", input: { query: "effect layers" } }, + result = new Search.Result({ + providerID: Integration.ID.make("parallel"), + text: "parallel results", + metadata: { requestID: "req_1" }, }) + const registry = yield* ToolRegistry.Service - expect(requests[0]).toMatchObject({ - url: WebSearchTool.PARALLEL_URL, - headers: { authorization: "Bearer parallel-secret" }, - body: { - jsonrpc: "2.0", - id: 1, - method: "tools/call", - params: { - name: "web_search", - arguments: { objective: "effect layers", search_queries: ["effect layers"], session_id: sessionID }, - }, - }, - }) - expect(requests[0]?.body).not.toHaveProperty("params.arguments.model_name") - expect(settled).toEqual({ + expect( + yield* settleTool(registry, { + sessionID, + ...toolIdentity, + call: { type: "tool-call", id: "call-parallel", name: "websearch", input: { query: "effect layers" } }, + }), + ).toEqual({ result: { type: "text", value: "parallel results" }, output: { - structured: { provider: "parallel", text: "parallel results" }, + structured: { provider: "parallel", text: "parallel results", metadata: { requestID: "req_1" } }, content: [{ type: "text", text: "parallel results" }], }, }) - expect(JSON.stringify(settled)).not.toContain("parallel-secret") }), ) - it.effect("keeps an Exa credential in the transport URL and out of model output", () => + it.effect("uses the concise no-results fallback", () => Effect.gen(function* () { - requests.length = 0 - assertions.length = 0 - responseBody = payload("credentialed exa results") - config = { provider: "exa", enableExa: false, enableParallel: false, exaApiKey: "exa secret" } - const registry = yield* ToolRegistry.Service - - const settled = yield* settleTool(registry, { - sessionID, - ...toolIdentity, - call: { type: "tool-call", id: "call-exa-key", name: "websearch", input: { query: "effect schema" } }, - }) - - expect(requests[0]?.url).toBe(`${WebSearchTool.EXA_URL}?exaApiKey=exa+secret`) - expect(JSON.stringify(settled)).not.toContain("exa secret") - }), - ) - - it.effect("returns the legacy no-results fallback as concise model text", () => - Effect.gen(function* () { - requests.length = 0 - assertions.length = 0 - responseBody = "" - config = { provider: "exa", enableExa: false, enableParallel: false } + result = new Search.Result({ providerID: Integration.ID.make("exa"), text: "" }) const registry = yield* ToolRegistry.Service expect( @@ -285,39 +180,4 @@ describe("WebSearchTool registration", () => { ).toEqual({ type: "text", value: WebSearchTool.NO_RESULTS }) }), ) - - it.effect("rejects oversized MCP response bodies", () => - Effect.gen(function* () { - requests.length = 0 - assertions.length = 0 - let chunksRead = 0 - let cancelled = false - makeResponse = () => - new Response( - new ReadableStream({ - pull(controller) { - chunksRead++ - if (chunksRead === 10) throw new Error("response was not stopped at the byte limit") - controller.enqueue(new Uint8Array(64 * 1024)) - }, - cancel() { - cancelled = true - }, - }), - { status: 200 }, - ) - config = { provider: "exa", enableExa: false, enableParallel: false } - const registry = yield* ToolRegistry.Service - - expect( - yield* executeTool(registry, { - sessionID, - ...toolIdentity, - call: { type: "tool-call", id: "call-large-response", name: "websearch", input: { query: "too much" } }, - }), - ).toEqual({ type: "error", value: "Unable to search the web for too much" }) - expect(chunksRead).toBeLessThan(10) - expect(cancelled).toBe(true) - }), - ) }) diff --git a/packages/plugin/src/v2/effect/index.ts b/packages/plugin/src/v2/effect/index.ts index 2ebd0215b5a4..3c2533a4950d 100644 --- a/packages/plugin/src/v2/effect/index.ts +++ b/packages/plugin/src/v2/effect/index.ts @@ -6,7 +6,12 @@ export type { AISDKHooks } from "./aisdk.js" export type { CatalogDraft, CatalogHooks, CatalogProviderRecord } from "./catalog.js" export type { CommandDraft, CommandHooks } from "./command.js" export type { EventHooks } from "./event.js" -export type { IntegrationDraft, IntegrationHooks, IntegrationMethodRegistration } from "./integration.js" +export type { + IntegrationDraft, + IntegrationHooks, + IntegrationMethodRegistration, + IntegrationSearchCapabilityRegistration, +} from "./integration.js" export type { ReferenceDraft, ReferenceHooks } from "./reference.js" export type { SkillDraft, SkillHooks } from "./skill.js" export * as Tool from "./tool.js" diff --git a/packages/plugin/src/v2/effect/integration.ts b/packages/plugin/src/v2/effect/integration.ts index 1433786634e1..0665b39b2d96 100644 --- a/packages/plugin/src/v2/effect/integration.ts +++ b/packages/plugin/src/v2/effect/integration.ts @@ -10,6 +10,7 @@ import type { IntegrationRef, } from "@opencode-ai/sdk/v2/types" import type { IntegrationApi } from "@opencode-ai/client/effect/api" +import type { Search } from "@opencode-ai/schema/search" import type { Effect, Scope } from "effect" import type { TransformHook } from "./registration.js" @@ -44,6 +45,18 @@ export type IntegrationMethodRegistration = readonly method: IntegrationEnvMethod } +export interface IntegrationSearchCapabilityRegistration { + readonly integrationID: string + readonly capability: { + readonly type: "search" + readonly connection: "optional" | "required" + } + readonly execute: ( + input: Search.Input, + context: { readonly credential?: CredentialValue; readonly sessionID?: string }, + ) => Effect.Effect +} + export interface IntegrationDraft { list(): readonly IntegrationRef[] get(id: string): IntegrationRef | undefined @@ -54,6 +67,13 @@ export interface IntegrationDraft { update(input: IntegrationMethodRegistration): void remove(integrationID: string, method: IntegrationMethod): void } + readonly capability: { + readonly search: { + list(): readonly IntegrationSearchCapabilityRegistration[] + update(input: IntegrationSearchCapabilityRegistration): void + remove(integrationID: string): void + } + } } export interface IntegrationHooks extends IntegrationApi { diff --git a/packages/plugin/src/v2/promise/index.ts b/packages/plugin/src/v2/promise/index.ts index 594ff7da3c42..1206e04c26d4 100644 --- a/packages/plugin/src/v2/promise/index.ts +++ b/packages/plugin/src/v2/promise/index.ts @@ -7,7 +7,12 @@ export type { AISDKHooks } from "./aisdk.js" export type { CatalogDraft, CatalogHooks, CatalogProviderRecord } from "./catalog.js" export type { CommandDraft, CommandHooks } from "./command.js" export type { EventHooks } from "./event.js" -export type { IntegrationDraft, IntegrationHooks, IntegrationMethodRegistration } from "./integration.js" +export type { + IntegrationDraft, + IntegrationHooks, + IntegrationMethodRegistration, + IntegrationSearchCapabilityRegistration, +} from "./integration.js" export type { ReferenceDraft, ReferenceHooks } from "./reference.js" export type { SessionHooks } from "./runtime.js" export type { SkillDraft, SkillHooks } from "./skill.js" diff --git a/packages/plugin/src/v2/promise/integration.ts b/packages/plugin/src/v2/promise/integration.ts index bd133e889eaf..67e820981767 100644 --- a/packages/plugin/src/v2/promise/integration.ts +++ b/packages/plugin/src/v2/promise/integration.ts @@ -1,9 +1,32 @@ import type { IntegrationApi } from "@opencode-ai/client/promise/api" -import type { IntegrationDraft, IntegrationMethodRegistration } from "../effect/integration.js" +import type { IntegrationMethodRegistration } from "../effect/integration.js" import type { CredentialValue } from "@opencode-ai/sdk/v2/types" +import type { Search } from "@opencode-ai/schema/search" import type { TransformHook } from "./registration.js" -export type { IntegrationDraft, IntegrationMethodRegistration } +export type { IntegrationMethodRegistration } + +export interface IntegrationSearchCapabilityRegistration { + readonly integrationID: string + readonly capability: { + readonly type: "search" + readonly connection: "optional" | "required" + } + readonly execute: ( + input: Search.Input, + context: { readonly credential?: CredentialValue; readonly sessionID?: string; readonly signal: AbortSignal }, + ) => Promise +} + +export interface IntegrationDraft extends Omit { + readonly capability: { + readonly search: { + list(): readonly IntegrationSearchCapabilityRegistration[] + update(input: IntegrationSearchCapabilityRegistration): void + remove(integrationID: string): void + } + } +} export interface IntegrationHooks extends IntegrationApi { readonly transform: TransformHook diff --git a/packages/protocol/src/client.ts b/packages/protocol/src/client.ts index 3d2993a02bf3..58f974184218 100644 --- a/packages/protocol/src/client.ts +++ b/packages/protocol/src/client.ts @@ -63,6 +63,7 @@ export const groupNames = { export const endpointNames = { "session.messages": "list", "integration.connect.key": "connectKey", + "integration.capability.select": "selectCapability", "integration.connect.oauth": "connectOauth", "integration.attempt.status": "attemptStatus", "integration.attempt.complete": "attemptComplete", diff --git a/packages/protocol/src/groups/integration.ts b/packages/protocol/src/groups/integration.ts index a6ec4d528205..9d6ff5f6934e 100644 --- a/packages/protocol/src/groups/integration.ts +++ b/packages/protocol/src/groups/integration.ts @@ -37,6 +37,23 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration") }), ), ) + .add( + HttpApiEndpoint.post("integration.capability.select", "/api/integration/:integrationID/capability", { + params: { integrationID: Integration.ID }, + query: LocationQuery, + payload: Schema.Struct({ capability: Schema.Literal("search") }), + success: HttpApiSchema.NoContent, + error: InvalidRequestError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.integration.capability.select", + summary: "Select integration capability", + description: "Set the default integration for a capability.", + }), + ), + ) .add( HttpApiEndpoint.post("integration.connect.key", "/api/integration/:integrationID/connect/key", { params: { integrationID: Integration.ID }, diff --git a/packages/schema/src/form.ts b/packages/schema/src/form.ts index df9d455f1390..65cb536cac45 100644 --- a/packages/schema/src/form.ts +++ b/packages/schema/src/form.ts @@ -3,6 +3,7 @@ export * as Form from "./form.js" import { Schema } from "effect" import { ephemeral, inventory } from "./event.js" import { ascending } from "./identifier.js" +import { IntegrationID } from "./integration-id.js" import { NonNegativeInt, optional, statics } from "./schema.js" const IDSchema = Schema.String.check(Schema.isStartsWith("frm_")).pipe(Schema.brand("Form.ID")) @@ -124,8 +125,15 @@ export const UrlInfo = Schema.Struct({ }).annotate({ identifier: "Form.UrlInfo" }) export interface UrlInfo extends Schema.Schema.Type {} -export const Info = Schema.Union([FormInfo, UrlInfo]).pipe(Schema.toTaggedUnion("mode")) -export type Info = FormInfo | UrlInfo +export const IntegrationInfo = Schema.Struct({ + ...InfoBase, + mode: Schema.Literal("integration"), + integrationID: IntegrationID, +}).annotate({ identifier: "Form.IntegrationInfo" }) +export interface IntegrationInfo extends Schema.Schema.Type {} + +export const Info = Schema.Union([FormInfo, UrlInfo, IntegrationInfo]).pipe(Schema.toTaggedUnion("mode")) +export type Info = FormInfo | UrlInfo | IntegrationInfo export const Value = Schema.Union([Schema.String, Schema.Number, Schema.Boolean, Schema.Array(Schema.String)]).annotate( { diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index 1454fab1cf42..527c1fdd11b5 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -16,6 +16,7 @@ export { Project } from "./project.js" export { ProjectCopy } from "./project-copy.js" export { Provider } from "./provider.js" export { Reference } from "./reference.js" +export { Search } from "./search.js" export { Revert } from "./revert.js" export { Session } from "./session.js" export { Vcs } from "./vcs.js" diff --git a/packages/schema/src/integration.ts b/packages/schema/src/integration.ts index 329bf8207ffa..66e73e0315c4 100644 --- a/packages/schema/src/integration.ts +++ b/packages/schema/src/integration.ts @@ -76,6 +76,16 @@ export type Method = typeof Method.Type export const Inputs = Schema.Record(Schema.String, Schema.String).annotate({ identifier: "Integration.Inputs" }) export type Inputs = typeof Inputs.Type +export interface SearchCapability extends Schema.Schema.Type {} +export const SearchCapability = Schema.Struct({ + type: Schema.Literal("search"), + connection: Schema.Literals(["optional", "required"]), + selected: Schema.Boolean, +}).annotate({ identifier: "Integration.SearchCapability" }) + +export const Capability = SearchCapability +export type Capability = SearchCapability + const Updated = ephemeral({ type: "integration.updated", schema: {}, @@ -96,6 +106,7 @@ export class Info extends Schema.Class("Integration.Info")({ id: ID, name: Schema.String, methods: Schema.Array(Method), + capabilities: Schema.Array(Capability), connections: Schema.Array(Connection.Info), }) {} diff --git a/packages/schema/src/search.ts b/packages/schema/src/search.ts new file mode 100644 index 000000000000..5b93fbf966e6 --- /dev/null +++ b/packages/schema/src/search.ts @@ -0,0 +1,26 @@ +export * as Search from "./search.js" + +import { Schema } from "effect" +import { IntegrationID } from "./integration-id.js" +import { optional, PositiveInt } from "./schema.js" + +export interface Input extends Schema.Schema.Type {} +export const Input = Schema.Struct({ + query: Schema.String, + providerID: IntegrationID.pipe(optional), + numResults: PositiveInt.check(Schema.isLessThanOrEqualTo(20)).pipe(optional), + livecrawl: Schema.Literals(["fallback", "preferred"]).pipe(optional), + type: Schema.Literals(["auto", "fast", "deep"]).pipe(optional), + contextMaxCharacters: PositiveInt.check(Schema.isLessThanOrEqualTo(50_000)).pipe(optional), +}).annotate({ identifier: "Search.Input" }) + +export interface ProviderOutput extends Schema.Schema.Type {} +export const ProviderOutput = Schema.Struct({ + text: Schema.String, + metadata: Schema.Json.pipe(optional), +}).annotate({ identifier: "Search.ProviderOutput" }) + +export class Result extends Schema.Class("Search.Result")({ + providerID: IntegrationID, + ...ProviderOutput.fields, +}) {} diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 59484dddfb5a..20da64a59c25 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -300,6 +300,8 @@ import type { V2IntegrationAttemptCompleteResponses, V2IntegrationAttemptStatusErrors, V2IntegrationAttemptStatusResponses, + V2IntegrationCapabilitySelectErrors, + V2IntegrationCapabilitySelectResponses, V2IntegrationConnectKeyErrors, V2IntegrationConnectKeyResponses, V2IntegrationConnectOauthErrors, @@ -334,6 +336,8 @@ import type { V2ProjectCurrentResponses, V2ProjectDirectoriesErrors, V2ProjectDirectoriesResponses, + V2ProjectListErrors, + V2ProjectListResponses, V2ProviderGetErrors, V2ProviderGetResponses, V2ProviderListErrors, @@ -6645,6 +6649,52 @@ 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 @@ -6919,6 +6969,11 @@ export class Integration extends HeyApiClient { }) } + private _capability?: Capability + get capability(): Capability { + return (this._capability ??= new Capability({ client: this.client })) + } + private _connect?: Connect get connect(): Connect { return (this._connect ??= new Connect({ client: this.client })) @@ -7032,6 +7087,18 @@ export class Credential extends HeyApiClient { } export class Project2 extends HeyApiClient { + /** + * List projects + * + * List known projects. + */ + public list(options?: Options) { + return (options?.client ?? this.client).get({ + url: "/api/project", + ...options, + }) + } + /** * Get current project * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 4d2cf1922c40..7648445ea762 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1447,7 +1447,7 @@ export type GlobalEvent = { id: string type: "form.created" properties: { - form: FormFormInfo | FormUrlInfo + form: FormFormInfo | FormUrlInfo | FormIntegrationInfo } } | { @@ -3476,6 +3476,15 @@ export type FormUrlInfo = { url: string } +export type FormIntegrationInfo = { + id: string + sessionID: string + title?: string + metadata?: FormMetadata + mode: "integration" + integrationID: string +} + export type FormValue = | string | number @@ -5362,6 +5371,12 @@ export type IntegrationEnvMethod = { names: Array } +export type IntegrationSearchCapability = { + type: "search" + connection: "optional" | "required" + selected: boolean +} + export type ConnectionCredentialInfo = { type: "credential" id: string @@ -5379,6 +5394,7 @@ export type IntegrationInfo = { id: string name: string methods: Array + capabilities: Array connections: Array } @@ -6212,7 +6228,7 @@ export type FormCreated = { type: "form.created" location?: LocationRef data: { - form: FormFormInfo | FormUrlInfo + form: FormFormInfo | FormUrlInfo | FormIntegrationInfo } } @@ -7456,7 +7472,7 @@ export type EventFormCreated = { id: string type: "form.created" properties: { - form: FormFormInfo | FormUrlInfo + form: FormFormInfo | FormUrlInfo | FormIntegrationInfo } } @@ -9229,6 +9245,12 @@ export type IntegrationEnvMethod2 = { export type IntegrationMethod2 = IntegrationOAuthMethod2 | IntegrationKeyMethod2 | IntegrationEnvMethod2 +export type IntegrationSearchCapability2 = { + type: "search" + connection: "optional" | "required" + selected: boolean +} + export type ConnectionCredentialInfo2 = { type: "credential" id: string @@ -9246,6 +9268,7 @@ export type IntegrationInfo2 = { id: string name: string methods: Array + capabilities: Array connections: Array } @@ -9329,6 +9352,38 @@ export type McpServer2 = { integrationID?: string } +export type ProjectVcs2 = "git" | "hg" + +export type ProjectIcon2 = { + url?: string + override?: string + color?: string +} + +export type ProjectCommands2 = { + /** + * Startup script to run when creating a new workspace (worktree) + */ + start?: string +} + +export type ProjectTime2 = { + created: number + updated: number + initialized?: number +} + +export type ProjectV2 = { + id: string + worktree: string + vcs?: ProjectVcs2 + name?: string + icon?: ProjectIcon2 + commands?: ProjectCommands2 + time: ProjectTime2 + sandboxes: Array +} + export type ProjectCurrent2 = { id: string directory: string @@ -9440,6 +9495,15 @@ export type FormUrlInfo2 = { url: string } +export type FormIntegrationInfo2 = { + id: string + sessionID: string + title?: string + metadata?: FormMetadata2 + mode: "integration" + integrationID: string +} + export type FormCreatePayload2 = { id?: string | null title?: string @@ -10764,6 +10828,15 @@ export type FormUrlInfo1 = { url: string } +export type FormIntegrationInfo1 = { + id: string + sessionID: string + title?: string + metadata?: FormMetadata1 + mode: "integration" + integrationID: string +} + export type FormCreated2 = { id: string created: number @@ -10773,7 +10846,7 @@ export type FormCreated2 = { type: "form.created" location?: LocationRef2 data: { - form: FormFormInfo1 | FormUrlInfo1 + form: FormFormInfo1 | FormUrlInfo1 | FormIntegrationInfo1 } } @@ -17040,6 +17113,46 @@ export type V2IntegrationGetResponses = { export type V2IntegrationGetResponse = V2IntegrationGetResponses[keyof V2IntegrationGetResponses] +export type V2IntegrationCapabilitySelectData = { + body: { + capability: "search" + } + path: { + integrationID: string + } + query?: { + location?: { + directory?: string | null + workspace?: string | null + } | null + } + url: "/api/integration/{integrationID}/capability" +} + +export type V2IntegrationCapabilitySelectErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError1 | InvalidRequestErrorV2 + /** + * UnauthorizedError + */ + 401: UnauthorizedErrorV2 +} + +export type V2IntegrationCapabilitySelectError = + V2IntegrationCapabilitySelectErrors[keyof V2IntegrationCapabilitySelectErrors] + +export type V2IntegrationCapabilitySelectResponses = { + /** + * + */ + 204: void +} + +export type V2IntegrationCapabilitySelectResponse = + V2IntegrationCapabilitySelectResponses[keyof V2IntegrationCapabilitySelectResponses] + export type V2IntegrationConnectKeyData = { body: { key: string @@ -17353,6 +17466,35 @@ export type V2CredentialUpdateResponses = { export type V2CredentialUpdateResponse = V2CredentialUpdateResponses[keyof V2CredentialUpdateResponses] +export type V2ProjectListData = { + body?: never + path?: never + query?: never + url: "/api/project" +} + +export type V2ProjectListErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestErrorV2 + /** + * UnauthorizedError + */ + 401: UnauthorizedErrorV2 +} + +export type V2ProjectListError = V2ProjectListErrors[keyof V2ProjectListErrors] + +export type V2ProjectListResponses = { + /** + * Success + */ + 200: Array +} + +export type V2ProjectListResponse = V2ProjectListResponses[keyof V2ProjectListResponses] + export type V2ProjectCurrentData = { body?: never path?: never @@ -17454,7 +17596,7 @@ export type V2FormRequestListResponses = { */ 200: { location: LocationInfo2 - data: Array + data: Array } } @@ -17491,7 +17633,7 @@ export type V2SessionFormListResponses = { * Success */ 200: { - data: Array + data: Array } } @@ -17532,7 +17674,7 @@ export type V2SessionFormCreateResponses = { * Success */ 200: { - data: FormFormInfo2 | FormUrlInfo2 + data: FormFormInfo2 | FormUrlInfo2 | FormIntegrationInfo2 } } @@ -17570,7 +17712,7 @@ export type V2SessionFormGetResponses = { * Success */ 200: { - data: FormFormInfo2 | FormUrlInfo2 + data: FormFormInfo2 | FormUrlInfo2 | FormIntegrationInfo2 } } diff --git a/packages/server/src/handlers/integration.ts b/packages/server/src/handlers/integration.ts index 6c29d5877607..0a00f7599e17 100644 --- a/packages/server/src/handlers/integration.ts +++ b/packages/server/src/handlers/integration.ts @@ -33,6 +33,21 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration" return yield* response(service.get(ctx.params.integrationID)) }), ) + .handle( + "integration.capability.select", + Effect.fn(function* (ctx) { + const service = yield* Integration.Service + const integration = yield* service.get(ctx.params.integrationID) + if (!integration?.capabilities.some((capability) => capability.type === ctx.payload.capability)) { + return yield* new InvalidRequestError({ + message: `Capability not found: ${ctx.payload.capability}`, + kind: "integration_capability_not_found", + }) + } + yield* service.capability.search.select(ctx.params.integrationID) + return HttpApiSchema.NoContent.make() + }), + ) .handle( "integration.connect.key", Effect.fn(function* (ctx) { diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx index 52aba6d0e3ce..c9cea13b101b 100644 --- a/packages/tui/src/component/dialog-integration.tsx +++ b/packages/tui/src/component/dialog-integration.tsx @@ -53,28 +53,46 @@ export function connectionSummary(integration: IntegrationInfo) { .join(", ") } -export function DialogIntegration(props: { onConnected?: OnIntegrationConnected } = {}) { +export function DialogIntegration( + props: { onConnected?: OnIntegrationConnected; integrationID?: string; connectionOnly?: boolean } = {}, +) { const data = useData() const dialog = useDialog() + const sdk = useSDK() + const toast = useToast() const { theme } = useTheme() const options = createMemo(() => - integrationOptions(data.location.integration.list() ?? []).map((integration) => { - const methods = connectMethods(integration) - const connected = integration.connections.length > 0 - return { - title: integration.name, - value: integration.id, - description: methods.length ? undefined : "Environment only", - footer: connectionSummary(integration) || undefined, - category: integration.id in INTEGRATION_PRIORITY ? "Popular" : "Services", - disabled: methods.length === 0, - gutter: connected ? () => โœ“ : undefined, - onSelect: () => - credentialConnections(integration).length - ? manageConnections(integration, methods, dialog, props.onConnected) - : selectMethod(integration, methods, dialog, props.onConnected), - } - }), + integrationOptions(data.location.integration.list() ?? []) + .filter((integration) => props.integrationID === undefined || integration.id === props.integrationID) + .map((integration) => { + const methods = connectMethods(integration) + const connected = integration.connections.length > 0 + const search = integration.capabilities.find((capability) => capability.type === "search") + return { + title: integration.name, + value: integration.id, + description: + search?.connection === "optional" ? "API key optional" : methods.length ? undefined : "Environment only", + footer: + [connectionSummary(integration), search?.selected ? "Web search default" : undefined] + .filter((value) => value !== undefined && value.length > 0) + .join(" ยท ") || undefined, + category: search ? "Web search" : integration.id in INTEGRATION_PRIORITY ? "Popular" : "Services", + disabled: methods.length === 0 && !search, + gutter: connected ? () => โœ“ : undefined, + onSelect: () => { + if (props.connectionOnly) { + return credentialConnections(integration).length + ? manageConnections(integration, methods, dialog, props.onConnected) + : selectMethod(integration, methods, dialog, props.onConnected) + } + if (search) return manageIntegration(integration, methods, search, data, sdk, dialog, toast) + return credentialConnections(integration).length + ? manageConnections(integration, methods, dialog, props.onConnected) + : selectMethod(integration, methods, dialog, props.onConnected) + }, + } + }), ) return ( @@ -90,6 +108,61 @@ export function DialogIntegration(props: { onConnected?: OnIntegrationConnected ) } +function manageIntegration( + integration: IntegrationInfo, + methods: ConnectMethod[], + search: IntegrationInfo["capabilities"][number], + data: ReturnType, + sdk: ReturnType, + dialog: ReturnType, + toast: ReturnType, +) { + const connected = integration.connections.length > 0 + const select = () => { + void sdk.api.integration + .selectCapability({ + integrationID: integration.id, + capability: "search", + location: location(data), + }) + .then(async () => { + await data.location.integration.refresh() + toast.show({ variant: "success", message: `${integration.name} is now the web search default` }) + dialog.clear() + }) + .catch(toast.error) + } + + dialog.replace(() => ( + selectMethod(integration, methods, dialog, select) + : select, + }, + ...(methods.length + ? [ + { + title: credentialConnections(integration).length ? "Manage connections" : "Connect", + value: "connect", + onSelect: () => + credentialConnections(integration).length + ? manageConnections(integration, methods, dialog) + : selectMethod(integration, methods, dialog), + }, + ] + : []), + ]} + /> + )) +} + function manageConnections( integration: IntegrationInfo, methods: ConnectMethod[], diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index d9b29c1fdd80..830447f2382d 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -2,6 +2,7 @@ import type { AgentV2Info, CommandV2Info, FormFormInfo, + FormIntegrationInfo, FormUrlInfo, IntegrationInfo, LocationRef, @@ -30,7 +31,7 @@ export type DataSessionStatus = "idle" | "running" const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_") -export type FormInfo = FormFormInfo | FormUrlInfo +export type FormInfo = FormFormInfo | FormUrlInfo | FormIntegrationInfo type LocationData = { agent?: AgentV2Info[] diff --git a/packages/tui/src/routes/session/form.tsx b/packages/tui/src/routes/session/form.tsx index e6f7a4818c83..42f45f2c743c 100644 --- a/packages/tui/src/routes/session/form.tsx +++ b/packages/tui/src/routes/session/form.tsx @@ -10,6 +10,8 @@ import { useSDK } from "../../context/sdk" import { SplitBorder } from "../../ui/border" import { useTuiConfig } from "../../config" import { useBindings, useOpencodeModeStack } from "../../keymap" +import { DialogIntegration } from "../../component/dialog-integration" +import { useDialog } from "../../ui/dialog" const FORM_MODE = "form" @@ -131,7 +133,50 @@ function display(field: Field, value: FormValue | undefined) { } export function FormPrompt(props: { form: FormInfo }) { - return props.form.mode === "url" ? : + if (props.form.mode === "url") return + if (props.form.mode === "integration") return + return +} + +function IntegrationPrompt(props: { form: FormInfo & { mode: "integration" } }) { + const sdk = useSDK() + const dialog = useDialog() + const { theme } = useTheme() + let settled = false + + onMount(() => { + dialog.replace( + () => ( + { + settled = true + dialog.clear() + void sdk.api.form.reply({ sessionID: props.form.sessionID, formID: props.form.id, answer: {} }) + }} + /> + ), + () => { + if (settled) return + void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }) + }, + ) + }) + + return ( + + + {props.form.title ?? "Connect integration"} + Complete the connection dialog to continue. + + + ) } function UrlPrompt(props: { form: FormInfo & { mode: "url" } }) { 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 f9cc482c6f02..e8ba0a3ddbc0 100644 --- a/packages/tui/test/cli/cmd/tui/integration-options.test.ts +++ b/packages/tui/test/cli/cmd/tui/integration-options.test.ts @@ -9,6 +9,7 @@ import { const integration = (value: Partial & Pick): IntegrationInfo => ({ methods: [], + capabilities: [], connections: [], ...value, }) From 56f44dbcaa4b9d3e407a9c77db810f145d1458af Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Mon, 6 Jul 2026 19:46:39 +0530 Subject: [PATCH 2/6] refactor: simplify search integration flow --- packages/core/src/form.ts | 18 ++- packages/core/src/plugin/search/exa.ts | 32 ++--- packages/core/src/search.ts | 57 +++++---- .../tui/src/component/dialog-integration.tsx | 109 +++++++++--------- 4 files changed, 105 insertions(+), 111 deletions(-) diff --git a/packages/core/src/form.ts b/packages/core/src/form.ts index 25d594e95cf6..f18f61a8ac19 100644 --- a/packages/core/src/form.ts +++ b/packages/core/src/form.ts @@ -136,12 +136,7 @@ export const layer = Layer.effect( title: input.title, ...(input.metadata === undefined ? {} : { metadata: input.metadata }), } - const form: Info = - input.mode === "form" - ? { ...base, mode: "form", fields: input.fields } - : input.mode === "url" - ? { ...base, mode: "url", url: input.url } - : { ...base, mode: "integration", integrationID: input.integrationID } + const form = makeInfo(input, base) const entry: Entry = { form, state: { status: "pending" }, @@ -252,6 +247,17 @@ function validateAnswer(form: Info, answer: Answer) { } } +function makeInfo(input: CreateInput, base: Omit): Info { + switch (input.mode) { + case "form": + return { ...base, mode: "form", fields: input.fields } + case "url": + return { ...base, mode: "url", url: input.url } + case "integration": + return { ...base, mode: "integration", integrationID: input.integrationID } + } +} + function isActive(field: Form.Field, answer: Answer) { if (!field.when) return true return field.when.every((when) => matches(when, answer[when.key])) diff --git a/packages/core/src/plugin/search/exa.ts b/packages/core/src/plugin/search/exa.ts index 23e5d3764ccc..d1fc44bfddf8 100644 --- a/packages/core/src/plugin/search/exa.ts +++ b/packages/core/src/plugin/search/exa.ts @@ -15,13 +15,6 @@ const Args = Schema.Struct({ contextMaxCharacters: Schema.optional(Schema.Number), }) -const url = (apiKey: string | undefined) => { - if (!apiKey) return endpoint - const value = new URL(endpoint) - value.searchParams.set("exaApiKey", apiKey) - return value.toString() -} - export const Plugin = define({ id: "opencode.search.exa", effect: Effect.fn("SearchExa.Plugin")(function* (ctx) { @@ -33,20 +26,17 @@ export const Plugin = define({ draft.capability.search.update({ integrationID: "exa", capability: { type: "search", connection: "optional" }, - execute: (input, context) => - SearchMcp.call( - http, - url(context.credential?.type === "key" ? context.credential.key : undefined), - "web_search_exa", - Args, - { - query: input.query, - type: input.type ?? "auto", - numResults: input.numResults ?? 8, - livecrawl: input.livecrawl ?? "fallback", - contextMaxCharacters: input.contextMaxCharacters, - }, - ).pipe(Effect.map((text) => ({ text: text ?? "" }))), + execute: (input, context) => { + const url = new URL(endpoint) + if (context.credential?.type === "key") url.searchParams.set("exaApiKey", context.credential.key) + return SearchMcp.call(http, url.toString(), "web_search_exa", Args, { + query: input.query, + type: input.type ?? "auto", + numResults: input.numResults ?? 8, + livecrawl: input.livecrawl ?? "fallback", + contextMaxCharacters: input.contextMaxCharacters, + }).pipe(Effect.map((text) => ({ text: text ?? "" }))) + }, }) }) }), diff --git a/packages/core/src/search.ts b/packages/core/src/search.ts index a11b1a029274..35833568778d 100644 --- a/packages/core/src/search.ts +++ b/packages/core/src/search.ts @@ -64,12 +64,6 @@ const layer = Layer.effect( const onboarding = Semaphore.makeUnsafe(1) const decodeOutput = Schema.decodeUnknownEffect(ProviderOutput) - const available = Effect.fn("Search.available")(function* () { - return new Map( - (yield* integrations.capability.search.list()).map((provider) => [provider.integrationID, provider]), - ) - }) - const requireProvider = ( providers: Map, providerID: Integration.ID, @@ -78,7 +72,7 @@ const layer = Layer.effect( return provider ? Effect.succeed(provider) : Effect.fail(new ProviderNotFoundError({ providerID })) } - const configured = Effect.fn("Search.configured")(function* () { + const configuredProvider = Effect.fn("Search.configuredProvider")(function* () { const providerID = Config.latest(yield* config.entries(), "search")?.provider if (providerID) return providerID if (process.env.OPENCODE_WEBSEARCH_PROVIDER) { @@ -115,17 +109,16 @@ const layer = Layer.effect( options: Array.from(providers.values()) .flatMap((provider) => { const info = infos.get(provider.integrationID) - return info ? [{ provider, info }] : [] + if (!info) return [] + const disconnected = + provider.capability.connection === "optional" ? "Keyless available" : "Connection required" + return [{ info, description: info.connections.length ? "Connected" : disconnected }] }) .toSorted((a, b) => a.info.name.localeCompare(b.info.name)) - .map(({ provider, info }) => ({ + .map(({ info, description }) => ({ value: info.id, label: info.name, - description: info.connections.length - ? "Connected" - : provider.capability.connection === "optional" - ? "Keyless available" - : "Connection required", + description, })), }, ], @@ -160,9 +153,11 @@ const layer = Layer.effect( }) const select = Effect.fn("Search.select")(function* (input: QueryInput) { - const providers = yield* available() + const providers = new Map( + (yield* integrations.capability.search.list()).map((provider) => [provider.integrationID, provider]), + ) if (input.providerID) return yield* requireProvider(providers, input.providerID) - const override = yield* configured() + 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 @@ -182,22 +177,22 @@ const layer = Layer.effect( ) }) - const query = Effect.fn("Search.query")(function* (input: QueryInput) { - const provider = yield* select(input) - const connection = yield* connect(provider, input.sessionID) - const credential = connection - ? yield* integrations.connection - .resolve(connection) - .pipe(Effect.mapError((cause) => new RequestError({ providerID: provider.integrationID, cause }))) - : undefined - const output = yield* provider.execute(input, { credential, sessionID: input.sessionID }).pipe( - Effect.flatMap(decodeOutput), - Effect.mapError((cause) => new RequestError({ providerID: provider.integrationID, cause })), - ) - return new Result({ providerID: provider.integrationID, ...output }) + return Service.of({ + query: Effect.fn("Search.query")(function* (input) { + const provider = yield* select(input) + const connection = yield* connect(provider, input.sessionID) + const credential = connection + ? yield* integrations.connection + .resolve(connection) + .pipe(Effect.mapError((cause) => new RequestError({ providerID: provider.integrationID, cause }))) + : undefined + const output = yield* provider.execute(input, { credential, sessionID: input.sessionID }).pipe( + Effect.flatMap(decodeOutput), + Effect.mapError((cause) => new RequestError({ providerID: provider.integrationID, cause })), + ) + return new Result({ providerID: provider.integrationID, ...output }) + }), }) - - return Service.of({ query }) }), ) diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx index c9cea13b101b..fbf9dfef5d79 100644 --- a/packages/tui/src/component/dialog-integration.tsx +++ b/packages/tui/src/component/dialog-integration.tsx @@ -58,8 +58,6 @@ export function DialogIntegration( ) { const data = useData() const dialog = useDialog() - const sdk = useSDK() - const toast = useToast() const { theme } = useTheme() const options = createMemo(() => integrationOptions(data.location.integration.list() ?? []) @@ -68,26 +66,28 @@ export function DialogIntegration( const methods = connectMethods(integration) const connected = integration.connections.length > 0 const search = integration.capabilities.find((capability) => capability.type === "search") + const credentials = credentialConnections(integration) + const description = search?.connection === "optional" ? "API key optional" : undefined + const category = search ? "Web search" : undefined return { title: integration.name, value: integration.id, - description: - search?.connection === "optional" ? "API key optional" : methods.length ? undefined : "Environment only", + description: description ?? (methods.length === 0 ? "Environment only" : undefined), footer: [connectionSummary(integration), search?.selected ? "Web search default" : undefined] .filter((value) => value !== undefined && value.length > 0) .join(" ยท ") || undefined, - category: search ? "Web search" : integration.id in INTEGRATION_PRIORITY ? "Popular" : "Services", + category: category ?? (integration.id in INTEGRATION_PRIORITY ? "Popular" : "Services"), disabled: methods.length === 0 && !search, gutter: connected ? () => โœ“ : undefined, onSelect: () => { if (props.connectionOnly) { - return credentialConnections(integration).length + return credentials.length ? manageConnections(integration, methods, dialog, props.onConnected) : selectMethod(integration, methods, dialog, props.onConnected) } - if (search) return manageIntegration(integration, methods, search, data, sdk, dialog, toast) - return credentialConnections(integration).length + if (search) return manageIntegration(integration, methods, search, dialog) + return credentials.length ? manageConnections(integration, methods, dialog, props.onConnected) : selectMethod(integration, methods, dialog, props.onConnected) }, @@ -112,55 +112,58 @@ function manageIntegration( integration: IntegrationInfo, methods: ConnectMethod[], search: IntegrationInfo["capabilities"][number], - data: ReturnType, - sdk: ReturnType, dialog: ReturnType, - toast: ReturnType, ) { const connected = integration.connections.length > 0 - const select = () => { - void sdk.api.integration - .selectCapability({ - integrationID: integration.id, - capability: "search", - location: location(data), - }) - .then(async () => { - await data.location.integration.refresh() - toast.show({ variant: "success", message: `${integration.name} is now the web search default` }) - dialog.clear() - }) - .catch(toast.error) - } + dialog.replace(() => { + const data = useData() + const sdk = useSDK() + const toast = useToast() + const credentials = credentialConnections(integration) + const selectSearch = () => { + void sdk.api.integration + .selectCapability({ + integrationID: integration.id, + capability: "search", + location: location(data), + }) + .then(async () => { + await data.location.integration.refresh() + toast.show({ variant: "success", message: `${integration.name} is now the web search default` }) + dialog.clear() + }) + .catch(toast.error) + } - dialog.replace(() => ( - selectMethod(integration, methods, dialog, select) - : select, - }, - ...(methods.length - ? [ - { - title: credentialConnections(integration).length ? "Manage connections" : "Connect", - value: "connect", - onSelect: () => - credentialConnections(integration).length - ? manageConnections(integration, methods, dialog) - : selectMethod(integration, methods, dialog), - }, - ] - : []), - ]} - /> - )) + return ( + selectMethod(integration, methods, dialog, selectSearch) + : selectSearch, + }, + ...(methods.length + ? [ + { + title: credentials.length ? "Manage connections" : "Connect", + value: "connect", + onSelect: () => + credentials.length + ? manageConnections(integration, methods, dialog) + : selectMethod(integration, methods, dialog), + }, + ] + : []), + ]} + /> + ) + }) } function manageConnections( From 66220071b5ce74507fa38e54d155f71d3b5b5792 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Tue, 7 Jul 2026 17:33:55 +0530 Subject: [PATCH 3/6] feat(sdk): expose web search --- packages/client/src/effect/api/api.ts | 18 ++++++ .../client/src/effect/generated/client.ts | 26 ++++++++ packages/client/src/effect/index.ts | 2 + packages/client/src/promise/api.ts | 2 + .../client/src/promise/generated/client.ts | 24 +++++++ .../client/src/promise/generated/types.ts | 63 +++++++++++++++++++ packages/client/src/promise/index.ts | 1 + packages/client/test/promise.test.ts | 28 +++++++++ packages/protocol/src/api.ts | 3 + packages/protocol/src/client.ts | 1 + packages/protocol/src/groups/search.ts | 31 +++++++++ packages/sdk-next/src/index.ts | 2 + packages/sdk-next/test/embedded.test.ts | 34 ++++++++++ packages/sdk/js/src/v2/gen/sdk.gen.ts | 57 +++++++++++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 60 ++++++++++++++++++ packages/server/src/handlers.ts | 2 + packages/server/src/handlers/search.ts | 62 ++++++++++++++++++ 17 files changed, 416 insertions(+) create mode 100644 packages/protocol/src/groups/search.ts create mode 100644 packages/server/src/handlers/search.ts diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 90b183731bd5..7124d7691941 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -887,6 +887,23 @@ 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"] +} +export type Endpoint26_0Output = EffectValue> +export type SearchQueryOperation = (input: Endpoint26_0Input) => Effect.Effect + +export interface SearchApi { + readonly query: SearchQueryOperation +} + export interface AppApi { readonly health: HealthApi readonly location: LocationApi @@ -914,4 +931,5 @@ export interface AppApi { readonly projectCopy: ProjectCopyApi readonly vcs: VcsApi readonly debug: DebugApi + readonly search: SearchApi } diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index ad3dac87ac14..077edaa413ea 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -1066,6 +1066,31 @@ 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"] +} +const Endpoint26_0 = (raw: RawClient["server.search"]) => (input: Endpoint26_0Input) => + raw["search.query"]({ + query: { location: input["location"] }, + payload: { + query: input["query"], + providerID: input["providerID"], + numResults: input["numResults"], + livecrawl: input["livecrawl"], + type: input["type"], + contextMaxCharacters: input["contextMaxCharacters"], + }, + }).pipe(Effect.mapError(mapClientError)) + +const adaptGroup26 = (raw: RawClient["server.search"]) => ({ query: Endpoint26_0(raw) }) + const adaptClient = (raw: RawClient) => ({ health: adaptGroup0(raw["server.health"]), location: adaptGroup1(raw["server.location"]), @@ -1093,6 +1118,7 @@ const adaptClient = (raw: RawClient) => ({ projectCopy: adaptGroup23(raw["server.projectCopy"]), vcs: adaptGroup24(raw["server.vcs"]), debug: adaptGroup25(raw["server.debug"]), + search: adaptGroup26(raw["server.search"]), }) export const make = (options?: { readonly baseUrl?: URL | string }) => diff --git a/packages/client/src/effect/index.ts b/packages/client/src/effect/index.ts index 8cde84695c93..3cc1f1dda798 100644 --- a/packages/client/src/effect/index.ts +++ b/packages/client/src/effect/index.ts @@ -14,6 +14,7 @@ export type { PluginApi, ProviderApi, ReferenceApi, + SearchApi, SessionApi, SkillApi, } from "./api.js" @@ -36,6 +37,7 @@ export { Provider } from "@opencode-ai/schema/provider" export { Pty } from "@opencode-ai/schema/pty" export { Question } from "@opencode-ai/schema/question" export { Reference } from "@opencode-ai/schema/reference" +export { Search } from "@opencode-ai/schema/search" export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema" export { Session } from "@opencode-ai/schema/session" export { SessionInput } from "@opencode-ai/schema/session-input" diff --git a/packages/client/src/promise/api.ts b/packages/client/src/promise/api.ts index e7d1c27d259a..734291c78ef7 100644 --- a/packages/client/src/promise/api.ts +++ b/packages/client/src/promise/api.ts @@ -7,6 +7,7 @@ import type { PluginApi as EffectPluginApi, ProviderApi as EffectProviderApi, ReferenceApi as EffectReferenceApi, + SearchApi as EffectSearchApi, SessionApi as EffectSessionApi, SkillApi as EffectSkillApi, } from "../effect/api/api.js" @@ -32,6 +33,7 @@ export type ModelApi = PromisifyApi> export type PluginApi = PromisifyApi> export type ProviderApi = PromisifyApi> export type ReferenceApi = PromisifyApi> +export type SearchApi = PromisifyApi> export type SessionApi = PromisifyApi> export type SkillApi = PromisifyApi> diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 692973679ce8..4cc2b56d4dbb 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -177,6 +177,8 @@ import type { VcsDiffInput, VcsDiffOutput, DebugLocationOutput, + SearchQueryInput, + SearchQueryOutput, } from "./types" import { ClientError } from "./client-error" @@ -1483,6 +1485,28 @@ export function make(options: ClientOptions) { requestOptions, ), }, + search: { + query: (input: SearchQueryInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/search`, + query: { location: input["location"] }, + body: { + query: input["query"], + providerID: input["providerID"], + numResults: input["numResults"], + livecrawl: input["livecrawl"], + type: input["type"], + contextMaxCharacters: input["contextMaxCharacters"], + }, + successStatus: 200, + declaredStatuses: [400, 503, 401], + empty: false, + }, + requestOptions, + ), + }, } } diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 598abcd9ef1a..26cd53ec4cec 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -6084,3 +6084,66 @@ export type VcsDiffOutput = { } export type DebugLocationOutput = ReadonlyArray<{ readonly directory: string; readonly workspaceID?: string }> + +export type SearchQueryInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] + readonly query: { + readonly query: string + readonly providerID?: string + readonly numResults?: number + readonly livecrawl?: "fallback" | "preferred" + readonly type?: "auto" | "fast" | "deep" + readonly contextMaxCharacters?: number + }["query"] + readonly providerID?: { + readonly query: string + readonly providerID?: string + readonly numResults?: number + readonly livecrawl?: "fallback" | "preferred" + readonly type?: "auto" | "fast" | "deep" + readonly contextMaxCharacters?: number + }["providerID"] + readonly numResults?: { + readonly query: string + readonly providerID?: string + readonly numResults?: number + readonly livecrawl?: "fallback" | "preferred" + readonly type?: "auto" | "fast" | "deep" + readonly contextMaxCharacters?: number + }["numResults"] + readonly livecrawl?: { + readonly query: string + readonly providerID?: string + readonly numResults?: number + readonly livecrawl?: "fallback" | "preferred" + readonly type?: "auto" | "fast" | "deep" + readonly contextMaxCharacters?: number + }["livecrawl"] + readonly type?: { + readonly query: string + readonly providerID?: string + readonly numResults?: number + readonly livecrawl?: "fallback" | "preferred" + readonly type?: "auto" | "fast" | "deep" + readonly contextMaxCharacters?: number + }["type"] + readonly contextMaxCharacters?: { + readonly query: string + readonly providerID?: string + readonly numResults?: number + readonly livecrawl?: "fallback" | "preferred" + readonly type?: "auto" | "fast" | "deep" + readonly contextMaxCharacters?: number + }["contextMaxCharacters"] +} + +export type SearchQueryOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: { readonly providerID: string; readonly text: string; readonly metadata?: JsonValue } +} diff --git a/packages/client/src/promise/index.ts b/packages/client/src/promise/index.ts index fd889c64e55e..75b59c5d4be6 100644 --- a/packages/client/src/promise/index.ts +++ b/packages/client/src/promise/index.ts @@ -9,6 +9,7 @@ export type { PluginApi, ProviderApi, ReferenceApi, + SearchApi, SessionApi, SkillApi, } from "./api.js" diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index a053752f9ebe..a5e4d5fae20b 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -31,6 +31,7 @@ test("exposes every standard HTTP API group", () => { "projectCopy", "vcs", "debug", + "search", ]) expect(Object.keys(client.debug)).toEqual(["location"]) expect(Object.keys(client.message)).toEqual(["list"]) @@ -44,6 +45,7 @@ test("exposes every standard HTTP API group", () => { "attemptComplete", "attemptCancel", ]) + expect(Object.keys(client.search)).toEqual(["query"]) expect(Object.keys(client.file)).toEqual(["read", "list", "find"]) expect(Object.keys(client.vcs)).toEqual(["status", "diff"]) expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"]) @@ -51,6 +53,32 @@ test("exposes every standard HTTP API group", () => { expect(Object.keys(client.project)).toEqual(["list", "current", "directories"]) }) +test("search.query uses the public HTTP contract", async () => { + let request: Request | undefined + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async (input, init) => { + request = input instanceof Request ? input : new Request(input, init) + return Response.json({ + location: { directory: "/tmp/project", project: { id: "proj_test", directory: "/tmp/project" } }, + data: { providerID: "exa", text: "result", metadata: { requestID: "req_test" } }, + }) + }, + }) + + const result = await client.search.query({ + query: "opencode", + providerID: "exa", + numResults: 5, + location: { directory: "/tmp/project" }, + }) + + expect(result.data).toEqual({ providerID: "exa", text: "result", metadata: { requestID: "req_test" } }) + expect(request?.method).toBe("POST") + expect(request?.url).toBe("http://localhost:3000/api/search?location%5Bdirectory%5D=%2Ftmp%2Fproject") + expect(await request?.json()).toEqual({ query: "opencode", providerID: "exa", numResults: 5 }) +}) + test("file.read returns binary content from the public HTTP contract", async () => { let request: Request | undefined const client = OpenCode.make({ diff --git a/packages/protocol/src/api.ts b/packages/protocol/src/api.ts index 2f91fae33226..d2937fade063 100644 --- a/packages/protocol/src/api.ts +++ b/packages/protocol/src/api.ts @@ -24,6 +24,7 @@ import { ReferenceGroup } from "./groups/reference.js" import { Authorization } from "./middleware/authorization.js" import { LocationGroup } from "./groups/location.js" import { IntegrationGroup } from "./groups/integration.js" +import { SearchGroup } from "./groups/search.js" import { McpGroup } from "./groups/mcp.js" import { CredentialGroup } from "./groups/credential.js" import { ProjectGroup } from "./groups/project.js" @@ -38,6 +39,7 @@ type LocationGroups = | HttpApiGroup.AddMiddleware | HttpApiGroup.AddMiddleware | HttpApiGroup.AddMiddleware + | HttpApiGroup.AddMiddleware | HttpApiGroup.AddMiddleware | HttpApiGroup.AddMiddleware | HttpApiGroup.AddMiddleware @@ -168,6 +170,7 @@ const makeApiFromGroup = < .add(ProjectCopyGroup.middleware(locationMiddleware)) .add(VcsGroup.middleware(locationMiddleware)) .add(DebugGroup) + .add(SearchGroup.middleware(locationMiddleware)) .annotateMerge( OpenApi.annotations({ title: "opencode HttpApi", diff --git a/packages/protocol/src/client.ts b/packages/protocol/src/client.ts index 58f974184218..30a6ed30eef7 100644 --- a/packages/protocol/src/client.ts +++ b/packages/protocol/src/client.ts @@ -44,6 +44,7 @@ export const groupNames = { "server.generate": "generate", "server.provider": "provider", "server.integration": "integration", + "server.search": "search", "server.credential": "credential", "server.form": "form", "server.permission": "permission", diff --git a/packages/protocol/src/groups/search.ts b/packages/protocol/src/groups/search.ts new file mode 100644 index 000000000000..c0f9837986e5 --- /dev/null +++ b/packages/protocol/src/groups/search.ts @@ -0,0 +1,31 @@ +import { Location } from "@opencode-ai/schema/location" +import { Search } from "@opencode-ai/schema/search" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { InvalidRequestError, ServiceUnavailableError } from "../errors.js" +import { LocationQuery, locationQueryOpenApi } from "./location.js" + +export const SearchGroup = HttpApiGroup.make("server.search") + .add( + HttpApiEndpoint.post("search.query", "/api/search", { + query: LocationQuery, + payload: Schema.Struct(Search.Input.fields), + success: Location.response(Search.Result), + error: [InvalidRequestError, ServiceUnavailableError], + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.search.query", + summary: "Search the web", + description: + "Run one web search through the selected integration. Specify a provider to override the configured default.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "search", + description: "Location-scoped web search routes.", + }), + ) diff --git a/packages/sdk-next/src/index.ts b/packages/sdk-next/src/index.ts index fc23219fc40e..e83272d81687 100644 --- a/packages/sdk-next/src/index.ts +++ b/packages/sdk-next/src/index.ts @@ -5,11 +5,13 @@ export { ClientError } from "@opencode-ai/client/effect" export { AbsolutePath, Agent, + Integration, Location, Model, Prompt, Provider, RelativePath, + Search, Session, SessionInput, SessionMessage, diff --git a/packages/sdk-next/test/embedded.test.ts b/packages/sdk-next/test/embedded.test.ts index 4ac31b70ab61..3440a4ef5b1f 100644 --- a/packages/sdk-next/test/embedded.test.ts +++ b/packages/sdk-next/test/embedded.test.ts @@ -141,6 +141,40 @@ it.live( 10_000, ) +it.live("embedded client exposes integration-backed search", () => + withEmbedded("opencode-embedded-search-", (fixture) => + Effect.gen(function* () { + const opencode = yield* fixture.sdk.OpenCode.create() + const providerID = fixture.sdk.Integration.ID.make("embedded-search") + yield* opencode.plugin({ + id: `embedded-search-${crypto.randomUUID()}`, + effect: (ctx) => + ctx.integration.transform((draft) => { + draft.update(providerID, (integration) => (integration.name = "Embedded search")) + draft.capability.search.update({ + integrationID: providerID, + capability: { type: "search", connection: "optional" }, + execute: (input) => + Effect.succeed({ text: `Found ${input.query}`, metadata: { source: "embedded" } }), + }) + }), + }) + + const result = yield* opencode.search.query({ + query: "opencode", + providerID, + location: location(fixture), + }) + + expect(result.data).toEqual({ + providerID, + text: "Found opencode", + metadata: { source: "embedded" }, + }) + }), + ), +) + it.live( "Location-owned runner events reach the ready global client", () => diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 20da64a59c25..7221f1ee7e22 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -360,6 +360,8 @@ import type { V2QuestionRequestListResponses, V2ReferenceListErrors, V2ReferenceListResponses, + V2SearchQueryErrors, + V2SearchQueryResponses, V2SessionActiveErrors, V2SessionActiveResponses, V2SessionBackgroundErrors, @@ -8119,6 +8121,56 @@ export class Debug extends HeyApiClient { } } +export class Search extends HeyApiClient { + /** + * Search the web + * + * Run one web search through the selected integration. Specify a provider to override the configured default. + */ + public query( + parameters?: { + location?: { + directory?: string | null + workspace?: string | null + } | null + query?: string + providerID?: string + numResults?: number + livecrawl?: "fallback" | "preferred" + type?: "auto" | "fast" | "deep" + contextMaxCharacters?: number + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "location" }, + { in: "body", key: "query" }, + { in: "body", key: "providerID" }, + { in: "body", key: "numResults" }, + { in: "body", key: "livecrawl" }, + { in: "body", key: "type" }, + { in: "body", key: "contextMaxCharacters" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/api/search", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + export class V2 extends HeyApiClient { private _health?: Health get health(): Health { @@ -8244,6 +8296,11 @@ export class V2 extends HeyApiClient { get debug(): Debug { return (this._debug ??= new Debug({ client: this.client })) } + + private _search?: Search + get search(): Search { + return (this._search ??= new Search({ client: this.client })) + } } export class OpencodeClient extends HeyApiClient { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 7648445ea762..dbe737dcb588 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -6693,6 +6693,12 @@ export type ProjectCopyCopy = { export type VcsMode = "working" | "branch" +export type SearchResult = { + providerID: string + text: string + metadata?: unknown +} + export type EventModelsDevRefreshed = { id: string type: "models-dev.refreshed" @@ -11460,6 +11466,12 @@ export type VcsFileStatusV2 = { export type VcsMode2 = "working" | "branch" +export type SearchResult2 = { + providerID: string + text: string + metadata?: unknown +} + export type AuthRemoveData = { body?: never path: { @@ -19273,6 +19285,54 @@ export type V2DebugLocationResponses = { export type V2DebugLocationResponse = V2DebugLocationResponses[keyof V2DebugLocationResponses] +export type V2SearchQueryData = { + body: { + query: string + providerID?: string + numResults?: number + livecrawl?: "fallback" | "preferred" + type?: "auto" | "fast" | "deep" + contextMaxCharacters?: number + } + path?: never + query?: { + location?: { + directory?: string | null + workspace?: string | null + } | null + } + url: "/api/search" +} + +export type V2SearchQueryErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError1 | InvalidRequestErrorV2 + /** + * UnauthorizedError + */ + 401: UnauthorizedErrorV2 + /** + * ServiceUnavailableError + */ + 503: ServiceUnavailableErrorV2 +} + +export type V2SearchQueryError = V2SearchQueryErrors[keyof V2SearchQueryErrors] + +export type V2SearchQueryResponses = { + /** + * Success + */ + 200: { + location: LocationInfo2 + data: SearchResult2 + } +} + +export type V2SearchQueryResponse = V2SearchQueryResponses[keyof V2SearchQueryResponses] + export type PtyConnectData = { body?: never path: { diff --git a/packages/server/src/handlers.ts b/packages/server/src/handlers.ts index 896cbbc59204..f6612faf0af3 100644 --- a/packages/server/src/handlers.ts +++ b/packages/server/src/handlers.ts @@ -20,6 +20,7 @@ import { QuestionHandler } from "./handlers/question" import { ReferenceHandler } from "./handlers/reference" import { LocationHandler } from "./handlers/location" import { IntegrationHandler } from "./handlers/integration" +import { SearchHandler } from "./handlers/search" import { McpHandler } from "./handlers/mcp" import { CredentialHandler } from "./handlers/credential" import { ProjectHandler } from "./handlers/project" @@ -38,6 +39,7 @@ export const handlers = Layer.mergeAll( GenerateHandler, ProviderHandler, IntegrationHandler, + SearchHandler, McpHandler, CredentialHandler, ProjectHandler, diff --git a/packages/server/src/handlers/search.ts b/packages/server/src/handlers/search.ts new file mode 100644 index 000000000000..45e5e3fbe249 --- /dev/null +++ b/packages/server/src/handlers/search.ts @@ -0,0 +1,62 @@ +import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" +import { Search } from "@opencode-ai/core/search" +import { InvalidRequestError, ServiceUnavailableError } from "@opencode-ai/protocol/errors" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { Api } from "../api" +import { response } from "../location" + +export const SearchHandler = HttpApiBuilder.group(Api, "server.search", (handlers) => + Effect.gen(function* () { + return handlers.handle( + "search.query", + Effect.fn("server.search.query")(function* (request) { + const plugins = yield* PluginSupervisor.Service + yield* plugins.ready.pipe( + Effect.timeoutOrElse({ + duration: "5 seconds", + orElse: () => + Effect.fail( + new ServiceUnavailableError({ + message: "Search integration initialization timed out", + service: "search", + }), + ), + }), + ) + const search = yield* Search.Service + return yield* response( + search.query(request.payload).pipe( + Effect.catchTags({ + "Search.ProviderRequired": () => + new InvalidRequestError({ + message: "Search provider is required", + kind: "search_provider_required", + field: "providerID", + }), + "Search.ProviderNotFound": (error) => + new InvalidRequestError({ + message: `Search provider not found: ${error.providerID}`, + kind: "search_provider_not_found", + field: "providerID", + }), + "Search.ConnectionRequired": (error) => + new InvalidRequestError({ + message: `Search provider requires a connection: ${error.providerID}`, + kind: "search_connection_required", + field: "providerID", + }), + "Search.Cancelled": () => + new InvalidRequestError({ message: "Search cancelled", kind: "search_cancelled" }), + "Search.Request": (error) => + new ServiceUnavailableError({ + message: `Search request failed: ${error.providerID}`, + service: error.providerID, + }), + }), + ), + ) + }), + ) + }), +) From 1f842fa654c0e106331fe95957072fd569ca5b81 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Tue, 7 Jul 2026 17:57:17 +0530 Subject: [PATCH 4/6] fix(tui): stabilize websearch labels --- packages/tui/src/routes/session/index.tsx | 13 +++++++++++-- packages/tui/src/util/tool-display.ts | 8 ++++++++ packages/tui/test/util/tool-display.test.ts | 21 ++++++++++++++++++++- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index d1ea5e1272dc..5ce997182caa 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 { webSearchProviderLabel } from "../../util/tool-display" +import { selectedWebSearchProvider, webSearchProviderLabel } from "../../util/tool-display" import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import { useSDK } from "../../context/sdk" import { useEditorContext } from "../../context/editor" @@ -2345,9 +2345,18 @@ function WebFetch(props: ToolProps) { } function WebSearch(props: ToolProps) { + const data = useData() + const [provider, setProvider] = createSignal( + selectedWebSearchProvider(data.location.integration.list() ?? []) ?? stringValue(props.metadata.provider), + ) + createEffect(() => { + if (provider()) return + const next = selectedWebSearchProvider(data.location.integration.list() ?? []) + if (next) setProvider(next) + }) return ( - {webSearchProviderLabel(props.metadata.provider)} "{stringValue(props.input.query)}"{" "} + {webSearchProviderLabel(provider())} "{stringValue(props.input.query)}"{" "} ({numberValue(props.metadata.numResults)} results) ) diff --git a/packages/tui/src/util/tool-display.ts b/packages/tui/src/util/tool-display.ts index 18e256bd34fd..c8c5fca873b6 100644 --- a/packages/tui/src/util/tool-display.ts +++ b/packages/tui/src/util/tool-display.ts @@ -1,9 +1,17 @@ +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/util/tool-display.test.ts b/packages/tui/test/util/tool-display.test.ts index f77f54cbab3c..0ee2fd3b2a6b 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 { toolDisplayMetadata, webSearchProviderLabel } from "../../src/util/tool-display" +import { selectedWebSearchProvider, toolDisplayMetadata, webSearchProviderLabel } from "../../src/util/tool-display" describe("webSearchProviderLabel", () => { test("labels known providers", () => { @@ -21,6 +21,25 @@ 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 } From 48f8b5de9877ede03b10bed4f52d8da264eb8c06 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Tue, 7 Jul 2026 18:46:51 +0530 Subject: [PATCH 5/6] feat(plugin): add integration registration --- packages/core/src/plugin/host.ts | 169 ++++++++---------- packages/core/src/plugin/promise.ts | 85 +++++---- packages/core/src/plugin/search/exa.ts | 18 +- packages/core/src/plugin/search/parallel.ts | 18 +- packages/core/test/plugin/host.ts | 159 ++++++++-------- packages/core/test/plugin/promise.test.ts | 16 +- packages/core/test/plugin/search.test.ts | 24 ++- packages/plugin/src/v2/effect/index.ts | 5 +- packages/plugin/src/v2/effect/integration.ts | 33 ++-- packages/plugin/src/v2/promise/index.ts | 6 +- packages/plugin/src/v2/promise/integration.ts | 57 ++++-- packages/sdk-next/test/embedded.test.ts | 15 +- 12 files changed, 342 insertions(+), 263 deletions(-) diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index d98e1fd03964..b280eaa54ee7 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -1,6 +1,7 @@ export * as PluginHost from "./host" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { IntegrationDefinition, IntegrationMethodRegistration, PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { CredentialOAuth } from "@opencode-ai/sdk/v2/types" import { EventManifest } from "@opencode-ai/schema/event-manifest" import { Effect, Schema, Stream } from "effect" import { AgentV2 } from "../agent" @@ -192,6 +193,10 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int connection.type === "credential" ? { ...connection, id: Credential.ID.make(connection.id) } : connection, ), }, + register: (definition) => + integration.transform((draft) => { + registerIntegration(draft, definition) + }), transform: (callback) => integration.transform((draft) => { callback({ @@ -201,98 +206,10 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int remove: (id) => draft.remove(Integration.ID.make(id)), method: { list: (id) => mutable(draft.method.list(Integration.ID.make(id))), - update: (input) => { - if ("authorize" in input) { - const methodID = Integration.MethodID.make(input.method.id) - const refresh = input.refresh - draft.method.update({ - integrationID: Integration.ID.make(input.integrationID), - method: { ...input.method, id: methodID }, - authorize: (inputs) => - input.authorize(inputs).pipe( - Effect.map((authorization) => { - if (authorization.mode === "auto") { - return { - ...authorization, - callback: authorization.callback.pipe( - Effect.map((credential) => - Credential.OAuth.make({ - ...credential, - methodID: Integration.MethodID.make(credential.methodID), - }), - ), - ), - } - } - return { - ...authorization, - callback: (code: string) => - authorization.callback(code).pipe( - Effect.map((credential) => - Credential.OAuth.make({ - ...credential, - methodID: Integration.MethodID.make(credential.methodID), - }), - ), - ), - } - }), - ), - ...(refresh - ? { - refresh: (value: Credential.OAuth) => - refresh(value).pipe( - Effect.map((next) => - Credential.OAuth.make({ - ...next, - methodID: Integration.MethodID.make(next.methodID), - }), - ), - ), - } - : {}), - ...(input.label ? { label: input.label } : {}), - }) - return - } - if (input.method.type === "env") { - draft.method.update({ - integrationID: Integration.ID.make(input.integrationID), - method: { type: "env", names: input.method.names }, - }) - return - } - draft.method.update({ - integrationID: Integration.ID.make(input.integrationID), - method: { type: "key", label: input.method.label }, - }) - }, + update: (input) => draft.method.update(methodImplementation(input)), remove: (id, method) => draft.method.remove(Integration.ID.make(id), Schema.decodeUnknownSync(Integration.Method)(method)), }, - capability: { - search: { - list: () => - draft.capability.search.list().map((provider) => ({ - integrationID: provider.integrationID, - capability: provider.capability, - execute: (input, context) => - provider.execute(input, { - ...context, - credential: context.credential - ? Schema.decodeUnknownSync(Credential.Value)(context.credential) - : undefined, - }), - })), - update: (input) => - draft.capability.search.update({ - integrationID: Integration.ID.make(input.integrationID), - capability: input.capability, - execute: input.execute, - }), - remove: (id) => draft.capability.search.remove(Integration.ID.make(id)), - }, - }, }) }), }, @@ -401,3 +318,75 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int }, } satisfies PluginContext }) + +function registerIntegration(draft: Integration.Draft, definition: IntegrationDefinition) { + const integrationID = Integration.ID.make(definition.id) + draft.update(integrationID, (integration) => (integration.name = definition.name)) + for (const method of definition.methods ?? []) { + if (method.type === "env") { + draft.method.update(methodImplementation({ integrationID: definition.id, method })) + continue + } + if (method.type === "key") { + draft.method.update(methodImplementation({ integrationID: definition.id, method })) + continue + } + const { authorize, refresh, credentialLabel, ...info } = method + draft.method.update( + methodImplementation({ + integrationID: definition.id, + method: info, + authorize, + ...(refresh ? { refresh } : {}), + ...(credentialLabel ? { label: credentialLabel } : {}), + }), + ) + } + if (!definition.search) return + draft.capability.search.update({ + integrationID, + capability: { type: "search", connection: definition.search.connection }, + execute: definition.search.execute, + }) +} + +function methodImplementation(input: IntegrationMethodRegistration): Integration.Implementation { + if ("authorize" in input) { + const refresh = input.refresh + return { + integrationID: Integration.ID.make(input.integrationID), + method: { ...input.method, id: Integration.MethodID.make(input.method.id) }, + authorize: (inputs) => + input.authorize(inputs).pipe( + Effect.map((authorization) => { + if (authorization.mode === "auto") { + return { + ...authorization, + callback: authorization.callback.pipe(Effect.map(credential)), + } + } + return { + ...authorization, + callback: (code: string) => authorization.callback(code).pipe(Effect.map(credential)), + } + }), + ), + ...(refresh ? { refresh: (value: Credential.OAuth) => refresh(value).pipe(Effect.map(credential)) } : {}), + ...(input.label ? { label: input.label } : {}), + } + } + if (input.method.type === "env") { + return { + integrationID: Integration.ID.make(input.integrationID), + method: { type: "env", names: input.method.names }, + } + } + return { + integrationID: Integration.ID.make(input.integrationID), + method: { type: "key", label: input.method.label }, + } +} + +function credential(value: CredentialOAuth) { + return Credential.OAuth.make({ ...value, methodID: Integration.MethodID.make(value.methodID) }) +} diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts index aaf8ea95ada2..65967969dc22 100644 --- a/packages/core/src/plugin/promise.ts +++ b/packages/core/src/plugin/promise.ts @@ -1,7 +1,7 @@ export * as PluginPromise from "./promise" import { define } from "@opencode-ai/plugin/v2/effect" -import type { Plugin, PluginContext } from "@opencode-ai/plugin/v2/promise" +import type { IntegrationDefinition, Plugin, PluginContext } from "@opencode-ai/plugin/v2/promise" import { Effect, Scope, Stream } from "effect" type HostRegistration = { readonly dispose: Effect.Effect } @@ -85,36 +85,8 @@ export function fromPromise(plugin: Plugin) { attemptStatus: (input) => run(host.integration.attemptStatus(input)), attemptComplete: (input) => run(host.integration.attemptComplete(input)), attemptCancel: (input) => run(host.integration.attemptCancel(input)), - transform: (callback) => - register( - host.integration.transform((draft) => { - callback({ - ...draft, - capability: { - search: { - list: () => - draft.capability.search.list().map((provider) => ({ - integrationID: provider.integrationID, - capability: provider.capability, - execute: (input, execution) => - Effect.runPromiseWith(context)(provider.execute(input, execution)), - })), - update: (input) => - draft.capability.search.update({ - integrationID: input.integrationID, - capability: input.capability, - execute: (query, execution) => - Effect.tryPromise({ - try: (signal) => input.execute(query, { ...execution, signal }), - catch: (cause) => cause, - }), - }), - remove: draft.capability.search.remove, - }, - }, - }) - }), - ), + register: (definition) => register(host.integration.register(adaptIntegration(definition))), + transform: transform(host.integration), reload: () => run(host.integration.reload()), connection: { active: (id) => Effect.runPromiseWith(context)(host.integration.connection.active(id)), @@ -147,3 +119,54 @@ export function fromPromise(plugin: Plugin) { }), }) } + +function adaptIntegration(definition: IntegrationDefinition) { + const { methods, search, ...info } = definition + return { + ...info, + methods: methods?.map((method) => { + if (method.type !== "oauth") return method + const { authorize, refresh, ...info } = method + return { + ...info, + authorize: (inputs: Parameters[0]) => + Effect.tryPromise({ try: () => authorize(inputs), catch: (cause) => cause }).pipe( + Effect.map((authorization) => { + if (authorization.mode === "auto") { + return { + ...authorization, + callback: Effect.tryPromise({ try: () => authorization.callback, catch: (cause) => cause }), + } + } + return { + ...authorization, + callback: (code: string) => + Effect.tryPromise({ try: () => authorization.callback(code), catch: (cause) => cause }), + } + }), + ), + ...(refresh + ? { + refresh: (credential: Parameters[0]) => + Effect.tryPromise({ try: () => refresh(credential), catch: (cause) => cause }), + } + : {}), + } + }), + ...(search + ? { + search: { + connection: search.connection, + execute: ( + input: Parameters[0], + execution: Omit[1], "signal">, + ) => + Effect.tryPromise({ + try: (signal) => search.execute(input, { ...execution, signal }), + catch: (cause) => cause, + }), + }, + } + : {}), + } +} diff --git a/packages/core/src/plugin/search/exa.ts b/packages/core/src/plugin/search/exa.ts index d1fc44bfddf8..4d0f46dc6873 100644 --- a/packages/core/src/plugin/search/exa.ts +++ b/packages/core/src/plugin/search/exa.ts @@ -19,13 +19,15 @@ export const Plugin = define({ id: "opencode.search.exa", effect: Effect.fn("SearchExa.Plugin")(function* (ctx) { const http = yield* HttpClient.HttpClient - yield* ctx.integration.transform((draft) => { - draft.update("exa", (integration) => (integration.name = "Exa")) - draft.method.update({ integrationID: "exa", method: { type: "key", label: "API key (optional)" } }) - draft.method.update({ integrationID: "exa", method: { type: "env", names: ["EXA_API_KEY"] } }) - draft.capability.search.update({ - integrationID: "exa", - capability: { type: "search", connection: "optional" }, + yield* ctx.integration.register({ + id: "exa", + name: "Exa", + methods: [ + { type: "key", label: "API key (optional)" }, + { type: "env", names: ["EXA_API_KEY"] }, + ], + search: { + connection: "optional", execute: (input, context) => { const url = new URL(endpoint) if (context.credential?.type === "key") url.searchParams.set("exaApiKey", context.credential.key) @@ -37,7 +39,7 @@ export const Plugin = define({ contextMaxCharacters: input.contextMaxCharacters, }).pipe(Effect.map((text) => ({ text: text ?? "" }))) }, - }) + }, }) }), }) diff --git a/packages/core/src/plugin/search/parallel.ts b/packages/core/src/plugin/search/parallel.ts index 75a9ab7c381f..ccaaecaee1af 100644 --- a/packages/core/src/plugin/search/parallel.ts +++ b/packages/core/src/plugin/search/parallel.ts @@ -18,13 +18,15 @@ export const Plugin = define({ id: "opencode.search.parallel", effect: Effect.fn("SearchParallel.Plugin")(function* (ctx) { const http = yield* HttpClient.HttpClient - yield* ctx.integration.transform((draft) => { - draft.update("parallel", (integration) => (integration.name = "Parallel")) - draft.method.update({ integrationID: "parallel", method: { type: "key", label: "API key (optional)" } }) - draft.method.update({ integrationID: "parallel", method: { type: "env", names: ["PARALLEL_API_KEY"] } }) - draft.capability.search.update({ - integrationID: "parallel", - capability: { type: "search", connection: "optional" }, + yield* ctx.integration.register({ + id: "parallel", + name: "Parallel", + methods: [ + { type: "key", label: "API key (optional)" }, + { type: "env", names: ["PARALLEL_API_KEY"] }, + ], + search: { + connection: "optional", execute: (input, context) => SearchMcp.call( http, @@ -41,7 +43,7 @@ export const Plugin = define({ ...(context.credential?.type === "key" ? { Authorization: `Bearer ${context.credential.key}` } : {}), }, ).pipe(Effect.map((text) => ({ text: text ?? "" }))), - }) + }, }) }), }) diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index 6c0840da415f..3d43c9b064ff 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -1,11 +1,16 @@ -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { IntegrationDefinition, IntegrationMethodRegistration, PluginContext } from "@opencode-ai/plugin/v2/effect" import { AgentV2 } from "@opencode-ai/core/agent" import { Catalog } from "@opencode-ai/core/catalog" import { Credential } from "@opencode-ai/core/credential" import { Integration } from "@opencode-ai/core/integration" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" -import type { IntegrationEnvMethod, IntegrationKeyMethod, IntegrationOAuthMethod } from "@opencode-ai/sdk/v2/types" +import type { + CredentialOAuth, + IntegrationEnvMethod, + IntegrationKeyMethod, + IntegrationOAuthMethod, +} from "@opencode-ai/sdk/v2/types" import { Effect, Stream } from "effect" type Overrides = Partial> @@ -51,6 +56,7 @@ export function host(overrides: Overrides = {}): PluginContext { attemptStatus: () => Effect.die("unused integration.attemptStatus"), attemptComplete: () => Effect.die("unused integration.attemptComplete"), attemptCancel: () => Effect.die("unused integration.attemptCancel"), + register: () => Effect.die("unused integration.register"), transform: () => Effect.die("unused integration.transform"), reload: () => Effect.die("unused integration.reload"), connection: { @@ -203,6 +209,10 @@ export function integrationHost(integration: Integration.Interface): PluginConte connection.type === "credential" ? { ...connection, id: Credential.ID.make(connection.id) } : connection, ), }, + register: (definition) => + integration.transform((draft) => { + registerIntegration(draft, definition) + }), transform: (callback) => integration.transform((draft) => callback({ @@ -215,91 +225,80 @@ export function integrationHost(integration: Integration.Interface): PluginConte remove: (id) => draft.remove(Integration.ID.make(id)), method: { list: (id) => draft.method.list(Integration.ID.make(id)).map(method), - update: (input) => { - if ("authorize" in input) { - const methodID = Integration.MethodID.make(input.method.id) - const refresh = input.refresh - draft.method.update({ - integrationID: Integration.ID.make(input.integrationID), - method: { ...input.method, id: methodID }, - authorize: (inputs) => - input.authorize(inputs).pipe( - Effect.map((authorization) => { - if (authorization.mode === "auto") { - return { - ...authorization, - callback: authorization.callback.pipe( - Effect.map((credential) => - Credential.OAuth.make({ - ...credential, - methodID: Integration.MethodID.make(credential.methodID), - }), - ), - ), - } - } - return { - ...authorization, - callback: (code: string) => - authorization.callback(code).pipe( - Effect.map((credential) => - Credential.OAuth.make({ - ...credential, - methodID: Integration.MethodID.make(credential.methodID), - }), - ), - ), - } - }), - ), - ...(refresh - ? { - refresh: (value: Credential.OAuth) => - refresh(value).pipe( - Effect.map((next) => - Credential.OAuth.make({ - ...next, - methodID: Integration.MethodID.make(next.methodID), - }), - ), - ), - } - : {}), - ...(input.label ? { label: input.label } : {}), - }) - return - } - if (input.method.type === "env") { - draft.method.update({ - integrationID: Integration.ID.make(input.integrationID), - method: { ...input.method, names: [...input.method.names] }, - }) - return - } - draft.method.update({ - integrationID: Integration.ID.make(input.integrationID), - method: input.method, - }) - }, + update: (input) => draft.method.update(methodImplementation(input)), remove: (id, item) => draft.method.remove(Integration.ID.make(id), internalMethod(item)), }, - capability: { - search: { - list: () => [], - update: (input) => - draft.capability.search.update({ - integrationID: Integration.ID.make(input.integrationID), - capability: input.capability, - execute: input.execute, - }), - remove: (id) => draft.capability.search.remove(Integration.ID.make(id)), - }, - }, }), ), } } +function registerIntegration(draft: Integration.Draft, definition: IntegrationDefinition) { + const integrationID = Integration.ID.make(definition.id) + draft.update(integrationID, (integration) => (integration.name = definition.name)) + for (const item of definition.methods ?? []) { + if (item.type === "env") { + draft.method.update(methodImplementation({ integrationID: definition.id, method: item })) + continue + } + if (item.type === "key") { + draft.method.update(methodImplementation({ integrationID: definition.id, method: item })) + continue + } + const { authorize, refresh, credentialLabel, ...method } = item + draft.method.update( + methodImplementation({ + integrationID: definition.id, + method, + authorize, + ...(refresh ? { refresh } : {}), + ...(credentialLabel ? { label: credentialLabel } : {}), + }), + ) + } + if (!definition.search) return + draft.capability.search.update({ + integrationID, + capability: { type: "search", connection: definition.search.connection }, + execute: definition.search.execute, + }) +} + +function methodImplementation(input: IntegrationMethodRegistration): Integration.Implementation { + if ("authorize" in input) { + const refresh = input.refresh + return { + integrationID: Integration.ID.make(input.integrationID), + method: { ...input.method, id: Integration.MethodID.make(input.method.id) }, + authorize: (inputs) => + input.authorize(inputs).pipe( + Effect.map((authorization) => { + if (authorization.mode === "auto") { + return { ...authorization, callback: authorization.callback.pipe(Effect.map(oauthCredential)) } + } + return { + ...authorization, + callback: (code: string) => authorization.callback(code).pipe(Effect.map(oauthCredential)), + } + }), + ), + ...(refresh ? { refresh: (value: Credential.OAuth) => refresh(value).pipe(Effect.map(oauthCredential)) } : {}), + ...(input.label ? { label: input.label } : {}), + } + } + if (input.method.type === "env") { + return { + integrationID: Integration.ID.make(input.integrationID), + method: { ...input.method, names: [...input.method.names] }, + } + } + return { integrationID: Integration.ID.make(input.integrationID), method: input.method } +} + +function oauthCredential(value: CredentialOAuth) { + return Credential.OAuth.make({ ...value, methodID: Integration.MethodID.make(value.methodID) }) +} + function method(value: Integration.Method) { if (value.type === "env") return { type: value.type, names: [...value.names] } if (value.type === "key") return { type: value.type, label: value.label } diff --git a/packages/core/test/plugin/promise.test.ts b/packages/core/test/plugin/promise.test.ts index f14bab47e0ab..a4be50a50e5d 100644 --- a/packages/core/test/plugin/promise.test.ts +++ b/packages/core/test/plugin/promise.test.ts @@ -103,17 +103,23 @@ describe("fromPromise", () => { const promisePlugin = define({ id: "promise-search", setup: async (ctx) => { - await ctx.integration.transform((draft) => { - draft.capability.search.update({ - integrationID: "promise-search", - capability: { type: "search", connection: "optional" }, + await ctx.integration.register({ + id: "promise-search", + name: "Promise Search", + methods: [{ type: "env", names: ["PROMISE_SEARCH_KEY"] }], + search: { + connection: "optional", execute: async (input) => ({ text: `promise: ${input.query}` }), - }) + }, }) }, }) yield* PluginPromise.fromPromise(promisePlugin).effect(host) + expect(yield* integrations.get(Integration.ID.make("promise-search"))).toMatchObject({ + name: "Promise Search", + methods: [{ type: "env", names: ["PROMISE_SEARCH_KEY"] }], + }) const provider = yield* integrations.capability.search.get(Integration.ID.make("promise-search")) if (!provider) return yield* Effect.die("Expected promise search provider") expect(yield* provider.execute({ query: "effect" }, {})).toEqual({ text: "promise: effect" }) diff --git a/packages/core/test/plugin/search.test.ts b/packages/core/test/plugin/search.test.ts index d390ab067d8b..656faeef982a 100644 --- a/packages/core/test/plugin/search.test.ts +++ b/packages/core/test/plugin/search.test.ts @@ -20,6 +20,29 @@ beforeEach(() => { const it = searchIntegrationTest describe("built-in search integrations", () => { + it.effect("registers and disposes an atomic search integration", () => + Effect.gen(function* () { + const integrations = yield* Integration.Service + const registration = yield* integrationHost(integrations).register({ + id: "test-search", + name: "Test Search", + methods: [{ type: "key", label: "API key" }], + search: { + connection: "required", + execute: (input) => Effect.succeed({ text: input.query }), + }, + }) + + expect(yield* integrations.get(Integration.ID.make("test-search"))).toMatchObject({ + name: "Test Search", + methods: [{ type: "key", label: "API key" }], + capabilities: [{ type: "search", connection: "required" }], + }) + yield* registration.dispose + expect(yield* integrations.get(Integration.ID.make("test-search"))).toBeUndefined() + }), + ) + it.effect("registers Exa and maps search hints to its MCP tool", () => Effect.gen(function* () { const integrations = yield* Integration.Service @@ -105,5 +128,4 @@ describe("built-in search integrations", () => { expect(JSON.stringify(output)).not.toContain("parallel-secret") }), ) - }) diff --git a/packages/plugin/src/v2/effect/index.ts b/packages/plugin/src/v2/effect/index.ts index 80434b87022a..75a87dae1c15 100644 --- a/packages/plugin/src/v2/effect/index.ts +++ b/packages/plugin/src/v2/effect/index.ts @@ -8,9 +8,12 @@ export type { CommandDraft, CommandHooks } from "./command.js" export type { EventHooks } from "./event.js" export type { IntegrationDraft, + IntegrationDefinition, IntegrationHooks, + IntegrationMethodDefinition, IntegrationMethodRegistration, - IntegrationSearchCapabilityRegistration, + IntegrationOAuthMethodDefinition, + IntegrationSearchDefinition, } from "./integration.js" export type { ReferenceDraft, ReferenceHooks } from "./reference.js" export type { SkillDraft, SkillHooks } from "./skill.js" diff --git a/packages/plugin/src/v2/effect/integration.ts b/packages/plugin/src/v2/effect/integration.ts index 0665b39b2d96..30c9d98fb119 100644 --- a/packages/plugin/src/v2/effect/integration.ts +++ b/packages/plugin/src/v2/effect/integration.ts @@ -12,7 +12,7 @@ import type { import type { IntegrationApi } from "@opencode-ai/client/effect/api" import type { Search } from "@opencode-ai/schema/search" import type { Effect, Scope } from "effect" -import type { TransformHook } from "./registration.js" +import type { Registration, TransformHook } from "./registration.js" export type IntegrationOAuthAuthorization = { readonly url: string @@ -45,18 +45,29 @@ export type IntegrationMethodRegistration = readonly method: IntegrationEnvMethod } -export interface IntegrationSearchCapabilityRegistration { - readonly integrationID: string - readonly capability: { - readonly type: "search" - readonly connection: "optional" | "required" - } +export type IntegrationOAuthMethodDefinition = IntegrationOAuthMethod & { + readonly authorize: (inputs: IntegrationInputs) => Effect.Effect + readonly refresh?: (credential: CredentialOAuth) => Effect.Effect + readonly credentialLabel?: (credential: CredentialOAuth) => string | undefined +} + +export type IntegrationMethodDefinition = IntegrationOAuthMethodDefinition | IntegrationKeyMethod | IntegrationEnvMethod + +export interface IntegrationSearchDefinition { + readonly connection: "optional" | "required" readonly execute: ( input: Search.Input, context: { readonly credential?: CredentialValue; readonly sessionID?: string }, ) => Effect.Effect } +export interface IntegrationDefinition { + readonly id: string + readonly name: string + readonly methods?: readonly IntegrationMethodDefinition[] + readonly search?: IntegrationSearchDefinition +} + export interface IntegrationDraft { list(): readonly IntegrationRef[] get(id: string): IntegrationRef | undefined @@ -67,16 +78,10 @@ export interface IntegrationDraft { update(input: IntegrationMethodRegistration): void remove(integrationID: string, method: IntegrationMethod): void } - readonly capability: { - readonly search: { - list(): readonly IntegrationSearchCapabilityRegistration[] - update(input: IntegrationSearchCapabilityRegistration): void - remove(integrationID: string): void - } - } } export interface IntegrationHooks extends IntegrationApi { + readonly register: (definition: IntegrationDefinition) => Effect.Effect readonly transform: TransformHook readonly reload: () => Effect.Effect readonly connection: { diff --git a/packages/plugin/src/v2/promise/index.ts b/packages/plugin/src/v2/promise/index.ts index e3a1a62e4303..7575d1a2664a 100644 --- a/packages/plugin/src/v2/promise/index.ts +++ b/packages/plugin/src/v2/promise/index.ts @@ -9,9 +9,13 @@ export type { CommandDraft, CommandHooks } from "./command.js" export type { EventHooks } from "./event.js" export type { IntegrationDraft, + IntegrationDefinition, IntegrationHooks, + IntegrationMethodDefinition, IntegrationMethodRegistration, - IntegrationSearchCapabilityRegistration, + IntegrationOAuthAuthorization, + IntegrationOAuthMethodDefinition, + IntegrationSearchDefinition, } from "./integration.js" export type { ReferenceDraft, ReferenceHooks } from "./reference.js" export type { SessionHooks } from "./runtime.js" diff --git a/packages/plugin/src/v2/promise/integration.ts b/packages/plugin/src/v2/promise/integration.ts index 67e820981767..4016442cc74b 100644 --- a/packages/plugin/src/v2/promise/integration.ts +++ b/packages/plugin/src/v2/promise/integration.ts @@ -1,34 +1,59 @@ import type { IntegrationApi } from "@opencode-ai/client/promise/api" import type { IntegrationMethodRegistration } from "../effect/integration.js" -import type { CredentialValue } from "@opencode-ai/sdk/v2/types" +import type { + CredentialOAuth, + CredentialValue, + IntegrationEnvMethod, + IntegrationInputs, + IntegrationKeyMethod, + IntegrationOAuthMethod, +} from "@opencode-ai/sdk/v2/types" import type { Search } from "@opencode-ai/schema/search" -import type { TransformHook } from "./registration.js" +import type { Registration, TransformHook } from "./registration.js" export type { IntegrationMethodRegistration } -export interface IntegrationSearchCapabilityRegistration { - readonly integrationID: string - readonly capability: { - readonly type: "search" - readonly connection: "optional" | "required" - } +export type IntegrationOAuthAuthorization = { + readonly url: string + readonly instructions: string +} & ( + | { + readonly mode: "auto" + readonly callback: Promise + } + | { + readonly mode: "code" + readonly callback: (code: string) => Promise + } +) + +export type IntegrationOAuthMethodDefinition = IntegrationOAuthMethod & { + readonly authorize: (inputs: IntegrationInputs) => Promise + readonly refresh?: (credential: CredentialOAuth) => Promise + readonly credentialLabel?: (credential: CredentialOAuth) => string | undefined +} + +export type IntegrationMethodDefinition = IntegrationOAuthMethodDefinition | IntegrationKeyMethod | IntegrationEnvMethod + +export interface IntegrationSearchDefinition { + readonly connection: "optional" | "required" readonly execute: ( input: Search.Input, context: { readonly credential?: CredentialValue; readonly sessionID?: string; readonly signal: AbortSignal }, ) => Promise } -export interface IntegrationDraft extends Omit { - readonly capability: { - readonly search: { - list(): readonly IntegrationSearchCapabilityRegistration[] - update(input: IntegrationSearchCapabilityRegistration): void - remove(integrationID: string): void - } - } +export interface IntegrationDefinition { + readonly id: string + readonly name: string + readonly methods?: readonly IntegrationMethodDefinition[] + readonly search?: IntegrationSearchDefinition } +export type IntegrationDraft = import("../effect/integration.js").IntegrationDraft + export interface IntegrationHooks extends IntegrationApi { + readonly register: (definition: IntegrationDefinition) => Promise readonly transform: TransformHook readonly reload: () => Promise readonly connection: { diff --git a/packages/sdk-next/test/embedded.test.ts b/packages/sdk-next/test/embedded.test.ts index 6c34cc8e9012..5aa44ccfc149 100644 --- a/packages/sdk-next/test/embedded.test.ts +++ b/packages/sdk-next/test/embedded.test.ts @@ -266,14 +266,13 @@ it.live("embedded client exposes integration-backed search", () => yield* opencode.plugin({ id: `embedded-search-${crypto.randomUUID()}`, effect: (ctx) => - ctx.integration.transform((draft) => { - draft.update(providerID, (integration) => (integration.name = "Embedded search")) - draft.capability.search.update({ - integrationID: providerID, - capability: { type: "search", connection: "optional" }, - execute: (input) => - Effect.succeed({ text: `Found ${input.query}`, metadata: { source: "embedded" } }), - }) + ctx.integration.register({ + id: providerID, + name: "Embedded search", + search: { + connection: "optional", + execute: (input) => Effect.succeed({ text: `Found ${input.query}`, metadata: { source: "embedded" } }), + }, }), }) From ccad83549d94f225b61860cfa1c687442418758d Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Tue, 7 Jul 2026 19:27:50 +0530 Subject: [PATCH 6/6] refactor(search): persist provider in global config --- packages/client/src/effect/api/api.ts | 99 ++++++------- .../client/src/effect/generated/client.ts | 104 +++++++------- .../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, 736 insertions(+), 589 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 e5ced5f774c0..cd9ced2979f4 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 +export type Endpoint9_3Output = EffectValue> +export type IntegrationConnectOauthOperation = ( + input: Endpoint9_3Input, +) => Effect.Effect -type Endpoint9_4Request = Parameters[0] +type Endpoint9_4Request = Parameters[0] export type Endpoint9_4Input = { - readonly integrationID: Endpoint9_4Request["params"]["integrationID"] + readonly attemptID: Endpoint9_4Request["params"]["attemptID"] 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 IntegrationConnectOauthOperation = ( +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_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_0Output = EffectValue> -export type SearchQueryOperation = (input: Endpoint26_0Input) => Effect.Effect +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 e26055b353c6..4b3856e20395 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"]({ + raw["integration.connect.key"]({ params: { integrationID: input["integrationID"] }, query: { location: input["location"] }, - payload: { capability: input["capability"] }, + payload: { key: input["key"], label: input["label"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint9_3Request = Parameters[0] +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 methodID: Endpoint9_3Request["payload"]["methodID"] + readonly inputs: Endpoint9_3Request["payload"]["inputs"] readonly label?: Endpoint9_3Request["payload"]["label"] } const Endpoint9_3 = (raw: RawClient["server.integration"]) => (input: Endpoint9_3Input) => - raw["integration.connect.key"]({ + raw["integration.connect.oauth"]({ params: { integrationID: input["integrationID"] }, query: { location: input["location"] }, - payload: { key: input["key"], label: input["label"] }, + payload: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint9_4Request = Parameters[0] +type Endpoint9_4Request = Parameters[0] type Endpoint9_4Input = { - readonly integrationID: Endpoint9_4Request["params"]["integrationID"] + readonly attemptID: Endpoint9_4Request["params"]["attemptID"] readonly location?: Endpoint9_4Request["query"]["location"] - readonly methodID: Endpoint9_4Request["payload"]["methodID"] - readonly inputs: Endpoint9_4Request["payload"]["inputs"] - readonly label?: Endpoint9_4Request["payload"]["label"] } const Endpoint9_4 = (raw: RawClient["server.integration"]) => (input: Endpoint9_4Input) => - raw["integration.connect.oauth"]({ - params: { integrationID: input["integrationID"] }, + raw["integration.attempt.status"]({ + params: { attemptID: input["attemptID"] }, 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_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_5 = (raw: RawClient["server.integration"]) => (input: Endpoint9_5Input) => - raw["integration.attempt.status"]({ + raw["integration.attempt.complete"]({ params: { attemptID: input["attemptID"] }, query: { location: input["location"] }, + payload: { code: input["code"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint9_6Request = Parameters[0] +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"] } const Endpoint9_6 = (raw: RawClient["server.integration"]) => (input: Endpoint9_6Input) => - raw["integration.attempt.complete"]({ - params: { attemptID: input["attemptID"] }, - query: { location: input["location"] }, - payload: { code: input["code"] }, - }).pipe(Effect.mapError(mapClientError)) - -type Endpoint9_7Request = Parameters[0] -type Endpoint9_7Input = { - readonly attemptID: Endpoint9_7Request["params"]["attemptID"] - readonly location?: Endpoint9_7Request["query"]["location"] -} -const Endpoint9_7 = (raw: RawClient["server.integration"]) => (input: Endpoint9_7Input) => 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_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_0 = (raw: RawClient["server.search"]) => (input: Endpoint26_0Input) => +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 32efe4a95bf3..8a7567977900 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 0731a8689970..3e78b520be52 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 593b17b78453..3a7efe570fa9 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 11707ed806cc..d41990944423 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 f50bdc0b7fd7..97139218411a 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 000000000000..efb375b911fd --- /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 792de1dc5e3f..99ca206c1f47 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 fb7709be6315..000000000000 --- 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 78346aea58c6..0bb6050bf6e5 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 959e7b644cdc..5990d93fa79a 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 996247afe087..000000000000 --- 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 b280eaa54ee7..46ec89c4374c 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 65967969dc22..ae0703f094c6 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 35833568778d..8cfb9e4bbcda 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 85db0a6bf3b5..496805d03838 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 08be8d78a9cf..376d2f9523bd 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 3d43c9b064ff..938db47f2538 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 2498e2b1e03f..545e09e0c38f 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 a4be50a50e5d..50253ac681b2 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 656faeef982a..34ced3bdee0c 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 811711ac97fa..60347acc2f21 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 eea7534ebdaf..a9ea4bc55d0b 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 9c33ceaebdf5..3f054fe49d2e 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 9d6ff5f6934e..a6ec4d528205 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 c0f9837986e5..40085d1252a8 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 66e73e0315c4..ce5fa6661eb9 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 d5cfd38c5917..e37f5b1ccab1 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 07809f07e8ca..b4b80f839ad8 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 0a00f7599e17..6c29d5877607 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 45e5e3fbe249..5938af35b3d9 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 fbf9dfef5d79..5e89327348b8 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 b2d126864b82..96f3c5e04fca 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 5ce997182caa..fd970a96fe34 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 c8c5fca873b6..18e256bd34fd 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 e8ba0a3ddbc0..f9cc482c6f02 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 0ee2fd3b2a6b..f77f54cbab3c 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 }