| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453 |
- import { describe, expect } from "bun:test"
- import { Message, SystemPart } from "@opencode-ai/ai"
- import { DateTime, Deferred, Effect, Fiber, Schema } from "effect"
- import { Agent } from "@opencode-ai/core/agent"
- import { Catalog } from "@opencode-ai/core/catalog"
- import { Model } from "@opencode-ai/core/model"
- import { Plugin } from "@opencode-ai/core/plugin"
- import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
- import { PluginHost } from "@opencode-ai/core/plugin/host"
- import { PluginPromise } from "@opencode-ai/core/plugin/promise"
- import { WebSearch } from "@opencode-ai/core/websearch"
- import { Session } from "@opencode-ai/core/session"
- import { SessionMessage } from "@opencode-ai/core/session/message"
- import { SessionPending } from "@opencode-ai/core/session/pending"
- import { Tool } from "@opencode-ai/core/tool"
- import { Provider } from "@opencode-ai/core/provider"
- import { define } from "@opencode-ai/plugin/promise/plugin"
- import type { SessionHooks, SessionHttpHandler } from "@opencode-ai/plugin/effect/session"
- import { testEffect } from "../lib/effect"
- import { PluginTestLayer } from "./fixture"
- import { host as testHost } from "./host"
- const it = testEffect(PluginTestLayer)
- describe("fromPromise", () => {
- it.effect("forwards transient session generation", () =>
- Effect.gen(function* () {
- const host = testHost({
- session: {
- generate: (input) => Effect.succeed({ text: `${input.sessionID}: ${input.prompt}` }),
- },
- })
- yield* PluginPromise.fromPromise(
- define({
- id: "promise-session-generate",
- setup: async (ctx) => {
- expect(await ctx.session.generate({ sessionID: "ses_generate", prompt: "Summarize" })).toEqual({
- text: "ses_generate: Summarize",
- })
- },
- }),
- ).effect(host)
- }),
- )
- it.effect("forwards synthetic session input", () =>
- Effect.gen(function* () {
- const input = {
- sessionID: "ses_synthetic",
- id: "msg_synthetic",
- text: "Background work completed",
- description: null,
- metadata: { shellID: "shell_1" },
- delivery: null,
- resume: null,
- }
- let seen: unknown
- const host = testHost({
- session: {
- synthetic: (value) => {
- seen = value
- return Effect.succeed(
- SessionPending.Synthetic.make({
- id: SessionMessage.ID.make(input.id),
- sessionID: Session.ID.make(input.sessionID),
- timeCreated: DateTime.makeUnsafe(0),
- type: "synthetic",
- data: {
- text: input.text,
- metadata: input.metadata,
- },
- delivery: "queue",
- }),
- )
- },
- },
- })
- yield* PluginPromise.fromPromise(
- define({
- id: "promise-session-synthetic",
- setup: async (ctx) => {
- await ctx.session.synthetic(input)
- },
- }),
- ).effect(host)
- expect(seen).toEqual({
- ...input,
- description: undefined,
- delivery: undefined,
- resume: undefined,
- })
- }),
- )
- it.effect("forwards standard client reads", () =>
- Effect.gen(function* () {
- const plugin = yield* Plugin.Service
- const host = yield* PluginHost.make(plugin)
- const seen: string[] = []
- const promisePlugin = define({
- id: "promise-client-reads",
- setup: async (ctx) => {
- const results = await Promise.all([
- ctx.agent.list(),
- ctx.catalog.provider.list(),
- ctx.catalog.model.list(),
- ctx.command.list(),
- ctx.integration.list(),
- ctx.plugin.list(),
- ctx.reference.list(),
- ctx.skill.list(),
- ])
- seen.push(...results.map((result) => result.location.directory))
- },
- })
- yield* PluginPromise.fromPromise(promisePlugin).effect(host)
- expect(seen).toHaveLength(8)
- expect(new Set(seen).size).toBe(1)
- }),
- )
- it.effect("forwards direct agent and model list reads", () =>
- Effect.gen(function* () {
- const agents = yield* Agent.Service
- const catalog = yield* Catalog.Service
- const plugin = yield* Plugin.Service
- const host = yield* PluginHost.make(plugin)
- yield* agents.transform((draft) =>
- draft.update(Agent.ID.make("reviewer"), (agent) => {
- agent.description = "Reviews code"
- }),
- )
- yield* catalog.transform((draft) =>
- draft.model.update(Provider.ID.make("test"), Model.ID.make("alias"), (model) => {
- model.modelID = Model.ID.make("gpt-5")
- }),
- )
- yield* PluginPromise.fromPromise(
- define({
- id: "promise-direct-reads",
- setup: async (ctx) => {
- expect((await ctx.agent.get({ agentID: Agent.ID.make("reviewer") })).data).toMatchObject({
- description: "Reviews code",
- })
- await expect(ctx.agent.get({ agentID: Agent.ID.make("missing") })).rejects.toThrow(
- "Agent not found: missing",
- )
- const models = (await ctx.catalog.model.list()).data
- expect(models.find((model) => model.providerID === "test" && model.id === "alias")).toMatchObject({
- modelID: "gpt-5",
- })
- expect(models.find((model) => model.providerID === "test" && model.id === "missing")).toBeUndefined()
- },
- }),
- ).effect(host)
- }),
- )
- it.effect("loads a promise plugin and registers a transform hook", () =>
- Effect.gen(function* () {
- const agents = yield* Agent.Service
- const plugin = yield* Plugin.Service
- const host = yield* PluginHost.make(plugin)
- const promisePlugin = define({
- id: "promise-example",
- setup: async (ctx) => {
- expect(ctx.options.mode).toBe("strict")
- await ctx.agent.transform((draft) => {
- draft.update("reviewer", (item) => {
- item.description = "Reviews code"
- item.mode = "subagent"
- })
- })
- },
- })
- const adapted = PluginPromise.fromPromise(promisePlugin)
- yield* adapted.effect({ ...host, options: { mode: "strict" } })
- expect(yield* agents.get(Agent.ID.make("reviewer"))).toMatchObject({
- description: "Reviews code",
- mode: "subagent",
- })
- }),
- )
- it.effect("forwards session context hooks", () =>
- Effect.gen(function* () {
- const plugin = yield* Plugin.Service
- const hooks = yield* PluginHooks.Service
- const host = yield* PluginHost.make(plugin)
- yield* PluginPromise.fromPromise(
- define({
- id: "promise-session-context",
- setup: async (ctx) => {
- await ctx.session.hook("context", (event) => {
- event.system.push(SystemPart.make("Promise hook"))
- delete event.tools.echo
- })
- },
- }),
- ).effect(host)
- const event: SessionHooks["context"] = {
- sessionID: Session.ID.make("ses_promise_session_context"),
- agent: Agent.ID.make("build"),
- model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
- system: [SystemPart.make("Initial")],
- messages: [Message.user("Hello")],
- tools: { echo: { description: "Echo", input: { type: "object" } } },
- }
- yield* hooks.trigger("session", "context", event)
- expect(event.system.map((part) => part.text)).toEqual(["Initial", "Promise hook"])
- expect(event.tools).toEqual({})
- }),
- )
- it.effect("adapts promise session HTTP hooks", () =>
- Effect.gen(function* () {
- const plugin = yield* Plugin.Service
- const hooks = yield* PluginHooks.Service
- const host = yield* PluginHost.make(plugin)
- const bodies: string[] = []
- yield* PluginPromise.fromPromise(
- define({
- id: "promise-session-http",
- setup: async (ctx) => {
- await ctx.session.hook("http", (event) => {
- event.use(async (request, next) => {
- request.headers.set("x-hook", "promise")
- await next(request)
- const response = await next(request)
- return new Response(`${await response.text()}-response`)
- })
- })
- await ctx.session.hook("http", (event) => {
- event.use(async (request, next) => {
- const response = await next(request)
- return new Response(`${await response.text()}-outer`)
- })
- })
- },
- }),
- ).effect(host)
- const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
- const event: PluginHooks.Domains["session"]["http"] = {
- sessionID: Session.ID.make("ses_promise_session_http"),
- agent: Agent.ID.make("build"),
- model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
- use: (item) =>
- Effect.sync(() => {
- middlewares.push(item)
- }),
- }
- yield* hooks.trigger("session", "http", event)
- const request = middlewares.reduce<SessionHttpHandler>(
- (next, item) => (input: Request) => item(input, next),
- (input: Request) =>
- Effect.promise(() => input.text()).pipe(
- Effect.tap((body) => Effect.sync(() => bodies.push(body))),
- Effect.as(new Response(input.headers.get("x-hook") ?? "missing")),
- ),
- )
- const response = yield* request(new Request("https://provider.test", { method: "POST", body: "payload" }))
- expect(bodies).toEqual(["payload", "payload"])
- expect(yield* Effect.promise(() => response.text())).toBe("promise-response-outer")
- }),
- )
- it.effect("interrupts the Effect request through a promise session HTTP hook", () =>
- Effect.gen(function* () {
- const plugin = yield* Plugin.Service
- const hooks = yield* PluginHooks.Service
- const host = yield* PluginHost.make(plugin)
- yield* PluginPromise.fromPromise(
- define({
- id: "promise-session-http-interrupt",
- setup: async (ctx) => {
- await ctx.session.hook("http", (event) => {
- event.use((request, next) => next(request))
- })
- },
- }),
- ).effect(host)
- const started = yield* Deferred.make<void>()
- const interrupted = yield* Deferred.make<void>()
- const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
- const event: PluginHooks.Domains["session"]["http"] = {
- sessionID: Session.ID.make("ses_promise_session_http_interrupt"),
- agent: Agent.ID.make("build"),
- model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
- use: (item) =>
- Effect.sync(() => {
- middlewares.push(item)
- }),
- }
- yield* hooks.trigger("session", "http", event)
- const request = middlewares.reduce<SessionHttpHandler>(
- (next, item) => (input: Request) => item(input, next),
- () =>
- Deferred.succeed(started, undefined).pipe(
- Effect.andThen(Effect.never),
- Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)),
- ),
- )
- const fiber = yield* request(new Request("https://provider.test")).pipe(Effect.forkChild)
- yield* Deferred.await(started)
- yield* Fiber.interrupt(fiber)
- expect(yield* Deferred.isDone(interrupted)).toBeTrue()
- }),
- )
- it.effect("disposes a hook registration on request", () =>
- Effect.gen(function* () {
- const agents = yield* Agent.Service
- const plugin = yield* Plugin.Service
- const host = yield* PluginHost.make(plugin)
- const promisePlugin = define({
- id: "promise-dispose",
- setup: async (ctx) => {
- const registration = await ctx.agent.transform((draft) => {
- draft.update("temp", (item) => {
- item.description = "temporary"
- })
- })
- await registration.dispose()
- },
- })
- const adapted = PluginPromise.fromPromise(promisePlugin)
- yield* adapted.effect(host)
- expect(yield* agents.get(Agent.ID.make("temp"))).toBeUndefined()
- }),
- )
- it.effect("registers a standalone web search provider", () =>
- Effect.gen(function* () {
- const websearch = yield* WebSearch.Service
- const plugin = yield* Plugin.Service
- const host = yield* PluginHost.make(plugin)
- const promisePlugin = define({
- id: "promise-websearch",
- setup: async (ctx) => {
- await ctx.websearch.transform((draft) => {
- draft.add({
- id: "promise-websearch",
- name: "Promise Web Search",
- execute: async (input) => [{ url: "https://example.com", content: `promise: ${input.query}`, time: {} }],
- })
- })
- },
- })
- yield* PluginPromise.fromPromise(promisePlugin).effect(host)
- expect(yield* websearch.providers()).toContainEqual({
- id: WebSearch.ID.make("promise-websearch"),
- name: "Promise Web Search",
- })
- expect(yield* websearch.query({ query: "effect", providerID: WebSearch.ID.make("promise-websearch") })).toEqual(
- new WebSearch.Response({
- providerID: WebSearch.ID.make("promise-websearch"),
- results: [{ url: "https://example.com", content: "promise: effect", time: {} }],
- }),
- )
- }),
- )
- it.effect("runs the setup cleanup when the plugin scope closes", () =>
- Effect.gen(function* () {
- const plugin = yield* Plugin.Service
- const host = yield* PluginHost.make(plugin)
- const events: string[] = []
- const promisePlugin = define({
- id: "promise-cleanup",
- setup: async () => {
- events.push("setup")
- return async () => {
- await Promise.resolve()
- events.push("cleanup")
- }
- },
- })
- yield* Effect.scoped(
- Effect.gen(function* () {
- yield* PluginPromise.fromPromise(promisePlugin).effect(host)
- expect(events).toEqual(["setup"])
- }),
- )
- expect(events).toEqual(["setup", "cleanup"])
- }),
- )
- it.effect("constructs plain Promise tool definitions in the host", () =>
- Effect.gen(function* () {
- const plugins = yield* Plugin.Service
- const registry = yield* Tool.Service
- const host = yield* PluginHost.make(plugins)
- const progress: Tool.Metadata[] = []
- const promisePlugin = define({
- id: "promise-tool",
- setup: async (ctx) => {
- await ctx.tool.transform((tools) => {
- tools.add({
- name: "hello",
- options: { codemode: false },
- description: "Hello",
- input: Schema.Struct({ name: Schema.String }),
- output: Schema.String,
- execute: async ({ name }, context) => {
- await context.progress({ phase: "greeting" })
- return { output: `Hello, ${name}!` }
- },
- })
- })
- },
- })
- yield* PluginPromise.fromPromise(promisePlugin).effect(host)
- const toolSet = yield* registry.snapshot()
- expect(toolSet.definitions).toContainEqual(expect.objectContaining({ name: "hello", description: "Hello" }))
- expect(
- yield* toolSet.execute({
- sessionID: Session.ID.make("ses_promise_tool"),
- agent: Agent.ID.make("build"),
- messageID: SessionMessage.ID.make("msg_promise_tool"),
- progress: (update) => Effect.sync(() => progress.push(update)),
- call: { type: "tool-call", id: "call_promise_tool", name: "hello", input: { name: "world" } },
- }),
- ).toMatchObject({
- output: "Hello, world!",
- content: [{ type: "text", text: "Hello, world!" }],
- })
- expect(progress).toEqual([{ phase: "greeting" }])
- }),
- )
- })
|