sse-fixture.ts 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. import { OpenCode, type OpenCodeEvent, type SessionMessageInfo } from "@opencode-ai/client/promise"
  2. type DurableEvent = Extract<OpenCodeEvent, { durable: unknown }>
  3. type EphemeralEvent = Exclude<OpenCodeEvent, DurableEvent>
  4. type RequestRecord = {
  5. readonly method: string
  6. readonly path: string
  7. readonly body?: unknown
  8. }
  9. type FixtureOptions = {
  10. readonly onPrompt?: (input: {
  11. readonly sessionID: string
  12. readonly id: string
  13. readonly body: unknown
  14. readonly signal: AbortSignal
  15. readonly send: (event: unknown) => void
  16. }) => void | Promise<void>
  17. readonly onInterrupt?: (input: {
  18. readonly sessionID: string
  19. readonly send: (event: unknown) => void
  20. }) => void | Promise<void>
  21. readonly onPermissionReply?: (input: {
  22. readonly sessionID: string
  23. readonly requestID: string
  24. readonly reply: string
  25. readonly body: unknown
  26. readonly send: (event: unknown) => void
  27. }) => void | Promise<void>
  28. readonly onFormCancel?: (input: {
  29. readonly sessionID: string
  30. readonly formID: string
  31. readonly send: (event: unknown) => void
  32. }) => void | Promise<void>
  33. }
  34. const ids = { next: 0 }
  35. export function durableEvent<Type extends DurableEvent["type"]>(
  36. type: Type,
  37. data: Extract<DurableEvent, { type: Type }>["data"],
  38. ) {
  39. ids.next++
  40. return {
  41. id: `evt_${ids.next}`,
  42. created: ids.next,
  43. type,
  44. durable: { aggregateID: "test", seq: ids.next, version: 1 },
  45. data,
  46. }
  47. }
  48. export function ephemeralEvent<Type extends EphemeralEvent["type"]>(
  49. type: Type,
  50. data: Extract<EphemeralEvent, { type: Type }>["data"],
  51. ) {
  52. ids.next++
  53. return { id: `evt_${ids.next}`, created: ids.next, type, data }
  54. }
  55. export function createSseFixture(options: FixtureOptions = {}) {
  56. const encoder = new TextEncoder()
  57. const streams = new Set<ReadableStreamDefaultController<Uint8Array>>()
  58. const requests: RequestRecord[] = []
  59. const messages = new Map<string, SessionMessageInfo>()
  60. const send = (event: unknown) => {
  61. for (const stream of streams) {
  62. try {
  63. stream.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`))
  64. } catch {
  65. streams.delete(stream)
  66. }
  67. }
  68. }
  69. const server = Bun.serve({
  70. port: 0,
  71. async fetch(request) {
  72. const url = new URL(request.url)
  73. const body = request.method === "GET" ? undefined : await request.json().catch(() => undefined)
  74. requests.push({ method: request.method, path: url.pathname, ...(body === undefined ? {} : { body }) })
  75. if (url.pathname === "/api/event") {
  76. const state: { stream?: ReadableStreamDefaultController<Uint8Array> } = {}
  77. return new Response(
  78. new ReadableStream<Uint8Array>({
  79. start(stream) {
  80. state.stream = stream
  81. streams.add(stream)
  82. stream.enqueue(
  83. encoder.encode(
  84. `data: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n`,
  85. ),
  86. )
  87. },
  88. cancel() {
  89. if (state.stream) streams.delete(state.stream)
  90. },
  91. }),
  92. { headers: { "content-type": "text/event-stream" } },
  93. )
  94. }
  95. const prompt = /^\/api\/session\/([^/]+)\/prompt$/.exec(url.pathname)
  96. if (prompt?.[1]) {
  97. const id = stringField(body, "id")
  98. if (!id) return new Response(null, { status: 400 })
  99. await options.onPrompt?.({
  100. sessionID: decodeURIComponent(prompt[1]),
  101. id,
  102. body,
  103. signal: request.signal,
  104. send,
  105. })
  106. return Response.json({ data: { text: stringField(body, "text") ?? "" } })
  107. }
  108. const message = /^\/api\/session\/([^/]+)\/message\/([^/]+)$/.exec(url.pathname)
  109. if (message?.[1] && message[2]) {
  110. const sessionID = decodeURIComponent(message[1])
  111. const messageID = decodeURIComponent(message[2])
  112. return Response.json({
  113. data: messages.get(`${sessionID}/${messageID}`) ?? messages.get(messageID) ?? assistantMessage(messageID),
  114. })
  115. }
  116. const permission = /^\/api\/session\/([^/]+)\/permission\/([^/]+)\/reply$/.exec(url.pathname)
  117. if (permission?.[1] && permission[2]) {
  118. const reply = stringField(body, "reply")
  119. if (!reply) return new Response(null, { status: 400 })
  120. await options.onPermissionReply?.({
  121. sessionID: decodeURIComponent(permission[1]),
  122. requestID: decodeURIComponent(permission[2]),
  123. reply,
  124. body,
  125. send,
  126. })
  127. return new Response(null, { status: 204 })
  128. }
  129. const form = /^\/api\/session\/([^/]+)\/form\/([^/]+)\/cancel$/.exec(url.pathname)
  130. if (form?.[1] && form[2]) {
  131. await options.onFormCancel?.({
  132. sessionID: decodeURIComponent(form[1]),
  133. formID: decodeURIComponent(form[2]),
  134. send,
  135. })
  136. return new Response(null, { status: 204 })
  137. }
  138. const interrupt = /^\/api\/session\/([^/]+)\/interrupt$/.exec(url.pathname)
  139. if (interrupt?.[1]) {
  140. await options.onInterrupt?.({ sessionID: decodeURIComponent(interrupt[1]), send })
  141. return new Response(null, { status: 204 })
  142. }
  143. return new Response(null, { status: 404 })
  144. },
  145. })
  146. return {
  147. client: OpenCode.make({ baseUrl: server.url.toString() }),
  148. messages,
  149. requests,
  150. send,
  151. async stop() {
  152. for (const stream of streams) {
  153. try {
  154. stream.close()
  155. } catch {}
  156. }
  157. streams.clear()
  158. await server.stop(true)
  159. },
  160. }
  161. }
  162. export async function withTimeout<Value>(promise: Promise<Value>, message: string, milliseconds = 2_000) {
  163. const timeout = Promise.withResolvers<never>()
  164. const timer = setTimeout(() => timeout.reject(new Error(message)), milliseconds)
  165. try {
  166. return await Promise.race([promise, timeout.promise])
  167. } finally {
  168. clearTimeout(timer)
  169. }
  170. }
  171. function stringField(value: unknown, key: string) {
  172. if (!value || typeof value !== "object") return undefined
  173. const field = Reflect.get(value, key)
  174. return typeof field === "string" ? field : undefined
  175. }
  176. function assistantMessage(id: string) {
  177. return {
  178. id,
  179. type: "assistant",
  180. agent: "build",
  181. model: { providerID: "test", id: "test-model" },
  182. content: [],
  183. finish: "stop",
  184. tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
  185. time: { created: 1, completed: 2 },
  186. } satisfies SessionMessageInfo
  187. }