embedded.test.ts 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  1. import { expect, test } from "bun:test"
  2. import { mkdtemp, rm } from "node:fs/promises"
  3. import { tmpdir } from "node:os"
  4. import { join } from "node:path"
  5. import { Flag } from "@opencode-ai/core/flag/flag"
  6. import { Deferred, Effect, Latch, Option, Schema, Stream } from "effect"
  7. import type { OpenCodeEvent } from "../src"
  8. test("embedded client uses the real router and handlers", async () => {
  9. const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-"))
  10. const database = Flag.OPENCODE_DB
  11. Flag.OPENCODE_DB = join(directory, "opencode.sqlite")
  12. const { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Provider, Session, Tool } = await import("../src")
  13. const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`)
  14. const model = Model.Ref.make({ id: Model.ID.make("embedded"), providerID: Provider.ID.make("test") })
  15. try {
  16. const program = Effect.gen(function* () {
  17. const opencode = yield* OpenCode.create()
  18. yield* opencode.tools.register({
  19. embedded_tool: Tool.make({
  20. description: "Embedded test tool",
  21. input: Schema.Struct({}),
  22. output: Schema.Struct({ ok: Schema.Boolean }),
  23. execute: () => Effect.succeed({ ok: true }),
  24. }),
  25. })
  26. const created = yield* opencode.sessions.create({
  27. id: sessionID,
  28. agent: Agent.ID.make("build"),
  29. location: Location.Ref.make({ directory: AbsolutePath.make(directory) }),
  30. })
  31. yield* opencode.sessions.switchModel({ sessionID, model })
  32. const selected = yield* opencode.sessions.get({ sessionID })
  33. const page = yield* opencode.sessions.list({ directory: AbsolutePath.make(directory) })
  34. const active = yield* opencode.sessions.active()
  35. const admitted = yield* opencode.sessions.prompt({
  36. sessionID,
  37. prompt: Prompt.make({ text: "Do not run" }),
  38. resume: false,
  39. })
  40. const context = yield* opencode.sessions.context({ sessionID })
  41. const wake = yield* opencode.sessions.prompt({
  42. sessionID,
  43. prompt: Prompt.make({ text: "Promote this input" }),
  44. })
  45. const prompted = yield* opencode.sessions.events({ sessionID }).pipe(
  46. Stream.filter((event) => event.type === "session.next.prompted" && event.data.messageID === wake.id),
  47. Stream.runHead,
  48. Effect.timeout("10 seconds"),
  49. Effect.map(Option.getOrThrow),
  50. )
  51. const wakeContext = yield* opencode.sessions.context({ sessionID })
  52. const event = yield* opencode.sessions
  53. .events({ sessionID })
  54. .pipe(Stream.take(1), Stream.runHead, Effect.map(Option.getOrUndefined))
  55. const modelMessage = Option.fromNullishOr(context.find((message) => message.type === "model-switched")).pipe(
  56. Option.getOrThrow,
  57. )
  58. const message = yield* opencode.sessions.message({ sessionID, messageID: modelMessage.id })
  59. yield* opencode.sessions.interrupt({ sessionID })
  60. const other = yield* opencode.sessions.create({
  61. location: Location.Ref.make({ directory: AbsolutePath.make(directory) }),
  62. })
  63. const missingSessionID = Session.ID.make(`ses_missing_${crypto.randomUUID()}`)
  64. const missing = yield* Effect.all(
  65. [
  66. opencode.sessions.events({ sessionID: missingSessionID }).pipe(Stream.runHead, Effect.flip),
  67. opencode.sessions.interrupt({ sessionID: missingSessionID }).pipe(Effect.flip),
  68. opencode.sessions.message({ sessionID: missingSessionID, messageID: modelMessage.id }).pipe(Effect.flip),
  69. ],
  70. { concurrency: "unbounded" },
  71. )
  72. const missingMessage = yield* Effect.flip(
  73. opencode.sessions.message({
  74. sessionID: other.id,
  75. messageID: modelMessage.id,
  76. }),
  77. )
  78. expect(created.id).toBe(sessionID)
  79. expect(selected.model?.id).toBe(model.id)
  80. expect(selected.model?.providerID).toBe(model.providerID)
  81. expect(page.data.some((session) => session.id === sessionID)).toBe(true)
  82. expect(active).toEqual({})
  83. expect(admitted.sessionID).toBe(sessionID)
  84. expect(prompted.type).toBe("session.next.prompted")
  85. expect(wakeContext).toContainEqual(expect.objectContaining({ id: wake.id, type: "user" }))
  86. expect(context.some((message) => message.type === "model-switched")).toBe(true)
  87. expect(event).toMatchObject({ type: "session.next.model.switched", durable: { seq: 1 } })
  88. expect(message).toEqual(modelMessage)
  89. expect(missing.map((error) => error._tag)).toEqual([
  90. "SessionNotFoundError",
  91. "SessionNotFoundError",
  92. "SessionNotFoundError",
  93. ])
  94. expect(missingMessage._tag).toBe("MessageNotFoundError")
  95. })
  96. await Effect.runPromise(Effect.scoped(program))
  97. } finally {
  98. Flag.OPENCODE_DB = database
  99. await rm(directory, { recursive: true, force: true })
  100. }
  101. })
  102. test("Location-owned runner events reach the ready global client", async () => {
  103. const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-events-"))
  104. const database = Flag.OPENCODE_DB
  105. Flag.OPENCODE_DB = join(directory, "opencode.sqlite")
  106. const { AbsolutePath, Location, OpenCode, Prompt, Session } = await import("../src")
  107. const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`)
  108. try {
  109. const program = Effect.gen(function* () {
  110. const opencode = yield* OpenCode.create()
  111. const connected = yield* Latch.make(false)
  112. const prompted = yield* Deferred.make<OpenCodeEvent>()
  113. yield* opencode.events.subscribe().pipe(
  114. Stream.runForEach((event) =>
  115. event.type === "server.connected"
  116. ? connected.open
  117. : event.type === "session.next.prompted" && event.data.sessionID === sessionID
  118. ? Deferred.succeed(prompted, event).pipe(Effect.asVoid)
  119. : Effect.void,
  120. ),
  121. Effect.forkScoped,
  122. )
  123. yield* connected.await
  124. yield* opencode.sessions.create({
  125. id: sessionID,
  126. location: Location.Ref.make({ directory: AbsolutePath.make(directory) }),
  127. })
  128. yield* opencode.sessions.prompt({ sessionID, prompt: Prompt.make({ text: "Observe this input" }) })
  129. const event = yield* Deferred.await(prompted).pipe(Effect.timeout("4 seconds"))
  130. expect(event.durable).toEqual(expect.objectContaining({ aggregateID: sessionID, seq: expect.any(Number) }))
  131. })
  132. await Effect.runPromise(Effect.scoped(program))
  133. } finally {
  134. Flag.OPENCODE_DB = database
  135. await rm(directory, { recursive: true, force: true })
  136. }
  137. }, 10_000)
  138. test("independent embedded hosts do not share live notifications", async () => {
  139. const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-hosts-"))
  140. const database = Flag.OPENCODE_DB
  141. Flag.OPENCODE_DB = join(directory, "opencode.sqlite")
  142. const { AbsolutePath, Agent, Location, OpenCode, Session } = await import("../src")
  143. const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`)
  144. try {
  145. const program = Effect.gen(function* () {
  146. const first = yield* OpenCode.create()
  147. const second = yield* OpenCode.create()
  148. const firstReady = yield* Latch.make(false)
  149. const secondReady = yield* Latch.make(false)
  150. const firstEvent = yield* Latch.make(false)
  151. const secondEvent = yield* Latch.make(false)
  152. const observe = (ready: Latch.Latch, event: Latch.Latch) =>
  153. Stream.runForEach((notification: OpenCodeEvent) =>
  154. notification.type === "server.connected"
  155. ? ready.open
  156. : notification.type === "session.next.agent.switched" && notification.data.sessionID === sessionID
  157. ? event.open
  158. : Effect.void,
  159. )
  160. yield* first.events.subscribe().pipe(observe(firstReady, firstEvent), Effect.forkScoped)
  161. yield* second.events.subscribe().pipe(observe(secondReady, secondEvent), Effect.forkScoped)
  162. yield* Effect.all([firstReady.await, secondReady.await], { discard: true })
  163. yield* first.sessions.create({
  164. id: sessionID,
  165. location: Location.Ref.make({ directory: AbsolutePath.make(directory) }),
  166. })
  167. yield* first.sessions.switchAgent({ sessionID, agent: Agent.ID.make("plan") })
  168. yield* firstEvent.await.pipe(Effect.timeout("2 seconds"))
  169. expect(Option.isNone(yield* secondEvent.await.pipe(Effect.timeoutOption("100 millis")))).toBe(true)
  170. })
  171. await Effect.runPromise(Effect.scoped(program))
  172. } finally {
  173. Flag.OPENCODE_DB = database
  174. await rm(directory, { recursive: true, force: true })
  175. }
  176. }, 10_000)
  177. test("embedded client is available as a Layer service", async () => {
  178. const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-layer-"))
  179. const database = Flag.OPENCODE_DB
  180. Flag.OPENCODE_DB = join(directory, "opencode.sqlite")
  181. const { AbsolutePath, Location, OpenCode, Session } = await import("../src")
  182. const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`)
  183. try {
  184. const created = await Effect.runPromise(
  185. Effect.gen(function* () {
  186. const opencode = yield* OpenCode.Service
  187. return yield* opencode.sessions.create({
  188. id: sessionID,
  189. location: Location.Ref.make({ directory: AbsolutePath.make(directory) }),
  190. })
  191. }).pipe(Effect.provide(OpenCode.layer), Effect.scoped),
  192. )
  193. expect(created.id).toBe(sessionID)
  194. } finally {
  195. Flag.OPENCODE_DB = database
  196. await rm(directory, { recursive: true, force: true })
  197. }
  198. })