From b275b12e90b1c8ae7edcb5f6118b13cd9f2a88bd Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Thu, 21 May 2026 13:27:20 +0530 Subject: [PATCH] fix(httpapi): expose v2 session not found (#28511) --- .../instance/httpapi/groups/v2/message.ts | 4 +- .../instance/httpapi/groups/v2/session.ts | 6 ++- .../instance/httpapi/handlers/v2/message.ts | 13 ++++- .../instance/httpapi/handlers/v2/session.ts | 52 +++++++++++++++++-- packages/opencode/src/v2/session.ts | 23 +++++--- .../test/server/httpapi-exercise/index.ts | 36 +++---------- .../server/httpapi-public-openapi.test.ts | 16 ++++++ .../test/server/httpapi-session.test.ts | 40 ++++++++++++++ 8 files changed, 142 insertions(+), 48 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts index 47bb01cd8a..d68ed162f9 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts @@ -2,7 +2,7 @@ import { SessionID } from "@/session/schema" import { SessionMessage } from "@opencode-ai/core/session-message" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { InvalidCursorError } from "../../errors" +import { InvalidCursorError, SessionNotFoundError } from "../../errors" import { V2Authorization } from "../../middleware/authorization" import { WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing" @@ -36,7 +36,7 @@ export const MessageGroup = HttpApiGroup.make("v2.message") next: Schema.String.pipe(Schema.optional), }), }).annotate({ identifier: "V2SessionMessagesResponse" }), - error: InvalidCursorError, + error: [InvalidCursorError, SessionNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "v2.session.messages", diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts index f74a9f50bf..4571580bdb 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts @@ -4,7 +4,7 @@ import { Prompt } from "@opencode-ai/core/session-prompt" import { SessionV2 } from "@/v2/session" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" -import { InvalidCursorError, InvalidRequestError } from "../../errors" +import { InvalidCursorError, InvalidRequestError, SessionNotFoundError } from "../../errors" import { V2Authorization } from "../../middleware/authorization" import { WorkspaceRoutingQuery, WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing" import { QueryBoolean } from "../query" @@ -61,6 +61,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session") delivery: SessionV2.Delivery.pipe(Schema.optional), }), success: SessionMessage.Message, + error: SessionNotFoundError, }).annotateMerge( OpenApi.annotations({ identifier: "v2.session.prompt", @@ -74,6 +75,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session") params: { sessionID: SessionID }, query: WorkspaceRoutingQuery, success: HttpApiSchema.NoContent, + error: SessionNotFoundError, }).annotateMerge( OpenApi.annotations({ identifier: "v2.session.compact", @@ -87,6 +89,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session") params: { sessionID: SessionID }, query: WorkspaceRoutingQuery, success: HttpApiSchema.NoContent, + error: SessionNotFoundError, }).annotateMerge( OpenApi.annotations({ identifier: "v2.session.wait", @@ -100,6 +103,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session") params: { sessionID: SessionID }, query: WorkspaceRoutingQuery, success: Schema.Array(SessionMessage.Message), + error: SessionNotFoundError, }).annotateMerge( OpenApi.annotations({ identifier: "v2.session.context", diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts index c809f6485d..c4aae9ce40 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts @@ -4,7 +4,7 @@ import { Effect, Schema } from "effect" import * as DateTime from "effect/DateTime" import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../../api" -import { InvalidCursorError } from "../../errors" +import { InvalidCursorError, SessionNotFoundError } from "../../errors" const DefaultMessagesLimit = 50 @@ -47,7 +47,16 @@ export const messageHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.message limit: ctx.query.limit ?? DefaultMessagesLimit, order, cursor: decoded ? { id: decoded.id, time: decoded.time, direction: decoded.direction } : undefined, - }) + }).pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + ) const first = messages[0] const last = messages.at(-1) return { diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts index 23b9376c8d..34ed4ed984 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts @@ -3,7 +3,7 @@ import { SessionV2 } from "@/v2/session" import { DateTime, Effect, Option, Schema } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../../api" -import { InvalidCursorError, InvalidRequestError } from "../../errors" +import { InvalidCursorError, InvalidRequestError, SessionNotFoundError } from "../../errors" const DefaultSessionsLimit = 50 @@ -137,27 +137,69 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session sessionID: ctx.params.sessionID, prompt: ctx.payload.prompt, delivery: ctx.payload.delivery ?? SessionV2.DefaultDelivery, - }) + }).pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + ) }), ) .handle( "compact", Effect.fn(function* (ctx) { - yield* session.compact(ctx.params.sessionID) + yield* session + .compact(ctx.params.sessionID) + .pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + ) return HttpApiSchema.NoContent.make() }), ) .handle( "wait", Effect.fn(function* (ctx) { - yield* session.wait(ctx.params.sessionID) + yield* session + .wait(ctx.params.sessionID) + .pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + ) return HttpApiSchema.NoContent.make() }), ) .handle( "context", Effect.fn(function* (ctx) { - return yield* session.context(ctx.params.sessionID) + return yield* session + .context(ctx.params.sessionID) + .pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + ) }), ) }), diff --git a/packages/opencode/src/v2/session.ts b/packages/opencode/src/v2/session.ts index ec67d1820a..917d602747 100644 --- a/packages/opencode/src/v2/session.ts +++ b/packages/opencode/src/v2/session.ts @@ -97,14 +97,14 @@ export interface Interface { time: number direction: "previous" | "next" } - }) => Effect.Effect - readonly context: (sessionID: SessionID) => Effect.Effect + }) => Effect.Effect + readonly context: (sessionID: SessionID) => Effect.Effect readonly prompt: (input: { id?: EventV2.ID sessionID: SessionID prompt: Prompt delivery?: Delivery - }) => Effect.Effect + }) => Effect.Effect readonly shell: (input: { id?: EventV2.ID; sessionID: SessionID; command: string }) => Effect.Effect readonly skill: (input: { id?: EventV2.ID; sessionID: SessionID; skill: string }) => Effect.Effect readonly subagent: (input: { @@ -116,8 +116,8 @@ export interface Interface { }) => Effect.Effect readonly switchAgent: (input: { sessionID: SessionID; agent: string }) => Effect.Effect readonly switchModel: (input: { sessionID: SessionID; model: ModelV2.Ref }) => Effect.Effect - readonly compact: (sessionID: SessionID) => Effect.Effect - readonly wait: (sessionID: SessionID) => Effect.Effect + readonly compact: (sessionID: SessionID) => Effect.Effect + readonly wait: (sessionID: SessionID) => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/Session") {} @@ -216,6 +216,7 @@ export const layer = Layer.effect( return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row)) }), messages: Effect.fn("V2Session.messages")(function* (input) { + yield* result.get(input.sessionID) const direction = input.cursor?.direction ?? "next" let order = input.order ?? "desc" // Query the adjacent rows in reverse, then flip them back into the requested order below. @@ -257,6 +258,7 @@ export const layer = Layer.effect( return rows.map((row) => decode(row)) }), context: Effect.fn("V2Session.context")(function* (sessionID) { + yield* result.get(sessionID) const rows = Database.use((db) => { const compaction = db .select() @@ -288,7 +290,8 @@ export const layer = Layer.effect( }) return rows.map((row) => decode(row)) }), - prompt: Effect.fn("V2Session.prompt")(function* (_input) { + prompt: Effect.fn("V2Session.prompt")(function* (input) { + yield* result.get(input.sessionID) return {} as any }), shell: Effect.fn("V2Session.shell")(function* (_input) {}), @@ -328,8 +331,12 @@ export const layer = Layer.effect( if (!text) return }).pipe(Effect.forkChild()) }), - compact: Effect.fn("V2Session.compact")(function* (_sessionID) {}), - wait: Effect.fn("V2Session.wait")(function* (_sessionID) {}), + compact: Effect.fn("V2Session.compact")(function* (sessionID) { + yield* result.get(sessionID) + }), + wait: Effect.fn("V2Session.wait")(function* (sessionID) { + yield* result.get(sessionID) + }), }) return result diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 293e2a3449..8999d51077 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -675,22 +675,14 @@ const scenarios: Scenario[] = [ path: route("/api/session/{sessionID}/context", { sessionID: "ses_httpapi_missing" }), headers: ctx.headers(), })) - .json(200, array, "none"), + .json(404, object, "status"), http.protected .get("/api/session/{sessionID}/message", "v2.session.messages") .at((ctx) => ({ path: route("/api/session/{sessionID}/message", { sessionID: "ses_httpapi_missing" }), headers: ctx.headers(), })) - .json( - 200, - (body) => { - object(body) - array(body.items) - object(body.cursor) - }, - "none", - ), + .json(404, object, "status"), http.protected .get("/api/session/{sessionID}/message", "v2.session.messages.params") .at((ctx) => ({ @@ -700,15 +692,7 @@ const scenarios: Scenario[] = [ })}`, headers: ctx.headers(), })) - .json( - 200, - (body) => { - object(body) - array(body.items) - object(body.cursor) - }, - "none", - ), + .json(404, object, "status"), http.protected .get("/api/session/{sessionID}/message", "v2.session.messages.cursor") .at((ctx) => ({ @@ -719,15 +703,7 @@ const scenarios: Scenario[] = [ })}`, headers: ctx.headers(), })) - .json( - 200, - (body) => { - object(body) - array(body.items) - object(body.cursor) - }, - "none", - ), + .json(404, object, "status"), http.protected .get("/api/session/{sessionID}/message", "v2.session.messages.cursor.invalid") .at((ctx) => ({ @@ -752,14 +728,14 @@ const scenarios: Scenario[] = [ path: route("/api/session/{sessionID}/compact", { sessionID: "ses_httpapi_missing" }), headers: ctx.headers(), })) - .status(204, undefined, "none"), + .status(404, undefined, "status"), http.protected .post("/api/session/{sessionID}/wait", "v2.session.wait") .at((ctx) => ({ path: route("/api/session/{sessionID}/wait", { sessionID: "ses_httpapi_missing" }), headers: ctx.headers(), })) - .status(204, undefined, "none"), + .status(404, undefined, "status"), http.protected .get("/session", "session.list") .seeded((ctx) => ctx.session({ title: "List me" })) diff --git a/packages/opencode/test/server/httpapi-public-openapi.test.ts b/packages/opencode/test/server/httpapi-public-openapi.test.ts index dea89be20e..3a7b8c7470 100644 --- a/packages/opencode/test/server/httpapi-public-openapi.test.ts +++ b/packages/opencode/test/server/httpapi-public-openapi.test.ts @@ -99,4 +99,20 @@ describe("PublicApi OpenAPI v2 errors", () => { "ServiceUnavailableError", ) }) + + test("documents v2 session not-found errors", () => { + const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec + + for (const route of [ + ["post", "/api/session/{sessionID}/prompt"], + ["post", "/api/session/{sessionID}/compact"], + ["post", "/api/session/{sessionID}/wait"], + ["get", "/api/session/{sessionID}/context"], + ["get", "/api/session/{sessionID}/message"], + ] as const) { + expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["404"]) ?? "")).toBe( + "SessionNotFoundError", + ) + } + }) }) diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 313245f732..bcdabcfdd0 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -402,6 +402,46 @@ describe("session HttpApi", () => { { git: true, config: { formatter: false, lsp: false } }, ) + it.instance( + "returns v2 public not found errors for missing sessions", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const headers = { "x-opencode-directory": test.directory } + const missing = SessionID.descending() + const expected = { + _tag: "SessionNotFoundError", + sessionID: missing, + message: `Session not found: ${missing}`, + } + + const messages = yield* request(`/api/session/${missing}/message`, { headers }) + expect(messages.status).toBe(404) + expect(yield* responseJson(messages)).toEqual(expected) + + const context = yield* request(`/api/session/${missing}/context`, { headers }) + expect(context.status).toBe(404) + expect(yield* responseJson(context)).toEqual(expected) + + const compact = yield* request(`/api/session/${missing}/compact`, { method: "POST", headers }) + expect(compact.status).toBe(404) + expect(yield* responseJson(compact)).toEqual(expected) + + const wait = yield* request(`/api/session/${missing}/wait`, { method: "POST", headers }) + expect(wait.status).toBe(404) + expect(yield* responseJson(wait)).toEqual(expected) + + const prompt = yield* request(`/api/session/${missing}/prompt`, { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ prompt: { text: "hello" } }), + }) + expect(prompt.status).toBe(404) + expect(yield* responseJson(prompt)).toEqual(expected) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + it.instance( "serves sessions with migrated summary diffs missing file details", () =>