promise.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396
  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. id: SessionMessage.ID.make(input.id),
  62. sessionID: Session.ID.make(input.sessionID),
  63. timeCreated: DateTime.makeUnsafe(0),
  64. type: "synthetic",
  65. data: {
  66. text: input.text,
  67. metadata: input.metadata,
  68. },
  69. delivery: "queue",
  70. }),
  71. )
  72. },
  73. },
  74. })
  75. yield* PluginPromise.fromPromise(
  76. define({
  77. id: "promise-session-synthetic",
  78. setup: async (ctx) => {
  79. await ctx.session.synthetic(input)
  80. },
  81. }),
  82. ).effect(host)
  83. expect(seen).toEqual({
  84. ...input,
  85. description: undefined,
  86. delivery: undefined,
  87. resume: undefined,
  88. })
  89. }),
  90. )
  91. it.effect("forwards standard client reads", () =>
  92. Effect.gen(function* () {
  93. const plugin = yield* Plugin.Service
  94. const host = yield* PluginHost.make(plugin)
  95. const seen: string[] = []
  96. const promisePlugin = define({
  97. id: "promise-client-reads",
  98. setup: async (ctx) => {
  99. const results = await Promise.all([
  100. ctx.agent.list(),
  101. ctx.catalog.provider.list(),
  102. ctx.catalog.model.list(),
  103. ctx.command.list(),
  104. ctx.integration.list(),
  105. ctx.plugin.list(),
  106. ctx.reference.list(),
  107. ctx.skill.list(),
  108. ])
  109. seen.push(...results.map((result) => result.location.directory))
  110. },
  111. })
  112. yield* PluginPromise.fromPromise(promisePlugin).effect(host)
  113. expect(seen).toHaveLength(8)
  114. expect(new Set(seen).size).toBe(1)
  115. }),
  116. )
  117. it.effect("forwards direct agent and model list reads", () =>
  118. Effect.gen(function* () {
  119. const agents = yield* Agent.Service
  120. const catalog = yield* Catalog.Service
  121. const plugin = yield* Plugin.Service
  122. const host = yield* PluginHost.make(plugin)
  123. yield* agents.transform((draft) =>
  124. draft.update(Agent.ID.make("reviewer"), (agent) => {
  125. agent.description = "Reviews code"
  126. }),
  127. )
  128. yield* catalog.transform((draft) =>
  129. draft.model.update(Provider.ID.make("test"), Model.ID.make("alias"), (model) => {
  130. model.modelID = Model.ID.make("gpt-5")
  131. }),
  132. )
  133. yield* PluginPromise.fromPromise(
  134. define({
  135. id: "promise-direct-reads",
  136. setup: async (ctx) => {
  137. expect((await ctx.agent.get({ agentID: Agent.ID.make("reviewer") })).data).toMatchObject({
  138. description: "Reviews code",
  139. })
  140. await expect(ctx.agent.get({ agentID: Agent.ID.make("missing") })).rejects.toThrow(
  141. "Agent not found: missing",
  142. )
  143. const models = (await ctx.catalog.model.list()).data
  144. expect(models.find((model) => model.providerID === "test" && model.id === "alias")).toMatchObject({
  145. modelID: "gpt-5",
  146. })
  147. expect(models.find((model) => model.providerID === "test" && model.id === "missing")).toBeUndefined()
  148. },
  149. }),
  150. ).effect(host)
  151. }),
  152. )
  153. it.effect("loads a promise plugin and registers a transform hook", () =>
  154. Effect.gen(function* () {
  155. const agents = yield* Agent.Service
  156. const plugin = yield* Plugin.Service
  157. const host = yield* PluginHost.make(plugin)
  158. const promisePlugin = define({
  159. id: "promise-example",
  160. setup: async (ctx) => {
  161. expect(ctx.options.mode).toBe("strict")
  162. await ctx.agent.transform((draft) => {
  163. draft.update("reviewer", (item) => {
  164. item.description = "Reviews code"
  165. item.mode = "subagent"
  166. })
  167. })
  168. },
  169. })
  170. const adapted = PluginPromise.fromPromise(promisePlugin)
  171. yield* adapted.effect({ ...host, options: { mode: "strict" } })
  172. expect(yield* agents.get(Agent.ID.make("reviewer"))).toMatchObject({
  173. description: "Reviews code",
  174. mode: "subagent",
  175. })
  176. }),
  177. )
  178. it.effect("forwards session context hooks", () =>
  179. Effect.gen(function* () {
  180. const plugin = yield* Plugin.Service
  181. const hooks = yield* PluginHooks.Service
  182. const host = yield* PluginHost.make(plugin)
  183. yield* PluginPromise.fromPromise(
  184. define({
  185. id: "promise-session-context",
  186. setup: async (ctx) => {
  187. await ctx.session.hook("context", (event) => {
  188. event.system.push(SystemPart.make("Promise hook"))
  189. delete event.tools.echo
  190. })
  191. },
  192. }),
  193. ).effect(host)
  194. const event: SessionHooks["context"] = {
  195. sessionID: Session.ID.make("ses_promise_session_context"),
  196. agent: Agent.ID.make("build"),
  197. model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
  198. system: [SystemPart.make("Initial")],
  199. messages: [Message.user("Hello")],
  200. tools: { echo: { description: "Echo", input: { type: "object" } } },
  201. }
  202. yield* hooks.trigger("session", "context", event)
  203. expect(event.system.map((part) => part.text)).toEqual(["Initial", "Promise hook"])
  204. expect(event.tools).toEqual({})
  205. }),
  206. )
  207. it.effect("adapts promise session HTTP request and response hooks", () =>
  208. Effect.gen(function* () {
  209. const plugin = yield* Plugin.Service
  210. const hooks = yield* PluginHooks.Service
  211. const host = yield* PluginHost.make(plugin)
  212. yield* PluginPromise.fromPromise(
  213. define({
  214. id: "promise-session-http",
  215. setup: async (ctx) => {
  216. await ctx.session.hook("http.request", (event) => {
  217. event.request = new Request("https://provider.test/changed", event.request)
  218. event.request.headers.set("x-hook", "promise")
  219. })
  220. await ctx.session.hook("http.response", async (event) => {
  221. event.response = new Response(`${await event.response.text()}-response`, {
  222. status: event.response.status,
  223. })
  224. })
  225. },
  226. }),
  227. ).effect(host)
  228. const context = {
  229. sessionID: Session.ID.make("ses_promise_session_http"),
  230. agent: Agent.ID.make("build"),
  231. model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
  232. }
  233. const request = yield* hooks.trigger("session", "http.request", {
  234. ...context,
  235. request: new Request("https://provider.test", { method: "POST", body: "payload" }),
  236. })
  237. const response = yield* hooks.trigger("session", "http.response", {
  238. ...context,
  239. request: request.request,
  240. response: new Response(request.request.headers.get("x-hook") ?? "missing"),
  241. })
  242. expect(request.request.url).toBe("https://provider.test/changed")
  243. expect(yield* Effect.promise(() => response.response.text())).toBe("promise-response")
  244. }),
  245. )
  246. it.effect("disposes a hook registration on request", () =>
  247. Effect.gen(function* () {
  248. const agents = yield* Agent.Service
  249. const plugin = yield* Plugin.Service
  250. const host = yield* PluginHost.make(plugin)
  251. const promisePlugin = define({
  252. id: "promise-dispose",
  253. setup: async (ctx) => {
  254. const registration = await ctx.agent.transform((draft) => {
  255. draft.update("temp", (item) => {
  256. item.description = "temporary"
  257. })
  258. })
  259. await registration.dispose()
  260. },
  261. })
  262. const adapted = PluginPromise.fromPromise(promisePlugin)
  263. yield* adapted.effect(host)
  264. expect(yield* agents.get(Agent.ID.make("temp"))).toBeUndefined()
  265. }),
  266. )
  267. it.effect("registers a standalone web search provider", () =>
  268. Effect.gen(function* () {
  269. const websearch = yield* WebSearch.Service
  270. const plugin = yield* Plugin.Service
  271. const host = yield* PluginHost.make(plugin)
  272. const promisePlugin = define({
  273. id: "promise-websearch",
  274. setup: async (ctx) => {
  275. await ctx.websearch.transform((draft) => {
  276. draft.add({
  277. id: "promise-websearch",
  278. name: "Promise Web Search",
  279. execute: async (input) => [{ url: "https://example.com", content: `promise: ${input.query}`, time: {} }],
  280. })
  281. })
  282. },
  283. })
  284. yield* PluginPromise.fromPromise(promisePlugin).effect(host)
  285. expect(yield* websearch.providers()).toContainEqual({
  286. id: WebSearch.ID.make("promise-websearch"),
  287. name: "Promise Web Search",
  288. })
  289. expect(yield* websearch.query({ query: "effect", providerID: WebSearch.ID.make("promise-websearch") })).toEqual(
  290. new WebSearch.Response({
  291. providerID: WebSearch.ID.make("promise-websearch"),
  292. results: [{ url: "https://example.com", content: "promise: effect", time: {} }],
  293. }),
  294. )
  295. }),
  296. )
  297. it.effect("runs the setup cleanup when the plugin scope closes", () =>
  298. Effect.gen(function* () {
  299. const plugin = yield* Plugin.Service
  300. const host = yield* PluginHost.make(plugin)
  301. const events: string[] = []
  302. const promisePlugin = define({
  303. id: "promise-cleanup",
  304. setup: async () => {
  305. events.push("setup")
  306. return async () => {
  307. await Promise.resolve()
  308. events.push("cleanup")
  309. }
  310. },
  311. })
  312. yield* Effect.scoped(
  313. Effect.gen(function* () {
  314. yield* PluginPromise.fromPromise(promisePlugin).effect(host)
  315. expect(events).toEqual(["setup"])
  316. }),
  317. )
  318. expect(events).toEqual(["setup", "cleanup"])
  319. }),
  320. )
  321. it.effect("constructs plain Promise tool definitions in the host", () =>
  322. Effect.gen(function* () {
  323. const plugins = yield* Plugin.Service
  324. const registry = yield* Tool.Service
  325. const host = yield* PluginHost.make(plugins)
  326. const progress: Tool.Metadata[] = []
  327. const promisePlugin = define({
  328. id: "promise-tool",
  329. setup: async (ctx) => {
  330. await ctx.tool.transform((tools) => {
  331. tools.add({
  332. name: "hello",
  333. options: { codemode: false },
  334. description: "Hello",
  335. input: Schema.Struct({ name: Schema.String }),
  336. output: Schema.String,
  337. execute: async ({ name }, context) => {
  338. await context.progress({ phase: "greeting" })
  339. return { output: `Hello, ${name}!` }
  340. },
  341. })
  342. })
  343. },
  344. })
  345. yield* PluginPromise.fromPromise(promisePlugin).effect(host)
  346. const toolSet = yield* registry.snapshot()
  347. expect(toolSet.definitions).toContainEqual(expect.objectContaining({ name: "hello", description: "Hello" }))
  348. expect(
  349. yield* toolSet.execute({
  350. sessionID: Session.ID.make("ses_promise_tool"),
  351. agent: Agent.ID.make("build"),
  352. messageID: SessionMessage.ID.make("msg_promise_tool"),
  353. progress: (update) => Effect.sync(() => progress.push(update)),
  354. call: { type: "tool-call", id: "call_promise_tool", name: "hello", input: { name: "world" } },
  355. }),
  356. ).toMatchObject({
  357. output: "Hello, world!",
  358. content: [{ type: "text", text: "Hello, world!" }],
  359. })
  360. expect(progress).toEqual([{ phase: "greeting" }])
  361. }),
  362. )
  363. })