refactor(search): isolate provider schemas
This commit is contained in:
parent
228db3dd9a
commit
97aa6713b5
14 changed files with 151 additions and 227 deletions
|
|
@ -943,10 +943,6 @@ export type Endpoint26_2Input = {
|
|||
readonly location?: Endpoint26_2Request["query"]["location"]
|
||||
readonly query: Endpoint26_2Request["payload"]["query"]
|
||||
readonly providerID?: Endpoint26_2Request["payload"]["providerID"]
|
||||
readonly numResults?: Endpoint26_2Request["payload"]["numResults"]
|
||||
readonly livecrawl?: Endpoint26_2Request["payload"]["livecrawl"]
|
||||
readonly type?: Endpoint26_2Request["payload"]["type"]
|
||||
readonly contextMaxCharacters?: Endpoint26_2Request["payload"]["contextMaxCharacters"]
|
||||
}
|
||||
export type Endpoint26_2Output = EffectValue<ReturnType<RawClient["server.search"]["search.query"]>>
|
||||
export type SearchQueryOperation<E = never> = (input: Endpoint26_2Input) => Effect.Effect<Endpoint26_2Output, E>
|
||||
|
|
|
|||
|
|
@ -1120,22 +1120,11 @@ type Endpoint26_2Input = {
|
|||
readonly location?: Endpoint26_2Request["query"]["location"]
|
||||
readonly query: Endpoint26_2Request["payload"]["query"]
|
||||
readonly providerID?: Endpoint26_2Request["payload"]["providerID"]
|
||||
readonly numResults?: Endpoint26_2Request["payload"]["numResults"]
|
||||
readonly livecrawl?: Endpoint26_2Request["payload"]["livecrawl"]
|
||||
readonly type?: Endpoint26_2Request["payload"]["type"]
|
||||
readonly contextMaxCharacters?: Endpoint26_2Request["payload"]["contextMaxCharacters"]
|
||||
}
|
||||
const Endpoint26_2 = (raw: RawClient["server.search"]) => (input: Endpoint26_2Input) =>
|
||||
raw["search.query"]({
|
||||
query: { location: input["location"] },
|
||||
payload: {
|
||||
query: input["query"],
|
||||
providerID: input["providerID"],
|
||||
numResults: input["numResults"],
|
||||
livecrawl: input["livecrawl"],
|
||||
type: input["type"],
|
||||
contextMaxCharacters: input["contextMaxCharacters"],
|
||||
},
|
||||
payload: { query: input["query"], providerID: input["providerID"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
const adaptGroup26 = (raw: RawClient["server.search"]) => ({
|
||||
|
|
|
|||
|
|
@ -1600,14 +1600,7 @@ export function make(options: ClientOptions) {
|
|||
method: "POST",
|
||||
path: `/api/search`,
|
||||
query: { location: input["location"] },
|
||||
body: {
|
||||
query: input["query"],
|
||||
providerID: input["providerID"],
|
||||
numResults: input["numResults"],
|
||||
livecrawl: input["livecrawl"],
|
||||
type: input["type"],
|
||||
contextMaxCharacters: input["contextMaxCharacters"],
|
||||
},
|
||||
body: { query: input["query"], providerID: input["providerID"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 503, 401],
|
||||
empty: false,
|
||||
|
|
|
|||
|
|
@ -6307,54 +6307,8 @@ export type SearchQueryInput = {
|
|||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
readonly query: {
|
||||
readonly query: string
|
||||
readonly providerID?: string
|
||||
readonly numResults?: number
|
||||
readonly livecrawl?: "fallback" | "preferred"
|
||||
readonly type?: "auto" | "fast" | "deep"
|
||||
readonly contextMaxCharacters?: number
|
||||
}["query"]
|
||||
readonly providerID?: {
|
||||
readonly query: string
|
||||
readonly providerID?: string
|
||||
readonly numResults?: number
|
||||
readonly livecrawl?: "fallback" | "preferred"
|
||||
readonly type?: "auto" | "fast" | "deep"
|
||||
readonly contextMaxCharacters?: number
|
||||
}["providerID"]
|
||||
readonly numResults?: {
|
||||
readonly query: string
|
||||
readonly providerID?: string
|
||||
readonly numResults?: number
|
||||
readonly livecrawl?: "fallback" | "preferred"
|
||||
readonly type?: "auto" | "fast" | "deep"
|
||||
readonly contextMaxCharacters?: number
|
||||
}["numResults"]
|
||||
readonly livecrawl?: {
|
||||
readonly query: string
|
||||
readonly providerID?: string
|
||||
readonly numResults?: number
|
||||
readonly livecrawl?: "fallback" | "preferred"
|
||||
readonly type?: "auto" | "fast" | "deep"
|
||||
readonly contextMaxCharacters?: number
|
||||
}["livecrawl"]
|
||||
readonly type?: {
|
||||
readonly query: string
|
||||
readonly providerID?: string
|
||||
readonly numResults?: number
|
||||
readonly livecrawl?: "fallback" | "preferred"
|
||||
readonly type?: "auto" | "fast" | "deep"
|
||||
readonly contextMaxCharacters?: number
|
||||
}["type"]
|
||||
readonly contextMaxCharacters?: {
|
||||
readonly query: string
|
||||
readonly providerID?: string
|
||||
readonly numResults?: number
|
||||
readonly livecrawl?: "fallback" | "preferred"
|
||||
readonly type?: "auto" | "fast" | "deep"
|
||||
readonly contextMaxCharacters?: number
|
||||
}["contextMaxCharacters"]
|
||||
readonly query: { readonly query: string; readonly providerID?: string }["query"]
|
||||
readonly providerID?: { readonly query: string; readonly providerID?: string }["providerID"]
|
||||
}
|
||||
|
||||
export type SearchQueryOutput = {
|
||||
|
|
|
|||
|
|
@ -64,14 +64,13 @@ test("search.query uses the public HTTP contract", async () => {
|
|||
const result = await client.search.query({
|
||||
query: "opencode",
|
||||
providerID: "exa",
|
||||
numResults: 5,
|
||||
location: { directory: "/tmp/project" },
|
||||
})
|
||||
|
||||
expect(result.data).toEqual({ providerID: "exa", text: "result", metadata: { requestID: "req_test" } })
|
||||
expect(request?.method).toBe("POST")
|
||||
expect(request?.url).toBe("http://localhost:3000/api/search?location%5Bdirectory%5D=%2Ftmp%2Fproject")
|
||||
expect(await request?.json()).toEqual({ query: "opencode", providerID: "exa", numResults: 5 })
|
||||
expect(await request?.json()).toEqual({ query: "opencode", providerID: "exa" })
|
||||
})
|
||||
|
||||
test("search provider methods use the public HTTP contract", async () => {
|
||||
|
|
|
|||
|
|
@ -7,12 +7,19 @@ import { SearchMcp } from "./mcp"
|
|||
|
||||
export const endpoint = "https://mcp.exa.ai/mcp"
|
||||
|
||||
const Args = Schema.Struct({
|
||||
const Input = Schema.Struct({
|
||||
query: Schema.String,
|
||||
type: Schema.String,
|
||||
numResults: Schema.Number,
|
||||
livecrawl: Schema.String,
|
||||
contextMaxCharacters: Schema.optional(Schema.Number),
|
||||
numResults: Schema.Number.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
const Output = Schema.Struct({
|
||||
content: Schema.Array(
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("text"),
|
||||
text: Schema.String,
|
||||
_meta: Schema.Struct({ searchTime: Schema.Number }).pipe(Schema.optional),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
|
||||
|
|
@ -31,13 +38,21 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
|
|||
execute: (input, context) => {
|
||||
const url = new URL(endpoint)
|
||||
if (context.credential?.type === "key") url.searchParams.set("exaApiKey", context.credential.key)
|
||||
return SearchMcp.call(http, url.toString(), "web_search_exa", Args, {
|
||||
query: input.query,
|
||||
type: input.type ?? "auto",
|
||||
numResults: input.numResults ?? 8,
|
||||
livecrawl: input.livecrawl ?? "fallback",
|
||||
contextMaxCharacters: input.contextMaxCharacters,
|
||||
}).pipe(Effect.map((text) => ({ text: text ?? "" })))
|
||||
return SearchMcp.call(
|
||||
http,
|
||||
url.toString(),
|
||||
"web_search_exa",
|
||||
{ input: Input, output: Output },
|
||||
{ query: input.query },
|
||||
).pipe(
|
||||
Effect.map((result) => {
|
||||
const content = result?.content.find((item) => item.text)
|
||||
return {
|
||||
text: content?.text ?? "",
|
||||
...(content?._meta ? { metadata: content._meta } : {}),
|
||||
}
|
||||
}),
|
||||
)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,30 +6,24 @@ 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* () {
|
||||
export const parseResponse = <F extends Schema.Struct.Fields>(body: string, result: Schema.Struct<F>) => {
|
||||
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ result })))
|
||||
const parse = (payload: string) => {
|
||||
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
|
||||
if (!trimmed.startsWith("{")) return Effect.succeed(undefined)
|
||||
return decode(trimmed).pipe(Effect.map((response) => response.result))
|
||||
}
|
||||
})
|
||||
return Effect.gen(function* () {
|
||||
const trimmed = body.trim()
|
||||
const direct = trimmed ? yield* parse(trimmed) : undefined
|
||||
if (direct) return direct
|
||||
for (const line of body.split("\n")) {
|
||||
if (!line.startsWith("data: ")) continue
|
||||
const data = yield* parse(line.substring(6))
|
||||
if (data) return data
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const Request = <F extends Schema.Struct.Fields>(args: Schema.Struct<F>) =>
|
||||
Schema.Struct({
|
||||
|
|
@ -39,11 +33,11 @@ const Request = <F extends Schema.Struct.Fields>(args: Schema.Struct<F>) =>
|
|||
params: Schema.Struct({ name: Schema.String, arguments: args }),
|
||||
})
|
||||
|
||||
export const call = <F extends Schema.Struct.Fields>(
|
||||
export const call = <F extends Schema.Struct.Fields, R extends Schema.Struct.Fields>(
|
||||
http: HttpClient.HttpClient,
|
||||
url: string,
|
||||
tool: string,
|
||||
args: Schema.Struct<F>,
|
||||
schema: { readonly input: Schema.Struct<F>; readonly output: Schema.Struct<R> },
|
||||
value: Schema.Struct.Type<F>,
|
||||
headers: Record<string, string> = {},
|
||||
) =>
|
||||
|
|
@ -51,7 +45,7 @@ export const call = <F extends Schema.Struct.Fields>(
|
|||
const request = yield* HttpClientRequest.post(url).pipe(
|
||||
HttpClientRequest.accept("application/json, text/event-stream"),
|
||||
HttpClientRequest.setHeaders(headers),
|
||||
HttpClientRequest.schemaBodyJson(Request(args))({
|
||||
HttpClientRequest.schemaBodyJson(Request(schema.input))({
|
||||
jsonrpc: "2.0" as const,
|
||||
id: 1 as const,
|
||||
method: "tools/call" as const,
|
||||
|
|
@ -65,7 +59,7 @@ export const call = <F extends Schema.Struct.Fields>(
|
|||
MAX_RESPONSE_BYTES,
|
||||
() => new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`),
|
||||
)
|
||||
return yield* parseResponse(body.toString("utf8"))
|
||||
return yield* parseResponse(body.toString("utf8"), schema.output)
|
||||
}).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: Duration.seconds(25),
|
||||
|
|
|
|||
|
|
@ -8,11 +8,46 @@ import { SearchMcp } from "./mcp"
|
|||
|
||||
export const endpoint = "https://search.parallel.ai/mcp"
|
||||
|
||||
const Args = Schema.Struct({
|
||||
const Input = Schema.Struct({
|
||||
objective: Schema.String,
|
||||
search_queries: Schema.Array(Schema.String),
|
||||
session_id: Schema.String.check(Schema.isMaxLength(100)).pipe(Schema.optional),
|
||||
model_name: Schema.String.check(Schema.isMaxLength(100)).pipe(Schema.optional),
|
||||
})
|
||||
|
||||
const Metadata = Schema.Struct({
|
||||
search_id: Schema.String,
|
||||
results: Schema.Array(
|
||||
Schema.Struct({
|
||||
url: Schema.String,
|
||||
title: Schema.NullOr(Schema.String).pipe(Schema.optional),
|
||||
publish_date: Schema.NullOr(Schema.String).pipe(Schema.optional),
|
||||
excerpts: Schema.Array(Schema.String),
|
||||
}),
|
||||
),
|
||||
warnings: Schema.NullOr(
|
||||
Schema.Array(
|
||||
Schema.Struct({
|
||||
type: Schema.Literals(["spec_validation_warning", "input_validation_warning", "warning"]),
|
||||
message: Schema.String,
|
||||
detail: Schema.NullOr(Schema.Record(Schema.String, Schema.Json)).pipe(Schema.optional),
|
||||
}),
|
||||
),
|
||||
).pipe(Schema.optional),
|
||||
usage: Schema.NullOr(
|
||||
Schema.Array(
|
||||
Schema.Struct({
|
||||
name: Schema.String,
|
||||
count: Schema.Int,
|
||||
}),
|
||||
),
|
||||
).pipe(Schema.optional),
|
||||
session_id: Schema.String,
|
||||
})
|
||||
const Output = Schema.Struct({
|
||||
content: Schema.Array(Schema.Struct({ type: Schema.Literal("text"), text: Schema.String })),
|
||||
structuredContent: Metadata,
|
||||
})
|
||||
|
||||
export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
|
||||
id: "opencode.search.parallel",
|
||||
|
|
@ -32,17 +67,25 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
|
|||
http,
|
||||
endpoint,
|
||||
"web_search",
|
||||
Args,
|
||||
{ input: Input, output: Output },
|
||||
{
|
||||
objective: input.query,
|
||||
search_queries: [input.query],
|
||||
session_id: context.sessionID ?? "opencode",
|
||||
...(context.sessionID ? { session_id: context.sessionID } : {}),
|
||||
},
|
||||
{
|
||||
"User-Agent": `opencode/${InstallationVersion}`,
|
||||
...(context.credential?.type === "key" ? { Authorization: `Bearer ${context.credential.key}` } : {}),
|
||||
},
|
||||
).pipe(Effect.map((text) => ({ text: text ?? "" }))),
|
||||
).pipe(
|
||||
Effect.map((result) => {
|
||||
const content = result?.content.find((item) => item.text)
|
||||
return {
|
||||
text: content?.text ?? "",
|
||||
...(result ? { metadata: result.structuredContent } : {}),
|
||||
}
|
||||
}),
|
||||
),
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -4,43 +4,19 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plu
|
|||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
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"
|
||||
|
||||
export const name = "websearch"
|
||||
export const NO_RESULTS = "No search results found. Please try a different query."
|
||||
export const EXA_URL = SearchExa.endpoint
|
||||
export const PARALLEL_URL = SearchParallel.endpoint
|
||||
export const MAX_NUM_RESULTS = 20
|
||||
export const MAX_CONTEXT_CHARACTERS = 50_000
|
||||
export const MAX_RESPONSE_BYTES = SearchMcp.MAX_RESPONSE_BYTES
|
||||
export const parseResponse = SearchMcp.parseResponse
|
||||
|
||||
export const description = `Search the web using the user's selected search integration. Use this for current information beyond knowledge cutoff.
|
||||
|
||||
Optional controls support result count, live crawling ('fallback' or 'preferred'), search type ('auto', 'fast', or 'deep'), and maximum context characters. Providers apply supported controls and otherwise use their defaults.
|
||||
|
||||
The current year is ${new Date().getFullYear()}. Use this year when searching for recent information or current events.`
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
query: Schema.String.annotate({ description: "Websearch query" }),
|
||||
numResults: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_NUM_RESULTS))).annotate({
|
||||
description: `Number of search results to return (maximum: ${MAX_NUM_RESULTS})`,
|
||||
}),
|
||||
livecrawl: Schema.optional(Schema.Literals(["fallback", "preferred"])).annotate({
|
||||
description: "Live crawl preference when supported by the selected provider",
|
||||
}),
|
||||
type: Schema.optional(Schema.Literals(["auto", "fast", "deep"])).annotate({
|
||||
description: "Search depth preference when supported by the selected provider",
|
||||
}),
|
||||
contextMaxCharacters: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_CONTEXT_CHARACTERS))).annotate(
|
||||
{ description: `Maximum context characters (maximum: ${MAX_CONTEXT_CHARACTERS})` },
|
||||
),
|
||||
})
|
||||
|
||||
const Output = Schema.Struct({
|
||||
|
|
|
|||
|
|
@ -9,11 +9,11 @@ import { requests, resetSearchFixture, searchIntegrationTest } from "./search-fi
|
|||
|
||||
beforeEach(() => {
|
||||
resetSearchFixture(
|
||||
JSON.stringify({
|
||||
`event: message\ndata: ${JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
result: { content: [{ type: "text", text: "search results" }] },
|
||||
}),
|
||||
result: { content: [{ type: "text", text: "search results", _meta: { searchTime: 123 } }] },
|
||||
})}\n\n`,
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -43,7 +43,7 @@ describe("built-in search integrations", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("registers Exa and maps search hints to its MCP tool", () =>
|
||||
it.effect("registers Exa with its MCP schema", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
yield* SearchExa.Plugin.effect(host({ integration: integrationHost(integrations) }))
|
||||
|
|
@ -59,16 +59,10 @@ describe("built-in search integrations", () => {
|
|||
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,
|
||||
},
|
||||
{ query: "effect typescript" },
|
||||
{ credential: Credential.Key.make({ type: "key", key: "exa secret" }) },
|
||||
),
|
||||
).toEqual({ text: "search results" })
|
||||
).toEqual({ text: "search results", metadata: { searchTime: 123 } })
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
url: `${SearchExa.endpoint}?exaApiKey=exa+secret`,
|
||||
|
|
@ -79,13 +73,7 @@ describe("built-in search integrations", () => {
|
|||
method: "tools/call",
|
||||
params: {
|
||||
name: "web_search_exa",
|
||||
arguments: {
|
||||
query: "effect typescript",
|
||||
type: "fast",
|
||||
numResults: 3,
|
||||
livecrawl: "preferred",
|
||||
contextMaxCharacters: 2500,
|
||||
},
|
||||
arguments: { query: "effect typescript" },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -95,6 +83,29 @@ describe("built-in search integrations", () => {
|
|||
|
||||
it.effect("registers Parallel and keeps its credential in the authorization header", () =>
|
||||
Effect.gen(function* () {
|
||||
resetSearchFixture(
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
result: {
|
||||
content: [{ type: "text", text: "search results" }],
|
||||
structuredContent: {
|
||||
search_id: "search_1",
|
||||
results: [
|
||||
{
|
||||
url: "https://effect.website",
|
||||
title: "Effect",
|
||||
publish_date: null,
|
||||
excerpts: ["Effect documentation"],
|
||||
},
|
||||
],
|
||||
warnings: null,
|
||||
usage: [{ name: "sku_search", count: 1 }],
|
||||
session_id: "ses_parallel",
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
const integrations = yield* Integration.Service
|
||||
yield* SearchParallel.Plugin.effect(host({ integration: integrationHost(integrations) }))
|
||||
const provider = yield* integrations.search.get(Integration.ID.make("parallel"))
|
||||
|
|
@ -107,7 +118,23 @@ describe("built-in search integrations", () => {
|
|||
credential: Credential.Key.make({ type: "key", key: "parallel-secret" }),
|
||||
},
|
||||
)
|
||||
expect(output).toEqual({ text: "search results" })
|
||||
expect(output).toEqual({
|
||||
text: "search results",
|
||||
metadata: {
|
||||
search_id: "search_1",
|
||||
results: [
|
||||
{
|
||||
url: "https://effect.website",
|
||||
title: "Effect",
|
||||
publish_date: null,
|
||||
excerpts: ["Effect documentation"],
|
||||
},
|
||||
],
|
||||
warnings: null,
|
||||
usage: [{ name: "sku_search", count: 1 }],
|
||||
session_id: "ses_parallel",
|
||||
},
|
||||
})
|
||||
expect(requests[0]).toMatchObject({
|
||||
url: SearchParallel.endpoint,
|
||||
headers: { authorization: "Bearer parallel-secret" },
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { beforeEach, describe, expect, test } from "bun:test"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { beforeEach, describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
|
|
@ -20,36 +20,6 @@ const webSearchToolNode = makeLocationNode({
|
|||
})
|
||||
|
||||
const sessionID = SessionV2.ID.make("ses_websearch_test")
|
||||
const payload = (text: string) =>
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
result: { content: [{ type: "text", text }] },
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
describe("WebSearchTool MCP response parser", () => {
|
||||
test("parses plain JSON-RPC responses", async () => {
|
||||
expect(await Effect.runPromise(WebSearchTool.parseResponse(payload("search results")))).toBe("search results")
|
||||
})
|
||||
|
||||
test("parses SSE JSON-RPC responses and ignores non-JSON frames", async () => {
|
||||
expect(
|
||||
await Effect.runPromise(
|
||||
WebSearchTool.parseResponse(`data: [DONE]\nevent: message\ndata: ${payload("search results")}\n\n`),
|
||||
),
|
||||
).toBe("search results")
|
||||
})
|
||||
})
|
||||
|
||||
const assertions: PermissionV2.AssertInput[] = []
|
||||
const queries: Search.QueryInput[] = []
|
||||
let result = new Search.Result({ providerID: Integration.ID.make("exa"), text: "search results" })
|
||||
|
|
@ -105,13 +75,7 @@ describe("WebSearchTool registration", () => {
|
|||
type: "tool-call",
|
||||
id: "call-search",
|
||||
name: "websearch",
|
||||
input: {
|
||||
query: "effect typescript",
|
||||
numResults: 3,
|
||||
livecrawl: "preferred",
|
||||
type: "fast",
|
||||
contextMaxCharacters: 2500,
|
||||
},
|
||||
input: { query: "effect typescript" },
|
||||
},
|
||||
}),
|
||||
).toEqual({ type: "text", value: "search results" })
|
||||
|
|
@ -121,23 +85,13 @@ describe("WebSearchTool registration", () => {
|
|||
action: "websearch",
|
||||
resources: ["effect typescript"],
|
||||
save: ["*"],
|
||||
metadata: {
|
||||
query: "effect typescript",
|
||||
numResults: 3,
|
||||
livecrawl: "preferred",
|
||||
type: "fast",
|
||||
contextMaxCharacters: 2500,
|
||||
},
|
||||
metadata: { query: "effect typescript" },
|
||||
},
|
||||
])
|
||||
expect(queries).toEqual([
|
||||
{
|
||||
sessionID,
|
||||
query: "effect typescript",
|
||||
numResults: 3,
|
||||
livecrawl: "preferred",
|
||||
type: "fast",
|
||||
contextMaxCharacters: 2500,
|
||||
},
|
||||
])
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -2,16 +2,12 @@ export * as Search from "./search.js"
|
|||
|
||||
import { Schema } from "effect"
|
||||
import { IntegrationID } from "./integration-id.js"
|
||||
import { optional, PositiveInt } from "./schema.js"
|
||||
import { optional } from "./schema.js"
|
||||
|
||||
export interface Input extends Schema.Schema.Type<typeof Input> {}
|
||||
export const Input = Schema.Struct({
|
||||
query: Schema.String,
|
||||
providerID: IntegrationID.pipe(optional),
|
||||
numResults: PositiveInt.check(Schema.isLessThanOrEqualTo(20)).pipe(optional),
|
||||
livecrawl: Schema.Literals(["fallback", "preferred"]).pipe(optional),
|
||||
type: Schema.Literals(["auto", "fast", "deep"]).pipe(optional),
|
||||
contextMaxCharacters: PositiveInt.check(Schema.isLessThanOrEqualTo(50_000)).pipe(optional),
|
||||
}).annotate({ identifier: "Search.Input" })
|
||||
|
||||
export interface ProviderOutput extends Schema.Schema.Type<typeof ProviderOutput> {}
|
||||
|
|
|
|||
|
|
@ -8339,10 +8339,6 @@ export class Search extends HeyApiClient {
|
|||
} | null
|
||||
query?: string
|
||||
providerID?: string
|
||||
numResults?: number
|
||||
livecrawl?: "fallback" | "preferred"
|
||||
type?: "auto" | "fast" | "deep"
|
||||
contextMaxCharacters?: number
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
|
|
@ -8354,10 +8350,6 @@ export class Search extends HeyApiClient {
|
|||
{ in: "query", key: "location" },
|
||||
{ in: "body", key: "query" },
|
||||
{ in: "body", key: "providerID" },
|
||||
{ in: "body", key: "numResults" },
|
||||
{ in: "body", key: "livecrawl" },
|
||||
{ in: "body", key: "type" },
|
||||
{ in: "body", key: "contextMaxCharacters" },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -19036,10 +19036,6 @@ export type V2SearchQueryData = {
|
|||
body: {
|
||||
query: string
|
||||
providerID?: string
|
||||
numResults?: number
|
||||
livecrawl?: "fallback" | "preferred"
|
||||
type?: "auto" | "fast" | "deep"
|
||||
contextMaxCharacters?: number
|
||||
}
|
||||
path?: never
|
||||
query?: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue