promise.test.ts 10.0 KB

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