embedded.test.ts 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129
  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 { Effect, Option, Schema, Stream } from "effect"
  7. test("embedded client uses the real router and handlers", async () => {
  8. const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-"))
  9. const database = Flag.OPENCODE_DB
  10. Flag.OPENCODE_DB = join(directory, "opencode.sqlite")
  11. const { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Provider, Session, Tool } = await import("../src")
  12. const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`)
  13. const model = Model.Ref.make({ id: Model.ID.make("embedded"), providerID: Provider.ID.make("test") })
  14. try {
  15. const program = Effect.gen(function* () {
  16. const opencode = yield* OpenCode.create()
  17. yield* opencode.tools.register({
  18. embedded_tool: Tool.make({
  19. description: "Embedded test tool",
  20. input: Schema.Struct({}),
  21. output: Schema.Struct({ ok: Schema.Boolean }),
  22. execute: () => Effect.succeed({ ok: true }),
  23. }),
  24. })
  25. const created = yield* opencode.sessions.create({
  26. id: sessionID,
  27. agent: Agent.ID.make("build"),
  28. location: Location.Ref.make({ directory: AbsolutePath.make(directory) }),
  29. })
  30. yield* opencode.sessions.switchModel({ sessionID, model })
  31. const selected = yield* opencode.sessions.get({ sessionID })
  32. const page = yield* opencode.sessions.list({ directory: AbsolutePath.make(directory) })
  33. const active = yield* opencode.sessions.active()
  34. const admitted = yield* opencode.sessions.prompt({
  35. sessionID,
  36. prompt: Prompt.make({ text: "Do not run" }),
  37. resume: false,
  38. })
  39. const context = yield* opencode.sessions.context({ sessionID })
  40. const wake = yield* opencode.sessions.prompt({
  41. sessionID,
  42. prompt: Prompt.make({ text: "Promote this input" }),
  43. })
  44. const prompted = yield* opencode.sessions.events({ sessionID }).pipe(
  45. Stream.filter((event) => event.type === "session.next.prompted" && event.data.messageID === wake.id),
  46. Stream.runHead,
  47. Effect.timeout("10 seconds"),
  48. Effect.map(Option.getOrThrow),
  49. )
  50. const wakeContext = yield* opencode.sessions.context({ sessionID })
  51. const event = yield* opencode.sessions
  52. .events({ sessionID })
  53. .pipe(Stream.take(1), Stream.runHead, Effect.map(Option.getOrUndefined))
  54. const modelMessage = Option.fromNullishOr(context.find((message) => message.type === "model-switched")).pipe(
  55. Option.getOrThrow,
  56. )
  57. const message = yield* opencode.sessions.message({ sessionID, messageID: modelMessage.id })
  58. yield* opencode.sessions.interrupt({ sessionID })
  59. const other = yield* opencode.sessions.create({
  60. location: Location.Ref.make({ directory: AbsolutePath.make(directory) }),
  61. })
  62. const missingSessionID = Session.ID.make(`ses_missing_${crypto.randomUUID()}`)
  63. const missing = yield* Effect.all(
  64. [
  65. opencode.sessions.events({ sessionID: missingSessionID }).pipe(Stream.runHead, Effect.flip),
  66. opencode.sessions.interrupt({ sessionID: missingSessionID }).pipe(Effect.flip),
  67. opencode.sessions.message({ sessionID: missingSessionID, messageID: modelMessage.id }).pipe(Effect.flip),
  68. ],
  69. { concurrency: "unbounded" },
  70. )
  71. const missingMessage = yield* Effect.flip(
  72. opencode.sessions.message({
  73. sessionID: other.id,
  74. messageID: modelMessage.id,
  75. }),
  76. )
  77. expect(created.id).toBe(sessionID)
  78. expect(selected.model?.id).toBe(model.id)
  79. expect(selected.model?.providerID).toBe(model.providerID)
  80. expect(page.data.some((session) => session.id === sessionID)).toBe(true)
  81. expect(active).toEqual({})
  82. expect(admitted.sessionID).toBe(sessionID)
  83. expect(prompted.type).toBe("session.next.prompted")
  84. expect(wakeContext).toContainEqual(expect.objectContaining({ id: wake.id, type: "user" }))
  85. expect(context.some((message) => message.type === "model-switched")).toBe(true)
  86. expect(event).toMatchObject({ type: "session.next.model.switched", durable: { seq: 1 } })
  87. expect(message).toEqual(modelMessage)
  88. expect(missing.map((error) => error._tag)).toEqual([
  89. "SessionNotFoundError",
  90. "SessionNotFoundError",
  91. "SessionNotFoundError",
  92. ])
  93. expect(missingMessage._tag).toBe("MessageNotFoundError")
  94. })
  95. await Effect.runPromise(Effect.scoped(program))
  96. } finally {
  97. Flag.OPENCODE_DB = database
  98. await rm(directory, { recursive: true, force: true })
  99. }
  100. })
  101. test("embedded client is available as a Layer service", async () => {
  102. const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-layer-"))
  103. const database = Flag.OPENCODE_DB
  104. Flag.OPENCODE_DB = join(directory, "opencode.sqlite")
  105. const { AbsolutePath, Location, OpenCode, Session } = await import("../src")
  106. const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`)
  107. try {
  108. const created = await Effect.runPromise(
  109. Effect.gen(function* () {
  110. const opencode = yield* OpenCode.Service
  111. return yield* opencode.sessions.create({
  112. id: sessionID,
  113. location: Location.Ref.make({ directory: AbsolutePath.make(directory) }),
  114. })
  115. }).pipe(Effect.provide(OpenCode.layer), Effect.scoped),
  116. )
  117. expect(created.id).toBe(sessionID)
  118. } finally {
  119. Flag.OPENCODE_DB = database
  120. await rm(directory, { recursive: true, force: true })
  121. }
  122. })