promise.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355
  1. import { describe, expect } from "bun:test"
  2. import { Message, SystemPart } from "@opencode-ai/ai"
  3. import { DateTime, Effect, Schema } from "effect"
  4. import { Agent } from "@opencode-ai/core/agent"
  5. import { Catalog } from "@opencode-ai/core/catalog"
  6. import { Model } from "@opencode-ai/core/model"
  7. import { Plugin } from "@opencode-ai/core/plugin"
  8. import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
  9. import { PluginHost } from "@opencode-ai/core/plugin/host"
  10. import { PluginPromise } from "@opencode-ai/core/plugin/promise"
  11. import { WebSearch } from "@opencode-ai/core/websearch"
  12. import { Session } from "@opencode-ai/core/session"
  13. import { SessionMessage } from "@opencode-ai/core/session/message"
  14. import { SessionPending } from "@opencode-ai/core/session/pending"
  15. import { Tool } from "@opencode-ai/core/tool"
  16. import { Provider } from "@opencode-ai/core/provider"
  17. import { define } from "@opencode-ai/plugin/promise/plugin"
  18. import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
  19. import { testEffect } from "../lib/effect"
  20. import { PluginTestLayer } from "./fixture"
  21. import { host as testHost } from "./host"
  22. const it = testEffect(PluginTestLayer)
  23. describe("fromPromise", () => {
  24. it.effect("forwards transient session generation", () =>
  25. Effect.gen(function* () {
  26. const host = testHost({
  27. session: {
  28. generate: (input) => Effect.succeed({ text: `${input.sessionID}: ${input.prompt}` }),
  29. },
  30. })
  31. yield* PluginPromise.fromPromise(
  32. define({
  33. id: "promise-session-generate",
  34. setup: async (ctx) => {
  35. expect(await ctx.session.generate({ sessionID: "ses_generate", prompt: "Summarize" })).toEqual({
  36. text: "ses_generate: Summarize",
  37. })
  38. },
  39. }),
  40. ).effect(host)
  41. }),
  42. )
  43. it.effect("forwards synthetic session input", () =>
  44. Effect.gen(function* () {
  45. const input = {
  46. sessionID: "ses_synthetic",
  47. id: "msg_synthetic",
  48. text: "Background work completed",
  49. description: null,
  50. metadata: { shellID: "shell_1" },
  51. delivery: null,
  52. resume: null,
  53. }
  54. let seen: unknown
  55. const host = testHost({
  56. session: {
  57. synthetic: (value) => {
  58. seen = value
  59. return Effect.succeed(
  60. SessionPending.Synthetic.make({
  61. admittedSeq: 1,
  62. id: SessionMessage.ID.make(input.id),
  63. sessionID: Session.ID.make(input.sessionID),
  64. timeCreated: DateTime.makeUnsafe(0),
  65. type: "synthetic",
  66. data: {
  67. text: input.text,
  68. metadata: input.metadata,
  69. },
  70. delivery: "queue",
  71. }),
  72. )
  73. },
  74. },
  75. })
  76. yield* PluginPromise.fromPromise(
  77. define({
  78. id: "promise-session-synthetic",
  79. setup: async (ctx) => {
  80. await ctx.session.synthetic(input)
  81. },
  82. }),
  83. ).effect(host)
  84. expect(seen).toEqual({
  85. ...input,
  86. description: undefined,
  87. delivery: undefined,
  88. resume: undefined,
  89. })
  90. }),
  91. )
  92. it.effect("forwards standard client reads", () =>
  93. Effect.gen(function* () {
  94. const plugin = yield* Plugin.Service
  95. const host = yield* PluginHost.make(plugin)
  96. const seen: string[] = []
  97. const promisePlugin = define({
  98. id: "promise-client-reads",
  99. setup: async (ctx) => {
  100. const results = await Promise.all([
  101. ctx.agent.list(),
  102. ctx.catalog.provider.list(),
  103. ctx.catalog.model.list(),
  104. ctx.command.list(),
  105. ctx.integration.list(),
  106. ctx.plugin.list(),
  107. ctx.reference.list(),
  108. ctx.skill.list(),
  109. ])
  110. seen.push(...results.map((result) => result.location.directory))
  111. },
  112. })
  113. yield* PluginPromise.fromPromise(promisePlugin).effect(host)
  114. expect(seen).toHaveLength(8)
  115. expect(new Set(seen).size).toBe(1)
  116. }),
  117. )
  118. it.effect("forwards direct agent and model list reads", () =>
  119. Effect.gen(function* () {
  120. const agents = yield* Agent.Service
  121. const catalog = yield* Catalog.Service
  122. const plugin = yield* Plugin.Service
  123. const host = yield* PluginHost.make(plugin)
  124. yield* agents.transform((draft) =>
  125. draft.update(Agent.ID.make("reviewer"), (agent) => {
  126. agent.description = "Reviews code"
  127. }),
  128. )
  129. yield* catalog.transform((draft) =>
  130. draft.model.update(Provider.ID.make("test"), Model.ID.make("alias"), (model) => {
  131. model.modelID = Model.ID.make("gpt-5")
  132. }),
  133. )
  134. yield* PluginPromise.fromPromise(
  135. define({
  136. id: "promise-direct-reads",
  137. setup: async (ctx) => {
  138. expect((await ctx.agent.get({ agentID: Agent.ID.make("reviewer") })).data).toMatchObject({
  139. description: "Reviews code",
  140. })
  141. await expect(ctx.agent.get({ agentID: Agent.ID.make("missing") })).rejects.toThrow("Agent not found: missing")
  142. const models = (await ctx.catalog.model.list()).data
  143. expect(models.find((model) => model.providerID === "test" && model.id === "alias")).toMatchObject({
  144. modelID: "gpt-5",
  145. })
  146. expect(models.find((model) => model.providerID === "test" && model.id === "missing")).toBeUndefined()
  147. },
  148. }),
  149. ).effect(host)
  150. }),
  151. )
  152. it.effect("loads a promise plugin and registers a transform hook", () =>
  153. Effect.gen(function* () {
  154. const agents = yield* Agent.Service
  155. const plugin = yield* Plugin.Service
  156. const host = yield* PluginHost.make(plugin)
  157. const promisePlugin = define({
  158. id: "promise-example",
  159. setup: async (ctx) => {
  160. expect(ctx.options.mode).toBe("strict")
  161. await ctx.agent.transform((draft) => {
  162. draft.update("reviewer", (item) => {
  163. item.description = "Reviews code"
  164. item.mode = "subagent"
  165. })
  166. })
  167. },
  168. })
  169. const adapted = PluginPromise.fromPromise(promisePlugin)
  170. yield* adapted.effect({ ...host, options: { mode: "strict" } })
  171. expect(yield* agents.get(Agent.ID.make("reviewer"))).toMatchObject({
  172. description: "Reviews code",
  173. mode: "subagent",
  174. })
  175. }),
  176. )
  177. it.effect("forwards session context hooks", () =>
  178. Effect.gen(function* () {
  179. const plugin = yield* Plugin.Service
  180. const hooks = yield* PluginHooks.Service
  181. const host = yield* PluginHost.make(plugin)
  182. yield* PluginPromise.fromPromise(
  183. define({
  184. id: "promise-session-context",
  185. setup: async (ctx) => {
  186. await ctx.session.hook("context", (event) => {
  187. event.system.push(SystemPart.make("Promise hook"))
  188. delete event.tools.echo
  189. })
  190. },
  191. }),
  192. ).effect(host)
  193. const event: SessionHooks["context"] = {
  194. sessionID: Session.ID.make("ses_promise_session_context"),
  195. agent: Agent.ID.make("build"),
  196. model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
  197. system: [SystemPart.make("Initial")],
  198. messages: [Message.user("Hello")],
  199. tools: { echo: { description: "Echo", input: { type: "object" } } },
  200. }
  201. yield* hooks.trigger("session", "context", event)
  202. expect(event.system.map((part) => part.text)).toEqual(["Initial", "Promise hook"])
  203. expect(event.tools).toEqual({})
  204. }),
  205. )
  206. it.effect("disposes a hook registration on request", () =>
  207. Effect.gen(function* () {
  208. const agents = yield* Agent.Service
  209. const plugin = yield* Plugin.Service
  210. const host = yield* PluginHost.make(plugin)
  211. const promisePlugin = define({
  212. id: "promise-dispose",
  213. setup: async (ctx) => {
  214. const registration = await ctx.agent.transform((draft) => {
  215. draft.update("temp", (item) => {
  216. item.description = "temporary"
  217. })
  218. })
  219. await registration.dispose()
  220. },
  221. })
  222. const adapted = PluginPromise.fromPromise(promisePlugin)
  223. yield* adapted.effect(host)
  224. expect(yield* agents.get(Agent.ID.make("temp"))).toBeUndefined()
  225. }),
  226. )
  227. it.effect("registers a standalone web search provider", () =>
  228. Effect.gen(function* () {
  229. const websearch = yield* WebSearch.Service
  230. const plugin = yield* Plugin.Service
  231. const host = yield* PluginHost.make(plugin)
  232. const promisePlugin = define({
  233. id: "promise-websearch",
  234. setup: async (ctx) => {
  235. await ctx.websearch.transform((draft) => {
  236. draft.add({
  237. id: "promise-websearch",
  238. name: "Promise Web Search",
  239. execute: async (input) => [{ url: "https://example.com", content: `promise: ${input.query}`, time: {} }],
  240. })
  241. })
  242. },
  243. })
  244. yield* PluginPromise.fromPromise(promisePlugin).effect(host)
  245. expect(yield* websearch.providers()).toContainEqual({
  246. id: WebSearch.ID.make("promise-websearch"),
  247. name: "Promise Web Search",
  248. })
  249. expect(yield* websearch.query({ query: "effect", providerID: WebSearch.ID.make("promise-websearch") })).toEqual(
  250. new WebSearch.Response({
  251. providerID: WebSearch.ID.make("promise-websearch"),
  252. results: [{ url: "https://example.com", content: "promise: effect", time: {} }],
  253. }),
  254. )
  255. }),
  256. )
  257. it.effect("runs the setup cleanup when the plugin scope closes", () =>
  258. Effect.gen(function* () {
  259. const plugin = yield* Plugin.Service
  260. const host = yield* PluginHost.make(plugin)
  261. const events: string[] = []
  262. const promisePlugin = define({
  263. id: "promise-cleanup",
  264. setup: async () => {
  265. events.push("setup")
  266. return async () => {
  267. await Promise.resolve()
  268. events.push("cleanup")
  269. }
  270. },
  271. })
  272. yield* Effect.scoped(
  273. Effect.gen(function* () {
  274. yield* PluginPromise.fromPromise(promisePlugin).effect(host)
  275. expect(events).toEqual(["setup"])
  276. }),
  277. )
  278. expect(events).toEqual(["setup", "cleanup"])
  279. }),
  280. )
  281. it.effect("constructs plain Promise tool definitions in the host", () =>
  282. Effect.gen(function* () {
  283. const plugins = yield* Plugin.Service
  284. const registry = yield* Tool.Service
  285. const host = yield* PluginHost.make(plugins)
  286. const progress: Tool.Metadata[] = []
  287. const promisePlugin = define({
  288. id: "promise-tool",
  289. setup: async (ctx) => {
  290. await ctx.tool.transform((tools) => {
  291. tools.add(
  292. {
  293. name: "hello",
  294. options: { codemode: false },
  295. description: "Hello",
  296. input: Schema.Struct({ name: Schema.String }),
  297. output: Schema.String,
  298. execute: async ({ name }, context) => {
  299. await context.progress({ phase: "greeting" })
  300. return { output: `Hello, ${name}!` }
  301. },
  302. },
  303. )
  304. })
  305. },
  306. })
  307. yield* PluginPromise.fromPromise(promisePlugin).effect(host)
  308. const toolSet = yield* registry.snapshot()
  309. expect(toolSet.definitions).toContainEqual(expect.objectContaining({ name: "hello", description: "Hello" }))
  310. expect(
  311. yield* toolSet.execute({
  312. sessionID: Session.ID.make("ses_promise_tool"),
  313. agent: Agent.ID.make("build"),
  314. messageID: SessionMessage.ID.make("msg_promise_tool"),
  315. progress: (update) => Effect.sync(() => progress.push(update)),
  316. call: { type: "tool-call", id: "call_promise_tool", name: "hello", input: { name: "world" } },
  317. }),
  318. ).toMatchObject({
  319. output: "Hello, world!",
  320. content: [{ type: "text", text: "Hello, world!" }],
  321. })
  322. expect(progress).toEqual([{ phase: "greeting" }])
  323. }),
  324. )
  325. })