tool-websearch.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  1. import { beforeEach, describe, expect, test } from "bun:test"
  2. import { Effect, Layer, Schema } from "effect"
  3. import { HttpClient, HttpClientResponse } from "effect/unstable/http"
  4. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  5. import { LayerNode } from "@opencode-ai/core/effect/layer-node"
  6. import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
  7. import { PermissionV2 } from "@opencode-ai/core/permission"
  8. import { SessionV2 } from "@opencode-ai/core/session"
  9. import { ToolRegistry } from "@opencode-ai/core/tool/registry"
  10. import { WebSearchTool } from "@opencode-ai/core/tool/websearch"
  11. import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
  12. import { testEffect } from "./lib/effect"
  13. import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
  14. const sessionID = SessionV2.ID.make("ses_websearch_test")
  15. const payload = (text: string) =>
  16. JSON.stringify({
  17. jsonrpc: "2.0",
  18. id: 1,
  19. result: { content: [{ type: "text", text }] },
  20. })
  21. describe("WebSearchTool provider selection", () => {
  22. test("rejects out-of-range numeric controls", () => {
  23. const decode = Schema.decodeUnknownSync(WebSearchTool.Input)
  24. expect(() => decode({ query: "x", numResults: 0 })).toThrow()
  25. expect(() => decode({ query: "x", numResults: WebSearchTool.MAX_NUM_RESULTS + 1 })).toThrow()
  26. expect(() => decode({ query: "x", contextMaxCharacters: WebSearchTool.MAX_CONTEXT_CHARACTERS + 1 })).toThrow()
  27. })
  28. test("selects a stable provider per session", () => {
  29. expect(WebSearchTool.selectProvider(sessionID)).toBe(WebSearchTool.selectProvider(sessionID))
  30. })
  31. test("supports an explicit operational override", () => {
  32. expect(WebSearchTool.selectProvider(sessionID, { enableExa: false, enableParallel: false }, "parallel")).toBe(
  33. "parallel",
  34. )
  35. expect(WebSearchTool.selectProvider(sessionID, { enableExa: false, enableParallel: false }, "exa")).toBe("exa")
  36. })
  37. test("prefers Parallel when both explicit flags are enabled", () => {
  38. expect(WebSearchTool.selectProvider(sessionID, { enableExa: true, enableParallel: true })).toBe("parallel")
  39. })
  40. test("prefers Exa when only its explicit flag is enabled", () => {
  41. expect(WebSearchTool.selectProvider(sessionID, { enableExa: true, enableParallel: false })).toBe("exa")
  42. })
  43. })
  44. describe("WebSearchTool MCP response parser", () => {
  45. test("parses plain JSON-RPC responses", async () => {
  46. expect(await Effect.runPromise(WebSearchTool.parseResponse(payload("search results")))).toBe("search results")
  47. })
  48. test("parses SSE JSON-RPC responses and ignores non-JSON frames", async () => {
  49. expect(
  50. await Effect.runPromise(
  51. WebSearchTool.parseResponse(`data: [DONE]\nevent: message\ndata: ${payload("search results")}\n\n`),
  52. ),
  53. ).toBe("search results")
  54. })
  55. })
  56. interface Request {
  57. readonly url: string
  58. readonly headers: Record<string, string>
  59. readonly body: unknown
  60. }
  61. const requests: Request[] = []
  62. const assertions: PermissionV2.AssertInput[] = []
  63. let responseBody = payload("search results")
  64. let makeResponse = () => new Response(responseBody, { status: 200 })
  65. let config: WebSearchTool.Config = { enableExa: false, enableParallel: false }
  66. beforeEach(() => {
  67. responseBody = payload("search results")
  68. makeResponse = () => new Response(responseBody, { status: 200 })
  69. })
  70. const http = Layer.succeed(
  71. HttpClient.HttpClient,
  72. HttpClient.make((request) =>
  73. Effect.sync(() => {
  74. if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`)
  75. requests.push({
  76. url: request.url,
  77. headers: request.headers,
  78. body: JSON.parse(new TextDecoder().decode(request.body.body)),
  79. })
  80. return HttpClientResponse.fromWeb(request, makeResponse())
  81. }),
  82. ),
  83. )
  84. const permission = Layer.succeed(
  85. PermissionV2.Service,
  86. PermissionV2.Service.of({
  87. assert: (input) => Effect.sync(() => assertions.push(input)),
  88. ask: () => Effect.die("unused"),
  89. reply: () => Effect.die("unused"),
  90. get: () => Effect.die("unused"),
  91. forSession: () => Effect.die("unused"),
  92. list: () => Effect.die("unused"),
  93. }),
  94. )
  95. const websearchConfig = Layer.succeed(
  96. WebSearchTool.ConfigService,
  97. WebSearchTool.ConfigService.of({
  98. get provider() {
  99. return config.provider
  100. },
  101. get enableExa() {
  102. return config.enableExa
  103. },
  104. get enableParallel() {
  105. return config.enableParallel
  106. },
  107. get exaApiKey() {
  108. return config.exaApiKey
  109. },
  110. get parallelApiKey() {
  111. return config.parallelApiKey
  112. },
  113. }),
  114. )
  115. const it = testEffect(
  116. AppNodeBuilder.build(
  117. LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebSearchTool.configNode, WebSearchTool.node]),
  118. [
  119. [PermissionV2.node, permission],
  120. [LayerNodePlatform.httpClient, http],
  121. [WebSearchTool.configNode, websearchConfig],
  122. [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
  123. ],
  124. ),
  125. )
  126. describe("WebSearchTool registration", () => {
  127. it.effect("registers websearch, asserts query permission, and calls Exa", () =>
  128. Effect.gen(function* () {
  129. requests.length = 0
  130. assertions.length = 0
  131. responseBody = payload("exa results")
  132. config = { provider: "exa", enableExa: false, enableParallel: false }
  133. const registry = yield* ToolRegistry.Service
  134. expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["websearch"])
  135. expect(
  136. yield* executeTool(registry, {
  137. sessionID,
  138. ...toolIdentity,
  139. call: {
  140. type: "tool-call",
  141. id: "call-exa",
  142. name: "websearch",
  143. input: {
  144. query: "effect typescript",
  145. numResults: 3,
  146. livecrawl: "preferred",
  147. type: "fast",
  148. contextMaxCharacters: 2500,
  149. },
  150. },
  151. }),
  152. ).toEqual({ type: "text", value: "exa results" })
  153. expect(assertions).toMatchObject([
  154. {
  155. sessionID,
  156. action: "websearch",
  157. resources: ["effect typescript"],
  158. save: ["*"],
  159. metadata: {
  160. query: "effect typescript",
  161. numResults: 3,
  162. livecrawl: "preferred",
  163. type: "fast",
  164. contextMaxCharacters: 2500,
  165. provider: "exa",
  166. },
  167. },
  168. ])
  169. expect(requests).toEqual([
  170. {
  171. url: WebSearchTool.EXA_URL,
  172. headers: expect.any(Object),
  173. body: {
  174. jsonrpc: "2.0",
  175. id: 1,
  176. method: "tools/call",
  177. params: {
  178. name: "web_search_exa",
  179. arguments: {
  180. query: "effect typescript",
  181. type: "fast",
  182. numResults: 3,
  183. livecrawl: "preferred",
  184. contextMaxCharacters: 2500,
  185. },
  186. },
  187. },
  188. },
  189. ])
  190. }),
  191. )
  192. it.effect("calls Parallel with session ID and keeps bearer credentials out of output", () =>
  193. Effect.gen(function* () {
  194. requests.length = 0
  195. assertions.length = 0
  196. responseBody = payload("parallel results")
  197. config = { provider: "parallel", enableExa: false, enableParallel: false, parallelApiKey: "parallel-secret" }
  198. const registry = yield* ToolRegistry.Service
  199. const settled = yield* settleTool(registry, {
  200. sessionID,
  201. ...toolIdentity,
  202. call: { type: "tool-call", id: "call-parallel", name: "websearch", input: { query: "effect layers" } },
  203. })
  204. expect(requests[0]).toMatchObject({
  205. url: WebSearchTool.PARALLEL_URL,
  206. headers: { authorization: "Bearer parallel-secret" },
  207. body: {
  208. jsonrpc: "2.0",
  209. id: 1,
  210. method: "tools/call",
  211. params: {
  212. name: "web_search",
  213. arguments: { objective: "effect layers", search_queries: ["effect layers"], session_id: sessionID },
  214. },
  215. },
  216. })
  217. expect(requests[0]?.body).not.toHaveProperty("params.arguments.model_name")
  218. expect(settled).toEqual({
  219. result: { type: "text", value: "parallel results" },
  220. output: {
  221. structured: { provider: "parallel", text: "parallel results" },
  222. content: [{ type: "text", text: "parallel results" }],
  223. },
  224. })
  225. expect(JSON.stringify(settled)).not.toContain("parallel-secret")
  226. }),
  227. )
  228. it.effect("keeps an Exa credential in the transport URL and out of model output", () =>
  229. Effect.gen(function* () {
  230. requests.length = 0
  231. assertions.length = 0
  232. responseBody = payload("credentialed exa results")
  233. config = { provider: "exa", enableExa: false, enableParallel: false, exaApiKey: "exa secret" }
  234. const registry = yield* ToolRegistry.Service
  235. const settled = yield* settleTool(registry, {
  236. sessionID,
  237. ...toolIdentity,
  238. call: { type: "tool-call", id: "call-exa-key", name: "websearch", input: { query: "effect schema" } },
  239. })
  240. expect(requests[0]?.url).toBe(`${WebSearchTool.EXA_URL}?exaApiKey=exa+secret`)
  241. expect(JSON.stringify(settled)).not.toContain("exa secret")
  242. }),
  243. )
  244. it.effect("returns the legacy no-results fallback as concise model text", () =>
  245. Effect.gen(function* () {
  246. requests.length = 0
  247. assertions.length = 0
  248. responseBody = ""
  249. config = { provider: "exa", enableExa: false, enableParallel: false }
  250. const registry = yield* ToolRegistry.Service
  251. expect(
  252. yield* executeTool(registry, {
  253. sessionID,
  254. ...toolIdentity,
  255. call: { type: "tool-call", id: "call-empty", name: "websearch", input: { query: "nothing" } },
  256. }),
  257. ).toEqual({ type: "text", value: WebSearchTool.NO_RESULTS })
  258. }),
  259. )
  260. it.effect("rejects oversized MCP response bodies", () =>
  261. Effect.gen(function* () {
  262. requests.length = 0
  263. assertions.length = 0
  264. let chunksRead = 0
  265. let cancelled = false
  266. makeResponse = () =>
  267. new Response(
  268. new ReadableStream({
  269. pull(controller) {
  270. chunksRead++
  271. if (chunksRead === 10) throw new Error("response was not stopped at the byte limit")
  272. controller.enqueue(new Uint8Array(64 * 1024))
  273. },
  274. cancel() {
  275. cancelled = true
  276. },
  277. }),
  278. { status: 200 },
  279. )
  280. config = { provider: "exa", enableExa: false, enableParallel: false }
  281. const registry = yield* ToolRegistry.Service
  282. expect(
  283. yield* executeTool(registry, {
  284. sessionID,
  285. ...toolIdentity,
  286. call: { type: "tool-call", id: "call-large-response", name: "websearch", input: { query: "too much" } },
  287. }),
  288. ).toEqual({ type: "error", value: "Unable to search the web for too much" })
  289. expect(chunksRead).toBeLessThan(10)
  290. expect(cancelled).toBe(true)
  291. }),
  292. )
  293. })