embedded.test.ts 20 KB

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