httpapi-json-parity.test.ts 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  1. import { afterEach, describe, expect } from "bun:test"
  2. import type { UpgradeWebSocket } from "hono/ws"
  3. import { Effect } from "effect"
  4. import { Flag } from "@opencode-ai/core/flag/flag"
  5. import { ModelID, ProviderID } from "../../src/provider/schema"
  6. import { Instance } from "../../src/project/instance"
  7. import { InstanceRoutes } from "../../src/server/routes/instance"
  8. import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/experimental"
  9. import { SessionPaths } from "../../src/server/routes/instance/httpapi/session"
  10. import { MessageID, PartID } from "../../src/session/schema"
  11. import { Session } from "@/session/session"
  12. import * as Log from "@opencode-ai/core/util/log"
  13. import { resetDatabase } from "../fixture/db"
  14. import { provideInstance, tmpdir } from "../fixture/fixture"
  15. import { it } from "../lib/effect"
  16. void Log.init({ print: false })
  17. const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
  18. const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
  19. function app(experimental: boolean) {
  20. Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
  21. return InstanceRoutes(websocket)
  22. }
  23. function pathFor(path: string, params: Record<string, string>) {
  24. return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), path)
  25. }
  26. const seedSessions = Effect.gen(function* () {
  27. const svc = yield* Session.Service
  28. const parent = yield* svc.create({ title: "parent" })
  29. yield* svc.create({ title: "child", parentID: parent.id })
  30. const message = yield* svc.updateMessage({
  31. id: MessageID.ascending(),
  32. role: "user",
  33. sessionID: parent.id,
  34. agent: "build",
  35. model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
  36. time: { created: Date.now() },
  37. })
  38. yield* svc.updatePart({
  39. id: PartID.ascending(),
  40. sessionID: parent.id,
  41. messageID: message.id,
  42. type: "text",
  43. text: "hello",
  44. })
  45. return { parent, message }
  46. })
  47. function withTmp<A, E, R>(
  48. options: Parameters<typeof tmpdir>[0],
  49. fn: (tmp: Awaited<ReturnType<typeof tmpdir>>) => Effect.Effect<A, E, R>,
  50. ) {
  51. return Effect.acquireRelease(
  52. Effect.promise(() => tmpdir(options)),
  53. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  54. ).pipe(Effect.flatMap((tmp) => fn(tmp).pipe(provideInstance(tmp.path))))
  55. }
  56. function readJson(label: string, app: ReturnType<typeof InstanceRoutes>, path: string, headers: HeadersInit) {
  57. return Effect.promise(async () => {
  58. const response = await app.request(path, { headers })
  59. if (response.status !== 200) throw new Error(`${label} returned ${response.status}: ${await response.text()}`)
  60. return await response.json()
  61. })
  62. }
  63. function expectJsonParity(input: {
  64. label: string
  65. legacy: ReturnType<typeof InstanceRoutes>
  66. httpapi: ReturnType<typeof InstanceRoutes>
  67. path: string
  68. headers: HeadersInit
  69. }) {
  70. return Effect.gen(function* () {
  71. const legacy = yield* readJson(input.label, input.legacy, input.path, input.headers)
  72. const httpapi = yield* readJson(input.label, input.httpapi, input.path, input.headers)
  73. expect({ label: input.label, body: httpapi }).toEqual({ label: input.label, body: legacy })
  74. return httpapi
  75. })
  76. }
  77. afterEach(async () => {
  78. Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
  79. await Instance.disposeAll()
  80. await resetDatabase()
  81. })
  82. describe("HttpApi JSON parity", () => {
  83. it.live(
  84. "matches legacy JSON shape for session read endpoints",
  85. withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
  86. Effect.gen(function* () {
  87. const headers = { "x-opencode-directory": tmp.path }
  88. const seeded = yield* seedSessions.pipe(Effect.provide(Session.defaultLayer))
  89. const legacy = app(false)
  90. const httpapi = app(true)
  91. const rootsFalse = yield* expectJsonParity({
  92. label: "session.list roots false",
  93. legacy,
  94. httpapi,
  95. path: `${SessionPaths.list}?roots=false`,
  96. headers,
  97. })
  98. expect((rootsFalse as Session.Info[]).map((session) => session.id)).toContain(seeded.parent.id)
  99. expect((rootsFalse as Session.Info[]).length).toBe(2)
  100. const experimentalRootsFalse = yield* expectJsonParity({
  101. label: "experimental.session roots false",
  102. legacy,
  103. httpapi,
  104. path: `${ExperimentalPaths.session}?${new URLSearchParams({ directory: tmp.path, limit: "10", roots: "false" })}`,
  105. headers,
  106. })
  107. expect((experimentalRootsFalse as Session.GlobalInfo[]).length).toBe(2)
  108. const experimentalArchivedFalse = yield* expectJsonParity({
  109. label: "experimental.session archived false",
  110. legacy,
  111. httpapi,
  112. path: `${ExperimentalPaths.session}?${new URLSearchParams({ directory: tmp.path, limit: "10", archived: "false" })}`,
  113. headers,
  114. })
  115. expect((experimentalArchivedFalse as Session.GlobalInfo[]).length).toBe(2)
  116. yield* Effect.forEach(
  117. [
  118. { label: "session.list roots", path: `${SessionPaths.list}?roots=true`, headers },
  119. { label: "session.list all", path: SessionPaths.list, headers },
  120. { label: "session.get", path: pathFor(SessionPaths.get, { sessionID: seeded.parent.id }), headers },
  121. {
  122. label: "session.children",
  123. path: pathFor(SessionPaths.children, { sessionID: seeded.parent.id }),
  124. headers,
  125. },
  126. {
  127. label: "session.messages",
  128. path: pathFor(SessionPaths.messages, { sessionID: seeded.parent.id }),
  129. headers,
  130. },
  131. {
  132. label: "session.messages empty before",
  133. path: `${pathFor(SessionPaths.messages, { sessionID: seeded.parent.id })}?before=`,
  134. headers,
  135. },
  136. {
  137. label: "session.message",
  138. path: pathFor(SessionPaths.message, { sessionID: seeded.parent.id, messageID: seeded.message.id }),
  139. headers,
  140. },
  141. {
  142. label: "experimental.session",
  143. path: `${ExperimentalPaths.session}?${new URLSearchParams({ directory: tmp.path, limit: "10" })}`,
  144. headers,
  145. },
  146. ],
  147. (input) => expectJsonParity({ ...input, legacy, httpapi }),
  148. { concurrency: 1 },
  149. )
  150. }),
  151. ),
  152. )
  153. })