diff --git a/packages/opencode/src/server/routes/instance/httpapi/errors.ts b/packages/opencode/src/server/routes/instance/httpapi/errors.ts new file mode 100644 index 0000000000..2c63b6351b --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/errors.ts @@ -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("opencode/Error/NotFound")( + { + name: Schema.tag("NotFoundError"), + data: Schema.Struct({ message: Schema.String }), + }, + { + description: "Not found", + httpApiStatus: 404, + }, +) {} diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index 77d064ff5a..feb77d2736 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -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", diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 4a67ba036e..327305b16c 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -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) 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 = (self: Effect.Effect) => 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 }) diff --git a/packages/opencode/test/server/httpapi-parity.test.ts b/packages/opencode/test/server/httpapi-parity.test.ts index 6922d8c43f..802fceb925 100644 --- a/packages/opencode/test/server/httpapi-parity.test.ts +++ b/packages/opencode/test/server/httpapi-parity.test.ts @@ -32,12 +32,12 @@ function runSession(fx: Effect.Effect) { 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", {