embedded.test.ts 18 KB

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