promise.test.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453
  1. import { describe, expect } from "bun:test"
  2. import { Message, SystemPart } from "@opencode-ai/ai"
  3. import { DateTime, Deferred, Effect, Fiber, 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, SessionHttpHandler } 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 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. const bodies: string[] = []
  213. yield* PluginPromise.fromPromise(
  214. define({
  215. id: "promise-session-http",
  216. setup: async (ctx) => {
  217. await ctx.session.hook("http", (event) => {
  218. event.use(async (request, next) => {
  219. request.headers.set("x-hook", "promise")
  220. await next(request)
  221. const response = await next(request)
  222. return new Response(`${await response.text()}-response`)
  223. })
  224. })
  225. await ctx.session.hook("http", (event) => {
  226. event.use(async (request, next) => {
  227. const response = await next(request)
  228. return new Response(`${await response.text()}-outer`)
  229. })
  230. })
  231. },
  232. }),
  233. ).effect(host)
  234. const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
  235. const event: PluginHooks.Domains["session"]["http"] = {
  236. sessionID: Session.ID.make("ses_promise_session_http"),
  237. agent: Agent.ID.make("build"),
  238. model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
  239. use: (item) =>
  240. Effect.sync(() => {
  241. middlewares.push(item)
  242. }),
  243. }
  244. yield* hooks.trigger("session", "http", event)
  245. const request = middlewares.reduce<SessionHttpHandler>(
  246. (next, item) => (input: Request) => item(input, next),
  247. (input: Request) =>
  248. Effect.promise(() => input.text()).pipe(
  249. Effect.tap((body) => Effect.sync(() => bodies.push(body))),
  250. Effect.as(new Response(input.headers.get("x-hook") ?? "missing")),
  251. ),
  252. )
  253. const response = yield* request(new Request("https://provider.test", { method: "POST", body: "payload" }))
  254. expect(bodies).toEqual(["payload", "payload"])
  255. expect(yield* Effect.promise(() => response.text())).toBe("promise-response-outer")
  256. }),
  257. )
  258. it.effect("interrupts the Effect request through a promise session HTTP hook", () =>
  259. Effect.gen(function* () {
  260. const plugin = yield* Plugin.Service
  261. const hooks = yield* PluginHooks.Service
  262. const host = yield* PluginHost.make(plugin)
  263. yield* PluginPromise.fromPromise(
  264. define({
  265. id: "promise-session-http-interrupt",
  266. setup: async (ctx) => {
  267. await ctx.session.hook("http", (event) => {
  268. event.use((request, next) => next(request))
  269. })
  270. },
  271. }),
  272. ).effect(host)
  273. const started = yield* Deferred.make<void>()
  274. const interrupted = yield* Deferred.make<void>()
  275. const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
  276. const event: PluginHooks.Domains["session"]["http"] = {
  277. sessionID: Session.ID.make("ses_promise_session_http_interrupt"),
  278. agent: Agent.ID.make("build"),
  279. model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
  280. use: (item) =>
  281. Effect.sync(() => {
  282. middlewares.push(item)
  283. }),
  284. }
  285. yield* hooks.trigger("session", "http", event)
  286. const request = middlewares.reduce<SessionHttpHandler>(
  287. (next, item) => (input: Request) => item(input, next),
  288. () =>
  289. Deferred.succeed(started, undefined).pipe(
  290. Effect.andThen(Effect.never),
  291. Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)),
  292. ),
  293. )
  294. const fiber = yield* request(new Request("https://provider.test")).pipe(Effect.forkChild)
  295. yield* Deferred.await(started)
  296. yield* Fiber.interrupt(fiber)
  297. expect(yield* Deferred.isDone(interrupted)).toBeTrue()
  298. }),
  299. )
  300. it.effect("disposes a hook registration on request", () =>
  301. Effect.gen(function* () {
  302. const agents = yield* Agent.Service
  303. const plugin = yield* Plugin.Service
  304. const host = yield* PluginHost.make(plugin)
  305. const promisePlugin = define({
  306. id: "promise-dispose",
  307. setup: async (ctx) => {
  308. const registration = await ctx.agent.transform((draft) => {
  309. draft.update("temp", (item) => {
  310. item.description = "temporary"
  311. })
  312. })
  313. await registration.dispose()
  314. },
  315. })
  316. const adapted = PluginPromise.fromPromise(promisePlugin)
  317. yield* adapted.effect(host)
  318. expect(yield* agents.get(Agent.ID.make("temp"))).toBeUndefined()
  319. }),
  320. )
  321. it.effect("registers a standalone web search provider", () =>
  322. Effect.gen(function* () {
  323. const websearch = yield* WebSearch.Service
  324. const plugin = yield* Plugin.Service
  325. const host = yield* PluginHost.make(plugin)
  326. const promisePlugin = define({
  327. id: "promise-websearch",
  328. setup: async (ctx) => {
  329. await ctx.websearch.transform((draft) => {
  330. draft.add({
  331. id: "promise-websearch",
  332. name: "Promise Web Search",
  333. execute: async (input) => [{ url: "https://example.com", content: `promise: ${input.query}`, time: {} }],
  334. })
  335. })
  336. },
  337. })
  338. yield* PluginPromise.fromPromise(promisePlugin).effect(host)
  339. expect(yield* websearch.providers()).toContainEqual({
  340. id: WebSearch.ID.make("promise-websearch"),
  341. name: "Promise Web Search",
  342. })
  343. expect(yield* websearch.query({ query: "effect", providerID: WebSearch.ID.make("promise-websearch") })).toEqual(
  344. new WebSearch.Response({
  345. providerID: WebSearch.ID.make("promise-websearch"),
  346. results: [{ url: "https://example.com", content: "promise: effect", time: {} }],
  347. }),
  348. )
  349. }),
  350. )
  351. it.effect("runs the setup cleanup when the plugin scope closes", () =>
  352. Effect.gen(function* () {
  353. const plugin = yield* Plugin.Service
  354. const host = yield* PluginHost.make(plugin)
  355. const events: string[] = []
  356. const promisePlugin = define({
  357. id: "promise-cleanup",
  358. setup: async () => {
  359. events.push("setup")
  360. return async () => {
  361. await Promise.resolve()
  362. events.push("cleanup")
  363. }
  364. },
  365. })
  366. yield* Effect.scoped(
  367. Effect.gen(function* () {
  368. yield* PluginPromise.fromPromise(promisePlugin).effect(host)
  369. expect(events).toEqual(["setup"])
  370. }),
  371. )
  372. expect(events).toEqual(["setup", "cleanup"])
  373. }),
  374. )
  375. it.effect("constructs plain Promise tool definitions in the host", () =>
  376. Effect.gen(function* () {
  377. const plugins = yield* Plugin.Service
  378. const registry = yield* Tool.Service
  379. const host = yield* PluginHost.make(plugins)
  380. const progress: Tool.Metadata[] = []
  381. const promisePlugin = define({
  382. id: "promise-tool",
  383. setup: async (ctx) => {
  384. await ctx.tool.transform((tools) => {
  385. tools.add({
  386. name: "hello",
  387. options: { codemode: false },
  388. description: "Hello",
  389. input: Schema.Struct({ name: Schema.String }),
  390. output: Schema.String,
  391. execute: async ({ name }, context) => {
  392. await context.progress({ phase: "greeting" })
  393. return { output: `Hello, ${name}!` }
  394. },
  395. })
  396. })
  397. },
  398. })
  399. yield* PluginPromise.fromPromise(promisePlugin).effect(host)
  400. const toolSet = yield* registry.snapshot()
  401. expect(toolSet.definitions).toContainEqual(expect.objectContaining({ name: "hello", description: "Hello" }))
  402. expect(
  403. yield* toolSet.execute({
  404. sessionID: Session.ID.make("ses_promise_tool"),
  405. agent: Agent.ID.make("build"),
  406. messageID: SessionMessage.ID.make("msg_promise_tool"),
  407. progress: (update) => Effect.sync(() => progress.push(update)),
  408. call: { type: "tool-call", id: "call_promise_tool", name: "hello", input: { name: "world" } },
  409. }),
  410. ).toMatchObject({
  411. output: "Hello, world!",
  412. content: [{ type: "text", text: "Hello, world!" }],
  413. })
  414. expect(progress).toEqual([{ phase: "greeting" }])
  415. }),
  416. )
  417. })