promise.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
  1. import { expect, test } from "bun:test"
  2. import { isSessionNotFoundError, isUnauthorizedError, OpenCode } from "../src/promise/index"
  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. "agent",
  9. "plugin",
  10. "session",
  11. "message",
  12. "model",
  13. "generate",
  14. "provider",
  15. "integration",
  16. "server.mcp",
  17. "credential",
  18. "project",
  19. "form",
  20. "permission",
  21. "file",
  22. "command",
  23. "skill",
  24. "event",
  25. "pty",
  26. "shell",
  27. "question",
  28. "reference",
  29. "projectCopy",
  30. "vcs",
  31. "debug",
  32. ])
  33. expect(Object.keys(client.debug)).toEqual(["location"])
  34. expect(Object.keys(client.debug.location)).toEqual(["list", "evict"])
  35. expect(Object.keys(client.message)).toEqual(["list"])
  36. expect(Object.keys(client.integration)).toEqual(["list", "get", "connect", "attempt"])
  37. expect(Object.keys(client.integration.connect)).toEqual(["key", "oauth"])
  38. expect(Object.keys(client.integration.attempt)).toEqual(["status", "complete", "cancel"])
  39. expect(Object.keys(client.file)).toEqual(["read", "list", "find"])
  40. expect(Object.keys(client.vcs)).toEqual(["status", "diff"])
  41. expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"])
  42. expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "timeout", "output", "remove"])
  43. expect(Object.keys(client.project)).toEqual(["list", "current", "directories"])
  44. })
  45. test("MCP resource catalog uses the public HTTP contract", async () => {
  46. let request: Request | undefined
  47. const client = OpenCode.make({
  48. baseUrl: "http://localhost:3000",
  49. fetch: async (input) => {
  50. request = input instanceof Request ? input : new Request(input)
  51. return Response.json({
  52. location: { directory: "/tmp/project", project: { id: "proj_test", directory: "/tmp/project" } },
  53. data: {
  54. resources: [{ server: "docs", name: "Readme", uri: "docs://readme" }],
  55. templates: [{ server: "docs", name: "File", uriTemplate: "docs://{path}" }],
  56. },
  57. })
  58. },
  59. })
  60. const result = await client["server.mcp"].resource.catalog({ location: { directory: "/tmp/project" } })
  61. expect(result.data.resources[0]?.uri).toBe("docs://readme")
  62. expect(request?.method).toBe("GET")
  63. expect(request?.url).toBe("http://localhost:3000/api/mcp/resource?location%5Bdirectory%5D=%2Ftmp%2Fproject")
  64. })
  65. test("file.read returns binary content from the public HTTP contract", async () => {
  66. let request: Request | undefined
  67. const client = OpenCode.make({
  68. baseUrl: "http://localhost:3000",
  69. fetch: async (input) => {
  70. request = input instanceof Request ? input : new Request(input)
  71. return new Response(new Uint8Array([104, 105]))
  72. },
  73. })
  74. const content = await client.file.read({
  75. path: "src/a b#c.ts",
  76. location: { directory: "/tmp/project" },
  77. })
  78. expect(Array.from(content)).toEqual([104, 105])
  79. expect(request?.url).toBe(
  80. "http://localhost:3000/api/fs/read/src/a%20b%23c.ts?location%5Bdirectory%5D=%2Ftmp%2Fproject",
  81. )
  82. })
  83. test("project methods use the public HTTP contract", async () => {
  84. const requests: string[] = []
  85. const client = OpenCode.make({
  86. baseUrl: "http://localhost:3000",
  87. fetch: async (input) => {
  88. const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
  89. requests.push(url)
  90. if (url.includes("/directories")) return Response.json([])
  91. return Response.json({ id: "proj_test", directory: "/tmp/project" })
  92. },
  93. })
  94. const current = await client.project.current({ location: { workspace: "wrk_test" } })
  95. const directories = await client.project.directories({
  96. projectID: current.id,
  97. location: { directory: current.directory },
  98. })
  99. expect(current).toEqual({ id: "proj_test", directory: "/tmp/project" })
  100. expect(directories).toEqual([])
  101. expect(requests).toEqual([
  102. "http://localhost:3000/api/project/current?location%5Bworkspace%5D=wrk_test",
  103. "http://localhost:3000/api/project/proj_test/directories?location%5Bdirectory%5D=%2Ftmp%2Fproject",
  104. ])
  105. })
  106. test("shell list and remove use the public HTTP contract", async () => {
  107. const requests: Array<{ method: string; url: string }> = []
  108. const shell = {
  109. id: "sh_test",
  110. status: "running",
  111. command: "pwd",
  112. cwd: "/tmp/project",
  113. shell: "/bin/zsh",
  114. file: "/tmp/opencode-shell",
  115. metadata: { sessionID: "ses_test" },
  116. time: { started: 1_717_171_717_000 },
  117. }
  118. const client = OpenCode.make({
  119. baseUrl: "http://localhost:3000",
  120. fetch: async (input, init) => {
  121. const request = input instanceof Request ? input : new Request(input, init)
  122. requests.push({ method: request.method, url: request.url })
  123. if (request.method === "DELETE") return new Response(null, { status: 204 })
  124. return Response.json({
  125. location: { directory: "/tmp/project", project: { id: "proj_test", directory: "/tmp/project" } },
  126. data: [shell],
  127. })
  128. },
  129. })
  130. const result = await client.shell.list({ location: { directory: "/tmp/project" } })
  131. await client.shell.remove({ id: shell.id })
  132. expect(result.data).toEqual([shell])
  133. expect(requests).toEqual([
  134. { method: "GET", url: "http://localhost:3000/api/shell?location%5Bdirectory%5D=%2Ftmp%2Fproject" },
  135. { method: "DELETE", url: "http://localhost:3000/api/shell/sh_test" },
  136. ])
  137. })
  138. test("session.get returns the wire projection", async () => {
  139. const client = OpenCode.make({
  140. baseUrl: "http://localhost:3000",
  141. fetch: async (input) => {
  142. expect(typeof input === "string" ? input : input instanceof URL ? input.href : input.url).toBe(
  143. "http://localhost:3000/api/session/ses_test",
  144. )
  145. return Response.json(session)
  146. },
  147. })
  148. const result = await client.session.get({ sessionID: "ses_test" })
  149. expect(result.time.created).toBe(1_717_171_717_000)
  150. })
  151. test("session instructions methods use the public HTTP contract", async () => {
  152. const requests: Array<{ method: string; url: string; body?: unknown }> = []
  153. const instructions = [{ key: "review-notes", value: { text: "Check the diff", priority: 1 } }]
  154. const client = OpenCode.make({
  155. baseUrl: "http://localhost:3000",
  156. fetch: async (input, init) => {
  157. const request = input instanceof Request ? input : new Request(input, init)
  158. requests.push({
  159. method: request.method,
  160. url: request.url,
  161. body: request.method === "PUT" ? await request.json() : undefined,
  162. })
  163. if (request.method === "GET") return Response.json({ data: instructions })
  164. return new Response(null, { status: 204 })
  165. },
  166. })
  167. const result = await client.session.instructions.entry.list({ sessionID: "ses_test" })
  168. await client.session.instructions.entry.put({
  169. sessionID: "ses_test",
  170. key: "review-notes",
  171. value: instructions[0].value,
  172. })
  173. await client.session.instructions.entry.remove({ sessionID: "ses_test", key: "review-notes" })
  174. expect(result).toEqual(instructions)
  175. expect(requests).toEqual([
  176. {
  177. method: "GET",
  178. url: "http://localhost:3000/api/session/ses_test/instructions/entries",
  179. body: undefined,
  180. },
  181. {
  182. method: "PUT",
  183. url: "http://localhost:3000/api/session/ses_test/instructions/entries/review-notes",
  184. body: { value: { text: "Check the diff", priority: 1 } },
  185. },
  186. {
  187. method: "DELETE",
  188. url: "http://localhost:3000/api/session/ses_test/instructions/entries/review-notes",
  189. body: undefined,
  190. },
  191. ])
  192. })
  193. test("event.subscribe exposes the Promise event stream wire projection", async () => {
  194. const client = OpenCode.make({
  195. baseUrl: "http://localhost:3000",
  196. fetch: async () =>
  197. new Response(
  198. `: heartbeat\n\ndata: ${JSON.stringify({ id: "evt_connected", created: 0, type: "server.connected", data: {} })}\n\n` +
  199. `data: ${JSON.stringify(modelSwitchedEvent)}\n\n`,
  200. { headers: { "content-type": "text/event-stream" } },
  201. ),
  202. })
  203. const events = []
  204. for await (const event of client.event.subscribe()) events.push(event)
  205. expect(events).toEqual([{ id: "evt_connected", created: 0, type: "server.connected", data: {} }, modelSwitchedEvent])
  206. expect(events[1]?.type === "session.model.selected" && events[1].created).toBe(1_717_171_717_000)
  207. })
  208. test("event.subscribe terminates on malformed Promise SSE data", async () => {
  209. const client = OpenCode.make({
  210. baseUrl: "http://localhost:3000",
  211. fetch: async () => new Response("data: {not-json}\n\n", { headers: { "content-type": "text/event-stream" } }),
  212. })
  213. await expect(client.event.subscribe()[Symbol.asyncIterator]().next()).rejects.toMatchObject({
  214. name: "ClientError",
  215. reason: "MalformedResponse",
  216. })
  217. })
  218. test("session methods use the public HTTP contract", async () => {
  219. const requests: Array<{ url: string; init?: RequestInit }> = []
  220. const client = OpenCode.make({
  221. baseUrl: "http://localhost:3000",
  222. fetch: async (input, init) => {
  223. const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
  224. requests.push({ url, init })
  225. if (url.includes("/event")) {
  226. return new Response(`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`, {
  227. headers: { "content-type": "text/event-stream" },
  228. })
  229. }
  230. if (url.includes("/log")) {
  231. return new Response(`data: ${JSON.stringify(modelSwitchedEvent)}\n\ndata: ${JSON.stringify(synced)}\n\n`, {
  232. headers: { "content-type": "text/event-stream" },
  233. })
  234. }
  235. if (url.includes("/prompt")) return Response.json(admission)
  236. if (url.endsWith("/compact")) return Response.json(compactionAdmission)
  237. if (url.includes("/context")) return Response.json({ data: [] })
  238. if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage })
  239. if (url.endsWith("/api/session/active")) return Response.json({ data: { ses_test: { type: "running" } } })
  240. if (init?.method === "POST" && url.endsWith("/api/session")) return Response.json(session)
  241. if (init?.method === "POST") return new Response(null, { status: 204 })
  242. return Response.json({ data: [session.data], cursor: { next: "next" } })
  243. },
  244. })
  245. const page = await client.session.list({ limit: 10, order: "desc", parentID: null })
  246. const active = await client.session.active()
  247. const created = await client.session.create({ location: { directory: "/tmp/project" } })
  248. await client.session.switchAgent({ sessionID: "ses_test", agent: "build" })
  249. await client.session.switchModel({
  250. sessionID: "ses_test",
  251. model: { id: "claude", providerID: "anthropic" },
  252. })
  253. const admitted = await client.session.prompt({
  254. sessionID: "ses_test",
  255. prompt: { text: "Hello" },
  256. resume: false,
  257. })
  258. await client.session.compact({ sessionID: "ses_test" })
  259. await client.session.wait({ sessionID: "ses_test" })
  260. const context = await client.session.context({ sessionID: "ses_test" })
  261. const log = []
  262. for await (const item of client.session.log({ sessionID: "ses_test", after: 0 })) log.push(item)
  263. await client.session.interrupt({ sessionID: "ses_test" })
  264. const message = await client.session.message({ sessionID: "ses_test", messageID: "msg_model" })
  265. expect(page.cursor.next).toBe("next")
  266. expect(active).toEqual({ ses_test: { type: "running" } })
  267. expect(created.id).toBe("ses_test")
  268. expect(admitted.id).toBe("msg_test")
  269. expect(context).toEqual([])
  270. expect(log).toEqual([modelSwitchedEvent, synced])
  271. expect(message).toEqual(modelSwitchedMessage)
  272. expect(requests.map((request) => [request.init?.method, request.url])).toEqual([
  273. ["GET", "http://localhost:3000/api/session?limit=10&order=desc&parentID=null"],
  274. ["GET", "http://localhost:3000/api/session/active"],
  275. ["POST", "http://localhost:3000/api/session"],
  276. ["POST", "http://localhost:3000/api/session/ses_test/agent"],
  277. ["POST", "http://localhost:3000/api/session/ses_test/model"],
  278. ["POST", "http://localhost:3000/api/session/ses_test/prompt"],
  279. ["POST", "http://localhost:3000/api/session/ses_test/compact"],
  280. ["POST", "http://localhost:3000/api/session/ses_test/wait"],
  281. ["GET", "http://localhost:3000/api/session/ses_test/context"],
  282. ["GET", "http://localhost:3000/api/experimental/session/ses_test/log?after=0"],
  283. ["POST", "http://localhost:3000/api/session/ses_test/interrupt"],
  284. ["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"],
  285. ])
  286. const body = requests.find((request) => request.url.endsWith("/api/session/ses_test/prompt"))?.init?.body
  287. if (typeof body !== "string") throw new Error("Expected JSON request body")
  288. expect(JSON.parse(body)).toEqual({
  289. prompt: { text: "Hello" },
  290. resume: false,
  291. })
  292. })
  293. test("middleware errors remain declared client errors", async () => {
  294. const client = OpenCode.make({
  295. baseUrl: "http://localhost:3000",
  296. fetch: async () =>
  297. Response.json({ _tag: "UnauthorizedError", message: "Authentication required" }, { status: 401 }),
  298. })
  299. try {
  300. await client.session.create({})
  301. throw new Error("Expected request to fail")
  302. } catch (error) {
  303. expect(isUnauthorizedError(error)).toBe(true)
  304. }
  305. })
  306. test("session.log decodes SessionNotFoundError", async () => {
  307. const client = OpenCode.make({
  308. baseUrl: "http://localhost:3000",
  309. fetch: async () =>
  310. Response.json(
  311. { _tag: "SessionNotFoundError", sessionID: "ses_missing", message: "Session not found" },
  312. { status: 404 },
  313. ),
  314. })
  315. try {
  316. await client.session.log({ sessionID: "ses_missing" })[Symbol.asyncIterator]().next()
  317. throw new Error("Expected request to fail")
  318. } catch (error) {
  319. expect(isSessionNotFoundError(error)).toBe(true)
  320. }
  321. })
  322. const session = {
  323. data: {
  324. id: "ses_test",
  325. projectID: "project",
  326. cost: 0,
  327. tokens: {
  328. input: 1,
  329. output: 2,
  330. reasoning: 3,
  331. cache: { read: 4, write: 5 },
  332. },
  333. time: {
  334. created: 1_717_171_717_000,
  335. updated: 1_717_171_717_000,
  336. },
  337. title: "Test",
  338. location: { directory: "/tmp/project" },
  339. },
  340. }
  341. const admission = {
  342. data: {
  343. admittedSeq: 0,
  344. id: "msg_test",
  345. sessionID: "ses_test",
  346. prompt: { text: "Hello" },
  347. delivery: "steer",
  348. timeCreated: 1_717_171_717_000,
  349. },
  350. }
  351. const compactionAdmission = {
  352. data: {
  353. type: "compaction",
  354. admittedSeq: 1,
  355. id: "msg_compaction",
  356. sessionID: "ses_test",
  357. timeCreated: 1_717_171_717_000,
  358. },
  359. }
  360. const modelSwitchedMessage = {
  361. id: "msg_model",
  362. type: "model-switched",
  363. time: { created: 1_717_171_717_000 },
  364. model: { id: "claude", providerID: "anthropic" },
  365. }
  366. const synced = { type: "log.synced", aggregateID: "ses_test", seq: 1 }
  367. const modelSwitchedEvent = {
  368. id: "evt_model",
  369. created: 1_717_171_717_000,
  370. type: "session.model.selected",
  371. durable: { aggregateID: "ses_test", seq: 1, version: 1 },
  372. data: {
  373. sessionID: "ses_test",
  374. model: { id: "claude", providerID: "anthropic" },
  375. },
  376. }