promise.test.ts 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
  1. import { expect, test } from "bun:test"
  2. import { isSessionNotFoundError, isUnauthorizedError, OpenCode } from "../src"
  3. test("sessions.get returns the wire projection", async () => {
  4. const client = OpenCode.make({
  5. baseUrl: "http://localhost:3000",
  6. fetch: async (input) => {
  7. expect(typeof input === "string" ? input : input instanceof URL ? input.href : input.url).toBe(
  8. "http://localhost:3000/api/session/ses_test",
  9. )
  10. return Response.json(session)
  11. },
  12. })
  13. const result = await client.sessions.get({ sessionID: "ses_test" })
  14. expect(result.time.created).toBe(1_717_171_717_000)
  15. })
  16. test("events.subscribe exposes the Promise event stream wire projection", async () => {
  17. const client = OpenCode.make({
  18. baseUrl: "http://localhost:3000",
  19. fetch: async () =>
  20. new Response(
  21. `: heartbeat\n\ndata: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n` +
  22. `data: ${JSON.stringify(modelSwitchedEvent)}\n\n`,
  23. { headers: { "content-type": "text/event-stream" } },
  24. ),
  25. })
  26. const events = []
  27. for await (const event of client.events.subscribe()) events.push(event)
  28. expect(events).toEqual([{ id: "evt_connected", type: "server.connected", data: {} }, modelSwitchedEvent])
  29. expect(events[1]?.type === "session.next.model.switched" && events[1].data.timestamp).toBe(1_717_171_717_000)
  30. })
  31. test("events.subscribe terminates on malformed Promise SSE data", async () => {
  32. const client = OpenCode.make({
  33. baseUrl: "http://localhost:3000",
  34. fetch: async () => new Response("data: {not-json}\n\n", { headers: { "content-type": "text/event-stream" } }),
  35. })
  36. await expect(client.events.subscribe()[Symbol.asyncIterator]().next()).rejects.toMatchObject({
  37. name: "ClientError",
  38. reason: "MalformedResponse",
  39. })
  40. })
  41. test("session methods use the public HTTP contract", async () => {
  42. const requests: Array<{ url: string; init?: RequestInit }> = []
  43. let historyPage = 0
  44. const client = OpenCode.make({
  45. baseUrl: "http://localhost:3000",
  46. fetch: async (input, init) => {
  47. const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
  48. requests.push({ url, init })
  49. if (url.includes("/event")) {
  50. return new Response(`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`, {
  51. headers: { "content-type": "text/event-stream" },
  52. })
  53. }
  54. if (url.includes("/history")) {
  55. historyPage++
  56. return Response.json(
  57. historyPage === 1 ? { data: [modelSwitchedEvent], hasMore: true } : { data: [], hasMore: false },
  58. )
  59. }
  60. if (url.includes("/prompt")) return Response.json(admission)
  61. if (url.includes("/context")) return Response.json({ data: [] })
  62. if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage })
  63. if (url.endsWith("/api/session/active")) return Response.json({ data: { ses_test: { type: "running" } } })
  64. if (init?.method === "POST" && url.endsWith("/api/session")) return Response.json(session)
  65. if (init?.method === "POST") return new Response(null, { status: 204 })
  66. return Response.json({ data: [session.data], cursor: { next: "next" } })
  67. },
  68. })
  69. const page = await client.sessions.list({ limit: 10, order: "desc" })
  70. const active = await client.sessions.active()
  71. const created = await client.sessions.create({ location: { directory: "/tmp/project" } })
  72. await client.sessions.switchAgent({ sessionID: "ses_test", agent: "build" })
  73. await client.sessions.switchModel({
  74. sessionID: "ses_test",
  75. model: { id: "claude", providerID: "anthropic" },
  76. })
  77. const admitted = await client.sessions.prompt({
  78. sessionID: "ses_test",
  79. prompt: { text: "Hello" },
  80. resume: false,
  81. })
  82. await client.sessions.compact({ sessionID: "ses_test" })
  83. await client.sessions.wait({ sessionID: "ses_test" })
  84. const context = await client.sessions.context({ sessionID: "ses_test" })
  85. const history = await client.sessions.history({ sessionID: "ses_test", after: 0, limit: 1 })
  86. const historyAfter = history.data.at(-1)?.durable?.seq
  87. const historyNext = history.hasMore
  88. ? await client.sessions.history({ sessionID: "ses_test", after: historyAfter, limit: 2 })
  89. : undefined
  90. const events = []
  91. for await (const event of client.sessions.events({ sessionID: "ses_test", after: 0 })) events.push(event)
  92. await client.sessions.interrupt({ sessionID: "ses_test" })
  93. const message = await client.sessions.message({ sessionID: "ses_test", messageID: "msg_model" })
  94. expect(page.cursor.next).toBe("next")
  95. expect(active).toEqual({ ses_test: { type: "running" } })
  96. expect(created.id).toBe("ses_test")
  97. expect(admitted.id).toBe("msg_test")
  98. expect(context).toEqual([])
  99. expect(history).toEqual({ data: [modelSwitchedEvent], hasMore: true })
  100. expect(historyNext).toEqual({ data: [], hasMore: false })
  101. expect(events).toEqual([modelSwitchedEvent])
  102. expect(message).toEqual(modelSwitchedMessage)
  103. expect(requests.map((request) => [request.init?.method, request.url])).toEqual([
  104. ["GET", "http://localhost:3000/api/session?limit=10&order=desc"],
  105. ["GET", "http://localhost:3000/api/session/active"],
  106. ["POST", "http://localhost:3000/api/session"],
  107. ["POST", "http://localhost:3000/api/session/ses_test/agent"],
  108. ["POST", "http://localhost:3000/api/session/ses_test/model"],
  109. ["POST", "http://localhost:3000/api/session/ses_test/prompt"],
  110. ["POST", "http://localhost:3000/api/session/ses_test/compact"],
  111. ["POST", "http://localhost:3000/api/session/ses_test/wait"],
  112. ["GET", "http://localhost:3000/api/session/ses_test/context"],
  113. ["GET", "http://localhost:3000/api/session/ses_test/history?limit=1&after=0"],
  114. ["GET", "http://localhost:3000/api/session/ses_test/history?limit=2&after=1"],
  115. ["GET", "http://localhost:3000/api/session/ses_test/event?after=0"],
  116. ["POST", "http://localhost:3000/api/session/ses_test/interrupt"],
  117. ["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"],
  118. ])
  119. const body = requests.find((request) => request.url.endsWith("/api/session/ses_test/prompt"))?.init?.body
  120. if (typeof body !== "string") throw new Error("Expected JSON request body")
  121. expect(JSON.parse(body)).toEqual({
  122. prompt: { text: "Hello" },
  123. resume: false,
  124. })
  125. })
  126. test("middleware errors remain declared client errors", async () => {
  127. const client = OpenCode.make({
  128. baseUrl: "http://localhost:3000",
  129. fetch: async () =>
  130. Response.json({ _tag: "UnauthorizedError", message: "Authentication required" }, { status: 401 }),
  131. })
  132. try {
  133. await client.sessions.create({})
  134. throw new Error("Expected request to fail")
  135. } catch (error) {
  136. expect(isUnauthorizedError(error)).toBe(true)
  137. }
  138. })
  139. test("sessions.history decodes SessionNotFoundError", async () => {
  140. const client = OpenCode.make({
  141. baseUrl: "http://localhost:3000",
  142. fetch: async () =>
  143. Response.json(
  144. { _tag: "SessionNotFoundError", sessionID: "ses_missing", message: "Session not found" },
  145. { status: 404 },
  146. ),
  147. })
  148. try {
  149. await client.sessions.history({ sessionID: "ses_missing" })
  150. throw new Error("Expected request to fail")
  151. } catch (error) {
  152. expect(isSessionNotFoundError(error)).toBe(true)
  153. }
  154. })
  155. const session = {
  156. data: {
  157. id: "ses_test",
  158. projectID: "project",
  159. cost: 0,
  160. tokens: {
  161. input: 1,
  162. output: 2,
  163. reasoning: 3,
  164. cache: { read: 4, write: 5 },
  165. },
  166. time: {
  167. created: 1_717_171_717_000,
  168. updated: 1_717_171_717_000,
  169. },
  170. title: "Test",
  171. location: { directory: "/tmp/project" },
  172. },
  173. }
  174. const admission = {
  175. data: {
  176. admittedSeq: 0,
  177. id: "msg_test",
  178. sessionID: "ses_test",
  179. prompt: { text: "Hello" },
  180. delivery: "steer",
  181. timeCreated: 1_717_171_717_000,
  182. },
  183. }
  184. const modelSwitchedMessage = {
  185. id: "msg_model",
  186. type: "model-switched",
  187. time: { created: 1_717_171_717_000 },
  188. model: { id: "claude", providerID: "anthropic" },
  189. }
  190. const modelSwitchedEvent = {
  191. id: "evt_model",
  192. type: "session.next.model.switched",
  193. durable: { aggregateID: "ses_test", seq: 1, version: 1 },
  194. data: {
  195. timestamp: 1_717_171_717_000,
  196. sessionID: "ses_test",
  197. messageID: "msg_model",
  198. model: { id: "claude", providerID: "anthropic" },
  199. },
  200. }