tool-websearch.test.ts 13 KB

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