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 4571580bdb..9668debfcf 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, SessionNotFoundError } from "../../errors" +import { InvalidCursorError, InvalidRequestError, ServiceUnavailableError, SessionNotFoundError } from "../../errors" import { V2Authorization } from "../../middleware/authorization" import { WorkspaceRoutingQuery, WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing" import { QueryBoolean } from "../query" @@ -61,7 +61,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session") delivery: SessionV2.Delivery.pipe(Schema.optional), }), success: SessionMessage.Message, - error: SessionNotFoundError, + error: [SessionNotFoundError, ServiceUnavailableError], }).annotateMerge( OpenApi.annotations({ identifier: "v2.session.prompt", @@ -75,7 +75,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session") params: { sessionID: SessionID }, query: WorkspaceRoutingQuery, success: HttpApiSchema.NoContent, - error: SessionNotFoundError, + error: [SessionNotFoundError, ServiceUnavailableError], }).annotateMerge( OpenApi.annotations({ identifier: "v2.session.compact", @@ -89,7 +89,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session") params: { sessionID: SessionID }, query: WorkspaceRoutingQuery, success: HttpApiSchema.NoContent, - error: SessionNotFoundError, + error: [SessionNotFoundError, ServiceUnavailableError], }).annotateMerge( OpenApi.annotations({ identifier: "v2.session.wait", 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 1fa99ac5bc..cad597f8a1 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, SessionNotFoundError } from "../../errors" +import { InvalidCursorError, InvalidRequestError, ServiceUnavailableError, SessionNotFoundError } from "../../errors" const DefaultSessionsLimit = 50 @@ -148,6 +148,14 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session }), ), ), + Effect.catchTag("Session.OperationUnavailableError", (error) => + Effect.fail( + new ServiceUnavailableError({ + message: `V2 session ${error.operation} is not available yet`, + service: `v2.session.${error.operation}`, + }), + ), + ), ) }), ) @@ -163,6 +171,14 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session }), ), ), + Effect.catchTag("Session.OperationUnavailableError", (error) => + Effect.fail( + new ServiceUnavailableError({ + message: `V2 session ${error.operation} is not available yet`, + service: `v2.session.${error.operation}`, + }), + ), + ), ) return HttpApiSchema.NoContent.make() }), @@ -179,6 +195,14 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session }), ), ), + Effect.catchTag("Session.OperationUnavailableError", (error) => + Effect.fail( + new ServiceUnavailableError({ + message: `V2 session ${error.operation} is not available yet`, + service: `v2.session.${error.operation}`, + }), + ), + ), ) return HttpApiSchema.NoContent.make() }), diff --git a/packages/opencode/src/v2/session.ts b/packages/opencode/src/v2/session.ts index 917d602747..cb85323159 100644 --- a/packages/opencode/src/v2/session.ts +++ b/packages/opencode/src/v2/session.ts @@ -65,6 +65,13 @@ export class NotFoundError extends Schema.TaggedErrorClass()("Ses sessionID: SessionID, }) {} +export class OperationUnavailableError extends Schema.TaggedErrorClass()( + "Session.OperationUnavailableError", + { + operation: Schema.Literals(["prompt", "compact", "wait"]), + }, +) {} + export interface Interface { readonly create: (input?: { agent?: string @@ -104,7 +111,7 @@ export interface Interface { 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: { @@ -113,11 +120,11 @@ export interface Interface { prompt: Prompt agent: string model?: ModelV2.Ref - }) => Effect.Effect + }) => 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") {} @@ -292,7 +299,7 @@ export const layer = Layer.effect( }), prompt: Effect.fn("V2Session.prompt")(function* (input) { yield* result.get(input.sessionID) - return {} as any + return yield* new OperationUnavailableError({ operation: "prompt" }) }), shell: Effect.fn("V2Session.shell")(function* (_input) {}), skill: Effect.fn("V2Session.skill")(function* (_input) {}), @@ -333,9 +340,11 @@ export const layer = Layer.effect( }), compact: Effect.fn("V2Session.compact")(function* (sessionID) { yield* result.get(sessionID) + return yield* new OperationUnavailableError({ operation: "compact" }) }), wait: Effect.fn("V2Session.wait")(function* (sessionID) { yield* result.get(sessionID) + return yield* new OperationUnavailableError({ operation: "wait" }) }), }) diff --git a/packages/opencode/test/server/httpapi-public-openapi.test.ts b/packages/opencode/test/server/httpapi-public-openapi.test.ts index 3a7b8c7470..3f9ffc528a 100644 --- a/packages/opencode/test/server/httpapi-public-openapi.test.ts +++ b/packages/opencode/test/server/httpapi-public-openapi.test.ts @@ -115,4 +115,18 @@ describe("PublicApi OpenAPI v2 errors", () => { ) } }) + + test("documents v2 unfinished session mutation 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"], + ] as const) { + expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["503"]) ?? "")).toBe( + "ServiceUnavailableError", + ) + } + }) }) diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index bcdabcfdd0..2efbdcf76b 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -442,6 +442,45 @@ describe("session HttpApi", () => { { git: true, config: { formatter: false, lsp: false } }, ) + it.instance( + "returns v2 public unavailable errors for unfinished session mutations", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const headers = { "x-opencode-directory": test.directory } + const session = yield* createSession({ title: "v2 unavailable" }) + + const prompt = yield* request(`/api/session/${session.id}/prompt`, { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ prompt: { text: "hello" } }), + }) + expect(prompt.status).toBe(503) + expect(yield* responseJson(prompt)).toEqual({ + _tag: "ServiceUnavailableError", + message: "V2 session prompt is not available yet", + service: "v2.session.prompt", + }) + + const compact = yield* request(`/api/session/${session.id}/compact`, { method: "POST", headers }) + expect(compact.status).toBe(503) + expect(yield* responseJson(compact)).toEqual({ + _tag: "ServiceUnavailableError", + message: "V2 session compact is not available yet", + service: "v2.session.compact", + }) + + const wait = yield* request(`/api/session/${session.id}/wait`, { method: "POST", headers }) + expect(wait.status).toBe(503) + expect(yield* responseJson(wait)).toEqual({ + _tag: "ServiceUnavailableError", + message: "V2 session wait is not available yet", + service: "v2.session.wait", + }) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + it.instance( "serves sessions with migrated summary diffs missing file details", () =>