From 52c04508a26aeccd52e7c8415a82a2bbd41f4755 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 27 Aug 2026 16:09:06 -0400 Subject: [PATCH] test(core): share controlled websearch test layer (#45465) --- packages/core/test/lib/websearch.ts | 44 +++ packages/core/test/tool-websearch.test.ts | 328 +++++++++------------- packages/core/test/websearch.test.ts | 44 ++- 3 files changed, 217 insertions(+), 199 deletions(-) create mode 100644 packages/core/test/lib/websearch.ts diff --git a/packages/core/test/lib/websearch.ts b/packages/core/test/lib/websearch.ts new file mode 100644 index 00000000000..4b3895bbe67 --- /dev/null +++ b/packages/core/test/lib/websearch.ts @@ -0,0 +1,44 @@ +export * as TestWebSearch from "./websearch" + +import { Context, Deferred, Effect, Layer } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/util/effect/layer-node" +import { Bus } from "@opencode-ai/core/bus" +import { KV } from "@opencode-ai/core/kv" +import { WebSearch } from "@opencode-ai/core/websearch" + +export interface Interface extends WebSearch.Interface { + readonly queries: readonly WebSearch.Input[] + /** Waits for query arrivals, not provider execution or query completion. */ + readonly wait: (count: number) => Effect.Effect +} + +export class Service extends Context.Service()("test/WebSearch") {} + +// No providers are installed: tests register local executors through transform. +// The normal Bus and KV implementations use the default in-memory database. +export const layer = Layer.effectContext( + Effect.gen(function* () { + const context = yield* Layer.build(AppNodeBuilder.build(LayerNode.group([WebSearch.node, Bus.node, KV.node]))) + const websearch = Context.get(context, WebSearch.Service) + const queries: WebSearch.Input[] = [] + let started = yield* Deferred.make() + const wait = (count: number): Effect.Effect => + Effect.suspend(() => + queries.length >= count ? Effect.void : Deferred.await(started).pipe(Effect.andThen(() => wait(count))), + ) + const test = Service.of({ + ...websearch, + queries, + wait, + query: Effect.fnUntraced(function* (input: WebSearch.Input) { + queries.push({ ...input }) + const previous = started + started = yield* Deferred.make() + yield* Deferred.succeed(previous, undefined) + return yield* websearch.query(input) + }), + }) + return Context.add(context, WebSearch.Service, test).pipe(Context.add(Service, test)) + }), +) diff --git a/packages/core/test/tool-websearch.test.ts b/packages/core/test/tool-websearch.test.ts index f45faca93bf..704d5a9ee68 100644 --- a/packages/core/test/tool-websearch.test.ts +++ b/packages/core/test/tool-websearch.test.ts @@ -1,13 +1,12 @@ -import { beforeEach, describe, expect } from "bun:test" -import { Deferred, Effect, Layer, Stream } from "effect" +import { describe, expect } from "bun:test" +import { Context, Effect, Layer } from "effect" import { HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { Permission } from "@opencode-ai/core/permission" -import { Config } from "@opencode-ai/core/config" +import { KV } from "@opencode-ai/core/kv" import { Form } from "@opencode-ai/core/form" import { WebSearch } from "@opencode-ai/core/websearch" -import { Document, Info } from "@opencode-ai/schema/config" import { Session } from "@opencode-ai/core/session" import { toSessionError } from "@opencode-ai/core/session/to-session-error" import { Tool } from "@opencode-ai/core/tool" @@ -19,7 +18,7 @@ import { imagePassthrough } from "./lib/image" import { permissionLayer } from "./lib/permission" import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool" import { webSearchHost } from "./plugin/host" -import { produce } from "immer" +import { TestWebSearch } from "./lib/websearch" const webSearchToolNode = makeLocationNode({ name: "test/websearch-tool-plugin", @@ -33,142 +32,78 @@ const webSearchToolNode = makeLocationNode({ }) const sessionID = Session.ID.make("ses_websearch_test") -const assertions: Permission.AssertInput[] = [] -const queries: WebSearch.Input[] = [] -const formRequests: Form.CreateInput[] = [] -let selection: WebSearch.ID | "random" | false | undefined const providers = [ { id: WebSearch.ID.make("exa"), name: "Exa" }, { id: WebSearch.ID.make("parallel"), name: "Parallel" }, ] -let providerRequired = false -let formResponse: Form.TerminalState = { status: "cancelled" } -const formResponses: Form.TerminalState[] = [] -let queryBarrier: Deferred.Deferred | undefined -let synchronizedQueries = 0 -let queryError: WebSearch.Error | undefined -let result = new WebSearch.Response({ - providerID: WebSearch.ID.make("exa"), - results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }], -}) -beforeEach(() => { - assertions.length = 0 - queries.length = 0 - formRequests.length = 0 - selection = undefined - providerRequired = false - formResponse = { status: "cancelled" } - formResponses.length = 0 - queryBarrier = undefined - synchronizedQueries = 0 - queryError = undefined - result = new WebSearch.Response({ - providerID: WebSearch.ID.make("exa"), - results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }], - }) -}) +class Fixture { + assertions: Permission.AssertInput[] = [] + events: string[] = [] + formRequests: Form.CreateInput[] = [] + formResponse: Form.TerminalState = { status: "cancelled" } + formResponses: Form.TerminalState[] = [] + formWait = Effect.void + error: HttpClientError.HttpClientError | undefined + results: readonly WebSearch.Result[] = [ + { url: "https://example.com", title: "Search results", content: "search results", time: {} }, + ] +} -const permission = permissionLayer({ - assert: (input) => Effect.sync(() => assertions.push(input)), -}) -const websearch = Layer.succeed( - WebSearch.Service, - WebSearch.Service.of({ - transform: (transform) => - Effect.sync(() => { - transform({ - add: () => undefined, - default: { - get: () => selection, - set: (next) => (selection = next), - }, - }) - return { dispose: Effect.void } - }), - reload: () => Effect.die("unused"), - providers: () => Effect.succeed(providers), - default: () => - Effect.gen(function* () { - if (selection === false) return yield* new WebSearch.DisabledError() - return selection ? providers.find((provider) => provider.id === selection) : undefined - }), - select: (next) => Effect.sync(() => (selection = next)), - query: (input) => - Effect.gen(function* () { - queries.push(input) - if (queryBarrier && synchronizedQueries < 5) { - synchronizedQueries++ - if (synchronizedQueries === 5) yield* Deferred.succeed(queryBarrier, undefined) - yield* Deferred.await(queryBarrier) - } - if (queryError) return yield* queryError - if (providerRequired && !selection) return yield* new WebSearch.ProviderRequiredError() - if (selection) - return new WebSearch.Response({ - providerID: selection === "random" ? result.providerID : WebSearch.ID.make(selection), - results: result.results, - }) - return result - }), - }), -) -const form = Layer.succeed( - Form.Service, - Form.Service.of({ - create: () => Effect.die("unused"), - ask: (input) => - Effect.sync(() => { - formRequests.push(input) - return formResponses.shift() ?? formResponse - }), - get: () => Effect.die("unused"), - list: () => Effect.die("unused"), - state: () => Effect.die("unused"), - reply: () => Effect.die("unused"), - cancel: () => Effect.die("unused"), - }), -) -const config = Layer.succeed( - Config.Service, - Config.Service.of({ - entries: () => - Effect.succeed([ - new Document({ - type: "document", - info: new Info({ - websearch: selection === undefined ? undefined : selection === false ? false : { provider: selection }, +const it = testEffect(TestWebSearch.layer) +const setup = Effect.gen(function* () { + const fixture = new Fixture() + const websearch = yield* TestWebSearch.Service + const kv = yield* KV.Service + yield* websearch.transform((draft) => + providers.forEach((provider) => + draft.add({ + ...provider, + execute: () => + Effect.gen(function* () { + fixture.events.push("query") + if (fixture.error) return yield* fixture.error + return fixture.results }), + }), + ), + ) + const context = yield* Layer.build( + AppNodeBuilder.build(LayerNode.group([Tool.node, webSearchToolNode]), [ + [ + Permission.node, + permissionLayer({ + assert: (input) => + Effect.sync(() => { + fixture.events.push("permission") + fixture.assertions.push(input) + }), }), - ]), - update: (update) => - Effect.sync(() => { - const info = produce( - new Info({ - websearch: selection === undefined ? undefined : selection === false ? false : { provider: selection }, - }), - update, - ) - selection = info.websearch === false ? false : info.websearch?.provider - return info - }), - changes: () => Stream.never, - }), -) -const it = testEffect( - AppNodeBuilder.build(LayerNode.group([Tool.node, WebSearch.node, webSearchToolNode]), [ - [Permission.node, permission], - [WebSearch.node, websearch], - [Form.node, form], - [Config.node, config], - [Image.node, imagePassthrough], - ]), -) + ], + [WebSearch.node, Layer.succeed(WebSearch.Service, websearch)], + [ + Form.node, + Layer.mock(Form.Service, { + ask: (input) => + Effect.gen(function* () { + fixture.formRequests.push(input) + yield* fixture.formWait + return fixture.formResponses.shift() ?? fixture.formResponse + }), + }), + ], + [Image.node, imagePassthrough], + ]), + ) + return Object.assign(fixture, { websearch, kv, registry: Context.get(context, Tool.Service) }) +}) describe("WebSearchTool registration", () => { it.effect("asserts permission before delegating to WebSearch", () => Effect.gen(function* () { - const registry = yield* Tool.Service + const fixture = yield* setup + const registry = fixture.registry + yield* fixture.websearch.select(WebSearch.ID.make("exa")) expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["websearch", "execute"]) expect( @@ -186,7 +121,7 @@ describe("WebSearchTool registration", () => { status: "completed", content: [{ type: "text", text: "## [Search results](https://example.com)\n\nsearch results" }], }) - expect(assertions).toMatchObject([ + expect(fixture.assertions).toMatchObject([ { sessionID, action: "websearch", @@ -195,28 +130,29 @@ describe("WebSearchTool registration", () => { metadata: { query: "effect typescript" }, }, ]) - expect(queries).toEqual([ + expect(fixture.websearch.queries).toEqual([ { query: "effect typescript", + providerID: WebSearch.ID.make("exa"), }, ]) + expect(fixture.events).toEqual(["permission", "query"]) }), ) it.effect("keeps normalized results in structured output", () => Effect.gen(function* () { - result = new WebSearch.Response({ - providerID: WebSearch.ID.make("parallel"), - results: [ - { - url: "https://effect.website", - title: "Effect", - content: "parallel results", - time: { published: Date.parse("2026-07-25T00:00:00.000Z") }, - }, - ], - }) - const registry = yield* Tool.Service + const fixture = yield* setup + yield* fixture.websearch.select(WebSearch.ID.make("parallel")) + fixture.results = [ + { + url: "https://effect.website", + title: "Effect", + content: "parallel results", + time: { published: Date.parse("2026-07-25T00:00:00.000Z") }, + }, + ] + const registry = fixture.registry expect( yield* executeTool(registry, { @@ -250,8 +186,10 @@ describe("WebSearchTool registration", () => { it.effect("uses the concise no-results fallback", () => Effect.gen(function* () { - result = new WebSearch.Response({ providerID: WebSearch.ID.make("exa"), results: [] }) - const registry = yield* Tool.Service + const fixture = yield* setup + yield* fixture.websearch.select(WebSearch.ID.make("exa")) + fixture.results = [] + const registry = fixture.registry expect( yield* executeTool(registry, { @@ -268,20 +206,21 @@ describe("WebSearchTool registration", () => { it.effect("asks once and uses the default provider when web search is first enabled", () => Effect.gen(function* () { - providerRequired = true - formResponse = { status: "answered", answer: { choice: "allow" } } - const registry = yield* Tool.Service + const fixture = yield* setup + fixture.formResponse = { status: "answered", answer: { choice: "allow" } } + const registry = fixture.registry - expect( - yield* executeTool(registry, { - sessionID, - ...toolIdentity, - call: { type: "tool-call", id: "call-enable", name: "websearch", input: { query: "effect" } }, - }), - ).toMatchObject({ status: "completed", metadata: { provider: "exa" } }) - expect(selection).toBe("random") - expect(queries).toHaveLength(2) - expect(formRequests).toEqual([ + const first = yield* executeTool(registry, { + sessionID, + ...toolIdentity, + call: { type: "tool-call", id: "call-enable", name: "websearch", input: { query: "effect" } }, + }) + expect(first.status).toBe("completed") + expect(["exa", "parallel"]).toContain(first.metadata?.provider) + expect(first.metadata?.provider).toBe(fixture.websearch.queries[1]?.providerID) + expect(yield* fixture.kv.get(WebSearch.ProviderKey)).toBe("random") + expect(fixture.websearch.queries).toHaveLength(2) + expect(fixture.formRequests).toEqual([ { sessionID, title: "Web Search", @@ -309,26 +248,27 @@ describe("WebSearchTool registration", () => { }, ]) - expect( - yield* executeTool(registry, { - sessionID, - ...toolIdentity, - call: { type: "tool-call", id: "call-enabled", name: "websearch", input: { query: "effect schema" } }, - }), - ).toMatchObject({ status: "completed", metadata: { provider: "exa" } }) - expect(formRequests).toHaveLength(1) - expect(queries).toHaveLength(3) + const second = yield* executeTool(registry, { + sessionID, + ...toolIdentity, + call: { type: "tool-call", id: "call-enabled", name: "websearch", input: { query: "effect schema" } }, + }) + expect(second.status).toBe("completed") + expect(["exa", "parallel"]).toContain(second.metadata?.provider) + expect(second.metadata?.provider).toBe(fixture.websearch.queries[2]?.providerID) + expect(fixture.formRequests).toHaveLength(1) + expect(fixture.websearch.queries).toHaveLength(3) }), ) it.effect("asks a second form when choosing another provider", () => Effect.gen(function* () { - providerRequired = true - formResponses.push( + const fixture = yield* setup + fixture.formResponses.push( { status: "answered", answer: { choice: "choose" } }, { status: "answered", answer: { provider: "parallel" } }, ) - const registry = yield* Tool.Service + const registry = fixture.registry expect( yield* executeTool(registry, { @@ -337,9 +277,10 @@ describe("WebSearchTool registration", () => { call: { type: "tool-call", id: "call-choose", name: "websearch", input: { query: "effect" } }, }), ).toMatchObject({ status: "completed", metadata: { provider: "parallel" } }) - expect(selection).toBe(WebSearch.ID.make("parallel")) - expect(queries).toHaveLength(2) - expect(formRequests[1]).toEqual({ + expect(yield* fixture.kv.get(WebSearch.ProviderKey)).toBe(WebSearch.ID.make("parallel")) + expect(fixture.websearch.queries).toHaveLength(2) + expect(fixture.websearch.queries[1]?.providerID).toBe(WebSearch.ID.make("parallel")) + expect(fixture.formRequests[1]).toEqual({ sessionID, title: "Choose a web search provider", metadata: { kind: "websearch.provider" }, @@ -362,10 +303,10 @@ describe("WebSearchTool registration", () => { it.effect("shares provider consent across concurrent searches", () => Effect.gen(function* () { - providerRequired = true - formResponse = { status: "answered", answer: { choice: "allow" } } - queryBarrier = yield* Deferred.make() - const registry = yield* Tool.Service + const fixture = yield* setup + fixture.formResponse = { status: "answered", answer: { choice: "allow" } } + fixture.formWait = fixture.websearch.wait(5) + const registry = fixture.registry const results = yield* Effect.all( Array.from({ length: 5 }, (_, index) => @@ -384,16 +325,16 @@ describe("WebSearchTool registration", () => { ) expect(results.every((item) => item.status === "completed")).toBe(true) - expect(formRequests).toHaveLength(1) - expect(selection).toBe("random") + expect(fixture.formRequests).toHaveLength(1) + expect(yield* fixture.kv.get(WebSearch.ProviderKey)).toBe("random") }), ) it.effect("persists the choice to disable web search", () => Effect.gen(function* () { - providerRequired = true - formResponse = { status: "answered", answer: { choice: "disable" } } - const registry = yield* Tool.Service + const fixture = yield* setup + fixture.formResponse = { status: "answered", answer: { choice: "disable" } } + const registry = fixture.registry expect( yield* executeTool(registry, { @@ -402,16 +343,18 @@ describe("WebSearchTool registration", () => { call: { type: "tool-call", id: "call-disable", name: "websearch", input: { query: "effect" } }, }), ).toMatchObject({ status: "error" }) - expect(selection).toBe(false) - expect(queries).toHaveLength(1) + expect(yield* fixture.kv.get(WebSearch.ProviderKey)).toBe(false) + expect(yield* fixture.websearch.default().pipe(Effect.flip)).toBeInstanceOf(WebSearch.DisabledError) + expect(fixture.websearch.queries).toHaveLength(1) }), ) it.effect("reports safe HTTP failures with the attempted provider", () => Effect.gen(function* () { - const registry = yield* Tool.Service + const fixture = yield* setup + const registry = fixture.registry const tools = yield* registry.snapshot() - selection = WebSearch.ID.make("exa") + yield* fixture.websearch.select(WebSearch.ID.make("exa")) yield* Effect.forEach( [ @@ -422,14 +365,11 @@ describe("WebSearchTool registration", () => { ({ status, message }, index) => Effect.gen(function* () { const request = HttpClientRequest.post("https://mcp.exa.ai/mcp?exaApiKey=secret") - queryError = new WebSearch.RequestError({ - providerID: WebSearch.ID.make("exa"), - cause: new HttpClientError.HttpClientError({ - reason: new HttpClientError.StatusCodeError({ - request, - response: HttpClientResponse.fromWeb(request, new Response(null, { status })), - description: "non 2xx status code", - }), + fixture.error = new HttpClientError.HttpClientError({ + reason: new HttpClientError.StatusCodeError({ + request, + response: HttpClientResponse.fromWeb(request, new Response(null, { status })), + description: "non 2xx status code", }), }) const progress: Tool.Metadata[] = [] diff --git a/packages/core/test/websearch.test.ts b/packages/core/test/websearch.test.ts index 3d512e5db27..ff5a30f3c76 100644 --- a/packages/core/test/websearch.test.ts +++ b/packages/core/test/websearch.test.ts @@ -1,13 +1,12 @@ import { describe, expect } from "bun:test" -import { Effect, Exit, Scope } from "effect" -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" -import { LayerNode } from "@opencode-ai/util/effect/layer-node" -import { Bus } from "@opencode-ai/core/bus" +import { Effect, Exit, Fiber, Scope } from "effect" +import { TestClock } from "effect/testing" import { KV } from "@opencode-ai/core/kv" import { WebSearch } from "@opencode-ai/core/websearch" import { testEffect } from "./lib/effect" +import { TestWebSearch } from "./lib/websearch" -const it = testEffect(AppNodeBuilder.build(LayerNode.group([WebSearch.node, Bus.node, KV.node]))) +const it = testEffect(TestWebSearch.layer) const register = (id: string) => Effect.gen(function* () { @@ -36,11 +35,28 @@ const register = (id: string) => }) describe("WebSearch", () => { + it.effect("shares the normal and test interfaces without installing live providers", () => + Effect.gen(function* () { + const websearch = yield* WebSearch.Service + const test = yield* TestWebSearch.Service + + expect(websearch).toBe(test) + expect(yield* websearch.providers()).toEqual([]) + expect(test.queries).toEqual([]) + expect((yield* websearch.query({ query: "unconfigured" }).pipe(Effect.flip))._tag).toBe( + "WebSearch.ProviderRequired", + ) + yield* test.wait(1) + expect(test.queries).toEqual([{ query: "unconfigured" }]) + }), + ) + it.effect("executes an explicit provider without changing the default", () => Effect.gen(function* () { yield* register("exa") const parallel = yield* register("parallel") const websearch = yield* WebSearch.Service + const test = yield* TestWebSearch.Service expect(yield* websearch.query({ query: "effect", providerID: parallel.providerID })).toEqual( new WebSearch.Response({ @@ -57,6 +73,7 @@ describe("WebSearch", () => { ) expect((yield* websearch.query({ query: "default" }).pipe(Effect.flip))._tag).toBe("WebSearch.ProviderRequired") expect(parallel.calls).toEqual([{ query: "effect" }]) + expect(test.queries).toEqual([{ query: "effect", providerID: parallel.providerID }, { query: "default" }]) }), ) @@ -81,6 +98,23 @@ describe("WebSearch", () => { }), ) + it.effect("reloads active transforms from their current source", () => + Effect.gen(function* () { + const exa = yield* register("exa") + const parallel = yield* register("parallel") + const websearch = yield* WebSearch.Service + const source = { providerID: exa.providerID } + yield* websearch.transform((draft) => draft.default.set(source.providerID)) + + expect((yield* websearch.default())?.id).toBe(exa.providerID) + source.providerID = parallel.providerID + const reload = yield* websearch.reload().pipe(Effect.forkChild({ startImmediately: true })) + yield* TestClock.adjust("500 millis") + yield* Fiber.join(reload) + expect((yield* websearch.default())?.id).toBe(parallel.providerID) + }), + ) + it.effect("persists the selected provider in KV", () => Effect.gen(function* () { const parallel = yield* register("parallel")