tool-websearch.test.ts 10 KB

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