Type session not-found errors (#25818)
This commit is contained in:
@@ -105,23 +105,22 @@ describe("404 mapping for missing session", () => {
|
||||
})
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Reproducer 3: 404 response body shape should match Hono's NamedError
|
||||
// envelope `{ name, data: { message } }`. HttpApi returns the typed-error
|
||||
// shape `{ _tag }` instead. SDK consumers reading `error.data.message`
|
||||
// see undefined.
|
||||
//
|
||||
// FIXME: unskip when error JSON shape policy is decided + applied (separate PR).
|
||||
// Reproducer 3: 404 response body shape should match Hono's public NamedError
|
||||
// envelope `{ name, data: { message } }`. SDK consumers read
|
||||
// `error.data.message`, so returning an Effect built-in `{ _tag }` body is a
|
||||
// compatibility break.
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe("Error JSON shape parity", () => {
|
||||
test.todo("HttpApi 404 body matches NamedError shape", async () => {
|
||||
test("HttpApi 404 body matches Hono shape", async () => {
|
||||
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
|
||||
const headers = { "x-opencode-directory": tmp.path }
|
||||
|
||||
const response = await app(true).request("/session/ses_does_not_exist", {
|
||||
headers: { "x-opencode-directory": tmp.path },
|
||||
})
|
||||
const hono = await app(false).request("/session/ses_does_not_exist", { headers })
|
||||
const httpapi = await app(true).request("/session/ses_does_not_exist", { headers })
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
const body = (await response.json()) as { name?: string; data?: { message?: string } }
|
||||
expect(httpapi.status).toBe(hono.status)
|
||||
const body = (await httpapi.json()) as { name?: string; data?: { message?: string } }
|
||||
expect(body).toEqual(await hono.json())
|
||||
expect(body.name).toBe("NotFoundError")
|
||||
expect(typeof body.data?.message).toBe("string")
|
||||
})
|
||||
|
||||
@@ -50,9 +50,9 @@ const effectIt = testEffect(
|
||||
),
|
||||
)
|
||||
|
||||
function app() {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
return Server.Default().app
|
||||
function app(experimental = true) {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
|
||||
return experimental ? Server.Default().app : Server.Legacy().app
|
||||
}
|
||||
|
||||
function serverUrl() {
|
||||
@@ -121,6 +121,18 @@ describe("pty HttpApi bridge", () => {
|
||||
expect(missing.status).toBe(404)
|
||||
})
|
||||
|
||||
test("matches Hono missing PTY error body", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const headers = { "x-opencode-directory": tmp.path }
|
||||
const path = PtyPaths.get.replace(":ptyID", PtyID.ascending())
|
||||
|
||||
const hono = await app(false).request(path, { headers })
|
||||
const httpapi = await app().request(path, { headers })
|
||||
|
||||
expect(httpapi.status).toBe(hono.status)
|
||||
expect(await httpapi.json()).toEqual(await hono.json())
|
||||
})
|
||||
|
||||
test("returns 404 for missing PTY websocket before upgrade", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const response = await app().request(PtyPaths.connect.replace(":ptyID", PtyID.ascending()), {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type * as Scope from "effect/Scope"
|
||||
import { HttpRouter } from "effect/unstable/http"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { validateSession } from "../../src/cli/cmd/tui/validate-session"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
|
||||
@@ -13,6 +14,7 @@ import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import type { Config } from "@/config/config"
|
||||
import { Session as SessionNs } from "@/session/session"
|
||||
import { errorMessage } from "../../src/util/error"
|
||||
import { TestLLMServer } from "../lib/llm-server"
|
||||
import path from "path"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
@@ -64,20 +66,23 @@ function client(
|
||||
directory?: string,
|
||||
input?: { password?: string; username?: string; headers?: Record<string, string> },
|
||||
) {
|
||||
const serverApp = app(backend, input)
|
||||
const fetch = Object.assign(
|
||||
async (request: RequestInfo | URL, init?: RequestInit) =>
|
||||
await serverApp.fetch(request instanceof Request ? request : new Request(request, init)),
|
||||
{ preconnect: globalThis.fetch.preconnect },
|
||||
) satisfies typeof globalThis.fetch
|
||||
return createOpencodeClient({
|
||||
baseUrl: "http://localhost",
|
||||
directory,
|
||||
headers: input?.headers,
|
||||
fetch,
|
||||
fetch: serverFetch(backend, input),
|
||||
})
|
||||
}
|
||||
|
||||
function serverFetch(backend: Backend, input?: { password?: string; username?: string }) {
|
||||
const serverApp = app(backend, input)
|
||||
return Object.assign(
|
||||
async (request: RequestInfo | URL, init?: RequestInit) =>
|
||||
await serverApp.fetch(request instanceof Request ? request : new Request(request, init)),
|
||||
{ preconnect: globalThis.fetch.preconnect },
|
||||
) satisfies typeof globalThis.fetch
|
||||
}
|
||||
|
||||
function authorization(username: string, password: string) {
|
||||
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
|
||||
}
|
||||
@@ -129,6 +134,16 @@ function capture(request: () => Promise<SdkResult>) {
|
||||
)
|
||||
}
|
||||
|
||||
function captureThrown(request: () => Promise<unknown>) {
|
||||
return call(async () => {
|
||||
try {
|
||||
await request()
|
||||
} catch (error) {
|
||||
return error
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function expectStatus(request: () => Promise<{ response: Response }>, status: number) {
|
||||
return call(request).pipe(
|
||||
Effect.tap((result) => Effect.sync(() => expect(result.response.status).toBe(status))),
|
||||
@@ -338,6 +353,46 @@ describe("HttpApi SDK", () => {
|
||||
),
|
||||
)
|
||||
|
||||
parity("matches generated SDK missing session errors across backends", (backend) =>
|
||||
withStandardProject(backend, ({ sdk }) =>
|
||||
Effect.gen(function* () {
|
||||
const sessionID = "ses_missing"
|
||||
const expected = {
|
||||
name: "NotFoundError",
|
||||
data: { message: `Session not found: ${sessionID}` },
|
||||
}
|
||||
const missing = yield* capture(() => sdk.session.get({ sessionID }))
|
||||
const thrown = yield* captureThrown(() => sdk.session.get({ sessionID }, { throwOnError: true }))
|
||||
|
||||
expect(missing.error).toEqual(expected)
|
||||
expect(thrown).toEqual(expected)
|
||||
return {
|
||||
status: missing.status,
|
||||
error: missing.error,
|
||||
thrown,
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
parity("formats missing session validation errors for -s", (backend) =>
|
||||
withStandardProject(backend, ({ directory }) =>
|
||||
Effect.gen(function* () {
|
||||
const sessionID = "ses_206f84f18ffeZ6hhD7pFYAiW5T"
|
||||
const thrown = yield* captureThrown(() =>
|
||||
validateSession({
|
||||
url: "http://localhost",
|
||||
directory,
|
||||
sessionID,
|
||||
fetch: serverFetch(backend),
|
||||
}),
|
||||
)
|
||||
expect(errorMessage(thrown)).toBe(`Session not found: ${sessionID}`)
|
||||
return errorMessage(thrown)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
parity("matches generated SDK basic auth behavior across backends", (backend) =>
|
||||
withStandardProject(backend, ({ directory }) =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -8,13 +8,12 @@ import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
||||
import { Workspace } from "../../src/control-plane/workspace"
|
||||
import { PermissionID } from "../../src/permission/schema"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { Project } from "../../src/project/project"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
|
||||
import { Session } from "@/session/session"
|
||||
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
|
||||
import { MessageID, PartID, SessionID, type SessionID as SessionIDType } from "../../src/session/schema"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { Database } from "@/storage/db"
|
||||
import { SessionMessageTable, SessionTable } from "@/session/session.sql"
|
||||
@@ -55,7 +54,7 @@ function createSession(directory: string, input?: Session.CreateInput) {
|
||||
)
|
||||
}
|
||||
|
||||
function createTextMessage(directory: string, sessionID: SessionID, text: string) {
|
||||
function createTextMessage(directory: string, sessionID: SessionIDType, text: string) {
|
||||
return Effect.promise(
|
||||
async () =>
|
||||
await WithInstance.provide({
|
||||
@@ -125,6 +124,10 @@ function json<T>(response: Response) {
|
||||
})
|
||||
}
|
||||
|
||||
function responseJson(response: Response) {
|
||||
return Effect.promise(() => response.json())
|
||||
}
|
||||
|
||||
function requestJson<T>(path: string, init?: RequestInit) {
|
||||
return request(path, init).pipe(Effect.flatMap(json<T>))
|
||||
}
|
||||
@@ -147,6 +150,47 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe("session HttpApi", () => {
|
||||
it.live(
|
||||
"returns declared not found errors for read routes",
|
||||
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const headers = { "x-opencode-directory": tmp.path }
|
||||
const missingSession = SessionID.descending()
|
||||
const missingSessionBody = {
|
||||
name: "NotFoundError",
|
||||
data: { message: `Session not found: ${missingSession}` },
|
||||
}
|
||||
|
||||
const get = yield* request(pathFor(SessionPaths.get, { sessionID: missingSession }), { headers })
|
||||
expect(get.status).toBe(404)
|
||||
expect(yield* responseJson(get)).toEqual(missingSessionBody)
|
||||
|
||||
const messages = yield* request(pathFor(SessionPaths.messages, { sessionID: missingSession }), { headers })
|
||||
expect(messages.status).toBe(404)
|
||||
expect(yield* responseJson(messages)).toEqual(missingSessionBody)
|
||||
|
||||
const remove = yield* request(pathFor(SessionPaths.remove, { sessionID: missingSession }), {
|
||||
headers,
|
||||
method: "DELETE",
|
||||
})
|
||||
expect(remove.status).toBe(404)
|
||||
expect(yield* responseJson(remove)).toEqual(missingSessionBody)
|
||||
|
||||
const session = yield* createSession(tmp.path, { title: "missing message" })
|
||||
const missingMessage = MessageID.ascending()
|
||||
const message = yield* request(
|
||||
pathFor(SessionPaths.message, { sessionID: session.id, messageID: missingMessage }),
|
||||
{ headers },
|
||||
)
|
||||
expect(message.status).toBe(404)
|
||||
expect(yield* responseJson(message)).toEqual({
|
||||
name: "NotFoundError",
|
||||
data: { message: `Message not found: ${missingMessage}` },
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"serves read routes through Hono bridge",
|
||||
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
|
||||
|
||||
@@ -72,14 +72,27 @@ describe("tui HttpApi bridge", () => {
|
||||
properties: { text: "from publish" },
|
||||
})
|
||||
|
||||
const missingSessionID = SessionID.descending()
|
||||
const missing = await app().request(TuiPaths.selectSession, {
|
||||
method: "POST",
|
||||
headers: { ...headers, "content-type": "application/json" },
|
||||
body: JSON.stringify({ sessionID: SessionID.descending() }),
|
||||
body: JSON.stringify({ sessionID: missingSessionID }),
|
||||
})
|
||||
expect(missing.status).toBe(404)
|
||||
})
|
||||
|
||||
test("matches Hono missing selected session error body", 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 body = JSON.stringify({ sessionID: SessionID.descending() })
|
||||
|
||||
const hono = await app(false).request(TuiPaths.selectSession, { method: "POST", headers, body })
|
||||
const httpapi = await app().request(TuiPaths.selectSession, { method: "POST", headers, body })
|
||||
|
||||
expect(httpapi.status).toBe(hono.status)
|
||||
expect(await httpapi.json()).toEqual(await hono.json())
|
||||
})
|
||||
|
||||
test("matches legacy unknown execute command behavior", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
|
||||
|
||||
Reference in New Issue
Block a user