From c103202ad51a0bbc04cdf248694c3536cfb319bc Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 27 Apr 2026 19:48:57 -0400 Subject: [PATCH] test(httpapi): cover session json parity (#24682) --- packages/opencode/src/util/schema.ts | 4 +- .../test/server/httpapi-json-parity.test.ts | 127 ++++++++++++++++++ .../test/session/session-schema.test.ts | 25 +++- 3 files changed, 153 insertions(+), 3 deletions(-) create mode 100644 packages/opencode/test/server/httpapi-json-parity.test.ts diff --git a/packages/opencode/src/util/schema.ts b/packages/opencode/src/util/schema.ts index 1daab260fb..2a6c02349f 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 = (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 0000000000..728a8ffb27 --- /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(fx: Effect.Effect) { + return Effect.runPromise(fx.pipe(Effect.provide(Session.defaultLayer))) +} + +function pathFor(path: string, params: Record) { + 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, + 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 + httpapi: ReturnType + 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 cefe6e73af..38531d15b4 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, "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 + + expect(Object.hasOwn(encoded.summary as Record, "diffs")).toBe(false) + for (const key of ["partID", "snapshot", "diff"]) { + expect(Object.hasOwn(encoded.revert as Record, key)).toBe(false) + } + }) })