mirror of
https://github.com/anomalyco/opencode.git
synced 2026-06-01 19:05:38 +00:00
fix(httpapi): align session boolean query parsing (#24693)
This commit is contained in:
@@ -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) => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user