tool-websearch.test.ts 11 KB

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