promise.test.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548
  1. import { describe, expect } from "bun:test"
  2. import { Message, SystemPart } from "@opencode-ai/ai"
  3. import { DateTime, Effect, Schema, Stream } 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 { Location } from "@opencode-ai/core/location"
  8. import { Plugin } from "@opencode-ai/core/plugin"
  9. import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
  10. import { PluginHost } from "@opencode-ai/core/plugin/host"
  11. import { PluginPromise } from "@opencode-ai/core/plugin/promise"
  12. import { WebSearch } from "@opencode-ai/core/websearch"
  13. import { Session } from "@opencode-ai/core/session"
  14. import { SessionMessage } from "@opencode-ai/core/session/message"
  15. import { SessionInbox } from "@opencode-ai/core/session/inbox"
  16. import { Tool } from "@opencode-ai/core/tool"
  17. import { Provider } from "@opencode-ai/core/provider"
  18. import { Project } from "@opencode-ai/core/project"
  19. import { AbsolutePath } from "@opencode-ai/core/schema"
  20. import { define } from "@opencode-ai/plugin/promise/plugin"
  21. import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
  22. import { Money } from "@opencode-ai/schema/money"
  23. import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
  24. import { testEffect } from "../lib/effect"
  25. import { PluginTestLayer } from "./fixture"
  26. import { host as testHost } from "./host"
  27. const it = testEffect(PluginTestLayer)
  28. describe("fromPromise", () => {
  29. it.effect("forwards a selected event type", () =>
  30. Effect.gen(function* () {
  31. let selected: string | undefined
  32. const subscribe: EffectPlugin.Context["event"]["subscribe"] = (type?) => {
  33. selected = type
  34. return Stream.empty
  35. }
  36. const host = testHost({ event: { subscribe } })
  37. yield* PluginPromise.fromPromise(
  38. define({
  39. id: "promise-event-subscribe",
  40. setup: (ctx) => {
  41. ctx.event.subscribe("config.updated")
  42. },
  43. }),
  44. ).effect(host)
  45. expect(selected).toBe("config.updated")
  46. }),
  47. )
  48. it.effect("adapts session creation through the protocol schema", () =>
  49. Effect.gen(function* () {
  50. let seen: unknown
  51. const host = testHost({
  52. session: {
  53. create: (input) => {
  54. seen = input
  55. return Effect.succeed(
  56. Session.Info.make({
  57. id: Session.ID.make("ses_protocol_adapter"),
  58. projectID: Project.ID.make("project"),
  59. cost: Money.USD.make(0),
  60. tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } },
  61. time: { created: DateTime.makeUnsafe(10), updated: DateTime.makeUnsafe(20) },
  62. title: input?.title,
  63. location: Location.Ref.make({ directory: AbsolutePath.make("/workspace") }),
  64. }),
  65. )
  66. },
  67. },
  68. })
  69. yield* PluginPromise.fromPromise(
  70. define({
  71. id: "promise-session-create",
  72. setup: async (ctx) => {
  73. await expect(Reflect.apply(ctx.session.create, undefined, [{ title: 42 }])).rejects.toBeDefined()
  74. const result = await ctx.session.create({
  75. id: null,
  76. title: "Promise title",
  77. agent: null,
  78. model: null,
  79. location: null,
  80. })
  81. expect(result).toMatchObject({
  82. id: "ses_protocol_adapter",
  83. title: "Promise title",
  84. time: { created: 10, updated: 20 },
  85. })
  86. },
  87. }),
  88. ).effect(host)
  89. expect(seen).toEqual({ title: "Promise title" })
  90. }),
  91. )
  92. it.effect("forwards transient session generation", () =>
  93. Effect.gen(function* () {
  94. const host = testHost({
  95. session: {
  96. generate: (input) => Effect.succeed({ text: `${input.sessionID}: ${input.prompt}` }),
  97. },
  98. })
  99. yield* PluginPromise.fromPromise(
  100. define({
  101. id: "promise-session-generate",
  102. setup: async (ctx) => {
  103. expect(await ctx.session.generate({ sessionID: "ses_generate", prompt: "Summarize" })).toEqual({
  104. text: "ses_generate: Summarize",
  105. })
  106. },
  107. }),
  108. ).effect(host)
  109. }),
  110. )
  111. it.effect("preserves no-content and rejected Promise behavior", () =>
  112. Effect.gen(function* () {
  113. const seen: unknown[] = []
  114. const host = testHost({
  115. session: {
  116. interrupt: (input) => {
  117. if (input.sessionID === Session.ID.make("ses_failure")) {
  118. return Effect.fail(new Error("interrupt failed"))
  119. }
  120. expect(input.continue).toBe(true)
  121. return Effect.void
  122. },
  123. rename: (input) => Effect.sync(() => seen.push(input)),
  124. wait: (input) => Effect.sync(() => seen.push(input)),
  125. },
  126. })
  127. yield* PluginPromise.fromPromise(
  128. define({
  129. id: "promise-session-interrupt",
  130. setup: async (ctx) => {
  131. expect(await ctx.session.interrupt({ sessionID: "ses_success", continue: true })).toBeUndefined()
  132. await expect(ctx.session.interrupt({ sessionID: "ses_failure" })).rejects.toThrow("interrupt failed")
  133. expect(await ctx.session.rename({ sessionID: "ses_success", title: "Renamed" })).toBeUndefined()
  134. expect(await ctx.session.wait({ sessionID: "ses_success" })).toBeUndefined()
  135. },
  136. }),
  137. ).effect(host)
  138. expect(seen).toEqual([
  139. { sessionID: Session.ID.make("ses_success"), title: "Renamed" },
  140. { sessionID: Session.ID.make("ses_success") },
  141. ])
  142. }),
  143. )
  144. it.effect("forwards synthetic session input", () =>
  145. Effect.gen(function* () {
  146. const input = {
  147. sessionID: "ses_synthetic",
  148. id: "msg_synthetic",
  149. text: "Background work completed",
  150. description: null,
  151. metadata: { shellID: "shell_1" },
  152. delivery: null,
  153. resume: null,
  154. }
  155. let seen: unknown
  156. const host = testHost({
  157. session: {
  158. synthetic: (value) => {
  159. seen = value
  160. return Effect.succeed(
  161. SessionInbox.Synthetic.make({
  162. id: SessionMessage.ID.make(input.id),
  163. sessionID: Session.ID.make(input.sessionID),
  164. timeCreated: DateTime.makeUnsafe(0),
  165. type: "synthetic",
  166. payload: {
  167. text: input.text,
  168. metadata: input.metadata,
  169. },
  170. delivery: "queue",
  171. }),
  172. )
  173. },
  174. },
  175. })
  176. yield* PluginPromise.fromPromise(
  177. define({
  178. id: "promise-session-synthetic",
  179. setup: async (ctx) => {
  180. await ctx.session.synthetic(input)
  181. },
  182. }),
  183. ).effect(host)
  184. expect(seen).toEqual({
  185. ...input,
  186. description: undefined,
  187. delivery: undefined,
  188. resume: undefined,
  189. })
  190. }),
  191. )
  192. it.effect("forwards standard client reads", () =>
  193. Effect.gen(function* () {
  194. const plugin = yield* Plugin.Service
  195. const host = yield* PluginHost.make(plugin)
  196. const seen: string[] = []
  197. const promisePlugin = define({
  198. id: "promise-client-reads",
  199. setup: async (ctx) => {
  200. const results = await Promise.all([
  201. ctx.agent.list(),
  202. ctx.catalog.provider.list(),
  203. ctx.catalog.model.list(),
  204. ctx.command.list(),
  205. ctx.integration.list(),
  206. ctx.plugin.list(),
  207. ctx.reference.list(),
  208. ctx.skill.list(),
  209. ])
  210. seen.push(...results.map((result) => result.location.directory))
  211. expect((await ctx.integration.get({ integrationID: "missing" })).data).toBeNull()
  212. },
  213. })
  214. yield* PluginPromise.fromPromise(promisePlugin).effect(host)
  215. expect(seen).toHaveLength(8)
  216. expect(new Set(seen).size).toBe(1)
  217. }),
  218. )
  219. it.effect("forwards direct agent and model list reads", () =>
  220. Effect.gen(function* () {
  221. const agents = yield* Agent.Service
  222. const catalog = yield* Catalog.Service
  223. const plugin = yield* Plugin.Service
  224. const host = yield* PluginHost.make(plugin)
  225. yield* agents.transform((draft) =>
  226. draft.update(Agent.ID.make("reviewer"), (agent) => {
  227. agent.description = "Reviews code"
  228. }),
  229. )
  230. yield* catalog.transform((draft) =>
  231. draft.model.update(Provider.ID.make("test"), Model.ID.make("alias"), (model) => {
  232. model.modelID = Model.ID.make("gpt-5")
  233. }),
  234. )
  235. yield* PluginPromise.fromPromise(
  236. define({
  237. id: "promise-direct-reads",
  238. setup: async (ctx) => {
  239. expect((await ctx.agent.get({ agentID: Agent.ID.make("reviewer") })).data).toMatchObject({
  240. description: "Reviews code",
  241. })
  242. await expect(ctx.agent.get({ agentID: Agent.ID.make("missing") })).rejects.toThrow(
  243. "Agent not found: missing",
  244. )
  245. const models = (await ctx.catalog.model.list()).data
  246. expect(models.find((model) => model.providerID === "test" && model.id === "alias")).toMatchObject({
  247. modelID: "gpt-5",
  248. })
  249. expect(models.find((model) => model.providerID === "test" && model.id === "missing")).toBeUndefined()
  250. },
  251. }),
  252. ).effect(host)
  253. }),
  254. )
  255. it.effect("loads a promise plugin and registers a transform hook", () =>
  256. Effect.gen(function* () {
  257. const agents = yield* Agent.Service
  258. const plugin = yield* Plugin.Service
  259. const host = yield* PluginHost.make(plugin)
  260. const promisePlugin = define({
  261. id: "promise-example",
  262. setup: async (ctx) => {
  263. expect(ctx.options.mode).toBe("strict")
  264. await ctx.agent.transform((draft) => {
  265. draft.update("reviewer", (item) => {
  266. item.description = "Reviews code"
  267. item.mode = "subagent"
  268. })
  269. })
  270. },
  271. })
  272. const adapted = PluginPromise.fromPromise(promisePlugin)
  273. yield* adapted.effect({ ...host, options: { mode: "strict" } })
  274. expect(yield* agents.get(Agent.ID.make("reviewer"))).toMatchObject({
  275. description: "Reviews code",
  276. mode: "subagent",
  277. })
  278. }),
  279. )
  280. it.effect("forwards session context hooks", () =>
  281. Effect.gen(function* () {
  282. const plugin = yield* Plugin.Service
  283. const hooks = yield* PluginHooks.Service
  284. const host = yield* PluginHost.make(plugin)
  285. yield* PluginPromise.fromPromise(
  286. define({
  287. id: "promise-session-context",
  288. setup: async (ctx) => {
  289. await ctx.session.hook("context", (event) => {
  290. event.system.push(SystemPart.make("Promise hook"))
  291. delete event.tools.echo
  292. })
  293. },
  294. }),
  295. ).effect(host)
  296. const event: SessionHooks["context"] = {
  297. sessionID: Session.ID.make("ses_promise_session_context"),
  298. agent: Agent.ID.make("build"),
  299. model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
  300. system: [SystemPart.make("Initial")],
  301. messages: [Message.user("Hello")],
  302. tools: { echo: { description: "Echo", input: { type: "object" } } },
  303. }
  304. yield* hooks.trigger("session", "context", event)
  305. expect(event.system.map((part) => part.text)).toEqual(["Initial", "Promise hook"])
  306. expect(event.tools).toEqual({})
  307. }),
  308. )
  309. it.effect("adapts promise session HTTP request and response hooks", () =>
  310. Effect.gen(function* () {
  311. const plugin = yield* Plugin.Service
  312. const hooks = yield* PluginHooks.Service
  313. const host = yield* PluginHost.make(plugin)
  314. yield* PluginPromise.fromPromise(
  315. define({
  316. id: "promise-session-http",
  317. setup: async (ctx) => {
  318. await ctx.session.hook("http.request", (event) => {
  319. event.request = new Request("https://provider.test/changed", event.request)
  320. event.request.headers.set("x-hook", "promise")
  321. })
  322. await ctx.session.hook("http.response", async (event) => {
  323. event.response = new Response(`${await event.response.text()}-response`, {
  324. status: event.response.status,
  325. })
  326. })
  327. },
  328. }),
  329. ).effect(host)
  330. const context = {
  331. sessionID: Session.ID.make("ses_promise_session_http"),
  332. agent: Agent.ID.make("build"),
  333. model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
  334. }
  335. const request = yield* hooks.trigger("session", "http.request", {
  336. ...context,
  337. request: new Request("https://provider.test", { method: "POST", body: "payload" }),
  338. })
  339. const response = yield* hooks.trigger("session", "http.response", {
  340. ...context,
  341. request: request.request,
  342. response: new Response(request.request.headers.get("x-hook") ?? "missing"),
  343. })
  344. expect(request.request.url).toBe("https://provider.test/changed")
  345. expect(yield* Effect.promise(() => response.response.text())).toBe("promise-response")
  346. }),
  347. )
  348. it.effect("disposes a hook registration on request", () =>
  349. Effect.gen(function* () {
  350. const agents = yield* Agent.Service
  351. const plugin = yield* Plugin.Service
  352. const host = yield* PluginHost.make(plugin)
  353. const promisePlugin = define({
  354. id: "promise-dispose",
  355. setup: async (ctx) => {
  356. const registration = await ctx.agent.transform((draft) => {
  357. draft.update("temp", (item) => {
  358. item.description = "temporary"
  359. })
  360. })
  361. await registration.dispose()
  362. },
  363. })
  364. const adapted = PluginPromise.fromPromise(promisePlugin)
  365. yield* adapted.effect(host)
  366. expect(yield* agents.get(Agent.ID.make("temp"))).toBeUndefined()
  367. }),
  368. )
  369. it.effect("registers a standalone web search provider", () =>
  370. Effect.gen(function* () {
  371. const websearch = yield* WebSearch.Service
  372. const plugin = yield* Plugin.Service
  373. const host = yield* PluginHost.make(plugin)
  374. const promisePlugin = define({
  375. id: "promise-websearch",
  376. setup: async (ctx) => {
  377. await ctx.websearch.transform((draft) => {
  378. draft.add({
  379. id: "promise-websearch",
  380. name: "Promise Web Search",
  381. execute: async (input) => [{ url: "https://example.com", content: `promise: ${input.query}`, time: {} }],
  382. })
  383. })
  384. },
  385. })
  386. yield* PluginPromise.fromPromise(promisePlugin).effect(host)
  387. expect(yield* websearch.providers()).toContainEqual({
  388. id: WebSearch.ID.make("promise-websearch"),
  389. name: "Promise Web Search",
  390. })
  391. expect(yield* websearch.query({ query: "effect", providerID: WebSearch.ID.make("promise-websearch") })).toEqual(
  392. new WebSearch.Response({
  393. providerID: WebSearch.ID.make("promise-websearch"),
  394. results: [{ url: "https://example.com", content: "promise: effect", time: {} }],
  395. }),
  396. )
  397. }),
  398. )
  399. it.effect("runs the setup cleanup when the plugin scope closes", () =>
  400. Effect.gen(function* () {
  401. const plugin = yield* Plugin.Service
  402. const host = yield* PluginHost.make(plugin)
  403. const events: string[] = []
  404. const promisePlugin = define({
  405. id: "promise-cleanup",
  406. setup: async () => {
  407. events.push("setup")
  408. return async () => {
  409. await Promise.resolve()
  410. events.push("cleanup")
  411. }
  412. },
  413. })
  414. yield* Effect.scoped(
  415. Effect.gen(function* () {
  416. yield* PluginPromise.fromPromise(promisePlugin).effect(host)
  417. expect(events).toEqual(["setup"])
  418. }),
  419. )
  420. expect(events).toEqual(["setup", "cleanup"])
  421. }),
  422. )
  423. it.effect("constructs plain Promise tool definitions in the host", () =>
  424. Effect.gen(function* () {
  425. const plugins = yield* Plugin.Service
  426. const registry = yield* Tool.Service
  427. const host = yield* PluginHost.make(plugins)
  428. const progress: Tool.Metadata[] = []
  429. const promisePlugin = define({
  430. id: "promise-tool",
  431. setup: async (ctx) => {
  432. await ctx.tool.transform((tools) => {
  433. tools.add({
  434. name: "hello",
  435. options: { codemode: false },
  436. description: "Hello",
  437. input: Schema.Struct({ name: Schema.String }),
  438. output: Schema.String,
  439. execute: async ({ name }, context) => {
  440. await context.progress({ phase: "greeting" })
  441. return { output: `Hello, ${name}!` }
  442. },
  443. })
  444. })
  445. },
  446. })
  447. yield* PluginPromise.fromPromise(promisePlugin).effect(host)
  448. const toolSet = yield* registry.snapshot()
  449. expect(toolSet.definitions).toContainEqual(expect.objectContaining({ name: "hello", description: "Hello" }))
  450. expect(
  451. yield* toolSet.execute({
  452. sessionID: Session.ID.make("ses_promise_tool"),
  453. agent: Agent.ID.make("build"),
  454. messageID: SessionMessage.ID.make("msg_promise_tool"),
  455. progress: (update) => Effect.sync(() => progress.push(update)),
  456. call: { type: "tool-call", id: "call_promise_tool", name: "hello", input: { name: "world" } },
  457. }),
  458. ).toMatchObject({
  459. output: "Hello, world!",
  460. content: [{ type: "text", text: "Hello, world!" }],
  461. })
  462. expect(progress).toEqual([{ phase: "greeting" }])
  463. }),
  464. )
  465. it.effect("returns content-only plugin results through Code Mode", () =>
  466. Effect.gen(function* () {
  467. const plugins = yield* Plugin.Service
  468. const registry = yield* Tool.Service
  469. const host = yield* PluginHost.make(plugins)
  470. const promisePlugin = define({
  471. id: "content-only-tool",
  472. setup: async (ctx) => {
  473. await ctx.tool.transform((tools) => {
  474. tools.add({
  475. name: "demo_status",
  476. description: "Returns a status string",
  477. input: Schema.Struct({}),
  478. execute: async () => ({ content: [{ type: "text", text: "hello" }] }),
  479. options: { codemode: true },
  480. })
  481. })
  482. },
  483. })
  484. yield* PluginPromise.fromPromise(promisePlugin).effect(host)
  485. const toolSet = yield* registry.snapshot()
  486. const throughCodeMode = yield* toolSet.execute({
  487. sessionID: Session.ID.make("ses_content_only_tool"),
  488. agent: Agent.ID.make("build"),
  489. messageID: SessionMessage.ID.make("msg_content_only_tool"),
  490. call: {
  491. type: "tool-call",
  492. id: "call_content_only_tool",
  493. name: "execute",
  494. input: { code: "return await tools.demo_status({})" },
  495. },
  496. })
  497. expect(throughCodeMode).toMatchObject({
  498. output: { output: "hello", toolCalls: [{ tool: "demo_status", status: "completed" }] },
  499. content: [{ type: "text", text: "hello" }],
  500. })
  501. }),
  502. )
  503. })