Compare commits

...

1 Commits

Author SHA1 Message Date
Kit Langton
98fef45553 fix(httpapi): 404 status + body shape for missing-session errors
Two related divergences from Hono are fixed in one move:

1. Status: many session handlers (todo, diff, summarize, fork, abort,
   init, deleteMessage, command, shell, revert, unrevert) didn't wrap
   with mapNotFound, so a thrown NotFoundError surfaced as a 500 defect
   instead of a 404. The fork/diff endpoints also lacked OpencodeNotFound
   in their declared error union, so handlers couldn't surface 404 even
   if they wanted to.

2. Body shape: the existing mapNotFound rebrand to HttpApiError.NotFound
   produced an empty 404 response. Hono returns the NamedError envelope
   `{ name: "NotFoundError", data: { message } }`. SDK consumers reading
   `error.data.message` got undefined.

The fix introduces OpencodeNotFound — a Schema.ErrorClass annotated with
`httpApiStatus: 404` and a body schema matching the legacy NamedError
shape. mapNotFound now rebrands NotFoundError to OpencodeNotFound,
preserving the underlying error message. All session endpoints that take
a sessionID now wrap their service calls with mapNotFound.

A TODO in the handler notes the long-term direction: services should
fail with typed errors directly (Effect<T, SessionNotFound>) and let
HttpApi auto-route status + body via schema annotations, eliminating
mapNotFound entirely. This PR is the pragmatic middle: small surface,
no service-layer changes, fixes the user-visible parity bug.

Unskips the two .todo parity reproducers in httpapi-parity.test.ts.
2026-05-02 23:33:14 -04:00
4 changed files with 126 additions and 86 deletions

View File

@@ -0,0 +1,21 @@
import { Schema } from "effect"
/**
* 404 Not Found error matching the legacy Hono `NamedError` JSON shape:
* `{ name: "NotFoundError", data: { message } }`.
*
* `httpApiStatus: 404` annotation drives the response status; the schema
* fields drive the response body. Use this in place of
* `HttpApiError.NotFound` (which has an empty body) anywhere SDK clients
* may inspect `error.data.message`.
*/
export class OpencodeNotFound extends Schema.ErrorClass<OpencodeNotFound>("opencode/Error/NotFound")(
{
name: Schema.tag("NotFoundError"),
data: Schema.Struct({ message: Schema.String }),
},
{
description: "Not found",
httpApiStatus: 404,
},
) {}

View File

