opencode/packages/core/test/tool-websearch.test.ts
2026-10-09 01:36:06 -07:00

472 lines
17 KiB
TypeScript

import { describe, expect } from "bun:test"
import { Context, Effect, Layer } from "effect"
import type { HttpClientError } from "effect/http"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Permission } from "@opencode/core/permission"
import { KV } from "@opencode/core/kv"
import { Form } from "@opencode/core/form"
import { WebSearch } from "@opencode/core/websearch"
import { Session } from "@opencode/core/session"
import { toSessionError } from "@opencode/core/session/to-session-error"
import { Tool } from "@opencode/core/tool"
import { WebSearchTool } from "@opencode/core/tool/plugin/websearch"
import { makeLocationNode } from "@opencode/util/effect/app-node"
import { Image } from "@opencode/core/image"
import { testEffect } from "./lib/effect"
import { imagePassthrough } from "./lib/image"
import { permissionLayer } from "./lib/permission"
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
import { webSearchHost } from "./plugin/host"
import { TestWebSearch } from "./lib/websearch"
const webSearchToolNode = makeLocationNode({
name: "test/websearch-tool-plugin",
layer: Layer.effectDiscard(
Effect.gen(function* () {
const websearch = yield* WebSearch.Service
yield* registerToolPlugin(WebSearchTool.Plugin, { websearch: webSearchHost(websearch) })
}),
),
deps: [Tool.node, Permission.node, WebSearch.node, Form.node],
})
const sessionID = Session.ID.make("ses_websearch_test")
const providers = [
{ id: WebSearch.ID.make("exa"), name: "Exa" },
{ id: WebSearch.ID.make("parallel"), name: "Parallel" },
]
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 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((editor) =>
providers.forEach((provider) =>
editor.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.replace(
permissionLayer({
assert: (input) =>
Effect.sync(() => {
fixture.events.push("permission")
fixture.assertions.push(input)
}),
}),
),
WebSearch.node.replace(Layer.succeed(WebSearch.Service, websearch)),
Form.node.replace(
Layer.mock(Form.Service, {
ask: (input) =>
Effect.gen(function* () {
fixture.formRequests.push(input)
yield* fixture.formWait
return fixture.formResponses.shift() ?? fixture.formResponse
}),
}),
),
Image.node.replace(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 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(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: {
type: "tool-call",
id: "call-search",
name: "websearch",
input: { query: "effect typescript" },
},
}),
).toMatchObject({
status: "completed",
content: [{ type: "text", text: "## [Search results](https://example.com)\n\nsearch results" }],
})
expect(fixture.assertions).toMatchObject([
{
sessionID,
action: "websearch",
resources: ["effect typescript"],
save: ["*"],
metadata: { query: "effect typescript" },
},
])
expect(fixture.websearch.queries).toEqual([
{
query: "effect typescript",
providerID: undefined,
},
])
expect(fixture.events).toEqual(["permission", "query"])
expect(fixture.websearch.sessionIDs).toEqual([sessionID])
}),
)
it.effect("keeps normalized results in structured output", () =>
Effect.gen(function* () {
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, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-parallel", name: "websearch", input: { query: "effect layers" } },
}),
).toEqual({
status: "completed",
output: {
provider: "parallel",
results: [
{
url: "https://effect.website",
title: "Effect",
content: "parallel results",
time: { published: Date.parse("2026-07-25T00:00:00.000Z") },
},
],
},
content: [
{
type: "text",
text: "## [Effect](https://effect.website)\nPublished: 2026-07-25T00:00:00.000Z\n\nparallel results",
},
],
metadata: { provider: "parallel" },
})
}),
)
it.effect("uses the concise no-results fallback", () =>
Effect.gen(function* () {
const fixture = yield* setup
yield* fixture.websearch.select(WebSearch.ID.make("exa"))
fixture.results = []
const registry = fixture.registry
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-empty", name: "websearch", input: { query: "nothing" } },
}),
).toMatchObject({
status: "completed",
content: [{ type: "text", text: WebSearchTool.NO_RESULTS }],
})
}),
)
it.effect("asks once and uses the default provider when web search is first enabled", () =>
Effect.gen(function* () {
const fixture = yield* setup
fixture.formResponse = { status: "answered", answer: { choice: "allow" } }
const registry = fixture.registry
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(fixture.websearch.sessionIDs).toEqual([sessionID, sessionID])
expect(yield* fixture.kv.get(WebSearch.ProviderKey)).toBe("random")
expect(fixture.websearch.queries).toHaveLength(2)
expect(fixture.formRequests).toEqual([
{
sessionID,
title: "Web Search",
metadata: { kind: "websearch.provider" },
fields: [
{
key: "choice",
description: "Allow OpenCode to search the web for up-to-date information?",
type: "string",
required: true,
custom: false,
options: [
{
value: "allow",
label: "Allow search via Exa, Parallel",
},
{
value: "choose",
label: "Choose another provider",
},
{ value: "disable", label: "Disable web search" },
],
},
],
},
])
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(first.metadata?.provider)
expect(fixture.formRequests).toHaveLength(1)
expect(fixture.websearch.queries).toHaveLength(3)
}),
)
it.effect("honors automatic consent when the configured provider is unavailable", () =>
Effect.gen(function* () {
const fixture = yield* setup
yield* fixture.websearch.transform((editor) => editor.default.set(WebSearch.ID.make("missing")))
fixture.formResponse = { status: "answered", answer: { choice: "allow" } }
const result = yield* executeTool(fixture.registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-missing", name: "websearch", input: { query: "effect" } },
})
expect(result.status).toBe("completed")
expect(yield* fixture.kv.get(WebSearch.ProviderKey)).toBe("random")
expect(result.metadata?.provider).toBe(
(yield* fixture.websearch.query({ query: "next" }, { sessionID })).providerID,
)
expect(fixture.formRequests).toHaveLength(1)
}),
)
it.effect("asks a second form when choosing another provider", () =>
Effect.gen(function* () {
const fixture = yield* setup
fixture.formResponses.push(
{ status: "answered", answer: { choice: "choose" } },
{ status: "answered", answer: { provider: "parallel" } },
)
const registry = fixture.registry
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-choose", name: "websearch", input: { query: "effect" } },
}),
).toMatchObject({ status: "completed", metadata: { provider: "parallel" } })
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" },
fields: [
{
key: "provider",
description: "Choose a provider for web search.",
type: "string",
required: true,
custom: false,
options: [
{ value: "exa", label: "Exa" },
{ value: "parallel", label: "Parallel" },
],
},
],
})
}),
)
it.effect("shares provider consent across concurrent searches", () =>
Effect.gen(function* () {
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) =>
executeTool(registry, {
sessionID,
...toolIdentity,
call: {
type: "tool-call",
id: `call-concurrent-${index}`,
name: "websearch",
input: { query: `effect ${index}` },
},
}),
),
{ concurrency: "unbounded" },
)
expect(results.every((item) => item.status === "completed")).toBe(true)
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* () {
const fixture = yield* setup
fixture.formResponse = { status: "answered", answer: { choice: "disable" } }
const registry = fixture.registry
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-disable", name: "websearch", input: { query: "effect" } },
}),
).toMatchObject({ status: "error" })
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("keeps provider progress, output, and metadata accurate across automatic failover", () =>
Effect.gen(function* () {
const fixture = yield* setup
yield* fixture.websearch.select("random")
const first = (yield* fixture.websearch.query({ query: "seed" }, { sessionID })).providerID
yield* fixture.websearch.transform((editor) =>
editor.add({
id: first,
name: first,
execute: () => Effect.fail(TestWebSearch.httpError()),
}),
)
const progress: Tool.Metadata[] = []
const tools = yield* fixture.registry.snapshot()
const result = yield* tools.execute({
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-failover", name: "websearch", input: { query: "effect" } },
progress: (metadata) =>
Effect.sync(() => {
progress.push(metadata)
}),
})
const replacement = WebSearch.ID.make(first === "exa" ? "parallel" : "exa")
expect(progress).toEqual([{ provider: first }, { provider: replacement }])
expect(result).toMatchObject({
output: { provider: replacement, results: fixture.results },
metadata: { provider: replacement },
})
expect(fixture.formRequests).toEqual([])
expect((yield* fixture.websearch.query({ query: "next" }, { sessionID })).providerID).toBe(replacement)
}),
)
it.effect("does not reopen consent when all automatic providers are cooling down", () =>
Effect.gen(function* () {
const fixture = yield* setup
yield* fixture.websearch.select("random")
fixture.error = TestWebSearch.httpError()
const tools = yield* fixture.registry.snapshot()
yield* Effect.forEach(["first", "cooling"], (query) =>
Effect.gen(function* () {
const error = yield* tools
.execute({
sessionID,
...toolIdentity,
call: { type: "tool-call", id: `call-${query}`, name: "websearch", input: { query } },
})
.pipe(Effect.flip)
expect(toSessionError(error)).toEqual({
type: "tool.execution",
message: "Web search rate limited (HTTP 429)",
})
expect(error.metadata).toMatchObject({ provider: expect.stringMatching(/^(exa|parallel)$/) })
}),
)
expect(fixture.events.filter((event) => event === "query")).toHaveLength(2)
expect(fixture.formRequests).toEqual([])
}),
)
it.effect("reports safe HTTP failures with the attempted provider", () =>
Effect.gen(function* () {
const fixture = yield* setup
const registry = fixture.registry
const tools = yield* registry.snapshot()
yield* fixture.websearch.select(WebSearch.ID.make("exa"))
yield* Effect.forEach(
[
{ status: 403, message: "Web search request failed (HTTP 403)" },
{ status: 429, message: "Web search rate limited (HTTP 429)" },
{ status: 401, message: "Web search authentication failed (HTTP 401)" },
],
({ status, message }, index) =>
Effect.gen(function* () {
fixture.error = TestWebSearch.httpError(status, undefined, "https://mcp.exa.ai/mcp?exaApiKey=secret")
const progress: Tool.Metadata[] = []
const error = yield* tools
.execute({
sessionID,
...toolIdentity,
call: {
type: "tool-call",
id: `call-http-${index}`,
name: "websearch",
input: { query: "effect" },
},
progress: (metadata) => Effect.sync(() => progress.push(metadata)),
})
.pipe(Effect.flip)
const sessionError = toSessionError(error)
expect(sessionError).toEqual({ type: "tool.execution", message })
expect(sessionError.message).not.toContain("secret")
expect(error.metadata).toEqual({ provider: "exa" })
expect(progress).toEqual([{ provider: "exa" }])
}),
{ discard: true },
)
}),
)
})