tool-websearch.test.ts 11 KB

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