promise.test.ts 11 KB

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