test(core): share controlled websearch test layer (#45465)

This commit is contained in:
Kit Langton
2026-08-27 16:09:06 -04:00
committed by GitHub
parent 5a67fcc17e
commit 52c04508a2
3 changed files with 217 additions and 199 deletions
+44
View File
@@ -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<void>
}
export class Service extends Context.Service<Service, Interface>()("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<void>()
const wait = (count: number): Effect.Effect<void> =>
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<void>()
yield* Deferred.succeed(previous, undefined)
return yield* websearch.query(input)
}),
})
return Context.add(context, WebSearch.Service, test).pipe(Context.add(Service, test))
}),
)
+134 -194
View File
@@ -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<void> | 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<void>()
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[] = []
+39 -5
View File
@@ -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")