tool-websearch.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435
  1. import { beforeEach, describe, expect } from "bun:test"
  2. import { Deferred, Effect, Layer } from "effect"
  3. import { HttpClientError, HttpClientRequest, 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 { Permission } from "@opencode-ai/core/permission"
  7. import { Form } from "@opencode-ai/core/form"
  8. import { KV } from "@opencode-ai/core/kv"
  9. import { WebSearch } from "@opencode-ai/core/websearch"
  10. import { Session } from "@opencode-ai/core/session"
  11. import { toSessionError } from "@opencode-ai/core/session/to-session-error"
  12. import { Tool } from "@opencode-ai/core/tool"
  13. import { WebSearchTool } from "@opencode-ai/core/tool/plugin/websearch"
  14. import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
  15. import { Image } from "@opencode-ai/core/image"
  16. import { testEffect } from "./lib/effect"
  17. import { imagePassthrough } from "./lib/image"
  18. import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
  19. import { webSearchHost } from "./plugin/host"
  20. const webSearchToolNode = makeLocationNode({
  21. name: "test/websearch-tool-plugin",
  22. layer: Layer.effectDiscard(
  23. Effect.gen(function* () {
  24. const websearch = yield* WebSearch.Service
  25. yield* registerToolPlugin(WebSearchTool.Plugin, { websearch: webSearchHost(websearch) })
  26. }),
  27. ),
  28. deps: [Tool.node, Permission.node, WebSearch.node, Form.node, KV.node],
  29. })
  30. const sessionID = Session.ID.make("ses_websearch_test")
  31. const assertions: Permission.AssertInput[] = []
  32. const queries: WebSearch.Input[] = []
  33. const formRequests: Form.CreateInput[] = []
  34. const values = new Map<string, KV.Value>()
  35. const providers = [
  36. { id: WebSearch.ID.make("exa"), name: "Exa" },
  37. { id: WebSearch.ID.make("parallel"), name: "Parallel" },
  38. ]
  39. let providerRequired = false
  40. let formResponse: Form.TerminalState = { status: "cancelled" }
  41. const formResponses: Form.TerminalState[] = []
  42. let queryBarrier: Deferred.Deferred<void> | undefined
  43. let synchronizedQueries = 0
  44. let queryError: WebSearch.Error | undefined
  45. let result = new WebSearch.Response({
  46. providerID: WebSearch.ID.make("exa"),
  47. results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
  48. })
  49. beforeEach(() => {
  50. assertions.length = 0
  51. queries.length = 0
  52. formRequests.length = 0
  53. values.clear()
  54. providerRequired = false
  55. formResponse = { status: "cancelled" }
  56. formResponses.length = 0
  57. queryBarrier = undefined
  58. synchronizedQueries = 0
  59. queryError = undefined
  60. result = new WebSearch.Response({
  61. providerID: WebSearch.ID.make("exa"),
  62. results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
  63. })
  64. })
  65. const permission = Layer.succeed(
  66. Permission.Service,
  67. Permission.Service.of({
  68. assert: (input) => Effect.sync(() => assertions.push(input)),
  69. ask: () => Effect.die("unused"),
  70. reply: () => Effect.die("unused"),
  71. get: () => Effect.die("unused"),
  72. forSession: () => Effect.die("unused"),
  73. list: () => Effect.die("unused"),
  74. }),
  75. )
  76. const websearch = Layer.succeed(
  77. WebSearch.Service,
  78. WebSearch.Service.of({
  79. transform: () => Effect.die("unused"),
  80. reload: () => Effect.die("unused"),
  81. providers: () => Effect.succeed(providers),
  82. default: () =>
  83. Effect.gen(function* () {
  84. const stored = values.get("websearch:provider")
  85. if (stored === false) return yield* new WebSearch.DisabledError()
  86. return typeof stored === "string" ? providers.find((provider) => provider.id === stored) : undefined
  87. }),
  88. query: (input) =>
  89. Effect.gen(function* () {
  90. queries.push(input)
  91. const stored = values.get("websearch:provider")
  92. if (queryBarrier && synchronizedQueries < 5) {
  93. synchronizedQueries++
  94. if (synchronizedQueries === 5) yield* Deferred.succeed(queryBarrier, undefined)
  95. yield* Deferred.await(queryBarrier)
  96. }
  97. if (queryError) return yield* queryError
  98. if (providerRequired && typeof stored !== "string") return yield* new WebSearch.ProviderRequiredError()
  99. if (typeof stored === "string")
  100. return new WebSearch.Response({ providerID: WebSearch.ID.make(stored), results: result.results })
  101. return result
  102. }),
  103. }),
  104. )
  105. const form = Layer.succeed(
  106. Form.Service,
  107. Form.Service.of({
  108. create: () => Effect.die("unused"),
  109. ask: (input) =>
  110. Effect.sync(() => {
  111. formRequests.push(input)
  112. return formResponses.shift() ?? formResponse
  113. }),
  114. get: () => Effect.die("unused"),
  115. list: () => Effect.die("unused"),
  116. state: () => Effect.die("unused"),
  117. reply: () => Effect.die("unused"),
  118. cancel: () => Effect.die("unused"),
  119. }),
  120. )
  121. const kv = Layer.succeed(
  122. KV.Service,
  123. KV.Service.of({
  124. get: (key) => Effect.succeed(values.get(key)),
  125. set: (key, value) => Effect.sync(() => values.set(key, value)).pipe(Effect.asVoid),
  126. remove: (key) => Effect.sync(() => values.delete(key)).pipe(Effect.asVoid),
  127. }),
  128. )
  129. const it = testEffect(
  130. AppNodeBuilder.build(LayerNode.group([Tool.node, WebSearch.node, webSearchToolNode]), [
  131. [Permission.node, permission],
  132. [WebSearch.node, websearch],
  133. [Form.node, form],
  134. [KV.node, kv],
  135. [Image.node, imagePassthrough],
  136. ]),
  137. )
  138. describe("WebSearchTool registration", () => {
  139. it.effect("asserts permission before delegating to WebSearch", () =>
  140. Effect.gen(function* () {
  141. const registry = yield* Tool.Service
  142. expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["websearch", "execute"])
  143. expect(
  144. yield* executeTool(registry, {
  145. sessionID,
  146. ...toolIdentity,
  147. call: {
  148. type: "tool-call",
  149. id: "call-search",
  150. name: "websearch",
  151. input: { query: "effect typescript" },
  152. },
  153. }),
  154. ).toMatchObject({
  155. status: "completed",
  156. content: [{ type: "text", text: "## [Search results](https://example.com)\n\nsearch results" }],
  157. })
  158. expect(assertions).toMatchObject([
  159. {
  160. sessionID,
  161. action: "websearch",
  162. resources: ["effect typescript"],
  163. save: ["*"],
  164. metadata: { query: "effect typescript" },
  165. },
  166. ])
  167. expect(queries).toEqual([
  168. {
  169. query: "effect typescript",
  170. },
  171. ])
  172. }),
  173. )
  174. it.effect("keeps normalized results in structured output", () =>
  175. Effect.gen(function* () {
  176. result = new WebSearch.Response({
  177. providerID: WebSearch.ID.make("parallel"),
  178. results: [
  179. {
  180. url: "https://effect.website",
  181. title: "Effect",
  182. content: "parallel results",
  183. time: { published: Date.parse("2026-07-25T00:00:00.000Z") },
  184. },
  185. ],
  186. })
  187. const registry = yield* Tool.Service
  188. expect(
  189. yield* executeTool(registry, {
  190. sessionID,
  191. ...toolIdentity,
  192. call: { type: "tool-call", id: "call-parallel", name: "websearch", input: { query: "effect layers" } },
  193. }),
  194. ).toEqual({
  195. status: "completed",
  196. output: {
  197. provider: "parallel",
  198. results: [
  199. {
  200. url: "https://effect.website",
  201. title: "Effect",
  202. content: "parallel results",
  203. time: { published: Date.parse("2026-07-25T00:00:00.000Z") },
  204. },
  205. ],
  206. },
  207. content: [
  208. {
  209. type: "text",
  210. text: "## [Effect](https://effect.website)\nPublished: 2026-07-25T00:00:00.000Z\n\nparallel results",
  211. },
  212. ],
  213. metadata: { provider: "parallel" },
  214. })
  215. }),
  216. )
  217. it.effect("uses the concise no-results fallback", () =>
  218. Effect.gen(function* () {
  219. result = new WebSearch.Response({ providerID: WebSearch.ID.make("exa"), results: [] })
  220. const registry = yield* Tool.Service
  221. expect(
  222. yield* executeTool(registry, {
  223. sessionID,
  224. ...toolIdentity,
  225. call: { type: "tool-call", id: "call-empty", name: "websearch", input: { query: "nothing" } },
  226. }),
  227. ).toMatchObject({
  228. status: "completed",
  229. content: [{ type: "text", text: WebSearchTool.NO_RESULTS }],
  230. })
  231. }),
  232. )
  233. it.effect("asks once and uses the default provider when web search is first enabled", () =>
  234. Effect.gen(function* () {
  235. providerRequired = true
  236. formResponse = { status: "answered", answer: { choice: "allow" } }
  237. const registry = yield* Tool.Service
  238. expect(
  239. yield* executeTool(registry, {
  240. sessionID,
  241. ...toolIdentity,
  242. call: { type: "tool-call", id: "call-enable", name: "websearch", input: { query: "effect" } },
  243. }),
  244. ).toMatchObject({ status: "completed", metadata: { provider: "exa" } })
  245. expect(values.get("websearch:provider")).toBe("exa")
  246. expect(queries).toHaveLength(2)
  247. expect(formRequests).toEqual([
  248. {
  249. sessionID,
  250. title: "Web Search",
  251. metadata: { kind: "websearch.provider" },
  252. fields: [
  253. {
  254. key: "choice",
  255. description: "Allow OpenCode to search the web for up-to-date information?",
  256. type: "string",
  257. required: true,
  258. custom: false,
  259. options: [
  260. {
  261. value: "allow",
  262. label: "Allow web search via Exa",
  263. },
  264. {
  265. value: "choose",
  266. label: "Choose another provider",
  267. },
  268. { value: "disable", label: "Disable web search" },
  269. ],
  270. },
  271. ],
  272. },
  273. ])
  274. expect(
  275. yield* executeTool(registry, {
  276. sessionID,
  277. ...toolIdentity,
  278. call: { type: "tool-call", id: "call-enabled", name: "websearch", input: { query: "effect schema" } },
  279. }),
  280. ).toMatchObject({ status: "completed", metadata: { provider: "exa" } })
  281. expect(formRequests).toHaveLength(1)
  282. expect(queries).toHaveLength(3)
  283. }),
  284. )
  285. it.effect("asks a second form when choosing another provider", () =>
  286. Effect.gen(function* () {
  287. providerRequired = true
  288. formResponses.push(
  289. { status: "answered", answer: { choice: "choose" } },
  290. { status: "answered", answer: { provider: "parallel" } },
  291. )
  292. const registry = yield* Tool.Service
  293. expect(
  294. yield* executeTool(registry, {
  295. sessionID,
  296. ...toolIdentity,
  297. call: { type: "tool-call", id: "call-choose", name: "websearch", input: { query: "effect" } },
  298. }),
  299. ).toMatchObject({ status: "completed", metadata: { provider: "parallel" } })
  300. expect(values.get("websearch:provider")).toBe("parallel")
  301. expect(queries).toHaveLength(2)
  302. expect(formRequests[1]).toEqual({
  303. sessionID,
  304. title: "Choose a web search provider",
  305. metadata: { kind: "websearch.provider" },
  306. fields: [
  307. {
  308. key: "provider",
  309. description: "Choose a provider for web search.",
  310. type: "string",
  311. required: true,
  312. custom: false,
  313. options: [
  314. { value: "exa", label: "Exa" },
  315. { value: "parallel", label: "Parallel" },
  316. ],
  317. },
  318. ],
  319. })
  320. }),
  321. )
  322. it.effect("shares provider consent across concurrent searches", () =>
  323. Effect.gen(function* () {
  324. providerRequired = true
  325. formResponse = { status: "answered", answer: { choice: "allow" } }
  326. queryBarrier = yield* Deferred.make<void>()
  327. const registry = yield* Tool.Service
  328. const results = yield* Effect.all(
  329. Array.from({ length: 5 }, (_, index) =>
  330. executeTool(registry, {
  331. sessionID,
  332. ...toolIdentity,
  333. call: {
  334. type: "tool-call",
  335. id: `call-concurrent-${index}`,
  336. name: "websearch",
  337. input: { query: `effect ${index}` },
  338. },
  339. }),
  340. ),
  341. { concurrency: "unbounded" },
  342. )
  343. expect(results.every((item) => item.status === "completed")).toBe(true)
  344. expect(formRequests).toHaveLength(1)
  345. expect(values.get("websearch:provider")).toBe("exa")
  346. }),
  347. )
  348. it.effect("persists the choice to disable web search", () =>
  349. Effect.gen(function* () {
  350. providerRequired = true
  351. formResponse = { status: "answered", answer: { choice: "disable" } }
  352. const registry = yield* Tool.Service
  353. expect(
  354. yield* executeTool(registry, {
  355. sessionID,
  356. ...toolIdentity,
  357. call: { type: "tool-call", id: "call-disable", name: "websearch", input: { query: "effect" } },
  358. }),
  359. ).toMatchObject({ status: "error" })
  360. expect(values.get("websearch:provider")).toBe(false)
  361. expect(queries).toHaveLength(1)
  362. }),
  363. )
  364. it.effect("reports safe HTTP failures with the attempted provider", () =>
  365. Effect.gen(function* () {
  366. const registry = yield* Tool.Service
  367. const tools = yield* registry.snapshot()
  368. values.set("websearch:provider", "exa")
  369. yield* Effect.forEach(
  370. [
  371. { status: 403, message: "Web search request failed (HTTP 403)" },
  372. { status: 429, message: "Web search rate limited (HTTP 429)" },
  373. { status: 401, message: "Web search authentication failed (HTTP 401)" },
  374. ],
  375. ({ status, message }, index) =>
  376. Effect.gen(function* () {
  377. const request = HttpClientRequest.post("https://mcp.exa.ai/mcp?exaApiKey=secret")
  378. queryError = new WebSearch.RequestError({
  379. providerID: WebSearch.ID.make("exa"),
  380. cause: new HttpClientError.HttpClientError({
  381. reason: new HttpClientError.StatusCodeError({
  382. request,
  383. response: HttpClientResponse.fromWeb(request, new Response(null, { status })),
  384. description: "non 2xx status code",
  385. }),
  386. }),
  387. })
  388. const progress: Tool.Metadata[] = []
  389. const error = yield* tools
  390. .execute({
  391. sessionID,
  392. ...toolIdentity,
  393. call: {
  394. type: "tool-call",
  395. id: `call-http-${index}`,
  396. name: "websearch",
  397. input: { query: "effect" },
  398. },
  399. progress: (metadata) => Effect.sync(() => progress.push(metadata)),
  400. })
  401. .pipe(Effect.flip)
  402. const sessionError = toSessionError(error)
  403. expect(sessionError).toEqual({ type: "tool.execution", message })
  404. expect(sessionError.message).not.toContain("secret")
  405. expect(error.metadata).toEqual({ provider: "exa" })
  406. expect(progress).toEqual([{ provider: "exa" }])
  407. }),
  408. { discard: true },
  409. )
  410. }),
  411. )
  412. })