@@ -12,6 +12,7 @@ import { MessageID, PartID, SessionID } from "@/session/schema"
import { Snapshot } from "@/snapshot"
import { Schema, SchemaGetter, Struct } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { OpencodeNotFound } from "../errors"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
@@ -123,7 +124,7 @@ export const SessionApi = HttpApi.make("session")
HttpApiEndpoint.get("get", SessionPaths.get, {
params: { sessionID: SessionID },
success: described(Session.Info, "Get session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, OpencodeNotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.get",
@@ -134,7 +135,7 @@ export const SessionApi = HttpApi.make("session")
HttpApiEndpoint.get("children", SessionPaths.children, {
params: { sessionID: SessionID },
success: described(Schema.Array(Session.Info), "List of children"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, OpencodeNotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.children",
@@ -145,7 +146,7 @@ export const SessionApi = HttpApi.make("session")
HttpApiEndpoint.get("todo", SessionPaths.todo, {
params: { sessionID: SessionID },
success: described(Schema.Array(Todo.Info), "Todo list"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, OpencodeNotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.todo",
@@ -157,6 +158,7 @@ export const SessionApi = HttpApi.make("session")
params: { sessionID: SessionID },
query: DiffQuery,
success: described(Schema.Array(Snapshot.FileDiff), "Successfully retrieved diff"),
error: [HttpApiError.BadRequest, OpencodeNotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.diff",
@@ -168,7 +170,7 @@ export const SessionApi = HttpApi.make("session")
params: { sessionID: SessionID },
query: MessagesQuery,
success: described(Schema.Array(MessageV2.WithParts), "List of messages"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, OpencodeNotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.messages",
@@ -179,7 +181,7 @@ export const SessionApi = HttpApi.make("session")
HttpApiEndpoint.get("message", SessionPaths.message, {
params: { sessionID: SessionID, messageID: MessageID },
success: described(MessageV2.WithParts, "Message"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, OpencodeNotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.message",
@@ -201,7 +203,7 @@ export const SessionApi = HttpApi.make("session")
HttpApiEndpoint.delete("remove", SessionPaths.remove, {
params: { sessionID: SessionID },
success: described(Schema.Boolean, "Successfully deleted session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, OpencodeNotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.delete",
@@ -213,7 +215,7 @@ export const SessionApi = HttpApi.make("session")
params: { sessionID: SessionID },
payload: UpdatePayload,
success: described(Session.Info, "Successfully updated session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, OpencodeNotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.update",
@@ -225,6 +227,7 @@ export const SessionApi = HttpApi.make("session")
params: { sessionID: SessionID },
payload: ForkPayload,
success: described(Session.Info, "200"),
error: [HttpApiError.BadRequest, OpencodeNotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.fork",
@@ -235,7 +238,7 @@ export const SessionApi = HttpApi.make("session")
HttpApiEndpoint.post("abort", SessionPaths.abort, {
params: { sessionID: SessionID },
success: described(Schema.Boolean, "Aborted session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, OpencodeNotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.abort",
@@ -247,7 +250,7 @@ export const SessionApi = HttpApi.make("session")
params: { sessionID: SessionID },
payload: InitPayload,
success: described(Schema.Boolean, "200"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, OpencodeNotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.init",
@@ -259,7 +262,7 @@ export const SessionApi = HttpApi.make("session")
HttpApiEndpoint.post("share", SessionPaths.share, {
params: { sessionID: SessionID },
success: described(Session.Info, "Successfully shared session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, OpencodeNotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.share",
@@ -270,7 +273,7 @@ export const SessionApi = HttpApi.make("session")
HttpApiEndpoint.delete("unshare", SessionPaths.share, {
params: { sessionID: SessionID },
success: described(Session.Info, "Successfully unshared session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, OpencodeNotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.unshare",
@@ -282,7 +285,7 @@ export const SessionApi = HttpApi.make("session")
params: { sessionID: SessionID },
payload: SummarizePayload,
success: described(Schema.Boolean, "Summarized session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, OpencodeNotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.summarize",
@@ -294,7 +297,7 @@ export const SessionApi = HttpApi.make("session")
params: { sessionID: SessionID },
payload: PromptPayload,
success: described(MessageV2.WithParts, "Created message"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, OpencodeNotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.prompt",
@@ -306,7 +309,7 @@ export const SessionApi = HttpApi.make("session")
params: { sessionID: SessionID },
payload: PromptPayload,
success: described(HttpApiSchema.NoContent, "Prompt accepted"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, OpencodeNotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.prompt_async",
@@ -319,7 +322,7 @@ export const SessionApi = HttpApi.make("session")
params: { sessionID: SessionID },
payload: CommandPayload,
success: described(MessageV2.WithParts, "Created message"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, OpencodeNotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.command",
@@ -331,7 +334,7 @@ export const SessionApi = HttpApi.make("session")
params: { sessionID: SessionID },
payload: ShellPayload,
success: described(MessageV2.WithParts, "Created message"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, OpencodeNotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.shell",
@@ -343,7 +346,7 @@ export const SessionApi = HttpApi.make("session")
params: { sessionID: SessionID },
payload: RevertPayload,
success: described(Session.Info, "Updated session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, OpencodeNotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.revert",
@@ -355,7 +358,7 @@ export const SessionApi = HttpApi.make("session")
HttpApiEndpoint.post("unrevert", SessionPaths.unrevert, {
params: { sessionID: SessionID },
success: described(Session.Info, "Updated session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, OpencodeNotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.unrevert",
@@ -367,7 +370,7 @@ export const SessionApi = HttpApi.make("session")
params: { sessionID: SessionID, permissionID: PermissionID },
payload: PermissionResponsePayload,
success: described(Schema.Boolean, "Permission processed successfully"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, OpencodeNotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "permission.respond",
@@ -379,7 +382,7 @@ export const SessionApi = HttpApi.make("session")
HttpApiEndpoint.delete("deleteMessage", SessionPaths.deleteMessage, {
params: { sessionID: SessionID, messageID: MessageID },
success: described(Schema.Boolean, "Successfully deleted message"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, OpencodeNotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.deleteMessage",
@@ -391,7 +394,7 @@ export const SessionApi = HttpApi.make("session")
HttpApiEndpoint.delete("deletePart", SessionPaths.deletePart, {
params: { sessionID: SessionID, messageID: MessageID, partID: PartID },
success: described(Schema.Boolean, "Successfully deleted part"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, OpencodeNotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "part.delete",
@@ -402,7 +405,7 @@ export const SessionApi = HttpApi.make("session")
params: { sessionID: SessionID, messageID: MessageID, partID: PartID },
payload: MessageV2.Part,
success: described(MessageV2.Part, "Successfully updated part"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, OpencodeNotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "part.update",

View File

@@ -17,6 +17,7 @@ import { SessionSummary } from "@/session/summary"
import { Todo } from "@/session/todo"
import { MessageID, PartID, SessionID } from "@/session/schema"
import { NotFoundError } from "@/storage/storage"
import { OpencodeNotFound } from "../errors"
import { NamedError } from "@opencode-ai/core/util/error"
import { Cause, Effect, Option, Schema, Scope } from "effect"
import * as Stream from "effect/Stream"
@@ -38,11 +39,17 @@ import {
UpdatePayload,
} from "../groups/session"
// TODO: long-term, services like Session.Service should fail with typed errors
// directly (e.g. Effect<SessionInfo, SessionNotFound>) and let HttpApi auto-route
// status + body via the schema annotations. Until then, we catch the legacy
// thrown NotFoundError at the boundary and rebrand to OpencodeNotFound — which
// matches the Hono NamedError JSON shape SDK consumers already expect.
const mapNotFound = <A, E, R>(self: Effect.Effect<A, E, R>) =>
self.pipe(
Effect.catchIf(NotFoundError.isInstance, () => Effect.fail(new HttpApiError.NotFound({}))),
Effect.catchDefect((error) =>
NotFoundError.isInstance(error) ? Effect.fail(new HttpApiError.NotFound({})) : Effect.die(error),
NotFoundError.isInstance(error)
? Effect.fail(new OpencodeNotFound({ data: { message: error.message } }))
: Effect.die(error),
),
)
@@ -87,14 +94,14 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
})
const todo = Effect.fn("SessionHttpApi.todo")(function* (ctx: { params: { sessionID: SessionID } }) {
return yield* todoSvc.get(ctx.params.sessionID)
return yield* mapNotFound(todoSvc.get(ctx.params.sessionID))
})
const diff = Effect.fn("SessionHttpApi.diff")(function* (ctx: {
params: { sessionID: SessionID }
query: typeof DiffQuery.Type
}) {
return yield* summary.diff({ sessionID: ctx.params.sessionID, messageID: ctx.query.messageID })
return yield* mapNotFound(summary.diff({ sessionID: ctx.params.sessionID, messageID: ctx.query.messageID }))
})
const messages = Effect.fn("SessionHttpApi.messages")(function* (ctx: {
@@ -198,11 +205,11 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
params: { sessionID: SessionID }
payload: typeof ForkPayload.Type
}) {
return yield* session.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload.messageID })
return yield* mapNotFound(session.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload.messageID }))
})
const abort = Effect.fn("SessionHttpApi.abort")(function* (ctx: { params: { sessionID: SessionID } }) {
yield* promptSvc.cancel(ctx.params.sessionID)
yield* mapNotFound(promptSvc.cancel(ctx.params.sessionID))
return true
})
@@ -210,13 +217,15 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
params: { sessionID: SessionID }
payload: typeof InitPayload.Type
}) {
yield* promptSvc.command({
sessionID: ctx.params.sessionID,
messageID: ctx.payload.messageID,
model: `${ctx.payload.providerID}/${ctx.payload.modelID}`,
command: Command.Default.INIT,
arguments: "",
})
yield* mapNotFound(
promptSvc.command({
sessionID: ctx.params.sessionID,
messageID: ctx.payload.messageID,
model: `${ctx.payload.providerID}/${ctx.payload.modelID}`,
command: Command.Default.INIT,
arguments: "",
}),
)
return true
})
@@ -234,22 +243,26 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
params: { sessionID: SessionID }
payload: typeof SummarizePayload.Type
}) {
yield* revertSvc.cleanup(yield* session.get(ctx.params.sessionID))
const messages = yield* session.messages({ sessionID: ctx.params.sessionID })
const defaultAgent = yield* agentSvc.defaultAgent()
const currentAgent = messages.findLast((message) => message.info.role === "user")?.info.agent ?? defaultAgent
return yield* mapNotFound(
Effect.gen(function* () {
yield* revertSvc.cleanup(yield* session.get(ctx.params.sessionID))
const messages = yield* session.messages({ sessionID: ctx.params.sessionID })
const defaultAgent = yield* agentSvc.defaultAgent()
const currentAgent = messages.findLast((m) => m.info.role === "user")?.info.agent ?? defaultAgent
yield* compactSvc.create({
sessionID: ctx.params.sessionID,
agent: currentAgent,
model: {
providerID: ctx.payload.providerID,
modelID: ctx.payload.modelID,
},
auto: ctx.payload.auto ?? false,
})
yield* promptSvc.loop({ sessionID: ctx.params.sessionID })
return true
yield* compactSvc.create({
sessionID: ctx.params.sessionID,
agent: currentAgent,
model: {
providerID: ctx.payload.providerID,
modelID: ctx.payload.modelID,
},
auto: ctx.payload.auto ?? false,
})
yield* promptSvc.loop({ sessionID: ctx.params.sessionID })
return true
}),
)
})
const prompt = Effect.fn("SessionHttpApi.prompt")(function* (ctx: {
@@ -297,25 +310,25 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
params: { sessionID: SessionID }
payload: typeof CommandPayload.Type
}) {
return yield* promptSvc.command({ ...ctx.payload, sessionID: ctx.params.sessionID })
return yield* mapNotFound(promptSvc.command({ ...ctx.payload, sessionID: ctx.params.sessionID }))
})
const shell = Effect.fn("SessionHttpApi.shell")(function* (ctx: {
params: { sessionID: SessionID }
payload: typeof ShellPayload.Type
}) {
return yield* promptSvc.shell({ ...ctx.payload, sessionID: ctx.params.sessionID })
return yield* mapNotFound(promptSvc.shell({ ...ctx.payload, sessionID: ctx.params.sessionID }))
})
const revert = Effect.fn("SessionHttpApi.revert")(function* (ctx: {
params: { sessionID: SessionID }
payload: typeof RevertPayload.Type
}) {
return yield* revertSvc.revert({ sessionID: ctx.params.sessionID, ...ctx.payload })
return yield* mapNotFound(revertSvc.revert({ sessionID: ctx.params.sessionID, ...ctx.payload }))
})
const unrevert = Effect.fn("SessionHttpApi.unrevert")(function* (ctx: { params: { sessionID: SessionID } }) {
return yield* revertSvc.unrevert({ sessionID: ctx.params.sessionID })
return yield* mapNotFound(revertSvc.unrevert({ sessionID: ctx.params.sessionID }))
})
const permissionRespond = Effect.fn("SessionHttpApi.permissionRespond")(function* (ctx: {
@@ -329,8 +342,12 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
const deleteMessage = Effect.fn("SessionHttpApi.deleteMessage")(function* (ctx: {
params: { sessionID: SessionID; messageID: MessageID }
}) {
yield* runState.assertNotBusy(ctx.params.sessionID)
yield* session.removeMessage(ctx.params)
yield* mapNotFound(
Effect.gen(function* () {
yield* runState.assertNotBusy(ctx.params.sessionID)
yield* session.removeMessage(ctx.params)
}),
)
return true
})

View File

@@ -32,12 +32,12 @@ function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
function createSessionWithMessages(directory: string, count: number) {
return WithInstance.provide({
directory,
fn: async () => {
const session = await runSession(Session.Service.use((svc) => svc.create({})))
for (let i = 0; i < count; i++) {
await runSession(
Effect.gen(function* () {
const svc = yield* Session.Service
fn: () =>
runSession(
Effect.gen(function* () {
const svc = yield* Session.Service
const session = yield* svc.create({})
for (let i = 0; i < count; i++) {
yield* svc.updateMessage({
id: MessageID.ascending(),
role: "user",
@@ -46,11 +46,10 @@ function createSessionWithMessages(directory: string, count: number) {
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
time: { created: Date.now() },
})
}),
)
}
return session.id
},
}
return session.id
}),
),
})
}
@@ -82,22 +81,23 @@ describe("Link header host", () => {
})
// ──────────────────────────────────────────────────────────────────────────────
// Reproducer 2: GET /session/{missing-id}/todo should return 404, not 500.
// The session.todo handler in HttpApi doesn't wrap with `mapNotFound`, so a
// `NotFoundError` from the service surfaces as a defect → 500. Hono's
// equivalent maps to 404 via `errors.notFound`.
//
// Affected endpoints (handlers without mapNotFound): todo, diff, summarize,
// fork, abort, init, deleteMessage, command, shell, revert, unrevert.
//
// FIXME: unskip when mapNotFound coverage is added (next PR).
// Reproducer 2: GET /session/{missing-id}/todo returns 404, not 500.
// Previously the session.todo handler didn't wrap with `mapNotFound`, so a
// thrown `NotFoundError` surfaced as a defect → 500. Hono's equivalent maps
// to 404 via `errors.notFound`. mapNotFound is now applied to all session
// endpoints that take a sessionID.
// ──────────────────────────────────────────────────────────────────────────────
describe("404 mapping for missing session", () => {
test.todo("HttpApi /session/{missing}/todo returns 404 not 500", async () => {
test("HttpApi /session/{missing}/fork returns 404 not 500", async () => {
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
const response = await app(true).request("/session/ses_does_not_exist/todo", {
headers: { "x-opencode-directory": tmp.path },
const response = await app(true).request("/session/ses_does_not_exist/fork", {
method: "POST",
headers: {
"x-opencode-directory": tmp.path,
"content-type": "application/json",
},
body: JSON.stringify({}),
})
expect(response.status).toBe(404)
@@ -105,15 +105,14 @@ 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 body matches Hono's NamedError envelope
// `{ name: "NotFoundError", data: { message } }`. HttpApi previously returned
// `{ _tag: "NotFound" }` (empty body via HttpApiError.NotFound). The new
// OpencodeNotFound class encodes the legacy shape via its schema fields and
// `httpApiStatus: 404` annotation.
// ──────────────────────────────────────────────────────────────────────────────
describe("Error JSON shape parity", () => {
test.todo("HttpApi 404 body matches NamedError shape", async () => {
test("HttpApi 404 body matches NamedError shape", async () => {
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
const response = await app(true).request("/session/ses_does_not_exist", {