Compare commits

...

2 Commits

Author SHA1 Message Date
Kit Langton 964101d07c fix(httpapi): document tui bad request responses 2026-04-27 22:39:45 -04:00
Kit Langton 5ae2ba3d92 fix(httpapi): align sync seq validation 2026-04-27 22:35:42 -04:00
4 changed files with 68 additions and 7 deletions
@@ -10,14 +10,15 @@ import { or } from "drizzle-orm"
import { SyncEvent } from "@/sync"
import { EventTable } from "@/sync/event.sql"
import { Effect, Layer, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
const root = "/sync"
const Seq = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))
const ReplayEvent = Schema.Struct({
id: Schema.String,
aggregateID: Schema.String,
seq: Schema.Number,
seq: Seq,
type: Schema.String,
data: Schema.Record(Schema.String, Schema.Unknown),
}).annotate({ identifier: "SyncReplayEvent" })
@@ -28,7 +29,7 @@ const ReplayPayload = Schema.Struct({
const ReplayResponse = Schema.Struct({
sessionID: Schema.String,
}).annotate({ identifier: "SyncReplayResponse" })
const HistoryPayload = Schema.Record(Schema.String, Schema.Number)
const HistoryPayload = Schema.Record(Schema.String, Seq)
const HistoryEvent = Schema.Struct({
id: Schema.String,
aggregate_id: Schema.String,
@@ -59,6 +60,7 @@ export const SyncApi = HttpApi.make("sync")
HttpApiEndpoint.post("replay", SyncPaths.replay, {
payload: ReplayPayload,
success: ReplayResponse,
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "sync.replay",
@@ -69,6 +71,7 @@ export const SyncApi = HttpApi.make("sync")
HttpApiEndpoint.post("history", SyncPaths.history, {
payload: HistoryPayload,
success: Schema.Array(HistoryEvent),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "sync.history.list",
@@ -61,6 +61,7 @@ export const TuiApi = HttpApi.make("tui")
HttpApiEndpoint.post("appendPrompt", TuiPaths.appendPrompt, {
payload: TuiEvent.PromptAppend.properties,
success: Schema.Boolean,
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "tui.appendPrompt",
@@ -113,6 +114,7 @@ export const TuiApi = HttpApi.make("tui")
HttpApiEndpoint.post("executeCommand", TuiPaths.executeCommand, {
payload: CommandPayload,
success: Schema.Boolean,
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "tui.executeCommand",
@@ -133,6 +135,7 @@ export const TuiApi = HttpApi.make("tui")
HttpApiEndpoint.post("publish", TuiPaths.publish, {
payload: TuiPublishPayload,
success: Schema.Boolean,
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "tui.publish",
@@ -143,7 +146,7 @@ export const TuiApi = HttpApi.make("tui")
HttpApiEndpoint.post("selectSession", TuiPaths.selectSession, {
payload: TuiEvent.SessionSelect.properties,
success: Schema.Boolean,
error: HttpApiError.NotFound,
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "tui.selectSession",
@@ -16,8 +16,8 @@ const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
function app() {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
function app(httpapi = true) {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = httpapi
return InstanceRoutes(websocket)
}
@@ -81,4 +81,48 @@ describe("sync HttpApi", () => {
expect(replayed.status).toBe(200)
expect(await replayed.json()).toEqual({ sessionID: session.id })
})
test("matches legacy seq validation", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
const cases = [
{
path: SyncPaths.history,
body: { aggregate: -1 },
},
{
path: SyncPaths.history,
body: { aggregate: 1.5 },
},
{
path: SyncPaths.replay,
body: {
directory: tmp.path,
events: [{ id: "event", aggregateID: "session", seq: -1, type: "session.created", data: {} }],
},
},
{
path: SyncPaths.replay,
body: {
directory: tmp.path,
events: [{ id: "event", aggregateID: "session", seq: 1.5, type: "session.created", data: {} }],
},
},
]
for (const item of cases) {
const legacy = await app(false).request(item.path, {
method: "POST",
headers,
body: JSON.stringify(item.body),
})
const httpapi = await app(true).request(item.path, {
method: "POST",
headers,
body: JSON.stringify(item.body),
})
expect(httpapi.status).toBe(legacy.status)
expect(httpapi.status).toBe(400)
}
})
})
@@ -5,9 +5,11 @@ import { Flag } from "@opencode-ai/core/flag/flag"
import { SessionID } from "../../src/session/schema"
import { Instance } from "../../src/project/instance"
import { InstanceRoutes } from "../../src/server/routes/instance"
import { TuiPaths } from "../../src/server/routes/instance/httpapi/tui"
import { TuiApi, TuiPaths } from "../../src/server/routes/instance/httpapi/tui"
import { callTui } from "../../src/server/routes/instance/tui"
import { Server } from "../../src/server/server"
import * as Log from "@opencode-ai/core/util/log"
import { OpenApi } from "effect/unstable/httpapi"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
@@ -38,6 +40,15 @@ afterEach(async () => {
})
describe("tui HttpApi bridge", () => {
test("documents legacy bad request responses", async () => {
const legacy = await Server.openapi()
const effect = OpenApi.fromApi(TuiApi)
for (const path of [TuiPaths.appendPrompt, TuiPaths.executeCommand, TuiPaths.publish, TuiPaths.selectSession]) {
expect(legacy.paths[path].post?.responses?.[400]).toBeDefined()
expect(effect.paths[path].post?.responses?.[400]).toBeDefined()
}
})
test("serves TUI command and event routes through experimental Effect routes", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const headers = { "x-opencode-directory": tmp.path }