tool-websearch.test.ts 12 KB

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