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

@@ -37,6 +37,8 @@ const ConsoleSwitchBody = z.object({
orgID: z.string(), orgID: z.string(),
}) })
const QueryBoolean = z.enum(["true", "false"]).transform((value) => value === "true")
export const ExperimentalRoutes = lazy(() => export const ExperimentalRoutes = lazy(() =>
new Hono() new Hono()
.get( .get(
@@ -346,7 +348,7 @@ export const ExperimentalRoutes = lazy(() =>
"query", "query",
z.object({ z.object({
directory: z.string().optional().meta({ description: "Filter sessions by project directory" }), directory: z.string().optional().meta({ description: "Filter sessions by project directory" }),
roots: z.coerce.boolean().optional().meta({ description: "Only return root sessions (no parentID)" }), roots: QueryBoolean.optional().meta({ description: "Only return root sessions (no parentID)" }),
start: z.coerce start: z.coerce
.number() .number()
.optional() .optional()
@@ -357,7 +359,7 @@ export const ExperimentalRoutes = lazy(() =>
.meta({ description: "Return sessions updated before this timestamp (milliseconds since epoch)" }), .meta({ description: "Return sessions updated before this timestamp (milliseconds since epoch)" }),
search: z.string().optional().meta({ description: "Filter sessions by title (case-insensitive)" }), search: z.string().optional().meta({ description: "Filter sessions by title (case-insensitive)" }),
limit: z.coerce.number().optional().meta({ description: "Maximum number of sessions to return" }), limit: z.coerce.number().optional().meta({ description: "Maximum number of sessions to return" }),
archived: z.coerce.boolean().optional().meta({ description: "Include archived sessions (default false)" }), archived: QueryBoolean.optional().meta({ description: "Include archived sessions (default false)" }),
}), }),
), ),
async (c) => { async (c) => {

View File

@@ -10,7 +10,7 @@ import { Session } from "@/session/session"
import { ToolRegistry } from "@/tool/registry" import { ToolRegistry } from "@/tool/registry"
import * as EffectZod from "@/util/effect-zod" import * as EffectZod from "@/util/effect-zod"
import { Worktree } from "@/worktree" import { Worktree } from "@/worktree"
import { Effect, Layer, Option, Schema } from "effect" import { Effect, Layer, Option, Schema, SchemaGetter } from "effect"
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse" import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth" import { Authorization } from "./auth"
@@ -51,15 +51,21 @@ const ToolListQuery = Schema.Struct({
model: ModelID, model: ModelID,
}) })
const QueryBoolean = Schema.Literals(["true", "false"]).pipe(
Schema.decodeTo(Schema.Boolean, {
decode: SchemaGetter.transform((value) => value === "true"),
encode: SchemaGetter.transform((value) => (value ? "true" : "false")),
}),
)
const WorktreeList = Schema.Array(Schema.String).annotate({ identifier: "WorktreeList" }) const WorktreeList = Schema.Array(Schema.String).annotate({ identifier: "WorktreeList" })
const SessionListQuery = Schema.Struct({ const SessionListQuery = Schema.Struct({
directory: Schema.optional(Schema.String), directory: Schema.optional(Schema.String),
roots: Schema.optional(Schema.Literals(["true", "false"])), roots: Schema.optional(QueryBoolean),
start: Schema.optional(Schema.NumberFromString), start: Schema.optional(Schema.NumberFromString),
cursor: Schema.optional(Schema.NumberFromString), cursor: Schema.optional(Schema.NumberFromString),
search: Schema.optional(Schema.String), search: Schema.optional(Schema.String),
limit: Schema.optional(Schema.NumberFromString), limit: Schema.optional(Schema.NumberFromString),
archived: Schema.optional(Schema.Literals(["true", "false"])), archived: Schema.optional(QueryBoolean),
}) })
export const ExperimentalPaths = { export const ExperimentalPaths = {
@@ -307,12 +313,12 @@ export const experimentalHandlers = Layer.unwrap(
const sessions = Array.from( const sessions = Array.from(
Session.listGlobal({ Session.listGlobal({
directory: ctx.query.directory, directory: ctx.query.directory,
roots: ctx.query.roots === "true" ? true : undefined, roots: ctx.query.roots,
start: ctx.query.start, start: ctx.query.start,
cursor: ctx.query.cursor, cursor: ctx.query.cursor,
search: ctx.query.search, search: ctx.query.search,
limit: limit + 1, limit: limit + 1,
archived: ctx.query.archived === "true" ? true : undefined, archived: ctx.query.archived,
}), }),
) )
const list = sessions.length > limit ? sessions.slice(0, limit) : sessions const list = sessions.length > limit ? sessions.slice(0, limit) : sessions

View File

@@ -21,7 +21,7 @@ import { MessageID, PartID, SessionID } from "@/session/schema"
import { Snapshot } from "@/snapshot" import { Snapshot } from "@/snapshot"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import { NamedError } from "@opencode-ai/core/util/error" import { NamedError } from "@opencode-ai/core/util/error"
import { Effect, Layer, Schema, Struct } from "effect" import { Effect, Layer, Schema, SchemaGetter, Struct } from "effect"
import * as Stream from "effect/Stream" import * as Stream from "effect/Stream"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { import {
@@ -37,9 +37,15 @@ import { Authorization } from "./auth"
const log = Log.create({ service: "server" }) const log = Log.create({ service: "server" })
const root = "/session" const root = "/session"
const QueryBoolean = Schema.Literals(["true", "false"]).pipe(
Schema.decodeTo(Schema.Boolean, {
decode: SchemaGetter.transform((value) => value === "true"),
encode: SchemaGetter.transform((value) => (value ? "true" : "false")),
}),
)
const ListQuery = Schema.Struct({ const ListQuery = Schema.Struct({
directory: Schema.optional(Schema.String), directory: Schema.optional(Schema.String),
roots: Schema.optional(Schema.Literals(["true", "false"])), roots: Schema.optional(QueryBoolean),
start: Schema.optional(Schema.NumberFromString), start: Schema.optional(Schema.NumberFromString),
search: Schema.optional(Schema.String), search: Schema.optional(Schema.String),
limit: Schema.optional(Schema.NumberFromString), limit: Schema.optional(Schema.NumberFromString),
@@ -436,7 +442,7 @@ export const sessionHandlers = Layer.unwrap(
Array.from( Array.from(
Session.list({ Session.list({
directory: ctx.query.directory, directory: ctx.query.directory,
roots: ctx.query.roots === "true" ? true : undefined, roots: ctx.query.roots,
start: ctx.query.start, start: ctx.query.start,
search: ctx.query.search, search: ctx.query.search,
limit: ctx.query.limit, limit: ctx.query.limit,
@@ -472,8 +478,8 @@ export const sessionHandlers = Layer.unwrap(
params: { sessionID: SessionID } params: { sessionID: SessionID }
query: typeof MessagesQuery.Type query: typeof MessagesQuery.Type
}) { }) {
if (ctx.query.before !== undefined && ctx.query.limit === undefined) return yield* new HttpApiError.BadRequest({}) if (ctx.query.before && ctx.query.limit === undefined) return yield* new HttpApiError.BadRequest({})
if (ctx.query.before !== undefined) { if (ctx.query.before) {
const before = ctx.query.before const before = ctx.query.before
yield* Effect.try({ yield* Effect.try({
try: () => MessageV2.cursor.decode(before), try: () => MessageV2.cursor.decode(before),

View File

@@ -30,6 +30,8 @@ import { jsonRequest, runRequest } from "./trace"
const log = Log.create({ service: "server" }) const log = Log.create({ service: "server" })
const QueryBoolean = z.enum(["true", "false"]).transform((value) => value === "true")
export const SessionRoutes = lazy(() => export const SessionRoutes = lazy(() =>
new Hono() new Hono()
.get( .get(
@@ -53,7 +55,7 @@ export const SessionRoutes = lazy(() =>
"query", "query",
z.object({ z.object({
directory: z.string().optional().meta({ description: "Filter sessions by project directory" }), directory: z.string().optional().meta({ description: "Filter sessions by project directory" }),
roots: z.coerce.boolean().optional().meta({ description: "Only return root sessions (no parentID)" }), roots: QueryBoolean.optional().meta({ description: "Only return root sessions (no parentID)" }),
start: z.coerce start: z.coerce
.number() .number()
.optional() .optional()

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 type { UpgradeWebSocket } from "hono/ws"
import { Effect } from "effect" import { Effect } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag" 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 { Session } from "@/session/session"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db" 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 }) void Log.init({ print: false })
@@ -23,70 +24,63 @@ function app(experimental: boolean) {
return InstanceRoutes(websocket) 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>) { function pathFor(path: string, params: Record<string, string>) {
return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), path) return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), path)
} }
async function seedSessions(directory: string) { const seedSessions = Effect.gen(function* () {
return await Instance.provide({ const svc = yield* Session.Service
directory, const parent = yield* svc.create({ title: "parent" })
fn: () => yield* svc.create({ title: "child", parentID: parent.id })
runSession( const message = yield* svc.updateMessage({
Effect.gen(function* () { id: MessageID.ascending(),
const svc = yield* Session.Service role: "user",
const parent = yield* svc.create({ title: "parent" }) sessionID: parent.id,
yield* svc.create({ title: "child", parentID: parent.id }) agent: "build",
const message = yield* svc.updateMessage({ model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
id: MessageID.ascending(), time: { created: Date.now() },
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 }
}),
),
}) })
} yield* svc.updatePart({
id: PartID.ascending(),
sessionID: parent.id,
messageID: message.id,
type: "text",
text: "hello",
})
return { parent, message }
})
async function readJson( function withTmp<A, E, R>(
label: string, options: Parameters<typeof tmpdir>[0],
app: ReturnType<typeof InstanceRoutes>, fn: (tmp: Awaited<ReturnType<typeof tmpdir>>) => Effect.Effect<A, E, R>,
directory: string,
path: string,
headers: HeadersInit,
) { ) {
const response = await Instance.provide({ return Effect.acquireRelease(
directory, Effect.promise(() => tmpdir(options)),
fn: () => app.request(path, { headers }), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
}) ).pipe(Effect.flatMap((tmp) => fn(tmp).pipe(provideInstance(tmp.path))))
if (response.status !== 200) throw new Error(`${label} returned ${response.status}: ${await response.text()}`)
return await response.json()
} }
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 label: string
legacy: ReturnType<typeof InstanceRoutes> legacy: ReturnType<typeof InstanceRoutes>
httpapi: ReturnType<typeof InstanceRoutes> httpapi: ReturnType<typeof InstanceRoutes>
directory: string
path: string path: string
headers: HeadersInit headers: HeadersInit
}) { }) {
const legacy = await readJson(input.label, input.legacy, input.directory, input.path, input.headers) return Effect.gen(function* () {
const httpapi = await readJson(input.label, input.httpapi, input.directory, input.path, input.headers) const legacy = yield* readJson(input.label, input.legacy, input.path, input.headers)
expect({ label: input.label, body: httpapi }).toEqual({ label: input.label, body: legacy }) 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 () => { afterEach(async () => {
@@ -96,32 +90,78 @@ afterEach(async () => {
}) })
describe("HttpApi JSON parity", () => { describe("HttpApi JSON parity", () => {
test("matches legacy JSON shape for session read endpoints", async () => { it.live(
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) "matches legacy JSON shape for session read endpoints",
const headers = { "x-opencode-directory": tmp.path } withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
const seeded = await seedSessions(tmp.path) Effect.gen(function* () {
const legacy = app(false) const headers = { "x-opencode-directory": tmp.path }
const httpapi = app(true) const seeded = yield* seedSessions.pipe(Effect.provide(Session.defaultLayer))
const legacy = app(false)
const httpapi = app(true)
await [ const rootsFalse = yield* expectJsonParity({
{ label: "session.list roots", path: `${SessionPaths.list}?roots=true`, headers }, label: "session.list roots false",
{ label: "session.list all", path: SessionPaths.list, headers }, legacy,
{ label: "session.get", path: pathFor(SessionPaths.get, { sessionID: seeded.parent.id }), headers }, httpapi,
{ label: "session.children", path: pathFor(SessionPaths.children, { sessionID: seeded.parent.id }), headers }, path: `${SessionPaths.list}?roots=false`,
{ label: "session.messages", path: pathFor(SessionPaths.messages, { sessionID: seeded.parent.id }), headers }, headers,
{ })
label: "session.message", expect((rootsFalse as Session.Info[]).map((session) => session.id)).toContain(seeded.parent.id)
path: pathFor(SessionPaths.message, { sessionID: seeded.parent.id, messageID: seeded.message.id }), expect((rootsFalse as Session.Info[]).length).toBe(2)
headers,
}, const experimentalRootsFalse = yield* expectJsonParity({
{ label: "experimental.session roots false",
label: "experimental.session", legacy,
path: `${ExperimentalPaths.session}?${new URLSearchParams({ directory: tmp.path, limit: "10" })}`, httpapi,
headers, path: `${ExperimentalPaths.session}?${new URLSearchParams({ directory: tmp.path, limit: "10", roots: "false" })}`,
}, headers,
].reduce( })
(promise, input) => promise.then(() => expectJsonParity({ ...input, legacy, httpapi, directory: tmp.path })), expect((experimentalRootsFalse as Session.GlobalInfo[]).length).toBe(2)
Promise.resolve(),
) 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 },
)
}),
),
)
}) })