promise.test.ts 9.0 KB

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