summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-04-27 19:48:57 -0400
committerGitHub <[email protected]>2026-04-27 19:48:57 -0400
commitc103202ad51a0bbc04cdf248694c3536cfb319bc (patch)
tree56f457abac98d6c604ea249d29ec60d7d0931957 /packages
parentce78a4265d7b01de6e414ab0ac3bbbcdd9ff226d (diff)
downloadopencode-c103202ad51a0bbc04cdf248694c3536cfb319bc.tar.gz
opencode-c103202ad51a0bbc04cdf248694c3536cfb319bc.zip
test(httpapi): cover session json parity (#24682)
Diffstat (limited to 'packages')
-rw-r--r--packages/opencode/src/util/schema.ts4
-rw-r--r--packages/opencode/test/server/httpapi-json-parity.test.ts127
-rw-r--r--packages/opencode/test/session/session-schema.test.ts25
3 files changed, 153 insertions, 3 deletions
diff --git a/packages/opencode/src/util/schema.ts b/packages/opencode/src/util/schema.ts
index 1daab260f..2a6c02349 100644
--- a/packages/opencode/src/util/schema.ts
+++ b/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))
/**
- * 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) =>
Schema.optionalKey(schema).pipe(
diff --git a/packages/opencode/test/server/httpapi-json-parity.test.ts b/packages/opencode/test/server/httpapi-json-parity.test.ts
new file mode 100644
index 000000000..728a8ffb2
--- /dev/null
+++ b/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(),
+ )
+ })
+})
diff --git a/packages/opencode/test/session/session-schema.test.ts b/packages/opencode/test/session/session-schema.test.ts
index cefe6e73a..38531d15b 100644
--- a/packages/opencode/test/session/session-schema.test.ts
+++ b/packages/opencode/test/session/session-schema.test.ts
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
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"
const info = {
@@ -50,4 +50,27 @@ describe("Session schema", () => {
expect(Object.hasOwn(encoded, "parentID")).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)
+ }
+ })
})