tool-websearch.test.ts 15 KB

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