From 129012c3eec43e916d33c2538947dda2263a905f Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Mon, 6 Jul 2026 19:33:13 +0530 Subject: [PATCH] feat(core): add integration-backed search --- packages/client/src/effect/api/api.ts | 60 +++-- .../client/src/effect/generated/client.ts | 78 +++--- .../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, 1753 insertions(+), 486 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 9b67aee42b3..90b183731bd 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 1001c1e0477..ad3dac87ac1 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.capability.select"]({ + params: { integrationID: input["integrationID"] }, + query: { location: input["location"] }, + payload: { capability: input["capability"] }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint9_3Request = Parameters[0] +type Endpoint9_3Input = { + readonly integrationID: Endpoint9_3Request["params"]["integrationID"] + readonly location?: Endpoint9_3Request["query"]["location"] + readonly key: Endpoint9_3Request["payload"]["key"] + readonly label?: Endpoint9_3Request["payload"]["label"] +} +const Endpoint9_3 = (raw: RawClient["server.integration"]) => (input: Endpoint9_3Input) => raw["integration.connect.key"]({ params: { integrationID: input["integrationID"] }, query: { location: input["location"] }, payload: { key: input["key"], label: input["label"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint9_3Request = Parameters[0] -type Endpoint9_3Input = { - readonly integrationID: Endpoint9_3Request["params"]["integrationID"] - readonly location?: Endpoint9_3Request["query"]["location"] - readonly methodID: Endpoint9_3Request["payload"]["methodID"] - readonly inputs: Endpoint9_3Request["payload"]["inputs"] - readonly label?: Endpoint9_3Request["payload"]["label"] +type Endpoint9_4Request = Parameters[0] +type Endpoint9_4Input = { + readonly integrationID: Endpoint9_4Request["params"]["integrationID"] + readonly location?: Endpoint9_4Request["query"]["location"] + readonly methodID: Endpoint9_4Request["payload"]["methodID"] + readonly inputs: Endpoint9_4Request["payload"]["inputs"] + readonly label?: Endpoint9_4Request["payload"]["label"] } -const Endpoint9_3 = (raw: RawClient["server.integration"]) => (input: Endpoint9_3Input) => +const Endpoint9_4 = (raw: RawClient["server.integration"]) => (input: Endpoint9_4Input) => raw["integration.connect.oauth"]({ params: { integrationID: input["integrationID"] }, query: { location: input["location"] }, payload: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint9_4Request = Parameters[0] -type Endpoint9_4Input = { - readonly attemptID: Endpoint9_4Request["params"]["attemptID"] - readonly location?: Endpoint9_4Request["query"]["location"] +type Endpoint9_5Request = Parameters[0] +type Endpoint9_5Input = { + readonly attemptID: Endpoint9_5Request["params"]["attemptID"] + readonly location?: Endpoint9_5Request["query"]["location"] } -const Endpoint9_4 = (raw: RawClient["server.integration"]) => (input: Endpoint9_4Input) => +const Endpoint9_5 = (raw: RawClient["server.integration"]) => (input: Endpoint9_5Input) => raw["integration.attempt.status"]({ params: { attemptID: input["attemptID"] }, query: { location: input["location"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint9_5Request = Parameters[0] -type Endpoint9_5Input = { - readonly attemptID: Endpoint9_5Request["params"]["attemptID"] - readonly location?: Endpoint9_5Request["query"]["location"] - readonly code?: Endpoint9_5Request["payload"]["code"] +type Endpoint9_6Request = Parameters[0] +type Endpoint9_6Input = { + readonly attemptID: Endpoint9_6Request["params"]["attemptID"] + readonly location?: Endpoint9_6Request["query"]["location"] + readonly code?: Endpoint9_6Request["payload"]["code"] } -const Endpoint9_5 = (raw: RawClient["server.integration"]) => (input: Endpoint9_5Input) => +const Endpoint9_6 = (raw: RawClient["server.integration"]) => (input: Endpoint9_6Input) => raw["integration.attempt.complete"]({ params: { attemptID: input["attemptID"] }, query: { location: input["location"] }, payload: { code: input["code"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint9_6Request = Parameters[0] -type Endpoint9_6Input = { - readonly attemptID: Endpoint9_6Request["params"]["attemptID"] - readonly location?: Endpoint9_6Request["query"]["location"] +type Endpoint9_7Request = Parameters[0] +type Endpoint9_7Input = { + readonly attemptID: Endpoint9_7Request["params"]["attemptID"] + readonly location?: Endpoint9_7Request["query"]["location"] } -const Endpoint9_6 = (raw: RawClient["server.integration"]) => (input: Endpoint9_6Input) => +const Endpoint9_7 = (raw: RawClient["server.integration"]) => (input: Endpoint9_7Input) => raw["integration.attempt.cancel"]({ params: { attemptID: input["attemptID"] }, query: { location: input["location"] }, @@ -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 d3e18279e0d..692973679ce 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 ac0efadb5c0..598abcd9ef1 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 2dbd58a8585..a053752f9eb 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 d9e0b20b22f..e0d7de983af 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 4bd33ca2402..597e3db9bef 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 00000000000..71734793398 --- /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 e6a236f5271..1f5de459930 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 00000000000..fb7709be631 --- /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 17a7d12aedd..d1da66fcbb1 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 7cdda87b4c2..25d594e95cf 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 12dad07dd34..959e7b644cd 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 00000000000..996247afe08 --- /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 21823f583b2..632c3d63994 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 2e58c6b1440..fb96beae34a 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 2789cc3c2e5..b18caa5efa8 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 bcd3db13235..aaf8ea95ada 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 00000000000..23e5d3764cc --- /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 00000000000..b816effe9b7 --- /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 00000000000..0bba789902f --- /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 00000000000..75a9ab7c381 --- /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 00000000000..a11b1a02927 --- /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 d0c984b89a3..4051149bc14 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 8defff611fa..b8921e05697 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 376d2f9523b..08be8d78a9c 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 8ef98c6530f..1e83c68972c 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 b3dfe275ba8..c368a3a358d 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 fbe4029c3a2..f14bab47e0a 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 00000000000..7472d57962b --- /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 00000000000..d390ab067d8 --- /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 00000000000..811711ac97f --- /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 53984ef69b7..eea7534ebda 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" } + result = new Search.Result({ + providerID: Integration.ID.make("parallel"), + text: "parallel results", + metadata: { requestID: "req_1" }, + }) const registry = yield* ToolRegistry.Service - const settled = yield* settleTool(registry, { - sessionID, - ...toolIdentity, - call: { type: "tool-call", id: "call-parallel", name: "websearch", input: { query: "effect layers" } }, - }) - - expect(requests[0]).toMatchObject({ - url: WebSearchTool.PARALLEL_URL, - headers: { authorization: "Bearer parallel-secret" }, - body: { - jsonrpc: "2.0", - id: 1, - method: "tools/call", - params: { - name: "web_search", - arguments: { objective: "effect layers", search_queries: ["effect layers"], session_id: sessionID }, - }, - }, - }) - expect(requests[0]?.body).not.toHaveProperty("params.arguments.model_name") - expect(settled).toEqual({ + expect( + yield* settleTool(registry, { + sessionID, + ...toolIdentity, + call: { type: "tool-call", id: "call-parallel", name: "websearch", input: { query: "effect layers" } }, + }), + ).toEqual({ result: { type: "text", value: "parallel results" }, output: { - structured: { provider: "parallel", text: "parallel results" }, + structured: { provider: "parallel", text: "parallel results", metadata: { requestID: "req_1" } }, content: [{ type: "text", text: "parallel results" }], }, }) - expect(JSON.stringify(settled)).not.toContain("parallel-secret") }), ) - it.effect("keeps an Exa credential in the transport URL and out of model output", () => + it.effect("uses the concise no-results fallback", () => Effect.gen(function* () { - requests.length = 0 - assertions.length = 0 - responseBody = payload("credentialed exa results") - config = { provider: "exa", enableExa: false, enableParallel: false, exaApiKey: "exa secret" } - const registry = yield* ToolRegistry.Service - - const settled = yield* settleTool(registry, { - sessionID, - ...toolIdentity, - call: { type: "tool-call", id: "call-exa-key", name: "websearch", input: { query: "effect schema" } }, - }) - - expect(requests[0]?.url).toBe(`${WebSearchTool.EXA_URL}?exaApiKey=exa+secret`) - expect(JSON.stringify(settled)).not.toContain("exa secret") - }), - ) - - it.effect("returns the legacy no-results fallback as concise model text", () => - Effect.gen(function* () { - requests.length = 0 - assertions.length = 0 - responseBody = "" - config = { provider: "exa", enableExa: false, enableParallel: false } + result = new Search.Result({ providerID: Integration.ID.make("exa"), text: "" }) const registry = yield* ToolRegistry.Service expect( @@ -285,39 +180,4 @@ describe("WebSearchTool registration", () => { ).toEqual({ type: "text", value: WebSearchTool.NO_RESULTS }) }), ) - - it.effect("rejects oversized MCP response bodies", () => - Effect.gen(function* () { - requests.length = 0 - assertions.length = 0 - let chunksRead = 0 - let cancelled = false - makeResponse = () => - new Response( - new ReadableStream({ - pull(controller) { - chunksRead++ - if (chunksRead === 10) throw new Error("response was not stopped at the byte limit") - controller.enqueue(new Uint8Array(64 * 1024)) - }, - cancel() { - cancelled = true - }, - }), - { status: 200 }, - ) - config = { provider: "exa", enableExa: false, enableParallel: false } - const registry = yield* ToolRegistry.Service - - expect( - yield* executeTool(registry, { - sessionID, - ...toolIdentity, - call: { type: "tool-call", id: "call-large-response", name: "websearch", input: { query: "too much" } }, - }), - ).toEqual({ type: "error", value: "Unable to search the web for too much" }) - expect(chunksRead).toBeLessThan(10) - expect(cancelled).toBe(true) - }), - ) }) diff --git a/packages/plugin/src/v2/effect/index.ts b/packages/plugin/src/v2/effect/index.ts index 2ebd0215b5a..3c2533a4950 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 1433786634e..0665b39b2d9 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 594ff7da3c4..1206e04c26d 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 bd133e889ea..67e82098176 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 3d2993a02bf..58f97418421 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 a6ec4d52820..9d6ff5f6934 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 df9d455f139..65cb536cac4 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 1454fab1cf4..527c1fdd11b 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 329bf8207ff..66e73e0315c 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 00000000000..5b93fbf966e --- /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 59484dddfb5..20da64a59c2 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 4d2cf1922c4..7648445ea76 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 6c29d587760..0a00f7599e1 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 52aba6d0e3c..c9cea13b101 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 d9b29c1fdd8..830447f2382 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 e6f7a4818c8..42f45f2c743 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 f9cc482c6f0..e8ba0a3ddbc 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, })