tool-websearch.test.ts 11 KB

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