| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330 |
- 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.defaultLayer.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))),
- bound: (input) => Effect.succeed({ output: input.output, outputPaths: [] }),
- 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 saved to /tmp/tool-output/tool_opaque ...\n\nTAIL",
- truncated: true,
- outputPath: "/tmp/tool-output/tool_opaque",
- })
- 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("/tmp/tool-output/tool_opaque"),
- })
- expect(settled.output?.structured).toMatchObject({
- provider: "exa",
- truncated: true,
- outputPath: "/tmp/tool-output/tool_opaque",
- })
- 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" })
- }),
- )
- })
|