tool-websearch.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  1. import { 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 { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
  9. import { testEffect } from "./lib/effect"
  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.Parameters)
  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. const truncations: ToolOutputStore.TruncateInput[] = []
  60. let responseBody = payload("search results")
  61. let config: WebSearchTool.Config = { enableExa: false, enableParallel: false }
  62. let truncate = (input: ToolOutputStore.TruncateInput): Effect.Effect<ToolOutputStore.TruncateResult> =>
  63. Effect.succeed({ content: input.content, truncated: false })
  64. const http = Layer.succeed(
  65. HttpClient.HttpClient,
  66. HttpClient.make((request) =>
  67. Effect.sync(() => {
  68. if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`)
  69. requests.push({
  70. url: request.url,
  71. headers: request.headers,
  72. body: JSON.parse(new TextDecoder().decode(request.body.body)),
  73. })
  74. return HttpClientResponse.fromWeb(request, new Response(responseBody, { status: 200 }))
  75. }),
  76. ),
  77. )
  78. const permission = Layer.succeed(
  79. PermissionV2.Service,
  80. PermissionV2.Service.of({
  81. assert: (input) => Effect.sync(() => assertions.push(input)),
  82. ask: () => Effect.die("unused"),
  83. reply: () => Effect.die("unused"),
  84. get: () => Effect.die("unused"),
  85. forSession: () => Effect.die("unused"),
  86. list: () => Effect.die("unused"),
  87. }),
  88. )
  89. const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
  90. const websearchConfig = Layer.succeed(
  91. WebSearchTool.ConfigService,
  92. WebSearchTool.ConfigService.of({
  93. get provider() {
  94. return config.provider
  95. },
  96. get enableExa() {
  97. return config.enableExa
  98. },
  99. get enableParallel() {
  100. return config.enableParallel
  101. },
  102. get exaApiKey() {
  103. return config.exaApiKey
  104. },
  105. get parallelApiKey() {
  106. return config.parallelApiKey
  107. },
  108. }),
  109. )
  110. const resources = Layer.succeed(
  111. ToolOutputStore.Service,
  112. ToolOutputStore.Service.of({
  113. limits: () => Effect.die("unused"),
  114. write: () => Effect.die("unused"),
  115. truncate: (input) => Effect.sync(() => truncations.push(input)).pipe(Effect.andThen(truncate(input))),
  116. bound: (input) => Effect.succeed({ output: input.output, outputPaths: [] }),
  117. cleanup: () => Effect.die("unused"),
  118. }),
  119. )
  120. const websearch = WebSearchTool.layer.pipe(
  121. Layer.provide(registry),
  122. Layer.provide(permission),
  123. Layer.provide(http),
  124. Layer.provide(websearchConfig),
  125. Layer.provide(resources),
  126. )
  127. const it = testEffect(Layer.mergeAll(registry, permission, http, websearchConfig, resources, websearch))
  128. describe("WebSearchTool contribution", () => {
  129. it.effect("registers websearch, asserts query permission, and calls Exa", () =>
  130. Effect.gen(function* () {
  131. requests.length = 0
  132. assertions.length = 0
  133. truncations.length = 0
  134. truncate = (input) => Effect.succeed({ content: input.content, truncated: false })
  135. responseBody = payload("exa results")
  136. config = { provider: "exa", enableExa: false, enableParallel: false }
  137. const registry = yield* ToolRegistry.Service
  138. expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["websearch"])
  139. expect(
  140. yield* registry.execute({
  141. sessionID,
  142. call: {
  143. type: "tool-call",
  144. id: "call-exa",
  145. name: "websearch",
  146. input: {
  147. query: "effect typescript",
  148. numResults: 3,
  149. livecrawl: "preferred",
  150. type: "fast",
  151. contextMaxCharacters: 2500,
  152. },
  153. },
  154. }),
  155. ).toEqual({ type: "text", value: "exa results" })
  156. expect(assertions).toEqual([
  157. {
  158. sessionID,
  159. action: "websearch",
  160. resources: ["effect typescript"],
  161. save: ["*"],
  162. metadata: {
  163. query: "effect typescript",
  164. numResults: 3,
  165. livecrawl: "preferred",
  166. type: "fast",
  167. contextMaxCharacters: 2500,
  168. provider: "exa",
  169. },
  170. },
  171. ])
  172. expect(requests).toEqual([
  173. {
  174. url: WebSearchTool.EXA_URL,
  175. headers: expect.any(Object),
  176. body: {
  177. jsonrpc: "2.0",
  178. id: 1,
  179. method: "tools/call",
  180. params: {
  181. name: "web_search_exa",
  182. arguments: {
  183. query: "effect typescript",
  184. type: "fast",
  185. numResults: 3,
  186. livecrawl: "preferred",
  187. contextMaxCharacters: 2500,
  188. },
  189. },
  190. },
  191. },
  192. ])
  193. }),
  194. )
  195. it.effect("calls Parallel with session ID and keeps bearer credentials out of output", () =>
  196. Effect.gen(function* () {
  197. requests.length = 0
  198. assertions.length = 0
  199. responseBody = payload("parallel results")
  200. config = { provider: "parallel", enableExa: false, enableParallel: false, parallelApiKey: "parallel-secret" }
  201. const registry = yield* ToolRegistry.Service
  202. const settled = yield* registry.settle({
  203. sessionID,
  204. call: { type: "tool-call", id: "call-parallel", name: "websearch", input: { query: "effect layers" } },
  205. })
  206. expect(requests[0]).toMatchObject({
  207. url: WebSearchTool.PARALLEL_URL,
  208. headers: { authorization: "Bearer parallel-secret" },
  209. body: {
  210. jsonrpc: "2.0",
  211. id: 1,
  212. method: "tools/call",
  213. params: {
  214. name: "web_search",
  215. arguments: { objective: "effect layers", search_queries: ["effect layers"], session_id: sessionID },
  216. },
  217. },
  218. })
  219. expect(requests[0]?.body).not.toHaveProperty("params.arguments.model_name")
  220. expect(settled).toEqual({
  221. result: { type: "text", value: "parallel results" },
  222. output: {
  223. structured: { provider: "parallel", text: "parallel results", truncated: false },
  224. content: [{ type: "text", text: "parallel results" }],
  225. },
  226. })
  227. expect(JSON.stringify(settled)).not.toContain("parallel-secret")
  228. }),
  229. )
  230. it.effect("keeps an Exa credential in the transport URL and out of model output", () =>
  231. Effect.gen(function* () {
  232. requests.length = 0
  233. assertions.length = 0
  234. responseBody = payload("credentialed exa results")
  235. config = { provider: "exa", enableExa: false, enableParallel: false, exaApiKey: "exa secret" }
  236. const registry = yield* ToolRegistry.Service
  237. const settled = yield* registry.settle({
  238. sessionID,
  239. call: { type: "tool-call", id: "call-exa-key", name: "websearch", input: { query: "effect schema" } },
  240. })
  241. expect(requests[0]?.url).toBe(`${WebSearchTool.EXA_URL}?exaApiKey=exa+secret`)
  242. expect(JSON.stringify(settled)).not.toContain("exa secret")
  243. }),
  244. )
  245. it.effect("returns the legacy no-results fallback as concise model text", () =>
  246. Effect.gen(function* () {
  247. requests.length = 0
  248. assertions.length = 0
  249. responseBody = ""
  250. config = { provider: "exa", enableExa: false, enableParallel: false }
  251. const registry = yield* ToolRegistry.Service
  252. expect(
  253. yield* registry.execute({
  254. sessionID,
  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("exposes managed overflow through typed structured output", () =>
  261. Effect.gen(function* () {
  262. requests.length = 0
  263. assertions.length = 0
  264. truncations.length = 0
  265. responseBody = payload("full search results")
  266. config = { provider: "exa", enableExa: false, enableParallel: false }
  267. truncate = (input) =>
  268. Effect.succeed({
  269. content: "HEAD\n\n... output truncated; full content saved to /tmp/tool-output/tool_opaque ...\n\nTAIL",
  270. truncated: true,
  271. outputPath: "/tmp/tool-output/tool_opaque",
  272. })
  273. const registry = yield* ToolRegistry.Service
  274. const settled = yield* registry.settle({
  275. sessionID,
  276. call: { type: "tool-call", id: "call-overflow", name: "websearch", input: { query: "verbose" } },
  277. })
  278. expect(settled.result).toMatchObject({
  279. type: "text",
  280. value: expect.stringContaining("/tmp/tool-output/tool_opaque"),
  281. })
  282. expect(settled.output?.structured).toMatchObject({
  283. provider: "exa",
  284. truncated: true,
  285. outputPath: "/tmp/tool-output/tool_opaque",
  286. })
  287. expect(truncations).toEqual([{ sessionID, toolCallID: "call-overflow", content: "full search results" }])
  288. }),
  289. )
  290. it.effect("rejects oversized MCP response bodies", () =>
  291. Effect.gen(function* () {
  292. requests.length = 0
  293. assertions.length = 0
  294. responseBody = "x".repeat(WebSearchTool.MAX_RESPONSE_BYTES + 1)
  295. config = { provider: "exa", enableExa: false, enableParallel: false }
  296. const registry = yield* ToolRegistry.Service
  297. expect(
  298. yield* registry.execute({
  299. sessionID,
  300. call: { type: "tool-call", id: "call-large-response", name: "websearch", input: { query: "too much" } },
  301. }),
  302. ).toEqual({ type: "error", value: "Unable to search the web for too much" })
  303. }),
  304. )
  305. })