embedded.test.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390
  1. import fs from "fs/promises"
  2. import path from "path"
  3. import { expect } from "bun:test"
  4. import { Flag } from "@opencode-ai/core/flag/flag"
  5. import { Deferred, Effect, Latch, Layer, Option, Ref, Schema, Stream } from "effect"
  6. import { testEffect } from "../../core/test/lib/effect"
  7. import { tmpdir } from "../../core/test/fixture/tmpdir"
  8. import type { OpenCodeEvent } from "../src"
  9. Flag.OPENCODE_DB = ":memory:"
  10. const it = testEffect(Layer.empty)
  11. type Sdk = typeof import("../src")
  12. type Fixture = { readonly directory: string; readonly sdk: Sdk }
  13. const withEmbedded = <A, E, R>(prefix: string, f: (fixture: Fixture) => Effect.Effect<A, E, R>) =>
  14. Effect.acquireRelease(
  15. Effect.promise(() => tmpdir(prefix)),
  16. (directory) => Effect.promise(() => directory[Symbol.asyncDispose]()),
  17. ).pipe(
  18. Effect.flatMap((directory) =>
  19. Effect.promise(() => import("../src")).pipe(Effect.flatMap((sdk) => f({ directory: directory.path, sdk }))),
  20. ),
  21. )
  22. const sessionID = (fixture: Fixture) => fixture.sdk.Session.ID.create()
  23. const location = (fixture: Fixture) =>
  24. fixture.sdk.Location.Ref.make({ directory: fixture.sdk.AbsolutePath.make(fixture.directory) })
  25. it.live(
  26. "reloads every booted Location after SDK plugin registration",
  27. () =>
  28. withEmbedded("opencode-embedded-plugin-reload-", (fixture) =>
  29. Effect.gen(function* () {
  30. const opencode = yield* fixture.sdk.OpenCode.create()
  31. const booted = yield* Deferred.make<void>()
  32. const activated = yield* Deferred.make<boolean>()
  33. const bootCount = yield* Ref.make(0)
  34. const activationCount = yield* Ref.make(0)
  35. const secondDirectory = path.join(fixture.directory, "second")
  36. yield* Effect.promise(() => fs.mkdir(secondDirectory))
  37. const refs = [
  38. location(fixture),
  39. fixture.sdk.Location.Ref.make({ directory: fixture.sdk.AbsolutePath.make(secondDirectory) }),
  40. ]
  41. const bootstrapID = `bootstrap-sdk-${crypto.randomUUID()}`
  42. const id = `late-sdk-${crypto.randomUUID()}`
  43. yield* opencode.plugin({
  44. id: bootstrapID,
  45. effect: (ctx) =>
  46. Effect.gen(function* () {
  47. yield* ctx.tool
  48. .transform((draft) =>
  49. draft.add(
  50. "bootstrap_sdk_tool",
  51. fixture.sdk.Tool.make({
  52. description: "Marks the initial Location plugin generation",
  53. input: Schema.Struct({}),
  54. output: Schema.Void,
  55. execute: () => Effect.void,
  56. }),
  57. ),
  58. )
  59. .pipe(Effect.orDie)
  60. if (yield* Ref.updateAndGet(bootCount, (count) => count + 1).pipe(Effect.map((count) => count === 2))) {
  61. yield* Deferred.succeed(booted, undefined)
  62. }
  63. }),
  64. })
  65. yield* Effect.all(
  66. refs.map((ref) => opencode.plugin.list({ location: ref })),
  67. { discard: true },
  68. )
  69. yield* Deferred.await(booted).pipe(Effect.timeout("4 seconds"))
  70. yield* opencode.plugin({
  71. id,
  72. effect: (ctx) =>
  73. Effect.gen(function* () {
  74. yield* ctx.tool
  75. .transform((draft) =>
  76. draft.add(
  77. "late_sdk_tool",
  78. fixture.sdk.Tool.make({
  79. description: "Tool registered after Location boot",
  80. input: Schema.Struct({}),
  81. output: Schema.Void,
  82. execute: () => Effect.void,
  83. }),
  84. ),
  85. )
  86. .pipe(Effect.orDie)
  87. if (
  88. yield* Ref.updateAndGet(activationCount, (count) => count + 1).pipe(Effect.map((count) => count === 2))
  89. ) {
  90. yield* Deferred.succeed(activated, true)
  91. }
  92. }),
  93. })
  94. expect(yield* Deferred.await(activated).pipe(Effect.timeout("10 seconds"))).toBe(true)
  95. }),
  96. ),
  97. 25_000,
  98. )
  99. it.live(
  100. "preserves SDK plugins across Location eviction",
  101. () =>
  102. withEmbedded("opencode-embedded-plugin-eviction-", (fixture) =>
  103. Effect.gen(function* () {
  104. const opencode = yield* fixture.sdk.OpenCode.create()
  105. const ref = location(fixture)
  106. const connected = yield* Latch.make(false)
  107. const booted = yield* Deferred.make<void>()
  108. // The rebooted Location commits its second plugin generation.
  109. const recommitted = yield* Deferred.make<void>()
  110. const generations = yield* Ref.make(0)
  111. const id = `evicted-sdk-${crypto.randomUUID()}`
  112. yield* opencode.events.subscribe().pipe(
  113. Stream.runForEach((event) => {
  114. if (event.type === "server.connected") return connected.open
  115. if (event.type !== "plugin.updated" || event.location?.directory !== fixture.directory) return Effect.void
  116. return Ref.updateAndGet(generations, (total) => total + 1).pipe(
  117. Effect.flatMap((total) => {
  118. if (total === 1) return Deferred.succeed(booted, undefined)
  119. if (total === 2) return Deferred.succeed(recommitted, undefined)
  120. return Effect.void
  121. }),
  122. Effect.asVoid,
  123. )
  124. }),
  125. Effect.forkScoped,
  126. )
  127. yield* connected.await
  128. yield* opencode.plugin({ id, effect: () => Effect.void })
  129. yield* opencode.plugin.list({ location: ref })
  130. yield* Deferred.await(booted).pipe(Effect.timeout("5 seconds"))
  131. yield* opencode.debug.location.evict({ location: ref })
  132. yield* opencode.plugin.list({ location: ref })
  133. yield* Deferred.await(recommitted).pipe(Effect.timeout("5 seconds"))
  134. expect((yield* opencode.plugin.list({ location: ref })).data.map((plugin) => String(plugin.id))).toContain(id)
  135. }),
  136. ),
  137. 15_000,
  138. )
  139. it.live(
  140. "keeps SDK plugin registration isolated between embedded hosts",
  141. () =>
  142. withEmbedded("opencode-embedded-plugin-isolation-", (fixture) =>
  143. Effect.gen(function* () {
  144. const first = yield* fixture.sdk.OpenCode.create()
  145. const second = yield* fixture.sdk.OpenCode.create()
  146. const firstReady = yield* Deferred.make<void>()
  147. const secondReady = yield* Deferred.make<void>()
  148. const activated = yield* Deferred.make<void>()
  149. const ref = location(fixture)
  150. const id = `isolated-sdk-${crypto.randomUUID()}`
  151. yield* first.plugin({
  152. id: `first-ready-${crypto.randomUUID()}`,
  153. effect: () => Deferred.succeed(firstReady, undefined),
  154. })
  155. yield* second.plugin({
  156. id: `second-ready-${crypto.randomUUID()}`,
  157. effect: () => Deferred.succeed(secondReady, undefined),
  158. })
  159. yield* Effect.all([first.plugin.list({ location: ref }), second.plugin.list({ location: ref })], {
  160. discard: true,
  161. })
  162. yield* Effect.all([Deferred.await(firstReady), Deferred.await(secondReady)], { discard: true })
  163. yield* first.plugin({ id, effect: () => Deferred.succeed(activated, undefined) })
  164. yield* Deferred.await(activated).pipe(Effect.timeout("5 seconds"))
  165. expect((yield* second.plugin.list({ location: ref })).data.map((plugin) => String(plugin.id))).not.toContain(id)
  166. }),
  167. ),
  168. 15_000,
  169. )
  170. it.live(
  171. "embedded client uses the real router and handlers",
  172. () =>
  173. withEmbedded("opencode-embedded-", (fixture) =>
  174. Effect.gen(function* () {
  175. const opencode = yield* fixture.sdk.OpenCode.create()
  176. const id = sessionID(fixture)
  177. const model = fixture.sdk.Model.Ref.make({
  178. id: fixture.sdk.Model.ID.make("embedded"),
  179. providerID: fixture.sdk.Provider.ID.make("test"),
  180. })
  181. yield* opencode.plugin({
  182. id: `embedded-tools-${crypto.randomUUID()}`,
  183. effect: (ctx) =>
  184. ctx.tool
  185. .transform((draft) =>
  186. draft.add(
  187. "embedded_tool",
  188. fixture.sdk.Tool.make({
  189. description: "Embedded test tool",
  190. input: Schema.Struct({}),
  191. output: Schema.Struct({ ok: Schema.Boolean }),
  192. execute: () => Effect.succeed({ ok: true }),
  193. }),
  194. ),
  195. )
  196. .pipe(Effect.orDie),
  197. })
  198. const created = yield* opencode.sessions.create({
  199. id,
  200. agent: fixture.sdk.Agent.ID.make("build"),
  201. location: location(fixture),
  202. })
  203. yield* opencode.sessions.switchModel({ sessionID: id, model })
  204. const selected = yield* opencode.sessions.get({ sessionID: id })
  205. const page = yield* opencode.sessions.list({ directory: fixture.sdk.AbsolutePath.make(fixture.directory) })
  206. const active = yield* opencode.sessions.active()
  207. const admitted = yield* opencode.sessions.prompt({
  208. sessionID: id,
  209. text: "Do not run",
  210. resume: false,
  211. })
  212. const context = yield* opencode.sessions.context({ sessionID: id })
  213. const pendingAfterAdmit = yield* opencode.sessions.pending.list({ sessionID: id })
  214. yield* opencode.sessions.instructions.entry.put({ sessionID: id, key: "deploy-target", value: "production" })
  215. yield* opencode.sessions.instructions.entry.put({ sessionID: id, key: "flags", value: { beta: true } })
  216. const contextEntries = yield* opencode.sessions.instructions.entry.list({ sessionID: id })
  217. yield* opencode.sessions.instructions.entry.remove({ sessionID: id, key: "flags" })
  218. const remainingContextEntries = yield* opencode.sessions.instructions.entry.list({ sessionID: id })
  219. const wake = yield* opencode.sessions.prompt({
  220. sessionID: id,
  221. text: "Promote this input",
  222. })
  223. const prompted = yield* opencode.sessions.log({ sessionID: id, follow: true }).pipe(
  224. Stream.filter((event) => event.type === "session.input.promoted" && event.data.inputID === wake.id),
  225. Stream.runHead,
  226. Effect.timeout("10 seconds"),
  227. Effect.map(Option.getOrThrow),
  228. )
  229. const wakeContext = yield* opencode.sessions.context({ sessionID: id })
  230. const pendingAfterPromote = yield* opencode.sessions.pending.list({ sessionID: id })
  231. const event = yield* opencode.sessions.log({ sessionID: id }).pipe(
  232. Stream.filter((item) => item.type !== "log.synced"),
  233. Stream.take(1),
  234. Stream.runHead,
  235. Effect.map(Option.getOrUndefined),
  236. )
  237. const modelMessage = Option.fromNullishOr(context.find((message) => message.type === "model-switched")).pipe(
  238. Option.getOrThrow,
  239. )
  240. const message = yield* opencode.sessions.message({ sessionID: id, messageID: modelMessage.id })
  241. yield* opencode.sessions.interrupt({ sessionID: id })
  242. const other = yield* opencode.sessions.create({ location: location(fixture) })
  243. const missingSessionID = fixture.sdk.Session.ID.create()
  244. const missing = yield* Effect.all(
  245. [
  246. opencode.sessions.log({ sessionID: missingSessionID }).pipe(Stream.runHead, Effect.flip),
  247. opencode.sessions.interrupt({ sessionID: missingSessionID }).pipe(Effect.flip),
  248. opencode.sessions.message({ sessionID: missingSessionID, messageID: modelMessage.id }).pipe(Effect.flip),
  249. opencode.sessions.instructions.entry.list({ sessionID: missingSessionID }).pipe(Effect.flip),
  250. opencode.sessions.pending.list({ sessionID: missingSessionID }).pipe(Effect.flip),
  251. ],
  252. { concurrency: "unbounded" },
  253. )
  254. const missingMessage = yield* Effect.flip(
  255. opencode.sessions.message({
  256. sessionID: other.id,
  257. messageID: modelMessage.id,
  258. }),
  259. )
  260. expect(created.id).toBe(id)
  261. expect(selected.model?.id).toBe(model.id)
  262. expect(selected.model?.providerID).toBe(model.providerID)
  263. expect(page.data.some((session) => session.id === id)).toBe(true)
  264. expect(active).toEqual({})
  265. expect(admitted.sessionID).toBe(id)
  266. expect(pendingAfterAdmit).toContainEqual(
  267. expect.objectContaining({ id: admitted.id, type: "user", delivery: "steer" }),
  268. )
  269. expect(prompted.type).toBe("session.input.promoted")
  270. expect(pendingAfterPromote.map((item) => item.id)).not.toContainAnyValues([admitted.id, wake.id])
  271. expect(wakeContext).toContainEqual(expect.objectContaining({ id: wake.id, type: "user" }))
  272. expect(contextEntries).toEqual([
  273. { key: "deploy-target", value: "production" },
  274. { key: "flags", value: { beta: true } },
  275. ])
  276. expect(remainingContextEntries).toEqual([{ key: "deploy-target", value: "production" }])
  277. expect(context.some((message) => message.type === "model-switched")).toBe(true)
  278. expect(event).toMatchObject({ type: "session.model.selected", durable: { seq: 1 } })
  279. expect(message).toEqual(modelMessage)
  280. expect(missing.map((error) => error._tag)).toEqual([
  281. "SessionNotFoundError",
  282. "SessionNotFoundError",
  283. "SessionNotFoundError",
  284. "SessionNotFoundError",
  285. "SessionNotFoundError",
  286. ])
  287. expect(missingMessage._tag).toBe("MessageNotFoundError")
  288. }),
  289. ),
  290. 10_000,
  291. )
  292. it.live(
  293. "Location-owned runner events reach the ready global client",
  294. () =>
  295. withEmbedded("opencode-embedded-events-", (fixture) =>
  296. Effect.gen(function* () {
  297. const opencode = yield* fixture.sdk.OpenCode.create()
  298. const id = sessionID(fixture)
  299. const connected = yield* Latch.make(false)
  300. const prompted = yield* Deferred.make<Extract<OpenCodeEvent, { type: "session.input.promoted" }>>()
  301. yield* opencode.events.subscribe().pipe(
  302. Stream.runForEach((event) =>
  303. event.type === "server.connected"
  304. ? connected.open
  305. : event.type === "session.input.promoted" && event.data.sessionID === id
  306. ? Deferred.succeed(prompted, event).pipe(Effect.asVoid)
  307. : Effect.void,
  308. ),
  309. Effect.forkScoped,
  310. )
  311. yield* connected.await
  312. yield* opencode.sessions.create({ id, location: location(fixture) })
  313. yield* opencode.sessions.prompt({
  314. sessionID: id,
  315. text: "Observe this input",
  316. })
  317. const event = yield* Deferred.await(prompted).pipe(Effect.timeout("4 seconds"))
  318. expect(event.durable).toEqual(expect.objectContaining({ aggregateID: id, seq: expect.any(Number) }))
  319. }),
  320. ),
  321. 10_000,
  322. )
  323. it.live(
  324. "independent embedded hosts do not share live notifications",
  325. () =>
  326. withEmbedded("opencode-embedded-hosts-", (fixture) =>
  327. Effect.gen(function* () {
  328. const first = yield* fixture.sdk.OpenCode.create()
  329. const second = yield* fixture.sdk.OpenCode.create()
  330. const id = sessionID(fixture)
  331. const firstReady = yield* Latch.make(false)
  332. const secondReady = yield* Latch.make(false)
  333. const firstEvent = yield* Latch.make(false)
  334. const secondEvent = yield* Latch.make(false)
  335. const observe = (ready: Latch.Latch, event: Latch.Latch) =>
  336. Stream.runForEach((notification: OpenCodeEvent) =>
  337. notification.type === "server.connected"
  338. ? ready.open
  339. : notification.type === "session.agent.selected" && notification.data.sessionID === id
  340. ? event.open
  341. : Effect.void,
  342. )
  343. yield* first.events.subscribe().pipe(observe(firstReady, firstEvent), Effect.forkScoped)
  344. yield* second.events.subscribe().pipe(observe(secondReady, secondEvent), Effect.forkScoped)
  345. yield* Effect.all([firstReady.await, secondReady.await], { discard: true })
  346. yield* first.sessions.create({ id, location: location(fixture) })
  347. yield* first.sessions.switchAgent({ sessionID: id, agent: fixture.sdk.Agent.ID.make("plan") })
  348. yield* firstEvent.await.pipe(Effect.timeout("2 seconds"))
  349. expect(Option.isNone(yield* secondEvent.await.pipe(Effect.timeoutOption("100 millis")))).toBe(true)
  350. }),
  351. ),
  352. 10_000,
  353. )
  354. it.live("embedded client is available as a Layer service", () =>
  355. withEmbedded("opencode-embedded-layer-", (fixture) => {
  356. const id = sessionID(fixture)
  357. return Effect.gen(function* () {
  358. const opencode = yield* fixture.sdk.OpenCode.Service
  359. const created = yield* opencode.sessions.create({ id, location: location(fixture) })
  360. expect(created.id).toBe(id)
  361. }).pipe(Effect.provide(fixture.sdk.OpenCode.layer))
  362. }),
  363. )