Skip to content

Commit c103202

Browse files
authored
test(httpapi): cover session json parity (#24682)
1 parent ce78a42 commit c103202

3 files changed

Lines changed: 153 additions & 3 deletions

File tree

packages/opencode/src/util/schema.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ export const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0))
1212
export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))
1313

1414
/**
15-
* Optional public JSON field that accepts explicit `undefined` internally but
16-
* encodes it as an omitted key, matching `JSON.stringify` legacy responses.
15+
* Optional public JSON field that can hold explicit `undefined` on the type
16+
* side but encodes it as an omitted key, matching legacy `JSON.stringify`.
1717
*/
1818
export const optionalOmitUndefined = <S extends Schema.Top>(schema: S) =>
1919
Schema.optionalKey(schema).pipe(
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { afterEach, describe, expect, test } 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 { tmpdir } from "../fixture/fixture"
15+
16+
void Log.init({ print: false })
17+
18+
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
19+
const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
20+
21+
function app(experimental: boolean) {
22+
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
23+
return InstanceRoutes(websocket)
24+
}
25+
26+
function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
27+
return Effect.runPromise(fx.pipe(Effect.provide(Session.defaultLayer)))
28+
}
29+
30+
function pathFor(path: string, params: Record<string, string>) {
31+
return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), path)
32+
}
33+
34+
async function seedSessions(directory: string) {
35+
return await Instance.provide({
36+
directory,
37+
fn: () =>
38+
runSession(
39+
Effect.gen(function* () {
40+
const svc = yield* Session.Service
41+
const parent = yield* svc.create({ title: "parent" })
42+
yield* svc.create({ title: "child", parentID: parent.id })
43+
const message = yield* svc.updateMessage({
44+
id: MessageID.ascending(),
45+
role: "user",
46+
sessionID: parent.id,
47+
agent: "build",
48+
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
49+
time: { created: Date.now() },
50+
})
51+
yield* svc.updatePart({
52+
id: PartID.ascending(),
53+
sessionID: parent.id,
54+
messageID: message.id,
55+
type: "text",
56+
text: "hello",
57+
})
58+
return { parent, message }
59+
}),
60+
),
61+
})
62+
}
63+
64+
async function readJson(
65+
label: string,
66+
app: ReturnType<typeof InstanceRoutes>,
67+
directory: string,
68+
path: string,
69+
headers: HeadersInit,
70+
) {
71+
const response = await Instance.provide({
72+
directory,
73+
fn: () => app.request(path, { headers }),
74+
})
75+
if (response.status !== 200) throw new Error(`${label} returned ${response.status}: ${await response.text()}`)
76+
return await response.json()
77+
}
78+
79+
async function expectJsonParity(input: {
80+
label: string
81+
legacy: ReturnType<typeof InstanceRoutes>
82+
httpapi: ReturnType<typeof InstanceRoutes>
83+
directory: string
84+
path: string
85+
headers: HeadersInit
86+
}) {
87+
const legacy = await readJson(input.label, input.legacy, input.directory, input.path, input.headers)
88+
const httpapi = await readJson(input.label, input.httpapi, input.directory, input.path, input.headers)
89+
expect({ label: input.label, body: httpapi }).toEqual({ label: input.label, body: legacy })
90+
}
91+
92+
afterEach(async () => {
93+
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
94+
await Instance.disposeAll()
95+
await resetDatabase()
96+
})
97+
98+
describe("HttpApi JSON parity", () => {
99+
test("matches legacy JSON shape for session read endpoints", async () => {
100+
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
101+
const headers = { "x-opencode-directory": tmp.path }
102+
const seeded = await seedSessions(tmp.path)
103+
const legacy = app(false)
104+
const httpapi = app(true)
105+
106+
await [
107+
{ label: "session.list roots", path: `${SessionPaths.list}?roots=true`, headers },
108+
{ label: "session.list all", path: SessionPaths.list, headers },
109+
{ label: "session.get", path: pathFor(SessionPaths.get, { sessionID: seeded.parent.id }), headers },
110+
{ label: "session.children", path: pathFor(SessionPaths.children, { sessionID: seeded.parent.id }), headers },
111+
{ label: "session.messages", path: pathFor(SessionPaths.messages, { sessionID: seeded.parent.id }), headers },
112+
{
113+
label: "session.message",
114+
path: pathFor(SessionPaths.message, { sessionID: seeded.parent.id, messageID: seeded.message.id }),
115+
headers,
116+
},
117+
{
118+
label: "experimental.session",
119+
path: `${ExperimentalPaths.session}?${new URLSearchParams({ directory: tmp.path, limit: "10" })}`,
120+
headers,
121+
},
122+
].reduce(
123+
(promise, input) => promise.then(() => expectJsonParity({ ...input, legacy, httpapi, directory: tmp.path })),
124+
Promise.resolve(),
125+
)
126+
})
127+
})

packages/opencode/test/session/session-schema.test.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, expect, test } from "bun:test"
22
import { Schema } from "effect"
33
import { ProjectID } from "../../src/project/schema"
4-
import { SessionID } from "../../src/session/schema"
4+
import { MessageID, SessionID } from "../../src/session/schema"
55
import { Session } from "../../src/session/session"
66

77
const info = {
@@ -50,4 +50,27 @@ describe("Session schema", () => {
5050
expect(Object.hasOwn(encoded, "parentID")).toBe(false)
5151
expect(Object.hasOwn(encoded.project as Record<string, unknown>, "name")).toBe(false)
5252
})
53+
54+
test("encodes nested undefined optional session fields as omitted keys", () => {
55+
const encoded = Schema.encodeUnknownSync(Session.Info)({
56+
...info,
57+
summary: {
58+
additions: 1,
59+
deletions: 2,
60+
files: 3,
61+
diffs: undefined,
62+
},
63+
revert: {
64+
messageID: MessageID.ascending(),
65+
partID: undefined,
66+
snapshot: undefined,
67+
diff: undefined,
68+
},
69+
}) as Record<string, unknown>
70+
71+
expect(Object.hasOwn(encoded.summary as Record<string, unknown>, "diffs")).toBe(false)
72+
for (const key of ["partID", "snapshot", "diff"]) {
73+
expect(Object.hasOwn(encoded.revert as Record<string, unknown>, key)).toBe(false)
74+
}
75+
})
5376
})

0 commit comments

Comments
 (0)