fix(httpapi): expose v2 session not found (#28511)

This commit is contained in:
Shoubhit Dash
2026-05-21 13:27:20 +05:30
committed by GitHub
parent 4d900b2f46
commit b275b12e90
8 changed files with 142 additions and 48 deletions
@@ -2,7 +2,7 @@ import { SessionID } from "@/session/schema"
import { SessionMessage } from "@opencode-ai/core/session-message" import { SessionMessage } from "@opencode-ai/core/session-message"
import { Schema } from "effect" import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { InvalidCursorError } from "../../errors" import { InvalidCursorError, SessionNotFoundError } from "../../errors"
import { V2Authorization } from "../../middleware/authorization" import { V2Authorization } from "../../middleware/authorization"
import { WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing" import { WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing"
@@ -36,7 +36,7 @@ export const MessageGroup = HttpApiGroup.make("v2.message")
next: Schema.String.pipe(Schema.optional), next: Schema.String.pipe(Schema.optional),
}), }),
}).annotate({ identifier: "V2SessionMessagesResponse" }), }).annotate({ identifier: "V2SessionMessagesResponse" }),
error: InvalidCursorError, error: [InvalidCursorError, SessionNotFoundError],
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "v2.session.messages", identifier: "v2.session.messages",
@@ -4,7 +4,7 @@ import { Prompt } from "@opencode-ai/core/session-prompt"
import { SessionV2 } from "@/v2/session" import { SessionV2 } from "@/v2/session"
import { Schema } from "effect" import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" 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 { V2Authorization } from "../../middleware/authorization"
import { WorkspaceRoutingQuery, WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing" import { WorkspaceRoutingQuery, WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing"
import { QueryBoolean } from "../query" import { QueryBoolean } from "../query"
@@ -61,6 +61,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
delivery: SessionV2.Delivery.pipe(Schema.optional), delivery: SessionV2.Delivery.pipe(Schema.optional),
}), }),
success: SessionMessage.Message, success: SessionMessage.Message,
error: SessionNotFoundError,
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "v2.session.prompt", identifier: "v2.session.prompt",
@@ -74,6 +75,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
params: { sessionID: SessionID }, params: { sessionID: SessionID },
query: WorkspaceRoutingQuery, query: WorkspaceRoutingQuery,
success: HttpApiSchema.NoContent, success: HttpApiSchema.NoContent,
error: SessionNotFoundError,
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "v2.session.compact", identifier: "v2.session.compact",
@@ -87,6 +89,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
params: { sessionID: SessionID }, params: { sessionID: SessionID },
query: WorkspaceRoutingQuery, query: WorkspaceRoutingQuery,
success: HttpApiSchema.NoContent, success: HttpApiSchema.NoContent,
error: SessionNotFoundError,
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "v2.session.wait", identifier: "v2.session.wait",
@@ -100,6 +103,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
params: { sessionID: SessionID }, params: { sessionID: SessionID },
query: WorkspaceRoutingQuery, query: WorkspaceRoutingQuery,
success: Schema.Array(SessionMessage.Message), success: Schema.Array(SessionMessage.Message),
error: SessionNotFoundError,
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "v2.session.context", identifier: "v2.session.context",
@@ -4,7 +4,7 @@ import { Effect, Schema } from "effect"
import * as DateTime from "effect/DateTime" import * as DateTime from "effect/DateTime"
import { HttpApiBuilder } from "effect/unstable/httpapi" import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../../api" import { InstanceHttpApi } from "../../api"
import { InvalidCursorError } from "../../errors" import { InvalidCursorError, SessionNotFoundError } from "../../errors"
const DefaultMessagesLimit = 50 const DefaultMessagesLimit = 50
@@ -47,7 +47,16 @@ export const messageHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.message
limit: ctx.query.limit ?? DefaultMessagesLimit, limit: ctx.query.limit ?? DefaultMessagesLimit,
order, order,
cursor: decoded ? { id: decoded.id, time: decoded.time, direction: decoded.direction } : undefined, 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 first = messages[0]
const last = messages.at(-1) const last = messages.at(-1)
return { return {
@@ -3,7 +3,7 @@ import { SessionV2 } from "@/v2/session"
import { DateTime, Effect, Option, Schema } from "effect" import { DateTime, Effect, Option, Schema } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../../api" import { InstanceHttpApi } from "../../api"
import { InvalidCursorError, InvalidRequestError } from "../../errors" import { InvalidCursorError, InvalidRequestError, SessionNotFoundError } from "../../errors"
const DefaultSessionsLimit = 50 const DefaultSessionsLimit = 50
@@ -137,27 +137,69 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session
sessionID: ctx.params.sessionID, sessionID: ctx.params.sessionID,
prompt: ctx.payload.prompt, prompt: ctx.payload.prompt,
delivery: ctx.payload.delivery ?? SessionV2.DefaultDelivery, 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( .handle(
"compact", "compact",
Effect.fn(function* (ctx) { 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() return HttpApiSchema.NoContent.make()
}), }),
) )
.handle( .handle(
"wait", "wait",
Effect.fn(function* (ctx) { 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() return HttpApiSchema.NoContent.make()
}), }),
) )
.handle( .handle(
"context", "context",
Effect.fn(function* (ctx) { 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}`,
}),
),
),
)
}), }),
) )
}), }),
+15 -8
View File
@@ -97,14 +97,14 @@ export interface Interface {
time: number time: number
direction: "previous" | "next" direction: "previous" | "next"
} }
}) => Effect.Effect<SessionMessage.Message[], never> }) => Effect.Effect<SessionMessage.Message[], NotFoundError>
readonly context: (sessionID: SessionID) => Effect.Effect<SessionMessage.Message[], never> readonly context: (sessionID: SessionID) => Effect.Effect<SessionMessage.Message[], NotFoundError>
readonly prompt: (input: { readonly prompt: (input: {
id?: EventV2.ID id?: EventV2.ID
sessionID: SessionID sessionID: SessionID
prompt: Prompt prompt: Prompt
delivery?: Delivery delivery?: Delivery
}) => Effect.Effect<SessionMessage.User, never> }) => Effect.Effect<SessionMessage.User, NotFoundError>
readonly shell: (input: { id?: EventV2.ID; sessionID: SessionID; command: string }) => Effect.Effect<void, never> readonly shell: (input: { id?: EventV2.ID; sessionID: SessionID; command: string }) => Effect.Effect<void, never>
readonly skill: (input: { id?: EventV2.ID; sessionID: SessionID; skill: string }) => Effect.Effect<void, never> readonly skill: (input: { id?: EventV2.ID; sessionID: SessionID; skill: string }) => Effect.Effect<void, never>
readonly subagent: (input: { readonly subagent: (input: {
@@ -116,8 +116,8 @@ export interface Interface {
}) => Effect.Effect<void, NotFoundError> }) => Effect.Effect<void, NotFoundError>
readonly switchAgent: (input: { sessionID: SessionID; agent: string }) => Effect.Effect<void, never> readonly switchAgent: (input: { sessionID: SessionID; agent: string }) => Effect.Effect<void, never>
readonly switchModel: (input: { sessionID: SessionID; model: ModelV2.Ref }) => Effect.Effect<void, never> readonly switchModel: (input: { sessionID: SessionID; model: ModelV2.Ref }) => Effect.Effect<void, never>
readonly compact: (sessionID: SessionID) => Effect.Effect<void, never> readonly compact: (sessionID: SessionID) => Effect.Effect<void, NotFoundError>
readonly wait: (sessionID: SessionID) => Effect.Effect<void, never> readonly wait: (sessionID: SessionID) => Effect.Effect<void, NotFoundError>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Session") {} export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Session") {}
@@ -216,6 +216,7 @@ export const layer = Layer.effect(
return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row)) return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row))
}), }),
messages: Effect.fn("V2Session.messages")(function* (input) { messages: Effect.fn("V2Session.messages")(function* (input) {
yield* result.get(input.sessionID)
const direction = input.cursor?.direction ?? "next" const direction = input.cursor?.direction ?? "next"
let order = input.order ?? "desc" let order = input.order ?? "desc"
// Query the adjacent rows in reverse, then flip them back into the requested order below. // 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)) return rows.map((row) => decode(row))
}), }),
context: Effect.fn("V2Session.context")(function* (sessionID) { context: Effect.fn("V2Session.context")(function* (sessionID) {
yield* result.get(sessionID)
const rows = Database.use((db) => { const rows = Database.use((db) => {
const compaction = db const compaction = db
.select() .select()
@@ -288,7 +290,8 @@ export const layer = Layer.effect(
}) })
return rows.map((row) => decode(row)) 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 return {} as any
}), }),
shell: Effect.fn("V2Session.shell")(function* (_input) {}), shell: Effect.fn("V2Session.shell")(function* (_input) {}),
@@ -328,8 +331,12 @@ export const layer = Layer.effect(
if (!text) return if (!text) return
}).pipe(Effect.forkChild()) }).pipe(Effect.forkChild())
}), }),
compact: Effect.fn("V2Session.compact")(function* (_sessionID) {}), compact: Effect.fn("V2Session.compact")(function* (sessionID) {
wait: Effect.fn("V2Session.wait")(function* (_sessionID) {}), yield* result.get(sessionID)
}),
wait: Effect.fn("V2Session.wait")(function* (sessionID) {
yield* result.get(sessionID)
}),
}) })
return result return result
@@ -675,22 +675,14 @@ const scenarios: Scenario[] = [
path: route("/api/session/{sessionID}/context", { sessionID: "ses_httpapi_missing" }), path: route("/api/session/{sessionID}/context", { sessionID: "ses_httpapi_missing" }),
headers: ctx.headers(), headers: ctx.headers(),
})) }))
.json(200, array, "none"), .json(404, object, "status"),
http.protected http.protected
.get("/api/session/{sessionID}/message", "v2.session.messages") .get("/api/session/{sessionID}/message", "v2.session.messages")
.at((ctx) => ({ .at((ctx) => ({
path: route("/api/session/{sessionID}/message", { sessionID: "ses_httpapi_missing" }), path: route("/api/session/{sessionID}/message", { sessionID: "ses_httpapi_missing" }),
headers: ctx.headers(), headers: ctx.headers(),
})) }))
.json( .json(404, object, "status"),
200,
(body) => {
object(body)
array(body.items)
object(body.cursor)
},
"none",
),
http.protected http.protected
.get("/api/session/{sessionID}/message", "v2.session.messages.params") .get("/api/session/{sessionID}/message", "v2.session.messages.params")
.at((ctx) => ({ .at((ctx) => ({
@@ -700,15 +692,7 @@ const scenarios: Scenario[] = [
})}`, })}`,
headers: ctx.headers(), headers: ctx.headers(),
})) }))
.json( .json(404, object, "status"),
200,
(body) => {
object(body)
array(body.items)
object(body.cursor)
},
"none",
),
http.protected http.protected
.get("/api/session/{sessionID}/message", "v2.session.messages.cursor") .get("/api/session/{sessionID}/message", "v2.session.messages.cursor")
.at((ctx) => ({ .at((ctx) => ({
@@ -719,15 +703,7 @@ const scenarios: Scenario[] = [
})}`, })}`,
headers: ctx.headers(), headers: ctx.headers(),
})) }))
.json( .json(404, object, "status"),
200,
(body) => {
object(body)
array(body.items)
object(body.cursor)
},
"none",
),
http.protected http.protected
.get("/api/session/{sessionID}/message", "v2.session.messages.cursor.invalid") .get("/api/session/{sessionID}/message", "v2.session.messages.cursor.invalid")
.at((ctx) => ({ .at((ctx) => ({
@@ -752,14 +728,14 @@ const scenarios: Scenario[] = [
path: route("/api/session/{sessionID}/compact", { sessionID: "ses_httpapi_missing" }), path: route("/api/session/{sessionID}/compact", { sessionID: "ses_httpapi_missing" }),
headers: ctx.headers(), headers: ctx.headers(),
})) }))
.status(204, undefined, "none"), .status(404, undefined, "status"),
http.protected http.protected
.post("/api/session/{sessionID}/wait", "v2.session.wait") .post("/api/session/{sessionID}/wait", "v2.session.wait")
.at((ctx) => ({ .at((ctx) => ({
path: route("/api/session/{sessionID}/wait", { sessionID: "ses_httpapi_missing" }), path: route("/api/session/{sessionID}/wait", { sessionID: "ses_httpapi_missing" }),
headers: ctx.headers(), headers: ctx.headers(),
})) }))
.status(204, undefined, "none"), .status(404, undefined, "status"),
http.protected http.protected
.get("/session", "session.list") .get("/session", "session.list")
.seeded((ctx) => ctx.session({ title: "List me" })) .seeded((ctx) => ctx.session({ title: "List me" }))
@@ -99,4 +99,20 @@ describe("PublicApi OpenAPI v2 errors", () => {
"ServiceUnavailableError", "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",
)
}
})
}) })
@@ -402,6 +402,46 @@ describe("session HttpApi", () => {
{ git: true, config: { formatter: false, lsp: false } }, { 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( it.instance(
"serves sessions with migrated summary diffs missing file details", "serves sessions with migrated summary diffs missing file details",
() => () =>