Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions packages/opencode/src/server/routes/instance/experimental.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ const ConsoleSwitchBody = z.object({
orgID: z.string(),
})

const QueryBoolean = z.enum(["true", "false"]).transform((value) => value === "true")

export const ExperimentalRoutes = lazy(() =>
new Hono()
.get(
Expand Down Expand Up @@ -346,7 +348,7 @@ export const ExperimentalRoutes = lazy(() =>
"query",
z.object({
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
.number()
.optional()
Expand All @@ -357,7 +359,7 @@ export const ExperimentalRoutes = lazy(() =>
.meta({ description: "Return sessions updated before this timestamp (milliseconds since epoch)" }),
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" }),
archived: z.coerce.boolean().optional().meta({ description: "Include archived sessions (default false)" }),
archived: QueryBoolean.optional().meta({ description: "Include archived sessions (default false)" }),
}),
),
async (c) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { Session } from "@/session/session"
import { ToolRegistry } from "@/tool/registry"
import * as EffectZod from "@/util/effect-zod"
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 { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
Expand Down Expand Up @@ -51,15 +51,21 @@ const ToolListQuery = Schema.Struct({
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 SessionListQuery = Schema.Struct({
directory: Schema.optional(Schema.String),
roots: Schema.optional(Schema.Literals(["true", "false"])),
roots: Schema.optional(QueryBoolean),
start: Schema.optional(Schema.NumberFromString),
cursor: Schema.optional(Schema.NumberFromString),
search: Schema.optional(Schema.String),
limit: Schema.optional(Schema.NumberFromString),
archived: Schema.optional(Schema.Literals(["true", "false"])),
archived: Schema.optional(QueryBoolean),
})

export const ExperimentalPaths = {
Expand Down Expand Up @@ -307,12 +313,12 @@ export const experimentalHandlers = Layer.unwrap(
const sessions = Array.from(
Session.listGlobal({
directory: ctx.query.directory,
roots: ctx.query.roots === "true" ? true : undefined,
roots: ctx.query.roots,
start: ctx.query.start,
cursor: ctx.query.cursor,
search: ctx.query.search,
limit: limit + 1,
archived: ctx.query.archived === "true" ? true : undefined,
archived: ctx.query.archived,
}),
)
const list = sessions.length > limit ? sessions.slice(0, limit) : sessions
Expand Down
16 changes: 11 additions & 5 deletions packages/opencode/src/server/routes/instance/httpapi/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { MessageID, PartID, SessionID } from "@/session/schema"
import { Snapshot } from "@/snapshot"
import * as Log from "@opencode-ai/core/util/log"
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 { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import {
Expand All @@ -37,9 +37,15 @@ import { Authorization } from "./auth"

const log = Log.create({ service: "server" })
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({
directory: Schema.optional(Schema.String),
roots: Schema.optional(Schema.Literals(["true", "false"])),
roots: Schema.optional(QueryBoolean),
start: Schema.optional(Schema.NumberFromString),
search: Schema.optional(Schema.String),
limit: Schema.optional(Schema.NumberFromString),
Expand Down Expand Up @@ -436,7 +442,7 @@ export const sessionHandlers = Layer.unwrap(
Array.from(
Session.list({
directory: ctx.query.directory,
roots: ctx.query.roots === "true" ? true : undefined,
roots: ctx.query.roots,
start: ctx.query.start,
search: ctx.query.search,
limit: ctx.query.limit,
Expand Down Expand Up @@ -472,8 +478,8 @@ export const sessionHandlers = Layer.unwrap(
params: { sessionID: SessionID }
query: typeof MessagesQuery.Type
}) {
if (ctx.query.before !== undefined && ctx.query.limit === undefined) return yield* new HttpApiError.BadRequest({})
if (ctx.query.before !== undefined) {
if (ctx.query.before && ctx.query.limit === undefined) return yield* new HttpApiError.BadRequest({})
if (ctx.query.before) {
const before = ctx.query.before
yield* Effect.try({
try: () => MessageV2.cursor.decode(before),
Expand Down
4 changes: 3 additions & 1 deletion packages/opencode/src/server/routes/instance/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import { jsonRequest, runRequest } from "./trace"

const log = Log.create({ service: "server" })

const QueryBoolean = z.enum(["true", "false"]).transform((value) => value === "true")

export const SessionRoutes = lazy(() =>
new Hono()
.get(
Expand All @@ -53,7 +55,7 @@ export const SessionRoutes = lazy(() =>
"query",
z.object({
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
.number()
.optional()
Expand Down
194 changes: 117 additions & 77 deletions packages/opencode/test/server/httpapi-json-parity.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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 })

Expand All @@ -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 }),
return Effect.acquireRelease(
Effect.promise(() => tmpdir(options)),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.flatMap((tmp) => fn(tmp).pipe(provideInstance(tmp.path))))
}

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()
})
if (response.status !== 200) throw new Error(`${label} returned ${response.status}: ${await response.text()}`)
return await response.json()
}

async function expectJsonParity(input: {
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 () => {
Expand All @@ -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 },
)
}),
),
)
})
Loading