Procházet zdrojové kódy

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

Shoubhit Dash před 3 měsíci
rodič
revize
b275b12e90

+ 2 - 2
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",

+ 5 - 1
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",

+ 11 - 2
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 {

+ 47 - 5
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}`,
+                  }),
+                ),
+              ),
+            )
         }),
       )
   }),

+ 15 - 8
packages/opencode/src/v2/session.ts

@@ -97,14 +97,14 @@ export interface Interface {
       time: number
       direction: "previous" | "next"
     }
-  }) => Effect.Effect<SessionMessage.Message[], never>
-  readonly context: (sessionID: SessionID) => Effect.Effect<SessionMessage.Message[], never>
+  }) => Effect.Effect<SessionMessage.Message[], NotFoundError>
+  readonly context: (sessionID: SessionID) => Effect.Effect<SessionMessage.Message[], NotFoundError>
   readonly prompt: (input: {
     id?: EventV2.ID
     sessionID: SessionID
     prompt: Prompt
     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 skill: (input: { id?: EventV2.ID; sessionID: SessionID; skill: string }) => Effect.Effect<void, never>
   readonly subagent: (input: {
@@ -116,8 +116,8 @@ export interface Interface {
   }) => Effect.Effect<void, NotFoundError>
   readonly switchAgent: (input: { sessionID: SessionID; agent: string }) => 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 wait: (sessionID: SessionID) => Effect.Effect<void, never>
+  readonly compact: (sessionID: SessionID) => Effect.Effect<void, NotFoundError>
+  readonly wait: (sessionID: SessionID) => Effect.Effect<void, NotFoundError>
 }
 
 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))
       }),
       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

+ 6 - 30
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" }))

+ 16 - 0
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",
+      )
+    }
+  })
 })

+ 40 - 0
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",
     () =>