Sfoglia il codice sorgente

test(httpapi): cover session json parity (#24682)

Kit Langton 3 mesi fa
parent
commit
c103202ad5

+ 2 - 2
packages/opencode/src/util/schema.ts

@@ -12,8 +12,8 @@ export const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0))
 export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))
 export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))
 
 
 /**
 /**
- * Optional public JSON field that accepts explicit `undefined` internally but
- * encodes it as an omitted key, matching `JSON.stringify` legacy responses.
+ * Optional public JSON field that can hold explicit `undefined` on the type
+ * side but encodes it as an omitted key, matching legacy `JSON.stringify`.
  */
  */
 export const optionalOmitUndefined = <S extends Schema.Top>(schema: S) =>
 export const optionalOmitUndefined = <S extends Schema.Top>(schema: S) =>
   Schema.optionalKey(schema).pipe(
   Schema.optionalKey(schema).pipe(

+ 127 - 0
packages/opencode/test/server/httpapi-json-parity.test.ts

@@ -0,0 +1,127 @@
+import { afterEach, describe, expect, test } from "bun:test"
+import type { UpgradeWebSocket } from "hono/ws"
+import { Effect } from "effect"
+import { Flag } from "@opencode-ai/core/flag/flag"
+import { ModelID, ProviderID } from "../../src/provider/schema"
+import { Instance } from "../../src/project/instance"
+import { InstanceRoutes } from "../../src/server/routes/instance"
+import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/experimental"
+import { SessionPaths } from "../../src/server/routes/instance/httpapi/session"
+import { MessageID, PartID } from "../../src/session/schema"
+import { Session } from "@/session/session"
+import * as Log from "@opencode-ai/core/util/log"
+import { resetDatabase } from "../fixture/db"
+import { tmpdir } from "../fixture/fixture"
+
+void Log.init({ print: false })
+
+const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
+const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
+
+function app(experimental: boolean) {
+  Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
+  return InstanceRoutes(websocket)
+}
+
+function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
+  return Effect.runPromise(fx.pipe(Effect.provide(Session.defaultLayer)))
+}
+
+function pathFor(path: string, params: Record<string, string>) {
+  return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), path)
+}
+
+async function seedSessions(directory: string) {
+  return await Instance.provide({
+    directory,
+    fn: () =>
+      runSession(
+        Effect.gen(function* () {
+          const svc = yield* Session.Service
+          const parent = yield* svc.create({ title: "parent" })
+          yield* svc.create({ title: "child", parentID: parent.id })
+          const message = yield* svc.updateMessage({
+            id: MessageID.ascending(),
+            role: "user",
+            sessionID: parent.id,
+            agent: "build",
+            model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
+            time: { created: Date.now() },
+          })
+          yield* svc.updatePart({
+            id: PartID.ascending(),
+            sessionID: parent.id,
+            messageID: message.id,
+            type: "text",
+            text: "hello",
+          })
+          return { parent, message }
+        }),
+      ),
+  })
+}
+
+async function readJson(
+  label: string,
+  app: ReturnType<typeof InstanceRoutes>,
+  directory: string,
+  path: string,
+  headers: HeadersInit,
+) {
+  const response = await Instance.provide({
+    directory,
+    fn: () => app.request(path, { headers }),
+  })
+  if (response.status !== 200) throw new Error(`${label} returned ${response.status}: ${await response.text()}`)
+  return await response.json()
+}
+
+async function expectJsonParity(input: {
+  label: string
+  legacy: ReturnType<typeof InstanceRoutes>
+  httpapi: ReturnType<typeof InstanceRoutes>
+  directory: string
+  path: string
+  headers: HeadersInit
+}) {
+  const legacy = await readJson(input.label, input.legacy, input.directory, input.path, input.headers)
+  const httpapi = await readJson(input.label, input.httpapi, input.directory, input.path, input.headers)
+  expect({ label: input.label, body: httpapi }).toEqual({ label: input.label, body: legacy })
+}
+
+afterEach(async () => {
+  Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
+  await Instance.disposeAll()
+  await resetDatabase()
+})
+
+describe("HttpApi JSON parity", () => {
+  test("matches legacy JSON shape for session read endpoints", async () => {
+    await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
+    const headers = { "x-opencode-directory": tmp.path }
+    const seeded = await seedSessions(tmp.path)
+    const legacy = app(false)
+    const httpapi = app(true)
+
+    await [
+      { label: "session.list roots", path: `${SessionPaths.list}?roots=true`, headers },
+      { label: "session.list all", path: SessionPaths.list, headers },
+      { label: "session.get", path: pathFor(SessionPaths.get, { sessionID: seeded.parent.id }), headers },
+      { label: "session.children", path: pathFor(SessionPaths.children, { sessionID: seeded.parent.id }), headers },
+      { label: "session.messages", path: pathFor(SessionPaths.messages, { sessionID: seeded.parent.id }), headers },
+      {
+        label: "session.message",
+        path: pathFor(SessionPaths.message, { sessionID: seeded.parent.id, messageID: seeded.message.id }),
+        headers,
+      },
+      {
+        label: "experimental.session",
+        path: `${ExperimentalPaths.session}?${new URLSearchParams({ directory: tmp.path, limit: "10" })}`,
+        headers,
+      },
+    ].reduce(
+      (promise, input) => promise.then(() => expectJsonParity({ ...input, legacy, httpapi, directory: tmp.path })),
+      Promise.resolve(),
+    )
+  })
+})

+ 24 - 1
packages/opencode/test/session/session-schema.test.ts

@@ -1,7 +1,7 @@
 import { describe, expect, test } from "bun:test"
 import { describe, expect, test } from "bun:test"
 import { Schema } from "effect"
 import { Schema } from "effect"
 import { ProjectID } from "../../src/project/schema"
 import { ProjectID } from "../../src/project/schema"
-import { SessionID } from "../../src/session/schema"
+import { MessageID, SessionID } from "../../src/session/schema"
 import { Session } from "../../src/session/session"
 import { Session } from "../../src/session/session"
 
 
 const info = {
 const info = {
@@ -50,4 +50,27 @@ describe("Session schema", () => {
     expect(Object.hasOwn(encoded, "parentID")).toBe(false)
     expect(Object.hasOwn(encoded, "parentID")).toBe(false)
     expect(Object.hasOwn(encoded.project as Record<string, unknown>, "name")).toBe(false)
     expect(Object.hasOwn(encoded.project as Record<string, unknown>, "name")).toBe(false)
   })
   })
+
+  test("encodes nested undefined optional session fields as omitted keys", () => {
+    const encoded = Schema.encodeUnknownSync(Session.Info)({
+      ...info,
+      summary: {
+        additions: 1,
+        deletions: 2,
+        files: 3,
+        diffs: undefined,
+      },
+      revert: {
+        messageID: MessageID.ascending(),
+        partID: undefined,
+        snapshot: undefined,
+        diff: undefined,
+      },
+    }) as Record<string, unknown>
+
+    expect(Object.hasOwn(encoded.summary as Record<string, unknown>, "diffs")).toBe(false)
+    for (const key of ["partID", "snapshot", "diff"]) {
+      expect(Object.hasOwn(encoded.revert as Record<string, unknown>, key)).toBe(false)
+    }
+  })
 })
 })