332 lines
12 KiB
TypeScript
332 lines
12 KiB
TypeScript
import { describe, expect, test } from "bun:test"
|
|
import { Effect, Layer, Schema } from "effect"
|
|
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
|
import { PermissionV2 } from "@opencode-ai/core/permission"
|
|
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 { testEffect } from "./lib/effect"
|
|
|
|
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 provider selection", () => {
|
|
test("rejects out-of-range numeric controls", () => {
|
|
const decode = Schema.decodeUnknownSync(WebSearchTool.Parameters)
|
|
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", () => {
|
|
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")
|
|
})
|
|
})
|
|
|
|
interface Request {
|
|
readonly url: string
|
|
readonly headers: Record<string, string>
|
|
readonly body: unknown
|
|
}
|
|
|
|
const requests: Request[] = []
|
|
const assertions: PermissionV2.AssertInput[] = []
|
|
const truncations: ToolOutputStore.TruncateInput[] = []
|
|
let responseBody = payload("search results")
|
|
let config: WebSearchTool.Config = { enableExa: false, enableParallel: false }
|
|
let truncate = (input: ToolOutputStore.TruncateInput): Effect.Effect<ToolOutputStore.TruncateResult> =>
|
|
Effect.succeed({ content: input.content, truncated: false })
|
|
|
|
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(responseBody, { status: 200 }))
|
|
}),
|
|
),
|
|
)
|
|
const permission = Layer.succeed(
|
|
PermissionV2.Service,
|
|
PermissionV2.Service.of({
|
|
assert: (input) => Effect.sync(() => assertions.push(input)),
|
|
ask: () => Effect.die("unused"),
|
|
reply: () => Effect.die("unused"),
|
|
get: () => Effect.die("unused"),
|
|
forSession: () => Effect.die("unused"),
|
|
list: () => Effect.die("unused"),
|
|
}),
|
|
)
|
|
const registry = ToolRegistry.layer.pipe(Layer.provide(permission))
|
|
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 resources = Layer.succeed(
|
|
ToolOutputStore.Service,
|
|
ToolOutputStore.Service.of({
|
|
limits: () => Effect.die("unused"),
|
|
write: () => Effect.die("unused"),
|
|
truncate: (input) => Effect.sync(() => truncations.push(input)).pipe(Effect.andThen(truncate(input))),
|
|
read: () => Effect.die("unused"),
|
|
cleanup: () => Effect.die("unused"),
|
|
}),
|
|
)
|
|
const websearch = WebSearchTool.layer.pipe(
|
|
Layer.provide(registry),
|
|
Layer.provide(permission),
|
|
Layer.provide(http),
|
|
Layer.provide(websearchConfig),
|
|
Layer.provide(resources),
|
|
)
|
|
const it = testEffect(Layer.mergeAll(registry, permission, http, websearchConfig, resources, websearch))
|
|
|
|
describe("WebSearchTool contribution", () => {
|
|
it.effect("registers websearch, asserts query permission, and calls Exa", () =>
|
|
Effect.gen(function* () {
|
|
requests.length = 0
|
|
assertions.length = 0
|
|
truncations.length = 0
|
|
truncate = (input) => Effect.succeed({ content: input.content, truncated: false })
|
|
responseBody = payload("exa results")
|
|
config = { provider: "exa", enableExa: false, enableParallel: false }
|
|
const registry = yield* ToolRegistry.Service
|
|
|
|
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["websearch"])
|
|
expect(
|
|
yield* registry.execute({
|
|
sessionID,
|
|
call: {
|
|
type: "tool-call",
|
|
id: "call-exa",
|
|
name: "websearch",
|
|
input: {
|
|
query: "effect typescript",
|
|
numResults: 3,
|
|
livecrawl: "preferred",
|
|
type: "fast",
|
|
contextMaxCharacters: 2500,
|
|
},
|
|
},
|
|
}),
|
|
).toEqual({ type: "text", value: "exa results" })
|
|
expect(assertions).toEqual([
|
|
{
|
|
sessionID,
|
|
action: "websearch",
|
|
resources: ["effect typescript"],
|
|
save: ["*"],
|
|
metadata: {
|
|
query: "effect typescript",
|
|
numResults: 3,
|
|
livecrawl: "preferred",
|
|
type: "fast",
|
|
contextMaxCharacters: 2500,
|
|
provider: "exa",
|
|
},
|
|
},
|
|
])
|
|
expect(requests).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,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
])
|
|
}),
|
|
)
|
|
|
|
it.effect("calls Parallel with session ID and keeps bearer credentials out of output", () =>
|
|
Effect.gen(function* () {
|
|
requests.length = 0
|
|
assertions.length = 0
|
|
responseBody = payload("parallel results")
|
|
config = { provider: "parallel", enableExa: false, enableParallel: false, parallelApiKey: "parallel-secret" }
|
|
const registry = yield* ToolRegistry.Service
|
|
|
|
const settled = yield* registry.settle({
|
|
sessionID,
|
|
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({
|
|
result: { type: "text", value: "parallel results" },
|
|
output: {
|
|
structured: { provider: "parallel", text: "parallel results", truncated: false },
|
|
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", () =>
|
|
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* registry.settle({
|
|
sessionID,
|
|
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 }
|
|
const registry = yield* ToolRegistry.Service
|
|
|
|
expect(
|
|
yield* registry.execute({
|
|
sessionID,
|
|
call: { type: "tool-call", id: "call-empty", name: "websearch", input: { query: "nothing" } },
|
|
}),
|
|
).toEqual({ type: "text", value: WebSearchTool.NO_RESULTS })
|
|
}),
|
|
)
|
|
|
|
it.effect("exposes managed overflow through typed structured output", () =>
|
|
Effect.gen(function* () {
|
|
requests.length = 0
|
|
assertions.length = 0
|
|
truncations.length = 0
|
|
responseBody = payload("full search results")
|
|
config = { provider: "exa", enableExa: false, enableParallel: false }
|
|
truncate = (input) =>
|
|
Effect.succeed({
|
|
content: "HEAD\n\n... output truncated; full content available as tool-output://opaque ...\n\nTAIL",
|
|
truncated: true,
|
|
resource: new ToolOutputStore.Resource({
|
|
uri: "tool-output://opaque",
|
|
mime: "text/plain",
|
|
size: input.content.length,
|
|
}),
|
|
})
|
|
const registry = yield* ToolRegistry.Service
|
|
|
|
const settled = yield* registry.settle({
|
|
sessionID,
|
|
call: { type: "tool-call", id: "call-overflow", name: "websearch", input: { query: "verbose" } },
|
|
})
|
|
|
|
expect(settled.result).toMatchObject({ type: "text", value: expect.stringContaining("tool-output://opaque") })
|
|
expect(settled.output?.structured).toMatchObject({
|
|
provider: "exa",
|
|
truncated: true,
|
|
resource: { uri: "tool-output://opaque", mime: "text/plain" },
|
|
})
|
|
expect(truncations).toEqual([{ sessionID, toolCallID: "call-overflow", content: "full search results" }])
|
|
}),
|
|
)
|
|
|
|
it.effect("rejects oversized MCP response bodies", () =>
|
|
Effect.gen(function* () {
|
|
requests.length = 0
|
|
assertions.length = 0
|
|
responseBody = "x".repeat(WebSearchTool.MAX_RESPONSE_BYTES + 1)
|
|
config = { provider: "exa", enableExa: false, enableParallel: false }
|
|
const registry = yield* ToolRegistry.Service
|
|
|
|
expect(
|
|
yield* registry.execute({
|
|
sessionID,
|
|
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" })
|
|
}),
|
|
)
|
|
})
|