Files
opencode/packages/core/test/websearch.test.ts
2026-07-08 17:27:31 +05:30

176 lines
6.8 KiB
TypeScript

import { beforeEach, describe, expect } from "bun:test"
import { Effect, Exit, Fiber, Layer, Scope } from "effect"
import path from "node:path"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Config } from "@opencode-ai/core/config"
import { ConfigGlobal } from "@opencode-ai/core/config/global"
import { ConfigWebSearch } from "@opencode-ai/core/config/websearch"
import { Credential } from "@opencode-ai/core/credential"
import { EventV2 } from "@opencode-ai/core/event"
import { Form } from "@opencode-ai/core/form"
import { Global } from "@opencode-ai/core/global"
import { Integration } from "@opencode-ai/core/integration"
import { WebSearch } from "@opencode-ai/core/websearch"
import { testEffect } from "./lib/effect"
let entries: Config.Entry[] = []
const writes: { path: readonly (string | number)[]; value: unknown }[] = []
const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed(entries) }))
const configGlobal = Layer.succeed(
ConfigGlobal.Service,
ConfigGlobal.Service.of({ update: (path, value) => Effect.sync(() => writes.push({ path, value })) }),
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([WebSearch.node, Integration.node, Credential.node, EventV2.node, Form.node, ConfigGlobal.node]),
[
[Config.node, config],
[ConfigGlobal.node, configGlobal],
],
),
)
const register = (id: string, connection: "optional" | "required" = "optional") =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const integrationID = Integration.ID.make(id)
const calls: { input: WebSearch.Input; credential?: Credential.Value; sessionID?: string }[] = []
yield* integrations.transform((draft) => {
draft.update(integrationID, (integration) => (integration.name = id.toUpperCase()))
draft.websearch.update({
integrationID,
connection,
execute: (input, context) =>
Effect.sync(() => {
calls.push({ input, ...context })
return { text: `${id}: ${input.query}`, metadata: { id } }
}),
})
})
return { integrationID, calls }
})
beforeEach(() => {
entries = []
writes.length = 0
})
describe("WebSearch", () => {
it.effect("executes an explicit provider without changing the default", () =>
Effect.gen(function* () {
const provider = yield* register("exa")
const websearch = yield* WebSearch.Service
expect(yield* websearch.query({ query: "effect", providerID: provider.integrationID })).toEqual(
new WebSearch.Result({
providerID: provider.integrationID,
text: "exa: effect",
metadata: { id: "exa" },
}),
)
expect(yield* websearch.selected()).toBeUndefined()
expect(provider.calls).toEqual([
{
input: { query: "effect", providerID: provider.integrationID },
credential: undefined,
sessionID: undefined,
},
])
}),
)
it.effect("uses and persists the global provider selection", () =>
Effect.gen(function* () {
yield* register("exa")
const parallel = yield* register("parallel")
const websearch = yield* WebSearch.Service
yield* websearch.select(parallel.integrationID)
expect((yield* websearch.query({ query: "layers" })).providerID).toBe(parallel.integrationID)
expect(yield* websearch.selected()).toBe(parallel.integrationID)
expect(writes).toEqual([
{ path: ["websearch"], value: new ConfigWebSearch.Info({ provider: parallel.integrationID }) },
])
}),
)
it.effect("reads the selected provider from global config", () =>
Effect.gen(function* () {
const provider = yield* register("exa")
const websearch = yield* WebSearch.Service
entries = [
new Config.Document({
type: "document",
path: path.join(Global.Path.config, "opencode.json"),
info: new Config.Info({ websearch: new ConfigWebSearch.Info({ provider: provider.integrationID }) }),
}),
]
expect(yield* websearch.selected()).toBe(provider.integrationID)
expect((yield* websearch.query({ query: "configured" })).providerID).toBe(provider.integrationID)
}),
)
it.effect("prefers the location config over the global selection", () =>
Effect.gen(function* () {
const exa = yield* register("exa")
const parallel = yield* register("parallel")
const websearch = yield* WebSearch.Service
yield* websearch.select(exa.integrationID)
entries = [
new Config.Document({
type: "document",
info: new Config.Info({ websearch: new ConfigWebSearch.Info({ provider: parallel.integrationID }) }),
}),
]
expect((yield* websearch.query({ query: "configured" })).providerID).toBe(parallel.integrationID)
}),
)
it.effect("serializes concurrent first-use onboarding and persists the answer", () =>
Effect.gen(function* () {
const provider = yield* register("exa")
const websearch = yield* WebSearch.Service
const forms = yield* Form.Service
const first = yield* websearch.query({ query: "one", sessionID: "ses_websearch" }).pipe(Effect.forkChild)
const second = yield* websearch.query({ query: "two", sessionID: "ses_websearch" }).pipe(Effect.forkChild)
yield* Effect.yieldNow
const pending = yield* forms.list({ sessionID: "ses_websearch" })
expect(pending).toHaveLength(1)
const form = pending[0]
if (!form) return yield* Effect.die("Expected an onboarding form")
yield* forms.reply({ id: form.id, answer: { provider: provider.integrationID } })
expect((yield* Fiber.join(first)).providerID).toBe(provider.integrationID)
expect((yield* Fiber.join(second)).providerID).toBe(provider.integrationID)
expect(yield* websearch.selected()).toBe(provider.integrationID)
}),
)
it.effect("requires a connection before invoking a required provider", () =>
Effect.gen(function* () {
const provider = yield* register("private", "required")
const websearch = yield* WebSearch.Service
expect(
yield* websearch.query({ query: "secret", providerID: provider.integrationID }).pipe(Effect.flip),
).toBeInstanceOf(WebSearch.ConnectionRequiredError)
expect(provider.calls).toEqual([])
}),
)
it.effect("removes scoped provider registrations", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const scope = yield* Scope.fork(yield* Scope.Scope)
const provider = yield* register("temporary").pipe(Scope.provide(scope))
expect(yield* integrations.websearch.get(provider.integrationID)).toBeDefined()
yield* Scope.close(scope, Exit.void)
expect(yield* integrations.websearch.get(provider.integrationID)).toBeUndefined()
}),
)
})