promise.test.ts 18 KB

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