fix(httpapi): align session boolean query parsing (#24693)

This commit is contained in:
Kit Langton
2026-04-27 20:53:27 -04:00
committed by GitHub
parent c103202ad5
commit faca24d487
5 changed files with 147 additions and 91 deletions

View File

@@ -1,4 +1,4 @@
import { afterEach, describe, expect, test } from "bun:test"
import { afterEach, describe, expect } from "bun:test"
import type { UpgradeWebSocket } from "hono/ws"
import { Effect } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag"
@@ -11,7 +11,8 @@ 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"
import { provideInstance, tmpdir } from "../fixture/fixture"
import { it } from "../lib/effect"
void Log.init({ print: false })
@@ -23,70 +24,63 @@ function app(experimental: boolean) {
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 }
}),
),
const seedSessions = 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,
function withTmp<A, E, R>(
options: Parameters<typeof tmpdir>[0],
fn: (tmp: Awaited<ReturnType<typeof tmpdir>>) => Effect.Effect<A, E, R>,
) {
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()
return Effect.acquireRelease(
Effect.promise(() => tmpdir(options)),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.flatMap((tmp) => fn(tmp).pipe(provideInstance(tmp.path))))
}
async function expectJsonParity(input: {
function readJson(label: string, app: ReturnType<typeof InstanceRoutes>, path: string, headers: HeadersInit) {
return Effect.promise(async () => {
const response = await app.request(path, { headers })
if (response.status !== 200) throw new Error(`${label} returned ${response.status}: ${await response.text()}`)
return await response.json()
})
}
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 })
return Effect.gen(function* () {
const legacy = yield* readJson(input.label, input.legacy, input.path, input.headers)
const httpapi = yield* readJson(input.label, input.httpapi, input.path, input.headers)
expect({ label: input.label, body: httpapi }).toEqual({ label: input.label, body: legacy })
return httpapi
})
}
afterEach(async () => {
@@ -96,32 +90,78 @@ afterEach(async () => {
})
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)
it.live(
"matches legacy JSON shape for session read endpoints",
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
Effect.gen(function* () {
const headers = { "x-opencode-directory": tmp.path }
const seeded = yield* seedSessions.pipe(Effect.provide(Session.defaultLayer))
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(),
)
})
const rootsFalse = yield* expectJsonParity({
label: "session.list roots false",
legacy,
httpapi,
path: `${SessionPaths.list}?roots=false`,
headers,
})
expect((rootsFalse as Session.Info[]).map((session) => session.id)).toContain(seeded.parent.id)
expect((rootsFalse as Session.Info[]).length).toBe(2)
const experimentalRootsFalse = yield* expectJsonParity({
label: "experimental.session roots false",
legacy,
httpapi,
path: `${ExperimentalPaths.session}?${new URLSearchParams({ directory: tmp.path, limit: "10", roots: "false" })}`,
headers,
})
expect((experimentalRootsFalse as Session.GlobalInfo[]).length).toBe(2)
const experimentalArchivedFalse = yield* expectJsonParity({
label: "experimental.session archived false",
legacy,
httpapi,
path: `${ExperimentalPaths.session}?${new URLSearchParams({ directory: tmp.path, limit: "10", archived: "false" })}`,
headers,
})
expect((experimentalArchivedFalse as Session.GlobalInfo[]).length).toBe(2)
yield* Effect.forEach(
[
{ 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.messages empty before",
path: `${pathFor(SessionPaths.messages, { sessionID: seeded.parent.id })}?before=`,
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,
},
],
(input) => expectJsonParity({ ...input, legacy, httpapi }),
{ concurrency: 1 },
)
}),
),
)
})